xmemcli 0.0.9__tar.gz → 0.0.10__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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: xmemcli
3
- Version: 0.0.9
3
+ Version: 0.0.10
4
4
  Summary: Command-line client for the xmemory HTTP API: PKCE auth, instances, read/write, schema and XMD tools.
5
5
  Keywords: api,cli,llm,memory,xmemory
6
6
  Requires-Python: <4,>=3.12
@@ -97,7 +97,7 @@ xmemcli version
97
97
  xmemcli auth login
98
98
  ```
99
99
 
100
- Version: **0.0.9**.
100
+ Version: **0.0.10**.
101
101
 
102
102
 
103
103
  ---
@@ -191,7 +191,7 @@ Fixed port **9636** ("xmem" on phone keypad) allows SSH/devcontainer forwarding;
191
191
 
192
192
  # `xmemcli` — capabilities reference
193
193
 
194
- **Version:** `0.0.9` (see `xmemcli version`)
194
+ **Version:** `0.0.10` (see `xmemcli version`)
195
195
 
196
196
  `xmemcli` is the command-line client for the **xmemory HTTP API**. It is **human-approved for key acquisition**: an AI-assisted IDE may run reads, writes, and inventory, and may initiate `auth login`, but a key is only issued after one explicit human approval — a click in the Console UI (PKCE handoff) or an approval of a sign-in email (`--email`). Quota limits change only in the **Console UI**. The full acquisition contract is in the auth-flow reference bundled with this package.
197
197
 
@@ -89,7 +89,7 @@ xmemcli version
89
89
  xmemcli auth login
90
90
  ```
91
91
 
92
- Version: **0.0.9**.
92
+ Version: **0.0.10**.
93
93
 
94
94
 
95
95
  ---
@@ -183,7 +183,7 @@ Fixed port **9636** ("xmem" on phone keypad) allows SSH/devcontainer forwarding;
183
183
 
184
184
  # `xmemcli` — capabilities reference
185
185
 
186
- **Version:** `0.0.9` (see `xmemcli version`)
186
+ **Version:** `0.0.10` (see `xmemcli version`)
187
187
 
188
188
  `xmemcli` is the command-line client for the **xmemory HTTP API**. It is **human-approved for key acquisition**: an AI-assisted IDE may run reads, writes, and inventory, and may initiate `auth login`, but a key is only issued after one explicit human approval — a click in the Console UI (PKCE handoff) or an approval of a sign-in email (`--email`). Quota limits change only in the **Console UI**. The full acquisition contract is in the auth-flow reference bundled with this package.
189
189
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "xmemcli"
3
- version = "0.0.9"
3
+ version = "0.0.10"
4
4
  description = "Command-line client for the xmemory HTTP API: PKCE auth, instances, read/write, schema and XMD tools."
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.12,<4"
@@ -4,5 +4,5 @@ requires-python = ">=3.12, <4"
4
4
 
5
5
  [[package]]
6
6
  name = "xmemcli"
7
- version = "0.0.9"
7
+ version = "0.0.10"
8
8
  source = { editable = "." }
@@ -1,3 +1,3 @@
1
1
  """Standalone xmemcli package (``xmemcli`` console script)."""
2
2
 
3
- __version__ = "0.0.9"
3
+ __version__ = "0.0.10"
@@ -94,6 +94,12 @@ _CONSUMER_HOOK_OUTPUT_LIMIT = 10_000
94
94
  #: just fits over the edge.
95
95
  MAX_PACK_CHARS = _CONSUMER_HOOK_OUTPUT_LIMIT - 500
96
96
 
97
+ #: Sections the server renders per pack (identity, owner, rules, schema, live state).
98
+ #: The footprint is a diagnostic, so a server sending more is not describing a pack this
99
+ #: client received; publishing them unbounded would let a small response build a large
100
+ #: document. Mirrors the renderer's section list rather than any transport limit.
101
+ _MAX_SECTIONS_PER_PACK = 5
102
+
97
103
 
98
104
  def _requestable_ids(autoload: tuple[Any, ...], listed: AbstractSet[str]) -> tuple[list[str], list[str]]:
