memd-engine 0.5.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. memd/__init__.py +7 -0
  2. memd/cli.py +699 -0
  3. memd/core/__init__.py +31 -0
  4. memd/core/schema.py +343 -0
  5. memd/engine/__init__.py +0 -0
  6. memd/engine/forward.py +1175 -0
  7. memd/engine/memory.py +3005 -0
  8. memd/harness/core.py +257 -0
  9. memd/harness/run.py +216 -0
  10. memd/harness/suites/__init__.py +0 -0
  11. memd/harness/suites/adversarial.py +113 -0
  12. memd/harness/suites/halumem_ops.py +54 -0
  13. memd/harness/suites/longmemeval_synthetic.py +221 -0
  14. memd/hosted/__init__.py +20 -0
  15. memd/hosted/app.py +258 -0
  16. memd/hosted/billing.py +909 -0
  17. memd/hosted/metering.py +460 -0
  18. memd/hosted/plans.py +138 -0
  19. memd/hosted/store.py +830 -0
  20. memd/index/__init__.py +3 -0
  21. memd/index/ann_usearch.py +1598 -0
  22. memd/index/sqlite_index.py +2413 -0
  23. memd/index/tantivy_lexical.py +886 -0
  24. memd/metrics.py +591 -0
  25. memd/pipeline/__init__.py +0 -0
  26. memd/pipeline/consolidation.py +174 -0
  27. memd/pipeline/embedder.py +370 -0
  28. memd/pipeline/extractor.py +567 -0
  29. memd/query/__init__.py +0 -0
  30. memd/query/dates.py +212 -0
  31. memd/query/fusion.py +90 -0
  32. memd/query/packing.py +671 -0
  33. memd/query/planner.py +76 -0
  34. memd/query/rerank.py +532 -0
  35. memd/sdk/__init__.py +3 -0
  36. memd/sdk/client.py +292 -0
  37. memd/server/__init__.py +0 -0
  38. memd/server/auth.py +423 -0
  39. memd/server/cluster.py +748 -0
  40. memd/server/http.py +976 -0
  41. memd/server/mcp_server.py +147 -0
  42. memd/storage/__init__.py +3 -0
  43. memd/storage/audit.py +504 -0
  44. memd/storage/crypto.py +1313 -0
  45. memd/storage/engine.py +5331 -0
  46. memd/storage/objectstore.py +597 -0
  47. memd/storage/replica.py +1040 -0
  48. memd/storage/s3store.py +1450 -0
  49. memd_engine-0.5.0.dist-info/METADATA +1230 -0
  50. memd_engine-0.5.0.dist-info/RECORD +54 -0
  51. memd_engine-0.5.0.dist-info/WHEEL +5 -0
  52. memd_engine-0.5.0.dist-info/entry_points.txt +2 -0
  53. memd_engine-0.5.0.dist-info/licenses/LICENSE +202 -0
  54. memd_engine-0.5.0.dist-info/top_level.txt +1 -0
