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 +310 -0
- memorydb.py +354 -0
- memorydb_mcp.py +145 -0
- superb_memory-0.2.1.dist-info/METADATA +79 -0
- superb_memory-0.2.1.dist-info/RECORD +8 -0
- superb_memory-0.2.1.dist-info/WHEEL +5 -0
- superb_memory-0.2.1.dist-info/entry_points.txt +3 -0
- superb_memory-0.2.1.dist-info/top_level.txt +3 -0
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,,
|