xmemcli 0.0.9__tar.gz → 0.0.11__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,3 +1,11 @@
1
+ Metadata-Version: 2.5
2
+ Name: xmemcli
3
+ Version: 0.0.11
4
+ Summary: Command-line client for the xmemory HTTP API: PKCE auth, instances, read/write, schema and XMD tools.
5
+ Keywords: api,cli,llm,memory,xmemory
6
+ Requires-Python: <4,>=3.12
7
+ Description-Content-Type: text/markdown
8
+
1
9
  # `xmemcli` — xmemory command-line client
2
10
 
3
11
  Single-command CLI for reading from, writing to, and managing xmemory instances over the HTTP API at `$XMEM_API_URL` (default `https://api.xmemory.ai`), using an API key from the Console UI or `xmemcli auth login`.
@@ -7,7 +15,7 @@ Full command reference is included below.
7
15
  ## Usage
8
16
 
9
17
  ```text
10
- xmemcli auth login [--no-browser] [--timeout S] [--console-url URL] [--email ADDR [--org-name NAME]]
18
+ xmemcli auth login [--no-browser] [--timeout S] [--console-url URL] [--callback-port N] [--email ADDR [--org-name NAME]]
11
19
  xmemcli auth logout
12
20
  xmemcli auth status
13
21
  xmemcli read <query>
@@ -89,7 +97,7 @@ xmemcli version
89
97
  xmemcli auth login
90
98
  ```
91
99
 
92
- Version: **0.0.9**.
100
+ Version: **0.0.11**.
93
101
 
94
102
 
95
103
  ---
@@ -100,7 +108,7 @@ The CLI is **human-approved for key acquisition**: an AI-assisted IDE may run re
100
108
 
101
109
  Two acquisition paths share `xmemcli auth login`:
102
110
 
103
- - **Browser PKCE handoff** (default): the CLI sends the human to **`/xmemcli/<challenge>`** on the Console, listens on **`127.0.0.1:9636/callback`**, and the Console redirects a signed short-lived **code** (not the key) to that callback.
111
+ - **Browser PKCE handoff** (default): the CLI sends the human to **`/xmemcli/<challenge>`** on the Console, listens on an ephemeral **`127.0.0.1`** port named in that URL, and the Console delivers a signed short-lived **code** (not the key) to that callback — in the background where the browser allows it, otherwise through a link the human clicks.
104
112
  - **Headless email sign-in** (`--email <addr>`): the CLI plays the waiting-browser role of the Console's cross-device email sign-in and then mints its own key — no browser on this machine. See [Email sign-in](#email-sign-in----email) below.
105
113
 
106
114
  > **Scope.** Shipped in this package: `.xmemrc.json` discovery, PKCE `auth login` / `logout` / `status`, email `auth login --email`, `/whoami` consumption, and `quota`. Server endpoints: `POST /cli/handoff`, `POST /cli/token`, and — for the email path — the Console sign-in and accounts routes listed below (all pre-existing Console surfaces; the email path added no server endpoint).
@@ -121,7 +129,7 @@ Plaintext keys in URLs leak to history and proxies. Handoff uses **PKCE + signed
121
129
 
122
130
  1. CLI mints **`verifier`**, derives **`challenge`** = `base64url(SHA256(verifier))`, opens **`/xmemcli/<challenge>`**.
123
131
  2. Human clicks **"Use this key in the CLI"** → Console **`POST`s the key** to `/cli/handoff` bound to the challenge → receives a short-lived **`code`**.
124
- 3. Browser navigates to **`http://127.0.0.1:9636/callback?code=…&state=<challenge>`** — key never in the URL.
132
+ 3. Browser delivers to **`http://127.0.0.1:<port>/callback?code=…&state=<challenge>`** — key never in the URL.
125
133
  4. CLI **`POST /cli/token`** with `{ code, verifier }` → receives key → writes `0600` `.xmemrc.json`.
126
134
 
127
135
  ### Sequence
@@ -129,7 +137,7 @@ Plaintext keys in URLs leak to history and proxies. Handoff uses **PKCE + signed
129
137
  ```
130
138
  AI-assisted IDE ── xmemcli auth login ──► CLI: PKCE verifier + localhost listener
131
139
  Human ── opens Console URL ──► creates key → "Use this key in the CLI"
132
- CLI ◄── localhost redirect ── redeems code + verifier → writes .xmemrc.json
140
+ CLI ◄── local callback ── redeems code + verifier → writes .xmemrc.json
133
141
  AI-assisted IDE ── xmemcli read/write ──► CLI resolves key; never handles plaintext key
134
142
  ```
135
143
 
