memorysync-cli 1.2.0__tar.gz → 1.3.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 (26) hide show
  1. {memorysync_cli-1.2.0 → memorysync_cli-1.3.0}/PKG-INFO +1 -1
  2. memorysync_cli-1.3.0/src/memorysync_cli/_version.py +1 -0
  3. {memorysync_cli-1.2.0 → memorysync_cli-1.3.0}/src/memorysync_cli/commands/memory.py +108 -6
  4. {memorysync_cli-1.2.0 → memorysync_cli-1.3.0}/src/memorysync_cli/registry.json +13 -3
  5. memorysync_cli-1.2.0/src/memorysync_cli/_version.py +0 -1
  6. {memorysync_cli-1.2.0 → memorysync_cli-1.3.0}/.gitignore +0 -0
  7. {memorysync_cli-1.2.0 → memorysync_cli-1.3.0}/LICENSE +0 -0
  8. {memorysync_cli-1.2.0 → memorysync_cli-1.3.0}/README.md +0 -0
  9. {memorysync_cli-1.2.0 → memorysync_cli-1.3.0}/pyproject.toml +0 -0
  10. {memorysync_cli-1.2.0 → memorysync_cli-1.3.0}/src/memorysync_cli/__init__.py +0 -0
  11. {memorysync_cli-1.2.0 → memorysync_cli-1.3.0}/src/memorysync_cli/__main__.py +0 -0
  12. {memorysync_cli-1.2.0 → memorysync_cli-1.3.0}/src/memorysync_cli/args.py +0 -0
  13. {memorysync_cli-1.2.0 → memorysync_cli-1.3.0}/src/memorysync_cli/commands/__init__.py +0 -0
  14. {memorysync_cli-1.2.0 → memorysync_cli-1.3.0}/src/memorysync_cli/commands/admin.py +0 -0
  15. {memorysync_cli-1.2.0 → memorysync_cli-1.3.0}/src/memorysync_cli/commands/init.py +0 -0
  16. {memorysync_cli-1.2.0 → memorysync_cli-1.3.0}/src/memorysync_cli/commands/source.py +0 -0
  17. {memorysync_cli-1.2.0 → memorysync_cli-1.3.0}/src/memorysync_cli/commands/tooling.py +0 -0
  18. {memorysync_cli-1.2.0 → memorysync_cli-1.3.0}/src/memorysync_cli/completions.py +0 -0
  19. {memorysync_cli-1.2.0 → memorysync_cli-1.3.0}/src/memorysync_cli/config.py +0 -0
  20. {memorysync_cli-1.2.0 → memorysync_cli-1.3.0}/src/memorysync_cli/credentials.py +0 -0
  21. {memorysync_cli-1.2.0 → memorysync_cli-1.3.0}/src/memorysync_cli/errors.py +0 -0
  22. {memorysync_cli-1.2.0 → memorysync_cli-1.3.0}/src/memorysync_cli/evaluation.py +0 -0
  23. {memorysync_cli-1.2.0 → memorysync_cli-1.3.0}/src/memorysync_cli/http.py +0 -0
  24. {memorysync_cli-1.2.0 → memorysync_cli-1.3.0}/src/memorysync_cli/main.py +0 -0
  25. {memorysync_cli-1.2.0 → memorysync_cli-1.3.0}/src/memorysync_cli/output.py +0 -0
  26. {memorysync_cli-1.2.0 → memorysync_cli-1.3.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.2.0
3
+ Version: 1.3.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.3.0"
@@ -505,11 +505,15 @@ def import_memories(ctx: dict) -> dict:
505
505
  try:
506
506
  raw = Path(source_path).read_text(encoding="utf-8")
507
507
  except OSError as error:
508
- raise usage_error(f"Could not read {source_path}: {error}") from None
508
+ raise usage_error(
509
+ f"Could not read {source_path}: {_describe_read_failure(error)}"
510
+ ) from None
509
511
 
510
512
  records: list[dict] = []
511
513
  errors: list[str] = []
512
514
 
515
+ ref_field = flags.get("ref_field") or None
516
+
513
517
  def _validate(number: int, entry: Any) -> None:
514
518
  """Record one candidate as either usable or an error, in line order."""
515
519
  if not isinstance(entry, dict):
@@ -526,6 +530,7 @@ def import_memories(ctx: dict) -> dict:
526
530
  "text": text.strip(),
527
531
  "source": entry.get("source") or "cli-import",
528
532
  "line": number,
533
+ "ref": _pick_ref(entry, ref_field),
529
534
  }
530
535
  if isinstance(entry.get("metadata"), dict):
531
536
  record["metadata"] = entry["metadata"]
@@ -562,12 +567,14 @@ def import_memories(ctx: dict) -> dict:
562
567
  if flags.get("dry_run"):
563
568
  preview = list(errors[:10])
564
569
  trailer = len(errors) - len(preview)
