memgres 0.1.0__py3-none-any.whl

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/store.py ADDED
@@ -0,0 +1,402 @@
1
+ """The store: create / edit / move / read / forget a memory, with history.
2
+
3
+ One memory = one mutable body plus metadata (`tags`, tree `path`, TTL). You
4
+ change it by sending a whole new body **or** a unified diff; a diff must carry
5
+ the `base_hash` it was cut against, so a stale diff is rejected with
6
+ :class:`Conflict` (optimistic concurrency, the 409 an HTTP layer maps to).
7
+
8
+ Every state change appends one hash-chained row to ``memory_history`` with
9
+ `source`/`reason` provenance. ``forget`` hard-deletes the row and (by cascade)
10
+ its whole history — real erasure, not a tombstone.
11
+
12
+ Tree moves cascade: changing a node's `path` rewrites every descendant's path in
13
+ one ``ltree`` update, so the subtree stays consistent. Search lives in
14
+ ``search.py``; this module owns mutation and retrieval-by-id.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import hashlib
20
+ from dataclasses import dataclass
21
+ from typing import List, Optional, Sequence
22
+
23
+ import psycopg
24
+
25
+ from .config import Config
26
+ from .diffing import apply_diff, byte_len, content_hash, make_diff
27
+ from .embeddings import Embedder, get_embedder
28
+
29
+
30
+ class Conflict(RuntimeError):
31
+ """base_hash didn't match the current body — re-read and retry (HTTP 409)."""
32
+
33
+
34
+ class NotFound(KeyError):
35
+ """No such memory in this namespace."""
36
+
37
+
38
+ class TooLarge(ValueError):
39
+ """A write or resulting body exceeds the configured ceiling."""
40
+
41
+
42
+ class NoParent(ValueError):
43
+ """MEMGRES_REQUIRE_PARENT is on and the node's parent path doesn't exist."""
44
+
45
+
46
+ @dataclass
47
+ class Memory:
48
+ id: str
49
+ body: str
50
+ content_hash: str
51
+ tags: List[str]
52
+ path: Optional[str]
53
+ seq: int
54
+ created_at: object
55
+ updated_at: object
56
+ expires_at: object
57
+
58
+
59
+ def _vec_literal(vec: Sequence[float]) -> str:
60
+ return "[" + ",".join(repr(float(x)) for x in vec) + "]"
61
+
62
+
63
+ def _row_hash(prev: Optional[str], memory_id: str, seq: int, op: str,
64
+ diff: Optional[str], hash_after: Optional[str],
65
+ path_after: Optional[str], tags_after: Optional[Sequence[str]],
66
+ source: Optional[str], reason: Optional[str]) -> str:
67
+ h = hashlib.sha256()
68
+ parts = [prev or "", memory_id, str(seq), op, diff or "", hash_after or "",
69
+ path_after or "", ",".join(tags_after or []), source or "", reason or ""]
70
+ h.update("\x1f".join(parts).encode("utf-8"))
71
+ return h.hexdigest()
72
+
73
+
74
+ class Store:
75
+ def __init__(self, cfg: Config, embedder: Optional[Embedder] = None,
76
+ conn: Optional["psycopg.Connection"] = None):
77
+ self.cfg = cfg
78
+ self.embedder = embedder if embedder is not None else get_embedder(cfg)
79
+ self._own_conn = conn is None
80
+ self._conn = conn or psycopg.connect(cfg.database_url or "")
81
+ # Qdrant holds vectors out-of-band; pgvector keeps them in-row (default).
82
+ self._use_qdrant = (cfg.vector_backend == "qdrant" and self.embedder is not None)
83
+ self._qdrant = None
84
+ if self._use_qdrant:
85
+ from .qdrant_backend import QdrantIndex
86
+ self._qdrant = QdrantIndex(self.embedder.dim)
87
+
88
+ def close(self):
89
+ if self._own_conn:
90
+ self._conn.close()
91
+
92
+ # ─── namespace / ttl helpers ────────────────────────────────────────────
93
+ def _ns(self, token: Optional[str]) -> str:
94
+ if not self.cfg.namespaces_enabled:
95
+ return ""
96
+ if not token:
97
+ raise PermissionError("MEMGRES_NAMESPACES is on: a token is required")
98
+ return hashlib.sha256(token.encode("utf-8")).hexdigest()
99
+
100
+ def _expiry_sql(self, ttl_days: Optional[int]) -> str:
101
+ days = ttl_days if ttl_days is not None else self.cfg.retention_days
102
+ return "NULL" if not days or days <= 0 else f"now() + interval '{int(days)} days'"
103
+
104
+ def _check_parent(self, cur, ns: str, path: Optional[str]):
105
+ """When MEMGRES_REQUIRE_PARENT is on, a non-root node's parent path must
106
+ already exist as a memory. Root nodes (nlevel 1) have no parent to check."""
107
+ if not (self.cfg.require_parent and path):
108
+ return
109
+ cur.execute(
110
+ "SELECT nlevel(%s::ltree) > 1 AND NOT EXISTS ("
111
+ " SELECT 1 FROM memory WHERE namespace=%s "
112
+ " AND path = subpath(%s::ltree, 0, nlevel(%s::ltree)-1))",
113
+ (path, ns, path, path))
114
+ if cur.fetchone()[0]:
115
+ raise NoParent(f"parent of '{path}' does not exist "
116
+ f"(MEMGRES_REQUIRE_PARENT is on)")
117
+
118
+ def _raw_vec(self, body: str) -> "Optional[list]":
119
+ if not self.embedder:
120
+ return None
121
+ return self.embedder.embed_documents([body])[0]
122
+
123
+ def _embed(self, body: str) -> Optional[str]:
124
+ """pgvector literal for the in-row embedding column. Returns None when
125
+ embeddings are off OR vectors live in Qdrant (out-of-band)."""
126
+ if not self.embedder or self._use_qdrant:
127
+ return None
128
+ return _vec_literal(self.embedder.embed_documents([body])[0])
129
+
130
+ # ─── write: create, replace, diff, move, retag (one entrypoint) ─────────
131
+ def write(self, token: Optional[str] = None, *, id: Optional[str] = None,
132
+ body: Optional[str] = None, diff: Optional[str] = None,
133
+ base_hash: Optional[str] = None, path: Optional[str] = None,
134
+ tags: Optional[Sequence[str]] = None, source: Optional[str] = None,
135
+ reason: Optional[str] = None, ttl_days: Optional[int] = None) -> Memory:
136
+ ns = self._ns(token)
137
+ with self._conn.transaction():
138
+ if id is None:
139
+ return self._create(ns, body, path, tags, source, reason, ttl_days)
140
+ return self._update(ns, id, body, diff, base_hash, path, tags,
141
+ source, reason, ttl_days)
142
+
143
+ def _check_write_size(self, payload: Optional[str]):
144
+ if payload is not None and byte_len(payload) > self.cfg.max_write_bytes:
145
+ raise TooLarge(
146
+ f"write is {byte_len(payload)}B > MEMGRES_MAX_WRITE_BYTES "
147
+ f"{self.cfg.max_write_bytes}")
148
+
149
+ def _check_body_size(self, body: str):
150
+ if byte_len(body) > self.cfg.max_body_bytes:
151
+ raise TooLarge(
152
+ f"body would be {byte_len(body)}B > MEMGRES_MAX_BODY_BYTES "
153
+ f"{self.cfg.max_body_bytes}")
154
+
155
+ def _create(self, ns, body, path, tags, source, reason, ttl_days) -> Memory:
156
+ if body is None:
157
+ raise ValueError("create needs a body (diffs apply to an existing memory)")
158
+ self._check_write_size(body)
159
+ self._check_body_size(body)
160
+ chash = content_hash(body)
161
+ tags = list(tags or [])
162
+ cur = self._conn.cursor()
163
+ self._check_parent(cur, ns, path)
164
+ emb = self._embed(body)
165
+ emb_col = ", embedding" if emb else ""
166
+ emb_val = ", %s::vector" if emb else ""
167
+ params = [ns, body, chash, tags, path, self.cfg.fts_language, body]
168
+ if emb:
169
+ params.append(emb)
170
+ cur.execute(
171
+ f"""INSERT INTO memory (namespace, body, content_hash, tags, path, fts{emb_col},
172
+ seq, expires_at)
173
+ VALUES (%s, %s, %s, %s, %s::ltree,
174
+ to_tsvector(%s::regconfig, %s){emb_val},
175
+ 1, {self._expiry_sql(ttl_days)})
176
+ RETURNING id, created_at, updated_at, expires_at""",
177
+ params,
178
+ )
179
+ mid, created, updated, expires = cur.fetchone()
180
+ if self._use_qdrant:
181
+ self._qdrant.upsert(str(mid), self._raw_vec(body), ns)
182
+ # store create as a diff-from-empty so the whole history is a self-contained
183
+ # chain (empty → current), replayable forward for reconstruct/annotate.
184
+ self._append_history(str(mid), 1, "create", make_diff("", body), None, chash,
185
+ None, path, None, tags, source, reason)
186
+ return Memory(str(mid), body, chash, tags, path, 1, created, updated, expires)
187
+
188
+ def _load(self, cur, ns, id) -> tuple:
189
+ cur.execute(
190
+ "SELECT body, content_hash, tags, path::text, seq FROM memory "
191
+ "WHERE id=%s AND namespace=%s FOR UPDATE", (id, ns))
192
+ row = cur.fetchone()
193
+ if row is None:
194
+ raise NotFound(id)
195
+ return row # body, content_hash, tags, path, seq
196
+
197
+ def _update(self, ns, id, body, diff, base_hash, path, tags,
198
+ source, reason, ttl_days) -> Memory:
199
+ cur = self._conn.cursor()
200
+ cur_body, cur_hash, cur_tags, cur_path, seq = self._load(cur, ns, id)
201
+
202
+ # decide the new body
203
+ if diff is not None:
204
+ if base_hash is None:
205
+ raise ValueError("a diff must carry base_hash (the body it was cut from)")
206
+ if base_hash != cur_hash:
207
+ raise Conflict(f"stale diff: base {base_hash[:12]} != current {cur_hash[:12]}")
208
+ self._check_write_size(diff)
209
+ new_body = apply_diff(cur_body, diff)
210
+ op = "diff"
211
+ elif body is not None:
212
+ if base_hash is not None and base_hash != cur_hash:
213
+ raise Conflict(f"stale replace: base {base_hash[:12]} != current {cur_hash[:12]}")
214
+ self._check_write_size(body)
215
+ new_body = body
216
+ op = "replace"
217
+ else:
218
+ new_body = cur_body # metadata-only
219
+ op = None
220
+
221
+ new_hash = content_hash(new_body)
222
+ new_path = path if path is not None else cur_path
223
+ new_tags = list(tags) if tags is not None else list(cur_tags)
224
+ body_changed = new_hash != cur_hash
225
+ path_changed = new_path != cur_path
226
+ tags_changed = new_tags != list(cur_tags)
227
+
228
+ if op is None: # nothing content-y: classify the metadata change
229
+ if path_changed:
230
+ op = "move"
231
+ elif tags_changed:
232
+ op = "retag"
233
+ else:
234
+ # pure touch: renew TTL, no history row
235
+ cur.execute(
236
+ f"UPDATE memory SET updated_at=now(), expires_at={self._expiry_sql(ttl_days)} "
237
+ "WHERE id=%s", (id,))
238
+ return self.get(None, id, _ns=ns, renew=False)
239
+
240
+ if body_changed:
241
+ self._check_body_size(new_body)
242
+
243
+ if path_changed:
244
+ self._check_parent(cur, ns, new_path)
245
+
246
+ # a path change cascades to the whole subtree (keep ltree consistent)
247
+ if path_changed and cur_path is not None:
248
+ cur.execute(
249
+ "UPDATE memory SET path = %s::ltree || subpath(path, nlevel(%s::ltree)) "
250
+ "WHERE namespace=%s AND path <@ %s::ltree AND id <> %s",
251
+ (new_path, cur_path, ns, cur_path, id))
252
+
253
+ emb = self._embed(new_body) if body_changed else None
254
+ set_embedding = ", embedding=%s::vector" if emb else ""
255
+ params = [new_body, new_hash, new_tags, new_path,
256
+ self.cfg.fts_language, new_body]
257
+ if emb:
258
+ params.append(emb)
259
+ params.append(id)
260
+ cur.execute(
261
+ f"""UPDATE memory SET body=%s, content_hash=%s, tags=%s, path=%s::ltree,
262
+ fts=to_tsvector(%s::regconfig, %s){set_embedding},
263
+ seq=seq+1, updated_at=now(), expires_at={self._expiry_sql(ttl_days)}
264
+ WHERE id=%s
265
+ RETURNING seq, created_at, updated_at, expires_at""",
266
+ params)
267
+ new_seq, created, updated, expires = cur.fetchone()
268
+ if self._use_qdrant and body_changed:
269
+ self._qdrant.upsert(str(id), self._raw_vec(new_body), ns)
270
+ # store the canonical diff (recomputed) even for a whole-body replace, so
271
+ # every body change is line-attributable and the chain stays replayable.
272
+ stored_diff = make_diff(cur_body, new_body) if body_changed else None
273
+ self._append_history(str(id), new_seq, op, stored_diff,
274
+ cur_hash, new_hash,
275
+ cur_path if path_changed else None,
276
+ new_path if path_changed else None,
277
+ list(cur_tags) if tags_changed else None,
278
+ new_tags if tags_changed else None,
279
+ source, reason)
280
+ return Memory(str(id), new_body, new_hash, new_tags, new_path, new_seq,
281
+ created, updated, expires)
282
+
283
+ def _append_history(self, memory_id, seq, op, diff, hash_before, hash_after,
284
+ path_before, path_after, tags_before, tags_after,
285
+ source, reason):
286
+ cur = self._conn.cursor()
287
+ cur.execute("SELECT row_hash FROM memory_history WHERE memory_id=%s "
288
+ "ORDER BY seq DESC LIMIT 1", (memory_id,))
289
+ prev = cur.fetchone()
290
+ prev_hash = prev[0] if prev else None
291
+ rhash = _row_hash(prev_hash, memory_id, seq, op, diff, hash_after,
292
+ path_after, tags_after, source, reason)
293
+ cur.execute(
294
+ """INSERT INTO memory_history (memory_id, seq, op, diff, hash_before,
295
+ hash_after, path_before, path_after, tags_before, tags_after,
296
+ source, reason, prev_row_hash, row_hash)
297
+ VALUES (%s,%s,%s,%s,%s,%s,%s::ltree,%s::ltree,%s,%s,%s,%s,%s,%s)""",
298
+ (memory_id, seq, op, diff, hash_before, hash_after, path_before,
299
+ path_after, tags_before, tags_after, source, reason, prev_hash, rhash))
300
+
301
+ # ─── recall: lexical / semantic / hybrid ────────────────────────────────
302
+ def recall(self, token: Optional[str], query: str, *, k: int = 10,
303
+ tags: Optional[Sequence[str]] = None,
304
+ path_prefix: Optional[str] = None, mode: str = "auto"):
305
+ from .search import recall as _recall
306
+ return _recall(self._conn, self.cfg, self.embedder, self._ns(token),
307
+ query, k=k, tags=tags, path_prefix=path_prefix, mode=mode,
308
+ qdrant=self._qdrant)
309
+
310
+ # ─── convenience: move ──────────────────────────────────────────────────
311
+ def move(self, token: Optional[str], id: str, new_path: str,
312
+ *, source: Optional[str] = None, reason: Optional[str] = None) -> Memory:
313
+ return self.write(token, id=id, path=new_path, source=source, reason=reason)
314
+
315
+ # ─── read ───────────────────────────────────────────────────────────────
316
+ def get(self, token: Optional[str], id: str, *, renew: bool = True,
317
+ _ns: Optional[str] = None) -> Memory:
318
+ ns = _ns if _ns is not None else self._ns(token)
319
+ cur = self._conn.cursor()
320
+ cur.execute(
321
+ "SELECT id, body, content_hash, tags, path::text, seq, created_at, "
322
+ "updated_at, expires_at FROM memory WHERE id=%s AND namespace=%s",
323
+ (id, ns))
324
+ row = cur.fetchone()
325
+ if row is None:
326
+ raise NotFound(id)
327
+ if renew and self.cfg.renew_on_read and self.cfg.retention_days > 0:
328
+ with self._conn.transaction():
329
+ cur.execute(
330
+ f"UPDATE memory SET expires_at={self._expiry_sql(None)} WHERE id=%s",
331
+ (id,))
332
+ return Memory(str(row[0]), row[1], row[2], list(row[3]), row[4], row[5],
333
+ row[6], row[7], row[8])
334
+
335
+ def history(self, token: Optional[str], id: str) -> List[dict]:
336
+ ns = self._ns(token)
337
+ cur = self._conn.cursor()
338
+ cur.execute("SELECT 1 FROM memory WHERE id=%s AND namespace=%s", (id, ns))
339
+ if cur.fetchone() is None:
340
+ raise NotFound(id)
341
+ cur.execute(
342
+ "SELECT seq, op, diff, hash_before, hash_after, path_before::text, "
343
+ "path_after::text, tags_before, tags_after, source, reason, "
344
+ "prev_row_hash, row_hash, created_at FROM memory_history "
345
+ "WHERE memory_id=%s ORDER BY seq", (id,))
346
+ cols = ["seq", "op", "diff", "hash_before", "hash_after", "path_before",
347
+ "path_after", "tags_before", "tags_after", "source", "reason",
348
+ "prev_row_hash", "row_hash", "created_at"]
349
+ return [dict(zip(cols, r)) for r in cur.fetchall()]
350
+
351
+ def annotate(self, token: Optional[str], id: str,
352
+ upto_seq: Optional[int] = None,
353
+ lines: Optional[Sequence[int]] = None) -> List[dict]:
354
+ """Blame: the body with each line tagged by who last changed it. Pass
355
+ `lines` (1-based line numbers) to return only those lines."""
356
+ from .blame import annotate as _annotate
357
+ return _annotate(self.history(token, id), upto_seq, lines)
358
+
359
+ def annotate_grouped(self, token: Optional[str], id: str,
360
+ upto_seq: Optional[int] = None,
361
+ include_text: bool = True) -> List[dict]:
362
+ """Blame as runs: consecutive same-author lines collapse into one block.
363
+ `include_text=False` returns a pure ownership map (ranges, no body)."""
364
+ from .blame import annotate_grouped as _grouped
365
+ return _grouped(self.history(token, id), upto_seq, include_text)
366
+
367
+ def reconstruct(self, token: Optional[str], id: str,
368
+ upto_seq: Optional[int] = None) -> str:
369
+ """The exact body text at a past version (default current)."""
370
+ from .blame import reconstruct as _reconstruct
371
+ return _reconstruct(self.history(token, id), upto_seq)
372
+
373
+ def verify_history(self, token: Optional[str], id: str) -> bool:
374
+ """Recompute the chain; True if untampered."""
375
+ rows = self.history(token, id)
376
+ prev = None
377
+ for r in rows:
378
+ expect = _row_hash(prev, id, r["seq"], r["op"], r["diff"],
379
+ r["hash_after"], r["path_after"], r["tags_after"],
380
+ r["source"], r["reason"])
381
+ if expect != r["row_hash"] or (r["prev_row_hash"] or None) != prev:
382
+ return False
383
+ prev = r["row_hash"]
384
+ return True
385
+
386
+ # ─── forget: real erasure ───────────────────────────────────────────────
387
+ def forget(self, token: Optional[str], id: str) -> bool:
388
+ ns = self._ns(token)
389
+ with self._conn.transaction():
390
+ cur = self._conn.cursor()
391
+ cur.execute("DELETE FROM memory WHERE id=%s AND namespace=%s", (id, ns))
392
+ deleted = cur.rowcount > 0
393
+ if deleted and self._use_qdrant:
394
+ self._qdrant.delete(id) # drop the out-of-band vector too
395
+ return deleted
396
+
397
+ def purge_expired(self) -> int:
398
+ with self._conn.transaction():
399
+ cur = self._conn.cursor()
400
+ cur.execute("DELETE FROM memory WHERE expires_at IS NOT NULL "
401
+ "AND expires_at < now()")
402
+ return cur.rowcount
@@ -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,18 @@
1
+ memgres/__init__.py,sha256=dR2Y_pbU3YA63QlfgzHMN1o5P-cBZhjQ7i_CByvHGLI,1382
2
+ memgres/blame.py,sha256=PvhSIaxRkvnoBCgx8GS3M2VrJfiBZCn2iKlOsuyP8c0,5623
3
+ memgres/config.py,sha256=qwcIyUmkTQT4RRiOM19QuUmNJUA4ENMuqsl3SVbniWg,4679
4
+ memgres/diffing.py,sha256=31_WFRSMeZzvTz9YeP9iCxWaSiV0ye9UF9Mi0XT78zg,4693
5
+ memgres/embeddings.py,sha256=XkTmGR2XDlVf6nnkhedKVRkjTzNbqtgT9fYIywuhkD0,5990
6
+ memgres/mcp_server.py,sha256=Xt7LufWFQ3jrapGWpV3zIk1RyD24RA_eicYVfsR8x9M,4566
7
+ memgres/qdrant_backend.py,sha256=FLjYxd7QroU48HbNpHmOQzj5qVgLxF3ect2I-wNZrhQ,3417
8
+ memgres/schema.py,sha256=BLctBO24eYoaSazi12SNhX9OspyhaRmFu266kDZp9yA,5201
9
+ memgres/search.py,sha256=1nbC3pu-B6ns9PZvhJVniFAdKsu6uRXExpXMicuFFDM,5187
10
+ memgres/server.py,sha256=_hRQ_0nYKV83vWdq49ORgghCkJo-JttAzelNov7GFCY,9165
11
+ memgres/store.py,sha256=iGy4JdqlOHIU0HrulMJw-XuVYQ-p2M_iS_xcABovrqk,19149
12
+ memgres/migrations/0001_core.sql,sha256=vJ951yh3z9ncxsh7IHUraIhwFioryRBscgUDVpcumIk,4452
13
+ memgres-0.1.0.dist-info/licenses/LICENSE,sha256=dcR7Im4-m7tPgEJ89OAqe1Hz75X79H5JAntVATnKEb8,1079
14
+ memgres-0.1.0.dist-info/METADATA,sha256=Hl2fDMDiypxiRRF3nHnKzSaIXrWHtL3wXmCnn5YaAFY,14214
15
+ memgres-0.1.0.dist-info/WHEEL,sha256=K260EYznzXsJYBQGqmI8VTxEdiZYNvDZwW9cBh9-_MA,91
16
+ memgres-0.1.0.dist-info/entry_points.txt,sha256=5B59ZQnqTZpFl-kd4I8QDINvAhN9_0wGr7wuIebYLa8,93
17
+ memgres-0.1.0.dist-info/top_level.txt,sha256=8qNyfB6IdNlUo4bRZnoRdfqKqJIYuByrdAMiKUDA2uY,8
18
+ memgres-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (83.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,3 @@
1
+ [console_scripts]
2
+ memgres-mcp = memgres.mcp_server:main
3
+ memgres-server = memgres.server:main