@@ -173,7 +181,7 @@ Resolution order: `--api-key` → `$XMEM_API_KEY` → `.xmemrc.json`. Origin: `-
173
181
  | Unsolicited sign-in email initiation | server-side send limits per mailbox (enforced per server node, so the fleet-wide budget scales with node count); the email itself grants nothing without the inbox owner's Approve, and the CLI best-effort-cancels attempts it cannot complete |
174
182
  | Approving someone else's email sign-in | **user-mediated** — the confirmation page leads with "only if this is your address" and states that approving lets the requesting device act on the account, including creating API keys; the stake is higher here than in the browser flow, because approving a CLI-started attempt ends in a durable API key, not a two-day browser session. The matching-code comparison that once narrowed this further is retired for now (the code still travels on the wire but no surface displays it). Approve only sign-ins you started. |
175
183
 
176
- Fixed port **9636** ("xmem" on phone keypad) allows SSH/devcontainer forwarding; security rests on PKCE, not port secrecy.
184
+ Each login binds its own port and names it in the login URL, so concurrent logins never contend for one listener. Security rests on PKCE and on the callback being reachable only from the CLI's own machine, not on port secrecy. For SSH or devcontainer use the port has to exist before the tunnel does, so pin it: `--callback-port <n>` or `$XMEM_CALLBACK_PORT`, then forward that port.
177
185
 
178
186
  ## Quota (observe vs escalate)
179
187
 
@@ -183,7 +191,7 @@ Fixed port **9636** ("xmem" on phone keypad) allows SSH/devcontainer forwarding;
183
191
 
184
192
  # `xmemcli` — capabilities reference
185
193
 
186
- **Version:** `0.0.9` (see `xmemcli version`)
194
+ **Version:** `0.0.11` (see `xmemcli version`)
187
195
 
188
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.
189
197
 
@@ -402,7 +410,7 @@ so a typo fails loudly rather than being ignored.
402
410
 
403
411
  | Command | Description |
404
412
  |---------|-------------|
405
- | **`auth login`** | PKCE via Console `/xmemcli/<challenge>` → `.xmemrc.json`. `--no-browser`, `--timeout`, `--console-url`. With `--email ADDR` (+ optional `--org-name`, used only when a new account is created; requires `--email`): headless email sign-in — one emailed approval, then the CLI mints its own org-wide key named `CLI email login (<hostname>, <timestamp>)`; details in the auth-flow reference. New-organisation creation additionally requires the deployment to allow public registration. Payload shapes: a fresh email mint carries `method: email`, `key_name`, `api_role`, and `org_created`; the browser path carries `method: pkce` (no role/org fields); a matching `--api-key` or rc credential short-circuits to `status: already_authorized` with `source`: flag / rc (`persisted: false` on the flag arm, which saves nothing); a matching `$XMEM_API_KEY` is copied into the rc and reports `status: authorized`, `source: environment`. Exit codes follow the standard HTTP contract (401 → 4 with stage `auth`, 403 → 3 with stage `permission`, other HTTP/network/server-state failures → 3). |
413
+ | **`auth login`** | PKCE via Console `/xmemcli/<challenge>` → `.xmemrc.json`. `--no-browser`, `--timeout`, `--console-url`, `--callback-port` (pins the loopback callback port for `ssh -L`; `0` or omitted means any free port, otherwise 1024–65535; also `$XMEM_CALLBACK_PORT`, which is read globally). With `--email ADDR` (+ optional `--org-name`, used only when a new account is created; requires `--email`): headless email sign-in — one emailed approval, then the CLI mints its own org-wide key named `CLI email login (<hostname>, <timestamp>)`; details in the auth-flow reference. New-organisation creation additionally requires the deployment to allow public registration. Payload shapes: a fresh email mint carries `method: email`, `key_name`, `api_role`, and `org_created`; the browser path carries `method: pkce` (no role/org fields); a matching `--api-key` or rc credential short-circuits to `status: already_authorized` with `source`: flag / rc (`persisted: false` on the flag arm, which saves nothing); a matching `$XMEM_API_KEY` is copied into the rc and reports `status: authorized`, `source: environment`. Exit codes follow the standard HTTP contract (401 → 4 with stage `auth`, 403 → 3 with stage `permission`, other HTTP/network/server-state failures → 3). |
406
414
  | **`auth logout`** | Remove active `.xmemrc.json` |
407
415
  | **`auth status`** | Call `/whoami`. No key → exit 0; bad key → auth error; good key → email + prefix. |
408
416
  | **`mcp <instance-id>`** | Serve one instance to an MCP client over stdio, using this CLI's credential. Not for humans — see below. |
@@ -586,6 +594,7 @@ These apply **before** any subcommand (`xmemcli help options`).
586
594
  | `--api-url` | `XMEM_API_URL` | API origin (default `https://api.xmemory.ai`) |
587
595
  | `--api-key` | `XMEM_API_KEY` | Bearer API key |
588
596
  | `--instance-id` | `XMEM_INSTANCE_ID` | Default instance for read/write and cluster derivation |
597
+
589
598
  | `--rc-dir` | `XMEM_RC_DIR` | Where `auth login` writes `.xmemrc.json`; reads walk **up** toward `$HOME` |
590
599
  | `--binding-dir` | `XMEM_BINDING_DIR` | Where `binding` commands look for `.xmemory.json`; reads walk **up** toward `$HOME` and **merge** every scope |
591
600
  | `--json` | — | Force one JSON document on stdout |
@@ -1,11 +1,3 @@
1
- Metadata-Version: 2.5
2
- Name: xmemcli
3
- Version: 0.0.9
4
- Summary: Command-line client for the xmemory HTTP API: PKCE auth, instances, read/write, schema and XMD tools.
5
- Keywords: api,cli,llm,memory,xmemory
6
- Requires-Python: <4,>=3.12
7
- Description-Content-Type: text/markdown
8
-
9
1
  # `xmemcli` — xmemory command-line client
10
2
 
11
3
  Single-command CLI for reading from, writing to, and managing xmemory instances over the HTTP API at `$XMEM_API_URL` (default `https://api.xmemory.ai`), using an API key from the Console UI or `xmemcli auth login`.
@@ -15,7 +7,7 @@ Full command reference is included below.
15
7
  ## Usage
16
8
 
17
9
  ```text
18
- xmemcli auth login [--no-browser] [--timeout S] [--console-url URL] [--email ADDR [--org-name NAME]]
10
+ xmemcli auth login [--no-browser] [--timeout S] [--console-url URL] [--callback-port N] [--email ADDR [--org-name NAME]]
19
11
  xmemcli auth logout
20
12
  xmemcli auth status
21
13
  xmemcli read <query>
@@ -97,7 +89,7 @@ xmemcli version
97
89
  xmemcli auth login
98
90
  ```
99
91
 
100
- Version: **0.0.9**.
92
+ Version: **0.0.11**.
101
93
 
102
94
 
103
95
  ---
@@ -108,7 +100,7 @@ The CLI is **human-approved for key acquisition**: an AI-assisted IDE may run re
108
100
 
109
101
  Two acquisition paths share `xmemcli auth login`:
110
102
 
