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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: superb-memory
3
- Version: 0.2.1
3
+ Version: 0.2.3
4
4
  Summary: Python client, mdb CLI and MCP server for the MemoryDB Go document and agent memory database
5
5
  Author: humaniods
6
6
  License-Expression: MIT
@@ -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.1" # the release tag mdb serve downloads by default
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 read_json_arg(value, path):
41
- if path:
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": json.loads(args.where), "limit": args.limit}
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)["results"])
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
- 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")))
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
- with open(args.file) as fh:
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.1"}
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.1"
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"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: superb-memory
3
- Version: 0.2.1
3
+ Version: 0.2.3
4
4
  Summary: Python client, mdb CLI and MCP server for the MemoryDB Go document and agent memory database
5
5
  Author: humaniods
6
6
  License-Expression: MIT
File without changes
File without changes