99
105
  """Ids the server will accept, and the ones dropped with a reason.
@@ -456,9 +462,94 @@ def _curated_unavailable(entry: Any) -> dict[str, Any]:
456
462
  return curated
457
463
 
458
464
 
465
+ def _curated_pack(entry: Any) -> dict[str, Any]:
466
+ """One pack's footprint: what it cost, whether it was cut, and where the cost sits.
467
+
468
+ Sizes and identity only. The pack's own text is already in ``text``, and republishing
469
+ section bodies here would hand a caller a second copy of content that the clamp and
470
+ scrub below bound exactly once.
471
+
472
+ Section titles are the server's own headings rather than anything an owner wrote, but
473
+ they arrive over the wire like everything else here, so they are scrubbed and length-
474
+ bounded. Not the UTF-16 clamp the pack text also gets — a title is capped at 200 code
475
+ points, so it cannot reach the limit that clamp exists for. A count that is not a
476
+ number is published as ``None``: a caller doing arithmetic on it should see a gap, not
477
+ a zero it cannot distinguish from a section that genuinely cost nothing.
478
+ """
479
+ curated: dict[str, Any] = {
480
+ "instance_id": scrub(entry["instance_id"], limit=200) if isinstance(entry.get("instance_id"), str) else None,
481
+ "estimated_tokens": _count(entry.get("estimated_tokens")),
482
+ "truncated": entry["truncated"] if isinstance(entry.get("truncated"), bool) else None,
483
+ }
484
+ raw_sections = entry.get("sections")
485
+ # Filtered before the cap, not after: slicing first let junk consume the slots, so a
486
+ # response whose first five entries were nulls published none of the real ones.
487
+ sections = [s for s in raw_sections if isinstance(s, dict)] if isinstance(raw_sections, list) else []
488
+ # Bounded as well as filtered. The server renders at most five sections per pack, so
489
+ # anything beyond that is not a breakdown of anything, and an unbounded array on a
490
+ # payload whose text is hard-capped is a way for a small response to build a large
491
+ # document. Not the only one — ``unavailable`` above is still uncapped — but the cap
492
+ # is cheap where the shape is known.
493
+ curated["sections"] = [
494
+ {
495
+ "title": scrub(section["title"], limit=200) if isinstance(section.get("title"), str) else None,
496
+ "estimated_tokens": _count(section.get("estimated_tokens")),
497
+ }
498
+ for section in sections[:_MAX_SECTIONS_PER_PACK]
499
+ ]
500
+ return curated
501
+
502
+
503
+ def _curated_universal_rules(entry: Any) -> dict[str, Any] | None:
504
+ """The response-level rules block's footprint, or ``None`` when the server sent none.
505
+
506
+ Published for the same reason the per-pack sections are: this is the one part of the response
507
+ that belongs to no pack, so a caller summing ``packs`` to account for ``estimated_tokens``
508
+ comes up short by exactly it. It used to be inside every pack and is now charged once, which
509
+ is a change someone reading the breakdown to explain a budget needs to see.
510
+
511
+ Title and size only, like ``_curated_pack`` — the body is already in ``text``.
512
+ """
513
+ if not isinstance(entry, dict):
514
+ return None
515
+ return {
516
+ "title": scrub(entry["title"], limit=200) if isinstance(entry.get("title"), str) else None,
517
+ "estimated_tokens": _count(entry.get("estimated_tokens")),
518
+ }
519
+
520
+
521
+ def _count(value: Any) -> int | None:
522
+ """A published token count, or ``None`` when the server sent something that is not one.
523
+
524
+ ``bool`` is excluded explicitly because it is a subclass of ``int``: without that,
525
+ ``"estimated_tokens": true`` published ``true`` and a caller summing the sections
526
+ added one, which is the arithmetic the ``None`` exists to keep honest. Spelled the
527
+ same way in ``_pretty.py`` and ``xmem.py``; ``_binding.py`` reaches the same result
528
+ through the negated form. The idiom appears at several sites in ``cli/`` and each
529
+ checks something different afterwards (``> 0``, ``>= 0``, ``< 1``), which is why this
530
+ stays local rather than becoming a shared helper.
531
+ """
532
+ return value if isinstance(value, int) and not isinstance(value, bool) else None
533
+
534
+
459
535
  def _nothing_to_inject() -> dict[str, Any]:
460
- """The payload for "there is no context to add", used by every such path."""
461
- return {"text": "", "instance_ids": [], "unavailable": [], "estimated_tokens": 0}
536
+ """The payload for "there is no context to add", used by every such path.
537
+
538
+ ``max_tokens`` is ``None`` rather than absent: the success path always carries it,
539
+ and a caller told the shape is stable should not have to test for the key before
540
+ reading it on the one branch that omitted it.
541
+ """
542
+ return {
543
+ "text": "",
544
+ "instance_ids": [],
545
+ "unavailable": [],
546
+ "estimated_tokens": 0,
547
+ "max_tokens": None,
548
+ "packs": [],
549
+ # Present and null for the same reason ``max_tokens`` is: the success path always carries
550
+ # the key, and a caller told the shape is stable should not have to test for it.
551
+ "universal_rules": None,
552
+ }
462
553
 
463
554
 
464
555
  def _command_budget(args: argparse.Namespace) -> float:
@@ -743,6 +834,23 @@ def _context(args: argparse.Namespace, deadline: float) -> tuple[CliOutput, int]
743
834
  # `diff_plan`, anything an internal renderer attaches) cannot arrive here and be
744
835
  # published; it is dropped until someone decides it belongs.
745
836
  unavailable = [_curated_unavailable(entry) for entry in unavailable]
837
+ # Shape-checked before iteration for the same reason `items` and `unavailable` above
838
+ # are: `result.get("packs") or []` keeps whatever truthy value arrived, so
839
+ # `{"packs": 7}` raised `TypeError` out of the comprehension and the blanket guard
840
+ # turned a perfectly good, already-clamped pack into an empty payload — the whole
841
+ # session's context discarded because an optional diagnostic field had the wrong
842
+ # type. A dict or a string would not even raise: they iterate, and publish phantom
843
+ # entries. The footprint is a diagnostic, so a bad one costs itself and nothing else.
844
+ raw_packs = result.get("packs")
845
+ if raw_packs is not None and not isinstance(raw_packs, list):
846
+ m._note_cli_warning(args, "xmemory returned an unreadable context footprint; sizes omitted")
847
+ raw_packs = []
848
+ # Non-dict elements are skipped rather than curated into all-null entries: a
849
+ # phantom pack is the outcome the guard above warns about, one level down, and a
850
+ # caller cannot tell it from an instance that genuinely reported nothing. Capped at
851
+ # the number of instances the server will accept in one request, for the same reason
852
+ # the sections are capped.
853
+ packs = [_curated_pack(p) for p in [p for p in (raw_packs or []) if isinstance(p, dict)][:MAX_REQUESTED_INSTANCES]]
746
854
  delivered = [i for i in requested if i not in missing]
747
855
  if unavailable:
748
856
  m._note_cli_warning(
@@ -769,7 +877,30 @@ def _context(args: argparse.Namespace, deadline: float) -> tuple[CliOutput, int]
769
877
  # passes the first at 9,500 and measures 19,000 to the second, and Claude Code
770
878
  # writes it to a file and injects a preview instead of the context. Bounding the
771
879
  # unit the consumer actually counts is what the cap was for.
772
- text = clamp_utf16(scrub(text, limit=MAX_PACK_CHARS), limit=MAX_PACK_CHARS)
880
+ # Sanitised first, with a limit that cannot bite, so the two jobs `scrub` performs are
881
+ # separated: it strips Cc/Cf/Cs characters *and* clamps. Measuring the final text
882
+ # against the raw server text conflated them — a fully delivered pack carrying one
883
+ # zero-width joiner, soft hyphen, BOM or bidi mark came out shorter and was reported as
884
+ # cut, which is a false alarm aimed at exactly the reader who asked why their context
885
+ # shrank. Only a length cut is a length cut.
886
+ sanitised = scrub(text, limit=len(text))
887
+ injected = clamp_utf16(scrub(sanitised, limit=MAX_PACK_CHARS), limit=MAX_PACK_CHARS)
888
+ # The footprint below describes what the *server* rendered. When this clamp bites, part
889
+ # of that never reaches the model, and a breakdown still reporting the full sizes with
890
+ # `truncated: false` misleads precisely the reader it exists for — someone asking why
891
+ # their live state disappeared, being shown a section that advertises 4,900 tokens and
892
+ # arrived as nothing. So a client-side cut marks every pack too. The flag means "what
893
+ # you are reading is not the whole of it", whichever side did the cutting; the warning
894
+ # names which side, because the fixes are different — retier a binding, or lower
895
+ # `pack_max_tokens`.
896
+ if len(injected) < len(sanitised):
897
+ m._note_cli_warning(
898
+ args,
899
+ "the rendered context was longer than this client can inject and was cut; "
900
+ "the sizes reported describe what xmemory rendered, not what was injected",
901
+ )
902
+ packs = [{**pack, "truncated": True} for pack in packs]
903
+ text = injected
773
904
  return (
774
905
  CliOutput(
775
906
  OutputKind.AGENT_CONTEXT,
@@ -777,8 +908,22 @@ def _context(args: argparse.Namespace, deadline: float) -> tuple[CliOutput, int]
777
908
  "text": text,
778
909
  "instance_ids": delivered,
779
910
  "unavailable": unavailable,
780
- "estimated_tokens": result.get("estimated_tokens") or 0,
781
- "max_tokens": result.get("max_tokens"),
911
+ # Through the same guard as the parts: publishing `true` as a total while
912
+ # the sections it explains correctly read null is the inconsistency the
913
+ # breakdown exists to avoid.
914
+ "estimated_tokens": _count(result.get("estimated_tokens")) or 0,
915
+ "max_tokens": _count(result.get("max_tokens")),
916
+ # Where the total sits, per instance and per section. The total alone says a
917
+ # pack was expensive or was cut; only the breakdown says which instance spent
918
+ # the budget and what a shrink took, which is what someone asking "why is my
919
+ # context so big" or "why did my live state disappear" is actually asking.
920
+ # Curated the way `unavailable` is, so a section body cannot arrive by this
921
+ # route and bypass the clamp the pack text goes through.
922
+ "packs": packs,
923
+ # The part of the total that belongs to no pack: the operating rules, carried once
924
+ # for the response rather than repeated in each pack. Without it the breakdown
925
+ # cannot account for its own total.
926
+ "universal_rules": _curated_universal_rules(result.get("universal_rules")),
782
927
  },
783
928
  ),
784
929
  m.EXIT_OK,
@@ -139,7 +139,18 @@ class OutputKind(StrEnum):
139
139
 
140
140
  #: Output of ``xmemcli context`` — the session-start pack for the autoload
141
141
  #: bindings in this directory. Curated payload: ``{text, instance_ids,
142
- #: unavailable, estimated_tokens, max_tokens}``. ``text`` is injected verbatim.
142
+ #: unavailable, estimated_tokens, max_tokens, packs, universal_rules}``. ``text`` is injected
143
+ #: verbatim; ``packs`` is the footprint breakdown — per instance, and per
144
+ #: section within it — carrying sizes and titles but no section bodies.
145
+ #: The breakdown accounts for slightly less than ``estimated_tokens``, in both
146
+ #: directions: each pack's heading, the blank lines between packs and the note
147
+ #: prepended when a project binding introduced instances are injected text
148
+ #: charged to no section, and a pack too long for this client is cut after the
149
+ #: sizes are read — every ``truncated`` is then ``true`` and a warning says so.
150
+ #: Present the parts as where the budget went, not as an exact decomposition.
151
+ #: ``max_tokens`` is ``null`` whenever no pack was delivered — which includes
152
+ #: every failure path, so read ``warnings`` for why rather than inferring an
153
+ #: empty binding.
143
154
  AGENT_CONTEXT = "agent_context"
144
155
 
145
156
  #: Output of ``xmemcli org list instances`` (and ``instance list`` alias).
@@ -114,6 +114,18 @@ def _render_read_structured(payload: Any, stream: IO[str]) -> None:
114
114
  The curated payload IS the rows / xresponse the operator explicitly
115
115
  asked for; dump it as JSON.
116
116
  """