memd/cli.py ADDED
@@ -0,0 +1,699 @@
1
+ """CLI: `memd serve --http|--mcp [--hosted] [--node-id N]`, `memd key create`,
2
+ `memd keys status|migrate|rotate`, `memd org`, `memd export`, `memd import
3
+ mem0`, `memd status`, `memd migrate --report`."""
4
+ from __future__ import annotations
5
+
6
+ import argparse
7
+ import json
8
+ import os
9
+ import sys
10
+
11
+
12
+ def _cmd_serve(args) -> int:
13
+ if args.mcp:
14
+ from memd.server.mcp_server import main as mcp_main
15
+
16
+ mcp_main()
17
+ return 0
18
+ import uvicorn
19
+
20
+ host = args.host or os.environ.get("MEMD_HOST", "127.0.0.1")
21
+ port = args.port or int(os.environ.get("MEMD_PORT", "8700"))
22
+ # --node-id (or MEMD_NODE_ID): one node of a fleet on one s3:// data
23
+ # root, routing each namespace to the node holding its lease.
24
+ # A single server never imports the cluster module.
25
+ cluster = None
26
+ if args.node_id or os.environ.get("MEMD_NODE_ID"):
27
+ from memd.server.cluster import ClusterConfig
28
+
29
+ cluster = ClusterConfig.from_env(node_id=args.node_id, advertise=args.advertise,
30
+ host=host, port=port)
31
+ app = create_app_from_env(hosted=True if args.hosted else None, cluster=cluster)
32
+ # graceful: SIGTERM stops accepting, drains in-flight requests, then the
33
+ # lifespan hook deregisters the node and releases its leases
34
+ uvicorn.run(app, host=host, port=port,
35
+ timeout_graceful_shutdown=int(os.environ.get("MEMD_SHUTDOWN_GRACE_S", "30")))
36
+ return 0
37
+
38
+
39
+ def create_app_from_env(hosted: bool | None = None, cluster=None):
40
+ """`hosted` None: MEMD_HOSTED decides (default off)."""
41
+ from memd.server.http import create_app
42
+
43
+ return create_app(
44
+ data_dir=os.environ.get("MEMD_DATA", "./memd-data"),
45
+ admin_key=os.environ.get("MEMD_ADMIN_KEY"),
46
+ hosted=hosted,
47
+ cluster=cluster,
48
+ )
49
+
50
+
51
+ def _admin_store(args):
52
+ from memd.hosted.store import AdminStore
53
+
54
+ data_dir = args.data or os.environ.get("MEMD_DATA", "./memd-data")
55
+ os.makedirs(data_dir, exist_ok=True)
56
+ return AdminStore.for_data_root(data_dir), data_dir
57
+
58
+
59
+ def _cmd_key_hosted(args) -> int:
60
+ """Hosted mode: keys live in the admin store, belong to an org and are
61
+ bound to one of its namespaces (claimed for the org on first use)."""
62
+ from memd.hosted.store import OwnershipError
63
+
64
+ store, data_dir = _admin_store(args)
65
+ try:
66
+ if args.sub == "create":
67
+ if not args.org:
68
+ print("hosted mode: --org is required (see `memd org create`)", file=sys.stderr)
69
+ return 2
70
+ scopes = (args.scopes or "memory").replace(",", " ").split()
71
+ if args.scope_override and "override" not in scopes:
72
+ scopes.append("override")
73
+ try:
74
+ full, kid = store.create_key(args.org, args.namespace, name=args.name or "",
75
+ scopes=scopes, pinned_user=args.pin_user)
76
+ except (KeyError, OwnershipError, ValueError) as ex:
77
+ print(f"refused: {ex}", file=sys.stderr)
78
+ return 1
79
+ print(json.dumps({"key": full, "key_id": kid, "namespace": args.namespace, "org": args.org,
80
+ "scopes": sorted(set(scopes))}, indent=1))
81
+ print("# store this now - it is not retrievable later", file=sys.stderr)
82
+ return 0
83
+ if args.sub == "list":
84
+ for k in store.list_keys(org_id=args.org):
85
+ print(json.dumps(k))
86
+ return 0
87
+ if args.sub == "revoke":
88
+ ok = store.revoke_key(args.key_id)
89
+ print("revoked" if ok else "not found")
90
+ return 0 if ok else 1
91
+ if args.sub == "migrate":
92
+ return _migrate_legacy_keys(store, data_dir, args)
93
+ return 2
94
+ finally:
95
+ store.close()
96
+
97
+
98
+ def _migrate_legacy_keys(store, data_dir: str, args) -> int:
99
+ """Adopt self-hosted keys (keys.toml.json) into an org: same key string,
100
+ same secret hash, now metered. Namespace-wide ('*') keys are skipped -
101
+ in hosted mode only the operator's MEMD_ADMIN_KEY spans namespaces."""
102
+ from memd.hosted.store import OwnershipError
103
+
104
+ if not args.org:
105
+ print("--org is required", file=sys.stderr)
106
+ return 2
107
+ path = os.path.join(data_dir, "keys.toml.json")
108
+ try:
109
+ with open(path) as f:
110
+ recs = json.load(f)
111
+ except FileNotFoundError:
112
+ print(f"no legacy key file at {path}", file=sys.stderr)
113
+ return 1
114
+ moved = skipped = 0
115
+ for r in recs:
116
+ if (r.get("revoked") or not r.get("hash") or r.get("namespace") in (None, "*")
117
+ or (args.namespace and r["namespace"] != args.namespace)):
118
+ skipped += 1
119
+ continue
120
+ scopes = ["memory"] + (["override"] if r.get("scope_override") else [])
121
+ try:
122
+ store.import_key(args.org, r["namespace"], r["key_id"], r["hash"], name=r.get("name", ""),
123
+ scopes=scopes, pinned_user=r.get("pinned_user"), created=r.get("created"))
124
+ moved += 1
125
+ except (OwnershipError, KeyError) as ex:
126
+ print(f"skipped {r['key_id']}: {ex}", file=sys.stderr)
127
+ skipped += 1
128
+ print(json.dumps({"migrated": moved, "skipped": skipped, "org": args.org}))
129
+ return 0
130
+
131
+
132
+ def _cmd_org(args) -> int:
133
+ """Hosted-mode orgs (the billing unit): create / list / set-plan."""
134
+ store, _ = _admin_store(args)
135
+ try:
136
+ if args.sub == "create":
137
+ from memd.hosted.plans import Plans
138
+
139
+ if args.plan not in Plans.from_env():
140
+ print(f"unknown plan {args.plan!r}", file=sys.stderr)
141
+ return 2
142
+ oid = store.create_org(args.name, plan=args.plan)
143
+ print(json.dumps({"org": oid, "name": args.name, "plan": args.plan}))
144
+ return 0
145
+ if args.sub == "list":
146
+ for o in store.list_orgs():
147
+ print(json.dumps(o))
148
+ return 0
149
+ if args.sub == "set-plan":
150
+ # operator override (comped / enterprise accounts); Stripe
151
+ # webhooks keep syncing paying orgs
152
+ from memd.hosted.plans import Plans
153
+
154
+ if args.plan not in Plans.from_env():
155
+ print(f"unknown plan {args.plan!r}", file=sys.stderr)
156
+ return 2
157
+ if store.get_org(args.org_id) is None:
158
+ print("not found", file=sys.stderr)
159
+ return 1
160
+ store.update_org(args.org_id, plan=args.plan)
161
+ print(json.dumps({"org": args.org_id, "plan": args.plan}))
162
+ return 0
163
+ return 2
164
+ finally:
165
+ store.close()
166
+
167
+
168
+ def _cmd_key(args) -> int:
169
+ from memd.hosted import hosted_enabled
170
+
171
+ if hosted_enabled(True if getattr(args, "hosted", False) else None):
172
+ return _cmd_key_hosted(args)
173
+ if args.sub == "migrate":
174
+ print("`memd key migrate` adopts keys into a hosted org: pass --hosted (or MEMD_HOSTED=1)",
175
+ file=sys.stderr)
176
+ return 2
177
+ from memd.server.auth import KeyStore
178
+
179
+ data_dir = args.data or os.environ.get("MEMD_DATA", "./memd-data")
180
+ os.makedirs(data_dir, exist_ok=True)
181
+ ks = KeyStore(os.path.join(data_dir, "keys.toml.json"))
182
+ if args.sub == "create":
183
+ full, kid = ks.create(
184
+ args.namespace,
185
+ name=args.name or "",
186
+ pinned_user=args.pin_user,
187
+ scope_override=args.scope_override,
188
+ )
189
+ print(json.dumps({"key": full, "key_id": kid, "namespace": args.namespace}, indent=1))
190
+ print("# store this now - it is not retrievable later", file=sys.stderr)
191
+ return 0
192
+ if args.sub == "list":
193
+ for k in ks.list_keys():
194
+ print(json.dumps(k))
195
+ return 0
196
+ if args.sub == "revoke":
197
+ ok = ks.revoke(args.key_id)
198
+ print("revoked" if ok else "not found")
199
+ return 0 if ok else 1
200
+ return 2
201
+
202
+
203
+ def _cmd_export(args) -> int:
204
+ from memd.engine.memory import Memory
205
+
206
+ mem = Memory(args.data, namespace=args.namespace)
207
+ try:
208
+ blob = mem.export_jsonl(namespace=args.namespace)
209
+ finally:
210
+ mem.close()
211
+ out = open(args.out, "wb") if args.out else sys.stdout.buffer
212
+ out.write(blob)
213
+ if args.out:
214
+ out.close()
215
+ print(f"wrote {len(blob)} bytes to {args.out}", file=sys.stderr)
216
+ return 0
217
+
218
+
219
+ def _import_native(records_in: list[dict], args, ns_name: str) -> int:
220
+ """Full-fidelity restore of a memd export: kind, provenance tiers,
221
+ bitemporal fields, scopes and entity keys preserved verbatim."""
222
+ from memd.core.schema import MemoryRecord
223
+ from memd.engine.memory import Memory
224
+
225
+ recs = [MemoryRecord.from_dict(r) for r in records_in]
226
+ for r in recs:
227
+ r.namespace = ns_name # retarget to the import namespace
228
+ mem = Memory(args.data, namespace=ns_name)
229
+ try:
230
+ ns_store = mem._ns_for(ns_name)
231
+ # preserve original ids: replay is idempotent by id
232
+ ns_store.append(recs)
233
+ count = len(recs)
234
+ finally:
235
+ mem.close()
236
+ print(f"restored {count} records into namespace {ns_name!r} (native full-fidelity restore)")
237
+ return 0
238
+
239
+
240
+ def _cmd_import(args) -> int:
241
+ """Importers map onto the explicit lane with provenance.source=import
242
+ and t_event preserved (a known gap: no lineage)."""
243
+ import os as _os
244
+
245
+ from memd.core.schema import Kind, MemoryRecord, Scope, Source
246
+ from memd.engine.memory import Memory
247
+
248
+ max_bytes = 100 * 1024 * 1024
249
+ fsize = _os.path.getsize(args.file)
250
+ if fsize > max_bytes:
251
+ print(f"refusing: export file {fsize} bytes exceeds {max_bytes} cap; split it first", file=sys.stderr)
252
+ return 1
253
+ data = None
254
+ try:
255
+ data = json.loads(raw := open(args.file).read())
256
+ except json.JSONDecodeError:
257
+ # memd native exports are JSONL: one record per line
258
+ data = [json.loads(l) for l in raw.splitlines() if l.strip()]
259
+ records: list[MemoryRecord] = []
260
+ ns_name = args.namespace
261
+
262
+ def norm_scope(item: dict) -> Scope:
263
+ md = item.get("metadata") or item.get("meta") or {}
264
+ return Scope(
265
+ user=item.get("user_id") or md.get("user_id"),
266
+ agent=md.get("agent_id"),
267
+ session=item.get("session_id") or md.get("session_id") or md.get("run_id"),
268
+ org=md.get("org_id"),
269
+ )
270
+
271
+ def t_event_of(item: dict) -> int | None:
272
+ for k in ("created_at", "timestamp", "t", "time"):
273
+ v = item.get(k)
274
+ if v is None:
275
+ continue
276
+ if isinstance(v, (int, float)):
277
+ return int(v * 1000 if v < 10**12 else v)
278
+ try:
279
+ import datetime as dt
280
+
281
+ d = dt.datetime.fromisoformat(str(v).replace("Z", "+00:00"))
282
+ return int(d.timestamp() * 1000)
283
+ except Exception:
284
+ pass
285
+ return None
286
+
287
+ if isinstance(data, dict):
288
+ # memd native export: full-fidelity restore (kind, tiers, times,
289
+ # scopes preserved); foreign formats fall through to mem0 mapping
290
+ if all(k in data for k in ("id", "namespace")):
291
+ return _import_native(data["results"] if isinstance(data.get("results"), list) else [data], args, ns_name)
292
+ if isinstance(data.get("events"), list) and isinstance(data.get("cases"), list):
293
+ print("harness dataset detected - not an importable export", file=sys.stderr)
294
+ return 1
295
+ # mem0 export shapes: {"results":[...]} / {"events":[...]} / list under any key
296
+ items = []
297
+ for v in data.values():
298
+ if isinstance(v, list):
299
+ items.extend(x for x in v if isinstance(x, dict))
300
+ elif isinstance(data, list):
301
+ # could be memd JSONL-parsed list OR foreign rows: sniff first row
302
+ first = next((x for x in data if isinstance(x, dict)), None)
303
+ if first and "id" in first and "namespace" in first and "kind" in first:
304
+ return _import_native(data, args, ns_name)
305
+ items = data
306
+ else:
307
+ items = []
308
+
309
+ if not items:
310
+ print("no importable records found; expected mem0-style export", file=sys.stderr)
311
+ return 1
312
+
313
+ for it in items:
314
+ text = it.get("memory") or it.get("text") or it.get("content") or it.get("data")
315
+ if not text:
316
+ continue
317
+ te = t_event_of(it)
318
+ rec = MemoryRecord.create(
319
+ namespace=ns_name,
320
+ kind=Kind.FACT,
321
+ content=str(text),
322
+ scope=norm_scope(it),
323
+ source=Source.IMPORT,
324
+ actor_id="import",
325
+ entity_keys=[],
326
+ t_event=te,
327
+ meta={"imported_from": args.source, "raw_import": {k: str(v)[:200] for k, v in it.items() if k not in ("memory", "text", "content", "data")}},
328
+ )
329
+ records.append(rec)
330
+
331
+ mem = Memory(args.data, namespace=ns_name)
332
+ try:
333
+ nstore = mem._ns_for(ns_name)
334
+ nstore.append(records)
335
+ count = len(records)
336
+ finally:
337
+ mem.close()
338
+ print(f"imported {count} facts into namespace {ns_name!r} (source=import, supersedable, not re-runnable)")
339
+ return 0
340
+
341
+
342
+ def _cmd_reindex(args) -> int:
343
+ """Re-embedding batch job: rebuild the vector lane from raw."""
344
+ from memd.engine.memory import Memory
345
+
346
+ mem = Memory(args.data, namespace=args.namespace)
347
+ try:
348
+ rep = mem.reembed(namespace=args.namespace)
349
+ print(json.dumps(rep))
350
+ return 0
351
+ finally:
352
+ mem.close()
353
+
354
+
355
+ def _cmd_metrics(args) -> int:
356
+ """Emit the in-process metrics snapshot (JSON). For live graphing use
357
+ GET /metrics on a running server, or set MEMD_METRICS_PATH for dumps."""
358
+ from memd.metrics import METRICS
359
+
360
+ snap = METRICS.snapshot()
361
+ import time as _t
362
+
363
+ snap["_epoch_ms"] = int(_t.time() * 1000)
364
+ print(json.dumps(snap, indent=1))
365
+ return 0
366
+
367
+
368
+ def _cmd_status(args) -> int:
369
+ from memd.engine.memory import Memory
370
+
371
+ mem = Memory(args.data, namespace=args.namespace)
372
+ try:
373
+ print(json.dumps(mem.status(), indent=1))
374
+ for nsmeta in mem.engine.list_namespaces():
375
+ st = mem.stats(namespace=nsmeta)
376
+ print(f"{nsmeta}: {json.dumps(st)}")
377
+ finally:
378
+ mem.close()
379
+ return 0
380
+
381
+
382
+ def _cmd_migrate(args) -> int:
383
+ """`memd migrate --report DATA`: per namespace, its store format, whether
384
+ its format-1 migration is still pending (previewed, not run), the ledger
385
+ deletes kept live on ambiguous evidence, the deletes recovered and
386
+ applied, and the losses an older version left that cannot be recovered.
387
+ Read-only: nothing is migrated, locked or written - run it before an
388
+ upgrade and after it (see the CHANGELOG's upgrade notes)."""
389
+ from memd.storage.crypto import LocalKeyEnvelope, NullKeyEnvelope
390
+ from memd.storage.engine import StorageEngine
391
+
392
+ data = args.report
393
+ if str(data).startswith("s3://"):
394
+ print("migrate --report reads a local data root; for S3, run it on the node that "
395
+ "holds local_dir and the keys", file=sys.stderr)
396
+ return 2
397
+ store_dir = os.path.join(data, "store")
398
+ if not os.path.isdir(store_dir):
399
+ print(f"no memd data root at {data!r} (expected {store_dir})", file=sys.stderr)
400
+ return 1
401
+ keys = os.path.join(data, "keys")
402
+ # an encrypted root has its keys here; never create them just to read
403
+ env = (LocalKeyEnvelope(keys) if os.path.exists(os.path.join(keys, "root.key"))
404
+ else NullKeyEnvelope())
405
+ rep = StorageEngine(store_dir, envelope=env).migration_report()
406
+ print(json.dumps(rep, indent=1, sort_keys=True))
407
+ return 0
408
+
409
+
410
+ def _keys_store(data: str):
411
+ """(object store, local dir) for a data root: a local path, or s3://
412
+ with MEMD_LOCAL_DIR (the node-local dir that holds `local` key files)."""
413
+ if str(data).startswith("s3://"):
414
+ from memd.storage.s3store import S3ObjectStore
415
+
416
+ bucket, _, prefix = str(data)[len("s3://"):].partition("/")
417
+ store = S3ObjectStore(bucket=bucket, prefix=prefix,
418
+ endpoint_url=os.environ.get("MEMD_S3_ENDPOINT"),
419
+ access_key=os.environ.get("MEMD_S3_ACCESS_KEY"),
420
+ secret_key=os.environ.get("MEMD_S3_SECRET_KEY"),
421
+ region=os.environ.get("AWS_REGION"))
422
+ local = os.environ.get("MEMD_LOCAL_DIR") or os.path.join(".memd-local", bucket, prefix or "_")
423
+ return store, local
424
+ from memd.storage.objectstore import LocalObjectStore
425
+
426
+ return LocalObjectStore(os.path.join(data, "store")), data
427
+
428
+
429
+ def _cmd_keys(args) -> int:
430
+ """`memd keys status|migrate|rotate`: data-key custody.
431
+
432
+ migrate --to aws-kms|vault-transit re-wraps every namespace's LOCAL data
433
+ key under the remote provider. The data key itself does not change, so
434
+ no data is re-encrypted; what moves is who can unwrap it. Crash-safe by
435
+ construction - every step is idempotent and ordered so that each
436
+ namespace always has at least one usable wrapped key:
437
+ 1. per namespace: wrap under the provider, create keys/<ns>.dek
438
+ (conditional create), read it back and unwrap it, compare;
439
+ 2. only when EVERY namespace verified: write the custody marker
440
+ (keys/_custody.json) - the swap: from here on a node still on the
441
+ `local` provider refuses to open the store instead of minting keys;
442
+ 3. then shred the local wrapped key files (root.key alone decrypts
443
+ nothing). Until this step completes, crypto-shred of a migrated
444
+ namespace does NOT cover its local copy - rerun to finish.
445
+ Rerunning after a crash at any point converges to the same end state."""
446
+ from memd.storage.crypto import (LocalKeyEnvelope, ObjectStoreKeyEnvelope, legacy_key_path,
447
+ provider_from_config, read_custody,
448
+ resolve_key_provider_name, wrapped_key_object)
449
+
450
+ store, local_dir = _keys_store(args.data)
451
+ keys_dir = os.path.join(local_dir, "keys")
452
+ if args.sub == "status":
453
+ custody = read_custody(store)
454
+ out: dict = {"custody": custody or {"provider": "local"}, "namespaces": {}}
455
+ local_env = (LocalKeyEnvelope(keys_dir)
456
+ if os.path.exists(os.path.join(keys_dir, "root.key")) else None)
457
+ nss = set(n for n in (local_env.namespaces() if local_env else []))
458
+ for k in store.list("keys/"):
459
+ if k.endswith(".dek"):
460
+ nss.add(k[len("keys/"):-len(".dek")])
461
+ for ns in sorted(nss):
462
+ row: dict = {"local_key": bool(local_env and os.path.exists(legacy_key_path(keys_dir, ns)))}
463
+ raw = store.get(wrapped_key_object(ns))
464
+ if raw:
465
+ rec = json.loads(raw)
466
+ row["wrapped"] = {k: rec.get(k) for k in ("provider", "key_id", "key_version", "created_ms")}
467
+ out["namespaces"][ns] = row
468
+ print(json.dumps(out, indent=1, sort_keys=True))
469
+ return 0
470
+
471
+ target = args.to if args.sub == "migrate" else resolve_key_provider_name({})
472
+ if target == "local":
473
+ print("the target must be a remote provider (aws-kms or vault-transit); moving keys back "
474
+ "to local files is not supported", file=sys.stderr)
475
+ return 2
476
+ provider = provider_from_config(target, {})
477
+ remote = ObjectStoreKeyEnvelope(provider, store)
478
+
479
+ if args.sub == "rotate":
480
+ done = []
481
+ for k in store.list("keys/"):
482
+ if k.endswith(".dek"):
483
+ done.append(remote.rewrap(k[len("keys/"):-len(".dek")]))
484
+ print(json.dumps({"rotated": len(done), "namespaces": done}, indent=1))
485
+ return 0
486
+
487
+ # ---- migrate
488
+ if not os.path.exists(os.path.join(keys_dir, "root.key")):
489
+ print(f"no local key directory at {keys_dir} (MEMD_LOCAL_DIR / --data)", file=sys.stderr)
490
+ return 1
491
+ custody = read_custody(store)
492
+ if custody and custody.get("provider") not in (target,):
493
+ print(f"refused: this store's keys are already held by {custody.get('provider')!r}",
494
+ file=sys.stderr)
495
+ return 1
496
+ local = LocalKeyEnvelope(keys_dir)
497
+ report: dict = {"to": target, "migrated": [], "already": [], "errors": {}}
498
+ # Hold every namespace's single-writer lock for the whole migration: a
499
+ # node still running on the `local` provider would otherwise MINT a new
500
+ # local key for a namespace it opens after step 3 removed the old one -
501
+ # and write data no key can read. Busy namespaces refuse the migration.
502
+ held, busy = _hold_namespaces(store, local.namespaces())
503
+ try:
504
+ if busy:
505
+ print(json.dumps({"to": target, "busy": busy}, indent=1))
506
+ print("refused: these namespaces are open in a running memd - stop it first",
507
+ file=sys.stderr)
508
+ return 1
509
+ return _migrate_keys(args, store, keys_dir, local, provider, remote, target, report)
510
+ finally:
511
+ _release_namespaces(store, held)
512
+
513
+
514
+ def _hold_namespaces(store, names: list[str]) -> tuple[list, list[str]]:
515
+ from memd.storage.engine import _acquire_owner, NamespaceBusyError, owner_lock_path
516
+
517
+ held: list = []
518
+ busy: list[str] = []
519
+ for ns in names:
520
+ leaser = getattr(store, "try_acquire_owner", None)
521
+ try:
522
+ if callable(leaser):
523
+ if leaser(ns, f"memd-keys-migrate@{os.getpid()}"):
524
+ held.append(("lease", ns))
525
+ else:
526
+ busy.append(ns)
527
+ else:
528
+ path = owner_lock_path(store.root, ns)
529
+ os.makedirs(os.path.dirname(path), exist_ok=True)
530
+ _acquire_owner(path)
531
+ held.append(("flock", path))
532
+ except NamespaceBusyError:
533
+ busy.append(ns)
534
+ return held, busy
535
+
536
+
537
+ def _release_namespaces(store, held: list) -> None:
538
+ from memd.storage.engine import _release_owner
539
+
540
+ for kind, what in held:
541
+ try:
542
+ if kind == "lease":
543
+ store.release_owner(what)
544
+ else:
545
+ _release_owner(what)
546
+ except Exception: # noqa: BLE001 - leases expire; flocks die with us
547
+ pass
548
+
549
+
550
+ def _migrate_keys(args, store, keys_dir, local, provider, remote, target, report) -> int:
551
+ from memd.storage.crypto import (KeyCustodyError, WrappedKey, _overwrite_unlink,
552
+ legacy_key_path, wrapped_key_object, write_custody)
553
+
554
+ for ns in local.namespaces():
555
+ try:
556
+ dk = local.peek_data_key(ns)
557
+ if dk is None:
558
+ continue
559
+ rec = remote.read_record(ns)
560
+ if rec is None:
561
+ wk = provider.wrap(ns, dk)
562
+ body = json.dumps(wk.to_record(ns), sort_keys=True).encode()
563
+ store.put_if_absent(wrapped_key_object(ns), body)
564
+ rec = remote.read_record(ns)
565
+ bucket = report["migrated"]
566
+ else:
567
+ bucket = report["already"]
568
+ if rec is None or rec.get("provider") != target:
569
+ raise KeyCustodyError(f"wrapped key for {ns!r} is missing or under another provider")
570
+ if provider.unwrap(ns, WrappedKey.from_record(rec)) != dk:
571
+ # someone minted a DIFFERENT key for this namespace remotely:
572
+ # never paper over that - both copies are kept for an operator
573
+ raise KeyCustodyError(f"the remote key for {ns!r} differs from the local one")
574
+ bucket.append(ns)
575
+ except Exception as ex: # noqa: BLE001 - report every namespace
576
+ report["errors"][ns] = f"{type(ex).__name__}: {ex}"
577
+ if report["errors"]:
578
+ report["swapped"] = False
579
+ print(json.dumps(report, indent=1, sort_keys=True))
580
+ print("not swapped: fix the errors above and rerun (nothing local was removed)", file=sys.stderr)
581
+ return 1
582
+ write_custody(store, provider) # the swap point
583
+ report["swapped"] = True
584
+ removed = []
585
+ if not args.keep_local:
586
+ for ns in report["migrated"] + report["already"]:
587
+ p = legacy_key_path(keys_dir, ns)
588
+ if os.path.exists(p):
589
+ _overwrite_unlink(p)
590
+ removed.append(ns)
591
+ report["local_keys_removed"] = removed
592
+ print(json.dumps(report, indent=1, sort_keys=True))
593
+ return 0
594
+
595
+
596
+ def main(argv=None) -> int:
597
+ p = argparse.ArgumentParser(prog="memd", description="memd - agent memory engine")
598
+ sub = p.add_subparsers(dest="cmd", required=True)
599
+
600
+ serve = sub.add_parser("serve", help="start a server")
601
+ serve.add_argument("--http", action="store_true", help="REST API on :8700")
602
+ serve.add_argument("--mcp", action="store_true", help="MCP over stdio")
603
+ serve.add_argument("--hosted", action="store_true",
604
+ help="hosted mode: orgs, metering, quotas, Stripe billing (also MEMD_HOSTED=1)")
605
+ serve.add_argument("--port", type=int, default=None)
606
+ serve.add_argument("--host", default=None)
607
+ serve.add_argument("--node-id", default=None,
608
+ help="cluster mode (also MEMD_NODE_ID): this node's id; nodes sharing an "
609
+ "s3:// MEMD_DATA route each namespace to its leaseholder")
610
+ serve.add_argument("--advertise", default=None,
611
+ help="cluster mode: the URL peers reach this node at (MEMD_ADVERTISE_URL; "
612
+ "default http://HOST:PORT)")
613
+ serve.set_defaults(fn=_cmd_serve)
614
+
615
+ key = sub.add_parser("key", help="manage API keys")
616
+ ksub = key.add_subparsers(dest="sub", required=True)
617
+ kc = ksub.add_parser("create")
618
+ kc.add_argument("--ns", "--namespace", dest="namespace", required=True)
619
+ kc.add_argument("--name", default="")
620
+ kc.add_argument("--pin-user", default=None)
621
+ kc.add_argument("--scope-override", action="store_true")
622
+ kc.add_argument("--org", default=None, help="hosted mode: the org the key belongs to (required)")
623
+ kc.add_argument("--scopes", default=None,
624
+ help="hosted mode: comma-separated subset of memory,billing,override (default memory)")
625
+ kl = ksub.add_parser("list")
626
+ kl.add_argument("--org", default=None, help="hosted mode: only this org's keys")
627
+ kr = ksub.add_parser("revoke")
628
+ kr.add_argument("key_id")
629
+ km = ksub.add_parser("migrate", help="hosted mode: adopt keys.toml.json keys into an org")
630
+ km.add_argument("--org", required=True)
631
+ km.add_argument("--ns", "--namespace", dest="namespace", default=None, help="only this namespace's keys")
632
+ for x in (kc, kl, kr, km):
633
+ x.add_argument("--data", default=None)
634
+ x.add_argument("--hosted", action="store_true", help="use the hosted admin store (also MEMD_HOSTED=1)")
635
+ key.set_defaults(fn=_cmd_key)
636
+
637
+ org = sub.add_parser("org", help="hosted mode: manage orgs (the billing unit)")
638
+ osub = org.add_subparsers(dest="sub", required=True)
639
+ oc = osub.add_parser("create")
640
+ oc.add_argument("--name", required=True)
641
+ oc.add_argument("--plan", default="free")
642
+ ol = osub.add_parser("list")
643
+ op_ = osub.add_parser("set-plan")
644
+ op_.add_argument("org_id")
645
+ op_.add_argument("plan")
646
+ for x in (oc, ol, op_):
647
+ x.add_argument("--data", default=None)
648
+ org.set_defaults(fn=_cmd_org)
649
+
650
+ exp = sub.add_parser("export", help="full JSONL export (anti-lock-in)")
651
+ exp.add_argument("--namespace", default="default")
652
+ exp.add_argument("--data", default="./memd-data")
653
+ exp.add_argument("--out", default=None)
654
+ exp.set_defaults(fn=_cmd_export)
655
+
656
+ imp = sub.add_parser("import", help="import from another system (mem0) or restore a memd export")
657
+ imp.add_argument("source", choices=["mem0", "memd"], help="source format")
658
+ imp.add_argument("--export", dest="file", required=True, help="path to their export JSON")
659
+ imp.add_argument("--namespace", default="default")
660
+ imp.add_argument("--data", default="./memd-data")
661
+ imp.set_defaults(fn=_cmd_import)
662
+
663
+ rex = sub.add_parser("reindex", help="re-embed records missing/outdated vectors (batch job)")
664
+ rex.add_argument("--namespace", default="default")
665
+ rex.add_argument("--data", default="./memd-data")
666
+ rex.set_defaults(fn=_cmd_reindex)
667
+
668
+ st = sub.add_parser("status", help="engine + namespaces overview")
669
+ st.add_argument("--namespace", default="default")
670
+ st.add_argument("--data", default="./memd-data")
671
+ st.set_defaults(fn=_cmd_status)
672
+
673
+ mt = sub.add_parser("metrics", help="dump in-process metrics snapshot (JSON)")
674
+ mt.set_defaults(fn=_cmd_metrics)
675
+
676
+ ks = sub.add_parser("keys", help="data-key custody: status, migrate to a KMS, rotate")
677
+ kssub = ks.add_subparsers(dest="sub", required=True)
678
+ kst = kssub.add_parser("status", help="custody marker + where each namespace's key is wrapped")
679
+ ksm = kssub.add_parser("migrate", help="re-wrap local data keys under a remote provider")
680
+ ksm.add_argument("--to", required=True, choices=["aws-kms", "vault-transit"])
681
+ ksm.add_argument("--keep-local", action="store_true",
682
+ help="keep the local wrapped key files (crypto-shred will NOT cover them)")
683
+ ksr = kssub.add_parser("rotate", help="re-wrap every data key under the provider's current key version")
684
+ for x in (kst, ksm, ksr):
685
+ x.add_argument("--data", default=os.environ.get("MEMD_DATA", "./memd-data"),
686
+ help="data root: a local path or s3://bucket/prefix (then MEMD_LOCAL_DIR)")
687
+ ks.set_defaults(fn=_cmd_keys)
688
+
689
+ mg = sub.add_parser("migrate", help="store-format upgrade: report what it did or would do")
690
+ mg.add_argument("--report", metavar="DATA", required=True,
691
+ help="data root to report on (read-only; JSON on stdout)")
692
+ mg.set_defaults(fn=_cmd_migrate)
693
+
694
+ args = p.parse_args(argv)
695
+ return args.fn(args)
696
+
697
+
698
+ if __name__ == "__main__":
699
+ raise SystemExit(main())