memorysync-cli 1.4.1__tar.gz → 1.5.0__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.
Files changed (27) hide show
  1. {memorysync_cli-1.4.1 → memorysync_cli-1.5.0}/PKG-INFO +1 -1
  2. memorysync_cli-1.5.0/src/memorysync_cli/_version.py +1 -0
  3. memorysync_cli-1.5.0/src/memorysync_cli/commands/migrate.py +748 -0
  4. {memorysync_cli-1.4.1 → memorysync_cli-1.5.0}/src/memorysync_cli/http.py +21 -0
  5. {memorysync_cli-1.4.1 → memorysync_cli-1.5.0}/src/memorysync_cli/main.py +9 -1
  6. {memorysync_cli-1.4.1 → memorysync_cli-1.5.0}/src/memorysync_cli/registry.json +43 -0
  7. memorysync_cli-1.4.1/src/memorysync_cli/_version.py +0 -1
  8. {memorysync_cli-1.4.1 → memorysync_cli-1.5.0}/.gitignore +0 -0
  9. {memorysync_cli-1.4.1 → memorysync_cli-1.5.0}/LICENSE +0 -0
  10. {memorysync_cli-1.4.1 → memorysync_cli-1.5.0}/README.md +0 -0
  11. {memorysync_cli-1.4.1 → memorysync_cli-1.5.0}/pyproject.toml +0 -0
  12. {memorysync_cli-1.4.1 → memorysync_cli-1.5.0}/src/memorysync_cli/__init__.py +0 -0
  13. {memorysync_cli-1.4.1 → memorysync_cli-1.5.0}/src/memorysync_cli/__main__.py +0 -0
  14. {memorysync_cli-1.4.1 → memorysync_cli-1.5.0}/src/memorysync_cli/args.py +0 -0
  15. {memorysync_cli-1.4.1 → memorysync_cli-1.5.0}/src/memorysync_cli/commands/__init__.py +0 -0
  16. {memorysync_cli-1.4.1 → memorysync_cli-1.5.0}/src/memorysync_cli/commands/admin.py +0 -0
  17. {memorysync_cli-1.4.1 → memorysync_cli-1.5.0}/src/memorysync_cli/commands/init.py +0 -0
  18. {memorysync_cli-1.4.1 → memorysync_cli-1.5.0}/src/memorysync_cli/commands/memory.py +0 -0
  19. {memorysync_cli-1.4.1 → memorysync_cli-1.5.0}/src/memorysync_cli/commands/source.py +0 -0
  20. {memorysync_cli-1.4.1 → memorysync_cli-1.5.0}/src/memorysync_cli/commands/tooling.py +0 -0
  21. {memorysync_cli-1.4.1 → memorysync_cli-1.5.0}/src/memorysync_cli/completions.py +0 -0
  22. {memorysync_cli-1.4.1 → memorysync_cli-1.5.0}/src/memorysync_cli/config.py +0 -0
  23. {memorysync_cli-1.4.1 → memorysync_cli-1.5.0}/src/memorysync_cli/credentials.py +0 -0
  24. {memorysync_cli-1.4.1 → memorysync_cli-1.5.0}/src/memorysync_cli/errors.py +0 -0
  25. {memorysync_cli-1.4.1 → memorysync_cli-1.5.0}/src/memorysync_cli/evaluation.py +0 -0
  26. {memorysync_cli-1.4.1 → memorysync_cli-1.5.0}/src/memorysync_cli/output.py +0 -0
  27. {memorysync_cli-1.4.1 → memorysync_cli-1.5.0}/src/memorysync_cli/registry.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: memorysync-cli
3
- Version: 1.4.1
3
+ Version: 1.5.0
4
4
  Summary: MemorySync from your terminal. Zero dependencies.
5
5
  Project-URL: Documentation, https://docs.memorysync.io/cli
6
6
  Project-URL: Homepage, https://memorysync.io/cli
