memorysync-cli 1.4.0__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.
- {memorysync_cli-1.4.0 → memorysync_cli-1.5.0}/PKG-INFO +1 -1
- memorysync_cli-1.5.0/src/memorysync_cli/_version.py +1 -0
- {memorysync_cli-1.4.0 → memorysync_cli-1.5.0}/src/memorysync_cli/commands/memory.py +13 -1
- memorysync_cli-1.5.0/src/memorysync_cli/commands/migrate.py +748 -0
- {memorysync_cli-1.4.0 → memorysync_cli-1.5.0}/src/memorysync_cli/http.py +21 -0
- {memorysync_cli-1.4.0 → memorysync_cli-1.5.0}/src/memorysync_cli/main.py +9 -1
- {memorysync_cli-1.4.0 → memorysync_cli-1.5.0}/src/memorysync_cli/registry.json +44 -1
- memorysync_cli-1.4.0/src/memorysync_cli/_version.py +0 -1
- {memorysync_cli-1.4.0 → memorysync_cli-1.5.0}/.gitignore +0 -0
- {memorysync_cli-1.4.0 → memorysync_cli-1.5.0}/LICENSE +0 -0
- {memorysync_cli-1.4.0 → memorysync_cli-1.5.0}/README.md +0 -0
- {memorysync_cli-1.4.0 → memorysync_cli-1.5.0}/pyproject.toml +0 -0
- {memorysync_cli-1.4.0 → memorysync_cli-1.5.0}/src/memorysync_cli/__init__.py +0 -0
- {memorysync_cli-1.4.0 → memorysync_cli-1.5.0}/src/memorysync_cli/__main__.py +0 -0
- {memorysync_cli-1.4.0 → memorysync_cli-1.5.0}/src/memorysync_cli/args.py +0 -0
- {memorysync_cli-1.4.0 → memorysync_cli-1.5.0}/src/memorysync_cli/commands/__init__.py +0 -0
- {memorysync_cli-1.4.0 → memorysync_cli-1.5.0}/src/memorysync_cli/commands/admin.py +0 -0
- {memorysync_cli-1.4.0 → memorysync_cli-1.5.0}/src/memorysync_cli/commands/init.py +0 -0
- {memorysync_cli-1.4.0 → memorysync_cli-1.5.0}/src/memorysync_cli/commands/source.py +0 -0
- {memorysync_cli-1.4.0 → memorysync_cli-1.5.0}/src/memorysync_cli/commands/tooling.py +0 -0
- {memorysync_cli-1.4.0 → memorysync_cli-1.5.0}/src/memorysync_cli/completions.py +0 -0
- {memorysync_cli-1.4.0 → memorysync_cli-1.5.0}/src/memorysync_cli/config.py +0 -0
- {memorysync_cli-1.4.0 → memorysync_cli-1.5.0}/src/memorysync_cli/credentials.py +0 -0
- {memorysync_cli-1.4.0 → memorysync_cli-1.5.0}/src/memorysync_cli/errors.py +0 -0
- {memorysync_cli-1.4.0 → memorysync_cli-1.5.0}/src/memorysync_cli/evaluation.py +0 -0
- {memorysync_cli-1.4.0 → memorysync_cli-1.5.0}/src/memorysync_cli/output.py +0 -0
- {memorysync_cli-1.4.0 → memorysync_cli-1.5.0}/src/memorysync_cli/registry.py +0 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "1.5.0"
|
|
@@ -534,6 +534,18 @@ def import_memories(ctx: dict) -> dict:
|
|
|
534
534
|
}
|
|
535
535
|
if isinstance(entry.get("metadata"), dict):
|
|
536
536
|
record["metadata"] = entry["metadata"]
|
|
537
|
+
# Carried through because ``--async`` carries them: the same file sent
|
|
538
|
+
# both ways has to produce the same memories. The type rules mirror
|
|
539
|
+
# ``parse_records`` in the server-side importer.
|
|
540
|
+
event_type = entry.get("event_type")
|
|
541
|
+
if isinstance(event_type, str) and event_type.strip():
|
|
542
|
+
record["event_type"] = event_type.strip()
|
|
543
|
+
if isinstance(entry.get("tags"), list):
|
|
544
|
+
record["tags"] = entry["tags"]
|
|
545
|
+
importance = entry.get("importance")
|
|
546
|
+
# ``bool`` is a subclass of ``int``, and True is not a weighting.
|
|
547
|
+
if isinstance(importance, (int, float)) and not isinstance(importance, bool):
|
|
548
|
+
record["importance"] = float(importance)
|
|
537
549
|
records.append(record)
|
|
538
550
|
|
|
539
551
|
stripped = raw.strip()
|
|
@@ -673,7 +685,7 @@ def import_memories(ctx: dict) -> dict:
|
|
|
673
685
|
item = {
|
|
674
686
|
key: value
|
|
675
687
|
for key, value in record.items()
|
|
676
|
-
if key in {"text", "source", "metadata"}
|
|
688
|
+
if key in {"text", "source", "metadata", "event_type", "tags", "importance"}
|
|
677
689
|
}
|
|
678
690
|
if resume and record.get("ref"):
|
|
679
691
|
item["client_ref"] = record["ref"]
|
|
@@ -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
|
|
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,
|
|
@@ -372,7 +372,7 @@
|
|
|
372
372
|
{
|
|
373
373
|
"name": "import",
|
|
374
374
|
"summary": "Bulk load memories from a JSON or JSONL file.",
|
|
375
|
-
"description": "Each record needs a text field, and may carry
|
|
375
|
+
"description": "Each record needs a text field, and may carry metadata, tags, source, event_type and importance. Every record in one import belongs to the same end user, from --user or your configured default. Validates the whole file before sending anything, and reports per-row errors with line numbers rather than failing on the first bad row. Pass --resume to make re-running the same file safe: each record is sent with an identifier the server remembers, so rows that already landed are reported as skipped instead of being stored a second time. Pass --async for a large file: the file is uploaded once and processed in the background, and you get a job id to poll with `memorysync import-status`.",
|
|
376
376
|
"usage": "memorysync import <file> [--user <id>] [--batch-size <n>] [--resume] [--async]",
|
|
377
377
|
"flags": [
|
|
378
378
|
{
|
|
@@ -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.0"
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|