memorysync-cli 1.1.4__tar.gz → 1.2.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.1.4 → memorysync_cli-1.2.1}/PKG-INFO +1 -1
- memorysync_cli-1.2.1/src/memorysync_cli/_version.py +1 -0
- {memorysync_cli-1.1.4 → memorysync_cli-1.2.1}/src/memorysync_cli/commands/memory.py +203 -39
- {memorysync_cli-1.1.4 → memorysync_cli-1.2.1}/src/memorysync_cli/http.py +9 -0
- memorysync_cli-1.1.4/src/memorysync_cli/_version.py +0 -1
- {memorysync_cli-1.1.4 → memorysync_cli-1.2.1}/.gitignore +0 -0
- {memorysync_cli-1.1.4 → memorysync_cli-1.2.1}/LICENSE +0 -0
- {memorysync_cli-1.1.4 → memorysync_cli-1.2.1}/README.md +0 -0
- {memorysync_cli-1.1.4 → memorysync_cli-1.2.1}/pyproject.toml +0 -0
- {memorysync_cli-1.1.4 → memorysync_cli-1.2.1}/src/memorysync_cli/__init__.py +0 -0
- {memorysync_cli-1.1.4 → memorysync_cli-1.2.1}/src/memorysync_cli/__main__.py +0 -0
- {memorysync_cli-1.1.4 → memorysync_cli-1.2.1}/src/memorysync_cli/args.py +0 -0
- {memorysync_cli-1.1.4 → memorysync_cli-1.2.1}/src/memorysync_cli/commands/__init__.py +0 -0
- {memorysync_cli-1.1.4 → memorysync_cli-1.2.1}/src/memorysync_cli/commands/admin.py +0 -0
- {memorysync_cli-1.1.4 → memorysync_cli-1.2.1}/src/memorysync_cli/commands/init.py +0 -0
- {memorysync_cli-1.1.4 → memorysync_cli-1.2.1}/src/memorysync_cli/commands/source.py +0 -0
- {memorysync_cli-1.1.4 → memorysync_cli-1.2.1}/src/memorysync_cli/commands/tooling.py +0 -0
- {memorysync_cli-1.1.4 → memorysync_cli-1.2.1}/src/memorysync_cli/completions.py +0 -0
- {memorysync_cli-1.1.4 → memorysync_cli-1.2.1}/src/memorysync_cli/config.py +0 -0
- {memorysync_cli-1.1.4 → memorysync_cli-1.2.1}/src/memorysync_cli/credentials.py +0 -0
- {memorysync_cli-1.1.4 → memorysync_cli-1.2.1}/src/memorysync_cli/errors.py +0 -0
- {memorysync_cli-1.1.4 → memorysync_cli-1.2.1}/src/memorysync_cli/evaluation.py +0 -0
- {memorysync_cli-1.1.4 → memorysync_cli-1.2.1}/src/memorysync_cli/main.py +0 -0
- {memorysync_cli-1.1.4 → memorysync_cli-1.2.1}/src/memorysync_cli/output.py +0 -0
- {memorysync_cli-1.1.4 → memorysync_cli-1.2.1}/src/memorysync_cli/registry.json +0 -0
- {memorysync_cli-1.1.4 → memorysync_cli-1.2.1}/src/memorysync_cli/registry.py +0 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "1.2.1"
|
|
@@ -505,11 +505,34 @@ 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(
|
|
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
|
+
def _validate(number: int, entry: Any) -> None:
|
|
516
|
+
"""Record one candidate as either usable or an error, in line order."""
|
|
517
|
+
if not isinstance(entry, dict):
|
|
518
|
+
errors.append(f"line {number}: expected an object")
|
|
519
|
+
return
|
|
520
|
+
text = entry.get("text") or entry.get("memory") or entry.get("content")
|
|
521
|
+
# Has to be a non-blank string, not merely truthy. ``{"text": 123}``
|
|
522
|
+
# would otherwise be stringified into a memory here and refused by the
|
|
523
|
+
# Node CLI, and the two must agree on what the file means.
|
|
524
|
+
if not isinstance(text, str) or not text.strip():
|
|
525
|
+
errors.append(f"line {number}: no text, memory or content field")
|
|
526
|
+
return
|
|
527
|
+
record: dict[str, Any] = {
|
|
528
|
+
"text": text.strip(),
|
|
529
|
+
"source": entry.get("source") or "cli-import",
|
|
530
|
+
"line": number,
|
|
531
|
+
}
|
|
532
|
+
if isinstance(entry.get("metadata"), dict):
|
|
533
|
+
record["metadata"] = entry["metadata"]
|
|
534
|
+
records.append(record)
|
|
535
|
+
|
|
513
536
|
stripped = raw.strip()
|
|
514
537
|
if stripped.startswith("["):
|
|
515
538
|
try:
|
|
@@ -518,66 +541,207 @@ def import_memories(ctx: dict) -> dict:
|
|
|
518
541
|
raise usage_error(f"{source_path} is not valid JSON: {error}") from None
|
|
519
542
|
if not isinstance(parsed, list):
|
|
520
543
|
raise usage_error(f"{source_path} must contain a JSON array.")
|
|
521
|
-
|
|
544
|
+
for number, entry in enumerate(parsed, start=1):
|
|
545
|
+
_validate(number, entry)
|
|
522
546
|
else:
|
|
523
|
-
|
|
547
|
+
# One ordered pass. Collecting parse failures first and field failures
|
|
548
|
+
# afterwards reported a bad line 3 ahead of a bad line 2, which the Node
|
|
549
|
+
# CLI does not do and which reads as though the file were shuffled.
|
|
524
550
|
for number, line in enumerate(stripped.splitlines(), start=1):
|
|
525
551
|
if not line.strip():
|
|
526
552
|
continue
|
|
527
553
|
try:
|
|
528
|
-
|
|
554
|
+
entry = json.loads(line)
|
|
529
555
|
except ValueError as error:
|
|
530
556
|
errors.append(f"line {number}: {error}")
|
|
557
|
+
continue
|
|
558
|
+
_validate(number, entry)
|
|
531
559
|
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
560
|
+
# The dry run reports problems; it does not refuse because of them. Checking
|
|
561
|
+
# this before the error gate is the whole point of the flag: someone running
|
|
562
|
+
# --dry-run on a file they suspect is malformed wants the list of what is
|
|
563
|
+
# wrong, not a single error telling them to fix it first.
|
|
564
|
+
if flags.get("dry_run"):
|
|
565
|
+
preview = list(errors[:10])
|
|
566
|
+
trailer = len(errors) - len(preview)
|
|
567
|
+
return {
|
|
568
|
+
"data": {
|
|
569
|
+
"file": source_path,
|
|
570
|
+
"valid": len(records),
|
|
571
|
+
"invalid": len(errors),
|
|
572
|
+
"errors": errors,
|
|
573
|
+
},
|
|
574
|
+
"text": lambda: "\n".join(
|
|
575
|
+
line
|
|
576
|
+
for line in [
|
|
577
|
+
style.yellow("Dry run. Nothing was sent."),
|
|
578
|
+
f"{style.dim('valid ')} {len(records)}",
|
|
579
|
+
f"{style.dim('invalid ')} {len(errors)}",
|
|
580
|
+
*[style.dim(f" {message}") for message in preview],
|
|
581
|
+
style.dim(f" ... and {trailer} more") if trailer > 0 else "",
|
|
582
|
+
style.yellow(
|
|
583
|
+
"A real run would refuse this file. Pass --continue-on-error "
|
|
584
|
+
"to skip bad rows."
|
|
585
|
+
)
|
|
586
|
+
if errors and not flags.get("continue_on_error")
|
|
587
|
+
else "",
|
|
588
|
+
]
|
|
589
|
+
if line
|
|
590
|
+
),
|
|
591
|
+
}
|
|
544
592
|
|
|
545
|
-
if errors:
|
|
593
|
+
if errors and not flags.get("continue_on_error"):
|
|
546
594
|
raise usage_error(
|
|
547
|
-
f"{
|
|
548
|
-
"
|
|
595
|
+
f"{len(errors)} record(s) in {source_path} are not usable.",
|
|
596
|
+
f"First problem: {errors[0]}. Run again with --dry-run to see them all, "
|
|
597
|
+
"or --continue-on-error to skip bad rows.",
|
|
549
598
|
)
|
|
550
599
|
if not records:
|
|
551
600
|
raise usage_error(f"{source_path} contained no records.")
|
|
552
601
|
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
602
|
+
batch_size = _resolve_batch_size(flags)
|
|
603
|
+
batches = [records[i : i + batch_size] for i in range(0, len(records), batch_size)]
|
|
604
|
+
totals = {
|
|
605
|
+
"total": len(records),
|
|
606
|
+
"imported": 0,
|
|
607
|
+
"memory_ids": [],
|
|
608
|
+
"skipped": 0,
|
|
609
|
+
"failed": 0,
|
|
610
|
+
"unreported": 0,
|
|
611
|
+
"failures": [],
|
|
612
|
+
"batches": len(batches),
|
|
613
|
+
"batch_size": batch_size,
|
|
614
|
+
}
|
|
560
615
|
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
616
|
+
for batch in batches:
|
|
617
|
+
before = totals["failed"]
|
|
618
|
+
items = [
|
|
619
|
+
{
|
|
620
|
+
key: value
|
|
621
|
+
for key, value in record.items()
|
|
622
|
+
if key in {"text", "source", "metadata"}
|
|
623
|
+
}
|
|
624
|
+
for record in batch
|
|
625
|
+
]
|
|
626
|
+
try:
|
|
627
|
+
response = api.bulk_add_memories({"items": items}) or {}
|
|
628
|
+
except Exception as error: # noqa: BLE001 — reported, not raised
|
|
629
|
+
# The whole batch never reached the server, so every record in it is
|
|
630
|
+
# unaccounted for. Reported as a line range because that is what the
|
|
631
|
+
# operator needs to retry.
|
|
632
|
+
totals["failed"] += len(batch)
|
|
633
|
+
totals["failures"].append(
|
|
634
|
+
f"lines {batch[0]['line']}-{batch[-1]['line']}: {error}"
|
|
635
|
+
)
|
|
636
|
+
if not flags.get("continue_on_error"):
|
|
637
|
+
break
|
|
638
|
+
continue
|
|
639
|
+
|
|
640
|
+
per_item = response.get("results") if isinstance(response, dict) else None
|
|
641
|
+
if not isinstance(per_item, list):
|
|
642
|
+
# The server accepted the batch without per-item detail. Counted as
|
|
643
|
+
# unreported rather than imported: claiming these were stored would
|
|
644
|
+
# be a guess, and a wrong one whenever the batch was not written.
|
|
645
|
+
totals["unreported"] += len(batch)
|
|
646
|
+
continue
|
|
647
|
+
|
|
648
|
+
for item in per_item:
|
|
649
|
+
index = item.get("index")
|
|
650
|
+
record = batch[index] if isinstance(index, int) and 0 <= index < len(batch) else None
|
|
651
|
+
where = f"line {record['line']}" if record else f"item {index}"
|
|
652
|
+
status = item.get("status")
|
|
653
|
+
if status == "created":
|
|
654
|
+
totals["imported"] += 1
|
|
655
|
+
totals["memory_ids"].extend(item.get("memory_ids") or [])
|
|
656
|
+
elif status == "skipped":
|
|
657
|
+
totals["skipped"] += 1
|
|
658
|
+
else:
|
|
659
|
+
totals["failed"] += 1
|
|
660
|
+
totals["failures"].append(f"{where}: {item.get('reason') or 'rejected'}")
|
|
661
|
+
|
|
662
|
+
if totals["failed"] > before and not flags.get("continue_on_error"):
|
|
663
|
+
break
|
|
569
664
|
|
|
570
665
|
return {
|
|
571
|
-
"data": {"
|
|
666
|
+
"data": {"file": source_path, "user": user, **totals},
|
|
572
667
|
"text": lambda: "\n".join(
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
style.
|
|
668
|
+
line
|
|
669
|
+
for line in [
|
|
670
|
+
f"{style.green('Imported')} {totals['imported']} of {totals['total']}",
|
|
671
|
+
style.dim(f" {totals['skipped']} skipped as duplicate or low value")
|
|
672
|
+
if totals["skipped"]
|
|
673
|
+
else "",
|
|
674
|
+
style.dim(
|
|
675
|
+
f" {totals['unreported']} accepted without a per-record result"
|
|
676
|
+
)
|
|
677
|
+
if totals["unreported"]
|
|
678
|
+
else "",
|
|
679
|
+
style.red(f" {totals['failed']} failed") if totals["failed"] else "",
|
|
680
|
+
*[style.dim(f" {f}") for f in totals["failures"][:5]],
|
|
681
|
+
style.dim(f" ... and {len(totals['failures']) - 5} more")
|
|
682
|
+
if len(totals["failures"]) > 5
|
|
683
|
+
else "",
|
|
576
684
|
]
|
|
577
|
-
|
|
685
|
+
if line
|
|
686
|
+
),
|
|
578
687
|
}
|
|
579
688
|
|
|
580
689
|
|
|
690
|
+
# Records the server accepts in one ``/memory/bulk-add`` request. The route
|
|
691
|
+
# rejects a larger payload outright, so this is a ceiling and not a preference: a
|
|
692
|
+
# higher ``--batch-size`` is clamped rather than sent and refused.
|
|
693
|
+
SERVER_BULK_MAX = 50
|
|
694
|
+
|
|
695
|
+
|
|
696
|
+
def _describe_read_failure(error: OSError) -> str:
|
|
697
|
+
"""Why a file could not be read, worded identically to the Node CLI.
|
|
698
|
+
|
|
699
|
+
``str(error)`` cannot be used directly: Python says
|
|
700
|
+
``[Errno 2] No such file or directory: 'relative'`` and Node says
|
|
701
|
+
``ENOENT: no such file or directory, open 'C:\\abs\\path'`` — different
|
|
702
|
+
wording, different casing, and Node leaks an absolute path the user did not
|
|
703
|
+
type. The two packages are published as interchangeable, so the common cases
|
|
704
|
+
are named here and the rest fall back to a sentence neither runtime supplies.
|
|
705
|
+
"""
|
|
706
|
+
import errno
|
|
707
|
+
|
|
708
|
+
if isinstance(error, IsADirectoryError) or error.errno == errno.EISDIR:
|
|
709
|
+
return "that is a directory, not a file."
|
|
710
|
+
if isinstance(error, PermissionError) or error.errno in {errno.EACCES, errno.EPERM}:
|
|
711
|
+
return "permission denied."
|
|
712
|
+
if isinstance(error, FileNotFoundError) or error.errno == errno.ENOENT:
|
|
713
|
+
return "no such file or directory."
|
|
714
|
+
return "the file could not be opened."
|
|
715
|
+
|
|
716
|
+
|
|
717
|
+
def _resolve_batch_size(flags: dict) -> int:
|
|
718
|
+
"""Effective batch size for this run.
|
|
719
|
+
|
|
720
|
+
Defaults to the server maximum, which is what the flag documents. A value
|
|
721
|
+
above the maximum is clamped instead of rejected — batch size is a transport
|
|
722
|
+
detail, and failing a 10,000-row import because someone asked for 500 per
|
|
723
|
+
request would be pedantry. The effective value is reported back in the result
|
|
724
|
+
so the clamp is visible rather than silent.
|
|
725
|
+
"""
|
|
726
|
+
requested = flags.get("batch_size")
|
|
727
|
+
if requested is None:
|
|
728
|
+
return SERVER_BULK_MAX
|
|
729
|
+
try:
|
|
730
|
+
value = int(str(requested).strip())
|
|
731
|
+
except (TypeError, ValueError):
|
|
732
|
+
value = 0
|
|
733
|
+
if value < 1:
|
|
734
|
+
# Quoted by hand rather than with ``!r``. ``repr`` gives single quotes and
|
|
735
|
+
# the Node CLI's idiom gives double ones, and the two are published as
|
|
736
|
+
# interchangeable.
|
|
737
|
+
raise usage_error(
|
|
738
|
+
"--batch-size must be a whole number of at least 1.",
|
|
739
|
+
f'Got "{requested}". The server accepts at most '
|
|
740
|
+
f"{SERVER_BULK_MAX} records per request.",
|
|
741
|
+
)
|
|
742
|
+
return min(value, SERVER_BULK_MAX)
|
|
743
|
+
|
|
744
|
+
|
|
581
745
|
def export_memories(ctx: dict) -> dict:
|
|
582
746
|
user = _require_user(ctx)
|
|
583
747
|
flags, api = ctx["flags"], ctx["api"]
|
|
@@ -368,6 +368,15 @@ class ApiClient:
|
|
|
368
368
|
"""
|
|
369
369
|
return self.request("GET", "/evaluation/usage")
|
|
370
370
|
|
|
371
|
+
def bulk_add_memories(self, body: dict) -> Any:
|
|
372
|
+
"""Ingest many records in one request.
|
|
373
|
+
|
|
374
|
+
Answers ``207`` with a per-item outcome for each record, so a batch is
|
|
375
|
+
routinely part success. The caller has to read ``results`` rather than
|
|
376
|
+
the status code to know what happened to any given record.
|
|
377
|
+
"""
|
|
378
|
+
return self.request("POST", "/memory/bulk-add", body=body)
|
|
379
|
+
|
|
371
380
|
def add_memory(self, body: dict) -> Any:
|
|
372
381
|
return self.request("POST", "/memory/add", body=body)
|
|
373
382
|
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
__version__ = "1.1.4"
|
|
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
|
|
File without changes
|
|
File without changes
|