lucen-cli 0.1.0__tar.gz → 0.1.2__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.
Files changed (103) hide show
  1. {lucen_cli-0.1.0 → lucen_cli-0.1.2}/PKG-INFO +29 -5
  2. {lucen_cli-0.1.0 → lucen_cli-0.1.2}/README.md +27 -3
  3. {lucen_cli-0.1.0 → lucen_cli-0.1.2}/pyproject.toml +2 -2
  4. {lucen_cli-0.1.0 → lucen_cli-0.1.2}/src/lucen/hub.py +3 -3
  5. {lucen_cli-0.1.0 → lucen_cli-0.1.2}/src/lucen_cli/__init__.py +1 -1
  6. {lucen_cli-0.1.0 → lucen_cli-0.1.2}/src/lucen_cli/client.py +24 -4
  7. {lucen_cli-0.1.0 → lucen_cli-0.1.2}/src/lucen_cli/cmd_quickstart.py +3 -3
  8. {lucen_cli-0.1.0 → lucen_cli-0.1.2}/src/lucen_cli/cmd_robot.py +67 -2
  9. {lucen_cli-0.1.0 → lucen_cli-0.1.2}/src/lucen_cli/cmd_runs.py +21 -5
  10. {lucen_cli-0.1.0 → lucen_cli-0.1.2}/src/lucen_cli/cmd_serve.py +60 -5
  11. lucen_cli-0.1.2/src/lucen_cli/extras.py +29 -0
  12. {lucen_cli-0.1.0 → lucen_cli-0.1.2}/src/lucen_cli/hf_dataset.py +90 -16
  13. {lucen_cli-0.1.0 → lucen_cli-0.1.2}/src/lucen_cli/main.py +28 -3
  14. {lucen_cli-0.1.0 → lucen_cli-0.1.2}/src/lucen_cli/output.py +12 -1
  15. {lucen_cli-0.1.0 → lucen_cli-0.1.2}/src/lucen_cli/push.py +2 -0
  16. lucen_cli-0.1.2/src/lucen_cli/rl/mirror.py +188 -0
  17. {lucen_cli-0.1.0 → lucen_cli-0.1.2}/src/lucen_cli/rl/ppo.py +24 -1
  18. {lucen_cli-0.1.0 → lucen_cli-0.1.2}/src/lucen_cli/rl/tasks.py +34 -2
  19. {lucen_cli-0.1.0 → lucen_cli-0.1.2}/src/lucen_cli/rl/vecenv.py +104 -1
  20. {lucen_cli-0.1.0 → lucen_cli-0.1.2}/src/lucen_cli/robot/__init__.py +2 -1
  21. {lucen_cli-0.1.0 → lucen_cli-0.1.2}/src/lucen_cli/robot/checks.py +12 -1
  22. lucen_cli-0.1.2/src/lucen_cli/robot/collada.py +338 -0
  23. {lucen_cli-0.1.0 → lucen_cli-0.1.2}/src/lucen_cli/robot/draft.py +85 -6
  24. lucen_cli-0.1.2/src/lucen_cli/robot/gains.py +191 -0
  25. {lucen_cli-0.1.0 → lucen_cli-0.1.2}/src/lucen_cli/robot/model.py +30 -1
  26. {lucen_cli-0.1.0 → lucen_cli-0.1.2}/src/lucen_cli/robot/urdf.py +221 -11
  27. {lucen_cli-0.1.0 → lucen_cli-0.1.2}/src/lucen_cli/robot_assets.py +2 -3
  28. {lucen_cli-0.1.0 → lucen_cli-0.1.2}/src/lucen_cli/rollout/engine.py +4 -11
  29. {lucen_cli-0.1.0 → lucen_cli-0.1.2}/src/lucen_cli/rollout/obs.py +49 -9
  30. {lucen_cli-0.1.0 → lucen_cli-0.1.2}/src/lucen_cli/rollout/plant.py +9 -0
  31. {lucen_cli-0.1.0 → lucen_cli-0.1.2}/src/lucen_cli/rollout/posture.py +46 -0
  32. {lucen_cli-0.1.0 → lucen_cli-0.1.2}/src/lucen_cli/rollout/worker.py +2 -2
  33. {lucen_cli-0.1.0 → lucen_cli-0.1.2}/src/lucen_cli/serve/lerobot_backend.py +3 -2
  34. {lucen_cli-0.1.0 → lucen_cli-0.1.2}/src/lucen_cli/serve/worker.py +37 -4
  35. {lucen_cli-0.1.0 → lucen_cli-0.1.2}/src/lucen_cli/templates/ppo_mujoco.py +151 -7
  36. {lucen_cli-0.1.0 → lucen_cli-0.1.2}/src/lucen_cli/templates/smolvla_lora.py +311 -32
  37. {lucen_cli-0.1.0 → lucen_cli-0.1.2}/src/lucen_cli/vla_manifest.py +7 -1
  38. {lucen_cli-0.1.0 → lucen_cli-0.1.2}/LICENSE +0 -0
  39. {lucen_cli-0.1.0 → lucen_cli-0.1.2}/src/lucen/__init__.py +0 -0
  40. {lucen_cli-0.1.0 → lucen_cli-0.1.2}/src/lucen/errors.py +0 -0
  41. {lucen_cli-0.1.0 → lucen_cli-0.1.2}/src/lucen/progress.py +0 -0
  42. {lucen_cli-0.1.0 → lucen_cli-0.1.2}/src/lucen/py.typed +0 -0
  43. {lucen_cli-0.1.0 → lucen_cli-0.1.2}/src/lucen/results.py +0 -0
  44. {lucen_cli-0.1.0 → lucen_cli-0.1.2}/src/lucen_cli/checkpoint_dir.py +0 -0
  45. {lucen_cli-0.1.0 → lucen_cli-0.1.2}/src/lucen_cli/checkpoint_publisher.py +0 -0
  46. {lucen_cli-0.1.0 → lucen_cli-0.1.2}/src/lucen_cli/cmd_auth.py +0 -0
  47. {lucen_cli-0.1.0 → lucen_cli-0.1.2}/src/lucen_cli/cmd_datasets.py +0 -0
  48. {lucen_cli-0.1.0 → lucen_cli-0.1.2}/src/lucen_cli/cmd_holds.py +0 -0
  49. {lucen_cli-0.1.0 → lucen_cli-0.1.2}/src/lucen_cli/cmd_hub.py +0 -0
  50. {lucen_cli-0.1.0 → lucen_cli-0.1.2}/src/lucen_cli/cmd_model.py +0 -0
  51. {lucen_cli-0.1.0 → lucen_cli-0.1.2}/src/lucen_cli/cmd_org.py +0 -0
  52. {lucen_cli-0.1.0 → lucen_cli-0.1.2}/src/lucen_cli/cmd_repo.py +0 -0
  53. {lucen_cli-0.1.0 → lucen_cli-0.1.2}/src/lucen_cli/cmd_rollout.py +0 -0
  54. {lucen_cli-0.1.0 → lucen_cli-0.1.2}/src/lucen_cli/cmd_worker.py +0 -0
  55. {lucen_cli-0.1.0 → lucen_cli-0.1.2}/src/lucen_cli/config.py +0 -0
  56. {lucen_cli-0.1.0 → lucen_cli-0.1.2}/src/lucen_cli/context.py +0 -0
  57. {lucen_cli-0.1.0 → lucen_cli-0.1.2}/src/lucen_cli/contract_derive.py +0 -0
  58. {lucen_cli-0.1.0 → lucen_cli-0.1.2}/src/lucen_cli/embodiment.py +0 -0
  59. {lucen_cli-0.1.0 → lucen_cli-0.1.2}/src/lucen_cli/errors.py +0 -0
  60. {lucen_cli-0.1.0 → lucen_cli-0.1.2}/src/lucen_cli/gate_scorer.py +0 -0
  61. {lucen_cli-0.1.0 → lucen_cli-0.1.2}/src/lucen_cli/group.py +0 -0
  62. {lucen_cli-0.1.0 → lucen_cli-0.1.2}/src/lucen_cli/live.py +0 -0
  63. {lucen_cli-0.1.0 → lucen_cli-0.1.2}/src/lucen_cli/local.py +0 -0
  64. {lucen_cli-0.1.0 → lucen_cli-0.1.2}/src/lucen_cli/model_card.py +0 -0
  65. {lucen_cli-0.1.0 → lucen_cli-0.1.2}/src/lucen_cli/offscreen.py +0 -0
  66. {lucen_cli-0.1.0 → lucen_cli-0.1.2}/src/lucen_cli/onnx_manifest.py +0 -0
  67. {lucen_cli-0.1.0 → lucen_cli-0.1.2}/src/lucen_cli/onnx_writer.py +0 -0
  68. {lucen_cli-0.1.0 → lucen_cli-0.1.2}/src/lucen_cli/ort_quiet.py +0 -0
  69. {lucen_cli-0.1.0 → lucen_cli-0.1.2}/src/lucen_cli/progress.py +0 -0
  70. {lucen_cli-0.1.0 → lucen_cli-0.1.2}/src/lucen_cli/pull.py +0 -0
  71. {lucen_cli-0.1.0 → lucen_cli-0.1.2}/src/lucen_cli/rl/__init__.py +0 -0
  72. {lucen_cli-0.1.0 → lucen_cli-0.1.2}/src/lucen_cli/robot/confine.py +0 -0
  73. {lucen_cli-0.1.0 → lucen_cli-0.1.2}/src/lucen_cli/robot/hf.py +0 -0
  74. {lucen_cli-0.1.0 → lucen_cli-0.1.2}/src/lucen_cli/robot/hub.py +0 -0
  75. {lucen_cli-0.1.0 → lucen_cli-0.1.2}/src/lucen_cli/robot/intake.py +0 -0
  76. {lucen_cli-0.1.0 → lucen_cli-0.1.2}/src/lucen_cli/robot/pose.py +0 -0
  77. {lucen_cli-0.1.0 → lucen_cli-0.1.2}/src/lucen_cli/robot/publish.py +0 -0
  78. {lucen_cli-0.1.0 → lucen_cli-0.1.2}/src/lucen_cli/robot/refs.py +0 -0
  79. {lucen_cli-0.1.0 → lucen_cli-0.1.2}/src/lucen_cli/robot/thumbnail.py +0 -0
  80. {lucen_cli-0.1.0 → lucen_cli-0.1.2}/src/lucen_cli/robot/worker.py +0 -0
  81. {lucen_cli-0.1.0 → lucen_cli-0.1.2}/src/lucen_cli/rollout/__init__.py +0 -0
  82. {lucen_cli-0.1.0 → lucen_cli-0.1.2}/src/lucen_cli/rollout/assets.py +0 -0
  83. {lucen_cli-0.1.0 → lucen_cli-0.1.2}/src/lucen_cli/rollout/conditions.py +0 -0
  84. {lucen_cli-0.1.0 → lucen_cli-0.1.2}/src/lucen_cli/rollout/contract.py +0 -0
  85. {lucen_cli-0.1.0 → lucen_cli-0.1.2}/src/lucen_cli/rollout/harness.py +0 -0
  86. {lucen_cli-0.1.0 → lucen_cli-0.1.2}/src/lucen_cli/rollout/video.py +0 -0
  87. {lucen_cli-0.1.0 → lucen_cli-0.1.2}/src/lucen_cli/serve/__init__.py +0 -0
  88. {lucen_cli-0.1.0 → lucen_cli-0.1.2}/src/lucen_cli/serve/msgpack_numpy.py +0 -0
  89. {lucen_cli-0.1.0 → lucen_cli-0.1.2}/src/lucen_cli/serve/policy.py +0 -0
  90. {lucen_cli-0.1.0 → lucen_cli-0.1.2}/src/lucen_cli/serve/server.py +0 -0
  91. {lucen_cli-0.1.0 → lucen_cli-0.1.2}/src/lucen_cli/serve/session.py +0 -0
  92. {lucen_cli-0.1.0 → lucen_cli-0.1.2}/src/lucen_cli/serve/stub_skill.py +0 -0
  93. {lucen_cli-0.1.0 → lucen_cli-0.1.2}/src/lucen_cli/serve/vla.py +0 -0
  94. {lucen_cli-0.1.0 → lucen_cli-0.1.2}/src/lucen_cli/sim_words.py +0 -0
  95. {lucen_cli-0.1.0 → lucen_cli-0.1.2}/src/lucen_cli/state.py +0 -0
  96. {lucen_cli-0.1.0 → lucen_cli-0.1.2}/src/lucen_cli/templates/__init__.py +0 -0
  97. {lucen_cli-0.1.0 → lucen_cli-0.1.2}/src/lucen_cli/templates/ppo_isaac.py +0 -0
  98. {lucen_cli-0.1.0 → lucen_cli-0.1.2}/src/lucen_cli/templates/stub_ppo.py +0 -0
  99. {lucen_cli-0.1.0 → lucen_cli-0.1.2}/src/lucen_cli/templates/stub_skill.py +0 -0
  100. {lucen_cli-0.1.0 → lucen_cli-0.1.2}/src/lucen_cli/tfevents.py +0 -0
  101. {lucen_cli-0.1.0 → lucen_cli-0.1.2}/src/lucen_cli/training_curve.py +0 -0
  102. {lucen_cli-0.1.0 → lucen_cli-0.1.2}/src/lucen_cli/transfer.py +0 -0
  103. {lucen_cli-0.1.0 → lucen_cli-0.1.2}/src/lucen_cli/worker.py +0 -0
