xmemcli 1.4.0__tar.gz → 1.5.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: xmemcli
3
- Version: 1.4.0
3
+ Version: 1.5.1
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
@@ -20,6 +20,8 @@ xmemcli auth logout
20
20
  xmemcli auth status
21
21
  xmemcli read <query>
22
22
  xmemcli write <text>
23
+ xmemcli write (--mutations-file PATH | --mutations-stdin) [--delete TYPE:ID]…
24
+ xmemcli write --delete TYPE:ID [--delete TYPE:ID]…
23
25
  xmemcli write-status <write_id> [<id> …]
24
26
  xmemcli quota
25
27
  xmemcli instance templates
@@ -48,6 +50,9 @@ Two local files, same discovery rule, different jobs: `.xmemrc.json` holds the c
48
50
  | `xmemcli write --extraction fast` (default) | `extraction_logic: "fast"` |
49
51
  | `xmemcli write --extraction deep` | `extraction_logic: "deep"` |
50
52
  | `xmemcli write --no-wait` | async write; poll with `write-status` |
53
+ | `xmemcli write --mutations-file m.json` / `--mutations-stdin` | `structured_mutations` — the JSON array as given; no `text`, no `extraction_logic` |
54
+ | `xmemcli write --scope Pet:name=Luna --scope-mode drop` | `scope: {objects: [...], mode: "drop"}` — out-of-scope changes are skipped and reported; `reject` is not sent |
55
+ | `xmemcli write --delete Person:name=Ada` | appends `{"object_mutation": {"object_type": "Person", "delete": {"key": {"name": "Ada"}}}}` (`Person:<xuid>` sends `{"xuid": …}` as the key) |
51
56
  | `xmemcli read --read-mode single` (default) | `mode: "single-answer"` |
52
57
  | `xmemcli read --read-mode raw` | `mode: "raw-tables"` |
53
58
  | `xmemcli read --read-mode xresponse` | `mode: "xresponse"` |
@@ -58,7 +63,7 @@ Two local files, same discovery rule, different jobs: `.xmemrc.json` holds the c
58
63
 
59
64
  ## Curated output
60
65
 
61
- The CLI does not echo raw API payloads. Server internals (`sql`, `diff_plan`, extraction trees) are never emitted. Read responses keep `pending_suggestions` (schema-evolution hint). `--verbose` adds `trace_id` and `console_url` when present.
66
+ The CLI does not echo raw API payloads. Server internals (`sql`, `diff_plan`, extraction trees) are never emitted. Read responses keep `pending_suggestions` (schema-evolution hint). A text write keeps a compact `extracted` summary (`Type(identifier)` tags); a structured write runs no extraction and has none. `write` and `write-status` keep the server's `changes` block (created / updated with old → new / deleted, bulk summaries, advisory records) when the write changed something. `--verbose` adds `trace_id` and `console_url` when present.
62
67
 
63
68
  **`trace …` is the one deliberate exception.** Those commands return the Console's own operation documents — the paged operations list, the Inspect search, and the per-operation detail — **verbatim**, because the Console already curates them per caller (a super admin sees LLM calls and raw token counts; everyone else does not). They are JSON on every stream and carry every internal id the Console does: `trace_id`, `instance_id`, `session_id`, `user_id`, `api_key_id`, and on detail documents `source_id`, `migration_id`, `llm_call_id`, `gateway_request_id`, …
64
69
 
@@ -138,7 +143,7 @@ xmemcli version
138
143
  xmemcli auth login
139
144
  ```
140
145
 
141
- Version: **1.4.0**.
146
+ Version: **1.5.1**.
142
147
 
143
148
 
144
149
  ---
@@ -238,7 +243,7 @@ Each login binds its own port and names it in the login URL, so concurrent login
238
243
 
239
244
  # `xmemcli` — capabilities reference
240
245
 
241
- **Version:** `1.4.0` (see `xmemcli version`)
246
+ **Version:** `1.5.1` (see `xmemcli version`)
242
247
 
243
248
  `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.
244
249
 
