memorysync-cli 1.2.1__tar.gz → 1.4.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.2.1 → memorysync_cli-1.4.0}/PKG-INFO +1 -1
- memorysync_cli-1.4.0/src/memorysync_cli/_version.py +1 -0
- {memorysync_cli-1.2.1 → memorysync_cli-1.4.0}/src/memorysync_cli/commands/memory.py +198 -4
- {memorysync_cli-1.2.1 → memorysync_cli-1.4.0}/src/memorysync_cli/http.py +97 -1
- {memorysync_cli-1.2.1 → memorysync_cli-1.4.0}/src/memorysync_cli/main.py +1 -0
- {memorysync_cli-1.2.1 → memorysync_cli-1.4.0}/src/memorysync_cli/registry.json +48 -3
- memorysync_cli-1.2.1/src/memorysync_cli/_version.py +0 -1
- {memorysync_cli-1.2.1 → memorysync_cli-1.4.0}/.gitignore +0 -0
- {memorysync_cli-1.2.1 → memorysync_cli-1.4.0}/LICENSE +0 -0
- {memorysync_cli-1.2.1 → memorysync_cli-1.4.0}/README.md +0 -0
- {memorysync_cli-1.2.1 → memorysync_cli-1.4.0}/pyproject.toml +0 -0
- {memorysync_cli-1.2.1 → memorysync_cli-1.4.0}/src/memorysync_cli/__init__.py +0 -0
- {memorysync_cli-1.2.1 → memorysync_cli-1.4.0}/src/memorysync_cli/__main__.py +0 -0
- {memorysync_cli-1.2.1 → memorysync_cli-1.4.0}/src/memorysync_cli/args.py +0 -0
- {memorysync_cli-1.2.1 → memorysync_cli-1.4.0}/src/memorysync_cli/commands/__init__.py +0 -0
- {memorysync_cli-1.2.1 → memorysync_cli-1.4.0}/src/memorysync_cli/commands/admin.py +0 -0
- {memorysync_cli-1.2.1 → memorysync_cli-1.4.0}/src/memorysync_cli/commands/init.py +0 -0
- {memorysync_cli-1.2.1 → memorysync_cli-1.4.0}/src/memorysync_cli/commands/source.py +0 -0
- {memorysync_cli-1.2.1 → memorysync_cli-1.4.0}/src/memorysync_cli/commands/tooling.py +0 -0
- {memorysync_cli-1.2.1 → memorysync_cli-1.4.0}/src/memorysync_cli/completions.py +0 -0
- {memorysync_cli-1.2.1 → memorysync_cli-1.4.0}/src/memorysync_cli/config.py +0 -0
- {memorysync_cli-1.2.1 → memorysync_cli-1.4.0}/src/memorysync_cli/credentials.py +0 -0
- {memorysync_cli-1.2.1 → memorysync_cli-1.4.0}/src/memorysync_cli/errors.py +0 -0
- {memorysync_cli-1.2.1 → memorysync_cli-1.4.0}/src/memorysync_cli/evaluation.py +0 -0
- {memorysync_cli-1.2.1 → memorysync_cli-1.4.0}/src/memorysync_cli/output.py +0 -0
- {memorysync_cli-1.2.1 → memorysync_cli-1.4.0}/src/memorysync_cli/registry.py +0 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "1.4.0"
|
|
@@ -512,6 +512,8 @@ def import_memories(ctx: dict) -> dict:
|
|
|
512
512
|
records: list[dict] = []
|
|
513
513
|
errors: list[str] = []
|
|
514
514
|
|
|
515
|
+
ref_field = flags.get("ref_field") or None
|
|
516
|
+
|
|
515
517
|
def _validate(number: int, entry: Any) -> None:
|
|
516
518
|
"""Record one candidate as either usable or an error, in line order."""
|
|
517
519
|
if not isinstance(entry, dict):
|
|
@@ -528,6 +530,7 @@ def import_memories(ctx: dict) -> dict:
|
|
|
528
530
|
"text": text.strip(),
|
|
529
531
|
"source": entry.get("source") or "cli-import",
|
|
530
532
|
"line": number,
|
|
533
|
+
"ref": _pick_ref(entry, ref_field),
|
|
531
534
|
}
|
|
532
535
|
if isinstance(entry.get("metadata"), dict):
|
|
533
536
|
record["metadata"] = entry["metadata"]
|
|
@@ -564,12 +567,14 @@ def import_memories(ctx: dict) -> dict:
|
|
|
564
567
|
if flags.get("dry_run"):
|
|
565
568
|
preview = list(errors[:10])
|
|
566
569
|
trailer = len(errors) - len(preview)
|
|
570
|
+
with_ref = sum(1 for record in records if record.get("ref"))
|
|
567
571
|
return {
|
|
568
572
|
"data": {
|
|
569
573
|
"file": source_path,
|
|
570
574
|
"valid": len(records),
|
|
571
575
|
"invalid": len(errors),
|
|
572
576
|
"errors": errors,
|
|
577
|
+
"identified": with_ref,
|
|
573
578
|
},
|
|
574
579
|
"text": lambda: "\n".join(
|
|
575
580
|
line
|
|
@@ -577,6 +582,10 @@ def import_memories(ctx: dict) -> dict:
|
|
|
577
582
|
style.yellow("Dry run. Nothing was sent."),
|
|
578
583
|
f"{style.dim('valid ')} {len(records)}",
|
|
579
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}",
|
|
580
589
|
*[style.dim(f" {message}") for message in preview],
|
|
581
590
|
style.dim(f" ... and {trailer} more") if trailer > 0 else "",
|
|
582
591
|
style.yellow(
|
|
@@ -599,6 +608,44 @@ def import_memories(ctx: dict) -> dict:
|
|
|
599
608
|
if not records:
|
|
600
609
|
raise usage_error(f"{source_path} contained no records.")
|
|
601
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
|
+
|
|
630
|
+
# --async hands the whole file to the server and returns a job id. The
|
|
631
|
+
# synchronous path below is capped at 50 records per request by the route, so a
|
|
632
|
+
# large file is thousands of round trips held open by one command; this is one
|
|
633
|
+
# upload and a poll.
|
|
634
|
+
if flags.get("async"):
|
|
635
|
+
job = api.create_import(
|
|
636
|
+
filename=Path(source_path).name,
|
|
637
|
+
content=raw.encode("utf-8"),
|
|
638
|
+
end_user_id=user,
|
|
639
|
+
resume=bool(flags.get("resume")),
|
|
640
|
+
continue_on_error=bool(flags.get("continue_on_error")),
|
|
641
|
+
)
|
|
642
|
+
waited = bool(flags.get("wait"))
|
|
643
|
+
finished = _poll_import(api, job["id"]) if waited else job
|
|
644
|
+
return {
|
|
645
|
+
"data": {"file": source_path, "user": user, **finished},
|
|
646
|
+
"text": lambda: _render_import_job(finished, waited=waited),
|
|
647
|
+
}
|
|
648
|
+
|
|
602
649
|
batch_size = _resolve_batch_size(flags)
|
|
603
650
|
batches = [records[i : i + batch_size] for i in range(0, len(records), batch_size)]
|
|
604
651
|
totals = {
|
|
@@ -606,23 +653,31 @@ def import_memories(ctx: dict) -> dict:
|
|
|
606
653
|
"imported": 0,
|
|
607
654
|
"memory_ids": [],
|
|
608
655
|
"skipped": 0,
|
|
656
|
+
# Rows the server had already stored under the same identifier. Counted
|
|
657
|
+
# apart from ``skipped`` because they mean something different to whoever
|
|
658
|
+
# is resuming: nothing was lost, this row is simply already in.
|
|
659
|
+
"already_imported": 0,
|
|
609
660
|
"failed": 0,
|
|
610
661
|
"unreported": 0,
|
|
611
662
|
"failures": [],
|
|
612
663
|
"batches": len(batches),
|
|
613
664
|
"batch_size": batch_size,
|
|
665
|
+
"resume": bool(flags.get("resume")),
|
|
614
666
|
}
|
|
615
667
|
|
|
668
|
+
resume = bool(flags.get("resume"))
|
|
616
669
|
for batch in batches:
|
|
617
670
|
before = totals["failed"]
|
|
618
|
-
items = [
|
|
619
|
-
|
|
671
|
+
items = []
|
|
672
|
+
for record in batch:
|
|
673
|
+
item = {
|
|
620
674
|
key: value
|
|
621
675
|
for key, value in record.items()
|
|
622
676
|
if key in {"text", "source", "metadata"}
|
|
623
677
|
}
|
|
624
|
-
|
|
625
|
-
|
|
678
|
+
if resume and record.get("ref"):
|
|
679
|
+
item["client_ref"] = record["ref"]
|
|
680
|
+
items.append(item)
|
|
626
681
|
try:
|
|
627
682
|
response = api.bulk_add_memories({"items": items}) or {}
|
|
628
683
|
except Exception as error: # noqa: BLE001 — reported, not raised
|
|
@@ -653,6 +708,11 @@ def import_memories(ctx: dict) -> dict:
|
|
|
653
708
|
if status == "created":
|
|
654
709
|
totals["imported"] += 1
|
|
655
710
|
totals["memory_ids"].extend(item.get("memory_ids") or [])
|
|
711
|
+
elif status == "skipped" and item.get("reason") == "already_ingested":
|
|
712
|
+
totals["already_imported"] += 1
|
|
713
|
+
# The ids the first run produced, so a resumed import still ends
|
|
714
|
+
# up knowing every memory its file corresponds to.
|
|
715
|
+
totals["memory_ids"].extend(item.get("memory_ids") or [])
|
|
656
716
|
elif status == "skipped":
|
|
657
717
|
totals["skipped"] += 1
|
|
658
718
|
else:
|
|
@@ -668,6 +728,11 @@ def import_memories(ctx: dict) -> dict:
|
|
|
668
728
|
line
|
|
669
729
|
for line in [
|
|
670
730
|
f"{style.green('Imported')} {totals['imported']} of {totals['total']}",
|
|
731
|
+
style.dim(
|
|
732
|
+
f" {totals['already_imported']} already imported on an earlier run"
|
|
733
|
+
)
|
|
734
|
+
if totals["already_imported"]
|
|
735
|
+
else "",
|
|
671
736
|
style.dim(f" {totals['skipped']} skipped as duplicate or low value")
|
|
672
737
|
if totals["skipped"]
|
|
673
738
|
else "",
|
|
@@ -692,6 +757,135 @@ def import_memories(ctx: dict) -> dict:
|
|
|
692
757
|
# higher ``--batch-size`` is clamped rather than sent and refused.
|
|
693
758
|
SERVER_BULK_MAX = 50
|
|
694
759
|
|
|
760
|
+
#: Terminal job states. Polling stops at any of these.
|
|
761
|
+
IMPORT_TERMINAL = frozenset({"completed", "failed", "cancelled"})
|
|
762
|
+
|
|
763
|
+
|
|
764
|
+
def _poll_import(api, job_id: str) -> dict:
|
|
765
|
+
"""Poll a job until it finishes.
|
|
766
|
+
|
|
767
|
+
Backs off from one second to five. A tight loop on a job that takes an hour is
|
|
768
|
+
thousands of pointless requests, and a fixed long interval makes a ten-second
|
|
769
|
+
job feel broken.
|
|
770
|
+
"""
|
|
771
|
+
import time
|
|
772
|
+
|
|
773
|
+
wait_s = 1.0
|
|
774
|
+
while True:
|
|
775
|
+
job = api.get_import(job_id) or {}
|
|
776
|
+
if job.get("status") in IMPORT_TERMINAL:
|
|
777
|
+
return job
|
|
778
|
+
time.sleep(wait_s)
|
|
779
|
+
wait_s = min(5.0, wait_s * 1.5)
|
|
780
|
+
|
|
781
|
+
|
|
782
|
+
def _render_import_job(job: dict, *, waited: bool = False) -> str:
|
|
783
|
+
"""One job as a human-readable block.
|
|
784
|
+
|
|
785
|
+
Shared by ``import --async`` and ``import-status``.
|
|
786
|
+
"""
|
|
787
|
+
status = job.get("status") or "unknown"
|
|
788
|
+
if status == "completed":
|
|
789
|
+
label = style.green("Completed")
|
|
790
|
+
elif status == "failed":
|
|
791
|
+
label = style.red("Failed")
|
|
792
|
+
elif status == "cancelled":
|
|
793
|
+
label = style.yellow("Cancelled")
|
|
794
|
+
else:
|
|
795
|
+
label = style.dim(status)
|
|
796
|
+
|
|
797
|
+
memories = job.get("memories_created") or 0
|
|
798
|
+
lines = [f"{label} {style.dim(job.get('id') or '')}"]
|
|
799
|
+
if job.get("total_rows"):
|
|
800
|
+
lines.append(
|
|
801
|
+
f"{style.dim('progress')} {job.get('processed_rows')}/{job.get('total_rows')}"
|
|
802
|
+
f" ({job.get('progress_percentage')}%)"
|
|
803
|
+
)
|
|
804
|
+
lines.append(
|
|
805
|
+
f"{style.dim('imported')} {job.get('created') or 0} record(s), "
|
|
806
|
+
f"{memories} memor{'y' if memories == 1 else 'ies'}"
|
|
807
|
+
)
|
|
808
|
+
if job.get("already_imported"):
|
|
809
|
+
lines.append(
|
|
810
|
+
style.dim(f" {job['already_imported']} already imported on an earlier run")
|
|
811
|
+
)
|
|
812
|
+
if job.get("skipped"):
|
|
813
|
+
lines.append(style.dim(f" {job['skipped']} skipped as duplicate or low value"))
|
|
814
|
+
if job.get("failed"):
|
|
815
|
+
lines.append(style.red(f" {job['failed']} failed"))
|
|
816
|
+
for problem in (job.get("invalid_rows") or [])[:5]:
|
|
817
|
+
lines.append(style.dim(f" {problem}"))
|
|
818
|
+
if job.get("error_message"):
|
|
819
|
+
lines.append(style.red(job["error_message"]))
|
|
820
|
+
if not waited and status not in IMPORT_TERMINAL:
|
|
821
|
+
lines.append(style.dim(f"Check again with: memorysync import-status {job.get('id')}"))
|
|
822
|
+
return "\n".join(lines)
|
|
823
|
+
|
|
824
|
+
|
|
825
|
+
def import_status(ctx: dict) -> dict:
|
|
826
|
+
"""Poll, list, or cancel a background import."""
|
|
827
|
+
flags, positionals, api = ctx["flags"], ctx["positionals"], ctx["api"]
|
|
828
|
+
job_id = positionals[0] if positionals else None
|
|
829
|
+
|
|
830
|
+
if not job_id:
|
|
831
|
+
response = api.list_imports(flags.get("limit")) or {}
|
|
832
|
+
jobs = response.get("jobs") or []
|
|
833
|
+
return {
|
|
834
|
+
"data": {"jobs": jobs, "count": len(jobs)},
|
|
835
|
+
"text": lambda: (
|
|
836
|
+
style.dim("No imports yet.")
|
|
837
|
+
if not jobs
|
|
838
|
+
else "\n\n".join(_render_import_job(job) for job in jobs)
|
|
839
|
+
),
|
|
840
|
+
}
|
|
841
|
+
|
|
842
|
+
if flags.get("cancel"):
|
|
843
|
+
job = api.cancel_import(job_id) or {}
|
|
844
|
+
return {
|
|
845
|
+
"data": job,
|
|
846
|
+
"text": lambda: "\n".join(
|
|
847
|
+
[
|
|
848
|
+
style.yellow("Cancellation requested."),
|
|
849
|
+
style.dim("Records already imported stay imported."),
|
|
850
|
+
_render_import_job(job),
|
|
851
|
+
]
|
|
852
|
+
),
|
|
853
|
+
}
|
|
854
|
+
|
|
855
|
+
waited = bool(flags.get("wait"))
|
|
856
|
+
job = _poll_import(api, job_id) if waited else (api.get_import(job_id) or {})
|
|
857
|
+
return {"data": job, "text": lambda: _render_import_job(job, waited=waited)}
|
|
858
|
+
|
|
859
|
+
|
|
860
|
+
def _pick_ref(entry: dict, ref_field: str | None) -> str | None:
|
|
861
|
+
"""The record's own id, for ``--resume``.
|
|
862
|
+
|
|
863
|
+
Checked in the order an export is likely to carry one. ``metadata.mem0_id``
|
|
864
|
+
is included because the Mem0 migration guide tells people to put it there, so
|
|
865
|
+
a file built by following our own documentation resumes without extra flags.
|
|
866
|
+
Numbers are accepted and stringified; a caller-supplied id is frequently an
|
|
867
|
+
integer and refusing it would be pedantry.
|
|
868
|
+
"""
|
|
869
|
+
metadata = entry.get("metadata") if isinstance(entry.get("metadata"), dict) else {}
|
|
870
|
+
if ref_field:
|
|
871
|
+
candidates = [entry.get(ref_field), metadata.get(ref_field)]
|
|
872
|
+
else:
|
|
873
|
+
candidates = [
|
|
874
|
+
entry.get("id"),
|
|
875
|
+
entry.get("_id"),
|
|
876
|
+
entry.get("uuid"),
|
|
877
|
+
metadata.get("mem0_id"),
|
|
878
|
+
]
|
|
879
|
+
for value in candidates:
|
|
880
|
+
if isinstance(value, str) and value.strip():
|
|
881
|
+
return value.strip()
|
|
882
|
+
# bool is an int subclass and is never a usable identifier.
|
|
883
|
+
if isinstance(value, int) and not isinstance(value, bool):
|
|
884
|
+
return str(value)
|
|
885
|
+
if isinstance(value, float) and value == int(value):
|
|
886
|
+
return str(int(value))
|
|
887
|
+
return None
|
|
888
|
+
|
|
695
889
|
|
|
696
890
|
def _describe_read_failure(error: OSError) -> str:
|
|
697
891
|
"""Why a file could not be read, worded identically to the Node CLI.
|
|
@@ -26,6 +26,8 @@ import time
|
|
|
26
26
|
import urllib.error
|
|
27
27
|
import urllib.parse
|
|
28
28
|
import urllib.request
|
|
29
|
+
import uuid
|
|
30
|
+
from dataclasses import dataclass, field
|
|
29
31
|
from typing import Any
|
|
30
32
|
|
|
31
33
|
from ._version import __version__
|
|
@@ -42,6 +44,50 @@ from .errors import (
|
|
|
42
44
|
END_USER_HEADER = "X-End-User-ID"
|
|
43
45
|
|
|
44
46
|
|
|
47
|
+
@dataclass
|
|
48
|
+
class MultipartBody:
|
|
49
|
+
"""One file plus simple text fields, for an upload."""
|
|
50
|
+
|
|
51
|
+
filename: str
|
|
52
|
+
content: bytes
|
|
53
|
+
fields: dict[str, str] = field(default_factory=dict)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _encode_multipart(part: MultipartBody) -> tuple[bytes, str]:
|
|
57
|
+
"""Build a ``multipart/form-data`` body by hand.
|
|
58
|
+
|
|
59
|
+
Written out rather than pulled from a library because this package has zero
|
|
60
|
+
dependencies, and the Node CLI's built-in ``FormData`` produces the same wire
|
|
61
|
+
format, so the two uploads are interchangeable.
|
|
62
|
+
|
|
63
|
+
The boundary is random per request. A fixed one would break the moment a
|
|
64
|
+
payload happened to contain it.
|
|
65
|
+
"""
|
|
66
|
+
boundary = f"----MemorySync{uuid.uuid4().hex}"
|
|
67
|
+
lines: list[bytes] = []
|
|
68
|
+
|
|
69
|
+
for name, value in part.fields.items():
|
|
70
|
+
lines.append(f"--{boundary}\r\n".encode())
|
|
71
|
+
lines.append(
|
|
72
|
+
f'Content-Disposition: form-data; name="{name}"\r\n\r\n'.encode()
|
|
73
|
+
)
|
|
74
|
+
lines.append(f"{value}\r\n".encode())
|
|
75
|
+
|
|
76
|
+
# The filename is quoted and any quote inside it escaped, so a file called
|
|
77
|
+
# `my"file.jsonl` cannot break out of the header.
|
|
78
|
+
safe_name = part.filename.replace('"', '\\"')
|
|
79
|
+
lines.append(f"--{boundary}\r\n".encode())
|
|
80
|
+
lines.append(
|
|
81
|
+
f'Content-Disposition: form-data; name="file"; filename="{safe_name}"\r\n'.encode()
|
|
82
|
+
)
|
|
83
|
+
lines.append(b"Content-Type: application/octet-stream\r\n\r\n")
|
|
84
|
+
lines.append(part.content)
|
|
85
|
+
lines.append(b"\r\n")
|
|
86
|
+
lines.append(f"--{boundary}--\r\n".encode())
|
|
87
|
+
|
|
88
|
+
return b"".join(lines), f"multipart/form-data; boundary={boundary}"
|
|
89
|
+
|
|
90
|
+
|
|
45
91
|
class ApiClient:
|
|
46
92
|
"""A thin, synchronous client over the MemorySync API."""
|
|
47
93
|
|
|
@@ -100,6 +146,7 @@ class ApiClient:
|
|
|
100
146
|
body: Any = None,
|
|
101
147
|
query: dict[str, Any] | None = None,
|
|
102
148
|
timeout: int | None = None,
|
|
149
|
+
multipart: "MultipartBody | None" = None,
|
|
103
150
|
) -> Any:
|
|
104
151
|
url = self.base_url + path
|
|
105
152
|
if query:
|
|
@@ -113,7 +160,10 @@ class ApiClient:
|
|
|
113
160
|
|
|
114
161
|
payload = None
|
|
115
162
|
headers = self.headers()
|
|
116
|
-
if
|
|
163
|
+
if multipart is not None:
|
|
164
|
+
payload, content_type = _encode_multipart(multipart)
|
|
165
|
+
headers["Content-Type"] = content_type
|
|
166
|
+
elif body is not None:
|
|
117
167
|
payload = json.dumps(body).encode("utf-8")
|
|
118
168
|
headers["Content-Type"] = "application/json"
|
|
119
169
|
|
|
@@ -368,6 +418,52 @@ class ApiClient:
|
|
|
368
418
|
"""
|
|
369
419
|
return self.request("GET", "/evaluation/usage")
|
|
370
420
|
|
|
421
|
+
# ── Asynchronous bulk import ──────────────────────────────────────
|
|
422
|
+
|
|
423
|
+
def create_import(
|
|
424
|
+
self,
|
|
425
|
+
*,
|
|
426
|
+
filename: str,
|
|
427
|
+
content: bytes,
|
|
428
|
+
end_user_id: str | None,
|
|
429
|
+
resume: bool,
|
|
430
|
+
continue_on_error: bool,
|
|
431
|
+
) -> Any:
|
|
432
|
+
"""Upload a payload and get a job id back.
|
|
433
|
+
|
|
434
|
+
Sent as multipart rather than JSON because the payload can be tens of
|
|
435
|
+
megabytes and the server streams it out of storage rather than holding it.
|
|
436
|
+
"""
|
|
437
|
+
fields: dict[str, str] = {
|
|
438
|
+
"resume": "true" if resume else "false",
|
|
439
|
+
"continue_on_error": "true" if continue_on_error else "false",
|
|
440
|
+
}
|
|
441
|
+
if end_user_id:
|
|
442
|
+
fields["end_user_id"] = end_user_id
|
|
443
|
+
return self.request(
|
|
444
|
+
"POST",
|
|
445
|
+
"/imports",
|
|
446
|
+
multipart=MultipartBody(
|
|
447
|
+
fields=fields,
|
|
448
|
+
filename=filename,
|
|
449
|
+
content=content,
|
|
450
|
+
),
|
|
451
|
+
# An upload is not a 30-second request. The server validates the whole
|
|
452
|
+
# payload before answering.
|
|
453
|
+
timeout=300_000,
|
|
454
|
+
)
|
|
455
|
+
|
|
456
|
+
def get_import(self, job_id: str) -> Any:
|
|
457
|
+
return self.request("GET", f"/imports/{urllib.parse.quote(job_id, safe='')}")
|
|
458
|
+
|
|
459
|
+
def list_imports(self, limit: int | None = None) -> Any:
|
|
460
|
+
return self.request("GET", "/imports", query={"limit": limit})
|
|
461
|
+
|
|
462
|
+
def cancel_import(self, job_id: str) -> Any:
|
|
463
|
+
return self.request(
|
|
464
|
+
"POST", f"/imports/{urllib.parse.quote(job_id, safe='')}/cancel"
|
|
465
|
+
)
|
|
466
|
+
|
|
371
467
|
def bulk_add_memories(self, body: dict) -> Any:
|
|
372
468
|
"""Ingest many records in one request.
|
|
373
469
|
|
|
@@ -63,6 +63,7 @@ _HANDLERS: dict[str, Handler] = {
|
|
|
63
63
|
"update": memory.update,
|
|
64
64
|
"delete": memory.delete_memories,
|
|
65
65
|
"import": memory.import_memories,
|
|
66
|
+
"import-status": memory.import_status,
|
|
66
67
|
"export": memory.export_memories,
|
|
67
68
|
"quota": admin.quota,
|
|
68
69
|
"status": admin.status,
|
|
@@ -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. 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
|
+
"usage": "memorysync import <file> [--user <id>] [--batch-size <n>] [--resume] [--async]",
|
|
377
377
|
"flags": [
|
|
378
378
|
{
|
|
379
379
|
"name": "--user",
|
|
@@ -386,6 +386,23 @@
|
|
|
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
|
+
},
|
|
398
|
+
{
|
|
399
|
+
"name": "--async",
|
|
400
|
+
"description": "Upload the file and return a job id instead of waiting. Use for large files."
|
|
401
|
+
},
|
|
402
|
+
{
|
|
403
|
+
"name": "--wait",
|
|
404
|
+
"description": "With --async, poll until the job finishes."
|
|
405
|
+
},
|
|
389
406
|
{
|
|
390
407
|
"name": "--continue-on-error",
|
|
391
408
|
"description": "Keep going after a failed batch."
|
|
@@ -396,11 +413,39 @@
|
|
|
396
413
|
}
|
|
397
414
|
],
|
|
398
415
|
"examples": [
|
|
399
|
-
"memorysync import memories.jsonl --user alice --dry-run"
|
|
416
|
+
"memorysync import memories.jsonl --user alice --dry-run",
|
|
417
|
+
"memorysync import memories.jsonl --user alice --resume",
|
|
418
|
+
"memorysync import memories.jsonl --user alice --resume --async --wait"
|
|
400
419
|
],
|
|
401
420
|
"requires_auth": true,
|
|
402
421
|
"consumes_quota": "add_requests"
|
|
403
422
|
},
|
|
423
|
+
{
|
|
424
|
+
"name": "import-status",
|
|
425
|
+
"summary": "Check a background import, or list recent ones.",
|
|
426
|
+
"description": "With a job id, reports progress and per-outcome counts. Without one, lists recent imports newest first. Pass --wait to poll until the job finishes, and --cancel to ask the worker to stop between batches — records already imported stay imported.",
|
|
427
|
+
"usage": "memorysync import-status [<job-id>] [--wait] [--cancel]",
|
|
428
|
+
"flags": [
|
|
429
|
+
{
|
|
430
|
+
"name": "--wait",
|
|
431
|
+
"description": "Poll until the job reaches a final state."
|
|
432
|
+
},
|
|
433
|
+
{
|
|
434
|
+
"name": "--cancel",
|
|
435
|
+
"description": "Ask the worker to stop between batches."
|
|
436
|
+
},
|
|
437
|
+
{
|
|
438
|
+
"name": "--limit",
|
|
439
|
+
"value": "n",
|
|
440
|
+
"description": "How many recent jobs to list. Default 20."
|
|
441
|
+
}
|
|
442
|
+
],
|
|
443
|
+
"examples": [
|
|
444
|
+
"memorysync import-status",
|
|
445
|
+
"memorysync import-status 3f9a1c7e-0b2d-4a51-9e88-1c2d3e4f5a6b --wait"
|
|
446
|
+
],
|
|
447
|
+
"requires_auth": true
|
|
448
|
+
},
|
|
404
449
|
{
|
|
405
450
|
"name": "export",
|
|
406
451
|
"summary": "Write a user's memories to a file or stdout.",
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
__version__ = "1.2.1"
|
|
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
|