@@ -1,13 +1,13 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: lucen-cli
3
- Version: 0.1.0
3
+ Version: 0.1.2
4
4
  Summary: MouseMouse CLI (`lucen`): push robot data, train and sim-test policies, hand them to a robot through a consent gate
5
5
  License-Expression: Apache-2.0
6
6
  License-File: LICENSE
7
7
  Requires-Dist: typer>=0.20
8
8
  Requires-Dist: rich>=14.0
9
9
  Requires-Dist: httpx>=0.28
10
- Requires-Dist: lucen-sdk>=0.1.0,<0.2
10
+ Requires-Dist: lucen-sdk>=0.1.1,<0.2
11
11
  Requires-Dist: pyyaml>=6.0
12
12
  Requires-Dist: huggingface-hub>=1.0,<2
13
13
  Requires-Dist: mujoco==3.12.0 ; extra == 'rl'
@@ -63,9 +63,10 @@ Extras bring the engines some commands need; everything else stays a few MB:
63
63
  | `uv tool install "lucen-cli[serve]"` | ONNX Runtime, websockets, msgpack | `lucen serve` (a hosted-endpoint server on your GPU box) |
64
64
  | `uv tool install "lucen-cli[vla]"` | LeRobot, torch, PEFT | the `smolvla_lora` template |
65
65
 
66
- A MouseMouse hub also serves its own copy of these wheels, built from the hub's
67
- own commit: `curl -fsSL <hub API>/install.sh | sh -s -- cli` installs from
68
- there and points the CLI at that hub.
66
+ A MouseMouse hub built from its API image also serves its own copy of these
67
+ wheels for machines that cannot reach PyPI, built from the hub's own commit:
68
+ `curl -fsSL <hub API>/install.sh | sh -s -- cli` installs from there and points
69
+ the CLI at that hub.
69
70
 
70
71
  ## First steps
71
72
 
@@ -110,4 +111,27 @@ The robot side of the consent gate is
110
111
  [`lucen-device`](https://pypi.org/project/lucen-device/); the hub's tools for an
111
112
  AI agent are [`lucen-mcp`](https://pypi.org/project/lucen-mcp/).
112
113
 
114
+ ## Changelog
115
+
116
+ - **0.1.2** (2026-10-18): `lucen robot init` takes the URDFs people actually
117
+ have: COLLADA (`.dae`) meshes are converted to STL, `package://` paths deeper
118
+ than the uploaded folder resolve, a xacro file is refused with the command
119
+ that expands it, and a model file you shipped is never overwritten. A
120
+ floating robot gets a floor and its declared pose's height, a model with no
121
+ gains gets a starting kp/kd found in simulation, and each leg's grounded leaf
122
+ is proposed as its foot. `lucen push --kind dataset` writes a LeRobot
123
+ dataset's episode and frame counts, as `lucen datasets import` does, and the
124
+ import without a terminal names the line to run with `--yes`. `lucen runs
125
+ create --max-hours`; `lucen endpoints estimate --owner`; a long wait is told
126
+ as a UTC time. `smolvla_lora` trains on an `hf://` dataset, refuses a base
127
+ checkpoint of another family, and stamps a policy for its robot only when the
128
+ dataset matches it. `lucen serve` tells the hub why it stopped. Needs
129
+ `lucen-sdk` 0.1.1.
130
+ - **0.1.1** (2026-10-13): the built-in `ppo_mujoco` trainer's `velocity` preset
131
+ trains under the sim tests' own conditions (the action delay, the rate limit
132
+ and the pushes), so a quick run on Laika stands at about 2° of tilt instead
133
+ of a crouch; ONNX Runtime's start-up messages stay out of a run's log; every
134
+ "install the extra" hint names the PyPI line.
135
+ - **0.1.0**: the first release on PyPI.
136
+
113
137
  Apache-2.0. © 2026 Lucen Robotics.
@@ -27,9 +27,10 @@ Extras bring the engines some commands need; everything else stays a few MB:
27
27
  | `uv tool install "lucen-cli[serve]"` | ONNX Runtime, websockets, msgpack | `lucen serve` (a hosted-endpoint server on your GPU box) |
28
28
  | `uv tool install "lucen-cli[vla]"` | LeRobot, torch, PEFT | the `smolvla_lora` template |
29
29
 
30
- A MouseMouse hub also serves its own copy of these wheels, built from the hub's
31
- own commit: `curl -fsSL <hub API>/install.sh | sh -s -- cli` installs from
32
- there and points the CLI at that hub.
30
+ A MouseMouse hub built from its API image also serves its own copy of these
31
+ wheels for machines that cannot reach PyPI, built from the hub's own commit:
32
+ `curl -fsSL <hub API>/install.sh | sh -s -- cli` installs from there and points
33
+ the CLI at that hub.
33
34
 
34
35
  ## First steps
35
36
 
@@ -74,4 +75,27 @@ The robot side of the consent gate is
74
75
  [`lucen-device`](https://pypi.org/project/lucen-device/); the hub's tools for an
75
76
  AI agent are [`lucen-mcp`](https://pypi.org/project/lucen-mcp/).
76
77
 
78
+ ## Changelog
79
+
80
+ - **0.1.2** (2026-10-18): `lucen robot init` takes the URDFs people actually
81
+ have: COLLADA (`.dae`) meshes are converted to STL, `package://` paths deeper
82
+ than the uploaded folder resolve, a xacro file is refused with the command
83
+ that expands it, and a model file you shipped is never overwritten. A
84
+ floating robot gets a floor and its declared pose's height, a model with no
85
+ gains gets a starting kp/kd found in simulation, and each leg's grounded leaf
86
+ is proposed as its foot. `lucen push --kind dataset` writes a LeRobot
87
+ dataset's episode and frame counts, as `lucen datasets import` does, and the
88
+ import without a terminal names the line to run with `--yes`. `lucen runs
89
+ create --max-hours`; `lucen endpoints estimate --owner`; a long wait is told
90
+ as a UTC time. `smolvla_lora` trains on an `hf://` dataset, refuses a base
91
+ checkpoint of another family, and stamps a policy for its robot only when the
92
+ dataset matches it. `lucen serve` tells the hub why it stopped. Needs
93
+ `lucen-sdk` 0.1.1.
94
+ - **0.1.1** (2026-10-13): the built-in `ppo_mujoco` trainer's `velocity` preset
95
+ trains under the sim tests' own conditions (the action delay, the rate limit
96
+ and the pushes), so a quick run on Laika stands at about 2° of tilt instead
97
+ of a crouch; ONNX Runtime's start-up messages stay out of a run's log; every
98
+ "install the extra" hint names the PyPI line.
99
+ - **0.1.0**: the first release on PyPI.
100
+
77
101
  Apache-2.0. © 2026 Lucen Robotics.
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "lucen-cli"
3
- version = "0.1.0"
3
+ version = "0.1.2"
4
4
  description = "MouseMouse CLI (`lucen`): push robot data, train and sim-test policies, hand them to a robot through a consent gate"
5
5
  # R1: `readme`, `license`, `license-files` and `[project.urls]` are what PyPI
6
6
  # renders; the LICENSE beside this file is Apache-2.0 verbatim (docs/DECISIONS.md "R1").
@@ -18,7 +18,7 @@ dependencies = [
18
18
  # Requires-Dist, where `[tool.uv.sources]` below does not reach, and PyPI has
19
19
  # to resolve it. The generated client is 0.x, so a minor bump is its breaking
20
20
  # signal: the CLI follows the SDK within one minor.
21
- "lucen-sdk>=0.1.0,<0.2",
21
+ "lucen-sdk>=0.1.1,<0.2",
22
22
  # M7-RL: `lucen runs create -f recipe.yaml`. The stub template stays stdlib
23
23
  # only and emits its own YAML; only the recipe *reader* needs a parser.
24
24
  "pyyaml>=6.0",
@@ -72,6 +72,7 @@ from lucen_cli.config import (
72
72
  )
73
73
  from lucen_cli.errors import CLIError
74
74
  from lucen_cli.errors import HubUnreachable as _CLIHubUnreachable
75
+ from lucen_cli.extras import install_line, pip_line
75
76
  from lucen_cli.progress import Reporter
76
77
  from lucen_cli.pull import run_pull
77
78
  from lucen_cli.push import run_push
@@ -85,9 +86,8 @@ NO_HUB_URL_FIX_PYTHON = (
85
86
 
86
87
  #: The sentence `hub.robots` and a local rollout refuse with when MuJoCo is absent.
87
88
  ENGINE_FIX = (
88
- "Install the rollout extra into this environment: "
89
- 'uv pip install --index lucen=<hub>/install/simple/ "lucen-cli[rollout]" '
90
- '(or, from a checkout: uv pip install ./packages/sdk/py "./packages/cli[rollout]").'
89
+ f"Install the rollout extra into this environment: {pip_line('rollout')} "
90
+ f"(as a tool on the PATH: {install_line('rollout')})."
91
91
  )
92
92
 
93
93
  #: `recipe` as a script hands it over: a mapping, or the path of a YAML/JSON file.
@@ -1,3 +1,3 @@
1
1
  """MouseMouse CLI."""
2
2
 
3
- __version__ = "0.1.0"
3
+ __version__ = "0.1.2"
@@ -28,6 +28,7 @@ import re
28
28
  import time
29
29
  from collections.abc import Callable, Iterator, Sequence
30
30
  from dataclasses import dataclass
31
+ from datetime import UTC, datetime, timedelta
31
32
  from http import HTTPStatus
32
33
  from types import TracebackType
33
34
  from typing import Any, Final
@@ -172,6 +173,7 @@ from lucen_sdk.models import (
172
173
  EndpointPage,
173
174
  EndpointReport,
174
175
  EndpointReportStatus,
176
+ EndpointServerStopReason,
175
177
  EndpointSession,
176
178
  EndpointSessionOpen,
177
179
  EndpointSessionOpened,
@@ -354,6 +356,18 @@ class RepoTarget:
354
356
  return cls(owner=owner, slug=slug)
355
357
 
356
358
 
359
+ def _retry_hint(retry: str | None) -> str:
360
+ """When to try again after a 429. A spend cap's or an allowance's `Retry-After` runs to the
361
+ 1st of next month, and "Wait 1189678 s" was the line a person read (T2)."""
362
+ seconds = int(retry) if retry and retry.strip().isdigit() else None
363
+ if seconds is None:
364
+ return "Wait a moment and run it again."
365
+ if seconds <= 3600:
366
+ return f"Wait {seconds} s and run it again."
367
+ when = datetime.now(UTC) + timedelta(seconds=seconds)
368
+ return f"Try again after {when:%Y-%m-%d %H:%M} UTC, or do what the hub's sentence above says."
369
+
370
+
357
371
  class HubClient:
358
372
  """Thin, typed, error-translating wrapper over the generated SDK."""
359
373
 
@@ -564,9 +578,7 @@ class HubClient:
564
578
  elif status == 429:
565
579
  retry = response.headers.get("retry-after")
566
580
  message = f"{action}: rate limited by the hub."
567
- hint = (
568
- f"Wait {retry} s and run it again." if retry else "Wait a moment and run it again."
569
- )
581
+ hint = _retry_hint(retry)
570
582
  elif status == 503:
571
583
  # A deliberate refusal ("this deployment cannot serve that"), not
572
584
  # an outage: the detail is the answer, and retrying will not help.
@@ -1432,6 +1444,7 @@ class HubClient:
1432
1444
  sessions: Sequence[dict[str, Any]] = (),
1433
1445
  error: str | None = None,
1434
1446
  log_chunk: str | None = None,
1447
+ stop_reason: str | None = None,
1435
1448
  ) -> Endpoint:
1436
1449
  states = []
1437
1450
  for item in sessions:
@@ -1453,6 +1466,7 @@ class HubClient:
1453
1466
  sessions=states if states else UNSET,
1454
1467
  error=error if error is not None else UNSET,
1455
1468
  log_chunk=log_chunk if log_chunk else UNSET,
1469
+ stop_reason=EndpointServerStopReason(stop_reason) if stop_reason else UNSET,
1456
1470
  )
1457
1471
  action = f"Reporting on endpoint {endpoint_id}"
1458
1472
  # F1: one attempt, translated — a hub that does not answer is an `ApiError`
@@ -1589,13 +1603,19 @@ class HubClient:
1589
1603
  total_frames: int,
1590
1604
  fps: float | None = None,
1591
1605
  robot_type: str | None = None,
1606
+ recorded_policy: str | None = None,
1592
1607
  ) -> DatasetMeta:
1593
- """`PUT /v1/datasets/{owner}/{repo}/meta` — the numbers `meta/info.json` declares."""
1608
+ """`PUT /v1/datasets/{owner}/{repo}/meta` — the numbers `meta/info.json` declares.
1609
+
1610
+ V3: `recorded_policy` (`owner/slug`) names the policy the robot ran while
1611
+ the data was recorded; left out, the hub records none.
1612
+ """
1594
1613
  body = DatasetMetaPut(
1595
1614
  episode_count=episode_count,
1596
1615
  total_frames=total_frames,
1597
1616
  fps=fps,
1598
1617
  robot_type=robot_type,
1618
+ recorded_policy=recorded_policy,
1599
1619
  )
1600
1620
  response = put_dataset_meta_api.sync_detailed(
1601
1621
  target.owner, target.slug, client=self._sdk, body=body
@@ -135,7 +135,7 @@ def plan_robot_file(path: Path) -> list[Step]:
135
135
  Step(
136
136
  f"lucen runs create --template ppo_mujoco --robot you/{slug} --executor worker",
137
137
  "Queue a PPO run on it; `lucen worker run` in a second terminal trains it "
138
- "here, free (needs the rl extra: install `cli[rl]`).",
138
+ "here, free (needs the rl extra: install `lucen-cli[rl]`).",
139
139
  )
140
140
  )
141
141
  return steps
@@ -151,7 +151,7 @@ def plan_robot_on_hub(robot: str) -> list[Step]:
151
151
  Step(
152
152
  "lucen worker run",
153
153
  "In a second terminal: claims the run, trains on this machine (free) and "
154
- "publishes the policy as a model repo (needs the rl extra: install `cli[rl]`).",
154
+ "publishes the policy as a model repo (needs the rl extra: install `lucen-cli[rl]`).",
155
155
  ),
156
156
  Step(
157
157
  f"lucen rollout {MODEL_PLACEHOLDER} --robot {target.full_name} --battery smoke --local",
@@ -205,7 +205,7 @@ def plan_no_file() -> list[Step]:
205
205
  Step(
206
206
  "lucen worker run",
207
207
  "In a second terminal: claims it, trains here (free) and publishes the policy "
208
- "(needs the rl extra: install `cli[rl]`).",
208
+ "(needs the rl extra: install `lucen-cli[rl]`).",
209
209
  ),
210
210
  Step(
211
211
  f"lucen rollout {MODEL_PLACEHOLDER} --robot lucen/laika --battery smoke --local",
@@ -237,7 +237,7 @@ def run_init(
237
237
  from lucen_cli.robot import draft as drafting
238
238
  from lucen_cli.robot import pose as poses
239
239
  from lucen_cli.robot import urdf
240
- from lucen_cli.robot.model import compile_file, facts_of
240
+ from lucen_cli.robot.model import compile_file, facts_of, rest_height
241
241
 
242
242
  if action not in _ACTIONS:
243
243
  raise RobotError("init.action", f"--action must be one of: {', '.join(_ACTIONS)}.")
@@ -306,6 +306,11 @@ def run_init(
306
306
  )
307
307
  if declared:
308
308
  _check_declared_pose(declared, facts)
309
+ base_height_m = conversion.base_height_m if conversion is not None else None
310
+ if declared and facts.floating and (conversion is not None or facts.default_source == "qpos0"):
311
+ # The height a converted model was lifted to is the straight-leg one (qpos0);
312
+ # the declared pose is what policies start from, so rest THAT on the floor.
313
+ base_height_m = rest_height(model, facts, declared) or base_height_m
309
314
  options = drafting.DraftOptions(
310
315
  control_hz=control_hz,
311
316
  action=action,
@@ -325,7 +330,7 @@ def run_init(
325
330
  mjcf_path=model_path.relative_to(root_dir).as_posix(),
326
331
  options=options,
327
332
  extra_limits=conversion.limits if conversion is not None else None,
328
- base_height_m=conversion.base_height_m if conversion is not None else None,
333
+ base_height_m=base_height_m,
329
334
  )
330
335
  if conversion is not None:
331
336
  for warning in conversion.warnings:
@@ -338,6 +343,7 @@ def run_init(
338
343
  "inferred_mass_kg": warning.get("inferred_mass_kg"),
339
344
  }
340
345
  )
346
+ _propose_gains(built, root_dir, model, facts, declared)
341
347
  card_path.write_text(
342
348
  json.dumps(built.document(), indent=2, ensure_ascii=False) + "\n", encoding="utf-8"
343
349
  )
@@ -363,6 +369,65 @@ def run_init(
363
369
  }
364
370
 
365
371
 
372
+ GAINS_FIELD = "interface.actuation[].kp / kd"
373
+
374
+
375
+ def _propose_gains(
376
+ built: Any, root_dir: Path, model: Any, facts: ModelFacts, declared: dict[str, float] | None
377
+ ) -> None:
378
+ """T1: attach a searched starting gain to the blocking kp/kd entry, as a proposal.
379
+
380
+ The entry stays blocking: accepting the proposal is re-running init with its
381
+ flags (the web's "Use these gains" does exactly that), so nothing is decided
382
+ for the person. When no candidate passes, the entry says so instead.
383
+ """
384
+ from lucen_cli.robot import gains
385
+
386
+ need = next((n for n in built.needs_you if n.get("field") == GAINS_FIELD), None)
387
+ if need is None:
388
+ return
389
+ pose = None
390
+ if not declared:
391
+ pose_need = next(
392
+ (n for n in built.needs_you if n.get("field") == "interface.default_pose"), None
393
+ )
394
+ if pose_need and isinstance(pose_need.get("suggestion"), dict):
395
+ pose = {str(k): float(v) for k, v in pose_need["suggestion"].items()}
396
+ try:
397
+ proposal = gains.propose(root_dir, built.card, model, facts, pose=pose)
398
+ except Exception as exc: # a proposal is help; it never fails init
399
+ need["why"] += f" (No starting gain was proposed: the search failed with {exc}.)"
400
+ return
401
+ if proposal is None:
402
+ need["why"] += (
403
+ " No uniform kp/kd up to 600 (with armature up to 0.1 kg·m²) held this model in "
404
+ "simulation, so there is no proposal: set gains per group."
405
+ )
406
+ return
407
+ line = gains.flags(proposal)
408
+ # `suggestion` is the flags (a string, as the hub's needs schema types it); `proposal`
409
+ # is the same values as numbers, for a page or an agent that fills them in.
410
+ need["suggestion"] = line
411
+ need["proposal"] = {
412
+ key: proposal[key] for key in ("kp", "kd", "armature") if proposal.get(key) is not None
413
+ }
414
+ if pose:
415
+ need["proposal"]["default_pose"] = pose
416
+ need["suggestion_source"] = (
417
+ "a PROPOSAL found by simulation, not read from the robot: the first uniform gain"
418
+ + (" (with armature, which a URDF cannot carry)" if proposal.get("armature") else "")
419
+ + " that passes `model.gains`, `stability.settle` and `stability.joint_response`"
420
+ + (" at the proposed default pose" if pose else "")
421
+ + f" ({proposal['candidates_tried']} candidate(s) simulated)"
422
+ )
423
+ need["settle"] = proposal.get("settle")
424
+ need["why"] += (
425
+ f" A starting point that held this model in simulation: {line}"
426
+ + (" (with the proposed default pose)" if pose else "")
427
+ + ". Check it against your motors, then pass it to `lucen robot init`."
428
+ )
429
+
430
+
366
431
  def _check_declared_pose(declared: dict[str, float], facts: ModelFacts) -> None:
367
432
  """`--default-pose` names only the model's 1-DoF joints, each inside its range."""
368
433
  known = {joint.name: joint for joint in facts.joints}
@@ -192,6 +192,16 @@ ParentOpt = Annotated[
192
192
  OwnerOpt = Annotated[
193
193
  str | None, typer.Option("--owner", help="Org slug to bill it to; default: you.")
194
194
  ]
195
+ MaxHoursOpt = Annotated[
196
+ float | None,
197
+ typer.Option(
198
+ "--max-hours",
199
+ min=0.01,
200
+ max=720,
201
+ help="With --template: the run's time limit. On Lucen cloud it is what the run "
202
+ "reserves against your free allowance (default 4 h).",
203
+ ),
204
+ ]
195
205
  JsonOpt = Annotated[bool, typer.Option("--json", help="Machine-readable output.")]
196
206
  IdempotencyOpt = Annotated[
197
207
  str | None,
@@ -266,15 +276,16 @@ def create_run(
266
276
  template: TemplateOpt = None,
267
277
  robot: RobotOpt = None,
268
278
  executor: ExecutorOpt = None,
279
+ max_hours: MaxHoursOpt = None,
269
280
  parent: ParentOpt = None,
270
281
  owner: OwnerOpt = None,
271
282
  idempotency_key: IdempotencyOpt = None,
272
283
  as_json: JsonOpt = False,
273
284
  ) -> None:
274
- """Submit a training run. It executes when a `lucen worker run` claims it."""
285
+ """Submit a training run: on your machine (`lucen worker run`), or on Lucen cloud."""
275
286
  state = ctx.ensure_object(AppState)
276
287
  recipe, file_owner, file_parent = load_recipe(
277
- file, template=template, robot=robot, executor=executor
288
+ file, template=template, robot=robot, executor=executor, max_hours=max_hours
278
289
  )
279
290
  try:
280
291
  client, run, created, web = _submit(
@@ -298,15 +309,18 @@ def load_recipe(
298
309
  template: str | None,
299
310
  robot: str | None,
300
311
  executor: str | None,
312
+ max_hours: float | None = None,
301
313
  ) -> tuple[dict[str, Any], str | None, str | None]:
302
314
  """A recipe from `-f FILE` or from `--template [--robot] [--executor]`, never both."""
303
315
  if file is not None and template is not None:
304
316
  raise CLIError("Give either -f RECIPE or --template, not both.")
305
317
  if file is not None:
306
- if robot is not None or executor is not None:
318
+ if robot is not None or executor is not None or max_hours is not None:
307
319
  raise CLIError(
308
- "--robot and --executor go with --template; a recipe file names its own.",
309
- hint="Put `robot:` and `executor:` in the file, or drop -f and use --template.",
320
+ "--robot, --executor and --max-hours go with --template; a recipe file "
321
+ "names its own.",
322
+ hint="Put `robot:`, `executor:` and `max_hours:` in the file, or drop -f and "
323
+ "use --template.",
310
324
  )
311
325
  return load_recipe_file(file)
312
326
  if template is None:
@@ -319,6 +333,8 @@ def load_recipe(
319
333
  recipe["robot"] = robot
320
334
  if executor is not None:
321
335
  recipe["executor"] = executor
336
+ if max_hours is not None:
337
+ recipe["max_hours"] = max_hours
322
338
  return recipe, None, None
323
339
 
324
340
 
@@ -19,6 +19,7 @@ import typer
19
19
 
20
20
  from lucen_cli.context import AppState
21
21
  from lucen_cli.errors import CLIError
22
+ from lucen_cli.extras import install_line
22
23
  from lucen_cli.group import LucenGroup
23
24
  from lucen_cli.output import emit_json, stdout
24
25
 
@@ -38,9 +39,7 @@ def _need_serve_extra() -> None:
38
39
  except ImportError as exc: # pragma: no cover - depends on the install
39
40
  raise CLIError(
40
41
  f"`lucen serve` needs the `serve` extra ({exc.name} is not installed).",
41
- hint=(
42
- 'uv tool install --from "./packages/cli[serve]" --with ./packages/sdk/py lucen-cli'
43
- ),
42
+ hint=install_line("serve"),
44
43
  ) from exc
45
44
 
46
45
 
@@ -160,6 +159,44 @@ def _is_directory(endpoint: Any) -> bool:
160
159
  return str(getattr(endpoint, "artifact", "onnx")) == "checkpoint_dir"
161
160
 
162
161
 
162
+ def billing_rule(endpoint: Any) -> str:
163
+ """One line: what this endpoint costs and how the hub counts it (P2)."""
164
+ from lucen_cli.client import unset_to_none
165
+
166
+ if not getattr(endpoint, "priced", False):
167
+ return "free: it runs on your own machine"
168
+ rate = float(getattr(endpoint, "rate_usd_per_hour", 0) or 0)
169
+ if unset_to_none(getattr(endpoint, "cost_basis", None)) == "sessions":
170
+ return f"${rate:.4f}/h per session's GPU time (created before uptime billing began)"
171
+ return (
172
+ f"${rate:.4f}/h for its uptime, from the claim until it stops, "
173
+ "whether or not a robot is connected"
174
+ )
175
+
176
+
177
+ def uptime(seconds: float) -> str:
178
+ """`41 s`, `12 min`, `3.5 h`: how long an endpoint has been billed for."""
179
+ if seconds < 90:
180
+ return f"{round(seconds)} s"
181
+ minutes = seconds / 60
182
+ if minutes < 90:
183
+ return f"{round(minutes)} min"
184
+ return f"{minutes / 60:.1f} h"
185
+
186
+
187
+ def cost_line(endpoint: Any) -> str:
188
+ """`cost so far $0.0171 · up for 60 s` / `cost $0.0171 · up for 41 min` / `free`."""
189
+ from lucen_cli.client import unset_to_none
190
+
191
+ if not getattr(endpoint, "priced", False):
192
+ return "free"
193
+ cost = float(unset_to_none(getattr(endpoint, "cost_usd", None)) or 0)
194
+ word = "cost so far" if str(endpoint.status) == "running" else "cost"
195
+ seconds = unset_to_none(getattr(endpoint, "billed_seconds", None))
196
+ up = f" · up for {uptime(float(seconds))}" if seconds is not None else ""
197
+ return f"{word} ${cost:.4f}{up}"
198
+
199
+
163
200
  def idle_rule(endpoint: Any) -> str:
164
201
  """One line: when the hub stops this endpoint for being idle, and the session timeout."""
165
202
  from lucen_cli.client import unset_to_none
@@ -288,6 +325,7 @@ def create(
288
325
  markup=False,
289
326
  )
290
327
  _print_artifact(endpoint)
328
+ stdout.print(f" {billing_rule(endpoint)}", markup=False)
291
329
  stdout.print(f" {idle_rule(endpoint)}", markup=False)
292
330
  if str(endpoint.executor) == "worker":
293
331
  extra = "[vla,serve]" if _is_directory(endpoint) else "[serve]"
@@ -316,12 +354,22 @@ def estimate(
316
354
  policy_path: Annotated[
317
355
  str | None, typer.Option("--policy-path", help="The checkpoint directory it would serve.")
318
356
  ] = None,
357
+ owner: Annotated[
358
+ str | None, typer.Option("--owner", help="Price it for this handle (as `create` bills).")
359
+ ] = None,
319
360
  as_json: Annotated[bool, typer.Option("--json", help="Machine-readable output.")] = False,
320
361
  ) -> None:
321
362
  """Price an endpoint: the tier its size selects x hours. Creates nothing."""
322
363
  state = ctx.ensure_object(AppState)
323
364
  body: dict[str, Any] = {"model_repo": model, "executor": executor, "hours": hours}
324
- for key, value in (("params", params), ("onnx_path", onnx_path), ("policy_path", policy_path)):
365
+ # T2: without `--owner` a member of a hosted organization was priced as themself and told
366
+ # hosted GPUs are not open to them, while `create --owner` would have been accepted.
367
+ for key, value in (
368
+ ("params", params),
369
+ ("onnx_path", onnx_path),
370
+ ("policy_path", policy_path),
371
+ ("owner", owner),
372
+ ):
325
373
  if value is not None:
326
374
  body[key] = value
327
375
  with state.client(require_auth=True) as client:
@@ -337,10 +385,16 @@ def estimate(
337
385
  stdout.print(
338
386
  f"{est.executor}: tier {est.tier} ({size}{est.vram_bytes / 2**30:.1f} GiB"
339
387
  + (f" from {basis}" if basis else "")
340
- + f") · ${float(est.rate_usd_per_hour):.4f}/h x {float(est.hours):g} h = "
388
+ + f") · ${float(est.rate_usd_per_hour):.4f}/h x {float(est.hours):g} h up = "
341
389
  f"${float(est.cost_usd_max):.4f}" + ("" if est.priced else " (unpriced)"),
342
390
  markup=False,
343
391
  )
392
+ if est.priced:
393
+ stdout.print(
394
+ " billed for its uptime, from the claim until it stops, whether or not a robot "
395
+ "is connected; sessions add nothing",
396
+ markup=False,
397
+ )
344
398
  notes = getattr(est, "notes", None)
345
399
  for note in notes if isinstance(notes, list) else []:
346
400
  stdout.print(f" note: {note}", markup=False)
@@ -397,6 +451,7 @@ def show(
397
451
  markup=False,
398
452
  )
399
453
  _print_artifact(endpoint)
454
+ stdout.print(f" {cost_line(endpoint)} · {billing_rule(endpoint)}", markup=False)
400
455
  stdout.print(f" {idle_rule(endpoint)}", markup=False)
401
456
  from lucen_cli.client import unset_to_none
402
457
 
@@ -0,0 +1,29 @@
1
+ """The install line for one of the CLI's extras, in one place (D1).
2
+
3
+ Every refusal that names a missing engine prints the line the docs show, which
4
+ since D1 is PyPI's: `uv tool install "lucen-cli[rollout]"`. Before this each
5
+ site carried its own copy of the checkout line (`uv tool install --from
6
+ "./packages/cli[rollout]" --with ./packages/sdk/py lucen-cli`), which a
7
+ stranger with the package from PyPI cannot run. The extra is quoted because an
8
+ unquoted `[…]` is a glob zsh refuses ("no matches found").
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from typing import Final
14
+
15
+ DISTRIBUTION: Final[str] = "lucen-cli"
16
+
17
+
18
+ def _spec(extras: tuple[str, ...]) -> str:
19
+ return f'"{DISTRIBUTION}[{",".join(extras)}]"' if extras else DISTRIBUTION
20
+
21
+
22
+ def install_line(*extras: str) -> str:
23
+ """`uv tool install "lucen-cli[rl]"`: the CLI as a tool on the PATH."""
24
+ return f"uv tool install {_spec(extras)}"
25
+
26
+
27
+ def pip_line(*extras: str) -> str:
28
+ """`uv pip install "lucen-cli[rollout]"`: into the environment a script runs in."""
29
+ return f"uv pip install {_spec(extras)}"