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/__init__.py +34 -0
- memgres/blame.py +143 -0
- memgres/config.py +111 -0
- memgres/diffing.py +130 -0
- memgres/embeddings.py +144 -0
- memgres/mcp_server.py +114 -0
- memgres/migrations/0001_core.sql +72 -0
- memgres/qdrant_backend.py +82 -0
- memgres/schema.py +133 -0
- memgres/search.py +125 -0
- memgres/server.py +222 -0
- memgres/store.py +402 -0
- memgres-0.1.0.dist-info/METADATA +231 -0
- memgres-0.1.0.dist-info/RECORD +18 -0
- memgres-0.1.0.dist-info/WHEEL +5 -0
- memgres-0.1.0.dist-info/entry_points.txt +3 -0
- memgres-0.1.0.dist-info/licenses/LICENSE +21 -0
- memgres-0.1.0.dist-info/top_level.txt +1 -0
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
|
+
[](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.
|
|
@@ -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,,
|