117
+ if isinstance(payload, dict) and payload.get("reader_result", ...) is None:
118
+ stream.write("No rows.\n")
119
+ pending = payload.get("pending_suggestions")
120
+ if isinstance(pending, int) and not isinstance(pending, bool) and pending > 0:
121
+ stream.write(f"({pending} pending schema suggestion(s) — {CLI_NAME} schema suggestions review)\n")
122
+ trace_id = payload.get("trace_id")
123
+ if isinstance(trace_id, str) and trace_id.strip():
124
+ stream.write(f" trace: {_terminal_safe(trace_id.strip())}\n")
125
+ console_url = payload.get("console_url")
126
+ if isinstance(console_url, str) and console_url.strip():
127
+ stream.write(f" console: {_terminal_safe(console_url.strip())}\n")
128
+ return
117
129
  json.dump(payload, stream, indent=2, default=str)
118
130
  stream.write("\n")
119
131
 
@@ -1,4 +1,4 @@
1
1
  """Release metadata for the xmemcli CLI (copied into standalone builds)."""
2
2
 
3
3
  CLI_NAME = "xmemcli"
4
- __version__ = "0.0.9"
4
+ __version__ = "0.0.10"
@@ -776,7 +776,12 @@ def _curate_read(item: dict[str, Any], *, read_mode: str, verbose: bool) -> dict
776
776
  rr = item.get("reader_result")
777
777
  rr_dict = rr if isinstance(rr, dict) else {}
778
778
  curated: dict[str, Any]
779
- if read_mode == "single":
779
+ if "reader_result" in item and rr is None:
780
+ # Preserve the server's explicit no-result signal. Converting None to
781
+ # an empty dict makes a zero-row read indistinguishable from an unknown
782
+ # or malformed ReaderResult shape.
783
+ curated = {"reader_result": None}
784
+ elif read_mode == "single":
780
785
  answer = _extract_single_answer(rr_dict)
781
786
  # No extractable answer string → emit the reader_result subtree so
782
787
  # the user still sees something truthful rather than `answer: null`.
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes