superb-memory 0.2.1__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.
cli.py ADDED
@@ -0,0 +1,310 @@
1
+ #!/usr/bin/env python3
2
+ """mdb — command-line access to a MemoryDB server for scripts, cron jobs and CI.
3
+
4
+ Connection comes from --url or MEMORYDB_URL (accepts http://host:port plus
5
+ MEMORYDB_TOKEN, or a memorydb://TOKEN@host:port connection string).
6
+ `mdb serve` installs and starts the server itself, so pip is the only thing you need.
7
+ Every command prints the JSON response on stdout; errors exit non-zero.
8
+ """
9
+ import argparse
10
+ import hashlib
11
+ import json
12
+ import os
13
+ import platform
14
+ import secrets
15
+ import shutil
16
+ import subprocess
17
+ import sys
18
+ import tarfile
19
+ import urllib.error
20
+ import urllib.request
21
+ from urllib.parse import urlparse
22
+
23
+ sys.path.insert(0, str(os.path.dirname(os.path.abspath(__file__))))
24
+ from memorydb import MemoryDB, MemoryDBError # noqa: E402
25
+
26
+ SERVER_VERSION = "0.2.1" # the release tag mdb serve downloads by default
27
+ REPO = "humaniods/MemoryDB"
28
+
29
+
30
+ def load_db(args):
31
+ url = args.url or os.environ.get("MEMORYDB_URL", "http://127.0.0.1:8080")
32
+ if url.startswith("memorydb"):
33
+ return MemoryDB.from_url(url)
34
+ token = args.token or os.environ.get("MEMORYDB_TOKEN")
35
+ if not token:
36
+ sys.exit("no token: pass --token or set MEMORYDB_TOKEN/MEMORYDB_URL")
37
+ return MemoryDB(url, token=token)
38
+
39
+
40
+ def read_json_arg(value, path):
41
+ if path:
42
+ with open(path) as fh:
43
+ return json.load(fh)
44
+ if value:
45
+ return json.loads(value)
46
+ sys.exit("provide --json or --file")
47
+
48
+
49
+ def emit(obj):
50
+ print(json.dumps(obj, indent=2, default=str))
51
+
52
+
53
+ # -- mdb serve: the database itself, installed over pip, with no Go or git -----------------
54
+ def home():
55
+ path = os.environ.get("MEMORYDB_HOME") or os.path.join(os.path.expanduser("~"), ".memorydb")
56
+ os.makedirs(path, exist_ok=True)
57
+ return path
58
+
59
+
60
+ def platform_target():
61
+ system = platform.system()
62
+ if system == "Windows" or system.startswith(("MINGW", "MSYS", "CYGWIN")):
63
+ sys.exit("mdb serve: no native Windows build (the database lock is Unix-only); "
64
+ "run the server in WSL, Docker or on your app host.")
65
+ osname = {"Linux": "linux", "Darwin": "darwin"}.get(system)
66
+ if osname is None:
67
+ sys.exit(f"mdb serve: unsupported operating system: {system}")
68
+ arch = {"x86_64": "amd64", "amd64": "amd64", "aarch64": "arm64", "arm64": "arm64"}.get(
69
+ platform.machine().lower())
70
+ if arch is None:
71
+ sys.exit(f"mdb serve: unsupported CPU architecture: {platform.machine()}")
72
+ return osname, arch
73
+
74
+
75
+ def fetch(url, dest):
76
+ """Copy a release archive down over https, or from a local path/file:// URL."""
77
+ if url.startswith("https://"):
78
+ with urllib.request.urlopen(url, timeout=120) as response, open(dest, "wb") as out:
79
+ shutil.copyfileobj(response, out)
80
+ return
81
+ source = urlparse(url).path if url.startswith("file://") else url
82
+ if not os.path.exists(source):
83
+ raise FileNotFoundError(source)
84
+ shutil.copyfile(source, dest)
85
+
86
+
87
+ def checksum(path):
88
+ digest = hashlib.sha256()
89
+ with open(path, "rb") as fh:
90
+ for block in iter(lambda: fh.read(1 << 20), b""):
91
+ digest.update(block)
92
+ return digest.hexdigest()
93
+
94
+
95
+ def extract_binary(archive, dest):
96
+ """Unpack only the memorydb member, so a crafted path in a tar entry cannot write elsewhere."""
97
+ with tarfile.open(archive) as tar:
98
+ member = next((m for m in tar.getmembers()
99
+ if m.isfile() and os.path.basename(m.name) == "memorydb"), None)
100
+ if member is None:
101
+ sys.exit(f"mdb serve: {os.path.basename(archive)} holds no memorydb binary")
102
+ with tar.extractfile(member) as src, open(dest, "wb") as out:
103
+ shutil.copyfileobj(src, out)
104
+ os.chmod(dest, 0o755)
105
+ return dest
106
+
107
+
108
+ def check_against_sidecar(archive, sidecar):
109
+ """A checksums file that exists but does not name this archive is treated as a mismatch."""
110
+ if not sidecar or not os.path.exists(sidecar):
111
+ return
112
+ wanted = {}
113
+ with open(sidecar) as fh:
114
+ for line in fh:
115
+ parts = line.split(None, 1)
116
+ if len(parts) == 2:
117
+ wanted[parts[1].strip().lstrip("*")] = parts[0]
118
+ name = os.path.basename(archive)
119
+ if name not in wanted:
120
+ sys.exit(f"mdb serve: {name} is not listed in {sidecar}; refusing to install it")
121
+ if checksum(archive) != wanted[name]:
122
+ sys.exit(f"mdb serve: sha256 mismatch for {name}; refusing to install it")
123
+
124
+
125
+ def install_from_archive(archive, version):
126
+ check_against_sidecar(archive, os.path.join(os.path.dirname(archive), "sha256sums.txt"))
127
+ staged = os.path.join(home(), "bin", f"memorydb-{version}.tmp")
128
+ target = binary_path(version)
129
+ extract_binary(archive, staged)
130
+ os.replace(staged, target)
131
+ return target
132
+
133
+
134
+ def binary_path(version):
135
+ return os.path.join(home(), "bin", f"memorydb-{version}")
136
+
137
+
138
+ def ensure_binary(args):
139
+ if args.bin:
140
+ return os.path.abspath(args.bin)
141
+ target = binary_path(args.version)
142
+ if os.path.exists(target) and not args.force:
143
+ return target
144
+ os.makedirs(os.path.join(home(), "bin"), exist_ok=True)
145
+ if args.from_archive:
146
+ return install_from_archive(args.from_archive, args.version)
147
+ if args.source:
148
+ if shutil.which("go") is None:
149
+ sys.exit("mdb serve: --source needs Go on PATH")
150
+ built = os.path.join(home(), "bin", f"memorydb-{args.version}.tmp")
151
+ subprocess.run(["go", "build", "-trimpath", "-o", built, "."], cwd=args.source, check=True)
152
+ os.replace(built, target)
153
+ return target
154
+ osname, arch = platform_target()
155
+ name = f"memorydb-{args.version}-{osname}-{arch}.tar.gz"
156
+ base = args.dist_base.format(repo=REPO, version=args.version)
157
+ archive = os.path.join(home(), "bin", name)
158
+ print(f"downloading {base}/{name}")
159
+ try:
160
+ fetch(base + "/" + name, archive)
161
+ try:
162
+ fetch(base + "/sha256sums.txt", os.path.join(os.path.dirname(archive), "sha256sums.txt"))
163
+ except FileNotFoundError:
164
+ sys.exit("mdb serve: this release publishes no sha256sums.txt; refusing to run an "
165
+ "unverified binary. Use --from <archive>, --source <tree> or --bin <path>.")
166
+ except (OSError, urllib.error.URLError) as exc:
167
+ sys.exit(f"mdb serve: could not fetch a release binary for {osname}/{arch} "
168
+ f"({exc.__class__.__name__}). If this version is not released yet, use "
169
+ f"--source <path-to-checkout> or --bin <path-to-memorydb>.")
170
+ return install_from_archive(archive, args.version)
171
+
172
+
173
+ def serve_token(explicit):
174
+ if explicit:
175
+ return explicit
176
+ if os.environ.get("MEMORYDB_TOKEN"):
177
+ return os.environ["MEMORYDB_TOKEN"]
178
+ path = os.path.join(home(), "token")
179
+ if os.path.exists(path):
180
+ with open(path) as fh:
181
+ token = fh.read().strip()
182
+ if token:
183
+ return token
184
+ token = secrets.token_urlsafe(32)
185
+ handle = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
186
+ with os.fdopen(handle, "w") as fh:
187
+ fh.write(token + "\n")
188
+ return token
189
+
190
+
191
+ def serve(args):
192
+ binary = ensure_binary(args)
193
+ data = args.data or os.path.join(home(), "data", "memory.wal")
194
+ os.makedirs(os.path.dirname(os.path.abspath(data)), exist_ok=True)
195
+ token = serve_token(args.token)
196
+ env = dict(os.environ, MEMORYDB_TOKEN=token)
197
+ command = [binary, "-addr", args.addr, "-data", data]
198
+ if args.worker:
199
+ command.append("-worker")
200
+ command += args.extra
201
+ scheme = "memorydbs" if args.extra and "-tls-cert" in args.extra else "memorydb"
202
+ host = args.addr if "://" not in args.addr else urlparse(args.addr).netloc
203
+ print(f"starting MemoryDB on {host}")
204
+ print(f" data: {data}")
205
+ print(f" connect: {scheme}://{token}@{host}")
206
+ print(f" console: http{'s' if scheme == 'memorydbs' else ''}://{host} "
207
+ f"studio: http{'s' if scheme == 'memorydbs' else ''}://{host}/studio/")
208
+ sys.stdout.flush()
209
+ return subprocess.run(command, env=env).returncode
210
+
211
+
212
+ def main(argv=None):
213
+ parser = argparse.ArgumentParser(prog="mdb", description=__doc__.splitlines()[0])
214
+ parser.add_argument("--url", help="http://host:port or memorydb://TOKEN@host:port")
215
+ parser.add_argument("--token")
216
+ sub = parser.add_subparsers(dest="cmd", required=True)
217
+
218
+ sub.add_parser("health")
219
+
220
+ p = sub.add_parser("collections"); p.add_argument("action", choices=["ls", "create"])
221
+ p.add_argument("name", nargs="?"); p.add_argument("--json", help="schema JSON")
222
+ p.add_argument("--file")
223
+
224
+ p = sub.add_parser("doc"); p.add_argument("action", choices=["add", "get", "rm", "patch"])
225
+ p.add_argument("collection"); p.add_argument("id", nargs="?")
226
+ p.add_argument("--json"); p.add_argument("--file"); p.add_argument("--version", type=int)
227
+
228
+ p = sub.add_parser("query"); p.add_argument("collection")
229
+ p.add_argument("--where", default="[]"); p.add_argument("--order"); p.add_argument("--desc", action="store_true")
230
+ p.add_argument("--limit", type=int, default=50); p.add_argument("--fields", help="comma-separated projection")
231
+
232
+ p = sub.add_parser("memory"); p.add_argument("action", choices=["add", "ls", "history"])
233
+ p.add_argument("--kind", default="fact"); p.add_argument("--content"); p.add_argument("--id")
234
+ p.add_argument("--source", default="cli"); p.add_argument("--q", default=""); p.add_argument("--limit", type=int, default=50)
235
+
236
+ p = sub.add_parser("search"); p.add_argument("text")
237
+ p.add_argument("--kind", default=""); p.add_argument("--limit", type=int, default=10)
238
+
239
+ p = sub.add_parser("ingest"); p.add_argument("--file", required=True)
240
+ p = sub.add_parser("tx"); p.add_argument("--file", required=True); p.add_argument("--key")
241
+
242
+ p = sub.add_parser("serve", help="install (if needed) and run the MemoryDB server")
243
+ p.add_argument("--addr", default="127.0.0.1:8080")
244
+ p.add_argument("--data", help="journal path (default ~/.memorydb/data/memory.wal)")
245
+ p.add_argument("--token", default=argparse.SUPPRESS, help="admin token (default MEMORYDB_TOKEN or a generated one)")
246
+ p.add_argument("--version", default=SERVER_VERSION, help="release binary to fetch")
247
+ p.add_argument("--bin", help="run this already-built server binary")
248
+ p.add_argument("--from", dest="from_archive", help="install the server from this release archive")
249
+ p.add_argument("--source", help="build the server from this source tree (needs Go)")
250
+ p.add_argument("--dist-base", default="https://github.com/{repo}/releases/download/v{version}",
251
+ help="release download base; {repo} and {version} are substituted")
252
+ p.add_argument("--worker", action="store_true", help="also run durable ingestion jobs")
253
+ p.add_argument("--force", action="store_true", help="re-download even when the binary is cached")
254
+ p.add_argument("extra", nargs=argparse.REMAINDER,
255
+ help="more server flags, e.g. -- -tls-cert cert.pem -tls-key key.pem")
256
+
257
+ args = parser.parse_args(argv)
258
+ if args.cmd == "serve":
259
+ return serve(args)
260
+ db = load_db(args)
261
+ try:
262
+ if args.cmd == "health":
263
+ emit(db.request("GET", "health/"))
264
+ elif args.cmd == "collections":
265
+ if args.action == "ls":
266
+ emit(db.collections.all())
267
+ else:
268
+ schema = read_json_arg(args.json, args.file) if (args.json or args.file) else {}
269
+ emit(db.collections.create(args.name, **schema))
270
+ elif args.cmd == "doc":
271
+ if args.action == "add":
272
+ emit(db.collection(args.collection).objects.create(data=read_json_arg(args.json, args.file)))
273
+ elif args.action == "get":
274
+ emit(db.collection(args.collection).objects.get(pk=args.id))
275
+ elif args.action == "patch":
276
+ emit(db.collection(args.collection).objects.update(pk=args.id, version=args.version, **read_json_arg(args.json, args.file)))
277
+ else:
278
+ emit(db.collection(args.collection).objects.delete(pk=args.id, version=args.version))
279
+ elif args.cmd == "query":
280
+ q = {"where": json.loads(args.where), "limit": args.limit}
281
+ if args.order:
282
+ q["order_by"] = args.order
283
+ q["descending"] = args.desc
284
+ if args.fields:
285
+ q["projection"] = args.fields.split(",")
286
+ emit(db.request("POST", "query/" + args.collection + "/", q))
287
+ elif args.cmd == "memory":
288
+ if args.action == "add":
289
+ emit(db.objects.create(kind=args.kind, content=args.content, source=args.source))
290
+ elif args.action == "ls":
291
+ emit(db.objects.filter(kind=args.kind if args.kind != "fact" else "", source=args.source, q=args.q, limit=args.limit))
292
+ else:
293
+ emit(db.objects.history(pk=args.id)["results"])
294
+ elif args.cmd == "search":
295
+ emit(db.objects.search(args.text, kind=args.kind, limit=args.limit))
296
+ elif args.cmd == "ingest":
297
+ with open(args.file) as fh:
298
+ payload = json.load(fh)
299
+ emit(db.ingest(payload["messages"], facts=payload.get("facts"), source=payload.get("source", "cli")))
300
+ elif args.cmd == "tx":
301
+ with open(args.file) as fh:
302
+ payload = json.load(fh)
303
+ emit(db.transaction(payload["changes"], idempotency_key=args.key))
304
+ except MemoryDBError as exc:
305
+ sys.exit(f"mdb error [{exc.status}]: {exc}")
306
+ return 0
307
+
308
+
309
+ if __name__ == "__main__":
310
+ sys.exit(main())
memorydb.py ADDED
@@ -0,0 +1,354 @@
1
+ """Django-style, dependency-free HTTP client for the original Go engine.
2
+ This is not a Django ORM backend: custom QuerySet support, but no SQL, migrations or joins.
3
+ """
4
+ import json
5
+ import os
6
+ from urllib.error import HTTPError
7
+ from urllib.parse import quote, unquote, urlencode, urlsplit
8
+ from urllib.request import Request, urlopen
9
+
10
+ class MemoryDBError(Exception):
11
+ def __init__(self, status, message):
12
+ self.status = status
13
+ super().__init__(message)
14
+
15
+ class MemoryManager:
16
+ def __init__(self, client):
17
+ self.client = client
18
+
19
+ def create(self, *, kind="fact", content, **fields):
20
+ return self.client.request("POST", "memories/", {"kind": kind, "content": content, **fields})
21
+
22
+ def filter(self, *, kind="", source="", q="", limit=50):
23
+ return self.client.request("GET", "memories/?" + urlencode(dict(kind=kind, source=source, q=q, limit=limit)))["results"]
24
+
25
+ def get(self, *, pk):
26
+ return self.client.request("GET", "memories/" + quote(pk, safe="") + "/")
27
+
28
+ def update(self, *, pk, version, **fields):
29
+ record = self.get(pk=pk)
30
+ if record["version"] != version:
31
+ raise MemoryDBError(409, "version conflict; reload the record")
32
+ record.update(fields)
33
+ record["version"] = version
34
+ return self.client.request("PUT", "memories/" + quote(pk, safe="") + "/", record)
35
+
36
+ def delete(self, *, pk, version):
37
+ return self.client.request("DELETE", "memories/" + quote(pk, safe="") + "/", headers={"If-Match": str(version)})
38
+
39
+ def history(self, *, pk):
40
+ return self.client.request("GET", "memories/" + quote(pk, safe="") + "/history/")["results"]
41
+
42
+ def search(self, text="", *, vector=None, kind="", source="", embedding_model="", agent="", limit=10):
43
+ return self.client.request("POST", "search/", dict(text=text, vector=vector or [], kind=kind, source=source, embedding_model=embedding_model, agent=agent, limit=limit))["results"]
44
+
45
+ def parse_connection_string(connection_string):
46
+ """memorydb://TOKEN@host:port (plain HTTP) or memorydbs://TOKEN@host:port (TLS).
47
+
48
+ The port defaults to 8080. This mirrors the mongodb:// and redis:// convention so
49
+ deployments can hand one env var (MEMORYDB_URL) to every client.
50
+ """
51
+ parts = urlsplit(connection_string)
52
+ if parts.scheme not in ("memorydb", "memorydbs"):
53
+ raise ValueError("connection string must start with memorydb:// or memorydbs://")
54
+ if not parts.username:
55
+ raise ValueError("connection string must carry the token: memorydb://TOKEN@host:port")
56
+ host = parts.hostname or "127.0.0.1"
57
+ port = parts.port or 8080
58
+ scheme = "https" if parts.scheme == "memorydbs" else "http"
59
+ return scheme + "://" + host + ":" + str(port), unquote(parts.username)
60
+
61
+
62
+ class MemoryDB:
63
+ def __init__(self, url="http://127.0.0.1:8080", *, token=None, timeout=35):
64
+ if token is None:
65
+ if url.startswith(("memorydb://", "memorydbs://")):
66
+ connection_string = url
67
+ else:
68
+ connection_string = os.environ.get("MEMORYDB_URL", "")
69
+ if not connection_string:
70
+ raise ValueError("pass a url with token=, a memorydb:// connection string, or set MEMORYDB_URL")
71
+ url, token = parse_connection_string(connection_string)
72
+ self.url = url.rstrip("/") + "/api/v1/"
73
+ self.token = token
74
+ self.timeout = timeout
75
+ self.objects = MemoryManager(self)
76
+ self.collections = CollectionManager(self)
77
+
78
+ @classmethod
79
+ def from_url(cls, connection_string, **fields):
80
+ """MemoryDB.from_url("memorydb://TOKEN@127.0.0.1:8080")"""
81
+ url, token = parse_connection_string(connection_string)
82
+ return cls(url, token=token, **fields)
83
+
84
+ def collection(self, name):
85
+ return Collection(self, name)
86
+
87
+ def transaction(self, changes, *, idempotency_key=None):
88
+ headers = {"Idempotency-Key": idempotency_key} if idempotency_key else None
89
+ return self.request("POST", "transactions/", {"changes": changes}, headers=headers)["results"]
90
+
91
+ def ingest(self, messages, *, facts=None, extract=False, source="python", context="", tools=None):
92
+ return self.request("POST", "ingest/", {"messages": messages, "facts": facts or [], "extract": extract,
93
+ "source": source, "context": context, "tools": tools or []})["results"]
94
+
95
+ def queue_ingest(self, **payload):
96
+ return self.request("POST", "jobs/", payload)
97
+
98
+ def request(self, method, path, body=None, headers=None):
99
+ req = Request(self.url + path, data=json.dumps(body).encode() if body is not None else None,
100
+ headers={"Authorization": "Bearer " + self.token, "Content-Type": "application/json", **(headers or {})}, method=method)
101
+ try:
102
+ with urlopen(req, timeout=self.timeout) as response:
103
+ return json.load(response)
104
+ except HTTPError as exc:
105
+ try:
106
+ message = json.load(exc).get("error", str(exc))
107
+ except (ValueError, AttributeError):
108
+ message = str(exc)
109
+ raise MemoryDBError(exc.code, message) from exc
110
+
111
+ SUPPORTED_LOOKUPS = {"eq", "ne", "gt", "gte", "lt", "lte", "in", "contains", "icontains",
112
+ "prefix", "exists", "iequals", "iexact"}
113
+
114
+
115
+ def _predicates(lookups):
116
+ """Turn Django-style kwargs into engine predicates; iexact is the Django spelling of iequals."""
117
+ predicates = []
118
+ for key, value in lookups.items():
119
+ parts = key.split("__")
120
+ op = parts.pop() if parts[-1] in SUPPORTED_LOOKUPS else "eq"
121
+ if op == "iexact":
122
+ op = "iequals"
123
+ predicates.append({"field": ".".join(parts), "op": op, "value": value})
124
+ return predicates
125
+
126
+
127
+ def _aggregate(fn, field, as_=None):
128
+ expr = {"fn": fn, "field": field}
129
+ if as_:
130
+ expr["as"] = as_
131
+ return expr
132
+
133
+
134
+ def Sum(field, as_=None):
135
+ return _aggregate("sum", field, as_)
136
+
137
+
138
+ def Avg(field, as_=None):
139
+ return _aggregate("avg", field, as_)
140
+
141
+
142
+ def Min(field, as_=None):
143
+ return _aggregate("min", field, as_)
144
+
145
+
146
+ def Max(field, as_=None):
147
+ return _aggregate("max", field, as_)
148
+
149
+
150
+ class QuerySet:
151
+ """Lazy immutable query builder. Pagination rejects concurrent DB changes."""
152
+ def __init__(self, manager, query=None):
153
+ self.manager = manager
154
+ self.query = dict(query or {"where": [], "limit": 100})
155
+
156
+ def filter(self, **lookups):
157
+ predicates = list(self.query.get("where", []))
158
+ predicates.extend(_predicates(lookups))
159
+ return QuerySet(self.manager, {**self.query, "where": predicates})
160
+
161
+ def exclude(self, **lookups):
162
+ predicates = list(self.query.get("not", []))
163
+ predicates.extend(_predicates(lookups))
164
+ return QuerySet(self.manager, {**self.query, "not": predicates})
165
+
166
+ def or_q(self, *groups):
167
+ """Require the filters above plus at least one of these lookup groups.
168
+
169
+ Each group is ANDed on its own, so or_q({"email": x}, {"username": x}) means
170
+ "either field matches" - something a flat where list cannot say.
171
+ """
172
+ or_groups = list(self.query.get("or", []))
173
+ for group in groups:
174
+ predicates = _predicates(group)
175
+ if not predicates:
176
+ raise ValueError("each or_q group needs at least one lookup")
177
+ or_groups.append(predicates)
178
+ return QuerySet(self.manager, {**self.query, "or": or_groups})
179
+
180
+ def aggregate(self, *exprs, **named):
181
+ """Fold the whole filtered set, not the current page.
182
+
183
+ projects.aggregate(total=Sum("views"), best=Max("views")) reads like Django's
184
+ aggregate(); the keyword becomes the output alias.
185
+ """
186
+ entries = list(self.query.get("aggregate", []))
187
+ entries.extend(exprs)
188
+ for alias, expr in named.items():
189
+ entries.append({**expr, "as": alias})
190
+ return QuerySet(self.manager, {**self.query, "aggregate": entries})
191
+
192
+ def group_by(self, *fields):
193
+ grouped = list(self.query.get("group_by", []))
194
+ grouped.extend(fields)
195
+ return QuerySet(self.manager, {**self.query, "group_by": grouped})
196
+
197
+ def groups(self):
198
+ """Return one {group, count, values} row per aggregate group."""
199
+ return self.page(limit=1).get("groups", [])
200
+
201
+ def order_by(self, *fields):
202
+ """Sort by one or more fields, prefixing a name with "-" for descending.
203
+
204
+ A single field keeps the compact wire form; several fields travel as a list so
205
+ ties fall through, the way "trending" needs likes, then views, then newest.
206
+ """
207
+ terms = [{"field": f.lstrip("-"), "descending": f.startswith("-")} for f in fields]
208
+ if not terms:
209
+ raise ValueError("order_by needs at least one field")
210
+ if len(terms) == 1:
211
+ return QuerySet(self.manager, {**self.query, "order_by": terms[0]["field"],
212
+ "descending": terms[0]["descending"]})
213
+ return QuerySet(self.manager, {**self.query, "order_by": terms, "descending": False})
214
+
215
+ def offset(self, n):
216
+ if n < 0:
217
+ raise ValueError("offset cannot be negative")
218
+ return QuerySet(self.manager, {**self.query, "offset": n})
219
+
220
+ def only(self, *fields):
221
+ return QuerySet(self.manager, {**self.query, "projection": list(fields)})
222
+
223
+ def page(self, cursor=None, limit=None):
224
+ query = dict(self.query)
225
+ if cursor:
226
+ query["cursor"] = cursor
227
+ if limit is not None:
228
+ query["limit"] = limit
229
+ return self.manager.client.request("POST", "query/" + self.manager.name + "/", query)
230
+
231
+ def count(self):
232
+ return self.page(limit=1)["total"]
233
+
234
+ def first(self):
235
+ results = self.page(limit=1)["results"]
236
+ return results[0] if results else None
237
+
238
+ def __iter__(self):
239
+ cursor = None
240
+ while True:
241
+ page = self.page(cursor=cursor)
242
+ yield from page["results"]
243
+ cursor = page.get("next_cursor")
244
+ if not cursor:
245
+ break
246
+
247
+ def all(self):
248
+ return list(self)
249
+
250
+ class DocumentManager:
251
+ def __init__(self, client, name):
252
+ import re
253
+ if not re.fullmatch(r"[A-Za-z][A-Za-z0-9_-]{0,63}", name):
254
+ raise ValueError("Invalid collection name")
255
+ self.client, self.name = client, name
256
+ self.path = "documents/" + name + "/"
257
+
258
+ def create(self, *, data=None, idempotency_key=None, **fields):
259
+ if data is not None and fields:
260
+ raise ValueError("Pass data or keyword fields, not both")
261
+ return self.client.request("POST", self.path, {"data": fields if data is None else data},
262
+ headers={"Idempotency-Key": idempotency_key} if idempotency_key else None)
263
+
264
+ def get(self, *, pk):
265
+ return self.client.request("GET", self.path + quote(pk, safe="") + "/")
266
+
267
+ def filter(self, **lookups):
268
+ return QuerySet(self).filter(**lookups)
269
+
270
+ def exclude(self, **lookups):
271
+ return QuerySet(self).exclude(**lookups)
272
+
273
+ def or_q(self, *groups):
274
+ return QuerySet(self).or_q(*groups)
275
+
276
+ def aggregate(self, *exprs, **named):
277
+ return QuerySet(self).aggregate(*exprs, **named)
278
+
279
+ def group_by(self, *fields):
280
+ return QuerySet(self).group_by(*fields)
281
+
282
+ def order_by(self, *fields):
283
+ return QuerySet(self).order_by(*fields)
284
+
285
+ def offset(self, n):
286
+ return QuerySet(self).offset(n)
287
+
288
+ def all(self):
289
+ return QuerySet(self)
290
+
291
+ def update(self, *, pk, version=None, unset=None, inc=None, **fields):
292
+ """Patch a record. Pass version for a compare-and-set, or inc to add onto
293
+ numeric fields server-side; inc re-reads the record under the engine lock, so
294
+ counters like page views cannot lose a concurrent write and need no version."""
295
+ body = {"version": version, "set": fields, "unset": unset or []}
296
+ if inc:
297
+ body["inc"] = inc
298
+ return self.client.request("PATCH", self.path + quote(pk, safe="") + "/", body)
299
+
300
+ def increment(self, *, pk, **steps):
301
+ """Atomically add to numeric fields, e.g. increment(pk=pid, views=1)."""
302
+ return self.update(pk=pk, inc=steps)
303
+
304
+ def delete(self, *, pk, version):
305
+ return self.client.request("DELETE", self.path + quote(pk, safe="") + "/", headers={"If-Match": str(version)})
306
+
307
+ def bulk_create(self, documents, *, idempotency_key=None):
308
+ return self.client.transaction([{"record": {"kind": "document", "collection": self.name, "data": d, "visibility": "team"}, "expected": 0} for d in documents], idempotency_key=idempotency_key)
309
+
310
+ class Collection:
311
+ def __init__(self, client, name):
312
+ self.objects = DocumentManager(client, name)
313
+
314
+ class CollectionManager:
315
+ def __init__(self, client):
316
+ self.client = client
317
+
318
+ def create(self, name, *, fields=None, unique=None, indexes=None, strict=False):
319
+ return self.client.request("POST", "collections/", {"name": name, "schema": {"fields": fields or {}, "unique": unique or [], "indexes": indexes or [], "strict": strict}})
320
+
321
+ def all(self):
322
+ return self.client.request("GET", "collections/")["results"]
323
+
324
+ def schema(self, name):
325
+ """The collection record, which carries both its schema and the revision to change it."""
326
+ for record in self.all():
327
+ if record["collection"] == name:
328
+ return record
329
+ raise MemoryDBError(404, f"collection {name} does not exist")
330
+
331
+ def alter(self, name, *, fields=None, unique=None, indexes=None, strict=None):
332
+ """Widen a collection in place, which is what ALTER TABLE ADD COLUMN amounts to here.
333
+
334
+ Only new declarations are added and an existing field may not be restated with a
335
+ different type. Rows written before an added field gain that field at its zero value,
336
+ so a reader never meets a document missing a column the schema promises. A required
337
+ or unique field that the stored rows cannot satisfy is refused by the engine.
338
+ """
339
+ record = self.schema(name)
340
+ schema = record["data"]
341
+ for path, field in (fields or {}).items():
342
+ current = schema.setdefault("fields", {}).get(path)
343
+ if current is not None and current != field:
344
+ raise MemoryDBError(409, f"{name}.{path} already has a different schema")
345
+ schema["fields"][path] = field
346
+ for key, added in (("unique", unique), ("indexes", indexes)):
347
+ existing = schema.setdefault(key, [])
348
+ for path in added or []:
349
+ if path not in existing:
350
+ existing.append(path)
351
+ if strict is not None:
352
+ schema["strict"] = strict
353
+ return self.client.request("PUT", "memories/" + quote(record["id"], safe="") + "/",
354
+ {**record, "data": schema})
memorydb_mcp.py ADDED
@@ -0,0 +1,145 @@
1
+ #!/usr/bin/env python3
2
+ """MCP (Model Context Protocol) server for MemoryDB over stdio JSON-RPC.
3
+
4
+ Lets any MCP-capable agent (Claude Desktop, Qoder, IDE assistants) read and write
5
+ MemoryDB memories without custom glue code. Dependency-free apart from the MemoryDB
6
+ client; point it at a server with MEMORYDB_URL=memorydb://TOKEN@host:port.
7
+
8
+ Register in an MCP client config, e.g.:
9
+ {"mcpServers": {"memorydb": {"command": "python3", "args": ["memorydb_mcp.py"],
10
+ "env": {"MEMORYDB_URL": "memorydb://TOKEN@127.0.0.1:8080"}}}}
11
+ """
12
+ import json
13
+ import sys
14
+ from urllib.parse import urlencode
15
+
16
+ sys.path.insert(0, str(__import__("pathlib").Path(__file__).resolve().parent))
17
+ from memorydb import MemoryDB # noqa: E402
18
+
19
+ PROTOCOL_VERSION = "2024-11-05"
20
+ SERVER_INFO = {"name": "memorydb", "version": "0.2.1"}
21
+
22
+ TOOL_DEFS = [
23
+ {"name": "memorydb_health", "description": "Server status, identity and transaction sequence.",
24
+ "inputSchema": {"type": "object", "properties": {}}},
25
+ {"name": "memorydb_search", "description": "Hybrid BM25 + vector search over readable memories.",
26
+ "inputSchema": {"type": "object", "properties": {
27
+ "text": {"type": "string"}, "vector": {"type": "array", "items": {"type": "number"}},
28
+ "kind": {"type": "string", "description": "chat|fact|summary|profile|intention|experience|skill|wiki|code|revision"},
29
+ "source": {"type": "string"}, "limit": {"type": "integer", "default": 10}}, "required": ["text"]}},
30
+ {"name": "memorydb_list_memories", "description": "List/search memories by kind, source and text.",
31
+ "inputSchema": {"type": "object", "properties": {
32
+ "kind": {"type": "string"}, "source": {"type": "string"}, "q": {"type": "string"},
33
+ "limit": {"type": "integer", "default": 50}}}},
34
+ {"name": "memorydb_add_memory", "description": "Store one memory record (kind + content, optional source/layer/metadata).",
35
+ "inputSchema": {"type": "object", "properties": {
36
+ "kind": {"type": "string", "default": "fact"}, "content": {"type": "string"},
37
+ "source": {"type": "string", "default": "mcp"}, "layer": {"type": "string"},
38
+ "metadata": {"type": "object", "additionalProperties": {"type": "string"}}}, "required": ["content"]}},
39
+ {"name": "memorydb_ingest", "description": "Ingest a conversation with evidence-linked facts. Each fact quote must appear verbatim in the referenced user message.",
40
+ "inputSchema": {"type": "object", "properties": {
41
+ "source": {"type": "string", "default": "mcp"},
42
+ "messages": {"type": "array", "items": {"type": "object", "properties": {
43
+ "role": {"type": "string"}, "content": {"type": "string"}}, "required": ["role", "content"]}},
44
+ "facts": {"type": "array", "items": {"type": "object", "properties": {
45
+ "subject": {"type": "string"}, "predicate": {"type": "string"}, "value": {"type": "string"},
46
+ "quote": {"type": "string"}, "message_index": {"type": "integer"},
47
+ "confidence": {"type": "number", "default": 1.0}},
48
+ "required": ["subject", "predicate", "value", "quote", "message_index"]}}},
49
+ "required": ["messages"]}},
50
+ {"name": "memorydb_create_collection", "description": "Create a document collection with a JSON schema (admin role required).",
51
+ "inputSchema": {"type": "object", "properties": {
52
+ "name": {"type": "string"},
53
+ "schema": {"type": "object", "properties": {
54
+ "fields": {"type": "object"}, "unique": {"type": "array", "items": {"type": "string"}},
55
+ "indexes": {"type": "array", "items": {"type": "string"}}, "strict": {"type": "boolean"}}}},
56
+ "required": ["name", "schema"]}},
57
+ {"name": "memorydb_query_documents", "description": "Query a collection: where[op eq|ne|gt|gte|lt|lte|in|contains|prefix|exists], order_by, descending, projection, limit.",
58
+ "inputSchema": {"type": "object", "properties": {
59
+ "collection": {"type": "string"},
60
+ "where": {"type": "array", "items": {"type": "object", "properties": {
61
+ "field": {"type": "string"}, "op": {"type": "string"}, "value": {}}}},
62
+ "order_by": {"type": "string"}, "descending": {"type": "boolean"},
63
+ "projection": {"type": "array", "items": {"type": "string"}},
64
+ "limit": {"type": "integer", "default": 50}}, "required": ["collection"]}},
65
+ ]
66
+
67
+
68
+ def handle(db, method, params):
69
+ if method == "memorydb_health":
70
+ return db.request("GET", "health/")
71
+ if method == "memorydb_search":
72
+ return db.objects.search(text=params.get("text", ""), vector=params.get("vector"),
73
+ kind=params.get("kind", ""), source=params.get("source", ""),
74
+ limit=params.get("limit", 10))
75
+ if method == "memorydb_list_memories":
76
+ return db.request("GET", "memories/?" + urlencode({"kind": params.get("kind", ""),
77
+ "source": params.get("source", ""), "q": params.get("q", ""), "limit": params.get("limit", 50)}))
78
+ if method == "memorydb_add_memory":
79
+ body = {"kind": params.get("kind", "fact"), "content": params["content"],
80
+ "source": params.get("source", "mcp")}
81
+ if params.get("layer"):
82
+ body["layer"] = params["layer"]
83
+ if params.get("metadata"):
84
+ body["metadata"] = params["metadata"]
85
+ return db.request("POST", "memories/", body)
86
+ if method == "memorydb_ingest":
87
+ return db.ingest(params["messages"], facts=params.get("facts"), source=params.get("source", "mcp"))
88
+ if method == "memorydb_create_collection":
89
+ schema = params["schema"]
90
+ return db.collections.create(params["name"], fields=schema.get("fields"),
91
+ unique=schema.get("unique"), indexes=schema.get("indexes"),
92
+ strict=schema.get("strict", False))
93
+ if method == "memorydb_query_documents":
94
+ q = {k: v for k, v in params.items() if k != "collection"}
95
+ return db.request("POST", "query/" + params["collection"] + "/", q)
96
+ raise ValueError("unknown tool " + method)
97
+
98
+
99
+ def serve():
100
+ import os
101
+ env_url = os.environ.get("MEMORYDB_URL", "")
102
+ if env_url.startswith("memorydb"):
103
+ db = MemoryDB.from_url(env_url)
104
+ else:
105
+ db = MemoryDB(env_url or "http://127.0.0.1:8080", token=os.environ.get("MEMORYDB_TOKEN"))
106
+ for line in sys.stdin:
107
+ line = line.strip()
108
+ if not line:
109
+ continue
110
+ try:
111
+ msg = json.loads(line)
112
+ except json.JSONDecodeError:
113
+ continue
114
+ method, params, msg_id = msg.get("method"), msg.get("params", {}), msg.get("id")
115
+ if method == "initialize":
116
+ reply(msg_id, {"protocolVersion": PROTOCOL_VERSION, "capabilities": {"tools": {}}, "serverInfo": SERVER_INFO})
117
+ elif method == "notifications/initialized" or msg_id is None:
118
+ continue
119
+ elif method == "tools/list":
120
+ reply(msg_id, {"tools": TOOL_DEFS})
121
+ elif method == "tools/call":
122
+ tool, arguments = params.get("name"), params.get("arguments", {})
123
+ try:
124
+ result = handle(db, tool, arguments)
125
+ reply(msg_id, {"content": [{"type": "text", "text": json.dumps(result, default=str)}]})
126
+ except Exception as exc: # MCP convention: tool errors are results, not protocol faults
127
+ reply(msg_id, {"content": [{"type": "text", "text": str(exc)}], "isError": True})
128
+ elif method == "ping":
129
+ reply(msg_id, {})
130
+ else:
131
+ reply(msg_id, None, error={"code": -32601, "message": "method not found: " + str(method)})
132
+
133
+
134
+ def reply(msg_id, result, error=None):
135
+ out = {"jsonrpc": "2.0", "id": msg_id}
136
+ if error:
137
+ out["error"] = error
138
+ else:
139
+ out["result"] = result
140
+ sys.stdout.write(json.dumps(out) + "\n")
141
+ sys.stdout.flush()
142
+
143
+
144
+ if __name__ == "__main__":
145
+ serve()
@@ -0,0 +1,79 @@
1
+ Metadata-Version: 2.4
2
+ Name: superb-memory
3
+ Version: 0.2.1
4
+ Summary: Python client, mdb CLI and MCP server for the MemoryDB Go document and agent memory database
5
+ Author: humaniods
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/humaniods/MemoryDB
8
+ Project-URL: Repository, https://github.com/humaniods/MemoryDB
9
+ Project-URL: Issues, https://github.com/humaniods/MemoryDB/issues
10
+ Keywords: database,document-database,agent-memory,langchain,mcp,vector-search
11
+ Classifier: Development Status :: 4 - Beta
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: Operating System :: POSIX
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Topic :: Database
16
+ Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
17
+ Requires-Python: >=3.10
18
+ Description-Content-Type: text/markdown
19
+
20
+ # MemoryDB Python client
21
+
22
+ `superb-memory` gives you the Python client, the `mdb` command-line tool and the
23
+ `memorydb-mcp` stdio server for [MemoryDB](https://github.com/humaniods/MemoryDB) — a single-binary
24
+ Go document database that also stores AI-agent memory (facts with evidence, summaries, tool
25
+ traces, hybrid BM25 + vector search).
26
+
27
+ ## One line to a running database
28
+
29
+ ```bash
30
+ pip install superb-memory
31
+ mdb serve # fetches the server binary, starts it on 127.0.0.1:8080
32
+ ```
33
+
34
+ `mdb serve` prints the connection string; every client reads it from the environment:
35
+
36
+ ```bash
37
+ export MEMORYDB_URL=memorydb://TOKEN@127.0.0.1:8080
38
+ ```
39
+
40
+ ## Documents
41
+
42
+ ```python
43
+ from memorydb import MemoryDB
44
+
45
+ db = MemoryDB() # reads MEMORYDB_URL
46
+ db.collections.create("customers",
47
+ fields={"email": {"type": "string", "required": True},
48
+ "age": {"type": "integer"}},
49
+ unique=["email"], indexes=["age"])
50
+
51
+ customers = db.collection("customers")
52
+ row = customers.objects.create(email="demo@example.com", age=24, idempotency_key="customer-demo-1")
53
+ customers.objects.filter(age__gte=18).order_by("-age").only("email").all()
54
+ customers.objects.increment(pk=row["id"], views=1) # atomic counter, no version needed
55
+ ```
56
+
57
+ Django-style lookups: `eq ne gt gte lt lte in contains icontains prefix iequals exists`,
58
+ plus `or_q`, `Sum/Avg/Min/Max` with `group_by`, cursor pagination and optimistic `version` checks.
59
+
60
+ ## Agent memory
61
+
62
+ ```python
63
+ db.ingest([{"role": "user", "content": "I prefer terse answers in Hindi"}],
64
+ facts=[{"subject": "user", "predicate": "answer_style", "value": "terse Hindi",
65
+ "quote": "terse answers in Hindi", "message_index": 0, "confidence": 0.95}])
66
+ db.objects.search("terse")
67
+ ```
68
+
69
+ Facts need an exact quote from a user message, so a stored memory always carries its evidence.
70
+
71
+ ## CLI and MCP
72
+
73
+ ```bash
74
+ mdb health; mdb search "fridays"; mdb memory add --content "user ships on fridays"
75
+ memorydb-mcp # stdio MCP server for Claude Desktop, Qoder, IDE agents
76
+ ```
77
+
78
+ Companion packages: `memorydb-django` (cache + session backends) and `memorydb-langchain`
79
+ (chat message history). Docs: `docs/QUICKSTART.md` and `docs/CONNECTING.md` in the repository.
@@ -0,0 +1,8 @@
1
+ cli.py,sha256=mW-LQGy5RJIXp6zEqQUaClTSfv1NBxjspbr6NA1_Av8,13559
2
+ memorydb.py,sha256=zuFKR7yPyOMwpDM7FsSCmd7po5HGoP1Vn5roAbFqURs,14919
3
+ memorydb_mcp.py,sha256=Te1vEHs_nN-6SGcBqjATufzY2SGyrw4bVbdO95LtL7U,7937
4
+ superb_memory-0.2.1.dist-info/METADATA,sha256=KSS0KxZSdd4449aCcasUTDSHRGC-PukgmDqADkjYF_M,3094
5
+ superb_memory-0.2.1.dist-info/WHEEL,sha256=mOdXR9C3wMVov7cSY8PY-XCzo3SmmWCHvGQr5csJoao,91
6
+ superb_memory-0.2.1.dist-info/entry_points.txt,sha256=XWmBm9A9zHw1CRISdSwJgCPElj0uz_fQqPpIEukuw_o,67
7
+ superb_memory-0.2.1.dist-info/top_level.txt,sha256=xOmN1kE5-YB-FTPMUSBidF9Z7oeKDxb7agowyTMe2Sw,26
8
+ superb_memory-0.2.1.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.2.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,3 @@
1
+ [console_scripts]
2
+ mdb = cli:main
3
+ memorydb-mcp = memorydb_mcp:serve
@@ -0,0 +1,3 @@
1
+ cli
2
+ memorydb
3
+ memorydb_mcp