@@ -256,12 +261,23 @@ Every command below has dedicated help — see [Help](#help).
256
261
 
257
262
  | Command | Description |
258
263
  |---------|-------------|
259
- | **`write`** `<text>` | Sync write (`POST …/write`). `--extraction fast\|deep` (default `fast`). `--no-wait` queues async write, returns `write_id`. `--scope TYPE:ID` (repeatable) anchors the write to existing objects: only they may be modified/deleted, new objects and links to them are allowed, anything else fails; works with either extraction mode. |
260
- | **`write-status`** `<write_id>` … | Poll async writes until terminal or `--timeout` (default 60s). Accepts multiple ids. |
264
+ | **`write`** `[text]` | Sync write (`POST …/write`). `--extraction fast\|deep` (default `fast`). `--no-wait` queues async write, returns `write_id`. `--scope TYPE:ID` (repeatable) anchors the write to existing objects: only they may be modified/deleted; by default new objects and links to them are allowed and anything else fails, and under `--scope-mode drop` only the objects a `--scope` names may be created and everything else is skipped; works with either extraction mode. `--scope-mode drop` (needs `--scope`) skips out-of-scope changes instead of failing and lists them under `changes.skipped_out_of_scope`; it creates only records a `--scope` names by primary key, and such a record need not be stored yet — create-or-update exactly the named record. `reject` is the server default and is not sent. Instead of text, a **structured write** sends `structured_mutations` — deterministic creates, updates and deletes with no extraction: `--mutations-file PATH` or `--mutations-stdin` (one JSON array, same shape as the API's `structured_mutations`; see [Structured writes](#structured-writes)) and/or `--delete TYPE:ID` (repeatable, `--scope` syntax: `Person:<xuid>` or `Person:name=Ada`; appended after the file's entries). Text and mutations are exclusive, as are the file and stdin forms; mutations refuse `--scope` and `--extraction deep`; empty or whitespace-only text is refused (exit **2**, before anything is read or sent). A text write's output lists what it extracted as `Type(identifier)` tags (`extracted`); a structured write's does not. Both carry the server's `changes` block (`created` / `updated` with per-field `old_value` → `new_value` / `deleted`, and any other bucket such as `created_keyless_objects`) whenever the write changed something, and on a drop-mode write that skipped everything, where the block carries `skipped_out_of_scope` alone. A `--no-wait` answer carries only the `write_id`; `write-status` reports the `changes` once the write completes. |
265
+ | **`write-status`** `<write_id>` … | Poll async writes until terminal or `--timeout` (default 60s). Accepts multiple ids. A completed write's non-empty `changes` block is kept, as on `write`. |
261
266
  | **`read`** `<query>` | Read from active instance. `--read-mode single\|raw\|xresponse`, `--scope TYPE:ID` (repeatable), `--scope-relations`, `--related-types` (also return `related_types`: per object type the read touched, the fields it did not return and the object types a declared relation links it to; needs `instance.get_own` on the key), `--related-types-depth N` (1, 2 or 3, refused as a usage error otherwise; follow the relations N levels out, each catalog entry saying its distance; implies `--related-types`). |
262
267
 
263
268
  Requires **`--instance-id`** or **`$XMEM_INSTANCE_ID`** for read/write (or auto-pick when exactly one instance is visible — see [Global flags](#global-flags--environment)).
264
269
 
270
+ #### Structured writes
271
+
272
+ `--mutations-file PATH` / `--mutations-stdin` send one JSON array as the API's `structured_mutations`, as given. The server takes at most **100** entries per write, `--delete` entries included; the CLI refuses more with exit **2** before sending anything. `--mutations-stdin` needs a pipe or a file on stdin and refuses a terminal. Each entry is one of:
273
+
274
+ - **`object_mutation`** — `{"object_mutation": {"object_type": "Person", "create": {"key": {"name": "Ada"}, "values": {"age": 36}}}}`. `update` takes the same `key` + `values` (the key may be `{"xuid": "…"}`; a null value clears a field); `delete` takes `{"key": {…}}` alone.
275
+ - **`relation_mutation`** — `{"relation_mutation": {"relation_type": "works_at", "create": {"endpoints": [{"object_name": "person", "key": {"name": "Ada"}}, {"object_name": "company", "key": {"name": "ACME"}}]}}}`. `create` lists every participant role, each keyed by primary key or `{"xuid": "…"}`, with no `key` and no `values`. `delete` takes either `endpoints` (a subset may be given) or a top-level `"key": {"xuid": "…"}` — never both — and `"allow_bulk_delete": true` to remove every matched link (without it, a delete matching more than one link is refused). Relation `update` is always refused: re-link by deleting and creating.
276
+
277
+ `--delete TYPE:ID` appends the object `delete` above; each key field may appear once and none may be empty. A delete that matches no record is a no-op, not an error: exit **0** and no `deleted` line — in a terminal the write reads `write <id> ok · nothing matched or changed`.
278
+
279
+ A deployment that has not enabled structured writes answers **400** `Structured writes are not enabled on this deployment.`; a server that predates structured writes answers **422** with two errors, both reported: `Field required (field: text); Extra inputs are not permitted (field: structured_mutations)`. Both exit **3** with `stage: "request"`.
280
+
265
281
  ---
266
282
 
267
283
  ### Org inventory
@@ -648,6 +664,10 @@ Some commands attach a more specific `stage` (`validate`, `usage`, `limit`, `not
648
664
 
649
665
  **Breaking change in 0.0.5:** HTTP **403** previously exited **4** (`auth`); it now exits **3** (`permission`). Scripts that treated exit **4** as “any auth-layer failure” must check `stage` or handle exit **3** for permission denials.
650
666
 
667
+ **New in 1.5.1:** `write --scope-mode drop` asks for a scoped write that skips and reports changes outside its scope instead of failing, and that creates only the records its `--scope` names — which may not be stored yet. The report is `changes.skipped_out_of_scope`: one terminal line per operation, type and set of fields, carrying `count=N`, the number of records it stands for, and naming a record's key only when the `--scope` named that record. A drop-mode write that skipped everything still returns a `changes` block, holding that report alone. A server that predates drop mode refuses `mode` in the scope (exit **3**), which is a clean failure; a deployment part-way through rolling it out is not, so publish 1.5.1 only after the server side is fully deployed — see [Releasing `xmemcli`](../docs/cli/XMEMCLI-RELEASE.md). `--scope-mode reject` is never sent, so plain scoped writes work against any server.
668
+
669
+ **New in 1.5.1:** `write` and `write-status` keep the server's `changes` block — what the write created, updated (field by field, old → new) and deleted, plus bulk summaries and advisory records — verbatim in `--json` output. In a terminal each change is one indented line: a bulk delete or update reads `deleted 3 × Ticket where status='open'`, one the server refused reads `not deleted Ticket: refused — <reason>`, the advisory `overwritten_field_values` / `merged_field_conflicts` records follow as `note:` lines, and past 50 lines the rest is counted in one closing line (`--json` is never truncated); `write-status` with several ids prints a status line per write, each followed by its changes. `changes` is omitted when the write changed nothing, so that output keeps its earlier shape; a drop-mode write that only skipped is the one write that changed nothing and still carries the block. `write` also takes structured mutations (`--mutations-file`, `--mutations-stdin`, `--delete TYPE:ID`) in place of text; the text positional is now optional, and a write with neither text nor mutations, or whose text is empty or only whitespace, is a usage error (exit **2**, `stage: "usage"`). Before 1.5.1 `write ""` reached the server, whose 400 exited **3** with `stage: "request"`, and whitespace-only text was sent and accepted. A structured write's output carries no `extracted`, since it runs no extraction; on a text write the tags now read `Type(identifier)` (`Person(name='Ada')`), as documented, where they read `?(Type)`. A malformed `--scope` (on `write` and `read`) or `--delete` — now including a key field given twice or an empty value — reports `stage: "usage"`, still exit **2**; `--scope` reported `"request"`. `--mutations-stdin` and `--schema-stdin` refuse a terminal instead of waiting on it, and every input file and piped input is read by one reader and decoded as strict UTF-8, so an input that is not UTF-8 (`--mutations-file`, `--schema-file`, `--migration-plan`, `schema suggestions decide --file`, the `xmd validate` / `convert` / `enhance` schema file, or the same bytes piped to `--mutations-stdin` / `--schema-stdin`) is a usage error at exit **2** instead of a traceback at exit **1** — or, piped, a garbled write at exit **0** — and a leading UTF-8 byte-order mark is accepted. Wherever an HTTP error from the API is reported as `HTTP <status>: …`, a body in the API's error envelope now gives the server's own message (`HTTP 400: <message>`, plus the request `field` when the server names one) instead of the first 200 characters of the raw body.
670
+
651
671
  **New in 1.3.0:** exit **8** with `stage: "processing"` for a `write` whose server-side wait window expired while the write kept running. In `--json` mode that document also carries a `write_id` field (omitted when the server sent no handle), so a script polls with it directly instead of parsing the message. Additive — no previously documented mapping changed — but a script that reads “non-zero, and not a code I recognise” as failed-and-retryable will now retry a write that has **not** failed, starting a second one the account pays for. Branch on **8** (or on `stage`) and poll `xmemcli write-status <write_id>` until it settles.
652
672
 
653
673
  **New in 1.2.0:** `instance templates` lists the ready-made instance shapes the service ships, with the prose to choose between them, and `instance create --template <id>` builds one from a pinned schema the server already holds. No schema is generated on that path: no description to compose, no `xmd generate` hop, and the instance comes out identical to the one the console would have made from the same template. (Creating an instance still generates its agent brief afterwards, on this path as on any other.)
@@ -12,6 +12,8 @@ xmemcli auth logout
12
12
  xmemcli auth status
13
13
  xmemcli read <query>
14
14
  xmemcli write <text>
15
+ xmemcli write (--mutations-file PATH | --mutations-stdin) [--delete TYPE:ID]…
16
+ xmemcli write --delete TYPE:ID [--delete TYPE:ID]…
15
17
  xmemcli write-status <write_id> [<id> …]
16
18
  xmemcli quota
17
19
  xmemcli instance templates
@@ -40,6 +42,9 @@ Two local files, same discovery rule, different jobs: `.xmemrc.json` holds the c
40
42
  | `xmemcli write --extraction fast` (default) | `extraction_logic: "fast"` |
41
43
  | `xmemcli write --extraction deep` | `extraction_logic: "deep"` |
42
44
  | `xmemcli write --no-wait` | async write; poll with `write-status` |
45
+ | `xmemcli write --mutations-file m.json` / `--mutations-stdin` | `structured_mutations` — the JSON array as given; no `text`, no `extraction_logic` |
46
+ | `xmemcli write --scope Pet:name=Luna --scope-mode drop` | `scope: {objects: [...], mode: "drop"}` — out-of-scope changes are skipped and reported; `reject` is not sent |
47
+ | `xmemcli write --delete Person:name=Ada` | appends `{"object_mutation": {"object_type": "Person", "delete": {"key": {"name": "Ada"}}}}` (`Person:<xuid>` sends `{"xuid": …}` as the key) |
43
48
  | `xmemcli read --read-mode single` (default) | `mode: "single-answer"` |
44
49
  | `xmemcli read --read-mode raw` | `mode: "raw-tables"` |
45
50
  | `xmemcli read --read-mode xresponse` | `mode: "xresponse"` |
@@ -50,7 +55,7 @@ Two local files, same discovery rule, different jobs: `.xmemrc.json` holds the c
50
55
 
51
56
  ## Curated output
52
57
 
53
- The CLI does not echo raw API payloads. Server internals (`sql`, `diff_plan`, extraction trees) are never emitted. Read responses keep `pending_suggestions` (schema-evolution hint). `--verbose` adds `trace_id` and `console_url` when present.
58
+ The CLI does not echo raw API payloads. Server internals (`sql`, `diff_plan`, extraction trees) are never emitted. Read responses keep `pending_suggestions` (schema-evolution hint). A text write keeps a compact `extracted` summary (`Type(identifier)` tags); a structured write runs no extraction and has none. `write` and `write-status` keep the server's `changes` block (created / updated with old → new / deleted, bulk summaries, advisory records) when the write changed something. `--verbose` adds `trace_id` and `console_url` when present.
54
59
 
55
60
  **`trace …` is the one deliberate exception.** Those commands return the Console's own operation documents — the paged operations list, the Inspect search, and the per-operation detail — **verbatim**, because the Console already curates them per caller (a super admin sees LLM calls and raw token counts; everyone else does not). They are JSON on every stream and carry every internal id the Console does: `trace_id`, `instance_id`, `session_id`, `user_id`, `api_key_id`, and on detail documents `source_id`, `migration_id`, `llm_call_id`, `gateway_request_id`, …
56
61
 
@@ -130,7 +135,7 @@ xmemcli version
130
135
  xmemcli auth login
131
136
  ```
132
137
 
133
- Version: **1.4.0**.
138
+ Version: **1.5.1**.
134
139
 
135
140
 
136
141
  ---
@@ -230,7 +235,7 @@ Each login binds its own port and names it in the login URL, so concurrent login
230
235
 
231
236
  # `xmemcli` — capabilities reference
232
237
 
233
- **Version:** `1.4.0` (see `xmemcli version`)
238
+ **Version:** `1.5.1` (see `xmemcli version`)
234
239
 
235
240
  `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.
236
241
 
@@ -248,12 +253,23 @@ Every command below has dedicated help — see [Help](#help).
248
253
 
249
254
  | Command | Description |
250
255
  |---------|-------------|
251
- | **`write`** `<text>` | Sync write (`POST …/write`). `--extraction fast\|deep` (default `fast`). `--no-wait` queues async write, returns `write_id`. `--scope TYPE:ID` (repeatable) anchors the write to existing objects: only they may be modified/deleted, new objects and links to them are allowed, anything else fails; works with either extraction mode. |
252
- | **`write-status`** `<write_id>` … | Poll async writes until terminal or `--timeout` (default 60s). Accepts multiple ids. |
256
+ | **`write`** `[text]` | Sync write (`POST …/write`). `--extraction fast\|deep` (default `fast`). `--no-wait` queues async write, returns `write_id`. `--scope TYPE:ID` (repeatable) anchors the write to existing objects: only they may be modified/deleted; by default new objects and links to them are allowed and anything else fails, and under `--scope-mode drop` only the objects a `--scope` names may be created and everything else is skipped; works with either extraction mode. `--scope-mode drop` (needs `--scope`) skips out-of-scope changes instead of failing and lists them under `changes.skipped_out_of_scope`; it creates only records a `--scope` names by primary key, and such a record need not be stored yet — create-or-update exactly the named record. `reject` is the server default and is not sent. Instead of text, a **structured write** sends `structured_mutations` — deterministic creates, updates and deletes with no extraction: `--mutations-file PATH` or `--mutations-stdin` (one JSON array, same shape as the API's `structured_mutations`; see [Structured writes](#structured-writes)) and/or `--delete TYPE:ID` (repeatable, `--scope` syntax: `Person:<xuid>` or `Person:name=Ada`; appended after the file's entries). Text and mutations are exclusive, as are the file and stdin forms; mutations refuse `--scope` and `--extraction deep`; empty or whitespace-only text is refused (exit **2**, before anything is read or sent). A text write's output lists what it extracted as `Type(identifier)` tags (`extracted`); a structured write's does not. Both carry the server's `changes` block (`created` / `updated` with per-field `old_value` → `new_value` / `deleted`, and any other bucket such as `created_keyless_objects`) whenever the write changed something, and on a drop-mode write that skipped everything, where the block carries `skipped_out_of_scope` alone. A `--no-wait` answer carries only the `write_id`; `write-status` reports the `changes` once the write completes. |
257
+ | **`write-status`** `<write_id>` … | Poll async writes until terminal or `--timeout` (default 60s). Accepts multiple ids. A completed write's non-empty `changes` block is kept, as on `write`. |
253
258
  | **`read`** `<query>` | Read from active instance. `--read-mode single\|raw\|xresponse`, `--scope TYPE:ID` (repeatable), `--scope-relations`, `--related-types` (also return `related_types`: per object type the read touched, the fields it did not return and the object types a declared relation links it to; needs `instance.get_own` on the key), `--related-types-depth N` (1, 2 or 3, refused as a usage error otherwise; follow the relations N levels out, each catalog entry saying its distance; implies `--related-types`). |
254
259
 
255
260
  Requires **`--instance-id`** or **`$XMEM_INSTANCE_ID`** for read/write (or auto-pick when exactly one instance is visible — see [Global flags](#global-flags--environment)).
256
261
 
262
+ #### Structured writes
263
+
264
+ `--mutations-file PATH` / `--mutations-stdin` send one JSON array as the API's `structured_mutations`, as given. The server takes at most **100** entries per write, `--delete` entries included; the CLI refuses more with exit **2** before sending anything. `--mutations-stdin` needs a pipe or a file on stdin and refuses a terminal. Each entry is one of:
265
+
266
+ - **`object_mutation`** — `{"object_mutation": {"object_type": "Person", "create": {"key": {"name": "Ada"}, "values": {"age": 36}}}}`. `update` takes the same `key` + `values` (the key may be `{"xuid": "…"}`; a null value clears a field); `delete` takes `{"key": {…}}` alone.
267
+ - **`relation_mutation`** — `{"relation_mutation": {"relation_type": "works_at", "create": {"endpoints": [{"object_name": "person", "key": {"name": "Ada"}}, {"object_name": "company", "key": {"name": "ACME"}}]}}}`. `create` lists every participant role, each keyed by primary key or `{"xuid": "…"}`, with no `key` and no `values`. `delete` takes either `endpoints` (a subset may be given) or a top-level `"key": {"xuid": "…"}` — never both — and `"allow_bulk_delete": true` to remove every matched link (without it, a delete matching more than one link is refused). Relation `update` is always refused: re-link by deleting and creating.
268
+
269
+ `--delete TYPE:ID` appends the object `delete` above; each key field may appear once and none may be empty. A delete that matches no record is a no-op, not an error: exit **0** and no `deleted` line — in a terminal the write reads `write <id> ok · nothing matched or changed`.
270
+
271
+ A deployment that has not enabled structured writes answers **400** `Structured writes are not enabled on this deployment.`; a server that predates structured writes answers **422** with two errors, both reported: `Field required (field: text); Extra inputs are not permitted (field: structured_mutations)`. Both exit **3** with `stage: "request"`.
272
+
257
273
  ---
258
274
 
259
275
  ### Org inventory
@@ -640,6 +656,10 @@ Some commands attach a more specific `stage` (`validate`, `usage`, `limit`, `not
640
656
 
641
657
  **Breaking change in 0.0.5:** HTTP **403** previously exited **4** (`auth`); it now exits **3** (`permission`). Scripts that treated exit **4** as “any auth-layer failure” must check `stage` or handle exit **3** for permission denials.
642
658
 
659
+ **New in 1.5.1:** `write --scope-mode drop` asks for a scoped write that skips and reports changes outside its scope instead of failing, and that creates only the records its `--scope` names — which may not be stored yet. The report is `changes.skipped_out_of_scope`: one terminal line per operation, type and set of fields, carrying `count=N`, the number of records it stands for, and naming a record's key only when the `--scope` named that record. A drop-mode write that skipped everything still returns a `changes` block, holding that report alone. A server that predates drop mode refuses `mode` in the scope (exit **3**), which is a clean failure; a deployment part-way through rolling it out is not, so publish 1.5.1 only after the server side is fully deployed — see [Releasing `xmemcli`](../docs/cli/XMEMCLI-RELEASE.md). `--scope-mode reject` is never sent, so plain scoped writes work against any server.
660
+
661
+ **New in 1.5.1:** `write` and `write-status` keep the server's `changes` block — what the write created, updated (field by field, old → new) and deleted, plus bulk summaries and advisory records — verbatim in `--json` output. In a terminal each change is one indented line: a bulk delete or update reads `deleted 3 × Ticket where status='open'`, one the server refused reads `not deleted Ticket: refused — <reason>`, the advisory `overwritten_field_values` / `merged_field_conflicts` records follow as `note:` lines, and past 50 lines the rest is counted in one closing line (`--json` is never truncated); `write-status` with several ids prints a status line per write, each followed by its changes. `changes` is omitted when the write changed nothing, so that output keeps its earlier shape; a drop-mode write that only skipped is the one write that changed nothing and still carries the block. `write` also takes structured mutations (`--mutations-file`, `--mutations-stdin`, `--delete TYPE:ID`) in place of text; the text positional is now optional, and a write with neither text nor mutations, or whose text is empty or only whitespace, is a usage error (exit **2**, `stage: "usage"`). Before 1.5.1 `write ""` reached the server, whose 400 exited **3** with `stage: "request"`, and whitespace-only text was sent and accepted. A structured write's output carries no `extracted`, since it runs no extraction; on a text write the tags now read `Type(identifier)` (`Person(name='Ada')`), as documented, where they read `?(Type)`. A malformed `--scope` (on `write` and `read`) or `--delete` — now including a key field given twice or an empty value — reports `stage: "usage"`, still exit **2**; `--scope` reported `"request"`. `--mutations-stdin` and `--schema-stdin` refuse a terminal instead of waiting on it, and every input file and piped input is read by one reader and decoded as strict UTF-8, so an input that is not UTF-8 (`--mutations-file`, `--schema-file`, `--migration-plan`, `schema suggestions decide --file`, the `xmd validate` / `convert` / `enhance` schema file, or the same bytes piped to `--mutations-stdin` / `--schema-stdin`) is a usage error at exit **2** instead of a traceback at exit **1** — or, piped, a garbled write at exit **0** — and a leading UTF-8 byte-order mark is accepted. Wherever an HTTP error from the API is reported as `HTTP <status>: …`, a body in the API's error envelope now gives the server's own message (`HTTP 400: <message>`, plus the request `field` when the server names one) instead of the first 200 characters of the raw body.
662
+
643
663
  **New in 1.3.0:** exit **8** with `stage: "processing"` for a `write` whose server-side wait window expired while the write kept running. In `--json` mode that document also carries a `write_id` field (omitted when the server sent no handle), so a script polls with it directly instead of parsing the message. Additive — no previously documented mapping changed — but a script that reads “non-zero, and not a code I recognise” as failed-and-retryable will now retry a write that has **not** failed, starting a second one the account pays for. Branch on **8** (or on `stage`) and poll `xmemcli write-status <write_id>` until it settles.
644
664
 
645
665
  **New in 1.2.0:** `instance templates` lists the ready-made instance shapes the service ships, with the prose to choose between them, and `instance create --template <id>` builds one from a pinned schema the server already holds. No schema is generated on that path: no description to compose, no `xmd generate` hop, and the instance comes out identical to the one the console would have made from the same template. (Creating an instance still generates its agent brief afterwards, on this path as on any other.)
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "xmemcli"
3
- version = "1.4.0"
3
+ version = "1.5.1"
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"
@@ -1,3 +1,3 @@
1
1
  """Standalone xmemcli package (``xmemcli`` console script)."""
2
2
 
3
- __version__ = "1.4.0"
3
+ __version__ = "1.5.1"
@@ -105,7 +105,7 @@ _OVERVIEW_SECTIONS: tuple[tuple[str, tuple[tuple[str, str], ...]], ...] = (
105
105
  (
106
106
  "Data",
107
107
  (
108
- ("write", "Write a fact to the active instance"),
108
+ ("write", "Write text, or create, update and delete records by key"),
109
109
  ("write-status", "Poll async writes until they complete (one or more write_ids)"),
110
110
  ("read", "Read from the active instance"),
111
111
  ),
@@ -163,10 +163,9 @@ def _read_schema_mutation_payload(
163
163
  ) -> tuple[dict[str, Any] | None, tuple[CliOutput, int] | None]:
164
164
  if not args.schema_file:
165
165
  return None, (make_error("--schema-file is required", stage="usage"), xmem_module().EXIT_USAGE)
166
- try:
167
- schema_text = Path(args.schema_file).read_text(encoding="utf-8")
168
- except OSError as exc:
169
- return None, (make_error(f"cannot read {args.schema_file}: {exc}", stage="usage"), xmem_module().EXIT_USAGE)
166
+ schema_text, err = xmem_module()._read_input_text(args.schema_file)
167
+ if err is not None:
168
+ return None, err
170
169
  schema_type = _infer_schema_type(args.schema_file, getattr(args, "schema_type", None))
171
170
  payload: dict[str, Any] = {
172
171
  "instance_schema": _build_instance_schema_payload(schema_text, schema_type),
@@ -174,10 +173,11 @@ def _read_schema_mutation_payload(
174
173
  }
175
174
  migration_plan = getattr(args, "migration_plan", None)
176
175
  if migration_plan:
177
- try:
178
- payload["migration_plan"] = json.loads(Path(migration_plan).read_text(encoding="utf-8"))
179
- except (OSError, json.JSONDecodeError) as exc:
180
- return None, (make_error(f"cannot read migration_plan: {exc}", stage="usage"), xmem_module().EXIT_USAGE)
176
+ # The shared input reader, like every other input file: a non-UTF-8 plan escaped as a traceback at exit 1.
177
+ plan, err = xmem_module()._read_json_input(migration_plan)
178
+ if err is not None:
179
+ return None, err
180
+ payload["migration_plan"] = plan
181
181
  return payload, None
182
182
 
183
183
 
@@ -776,17 +776,14 @@ def cmd_instance_create(args: argparse.Namespace) -> tuple[CliOutput, int]:
776
776
  # The id is the whole input: no file to read, nothing to parse, and
777
777
  # nothing generated. The server holds the shape.
778
778
  payload["template_id"] = template_id
779
- elif args.schema_stdin:
780
- payload["instance_schema"] = _build_instance_schema_payload(
781
- sys.stdin.read(), _infer_schema_type(None, args.schema_type)
779
+ elif args.schema_stdin or args.schema_file:
780
+ schema_text, err = m._read_input_text(
781
+ args.schema_file, use_stdin=args.schema_stdin, stdin_flag="--schema-stdin", file_flag="--schema-file"
782
782
  )
783
- elif args.schema_file:
784
- try:
785
- schema_text = Path(args.schema_file).read_text(encoding="utf-8")
786
- except OSError as exc:
787
- return (make_error(f"cannot read {args.schema_file}: {exc}", stage="usage"), m.EXIT_USAGE)
783
+ if err is not None:
784
+ return err
788
785
  payload["instance_schema"] = _build_instance_schema_payload(
789
- schema_text, _infer_schema_type(args.schema_file, args.schema_type)
786
+ schema_text, _infer_schema_type(None if args.schema_stdin else args.schema_file, args.schema_type)
790
787
  )
791
788
  else:
792
789
  return (
@@ -934,10 +931,9 @@ def cmd_xmd_enhance(args: argparse.Namespace) -> tuple[CliOutput, int]:
934
931
  return (make_error("instance has no live schema to enhance", stage="usage"), m.EXIT_USAGE)
935
932
  current_yaml = _yaml_dump_or_json(schema)
936
933
  elif schema_file:
937
- try:
938
- current_yaml = Path(schema_file).read_text(encoding="utf-8")
939
- except OSError as exc:
940
- return (make_error(f"cannot read {schema_file}: {exc}", stage="usage"), m.EXIT_USAGE)
934
+ current_yaml, err = m._read_input_text(schema_file)
935
+ if err is not None:
936
+ return err
941
937
  else:
942
938
  return (
943
939
  make_error(
@@ -988,10 +984,9 @@ def _xmd_validate_impl(
988
984
  return cluster_err
989
985
  if not cluster_id:
990
986
  return (make_error("could not resolve cluster_id", stage="usage"), m.EXIT_USAGE)
991
- try:
992
- text = Path(args.schema_file).read_text(encoding="utf-8")
993
- except OSError as exc:
994
- return (make_error(f"cannot read {args.schema_file}: {exc}", stage="usage"), m.EXIT_USAGE)
987
+ text, err = m._read_input_text(args.schema_file)
988
+ if err is not None:
989
+ return err
995
990
 
996
991
  use_json = format_hint == "json" or (format_hint is None and Path(args.schema_file).suffix.lower() == ".json")
997
992
  if use_json:
@@ -1038,12 +1033,9 @@ def cmd_xmd_convert(args: argparse.Namespace) -> tuple[CliOutput, int]:
1038
1033
  input_file = Path(args.schema_file)
1039
1034
  if not input_file.is_file():
1040
1035
  return (make_error(f"cannot read {args.schema_file}: file not found", stage="usage"), m.EXIT_USAGE)
1041
- try:
1042
- json_schema = json.loads(input_file.read_text(encoding="utf-8"))
1043
- except json.JSONDecodeError as exc:
1044
- return (make_error(f"invalid JSON in {args.schema_file}: {exc}", stage="usage"), m.EXIT_USAGE)
1045
- except OSError as exc:
1046
- return (make_error(f"cannot read {args.schema_file}: {exc}", stage="usage"), m.EXIT_USAGE)
1036
+ json_schema, err = m._read_json_input(args.schema_file)
1037
+ if err is not None:
1038
+ return err
1047
1039
 
1048
1040
  root_object = getattr(args, "root_object", None) or "MainObject"
1049
1041
  body: dict[str, Any] = {"json_schema": json_schema, "root_object_name": root_object}
@@ -1419,13 +1411,10 @@ def _curate_suggestions_apply(item: dict[str, Any], *, verbose: bool) -> dict[st
1419
1411
  def _read_decisions_file(path: str) -> tuple[list[dict[str, Any]] | None, tuple[CliOutput, int] | None]:
1420
1412
  """Parse a decisions JSON file into wire rows; any malformed row is a usage error."""
1421
1413
  m = xmem_module()
1422
- try:
1423
- raw = json.loads(Path(path).read_text(encoding="utf-8"))
1424
- except (OSError, json.JSONDecodeError) as exc:
1425
- return None, (
1426
- make_error(f"cannot read decisions file: {exc}", stage="usage"),
1427
- m.EXIT_USAGE,
1428
- )
1414
+ # The shared input reader, like every other input file: a non-UTF-8 file escaped as a traceback at exit 1.
1415
+ raw, err = m._read_json_input(path)
1416
+ if err is not None:
1417
+ return None, err
1429
1418
  if isinstance(raw, dict) and isinstance(raw.get("decisions"), list):
1430
1419
  raw = raw["decisions"]
1431
1420
  if not isinstance(raw, list):
@@ -79,12 +79,13 @@ class OutputKind(StrEnum):
79
79
  server payload, and that's fine.
80
80
  """
81
81
 
82
- #: Synchronous write that completed (``POST …/write``). Curated payload:
83
- #: ``write_id`` + ``extracted`` (compact ``Type(name)`` tags).
82
+ #: Synchronous write that completed (``POST …/write``). Curated payload: ``write_id``, ``extracted`` (compact
83
+ #: ``Type(identifier)`` tags) on a text write only — a structured write runs no extraction — and the server's
84
+ #: ``changes`` block verbatim when any of its buckets is non-empty.
84
85
  WRITE_SYNC = "write_sync"
85
86
 
86
- #: Asynchronous write that was queued (``POST …/write_async``). Curated
87
- #: payload: ``write_id``; the caller must poll.
87
+ #: Asynchronous write that was queued (``POST …/write_async``). Curated payload: ``write_id``; the caller must poll.
88
+ #: ``changes`` is curated as on `WRITE_SYNC`, but the queued answer carries none: it arrives on `WRITE_STATUS`.
88
89
  WRITE_ASYNC = "write_async"
89
90
 
90
91
  #: Result of an ``async_write_status`` poll. Curated payload: ``write_id`` + ``write_status``
@@ -92,7 +93,7 @@ class OutputKind(StrEnum):
92
93
  #: ``"error"`` / ``"unknown"`` fallbacks — whichever of ``submitted_at`` / ``started_at`` /
93
94
  #: ``completed_at`` is set, ``restart_count`` once non-zero (``--verbose`` keeps a zero count and
94
95
  #: drops only the null an unknown ``write_id`` reports), and ``error_detail`` / ``error``
95
- #: / ``http_status`` / ``timed_out`` when applicable.
96
+ #: / ``http_status`` / ``timed_out`` when applicable, and ``changes`` as on `WRITE_SYNC`.
96
97
  WRITE_STATUS = "write_status"
97
98
 
98
99
  #: Read in ``mode=single-answer``. Curated payload: ``answer`` (falls