570
+ with_ref = sum(1 for record in records if record.get("ref"))
565
571
  return {
566
572
  "data": {
567
573
  "file": source_path,
568
574
  "valid": len(records),
569
575
  "invalid": len(errors),
570
576
  "errors": errors,
577
+ "identified": with_ref,
571
578
  },
572
579
  "text": lambda: "\n".join(
573
580
  line
@@ -575,6 +582,10 @@ def import_memories(ctx: dict) -> dict:
575
582
  style.yellow("Dry run. Nothing was sent."),
576
583
  f"{style.dim('valid ')} {len(records)}",
577
584
  f"{style.dim('invalid ')} {len(errors)}",
585
+ # Shown always, not only under --resume, because knowing the
586
+ # file has usable ids is what tells someone --resume is
587
+ # available to them.
588
+ f"{style.dim('with id ')} {with_ref}",
578
589
  *[style.dim(f" {message}") for message in preview],
579
590
  style.dim(f" ... and {trailer} more") if trailer > 0 else "",
580
591
  style.yellow(
@@ -597,6 +608,25 @@ def import_memories(ctx: dict) -> dict:
597
608
  if not records:
598
609
  raise usage_error(f"{source_path} contained no records.")
599
610
 
611
+ # --resume is only honest if every record has a stable identity. Falling back
612
+ # to the line number would break the moment the file is re-exported in a
613
+ # different order, and falling back to a hash of the text would make two
614
+ # genuinely identical records collide, so the second would be reported as
615
+ # already stored and silently dropped. Refusing names the problem instead.
616
+ if flags.get("resume"):
617
+ missing = [record for record in records if not record.get("ref")]
618
+ if missing:
619
+ shown = ", ".join(f"line {record['line']}" for record in missing[:5])
620
+ looked_for = f'"{ref_field}"' if ref_field else "id, _id, uuid"
621
+ extra = "" if ref_field else " and metadata.mem0_id"
622
+ raise usage_error(
623
+ f"--resume needs an id on every record, and {len(missing)} "
624
+ f"{'has' if len(missing) == 1 else 'have'} none.",
625
+ f"First: {shown}. Looked for {looked_for}{extra}. "
626
+ "Pass --ref-field <name> to use a different field, or run without "
627
+ "--resume.",
628
+ )
629
+
600
630
  batch_size = _resolve_batch_size(flags)
601
631
  batches = [records[i : i + batch_size] for i in range(0, len(records), batch_size)]
602
632
  totals = {
@@ -604,23 +634,31 @@ def import_memories(ctx: dict) -> dict:
604
634
  "imported": 0,
605
635
  "memory_ids": [],
606
636
  "skipped": 0,
637
+ # Rows the server had already stored under the same identifier. Counted
638
+ # apart from ``skipped`` because they mean something different to whoever
639
+ # is resuming: nothing was lost, this row is simply already in.
640
+ "already_imported": 0,
607
641
  "failed": 0,
608
642
  "unreported": 0,
609
643
  "failures": [],
610
644
  "batches": len(batches),
611
645
  "batch_size": batch_size,
646
+ "resume": bool(flags.get("resume")),
612
647
  }
613
648
 
649
+ resume = bool(flags.get("resume"))
614
650
  for batch in batches:
615
651
  before = totals["failed"]
616
- items = [
617
- {
652
+ items = []
653
+ for record in batch:
654
+ item = {
618
655
  key: value
619
656
  for key, value in record.items()
620
657
  if key in {"text", "source", "metadata"}
621
658
  }
622
- for record in batch
623
- ]
659
+ if resume and record.get("ref"):
660
+ item["client_ref"] = record["ref"]
661
+ items.append(item)
624
662
  try:
625
663
  response = api.bulk_add_memories({"items": items}) or {}
626
664
  except Exception as error: # noqa: BLE001 — reported, not raised
@@ -651,6 +689,11 @@ def import_memories(ctx: dict) -> dict:
651
689
  if status == "created":
652
690
  totals["imported"] += 1
653
691
  totals["memory_ids"].extend(item.get("memory_ids") or [])
692
+ elif status == "skipped" and item.get("reason") == "already_ingested":
693
+ totals["already_imported"] += 1
694
+ # The ids the first run produced, so a resumed import still ends
695
+ # up knowing every memory its file corresponds to.
696
+ totals["memory_ids"].extend(item.get("memory_ids") or [])
654
697
  elif status == "skipped":
655
698
  totals["skipped"] += 1
656
699
  else:
@@ -666,6 +709,11 @@ def import_memories(ctx: dict) -> dict:
666
709
  line
667
710
  for line in [
668
711
  f"{style.green('Imported')} {totals['imported']} of {totals['total']}",
712
+ style.dim(
713
+ f" {totals['already_imported']} already imported on an earlier run"
714
+ )
715
+ if totals["already_imported"]
716
+ else "",
669
717
  style.dim(f" {totals['skipped']} skipped as duplicate or low value")
670
718
  if totals["skipped"]
671
719
  else "",
@@ -691,6 +739,57 @@ def import_memories(ctx: dict) -> dict:
691
739
  SERVER_BULK_MAX = 50
692
740
 
693
741
 
742
+ def _pick_ref(entry: dict, ref_field: str | None) -> str | None:
743
+ """The record's own id, for ``--resume``.
744
+
745
+ Checked in the order an export is likely to carry one. ``metadata.mem0_id``
746
+ is included because the Mem0 migration guide tells people to put it there, so
747
+ a file built by following our own documentation resumes without extra flags.
748
+ Numbers are accepted and stringified; a caller-supplied id is frequently an
749
+ integer and refusing it would be pedantry.
750
+ """
751
+ metadata = entry.get("metadata") if isinstance(entry.get("metadata"), dict) else {}
752
+ if ref_field:
753
+ candidates = [entry.get(ref_field), metadata.get(ref_field)]
754
+ else:
755
+ candidates = [
756
+ entry.get("id"),
757
+ entry.get("_id"),
758
+ entry.get("uuid"),
759
+ metadata.get("mem0_id"),
760
+ ]
761
+ for value in candidates:
762
+ if isinstance(value, str) and value.strip():
763
+ return value.strip()
764
+ # bool is an int subclass and is never a usable identifier.
765
+ if isinstance(value, int) and not isinstance(value, bool):
766
+ return str(value)
767
+ if isinstance(value, float) and value == int(value):
768
+ return str(int(value))
769
+ return None
770
+
771
+
772
+ def _describe_read_failure(error: OSError) -> str:
773
+ """Why a file could not be read, worded identically to the Node CLI.
774
+
775
+ ``str(error)`` cannot be used directly: Python says
776
+ ``[Errno 2] No such file or directory: 'relative'`` and Node says
777
+ ``ENOENT: no such file or directory, open 'C:\\abs\\path'`` — different
778
+ wording, different casing, and Node leaks an absolute path the user did not
779
+ type. The two packages are published as interchangeable, so the common cases
780
+ are named here and the rest fall back to a sentence neither runtime supplies.
781
+ """
782
+ import errno
783
+
784
+ if isinstance(error, IsADirectoryError) or error.errno == errno.EISDIR:
785
+ return "that is a directory, not a file."
786
+ if isinstance(error, PermissionError) or error.errno in {errno.EACCES, errno.EPERM}:
787
+ return "permission denied."
788
+ if isinstance(error, FileNotFoundError) or error.errno == errno.ENOENT:
789
+ return "no such file or directory."
790
+ return "the file could not be opened."
791
+
792
+
694
793
  def _resolve_batch_size(flags: dict) -> int:
695
794
  """Effective batch size for this run.
696
795
 
@@ -708,9 +807,12 @@ def _resolve_batch_size(flags: dict) -> int:
708
807
  except (TypeError, ValueError):
709
808
  value = 0
710
809
  if value < 1:
810
+ # Quoted by hand rather than with ``!r``. ``repr`` gives single quotes and
811
+ # the Node CLI's idiom gives double ones, and the two are published as
812
+ # interchangeable.
711
813
  raise usage_error(
712
814
  "--batch-size must be a whole number of at least 1.",
713
- f"Got {requested!r}. The server accepts at most "
815
+ f'Got "{requested}". The server accepts at most '
714
816
  f"{SERVER_BULK_MAX} records per request.",
715
817
  )
716
818
  return min(value, SERVER_BULK_MAX)
@@ -372,8 +372,8 @@
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 its own user and metadata. Validates the whole file before sending anything, and reports per-row errors with line numbers rather than failing on the first bad row.",
376
- "usage": "memorysync import <file> [--user <id>] [--batch-size <n>]",
375
+ "description": "Each record needs a text field, and may carry its own user and metadata. 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.",
376
+ "usage": "memorysync import <file> [--user <id>] [--batch-size <n>] [--resume]",
377
377
  "flags": [
378
378
  {
379
379
  "name": "--user",
@@ -386,6 +386,15 @@
386
386
  "value": "n",
387
387
  "description": "Records per request. Default 50."
388
388
  },
389
+ {
390
+ "name": "--resume",
391
+ "description": "Send a per-record identifier so re-running the same file skips rows already stored."
392
+ },
393
+ {
394
+ "name": "--ref-field",
395
+ "value": "name",
396
+ "description": "Field holding each record’s own id, used by --resume. Default: id, then _id, then uuid."
397
+ },
389
398
  {
390
399
  "name": "--continue-on-error",
391
400
  "description": "Keep going after a failed batch."
@@ -396,7 +405,8 @@
396
405
  }
397
406
  ],
398
407
  "examples": [
399
- "memorysync import memories.jsonl --user alice --dry-run"
408
+ "memorysync import memories.jsonl --user alice --dry-run",
409
+ "memorysync import memories.jsonl --user alice --resume"
400
410
  ],
401
411
  "requires_auth": true,
402
412
  "consumes_quota": "add_requests"
@@ -1 +0,0 @@
1
- __version__ = "1.2.0"
File without changes
File without changes