111
- - **Browser PKCE handoff** (default): the CLI sends the human to **`/xmemcli/<challenge>`** on the Console, listens on **`127.0.0.1:9636/callback`**, and the Console redirects a signed short-lived **code** (not the key) to that callback.
103
+ - **Browser PKCE handoff** (default): the CLI sends the human to **`/xmemcli/<challenge>`** on the Console, listens on an ephemeral **`127.0.0.1`** port named in that URL, and the Console delivers a signed short-lived **code** (not the key) to that callback — in the background where the browser allows it, otherwise through a link the human clicks.
112
104
  - **Headless email sign-in** (`--email <addr>`): the CLI plays the waiting-browser role of the Console's cross-device email sign-in and then mints its own key — no browser on this machine. See [Email sign-in](#email-sign-in----email) below.
113
105
 
114
106
  > **Scope.** Shipped in this package: `.xmemrc.json` discovery, PKCE `auth login` / `logout` / `status`, email `auth login --email`, `/whoami` consumption, and `quota`. Server endpoints: `POST /cli/handoff`, `POST /cli/token`, and — for the email path — the Console sign-in and accounts routes listed below (all pre-existing Console surfaces; the email path added no server endpoint).
@@ -129,7 +121,7 @@ Plaintext keys in URLs leak to history and proxies. Handoff uses **PKCE + signed
129
121
 
130
122
  1. CLI mints **`verifier`**, derives **`challenge`** = `base64url(SHA256(verifier))`, opens **`/xmemcli/<challenge>`**.
131
123
  2. Human clicks **"Use this key in the CLI"** → Console **`POST`s the key** to `/cli/handoff` bound to the challenge → receives a short-lived **`code`**.
132
- 3. Browser navigates to **`http://127.0.0.1:9636/callback?code=…&state=<challenge>`** — key never in the URL.
124
+ 3. Browser delivers to **`http://127.0.0.1:<port>/callback?code=…&state=<challenge>`** — key never in the URL.
133
125
  4. CLI **`POST /cli/token`** with `{ code, verifier }` → receives key → writes `0600` `.xmemrc.json`.
134
126
 
135
127
  ### Sequence
@@ -137,7 +129,7 @@ Plaintext keys in URLs leak to history and proxies. Handoff uses **PKCE + signed
137
129
  ```
138
130
  AI-assisted IDE ── xmemcli auth login ──► CLI: PKCE verifier + localhost listener
139
131
  Human ── opens Console URL ──► creates key → "Use this key in the CLI"
140
- CLI ◄── localhost redirect ── redeems code + verifier → writes .xmemrc.json
132
+ CLI ◄── local callback ── redeems code + verifier → writes .xmemrc.json
141
133
  AI-assisted IDE ── xmemcli read/write ──► CLI resolves key; never handles plaintext key
142
134
  ```
143
135
 
@@ -181,7 +173,7 @@ Resolution order: `--api-key` → `$XMEM_API_KEY` → `.xmemrc.json`. Origin: `-
181
173
  | Unsolicited sign-in email initiation | server-side send limits per mailbox (enforced per server node, so the fleet-wide budget scales with node count); the email itself grants nothing without the inbox owner's Approve, and the CLI best-effort-cancels attempts it cannot complete |
182
174
  | Approving someone else's email sign-in | **user-mediated** — the confirmation page leads with "only if this is your address" and states that approving lets the requesting device act on the account, including creating API keys; the stake is higher here than in the browser flow, because approving a CLI-started attempt ends in a durable API key, not a two-day browser session. The matching-code comparison that once narrowed this further is retired for now (the code still travels on the wire but no surface displays it). Approve only sign-ins you started. |
183
175
 
184
- Fixed port **9636** ("xmem" on phone keypad) allows SSH/devcontainer forwarding; security rests on PKCE, not port secrecy.
176
+ Each login binds its own port and names it in the login URL, so concurrent logins never contend for one listener. Security rests on PKCE and on the callback being reachable only from the CLI's own machine, not on port secrecy. For SSH or devcontainer use the port has to exist before the tunnel does, so pin it: `--callback-port <n>` or `$XMEM_CALLBACK_PORT`, then forward that port.
185
177
 
186
178
  ## Quota (observe vs escalate)
187
179
 
@@ -191,7 +183,7 @@ Fixed port **9636** ("xmem" on phone keypad) allows SSH/devcontainer forwarding;
191
183
 
192
184
  # `xmemcli` — capabilities reference
193
185
 
194
- **Version:** `0.0.9` (see `xmemcli version`)
186
+ **Version:** `0.0.11` (see `xmemcli version`)
195
187
 
196
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.
197
189
 
@@ -410,7 +402,7 @@ so a typo fails loudly rather than being ignored.
410
402
 
411
403
  | Command | Description |
412
404
  |---------|-------------|
413
- | **`auth login`** | PKCE via Console `/xmemcli/<challenge>` → `.xmemrc.json`. `--no-browser`, `--timeout`, `--console-url`. With `--email ADDR` (+ optional `--org-name`, used only when a new account is created; requires `--email`): headless email sign-in — one emailed approval, then the CLI mints its own org-wide key named `CLI email login (<hostname>, <timestamp>)`; details in the auth-flow reference. New-organisation creation additionally requires the deployment to allow public registration. Payload shapes: a fresh email mint carries `method: email`, `key_name`, `api_role`, and `org_created`; the browser path carries `method: pkce` (no role/org fields); a matching `--api-key` or rc credential short-circuits to `status: already_authorized` with `source`: flag / rc (`persisted: false` on the flag arm, which saves nothing); a matching `$XMEM_API_KEY` is copied into the rc and reports `status: authorized`, `source: environment`. Exit codes follow the standard HTTP contract (401 → 4 with stage `auth`, 403 → 3 with stage `permission`, other HTTP/network/server-state failures → 3). |
405
+ | **`auth login`** | PKCE via Console `/xmemcli/<challenge>` → `.xmemrc.json`. `--no-browser`, `--timeout`, `--console-url`, `--callback-port` (pins the loopback callback port for `ssh -L`; `0` or omitted means any free port, otherwise 1024–65535; also `$XMEM_CALLBACK_PORT`, which is read globally). With `--email ADDR` (+ optional `--org-name`, used only when a new account is created; requires `--email`): headless email sign-in — one emailed approval, then the CLI mints its own org-wide key named `CLI email login (<hostname>, <timestamp>)`; details in the auth-flow reference. New-organisation creation additionally requires the deployment to allow public registration. Payload shapes: a fresh email mint carries `method: email`, `key_name`, `api_role`, and `org_created`; the browser path carries `method: pkce` (no role/org fields); a matching `--api-key` or rc credential short-circuits to `status: already_authorized` with `source`: flag / rc (`persisted: false` on the flag arm, which saves nothing); a matching `$XMEM_API_KEY` is copied into the rc and reports `status: authorized`, `source: environment`. Exit codes follow the standard HTTP contract (401 → 4 with stage `auth`, 403 → 3 with stage `permission`, other HTTP/network/server-state failures → 3). |
414
406
  | **`auth logout`** | Remove active `.xmemrc.json` |
415
407
  | **`auth status`** | Call `/whoami`. No key → exit 0; bad key → auth error; good key → email + prefix. |
416
408
  | **`mcp <instance-id>`** | Serve one instance to an MCP client over stdio, using this CLI's credential. Not for humans — see below. |
@@ -594,6 +586,7 @@ These apply **before** any subcommand (`xmemcli help options`).
594
586
  | `--api-url` | `XMEM_API_URL` | API origin (default `https://api.xmemory.ai`) |
