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.
- memd/__init__.py +7 -0
- memd/cli.py +699 -0
- memd/core/__init__.py +31 -0
- memd/core/schema.py +343 -0
- memd/engine/__init__.py +0 -0
- memd/engine/forward.py +1175 -0
- memd/engine/memory.py +3005 -0
- memd/harness/core.py +257 -0
- memd/harness/run.py +216 -0
- memd/harness/suites/__init__.py +0 -0
- memd/harness/suites/adversarial.py +113 -0
- memd/harness/suites/halumem_ops.py +54 -0
- memd/harness/suites/longmemeval_synthetic.py +221 -0
- memd/hosted/__init__.py +20 -0
- memd/hosted/app.py +258 -0
- memd/hosted/billing.py +909 -0
- memd/hosted/metering.py +460 -0
- memd/hosted/plans.py +138 -0
- memd/hosted/store.py +830 -0
- memd/index/__init__.py +3 -0
- memd/index/ann_usearch.py +1598 -0
- memd/index/sqlite_index.py +2413 -0
- memd/index/tantivy_lexical.py +886 -0
- memd/metrics.py +591 -0
- memd/pipeline/__init__.py +0 -0
- memd/pipeline/consolidation.py +174 -0
- memd/pipeline/embedder.py +370 -0
- memd/pipeline/extractor.py +567 -0
- memd/query/__init__.py +0 -0
- memd/query/dates.py +212 -0
- memd/query/fusion.py +90 -0
- memd/query/packing.py +671 -0
- memd/query/planner.py +76 -0
- memd/query/rerank.py +532 -0
- memd/sdk/__init__.py +3 -0
- memd/sdk/client.py +292 -0
- memd/server/__init__.py +0 -0
- memd/server/auth.py +423 -0
- memd/server/cluster.py +748 -0
- memd/server/http.py +976 -0
- memd/server/mcp_server.py +147 -0
- memd/storage/__init__.py +3 -0
- memd/storage/audit.py +504 -0
- memd/storage/crypto.py +1313 -0
- memd/storage/engine.py +5331 -0
- memd/storage/objectstore.py +597 -0
- memd/storage/replica.py +1040 -0
- memd/storage/s3store.py +1450 -0
- memd_engine-0.5.0.dist-info/METADATA +1230 -0
- memd_engine-0.5.0.dist-info/RECORD +54 -0
- memd_engine-0.5.0.dist-info/WHEEL +5 -0
- memd_engine-0.5.0.dist-info/entry_points.txt +2 -0
- memd_engine-0.5.0.dist-info/licenses/LICENSE +202 -0
- 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())
|