@@ -0,0 +1 @@
1
+ __version__ = "1.5.0"
@@ -0,0 +1,748 @@
1
+ """``migrate`` — move an account from another memory provider into MemorySync.
2
+
3
+ Separate from ``import`` on purpose. ``import`` takes a file already in our shape
4
+ and sends it. ``migrate`` takes someone else's shape, understands its fields, maps
5
+ them onto ours, splits the result across end-user scopes, and reports what it did
6
+ per scope. Folding the two together would mean one command with two unrelated
7
+ code paths and two sets of flags.
8
+
9
+ This command writes customer data into scopes derived from another system's
10
+ identifiers, so every ambiguity here is resolved by refusing rather than
11
+ guessing. The three rules that follow from that:
12
+
13
+ 1. **A record whose scope cannot be determined stops the run.** Writing it into
14
+ a default or fallback scope would be a silent misfiling — the data is present,
15
+ retrievable by nobody, and there is no error to notice.
16
+
17
+ 2. **One API client per end-user group, never a per-item ``end_user_id``.** The
18
+ server resolves the end user as ``(header_val or body_end_user_id)``, so a
19
+ header set once wins over every per-item value. Varying the item field under a
20
+ fixed header would collapse an entire multi-user export into one scope and
21
+ return 207 success for all of it.
22
+
23
+ 3. **``client_ref`` is always sent, and there is no flag to turn it off.** A Mem0
24
+ record always carries an ``id``, so the reference is always available, and a
25
+ migration that is not safe to re-run is a migration that cannot be recovered
26
+ when it fails halfway.
27
+
28
+ Mirrors ``sdk/cli/src/commands/migrate.mjs`` case for case. The two packages are
29
+ documented as interchangeable, so a difference here is a defect in both.
30
+ """
31
+
32
+ from __future__ import annotations
33
+
34
+ import json
35
+ import os
36
+ import urllib.error
37
+ import urllib.parse
38
+ import urllib.request
39
+ from pathlib import Path
40
+ from typing import Any
41
+
42
+ from ..errors import CliError, Exit, usage_error
43
+ from ..output import style
44
+ from .memory import _resolve_batch_size
45
+
46
+
47
+ def _chunk_records(records: list, size: int) -> list[list]:
48
+ """Split into request-sized batches, mirroring the Node CLI's `chunkRecords`."""
49
+ return [records[i : i + size] for i in range(0, len(records), size)]
50
+
51
+ #: Providers this command understands. Named so an unknown one lists the real set.
52
+ PROVIDERS = ("mem0",)
53
+
54
+ #: Mem0's paginated list endpoint. Entity ids go in ``filters``, not at the top level.
55
+ MEM0_BASE = "https://api.mem0.ai"
56
+ MEM0_PAGE_SIZE = 100
57
+
58
+ #: Records held in memory at once before we insist on a file-based run.
59
+ LIVE_PULL_CEILING = 50_000
60
+
61
+
62
+ def migrate(ctx: dict) -> dict:
63
+ positionals = ctx.get("positionals") or []
64
+ provider = (positionals[0] if positionals else "").strip().lower()
65
+ if not provider:
66
+ raise usage_error(
67
+ "Which provider are you migrating from?",
68
+ f"Supported: {', '.join(PROVIDERS)}. Try "
69
+ "`memorysync migrate mem0 --file export.json --dry-run`.",
70
+ )
71
+ if provider not in PROVIDERS:
72
+ raise usage_error(
73
+ f'Cannot migrate from "{provider}".',
74
+ f"Supported providers: {', '.join(PROVIDERS)}.",
75
+ )
76
+ return _migrate_mem0(ctx)
77
+
78
+
79
+ # ---------------------------------------------------------------------------
80
+ # Mem0
81
+ # ---------------------------------------------------------------------------
82
+
83
+
84
+ def _migrate_mem0(ctx: dict) -> dict:
85
+ flags = ctx.get("flags") or {}
86
+ api = ctx.get("api")
87
+
88
+ source = _acquire_mem0_records(ctx)
89
+ groups, problems, total = _normalise_mem0(source["records"], source["scope_hint"])
90
+
91
+ if total == 0:
92
+ raise usage_error(
93
+ f"{source['label']} contained no memories.",
94
+ "Check that the export is not empty, and that it is the memories file "
95
+ "rather than a wrapper around one.",
96
+ )
97
+
98
+ # Unresolvable scope is fatal even under --continue-on-error, and it is
99
+ # checked before "nothing was usable" because its hint is the actionable one.
100
+ # Skipping such a record loses it silently; writing it to a fallback scope
101
+ # files it where nobody will look. Both are worse than refusing and naming
102
+ # the fix.
103
+ unscoped = [p for p in problems if "no user could be resolved" in p]
104
+ if unscoped:
105
+ raise CliError(
106
+ f"{len(unscoped)} record(s) carry no user id, so their scope cannot be determined.",
107
+ exit_code=Exit.USAGE,
108
+ code="unresolvable_scope",
109
+ hint=(
110
+ f"First: {unscoped[0]}. "
111
+ "Nothing was written. Pass --source-user <id> to state the scope for the "
112
+ "whole file, or re-export from Mem0 including user_id on every record. "
113
+ "A record written into a guessed scope would be unreachable by the user "
114
+ "it belongs to."
115
+ ),
116
+ )
117
+
118
+ usable = sum(len(records) for records in groups.values())
119
+ if usable == 0:
120
+ raise usage_error(
121
+ f"None of the {total} record(s) in {source['label']} are usable.",
122
+ f"First problem: {problems[0]}. Every record needs non-empty text and an id.",
123
+ )
124
+
125
+ batch_size = _resolve_batch_size(flags)
126
+
127
+ if flags.get("dry_run"):
128
+ return _dry_run_report(source, groups, problems, total, usable, batch_size)
129
+
130
+ if problems and not flags.get("continue_on_error"):
131
+ raise usage_error(
132
+ f"{len(problems)} record(s) in {source['label']} are not usable.",
133
+ f"First problem: {problems[0]}. Run with --dry-run to see them all, "
134
+ "or --continue-on-error to migrate the rest.",
135
+ )
136
+
137
+ return _run_mem0_migration(ctx, api, source, groups, problems, total, batch_size)
138
+
139
+
140
+ # ---------------------------------------------------------------------------
141
+ # Acquire
142
+ # ---------------------------------------------------------------------------
143
+
144
+
145
+ def _acquire_mem0_records(ctx: dict) -> dict:
146
+ """Get raw Mem0 records, from a file or from Mem0 itself.
147
+
148
+ ``scope_hint`` is the end user to attribute records to when the records
149
+ themselves do not say. It is only ever set from an explicit ``--source-user``,
150
+ never inferred, because an inferred scope is the failure mode this command
151
+ exists to avoid.
152
+ """
153
+ flags = ctx.get("flags") or {}
154
+ positionals = ctx.get("positionals") or []
155
+ file = flags.get("file") or (positionals[1] if len(positionals) > 1 else None)
156
+ explicit_user = (flags.get("source_user") or "").strip() or None
157
+
158
+ if file:
159
+ if flags.get("key"):
160
+ raise usage_error(
161
+ "Pass either --file or --key, not both.",
162
+ "A file is read from disk; a key pulls from Mem0. Two sources in one "
163
+ "run would be ambiguous.",
164
+ )
165
+ try:
166
+ raw = Path(file).read_text(encoding="utf-8")
167
+ except OSError as error:
168
+ raise usage_error(
169
+ f"Could not read {file}: {_describe_read_failure(error)}"
170
+ ) from None
171
+ return {
172
+ "label": str(file),
173
+ "origin": "file",
174
+ "records": _parse_mem0_file(raw, str(file)),
175
+ "scope_hint": explicit_user,
176
+ }
177
+
178
+ key = (flags.get("key") or os.environ.get("MEM0_API_KEY") or "").strip()
179
+ if not key:
180
+ raise usage_error(
181
+ "Nothing to migrate from.",
182
+ "Pass --file <path> for an export you already have, or --key <mem0-api-key> "
183
+ "(or set MEM0_API_KEY) to pull from Mem0 directly.",
184
+ )
185
+
186
+ records = _pull_from_mem0(key, explicit_user, flags)
187
+ return {
188
+ "label": f"Mem0 user {explicit_user}" if explicit_user else "your Mem0 account",
189
+ "origin": "api",
190
+ "records": records,
191
+ "scope_hint": explicit_user,
192
+ }
193
+
194
+
195
+ def _parse_mem0_file(raw: str, file: str) -> list:
196
+ """Read a Mem0 export file.
197
+
198
+ Mem0 hands back a paginated envelope from the API and a wrapper object from
199
+ the dashboard, and people also save just the array. All three are accepted
200
+ because all three are things a user will actually have on disk; anything else
201
+ is refused with the shape we were looking for rather than a type error.
202
+ """
203
+ trimmed = raw.strip()
204
+ if not trimmed:
205
+ raise usage_error(f"{file} is empty.")
206
+
207
+ try:
208
+ parsed = json.loads(trimmed)
209
+ except ValueError as error:
210
+ raise usage_error(
211
+ f"{file} is not valid JSON: {error}",
212
+ "A Mem0 export is a JSON file. If you have JSONL in our own record shape, "
213
+ "use `memorysync import` instead.",
214
+ ) from None
215
+
216
+ if isinstance(parsed, list):
217
+ return parsed
218
+ if isinstance(parsed, dict):
219
+ for key in ("results", "memories", "data", "items"):
220
+ if isinstance(parsed.get(key), list):
221
+ return parsed[key]
222
+ raise usage_error(
223
+ f"{file} does not look like a Mem0 export.",
224
+ 'Expected a JSON array of records, or an object with a "results" or '
225
+ '"memories" array.',
226
+ )
227
+
228
+
229
+ def _pull_from_mem0(key: str, user: str | None, flags: dict) -> list:
230
+ """Page through Mem0's list endpoint.
231
+
232
+ ``filters`` is required and must name at least one entity, so a run without
233
+ ``--source-user`` uses the documented ``*`` wildcard. That returns every user's
234
+ memories in one stream, and the records must then carry ``user_id``
235
+ themselves for grouping to be possible — checked in ``_normalise_mem0``,
236
+ which refuses rather than pooling them.
237
+ """
238
+ filters = {"user_id": user} if user else {"user_id": "*"}
239
+ collected: list = []
240
+ page = 1
241
+
242
+ while True:
243
+ payload = _mem0_request(key, page, {"filters": filters}, flags.get("timeout"))
244
+
245
+ if isinstance(payload, dict) and isinstance(payload.get("results"), list):
246
+ batch = payload["results"]
247
+ elif isinstance(payload, list):
248
+ batch = payload
249
+ else:
250
+ raise CliError(
251
+ "Mem0 returned a response this version does not understand.",
252
+ exit_code=Exit.NETWORK,
253
+ code="mem0_unexpected_response",
254
+ hint=(
255
+ 'Expected a paginated object with a "results" array. Export from '
256
+ "Mem0 and migrate the file with --file instead."
257
+ ),
258
+ )
259
+
260
+ collected.extend(batch)
261
+
262
+ if len(collected) > LIVE_PULL_CEILING:
263
+ raise CliError(
264
+ f"This account has more than {LIVE_PULL_CEILING:,} memories.",
265
+ exit_code=Exit.USAGE,
266
+ code="account_too_large_for_live_pull",
267
+ hint=(
268
+ "Export from Mem0 to a file and run "
269
+ "`memorysync migrate mem0 --file <path>`, which streams from disk "
270
+ "instead of holding the account in memory."
271
+ ),
272
+ )
273
+
274
+ has_next = isinstance(payload, dict) and payload.get("next")
275
+ if not has_next or not batch:
276
+ break
277
+ page += 1
278
+
279
+ return collected
280
+
281
+
282
+ def _mem0_request(key: str, page: int, body: dict, timeout: Any) -> Any:
283
+ """One page from Mem0, with its failures translated into our exit codes."""
284
+ query = urllib.parse.urlencode({"page": page, "page_size": MEM0_PAGE_SIZE})
285
+ url = f"{MEM0_BASE}/v3/memories/?{query}"
286
+ limit_ms = int(timeout) if timeout and int(timeout) > 0 else 60000
287
+
288
+ request = urllib.request.Request(
289
+ url,
290
+ data=json.dumps(body).encode("utf-8"),
291
+ method="POST",
292
+ headers={
293
+ # Mem0's own scheme. Not Bearer, and not our X-API-Key.
294
+ "Authorization": f"Token {key}",
295
+ "Content-Type": "application/json",
296
+ "Accept": "application/json",
297
+ },
298
+ )
299
+
300
+ try:
301
+ with urllib.request.urlopen(request, timeout=limit_ms / 1000) as response:
302
+ text = response.read().decode("utf-8", errors="replace")
303
+ except urllib.error.HTTPError as error:
304
+ status = error.code
305
+ if status in (401, 403):
306
+ raise CliError(
307
+ "Mem0 rejected that API key.",
308
+ exit_code=Exit.AUTH,
309
+ code="mem0_unauthorized",
310
+ hint="Check the key in your Mem0 dashboard. It is a Mem0 key, not your "
311
+ "MemorySync key.",
312
+ ) from None
313
+ if status == 429:
314
+ retry = error.headers.get("retry-after") if error.headers else None
315
+ raise CliError(
316
+ "Mem0 is rate limiting this account.",
317
+ exit_code=Exit.NETWORK,
318
+ code="mem0_rate_limited",
319
+ hint=(
320
+ f"Retry in {retry}s, or export to a file and migrate that instead."
321
+ if retry
322
+ else "Wait a moment and try again, or export to a file and migrate "
323
+ "that instead."
324
+ ),
325
+ ) from None
326
+ detail = ""
327
+ try:
328
+ detail = error.read().decode("utf-8", errors="replace")
329
+ except Exception: # noqa: BLE001 — the body is a nicety, not required
330
+ detail = ""
331
+ raise CliError(
332
+ f"Mem0 answered {status} while listing memories.",
333
+ exit_code=Exit.NETWORK,
334
+ code="mem0_error",
335
+ hint=detail[:300] if detail else "Export to a file and migrate that instead.",
336
+ ) from None
337
+ except TimeoutError:
338
+ raise CliError(
339
+ f"Mem0 did not respond within {limit_ms}ms.",
340
+ exit_code=Exit.NETWORK,
341
+ code="mem0_timeout",
342
+ hint="Raise it with --timeout, or export to a file and migrate that.",
343
+ ) from None
344
+ except urllib.error.URLError as error:
345
+ reason = getattr(error, "reason", error)
346
+ if isinstance(reason, TimeoutError) or "timed out" in str(reason).lower():
347
+ raise CliError(
348
+ f"Mem0 did not respond within {limit_ms}ms.",
349
+ exit_code=Exit.NETWORK,
350
+ code="mem0_timeout",
351
+ hint="Raise it with --timeout, or export to a file and migrate that.",
352
+ ) from None
353
+ raise CliError(
354
+ f"Could not reach Mem0: {reason}",
355
+ exit_code=Exit.NETWORK,
356
+ code="mem0_unreachable",
357
+ ) from None
358
+
359
+ try:
360
+ return json.loads(text)
361
+ except ValueError:
362
+ raise CliError(
363
+ "Mem0 returned a body that is not JSON.",
364
+ exit_code=Exit.NETWORK,
365
+ code="mem0_bad_json",
366
+ ) from None
367
+
368
+
369
+ def _describe_read_failure(error: OSError) -> str:
370
+ import errno
371
+
372
+ if error.errno == errno.ENOENT:
373
+ return "no such file or directory."
374
+ if error.errno in (errno.EACCES, errno.EPERM):
375
+ return "permission denied."
376
+ if error.errno in (errno.EISDIR, getattr(errno, "EISDIR", None)):
377
+ return "that is a directory, not a file."
378
+ return "could not be read."
379
+
380
+
381
+ # ---------------------------------------------------------------------------
382
+ # Normalise
383
+ # ---------------------------------------------------------------------------
384
+
385
+
386
+ def _normalise_mem0(raw_records: list, scope_hint: str | None) -> tuple[dict, list[str], int]:
387
+ """Map Mem0 records onto ours, grouped by the end user each belongs to.
388
+
389
+ Position is reported as ``record N`` counting from 1 across the whole source,
390
+ so a problem names something the user can find in their file whichever shape
391
+ it arrived in.
392
+ """
393
+ groups: dict[str, list[dict]] = {}
394
+ problems: list[str] = []
395
+ total = 0
396
+
397
+ for index, entry in enumerate(raw_records):
398
+ total += 1
399
+ at = f"record {index + 1}"
400
+
401
+ if not isinstance(entry, dict):
402
+ problems.append(f"{at}: expected an object")
403
+ continue
404
+
405
+ # ``memory`` is what the v3 API returns; ``content`` and ``text`` appear
406
+ # in older exports and in files people have already reshaped by hand.
407
+ text = entry.get("memory")
408
+ if text is None:
409
+ text = entry.get("content")
410
+ if text is None:
411
+ text = entry.get("text")
412
+ if not isinstance(text, str) or not text.strip():
413
+ problems.append(f"{at}: no memory, content or text field")
414
+ continue
415
+
416
+ # The reference that makes re-running safe. Mem0 ids are strings, but a
417
+ # reshaped export can carry a number, which is stringified rather than
418
+ # refused — an id is an identifier, not a measurement.
419
+ ref = _usable_ref(
420
+ entry.get("id") if entry.get("id") is not None
421
+ else entry.get("memory_id") if entry.get("memory_id") is not None
422
+ else entry.get("uuid")
423
+ )
424
+ if not ref:
425
+ problems.append(f"{at}: no id, so re-running this migration could not skip it")
426
+ continue
427
+
428
+ # Scope, in order of authority: the record's own user, then an explicit
429
+ # --source-user. Never a default. An ``agent_id`` is *not* accepted as a
430
+ # scope: it identifies which agent learned something, not whose memory it
431
+ # is, and treating it as an end user would create a scope no real user
432
+ # reads from.
433
+ record_user = _first_non_empty(
434
+ entry.get("user_id"), entry.get("userId"), entry.get("end_user_id")
435
+ )
436
+ user = record_user or scope_hint
437
+ if not user:
438
+ problems.append(f"{at}: no user could be resolved (id {ref})")
439
+ continue
440
+
441
+ metadata: dict[str, Any] = {}
442
+ if isinstance(entry.get("metadata"), dict):
443
+ metadata.update(entry["metadata"])
444
+ # Ours are written last so they are authoritative. A record whose own
445
+ # metadata happened to carry ``mem0_id`` would otherwise overwrite the
446
+ # reconciliation key with something that is not an id.
447
+ metadata["mem0_id"] = ref
448
+ created_at = entry.get("created_at")
449
+ if isinstance(created_at, str) and created_at.strip():
450
+ # Kept in metadata because our ``created_at`` is server-assigned and
451
+ # feeds the recency signal in ranking. Back-dating it would make every
452
+ # migrated memory look stale and rank below anything written after.
453
+ metadata["mem0_created_at"] = created_at.strip()
454
+ agent_id = _first_non_empty(entry.get("agent_id"), entry.get("agentId"))
455
+ if agent_id:
456
+ metadata["mem0_agent_id"] = agent_id
457
+ app_id = _first_non_empty(entry.get("app_id"), entry.get("appId"))
458
+ if app_id:
459
+ metadata["mem0_app_id"] = app_id
460
+ run_id = _first_non_empty(entry.get("run_id"), entry.get("runId"))
461
+ if run_id:
462
+ metadata["mem0_run_id"] = run_id
463
+
464
+ record: dict[str, Any] = {"text": text.strip(), "ref": ref, "metadata": metadata, "at": at}
465
+
466
+ # Mem0's ``categories`` is a list of labels, which is what our ``tags``
467
+ # is. Non-strings are dropped rather than coerced: a tag is matched
468
+ # exactly at query time, so a stringified object would be a tag nobody
469
+ # can ever match.
470
+ if isinstance(entry.get("categories"), list):
471
+ tags = [t.strip() for t in entry["categories"] if isinstance(t, str) and t.strip()]
472
+ if tags:
473
+ record["tags"] = tags
474
+
475
+ groups.setdefault(user, []).append(record)
476
+
477
+ return groups, problems, total
478
+
479
+
480
+ def _usable_ref(value: Any) -> str | None:
481
+ """A reference we are willing to send as ``client_ref``.
482
+
483
+ Fractional numbers are refused because the two CLIs would render them
484
+ differently — Python's ``str(1.0)`` is ``'1.0'`` and JavaScript's
485
+ ``String(1.0)`` is ``'1'`` — so the same record would claim two different
486
+ references depending on which package ran the migration, and a re-run from
487
+ the other one would store it twice.
488
+ """
489
+ if isinstance(value, str):
490
+ return value.strip() or None
491
+ # ``bool`` is a subclass of ``int`` and True is not an identifier.
492
+ if isinstance(value, int) and not isinstance(value, bool):
493
+ return str(value)
494
+ return None
495
+
496
+
497
+ def _first_non_empty(*values: Any) -> str | None:
498
+ for value in values:
499
+ if isinstance(value, str) and value.strip():
500
+ return value.strip()
501
+ return None
502
+
503
+
504
+ # ---------------------------------------------------------------------------
505
+ # Report
506
+ # ---------------------------------------------------------------------------
507
+
508
+
509
+ def _dry_run_report(
510
+ source: dict, groups: dict, problems: list[str], total: int, usable: int, batch_size: int
511
+ ) -> dict:
512
+ per_user = sorted(
513
+ ({"user": user, "records": len(records)} for user, records in groups.items()),
514
+ key=lambda g: (-g["records"], g["user"]),
515
+ )
516
+ requests = sum(-(-g["records"] // batch_size) for g in per_user)
517
+
518
+ def render() -> str:
519
+ lines: list[str | None] = [
520
+ style.yellow("Dry run. Nothing was written."),
521
+ f"{style.dim('source ')} {source['label']}",
522
+ f"{style.dim('records ')} {total}",
523
+ f"{style.dim('usable ')} {usable}",
524
+ f"{style.dim('unusable ')} {len(problems)}",
525
+ f"{style.dim('end users ')} {len(per_user)}",
526
+ f"{style.dim('requests ')} {requests} at {batch_size} per request",
527
+ "",
528
+ style.dim("Would write:"),
529
+ ]
530
+ for group in per_user[:15]:
531
+ lines.append(f" {group['user']:<24} {group['records']:>7}")
532
+ if len(per_user) > 15:
533
+ lines.append(style.dim(f" ... and {len(per_user) - 15} more users"))
534
+ if problems:
535
+ lines.append("")
536
+ lines.append(style.dim("Problems:"))
537
+ for problem in problems[:10]:
538
+ lines.append(style.dim(f" {problem}"))
539
+ if len(problems) > 10:
540
+ lines.append(style.dim(f" ... and {len(problems) - 10} more"))
541
+ lines.append(
542
+ style.yellow(
543
+ "A real run would refuse these. Pass --continue-on-error to "
544
+ "migrate the rest."
545
+ )
546
+ )
547
+ return "\n".join(line for line in lines if line is not None)
548
+
549
+ return {
550
+ "data": {
551
+ "source": source["label"],
552
+ "origin": source["origin"],
553
+ "provider": "mem0",
554
+ "total": total,
555
+ "usable": usable,
556
+ "unusable": len(problems),
557
+ "users": len(per_user),
558
+ "per_user": per_user,
559
+ "requests": requests,
560
+ "batch_size": batch_size,
561
+ "problems": problems,
562
+ "dry_run": True,
563
+ },
564
+ "text": render,
565
+ }
566
+
567
+
568
+ # ---------------------------------------------------------------------------
569
+ # Run
570
+ # ---------------------------------------------------------------------------
571
+
572
+
573
+ def _run_mem0_migration(
574
+ ctx: dict,
575
+ api: Any,
576
+ source: dict,
577
+ groups: dict,
578
+ problems: list[str],
579
+ total: int,
580
+ batch_size: int,
581
+ ) -> dict:
582
+ flags = ctx.get("flags") or {}
583
+ results: dict[str, Any] = {
584
+ "source": source["label"],
585
+ "provider": "mem0",
586
+ "total": total,
587
+ "unusable": len(problems),
588
+ "users": len(groups),
589
+ "imported": 0,
590
+ "already_imported": 0,
591
+ "skipped": 0,
592
+ "unreported": 0,
593
+ "failed": 0,
594
+ "memory_ids": [],
595
+ "failures": [],
596
+ "per_user": [],
597
+ "batch_size": batch_size,
598
+ }
599
+
600
+ stopped = False
601
+
602
+ for user, records in groups.items():
603
+ # One client per group. See the note at the top of this module: the
604
+ # server trusts the header over any per-item value, so this is the only
605
+ # way to write more than one scope in a single run without silently
606
+ # pooling them.
607
+ scoped = api.with_user(user)
608
+ per_user = {
609
+ "user": user,
610
+ "records": len(records),
611
+ "imported": 0,
612
+ "already_imported": 0,
613
+ "skipped": 0,
614
+ "unreported": 0,
615
+ "failed": 0,
616
+ }
617
+
618
+ for batch in _chunk_records(records, batch_size):
619
+ items = []
620
+ for record in batch:
621
+ item: dict[str, Any] = {
622
+ "text": record["text"],
623
+ "source": "mem0-import",
624
+ "metadata": record["metadata"],
625
+ # Always sent. There is no flag to suppress it.
626
+ "client_ref": record["ref"],
627
+ }
628
+ if record.get("tags"):
629
+ item["tags"] = record["tags"]
630
+ items.append(item)
631
+
632
+ try:
633
+ response = scoped.bulk_add_memories({"items": items})
634
+ except CliError as error:
635
+ per_user["failed"] += len(batch)
636
+ results["failed"] += len(batch)
637
+ results["failures"].append(
638
+ f"{user}: {batch[0]['at']}-{batch[-1]['at']}: {error.message}"
639
+ )
640
+ if not flags.get("continue_on_error"):
641
+ stopped = True
642
+ break
643
+ continue
644
+
645
+ per_item = response.get("results") if isinstance(response, dict) else None
646
+ if not isinstance(per_item, list):
647
+ # Over-quota returns a success envelope with no per-item detail
648
+ # and stores nothing. Counting these as imported would report a
649
+ # migration that did not happen, which is the one thing this must
650
+ # never do.
651
+ per_user["unreported"] += len(batch)
652
+ results["unreported"] += len(batch)
653
+ continue
654
+
655
+ for item in per_item:
656
+ index = item.get("index")
657
+ record = batch[index] if isinstance(index, int) and 0 <= index < len(batch) else None
658
+ where = f"{user}: {record['at']}" if record else f"{user}: item {index}"
659
+ status = item.get("status")
660
+ if status == "created":
661
+ per_user["imported"] += 1
662
+ results["imported"] += 1
663
+ results["memory_ids"].extend(item.get("memory_ids") or [])
664
+ elif status == "skipped" and item.get("reason") == "already_ingested":
665
+ per_user["already_imported"] += 1
666
+ results["already_imported"] += 1
667
+ results["memory_ids"].extend(item.get("memory_ids") or [])
668
+ elif status == "skipped":
669
+ per_user["skipped"] += 1
670
+ results["skipped"] += 1
671
+ else:
672
+ per_user["failed"] += 1
673
+ results["failed"] += 1
674
+ results["failures"].append(f"{where}: {item.get('reason') or 'rejected'}")
675
+
676
+ if per_user["failed"] > 0 and not flags.get("continue_on_error"):
677
+ stopped = True
678
+ break
679
+
680
+ results["per_user"].append(per_user)
681
+ if stopped:
682
+ break
683
+
684
+ results["stopped_early"] = stopped
685
+ results["completed_users"] = len(results["per_user"])
686
+ return {"data": results, "text": lambda: _render_migration(results)}
687
+
688
+
689
+ def _render_migration(results: dict) -> str:
690
+ lines = [
691
+ f"{style.green('Migrated')} {results['imported']} of {results['total']} record(s) "
692
+ f"across {results['completed_users']} end user(s)"
693
+ ]
694
+ if results["already_imported"]:
695
+ lines.append(
696
+ style.dim(f" {results['already_imported']} already migrated on an earlier run")
697
+ )
698
+ if results["skipped"]:
699
+ lines.append(style.dim(f" {results['skipped']} skipped as duplicate or low value"))
700
+ if results["unreported"]:
701
+ lines.append(
702
+ style.yellow(
703
+ f" {results['unreported']} accepted without a per-record result — "
704
+ "likely over your plan limit"
705
+ )
706
+ )
707
+ lines.append(
708
+ style.dim(
709
+ " Nothing was stored for those. Re-run this command when your cycle resets."
710
+ )
711
+ )
712
+ if results["unusable"]:
713
+ lines.append(style.dim(f" {results['unusable']} unusable record(s) skipped"))
714
+ if results["failed"]:
715
+ lines.append(style.red(f" {results['failed']} failed"))
716
+ for failure in results["failures"][:5]:
717
+ lines.append(style.dim(f" {failure}"))
718
+ if len(results["failures"]) > 5:
719
+ lines.append(style.dim(f" ... and {len(results['failures']) - 5} more"))
720
+
721
+ if len(results["per_user"]) > 1:
722
+ lines.append("")
723
+ lines.append(style.dim("Per end user:"))
724
+ for group in results["per_user"][:15]:
725
+ lines.append(
726
+ f" {group['user']:<24} {group['imported']:>7} of {group['records']}"
727
+ )
728
+ if len(results["per_user"]) > 15:
729
+ lines.append(style.dim(f" ... and {len(results['per_user']) - 15} more"))
730
+
731
+ if results["stopped_early"]:
732
+ lines.append("")
733
+ lines.append(style.yellow("Stopped at the first failure. Nothing after it was attempted."))
734
+ lines.append(
735
+ style.dim(
736
+ "Re-run the same command to continue: records already migrated are "
737
+ "recognised and not stored twice."
738
+ )
739
+ )
740
+ else:
741
+ lines.append("")
742
+ lines.append(
743
+ style.dim(
744
+ "Safe to re-run. Every record carries its Mem0 id, so nothing is stored twice."
745
+ )
746
+ )
747
+
748
+ return "\n".join(lines)
@@ -108,6 +108,27 @@ class ApiClient:
108
108
  self.timeout = timeout
109
109
  self.verbose = verbose
110
110
 
111
+ def with_user(self, user: str | None) -> "ApiClient":
112
+ """The same client scoped to a different end user.
113
+
114
+ ``migrate`` needs this. One Mem0 export routinely carries memories for
115
+ many ``user_id`` values, and each group has to be written under its own
116
+ end-user scope. Doing that by varying ``end_user_id`` per bulk-add item
117
+ does not work and fails silently: the server resolves
118
+ ``(header_val or body_end_user_id)``, so a header set once wins over
119
+ every per-item value and the whole export collapses into one scope with
120
+ no error to say so. A separate client per group keeps the header
121
+ authoritative, which is the field the server trusts.
122
+ """
123
+ return ApiClient(
124
+ base_url=self.base_url,
125
+ api_key=self.api_key,
126
+ user=user,
127
+ project=self.project,
128
+ timeout=self.timeout,
129
+ verbose=self.verbose,
130
+ )
131
+
111
132
  def headers(self, extra: dict[str, str] | None = None) -> dict[str, str]:
112
133
  headers = {
113
134
  "Accept": "application/json",
@@ -18,7 +18,14 @@ from typing import Any, Callable
18
18
  from . import config as config_module
19
19
  from . import registry
20
20
  from .args import parse_args, suggest
21
- from .commands import admin, init as init_command, memory, source as source_command, tooling
21
+ from .commands import (
22
+ admin,
23
+ init as init_command,
24
+ memory,
25
+ migrate as migrate_command,
26
+ source as source_command,
27
+ tooling,
28
+ )
22
29
  from .credentials import read_key
23
30
  from .errors import CliError, Exit, auth_error, usage_error
24
31
  from .evaluation import claim_reminder
@@ -63,6 +70,7 @@ _HANDLERS: dict[str, Handler] = {
63
70
  "update": memory.update,
64
71
  "delete": memory.delete_memories,
65
72
  "import": memory.import_memories,
73
+ "migrate": migrate_command.migrate,
66
74
  "import-status": memory.import_status,
67
75
  "export": memory.export_memories,
68
76
  "quota": admin.quota,
@@ -420,6 +420,49 @@
420
420
  "requires_auth": true,
421
421
  "consumes_quota": "add_requests"
422
422
  },
423
+ {
424
+ "name": "migrate",
425
+ "summary": "Move an account from another memory provider into MemorySync.",
426
+ "description": "Reads a Mem0 export from disk, or pulls the account from Mem0 directly with your Mem0 API key, maps every record onto a MemorySync memory, and writes each one into the end-user scope it belongs to. Mem0 ids become client references, so re-running the same migration recognises what already landed instead of storing it twice — a run interrupted halfway is finished by running it again. Categories become tags, and the original id, timestamp, agent, app and run ids are kept in metadata so the two systems stay reconcilable. Always start with --dry-run: it reports the per-user breakdown and every unusable record without writing anything.",
427
+ "usage": "memorysync migrate <provider> [--file <path> | --key <key>] [--dry-run]",
428
+ "flags": [
429
+ {
430
+ "name": "--file",
431
+ "value": "path",
432
+ "description": "A Mem0 export on disk. Use instead of --key."
433
+ },
434
+ {
435
+ "name": "--key",
436
+ "value": "key",
437
+ "description": "Mem0 API key, to pull the account directly. Falls back to MEM0_API_KEY."
438
+ },
439
+ {
440
+ "name": "--source-user",
441
+ "value": "id",
442
+ "description": "Migrate only this user from the source provider, and use it as the scope for records that name no user."
443
+ },
444
+ {
445
+ "name": "--batch-size",
446
+ "value": "n",
447
+ "description": "Records per request. Default 50."
448
+ },
449
+ {
450
+ "name": "--dry-run",
451
+ "description": "Report what would be written, and write nothing."
452
+ },
453
+ {
454
+ "name": "--continue-on-error",
455
+ "description": "Migrate the usable records instead of refusing the whole file."
456
+ }
457
+ ],
458
+ "examples": [
459
+ "memorysync migrate mem0 --file mem0_export.json --dry-run",
460
+ "memorysync migrate mem0 --file mem0_export.json",
461
+ "memorysync migrate mem0 --key m0-xxxx --source-user alice"
462
+ ],
463
+ "requires_auth": true,
464
+ "consumes_quota": "add_requests"
465
+ },
423
466
  {
424
467
  "name": "import-status",
425
468
  "summary": "Check a background import, or list recent ones.",
@@ -1 +0,0 @@
1
- __version__ = "1.4.1"
File without changes
File without changes