595
587
  | `--api-key` | `XMEM_API_KEY` | Bearer API key |
596
588
  | `--instance-id` | `XMEM_INSTANCE_ID` | Default instance for read/write and cluster derivation |
589
+
597
590
  | `--rc-dir` | `XMEM_RC_DIR` | Where `auth login` writes `.xmemrc.json`; reads walk **up** toward `$HOME` |
598
591
  | `--binding-dir` | `XMEM_BINDING_DIR` | Where `binding` commands look for `.xmemory.json`; reads walk **up** toward `$HOME` and **merge** every scope |
599
592
  | `--json` | — | Force one JSON document on stdout |
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "xmemcli"
3
- version = "0.0.9"
3
+ version = "0.0.11"
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.11"
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.11"
@@ -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,
@@ -1,11 +1,16 @@
1
1
  """The browser handoff behind ``xmem auth login`` (PKCE + loopback callback).
2
2
 
3
3
  The CLI mints a PKCE ``verifier`` / ``challenge`` and opens ``/xmemcli/<challenge>``.
4
- It listens on ``127.0.0.1:9636`` while the human creates a key and clicks **Use this key
5
- in the CLI**. The Console POSTs the key to ``/cli/handoff``, receives a signed short-lived
6
- ``code``, then redirects the browser to the local callback with ``code`` + ``state``.
4
+ It listens on an ephemeral ``127.0.0.1`` port, named in the login URL, while the human
5
+ creates a key and clicks **Use this key in the CLI**. A port per run means two logins never
6
+ contend for one listener. The Console POSTs the key to ``/cli/handoff``, receives a signed
7
+ short-lived ``code``, then delivers it to that callback with ``code`` + ``state`` — in the
8
+ background where the browser allows that, otherwise through a link the human clicks.
7
9
  The CLI redeems with ``verifier`` + ``code`` over TLS; only it holds the verifier.
8
10
 
11
+ Delivery reaching the CLI's own machine is what binds the key to the human who approved it:
12
+ the code is only ever handed to a listener on their loopback interface.
13
+
9
14
  This loopback callback is a conscious multi-node design choice: the API remains stateless
10
15
  for sign-in, because any node can mint/verify the signed code and no shared relay table,
11
16
  cache, sticky session, or cross-node pub/sub is required.
@@ -13,6 +18,7 @@ Standard-library only, like the rest of the CLI.
13
18
  """
14
19
 
15
20
  import base64
21
+ import errno
16
22
  import hashlib
17
23
  import json
18
24
  import os
@@ -23,7 +29,7 @@ import threading
23
29
  import time
24
30
  import urllib.error
25
31
  import urllib.request
26
- from http.server import BaseHTTPRequestHandler, HTTPServer
32
+ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
27
33
  from typing import IO, Any, Callable, NamedTuple, cast, override
28
34
  from urllib.parse import parse_qs, quote, urlparse, urlsplit
29
35
 
@@ -46,8 +52,27 @@ _ENV_CONSOLE_URL = "XMEM_CONSOLE_URL"
46
52
  _ENV_API_URL = "XMEM_API_URL"
47
53
  DEFAULT_CONSOLE_URL = "https://console.xmemory.ai"
48
54
 
49
- CALLBACK_PORT = 9636
55
+ #: Every login binds its own ephemeral port and tells the Console which one in the login URL, so
56
+ #: two ``auth login`` runs never contend for one listener. This value is only the legacy default a
57
+ #: pre-ephemeral client used; it is what ``build_login_page_url`` compares against to decide the
58
+ #: URL still needs a ``callback_url``.
59
+ LEGACY_CALLBACK_PORT = 9636
60
+ #: Bind an ephemeral port: the OS picks a free one and the listener reports it back.
61
+ EPHEMERAL_CALLBACK_PORT = 0
50
62
  CALLBACK_PATH = "/callback"
