superb-memory 0.2.1__tar.gz → 0.2.3__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {superb_memory-0.2.1 → superb_memory-0.2.3}/PKG-INFO +1 -1
- {superb_memory-0.2.1 → superb_memory-0.2.3}/cli.py +79 -13
- {superb_memory-0.2.1 → superb_memory-0.2.3}/memorydb.py +30 -0
- {superb_memory-0.2.1 → superb_memory-0.2.3}/memorydb_mcp.py +23 -1
- {superb_memory-0.2.1 → superb_memory-0.2.3}/pyproject.toml +1 -1
- {superb_memory-0.2.1 → superb_memory-0.2.3}/superb_memory.egg-info/PKG-INFO +1 -1
- {superb_memory-0.2.1 → superb_memory-0.2.3}/README.md +0 -0
- {superb_memory-0.2.1 → superb_memory-0.2.3}/setup.cfg +0 -0
- {superb_memory-0.2.1 → superb_memory-0.2.3}/superb_memory.egg-info/SOURCES.txt +0 -0
- {superb_memory-0.2.1 → superb_memory-0.2.3}/superb_memory.egg-info/dependency_links.txt +0 -0
- {superb_memory-0.2.1 → superb_memory-0.2.3}/superb_memory.egg-info/entry_points.txt +0 -0
- {superb_memory-0.2.1 → superb_memory-0.2.3}/superb_memory.egg-info/top_level.txt +0 -0
|
@@ -11,6 +11,7 @@ import hashlib
|
|
|
11
11
|
import json
|
|
12
12
|
import os
|
|
13
13
|
import platform
|
|
14
|
+
import re
|
|
14
15
|
import secrets
|
|
15
16
|
import shutil
|
|
16
17
|
import subprocess
|
|
@@ -23,7 +24,7 @@ from urllib.parse import urlparse
|
|
|
23
24
|
sys.path.insert(0, str(os.path.dirname(os.path.abspath(__file__))))
|
|
24
25
|
from memorydb import MemoryDB, MemoryDBError # noqa: E402
|
|
25
26
|
|
|
26
|
-
SERVER_VERSION = "0.2.
|
|
27
|
+
SERVER_VERSION = "0.2.3" # the release tag mdb serve downloads by default
|
|
27
28
|
REPO = "humaniods/MemoryDB"
|
|
28
29
|
|
|
29
30
|
|
|
@@ -37,10 +38,22 @@ def load_db(args):
|
|
|
37
38
|
return MemoryDB(url, token=token)
|
|
38
39
|
|
|
39
40
|
|
|
40
|
-
def
|
|
41
|
-
|
|
41
|
+
def read_json_file(path):
|
|
42
|
+
"""Read a JSON input file named on the command line.
|
|
43
|
+
|
|
44
|
+
Paths in these commands come from README and docs, so a wrong one has to name the
|
|
45
|
+
file instead of raising a traceback from wherever argparse happened to call us.
|
|
46
|
+
"""
|
|
47
|
+
try:
|
|
42
48
|
with open(path) as fh:
|
|
43
49
|
return json.load(fh)
|
|
50
|
+
except OSError as exc:
|
|
51
|
+
sys.exit(f"mdb: cannot read {path}: {exc.strerror or exc}")
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def read_json_arg(value, path):
|
|
55
|
+
if path:
|
|
56
|
+
return read_json_file(path)
|
|
44
57
|
if value:
|
|
45
58
|
return json.loads(value)
|
|
46
59
|
sys.exit("provide --json or --file")
|
|
@@ -50,6 +63,37 @@ def emit(obj):
|
|
|
50
63
|
print(json.dumps(obj, indent=2, default=str))
|
|
51
64
|
|
|
52
65
|
|
|
66
|
+
def parse_where(value):
|
|
67
|
+
"""Accept the API's JSON for --where, and the field=value shorthand `mdb demo` prints.
|
|
68
|
+
|
|
69
|
+
Shell quoting is fiddly enough that a hint has to work exactly as typed, so
|
|
70
|
+
region="Rajasthan" and region=Rajasthan both mean one equality term.
|
|
71
|
+
"""
|
|
72
|
+
try:
|
|
73
|
+
parsed = json.loads(value)
|
|
74
|
+
except json.JSONDecodeError:
|
|
75
|
+
parsed = None
|
|
76
|
+
if isinstance(parsed, dict):
|
|
77
|
+
return [parsed]
|
|
78
|
+
if isinstance(parsed, list):
|
|
79
|
+
return parsed
|
|
80
|
+
terms = []
|
|
81
|
+
for chunk in re.split(r",|\s+and\s+", value.strip()):
|
|
82
|
+
field, sep, raw = chunk.strip().partition("=")
|
|
83
|
+
if not sep or not field.strip():
|
|
84
|
+
sys.exit('mdb: --where wants JSON like [{"field":"region","op":"eq","value":"Rajasthan"}] '
|
|
85
|
+
"or field=value terms, got: " + value)
|
|
86
|
+
raw = raw.strip().strip("\"'")
|
|
87
|
+
for cast in (int, float):
|
|
88
|
+
try:
|
|
89
|
+
raw = cast(raw)
|
|
90
|
+
break
|
|
91
|
+
except ValueError:
|
|
92
|
+
continue
|
|
93
|
+
terms.append({"field": field.strip(), "op": "eq", "value": raw})
|
|
94
|
+
return terms
|
|
95
|
+
|
|
96
|
+
|
|
53
97
|
# -- mdb serve: the database itself, installed over pip, with no Go or git -----------------
|
|
54
98
|
def home():
|
|
55
99
|
path = os.environ.get("MEMORYDB_HOME") or os.path.join(os.path.expanduser("~"), ".memorydb")
|
|
@@ -226,7 +270,7 @@ def main(argv=None):
|
|
|
226
270
|
p.add_argument("--json"); p.add_argument("--file"); p.add_argument("--version", type=int)
|
|
227
271
|
|
|
228
272
|
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")
|
|
273
|
+
p.add_argument("--where", default="[]", help='[{"field":…,"op":…,"value":…}] or region=Rajasthan shorthand'); p.add_argument("--order"); p.add_argument("--desc", action="store_true")
|
|
230
274
|
p.add_argument("--limit", type=int, default=50); p.add_argument("--fields", help="comma-separated projection")
|
|
231
275
|
|
|
232
276
|
p = sub.add_parser("memory"); p.add_argument("action", choices=["add", "ls", "history"])
|
|
@@ -236,8 +280,19 @@ def main(argv=None):
|
|
|
236
280
|
p = sub.add_parser("search"); p.add_argument("text")
|
|
237
281
|
p.add_argument("--kind", default=""); p.add_argument("--limit", type=int, default=10)
|
|
238
282
|
|
|
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")
|
|
283
|
+
p = sub.add_parser("ingest"); p.add_argument("--file", required=True, help='{"messages":[…],"facts":[…],"source":…}')
|
|
284
|
+
p = sub.add_parser("tx"); p.add_argument("--file", required=True, help='{"changes":[{"record":…,"expected":N}]}'); p.add_argument("--key")
|
|
285
|
+
|
|
286
|
+
p = sub.add_parser("sync", help="mirror an external table; writes only the rows that changed")
|
|
287
|
+
p.add_argument("source"); p.add_argument("--collection", required=True)
|
|
288
|
+
p.add_argument("--file", help="JSON array of rows (or objects with data/content)")
|
|
289
|
+
p.add_argument("--json", help="the same array inline")
|
|
290
|
+
p.add_argument("--key-field", default="id"); p.add_argument("--delete-missing", action="store_true")
|
|
291
|
+
p.add_argument("--tag", dest="source_tag", default="", help="value stored as the row's source")
|
|
292
|
+
p.add_argument("--idempotency-key")
|
|
293
|
+
|
|
294
|
+
p = sub.add_parser("sources"); p.add_argument("action", choices=["ls"], nargs="?", default="ls")
|
|
295
|
+
p = sub.add_parser("demo", help="load the built-in sample workspace into the server")
|
|
241
296
|
|
|
242
297
|
p = sub.add_parser("serve", help="install (if needed) and run the MemoryDB server")
|
|
243
298
|
p.add_argument("--addr", default="127.0.0.1:8080")
|
|
@@ -277,7 +332,7 @@ def main(argv=None):
|
|
|
277
332
|
else:
|
|
278
333
|
emit(db.collection(args.collection).objects.delete(pk=args.id, version=args.version))
|
|
279
334
|
elif args.cmd == "query":
|
|
280
|
-
q = {"where":
|
|
335
|
+
q = {"where": parse_where(args.where), "limit": args.limit}
|
|
281
336
|
if args.order:
|
|
282
337
|
q["order_by"] = args.order
|
|
283
338
|
q["descending"] = args.desc
|
|
@@ -290,17 +345,28 @@ def main(argv=None):
|
|
|
290
345
|
elif args.action == "ls":
|
|
291
346
|
emit(db.objects.filter(kind=args.kind if args.kind != "fact" else "", source=args.source, q=args.q, limit=args.limit))
|
|
292
347
|
else:
|
|
293
|
-
emit(db.objects.history(pk=args.id)
|
|
348
|
+
emit(db.objects.history(pk=args.id))
|
|
294
349
|
elif args.cmd == "search":
|
|
295
350
|
emit(db.objects.search(args.text, kind=args.kind, limit=args.limit))
|
|
296
351
|
elif args.cmd == "ingest":
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
352
|
+
payload = read_json_file(args.file)
|
|
353
|
+
emit(db.ingest(payload["messages"], facts=payload.get("facts"),
|
|
354
|
+
source=payload.get("source", "cli"),
|
|
355
|
+
context=payload.get("context", "")))
|
|
300
356
|
elif args.cmd == "tx":
|
|
301
|
-
|
|
302
|
-
payload = json.load(fh)
|
|
357
|
+
payload = read_json_file(args.file)
|
|
303
358
|
emit(db.transaction(payload["changes"], idempotency_key=args.key))
|
|
359
|
+
elif args.cmd == "sync":
|
|
360
|
+
rows = read_json_arg(args.json, args.file)
|
|
361
|
+
if not isinstance(rows, list):
|
|
362
|
+
sys.exit("sync input must be a JSON array of rows")
|
|
363
|
+
emit(db.sources.sync(args.source, collection=args.collection, items=rows,
|
|
364
|
+
key_field=args.key_field, delete_missing=args.delete_missing,
|
|
365
|
+
source=args.source_tag, idempotency_key=args.idempotency_key))
|
|
366
|
+
elif args.cmd == "sources":
|
|
367
|
+
emit(db.sources.all())
|
|
368
|
+
elif args.cmd == "demo":
|
|
369
|
+
emit(db.demo_seed())
|
|
304
370
|
except MemoryDBError as exc:
|
|
305
371
|
sys.exit(f"mdb error [{exc.status}]: {exc}")
|
|
306
372
|
return 0
|
|
@@ -59,6 +59,28 @@ def parse_connection_string(connection_string):
|
|
|
59
59
|
return scheme + "://" + host + ":" + str(port), unquote(parts.username)
|
|
60
60
|
|
|
61
61
|
|
|
62
|
+
class SourceManager:
|
|
63
|
+
"""Mirror an external table into a collection.
|
|
64
|
+
|
|
65
|
+
A sync writes only the rows whose data changed since the last sync, so a cron job
|
|
66
|
+
that re-pulls a whole CSV or REST list costs one read, not a rewrite of every row.
|
|
67
|
+
"""
|
|
68
|
+
|
|
69
|
+
def __init__(self, client):
|
|
70
|
+
self.client = client
|
|
71
|
+
|
|
72
|
+
def sync(self, name, *, collection, items, key_field="id", delete_missing=False, source="", idempotency_key=None):
|
|
73
|
+
"""items is a list of dicts (one row each) or of {"data": {...}, "content": "..."} pairs."""
|
|
74
|
+
rows = [{"data": i, "content": ""} if isinstance(i, dict) and "data" not in i else i for i in items]
|
|
75
|
+
headers = {"Idempotency-Key": idempotency_key} if idempotency_key else None
|
|
76
|
+
return self.client.request("POST", "sources/" + quote(name, safe="") + "/sync/",
|
|
77
|
+
dict(collection=collection, key_field=key_field, items=rows,
|
|
78
|
+
delete_missing=delete_missing, source=source), headers=headers)
|
|
79
|
+
|
|
80
|
+
def all(self):
|
|
81
|
+
return self.client.request("GET", "sources/")["results"]
|
|
82
|
+
|
|
83
|
+
|
|
62
84
|
class MemoryDB:
|
|
63
85
|
def __init__(self, url="http://127.0.0.1:8080", *, token=None, timeout=35):
|
|
64
86
|
if token is None:
|
|
@@ -74,6 +96,7 @@ class MemoryDB:
|
|
|
74
96
|
self.timeout = timeout
|
|
75
97
|
self.objects = MemoryManager(self)
|
|
76
98
|
self.collections = CollectionManager(self)
|
|
99
|
+
self.sources = SourceManager(self)
|
|
77
100
|
|
|
78
101
|
@classmethod
|
|
79
102
|
def from_url(cls, connection_string, **fields):
|
|
@@ -95,6 +118,13 @@ class MemoryDB:
|
|
|
95
118
|
def queue_ingest(self, **payload):
|
|
96
119
|
return self.request("POST", "jobs/", payload)
|
|
97
120
|
|
|
121
|
+
def demo_seed(self):
|
|
122
|
+
"""Load the built-in sample workspace (four mirrored tables, notes and facts).
|
|
123
|
+
|
|
124
|
+
Safe to call twice: a workspace that already has the sample is left alone.
|
|
125
|
+
"""
|
|
126
|
+
return self.request("POST", "demo/", None)
|
|
127
|
+
|
|
98
128
|
def request(self, method, path, body=None, headers=None):
|
|
99
129
|
req = Request(self.url + path, data=json.dumps(body).encode() if body is not None else None,
|
|
100
130
|
headers={"Authorization": "Bearer " + self.token, "Content-Type": "application/json", **(headers or {})}, method=method)
|
|
@@ -17,7 +17,7 @@ sys.path.insert(0, str(__import__("pathlib").Path(__file__).resolve().parent))
|
|
|
17
17
|
from memorydb import MemoryDB # noqa: E402
|
|
18
18
|
|
|
19
19
|
PROTOCOL_VERSION = "2024-11-05"
|
|
20
|
-
SERVER_INFO = {"name": "memorydb", "version": "0.2.
|
|
20
|
+
SERVER_INFO = {"name": "memorydb", "version": "0.2.3"}
|
|
21
21
|
|
|
22
22
|
TOOL_DEFS = [
|
|
23
23
|
{"name": "memorydb_health", "description": "Server status, identity and transaction sequence.",
|
|
@@ -62,6 +62,20 @@ TOOL_DEFS = [
|
|
|
62
62
|
"order_by": {"type": "string"}, "descending": {"type": "boolean"},
|
|
63
63
|
"projection": {"type": "array", "items": {"type": "string"}},
|
|
64
64
|
"limit": {"type": "integer", "default": 50}}, "required": ["collection"]}},
|
|
65
|
+
{"name": "memorydb_sync", "description": "Mirror a table/CSV/REST list into a collection. Only rows whose data changed are written; re-running is cheap.",
|
|
66
|
+
"inputSchema": {"type": "object", "properties": {
|
|
67
|
+
"source": {"type": "string", "description": "connector name, e.g. stripe or postgres"},
|
|
68
|
+
"collection": {"type": "string"},
|
|
69
|
+
"key_field": {"type": "string", "default": "id"},
|
|
70
|
+
"items": {"type": "array", "items": {"type": "object", "properties": {
|
|
71
|
+
"data": {"type": "object"}, "content": {"type": "string"}}},
|
|
72
|
+
"description": "up to 500 rows; each row must carry key_field"},
|
|
73
|
+
"delete_missing": {"type": "boolean", "default": False}},
|
|
74
|
+
"required": ["source", "collection", "items"]}},
|
|
75
|
+
{"name": "memorydb_list_sources", "description": "List the connectors that own rows in this workspace, with row counts and last sync time.",
|
|
76
|
+
"inputSchema": {"type": "object", "properties": {}}},
|
|
77
|
+
{"name": "memorydb_demo", "description": "Load the built-in sample workspace (four mirrored tables, notes and facts) so the console and search have data. Idempotent.",
|
|
78
|
+
"inputSchema": {"type": "object", "properties": {}}},
|
|
65
79
|
]
|
|
66
80
|
|
|
67
81
|
|
|
@@ -93,6 +107,14 @@ def handle(db, method, params):
|
|
|
93
107
|
if method == "memorydb_query_documents":
|
|
94
108
|
q = {k: v for k, v in params.items() if k != "collection"}
|
|
95
109
|
return db.request("POST", "query/" + params["collection"] + "/", q)
|
|
110
|
+
if method == "memorydb_sync":
|
|
111
|
+
return db.sources.sync(params["source"], collection=params["collection"], items=params["items"],
|
|
112
|
+
key_field=params.get("key_field", "id"),
|
|
113
|
+
delete_missing=params.get("delete_missing", False))
|
|
114
|
+
if method == "memorydb_list_sources":
|
|
115
|
+
return db.sources.all()
|
|
116
|
+
if method == "memorydb_demo":
|
|
117
|
+
return db.demo_seed()
|
|
96
118
|
raise ValueError("unknown tool " + method)
|
|
97
119
|
|
|
98
120
|
|
|
@@ -3,7 +3,7 @@ requires = ["setuptools>=77"]
|
|
|
3
3
|
build-backend = "setuptools.build_meta"
|
|
4
4
|
[project]
|
|
5
5
|
name = "superb-memory"
|
|
6
|
-
version = "0.2.
|
|
6
|
+
version = "0.2.3"
|
|
7
7
|
description = "Python client, mdb CLI and MCP server for the MemoryDB Go document and agent memory database"
|
|
8
8
|
readme = "README.md"
|
|
9
9
|
requires-python = ">=3.10"
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|