#!/usr/bin/env bash set -euo pipefail if ! command -v python3 >/dev/null 2>&1; then printf '%s\n' "Error: python3 is required to run the Mem0 migration. Install Python 3, then rerun this command." >&2 exit 1 fi exec python3 - "$@" <<'PY' from __future__ import annotations import argparse from collections import Counter import getpass import json import os import platform import re import stat import sys import uuid from datetime import datetime, timezone from hashlib import sha256 from pathlib import Path from typing import Any, Dict, List, Optional, Set, Tuple from urllib.error import HTTPError, URLError from urllib.request import Request, urlopen DEFAULT_BASE_URL = "https://api.mem0.ai" POSTHOG_API_KEY = "phc_hgJkUVJFYtmaJqrvf6CYN67TIQ8yhXAkWzUn9AMU4yX" POSTHOG_CAPTURE_URL = "https://us.i.posthog.com/i/v0/e/" SCRIPT_VERSION = "oss-to-platform-migrate-v1" EMAIL_RE = re.compile(r"^[^@\s]+@[^@\s]+\.[^@\s]+$") DEFAULT_QDRANT_COLLECTION = "mem0" DEFAULT_EXPORT_PAGE_SIZE = 100 DEFAULT_PLATFORM_PAGE_SIZE = 100 class MigrationError(Exception): pass class AuthError(MigrationError): pass class HTTPStatusError(MigrationError): def __init__(self, status_code: int, detail: str): self.status_code = status_code self.detail = detail super().__init__(detail) def utc_now() -> str: return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z") def print_info(message: str) -> None: print(message) def print_error(message: str) -> None: print(f"Error: {message}", file=sys.stderr) def mem0_dir() -> Path: configured = os.environ.get("MEM0_DIR") if configured: return Path(configured).expanduser() return Path.home() / ".mem0" def config_path() -> Path: return mem0_dir() / "config.json" def load_config(path: Path) -> Dict[str, Any]: if not path.exists(): return {} try: data = json.loads(path.read_text(encoding="utf-8")) return data if isinstance(data, dict) else {} except Exception: return {} def write_config(path: Path, config: Dict[str, Any]) -> None: path.parent.mkdir(parents=True, exist_ok=True) os.chmod(path.parent, stat.S_IRWXU) path.write_text(json.dumps(config, indent=4) + "\n", encoding="utf-8") os.chmod(path, stat.S_IRUSR | stat.S_IWUSR) def ensure_oss_anon_id(path: Path, config: Dict[str, Any]) -> str: existing = config.get("user_id") if isinstance(existing, str) and existing: return existing generated = str(uuid.uuid4()) config["user_id"] = generated try: write_config(path, config) except Exception: # Keep the run usable even on read-only home directories. Telemetry can # still use the transient ID for this run. pass return generated def read_anon_ids(config: Dict[str, Any], fallback_oss_id: str) -> Dict[str, Any]: telemetry = config.get("telemetry") if not isinstance(telemetry, dict): telemetry = {} aliased_pairs = telemetry.get("aliased_pairs") if not isinstance(aliased_pairs, list): aliased_pairs = [] return { "oss": config.get("user_id") if isinstance(config.get("user_id"), str) else fallback_oss_id, "cli": telemetry.get("anonymous_id") if isinstance(telemetry.get("anonymous_id"), str) else None, "aliased_pairs": [item for item in aliased_pairs if isinstance(item, str)], } def primary_anon_id(anon_ids: Dict[str, Any]) -> str: return anon_ids.get("oss") or anon_ids.get("cli") or "anonymous_user" def unique_anon_ids(anon_ids: Dict[str, Any]) -> List[str]: seen: Set[str] = set() result: List[str] = [] for anon_id in (anon_ids.get("oss"), anon_ids.get("cli")): if not anon_id or anon_id in seen: continue seen.add(anon_id) result.append(anon_id) return result def alias_pair_marker(anon_id: str, email: str) -> str: return sha256(f"{anon_id}\0{email}".encode("utf-8")).hexdigest() def is_aliased(config: Dict[str, Any], anon_id: str, email: str) -> bool: telemetry = config.get("telemetry") if not isinstance(telemetry, dict): return False aliased_pairs = telemetry.get("aliased_pairs") if not isinstance(aliased_pairs, list): return False return alias_pair_marker(anon_id, email) in aliased_pairs def mark_aliased(path: Path, config: Dict[str, Any], anon_id: str, email: str) -> None: telemetry = config.get("telemetry") if not isinstance(telemetry, dict): telemetry = {} aliased_pairs = telemetry.get("aliased_pairs") if not isinstance(aliased_pairs, list): aliased_pairs = [] marker = alias_pair_marker(anon_id, email) if marker not in aliased_pairs: aliased_pairs.append(marker) telemetry["aliased_pairs"] = aliased_pairs config["telemetry"] = telemetry write_config(path, config) def normalize_email(email: str) -> str: normalized = email.strip().lower() if not EMAIL_RE.match(normalized): raise MigrationError(f"Invalid email address: {email!r}") return normalized def parse_error_body(raw: bytes, fallback: str) -> str: text = raw.decode("utf-8", errors="replace").strip() if not text: return fallback try: parsed = json.loads(text) if isinstance(parsed, dict): for key in ("error", "detail", "message"): value = parsed.get(key) if value: return str(value) except Exception: pass return text def request_json( method: str, url: str, *, payload: Optional[Dict[str, Any]] = None, headers: Optional[Dict[str, str]] = None, timeout: float = 30.0, ) -> Dict[str, Any]: request_headers = {"Accept": "application/json"} if headers: request_headers.update(headers) data = None if payload is not None: data = json.dumps(payload).encode("utf-8") request_headers["Content-Type"] = "application/json" req = Request(url, data=data, headers=request_headers, method=method) try: with urlopen(req, timeout=timeout) as resp: raw = resp.read() except HTTPError as exc: detail = parse_error_body(exc.read(), exc.reason or f"HTTP {exc.code}") raise HTTPStatusError(exc.code, detail) from exc except URLError as exc: raise MigrationError(f"Network request failed for {url}: {exc.reason}. Check your connection and rerun the command.") from exc if not raw: return {} try: parsed = json.loads(raw.decode("utf-8")) except json.JSONDecodeError as exc: raise MigrationError(f"Expected JSON response from {url}") from exc return parsed if isinstance(parsed, dict) else {} def post_json_best_effort(url: str, payload: Dict[str, Any], timeout: float = 5.0) -> bool: data = json.dumps(payload).encode("utf-8") req = Request(url, data=data, headers={"Content-Type": "application/json"}, method="POST") try: with urlopen(req, timeout=timeout) as resp: resp.read() return 200 <= resp.status < 300 except Exception: return False def telemetry_enabled() -> bool: raw = os.environ.get("MEM0_TELEMETRY", "true") return raw.lower() in ("true", "1", "yes") def telemetry_url() -> str: return os.environ.get("MEM0_MIGRATE_TELEMETRY_URL", POSTHOG_CAPTURE_URL) def base_event_properties(anon_ids: Dict[str, Any], email: Optional[str] = None) -> Dict[str, Any]: properties: Dict[str, Any] = { "client_source": "python", "client_version": SCRIPT_VERSION, "migration_phase": "migration", "local_anonymous_id": primary_anon_id(anon_ids), "oss_anonymous_id": anon_ids.get("oss"), "cli_anonymous_id": anon_ids.get("cli"), "python_version": sys.version, "os": sys.platform, "os_version": platform.version(), "os_release": platform.release(), "machine": platform.machine(), "timestamp": utc_now(), "$lib": "posthog-python", } if email: properties["authenticated_email"] = email return properties def capture_migration_event( event_name: str, anon_ids: Dict[str, Any], *, email: Optional[str] = None, additional: Optional[Dict[str, Any]] = None, ) -> bool: if not telemetry_enabled(): return False distinct_id = email or primary_anon_id(anon_ids) properties = base_event_properties(anon_ids, email) if additional: properties.update(additional) payload = { "api_key": POSTHOG_API_KEY, "distinct_id": distinct_id, "event": event_name, "properties": properties, } return post_json_best_effort(telemetry_url(), payload) def capture_identify(anon_id: str, email: str) -> bool: if not telemetry_enabled() or not anon_id or not email or anon_id == email: return False payload = { "api_key": POSTHOG_API_KEY, "distinct_id": email, "event": "$identify", "properties": { "$anon_distinct_id": anon_id, "client_source": "python", "client_version": SCRIPT_VERSION, "$lib": "posthog-python", }, } return post_json_best_effort(telemetry_url(), payload) def stitch_identities(config_path_value: Path, config: Dict[str, Any], anon_ids: Dict[str, Any], email: str) -> None: for anon_id in unique_anon_ids(anon_ids): if anon_id == email or is_aliased(config, anon_id, email): continue if capture_identify(anon_id, email): try: mark_aliased(config_path_value, config, anon_id, email) except Exception: pass def auth_headers(language: str = "python") -> Dict[str, str]: return { "X-Mem0-Source": "migration", "X-Mem0-Client-Language": language, "X-Mem0-Client-Version": SCRIPT_VERSION, } def platform_headers(api_key: str, language: str = "python") -> Dict[str, str]: return { "Authorization": f"Token {api_key}", "X-Mem0-Source": "migration", "X-Mem0-Client-Language": language, "X-Mem0-Client-Version": SCRIPT_VERSION, } def api_base_url(args: argparse.Namespace, config: Dict[str, Any]) -> str: platform_config = config.get("platform") if isinstance(config.get("platform"), dict) else {} value = args.base_url or os.environ.get("MEM0_BASE_URL") or platform_config.get("base_url") or DEFAULT_BASE_URL return str(value).rstrip("/") def configured_api_key(args: argparse.Namespace, config: Dict[str, Any]) -> Tuple[Optional[str], str]: if args.api_key: return args.api_key, "flag" env_key = os.environ.get("MEM0_API_KEY") if env_key: return env_key, "env" platform_config = config.get("platform") if isinstance(config.get("platform"), dict) else {} file_key = platform_config.get("api_key") if isinstance(file_key, str) and file_key: return file_key, "config" return None, "" def validate_api_key(api_key: str, base_url: str) -> str: try: result = request_json( "GET", f"{base_url}/v1/ping/", headers={"Authorization": f"Token {api_key}"}, timeout=5.0, ) except HTTPStatusError as exc: if exc.status_code == 401: raise AuthError("Invalid or expired API key.") from exc raise email = result.get("user_email") if not isinstance(email, str) or not email: raise MigrationError("Platform authenticated the API key but did not return an account email.") return normalize_email(email) def prompt_line(prompt: str) -> str: try: with open("/dev/tty", "r", encoding="utf-8") as tty_in, open("/dev/tty", "w", encoding="utf-8") as tty_out: tty_out.write(prompt) tty_out.flush() line = tty_in.readline() except OSError as exc: raise MigrationError( "No interactive terminal is available. Re-run with --email and --code, or use --yes with a valid API key." ) from exc if line == "": raise MigrationError("No input received from terminal.") return line.strip() def prompt_line_default(prompt: str, default: str) -> str: answer = prompt_line(f"{prompt} [{default}]: ") return answer or default def prompt_secret(prompt: str) -> str: ensure_interactive_terminal() try: with open("/dev/tty", "w", encoding="utf-8") as tty_out: return getpass.getpass(prompt, stream=tty_out).strip() except OSError as exc: raise MigrationError( "No interactive terminal is available. Re-run with the required flags or environment variables." ) from exc def has_interactive_terminal() -> bool: try: with open("/dev/tty", "r", encoding="utf-8"), open("/dev/tty", "w", encoding="utf-8"): return True except OSError: return False def ensure_interactive_terminal() -> None: try: with open("/dev/tty", "r", encoding="utf-8"), open("/dev/tty", "w", encoding="utf-8"): return except OSError as exc: raise MigrationError( "No interactive terminal is available. Re-run with --email and --code, or use --yes with a valid API key." ) from exc def prompt_yes_no(prompt: str, *, default: bool = True) -> bool: suffix = " [Y/n]: " if default else " [y/N]: " answer = prompt_line(prompt + suffix).strip().lower() if not answer: return default return answer in ("y", "yes") def request_email_code(email: str, base_url: str) -> None: try: request_json( "POST", f"{base_url}/api/v1/auth/email_code/", payload={"email": email}, headers=auth_headers(), ) except HTTPStatusError as exc: if exc.status_code == 429: raise MigrationError("Too many attempts. Try again in a few minutes.") from exc raise MigrationError(f"Failed to send verification code: {exc.detail}") from exc def verify_email_code(email: str, code: str, base_url: str) -> str: try: result = request_json( "POST", f"{base_url}/api/v1/auth/email_code/verify/", payload={"email": email, "code": code.strip()}, headers=auth_headers(), ) except HTTPStatusError as exc: if exc.status_code == 429: raise MigrationError("Too many attempts. Try again in a few minutes.") from exc raise MigrationError(f"Verification failed: {exc.detail}") from exc api_key = result.get("api_key") if not isinstance(api_key, str) or not api_key: raise MigrationError("Auth succeeded but no API key was returned. Contact support.") return api_key def email_code_auth(args: argparse.Namespace, base_url: str) -> Tuple[str, str, str]: email = normalize_email(args.email) if args.email else normalize_email(prompt_line("Email: ")) code = args.code if not code: ensure_interactive_terminal() request_email_code(email, base_url) print_info("Verification code sent. Check your email.") code = prompt_line("Verification code: ") if not code: raise MigrationError("Verification code is required.") api_key = verify_email_code(email, code, base_url) resolved_email = validate_api_key(api_key, base_url) return api_key, resolved_email, "email_code" def existing_session_auth(args: argparse.Namespace, api_key: str, key_source: str, base_url: str) -> Optional[Tuple[str, str, str]]: try: email = validate_api_key(api_key, base_url) except AuthError: if key_source in ("flag", "env"): raise print_info("Stored Mem0 Platform API key is invalid or expired. Continuing with email login.") return None except MigrationError: if key_source in ("flag", "env"): raise print_info("Could not validate stored Mem0 Platform session. Continuing with email login.") return None if args.email: return None if not args.yes: print_info("") print_info("Found an existing Mem0 Platform session.") print_info("") print_info(f"Account: {email}") print_info("This migration will copy your local OSS memories into this Platform account.") print_info("") if not prompt_yes_no(f"Continue with {email}?", default=True): return None return api_key, email, "existing_api_key" def resolve_auth(args: argparse.Namespace, config: Dict[str, Any], base_url: str) -> Tuple[str, str, str]: if args.api_key and args.email: raise MigrationError("Cannot use both --api-key and --email.") if args.code and not args.email: raise MigrationError("--code requires --email.") api_key, key_source = configured_api_key(args, config) if api_key: result = existing_session_auth(args, api_key, key_source, base_url) if result: return result return email_code_auth(args, base_url) def default_export_path() -> Path: timestamp = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ") return mem0_dir() / "migrations" / f"mem0-qdrant-export-{timestamp}.json" def strip_env_assignment(value: str, name: str) -> str: prefix = f"{name}=" return value[len(prefix):] if value.startswith(prefix) else value def qdrant_url(args: argparse.Namespace) -> str: value = args.qdrant_url env_value = os.environ.get("QDRANT_URL") if not value and env_value: if has_interactive_terminal(): normalized_env_value = strip_env_assignment(env_value.strip(), "QDRANT_URL") print_info("") print_info("Found QDRANT_URL in your environment:") print_info(normalized_env_value) if prompt_yes_no("Use this Qdrant URL?", default=True): value = env_value else: value = env_value if not value: if not has_interactive_terminal(): raise MigrationError("Hosted Qdrant export requires --qdrant-url or QDRANT_URL.") print_info("") print_info("Python OSS hosted Qdrant export setup") print_info("Enter the Qdrant Cloud details used by your OSS Memory configuration.") value = prompt_line("Qdrant URL: ") value = strip_env_assignment(value.strip(), "QDRANT_URL") if not value: raise MigrationError("Qdrant URL is required.") return value.rstrip("/") def qdrant_api_key(args: argparse.Namespace) -> str: value = args.qdrant_api_key env_value = os.environ.get("QDRANT_API_KEY") if not value and env_value: if has_interactive_terminal(): print_info("") print_info("Found QDRANT_API_KEY in your environment.") if prompt_yes_no("Use this Qdrant API key?", default=True): value = env_value else: value = env_value if not value: if not has_interactive_terminal(): raise MigrationError("Hosted Qdrant export requires --qdrant-api-key or QDRANT_API_KEY.") value = prompt_secret("Qdrant API key: ") value = strip_env_assignment(value.strip(), "QDRANT_API_KEY") if not value: raise MigrationError("Qdrant API key is required.") return value def qdrant_collection(args: argparse.Namespace) -> str: value = args.qdrant_collection or os.environ.get("QDRANT_COLLECTION") if not value: if has_interactive_terminal(): value = prompt_line_default("Qdrant collection", DEFAULT_QDRANT_COLLECTION) else: value = DEFAULT_QDRANT_COLLECTION value = value.strip() if not value: raise MigrationError("Qdrant collection name cannot be empty.") return value def qdrant_headers(api_key: str) -> Dict[str, str]: return {"api-key": api_key} def qdrant_request_json( method: str, url: str, *, api_key: str, payload: Optional[Dict[str, Any]] = None, ) -> Dict[str, Any]: try: return request_json(method, url, payload=payload, headers=qdrant_headers(api_key), timeout=30.0) except HTTPStatusError as exc: if exc.status_code in (401, 403): raise MigrationError("Qdrant authentication failed. Check your QDRANT_API_KEY.") from exc if exc.status_code == 404: raise MigrationError(f"Qdrant resource not found: {url}") from exc raise MigrationError(f"Qdrant request failed: {exc.detail}") from exc def preflight_qdrant_collection(base_url: str, api_key: str, collection_name: str) -> None: qdrant_request_json( "GET", f"{base_url}/collections/{collection_name}", api_key=api_key, ) def qdrant_filter(args: argparse.Namespace) -> Optional[Dict[str, Any]]: filters = [] for key, value in (("user_id", args.user_id), ("agent_id", args.agent_id), ("run_id", args.run_id)): if value: filters.append({"key": key, "match": {"value": value}}) if not filters: if args.all: return None if not has_interactive_terminal(): raise MigrationError("Export requires --user-id, --agent-id, --run-id, or --all.") print_info("") print_info("Choose which OSS memories to export.") print_info("Enter a user_id to export one user, or press Enter to export the full collection.") user_id = prompt_line("User ID to export (press Enter for full collection): ") if user_id: args.user_id = user_id filters.append({"key": "user_id", "match": {"value": user_id}}) else: args.all = True return None return {"must": filters} def scroll_qdrant_points( base_url: str, api_key: str, collection_name: str, scroll_filter: Optional[Dict[str, Any]], page_size: int, ) -> List[Dict[str, Any]]: points: List[Dict[str, Any]] = [] offset = None while True: payload: Dict[str, Any] = { "limit": page_size, "with_payload": True, "with_vector": False, } if scroll_filter is not None: payload["filter"] = scroll_filter if offset is not None: payload["offset"] = offset response = qdrant_request_json( "POST", f"{base_url}/collections/{collection_name}/points/scroll", api_key=api_key, payload=payload, ) result = response.get("result") if not isinstance(result, dict): raise MigrationError("Qdrant scroll response did not include a result object.") page_points = result.get("points") if not isinstance(page_points, list): raise MigrationError("Qdrant scroll response did not include a points list.") points.extend(point for point in page_points if isinstance(point, dict)) offset = result.get("next_page_offset") if offset is None: break return points def normalize_qdrant_point(point: Dict[str, Any]) -> Dict[str, Any]: payload = point.get("payload") if not isinstance(payload, dict): payload = {} promoted_payload_keys = ("user_id", "agent_id", "run_id", "actor_id", "role") core_and_promoted_keys = { "data", "hash", "created_at", "updated_at", "id", "text_lemmatized", "attributed_to", *promoted_payload_keys, } record: Dict[str, Any] = { "id": str(point.get("id")), "memory": payload.get("data", ""), "hash": payload.get("hash"), "created_at": payload.get("created_at"), "updated_at": payload.get("updated_at"), "source": "python_oss_qdrant", "source_payload": payload, } for key in promoted_payload_keys: if key in payload: record[key] = payload[key] metadata = {key: value for key, value in payload.items() if key not in core_and_promoted_keys} if metadata: record["metadata"] = metadata return record def write_export_file(output_path: Path, artifact: Dict[str, Any]) -> None: output_path.parent.mkdir(parents=True, exist_ok=True) output_path.write_text(json.dumps(artifact, indent=2) + "\n", encoding="utf-8") os.chmod(output_path, stat.S_IRUSR | stat.S_IWUSR) def export_qdrant_memories(args: argparse.Namespace, anon_ids: Dict[str, Any]) -> Tuple[Path, int]: base_url = qdrant_url(args) api_key = qdrant_api_key(args) collection_name = qdrant_collection(args) output_path = Path(args.output).expanduser() if args.output else default_export_path() page_size = args.qdrant_page_size if page_size < 1: raise MigrationError("--qdrant-page-size must be greater than 0.") scroll_filter = qdrant_filter(args) preflight_qdrant_collection(base_url, api_key, collection_name) points = scroll_qdrant_points(base_url, api_key, collection_name, scroll_filter, page_size) records = [normalize_qdrant_point(point) for point in points] artifact = { "version": 1, "kind": "mem0_oss_qdrant_export", "exported_at": utc_now(), "source": { "sdk": "python", "vector_store": "qdrant", "storage": "hosted", "collection": collection_name, "url": base_url, "filters": { "user_id": args.user_id, "agent_id": args.agent_id, "run_id": args.run_id, "all": args.all, }, }, "local_anonymous_id": primary_anon_id(anon_ids), "anonymous_ids": { "oss": anon_ids.get("oss"), "cli": anon_ids.get("cli"), }, "record_count": len(records), "records": records, } write_export_file(output_path, artifact) return output_path, len(records) def platform_request_json( method: str, url: str, *, api_key: str, payload: Optional[Dict[str, Any]] = None, ) -> Dict[str, Any]: try: return request_json(method, url, payload=payload, headers=platform_headers(api_key), timeout=30.0) except HTTPStatusError as exc: if exc.status_code in (401, 403): raise AuthError("Platform authentication failed. Check your MEM0_API_KEY or rerun email login.") from exc raise MigrationError(f"Platform request failed: {exc.detail}") from exc def import_input_path(args: argparse.Namespace) -> Path: if not args.input: raise MigrationError("--input is required for --import-only.") return Path(args.input).expanduser() def read_import_records(input_path: Path) -> Tuple[Dict[str, Any], List[Dict[str, Any]]]: try: parsed = json.loads(input_path.read_text(encoding="utf-8")) except FileNotFoundError as exc: raise MigrationError(f"Import file not found: {input_path}") from exc except json.JSONDecodeError as exc: raise MigrationError(f"Import file is not valid JSON: {input_path}") from exc if isinstance(parsed, list): records = parsed artifact: Dict[str, Any] = {} elif isinstance(parsed, dict): artifact = parsed raw_records = parsed.get("records") if not isinstance(raw_records, list): raise MigrationError("Import artifact must contain a records array.") records = raw_records else: raise MigrationError("Import file must be a JSON object or array.") return artifact, [record for record in records if isinstance(record, dict)] def migration_import_key(source: Dict[str, Any], record: Dict[str, Any]) -> str: raw = ":".join( [ str(source.get("sdk") or "unknown"), str(source.get("vector_store") or "unknown"), str(source.get("collection") or "unknown"), str(record.get("id") or ""), ] ) return sha256(raw.encode("utf-8")).hexdigest() def parse_timestamp(value: Any) -> Optional[int]: if not isinstance(value, str) or not value: return None try: return int(datetime.fromisoformat(value.replace("Z", "+00:00")).timestamp()) except ValueError: return None def build_import_metadata(source: Dict[str, Any], record: Dict[str, Any]) -> Dict[str, Any]: existing_metadata = record.get("metadata") if isinstance(record.get("metadata"), dict) else {} metadata = dict(existing_metadata) metadata.update( { "mem0_migration_source": record.get("source") or "python_oss_qdrant", "mem0_migration_collection": source.get("collection"), "mem0_migration_local_id": record.get("id"), "mem0_migration_local_hash": record.get("hash"), "mem0_migration_import_key": migration_import_key(source, record), } ) return {key: value for key, value in metadata.items() if value is not None} def build_import_payload(source: Dict[str, Any], record: Dict[str, Any]) -> Dict[str, Any]: memory = record.get("memory") if not isinstance(memory, str) or not memory.strip(): raise MigrationError("record is missing memory text") payload: Dict[str, Any] = { "messages": [{"role": "user", "content": memory}], "metadata": build_import_metadata(source, record), "infer": False, "source": "migration", } for key in ("user_id", "agent_id", "run_id"): value = record.get(key) if value: payload[key] = value timestamp = parse_timestamp(record.get("created_at")) if timestamp is not None: payload["timestamp"] = timestamp if not any(payload.get(key) for key in ("user_id", "agent_id", "run_id")): raise MigrationError("record must have at least one of user_id, agent_id, or run_id") return payload def payload_scope(payload: Dict[str, Any]) -> Tuple[str, str]: for key in ("user_id", "agent_id", "run_id"): value = payload.get(key) if value: return key, str(value) raise MigrationError("import payload must have at least one of user_id, agent_id, or run_id") def platform_get_all_scope(base_url: str, api_key: str, scope_field: str, scope_value: str) -> List[Dict[str, Any]]: memories: List[Dict[str, Any]] = [] page = 1 while True: response = platform_request_json( "POST", f"{base_url}/v3/memories/?page={page}&page_size={DEFAULT_PLATFORM_PAGE_SIZE}", api_key=api_key, payload={"filters": {scope_field: scope_value}, "source": "migration"}, ) page_results = response.get("results") if not isinstance(page_results, list): raise MigrationError("Platform get_all response did not include a results list.") memories.extend(memory for memory in page_results if isinstance(memory, dict)) if not response.get("next"): return memories page += 1 def existing_migration_memories( base_url: str, api_key: str, payloads: List[Dict[str, Any]], ) -> Dict[str, Dict[str, Any]]: scopes: List[Tuple[str, str]] = [] seen_scopes: Set[Tuple[str, str]] = set() for payload in payloads: scope = payload_scope(payload) if scope not in seen_scopes: seen_scopes.add(scope) scopes.append(scope) existing: Dict[str, Dict[str, Any]] = {} for scope_field, scope_value in scopes: print_info(f"Checking existing Platform memories for {scope_field}={scope_value!r}...") for memory in platform_get_all_scope(base_url, api_key, scope_field, scope_value): metadata = memory.get("metadata") if not isinstance(metadata, dict): continue key = metadata.get("mem0_migration_import_key") if isinstance(key, str) and key: existing[key] = memory return existing def review_file_path(input_path: Path) -> Path: timestamp = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ") return input_path.with_name(f"{input_path.stem}-import-review-{timestamp}.json") def write_import_review_file(input_path: Path, review_records: List[Dict[str, Any]], summary: Dict[str, Any]) -> Optional[Path]: if not review_records: return None path = review_file_path(input_path) payload = { "version": 1, "kind": "mem0_platform_import_review", "created_at": utc_now(), "summary": summary, "records": review_records, } path.write_text(json.dumps(payload, indent=2) + "\n", encoding="utf-8") os.chmod(path, stat.S_IRUSR | stat.S_IWUSR) return path def import_platform_memories(input_path: Path, api_key: str, base_url: str) -> Dict[str, Any]: artifact, records = read_import_records(input_path) source = artifact.get("source") if isinstance(artifact.get("source"), dict) else {} payloads: List[Tuple[Dict[str, Any], Dict[str, Any]]] = [] review_records: List[Dict[str, Any]] = [] invalid = 0 for record in records: try: payloads.append((record, build_import_payload(source, record))) except MigrationError as exc: invalid += 1 review_records.append({"status": "invalid", "error": str(exc), "record": record}) existing = existing_migration_memories(base_url, api_key, [payload for _record, payload in payloads]) if payloads else {} imported = 0 skipped_existing = 0 changed_existing = 0 failed = 0 statuses: Counter[str] = Counter() for record, payload in payloads: key = payload["metadata"]["mem0_migration_import_key"] existing_memory = existing.get(key) if existing_memory: existing_metadata = existing_memory.get("metadata") if isinstance(existing_memory.get("metadata"), dict) else {} existing_hash = existing_metadata.get("mem0_migration_local_hash") local_hash = payload["metadata"].get("mem0_migration_local_hash") if existing_hash == local_hash: skipped_existing += 1 else: changed_existing += 1 review_records.append( { "status": "changed_existing", "platform_memory_id": existing_memory.get("id"), "existing_hash": existing_hash, "local_hash": local_hash, "record": record, } ) continue try: response = platform_request_json("POST", f"{base_url}/v3/memories/add/", api_key=api_key, payload=payload) imported += 1 statuses[str(response.get("status", "unknown"))] += 1 except MigrationError as exc: failed += 1 review_records.append({"status": "failed", "error": str(exc), "record": record}) summary = { "input_records": len(records), "valid_records": len(payloads), "invalid": invalid, "imported": imported, "skipped_existing": skipped_existing, "changed_existing": changed_existing, "failed": failed, "response_statuses": dict(statuses), } review_path = write_import_review_file(input_path, review_records, summary) summary["review_path"] = str(review_path) if review_path else None return summary def print_import_summary(summary: Dict[str, Any]) -> None: print_info(f"Imported: {summary['imported']}") print_info(f"Skipped existing identical: {summary['skipped_existing']}") print_info(f"Changed existing: {summary['changed_existing']}") print_info(f"Invalid: {summary['invalid']}") print_info(f"Failed: {summary['failed']}") if summary.get("review_path"): print_info(f"Review file: {summary['review_path']}") def parse_args(argv: List[str]) -> argparse.Namespace: parser = argparse.ArgumentParser( prog="oss-to-platform-migrate.sh", description="Migrate Python OSS hosted-Qdrant memories to a Mem0 Platform account.", ) parser.add_argument("--auth-only", action="store_true", help="Run only auth/account resolution.") parser.add_argument("--export-only", action="store_true", help="Run only Python OSS hosted-Qdrant JSON export.") parser.add_argument("--import-only", action="store_true", help="Run only Platform import from a migration JSON file.") parser.add_argument("--email", help="Email address for email-code authentication.") parser.add_argument("--code", help="Verification code for non-interactive email-code authentication.") parser.add_argument("--api-key", help="Platform API key to use for this run only.") parser.add_argument("--base-url", help=f"Mem0 Platform API base URL. Defaults to {DEFAULT_BASE_URL}.") parser.add_argument("--yes", action="store_true", help="Accept an existing valid Platform session without prompting.") parser.add_argument("--qdrant-url", help="Hosted Qdrant URL for Python OSS memory export.") parser.add_argument("--qdrant-api-key", help="Hosted Qdrant API key for this run only.") parser.add_argument("--qdrant-collection", help=f"Qdrant collection name. Defaults to {DEFAULT_QDRANT_COLLECTION}.") parser.add_argument("--qdrant-page-size", type=int, default=DEFAULT_EXPORT_PAGE_SIZE, help="Qdrant scroll page size.") parser.add_argument("--user-id", help="Export memories matching this user_id.") parser.add_argument("--agent-id", help="Export memories matching this agent_id.") parser.add_argument("--run-id", help="Export memories matching this run_id.") parser.add_argument("--all", action="store_true", help="Export the full Qdrant collection without a scope filter.") parser.add_argument("--output", help="Path for the migration JSON export file.") parser.add_argument("--input", help="Path to a migration JSON file to import into Platform.") return parser.parse_args(argv) def main(argv: List[str]) -> int: args = parse_args(argv) exclusive_modes = [args.auth_only, args.export_only, args.import_only] if sum(1 for enabled in exclusive_modes if enabled) > 1: print_error("Use only one of --auth-only, --export-only, or --import-only.") return 1 path = config_path() config = load_config(path) oss_anon_id = ensure_oss_anon_id(path, config) anon_ids = read_anon_ids(config, oss_anon_id) base_url = api_base_url(args, config) print_info("Mem0 OSS to Platform migration") if args.export_only: print_info("Export phase") elif args.import_only: print_info("Import phase") else: print_info("Auth phase") print_info("Supported environments: macOS, Linux, and Windows via WSL/Git Bash with bash and python3.") if args.auth_only: print_info("No memories are imported during this auth step.") elif args.export_only: print_info("No memories are imported to Platform during this step.") else: print_info("Memories will be imported to your authenticated Mem0 Platform account.") print_info("") capture_migration_event("oss.migrate.started", anon_ids, additional={"base_url": base_url}) try: email = None api_key = None output_path = None export_count = 0 import_summary: Optional[Dict[str, Any]] = None if args.import_only: input_path = import_input_path(args) api_key, key_source = configured_api_key(args, config) if not api_key: raise MigrationError("--import-only requires --api-key, MEM0_API_KEY, or a stored Platform API key.") try: email = validate_api_key(api_key, base_url) except MigrationError: if key_source in ("flag", "env"): raise email = None if email: capture_migration_event( "oss.migrate.authenticated", anon_ids, email=email, additional={"auth_method": f"{key_source}_api_key", "base_url": base_url}, ) stitch_identities(path, config, anon_ids, email) print_info("Phase 1/1: Import memories into Mem0 Platform") import_summary = import_platform_memories(input_path, api_key, base_url) elif not args.export_only: phase_label = "Phase 1/1" if args.auth_only else "Phase 1/3" print_info(f"{phase_label}: Authenticate with Mem0 Platform") api_key, email, auth_method = resolve_auth(args, config, base_url) capture_migration_event( "oss.migrate.authenticated", anon_ids, email=email, additional={"auth_method": auth_method, "base_url": base_url}, ) stitch_identities(path, config, anon_ids, email) print_info(f"Authenticated as {email}") print_info("") if not args.auth_only: if args.export_only: print_info("Phase 1/1: Export Python OSS memories from hosted Qdrant") output_path, export_count = export_qdrant_memories(args, anon_ids) elif not args.import_only: print_info("Phase 2/3: Export Python OSS memories from hosted Qdrant") output_path, export_count = export_qdrant_memories(args, anon_ids) print_info(f"Exported {export_count} memories to {output_path}") print_info("") print_info("Phase 3/3: Import memories into Mem0 Platform") import_summary = import_platform_memories(output_path, api_key or "", base_url) if import_summary is not None: capture_migration_event( "oss.migrate.completed", anon_ids, email=email, additional={ "base_url": base_url, "phase": "import", "exported": export_count, "imported": import_summary["imported"], "skipped_existing": import_summary["skipped_existing"], "changed_existing": import_summary["changed_existing"], "invalid": import_summary["invalid"], "failed": import_summary["failed"], }, ) except MigrationError as exc: capture_migration_event( "oss.migrate.failed", anon_ids, email=email, additional={"error": str(exc), "base_url": base_url}, ) print_error(str(exc)) return 1 if args.auth_only: print_info("Auth-only mode complete. Export and import were not run.") elif args.export_only: print_info(f"Exported {export_count} memories to {output_path}") print_info("Export-only mode complete. Platform import was not run.") elif args.import_only: print_import_summary(import_summary or {}) print_info("Import-only mode complete. Export was not run.") else: print_info("") print_info(f"Export file: {output_path}") print_info(f"Exported: {export_count}") print_import_summary(import_summary or {}) print_info("Migration complete.") return 0 if __name__ == "__main__": raise SystemExit(main(sys.argv[1:])) PY