63
+ # Chrome restricts a public HTTPS page (the Console) reaching a loopback address behind a
64
+ # Local Network Access permission. Measured on Chrome 150: the same request issued straight
65
+ # off the human's click prompts for that permission, while one issued after an awaited POST
66
+ # is refused outright ("Permission was denied for this request to access the `loopback`
67
+ # address space") with no prompt at all. No Chrome/WICG/MDN document explains the difference
68
+ # — spending the click's transient user activation is the likeliest cause but is inference,
69
+ # not a documented contract. The Console therefore hits this path first, before it awaits
70
+ # anything else. Nothing is captured here, so a permission request is never mistaken for a
71
+ # callback.
72
+ PERMISSION_PROBE_PATH = "/permission-probe"
73
+ #: Answered on the probe so the Console can tell a real xmemcli listener from any other local
74
+ #: server on the port the login URL named. Must match cli_handoff.js XMEMCLI_MARKER_HEADER.
75
+ XMEMCLI_MARKER_HEADER = "X-Xmemcli"
51
76
  CLI_LOGIN_PATH = "/xmemcli"
52
77
  CLI_TOKEN_PROXY_PATH = "/api/cli/token"
53
78
  CLI_WHOAMI_PROXY_PATH = "/api/cli/whoami"
@@ -58,10 +83,15 @@ API_KEY_PREFIX = "xmem_"
58
83
  #: local-dev Console. One list, shared — three copies had already grown two different values.
59
84
  LOOPBACK_HOSTS = ("localhost", "127.0.0.1", "::1")
60
85
 
86
+ # Deliberately does NOT claim success: this page is written when the code arrives, before the
87
+ # CLI has redeemed it, and redemption can still fail (an expired code, most plausibly when a
88
+ # human clicks the fallback link minutes after it was minted). Reporting the outcome here would
89
+ # mean holding the response open across the redeem; pointing at the terminal, which does know,
90
+ # is honest and keeps the handler a single non-blocking write.
61
91
  _DONE_PAGE = (
62
92
  f"<!doctype html><html><head><meta charset='utf-8'><title>{CLI_NAME}</title></head>"
63
93
  "<body style='font-family:system-ui;margin:3rem'>"
64
- "<h2>You're all set.</h2><p>You can close this tab and return to your terminal.</p>"
94
+ "<h2>Code received.</h2><p>Return to your terminal — it reports whether the login completed.</p>"
65
95
  "</body></html>"
66
96
  ).encode("utf-8")
67
97
 
