memorysync-cli 1.3.0__tar.gz → 1.4.1__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.3.0 → memorysync_cli-1.4.1}/PKG-INFO +1 -1
  2. memorysync_cli-1.4.1/src/memorysync_cli/_version.py +1 -0
  3. {memorysync_cli-1.3.0 → memorysync_cli-1.4.1}/src/memorysync_cli/commands/memory.py +131 -1
  4. {memorysync_cli-1.3.0 → memorysync_cli-1.4.1}/src/memorysync_cli/http.py +97 -1
  5. {memorysync_cli-1.3.0 → memorysync_cli-1.4.1}/src/memorysync_cli/main.py +1 -0
  6. {memorysync_cli-1.3.0 → memorysync_cli-1.4.1}/src/memorysync_cli/registry.json +38 -3
  7. memorysync_cli-1.3.0/src/memorysync_cli/_version.py +0 -1
  8. {memorysync_cli-1.3.0 → memorysync_cli-1.4.1}/.gitignore +0 -0
  9. {memorysync_cli-1.3.0 → memorysync_cli-1.4.1}/LICENSE +0 -0
  10. {memorysync_cli-1.3.0 → memorysync_cli-1.4.1}/README.md +0 -0
  11. {memorysync_cli-1.3.0 → memorysync_cli-1.4.1}/pyproject.toml +0 -0
  12. {memorysync_cli-1.3.0 → memorysync_cli-1.4.1}/src/memorysync_cli/__init__.py +0 -0
  13. {memorysync_cli-1.3.0 → memorysync_cli-1.4.1}/src/memorysync_cli/__main__.py +0 -0
  14. {memorysync_cli-1.3.0 → memorysync_cli-1.4.1}/src/memorysync_cli/args.py +0 -0
  15. {memorysync_cli-1.3.0 → memorysync_cli-1.4.1}/src/memorysync_cli/commands/__init__.py +0 -0
  16. {memorysync_cli-1.3.0 → memorysync_cli-1.4.1}/src/memorysync_cli/commands/admin.py +0 -0
  17. {memorysync_cli-1.3.0 → memorysync_cli-1.4.1}/src/memorysync_cli/commands/init.py +0 -0
  18. {memorysync_cli-1.3.0 → memorysync_cli-1.4.1}/src/memorysync_cli/commands/source.py +0 -0
  19. {memorysync_cli-1.3.0 → memorysync_cli-1.4.1}/src/memorysync_cli/commands/tooling.py +0 -0
  20. {memorysync_cli-1.3.0 → memorysync_cli-1.4.1}/src/memorysync_cli/completions.py +0 -0
  21. {memorysync_cli-1.3.0 → memorysync_cli-1.4.1}/src/memorysync_cli/config.py +0 -0
  22. {memorysync_cli-1.3.0 → memorysync_cli-1.4.1}/src/memorysync_cli/credentials.py +0 -0
  23. {memorysync_cli-1.3.0 → memorysync_cli-1.4.1}/src/memorysync_cli/errors.py +0 -0
  24. {memorysync_cli-1.3.0 → memorysync_cli-1.4.1}/src/memorysync_cli/evaluation.py +0 -0
  25. {memorysync_cli-1.3.0 → memorysync_cli-1.4.1}/src/memorysync_cli/output.py +0 -0
  26. {memorysync_cli-1.3.0 → memorysync_cli-1.4.1}/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.3.0
3
+ Version: 1.4.1
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.4.1"
@@ -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()
@@ -627,6 +639,25 @@ def import_memories(ctx: dict) -> dict:
627
639
  "--resume.",
628
640
  )
629
641
 
642
+ # --async hands the whole file to the server and returns a job id. The
643
+ # synchronous path below is capped at 50 records per request by the route, so a
644
+ # large file is thousands of round trips held open by one command; this is one
645
+ # upload and a poll.
646
+ if flags.get("async"):
647
+ job = api.create_import(
648
+ filename=Path(source_path).name,
649
+ content=raw.encode("utf-8"),
650
+ end_user_id=user,
651
+ resume=bool(flags.get("resume")),
652
+ continue_on_error=bool(flags.get("continue_on_error")),
653
+ )
654
+ waited = bool(flags.get("wait"))
655
+ finished = _poll_import(api, job["id"]) if waited else job
656
+ return {
657
+ "data": {"file": source_path, "user": user, **finished},
658
+ "text": lambda: _render_import_job(finished, waited=waited),
659
+ }
660
+
630
661
  batch_size = _resolve_batch_size(flags)
631
662
  batches = [records[i : i + batch_size] for i in range(0, len(records), batch_size)]
632
663
  totals = {
@@ -654,7 +685,7 @@ def import_memories(ctx: dict) -> dict:
654
685
  item = {
655
686
  key: value
656
687
  for key, value in record.items()
657
- if key in {"text", "source", "metadata"}
688
+ if key in {"text", "source", "metadata", "event_type", "tags", "importance"}
658
689
  }
659
690
  if resume and record.get("ref"):
660
691
  item["client_ref"] = record["ref"]
@@ -738,6 +769,105 @@ def import_memories(ctx: dict) -> dict:
738
769
  # higher ``--batch-size`` is clamped rather than sent and refused.
739
770
  SERVER_BULK_MAX = 50
740
771
 
772
+ #: Terminal job states. Polling stops at any of these.
773
+ IMPORT_TERMINAL = frozenset({"completed", "failed", "cancelled"})
774
+
775
+
776
+ def _poll_import(api, job_id: str) -> dict:
777
+ """Poll a job until it finishes.
778
+
779
+ Backs off from one second to five. A tight loop on a job that takes an hour is
780
+ thousands of pointless requests, and a fixed long interval makes a ten-second
781
+ job feel broken.
782
+ """
783
+ import time
784
+
785
+ wait_s = 1.0
786
+ while True:
787
+ job = api.get_import(job_id) or {}
788
+ if job.get("status") in IMPORT_TERMINAL:
789
+ return job
790
+ time.sleep(wait_s)
791
+ wait_s = min(5.0, wait_s * 1.5)
792
+
793
+
794
+ def _render_import_job(job: dict, *, waited: bool = False) -> str:
795
+ """One job as a human-readable block.
796
+
797
+ Shared by ``import --async`` and ``import-status``.
798
+ """
799
+ status = job.get("status") or "unknown"
800
+ if status == "completed":
801
+ label = style.green("Completed")
802
+ elif status == "failed":
803
+ label = style.red("Failed")
804
+ elif status == "cancelled":
805
+ label = style.yellow("Cancelled")
806
+ else:
807
+ label = style.dim(status)
808
+
809
+ memories = job.get("memories_created") or 0
810
+ lines = [f"{label} {style.dim(job.get('id') or '')}"]
811
+ if job.get("total_rows"):
812
+ lines.append(
813
+ f"{style.dim('progress')} {job.get('processed_rows')}/{job.get('total_rows')}"
814
+ f" ({job.get('progress_percentage')}%)"
815
+ )
816
+ lines.append(
817
+ f"{style.dim('imported')} {job.get('created') or 0} record(s), "
818
+ f"{memories} memor{'y' if memories == 1 else 'ies'}"
819
+ )
820
+ if job.get("already_imported"):
821
+ lines.append(
822
+ style.dim(f" {job['already_imported']} already imported on an earlier run")
823
+ )
824
+ if job.get("skipped"):
825
+ lines.append(style.dim(f" {job['skipped']} skipped as duplicate or low value"))
826
+ if job.get("failed"):
827
+ lines.append(style.red(f" {job['failed']} failed"))
828
+ for problem in (job.get("invalid_rows") or [])[:5]:
829
+ lines.append(style.dim(f" {problem}"))
830
+ if job.get("error_message"):
831
+ lines.append(style.red(job["error_message"]))
832
+ if not waited and status not in IMPORT_TERMINAL:
833
+ lines.append(style.dim(f"Check again with: memorysync import-status {job.get('id')}"))
834
+ return "\n".join(lines)
835
+
836
+
837
+ def import_status(ctx: dict) -> dict:
838
+ """Poll, list, or cancel a background import."""
839
+ flags, positionals, api = ctx["flags"], ctx["positionals"], ctx["api"]
840
+ job_id = positionals[0] if positionals else None
841
+
842
+ if not job_id:
843
+ response = api.list_imports(flags.get("limit")) or {}
844
+ jobs = response.get("jobs") or []
845
+ return {
846
+ "data": {"jobs": jobs, "count": len(jobs)},
847
+ "text": lambda: (
848
+ style.dim("No imports yet.")
849
+ if not jobs
850
+ else "\n\n".join(_render_import_job(job) for job in jobs)
851
+ ),
852
+ }
853
+
854
+ if flags.get("cancel"):
855
+ job = api.cancel_import(job_id) or {}
856
+ return {
857
+ "data": job,
858
+ "text": lambda: "\n".join(
859
+ [
860
+ style.yellow("Cancellation requested."),
861
+ style.dim("Records already imported stay imported."),
862
+ _render_import_job(job),
863
+ ]
864
+ ),
865
+ }
866
+
867
+ waited = bool(flags.get("wait"))
868
+ job = _poll_import(api, job_id) if waited else (api.get_import(job_id) or {})
869
+ return {"data": job, "text": lambda: _render_import_job(job, waited=waited)}
870
+
741
871
 
742
872
  def _pick_ref(entry: dict, ref_field: str | None) -> str | None:
743
873
  """The record's own id, for ``--resume``.
@@ -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 body is not None:
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. 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]",
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
+ "usage": "memorysync import <file> [--user <id>] [--batch-size <n>] [--resume] [--async]",
377
377
  "flags": [
378
378
  {
379
379
  "name": "--user",
@@ -395,6 +395,14 @@
395
395
  "value": "name",
396
396
  "description": "Field holding each record’s own id, used by --resume. Default: id, then _id, then uuid."
397
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
+ },
398
406
  {
399
407
  "name": "--continue-on-error",
400
408
  "description": "Keep going after a failed batch."
@@ -406,11 +414,38 @@
406
414
  ],
407
415
  "examples": [
408
416
  "memorysync import memories.jsonl --user alice --dry-run",
409
- "memorysync import memories.jsonl --user alice --resume"
417
+ "memorysync import memories.jsonl --user alice --resume",
418
+ "memorysync import memories.jsonl --user alice --resume --async --wait"
410
419
  ],
411
420
  "requires_auth": true,
412
421
  "consumes_quota": "add_requests"
413
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
+ },
414
449
  {
415
450
  "name": "export",
416
451
  "summary": "Write a user's memories to a file or stdout.",
@@ -1 +0,0 @@
1
- __version__ = "1.3.0"
File without changes
File without changes