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.
- {xmemcli-0.0.9 → xmemcli-0.0.10}/PKG-INFO +3 -3
- {xmemcli-0.0.9 → xmemcli-0.0.10}/README.md +2 -2
- {xmemcli-0.0.9 → xmemcli-0.0.10}/pyproject.toml +1 -1
- {xmemcli-0.0.9 → xmemcli-0.0.10}/uv.lock +1 -1
- {xmemcli-0.0.9 → xmemcli-0.0.10}/xmemcli/__init__.py +1 -1
- {xmemcli-0.0.9 → xmemcli-0.0.10}/xmemcli/_context_cmd.py +150 -5
- {xmemcli-0.0.9 → xmemcli-0.0.10}/xmemcli/_output.py +12 -1
- {xmemcli-0.0.9 → xmemcli-0.0.10}/xmemcli/_pretty.py +12 -0
- {xmemcli-0.0.9 → xmemcli-0.0.10}/xmemcli/_version.py +1 -1
- {xmemcli-0.0.9 → xmemcli-0.0.10}/xmemcli/cli.py +6 -1
- {xmemcli-0.0.9 → xmemcli-0.0.10}/.gitignore +0 -0
- {xmemcli-0.0.9 → xmemcli-0.0.10}/xmemcli/_binding.py +0 -0
- {xmemcli-0.0.9 → xmemcli-0.0.10}/xmemcli/_binding_cmd.py +0 -0
- {xmemcli-0.0.9 → xmemcli-0.0.10}/xmemcli/_email_login.py +0 -0
- {xmemcli-0.0.9 → xmemcli-0.0.10}/xmemcli/_envelope.py +0 -0
- {xmemcli-0.0.9 → xmemcli-0.0.10}/xmemcli/_help.py +0 -0
- {xmemcli-0.0.9 → xmemcli-0.0.10}/xmemcli/_instance_schema.py +0 -0
- {xmemcli-0.0.9 → xmemcli-0.0.10}/xmemcli/_login.py +0 -0
- {xmemcli-0.0.9 → xmemcli-0.0.10}/xmemcli/_mcp_bridge.py +0 -0
- {xmemcli-0.0.9 → xmemcli-0.0.10}/xmemcli/_org_cluster.py +0 -0
- {xmemcli-0.0.9 → xmemcli-0.0.10}/xmemcli/_rc.py +0 -0
- {xmemcli-0.0.9 → xmemcli-0.0.10}/xmemcli/_text.py +0 -0
- {xmemcli-0.0.9 → xmemcli-0.0.10}/xmemcli/_xmem_bridge.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: xmemcli
|
|
3
|
-
Version: 0.0.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
781
|
-
|
|
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
|
|
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
|
|
|
@@ -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
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|