@@ -136,13 +166,14 @@ def build_login_page_url(console_url: str, *, challenge: str, callback_url: str
136
166
  """Console CLI login URL: ``/xmemcli/<challenge>`` (PKCE S256, also the callback state)."""
137
167
  segment = quote(challenge, safe="-_")
138
168
  url = f"{console_url.rstrip('/')}{CLI_LOGIN_PATH}/{segment}"
139
- if callback_url and callback_url != redirect_uri_for_port(CALLBACK_PORT):
169
+ if callback_url and callback_url != redirect_uri_for_port(LEGACY_CALLBACK_PORT):
140
170
  url += "?callback_url=" + quote(callback_url, safe=":/?&=-_.")
141
171
  return url
142
172
 
143
173
 
144
174
  def _login_prompt_line(label: str, body: str) -> str:
145
- return f" {label:<10}{body}"
175
+ # Width fits the longest label below; anything longer would run into its own body text.
176
+ return f" {label:<12}{body}"
146
177
 
147
178
 
148
179
  def write_login_handoff_prompt(
@@ -153,7 +184,7 @@ def write_login_handoff_prompt(
153
184
  ) -> None:
154
185
  """Human-facing instructions while ``auth login`` waits for a credential."""
155
186
  out = stream or sys.stderr
156
- body_indent = " "
187
+ body_indent = " " * 14 # 2-space margin + the 12-wide label column, so bodies line up
157
188
  lines = [
158
189
  "",
159
190
  f"Authorize {CLI_NAME} on this machine.",
@@ -163,12 +194,16 @@ def write_login_handoff_prompt(
163
194
  "",
164
195
  _login_prompt_line(
165
196
  "Onboarding",
166
- "During first-instance setup the Console auto-delivers the key — no extra clicks.",
197
+ "During first-instance setup the Console delivers the key for you where it can.",
167
198
  ),
168
199
  _login_prompt_line(
169
- "After onboarding",
200
+ "Otherwise",
170
201
  'Create a key on API Keys and click "Use this key in the CLI".',
171
202
  ),
203
+ _login_prompt_line(
204
+ "New tab",
205
+ "Some browsers (Safari) finish the login in a new tab — follow the link if one appears.",
206
+ ),
172
207
  ]
173
208
  if offer_paste:
174
209
  lines.extend(
@@ -287,21 +322,183 @@ def validate_api_key_shape(key: str) -> str | None:
287
322
  return None
288
323
 
289
324
 
290
- class _LoopbackServer(HTTPServer):
325
+ class _LoopbackServer(ThreadingHTTPServer):
326
+ # Threading, not the plain server: browsers open speculative preconnects that send nothing,
327
+ # and a single-threaded server serves them one at a time — each costing the full per-request
328
+ # timeout before the real callback is even accepted. CPython documents ThreadingHTTPServer as
329
+ # existing for exactly this. Handlers run concurrently, so ``captured`` is written under a
330
+ # lock and only once: first delivery wins. Without that, two callbacks racing would both
331
+ # write and the later one — which may carry a stale or mismatched ``state`` — would replace
332
+ # the one already accepted.
333
+ daemon_threads = True
291
334
  captured: dict[str, str] | None = None
335
+ # Serialized origin of the Console this login is talking to, per RFC 6454. Carried on the
336
+ # server so the handler compares against the origin actually in play — including one passed
337
+ # via ``--console-url`` — instead of re-deriving it from the environment.
338
+ console_origin: str = ""
339
+
340
+ @override
341
+ def __init__(self, *args: Any, **kwargs: Any) -> None:
342
+ # Owned here rather than by the caller: every construction site needs it, including the
343
+ # tests that build a listener directly, and a missing lock fails as a dropped connection
344
+ # rather than anything that names the cause.
345
+ self.capture_lock = threading.Lock()
346
+ super().__init__(*args, **kwargs)
347
+
348
+ @override
349
+ def handle_error(self, request: Any, client_address: Any) -> None:
350
+ """Swallow the disconnects this listener expects; let anything else surface.
351
+
352
+ Delivery is up to three connections plus browser preconnects, two of them fire-and-forget
353
+ ``no-cors`` fetches whose sockets the browser may reset. The default handler would dump
354
+ those tracebacks onto stderr, interleaved with the login prompt.
355
+
356
+ Scoped deliberately: swallowing everything would hide a bug raised inside a handler, and
357
+ the only symptom would be a login that waits out its full timeout with nothing printed.
358
+ (Socket timeouts are quiet for a different reason — the ``log_message`` override.)
359
+ """
360
+ exc = sys.exc_info()[1]
361
+ if isinstance(exc, (ConnectionResetError, BrokenPipeError, TimeoutError)):
362
+ return
363
+ super().handle_error(request, client_address)
364
+
365
+
366
+ def _same_console_origin(actual: str, expected: str) -> bool:
367
+ """Whether two serialized origins name the same Console.
368
+
369
+ Exact equality except among the loopback spellings, which are the same host reached by
370
+ different names — the equivalence ``LOOPBACK_HOSTS`` exists for. A developer whose CLI
371
+ derives ``http://localhost:8002`` but who opens ``http://127.0.0.1:8002`` is at their own
372
+ Console; refusing the marker there disables background delivery and blames a permission
373
+ the browser never asked about.
374
+
375
+ Deliberately not a general relaxation: scheme and port must still match exactly, so this
376
+ cannot bridge a loopback origin to a public one.
377
+ """
378
+ if not actual or not expected:
379
+ return False
380
+ if actual == expected:
381
+ return True
382
+ parsed_actual, parsed_expected = urlsplit(actual), urlsplit(expected)
383
+ if parsed_actual.scheme != parsed_expected.scheme:
384
+ return False
385
+ if (parsed_actual.hostname or "") not in LOOPBACK_HOSTS:
386
+ return False
387
+ if (parsed_expected.hostname or "") not in LOOPBACK_HOSTS:
388
+ return False
389
+ try:
390
+ return parsed_actual.port == parsed_expected.port
391
+ except ValueError:
392
+ return False
393
+
394
+
395
+ def serialized_origin(url: str) -> str:
396
+ """``scheme://host[:port]``, lowercased, default port elided — comparable to an ``Origin``."""
397
+ parsed = urlsplit(url.strip())
398
+ scheme = parsed.scheme.lower()
399
+ host = (parsed.hostname or "").lower()
400
+ if not scheme or not host:
401
+ return ""
402
+ try:
403
+ port = parsed.port
404
+ except ValueError:
405
+ # A non-numeric or out-of-range port is a malformed origin, and this value is only ever
406
+ # compared against a request's Origin header — an unparseable one matches nothing, which
407
+ # is the safe answer. Raising here would escape before the listener can be closed.
408
+ return ""
409
+ if port is None or (scheme, port) in (("http", 80), ("https", 443)):
410
+ return f"{scheme}://{host}"
411
+ return f"{scheme}://{host}:{port}"
292
412
 
293
413
 
294
414
  class _CallbackHandler(BaseHTTPRequestHandler):
415
+ # A connection that opens and then says nothing must not wedge the listener: HTTPServer is
416
+ # threaded, so a silent peer gets its own thread rather than starving the listener — but
417
+ # without this that thread would live for the whole login, so
418
+ # Threading keeps accept() moving, so a silent peer no longer delays the real callback. This
419
+ # timeout is what bounds how long that peer's worker thread sits in rfile.readline() — without
420
+ # it the thread would live for the whole login. Do not delete it as "redundant now we thread":
421
+ # the two solve different halves.
422
+ timeout = 5
423
+
424
+ def _console_origin_or_none(self) -> str | None:
425
+ """The request's ``Origin`` when it is this login's Console, else ``None``.
426
+
427
+ One predicate for both the preflight and the probe. Written twice it drifted: the probe
428
+ branch echoed the raw header, so an obs-folded value carrying CR/LF round-tripped into
429
+ ``Access-Control-Allow-Origin`` while the identical input on ``OPTIONS`` was refused.
430
+ """
431
+ origin = self.headers.get("Origin")
432
+ if not origin or "\r" in origin or "\n" in origin:
433
+ return None
434
+ expected = cast(_LoopbackServer, self.server).console_origin
435
+ if not expected:
436
+ return None
437
+ return origin if _same_console_origin(serialized_origin(origin), expected) else None
438
+
439
+ def do_OPTIONS(self) -> None: # noqa: N802
440
+ """Answer a CORS/Private-Network preflight, should one ever be sent.
441
+
442
+ Defensive rather than load-bearing: both Console requests are ``no-cors`` GETs, which are
443
+ never CORS-preflighted, and the only mechanism that would have preflighted them —
444
+ Private Network Access — is on hold, with its Local Network Access successor gating on a
445
+ permission instead. Left in place because ``BaseHTTPRequestHandler`` would otherwise
446
+ answer 501, which fails such a preflight and silently drops the callback.
447
+ """
448
+ origin = self._console_origin_or_none()
449
+ self.send_response(204)
450
+ self.send_header("Vary", "Origin")
451
+ if origin:
452
+ self.send_header("Access-Control-Allow-Origin", origin)
453
+ self.send_header("Access-Control-Allow-Methods", "GET, OPTIONS")
454
+ # Private Network Access. Its Local Network Access successor dropped the header
455
+ # mechanism for a permission prompt, so there is no second header to send.
456
+ if self.headers.get("Access-Control-Request-Private-Network") == "true":
457
+ self.send_header("Access-Control-Allow-Private-Network", "true")
458
+ self.send_header("Access-Control-Max-Age", "60")
459
+ self.end_headers()
460
+
295
461
  def do_GET(self) -> None: # noqa: N802
462
+ # The callback carries no CORS headers: the Console fetches it with ``mode: 'no-cors'``
463
+ # and never reads the response, so letting arbitrary pages read it buys nothing. The
464
+ # probe above is the deliberate exception — it is fetched with ``mode: 'cors'`` precisely
465
+ # so the marker is readable, which an opaque response would prevent.
296
466
  parsed = urlparse(self.path)
467
+ if parsed.path == PERMISSION_PROBE_PATH:
468
+ # Captures nothing — its job is to let the Console tell this listener apart from an
469
+ # unrelated service that happens to hold the port named in the URL, before it mints a
470
+ # key toward it. The marker is a fixed public value, so it proves cooperation with
471
+ # this protocol, not identity: malicious local code can send the same headers, which
472
+ # is out of scope — a process on the user's loopback already implies code execution
473
+ # on the machine. What it does buy is that a benign service answering there gets no
474
+ # key. The CORS headers
475
+ # are what let the Console read the marker cross-origin; without a matching Origin we
476
+ # send neither, so nothing here leaks to an arbitrary page. Reaching it at all is also
477
+ # still what makes the browser prompt for local-network access.
478
+ origin = self._console_origin_or_none()
479
+ self.send_response(204)
480
+ self.send_header("Vary", "Origin")
481
+ if origin:
482
+ self.send_header("Access-Control-Allow-Origin", origin)
483
+ self.send_header("Access-Control-Expose-Headers", XMEMCLI_MARKER_HEADER)
484
+ self.send_header(XMEMCLI_MARKER_HEADER, "1")
485
+ self.end_headers()
486
+ return
297
487
  if parsed.path != CALLBACK_PATH:
298
488
  self.send_response(404)
489
+ self.send_header("Content-Length", "0")
299
490
  self.end_headers()
300
491
  return
301
492
  params = {k: v[0] for k, v in parse_qs(parsed.query).items() if v}
302
- cast(_LoopbackServer, self.server).captured = params
493
+ server = cast(_LoopbackServer, self.server)
494
+ # First delivery wins. Handlers are concurrent now, so an unconditional write would let a
495
+ # second callback replace one already accepted.
496
+ with server.capture_lock:
497
+ if server.captured is None:
498
+ server.captured = params
303
499
  self.send_response(200)
304
500
  self.send_header("Content-Type", "text/html; charset=utf-8")
501
+ self.send_header("Content-Length", str(len(_DONE_PAGE)))
305
502
  self.end_headers()
306
503
  self.wfile.write(_DONE_PAGE)
307
504
 
@@ -316,8 +513,9 @@ def wait_for_callback(
316
513
  on_url: Callable[[str], None],
317
514
  timeout_s: float,
318
515
  *,
319
- callback_port: int = CALLBACK_PORT,
516
+ callback_port: int = EPHEMERAL_CALLBACK_PORT,
320
517
  stop: threading.Event | None = None,
518
+ console_url: str | None = None,
321
519
  ) -> dict[str, str]:
322
520
  """Listen for the browser's local callback carrying the signed code.
323
521
 
@@ -327,13 +525,30 @@ def wait_for_callback(
327
525
  try:
328
526
  server = _LoopbackServer(("127.0.0.1", callback_port), _CallbackHandler)
329
527
  except OSError as e:
330
- label = callback_port if callback_port != 0 else CALLBACK_PORT
331
- raise LoginError(
332
- f"could not bind 127.0.0.1:{label} — is another `{CLI_NAME} auth login` in progress? ({e})"
333
- ) from None
528
+ # Say what actually happened. Naming a cause the errno does not support sends people
529
+ # hunting the wrong thing — a permission failure is not a port conflict — and the raw
530
+ # exception text ("[Errno 98] ...") is not something to put in front of a user.
531
+ if callback_port == EPHEMERAL_CALLBACK_PORT:
532
+ raise LoginError("could not bind a local port for the login callback") from None
533
+ if e.errno == errno.EACCES:
534
+ # Reachable through direct library use with a privileged port, and via the CLI only
535
+ # when something other than the port number denied the bind — `_callback_port` has
536
+ # already required >= 1024 there. So it names both causes rather than telling a CLI
537
+ # user to do what they just did.
538
+ raise LoginError(
539
+ f"not allowed to bind 127.0.0.1:{callback_port} — ports below 1024 need elevated "
540
+ f"privileges, and a host firewall or security product can deny a bind at any port."
541
+ ) from None
542
+ if e.errno == errno.EADDRINUSE:
543
+ raise LoginError(
544
+ f"could not bind 127.0.0.1:{callback_port} — something is already using that port, "
545
+ f"possibly another `{CLI_NAME} auth login`."
546
+ ) from None
547
+ raise LoginError(f"could not bind 127.0.0.1:{callback_port} for the login callback") from None
334
548
  bound_port = server.server_address[1]
335
549
  server.timeout = 0.5
336
550
  try:
551
+ server.console_origin = serialized_origin(console_url_for_api(console_url=console_url))
337
552
  on_url(build_url(redirect_uri_for_port(bound_port)))
338
553
  deadline = time.monotonic() + timeout_s
339
554
  while server.captured is None and not (stop and stop.is_set()):
@@ -548,7 +763,7 @@ def wait_for_handoff(
548
763
  build_url: Callable[[str], str],
549
764
  on_url: Callable[[str], None],
550
765
  timeout_s: float,
551
- callback_port: int = CALLBACK_PORT,
766
+ callback_port: int = EPHEMERAL_CALLBACK_PORT,
552
767
  interactive: bool = False,
553
768
  notify: Callable[[str], None] | None = None,
554
769
  ) -> HandoffResult:
@@ -582,6 +797,7 @@ def wait_for_handoff(
582
797
  timeout_s,
583
798
  callback_port=callback_port,
584
799
  stop=stop,
800
+ console_url=console_url,
585
801
  )
586
802
  code = params.get("code")
587
803
  if not code:
@@ -674,7 +890,7 @@ def run_pkce_login(
674
890
  verifier: str,
675
891
  on_url: Callable[[str], None],
676
892
  timeout_s: float,
677
- callback_port: int = CALLBACK_PORT,
893
+ callback_port: int = EPHEMERAL_CALLBACK_PORT,
678
894
  interactive: bool = False,
679
895
  notify: Callable[[str], None] | None = None,
680
896
  ) -> HandoffResult:
@@ -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.11"
@@ -149,6 +149,12 @@ ENV_INSTANCE_ID = "XMEM_INSTANCE_ID"
149
149
  ENV_API_URL = "XMEM_API_URL"
150
150
  ENV_CLUSTER_ID = "XMEM_CLUSTER_ID"
151
151
  ENV_CONSOLE_URL = "XMEM_CONSOLE_URL"
152
+ #: Pin the loopback callback port. Unset means any free port, which is what lets concurrent
153
+ #: logins coexist; pinning is for when the port has to be known before the login starts.
154
+ ENV_CALLBACK_PORT = "XMEM_CALLBACK_PORT"
155
+ #: Ports below this need privileges the CLI never takes, and the Console refuses them too.
156
+ #: Must match cli_handoff.js CLI_MIN_CALLBACK_PORT.
157
+ MIN_CALLBACK_PORT = 1024
152
158
  DEFAULT_API_URL = "https://api.xmemory.ai"
153
159
 
154
160
  #: Where MCP connections go. A separate origin from the API rather than a path on it,
@@ -776,7 +782,12 @@ def _curate_read(item: dict[str, Any], *, read_mode: str, verbose: bool) -> dict
776
782
  rr = item.get("reader_result")
777
783
  rr_dict = rr if isinstance(rr, dict) else {}
778
784
  curated: dict[str, Any]
779
- if read_mode == "single":
785
+ if "reader_result" in item and rr is None:
786
+ # Preserve the server's explicit no-result signal. Converting None to
787
+ # an empty dict makes a zero-row read indistinguishable from an unknown
788
+ # or malformed ReaderResult shape.
789
+ curated = {"reader_result": None}
790
+ elif read_mode == "single":
780
791
  answer = _extract_single_answer(rr_dict)
781
792
  # No extractable answer string → emit the reader_result subtree so
782
793
  # the user still sees something truthful rather than `answer: null`.
@@ -1212,6 +1223,35 @@ def cmd_auth_status(args: argparse.Namespace) -> tuple[CliOutput, int]:
1212
1223
  )
1213
1224
 
1214
1225
 
1226
+ def _callback_port(args: argparse.Namespace) -> int:
1227
+ """The port to bind the login callback to; ``0`` means "any free port".
1228
+
1229
+ Any free port is the default because it lets two logins run at once — one fixed port meant
1230
+ the second could not bind. Pinning exists for the case where the port must be known before
1231
+ the login starts, which an ``ssh -L`` tunnel to a remote dev box needs: the tunnel has to be
1232
+ up first, so the port cannot be chosen after.
1233
+ """
1234
+ explicit = getattr(args, "callback_port", None)
1235
+ if explicit is None:
1236
+ from_env = os.environ.get(ENV_CALLBACK_PORT, "").strip()
1237
+ if not from_env:
1238
+ return _login.EPHEMERAL_CALLBACK_PORT
1239
+ try:
1240
+ explicit = int(from_env)
1241
+ except ValueError:
1242
+ # Bounded: this value is echoed back in a JSON error field and comes from the
1243
+ # environment, so it is not something to reproduce in full.
1244
+ shown = from_env if len(from_env) <= 32 else from_env[:32] + "…"
1245
+ raise UsageError(f"${ENV_CALLBACK_PORT} must be a port number, got {shown!r}") from None
1246
+ port = int(explicit)
1247
+ # Below 1024 is refused rather than merely unbindable: the Console rejects such a callback
1248
+ # too (WHATWG URL elides :80 entirely, so it never arrives as a port at all), and the login
1249
+ # would fail later with nothing pointing at the cause. 0 stays legal — it means ephemeral.
1250
+ if port != _login.EPHEMERAL_CALLBACK_PORT and not MIN_CALLBACK_PORT <= port <= 65535:
1251
+ raise UsageError(f"callback port must be 0 or between {MIN_CALLBACK_PORT} and 65535, got {port}")
1252
+ return port
1253
+
1254
+
1215
1255
  def _login_timeout(args: argparse.Namespace, *, default: float) -> float:
1216
1256
  """The ``--timeout`` value, or the per-path default when the flag was not passed.
1217
1257
 
@@ -1663,6 +1703,7 @@ def cmd_auth_login(args: argparse.Namespace) -> tuple[CliOutput, int]:
1663
1703
  verifier=verifier,
1664
1704
  on_url=on_url,
1665
1705
  timeout_s=_login_timeout(args, default=300.0),
1706
+ callback_port=_callback_port(args),
1666
1707
  interactive=interactive,
1667
1708
  notify=_eprint,
1668
1709
  )
@@ -2548,6 +2589,16 @@ def _build_parser() -> argparse.ArgumentParser:
2548
2589
  add_help=False,
2549
2590
  )
2550
2591
  pal.add_argument("--no-browser", action="store_true", help="Print the URL only; do not open a browser")
2592
+ pal.add_argument(
2593
+ "--callback-port",
2594
+ type=int,
2595
+ default=None,
2596
+ help=(
2597
+ "Bind the login callback to this port instead of any free one "
2598
+ f"(or ${ENV_CALLBACK_PORT}). Use when the port must be known in advance, e.g. an "
2599
+ "ssh -L tunnel for remote development."
2600
+ ),
2601
+ )
2551
2602
  pal.add_argument(
2552
2603
  "--timeout",
2553
2604
  type=float,
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes