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.
- {memorysync_cli-1.3.0 → memorysync_cli-1.4.1}/PKG-INFO +1 -1
- memorysync_cli-1.4.1/src/memorysync_cli/_version.py +1 -0
- {memorysync_cli-1.3.0 → memorysync_cli-1.4.1}/src/memorysync_cli/commands/memory.py +131 -1
- {memorysync_cli-1.3.0 → memorysync_cli-1.4.1}/src/memorysync_cli/http.py +97 -1
- {memorysync_cli-1.3.0 → memorysync_cli-1.4.1}/src/memorysync_cli/main.py +1 -0
- {memorysync_cli-1.3.0 → memorysync_cli-1.4.1}/src/memorysync_cli/registry.json +38 -3
- memorysync_cli-1.3.0/src/memorysync_cli/_version.py +0 -1
- {memorysync_cli-1.3.0 → memorysync_cli-1.4.1}/.gitignore +0 -0
- {memorysync_cli-1.3.0 → memorysync_cli-1.4.1}/LICENSE +0 -0
- {memorysync_cli-1.3.0 → memorysync_cli-1.4.1}/README.md +0 -0
- {memorysync_cli-1.3.0 → memorysync_cli-1.4.1}/pyproject.toml +0 -0
- {memorysync_cli-1.3.0 → memorysync_cli-1.4.1}/src/memorysync_cli/__init__.py +0 -0
- {memorysync_cli-1.3.0 → memorysync_cli-1.4.1}/src/memorysync_cli/__main__.py +0 -0
- {memorysync_cli-1.3.0 → memorysync_cli-1.4.1}/src/memorysync_cli/args.py +0 -0
- {memorysync_cli-1.3.0 → memorysync_cli-1.4.1}/src/memorysync_cli/commands/__init__.py +0 -0
- {memorysync_cli-1.3.0 → memorysync_cli-1.4.1}/src/memorysync_cli/commands/admin.py +0 -0
- {memorysync_cli-1.3.0 → memorysync_cli-1.4.1}/src/memorysync_cli/commands/init.py +0 -0
- {memorysync_cli-1.3.0 → memorysync_cli-1.4.1}/src/memorysync_cli/commands/source.py +0 -0
- {memorysync_cli-1.3.0 → memorysync_cli-1.4.1}/src/memorysync_cli/commands/tooling.py +0 -0
- {memorysync_cli-1.3.0 → memorysync_cli-1.4.1}/src/memorysync_cli/completions.py +0 -0
- {memorysync_cli-1.3.0 → memorysync_cli-1.4.1}/src/memorysync_cli/config.py +0 -0
- {memorysync_cli-1.3.0 → memorysync_cli-1.4.1}/src/memorysync_cli/credentials.py +0 -0
- {memorysync_cli-1.3.0 → memorysync_cli-1.4.1}/src/memorysync_cli/errors.py +0 -0
- {memorysync_cli-1.3.0 → memorysync_cli-1.4.1}/src/memorysync_cli/evaluation.py +0 -0
- {memorysync_cli-1.3.0 → memorysync_cli-1.4.1}/src/memorysync_cli/output.py +0 -0
- {memorysync_cli-1.3.0 → memorysync_cli-1.4.1}/src/memorysync_cli/registry.py +0 -0
|
@@ -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
|
|
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
|
|
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
|
|
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
|