A live overview of everything your robots have seen and done — what they detected, how often, which robots are active, and how your behaviors score. Updates every few seconds.
+ +diff --git a/.github/workflows/no-private-leak.yml b/.github/workflows/no-private-leak.yml new file mode 100644 index 0000000..aed5ae7 --- /dev/null +++ b/.github/workflows/no-private-leak.yml @@ -0,0 +1,27 @@ +name: no-private-leak + +# Hard gate: the public repo must never track private strategy material. +# This is the backstop for the one footgun that bit us before — a `git add -f` +# slipping a private/ file past .gitignore. Runs on every push and PR. + +on: + push: + pull_request: + +jobs: + guard: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Fail if any private path is tracked + run: | + set -euo pipefail + # Paths that must never be committed to the public repo. + forbidden='^(private/|site/|docs/ANTIOCH_PARITY\.md$|docs/DEMO_SPEC\.md$|docs/RL_HARNESS_SPEC\.md$)' + hits="$(git ls-files | grep -E "$forbidden" || true)" + if [ -n "$hits" ]; then + echo "::error::Private material is tracked in the public repo:" + echo "$hits" + exit 1 + fi + echo "OK — no private paths tracked." diff --git a/.gitignore b/.gitignore index 5b388cf..1f0ac0c 100644 --- a/.gitignore +++ b/.gitignore @@ -28,6 +28,17 @@ private/ *.pem *.key +# Strategy / planning docs — live in the private website repo only, never public +/docs/ANTIOCH_PARITY.md +/docs/DEMO_SPEC.md +/docs/RL_HARNESS_SPEC.md + +# Local agent / dev tooling — belongs in neither published repo +.agents/ +.claude/ +.goalkeeper/ +skills-lock.json + # Logs *.log *.tmp diff --git a/CHANGELOG.md b/CHANGELOG.md index b147c47..de35ff3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,24 @@ # Changelog +## Unreleased + +### Added +- **FLEET sandbox** (`/fleet`) — a multi-quadruped teaching arena reachable from the right of the robot picker. Configure the radio (range, link reliability), the airtime each robot gets, and its onboard memory + inbox depth, then watch a fleet split a field under four coordination strategies (lone wolves, gossip, claim-and-yield, one commander). Live links, in-flight messages, dropped packets, coverage and wasted re-walks all rendered. +- Fleet **hover-to-inspect**: mouse over any robot to highlight what *it* knows — tiles it sensed firsthand (filled) vs. only heard from peers (outlined) — with a live knowledge/inbox/data card. +- Fleet **base station + data points**: discover data and relay it home with greedy geographic routing (multi-hop / data-mule), plus selectable **environments** (open / obstacles / building with line-of-sight-blocking walls). +- Fleet **CONCEPTS panel** (expandable): in-UI explanations of delivered vs. dropped, overlapping searches, optimisation, "is this libp2p?", swarm intelligence/stigmergy, and the base-station sink — plus hover tooltips on every metric. +- Fleet **bring-your-own algorithm**: a "✨ Your algorithm" strategy with a live JS editor, and a **Generate** button that asks the local runtime's LLM (`POST /api/fleet/strategy`) to draft a coordination policy from a plain-language goal — runnable on the spot against the same radio limits. +- **`roborun/swarm/`** — the comms model, four strategies, base-station relay and data points as runnable Python (`python -m roborun.swarm`), the headless twin of the sandbox; ships with the package. +- ROS card **network scan**: an "Allow network scan to load robots" button that trips the browser's local-network permission and lists every rosbridge robot found as its own one-click view. + +### Changed +- ROS-connected robots land in the **exact same multi-panel deck** as the rapier.js sim — the live camera (EYES) now docks into the deck layout where the sim shows its POV, instead of floating. +- The FLEET picker card is now a single **Open Fleet Lab** button; every knob lives inside the sandbox. +- Replaced the last native `confirm()` (deploy-to-robot) with the in-app styled modal, usable from the deck as well as the cockpit. + +### Deploy +- `vercel.json` with `cleanUrls` so `/fleet` resolves on the static Vercel build, matching the local server's route. + ## v0.9.2 — 2026-06-09 ### Fixed diff --git a/README.md b/README.md index 1cfff8e..1de0173 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@
The base layer for coding robots: see / move / ask primitives, hot-reload Python behaviors,
the same file from webcam + MuJoCo to real hardware. MCP-native for AI agents, every run flight-recorded.
The base layer for coding robots: see / move / ask primitives, hot-reload Python behaviors,
the same file from webcam + MuJoCo to real hardware. MCP-native for AI agents — and every run is flight-recorded, sealed, and searchable, so you can find anything your robots ever saw, across all of time.
@@ -19,10 +19,19 @@
```bash
pip install ros-agent # the package keeps its PyPI name; the command is roborun
-roborun
+roborun # serves the UI at http://localhost:8765 (prints the URL)
```
-The browser opens live, and a `behaviors/` folder appears with the robot's brain. Open **`/arena`** — a robot dog in a browser sim, body and eyes in the same world (what it does changes what it sees). Nothing else to install; the base package is three small dependencies, no torch. The robot's brain:
+Open the printed URL and a `behaviors/` folder appears with the robot's brain. Open **`/arena`** — a robot dog in a browser sim, body and eyes in the same world (what it does changes what it sees). Nothing else to install; the base package is three small dependencies, no torch.
+
+Prefer the terminal? It runs fully without the web UI:
+
+```bash
+roborun run # headless: drives behaviors, streams the see/move/ask loop to stdout
+roborun tui # full-screen terminal dashboard (pip install 'ros-agent[tui]')
+```
+
+The robot's brain:
```python
# behaviors/follow_person.py (already running)
@@ -55,6 +64,7 @@ Want real eyes instead of the sim? `pip install 'ros-agent[vision]'` (YOLO + CLI
| `robot.delegate("fix my search pattern")` | async LLM **with tools** — it can call any MCP tool, including rewriting the running policy (hot reload applies it live) |
| `robot.tool("navigate", x=2, y=3)` | call any MCP tool from inside the policy |
| `robot.lidar()` | 360° ranges in meters, `[0]` = straight ahead |
+| `robot.go_to_place("the charging dock")` | semantic navigation — recall where it last saw something, then drive there |
| `robot.remember(k, v)` / `robot.recall(k)` | memory that survives restarts |
| `robot.state` | dict that survives across loop ticks |
@@ -101,6 +111,33 @@ What this proves: the recorded run — images, detections, and decisions include
The UI at `http://localhost:8765` is the flight deck itself: live camera with YOLO boxes, the black box streaming, the live anchor badge, a command bar, and director keys. `M` record/seal · `V` verify · `T` tamper · `R` runs/replay · `C` sources.
+## Track and search everything over time
+
+Every run — sim, real robot, or webcam — flows through one loop: **YOLO + CLIP → sealed MCAP → a live index you can search across all of history.** So "where did I last see the forklift", "who was in the lobby yesterday", "every red mug across the fleet" are one query — semantic (CLIP), label (YOLO), place, or time window — over every run and robot.
+
+The cockpit's **▤ VIEWS** menu opens the dashboards (also `roborun demo` to populate them instantly):
+
+- **/search** — find anything/anyone over time; export the hits as a labeled dataset (sealed provenance).
+- **/scenarios** — give a behavior a task, run it, and see if it passed; group runs into suites with a pass-rate, and re-run after every change.
+- **/run** — per-run trajectory · velocity · clearance · LiDAR, with **synced playback** (scrub a moment → the frame the robot saw) and **⚑ Flag** to bookmark incidents to revisit.
+- **/analytics** — detections over time, suite pass-rates, per-robot fleet activity.
+- **/timeline** — the live event stream + recent sightings.
+
+From the local runner, no browser needed:
+
+```bash
+roborun demo # load sample data so the dashboards aren't empty
+roborun ask "patrol the lobby" # tell the robot what to do in plain English
+roborun search "person" # across every recorded run, all-time
+roborun scenarios run mjx_reach # score a scenario (vectorized MuJoCo, sealed)
+roborun dataset "forklift" ./ds # curate a labeled training set from a search
+roborun status # is it running, what's connected, how much recorded
+```
+
+(`roborun help` lists every verb.)
+
+The robot handle gets it too: `robot.go_to_place("the charging dock")` navigates to where it last saw something (semantic memory), and the same `recall_place` is an MCP tool any agent can call.
+
## Connect a real robot
```bash
@@ -109,7 +146,7 @@ roborun connect 192.168.1.42 --move # proves it: clamped 0.5s nudge, then stop
roborun connect --scan # DDS discovery — nothing to install on the robot
```
-If rosbridge isn't running on the robot yet, the command prints the exact two lines to run there — that's the whole setup. **No ROS install on your machine.** Once connected, plain `roborun` drives that robot and the same `behaviors/*.py` files now move real hardware: Unitree Go2/G1, TurtleBot, arms, drones, NVIDIA Isaac Sim, Gazebo. `robot.move()` goes to the sim if it's running, otherwise to the connected robot, always through the same safety clamps.
+If rosbridge isn't running on the robot yet, the command prints the exact two lines to run there — that's the whole setup. **No ROS install on your machine, and it works with both ROS 1 and ROS 2** — rosbridge speaks both, and RoboRun detects which (the DDS path is ROS 2-only). Once connected, plain `roborun` drives that robot and the same `behaviors/*.py` files now move real hardware: Unitree Go2/G1, TurtleBot, arms, drones, NVIDIA Isaac Sim, Gazebo. `robot.move()` goes to the sim if it's running, otherwise to the connected robot, always through the same safety clamps.
Optional extras: `pip install ros-agent[vision]` (YOLO + CLIP), `[sim]` (MuJoCo), `[ros]` (direct DDS), `[crypto]` (Ed25519 signing), `[anchor]` (RFC 3161 timestamping), `[fleet]` (R2 + DuckDB cross-robot), `[all]`.
diff --git a/app/.gitignore b/app/.gitignore
new file mode 100644
index 0000000..f4e2c6d
--- /dev/null
+++ b/app/.gitignore
@@ -0,0 +1,3 @@
+node_modules/
+dist/
+*.tsbuildinfo
diff --git a/app/index.html b/app/index.html
new file mode 100644
index 0000000..b8d8ea1
--- /dev/null
+++ b/app/index.html
@@ -0,0 +1,14 @@
+
+
+
+ RoboRun is an MCP server — point any agent at it and it can see, move, and drive this robot + with the same tools you use. Endpoint: +
+
+ {c.cmd}
+
+ roborun, then this lists every tool an agent gets.
+ roborun demo.+ Write one behavior file and run it in a browser sim or on real hardware. Every run is recorded, + sealed, and searchable — nothing to install to start. +
+{v.merkle_root.slice(0, 10)}…}
+
+
+
+ >
+ );
+}
+
+export function Runs() {
+ const source = useStudio((s) => s.source);
+ const openRun = useStudio((s) => s.openRun);
+ const [rows, setRows] = useState+ A scenario runs a behavior against a target and scores it pass/fail — like unit tests, but for + how the robot acts. Run one after you change a behavior to answer “did I break anything?”. Every + result is recorded and tamper-proof. Press “run” on any test below to try it. +
+| result | +scenario | +suite | +scores | +when | +
|---|---|---|---|---|
| ● {v} | +{r.name} | +{r.suite} | +{r.metrics ? JSON.stringify(r.metrics) : "—"} | +{fmtWhen(r.ended || r.started)} | +
roborun demo) to see scored, sealed results here.
+ pip install 'ros-agent[vision]'
+
+ )}
+ behaviors/; the same file runs on every target above.
+ . Both share font/size/line-height/padding/border so the
+ * caret lands on the right glyph; the textarea drives sizing+scroll, the pre
+ * is clipped and scroll-synced underneath. */
+.beh-code-wrap { position: relative; width: 100%; }
+.beh-code, .beh-hl { margin: 0; box-sizing: border-box; font-family: var(--mono);
+ font-size: 12.5px; line-height: 1.6; tab-size: 4; padding: 12px;
+ border: 1px solid transparent; border-radius: var(--r-sm);
+ white-space: pre-wrap; word-break: break-word; }
+.beh-hl { position: absolute; inset: 0; overflow: hidden; pointer-events: none;
+ background: var(--bg); color: var(--fg); z-index: 0; }
+.beh-hl code { font: inherit; }
+.beh-code { position: relative; z-index: 1; display: block; width: 100%; min-height: 220px;
+ resize: vertical; background: transparent; color: transparent; caret-color: var(--fg);
+ border-color: var(--line); }
+.beh-code:focus { outline: none; border-color: var(--accent); }
+/* token palette — single accent for keywords, muted for the rest, per DS */
+.beh-hl .t-c { color: var(--fg-dim); font-style: italic; } /* comments */
+.beh-hl .t-s { color: var(--blue); } /* strings */
+.beh-hl .t-k { color: var(--accent); } /* keywords */
+.beh-hl .t-d { color: #e0c050; } /* decorators */
+.beh-hl .t-n { color: #c98a5e; } /* numbers */
+.code-note { margin-top: 11px; font-size: 12px; color: var(--fg-dim); line-height: 1.5; }
+.code-note code { background: var(--bg); border: 1px solid var(--line); border-radius: 5px; padding: 1px 6px; color: var(--accent); }
+
+/* search */
+.search-bar { display: flex; gap: 10px; margin-bottom: 8px; }
+.search-input { flex: 1; font-family: var(--mono); font-size: 14px; color: var(--fg);
+ background: var(--panel); border: 1px solid var(--line); border-radius: var(--r-sm); padding: 13px 16px; }
+.search-input:focus { border-color: var(--accent); outline: none; }
+.search-scope { font-size: 13px; color: var(--fg-dim); margin-bottom: 18px; }
+.search-scope b { color: var(--accent); }
+.chips { display: flex; gap: 8px; margin-bottom: 18px; }
+.chip-btn { font-size: 12px; padding: 6px 12px; border-radius: 999px;
+ border: 1px solid var(--line); background: var(--panel-2); color: var(--fg-dim); cursor: pointer; }
+.chip-btn.on { color: var(--accent); border-color: var(--accent-dim); background: var(--accent-fill); }
+
+/* guided tour overlay */
+.tour { position: fixed; left: 0; right: 0; bottom: 22px; display: flex; justify-content: center; z-index: 80; pointer-events: none; }
+.tour-card { pointer-events: auto; width: min(560px, 92vw); background: var(--panel);
+ border: 1px solid var(--accent-dim); border-radius: var(--r); padding: 16px 18px; box-shadow: var(--shadow); }
+.tour-step { font-size: 10px; letter-spacing: .16em; color: var(--accent); margin-bottom: 6px; }
+.tour-title { font-size: 16px; font-weight: 600; color: var(--fg); margin-bottom: 6px; }
+.tour-body { font-size: 13px; color: var(--fg-2); line-height: 1.55; margin-bottom: 14px; }
+.tour-actions { display: flex; gap: 8px; justify-content: flex-end; }
+
+/* first-run welcome — the one tinted hero surface (radial accent wash) */
+.welcome { min-height: calc(100vh - 140px); display: grid; place-items: center; padding: 24px; }
+.welcome-card { max-width: 680px; text-align: center; background: var(--rr-hero-grad);
+ border: 1px solid var(--accent-dim); border-radius: var(--r); padding: 40px 44px; box-shadow: var(--shadow); }
+.welcome-kicker { font-size: 12px; letter-spacing: .16em; text-transform: uppercase; color: var(--accent); margin-bottom: 14px; }
+.welcome-h1 { font-size: 21px; line-height: 1.2; margin: 0 0 14px; color: var(--fg); font-weight: 700; }
+.welcome-sub { font-size: 14px; line-height: 1.65; color: var(--fg-2); margin: 0 auto 26px; max-width: 540px; }
+.welcome-actions { display: flex; gap: 12px; justify-content: center; margin-bottom: 26px; }
+.welcome-primary { background: var(--accent); border-color: var(--accent); color: #06120a; font-weight: 600; }
+.welcome-primary:hover { background: #1ee08c; border-color: #1ee08c; }
+.welcome-steps { display: flex; gap: 12px; justify-content: center; align-items: center; flex-wrap: wrap; font-size: 12px; color: var(--fg-dim); }
+.welcome-steps b { color: var(--accent); margin-right: 5px; }
+.welcome-arrow { color: var(--line-soft); }
+.welcome-sample { margin-top: 22px; background: none; border: 0; color: var(--fg-dim); font-size: 13px;
+ cursor: pointer; text-decoration: underline; text-underline-offset: 3px; }
+.welcome-sample:hover { color: var(--accent); }
+.welcome-sample:disabled { color: var(--accent); cursor: default; text-decoration: none; }
+
+/* drop-zone */
+.dropzone { margin: 10px; padding: 16px 14px; border: 1px dashed var(--line-soft); border-radius: var(--r);
+ text-align: center; font-size: 12.5px; color: var(--fg-dim); cursor: pointer; background: var(--bg);
+ transition: border-color var(--rr-dur-fast), background var(--rr-dur-fast); }
+.dropzone:hover { border-color: var(--accent); }
+.dropzone.over { border-color: var(--accent); background: var(--accent-fill); color: var(--fg); }
+
+/* run list rows */
+.run-row { display: flex; justify-content: space-between; align-items: center; gap: 8px;
+ width: 100%; text-align: left; border: 0; background: transparent; cursor: pointer;
+ padding: 9px 14px; font-size: 12px; color: var(--fg-2); border-left: 2px solid transparent; }
+.run-row:hover { background: var(--panel-2); }
+.run-row.on { background: var(--accent-fill); color: var(--accent); border-left-color: var(--accent); }
+.run-row .rr-ev { color: var(--fg-dim); }
+.run-row.col { flex-direction: column; align-items: stretch; gap: 3px; }
+.rr-top { display: flex; justify-content: space-between; align-items: center; gap: 8px; }
+.rr-title { font-size: 13px; font-weight: 500; color: var(--fg); }
+.run-row.on .rr-title { color: var(--accent); }
+.rr-meta { font-size: 11px; color: var(--fg-dim); }
+.rr-badge { font-size: 9px; letter-spacing: .08em; padding: 2px 6px; border-radius: 999px; border: 1px solid var(--line); color: var(--fg-dim); }
+.rr-badge.ok { color: var(--accent); border-color: var(--accent-dim); }
+.rr-badge.rec { color: var(--bad); border-color: var(--bad); }
+
+/* provenance bar */
+.prov-bar { display: flex; align-items: center; gap: 14px; padding: 9px 14px; border: 1px solid var(--line);
+ border-radius: var(--r); background: var(--bg-2); }
+.prov-run { font-size: 12px; color: var(--fg-2); }
+.prov { font-size: 12px; color: var(--fg-dim); display: inline-flex; align-items: center; gap: 8px; }
+.prov.ok { color: var(--accent); }
+.prov.warn { color: var(--warn); }
+.prov.bad { color: var(--bad); }
+.prov-root { background: var(--bg); border: 1px solid var(--line); border-radius: 5px; padding: 1px 6px; color: var(--fg-2); }
diff --git a/app/src/sims/BehaviorEditor.tsx b/app/src/sims/BehaviorEditor.tsx
new file mode 100644
index 0000000..9540d91
--- /dev/null
+++ b/app/src/sims/BehaviorEditor.tsx
@@ -0,0 +1,121 @@
+import { useEffect, useRef, useState } from "react";
+
+// Edit a behavior file in-app. Save writes through /api/behaviors/write, which
+// the runtime's file watcher hot-reloads into the running robot — so the
+// "change a number, save, watch it change" pitch is actually doable here.
+type Beh = { name: string; enabled?: boolean; errors?: number; last_error?: string | null };
+
+const esc = (s: string) => s.replace(/&/g, "&").replace(//g, ">");
+
+// ponytail: ~15-line regex tokenizer instead of a CodeMirror/Monaco dependency.
+// Good enough for short behavior files; if multi-file editing ever lands, swap
+// in CodeMirror 6. One pass, escaping the gaps so it's xss-safe.
+const TOKEN =
+ /(#[^\n]*)|('''[\s\S]*?'''|"""[\s\S]*?"""|'(?:[^'\\]|\\.)*'|"(?:[^"\\]|\\.)*")|(@[A-Za-z_]\w*)|\b(def|class|return|if|elif|else|for|while|import|from|as|with|try|except|finally|raise|in|not|and|or|is|None|True|False|pass|break|continue|lambda|yield|global|nonlocal|assert|del|async|await|self)\b|\b(\d+\.?\d*)\b/g;
+function highlight(src: string): string {
+ let out = "";
+ let last = 0;
+ let m: RegExpExecArray | null;
+ TOKEN.lastIndex = 0;
+ while ((m = TOKEN.exec(src))) {
+ out += esc(src.slice(last, m.index));
+ const cls = m[1] ? "c" : m[2] ? "s" : m[3] ? "d" : m[4] ? "k" : "n";
+ out += `${esc(m[0])}`;
+ last = m.index + m[0].length;
+ }
+ // trailing "\n" keeps the highlight layer as tall as the textarea's last line
+ return out + esc(src.slice(last)) + "\n";
+}
+
+export function BehaviorEditor() {
+ const [list, setList] = useState([]);
+ const [name, setName] = useState("");
+ const [src, setSrc] = useState("");
+ const [status, setStatus] = useState("");
+ const [busy, setBusy] = useState(false);
+ const taRef = useRef(null);
+ const preRef = useRef(null);
+
+ useEffect(() => {
+ fetch("/api/behaviors")
+ .then((r) => r.json())
+ .then((d) => {
+ const bs: Beh[] = d.behaviors || [];
+ setList(bs);
+ const first = bs.find((b) => b.name === "follow_person") || bs[0];
+ if (first) load(first.name);
+ })
+ .catch(() => {});
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, []);
+
+ const load = async (n: string) => {
+ setName(n);
+ setStatus("");
+ const d = await (await fetch("/api/behaviors/read", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ name: n }) })).json();
+ if (d.ok) setSrc(d.source);
+ else setStatus(d.error || "couldn't read file");
+ };
+
+ const save = async () => {
+ setBusy(true);
+ setStatus("");
+ try {
+ const d = await (await fetch("/api/behaviors/write", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ name, source: src }) })).json();
+ setStatus(d.ok ? "✓ saved · hot-reloaded into the running robot" : "✗ " + (d.error || "write failed"));
+ } finally {
+ setBusy(false);
+ }
+ };
+
+ // keep the highlight layer scrolled in lockstep with the textarea
+ const syncScroll = () => {
+ const ta = taRef.current, pre = preRef.current;
+ if (ta && pre) { pre.scrollTop = ta.scrollTop; pre.scrollLeft = ta.scrollLeft; }
+ };
+
+ const onKeyDown = (e: React.KeyboardEvent) => {
+ if ((e.metaKey || e.ctrlKey) && e.key === "s") { e.preventDefault(); save(); return; }
+ // Tab inserts 4 spaces instead of leaving the field — table stakes for a code box
+ if (e.key === "Tab") {
+ e.preventDefault();
+ const ta = e.currentTarget;
+ const { selectionStart: a, selectionEnd: b } = ta;
+ const next = src.slice(0, a) + " " + src.slice(b);
+ setSrc(next);
+ requestAnimationFrame(() => { ta.selectionStart = ta.selectionEnd = a + 4; });
+ }
+ };
+
+ return (
+
+
+
+
+ {status && {status}}
+
+
+
+
+
+ );
+}
diff --git a/app/src/sims/ConnectRobot.tsx b/app/src/sims/ConnectRobot.tsx
new file mode 100644
index 0000000..40b5ee0
--- /dev/null
+++ b/app/src/sims/ConnectRobot.tsx
@@ -0,0 +1,94 @@
+import { useEffect, useState } from "react";
+import { useNavigate } from "react-router-dom";
+
+// Focused "connect your hardware" flow — NOT the generic multi-backend wizard.
+// rosbridge IP → Connect. The same behavior file then drives the real robot.
+export function ConnectRobot() {
+ const navigate = useNavigate();
+ const [host, setHost] = useState("");
+ const [busy, setBusy] = useState(false);
+ const [err, setErr] = useState("");
+ const [status, setStatus] = useState<{ connected: boolean; host?: string | null }>({ connected: false });
+
+ const refresh = () =>
+ fetch("/api/ros/status").then((r) => r.json()).then(setStatus).catch(() => {});
+ useEffect(() => {
+ refresh();
+ const id = setInterval(refresh, 3000);
+ return () => clearInterval(id);
+ }, []);
+
+ const connect = async () => {
+ setBusy(true);
+ setErr("");
+ try {
+ const r = await fetch("/api/ros/connect", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ host: host.trim() }),
+ });
+ const d = await r.json();
+ if (d.ok) refresh();
+ else setErr(d.error || "couldn't reach rosbridge there");
+ } catch (e) {
+ setErr(String(e));
+ } finally {
+ setBusy(false);
+ }
+ };
+
+ return (
+
+
+ Connect a robot
+
+ {status.connected ? (
+
+ ● connected to {status.host} — your behaviors now drive it. Open{" "}
+ Live to watch.
+
+
+
+
+ ) : (
+ <>
+
+ Enter your robot's rosbridge address (ROS 1 or 2). The same see / move / ask{" "}
+ behavior file drives it — nothing else to install on the robot.
+
+
+ setHost(e.target.value)}
+ onKeyDown={(e) => e.key === "Enter" && host.trim() && connect()}
+ />
+
+
+ {err && {err}}
+
+ No rosbridge yet? On the robot:
+
+{`ROS 2: ros2 launch rosbridge_server rosbridge_websocket_launch.xml
+ROS 1: roslaunch rosbridge_server rosbridge_websocket.launch`}
+
+
+
+ No robot handy?{" "}
+ navigate("/sims")} style={{ color: "var(--accent)", cursor: "pointer" }}>
+ run a sim instead →
+
+
+ >
+ )}
+
+
+
+ );
+}
diff --git a/app/src/sims/DataSim.tsx b/app/src/sims/DataSim.tsx
new file mode 100644
index 0000000..a727008
--- /dev/null
+++ b/app/src/sims/DataSim.tsx
@@ -0,0 +1,80 @@
+import { useEffect, useState } from "react";
+
+// Server-side MuJoCo (Go1 / G1 / drone). Controls hit /api/sim/*; the rendered
+// frames already flow into the Live camera panel via /api/camera/frame. So this
+// is just the launcher — the data shows up in the shared panels.
+type SimState = { running?: boolean; robot?: string; fps?: number };
+type RobotDef = { id: string; name?: string; available?: boolean };
+
+export function DataSim() {
+ const [robots, setRobots] = useState([]);
+ const [state, setState] = useState({});
+ const [robot, setRobot] = useState("");
+
+ const refresh = () =>
+ fetch("/api/sim/state")
+ .then((r) => r.json())
+ .then((d) => setState(d.ok === false ? {} : d))
+ .catch(() => {});
+
+ useEffect(() => {
+ fetch("/api/sim/robots")
+ .then((r) => r.json())
+ .then((d) => {
+ const list: RobotDef[] = d.robots || [];
+ setRobots(list);
+ setRobot(list[0]?.id || "");
+ })
+ .catch(() => {});
+ refresh();
+ const iv = setInterval(refresh, 2000);
+ return () => clearInterval(iv);
+ }, []);
+
+ const post = (path: string, body: object = {}) =>
+ fetch(path, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) }).then(
+ refresh
+ );
+
+ return (
+
+ Data Sim · MuJoCo
+
+
+
+ {state.running ? (
+
+ ) : (
+
+ )}
+
+
+
+ {state.running ? `running ${state.robot} · ${state.fps ?? "?"} fps` : "stopped"}
+ {state.running && (
+
+ → watch it in Live
+
+ )}
+
+
+
+ );
+}
diff --git a/app/src/source/LiveSource.ts b/app/src/source/LiveSource.ts
new file mode 100644
index 0000000..aefd023
--- /dev/null
+++ b/app/src/source/LiveSource.ts
@@ -0,0 +1,116 @@
+import type { Source, EventMsg, Sample, SceneState, Detection, Pose } from "./Source";
+import { apiUrl, wsUrl } from "../runtime";
+
+// Live robot or running sim. Events via SSE, telemetry via the WS on :8766
+// (ring-buffered per channel), camera via the MJPEG/frame endpoint. The
+// playhead "follows NOW", so sceneAt() ignores t and returns the latest state.
+export class LiveSource implements Source {
+ kind = "live" as const;
+ id = "live";
+
+ private es?: EventSource;
+ private ws?: WebSocket;
+ private events: EventMsg[] = [];
+ private channels = new Map();
+ private scene: SceneState = { detections: [] };
+ private opened = false;
+
+ // Lazy: don't open SSE+WS until a panel actually consumes this source. The
+ // store creates a LiveSource eagerly, but on non-live routes (Analytics,
+ // Search, Agent…) nothing reads it, so no connections open.
+ private ensure() {
+ if (this.opened) return;
+ this.opened = true;
+ this.es = new EventSource(apiUrl("/api/events/stream"));
+ this.es.onmessage = (m) => {
+ try {
+ const e = JSON.parse(m.data) as EventMsg;
+ this.absorb(e);
+ this.events.push(e);
+ if (this.events.length > 200) this.events.shift();
+ } catch {
+ /* ping comment lines */
+ }
+ };
+ this.connectWS();
+ }
+
+ private connectWS() {
+ // The telemetry WS lives on its own port; reach it on the resolved backend
+ // host (so a hosted page hits the user's localhost, not the Vercel domain),
+ // matching scheme so it isn't blocked as mixed content under https.
+ const port = (window as { ROBORUN_WS_PORT?: number }).ROBORUN_WS_PORT ?? 8766;
+ const url = wsUrl(port);
+ try {
+ this.ws = new WebSocket(url);
+ } catch {
+ return;
+ }
+ this.ws.onmessage = (m) => {
+ try {
+ const d = JSON.parse(m.data);
+ const rows = d.type === "history" ? d.data : [d];
+ for (const r of rows) this.pushSample(r);
+ } catch {
+ /* ignore */
+ }
+ };
+ this.ws.onclose = () => {
+ // best-effort reconnect; the sim/robot may restart the bus
+ setTimeout(() => this.connectWS(), 2000);
+ };
+ }
+
+ private pushSample(r: any) {
+ if (!r || !r.channel) return;
+ const { channel, robot_id, t, ...rest } = r;
+ const buf = this.channels.get(channel) ?? [];
+ const nums: Sample = { t: t ?? Date.now() / 1000 };
+ for (const [k, v] of Object.entries(rest)) {
+ if (typeof v === "number") nums[k] = v;
+ }
+ buf.push(nums);
+ if (buf.length > 600) buf.shift();
+ this.channels.set(channel, buf);
+ }
+
+ private absorb(e: EventMsg) {
+ // keep the latest detections / pose for the scene panel
+ const d = e.detail || {};
+ if (e.type === "detection" && Array.isArray((d as any).detections)) {
+ this.scene.detections = (d as any).detections as Detection[];
+ }
+ if ((d as any).pose) this.scene.pose = (d as any).pose as Pose;
+ }
+
+ bounds() {
+ return null; // open-ended: live follows NOW
+ }
+
+ snapshotEvents(_t: number) {
+ this.ensure();
+ return this.events;
+ }
+
+ frameURL(_t: number, camera = "auto") {
+ this.ensure();
+ // cache-bust so the
actually refreshes when polled
+ return apiUrl(`/api/camera/frame?source=${encodeURIComponent(camera)}&_=${Date.now()}`);
+ }
+
+ async series(channel: string) {
+ this.ensure();
+ return this.channels.get(channel) ?? [];
+ }
+
+ sceneAt(_t: number) {
+ this.ensure();
+ return this.scene;
+ }
+
+ dispose() {
+ this.es?.close();
+ this.ws?.close();
+ this.events = [];
+ }
+}
diff --git a/app/src/source/RunSource.ts b/app/src/source/RunSource.ts
new file mode 100644
index 0000000..5cf9a2c
--- /dev/null
+++ b/app/src/source/RunSource.ts
@@ -0,0 +1,80 @@
+import type { Source, EventMsg, Sample, SceneState, Detection } from "./Source";
+import { apiUrl } from "../runtime";
+
+// A sealed run, replayed. Events are fetched once and filtered by the playhead;
+// frames come per-t from /api/run/frame; series come from /api/run/series
+// (named arrays — we map a channel name onto the relevant one). sceneAt(t)
+// derives pose + active detections from the events at or before t.
+export class RunSource implements Source {
+ kind = "run" as const;
+ id: string;
+
+ private events: EventMsg[] = [];
+ private seriesCache?: Promise;
+ private range: { start: number; end: number } | null = null;
+ private ready: Promise;
+
+ constructor(runName: string) {
+ this.id = runName;
+ this.ready = this.load();
+ }
+
+ private async load() {
+ const r = await fetch(`/api/run/events?run=${encodeURIComponent(this.id)}&limit=100000`);
+ const d = await r.json();
+ this.events = (d.events || []).filter((e: EventMsg) => typeof e.ts === "number");
+ this.events.sort((a, b) => a.ts - b.ts);
+ if (this.events.length) {
+ this.range = { start: this.events[0].ts, end: this.events[this.events.length - 1].ts };
+ }
+ }
+
+ whenReady() {
+ return this.ready;
+ }
+
+ bounds() {
+ return this.range;
+ }
+
+ // Purely derived from the playhead: every event up to t, newest last. No
+ // mutable replay cursor, so scrubbing in either direction stays correct.
+ snapshotEvents(t: number) {
+ return this.events.filter((e) => e.ts <= t).slice(-200);
+ }
+
+ frameURL(t: number, _camera?: string) {
+ return apiUrl(`/api/run/frame?id=${encodeURIComponent(this.id)}&t=${t}`);
+ }
+
+ private loadSeries() {
+ if (!this.seriesCache) {
+ this.seriesCache = fetch(`/api/run/series?id=${encodeURIComponent(this.id)}`).then((r) => r.json());
+ }
+ return this.seriesCache;
+ }
+
+ async series(channel: string): Promise {
+ const d = await this.loadSeries();
+ // run_series returns named arrays already shaped as {t, ...numbers}
+ const arr = (d?.[channel] as Sample[]) || [];
+ return arr;
+ }
+
+ sceneAt(t: number): SceneState {
+ const scene: SceneState = { detections: [] };
+ for (const e of this.events) {
+ if (e.ts > t) break;
+ const det = e.detail || {};
+ if (e.type === "detection" && Array.isArray((det as any).detections)) {
+ scene.detections = (det as any).detections as Detection[];
+ }
+ if ((det as any).pose) scene.pose = (det as any).pose;
+ }
+ return scene;
+ }
+
+ dispose() {
+ /* nothing to release: no open sockets */
+ }
+}
diff --git a/app/src/source/Source.ts b/app/src/source/Source.ts
new file mode 100644
index 0000000..2f375fc
--- /dev/null
+++ b/app/src/source/Source.ts
@@ -0,0 +1,53 @@
+// The spine of Studio: panels bind to a Source and never know whether they
+// are showing a live robot/sim or a sealed run. Two implementations:
+// LiveSource (follows NOW) and RunSource (scrubs a recorded run).
+
+export type EventMsg = {
+ id?: string;
+ type: string;
+ source: string;
+ title: string;
+ detail?: Record;
+ ts: number;
+ prev?: string;
+};
+
+export type Detection = {
+ label: string;
+ confidence?: number;
+ bbox?: [number, number, number, number];
+};
+
+export type Pose = { x: number; y: number; z: number };
+
+export type SceneState = {
+ pose?: Pose;
+ detections: Detection[];
+};
+
+export type Sample = { t: number; [k: string]: number };
+
+export interface Source {
+ kind: "live" | "run";
+ id: string; // 'live' | run name
+
+ /** [start,end] in unix seconds, or null for an open-ended live source. */
+ bounds(): { start: number; end: number } | null;
+
+ /** Events visible at the playhead. Live: rolling buffer (t ignored). Run:
+ * every event with ts <= t. Purely derived from t, so scrubbing back and
+ * forth never duplicates or loses entries. */
+ snapshotEvents(t: number): EventMsg[];
+
+ /** URL for a camera frame at time t (live: latest; run: nearest to t). */
+ frameURL(t: number, camera?: string): string;
+
+ /** Telemetry series for a channel (live: ring buffer; run: full series). */
+ series(channel: string, robot?: string): Promise;
+
+ /** Poses + detections for the 3D/detection panels at time t. */
+ sceneAt(t: number): SceneState;
+
+ /** Release any open connections (EventSource, WS). */
+ dispose(): void;
+}
diff --git a/app/src/store.ts b/app/src/store.ts
new file mode 100644
index 0000000..4526ca3
--- /dev/null
+++ b/app/src/store.ts
@@ -0,0 +1,73 @@
+import { create } from "zustand";
+import type { Source } from "./source/Source";
+import { LiveSource } from "./source/LiveSource";
+import { RunSource } from "./source/RunSource";
+
+type StudioState = {
+ source: Source;
+ t: number; // playhead, unix seconds
+ playing: boolean;
+ follow: boolean; // live: pin t to NOW
+ scopeKey: number; // bumped on project switch to refetch scoped data (no reload)
+ bumpScope: () => void;
+ tourStep: number; // 0 = off; 1..N = guided golden-path tour
+ startTour: () => void;
+ setTour: (n: number) => void;
+ goLive: () => void;
+ openRun: (name: string, atT?: number) => Promise;
+ setT: (t: number) => void;
+ togglePlay: () => void;
+ tick: () => void;
+};
+
+export const useStudio = create((set, get) => ({
+ source: new LiveSource(),
+ t: Date.now() / 1000,
+ playing: true,
+ follow: true,
+ scopeKey: 0,
+
+ bumpScope: () => set((s) => ({ scopeKey: s.scopeKey + 1 })),
+
+ tourStep: 0,
+ startTour: () => set({ tourStep: 1 }),
+ setTour: (n) => set({ tourStep: n }),
+
+ goLive: () => {
+ get().source.dispose();
+ set({ source: new LiveSource(), follow: true, playing: true, t: Date.now() / 1000 });
+ },
+
+ openRun: async (name, atT) => {
+ get().source.dispose();
+ const run = new RunSource(name);
+ await run.whenReady();
+ const b = run.bounds();
+ const t = atT ?? b?.start ?? 0;
+ set({ source: run, follow: false, playing: false, t });
+ },
+
+ setT: (t) => {
+ set({ t, follow: false });
+ },
+
+ // live: pause = stop pinning to NOW (freeze playhead). run: pause = stop playback.
+ togglePlay: () => set((st) => (st.source.kind === "live" ? { follow: !st.follow } : { playing: !st.playing })),
+
+ // driven by one rAF loop in App; advances the playhead.
+ tick: () => {
+ const { source, playing, follow, t } = get();
+ if (source.kind === "live") {
+ if (follow) set({ t: Date.now() / 1000 });
+ return;
+ }
+ if (!playing) return;
+ const b = source.bounds();
+ const next = t + 0.1; // matches the 100ms tick → realtime replay
+ if (b && next >= b.end) {
+ set({ t: b.end, playing: false });
+ return;
+ }
+ set({ t: next });
+ },
+}));
diff --git a/app/src/theme.css b/app/src/theme.css
new file mode 100644
index 0000000..aace979
--- /dev/null
+++ b/app/src/theme.css
@@ -0,0 +1,49 @@
+/* RoboRun Studio theme — conforms to the RoboRun Design System
+ * (claude.ai/design project f84aeaa8). Dashboards are set in the SYSTEM
+ * monospace stack (no webfont; IBM Plex is cockpit-only). Depth comes from
+ * flat layered surfaces + ONE soft shadow — never glow. Tokens mirror ui.css. */
+
+:root {
+ /* ui.css already defines --bg/--panel/--accent/--mono/--r/--r-sm etc.
+ * These are the DS additions it doesn't carry. */
+ --accent-fill: #0e1a12; /* the recurring "active" tile fill */
+ --ok-wash: rgba(0, 212, 126, 0.15);
+ --bad-wash: rgba(224, 86, 63, 0.15);
+ --warn-wash: rgba(212, 160, 48, 0.15);
+ --shadow: 0 6px 24px rgba(0, 0, 0, 0.35);
+ --rr-hero-grad:
+ radial-gradient(120% 140% at 100% 0%, rgba(0, 212, 126, 0.1), transparent 60%),
+ linear-gradient(180deg, #0e1a12, #0b110d);
+ --rr-sidebar-w: 232px;
+ --rr-main-max: 1280px;
+ --rr-topbar-h: 53px;
+ --rr-dur-fast: 0.12s;
+}
+
+* { box-sizing: border-box; }
+
+html, body, #root {
+ margin: 0;
+ height: 100%;
+ /* control-station: monospace everywhere, tabular numerals line up metrics */
+ font-family: var(--mono);
+ font-variant-numeric: tabular-nums;
+ -webkit-font-smoothing: antialiased;
+ color: var(--fg);
+ background: var(--bg); /* flat — no grid, no ambient gradient */
+}
+
+.mono, .panel-head, .nav-group, .app-title, .seclabel { font-family: var(--mono); }
+
+:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; border-radius: 4px; }
+
+::-webkit-scrollbar { width: 10px; height: 10px; }
+::-webkit-scrollbar-thumb { background: var(--line); border-radius: 6px; border: 2px solid var(--bg); }
+::-webkit-scrollbar-thumb:hover { background: var(--line-soft); }
+
+/* the one "live" flourish the DS allows: a slow pulse ring on status dots */
+@keyframes rr-pulse {
+ 0% { box-shadow: 0 0 0 0 rgba(0, 212, 126, 0.45); }
+ 70% { box-shadow: 0 0 0 6px rgba(0, 212, 126, 0); }
+ 100% { box-shadow: 0 0 0 0 rgba(0, 212, 126, 0); }
+}
diff --git a/app/tsconfig.json b/app/tsconfig.json
new file mode 100644
index 0000000..c351008
--- /dev/null
+++ b/app/tsconfig.json
@@ -0,0 +1,20 @@
+{
+ "compilerOptions": {
+ "target": "ES2022",
+ "useDefineForClassFields": true,
+ "lib": ["ES2022", "DOM", "DOM.Iterable"],
+ "module": "ESNext",
+ "skipLibCheck": true,
+ "moduleResolution": "bundler",
+ "allowImportingTsExtensions": true,
+ "isolatedModules": true,
+ "moduleDetection": "force",
+ "noEmit": true,
+ "jsx": "react-jsx",
+ "strict": true,
+ "noUnusedLocals": true,
+ "noUnusedParameters": true,
+ "noFallthroughCasesInSwitch": true
+ },
+ "include": ["src"]
+}
diff --git a/app/vite.config.ts b/app/vite.config.ts
new file mode 100644
index 0000000..fcfe501
--- /dev/null
+++ b/app/vite.config.ts
@@ -0,0 +1,26 @@
+import { defineConfig } from "vite";
+import react from "@vitejs/plugin-react";
+
+// Studio is served under /studio during migration (flip to / in Phase 5),
+// so it never clobbers the existing roborun/web/*.html pages. The python
+// server already owns /api, /mcp, SSE, and MJPEG — dev just proxies to it.
+// The shared theme (ui.css/deck.css) is served by the python server too, so
+// proxy it rather than copy it: one source of truth.
+const API = "http://127.0.0.1:8765";
+
+export default defineConfig({
+ base: "/studio/",
+ plugins: [react()],
+ build: {
+ outDir: "../roborun/web/studio",
+ emptyOutDir: true, // isolated subdir — safe to wipe
+ },
+ server: {
+ proxy: {
+ "/api": API,
+ "/mcp": API,
+ "/ui.css": API,
+ "/deck.css": API,
+ },
+ },
+});
diff --git a/docs/STRATA_SPEC.md b/docs/STRATA_SPEC.md
new file mode 100644
index 0000000..a9573fb
--- /dev/null
+++ b/docs/STRATA_SPEC.md
@@ -0,0 +1,197 @@
+# Strata — object-storage-native data engine
+
+**One engine, two data models, on any S3-compatible endpoint.**
+
+Strata stores *time-series blobs* (ReductStore's model) **and** *vectors +
+full-text* (turbopuffer's model) with object storage as the single source of
+truth. Local RAM/NVMe is only a cache. There is no external database and no lock
+service: serializability comes from a compare-and-swap'd, numbered manifest log
+written directly to the bucket.
+
+```python
+from strata import Strata
+db = Strata("s3://my-bucket/strata?endpoint=http://minio:9000")
+
+# time-series blobs (ReductStore-style)
+db.blobs.create_bucket("telemetry", {"quota_type": "FIFO", "quota_size": 10 << 30})
+db.blobs.write("telemetry", "odom", payload, labels={"floor": "3"})
+
+# vectors + full-text (turbopuffer-style)
+db.vectors.upsert("memory", [{"id": "x", "vector": v, "attributes": {"floor": 3}}])
+hits = db.vectors.query("memory", vector=v, top_k=10, filters=["floor", "Eq", 3])
+text = db.vectors.query("memory", rank_by=("caption", "BM25", "yellow crate"))
+```
+
+Run it as a server: `strata serve --store s3://bucket/prefix?endpoint=... --port 8420`.
+
+---
+
+## Why object storage is the source of truth
+
+Object stores (S3/MinIO/R2/B2) are cheap, infinitely durable, and globally
+available — but they have no transactions. Strata gets transactions from a single
+atomic primitive every modern S3 implementation supports: **conditional create**
+(`PUT If-None-Match: *`). On that one primitive it builds a serializable commit
+log, exactly like object-store table formats (Iceberg/Delta) and serverless
+vector databases.
+
+```
+/_catalog/index.json registry of buckets + namespaces + tokens
+/b///manifest/NNNN.json.gz commit log (delta: add/remove/meta)
+/b///checkpoint/NNNN.json.gz full snapshot every 32 commits
+/b///seg/.seg immutable blob segment
+/v//manifest/NNNN.json.gz vector namespace commit log
+/v//seg/.vseg immutable columnar vector segment
+```
+
+**Commit protocol.** A writer reads the current version `V` (highest manifest
+number), computes its delta, and `put_if_absent`s manifest `V+1`. If a
+concurrent writer already created `V+1`, the create fails, the writer reloads and
+retries against fresh state. No write is ever lost or partially applied. Reads
+load the newest checkpoint ≤ V and replay ≤ 32 deltas.
+
+**Segments are immutable.** Writes append new segments; deletes/upserts are
+tombstones and shadowing recorded in the manifest. Space is reclaimed by
+compaction (rewriting live records into a fresh segment) and, for blobs, by FIFO
+eviction.
+
+**Hot/cold split.** Cold truth lives in the bucket; the engine caches
+deserialized segments and a per-namespace *live view* (deduped matrix + norms +
+attribute columns) in RAM, keyed by manifest version, so repeated queries never
+touch object storage and a new write simply invalidates the cache.
+
+---
+
+## Architecture
+
+| Module | Responsibility |
+|---|---|
+| `backend.py` | `ObjectStore` interface + `Local`/`S3`/`Memory` backends; CAS via `put_if_absent`. |
+| `manifest.py` | `Collection` (numbered commit log + checkpoints, optimistic retry) + `Catalog`. |
+| `segment.py` | Immutable `BlobSegment` (time-series records) and columnar `VectorSegment`. |
+| `blobstore.py` | Buckets/entries/records, write-block coalescing, time/label queries, downsampling. |
+| `vectors.py` | Namespaces, upsert/delete, exact ANN (cosine/l2/dot), attribute filters, BM25. |
+| `query.py` | Shared filter AST (`Eq/Gt/In/Glob/And/Or/Not/...`). |
+| `retention.py` | FIFO quota eviction (drop globally-oldest segment until under quota). |
+| `auth.py` | Bearer tokens with per-resource read/write/full permissions. |
+| `replication.py` | Checkpointed, filtered source→destination mirroring. |
+| `server.py` | REST API (blob + vector) + idle-block sealing ticker. |
+| `client.py` / `cli.py` | Python client and `strata` CLI. |
+
+Backends are swappable via URL: `file:///path`, bare path, or
+`s3://bucket/prefix?endpoint=...®ion=...&access_key=...&secret_key=...`. The
+*same* engine code runs on every backend — the S3 path is exercised in tests
+against a fake S3 client including the `If-None-Match` CAS.
+
+---
+
+## ReductStore feature parity
+
+| ReductStore feature | Strata |
+|---|---|
+| Buckets | ✅ `create_bucket`, settings, stats |
+| Entries (time-series streams) | ✅ auto-created on first write |
+| Records: timestamp (µs) + labels + content-type + blob | ✅ `Record` |
+| Write by timestamp / batched write | ✅ `write`, `write_records`, `/batch` endpoint |
+| Read latest / read at timestamp | ✅ `latest`, `read(ts)` |
+| Query by time range | ✅ `query(start, stop)` |
+| Query by labels (conditional) | ✅ filter AST on labels |
+| Downsampling (`each_n`, `each_s`) | ✅ `query(each_n=…, each_s=…)` |
+| FIFO quota (auto-evict oldest) | ✅ `quota_type=FIFO`, segment-granularity eviction |
+| Block (write buffer) sealing by size/records/age | ✅ `max_block_*`, idle ticker |
+| Compaction (merge small blocks) | ✅ `compact(bucket, entry)` |
+| Bearer-token auth with permissions | ✅ full / read / write per bucket |
+| Replication (filtered, checkpointed) | ✅ `Replication`, at-least-once, idempotent |
+| HTTP REST API | ✅ `/api/v1/b/...` |
+| Runs on object storage / disk | ✅ object storage *is* the store, not just a tier |
+
+## turbopuffer feature parity
+
+| turbopuffer feature | Strata |
+|---|---|
+| Namespaces | ✅ |
+| Upsert documents (id, vector, attributes) | ✅ `upsert` |
+| Schemaless attributes | ✅ columnar, per-segment |
+| ANN vector query | ✅ exact NumPy ANN (default) **+ approximate IVF** at scale |
+| Approximate ANN / recall-latency tradeoff | ✅ `approx=True`, tunable `nprobe` (7.6× faster at 200k vecs) |
+| Distance metrics: cosine / euclidean / dot | ✅ |
+| Attribute filters (pushed down) | ✅ shared filter AST |
+| Full-text BM25 ranking | ✅ `rank_by=(field, "BM25", query)` |
+| Hybrid search (vector + BM25) | ✅ pass both → reciprocal-rank fusion |
+| Delete by id | ✅ tombstones |
+| Upsert overwrites (latest wins) | ✅ segment-seq shadowing |
+| Compaction (reclaim dead rows) | ✅ `compact(ns)` merges to one segment, drops shadowed/tombstoned |
+| `include_attributes` in results | ✅ |
+| Object storage as source of truth, cached locally | ✅ live-view cache keyed by manifest version |
+| Serverless / serializable writes on S3 | ✅ CAS'd manifest log |
+
+> ANN has two modes. **Exact** (default) brute-forces the cached live view with
+> NumPy — ground-truth neighbours, sub-millisecond up to ~50k vectors. **Approximate**
+> (`approx=True`) builds a cached IVF index (k≈√n clusters via k-means) and scores
+> only the `nprobe` nearest clusters: **7.6× faster at 200k vectors**, with recall
+> tuned by `nprobe` (higher `nprobe` → recall → 1.0), the same recall/latency dial
+> turbopuffer exposes. The index is cached per manifest version and rebuilt only
+> when the namespace changes. Per-segment HNSW for billion-scale is the next step.
+
+---
+
+## HTTP API
+
+Auth (when any token exists): `Authorization: Bearer `.
+
+**Meta** — `GET /api/v1/info`, `GET /api/v1/list`
+
+**Blobs**
+- `POST /api/v1/b/{bucket}` — create (body = settings JSON)
+- `PUT /api/v1/b/{bucket}` — update settings · `DELETE` — remove · `GET` — stats
+- `POST /api/v1/b/{bucket}/{entry}?ts=<µs>` — write (body = blob; labels via
+ `x-strata-label-` headers; `Content-Type` preserved)
+- `GET /api/v1/b/{bucket}/{entry}?ts=<µs>` — read (omit `ts` → latest)
+- `POST /api/v1/b/{bucket}/{entry}/batch` — batch write (JSON, base64 data)
+- `POST /api/v1/b/{bucket}/{entry}/compact` — merge small segments
+- `GET /api/v1/b/{bucket}/{entry}/q?start=&stop=&limit=&labels=&each_n=&each_s=&data=1`
+
+**Vectors**
+- `GET /api/v1/vectors` — list namespaces
+- `POST /api/v1/vectors/{ns}` — upsert (`{upserts:[...], distance_metric}`)
+- `POST /api/v1/vectors/{ns}/query` — `{vector, top_k, filters, distance_metric, rank_by, include_attributes, approx, nprobe}`
+ (pass both `vector` and `rank_by` → hybrid reciprocal-rank fusion; `approx:true` → IVF)
+- `POST /api/v1/vectors/{ns}/compact` — merge segments, drop dead rows
+- `POST /api/v1/vectors/{ns}/delete` — `{ids:[...]}` · `DELETE` — drop namespace
+
+**Tokens** (admin) — `GET /api/v1/tokens`, `POST /api/v1/tokens/{name}`, `DELETE`
+
+---
+
+## Filter language
+
+JSON AST, identical for blob labels and vector attributes:
+
+```
+["floor", "Eq", 3] Eq NotEq Gt Gte Lt Lte In NotIn Glob Contains
+["label", "In", ["pallet", "crate"]]
+["And", [["floor", "Eq", 3], ["score", "Gte", 0.8]]]
+["Or", [ ... ]] ["Not", [ ... ]]
+```
+
+Missing fields never satisfy a positive predicate and always satisfy
+`NotEq`/`NotIn`.
+
+---
+
+## Status
+
+Implemented and tested (`tests/test_strata.py`, 26 cases): both backends + CAS,
+segment formats, manifest commit/checkpoint/**concurrent writers**, blob
+write/read/query/downsample/FIFO/durability, vector ANN/metrics/shadow/delete/
+BM25, auth, server+client e2e, replication, and the **whole engine running on
+the S3 backend**.
+
+Compaction (blob + vector), hybrid search, approximate IVF ANN, and the manifest
+concurrency fix are implemented and tested (30 cases; concurrent-writer test
+stress-passed 10×; IVF measured 7.6× faster than exact at 200k vectors).
+
+Roadmap: per-segment HNSW for billion-scale namespaces, a scheduler to run
+compaction automatically, multi-region read replicas (object storage already
+gives durable fan-out), and a RoboRun adapter so MCAP runs + spatial memory
+persist straight to Strata on S3.
diff --git a/docs/STUDIO_AUDIT.md b/docs/STUDIO_AUDIT.md
new file mode 100644
index 0000000..b053253
--- /dev/null
+++ b/docs/STUDIO_AUDIT.md
@@ -0,0 +1,465 @@
+# RoboRun Studio — sequential audit (User · Investor · Competitor)
+
+Living document. Each pass walks every Studio page through three lenses in order.
+Screenshots: `/tmp/studio-shots/pass1/*.png`. Server: `localhost:8765`, SPA at `/studio`.
+Pages in nav order: **Live · Sims · Runs · Search** (primary) → **Analytics · Scenarios · Swarm Lab** (More).
+
+Severity key: **P0** blocks/looks broken · **P1** real friction · **P2** polish · **P3** nice-to-have.
+
+---
+
+## PASS 1 — USER LENS
+
+> Persona: a robotics dev trying RoboRun for the first time, no hardware, wants to see it work
+> and understand how they'd build with it. Also a returning dev reviewing recorded runs.
+
+### Shell / navigation (all pages)
+- ✅ Consistent left rail (priority-flat: Live/Sims/Runs/Search, then More), header with project scope +
+ Record, instrument-panel aesthetic, IBM Plex type. Reads as one product.
+- **P1 — Record gives no feedback about *what* it's capturing.** Clicking ● Record turns it red but the
+ user can't tell which source/robot is being recorded or that anything is happening. No elapsed timer,
+ no "recording arena" label. Easy to think it did nothing.
+- **P2 — Project scope is invisible value.** The chip says "scratch" but a newcomer has no idea what a
+ project/environment *is* or why they'd switch. No first-run hint.
+- **P2 — No global "what is this / help".** No onboarding, tour, or docs link anywhere in the shell.
+- **P3 — Brand "RoboRun Studio" wordmark** isn't a home affordance beyond /live; fine.
+
+### Live
+- ✅ Camera / Events / Detections / Telemetry quadrant; events stream in real time; LIVE pill pulses;
+ pause now freezes follow (fixed).
+- ✅ Empty camera is now actionable: "○ no camera — Start a sim in Sims or connect a robot."
+- **P1 — On a cold start the page is 3/4 empty** (no camera, "nothing detected", "Time: --" plot). The one
+ actionable hint is inside the camera tile; Detections/Telemetry just look dead. Newcomer's first
+ impression is "nothing works." Needs a single clear primary CTA ("▶ Start a sim") at the top of Live.
+- **P1 — Telemetry plot shows "Time: --" with no series** when nothing publishes `velocity`. Reads as
+ broken rather than "no data." Needs an empty state like the camera has.
+- **P2 — Camera polls `/api/camera/frame` at 10Hz forever even while 503ing.** Wasteful; should back off
+ when there's no source. (Confirmed in server log: continuous 503s.)
+- **P2 — No way to tell *which* source Live is showing** (webcam? sim? which robot?). No source label.
+
+### Sims — Arena
+- ✅ Slim target switcher (Arena/Fleet/Data/Real), one "how to use" line, single native picker, code on
+ demand. Iframes keep-mounted so switching no longer flickers (fixed).
+- **P1 — Two "how do I drive it?" stories collide.** The strip says "drive with WASD," but the arena's own
+ "PICK A ROBOT & TASK" modal is the first thing you see; WASD only works after you dismiss it and click
+ into the canvas. The hand-off isn't explained (does focus need to be in the iframe?).
+- **P2 — First load of the arena is still heavy** (three.js + Rapier from CDN); a few seconds of dark
+ canvas before the modal. No loading indicator in the Studio frame.
+- **P2 — "how to code it" shows `follow_person.py` but there's no path from here to actually edit it.**
+ The code is read-only; the user can't open `behaviors/` or an editor. The pitch ("change a number, save")
+ has no in-app action.
+
+### Sims — Fleet
+- ✅ Renders the Rapier warehouse in-shell; sliders + play; auto-rotate now stops once you grab it (fixed).
+- **P1 — "Play" then what?** Robots wander; the FOUND/COVERAGE stats tick, but the user isn't told the goal
+ or how their behavior/strategy affects it. The "Swarm Lab →" link is the missing bridge but it's buried
+ in the descriptive paragraph.
+- **P2 — Controls density.** Robots/Floors/Speed + 4 stat tiles + a legend + per-floor counts + the 3D view
+ is a lot at once for a first look.
+
+### Sims — Data Sim
+- ✅ Now loads without the state error; model dropdown shows real names; Start/Reset.
+- **P1 — "frames stream into Live" but there's no link to go see them.** User presses Start here, nothing
+ visibly happens on *this* page (the render is server-side); they must know to navigate to Live. Dead-end.
+- **P2 — No feedback while a model boots** (MuJoCo load can take a moment).
+
+### Sims — Real robot
+- **P1 — Copy/ీreality mismatch.** The "how to use" promises "enter your rosbridge IP and Connect," but the
+ panel is the full 4-step setup wizard starting at "Project." The promised IP field is several steps in.
+- **P2 — For a no-hardware user this is a dead end** with no "I don't have a robot, show me a sim instead."
+
+### Runs + Replay
+- ✅ Run list → click flips to replay; scrubber switches to scrub mode; events fill by playhead; replay
+ speed now realtime (fixed).
+- **P1 — Run list is opaque.** Rows are `run_20260619_003800 arena · 277B` — timestamps + bytes. No
+ human label, no thumbnail, no duration, no "what happened" summary. Hard to pick the run you want.
+- **P1 — Replaying an event-only run shows "no camera frame" + "nothing detected" + empty plot** — looks
+ broken even though it's working (those runs have no MCAP camera). No indication the run simply lacks video.
+- **P2 — No seal/verify affordance in the replay UI.** The black-box/tamper-evidence story (a headline
+ feature) is invisible here — no "VERIFIED ✓" badge, no verify button.
+- **P2 — Can't delete/flag/export a run from the list.**
+
+### Search
+- ✅ Explains scope ("every recorded observation in scratch · across all runs"), defaults to recent
+ results, by-meaning/by-object/recent chips, clicking a hit jumps into replay.
+- **P1 — Result cards are text-only** ("yellow crate · dog-sim · timestamp"). For a *visual* search over
+ what robots saw, the lack of thumbnails is a big miss — you can't recognize a result at a glance.
+- **P2 — No result count / no "showing N of M".** Grid just fills; unclear if it's everything.
+- **P2 — "by object" with a free-text query** — does it substring match labels? Behavior of each chip vs the
+ text box isn't explained.
+
+### Analytics
+- ✅ Native panels, stat tiles, label bars, 24h chart, fleet activity. Consistent chrome.
+- **P2 — "0 AI-searchable" and "0 robots online" read as failures**, not "nothing embedded yet / no live
+ robots." Zero-states need framing.
+- **P2 — Static snapshot** (no refresh / time-range control). "Last 24h" is fixed.
+- **P3 — No drill-down** — clicking a label/robot doesn't filter Search/Runs.
+
+### Scenarios
+- ✅ Native suites + scenario cards + run buttons + results table; no more dead-whitespace void.
+- **P1 — "run" gives little feedback.** Clicking run sets "running…" then a row appears in the table; no
+ live progress, no link to the recorded run it produced, no pass/fail color until done.
+- **P2 — "Runs · 0" + empty table** dominate the lower half before you've run anything.
+
+### Swarm Lab (hosted)
+- ✅ Dense control panel + 2D coordination map + live strategy code; badge suppressed in frame.
+- **P1 — Heaviest cognitive load of any page** (sliders for radio range / link reliability / airtime /
+ onboard memory / inbox depth…), unexplained jargon, and it's a hosted page so it visually diverges
+ slightly (denser, flatter) from the native Studio pages.
+- **P2 — Relationship to Fleet sim is unclear** — both are "fleet," two different pages.
+
+### Cross-cutting (user)
+- **P1 — No global loading states.** Iframes and fetches pop in; the app never says "loading."
+- **P2 — Keyboard/focus:** scope menu has no Esc; sim target tabs have no arrow-key roving focus.
+- **P2 — Not responsive** under ~900px (fixed 220px rail + grid). Fine for desktop control station, breaks
+ on a laptop in a split window.
+- **P2 — No dark/light or density options;** fine for the aesthetic, noting it.
+
+---
+
+## PASS 1 — INVESTOR LENS
+
+> Persona: a seed/Series-A investor who knows robotics + dev-tools. Question on every page: *does this
+> show a wedge, a moat, and a "holy shit" demo moment — or is it a nicer Foxglove?* RoboRun's thesis:
+> one behavior file runs sim→real on any ROS robot; every run is sealed (tamper-evident) and searchable
+> across a fleet; MCP-native so agents can drive robots.
+
+### The thesis, surfaced (cross-page)
+- **Strong wedge, weakly surfaced.** The three genuinely-differentiated assets — (1) the same `see/move/ask`
+ file from sim→real, (2) sealed/anchored black-box runs, (3) cross-fleet semantic search — are all *present*
+ but none is the hero of any page. A visitor could leave thinking "browser robot sim + a dashboard."
+- **No "holy shit" moment staged.** The killer demo is: run a behavior in sim → record → it's sealed +
+ searchable → replay the exact frame → prove it wasn't tampered. Studio has all the parts but no guided
+ path that detonates that in 60 seconds. **This is the single highest-leverage investor fix.**
+
+### Live — investor view
+- ✅ "Robot doing something in my browser, no install" is a credible cold-open.
+- ❌ On load it's mostly empty (no camera/detections) → reads as "pre-product." An investor's first 5
+ seconds are wasted. **Demo-readiness P0:** Live must look alive on first paint (seed with the running
+ arena or a demo run).
+- Moat signal here: low. It looks like telemetry. The provenance/fleet story isn't visible.
+
+### Sims — investor view
+- ✅ **The strongest page for the thesis.** Sim→real switcher + "same code runs everywhere" + the
+ `follow_person.py` snippet is *exactly* the pitch. A dev-investor gets it.
+- ❌ But "Real robot" dead-ends in a setup wizard, and the code is read-only — so the "write once, run
+ anywhere, edit live" claim isn't *demonstrated*, only stated. Investors discount stated-not-shown.
+- Defensibility: the unified sim/real abstraction is real IP; make it the headline.
+
+### Runs + Replay — investor view
+- ✅ This is where the **moat** lives (sealed MCAP, Merkle root, RFC-3161 anchor, Ed25519). Foxglove can't
+ prove a recording wasn't edited; RoboRun can.
+- ❌ **The moat is completely invisible in the UI.** No VERIFIED badge, no merkle root, no "tamper → detect"
+ demo. An investor reviewing Runs sees a worse Foxglove, not a defensible provenance product. **P0 for
+ fundraising:** surface verify/seal state prominently and offer a one-click tamper-demo.
+
+### Search — investor view
+- ✅ "Search everything every robot ever saw, across the fleet, offline" is a fundable line — it's the
+ data-network-effect story (more robots → more searchable history → more value).
+- ❌ Text-only results undersell it; a wall of thumbnails of "what robots saw" is the screenshot that goes
+ in the deck. Also nothing communicates *fleet-wide / cross-robot* — looks single-machine.
+
+### Analytics — investor view
+- ✅ Good "traction surrogate" for a demo (things seen, runs, storage). Cheap credibility.
+- ❌ All zeros on AI-searchable / robots-online read as "no traction." For a demo, seed it.
+- Not a moat; it's table stakes. Fine as supporting.
+
+### Scenarios — investor view
+- ✅ **Underrated wedge:** "scored, tamper-proof behavior tests" = CI for robots. That's a real category
+ (robot eval/regression) investors like, and it compounds with the sealed-runs moat.
+- ❌ Buried under "More", empty by default, no pass-rate trend. The story (regression-test your robot,
+ provably) isn't told.
+
+### Swarm Lab — investor view
+- ✅ Multi-robot coordination is a sexy demo (swarms photograph well).
+- ❌ Too complex/jargon-heavy to land in a pitch; reads as a research toy, not a product. Either simplify to
+ a "watch the swarm cover the map" hero or de-emphasize for fundraising.
+
+### Investor verdict (Pass 1)
+- **Funded thesis is real; the product hides it.** Priorities to be fundable: (1) a guided 60-sec
+ sim→record→seal→search→verify demo path; (2) surface provenance (VERIFIED badge + tamper demo) in Runs;
+ (3) make Live/Analytics look alive (seed demo data — `roborun demo`); (4) thumbnails in Search; (5) lead
+ Sims with "same file, sim→real" and make the code editable/runnable in-app.
+- **Risk flags:** lots of breadth (7 pages) but each shallow; the differentiators are stated not shown;
+ empty zero-states everywhere make it look pre-traction.
+
+---
+
+## PASS 1 — COMPETITOR LENS
+
+> Benchmarks: **Foxglove Studio** (the incumbent robotics viz/replay), **BAGEL** (browser-only bag viewer,
+> zero-install, 344 tests, polished panels), **Antioch** (north-star 5-stage agent loop). Question per page:
+> *would a user pick this over the incumbent, and why?*
+
+### Where RoboRun structurally wins (true on every page)
+- **It runs robots** (sim + real + behaviors + MCP). Foxglove/BAGEL only *view* data. Different category.
+- **Provenance**: sealed, anchored, verifiable runs. Neither Foxglove nor BAGEL can prove a recording is
+ untampered. Unique.
+- **Fleet-wide semantic search** over everything seen. Foxglove has no cross-run CLIP search.
+- **Zero-install, offline, local-first** like BAGEL — but with the runtime attached.
+
+### Live vs competitors
+- Foxglove's live (rosbridge/ws) panels are far richer (plotting, image annotations, 3D, raw topic
+ inspection). RoboRun Live is a fixed quadrant. **Behind on viewer depth.**
+- But Foxglove can't *start* a robot/sim from the UI; RoboRun can (Sims). Net: different value.
+
+### Sims vs competitors
+- **No competitor has this.** Foxglove/BAGEL have no sim launcher; this is pure RoboRun. Strongest
+ differentiator on any page. The threat is upstream (Gazebo/Isaac/MuJoCo Studio) not Foxglove.
+
+### Runs + Replay vs competitors
+- **This is the contested ground and RoboRun currently loses on polish.** BAGEL/Foxglove replay is mature:
+ scrub, multi-panel sync, image distortion, TF tree, timeline bookmarks, frame-export, multi-bag overlay.
+ RoboRun replay is a basic quadrant with text events + (often) no video.
+- **RoboRun's only winning move here is provenance** (verify/seal) + "replay any sealed run from the fleet."
+ That's not surfaced (see investor P0). Without it, this page reads as "worse Foxglove."
+- BAGEL opens `.mcap`/`.db3`/`.bag`; RoboRun replay only opens its own runs. Foxglove opens RoboRun's MCAP
+ natively — so today a serious user would record in RoboRun and *replay in Foxglove*. That's the gap to close.
+
+### Search vs competitors
+- **Category-defining, no direct competitor.** Foxglove/BAGEL have no semantic search across runs/fleet.
+ This + provenance is the defensible combo. Just undersold (text-only, see user P1).
+
+### Analytics vs competitors
+- Foxglove has dashboards/Foxglove Studio layouts; this is lighter. Table stakes, not a battleground.
+
+### Scenarios vs competitors
+- Closest analog is robotics CI / eval harnesses (not Foxglove). "Scored + sealed behavior tests" is a real
+ differentiator vs ad-hoc rosbag regression. Underexposed.
+
+### Swarm Lab vs competitors
+- Niche; competes with research/academic swarm sims, not the viewer incumbents. Not a commercial battleground.
+
+### Antioch parity (north-star)
+- Antioch's value is the tight 5-stage agent loop (perceive→decide→act→record→learn). RoboRun has the
+ pieces (see/move/ask + MCP + record + search) but Studio doesn't *show the loop closing*. The agent story
+ (MCP-native, `robot.delegate`, hot-reload) is invisible in Studio — there's no "agent is driving" view.
+ **Gap: an agent/MCP activity surface** would directly target Antioch parity and is absent.
+
+### Competitor verdict (Pass 1)
+- **Win by category, not by polish.** Don't try to out-Foxglove Foxglove on replay panels. Double down on
+ the three things no competitor has — sim→real runtime, provenance, fleet search — and make them the
+ spine of Studio. Biggest competitive risk: Runs/Replay invites a direct Foxglove comparison RoboRun loses,
+ while the winning features sit unsurfaced. Add an **agent/MCP view** to chase Antioch parity.
+
+---
+
+## CONSOLIDATED BACKLOG (Pass 1 synthesis)
+
+Ranked by leverage across all three lenses. Already-fixed items (pause, sim/state, flicker, scope, dead CSS,
+realtime replay) are excluded.
+
+### P0 — fundraising / first-impression blockers
+1. **Stage a guided "golden path" demo** (sim → record → seal → search → verify) so the differentiators
+ detonate in ~60s. Touches Live/Sims/Runs/Search. *(investor P0, competitor verdict)*
+2. **Surface provenance in Runs/Replay** — VERIFIED/ANCHORED badge, merkle root, one-click tamper→detect
+ demo. Turns "worse Foxglove" into the moat. *(investor P0, user P2)*
+3. **Make Live + Analytics look alive on first paint** — seed `roborun demo` data / auto-start a demo run so
+ the landing isn't 3/4 empty zero-states. *(user P1, investor demo-readiness)*
+
+### P1 — real friction / undersold value
+4. **Thumbnails in Search results** (visual recall) — and signal it's fleet-wide. *(user+investor+competitor)*
+5. **Human-readable Run list** — label, duration, thumbnail, sealed badge, "what happened" — not `ts · bytes`.
+6. **Record feedback** — show what's being recorded + elapsed timer + where it lands.
+7. **Live primary CTA + Telemetry empty-state** — "▶ Start a sim" at top; frame "no data" like the camera.
+8. **Agent/MCP activity view** — show the agent driving (Antioch parity); currently invisible in Studio.
+9. **Close the Sims loops** — Data Sim → "view in Live"; Real-robot copy matches the actual flow; an in-app
+ path to edit/run a behavior (the "change a number, save" pitch is stated, not actionable).
+
+### P2 — polish
+10. Camera 503 back-off (stop 10Hz polling with no source). 11. Global loading states (iframes/fetches).
+12. Zero-state framing on Analytics ("0" ≠ failure). 13. Search result count / "showing N of M".
+14. Keyboard: Esc-close scope menu, arrow-key roving on tabs. 15. Fleet sim "Play → goal" explainer +
+ clarify Fleet-vs-Swarm-Lab relationship. 16. Scenarios run progress + link to produced run.
+
+### P3
+17. Responsive layout < 900px. 18. Analytics refresh / time-range. 19. Analytics drill-down into Search/Runs.
+
+**Top issues filed as tracked tasks** (TaskCreate) — items 1–9 above. The rest live in this doc as the
+standing backlog.
+
+---
+
+## PASS 2 — DEEPER RE-WALK (interaction-level delta)
+
+No backlog items were implemented between Pass 1 and Pass 2, so there is no "fixed" delta; Pass 2's value is
+exercising real click-flows (Playwright) to surface what a static look missed.
+
+### NEW — P1: Search → replay is frequently a *dead click*
+- Probed live: of the 48 default ("recent") Search results, **zero contained "↳ open in replay"** — i.e.
+ none were run-backed. Most indexed observations have `source = sim/stream` and **no `run_id`**, so their
+ cards aren't openable. Clicking them calls `jump()` which returns silently (no `run_id`).
+- Impact: the headline unification ("search anything → jump into the exact replay frame") is, with the data
+ that's actually in the index, *usually unavailable*. It only works for observations extracted from a
+ sealed MCAP (`source=mcap`). This was assumed-working in earlier verification but is data-dependent.
+- Fix options: (a) link stream/sim observations back to their run at index time, or (b) clearly mark
+ non-replayable results and disable/relabel their click.
+
+### NEW — P2: silent dead clicks have no feedback
+- Result cards look identical whether replayable or not; only a subtle accent line distinguishes them, and a
+ non-replayable click does nothing with no toast/cursor/disabled state.
+
+### CONFIRMED via interaction (promoted from "suspected")
+- **Scope menu ignores Esc** — opened menu stayed open after Escape. Keyboard-trap-ish. (was user P2)
+- **Scenarios "run" works** — clicking run populated result rows (2 rows appeared), so there *is* terminal
+ feedback; but still no live progress and no link to the run it produced. (refines Pass-1 user P1)
+- **Replay robustness is good** — opening an old/event-only run produced **0 page errors**; graceful empty
+ states. (positive confirmation)
+
+### Pass-2 verdict
+The most important new finding is that **search→replay is mostly non-functional against real indexed data**
+(missing `run_id` linkage), which undercuts the single most differentiated user flow. Promote to the P1 band
+of the backlog and verify the indexing path (`StreamingExtractor` / `export`/`extract_run`) sets `run_id` for
+live/sim observations, not just MCAP extraction.
+
+### Pass 2 — per-page re-walk (completing the pages skipped above)
+Interaction-probed the remaining pages so Pass 2 genuinely covers *every* page. **0 page errors across all.**
+
+- **Live** — pause/resume verified at the DOM level (`LIVE → pause → PAUSED → resume`); the throttle didn't
+ break following. Camera still polls 10Hz while 503ing (P1 stands). Telemetry plot did *not* show "Time: --"
+ in this probe (data-dependent — shows the empty legend only when no `velocity` series), confirming the
+ "looks broken when empty" risk is intermittent, not constant.
+- **Sims · Arena** — selecting Arena shows only the `/sim` iframe; it stays mounted when you switch away and
+ back (no reload). Keep-mounted fix confirmed live.
+- **Sims · Fleet** — selecting Fleet shows `/fleet-sim`; mounts alongside Arena (both retained), instant
+ switch, no errors. Confirmed.
+- **Sims · Data Sim** — renders the native `DataSim` component (no iframe) and loads without the old
+ `_drone_ctrl` crash (fixed). Start/Reset present. Dead-end-to-Live (P1) still stands.
+- **Sims · Real robot** — shows the `/setup` iframe = the full 4-step wizard; confirms the copy/flow
+ mismatch (P1) — the promised "IP + Connect" is several steps in.
+- **Analytics** — 10 panels render; **no refresh / time-range control** (static snapshot confirmed). Zero
+ framing on "0 AI-searchable / 0 robots online" still reads as failure (P2 stands).
+- **Swarm Lab** — single hosted iframe loads cleanly with the floating badge suppressed; density/jargon
+ concern (P1) stands; still visually flatter than native pages.
+
+**Robustness positive:** the full deeper re-walk (Live + 4 Sims targets + Runs + Search + Scenarios +
+Analytics + Swarm + scope menu) produced **zero page/console errors** — the app is stable; the gaps are
+product/UX depth, not crashes.
+
+---
+
+## PASS 3 — IMPLEMENTED (first-run + ease-of-use overhaul)
+
+Driven by the "it makes no sense when I open it" feedback. Root causes found and fixed:
+
+- **"Why is it recording?"** — the run-list `recording` flag tracks the *always-open event journal*, not a
+ real MCAP recorder, so the RecordButton showed red on every fresh load. Fixed: RecordButton now reads the
+ true state from `/api/run/mcap.recording`; relabeled "● Record run" with a tooltip + live timer; run-list
+ REC badge also uses the true active-recorder run (verified: 0 false REC badges, sealed run shows ANCHORED).
+ Also stopped a stale recording left from testing.
+- **Empty/jargon Live on open** — added a first-run **welcome hero** (value prop + "▶ Start a sim — no
+ install" + 3-step guide) shown whenever nothing is live; swaps to panels once a webcam/sim/robot **or the
+ browser arena** is producing data (active-detection includes fresh events, not just `/api/dashboard`).
+- **"scratch" noise** — the project scope chip is now **hidden** until a project exists (newcomers see
+ nothing); it appears once you create one.
+- **Event-log noise** — filtered the "no actuator" nag, behavior-autoload lines, and per-frame camera
+ hashes; collapse consecutive duplicates; source-aware empty states (live vs replay).
+- **Runs legibility** — rows now show date · robot · "683 poses · 44 detections" · size, with SEALED/
+ ANCHORED/REC badges instead of `ts · bytes`.
+- **Provenance surfaced** — a replay shows a **✓ VERIFIED · ANCHORED + merkle-root** bar (the moat, finally
+ visible). (Tamper→detect demo still pending.)
+- **Search dead-clicks** — result count + "N replayable"; non-replayable hits dimmed + labeled "not in a
+ recorded run" with a tooltip, instead of silent no-ops.
+- **Sims loops** — Data Sim shows "→ watch it in Live" once running; Real-robot copy now matches the setup
+ wizard. (In-app behavior editing still pending.)
+- **Analytics zero-states** — "0 AI-searchable / 0 robots online" reframed ("install vision to embed" /
+ "N seen, none live").
+
+Still pending from the backlog (tracked tasks): guided golden-path demo (#24), tamper→detect demo (#25),
+auto-seed demo data (#26), Search thumbnails (#27), in-app behavior editor (#32), backend run_id linkage so
+*all* observations are replayable (#33), agent/MCP view (#31).
+
+---
+
+## PASS 4 — full re-walk (current state), found + fixed
+
+Screenshotted every page again. Found two real bugs and two data findings:
+
+- **FIXED — replay 500 on a corrupt run.** `/api/run/series` and `/api/run/frame` raised
+ `RecordLengthLimitExceeded` (uncaught) on a malformed MCAP → HTTP 500, breaking the replay view. Added a
+ `_safe_messages()` iterator in `run_series.py` that stops cleanly on a bad/partial file; series now
+ returns graceful empty (200) and frame returns 404, both handled by the UI. Good runs unaffected.
+- **FIXED — Scenarios results all showed "—".** The table mapped `scenario/result/scores`; the API returns
+ `name/outcome/metrics`. Re-mapped → results now show "● passed · threshold_gain · demo".
+- **FINDING — some demo runs have unreadable MCAPs.** `RunRecorder`/`_ChainedStream` occasionally writes an
+ MCAP the stock reader can't summarize (corrupt record length), so those runs have no thumbnail/replay.
+ Defensive handling now prevents breakage; the root recorder corruption is a backend bug (tracked).
+- **CLARIFIED — "why is it recording" during a sim.** The browser arena auto-records its session via the
+ pyodide shim (`web/py/shim.py`), so REC turns on when you run the arena. This is now *truthful* and
+ labeled (real recorder state + timer + tooltip), unlike the original always-on false flag — acceptable
+ under the "seal every run" design, though lingering recordings could auto-stop on leaving the sim.
+
+### g14 resolution — the "corrupt MCAP" was unsealed partials, not a recorder bug
+- A fresh `record/start → (sim data) → record/stop` run replays perfectly; **RunRecorder is fine for
+ sealed runs.** The unreadable files (7 of 16) were all **unsealed partials** — arena auto-records that
+ never closed cleanly (page closed / server restart mid-record). An MCAP is only valid once finalized.
+- Done: (1) defensive `_safe_messages` contains any partial (no 500s); (2) cleaned up the 7 abandoned
+ unsealed partials so they don't litter Runs/Search. Demo runs verified readable (27/27/21 msgs).
+- Deeper improvement (tracked #34): the arena auto-record should seal on stop/unload, or the recorder
+ should write a recoverable summary incrementally, so an interrupted run is still readable.
+
+---
+
+## PASS 5 — sign-off (full re-walk after all changes)
+
+Screenshotted every page after the overhaul. **All pages clean of console/page errors** except one graceful
+404 on Search (a thumbnail for a frameless run → onError hides it). Net state:
+
+- **First-run**: welcome hero ("Run a robot. Watch it. Search everything it saw.") with Start-a-sim, the
+ 60-sec guided tour, and load-sample-data; no false REC; no "scratch" noise.
+- **Live**: legible (filtered events, framed empty states, real camera when a sim runs).
+- **Sims**: thin launcher; Real robot = native connect panel (no wizard); editable behaviors that hot-reload.
+- **Runs**: human-readable list + provenance bar with live **verify** and a reversible **tamper demo**.
+- **Search**: thumbnails + replayable affordance + count; hits jump into replay.
+- **Analytics / Scenarios**: native, framed zero-states, real results.
+- **Agent**: MCP connect commands + live agent activity (Antioch parity).
+- **Guided tour**: run → sealed/tamper → search → agent, in 4 steps.
+- **Robustness**: corrupt/partial runs contained (no 500s); abandoned partials cleaned; lazy LiveSource so
+ non-live routes open no sockets.
+
+Remaining tracked backend item (non-blocking): #34 — arena auto-record should seal on stop/unload so an
+interrupted run stays readable (today contained gracefully).
+
+---
+
+## PASS 6 — embedding sweep (stale standalone chrome on hosted pages)
+
+Hosted pages (arena `/sim`, fleet `/fleet-sim`, swarm `/fleet`) carried their own standalone navigation that
+duplicated/contradicted Studio's. Swept systematically:
+
+- **Arena (`arena.js` framed guard):** when embedded, removes `#ck-home`, `#ck-views` + `#tb-views` (the two
+ ▤ VIEWS menus), `#projChip`, all `a[href="/setup"]`, `.server-only` deck links, and the ROS + Fleet picker
+ cards; rewrites the picker subtitle. Keeps the real sim controls. Verified: 0 stale links, all nav chrome
+ gone.
+- **shell.js pages (fleet, fleet-sim) — generic fix:** when framed, shell.js now rewrites every
+ `a[href^="/"]` to the matching **Studio** route and sets `target="_top"`, so cross-links (e.g. Fleet's
+ "Fleet Sim →", Fleet-Sim's "Swarm Lab") land on a Studio tab instead of loading a standalone page in the
+ frame. A MutationObserver re-applies it to late renders. Verified: Swarm's cross-link → `/studio/sims`.
+- **Native sweep:** `ScopeSwitcher` "+ new sim / robot" repointed `/setup → /studio/sims` (the only
+ app/src link that left Studio). All other internal links already target `/studio/*`.
+
+**Rule (to prevent regression):** any page Studio embeds must, when framed (`window.self !== window.top`),
+hide its own nav chrome and route internal links to the Studio tab. shell.js pages get this automatically;
+non-shell pages (arena) self-guard at the top of their script.
+
+---
+
+## PASS 7 — conform to the RoboRun Design System (claude.ai/design f84aeaa8)
+
+Imported the official **RoboRun Design System** via the claude_design MCP and re-skinned Studio to match its
+visual language (the DS was itself lifted from this branch, so layouts already aligned — the deltas were the
+*design language*):
+- **System monospace** everywhere on the dashboards (DS: "no webfont; monospace gives the control-station
+ feel"). Dropped the IBM Plex webfonts (`@fontsource` deps removed); body now `var(--mono)` with tabular nums.
+- **Flat + one soft shadow, no glow** — removed every `--glow-*`; depth is layered surfaces + a single
+ `0 6px 24px rgba(0,0,0,.35)` on the hero and floating menus only. Panels are flat.
+- **`--accent-fill` (#0e1a12)** for all active states (nav/scope/chips/run-rows/target-seg), accent text,
+ `--accent-dim` border — replacing the old accent-dim-bg + glow-bar treatment. Nav glyphs are plain (no tile).
+- DS **radii** (10px panels / 6px controls / 999px pills), **232px sidebar**, **1280px content cap**,
+ flat background (removed the instrument-grid texture). Welcome is the one tinted **hero** (radial accent
+ wash + green border + soft shadow). `rr-pulse` ring on the live dot.
+- Tokens added to `theme.css` to mirror the DS (`--accent-fill`, washes, `--shadow`, `--rr-hero-grad`,
+ `--rr-sidebar-w`, `--rr-main-max`). Kept the user's consolidated nav (not the DS's many-page grouping).
+
+Files: `app/src/theme.css`, `app/src/shell/shell.css`, `app/src/main.tsx`, `app/package.json`. Verified:
+body font = system mono, no console errors, Live/Runs render flat + on-brand.
diff --git a/docs/SYSTEM_MAP.md b/docs/SYSTEM_MAP.md
new file mode 100644
index 0000000..be039ba
--- /dev/null
+++ b/docs/SYSTEM_MAP.md
@@ -0,0 +1,49 @@
+# RoboRun system map
+
+*How the pieces fit, for the three ways people use it. One loop, three modes.*
+
+## The one loop
+```
+ source ─▶ YOLO + CLIP ─▶ sealed MCAP (the black box) ─▶ live index ─▶ search/analyze
+ (sim/robot/cam) recorder.py spatial_memory.py session.search
+```
+Everything is the same loop whether the source is a sim, a robot, or a webcam.
+`PerceptionSession.for_mode("sim"|"robot"|"production")` runs it (`session.py`).
+
+## Three usage modes (what runs where)
+| You are… | You use… | Entry points |
+|---|---|---|
+| **a front-end user** | the browser dashboards | `/` cockpit · `/scenarios` · `/search` · `/timeline` · `/analytics` · `/fleet` · `/run` (linked from the cockpit ▤ VIEWS menu) |
+| **a local-runner operator** | the `roborun` CLI + Python handle | `roborun` (server) · `roborun connect ` · `roborun search ` · `roborun scenarios [run\|suite]` · `behaviors/*.py` with the `robot.*` handle |
+| **a fleet (many robots)** | shared R2 + signed beacons | each robot runs the loop; `index/*/*.parquet` + `beacons/` shared via R2; `/analytics` fleet panel + cross-robot `/search` |
+
+## Where things live (the map)
+- **Record everything** → `recorder.py` (MCAP channels: camera/detections/clip/pose/
+ `/cmd`/telemetry/gps/cloud), `retention.py` (GC), `anchor.py` (RFC 3161 seal).
+- **Perceive** → `webcam.py`/`ros_camera.py`/`synthetic_camera.py`, `cameras.py`
+ (multi-cam), `models.py` (YOLO+CLIP), `depth.py`/`scene_builder.py`.
+- **Index + search over time** → `spatial_memory.py` (SQLite + numpy/sqlite-vec ANN),
+ `session.search`, `observations.py` (extract + fleet DuckDB).
+- **Sim** → `simulator.py` (MuJoCo), `sim_backend.py` (sense via handle),
+ `mjx_env.py` (vectorized), `gz.py`/`gz_mujoco.py` (Gazebo).
+- **Drive** → `behaviors.py` (`robot.*` handle, hot-reload), `transport/` + `rosbridge.py`
+ (ROS 1 & ROS 2), `connect.py`.
+- **Evaluate** → `scenario.py` (scored runs + suites), `scenario_defs.py` (runnable +
+ `run_matrix`/`regression_gate`), `demo_scenarios.py`.
+- **Agent** → `agent.py` (deck command bar), `ros_mcp.py` (MCP tools incl.
+ `recall_place`), `skills/` (GitHub-installable).
+- **View / analyze** → `routes/scenarios.py`, `routes/search.py`, `run_series.py`
+ (`/run` synced playback), `web/*.html`.
+
+## The contract (why one file works everywhere)
+A behavior written against `robot.see/move/lidar/pose/goto/go_to_place` runs
+unchanged on the arena, MuJoCo, MJX, a gz world, and a real ROS 1/2 robot — the
+primitives are schema-identical per backend (`docs/SIM_SPEC.md`,
+`tests/test_contract.py`). Scale-up (MJX) and fidelity (gz/real) ride on top.
+
+## Keep it lean
+Optional heavy deps are extras (`[vision] [sim] [ros] [fleet] [ann] [video] [mjx]`);
+the core is 3 deps. The index is derived/rebuildable from the MCAP. If a feature
+needs a new subsystem, first check it can't compose existing primitives (semantic
+nav = recall + goto; the data layer is one Observation row).
+```
diff --git a/pyproject.toml b/pyproject.toml
index 03d2fc4..0833469 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "ros-agent"
-version = "0.12.0"
+version = "0.13.0"
description = "Quickly run and manage ROS 1/2 robots. Built-in MCP for Claude, Cursor, or any AI client, plus vision, sim, and hot-reload behaviors. Merkle proofs make every run tamper-evident and replayable."
readme = "README.md"
license = {text = "MIT"}
@@ -46,7 +46,12 @@ ros = ["cyclonedds>=0.10"]
crypto = ["cryptography>=42.0"]
anchor = ["asn1crypto>=1.5"]
fleet = ["boto3>=1.28", "duckdb>=1.0"]
-all = ["ros-agent[vision,sim,depth,jepa,gemini,ros,crypto,anchor,fleet]"]
+ann = ["sqlite-vec>=0.1"] # CLIP ANN past ~1M vectors
+video = ["av>=11.0"] # H.264 camera channel (10–50× vs JPEG)
+mjx = ["mujoco-mjx>=3.0", "jax>=0.4"] # vectorized N-world sim (GPU-optional)
+strata = ["numpy>=1.24", "boto3>=1.28"] # object-storage-native blob + vector engine
+tui = ["rich>=13.0"] # full-screen terminal dashboard (roborun tui)
+all = ["ros-agent[vision,sim,depth,jepa,gemini,ros,crypto,anchor,fleet,ann,video,mjx,strata,tui]"]
[project.scripts]
roborun = "roborun.server:main"
@@ -54,9 +59,11 @@ roborun-mcp = "roborun.mcp_stdio:main"
# legacy aliases (the PyPI package is named ros-agent)
ros-agent = "roborun.server:main"
ros-agent-mcp = "roborun.mcp_stdio:main"
+# Strata — object-storage-native time-series + vector engine
+strata = "strata.cli:main"
[tool.setuptools.packages.find]
-include = ["roborun*"]
+include = ["roborun*", "strata*"]
[tool.setuptools.package-data]
roborun = ["web/**/*"]
diff --git a/roborun/agent.py b/roborun/agent.py
index 2325c73..2091392 100644
--- a/roborun/agent.py
+++ b/roborun/agent.py
@@ -163,7 +163,52 @@ def _get_soul() -> str:
- get_telemetry: battery, position, velocity, joints
You already have the live camera frame — don't call perception tools redundantly.
-After actions, verify via the updated frame in the next turn. Be concise."""
+After actions, verify via the updated frame in the next turn. Be concise.
+
+## Authoring behaviors (the real superpower)
+move only nudges the robot once. The lasting way to give it a skill is to WRITE A
+BEHAVIOR: a small Python control loop that runs at 10 Hz with no LLM in the loop.
+Prefer authoring a behavior for any standing task ("patrol", "follow me", "back off
+from walls"); use move only for one-off pokes. The closed loop you run:
+
+ 1. write_behavior(name, source) → creates behaviors/.py, hot-reloads and
+ starts within ~1s. The tool reports back whether it loaded and any error.
+ 2. arena_status + read_timeline → watch what it actually did (pose, detections,
+ anything it logged, and crashes). This is how you debug "why did it fail".
+ 3. read_behavior → write_behavior again to fix it. Iterate until it works.
+ 4. set_behavior(name, "disable") to stop it; "enable" to resume.
+ list_behaviors shows everything running, its run/error counts and last error.
+
+A behavior is ONE file. Use exactly this handle — nothing else is portable:
+
+ from roborun.behaviors import behavior
+
+ @behavior(hz=10) # control loop; or @behavior(every=5.0) for slow loops
+ def patrol(robot):
+ people = robot.see("person") # list of {cx,cy,w,h,label,conf,dist}
+ if people:
+ robot.say("person spotted")
+ return robot.stop()
+ ahead = robot.lidar()[0] # 36 floats, meters, [0] = straight ahead
+ if ahead < 0.8:
+ return robot.move(turn=1.0) # too close to a wall, turn
+ robot.move(forward=0.4) # forward / strafe / turn / climb, each in [-1,1]
+
+Handle reference (the whole contract — same file runs sim → real robot):
+ robot.see(label) · robot.move(forward,strafe,turn,climb) · robot.stop()
+ robot.lidar() → 36 floats · robot.pose() → {x,z,heading} · robot.say(t)/robot.log(t)
+ robot.remember(k,v)/robot.recall(k) · robot.state (dict persists across ticks)
+ robot.goto(x,z) · robot.approach(thing) · robot.seen(label)
+Keep behaviors short and readable — a person should grasp the file in ten seconds.
+
+## Triaging from the eval scoreboard
+Behaviors are scored by *scenarios* (named runs with pass/fail + metrics), grouped
+into *suites* with a pass-rate. When asked to improve the robot, start from the
+scoreboard, don't guess: list_suites → find the lowest pass-rate → list_scenarios
+(suite=…, outcome="failed") to see which scenarios fail and why → read_behavior on
+the behavior behind it → write_behavior to fix → re-observe. That's the same
+"reproduce, find the failure mode, fix, re-validate" loop a robotics engineer runs,
+but at software speed."""
_FAST_TOOLS = [
{
@@ -275,6 +320,78 @@ def _get_soul() -> str:
"required": ["label"],
},
},
+ {
+ "name": "list_behaviors",
+ "description": "List every behavior currently loaded — name, file, whether it's running (enabled), tick count, error count, and last error. Use this to see what skills the robot has before writing or editing one.",
+ "input_schema": {"type": "object", "properties": {}},
+ },
+ {
+ "name": "read_behavior",
+ "description": "Read the Python source of a behavior so you can edit it. Pass the behavior name (or file stem).",
+ "input_schema": {
+ "type": "object",
+ "properties": {"name": {"type": "string", "description": "Behavior name / file stem, e.g. 'patrol'"}},
+ "required": ["name"],
+ },
+ },
+ {
+ "name": "write_behavior",
+ "description": "Create or overwrite behaviors/.py with a full @behavior-decorated control loop. It hot-reloads and starts within ~1s; this tool waits and reports back whether it loaded and any runtime error so you can iterate. This is how you give the robot a lasting skill from natural language.",
+ "input_schema": {
+ "type": "object",
+ "properties": {
+ "name": {"type": "string", "description": "snake_case file stem, e.g. 'perimeter_patrol'"},
+ "source": {"type": "string", "description": "Complete Python source: 'from roborun.behaviors import behavior' + an @behavior(hz=...) function taking (robot)."},
+ },
+ "required": ["name", "source"],
+ },
+ },
+ {
+ "name": "set_behavior",
+ "description": "Start (enable) or stop (disable) a loaded behavior by name. Disabling stops the robot and halts that loop; enabling resumes it.",
+ "input_schema": {
+ "type": "object",
+ "properties": {
+ "name": {"type": "string"},
+ "action": {"type": "string", "enum": ["enable", "disable"]},
+ },
+ "required": ["name", "action"],
+ },
+ },
+ {
+ "name": "read_timeline",
+ "description": "Read the most recent events from the run timeline — behavior loads/errors, things the robot said or logged, detections, agent actions. This is how you observe what a behavior actually did and debug why it failed.",
+ "input_schema": {
+ "type": "object",
+ "properties": {
+ "limit": {"type": "integer", "description": "How many recent events (default 25)"},
+ "contains": {"type": "string", "description": "Optional: only events whose source or title contains this substring (e.g. a behavior name)"},
+ },
+ },
+ },
+ {
+ "name": "arena_status",
+ "description": "Get the sim's ground-truth state — whether the arena is open, the robot's pose, the current level, and occlusion-checked detections. Use it to verify a behavior is moving the robot the way you intended.",
+ "input_schema": {"type": "object", "properties": {}},
+ },
+ {
+ "name": "list_scenarios",
+ "description": "List recent scored scenario runs — name, outcome (passed/failed/error), suite, tags, key metrics. This is the eval record: how the robot's behaviors have actually performed. Filter by suite, outcome, or scenario name.",
+ "input_schema": {
+ "type": "object",
+ "properties": {
+ "limit": {"type": "integer", "description": "Max rows (default 15)"},
+ "suite": {"type": "string"},
+ "outcome": {"type": "string", "enum": ["passed", "failed", "error"]},
+ "name": {"type": "string"},
+ },
+ },
+ },
+ {
+ "name": "list_suites",
+ "description": "List scenario suites with their pass-rate, run count, and latest activity — the scoreboard of how each capability is doing. Start here to find what's failing, then list_scenarios(suite=…) to drill in, then read_behavior / write_behavior to fix the behavior behind it.",
+ "input_schema": {"type": "object", "properties": {}},
+ },
]
@@ -395,9 +512,129 @@ def _execute_fast_tool(name: str, args: dict) -> str:
elif name == "find_object":
return _find_object(args.get("label", ""), float(args.get("max_rotation_deg", 360)))
+ elif name == "list_behaviors":
+ from roborun.behaviors import BehaviorRunner
+ rows = BehaviorRunner.get().statuses()
+ if not rows:
+ return "No behaviors loaded. Write one with write_behavior."
+ lines = []
+ for s in rows:
+ run = "running" if s.get("enabled") else "stopped"
+ tail = f" · last_error: {s['last_error']}" if s.get("last_error") else ""
+ lines.append(f"{s['name']} [{run}] {Path(s['file']).name} "
+ f"· {s.get('runs',0)} ticks, {s.get('errors',0)} errors{tail}")
+ return "\n".join(lines)
+
+ elif name == "read_behavior":
+ return _read_behavior_source(str(args.get("name", "")))
+
+ elif name == "write_behavior":
+ from roborun.behaviors import write_behavior_file
+ bname = str(args.get("name", ""))
+ result = write_behavior_file(bname, str(args.get("source", "")))
+ if not result.get("ok"):
+ return f"Not written: {result.get('error')}"
+ # Give hot reload a beat, then report whether it actually loaded + ran.
+ time.sleep(1.4)
+ return f"Wrote {result['path']}. " + _behavior_health(bname)
+
+ elif name == "set_behavior":
+ from roborun.behaviors import BehaviorRunner
+ bname = str(args.get("name", "")).strip()
+ action = str(args.get("action", "enable"))
+ ok = BehaviorRunner.get().set_enabled(bname, action == "enable")
+ return (f"{bname} {action}d." if ok else f"No behavior named {bname!r}.")
+
+ elif name == "read_timeline":
+ from roborun.events import recent
+ limit = int(args.get("limit", 25))
+ contains = str(args.get("contains", "")).lower().strip()
+ rows = recent(limit if not contains else 200)
+ if contains:
+ rows = [e for e in rows
+ if contains in str(e.get("source", "")).lower()
+ or contains in str(e.get("title", "")).lower()][-limit:]
+ if not rows:
+ return "Timeline empty (no matching events)."
+ return "\n".join(f"[{e.get('type','?')}/{e.get('source','?')}] {e.get('title','')}"
+ for e in rows)
+
+ elif name == "arena_status":
+ from roborun.ros_mcp import _tool_arena_status
+ r = _tool_arena_status({})
+ if not r.get("active"):
+ return r.get("hint", "Arena not open.")
+ dets = r.get("detections") or []
+ labels = ", ".join(sorted({d.get("label", "?") for d in dets})) or "none"
+ return (f"Arena live · level={r.get('level')} · pose={r.get('pose')} · "
+ f"sees: {labels}")
+
+ elif name == "list_scenarios":
+ from roborun.scenario import list_results
+ rows = list_results(limit=int(args.get("limit", 15)),
+ suite=args.get("suite"), outcome=args.get("outcome"),
+ name=args.get("name"))
+ if not rows:
+ return "No scenario runs recorded yet."
+ lines = []
+ for r in rows:
+ ev = r.get("evaluation") or {}
+ ev_s = (" · " + ", ".join(f"{g}:{v}" for g, v in ev.items())) if ev else ""
+ tail = f" — {r['reason']}" if r.get("reason") else ""
+ lines.append(f"{r['name']} [{r['outcome']}] "
+ f"suite={r.get('suite') or '-'} "
+ f"{r.get('metrics', {})}{ev_s}{tail}")
+ return "\n".join(lines)
+
+ elif name == "list_suites":
+ from roborun.scenario import list_suites
+ cards = list_suites()
+ if not cards:
+ return "No suites yet — runs scored with a suite= group here."
+ return "\n".join(
+ f"{c['suite']}: {int(c['pass_rate']*100)}% pass "
+ f"({c['passed']}/{c['runs']} runs) · {', '.join(c['scenarios'][:6])}"
+ for c in cards)
+
return f"Unknown tool: {name}"
+def _resolve_behavior_path(name: str) -> Path | None:
+ """Map a behavior name / file stem to its source file."""
+ from roborun.behaviors import BehaviorRunner
+ stem = name.strip().removesuffix(".py")
+ for s in BehaviorRunner.get().statuses():
+ if s.get("name") == name or Path(s.get("file", "")).stem == stem:
+ return Path(s["file"])
+ cand = Path("behaviors") / f"{stem}.py"
+ return cand if cand.exists() else None
+
+
+def _read_behavior_source(name: str) -> str:
+ path = _resolve_behavior_path(name)
+ if path is None:
+ return f"No behavior file for {name!r}. Use list_behaviors to see what exists."
+ try:
+ return f"# {path}\n{path.read_text()}"
+ except Exception as exc:
+ return f"Could not read {path}: {exc}"
+
+
+def _behavior_health(name: str) -> str:
+ """One-line health for a just-written behavior, by file stem."""
+ from roborun.behaviors import BehaviorRunner
+ stem = name.strip().removesuffix(".py")
+ for s in BehaviorRunner.get().statuses():
+ if Path(s.get("file", "")).stem == stem:
+ if s.get("last_error"):
+ return (f"Loaded but erroring: {s['last_error']} "
+ f"({s.get('errors',0)} errors). Read the timeline and fix it.")
+ run = "running" if s.get("enabled") else "loaded (stopped)"
+ return f"It's {run} — {s.get('runs',0)} ticks, no errors so far."
+ return ("Not visible in the runner yet — it may have failed to import. "
+ "Check read_timeline for a load error.")
+
+
def _find_object(label: str, max_rotation_deg: float = 360) -> str:
"""Rotate the robot incrementally, checking camera at each step."""
import math
@@ -443,7 +680,7 @@ class FastRobotAgent:
Requires ANTHROPIC_API_KEY in environment.
"""
- MAX_TOOL_ROUNDS = 6
+ MAX_TOOL_ROUNDS = 12 # authoring loops (write → observe → fix) need headroom
@property
def MODEL(self) -> str:
diff --git a/roborun/backends.py b/roborun/backends.py
new file mode 100644
index 0000000..8e334ed
--- /dev/null
+++ b/roborun/backends.py
@@ -0,0 +1,101 @@
+"""Backend registry + capability matrix (platform spec 03).
+
+One declarative place that answers "which embodiments can I run, and what can
+each do" — rapier / mujoco / mjx / gazebo / isaac / real. The UI reads this to
+offer/grey-out backends when binding an environment (spec 08). The runtime
+`Backend` protocol is the contract every backend implements; `SimBackend`
+(`sim_backend.py`) is the reference impl.
+"""
+from __future__ import annotations
+
+from typing import Any, Protocol, runtime_checkable
+
+
+@runtime_checkable
+class Backend(Protocol):
+ """The uniform handle every backend exposes (spec 03 P1)."""
+ def capabilities(self) -> dict[str, Any]: ...
+ def reset(self, seed: int | None = None) -> None: ...
+ def pose(self) -> dict[str, float] | None: ...
+ def lidar(self, max_range: float = 10.0) -> list[float]: ...
+ def see(self, label: str | None = None) -> list: ...
+ def move(self, forward: float = 0.0, strafe: float = 0.0,
+ turn: float = 0.0, climb: float = 0.0) -> None: ...
+ def clock(self) -> float: ...
+
+
+# Static description of each backend. `status` is refined at runtime by probing.
+_REGISTRY: dict[str, dict[str, Any]] = {
+ "rapier": {
+ "name": "Rapier (browser)", "where": "web/physics.js",
+ "kind": "sim", "base_status": "ready",
+ "caps": {"physics": True, "camera": True, "lidar": True,
+ "fleet": True, "determinism": "seeded", "headless": False},
+ },
+ "mujoco": {
+ "name": "MuJoCo (headless)", "where": "sim_backend.py:23",
+ "kind": "sim", "base_status": "ready",
+ "caps": {"physics": True, "camera": True, "lidar": True,
+ "fleet": False, "determinism": "seeded", "headless": True},
+ },
+ "mjx": {
+ "name": "MJX (vectorized)", "where": "mjx_env.py",
+ "kind": "sim", "base_status": "optional",
+ "caps": {"physics": True, "camera": False, "lidar": True,
+ "fleet": True, "determinism": "seeded", "headless": True,
+ "vectorized": True},
+ },
+ "gazebo": {
+ "name": "Gazebo (gz-sim)", "where": "gz.py:23",
+ "kind": "sim", "base_status": "available",
+ "caps": {"physics": True, "camera": True, "lidar": True,
+ "fleet": True, "determinism": "lockstep", "headless": True},
+ },
+ "isaac": {
+ "name": "Isaac Sim", "where": "isaac.py:detect_world",
+ "kind": "sim", "base_status": "available",
+ "caps": {"physics": True, "camera": True, "lidar": True,
+ "fleet": True, "determinism": "lockstep", "headless": True,
+ "photoreal": True},
+ },
+ "real": {
+ "name": "Real robot (ROS)", "where": "transport/, ros_telemetry.py",
+ "kind": "real", "base_status": "available",
+ "caps": {"physics": True, "camera": True, "lidar": True,
+ "fleet": True, "determinism": "none", "headless": True},
+ },
+}
+
+
+def _status(bid: str, meta: dict) -> str:
+ """Refine base status by actually probing what's installed/live."""
+ base = meta["base_status"]
+ try:
+ if bid == "mjx":
+ from roborun import mjx_env
+ return "ready" if mjx_env.installed() else "optional"
+ if bid == "gazebo":
+ from roborun import gz
+ return "ready" if gz.detect_world() else "available"
+ if bid == "isaac":
+ from roborun import isaac
+ return "ready" if isaac.detect_world() else "available"
+ except Exception:
+ pass
+ return base
+
+
+def list_backends() -> list[dict[str, Any]]:
+ out = []
+ for bid, meta in _REGISTRY.items():
+ out.append({"id": bid, "name": meta["name"], "where": meta["where"],
+ "kind": meta["kind"], "status": _status(bid, meta),
+ "caps": meta["caps"]})
+ return out
+
+
+def get(bid: str) -> dict[str, Any] | None:
+ meta = _REGISTRY.get(bid)
+ if not meta:
+ return None
+ return {"id": bid, **meta, "status": _status(bid, meta)}
diff --git a/roborun/behaviors.py b/roborun/behaviors.py
index 81401bc..ce66112 100644
--- a/roborun/behaviors.py
+++ b/roborun/behaviors.py
@@ -65,6 +65,21 @@ def follow_person(robot):
_thoughts_lock = threading.Lock()
+def _sim_backend():
+ """A SimBackend over the running MuJoCo sim, so pose()/lidar() sense the sim
+ through the handle when no arena is open (LOCAL_SIM_SPEC Phase 1). None if no
+ sim is running."""
+ try:
+ from roborun.routes._singletons import get_simulator
+ sim = get_simulator()
+ if getattr(sim, "is_running", False):
+ from roborun.sim_backend import SimBackend
+ return SimBackend(sim)
+ except Exception:
+ pass
+ return None
+
+
def behavior(hz: float | None = None, every: float | None = None,
name: str | None = None, autostart: bool = True) -> Callable:
"""Mark a function as a behavior loop. `hz` for control loops,
@@ -280,6 +295,11 @@ def pose(self) -> dict | None:
return a.pose()
except Exception:
pass
+ sb = _sim_backend()
+ if sb is not None:
+ p = sb.pose()
+ if p is not None:
+ return p
try:
from roborun.ros_telemetry import get_bridge
return get_bridge().handle_pose()
@@ -524,6 +544,12 @@ def lidar(self) -> list[float]:
return a.lidar()
except Exception:
pass
+ sb = _sim_backend()
+ if sb is not None:
+ try:
+ return sb.lidar()
+ except Exception:
+ pass
try:
from roborun.ros_telemetry import get_bridge
return get_bridge().handle_lidar()
@@ -551,18 +577,21 @@ def frame_jpeg(self) -> bytes | None:
def move(self, forward: float = 0.0, strafe: float = 0.0, turn: float = 0.0,
climb: float = 0.0) -> None:
+ raw = (forward, strafe, turn, climb)
forward = max(-MAX_LINEAR, min(MAX_LINEAR, forward))
strafe = max(-MAX_LINEAR, min(MAX_LINEAR, strafe))
turn = max(-MAX_ANGULAR, min(MAX_ANGULAR, turn))
climb = max(-MAX_LINEAR, min(MAX_LINEAR, climb)) # Twist linear.z
+ clamped = (forward, strafe, turn, climb) != raw
sent = False
+ target = "sim" # where the command actually went — keeps the timeline honest
try:
from roborun.arena import get_arena
arena = get_arena()
if arena.is_active():
arena.set_cmd(forward, strafe, turn, climb)
- sent = True
+ sent = True; target = "sim"
except Exception:
pass
if not sent:
@@ -571,7 +600,7 @@ def move(self, forward: float = 0.0, strafe: float = 0.0, turn: float = 0.0,
sim = get_simulator()
if sim.is_running:
sim.set_cmd_vel(forward, strafe, turn)
- sent = True
+ sent = True; target = "sim"
except Exception:
pass
if not sent:
@@ -585,7 +614,7 @@ def move(self, forward: float = 0.0, strafe: float = 0.0, turn: float = 0.0,
from roborun.ros_telemetry import get_bridge
client.move(forward, strafe, turn,
get_bridge().cmd_vel_topic, linear_z=climb)
- sent = True
+ sent = True; target = "robot"
except Exception:
pass
@@ -599,6 +628,17 @@ def move(self, forward: float = 0.0, strafe: float = 0.0, turn: float = 0.0,
return
self._warned_no_actuator = False
+ # Record the command actually sent (post-clamp) into the sealed run, so
+ # replay answers "why did it move", not just "what did it see".
+ try:
+ from roborun.recorder import active_recorder
+ rec = active_recorder()
+ if rec is not None:
+ rec.write_cmd(forward, strafe, turn, climb,
+ source=self._name, clamped=clamped)
+ except Exception:
+ pass
+
# Real actuator: log on sharp changes immediately, otherwise ≤1/sec.
cmd = (forward, strafe, turn)
delta = max(abs(a - b) for a, b in zip(cmd, self._last_cmd))
@@ -606,7 +646,7 @@ def move(self, forward: float = 0.0, strafe: float = 0.0, turn: float = 0.0,
if delta >= 0.3 or (delta > 0.01 and now - self._last_move_emit >= 1.0):
self._last_cmd = cmd
self._last_move_emit = now
- emit("ros", self._name, f"move fwd={forward:.2f} turn={turn:.2f}",
+ emit(target, self._name, f"move fwd={forward:.2f} turn={turn:.2f}",
{"forward": round(forward, 2), "strafe": round(strafe, 2),
"turn": round(turn, 2)})
@@ -664,6 +704,34 @@ def remember(self, key: str, value: Any) -> None:
def recall(self, key: str, default: Any = None) -> Any:
return self._load_memory().get(key, default)
+ # ---- semantic spatial recall + navigation (dimOS-style, over the index) ----
+
+ def recall_place(self, query: str, by: str = "label") -> dict | None:
+ """Where/when did I see ? Searches the persistent, all-time spatial
+ index (CLIP semantic / YOLO label) and returns the best-matching past
+ observation that has a position: {x, y, ts, label, robot_id, ...}, or None.
+ This is the memory behind 'go to where you last saw the red mug'."""
+ try:
+ from roborun.routes._singletons import get_memory
+ from roborun.session import search
+ for h in search(get_memory(), query, by=by, k=15):
+ if h.get("x") is not None:
+ return h
+ except Exception:
+ pass
+ return None
+
+ def go_to_place(self, query: str, by: str = "label", tol: float = 0.5) -> bool:
+ """Semantic navigation: recall where was seen, then goto it. True
+ when arrived, False if the place isn't in memory. Composes recall_place +
+ goto — the natural-language 'take me to X' verb, grounded in the seal."""
+ place = self.recall_place(query, by=by)
+ if place is None:
+ self.stop()
+ return False
+ self._intent("go_to_place", query, (place["x"], place.get("y", 0.0)))
+ return self.goto(place["x"], place.get("y", 0.0), tol=tol)
+
@staticmethod
def _load_memory() -> dict:
try:
diff --git a/roborun/cameras.py b/roborun/cameras.py
new file mode 100644
index 0000000..addd0c1
--- /dev/null
+++ b/roborun/cameras.py
@@ -0,0 +1,89 @@
+"""Multi-camera runtime (PERCEPTION_DATA_SPEC) — N source-tagged pipelines.
+
+Ingestion today picks one camera; this manages several at once, each tagged with a
+`source_id` so its frames/detections land on per-named MCAP channels
+(`/camera/`, `/detections/`) and Observations carry the
+source. Pipelines are duck-typed (`start/stop/snapshot/get_detections`), so the
+manager works with `WebcamPipeline`, `RosCameraPipeline`, or a test stub — the
+physical camera is the only part this can't supply.
+"""
+from __future__ import annotations
+
+import threading
+from typing import Any
+
+
+class CameraManager:
+ _instance: "CameraManager | None" = None
+
+ def __init__(self) -> None:
+ self._sources: dict[str, Any] = {}
+ self._lock = threading.Lock()
+
+ @classmethod
+ def get(cls) -> "CameraManager":
+ if cls._instance is None:
+ cls._instance = CameraManager()
+ return cls._instance
+
+ def register(self, source_id: str, pipeline: Any) -> None:
+ with self._lock:
+ self._sources[source_id] = pipeline
+
+ def remove(self, source_id: str) -> None:
+ with self._lock:
+ p = self._sources.pop(source_id, None)
+ if p is not None:
+ try:
+ p.stop()
+ except Exception:
+ pass
+
+ def sources(self) -> list[str]:
+ with self._lock:
+ return sorted(self._sources)
+
+ def start_all(self, **kw: Any) -> dict[str, Any]:
+ out = {}
+ for sid, p in list(self._sources.items()):
+ try:
+ out[sid] = p.start(**kw)
+ except Exception as exc:
+ out[sid] = {"ok": False, "error": str(exc)}
+ return out
+
+ def stop_all(self) -> None:
+ for p in list(self._sources.values()):
+ try:
+ p.stop()
+ except Exception:
+ pass
+
+ def snapshot(self, source_id: str):
+ with self._lock:
+ p = self._sources.get(source_id)
+ return p.snapshot() if p is not None else None
+
+ def detections(self, source_id: str) -> list[dict]:
+ with self._lock:
+ p = self._sources.get(source_id)
+ return p.get_detections() if p is not None else []
+
+ def record_into(self, recorder, encode=True) -> int:
+ """Tap every source's current frame + detections into the recorder on
+ per-source channels. Returns how many sources contributed."""
+ import cv2
+ n = 0
+ for sid in self.sources():
+ frame = self.snapshot(sid)
+ if frame is None:
+ continue
+ dets = self.detections(sid)
+ if encode:
+ ok, buf = cv2.imencode(".jpg", frame, [cv2.IMWRITE_JPEG_QUALITY, 85])
+ if ok:
+ recorder.write_camera(buf.tobytes(), name=sid)
+ if dets:
+ recorder.write_detections(dets, name=sid)
+ n += 1
+ return n
diff --git a/roborun/cli.py b/roborun/cli.py
new file mode 100644
index 0000000..643a516
--- /dev/null
+++ b/roborun/cli.py
@@ -0,0 +1,226 @@
+"""Local-runner CLI verbs: `roborun search …` and `roborun scenarios …`.
+
+For operators driving the runner locally (no browser needed): query the all-time
+index and run/inspect scenarios from the terminal. Lean — composes the same
+functions the UI uses.
+"""
+from __future__ import annotations
+
+import time
+
+
+def search_cli(argv: list[str]) -> int:
+ import argparse
+ p = argparse.ArgumentParser(prog="roborun search",
+ description="Find anything/anyone across all recorded runs.")
+ p.add_argument("query", nargs="?", default="", help="what/who to find")
+ p.add_argument("--by", default="label", choices=["label", "clip", "near", "time"])
+ p.add_argument("--k", type=int, default=15)
+ p.add_argument("--since", type=float, help="unix seconds")
+ p.add_argument("--robot", help="filter to one robot")
+ a = p.parse_args(argv)
+ from roborun.spatial_memory import SpatialMemoryStore
+ from roborun.session import search
+ rows = search(SpatialMemoryStore(), a.query, by=a.by, k=a.k,
+ since=a.since, robot_id=a.robot)
+ if not rows:
+ print("no hits"); return 0
+ print(f"{len(rows)} hit(s) across all history:")
+ for r in rows:
+ labels = ",".join(sorted({d.get("label") for d in (r.get("detections") or [])})) or "—"
+ when = time.strftime("%Y-%m-%d %H:%M", time.localtime(r.get("ts", 0)))
+ where = f"({r.get('x')},{r.get('y')})" if r.get("x") is not None else "—"
+ print(f" {when} {labels:18} {where:14} {r.get('source','?')}/{r.get('robot_id','')}")
+ return 0
+
+
+def _base_url() -> str:
+ import os
+ return f"http://127.0.0.1:{os.environ.get('ROBORUN_PORT', '8765')}"
+
+
+def ask_cli(argv: list[str]) -> int:
+ """Send a natural-language instruction to the running robot's agent (it sees
+ the live camera and can move/act). `roborun ask "patrol the lobby"`.
+ Needs `roborun` running in another terminal."""
+ import json
+ import urllib.request
+ msg = " ".join(argv).strip()
+ if not msg:
+ print('usage: roborun ask "what you want the robot to do"'); return 2
+ req = urllib.request.Request(_base_url() + "/api/agent/chat",
+ data=json.dumps({"message": msg}).encode(),
+ headers={"Content-Type": "application/json"})
+ try:
+ resp = urllib.request.urlopen(req, timeout=120)
+ except Exception as exc:
+ print(f"can't reach RoboRun ({exc}). Start it with `roborun` first."); return 1
+ printed = False
+ buf = ""
+ for raw in resp:
+ line = raw.decode().strip()
+ if not line.startswith("data:"):
+ buf += line # non-SSE body (e.g. a plain JSON error)
+ continue
+ try:
+ ev = json.loads(line[5:].strip())
+ except Exception:
+ continue
+ if ev.get("type") == "text":
+ print(ev.get("text", ""), end="", flush=True); printed = True
+ elif ev.get("type") == "tool_use":
+ print(f"\n · {ev.get('tool_name')}({json.dumps(ev.get('tool_input', {}))[:60]})", flush=True)
+ elif ev.get("type") == "error":
+ print(f"\n[agent error] {ev.get('error')}"); return 1
+ elif ev.get("type") == "done":
+ print(); return 0
+ if printed:
+ print(); return 0
+ # nothing streamed — surface a JSON error body (e.g. agent unavailable)
+ try:
+ err = json.loads(buf)
+ print(err.get("error", "the agent isn't available."))
+ except Exception:
+ print("the agent isn't available — set ANTHROPIC_API_KEY and restart `roborun`.")
+ return 1
+
+
+def stop_cli(argv: list[str]) -> int:
+ """Emergency stop: halt every actuator + disable behaviors. `roborun stop`."""
+ import json
+ import urllib.request
+ req = urllib.request.Request(_base_url() + "/api/estop", data=b"{}",
+ headers={"Content-Type": "application/json"})
+ try:
+ with urllib.request.urlopen(req, timeout=10) as r:
+ json.loads(r.read())
+ print("🛑 emergency stop sent — actuators halted, behaviors disabled.")
+ return 0
+ except Exception:
+ # server down: still try an in-process estop so the verb always does something
+ try:
+ from roborun.ros_mcp import handle_tool_call
+ handle_tool_call("estop", {})
+ print("🛑 estop sent in-process (server wasn't running).")
+ return 0
+ except Exception as exc:
+ print(f"couldn't send estop: {exc}"); return 1
+
+
+def status_cli(argv: list[str]) -> int:
+ """Quick health: is the server up, what's connected, how much is recorded."""
+ import json
+ import urllib.request
+ up = False
+ try:
+ with urllib.request.urlopen(_base_url() + "/api/agent/status", timeout=3) as r:
+ json.loads(r.read()); up = True
+ except Exception:
+ pass
+ print(f"server: {'up at ' + _base_url() if up else 'not running (start with `roborun`)'}")
+ try:
+ from roborun.spatial_memory import SpatialMemoryStore
+ from roborun.recorder import list_runs
+ from roborun.retention import status as st
+ s = SpatialMemoryStore().stats()
+ runs = list_runs()
+ ss = st()
+ print(f"data: {s.get('total', 0)} things seen across {len(runs)} runs "
+ f"({ss.get('used_gb', 0)}/{ss.get('cap_gb', 0)} GB)")
+ from roborun.connect import saved_robot
+ rb = saved_robot()
+ print(f"robot: {rb['host'] + ' (' + rb.get('type', '?') + ')' if rb else 'none connected — `roborun connect `'}")
+ except Exception as exc:
+ print(f"data: (unavailable: {exc})")
+ return 0
+
+
+def demo_cli(argv: list[str]) -> int:
+ """Seed a populated demo so a fresh install shows a live UI immediately:
+ a recorded synthetic run (camera+detections+pose, indexed + searchable) and a
+ scored scenario suite. `roborun demo` then open the dashboards."""
+ import time
+ import numpy as np
+ from roborun.recorder import RunRecorder
+ from roborun.events import runs_root
+ from roborun.observations import StreamingExtractor
+ from roborun.spatial_memory import SpatialMemoryStore
+ from roborun.synthetic_camera import SyntheticCamera
+ from roborun.session import PerceptionSession
+ import roborun.demo_scenarios # noqa
+ from roborun.scenario_defs import run_suite
+
+ store = SpatialMemoryStore()
+ # real CLIP image embeddings when the vision extra is present, so seeded data
+ # is genuinely semantic-searchable; else None (no fake placeholder vectors).
+ try:
+ from roborun.models import CLIPMatcher
+ _clip = CLIPMatcher()
+ embed_fn = lambda f: _clip.embed_image(f)
+ except Exception:
+ embed_fn = None
+ labels = ["person", "forklift", "pallet"]
+ for lab in labels:
+ rec = RunRecorder(robot_id=f"demo-{lab}", root=runs_root(), checkpoint_interval=0.05)
+ rec.extractor = StreamingExtractor(store, robot_id=f"demo-{lab}",
+ run_id=rec.run_id, source="production")
+ cam = SyntheticCamera(label=lab)
+ sess = PerceptionSession(cam, store, mode="production", source_id=f"{lab}-cam",
+ recorder=rec, embed_fn=embed_fn,
+ hz=30)
+ cam.start()
+ try:
+ for _ in range(10):
+ sess.tick(); time.sleep(0.01)
+ finally:
+ cam.stop()
+ rec.close(do_anchor=False)
+ run_suite("demo")
+ print("Demo seeded: 3 runs recorded + indexed, demo suite scored.")
+ print("Open http://localhost:8765 → ▤ VIEWS → Search / Analytics / Scenarios")
+ return 0
+
+
+def dataset_cli(argv: list[str]) -> int:
+ import argparse
+ p = argparse.ArgumentParser(prog="roborun dataset",
+ description="Curate a labeled dataset from a search.")
+ p.add_argument("query", nargs="?", default="",
+ help="what to curate (optional for --by uncertain)")
+ p.add_argument("out", help="output directory")
+ p.add_argument("--by", default="label",
+ choices=["label", "clip", "near", "time", "uncertain"],
+ help="'uncertain' = active learning: the examples worth labeling")
+ p.add_argument("--k", type=int, default=500)
+ p.add_argument("--since", type=float)
+ a = p.parse_args(argv)
+ from roborun.spatial_memory import SpatialMemoryStore
+ from roborun.session import export_dataset
+ r = export_dataset(SpatialMemoryStore(), a.query, a.out, by=a.by, k=a.k, since=a.since)
+ print(f"wrote {r['count']} images + labels.jsonl → {r['dir']}")
+ return 0
+
+
+def scenarios_cli(argv: list[str]) -> int:
+ import roborun.demo_scenarios # noqa: F401 (register built-ins)
+ from roborun.scenario import list_suites
+ from roborun.scenario_defs import list_defs, run_scenario, run_suite
+ if argv and argv[0] == "run" and len(argv) > 1:
+ rec = run_scenario(argv[1])
+ print(f"{argv[1]}: {rec['outcome'].upper()}"
+ + (f" — {rec.get('reason')}" if rec.get("reason") else ""))
+ return 0 if rec["outcome"] == "passed" else 1
+ if argv and argv[0] == "suite" and len(argv) > 1:
+ s = run_suite(argv[1])
+ print(f"{argv[1]}: {int(s['pass_rate']*100)}% ({s['passed']}/{s['runs']})")
+ return 0
+ print("Runnable scenarios:")
+ for d in list_defs():
+ print(f" {d['name']:20} suite={d['suite'] or '-'} {d.get('doc','')[:50]}")
+ suites = list_suites()
+ if suites:
+ print("\nSuites (history):")
+ for s in suites:
+ print(f" {s['suite']:20} {int(s['pass_rate']*100):>3}% ({s['passed']}/{s['runs']})")
+ print("\n roborun scenarios run · roborun scenarios suite ")
+ return 0
diff --git a/roborun/demo_scenarios.py b/roborun/demo_scenarios.py
new file mode 100644
index 0000000..84da140
--- /dev/null
+++ b/roborun/demo_scenarios.py
@@ -0,0 +1,59 @@
+"""Built-in runnable scenarios so the /scenarios board is live out of the box.
+
+Importing this registers a few `@scenario_def`s that run headlessly (no hardware):
+a couple of deterministic smoke checks, plus a real vectorized-MJX reach when the
+`[mjx]` extra is present. They give the board, suites, and the agent something to
+run and score immediately.
+"""
+from __future__ import annotations
+
+from roborun.scenario_defs import scenario_def
+
+
+@scenario_def("smoke_pass", suite="demo", tags=["smoke"])
+def smoke_pass(ctx):
+ """Always-green smoke check — proves the run→score→seal loop end to end."""
+ ctx.run.metric("ok", True)
+ ctx.run.evaluate("health", uptime=1.0)
+ ctx.run.passed(seed=ctx.seed)
+
+
+@scenario_def("threshold_gain", suite="demo", tags=["tuning"],
+ params={"gain": 1.0, "min_gain": 0.8})
+def threshold_gain(ctx):
+ """Passes when params['gain'] >= params['min_gain'] — a tunable A/B target."""
+ g, lo = ctx.params["gain"], ctx.params["min_gain"]
+ ctx.run.metric("gain", g)
+ ctx.run.passed() if g >= lo else ctx.run.failed(f"gain {g} < {lo}")
+
+
+def _register_mjx() -> None:
+ # Register iff the [mjx] extra is present — but DON'T import it here (that
+ # triggers mujoco_warp's noisy optional-dep messages on every CLI listing).
+ try:
+ from roborun.mjx_env import installed
+ if not installed():
+ return
+ except Exception:
+ return
+
+ @scenario_def("mjx_reach", suite="sim", tags=["mjx", "vectorized"],
+ params={"n_envs": 16, "steps": 15})
+ def mjx_reach(ctx):
+ """Real vectorized physics: push N MuJoCo carts right; pass if ≥90% move."""
+ import numpy as np
+ from roborun.mjx_env import make_vec, score_vectorized, SANITY_XML
+ n = int(ctx.params["n_envs"])
+ env = make_vec(SANITY_XML, n_envs=n)
+ r = score_vectorized(
+ env,
+ policy_fn=lambda o: np.ones((n, env.nu), np.float32),
+ reward_fn=lambda o: np.where(np.asarray(o)[:, 0] > 0, 1.0, -1.0),
+ steps=int(ctx.params["steps"]), seed=ctx.seed or 0)
+ ctx.run.metric("worlds", r["n"])
+ ctx.run.evaluate("reward", mean=round(r["mean"], 3), std=round(r["std"], 3))
+ ctx.run.passed() if r["pass_rate"] >= 0.9 else ctx.run.failed(
+ f"only {int(r['pass_rate']*100)}% reached")
+
+
+_register_mjx()
diff --git a/roborun/environments.py b/roborun/environments.py
new file mode 100644
index 0000000..b46ad57
--- /dev/null
+++ b/roborun/environments.py
@@ -0,0 +1,128 @@
+"""Environments — a registered space inside a project (platform specs 08 + 09).
+
+An Environment binds a coordinate frame + map + tagged camera placements to a
+backend (rapier/gazebo/isaac/real) and a mode (scratch/test/production). Data
+for a session lands under the active project/environment (see projects.py).
+
+ /projects///env.json
+ { id, name, backend, mode, world_ref, map_ref, cameras[] }
+
+`cameras[]` entries carry placement/extrinsics so every sensor maps into one
+spatial frame (spec 09): {source_id, kind: robot|fixed, placement:{x,y,z,
+roll,pitch,yaw}, intrinsics?}
+"""
+from __future__ import annotations
+
+import json
+import time
+from pathlib import Path
+from typing import Any
+
+from roborun import projects
+from roborun.projects import slug, MODES
+
+BACKENDS = ("rapier", "mujoco", "mjx", "gazebo", "isaac", "real")
+
+
+def _env_dir(project: str, env: str) -> Path:
+ return projects.projects_root() / slug(project) / slug(env)
+
+
+def _meta_path(project: str, env: str) -> Path:
+ return _env_dir(project, env) / "env.json"
+
+
+def create(project: str, name: str, backend: str = "rapier",
+ mode: str = "scratch", world_ref: str | None = None) -> dict[str, Any]:
+ projects.create(project) # ensure the project exists
+ eid = slug(name)
+ p = _meta_path(project, eid)
+ p.parent.mkdir(parents=True, exist_ok=True)
+ if p.exists():
+ return json.loads(p.read_text())
+ meta = {
+ "id": eid, "name": name,
+ "backend": backend if backend in BACKENDS else "rapier",
+ "mode": mode if mode in MODES else "scratch",
+ "world_ref": world_ref,
+ "map_ref": None, # populated when a map is registered (spec 09)
+ "cameras": [], # tagged placements (spec 09)
+ "frame": "world",
+ "created": time.time(),
+ }
+ p.write_text(json.dumps(meta, indent=2))
+ _link_to_project(project, eid)
+ return meta
+
+
+def _link_to_project(project: str, env: str) -> None:
+ meta = projects.get(project)
+ if meta is None:
+ return
+ envs = set(meta.get("environments", []))
+ if env not in envs:
+ meta.setdefault("environments", []).append(env)
+ projects._meta_path(slug(project)).write_text(json.dumps(meta, indent=2))
+
+
+def get(project: str, env: str) -> dict[str, Any] | None:
+ p = _meta_path(project, env)
+ if not p.exists():
+ return None
+ try:
+ return json.loads(p.read_text())
+ except Exception:
+ return None
+
+
+def list_envs(project: str) -> list[dict[str, Any]]:
+ root = projects.projects_root() / slug(project)
+ if not root.exists():
+ return []
+ out = []
+ for d in sorted(root.iterdir()):
+ if not d.is_dir():
+ continue
+ m = d / "env.json"
+ if m.exists():
+ try:
+ meta = json.loads(m.read_text())
+ except Exception:
+ continue
+ meta["runs"] = len(list((d / "runs").glob("*/*.mcap"))) + \
+ len(list((d / "runs").glob("*.mcap"))) if (d / "runs").exists() else 0
+ out.append(meta)
+ return out
+
+
+def update(project: str, env: str, **fields: Any) -> dict[str, Any] | None:
+ meta = get(project, env)
+ if meta is None:
+ return None
+ meta.update({k: v for k, v in fields.items() if v is not None})
+ _meta_path(project, env).write_text(json.dumps(meta, indent=2))
+ return meta
+
+
+def register_camera(project: str, env: str, source_id: str,
+ placement: dict | None = None, kind: str = "robot",
+ intrinsics: dict | None = None) -> dict[str, Any] | None:
+ """Tag a camera's placement in the environment frame (spec 09)."""
+ meta = get(project, env)
+ if meta is None:
+ return None
+ cams = [c for c in meta.get("cameras", []) if c.get("source_id") != source_id]
+ cams.append({"source_id": source_id, "kind": kind,
+ "placement": placement or {"x": 0, "y": 0, "z": 0,
+ "roll": 0, "pitch": 0, "yaw": 0},
+ "intrinsics": intrinsics})
+ meta["cameras"] = cams
+ _meta_path(project, env).write_text(json.dumps(meta, indent=2))
+ return meta
+
+
+def active_meta() -> dict[str, Any] | None:
+ a = projects.active()
+ if not a:
+ return None
+ return get(a["project"], a["environment"])
diff --git a/roborun/events.py b/roborun/events.py
index 0775441..fa23284 100644
--- a/roborun/events.py
+++ b/roborun/events.py
@@ -43,6 +43,15 @@
def runs_root() -> Path:
+ # Scope the event journal under the active project/environment (spec 07);
+ # legacy flat layout when nothing is selected.
+ try:
+ from roborun import projects
+ dr = projects.data_root()
+ if dr is not None:
+ return dr / "runs"
+ except Exception:
+ pass
base = os.environ.get("ROBORUN_STATE_DIR")
root = Path(base) if base else Path.home() / ".roborun"
return root / "runs"
diff --git a/roborun/gz.py b/roborun/gz.py
new file mode 100644
index 0000000..d7f9633
--- /dev/null
+++ b/roborun/gz.py
@@ -0,0 +1,131 @@
+"""gz-sim discovery + lockstep driver (GAZEBO_RUNNER_SPEC).
+
+gz-sim publishes standard ROS 2 topics, so sensing/actuation already flow through
+`ros_telemetry` + the existing transports — this module adds only the three things
+gz needs that a real robot doesn't: (1) recognizing a live gz world, (2) owning the
+clock for determinism, (3) spawning a RoboRun level as SDF entities.
+
+ROS 2 / gz aren't importable in every environment; everything here degrades to
+`None`/no-op when the graph isn't reachable, and the pure level→entity mapping is
+unit-testable without a running world.
+"""
+from __future__ import annotations
+
+from typing import Any
+
+# Capability row the agent/UI reads (mirrors transport.CAPABILITY_MATRIX shape).
+CAPABILITIES = {
+ "discovery": True, "subscribe": True, "publish": True,
+ "services": True, "actions": True, "clock_control": True,
+}
+
+
+def detect_world(transport: Any = None) -> dict | None:
+ """Return {world, sim_time} if a gz world is up (a `/clock` + `/world/*`
+ surface), else None. Uses whatever transport is connected."""
+ try:
+ tp = transport or _default_transport()
+ if tp is None:
+ return None
+ topics = tp.topics()
+ if "/clock" not in topics:
+ return None
+ worlds = [t for t in topics if t.startswith("/world/")]
+ name = worlds[0].split("/")[2] if worlds else "default"
+ return {"world": name, "clock": True}
+ except Exception:
+ return None
+
+
+def _default_transport():
+ try:
+ from roborun.rosbridge import get_client
+ c = get_client(auto_connect=False)
+ return c if c and c.health.get("connected") else None
+ except Exception:
+ return None
+
+
+# ── level → SDF entities (pure mapping; testable with no world) ─────────────
+
+def level_to_spawns(level: dict, seed: int = 0) -> list[dict]:
+ """Translate a RoboRun level (walls/props/robot/zones) into gz SpawnEntity
+ requests. Deterministic given `seed`. Returns a list of
+ {service, name, type, pose} dicts the driver POSTs to `/world//create`."""
+ spawns: list[dict] = []
+ spawn = level.get("spawn", {"x": 0, "z": 0, "heading": 0})
+ robot_model = {"dog": "go2", "wheeled": "turtlebot3", "drone": "x500",
+ "biped": "g1"}.get(level.get("robot", "wheeled"), "turtlebot3")
+ spawns.append({"service": "create", "name": "robot", "type": robot_model,
+ "pose": {"x": spawn.get("x", 0), "y": -spawn.get("z", 0),
+ "yaw": spawn.get("heading", 0)}})
+ for i, p in enumerate(level.get("props", [])):
+ spawns.append({"service": "create", "name": f"prop_{i}",
+ "type": p.get("kind", "box"),
+ "pose": {"x": p.get("x", 0), "y": -p.get("z", 0), "yaw": 0}})
+ for i, w in enumerate(level.get("walls", [])):
+ spawns.append({"service": "create", "name": f"wall_{i}", "type": "wall",
+ "pose": {"a": w}})
+ return spawns
+
+
+class GzClock:
+ """Lockstep clock: pause the world, step N at a time, seed physics — the
+ determinism contract gz lacks by default. No-op when no world/transport."""
+
+ def __init__(self, world: str, transport: Any = None) -> None:
+ self.world = world
+ self._tp = transport or _default_transport()
+
+ def _call(self, service: str, args: dict) -> dict | None:
+ if self._tp is None:
+ return None
+ try:
+ return self._tp.call_service(f"/world/{self.world}/{service}",
+ args=args, timeout=5.0)
+ except Exception:
+ return None
+
+ def pause(self) -> dict | None:
+ return self._call("control", {"pause": True})
+
+ def step(self, n: int = 1) -> dict | None:
+ return self._call("control", {"pause": True, "multi_step": int(n)})
+
+ def reset(self, seed: int = 0) -> dict | None:
+ return self._call("control", {"reset": {"all": True}, "seed": int(seed)})
+
+
+class GzRunner:
+ """Orchestrates a deterministic gz episode: detect the world, spawn a level,
+ own the clock. Degrades to a dry-run plan (no calls) when no world/transport,
+ so the flow is testable without ROS/gz."""
+
+ def __init__(self, transport: Any = None) -> None:
+ self.transport = transport
+ self.world = None
+ self.clock: GzClock | None = None
+
+ def attach(self) -> bool:
+ info = detect_world(self.transport)
+ if info is None:
+ return False
+ self.world = info["world"]
+ self.clock = GzClock(self.world, self.transport)
+ return True
+
+ def plan_level(self, level: dict, seed: int = 0) -> list[dict]:
+ """The spawn plan for a level (pure; what attach() would POST)."""
+ return level_to_spawns(level, seed)
+
+ def run_level(self, level: dict, seed: int = 0, steps: int = 100) -> dict:
+ """Spawn + step. Returns a report; status 'no-world' when offline."""
+ if not self.attach():
+ return {"status": "no-world", "plan": self.plan_level(level, seed)}
+ self.clock.reset(seed)
+ for sp in self.plan_level(level, seed):
+ self.transport.call_service(f"/world/{self.world}/{sp['service']}",
+ args=sp, timeout=5.0)
+ for _ in range(steps):
+ self.clock.step(1)
+ return {"status": "ran", "world": self.world, "steps": steps, "seed": seed}
diff --git a/roborun/gz_mujoco.py b/roborun/gz_mujoco.py
new file mode 100644
index 0000000..ebd5621
--- /dev/null
+++ b/roborun/gz_mujoco.py
@@ -0,0 +1,66 @@
+"""MuJoCo-backed gz world emulator (GAZEBO_RUNNER_SPEC, in-code).
+
+The gz integration (`gz.GzRunner`) drives a world over a service/topic surface
+(`/world//control`, `/create`, `/clock`, `/odom`). This object presents that
+exact surface backed by a **real MuJoCo sim**, so the whole gz code path runs
+against real physics with no gz binary and no ROS — the deterministic lockstep
+(`multi_step`), entity spawn tracking, and odom readback all exercise live
+dynamics. Swapping this for a real ros_gz transport is the only change to go live.
+"""
+from __future__ import annotations
+
+from typing import Any
+
+from roborun.mjx_env import SANITY_XML
+
+
+class MujocoGzWorld:
+ def __init__(self, model_xml: str = SANITY_XML, world: str = "sim") -> None:
+ import mujoco
+ self._mj = mujoco
+ self.model = mujoco.MjModel.from_xml_string(model_xml)
+ self.data = mujoco.MjData(self.model)
+ mujoco.mj_forward(self.model, self.data)
+ self.world = world
+ self.spawned: list[dict] = []
+ self.stepped = 0
+ self.seed = 0
+
+ # ── the gz transport surface GzRunner uses ──────────────────────────────
+ def topics(self) -> dict[str, str]:
+ return {
+ "/clock": "rosgraph_msgs/Clock",
+ f"/world/{self.world}/control": "gz.msgs.WorldControl",
+ f"/world/{self.world}/create": "gz.msgs.EntityFactory",
+ "/odom": "nav_msgs/Odometry",
+ "/cmd_vel": "geometry_msgs/Twist",
+ }
+
+ def call_service(self, name: str, args: dict | None = None,
+ timeout: float = 5.0) -> dict:
+ args = args or {}
+ if name.endswith("/control"):
+ if args.get("reset"):
+ self._mj.mj_resetData(self.model, self.data)
+ self.seed = int(args.get("seed", 0))
+ # deterministic per-seed qpos jitter
+ import numpy as np
+ rng = np.random.default_rng(self.seed)
+ self.data.qpos[:] += rng.uniform(-0.01, 0.01, size=self.model.nq)
+ self._mj.mj_forward(self.model, self.data)
+ n = int(args.get("multi_step", 0))
+ for _ in range(n):
+ self._mj.mj_step(self.model, self.data)
+ self.stepped += n
+ return {"ok": True, "sim_time": float(self.data.time)}
+ if name.endswith("/create"):
+ self.spawned.append(args)
+ return {"ok": True}
+ return {"ok": True}
+
+ # ── odom readback (what ros_telemetry would map to the handle) ──────────
+ def odom(self) -> dict:
+ q = self.data.qpos
+ return {"x": float(q[0]),
+ "y": float(q[1]) if self.model.nq > 1 else 0.0,
+ "sim_time": float(self.data.time)}
diff --git a/roborun/incidents.py b/roborun/incidents.py
new file mode 100644
index 0000000..c73b619
--- /dev/null
+++ b/roborun/incidents.py
@@ -0,0 +1,57 @@
+"""Incidents — flag a moment in a run to revisit (the data-flywheel primitive).
+
+The winning physical-AI loop turns production data into curated "incidents to
+revisit" (Foxglove's framing). An incident is just a `{run_id, ts, note, tag}`
+bookmark — stored as a small JSONL sidecar next to the runs, emitted onto the
+event timeline, and surfaced on the /run player. Tiny, composes existing infra,
+no new store. Search/curation already cover the heavy lifting.
+"""
+from __future__ import annotations
+
+import json
+import time
+import uuid
+from typing import Any
+
+from roborun.events import emit, runs_root
+
+
+def _path():
+ p = runs_root() / "incidents.jsonl"
+ p.parent.mkdir(parents=True, exist_ok=True)
+ return p
+
+
+def flag(run_id: str, ts: float | None = None, note: str = "",
+ tag: str = "incident", robot_id: str | None = None) -> dict[str, Any]:
+ """Bookmark a moment. `ts` defaults to now (live flagging). Returns the record."""
+ rec = {"id": uuid.uuid4().hex[:12], "run_id": run_id,
+ "ts": ts if ts is not None else time.time(),
+ "note": note, "tag": tag, "robot_id": robot_id,
+ "flagged_at": time.time()}
+ with open(_path(), "a") as fh:
+ fh.write(json.dumps(rec) + "\n")
+ emit("incident", robot_id or "operator",
+ f"flagged · {tag}" + (f" · {note}" if note else ""),
+ {"run_id": run_id, "ts": rec["ts"], "incident_id": rec["id"]})
+ return rec
+
+
+def list_incidents(run_id: str | None = None, tag: str | None = None,
+ limit: int = 200) -> list[dict[str, Any]]:
+ p = _path()
+ if not p.exists():
+ return []
+ out: list[dict] = []
+ for line in p.read_text().splitlines():
+ try:
+ r = json.loads(line)
+ except Exception:
+ continue
+ if run_id and r.get("run_id") != run_id:
+ continue
+ if tag and r.get("tag") != tag:
+ continue
+ out.append(r)
+ out.sort(key=lambda r: r.get("flagged_at", 0), reverse=True)
+ return out[:limit]
diff --git a/roborun/isaac.py b/roborun/isaac.py
new file mode 100644
index 0000000..a106496
--- /dev/null
+++ b/roborun/isaac.py
@@ -0,0 +1,129 @@
+"""Isaac Sim discovery + lockstep driver (platform spec 03 P2).
+
+Mirrors gz.py: Isaac Sim publishes ROS 2 topics through its ROS bridge, so
+sensing/actuation already flow through `ros_telemetry` + the transports. This
+module adds the Isaac-specific bits: (1) recognizing a live Isaac stage, (2)
+owning the clock for determinism, (3) mapping a RoboRun level to USD stage prims.
+
+Isaac/omni aren't importable in most environments; everything degrades to
+`None`/no-op when the bridge isn't reachable, and the pure level→prim mapping is
+unit-testable without a running Isaac.
+"""
+from __future__ import annotations
+
+from typing import Any
+
+CAPABILITIES = {
+ "discovery": True, "subscribe": True, "publish": True,
+ "services": True, "actions": True, "clock_control": True,
+ "photoreal": True,
+}
+
+# RoboRun robot → Isaac USD asset (Nucleus paths in a real deploy).
+_ROBOT_USD = {
+ "dog": "/Isaac/Robots/Unitree/go2.usd",
+ "wheeled": "/Isaac/Robots/Nova/nova_carter.usd",
+ "drone": "/Isaac/Robots/Quadcopter/quadcopter.usd",
+ "biped": "/Isaac/Robots/Unitree/g1.usd",
+}
+
+
+def _default_transport():
+ try:
+ from roborun.rosbridge import get_client
+ c = get_client(auto_connect=False)
+ return c if c and c.health.get("connected") else None
+ except Exception:
+ return None
+
+
+def detect_world(transport: Any = None) -> dict | None:
+ """Return {stage, clock} if an Isaac stage is live (its ROS bridge exposes
+ `/clock` + Isaac-specific topics), else None."""
+ try:
+ tp = transport or _default_transport()
+ if tp is None:
+ return None
+ topics = tp.topics()
+ if "/clock" not in topics:
+ return None
+ isaac_topics = [t for t in topics
+ if t.startswith("/isaac") or "/omni" in t or t == "/rtf"]
+ if not isaac_topics:
+ return None
+ return {"stage": "isaac", "clock": True}
+ except Exception:
+ return None
+
+
+def level_to_prims(level: dict, seed: int = 0) -> list[dict]:
+ """Translate a RoboRun level into Isaac USD stage prims (pure; testable with
+ no Isaac). Returns a list of {prim_path, usd, pose} the runner would add."""
+ prims: list[dict] = []
+ spawn = level.get("spawn", {"x": 0, "z": 0, "heading": 0})
+ usd = _ROBOT_USD.get(level.get("robot", "wheeled"), _ROBOT_USD["wheeled"])
+ prims.append({"prim_path": "/World/robot", "usd": usd,
+ "pose": {"x": spawn.get("x", 0), "y": -spawn.get("z", 0),
+ "yaw": spawn.get("heading", 0)}})
+ for i, p in enumerate(level.get("props", [])):
+ prims.append({"prim_path": f"/World/prop_{i}",
+ "usd": f"/Isaac/Props/{p.get('kind', 'box')}.usd",
+ "pose": {"x": p.get("x", 0), "y": -p.get("z", 0), "yaw": 0}})
+ for i, w in enumerate(level.get("walls", [])):
+ prims.append({"prim_path": f"/World/wall_{i}", "usd": "/Isaac/Props/wall.usd",
+ "pose": {"a": w}})
+ return prims
+
+
+class IsaacClock:
+ """Lockstep: pause the stage, step N, seed physics — the determinism contract.
+ No-op when no stage/transport."""
+
+ def __init__(self, transport: Any = None) -> None:
+ self._tp = transport or _default_transport()
+
+ def _call(self, service: str, args: dict) -> dict | None:
+ if self._tp is None:
+ return None
+ try:
+ return self._tp.call_service(service, args=args, timeout=5.0)
+ except Exception:
+ return None
+
+ def pause(self) -> dict | None:
+ return self._call("/isaac/pause", {"pause": True})
+
+ def step(self, n: int = 1) -> dict | None:
+ return self._call("/isaac/step", {"count": int(n)})
+
+ def reset(self, seed: int = 0) -> dict | None:
+ return self._call("/isaac/reset", {"seed": int(seed)})
+
+
+class IsaacRunner:
+ """Deterministic Isaac episode: detect the stage, add a level's prims, own the
+ clock. Degrades to a dry-run plan when offline — so the flow is testable."""
+
+ def __init__(self, transport: Any = None) -> None:
+ self.transport = transport
+ self.stage = None
+ self.clock: IsaacClock | None = None
+
+ def attach(self) -> bool:
+ info = detect_world(self.transport)
+ if info is None:
+ return False
+ self.stage = info["stage"]
+ self.clock = IsaacClock(self.transport)
+ return True
+
+ def plan_level(self, level: dict, seed: int = 0) -> list[dict]:
+ return level_to_prims(level, seed)
+
+ def run_level(self, level: dict, seed: int = 0, steps: int = 100) -> dict:
+ if not self.attach():
+ return {"status": "no-stage", "plan": self.plan_level(level, seed)}
+ self.clock.reset(seed)
+ for _ in range(steps):
+ self.clock.step(1)
+ return {"status": "ran", "stage": self.stage, "steps": steps, "seed": seed}
diff --git a/roborun/mjx_env.py b/roborun/mjx_env.py
new file mode 100644
index 0000000..4245e6b
--- /dev/null
+++ b/roborun/mjx_env.py
@@ -0,0 +1,154 @@
+"""MJX vectorized backend (LOCAL_SIM_SPEC Phase 3 — the Simulate crux).
+
+PufferLib's lesson applied to physical sim: one wrapper, N parallel worlds, flat
+fixed obs. MJX (MuJoCo-on-JAX) batches `MjData` and `jax.vmap`s the step, so the
+same contract primitives run across thousands of worlds on CPU or GPU. This is the
+"thousands of tests in parallel" gap vs Antioch, on our substrate.
+
+Optional dep (`mujoco-mjx`, `jax`). Throughput scales with device; CPU is modest,
+GPU is where it pays. The contract (pose/lidar schema) rides on top, so policies
+trained here stay portable to the single-instance sim and real robots.
+"""
+from __future__ import annotations
+
+from typing import Any, Callable
+
+
+def installed() -> bool:
+ """Whether the [mjx] extra is importable — WITHOUT importing it, so the common
+ CLI/listing path never triggers mujoco_warp's noisy optional-dep messages."""
+ import importlib.util
+ return all(importlib.util.find_spec(m) is not None for m in ("jax", "mujoco"))
+
+
+def available() -> bool:
+ try:
+ import jax # noqa: F401
+ import mujoco.mjx # noqa: F401
+ return True
+ except Exception:
+ return False
+
+
+class MJXVecEnv:
+ """N MuJoCo worlds stepped in lockstep via jax.vmap. Deterministic given the
+ same seed + control stream (the replay/A-B contract)."""
+
+ def __init__(self, model: Any, n_envs: int = 256, ctrl_substeps: int = 4) -> None:
+ import jax
+ import mujoco
+ from mujoco import mjx
+ self._jax = jax
+ self._mjx = mjx
+ self.n = n_envs
+ self.ctrl_substeps = ctrl_substeps
+ self._mjx_model = mjx.put_model(model)
+ self.nq = int(model.nq)
+ self.nv = int(model.nv)
+ self.nu = int(model.nu)
+ d0 = mjx.make_data(model)
+ # batch the data across n worlds
+ self._data = jax.vmap(lambda _: d0)(jax.numpy.arange(n_envs))
+ # jit a vmapped multi-substep step
+ def _step(data, ctrl):
+ data = data.replace(ctrl=ctrl)
+ for _ in range(ctrl_substeps):
+ data = mjx.step(self._mjx_model, data)
+ return data
+ self._step_fn = jax.jit(jax.vmap(_step))
+
+ def reset(self, seed: int = 0):
+ """Reset all worlds; seed sets per-world qpos jitter deterministically."""
+ import jax
+ import jax.numpy as jnp
+ key = jax.random.PRNGKey(seed)
+ jitter = jax.random.uniform(key, (self.n, self.nq), minval=-0.01, maxval=0.01)
+ qpos0 = jnp.broadcast_to(self._data.qpos[0], (self.n, self.nq)) + jitter
+ self._data = self._data.replace(
+ qpos=qpos0,
+ qvel=jnp.zeros((self.n, self.nv)),
+ ctrl=jnp.zeros((self.n, self.nu)))
+ return self.obs()
+
+ def step(self, ctrl):
+ """ctrl: [n_envs, nu] in the model's actuator space. Returns obs [n, *]."""
+ import jax.numpy as jnp
+ self._data = self._step_fn(self._data, jnp.asarray(ctrl))
+ return self.obs()
+
+ def obs(self):
+ """Flat per-world obs: concat(qpos, qvel). Subclass/task maps to the
+ handle's pose/lidar contract; this is the raw vectorized state."""
+ import jax.numpy as jnp
+ return jnp.concatenate([self._data.qpos, self._data.qvel], axis=1)
+
+
+ def measure_sps(self, steps: int = 100, seed: int = 0) -> dict:
+ """Steps-per-second on the present device (CPU here; GPU pays off). The
+ budget the LOCAL_SIM/PERF specs ask for — measured, not asserted."""
+ import time
+ import numpy as np
+ self.reset(seed)
+ ctrl = np.zeros((self.n, self.nu), np.float32)
+ self.step(ctrl) # warm jit
+ t0 = time.time()
+ for _ in range(steps):
+ self.step(ctrl)
+ dt = time.time() - t0
+ sps = self.n * steps / dt if dt > 0 else 0.0
+ return {"n_envs": self.n, "steps": steps, "seconds": round(dt, 3),
+ "sps": int(sps)}
+
+
+def rollout(env: MJXVecEnv, policy_fn: Callable, steps: int, seed: int = 0):
+ """Run a vectorized rollout: policy_fn(obs)->ctrl[n,nu]. Returns the final
+ obs. Single code path whether n=1 (a CleanRL-style env) or n=10k."""
+ import numpy as np
+ obs = env.reset(seed)
+ for _ in range(steps):
+ obs = env.step(np.asarray(policy_fn(obs)))
+ return obs
+
+
+def make_vec(model_xml: str, n_envs: int = 256) -> MJXVecEnv:
+ import mujoco
+ model = mujoco.MjModel.from_xml_string(model_xml)
+ return MJXVecEnv(model, n_envs=n_envs)
+
+
+def score_vectorized(env: MJXVecEnv, policy_fn: Callable, reward_fn: Callable,
+ steps: int, seed: int = 0) -> dict:
+ """Run N worlds and reduce to a pass-rate — the bridge from Simulate (MJX) to
+ Define (`scenario`). `reward_fn(obs)->float[n]` scores each world; a world
+ "passes" when its final reward >= 0. Returns per-world + aggregate, so the
+ scenario layer can record a sealed, scored MJX sweep.
+
+ with scenario("mjx-reach", suite="sim", seed=seed) as run:
+ r = score_vectorized(env, policy, reward, steps, seed)
+ run.metric("worlds", r["n"]); run.evaluate("reward", mean=r["mean"])
+ run.passed() if r["pass_rate"] >= 0.9 else run.failed("low pass-rate")
+ """
+ import numpy as np
+ obs = rollout(env, policy_fn, steps, seed)
+ rewards = np.asarray(reward_fn(obs)).reshape(-1)
+ passed = int((rewards >= 0).sum())
+ n = int(rewards.shape[0])
+ return {"n": n, "passed": passed,
+ "pass_rate": round(passed / n, 3) if n else 0.0,
+ "mean": float(rewards.mean()), "std": float(rewards.std())}
+
+
+# A minimal model for smoke tests / the "Ocean"-style sanity env (PufferLib idea):
+# a single actuated slider — trains/sanity-checks in seconds, no assets.
+SANITY_XML = """
+
+
+
+
+
+
+
+
+
+
+"""
diff --git a/roborun/observations.py b/roborun/observations.py
index 56ff924..a63fad3 100644
--- a/roborun/observations.py
+++ b/roborun/observations.py
@@ -94,6 +94,49 @@ def _pose_xyz(pose_msg: dict | None) -> tuple[float | None, float | None, float
return pos.get("x"), pos.get("y"), pos.get("z")
+class StreamingExtractor:
+ """Index Observations *while recording* instead of only on close
+ (PERCEPTION_DATA_SPEC "sync"). The recorder forwards each write here; a
+ camera frame is stored immediately, joined with the most-recent detections /
+ CLIP / pose within JOIN_TOLERANCE. Removes the close-time extraction spike
+ and makes search live during a run."""
+
+ def __init__(self, store, robot_id: str = "local", run_id: str | None = None,
+ join_tol: float = JOIN_TOLERANCE, source: str = "stream") -> None:
+ self.store = store
+ self.robot_id = robot_id
+ self.run_id = run_id
+ self.join_tol = join_tol
+ self.source = source # mode tag (sim/robot/production) for the index
+ self._det: tuple[float, list] | None = None
+ self._clip: tuple[float, Any] | None = None
+ self._pose: tuple[float, tuple] | None = None
+ self.inserted = 0
+
+ def _fresh(self, slot, ts):
+ return slot is not None and abs(slot[0] - ts) <= self.join_tol
+
+ def on_detections(self, ts: float, dets: list) -> None:
+ self._det = (ts, dets or [])
+
+ def on_clip(self, ts: float, vec) -> None:
+ self._clip = (ts, vec)
+
+ def on_pose(self, ts: float, x, y, z) -> None:
+ self._pose = (ts, (x, y, z))
+
+ def on_camera(self, ts: float, topic: str, source_id: str | None = None) -> None:
+ x, y, z = self._pose[1] if self._fresh(self._pose, ts) else (None, None, None)
+ emb = self._clip[1] if self._fresh(self._clip, ts) else None
+ dets = self._det[1] if self._fresh(self._det, ts) else []
+ self.store.store(frame=None, embedding=emb, detections=dets,
+ x=x, y=y, z=z, robot_id=self.robot_id, ts=ts,
+ run_id=self.run_id, frame_topic=topic,
+ frame_log_time=int(ts * 1e9), source=self.source,
+ source_id=source_id)
+ self.inserted += 1
+
+
def extract_run(mcap_path: str | Path, store=None,
robot_id: str | None = None, thumbnails: bool = True) -> dict[str, Any]:
"""MCAP → Observation rows in the hot store. Runs on run close (spec §2.3).
diff --git a/roborun/projects.py b/roborun/projects.py
new file mode 100644
index 0000000..a90ba3a
--- /dev/null
+++ b/roborun/projects.py
@@ -0,0 +1,141 @@
+"""Projects — the first-class container that scopes data (platform spec 07).
+
+A Project is a named body of work that owns a data root and can span
+Environments (spec 08). The **active** project/environment is small runtime
+state the server + runner read; when one is selected, the recorder + event
+journal write under ``/projects///`` instead of the flat
+legacy ``/runs``. With nothing selected, everything behaves exactly as
+before — so this layer is purely additive and back-compatible.
+
+ /projects//project.json
+ /projects///env.json
+ /active.json # {project, environment}
+
+```` is ``ROBORUN_STATE_DIR`` or ``~/.roborun`` (matches recorder.py).
+"""
+from __future__ import annotations
+
+import json
+import os
+import re
+import time
+from pathlib import Path
+from typing import Any
+
+SCRATCH = "scratch" # the zero-friction default project (spec 08)
+MODES = ("scratch", "test", "production")
+
+
+def state_dir() -> Path:
+ base = os.environ.get("ROBORUN_STATE_DIR")
+ return Path(base) if base else Path.home() / ".roborun"
+
+
+def projects_root() -> Path:
+ return state_dir() / "projects"
+
+
+def slug(name: str) -> str:
+ s = re.sub(r"[^a-z0-9]+", "-", (name or "").lower()).strip("-")
+ return s or "untitled"
+
+
+# ── active context ────────────────────────────────────────────────────────
+def _active_file() -> Path:
+ return state_dir() / "active.json"
+
+
+def active() -> dict[str, str] | None:
+ """The active {project, environment}, or None (→ legacy flat layout).
+
+ Env vars win so a runner can be pinned without touching disk state."""
+ penv = os.environ.get("ROBORUN_PROJECT")
+ if penv:
+ return {"project": slug(penv),
+ "environment": slug(os.environ.get("ROBORUN_ENV") or "default")}
+ f = _active_file()
+ if f.exists():
+ try:
+ d = json.loads(f.read_text())
+ if d.get("project"):
+ return {"project": d["project"],
+ "environment": d.get("environment") or "default"}
+ except Exception:
+ return None
+ return None
+
+
+def set_active(project: str, environment: str = "default") -> dict[str, str]:
+ pid, eid = slug(project), slug(environment)
+ state_dir().mkdir(parents=True, exist_ok=True)
+ ctx = {"project": pid, "environment": eid}
+ _active_file().write_text(json.dumps(ctx))
+ return ctx
+
+
+def clear_active() -> None:
+ f = _active_file()
+ if f.exists():
+ f.unlink()
+
+
+def data_root() -> Path | None:
+ """``/projects//`` when a project is active, else None
+ (callers fall back to the legacy ``/runs``)."""
+ a = active()
+ if not a:
+ return None
+ return projects_root() / a["project"] / a["environment"]
+
+
+# ── project CRUD ──────────────────────────────────────────────────────────
+def _meta_path(pid: str) -> Path:
+ return projects_root() / pid / "project.json"
+
+
+def create(name: str, mode: str = "scratch") -> dict[str, Any]:
+ pid = slug(name)
+ p = _meta_path(pid)
+ p.parent.mkdir(parents=True, exist_ok=True)
+ if p.exists():
+ return json.loads(p.read_text())
+ meta = {"id": pid, "name": name, "created": time.time(),
+ "mode_default": mode if mode in MODES else "scratch",
+ "environments": []}
+ p.write_text(json.dumps(meta, indent=2))
+ return meta
+
+
+def get(pid: str) -> dict[str, Any] | None:
+ p = _meta_path(slug(pid))
+ if not p.exists():
+ return None
+ try:
+ return json.loads(p.read_text())
+ except Exception:
+ return None
+
+
+def list_projects() -> list[dict[str, Any]]:
+ root = projects_root()
+ if not root.exists():
+ return []
+ out = []
+ for d in sorted(root.iterdir()):
+ m = d / "project.json"
+ if m.exists():
+ try:
+ meta = json.loads(m.read_text())
+ except Exception:
+ continue
+ # cheap activity rollup: count runs across the project's envs
+ runs = len(list(d.glob("*/runs/*/*.mcap"))) + len(list(d.glob("*/runs/*.mcap")))
+ meta["runs"] = runs
+ out.append(meta)
+ out.sort(key=lambda m: m.get("created", 0), reverse=True)
+ return out
+
+
+def ensure_scratch() -> dict[str, Any]:
+ """The default project always exists so 'just playing' has a home."""
+ return create("scratch", mode="scratch")
diff --git a/roborun/r2sync.py b/roborun/r2sync.py
index 6c0df7d..e88ad1f 100644
--- a/roborun/r2sync.py
+++ b/roborun/r2sync.py
@@ -73,10 +73,14 @@ def get_json(self, key: str) -> dict | None:
except Exception:
return None
- def list_keys(self, prefix: str, limit: int = 1000) -> list[str]:
+ def list_keys(self, prefix: str, limit: int = 1000,
+ start_after: str | None = None) -> list[str]:
+ """List keys under `prefix`. `start_after` pushes the cursor server-side
+ (S3 StartAfter) so callers fetch only new keys, not the whole prefix."""
out: list[str] = []
paginator = self._s3.get_paginator("list_objects_v2")
- for page in paginator.paginate(Bucket=self.bucket, Prefix=self._key(prefix)):
+ kw = {"StartAfter": self._key(start_after)} if start_after else {}
+ for page in paginator.paginate(Bucket=self.bucket, Prefix=self._key(prefix), **kw):
for item in page.get("Contents", []):
key = item["Key"]
if self.prefix and key.startswith(self.prefix):
diff --git a/roborun/recorder.py b/roborun/recorder.py
index 301c7e8..f14559c 100644
--- a/roborun/recorder.py
+++ b/roborun/recorder.py
@@ -60,6 +60,15 @@
"format": {"type": "string"},
},
},
+ "foxglove.CompressedVideo": {
+ "type": "object",
+ "properties": {
+ "timestamp": {"type": "object"},
+ "frame_id": {"type": "string"},
+ "data": {"type": "string", "contentEncoding": "base64"},
+ "format": {"type": "string"},
+ },
+ },
"foxglove.PoseInFrame": {
"type": "object",
"properties": {
@@ -112,11 +121,53 @@
"prev": {"type": "string"},
},
},
+ "roborun.Command": {
+ "type": "object",
+ "properties": {
+ "timestamp": {"type": "object"},
+ "forward": {"type": "number"}, "strafe": {"type": "number"},
+ "turn": {"type": "number"}, "climb": {"type": "number"},
+ "grip": {"type": "boolean"}, "source": {"type": "string"},
+ "clamped": {"type": "boolean"},
+ },
+ },
+ "roborun.Telemetry": {
+ "type": "object",
+ "properties": {
+ "timestamp": {"type": "object"},
+ "channel": {"type": "string"}, "data": {"type": "object"},
+ },
+ },
+ "sensor_msgs/NavSatFix": {
+ "type": "object",
+ "properties": {
+ "timestamp": {"type": "object"},
+ "latitude": {"type": "number"}, "longitude": {"type": "number"},
+ "altitude": {"type": "number"}, "status": {"type": "integer"},
+ },
+ },
+ "foxglove.PointCloud": {
+ "type": "object",
+ "properties": {
+ "timestamp": {"type": "object"}, "frame_id": {"type": "string"},
+ "point_count": {"type": "integer"},
+ "points": {"type": "string", "contentEncoding": "base64"},
+ },
+ },
"roborun.Json": {"type": "object"},
}
def runs_root() -> Path:
+ # When a project/environment is active, scope runs under it (spec 07/08);
+ # otherwise the legacy flat layout — fully back-compatible.
+ try:
+ from roborun import projects
+ dr = projects.data_root()
+ if dr is not None:
+ return dr / "runs"
+ except Exception:
+ pass
base = os.environ.get("ROBORUN_STATE_DIR")
root = Path(base) if base else Path.home() / ".roborun"
return root / "runs"
@@ -198,6 +249,8 @@ def __init__(self, robot_id: str = "local", root: Path | None = None,
self.started_at = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
self.prev_run = _latest_sealed_run(self._root)
self._closed = False
+ self._video: dict[str, Any] = {} # per-camera H.264 encoders
+ self.extractor = None # optional StreamingExtractor (live indexing)
self._bus_queue: queue.Queue | None = None
self._bus_thread: threading.Thread | None = None
@@ -246,6 +299,41 @@ def write_camera(self, jpeg: bytes, name: str = "webcam",
"timestamp": _ts_obj(ts), "frame_id": frame_id,
"data": base64.b64encode(jpeg).decode(), "format": "jpeg",
}, ts)
+ if self.extractor is not None:
+ try:
+ self.extractor.on_camera(ts, f"/camera/{name}", source_id=name)
+ except Exception:
+ pass
+
+ def write_video(self, frame_bgr, name: str = "webcam", fps: int = 30,
+ ts: float | None = None, frame_id: str = "camera") -> None:
+ """Encode a BGR frame to H.264 and write any resulting packets to the
+ `foxglove.CompressedVideo` channel — 10–50× smaller than per-frame JPEG.
+ Lazily spins up one encoder per camera. Needs the `av` extra."""
+ import numpy as np
+ ts = ts if ts is not None else time.time()
+ enc = self._video.get(name)
+ if enc is None:
+ from roborun.video import H264Encoder
+ h, w = frame_bgr.shape[:2]
+ enc = H264Encoder(w, h, fps=fps)
+ self._video[name] = enc
+ for pkt in enc.add(np.ascontiguousarray(frame_bgr)):
+ self.write_json(f"/camera/{name}", "foxglove.CompressedVideo", {
+ "timestamp": _ts_obj(ts), "frame_id": frame_id,
+ "data": base64.b64encode(pkt).decode(), "format": "h264",
+ }, ts)
+
+ def _flush_video(self) -> None:
+ for name, enc in list(getattr(self, "_video", {}).items()):
+ try:
+ for pkt in enc.flush():
+ self.write_json(f"/camera/{name}", "foxglove.CompressedVideo", {
+ "timestamp": _ts_obj(time.time()), "frame_id": "camera",
+ "data": base64.b64encode(pkt).decode(), "format": "h264",
+ })
+ except Exception:
+ pass
def write_detections(self, detections: list[dict], name: str = "yolo",
ts: float | None = None) -> None:
@@ -253,6 +341,11 @@ def write_detections(self, detections: list[dict], name: str = "yolo",
self.write_json(f"/detections/{name}", "roborun.Detections", {
"timestamp": _ts_obj(ts), "detections": detections,
}, ts)
+ if self.extractor is not None:
+ try:
+ self.extractor.on_detections(ts, detections)
+ except Exception:
+ pass
def write_clip(self, embedding, frame_topic: str = "/camera/webcam",
label: str | None = None, ts: float | None = None) -> None:
@@ -264,6 +357,11 @@ def write_clip(self, embedding, frame_topic: str = "/camera/webcam",
"vec": base64.b64encode(vec.tobytes()).decode(),
"frame_topic": frame_topic, "label": label or "",
}, ts)
+ if self.extractor is not None:
+ try:
+ self.extractor.on_clip(ts, vec)
+ except Exception:
+ pass
def write_pose(self, x: float, y: float, z: float = 0.0,
orientation: dict | None = None, frame_id: str = "world",
@@ -278,6 +376,56 @@ def write_pose(self, x: float, y: float, z: float = 0.0,
"pose": {"position": {"x": x, "y": y, "z": z},
"orientation": orientation or {"x": 0, "y": 0, "z": 0, "w": 1}},
}, ts)
+ if self.extractor is not None:
+ try:
+ self.extractor.on_pose(ts, x, y, z)
+ except Exception:
+ pass
+
+ def write_cmd(self, forward: float = 0.0, strafe: float = 0.0,
+ turn: float = 0.0, climb: float = 0.0, grip: bool = False,
+ source: str = "", clamped: bool = False,
+ ts: float | None = None) -> None:
+ """The commanded action actually sent to the robot (post safety-clamp).
+ Records *why* it moved, not just what it saw — the spine of replay."""
+ ts = ts if ts is not None else time.time()
+ self.write_json("/cmd", "roborun.Command", {
+ "timestamp": _ts_obj(ts), "forward": forward, "strafe": strafe,
+ "turn": turn, "climb": climb, "grip": bool(grip),
+ "source": source, "clamped": bool(clamped),
+ }, ts)
+
+ def write_telemetry(self, channel: str, data: dict,
+ ts: float | None = None) -> None:
+ """A telemetry series sample (battery/imu/joints/velocity/…) as a durable
+ MCAP channel, not just the ephemeral in-memory ring."""
+ ts = ts if ts is not None else time.time()
+ self.write_json(f"/telemetry/{channel}", "roborun.Telemetry", {
+ "timestamp": _ts_obj(ts), "channel": channel, "data": data,
+ }, ts)
+
+ def write_gps(self, latitude: float, longitude: float, altitude: float = 0.0,
+ status: int = 0, ts: float | None = None) -> None:
+ ts = ts if ts is not None else time.time()
+ self.write_json("/gps", "sensor_msgs/NavSatFix", {
+ "timestamp": _ts_obj(ts), "latitude": latitude,
+ "longitude": longitude, "altitude": altitude, "status": status,
+ }, ts)
+
+ def write_cloud(self, name: str, points: list, frame_id: str = "world",
+ ts: float | None = None) -> None:
+ """A point cloud (lidar/depth/gz) as foxglove.PointCloud. `points` is a
+ flat [x,y,z,...] float list, base64-packed; full cloud lives here, a
+ summary (centroid/bbox) goes to the hot index (PERCEPTION_DATA_SPEC)."""
+ import base64
+ import struct
+ ts = ts if ts is not None else time.time()
+ flat = [float(v) for v in points]
+ packed = base64.b64encode(struct.pack(f"<{len(flat)}f", *flat)).decode()
+ self.write_json(f"/cloud/{name}", "foxglove.PointCloud", {
+ "timestamp": _ts_obj(ts), "frame_id": frame_id,
+ "point_count": len(flat) // 3, "points": packed,
+ }, ts)
def write_scan(self, ranges: list, x: float, y: float, heading: float,
frame_id: str = "world", ts: float | None = None) -> None:
@@ -392,6 +540,11 @@ def checkpoint(self) -> None:
with self._lock:
self._checkpoint_locked()
+ def channels(self) -> list[str]:
+ """The topics written so far — the run manifest's channel list (spec 01)."""
+ with self._lock:
+ return sorted(self._channel_ids.keys())
+
def status(self) -> dict[str, Any]:
with self._lock:
return {
@@ -399,15 +552,26 @@ def status(self) -> dict[str, Any]:
"mcap": str(self.mcap_path), "bytes": self._stream.offset,
"segments": len(self._segments),
"messages": dict(self._message_counts),
+ "channels": sorted(self._channel_ids.keys()),
"recording": not self._closed,
}
- def close(self, do_anchor: bool = True) -> dict[str, Any]:
- """Finish the MCAP, seal it (O(1) seal + Merkle root), anchor the root."""
+ def close(self, do_anchor: bool = True,
+ anchor_async: bool = True) -> dict[str, Any]:
+ """Finish the MCAP and seal it (O(1) Merkle root + Ed25519 signature).
+
+ The signature is computed synchronously — that IS the integrity anchor,
+ and it binds the run at seal time. The *external timestamp* (RFC 3161) is
+ deferred to a background thread by default (`anchor_async`), so sealing no
+ longer blocks on TSA HTTP (was up to 10 s/TSA). `verify` already reports
+ `consistent_unanchored` → `verified_anchored` once the `.tsr` lands, so
+ async anchoring is exactly the existing offline path made the default —
+ no new trust assumption. Pass `anchor_async=False` for a blocking stamp."""
with self._lock:
if self._closed:
return json.loads(self.seal_path.read_text())
self._detach_event_bus()
+ self._flush_video() # drain any pending H.264 packets first
self._writer.finish()
self._checkpoint_locked() # footer bytes
self._closed = True
@@ -431,21 +595,41 @@ def close(self, do_anchor: bool = True) -> dict[str, Any]:
"prev_run": self.prev_run,
"signature": sign_message(
f"{root}|{len(self._segments)}|{sealed_at}".encode()),
+ "anchor": {"status": "unanchored"},
}
- anchor_info: dict[str, Any] = {"status": "unanchored"}
- if do_anchor:
- tsr_bytes = anchor.stamp_digest(bytes.fromhex(root))
- if tsr_bytes is not None:
- tsr_path = self.seal_path.with_suffix(".seal.tsr")
- tsr_path.write_bytes(tsr_bytes)
- anchor_info = {**anchor.status(
- tsr_path, expected_digest=bytes.fromhex(root)),
- "tsr": tsr_path.name}
- else:
- anchor_info["reason"] = "offline or asn1crypto unavailable"
+ # Write the sealed (signed) record immediately — integrity is anchored.
+ self.seal_path.write_text(json.dumps(seal, indent=1))
+
+ if do_anchor:
+ if anchor_async:
+ threading.Thread(target=self._anchor_into_seal, args=(root,),
+ daemon=True, name=f"anchor-{self.run_id}").start()
+ else:
+ self._anchor_into_seal(root)
+ return json.loads(self.seal_path.read_text())
+ return seal
+
+ def _anchor_into_seal(self, root: str) -> dict[str, Any]:
+ """Stamp the Merkle root with an RFC 3161 TSA and fold the proof into the
+ already-written seal. Safe to run in a background thread."""
+ anchor_info: dict[str, Any] = {"status": "unanchored"}
+ try:
+ tsr_bytes = anchor.stamp_digest(bytes.fromhex(root))
+ if tsr_bytes is not None:
+ tsr_path = self.seal_path.with_suffix(".seal.tsr")
+ tsr_path.write_bytes(tsr_bytes)
+ anchor_info = {**anchor.status(
+ tsr_path, expected_digest=bytes.fromhex(root)),
+ "tsr": tsr_path.name}
+ else:
+ anchor_info["reason"] = "offline or asn1crypto unavailable"
+ except Exception as exc: # never let anchoring crash a finished run
+ anchor_info["reason"] = f"anchor error: {exc}"
+ with self._lock:
+ seal = json.loads(self.seal_path.read_text())
seal["anchor"] = anchor_info
self.seal_path.write_text(json.dumps(seal, indent=1))
- return seal
+ return anchor_info
# ── verification ─────────────────────────────────────────────────────────
@@ -661,13 +845,24 @@ def export_clip(mcap_path: str | Path, start_ts: float, end_ts: float,
_active_lock = threading.Lock()
-def start_recording(robot_id: str = "local", **kwargs) -> RunRecorder:
+def start_recording(robot_id: str = "local", stream_index: bool = False,
+ **kwargs) -> RunRecorder:
+ """Open a run. `stream_index=True` wires a StreamingExtractor so Observations
+ are indexed live (search works mid-run, no close-time spike)."""
global _active
with _active_lock:
if _active is not None and not _active._closed:
return _active
_active = RunRecorder(robot_id=robot_id, **kwargs)
_active.attach_event_bus()
+ if stream_index:
+ try:
+ from roborun.observations import StreamingExtractor
+ from roborun.spatial_memory import SpatialMemoryStore
+ _active.extractor = StreamingExtractor(
+ SpatialMemoryStore(), robot_id=robot_id, run_id=_active.run_id)
+ except Exception:
+ pass
return _active
diff --git a/roborun/retention.py b/roborun/retention.py
new file mode 100644
index 0000000..a9a2f86
--- /dev/null
+++ b/roborun/retention.py
@@ -0,0 +1,120 @@
+"""Run retention / GC (RECORD_EVERYTHING_SPEC).
+
+Always-on recording will fill disk. This caps `~/.roborun/runs/` by total bytes,
+evicting the **oldest MCAP bodies** first, but only ones that are safe to drop:
+ * the run is sealed (a `.seal` exists), AND
+ * the run is uploaded to R2 (a `.uploaded` marker), unless `require_uploaded`
+ is False (offline-only deployments).
+The small `.seal`/`.seal.tsr`/`.chain.jsonl` provenance files are **kept** — the
+record that the run existed and its proof survive even when the heavy MCAP is gone.
+
+No daemon here; call `enforce()` from a timer. Pure stdlib, no infra.
+"""
+from __future__ import annotations
+
+import os
+from pathlib import Path
+from typing import Any
+
+from roborun.events import runs_root
+
+DEFAULT_MAX_GB = float(os.environ.get("ROBORUN_RUNS_MAX_GB", "20"))
+
+
+def _mcap_bodies(root: Path) -> list[Path]:
+ return sorted(root.glob("*/*.mcap"), key=lambda p: p.stat().st_mtime)
+
+
+def _is_sealed(mcap: Path) -> bool:
+ return mcap.with_suffix(".seal").exists()
+
+
+def _is_uploaded(mcap: Path) -> bool:
+ return mcap.with_suffix(".uploaded").exists()
+
+
+def dir_bytes(root: Path) -> int:
+ return sum(p.stat().st_size for p in root.rglob("*") if p.is_file())
+
+
+def status(root: Path | None = None, max_gb: float | None = None) -> dict[str, Any]:
+ """Storage footprint + retention picture for fleet-cost awareness (robots make
+ 30–100 GB/shift; local-first + GC keeps cost sane). Read-only; evicts nothing."""
+ root = root or runs_root()
+ cap_gb = DEFAULT_MAX_GB if max_gb is None else max_gb
+ if not root.exists():
+ return {"used_bytes": 0, "used_gb": 0.0, "cap_gb": cap_gb, "pct": 0.0,
+ "runs": 0, "sealed": 0, "uploaded": 0, "evictable_bytes": 0}
+ bodies = _mcap_bodies(root)
+ used = dir_bytes(root)
+ sealed = sum(1 for m in bodies if _is_sealed(m))
+ uploaded = sum(1 for m in bodies if _is_uploaded(m))
+ evictable = sum(m.stat().st_size for m in bodies
+ if _is_sealed(m) and _is_uploaded(m))
+ return {"used_bytes": used, "used_gb": round(used / (1 << 30), 3),
+ "cap_gb": cap_gb, "pct": round(used / (cap_gb * (1 << 30)) * 100, 1) if cap_gb else 0.0,
+ "runs": len(bodies), "sealed": sealed, "uploaded": uploaded,
+ "evictable_bytes": evictable,
+ "evictable_gb": round(evictable / (1 << 30), 3)}
+
+
+def enforce(root: Path | None = None, max_gb: float | None = None,
+ require_uploaded: bool = True) -> dict[str, Any]:
+ """Evict oldest evictable MCAP bodies until total ≤ max_gb. Returns a report.
+ Only the `.mcap` body is removed; the seal/tsr/chain provenance is retained."""
+ root = root or runs_root()
+ max_bytes = int((DEFAULT_MAX_GB if max_gb is None else max_gb) * (1 << 30))
+ if not root.exists():
+ return {"total_bytes": 0, "evicted": [], "kept_over_cap": False}
+ total = dir_bytes(root)
+ evicted: list[str] = []
+ if total <= max_bytes:
+ return {"total_bytes": total, "evicted": evicted, "kept_over_cap": False}
+ for mcap in _mcap_bodies(root): # oldest first
+ if total <= max_bytes:
+ break
+ if not _is_sealed(mcap):
+ continue # never drop an unsealed (possibly in-flight) run
+ if require_uploaded and not _is_uploaded(mcap):
+ continue # never drop a run not yet backed up
+ size = mcap.stat().st_size
+ mcap.unlink()
+ total -= size
+ evicted.append(mcap.name)
+ return {"total_bytes": total, "evicted": evicted,
+ "kept_over_cap": total > max_bytes}
+
+
+# ── mode-aware retention across projects (platform spec 08) ────────────────
+# scratch is throwaway → small cap, evict sealed even if not uploaded.
+# production is precious → big cap, evict only sealed + uploaded.
+MODE_CAPS = {
+ "scratch": float(os.environ.get("ROBORUN_SCRATCH_MAX_GB", "2")),
+ "test": float(os.environ.get("ROBORUN_TEST_MAX_GB", "20")),
+ "production": float(os.environ.get("ROBORUN_PROD_MAX_GB", "100")),
+}
+
+
+def enforce_all() -> dict[str, Any]:
+ """Walk every project/environment and apply its mode's retention policy, so a
+ real-robot production env never competes with rapier scratch for space."""
+ from roborun import projects, environments
+ root = projects.projects_root()
+ reports: dict[str, Any] = {}
+ if not root.exists():
+ return {"projects": reports}
+ for pdir in sorted(root.iterdir()):
+ if not pdir.is_dir():
+ continue
+ for edir in sorted(pdir.iterdir()):
+ runs = edir / "runs"
+ if not runs.is_dir():
+ continue
+ meta = environments.get(pdir.name, edir.name) or {}
+ mode = meta.get("mode", "scratch")
+ cap = MODE_CAPS.get(mode, 20.0)
+ rep = enforce(root=runs, max_gb=cap,
+ require_uploaded=(mode != "scratch"))
+ reports[f"{pdir.name}/{edir.name}"] = {
+ "mode": mode, "cap_gb": cap, **rep}
+ return {"projects": reports}
diff --git a/roborun/rl_env.py b/roborun/rl_env.py
new file mode 100644
index 0000000..3d42043
--- /dev/null
+++ b/roborun/rl_env.py
@@ -0,0 +1,101 @@
+"""RoboRunEnv — the episode wrapper over the handle (LOCAL_SIM_SPEC Phase 2).
+
+RoboRun is a control loop today; RL needs reset/step/reward/done. This wraps any
+backend (SimBackend / arena / ros) + a task into a Gymnasium-style env with a
+**flat, fixed-size** observation (PufferLib's emulation lesson):
+
+ obs = concat(pose[3], lidar[36], see_padded[N*K], last_action[4], state_scalars)
+
+A task is `(reset_fn, reward_fn, done_fn)` defined once against the handle, so it
+runs on every backend and embodiment by construction (the contract).
+
+Gymnasium is optional: if installed we subclass `gymnasium.Env`; otherwise we
+expose the same `reset/step` duck-type so tests + simple loops work with no dep.
+"""
+from __future__ import annotations
+
+from typing import Any, Callable
+
+try: # optional
+ import gymnasium as gym
+ _Base = gym.Env
+except Exception: # pragma: no cover - gymnasium not installed
+ gym = None
+ _Base = object
+
+import numpy as np
+
+SEE_N = 5 # max detections in the obs (pad/truncate) — fixed shape
+SEE_K = 4 # per-detection features: cx, cy, dist, present-mask
+ACTION_DIM = 4 # forward, strafe, turn, climb
+
+
+def obs_dim() -> int:
+ return 3 + 36 + SEE_N * SEE_K + ACTION_DIM + 1 # +1 sim_time-ish scalar
+
+
+def _encode_see(things: list) -> np.ndarray:
+ out = np.zeros((SEE_N, SEE_K), dtype=np.float32)
+ for i, t in enumerate(things[:SEE_N]):
+ cx = getattr(t, "cx", None)
+ if cx is None and isinstance(t, dict):
+ cx = t.get("cx", 0.0)
+ dist = getattr(t, "dist", None)
+ if dist is None and isinstance(t, dict):
+ dist = t.get("distance")
+ out[i] = [float(cx or 0.0),
+ float(getattr(t, "cy", 0.0) if not isinstance(t, dict) else t.get("cy", 0.0)),
+ float(dist or 0.0), 1.0] # presence mask
+ return out.flatten()
+
+
+class RoboRunEnv(_Base):
+ """task = (reset_fn, reward_fn, done_fn); each takes the backend (+ env)."""
+
+ def __init__(self, backend: Any, task: tuple[Callable, Callable, Callable],
+ profile: str = "dog", max_steps: int = 500) -> None:
+ self.backend = backend
+ self.reset_fn, self.reward_fn, self.done_fn = task
+ self.profile = profile
+ self.max_steps = max_steps
+ self._last_action = np.zeros(ACTION_DIM, dtype=np.float32)
+ self._steps = 0
+ # capability gate: drone-only climb is masked off otherwise
+ self._climb_ok = profile == "drone"
+ if gym is not None:
+ self.observation_space = gym.spaces.Box(-np.inf, np.inf,
+ (obs_dim(),), np.float32)
+ self.action_space = gym.spaces.Box(-1.0, 1.0, (ACTION_DIM,), np.float32)
+
+ def _obs(self) -> np.ndarray:
+ p = self.backend.pose() or {"x": 0, "z": 0, "heading": 0}
+ pose = np.array([p.get("x", 0), p.get("z", 0), p.get("heading", 0)], np.float32)
+ lidar = np.asarray(self.backend.lidar(), np.float32)
+ if lidar.shape[0] != 36:
+ lidar = np.zeros(36, np.float32)
+ see = _encode_see(self.backend.see())
+ st = self.backend.state() if hasattr(self.backend, "state") else {}
+ scalar = np.array([float(st.get("sim_time", 0.0))], np.float32)
+ return np.concatenate([pose, lidar, see, self._last_action, scalar])
+
+ def reset(self, seed: int | None = None, options=None):
+ if seed is not None and gym is not None:
+ super().reset(seed=seed)
+ self._steps = 0
+ self._last_action[:] = 0
+ if self.reset_fn:
+ self.reset_fn(self.backend)
+ return self._obs(), {}
+
+ def step(self, action):
+ a = np.clip(np.asarray(action, np.float32), -1.0, 1.0)
+ if not self._climb_ok:
+ a[3] = 0.0 # capability gate
+ self.backend.move(float(a[0]), float(a[1]), float(a[2]), float(a[3]))
+ self._last_action = a
+ self._steps += 1
+ obs = self._obs()
+ reward = float(self.reward_fn(self.backend)) if self.reward_fn else 0.0
+ terminated = bool(self.done_fn(self.backend)) if self.done_fn else False
+ truncated = self._steps >= self.max_steps
+ return obs, reward, terminated, truncated, {}
diff --git a/roborun/robot_types.py b/roborun/robot_types.py
index 8cbde00..bc0b964 100644
--- a/roborun/robot_types.py
+++ b/roborun/robot_types.py
@@ -166,6 +166,11 @@ def detect_type(
if joint_count_hint > 3:
return RobotType.HUMANOID
return RobotType.QUADRUPED
+ # a wheeled / diff-drive ground robot: drives on /cmd_vel, senses with
+ # a laser scanner — no joints, no mavros. Mobile ground robot, so use
+ # the quadruped profile (cmd_vel control + lidar + camera).
+ if "/cmd_vel" in topic_set and ("/scan" in topic_set or "/odom" in topic_set):
+ return RobotType.QUADRUPED
if any(k in slug for k in ("go1", "go2", "a1", "b1", "b2", "spot")):
return RobotType.QUADRUPED
diff --git a/roborun/ros_camera.py b/roborun/ros_camera.py
index 9b40980..31ffaa8 100644
--- a/roborun/ros_camera.py
+++ b/roborun/ros_camera.py
@@ -6,7 +6,7 @@
front of the *robot*" on every backend: arena → arena ground truth,
connected robot → this pipeline, neither → local webcam.
-Frames also land at /tmp/roborun_frame.jpg, so the deck's camera panel and
+Frames also land at /tmp/roborun_robot_frame.jpg, so the deck's camera panel and
robot.ask(image=True) show the robot's view, not your desk.
"""
from __future__ import annotations
@@ -17,7 +17,7 @@
from pathlib import Path
from typing import Any
-FRAME_PATH = Path("/tmp/roborun_frame.jpg")
+FRAME_PATH = Path("/tmp/roborun_robot_frame.jpg")
_FRESH = 2.0
_DETECT_HZ = 5.0
@@ -136,9 +136,33 @@ def snapshot(self):
with self._lock:
return self._frame
+ def detections_normalized(self) -> dict[str, Any]:
+ """Detections as fractions of the frame, for a camera overlay that
+ scales with however the HUD sizes the feed."""
+ with self._lock:
+ frame = self._frame
+ dets = list(self._detections)
+ if frame is None:
+ return {"w": 0, "h": 0, "detections": []}
+ h, w = frame.shape[:2]
+ out = []
+ for d in dets:
+ x1, y1, x2, y2 = d.get("bbox", (0, 0, 0, 0))
+ out.append({
+ "label": d.get("label", "?"),
+ "conf": d.get("confidence", 0.0),
+ "track_id": d.get("track_id"),
+ "x": x1 / w, "y": y1 / h,
+ "w": (x2 - x1) / w, "h": (y2 - y1) / h,
+ })
+ return {"w": w, "h": h, "detections": out}
+
def state(self) -> dict[str, Any]:
with self._lock:
- return {"topic": self._topic, "active": self.is_active(),
+ # inline the freshness check: is_active() takes this same
+ # non-reentrant lock, so calling it here would self-deadlock
+ return {"topic": self._topic,
+ "active": time.monotonic() - self._frame_ts < _FRESH,
"frames": self._frames, "detections": len(self._detections)}
diff --git a/roborun/ros_mcp.py b/roborun/ros_mcp.py
index 061bcae..96cfa86 100644
--- a/roborun/ros_mcp.py
+++ b/roborun/ros_mcp.py
@@ -678,10 +678,22 @@ def _tool_get_robot_info(args: dict) -> dict:
"dds": _check_dds(),
"rosbridge": rb is not None and rb.is_connected if rb else False,
},
+ "ros_version": (_ros_version_from_topics(disc["topics"])),
"discovered_topics": len(disc["topics"]),
}
+def _ros_version_from_topics(topics: list) -> str:
+ """ROS1/ROS2 from the discovered topic set (works for DDS or rosbridge)."""
+ names = {t.get("name") for t in topics}
+ types = {str(t.get("type", "")) for t in topics}
+ if "/parameter_events" in names or any(tp.startswith("rcl_interfaces/") for tp in types):
+ return "ros2"
+ if "/rosout_agg" in names or "rosgraph_msgs/Log" in types:
+ return "ros1"
+ return "unknown"
+
+
_active_tap = None
_tap_lock = threading.Lock()
@@ -941,6 +953,23 @@ def _tool_seen(args: dict) -> dict:
return {"ok": True, "sightings": summary(args.get("label"))}
+def _tool_recall_place(args: dict) -> dict:
+ """Semantic spatial recall over the all-time index: where/when was
+ seen, across every run and mode. dimOS-style 'where did I last see X'."""
+ from roborun.routes._singletons import get_memory
+ from roborun.session import search
+ by = str(args.get("by", "label"))
+ hits = search(get_memory(), args.get("query", ""), by=by,
+ k=int(args.get("k", 10)),
+ since=args.get("since"), until=args.get("until"))
+ places = [{"x": h.get("x"), "y": h.get("y"), "ts": h.get("ts"),
+ "labels": sorted({d.get("label") for d in (h.get("detections") or [])}),
+ "robot_id": h.get("robot_id"), "source": h.get("source")}
+ for h in hits]
+ return {"ok": True, "query": args.get("query"), "count": len(places),
+ "places": places}
+
+
def _tool_arena_status(args: dict) -> dict:
from roborun.arena import get_arena
a = get_arena()
@@ -1323,6 +1352,15 @@ def _tool_notify(args: dict) -> dict:
"inputSchema": {"type": "object", "properties": {
"label": {"type": "string", "description": "Filter to one label"}}},
},
+ {
+ "name": "recall_place",
+ "description": "Semantic spatial recall over the ALL-TIME index (every run, every robot, every mode): where and when was something seen, with its position. 'where did I last see the forklift', 'who was in the lobby yesterday'. by='label' (YOLO) or 'clip' (semantic text); optional since/until unix seconds.",
+ "inputSchema": {"type": "object", "properties": {
+ "query": {"type": "string", "description": "what/who to find"},
+ "by": {"type": "string", "enum": ["label", "clip", "near", "time"]},
+ "k": {"type": "integer"},
+ "since": {"type": "number"}, "until": {"type": "number"}}},
+ },
{
"name": "arena_status",
"description": "Arena chamber state: pose, rooms visited, won, live detections. The game loop: write_behavior, enable it, poll this until won.",
@@ -1365,6 +1403,7 @@ def _tool_notify(args: dict) -> dict:
"move": _tool_move,
"see": _tool_see,
"seen": _tool_seen,
+ "recall_place": _tool_recall_place,
"arena_status": _tool_arena_status,
"write_behavior": _tool_write_behavior,
"behaviors": _tool_behaviors,
diff --git a/roborun/ros_telemetry.py b/roborun/ros_telemetry.py
index 90c98a4..1b0bf86 100644
--- a/roborun/ros_telemetry.py
+++ b/roborun/ros_telemetry.py
@@ -25,8 +25,21 @@
("/cmd_vel", "geometry_msgs/Twist"),
("/scan", "sensor_msgs/LaserScan"),
("/tf", "tf2_msgs/TFMessage"),
+ ("/gps/fix", "sensor_msgs/NavSatFix"),
+ ("/fix", "sensor_msgs/NavSatFix"),
+ ("/navsat/fix", "sensor_msgs/NavSatFix"),
]
+
+def _saved_robot_type() -> str | None:
+ """Type `roborun connect` wrote to ~/.roborun/robot.json, if any."""
+ import json
+ from pathlib import Path
+ try:
+ return json.loads((Path.home() / ".roborun" / "robot.json").read_text()).get("type")
+ except Exception:
+ return None
+
_instance: RosTelemetryBridge | None = None
_lock = threading.Lock()
@@ -148,17 +161,18 @@ def _try_subscribe(self, bus: Any, fallback_host: str | None) -> None:
except Exception:
pass
- if not host:
- return
-
- client = get_client(host, auto_connect=False)
+ # no configured host: ride whatever connection `roborun connect` /
+ # boot already established rather than demanding a profile entry
+ client = (get_client(host, auto_connect=False) if host
+ else get_client(auto_connect=False))
if not client or not client.is_connected:
self._subscribed_topics.clear()
return
- if self._last_host != host:
+ key = host or "active-client"
+ if self._last_host != key:
self._subscribed_topics.clear()
- self._last_host = host
+ self._last_host = key
self._subscribe_client(bus, client)
@@ -170,14 +184,32 @@ def _subscribe_client(self, bus: Any, client: Any) -> None:
try:
available = client.list_topics(timeout=3.0)
except Exception:
- return
+ available = []
available_names = {t["topic"] for t in available}
- # the robot's own topic map (mavros drones, etc.) extends the
- # standard table — same handlers, the robot's topic names
- from roborun.robot_types import detect_type, get_profile
- self.robot_type = detect_type(ros_topics=sorted(available_names))
+ # rosapi topic discovery is flaky over rosbridge (the /rosapi/topics
+ # service times out on some setups). When it comes back empty, don't
+ # go dark: trust the type `roborun connect` saved and subscribe to the
+ # candidate topics blind — a subscribe to a not-yet-seen topic is
+ # harmless and starts flowing the moment the topic appears.
+ discovery_ok = bool(available_names)
+
+ from roborun.robot_types import detect_type, get_profile, RobotType
+ if discovery_ok:
+ self.robot_type = detect_type(ros_topics=sorted(available_names))
+ elif self.robot_type is None:
+ saved = None
+ try:
+ from roborun.routes.dashboard import load_profile
+ saved = (load_profile().get("robotType")
+ or _saved_robot_type())
+ except Exception:
+ pass
+ try:
+ self.robot_type = RobotType(saved) if saved else None
+ except Exception:
+ self.robot_type = None
type_topics = (get_profile(self.robot_type) or {}).get("ros_topics", {})
self.cmd_vel_topic = type_topics.get("cmd_vel", "/cmd_vel")
_TYPE_MSG = {"odom": "nav_msgs/Odometry",
@@ -192,7 +224,8 @@ def _subscribe_client(self, bus: Any, client: Any) -> None:
for topic, msg_type in table:
if topic in self._subscribed_topics:
continue
- if topic not in available_names:
+ # gate on availability only when discovery actually worked
+ if discovery_ok and topic not in available_names:
continue
handler = self._make_handler(topic, msg_type, bus)
@@ -225,6 +258,27 @@ def on_battery(msg: dict) -> None:
})
return on_battery
+ if "NavSatFix" in msg_type:
+ def on_gps(msg: dict) -> None:
+ lat = msg.get("latitude", 0.0)
+ lon = msg.get("longitude", 0.0)
+ alt = msg.get("altitude", 0.0)
+ st = msg.get("status", 0)
+ if isinstance(st, dict):
+ st = st.get("status", 0)
+ bus.push(robot_id, "gps", {"latitude": lat, "longitude": lon,
+ "altitude": alt, "status": st})
+ # fold GPS into the run's MCAP (spec 01 — /gps NavSatFix channel)
+ try:
+ from roborun.recorder import active_recorder
+ rec = active_recorder()
+ if rec is not None:
+ rec.write_gps(float(lat), float(lon), float(alt),
+ status=int(st or 0))
+ except Exception:
+ pass
+ return on_gps
+
if "Odometry" in msg_type:
def on_odom(msg: dict) -> None:
pose = msg.get("pose", {}).get("pose", {})
diff --git a/roborun/rosbridge.py b/roborun/rosbridge.py
index c6677a2..51e9b15 100644
--- a/roborun/rosbridge.py
+++ b/roborun/rosbridge.py
@@ -121,12 +121,15 @@ def _send(self, msg: dict) -> None:
self._ws.send(json.dumps(msg))
def _recv_loop(self) -> None:
+ from websocket import WebSocketTimeoutException
while self._connected and self._ws:
try:
raw = self._ws.recv()
if not raw:
break
msg = json.loads(raw)
+ except WebSocketTimeoutException:
+ continue # a quiet socket is not a dead socket
except Exception:
break
self._dispatch(msg)
@@ -164,6 +167,20 @@ def list_topics(self, timeout: float = 5.0) -> list[dict[str, str]]:
types = values.get("types", [])
return [{"topic": t, "type": tp} for t, tp in zip(topics, types)]
+ def ros_version(self, timeout: float = 5.0) -> str:
+ """ROS1 or ROS2 from topic/type heuristics over the same rosbridge link
+ (rosbridge_suite speaks both). 'ros2' if ROS2-only signals present,
+ 'ros1' for ROS1-only signals, else 'unknown'."""
+ topics = self.list_topics(timeout=timeout)
+ names = {t["topic"] for t in topics}
+ types = {t["type"] for t in topics}
+ if "/parameter_events" in names or any(
+ str(tp).startswith("rcl_interfaces/") for tp in types):
+ return "ros2"
+ if "/rosout_agg" in names or "rosgraph_msgs/Log" in types:
+ return "ros1"
+ return "unknown"
+
def publish(self, topic: str, msg_type: str, message: dict) -> None:
self._send({"op": "publish", "topic": topic,
"type": msg_type, "msg": message})
diff --git a/roborun/routes/_singletons.py b/roborun/routes/_singletons.py
index b776d8f..b983049 100644
--- a/roborun/routes/_singletons.py
+++ b/roborun/routes/_singletons.py
@@ -26,15 +26,29 @@ def get_simulator():
return _simulator
+_spatial_memory_key = None
+
+
def get_memory():
- global _spatial_memory
- if _spatial_memory is None:
+ """The search index for the active project/environment. Rebuilt when the
+ active context switches so each project searches only its own data."""
+ global _spatial_memory, _spatial_memory_key
+ key = None
+ try:
+ from roborun import projects
+ a = projects.active()
+ if a:
+ key = (a["project"], a["environment"])
+ except Exception:
+ pass
+ if _spatial_memory is None or _spatial_memory_key != key:
from roborun.spatial_memory import SpatialMemoryStore
_spatial_memory = SpatialMemoryStore(
s3_bucket=os.environ.get("ROBORUN_S3_BUCKET"),
s3_prefix=os.environ.get("ROBORUN_S3_PREFIX", "roborun/memories/"),
s3_endpoint=os.environ.get("ROBORUN_S3_ENDPOINT"),
)
+ _spatial_memory_key = key
return _spatial_memory
diff --git a/roborun/routes/arena.py b/roborun/routes/arena.py
index b991ca7..ecf0f1d 100644
--- a/roborun/routes/arena.py
+++ b/roborun/routes/arena.py
@@ -19,6 +19,37 @@ def arena_cmd(h):
_last_level: list[str] = [""]
+_last_index: list[float] = [0.0] # throttle live store-indexing
+
+
+def _index_live(payload: dict) -> None:
+ """Doing stuff in rapier should GENERATE real, searchable data — not seeds.
+ Index the sim's detections into the spatial store (scoped to the active
+ project/environment), throttled, so /search, /api/spatial and /analytics
+ fill from real play. Best-effort; never blocks the sim."""
+ import time
+ dets = payload.get("detections") or []
+ if not dets:
+ return
+ now = time.time()
+ if now - _last_index[0] < 1.0: # ~1 Hz is plenty for a searchable index
+ return
+ _last_index[0] = now
+ try:
+ from roborun.routes._singletons import get_memory
+ from roborun import recorder as rec_mod
+ pose = payload.get("pose") or {}
+ robot = (payload.get("level") or {}).get("robot") or "sim"
+ # link to the active run so the observation is openable in replay; when
+ # nothing is recording, run_id stays None (correctly non-replayable).
+ rec = rec_mod.active_recorder()
+ run_id = rec.run_id if rec is not None else None
+ get_memory().store(detections=dets, ts=now,
+ x=pose.get("x", 0.0), y=-pose.get("z", 0.0),
+ robot_id=f"{robot}-sim", source="sim", source_id="arena",
+ run_id=run_id, frame_topic="/detections/arena")
+ except Exception:
+ pass
@post("/api/arena/state")
@@ -32,6 +63,7 @@ def arena_state(h, payload):
sightings.reset()
sightings.observe(payload.get("detections") or [],
pose=payload.get("pose"), source="arena")
+ _index_live(payload) # real rapier play → real searchable data
# the black box gets the full ROS-shaped view: /pose, /detections, /lidar
try:
from roborun import recorder as rec_mod
@@ -39,22 +71,42 @@ def arena_state(h, payload):
if rec is not None:
pose = payload.get("pose") or {}
fx, fy = pose.get("x", 0.0), -pose.get("z", 0.0)
- h = pose.get("heading", 0.0)
+ hd = pose.get("heading", 0.0) # not `h` — that's the HTTP handler
alt = (pose.get("y", 0.0)
if (payload.get("level") or {}).get("robot") == "drone" else 0.0)
- rec.write_pose(fx, fy, alt, heading=h)
+ rec.write_pose(fx, fy, alt, heading=hd)
dets = payload.get("detections") or []
if dets:
rec.write_detections(dets, name="arena")
- rec.write_detection_scene(dets, fx, fy, h)
+ rec.write_detection_scene(dets, fx, fy, hd)
lidar = payload.get("lidar") or []
if lidar:
- rec.write_scan(lidar, fx, fy, h)
+ rec.write_scan(lidar, fx, fy, hd)
except Exception:
pass
send_json(h, 200, {"ok": True})
+@post("/api/fleet/observe")
+def fleet_observe(h, payload):
+ """A fleet robot reports a detection at its world pose → indexed into the
+ active project/environment store (real multi-robot data, platform spec 04).
+ No throttle: each robot's first sighting of an item is one real observation."""
+ dets = payload.get("detections") or []
+ if dets:
+ try:
+ import time
+ from roborun.routes._singletons import get_memory
+ pose = payload.get("pose") or {}
+ get_memory().store(detections=dets, ts=time.time(),
+ x=float(pose.get("x", 0.0)), y=float(pose.get("y", 0.0)),
+ robot_id=str(payload.get("robot_id", "fleet")),
+ source="sim", source_id="fleet")
+ except Exception:
+ pass
+ send_json(h, 200, {"ok": True})
+
+
@post("/api/arena/event")
def arena_event(h, payload):
title = str(payload.get("title", "")).strip()
diff --git a/roborun/routes/behaviors.py b/roborun/routes/behaviors.py
index 4dad9fa..009938c 100644
--- a/roborun/routes/behaviors.py
+++ b/roborun/routes/behaviors.py
@@ -29,6 +29,21 @@ def disable(h, payload):
{"ok": ok} if ok else {"ok": False, "error": f"no behavior named {name!r}"})
+@post("/api/behaviors/read")
+def read_source(h, payload):
+ """Editor loads a running behavior's source. Stem only — no paths."""
+ from pathlib import Path
+ name = str(payload.get("name", "")).strip()
+ if not name or "/" in name or "\\" in name or ".." in name:
+ send_json(h, 400, {"ok": False, "error": "bad name"})
+ return
+ p = Path("behaviors") / f"{name}.py"
+ if not p.is_file():
+ send_json(h, 404, {"ok": False, "error": f"{name} not found"})
+ return
+ send_json(h, 200, {"ok": True, "name": name, "source": p.read_text()})
+
+
@post("/api/behaviors/write")
def write(h, payload):
"""The arena code panel writes through here — same validation as MCP."""
diff --git a/roborun/routes/fleet.py b/roborun/routes/fleet.py
index 364ba2e..d910056 100644
--- a/roborun/routes/fleet.py
+++ b/roborun/routes/fleet.py
@@ -19,6 +19,55 @@
_blueprints_lock = threading.Lock()
+# ── LLM-authored swarm strategy (for the /fleet sandbox) ─────────────────────
+
+_STRATEGY_SYSTEM = """You write coordination policies for a swarm of robots in a \
+2-D exploration sandbox. Output ONLY a JavaScript function body (no markdown, no \
+fences, no function wrapper). It runs ~5 times per second for EACH robot, with \
+two locals in scope:
+
+ r — this robot. Fields: r.id, r.x, r.y (metres), r.payload (Set of data ids
+ it carries), r.inbox (queued messages).
+ H — helpers (all take r first):
+ H.arrived(r) -> true when the robot is free to pick a new goal
+ H.inbox(r) -> array of messages: {kind:'cells',cells:[...]} or
+ {kind:'claim',cell,by,dist}
+ H.learn(r, cells) -> merge map cells you heard from a peer
+ H.reserve(r, cell, by, dist) -> record someone's claim on a cell
+ H.shareMapped(r) -> broadcast cells you just sensed (costs 1 airtime)
+ H.broadcastClaim(r, cell) -> announce you are taking `cell`
+ H.claimedByOther(r, cell) -> true if a closer robot already claimed it
+ H.nearestUnknown(r, avoidClaims) -> nearest unmapped cell index, or -1
+ H.goto(r, cell) -> commit to a cell (the robot does ONE goal at a time)
+ H.neighborCount(r) -> how many robots are in radio range right now
+ H.knows(r, cell) -> already mapped (sensed or heard)?
+
+Hard limits the policy must respect: a robot can only send a few messages per \
+second (airtime), only hears robots within radio range, has finite memory, and \
+moves to one goal at a time. Keep it short, defensive (handle cell === -1), and \
+fast. Return only the body.""".strip()
+
+
+@post("/api/fleet/strategy")
+def fleet_strategy(h, payload):
+ """Draft a swarm-coordination policy from a natural-language goal, using
+ whatever LLM the runtime has configured. Returns a JS function body the
+ /fleet sandbox compiles and runs."""
+ goal = str(payload.get("goal", "")).strip()
+ if not goal:
+ raise ApiError(400, "Describe the algorithm you want")
+ from roborun import llm
+ try:
+ code = llm.complete(goal, system=_STRATEGY_SYSTEM, tier="smart", max_tokens=600)
+ except Exception as exc:
+ keys = llm.capabilities().get("configured_keys") or []
+ hint = "" if keys else " — set ANTHROPIC_API_KEY (or OPENAI_API_KEY) and restart roborun"
+ raise ApiError(502, f"LLM unavailable: {exc}{hint}")
+ code = re.sub(r"^```[a-zA-Z]*\n?|```$", "", code.strip()).strip()
+ model = llm.resolve("smart")[1]
+ send_json(h, 200, {"ok": True, "code": code, "model": model})
+
+
# ── Fleet ────────────────────────────────────────────────────────────────────
def _load_fleet() -> list[dict]:
diff --git a/roborun/routes/mcp.py b/roborun/routes/mcp.py
index 4bdf83d..d2139ed 100644
--- a/roborun/routes/mcp.py
+++ b/roborun/routes/mcp.py
@@ -20,7 +20,6 @@ def _mcp_reply(h, req_id: Any, result: Any) -> None:
h.send_response(200)
h.send_header("Content-Type", "application/json")
h.send_header("Content-Length", str(len(body)))
- h.send_header("Access-Control-Allow-Origin", "*")
h.send_header("Cache-Control", "no-cache, no-store, must-revalidate")
h.end_headers()
h.wfile.write(body)
@@ -32,7 +31,6 @@ def _mcp_error(h, req_id: Any, code: int, message: str) -> None:
h.send_response(200)
h.send_header("Content-Type", "application/json")
h.send_header("Content-Length", str(len(body)))
- h.send_header("Access-Control-Allow-Origin", "*")
h.send_header("Cache-Control", "no-cache, no-store, must-revalidate")
h.end_headers()
h.wfile.write(body)
@@ -58,7 +56,6 @@ def handle_mcp_request(h, payload: dict) -> None:
if method == "notifications/initialized":
h.send_response(204)
- h.send_header("Access-Control-Allow-Origin", "*")
h.end_headers()
return
@@ -137,7 +134,6 @@ def handle_mcp_sse(h) -> None:
h.send_response(200)
h.send_header("Content-Type", "text/event-stream")
h.send_header("Cache-Control", "no-cache")
- h.send_header("Access-Control-Allow-Origin", "*")
h.end_headers()
msg = f'data: {{"type":"endpoint","url":"http://127.0.0.1:{PORT}/mcp"}}\n\n'
try:
diff --git a/roborun/routes/projects.py b/roborun/routes/projects.py
new file mode 100644
index 0000000..f096202
--- /dev/null
+++ b/roborun/routes/projects.py
@@ -0,0 +1,162 @@
+"""Project + Environment routes (platform specs 07/08).
+
+Read-only listing + create + active-context switch. Pure files, no infra.
+Selecting a project/environment re-scopes the recorder + event journal + the
+project-aware APIs (timeline/scenarios) under it.
+"""
+from __future__ import annotations
+
+from urllib.parse import parse_qs, urlparse
+
+from roborun.routes import get, post, send_json, ApiError
+from roborun import projects, environments
+
+
+@get("/api/projects")
+def list_projects(h):
+ send_json(h, 200, {"ok": True, "projects": projects.list_projects(),
+ "active": projects.active()})
+
+
+@post("/api/projects")
+def create_project(h, payload):
+ name = str(payload.get("name", "")).strip()
+ if not name:
+ raise ApiError(400, "name required")
+ meta = projects.create(name, mode=str(payload.get("mode", "scratch")))
+ # a project is only useful with at least one environment
+ backend = str(payload.get("backend", "rapier"))
+ if not environments.list_envs(meta["id"]):
+ environments.create(meta["id"], "default", backend=backend,
+ mode=meta.get("mode_default", "scratch"))
+ send_json(h, 200, {"ok": True, "project": meta})
+
+
+@get("/api/projects/active")
+def get_active(h):
+ a = projects.active()
+ env = environments.active_meta() if a else None
+ send_json(h, 200, {"ok": True, "active": a, "environment": env})
+
+
+@post("/api/projects/active")
+def set_active(h, payload):
+ project = str(payload.get("project", "")).strip()
+ if not project:
+ raise ApiError(400, "project required")
+ env = str(payload.get("environment", "default")).strip() or "default"
+ projects.create(project) # idempotent
+ if not environments.get(project, env):
+ environments.create(project, env,
+ backend=str(payload.get("backend", "rapier")))
+ ctx = projects.set_active(project, env)
+ send_json(h, 200, {"ok": True, "active": ctx})
+
+
+@post("/api/projects/active/clear")
+def clear_active(h, payload):
+ projects.clear_active()
+ send_json(h, 200, {"ok": True, "active": None})
+
+
+@get("/api/environments")
+def list_environments(h):
+ q = parse_qs(urlparse(h.path).query)
+ project = (q.get("project") or [None])[0] or (projects.active() or {}).get("project")
+ if not project:
+ send_json(h, 200, {"ok": True, "environments": [], "project": None})
+ return
+ send_json(h, 200, {"ok": True, "project": project,
+ "environments": environments.list_envs(project)})
+
+
+@post("/api/environments")
+def create_environment(h, payload):
+ project = str(payload.get("project", "")).strip() or (projects.active() or {}).get("project")
+ name = str(payload.get("name", "")).strip()
+ if not project or not name:
+ raise ApiError(400, "project and name required")
+ meta = environments.create(project, name,
+ backend=str(payload.get("backend", "rapier")),
+ mode=str(payload.get("mode", "scratch")),
+ world_ref=payload.get("world_ref"))
+ send_json(h, 200, {"ok": True, "environment": meta})
+
+
+@post("/api/environments/camera")
+def register_camera(h, payload):
+ """Tag a camera's placement in the environment frame (spec 09)."""
+ project = str(payload.get("project", "")).strip() or (projects.active() or {}).get("project")
+ env = str(payload.get("environment", "")).strip() or (projects.active() or {}).get("environment")
+ source_id = str(payload.get("source_id", "")).strip()
+ if not project or not env or not source_id:
+ raise ApiError(400, "project, environment, source_id required")
+ meta = environments.register_camera(
+ project, env, source_id,
+ placement=payload.get("placement"),
+ kind=str(payload.get("kind", "robot")),
+ intrinsics=payload.get("intrinsics"))
+ if meta is None:
+ raise ApiError(404, "environment not found")
+ send_json(h, 200, {"ok": True, "environment": meta})
+
+
+@get("/api/worlds/warehouse")
+def warehouse_world(h):
+ """A large multi-floor warehouse world for the fleet sim (spec 04 P1).
+ ?floors=&rooms=&size=&seed="""
+ from roborun import worlds
+ q = parse_qs(urlparse(h.path).query)
+ one = lambda k, d: (q.get(k) or [d])[0]
+ world = worlds.warehouse(floors=int(one("floors", "3")),
+ rooms_per_floor=int(one("rooms", "6")),
+ size=float(one("size", "48")),
+ seed=int(one("seed", "0")))
+ send_json(h, 200, {"ok": True, "world": world,
+ "items": worlds.item_count(world)})
+
+
+@get("/api/backends")
+def list_backends(h):
+ """Backend registry + live capability matrix (spec 03)."""
+ from roborun import backends
+ send_json(h, 200, {"ok": True, "backends": backends.list_backends()})
+
+
+@get("/api/spatial")
+def spatial_view(h):
+ """The environment's spatial picture (specs 05/09 P4): object tracks fused
+ into the env frame + the registered camera placements/frusta. Scoped to the
+ active project/environment."""
+ from roborun.routes._singletons import get_memory
+ from roborun import spatial as _sp
+ a = projects.active()
+ env = environments.active_meta() if a else None
+ cams = (env or {}).get("cameras", [])
+ frusta = []
+ for c in cams:
+ p = c.get("placement") or {}
+ frusta.append({"source_id": c.get("source_id"), "kind": c.get("kind"),
+ "placement": p, "frustum": _sp.camera_frustum(p)})
+ try:
+ tracks = get_memory().object_tracks(radius=float(
+ (parse_qs(urlparse(h.path).query).get("radius") or ["1.5"])[0]))
+ except Exception:
+ tracks = []
+ send_json(h, 200, {"ok": True, "active": a, "environment": (env or {}).get("id"),
+ "cameras": frusta, "tracks": tracks})
+
+
+@get("/api/runs")
+def list_runs(h):
+ """MCAP runs in the active project/environment, with their manifests —
+ the telemetry browser's index (spec 02)."""
+ from roborun import recorder as rec_mod, run_manifest
+ runs = rec_mod.list_runs()
+ for r in runs:
+ m = run_manifest.read(r.get("mcap", "")) if r.get("mcap") else None
+ if m:
+ r["manifest"] = {k: m.get(k) for k in
+ ("project", "environment", "backend", "started",
+ "ended", "cameras")}
+ send_json(h, 200, {"ok": True, "runs": runs, "active": projects.active()})
diff --git a/roborun/routes/ros.py b/roborun/routes/ros.py
index 1dde1c4..9e871ca 100644
--- a/roborun/routes/ros.py
+++ b/roborun/routes/ros.py
@@ -6,16 +6,40 @@
def _get_ros_client(host: str | None = None):
+ from roborun.rosbridge import get_client
host = host or load_profile().get("robotIp", "")
if not host:
+ # no profile entry, but `roborun connect` / boot may already hold a
+ # live connection — use it rather than demanding configuration
+ client = get_client(auto_connect=False)
+ if client and client.is_connected:
+ return client
raise ApiError(400, "No robot IP configured")
- from roborun.rosbridge import get_client
client = get_client(host)
if not client:
raise ApiError(503, "Not connected to rosbridge")
return client
+@post("/api/estop")
+def estop(h, payload):
+ """Emergency stop — halt every live actuator now. The one command that's always
+ allowed; also disables all running behaviors so nothing re-commands motion.
+ Recorded into the sealed run as evidence the stop happened."""
+ from roborun.ros_mcp import handle_tool_call
+ res = handle_tool_call("estop", {})
+ try:
+ from roborun.behaviors import BehaviorRunner
+ for s in BehaviorRunner.get().statuses():
+ if s.get("enabled"):
+ BehaviorRunner.get().set_enabled(s["name"], False)
+ except Exception:
+ pass
+ from roborun.events import emit
+ emit("system", "estop", "EMERGENCY STOP — actuators halted, behaviors disabled", {})
+ send_json(h, 200, {"ok": True, "estop": res})
+
+
@get("/api/ros/cloud")
def cloud(h):
"""World-frame lidar point cloud + live pose, for the deck's SPATIAL
@@ -26,6 +50,7 @@ def cloud(h):
"ok": True,
"points": b.cloud_points(),
"pose": b.handle_pose(),
+ "lidar": b.handle_lidar(),
"robot_type": b.robot_type.value if b.robot_type else None,
})
@@ -230,11 +255,12 @@ def ros_depth(h, payload):
def ros_move(h, payload):
linear_x = float(payload.get("linear_x", 0.0))
linear_y = float(payload.get("linear_y", 0.0))
+ linear_z = float(payload.get("linear_z", 0.0)) # climb, for drones
angular_z = float(payload.get("angular_z", 0.0))
topic = str(payload.get("topic", "/cmd_vel"))
try:
client = _get_ros_client()
- client.move(linear_x, linear_y, angular_z, topic)
+ client.move(linear_x, linear_y, angular_z, topic, linear_z=linear_z)
from roborun.events import emit
emit("ros", "rosbridge",
f"cmd_vel linear={linear_x:.2f} angular={angular_z:.2f}",
diff --git a/roborun/routes/run.py b/roborun/routes/run.py
index 742d625..a92fa1f 100644
--- a/roborun/routes/run.py
+++ b/roborun/routes/run.py
@@ -31,6 +31,63 @@ def _runs() -> list[Path]:
return sorted([p for p in root.iterdir() if (p / "run.jsonl").exists()])
+@get("/api/run/series")
+def run_series_route(h):
+ """Per-run telemetry series for the Analyze panels. ?id=&robot="""
+ from urllib.parse import parse_qs, urlparse
+ from roborun.run_series import run_series
+ q = parse_qs(urlparse(h.path).query)
+ run_id = (q.get("id") or q.get("run") or [""])[0]
+ robot = (q.get("robot") or [None])[0]
+ if not run_id:
+ send_json(h, 400, {"ok": False, "error": "id required"})
+ return
+ send_json(h, 200, run_series(run_id, robot))
+
+
+@post("/api/incidents/flag")
+def incident_flag(h, payload):
+ """Flag a moment in a run to revisit. Body: {run_id, ts?, note?, tag?}."""
+ from roborun.incidents import flag
+ run_id = str(payload.get("run_id", "")).strip()
+ if not run_id:
+ send_json(h, 400, {"ok": False, "error": "run_id required"})
+ return
+ rec = flag(run_id, ts=payload.get("ts"), note=str(payload.get("note", "")),
+ tag=str(payload.get("tag", "incident")),
+ robot_id=payload.get("robot_id"))
+ send_json(h, 200, {"ok": True, "incident": rec})
+
+
+@get("/api/incidents")
+def incidents_list(h):
+ """List incidents, optional ?run=&tag=."""
+ from urllib.parse import parse_qs, urlparse
+ from roborun.incidents import list_incidents
+ q = parse_qs(urlparse(h.path).query)
+ rows = list_incidents(run_id=(q.get("run") or [None])[0],
+ tag=(q.get("tag") or [None])[0])
+ send_json(h, 200, {"ok": True, "incidents": rows, "total": len(rows)})
+
+
+@get("/api/run/frame")
+def run_frame(h):
+ """Synced-playback frame: the camera JPEG nearest ?t= in run ?id=."""
+ from urllib.parse import parse_qs, urlparse
+ from roborun.run_series import frame_at
+ q = parse_qs(urlparse(h.path).query)
+ run_id = (q.get("id") or [""])[0]
+ t = float((q.get("t") or ["0"])[0])
+ jpeg = frame_at(run_id, t, (q.get("robot") or [None])[0]) if run_id else None
+ if jpeg is None:
+ h.send_response(404); h.end_headers(); return
+ h.send_response(200)
+ h.send_header("Content-Type", "image/jpeg")
+ h.send_header("Content-Length", str(len(jpeg)))
+ h.end_headers()
+ h.wfile.write(jpeg)
+
+
@get("/api/run/events")
def run_events(h):
"""Events of a recorded run, for replay. ?run=&limit=N"""
@@ -68,6 +125,10 @@ def _mcap_path(payload: dict) -> Path | None:
@post("/api/run/record/start")
def record_start(h, payload):
rec = rec_mod.start_recording(robot_id=payload.get("robot_id", "local"))
+ from roborun import run_manifest
+ run_manifest.write_start(rec.mcap_path, rec.run_id,
+ payload.get("robot_id", "local"),
+ backend=payload.get("backend"))
bus.emit("system", "recorder", f"RECORDING · {rec.run_id}",
{"run": rec.run_id, "mcap": str(rec.mcap_path)})
send_json(h, 200, {"ok": True, **rec.status()})
@@ -75,11 +136,16 @@ def record_start(h, payload):
@post("/api/run/record/stop")
def record_stop(h, payload):
+ # capture the channel list before the recorder closes (manifest, spec 01)
+ _live = rec_mod.active_recorder()
+ _channels = _live.channels() if _live is not None else None
seal = rec_mod.stop_recording(do_anchor=not payload.get("no_anchor", False))
if seal is None:
send_json(h, 200, {"ok": False, "error": "nothing is recording"})
return
mcap_path = rec_mod.runs_root() / seal["robot_id"] / f"{seal['run']}.mcap"
+ from roborun import run_manifest
+ run_manifest.finalize(mcap_path, seal=seal, channels=_channels)
indexed = None
try:
from roborun.observations import extract_run
@@ -217,6 +283,32 @@ def badge(h):
"sealed_at": result.get("sealed_at")})
+_demo_seeding = {"running": False}
+
+
+@post("/api/demo/seed")
+def demo_seed(h, payload):
+ """Populate a fresh install with sample recorded+indexed runs so Runs/Search/
+ Analytics aren't empty on first open. Runs in a thread (~couple seconds)."""
+ import threading
+ if _demo_seeding["running"]:
+ send_json(h, 200, {"ok": True, "seeding": True, "already": True})
+ return
+
+ def _go():
+ _demo_seeding["running"] = True
+ try:
+ from roborun.cli import demo_cli
+ demo_cli([])
+ except Exception as exc:
+ bus.emit("system", "demo", f"demo seed failed: {exc}", {})
+ finally:
+ _demo_seeding["running"] = False
+
+ threading.Thread(target=_go, daemon=True, name="DemoSeed").start()
+ send_json(h, 200, {"ok": True, "seeding": True})
+
+
@get("/api/run/list")
def list_runs(h):
live = bus.current_run()
diff --git a/roborun/routes/scenarios.py b/roborun/routes/scenarios.py
new file mode 100644
index 0000000..95a2794
--- /dev/null
+++ b/roborun/routes/scenarios.py
@@ -0,0 +1,67 @@
+"""Scenario registry routes — the /scenarios board (Antioch-style suites).
+
+Read-only over the scenario JSON registry + a run trigger. Pure files, no infra.
+"""
+from __future__ import annotations
+
+from roborun.routes import get, post, send_json, ApiError
+
+# Registering built-in runnable scenarios so the board is live out of the box.
+import roborun.demo_scenarios # noqa: F401
+
+
+@get("/api/scenarios")
+def list_scenarios(h):
+ """Scored runs, newest-first, with optional ?suite=&outcome=&name= filters."""
+ from urllib.parse import parse_qs, urlparse
+ from roborun.scenario import list_results
+ q = parse_qs(urlparse(h.path).query)
+ one = lambda k: (q.get(k) or [None])[0]
+ rows = list_results(limit=int((q.get("limit") or [100])[0]),
+ suite=one("suite"), outcome=one("outcome"), name=one("name"),
+ tag=one("tag"))
+ send_json(h, 200, {"ok": True, "results": rows, "total": len(rows)})
+
+
+@get("/api/scenarios/by-run")
+def scenario_by_run(h):
+ """The scored record for one run — metadata/params/results/evaluation for
+ the run-detail view. ?run=."""
+ from urllib.parse import parse_qs, urlparse
+ from roborun.scenario import find_by_run
+ run = (parse_qs(urlparse(h.path).query).get("run") or [""])[0]
+ rec = find_by_run(run)
+ send_json(h, 200, {"ok": rec is not None, "result": rec})
+
+
+@get("/api/scenarios/suites")
+def list_suites(h):
+ """Suite cards: pass-rate, run count, latest — the Antioch Suites view."""
+ from roborun.scenario import list_suites as _suites
+ send_json(h, 200, {"ok": True, "suites": _suites()})
+
+
+@get("/api/scenarios/defs")
+def list_defs(h):
+ """The catalog of *runnable* scenarios (not past results)."""
+ from roborun.scenario_defs import list_defs as _defs, suites_defined
+ send_json(h, 200, {"ok": True, "defs": _defs(), "suites": suites_defined()})
+
+
+@post("/api/scenarios/run")
+def run(h, payload):
+ """Run a registered scenario or a whole suite. Body: {scenario} or {suite}."""
+ from roborun.scenario_defs import run_scenario, run_suite
+ suite = str(payload.get("suite", "")).strip()
+ name = str(payload.get("scenario", "")).strip()
+ seed = payload.get("seed")
+ try:
+ if suite:
+ send_json(h, 200, {"ok": True, "summary": run_suite(suite)})
+ elif name:
+ send_json(h, 200, {"ok": True, "result": run_scenario(
+ name, seed=int(seed) if seed is not None else None)})
+ else:
+ raise ApiError(400, "provide 'scenario' or 'suite'")
+ except KeyError as exc:
+ raise ApiError(404, str(exc))
diff --git a/roborun/routes/search.py b/roborun/routes/search.py
new file mode 100644
index 0000000..9a2f90d
--- /dev/null
+++ b/roborun/routes/search.py
@@ -0,0 +1,178 @@
+"""Search-over-time + perception session routes — the production surface.
+
+`/api/search` finds anything/anyone across all recorded history (semantic + label +
+time window), regardless of which mode produced it. `/api/perception/*` runs the
+unified capture loop in a chosen mode.
+"""
+from __future__ import annotations
+
+from roborun.routes import get, post, send_json, ApiError
+
+_session = None
+
+
+@post("/api/recall")
+def recall_unified(h, payload):
+ """Unified retrieval (platform spec 06): combine semantic + label + spatial +
+ time in one query, scoped to the active project's index. Body: {text?, label?,
+ near?{x,y,radius?}, since?, until?, k?, source_id?}."""
+ from roborun.routes._singletons import get_memory
+ try:
+ store = get_memory()
+ except Exception:
+ from roborun.spatial_memory import SpatialMemoryStore
+ store = SpatialMemoryStore()
+ if not any(payload.get(key) is not None and payload.get(key) != ""
+ for key in ("text", "label", "near", "since", "until")):
+ raise ApiError(400, "provide at least one of text/label/near/since/until")
+ rows = store.recall_combined(
+ text=payload.get("text"), label=payload.get("label"),
+ near=payload.get("near"), since=payload.get("since"),
+ until=payload.get("until"), k=int(payload.get("k", 12)),
+ source_id=payload.get("source_id"))
+ from roborun import projects
+ send_json(h, 200, {"ok": True, "results": rows, "total": len(rows),
+ "scope": projects.active()})
+
+
+@get("/api/search/caps")
+def search_caps(h):
+ """Honest capability report so the UI never offers fake semantic search.
+ "Real" embeddings are high-dimensional CLIP vectors (~2KB); the old demo
+ placeholder was 3 floats (~12B). Semantic search only works if real ones
+ exist — checked cheaply by blob length, no model load."""
+ real = total = 0
+ try:
+ from roborun.routes._singletons import get_memory
+ conn = get_memory()._conn
+ real = conn.execute(
+ "SELECT COUNT(*) FROM observations WHERE embedding IS NOT NULL AND length(embedding) > 64"
+ ).fetchone()[0]
+ total = conn.execute("SELECT COUNT(*) FROM observations").fetchone()[0]
+ except Exception:
+ pass
+ send_json(h, 200, {"ok": True, "semantic": real > 0, "embeddings": int(real), "total": int(total)})
+
+
+@post("/api/search")
+def search_history(h, payload):
+ """Body: {query, by?: clip|label|near, k?, since?, until?, source_id?}.
+ Searches all runs/modes; since/until are unix seconds."""
+ from roborun.routes._singletons import get_memory # SpatialMemoryStore singleton
+ from roborun.session import search
+ query = payload.get("query")
+ if not query and payload.get("by") not in ("time", "uncertain"):
+ raise ApiError(400, "query required")
+ try:
+ store = get_memory()
+ except Exception:
+ from roborun.spatial_memory import SpatialMemoryStore
+ store = SpatialMemoryStore()
+ rows = search(store, query, by=str(payload.get("by", "clip")),
+ k=int(payload.get("k", 10)),
+ since=payload.get("since"), until=payload.get("until"),
+ source_id=payload.get("source_id"))
+ send_json(h, 200, {"ok": True, "results": rows, "total": len(rows)})
+
+
+@post("/api/search/export")
+def search_export(h, payload):
+ """Curate a labeled dataset from a search → datasets//. Body: {query,
+ by?, since?, until?, name?}."""
+ import time
+ from pathlib import Path
+ from roborun.session import export_dataset
+ try:
+ from roborun.routes._singletons import get_memory
+ store = get_memory()
+ except Exception:
+ from roborun.spatial_memory import SpatialMemoryStore
+ store = SpatialMemoryStore()
+ name = str(payload.get("name") or f"ds_{int(time.time())}")
+ out = Path("datasets") / name
+ r = export_dataset(store, payload.get("query", ""), str(out),
+ by=str(payload.get("by", "label")),
+ since=payload.get("since"), until=payload.get("until"))
+ send_json(h, 200, r)
+
+
+@post("/api/perception/start")
+def perception_start(h, payload):
+ """Start the unified capture loop in a mode (sim|robot|production)."""
+ global _session
+ from roborun.session import PerceptionSession, MODES
+ mode = str(payload.get("mode", "production"))
+ if mode not in MODES:
+ raise ApiError(400, f"mode must be one of {MODES}")
+ if _session is not None:
+ _session.stop()
+ try:
+ store = None
+ from roborun.routes._singletons import get_memory
+ store = get_memory()
+ except Exception:
+ store = None
+ _session = PerceptionSession.for_mode(
+ mode, store=store, source_id=str(payload.get("source_id", "cam")))
+ _session.start()
+ send_json(h, 200, {"ok": True, "mode": mode, "source_id": _session.source_id})
+
+
+@post("/api/perception/stop")
+def perception_stop(h, payload):
+ global _session
+ if _session is not None:
+ _session.stop()
+ _session = None
+ send_json(h, 200, {"ok": True})
+
+
+@get("/api/perception/status")
+def perception_status(h):
+ send_json(h, 200, {"ok": True, "running": _session is not None,
+ "mode": getattr(_session, "mode", None),
+ "indexed": getattr(_session, "indexed", 0)})
+
+
+@get("/api/analytics")
+def analytics(h):
+ """One dashboard payload: detections histogram, observations over time, source
+ breakdown, suite pass-rates, run + fleet counts. Everything tracked, summarized."""
+ out: dict = {"ok": True}
+ try:
+ from roborun.routes._singletons import get_memory
+ store = get_memory()
+ out["observations"] = store.stats()
+ out["labels"] = store.label_histogram(top=15)
+ out["over_time"] = store.counts_over_time(bucket_s=3600.0, buckets=24)
+ out["sources"] = store.source_breakdown()
+ out["robots"] = store.robots_breakdown()
+ except Exception as exc:
+ out["observations_error"] = str(exc)
+ try:
+ from roborun.scenario import list_suites
+ out["suites"] = list_suites()
+ except Exception:
+ out["suites"] = []
+ try:
+ from roborun.recorder import list_runs
+ runs = list_runs()
+ out["runs"] = {"count": len(runs),
+ "total_bytes": sum(r.get("size", 0) for r in runs),
+ "sealed": sum(1 for r in runs if r.get("sealed")),
+ "anchored": sum(1 for r in runs if r.get("anchored"))}
+ except Exception:
+ out["runs"] = {"count": 0}
+ try:
+ from roborun.retention import status as storage_status
+ out["storage"] = storage_status()
+ except Exception:
+ out["storage"] = {}
+ try:
+ from roborun.routes.fleet import _load_fleet
+ fleet = _load_fleet()
+ out["fleet"] = {"total": len(fleet),
+ "online": sum(1 for r in fleet if r.get("status") == "online")}
+ except Exception:
+ out["fleet"] = {"total": 0, "online": 0}
+ send_json(h, 200, out)
diff --git a/roborun/routes/sources.py b/roborun/routes/sources.py
new file mode 100644
index 0000000..8e05142
--- /dev/null
+++ b/roborun/routes/sources.py
@@ -0,0 +1,23 @@
+"""Source routes — what can see/move right now, and what's on the network."""
+from __future__ import annotations
+
+from roborun.routes import get, post, send_json
+
+
+@get("/api/sources")
+def sources(h):
+ from roborun.sources import inventory
+ send_json(h, 200, inventory())
+
+
+@post("/api/sources/scan")
+def sources_scan(h, payload):
+ from roborun.sources import network_scan
+ send_json(h, 200, {"ok": True, **network_scan(force=True)})
+
+
+@get("/api/robot/detections")
+def robot_detections(h):
+ """Normalized YOLO boxes from the robot camera, for the cockpit overlay."""
+ from roborun.ros_camera import get_ros_camera
+ send_json(h, 200, {"ok": True, **get_ros_camera().detections_normalized()})
diff --git a/roborun/run_manifest.py b/roborun/run_manifest.py
new file mode 100644
index 0000000..752232a
--- /dev/null
+++ b/roborun/run_manifest.py
@@ -0,0 +1,66 @@
+"""Run manifest (platform spec 01 P2) — a run's table of contents.
+
+A tiny JSON written beside each MCAP so consumers (telemetry browser, search)
+can list a run and know its project/environment/backend/cameras without
+cracking the tape. Best-effort: never let manifest IO break a recording.
+
+ .run.json next to .mcap
+"""
+from __future__ import annotations
+
+import json
+import time
+from pathlib import Path
+from typing import Any
+
+
+def manifest_path(mcap_path: str | Path) -> Path:
+ return Path(mcap_path).with_suffix(".run.json")
+
+
+def write_start(mcap_path: str | Path, run_id: str, robot_id: str,
+ backend: str | None = None) -> dict[str, Any]:
+ try:
+ from roborun import projects, environments
+ a = projects.active() or {}
+ env = environments.active_meta() or {}
+ m = {
+ "run_id": run_id, "robot_id": robot_id,
+ "project": a.get("project"), "environment": a.get("environment"),
+ "backend": backend or env.get("backend"),
+ "cameras": env.get("cameras", []),
+ "frame": env.get("frame", "world"),
+ "started": time.time(), "ended": None,
+ "channels": [], "seal": None,
+ }
+ manifest_path(mcap_path).write_text(json.dumps(m, indent=2))
+ return m
+ except Exception:
+ return {}
+
+
+def finalize(mcap_path: str | Path, seal: dict | None = None,
+ channels: list[str] | None = None) -> dict[str, Any]:
+ try:
+ p = manifest_path(mcap_path)
+ m = json.loads(p.read_text()) if p.exists() else {"run_id": Path(mcap_path).stem}
+ m["ended"] = time.time()
+ if seal:
+ m["seal"] = {"merkle_root": seal.get("merkle_root"),
+ "anchor": (seal.get("anchor") or {}).get("status")}
+ if channels:
+ m["channels"] = channels
+ p.write_text(json.dumps(m, indent=2))
+ return m
+ except Exception:
+ return {}
+
+
+def read(mcap_path: str | Path) -> dict[str, Any] | None:
+ p = manifest_path(mcap_path)
+ if not p.exists():
+ return None
+ try:
+ return json.loads(p.read_text())
+ except Exception:
+ return None
diff --git a/roborun/run_series.py b/roborun/run_series.py
new file mode 100644
index 0000000..d5d29fc
--- /dev/null
+++ b/roborun/run_series.py
@@ -0,0 +1,125 @@
+"""Per-run telemetry series for the Analyze view (Antioch parity).
+
+Reads a sealed run's MCAP into the time series the run-detail panels draw:
+trajectory (the actual path), velocity + commanded action, obstacle clearance,
+and the latest lidar sweep. Pure read over the recorded channels — `/pose`,
+`/cmd`, `/scan`, `/telemetry/*` — so every panel is backed by the sealed record.
+"""
+from __future__ import annotations
+
+import json
+from pathlib import Path
+from typing import Any
+
+from roborun.events import runs_root
+
+
+def _find_mcap(run_id: str, robot_id: str | None = None) -> Path | None:
+ root = runs_root()
+ if robot_id:
+ p = root / robot_id / f"{run_id}.mcap"
+ return p if p.exists() else None
+ hits = list(root.glob(f"*/{run_id}.mcap"))
+ return hits[0] if hits else None
+
+
+def _safe_messages(fh):
+ """Iterate MCAP messages, stopping cleanly if the file is corrupt/partial
+ (e.g. a half-written or malformed run) instead of raising — so one bad run
+ can't 500 the replay endpoints."""
+ from mcap.reader import make_reader
+ try:
+ for m in make_reader(fh).iter_messages():
+ yield m
+ except Exception:
+ return
+
+
+def run_series(run_id: str, robot_id: str | None = None) -> dict[str, Any]:
+ """Extract the panels' series from a run. {ok, trajectory, velocity, cmd,
+ clearance, scan, t0, t1, counts}."""
+ mcap = _find_mcap(run_id, robot_id)
+ if mcap is None:
+ return {"ok": False, "error": f"run {run_id} not found"}
+
+ traj: list[list[float]] = []
+ velocity: list[dict] = []
+ cmd: list[dict] = []
+ clearance: list[dict] = []
+ scan_latest: list[float] = []
+ frames: list[float] = [] # camera frame timestamps (for the scrubber)
+ t0 = t1 = None
+ counts: dict[str, int] = {}
+
+ with open(mcap, "rb") as fh:
+ for _s, channel, message in _safe_messages(fh):
+ if channel.message_encoding != "json":
+ continue
+ try:
+ obj = json.loads(message.data)
+ except Exception:
+ continue
+ ts = message.log_time / 1e9
+ t0 = ts if t0 is None else min(t0, ts)
+ t1 = ts if t1 is None else max(t1, ts)
+ topic = channel.topic
+ counts[topic] = counts.get(topic, 0) + 1
+ if topic.startswith("/camera/"):
+ frames.append(round(ts, 3))
+ if topic in ("/pose", "/odom"):
+ p = (obj.get("pose") or {}).get("position") or {}
+ if "x" in p:
+ traj.append([round(p.get("x", 0), 3), round(p.get("z", 0), 3)])
+ elif topic == "/cmd":
+ cmd.append({"t": round(ts, 3), "forward": obj.get("forward", 0),
+ "turn": obj.get("turn", 0)})
+ elif topic.startswith("/telemetry/velocity"):
+ d = obj.get("data") or {}
+ velocity.append({"t": round(ts, 3), "linear": d.get("x", 0),
+ "angular": d.get("angular_z", 0)})
+ elif topic == "/scan":
+ ranges = [r for r in (obj.get("ranges") or []) if isinstance(r, (int, float)) and r > 0]
+ if ranges:
+ clearance.append({"t": round(ts, 3), "min": round(min(ranges), 3)})
+ scan_latest = [round(r, 3) for r in obj.get("ranges") or []]
+
+ # velocity falls back to commanded action if no /telemetry/velocity channel
+ if not velocity and cmd:
+ velocity = [{"t": c["t"], "linear": c["forward"], "angular": c["turn"]} for c in cmd]
+
+ return {"ok": True, "run": run_id, "robot_id": robot_id or mcap.parent.name,
+ "trajectory": traj, "velocity": velocity, "cmd": cmd,
+ "clearance": clearance, "scan": scan_latest, "frames": frames,
+ "t0": t0, "t1": t1, "duration_s": round((t1 - t0), 2) if t0 and t1 else 0,
+ "counts": counts}
+
+
+def frame_at(run_id: str, ts: float, robot_id: str | None = None) -> bytes | None:
+ """The camera JPEG nearest `ts` in a run — the synced-playback primitive
+ (scrub a time → see what the robot saw). CompressedImage only; codec video
+ opens in Foxglove. Returns raw JPEG bytes or None."""
+ import base64
+ mcap = _find_mcap(run_id, robot_id)
+ if mcap is None:
+ return None
+ best = None
+ best_dt = None
+ with open(mcap, "rb") as fh:
+ for _s, channel, message in _safe_messages(fh):
+ if not channel.topic.startswith("/camera/") or channel.message_encoding != "json":
+ continue
+ try:
+ obj = json.loads(message.data)
+ except Exception:
+ continue
+ if obj.get("format") != "jpeg" or not obj.get("data"):
+ continue
+ dt = abs(message.log_time / 1e9 - ts)
+ if best_dt is None or dt < best_dt:
+ best_dt, best = dt, obj["data"]
+ if best is None:
+ return None
+ try:
+ return base64.b64decode(best)
+ except Exception:
+ return None
diff --git a/roborun/scenario.py b/roborun/scenario.py
new file mode 100644
index 0000000..3e38f42
--- /dev/null
+++ b/roborun/scenario.py
@@ -0,0 +1,291 @@
+"""Scenarios — scored, taggable, replayable runs (RoboRun's eval layer).
+
+This is the missing paradigm RL_HARNESS_SPEC.md calls out: RoboRun is a control
+loop (`@behavior`, `robot.move()`) with no notion of an *episode* — no reset, no
+reward, no pass/fail. A scenario wraps a stretch of robot activity into one scored
+record:
+
+ from roborun.scenario import scenario
+
+ with scenario("lobby-nav", tags=["nav", "obstacles"],
+ params={"map": "lobby_v2", "num_obstacles": 6}) as run:
+ drive_to_goal(robot) # any behaviors / handle calls
+ run.metric("path_length_m", 18.4)
+ run.metric("collisions", 0)
+ run.evaluate("safety", min_clearance_m=0.31, near_misses=0)
+ run.passed(goal_reached=True)
+
+On exit it writes one `.scenario.json` into the runs root and emits markers
+into the timeline (so, if a recording is live, the scored result lands *inside*
+the sealed, tamper-evident MCAP). Two things make this superset Antioch's
+Scenarios rather than copy it:
+
+ * every scenario row is backed by a sealed run (recorder.py), so the score is
+ verifiable, not just asserted; and
+ * the same scenario, written once against the portable handle, runs on every
+ embodiment by construction (SIM_SPEC's contract) — Antioch's Isaac scenarios
+ are single-stack.
+
+No new dependencies: stdlib + the existing event bus. Recording is optional.
+"""
+from __future__ import annotations
+
+import json
+import time
+import traceback
+from dataclasses import dataclass, field
+from pathlib import Path
+from typing import Any
+
+from roborun.events import emit, runs_root
+
+SCHEMA = "roborun-scenario/1"
+
+
+def _scenarios_dir() -> Path:
+ d = runs_root() / "scenarios"
+ d.mkdir(parents=True, exist_ok=True)
+ return d
+
+
+@dataclass
+class ScenarioRun:
+ """The live handle yielded by `scenario(...)`. Collect metrics as you go;
+ declare the outcome before the block ends (or it's inferred)."""
+
+ name: str
+ tags: list[str] = field(default_factory=list)
+ params: dict[str, Any] = field(default_factory=dict)
+ robot: str = "local"
+ suite: str | None = None # groups runs into a pass-rate card (Antioch suites)
+ seed: int | None = None # recorded for deterministic replay + fair A/B
+ scenario_id: str = ""
+ run_id: str | None = None # linked sealed MCAP run, if recording
+ started: float = field(default_factory=time.time)
+ metrics: dict[str, Any] = field(default_factory=dict)
+ evaluation: dict[str, dict[str, Any]] = field(default_factory=dict)
+ outcome: str | None = None # "passed" | "failed" | "error"
+ reason: str | None = None
+
+ # ── collection API ──────────────────────────────────────────────────────
+ def metric(self, key: str, value: Any) -> "ScenarioRun":
+ """Record one flat result, e.g. metric('collisions', 0). Antioch's
+ Results row."""
+ self.metrics[key] = value
+ return self
+
+ def metrics_update(self, **kw: Any) -> "ScenarioRun":
+ self.metrics.update(kw)
+ return self
+
+ def evaluate(self, group: str, **scores: Any) -> "ScenarioRun":
+ """Record a nested evaluation group, e.g.
+ evaluate('safety', min_clearance_m=0.31, near_misses=0). Antioch's
+ evaluation → path_quality / safety tree."""
+ self.evaluation.setdefault(group, {}).update(scores)
+ return self
+
+ # ── outcome API ─────────────────────────────────────────────────────────
+ def passed(self, **results: Any) -> "ScenarioRun":
+ self.metrics.update(results)
+ self.outcome = "passed"
+ return self
+
+ def failed(self, reason: str = "", **results: Any) -> "ScenarioRun":
+ self.metrics.update(results)
+ self.outcome = "failed"
+ self.reason = reason or self.reason
+ return self
+
+ def fail_if(self, condition: bool, reason: str) -> bool:
+ """Guard helper: `run.fail_if(collisions > 0, 'hit something')`.
+ Returns the condition so callers can branch."""
+ if condition:
+ self.failed(reason)
+ return condition
+
+ # ── persistence ─────────────────────────────────────────────────────────
+ def to_dict(self) -> dict[str, Any]:
+ ended = getattr(self, "_ended", time.time())
+ return {
+ "schema": SCHEMA,
+ "scenario_id": self.scenario_id,
+ "name": self.name,
+ "tags": self.tags,
+ "params": self.params,
+ "robot": self.robot,
+ "suite": self.suite,
+ "seed": self.seed,
+ "run_id": self.run_id,
+ "outcome": self.outcome,
+ "reason": self.reason,
+ "metrics": self.metrics,
+ "evaluation": self.evaluation,
+ "started": _iso(self.started),
+ "ended": _iso(ended),
+ "duration_s": round(ended - self.started, 3),
+ }
+
+ def _persist(self) -> Path:
+ path = _scenarios_dir() / f"{self.scenario_id}.scenario.json"
+ path.write_text(json.dumps(self.to_dict(), indent=2, default=str))
+ return path
+
+
+def _iso(ts: float) -> str:
+ return time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime(ts))
+
+
+class _ScenarioCtx:
+ def __init__(self, run: ScenarioRun) -> None:
+ self.run = run
+
+ def __enter__(self) -> ScenarioRun:
+ r = self.run
+ emit("scenario", r.name,
+ f"scenario start · {r.name}"
+ + (f" [{', '.join(r.tags)}]" if r.tags else ""),
+ {"scenario_id": r.scenario_id, "params": r.params, "tags": r.tags})
+ return r
+
+ def __exit__(self, exc_type, exc, tb) -> bool:
+ r = self.run
+ r._ended = time.time() # type: ignore[attr-defined]
+ if exc_type is not None:
+ # An exception inside the block is an errored scenario, not a crash
+ # of the program — record it and re-raise.
+ r.outcome = "error"
+ r.reason = f"{exc_type.__name__}: {exc}"
+ last = traceback.format_exc().strip().splitlines()[-1]
+ r.reason = last
+ elif r.outcome is None:
+ # Infer: an explicit goal_reached metric decides; else assume passed.
+ goal = r.metrics.get("goal_reached")
+ r.outcome = "failed" if goal is False else "passed"
+ path = r._persist()
+ emit("scenario", r.name,
+ f"scenario {r.outcome} · {r.name}"
+ + (f" — {r.reason}" if r.reason else ""),
+ {"scenario_id": r.scenario_id, "outcome": r.outcome,
+ "metrics": r.metrics, "evaluation": r.evaluation,
+ "duration_s": r.to_dict()["duration_s"], "file": str(path)})
+ return False # never suppress exceptions
+
+
+def scenario(name: str, tags: list[str] | None = None,
+ params: dict[str, Any] | None = None,
+ robot: str = "local", suite: str | None = None,
+ seed: int | None = None) -> _ScenarioCtx:
+ """Open a scored scenario. Use as a context manager; see module docstring.
+
+ `suite` groups runs into one pass-rate card (Antioch's suites). If a
+ recording is live (recorder.py), the scenario binds to that run_id so the
+ score is committed inside the sealed MCAP."""
+ base = time.strftime("scn_%Y%m%d_%H%M%S", time.gmtime())
+ sid, n = base, 1
+ while (_scenarios_dir() / f"{sid}.scenario.json").exists():
+ sid = f"{base}_{n}"
+ n += 1
+ run_id = None
+ try:
+ from roborun import recorder
+ rec = recorder.active_recorder()
+ if rec is not None:
+ run_id = rec.run_id
+ robot = getattr(rec, "robot_id", robot) or robot
+ except Exception:
+ pass
+ run = ScenarioRun(name=name, tags=list(tags or []),
+ params=dict(params or {}), robot=robot, suite=suite,
+ seed=seed, scenario_id=sid, run_id=run_id)
+ return _ScenarioCtx(run)
+
+
+# ── registry (the "Scenarios" list view, headless) ─────────────────────────
+
+def list_results(limit: int = 100, tag: str | None = None,
+ outcome: str | None = None,
+ name: str | None = None,
+ suite: str | None = None) -> list[dict[str, Any]]:
+ """Read scored scenario records newest-first, with Antioch-style filters
+ (by tag, outcome, scenario name, suite). Pure file scan — no service."""
+ rows: list[dict[str, Any]] = []
+ for p in sorted(_scenarios_dir().glob("*.scenario.json"), reverse=True):
+ try:
+ row = json.loads(p.read_text())
+ except Exception:
+ continue
+ if tag and tag not in row.get("tags", []):
+ continue
+ if outcome and row.get("outcome") != outcome:
+ continue
+ if name and row.get("name") != name:
+ continue
+ if suite and (row.get("suite") or "(ungrouped)") != suite:
+ continue
+ rows.append(row)
+ if len(rows) >= limit:
+ break
+ return rows
+
+
+def get_result(scenario_id: str) -> dict[str, Any] | None:
+ p = _scenarios_dir() / f"{scenario_id}.scenario.json"
+ if not p.exists():
+ return None
+ try:
+ return json.loads(p.read_text())
+ except Exception:
+ return None
+
+
+def find_by_run(run_id: str) -> dict[str, Any] | None:
+ """The scored scenario record whose run_id matches — the run-detail's
+ metadata/params/results/evaluation source. Newest-first file scan."""
+ if not run_id:
+ return None
+ for p in sorted(_scenarios_dir().glob("*.scenario.json"), reverse=True):
+ try:
+ row = json.loads(p.read_text())
+ except Exception:
+ continue
+ if row.get("run_id") == run_id:
+ return row
+ return None
+
+
+def list_suites() -> list[dict[str, Any]]:
+ """Aggregate scored runs into suite cards — pass rate, run count, latest
+ activity — the Antioch "Suites" view. Runs with no `suite` fall under
+ "(ungrouped)". `passed` counts toward the rate; `failed` and `error` don't.
+ """
+ buckets: dict[str, list[dict[str, Any]]] = {}
+ for row in list_results(limit=10_000):
+ buckets.setdefault(row.get("suite") or "(ungrouped)", []).append(row)
+ cards = []
+ for suite, rows in buckets.items():
+ passed = sum(1 for r in rows if r.get("outcome") == "passed")
+ cards.append({
+ "suite": suite,
+ "runs": len(rows),
+ "passed": passed,
+ "pass_rate": round(passed / len(rows), 3) if rows else 0.0,
+ "latest": rows[0].get("ended"), # list_results is newest-first
+ "scenarios": sorted({r.get("name", "?") for r in rows}),
+ })
+ cards.sort(key=lambda c: c["latest"] or "", reverse=True)
+ return cards
+
+
+def suite_summary(suite: str) -> dict[str, Any]:
+ """One suite's card plus its runs (the drill-in view)."""
+ rows = [r for r in list_results(limit=10_000)
+ if (r.get("suite") or "(ungrouped)") == suite]
+ passed = sum(1 for r in rows if r.get("outcome") == "passed")
+ return {
+ "suite": suite,
+ "runs": len(rows),
+ "passed": passed,
+ "pass_rate": round(passed / len(rows), 3) if rows else 0.0,
+ "results": rows,
+ }
diff --git a/roborun/scenario_defs.py b/roborun/scenario_defs.py
new file mode 100644
index 0000000..5c5d94f
--- /dev/null
+++ b/roborun/scenario_defs.py
@@ -0,0 +1,228 @@
+"""Runnable scenarios — the executable half of the eval layer.
+
+`scenario.py` *scores* a stretch of activity. This module makes scenarios
+*launchable*: a `@scenario_def` is a named, suite-grouped function the agent (or
+CI, or a person) can run on demand. Running one drives a robot handle to a
+deadline, scores it via `scenario()`, and persists the sealed record — so the
+Antioch "Accelerate" demo ("run the suite, find the failures, fix them") becomes
+a real loop, not a screenshot.
+
+ from roborun.scenario_defs import scenario_def
+
+ @scenario_def("reach_goal", suite="navigation", tags=["nav"],
+ params={"goal": (3.0, 0.0), "tol": 0.4})
+ def reach_goal(ctx):
+ gx, gz = ctx.params["goal"]
+ ok = ctx.until(lambda: ctx.robot.goto(gx, gz, tol=ctx.params["tol"]))
+ p = ctx.robot.pose() or {}
+ ctx.run.metric("final_pose", [p.get("x"), p.get("z")])
+ (ctx.run.passed if ok else ctx.run.failed)(
+ "reached goal" if ok else "timed out before goal")
+
+The handle is the same one behaviors get, so a scenario written once runs on
+every backend and embodiment by the SIM_SPEC contract. Tests inject a handle so
+no browser arena is needed; in the deck the live arena is the handle.
+"""
+from __future__ import annotations
+
+import time
+from dataclasses import dataclass, field
+from typing import Any, Callable
+
+from roborun.events import emit
+from roborun.scenario import get_result, scenario
+
+# ── registry ────────────────────────────────────────────────────────────────
+
+
+@dataclass
+class ScenarioDef:
+ name: str
+ fn: Callable[["ScenarioContext"], Any]
+ suite: str | None = None
+ tags: list[str] = field(default_factory=list)
+ params: dict[str, Any] = field(default_factory=dict)
+ timeout_s: float = 30.0
+ doc: str = ""
+
+
+_REGISTRY: dict[str, ScenarioDef] = {}
+
+
+def scenario_def(name: str, *, suite: str | None = None,
+ tags: list[str] | None = None,
+ params: dict[str, Any] | None = None,
+ timeout_s: float = 30.0):
+ """Register a runnable scenario. Decorate a `fn(ctx)` that scores via
+ `ctx.run`."""
+ def deco(fn: Callable[["ScenarioContext"], Any]):
+ _REGISTRY[name] = ScenarioDef(
+ name=name, fn=fn, suite=suite, tags=list(tags or []),
+ params=dict(params or {}), timeout_s=timeout_s,
+ doc=(fn.__doc__ or "").strip().split("\n")[0])
+ return fn
+ return deco
+
+
+def list_defs(suite: str | None = None) -> list[dict[str, Any]]:
+ """The catalog of runnable scenarios (not their past results)."""
+ out = []
+ for d in _REGISTRY.values():
+ if suite and d.suite != suite:
+ continue
+ out.append({"name": d.name, "suite": d.suite, "tags": d.tags,
+ "params": d.params, "timeout_s": d.timeout_s, "doc": d.doc})
+ return sorted(out, key=lambda x: (x["suite"] or "", x["name"]))
+
+
+def suites_defined() -> list[str]:
+ return sorted({d.suite for d in _REGISTRY.values() if d.suite})
+
+
+# ── execution context ───────────────────────────────────────────────────────
+
+
+class ScenarioContext:
+ """Handed to a scenario function. Exposes the robot handle, merged params,
+ the scoring run, and a deadline-aware control helper."""
+
+ def __init__(self, robot: Any, run: Any, params: dict[str, Any],
+ deadline: float, tick_hz: float = 10.0,
+ seed: int | None = None) -> None:
+ self.robot = robot
+ self.run = run
+ self.params = params
+ self.deadline = deadline
+ self.seed = seed
+ self._dt = 1.0 / max(1e-3, tick_hz)
+
+ @property
+ def expired(self) -> bool:
+ return time.time() >= self.deadline
+
+ @property
+ def remaining(self) -> float:
+ return max(0.0, self.deadline - time.time())
+
+ def until(self, done: Callable[[], bool],
+ on_tick: Callable[[], Any] | None = None,
+ sleep: Callable[[float], None] = time.sleep) -> bool:
+ """Tick until `done()` is truthy or the deadline passes. Returns
+ whether `done()` succeeded. `sleep` is injectable for fast tests."""
+ while not self.expired:
+ if on_tick is not None:
+ on_tick()
+ if done():
+ return True
+ sleep(self._dt)
+ return bool(done())
+
+
+# ── runner ──────────────────────────────────────────────────────────────────
+
+
+def _default_handle():
+ from roborun.behaviors import Robot
+ return Robot("scenario")
+
+
+def run_scenario(name: str, robot: Any = None,
+ params: dict[str, Any] | None = None,
+ tick_hz: float = 10.0, seed: int | None = None) -> dict[str, Any]:
+ """Run one scenario by name; return its scored record. Raises KeyError if
+ the scenario isn't registered. `seed` is recorded into the run envelope for
+ deterministic replay + fair A/B (LOCAL_SIM_SPEC Phase 4)."""
+ d = _REGISTRY.get(name)
+ if d is None:
+ raise KeyError(f"no scenario named {name!r}")
+ robot = robot if robot is not None else _default_handle()
+ merged = {**d.params, **(params or {})}
+ with scenario(d.name, tags=d.tags, params=merged, suite=d.suite,
+ seed=seed) as run:
+ ctx = ScenarioContext(robot=robot, run=run, params=merged,
+ deadline=time.time() + d.timeout_s, tick_hz=tick_hz,
+ seed=seed)
+ d.fn(ctx)
+ # The record is persisted on context exit; read it back by id.
+ rec = get_result(run.scenario_id)
+ return rec or run.to_dict()
+
+
+def run_suite(suite: str, robot: Any = None,
+ tick_hz: float = 10.0) -> dict[str, Any]:
+ """Run every scenario in a suite; return per-scenario records + the
+ aggregate pass-rate. This is the Antioch "run the suite" action."""
+ names = [d.name for d in _REGISTRY.values() if d.suite == suite]
+ if not names:
+ return {"suite": suite, "runs": 0, "passed": 0, "pass_rate": 0.0,
+ "results": [], "error": f"no scenarios in suite {suite!r}"}
+ emit("scenario", "suite", f"running suite {suite} ({len(names)} scenarios)",
+ {"suite": suite, "scenarios": names})
+ results = [run_scenario(n, robot=robot, tick_hz=tick_hz) for n in names]
+ passed = sum(1 for r in results if r.get("outcome") == "passed")
+ summary = {"suite": suite, "runs": len(results), "passed": passed,
+ "pass_rate": round(passed / len(results), 3),
+ "results": results}
+ emit("scenario", "suite",
+ f"suite {suite} done · {int(summary['pass_rate']*100)}% "
+ f"({passed}/{len(results)})", {"suite": suite})
+ return summary
+
+
+# ── algorithm testing: A/B + sweeps + regression gates ──────────────────────
+
+
+def run_matrix(scenario_name: str,
+ variants: dict[str, dict[str, Any]] | None = None,
+ seeds: "list[int] | range" = (0,),
+ robot_factory: Callable[[str, int], Any] | None = None,
+ tick_hz: float = 10.0) -> dict[str, Any]:
+ """Run one scenario across {variant × seed} and compare — the A/B + sweep
+ runner. `variants` maps a name → param overrides; `robot_factory(variant,
+ seed)` supplies the handle per cell (defaults to the scenario's own handle).
+ Each cell is a sealed run, so the comparison is verifiable, not asserted.
+
+ Returns {cells, by_variant:{v:{runs,passed,pass_rate,mean,std}}, winner}."""
+ variants = variants or {"default": {}}
+ seeds = list(seeds)
+ cells: list[dict[str, Any]] = []
+ for vname, overrides in variants.items():
+ for s in seeds:
+ robot = robot_factory(vname, s) if robot_factory else None
+ rec = run_scenario(scenario_name, robot=robot,
+ params={**(overrides or {}), "_variant": vname},
+ tick_hz=tick_hz, seed=s)
+ cells.append({"variant": vname, "seed": s,
+ "outcome": rec.get("outcome"),
+ "metrics": rec.get("metrics", {}),
+ "scenario_id": rec.get("scenario_id"),
+ "run_id": rec.get("run_id")})
+ by_variant: dict[str, dict[str, Any]] = {}
+ for vname in variants:
+ rows = [c for c in cells if c["variant"] == vname]
+ passed = sum(1 for c in rows if c["outcome"] == "passed")
+ by_variant[vname] = {
+ "runs": len(rows), "passed": passed,
+ "pass_rate": round(passed / len(rows), 3) if rows else 0.0,
+ }
+ winner = max(by_variant, key=lambda v: by_variant[v]["pass_rate"]) if by_variant else None
+ emit("scenario", "matrix",
+ f"matrix {scenario_name} · {len(variants)}×{len(seeds)} → winner {winner}",
+ {"scenario": scenario_name})
+ return {"scenario": scenario_name, "seeds": seeds, "cells": cells,
+ "by_variant": by_variant, "winner": winner}
+
+
+def regression_gate(suite: str, baseline_pass_rate: float,
+ robot: Any = None) -> dict[str, Any]:
+ """Run a suite and return {ok, pass_rate, baseline}. `ok` is False when the
+ suite regressed below the baseline — wire into CI (`exit(0 if ok else 1)`)."""
+ summary = run_suite(suite, robot=robot)
+ ok = summary.get("pass_rate", 0.0) >= baseline_pass_rate
+ summary["ok"] = ok
+ summary["baseline"] = baseline_pass_rate
+ emit("scenario", "gate",
+ f"gate {suite}: {int(summary.get('pass_rate',0)*100)}% vs "
+ f"{int(baseline_pass_rate*100)}% → {'PASS' if ok else 'REGRESSION'}",
+ {"suite": suite, "ok": ok})
+ return summary
diff --git a/roborun/server.py b/roborun/server.py
index 14e5f7f..24fc432 100644
--- a/roborun/server.py
+++ b/roborun/server.py
@@ -11,6 +11,7 @@
import json
import os
+import re
import threading
import time
from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer
@@ -23,13 +24,49 @@
PORT = int(os.environ.get("ROBORUN_PORT", "8765"))
STATE_ROOT = ROOT / ".roborun"
-_FRAME_PATHS = [
- Path("/tmp/roborun_frame.jpg"),
- Path("/tmp/roborun_camera.jpg"),
-]
+# CORS allowlist. A running roborun exposes its API to the browser; allow only
+# same-machine origins (the local UI / dev) and the known hosted demo platforms
+# (Vercel, GitHub Pages), plus anything in ROBORUN_ALLOWED_ORIGINS. Anything else
+# is refused, so a random site the user visits can't drive their robot. Replaces
+# the old blanket "*".
+_ALLOWED_ORIGIN_RE = re.compile(
+ r"^https?://(localhost|127\.0\.0\.1)(:\d+)?$"
+ r"|^https://([a-z0-9-]+\.)*(vercel\.app|github\.io)$",
+ re.IGNORECASE,
+)
+
+
+def allowed_origin(origin: str | None) -> str | None:
+ """Echo `origin` back iff it may call this server cross-origin, else None."""
+ if not origin:
+ return None
+ extra = {o.strip() for o in os.environ.get("ROBORUN_ALLOWED_ORIGINS", "").split(",") if o.strip()}
+ if origin in extra or _ALLOWED_ORIGIN_RE.match(origin):
+ return origin
+ return None
+
+# each pipeline writes its own file; the stream picks by ?source=
+_SOURCE_FRAMES = {
+ "robot": [Path("/tmp/roborun_robot_frame.jpg")],
+ "webcam": [Path("/tmp/roborun_frame.jpg"), Path("/tmp/roborun_camera.jpg")],
+}
+
+
+def _stream_paths(source: str) -> list:
+ if source in _SOURCE_FRAMES:
+ return _SOURCE_FRAMES[source]
+ # auto: a robot camera producing fresh frames outranks the webcam
+ robot = _SOURCE_FRAMES["robot"][0]
+ try:
+ if time.time() - robot.stat().st_mtime < 2.0:
+ return [robot]
+ except OSError:
+ pass
+ return _SOURCE_FRAMES["webcam"]
# Import route modules — registering all @get/@post handlers
import roborun.routes.dashboard # noqa: F401
+import roborun.routes.sources # noqa: F401
import roborun.routes.fleet # noqa: F401
import roborun.routes.tasks # noqa: F401
import roborun.routes.webcam # noqa: F401
@@ -42,6 +79,9 @@
import roborun.routes.run # noqa: F401
import roborun.routes.arena # noqa: F401
import roborun.routes.behaviors # noqa: F401
+import roborun.routes.scenarios # noqa: F401
+import roborun.routes.search # noqa: F401
+import roborun.routes.projects # noqa: F401
from roborun.routes import dispatch_get, dispatch_post, read_json, send_json, ApiError
from roborun.routes.mcp import handle_mcp_request, handle_mcp_sse
@@ -55,11 +95,25 @@ def log_message(self, fmt: str, *args: Any) -> None:
def end_headers(self) -> None:
self.send_header("Cache-Control", "no-cache, no-store, must-revalidate")
+ # CORS, centralized: the static/hosted site probes this server
+ # cross-origin and upgrades itself to the live cockpit. Echo only
+ # allowlisted origins (see allowed_origin) — never a blanket "*".
+ origin = self.headers.get("Origin") if getattr(self, "headers", None) else None
+ allow = allowed_origin(origin)
+ if allow:
+ self.send_header("Access-Control-Allow-Origin", allow)
+ self.send_header("Vary", "Origin")
super().end_headers()
def do_GET(self) -> None:
path_only = self.path.split("?", 1)[0]
+ # quiet the browser's favicon probe (was the only console 404)
+ if path_only == "/favicon.ico":
+ self.send_response(204)
+ self.end_headers()
+ return
+
# MCP SSE discovery
if path_only in ("/mcp", "/mcp/ros"):
handle_mcp_sse(self)
@@ -72,28 +126,112 @@ def do_GET(self) -> None:
# MJPEG camera stream
if path_only == "/api/camera/stream":
- self._mjpeg_stream()
+ from urllib.parse import parse_qs, urlparse
+ q = parse_qs(urlparse(self.path).query)
+ self._mjpeg_stream((q.get("source") or ["auto"])[0])
+ return
+
+ # single JPEG frame — robust feed for clients that poll img.src
+ if path_only == "/api/camera/frame":
+ from urllib.parse import parse_qs, urlparse
+ q = parse_qs(urlparse(self.path).query)
+ self._camera_frame((q.get("source") or ["auto"])[0])
return
# Route registry
if dispatch_get(self.path, self):
return
- # Static files — the flight deck IS the UI; the arena is the sim
- if path_only in ("/", "/deck"):
- self.path = "/deck.html"
- elif path_only == "/arena":
+ # One canonical view at one URL: "/". The old paths just redirect
+ # there so nothing 404s, but there's a single route, not three.
+ # Studio is the one front door. The old standalone pages collapse into
+ # it: cockpit+fleet-sim → /sims, browser+timeline+run → /runs, the rest
+ # are reachable inside Studio's shell. Redirect the absorbed routes.
+ _STUDIO_REDIRECT = {
+ "/": "/studio/live", "/deck": "/studio/live", "/home": "/studio/live",
+ "/arena": "/studio/sims", "/browser": "/studio/runs",
+ "/timeline": "/studio/runs", "/run": "/studio/runs",
+ "/search": "/studio/search",
+ }
+ if path_only in _STUDIO_REDIRECT:
+ self.send_response(302)
+ self.send_header("Location", _STUDIO_REDIRECT[path_only])
+ self.end_headers()
+ return
+ # Pages Studio still hosts (iframed) or links to — keep serving their html.
+ if path_only == "/setup":
+ self.path = "/setup.html"
+ if path_only == "/fleet": # Swarm Lab (Studio /swarm)
+ self.path = "/fleet.html"
+ if path_only == "/scenarios": # Studio /scenarios
+ self.path = "/scenarios.html"
+ if path_only == "/analytics": # Studio /analytics
+ self.path = "/analytics.html"
+ if path_only == "/sim": # Studio Sims · Arena
self.path = "/arena.html"
+ if path_only == "/projects":
+ self.path = "/projects.html"
+ if path_only == "/fleet-sim": # Studio Sims · Fleet
+ self.path = "/fleet-sim.html"
+ # Studio SPA (React, client-side routing): any /studio/* that isn't a
+ # real built asset falls back to its index.html. Isolated subpath so the
+ # old pages keep working until parity; flipped to "/" in the last phase.
+ if path_only == "/studio" or path_only.startswith("/studio/"):
+ if not (WEB_ROOT / path_only.lstrip("/")).is_file():
+ self.path = "/studio/index.html"
super().do_GET()
def do_OPTIONS(self) -> None:
self.send_response(200)
- self.send_header("Access-Control-Allow-Origin", "*")
+ # Allow-Origin comes from end_headers — sending it here too would
+ # duplicate the header, which browsers reject outright
self.send_header("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
self.send_header("Access-Control-Allow-Headers", "Content-Type")
+ # Chrome Private Network Access: lets a public https site (the hosted
+ # demo) reach this server on 127.0.0.1 once PNA enforcement lands
+ self.send_header("Access-Control-Allow-Private-Network", "true")
self.end_headers()
+ def _handle_upload(self) -> None:
+ """Drop-zone: accept a raw .mcap upload and save it as a replayable run
+ (robot_id 'uploaded'). The body is binary, so this bypasses JSON dispatch."""
+ import re as _re
+ from urllib.parse import parse_qs, urlparse
+ from roborun.recorder import runs_root
+ q = parse_qs(urlparse(self.path).query)
+ name = (q.get("name") or ["upload.mcap"])[0]
+ stem = _re.sub(r"[^A-Za-z0-9_.-]", "_", name).rsplit(".mcap", 1)[0][:80] or \
+ time.strftime("upload_%Y%m%d_%H%M%S")
+ length = int(self.headers.get("Content-Length", "0") or "0")
+ data = self.rfile.read(length) if length else b""
+ # MCAP files start with the magic \x89MCAP0\r\n — reject anything else
+ if not data[:5] == b"\x89MCAP":
+ send_json(self, 400, {"ok": False,
+ "error": "not an .mcap file (.bag/.db3 need conversion to MCAP first)"})
+ return
+ dest = runs_root() / "uploaded"
+ dest.mkdir(parents=True, exist_ok=True)
+ path = dest / f"{stem}.mcap"
+ n = 1
+ while path.exists():
+ path = dest / f"{stem}_{n}.mcap"; n += 1
+ path.write_bytes(data)
+ indexed = None
+ try: # best-effort: index detections/clips so it's searchable too
+ from roborun.observations import extract_run
+ from roborun.routes._singletons import get_memory
+ indexed = extract_run(path, get_memory(), robot_id="uploaded")
+ except Exception as exc:
+ indexed = {"ok": False, "error": str(exc)}
+ from roborun.events import emit
+ emit("system", "upload", f"imported {path.name} ({len(data)//1024}KB)", {"run": path.stem})
+ send_json(self, 200, {"ok": True, "run": path.stem, "robot_id": "uploaded",
+ "bytes": len(data), "indexed": indexed})
+
def do_POST(self) -> None:
+ if self.path.split("?", 1)[0] == "/api/run/upload":
+ self._handle_upload()
+ return
# MCP JSON-RPC
if self.path in ("/mcp", "/mcp/ros"):
try:
@@ -104,7 +242,6 @@ def do_POST(self) -> None:
self.send_response(200)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(body)))
- self.send_header("Access-Control-Allow-Origin", "*")
self.end_headers()
self.wfile.write(body)
return
@@ -125,7 +262,6 @@ def _event_stream(self) -> None:
self.send_response(200)
self.send_header("Content-Type", "text/event-stream")
self.send_header("Cache-Control", "no-cache")
- self.send_header("Access-Control-Allow-Origin", "*")
self.send_header("Connection", "keep-alive")
self.end_headers()
q = subscribe()
@@ -148,11 +284,26 @@ def _event_stream(self) -> None:
finally:
unsubscribe(q)
- def _mjpeg_stream(self) -> None:
+ def _camera_frame(self, source: str = "auto") -> None:
+ for p in _stream_paths(source):
+ try:
+ data = p.read_bytes()
+ except OSError:
+ continue
+ self.send_response(200)
+ self.send_header("Content-Type", "image/jpeg")
+ self.send_header("Content-Length", str(len(data)))
+ self.send_header("Cache-Control", "no-cache, no-store, must-revalidate")
+ self.end_headers()
+ self.wfile.write(data)
+ return
+ self.send_response(503)
+ self.end_headers()
+
+ def _mjpeg_stream(self, source: str = "auto") -> None:
self.send_response(200)
self.send_header("Content-Type", "multipart/x-mixed-replace; boundary=frame")
self.send_header("Cache-Control", "no-cache")
- self.send_header("Access-Control-Allow-Origin", "*")
self.end_headers()
try:
last_mtime = 0.0
@@ -162,7 +313,7 @@ def _mjpeg_stream(self) -> None:
except Exception:
pass
while time.monotonic() - started < 300:
- for p in _FRAME_PATHS:
+ for p in _stream_paths(source):
if p.exists():
mtime = p.stat().st_mtime
if mtime != last_mtime:
@@ -203,16 +354,77 @@ def _frame_recorder_loop() -> None:
time.sleep(0.1)
-def main() -> None:
- import sys
- if len(sys.argv) > 1 and sys.argv[1] == "skill":
- from roborun.skills.manager import cli
- raise SystemExit(cli(sys.argv[2:]))
- if len(sys.argv) > 1 and sys.argv[1] == "connect":
- from roborun.connect import cli
- raise SystemExit(cli(sys.argv[2:]))
- if not WEB_ROOT.exists():
- raise SystemExit(f"Missing web directory at {WEB_ROOT}")
+_HELP = """RoboRun — run robots, record everything, search it over time.
+
+ roborun start the server + UI (http://localhost:8765)
+ roborun run headless: drive behaviors, stream the loop to the terminal (no UI)
+ roborun tui full-screen terminal dashboard (live events · behaviors · vision)
+ roborun status is it running, what's connected, how much is recorded
+ roborun demo load sample data so the dashboards aren't empty
+ roborun ask "" tell the robot what to do in plain English (agent drives)
+ roborun stop EMERGENCY STOP — halt all actuators + disable behaviors
+ roborun connect connect a real robot (ROS 1 or ROS 2, via rosbridge)
+ roborun search find anything/anyone across every recorded run
+ roborun scenarios list / run scored behavior tests (run | suite )
+ roborun dataset curate a labeled training set from a search (sealed provenance)
+ roborun flag bookmark a moment in a run to revisit
+ roborun skill <...> install / manage skills from GitHub
+
+Open the cockpit, then its ▤ VIEWS menu for: search · scenarios · timeline · analytics · fleet."""
+
+
+def _run_autostart() -> None:
+ """First boot should be alive, not a NO SIGNAL screen: try the webcam with
+ YOLO; fall back to the MuJoCo sim. ROBORUN_AUTOSTART=0 disables it."""
+ from roborun.events import emit
+ time.sleep(2.0) # let a previous instance release the camera
+ # a connected/saved robot IS the camera source — never grab the laptop
+ # webcam (and its privacy light) out from under the user
+ try:
+ from roborun.connect import saved_robot
+ if saved_robot():
+ emit("system", "server", "autostart: a robot is the source — webcam left off")
+ return
+ except Exception:
+ pass
+ why: list[str] = []
+ try:
+ from roborun.routes._singletons import get_webcam
+ result = get_webcam().start(camera_index=0, models=["yolo"])
+ if result.get("ok"):
+ emit("system", "server", "autostart: webcam live with YOLO")
+ return
+ why.append(f"webcam: {result.get('error', 'failed')}")
+ except ImportError:
+ why.append("webcam vision not installed (pip install 'ros-agent[vision]')")
+ except Exception as exc:
+ why.append(f"webcam: {exc}")
+ try:
+ from roborun.routes._singletons import get_simulator
+ result = get_simulator().start()
+ if result.get("ok"):
+ emit("system", "server", f"autostart: MuJoCo sim ({result.get('robot', 'robot')})")
+ return
+ why.append(f"sim: {result.get('error', 'failed')}")
+ except ImportError:
+ why.append("MuJoCo sim not installed (pip install 'ros-agent[sim]')")
+ except Exception as exc:
+ why.append(f"sim: {exc}")
+ # A blank deck with no explanation reads as broken — say exactly what didn't
+ # start and point at the path that needs no installs.
+ emit("system", "server",
+ "no camera or sim started — " + "; ".join(why) +
+ ". Open the cockpit: browser sim, nothing to install.")
+
+
+def start_runtime(announce=print, autostart: bool = True) -> None:
+ """Boot the live robot runtime — telemetry WS, ROS bridge, trajectory
+ recorder, frame hashing, and behavior hot-reload — WITHOUT the HTTP server.
+
+ Shared by the web server (`roborun`) and the headless / TUI run modes
+ (`roborun run`, `roborun tui`), so the see/move/ask loop runs identically
+ with or without a browser. Workers are daemon threads; returns once started.
+ """
STATE_ROOT.mkdir(parents=True, exist_ok=True)
# A robot saved by `roborun connect` is the robot behaviors drive
@@ -222,24 +434,22 @@ def main() -> None:
from roborun.rosbridge import get_client
client = get_client(robot["host"], robot.get("port", 9090))
state = "connected" if client and client.is_connected else "unreachable — will retry"
- print(f" Robot {robot['host']} ({robot.get('type', '?')}): {state}")
+ announce(f" Robot {robot['host']} ({robot.get('type', '?')}): {state}")
if client and client.is_connected:
try:
from roborun.ros_camera import get_ros_camera
cam = get_ros_camera().start()
if cam.get("ok"):
- print(f" Robot camera: {cam['topic']} → YOLO → robot.see()")
+ announce(f" Robot camera: {cam['topic']} → YOLO → robot.see()")
except Exception:
pass
- # Load skills
from roborun.skills import load_skills
count = load_skills()
if count:
- print(f" Loaded {count} skill(s)")
+ announce(f" Loaded {count} skill(s)")
- recorder = threading.Thread(target=_frame_recorder_loop, daemon=True, name="FrameRecorder")
- recorder.start()
+ threading.Thread(target=_frame_recorder_loop, daemon=True, name="FrameRecorder").start()
from roborun.telemetry import start_ws_server
start_ws_server()
@@ -254,55 +464,83 @@ def main() -> None:
from roborun.behaviors import BehaviorRunner, write_examples
created = write_examples()
if created:
- print(f" Created {created}/ — edit follow_person.py and save. It reloads live.")
+ announce(f" Created {created}/ — edit follow_person.py and save. It reloads live.")
BehaviorRunner.get().start()
# Reach-a-human channel: forward notify events to an OpenClaw gateway
from roborun.openclaw import start_bridge
if start_bridge():
- print(f" OpenClaw bridge: notify() → {os.environ['OPENCLAW_HOOKS_URL']}")
-
- # First boot should be alive, not a NO SIGNAL screen: try the webcam
- # with YOLO; fall back to the MuJoCo sim. ROBORUN_AUTOSTART=0 disables.
- if os.environ.get("ROBORUN_AUTOSTART", "1") != "0":
- def _autostart() -> None:
- from roborun.events import emit
- time.sleep(2.0) # let a previous instance release the camera
- why: list[str] = []
- try:
- from roborun.routes._singletons import get_webcam
- result = get_webcam().start(camera_index=0, models=["yolo"])
- if result.get("ok"):
- emit("system", "server", "autostart: webcam live with YOLO")
- return
- why.append(f"webcam: {result.get('error', 'failed')}")
- except ImportError:
- why.append("webcam vision not installed (pip install 'ros-agent[vision]')")
- except Exception as exc:
- why.append(f"webcam: {exc}")
- try:
- from roborun.routes._singletons import get_simulator
- result = get_simulator().start()
- if result.get("ok"):
- emit("system", "server",
- f"autostart: MuJoCo sim ({result.get('robot', 'robot')})")
- return
- why.append(f"sim: {result.get('error', 'failed')}")
- except ImportError:
- why.append("MuJoCo sim not installed (pip install 'ros-agent[sim]')")
- except Exception as exc:
- why.append(f"sim: {exc}")
- # A blank deck with no explanation reads as broken — say exactly
- # what didn't start and point at the path that needs no installs.
- emit("system", "server",
- "no camera or sim started — " + "; ".join(why) +
- ". Open /arena: browser sim, nothing to install.")
- threading.Thread(target=_autostart, daemon=True, name="Autostart").start()
+ announce(f" OpenClaw bridge: notify() → {os.environ['OPENCLAW_HOOKS_URL']}")
+
+ if autostart and os.environ.get("ROBORUN_AUTOSTART", "1") != "0":
+ threading.Thread(target=_run_autostart, daemon=True, name="Autostart").start()
+
+
+def main() -> None:
+ import sys
+ if len(sys.argv) > 1 and sys.argv[1] in ("help", "--help", "-h"):
+ print(_HELP)
+ raise SystemExit(0)
+ if len(sys.argv) > 1 and sys.argv[1] == "skill":
+ from roborun.skills.manager import cli
+ raise SystemExit(cli(sys.argv[2:]))
+ if len(sys.argv) > 1 and sys.argv[1] == "connect":
+ from roborun.connect import cli
+ raise SystemExit(cli(sys.argv[2:]))
+ if len(sys.argv) > 1 and sys.argv[1] == "search":
+ from roborun.cli import search_cli
+ raise SystemExit(search_cli(sys.argv[2:]))
+ if len(sys.argv) > 1 and sys.argv[1] == "scenarios":
+ from roborun.cli import scenarios_cli
+ raise SystemExit(scenarios_cli(sys.argv[2:]))
+ if len(sys.argv) > 1 and sys.argv[1] == "dataset":
+ from roborun.cli import dataset_cli
+ raise SystemExit(dataset_cli(sys.argv[2:]))
+ if len(sys.argv) > 1 and sys.argv[1] == "demo":
+ from roborun.cli import demo_cli
+ raise SystemExit(demo_cli(sys.argv[2:]))
+ if len(sys.argv) > 1 and sys.argv[1] == "ask":
+ from roborun.cli import ask_cli
+ raise SystemExit(ask_cli(sys.argv[2:]))
+ if len(sys.argv) > 1 and sys.argv[1] == "status":
+ from roborun.cli import status_cli
+ raise SystemExit(status_cli(sys.argv[2:]))
+ if len(sys.argv) > 1 and sys.argv[1] == "stop":
+ from roborun.cli import stop_cli
+ raise SystemExit(stop_cli(sys.argv[2:]))
+ if len(sys.argv) > 1 and sys.argv[1] == "flag":
+ from roborun.incidents import flag, list_incidents
+ if len(sys.argv) > 2:
+ r = flag(sys.argv[2], note=" ".join(sys.argv[3:]))
+ print(f"flagged {r['id']} on {r['run_id']}")
+ else:
+ for i in list_incidents():
+ print(f" {i['run_id']} ⚑ {i.get('note') or i['tag']}")
+ raise SystemExit(0)
+ # Headless + TUI run modes — drive the see/move/ask loop with no web UI
+ if len(sys.argv) > 1 and sys.argv[1] in ("run", "headless"):
+ from roborun.tui import run_headless
+ raise SystemExit(run_headless(sys.argv[2:]))
+ if len(sys.argv) > 1 and sys.argv[1] == "tui":
+ from roborun.tui import run_tui
+ raise SystemExit(run_tui(sys.argv[2:]))
+ if not WEB_ROOT.exists():
+ raise SystemExit(f"Missing web directory at {WEB_ROOT}")
+
+ start_runtime(announce=print)
server = ThreadingHTTPServer((HOST, PORT), Handler)
print(f"\n RoboRun is live: http://{HOST}:{PORT}")
- print(f" Telemetry WS: ws://127.0.0.1:8766")
- print(f" MCP endpoint: http://{HOST}:{PORT}/mcp\n")
+ print(f" MCP endpoint: http://{HOST}:{PORT}/mcp · Telemetry WS: ws://127.0.0.1:8766")
+ # Quick-start: point new users at the dashboards + a one-command win.
+ try:
+ from roborun.spatial_memory import SpatialMemoryStore
+ if SpatialMemoryStore().stats().get("total", 0) == 0:
+ print(f"\n New here? Run roborun demo in another terminal to load sample data,")
+ print(f" then open http://{HOST}:{PORT}/search to find anything your robots have seen.")
+ except Exception:
+ pass
+ print(f"\n Views: /search · /scenarios · /timeline · /analytics · /run (or the cockpit's ▤ VIEWS menu)\n")
from roborun.events import emit
emit("system", "server", "roborun started", {"port": PORT})
try:
diff --git a/roborun/session.py b/roborun/session.py
new file mode 100644
index 0000000..c079742
--- /dev/null
+++ b/roborun/session.py
@@ -0,0 +1,240 @@
+"""PerceptionSession — the one system across sim / robot / production.
+
+Whatever the source (a sim camera, a robot's ROS camera, or a production webcam),
+the same loop runs: grab a frame → YOLO detections + CLIP embedding → record to the
+sealed MCAP → stream into the searchable index. The store accumulates across every
+run, so "find anyone/anything over time" is one query over all of history —
+semantic (CLIP) + label (YOLO) + a time window — regardless of which mode produced
+the data. This is the through-line the whole stack was built for.
+
+ sess = PerceptionSession.for_mode("production", source_id="lobby")
+ sess.start()
+ ...
+ search(sess.store, "person in red", since=yesterday) # across all runs/modes
+"""
+from __future__ import annotations
+
+import threading
+import time
+from typing import Any, Callable
+
+MODES = ("sim", "robot", "production")
+
+
+def default_embedder() -> Callable | None:
+ """A CLIP image embedder if vision is installed, else None (YOLO-only)."""
+ try:
+ from roborun.models import CLIPMatcher
+ clip = CLIPMatcher()
+ return lambda frame: clip.embed_image(frame)
+ except Exception:
+ return None
+
+
+def text_embedder() -> Callable | None:
+ try:
+ from roborun.models import CLIPMatcher
+ clip = CLIPMatcher()
+ return lambda text: clip.embed_text(text)
+ except Exception:
+ return None
+
+
+def open_source(mode: str, **kw: Any):
+ """Pick the frame source for a mode. All share the snapshot/get_detections
+ duck-type, so the session loop is identical across modes."""
+ if mode == "sim":
+ from roborun.synthetic_camera import SyntheticCamera
+ return SyntheticCamera(label=kw.get("label", "person"))
+ if mode == "robot":
+ from roborun.ros_camera import get_ros_camera
+ return get_ros_camera()
+ if mode == "production":
+ from roborun.routes._singletons import get_webcam
+ return get_webcam()
+ raise ValueError(f"mode must be one of {MODES}, got {mode!r}")
+
+
+class PerceptionSession:
+ def __init__(self, source: Any, store: Any, robot_id: str = "local",
+ mode: str = "production", source_id: str = "cam",
+ recorder: Any = None, embed_fn: Callable | None = None,
+ hz: float = 5.0, run_id: str | None = None) -> None:
+ self.source = source
+ self.store = store
+ self.robot_id = robot_id
+ self.mode = mode
+ self.source_id = source_id
+ self.recorder = recorder
+ self.embed_fn = embed_fn
+ self.run_id = run_id
+ self._dt = 1.0 / max(0.1, hz)
+ self._stop = threading.Event()
+ self._t: threading.Thread | None = None
+ self.indexed = 0
+ # so the live index tags observations with the real mode (sim/robot/production)
+ ext = getattr(recorder, "extractor", None)
+ if ext is not None:
+ ext.source = mode
+
+ @classmethod
+ def for_mode(cls, mode: str, store: Any = None, source_id: str = "cam",
+ recorder: Any = None, embed: bool = True, **kw: Any):
+ if store is None:
+ from roborun.spatial_memory import SpatialMemoryStore
+ store = SpatialMemoryStore()
+ return cls(open_source(mode, **kw), store, mode=mode, source_id=source_id,
+ recorder=recorder,
+ embed_fn=default_embedder() if embed else None,
+ run_id=getattr(recorder, "run_id", None))
+
+ def tick(self) -> bool:
+ """One frame through YOLO+CLIP → record + index. Returns True if a frame
+ was processed."""
+ frame = self.source.snapshot()
+ if frame is None:
+ return False
+ dets = self.source.get_detections()
+ emb = None
+ if self.embed_fn is not None:
+ try:
+ emb = self.embed_fn(frame)
+ except Exception:
+ emb = None
+ ts = time.time()
+ if self.recorder is not None:
+ import cv2
+ ok, buf = cv2.imencode(".jpg", frame, [cv2.IMWRITE_JPEG_QUALITY, 85])
+ if ok:
+ self.recorder.write_camera(buf.tobytes(), name=self.source_id, ts=ts)
+ if dets:
+ self.recorder.write_detections(dets, name=self.source_id, ts=ts)
+ if emb is not None:
+ self.recorder.write_clip(emb, frame_topic=f"/camera/{self.source_id}", ts=ts)
+ # streaming extractor (if attached) indexes it; else index directly
+ if getattr(self.recorder, "extractor", None) is None:
+ self._store(frame, emb, dets, ts)
+ else:
+ self._store(frame, emb, dets, ts)
+ self.indexed += 1
+ return True
+
+ def _store(self, frame, emb, dets, ts) -> None:
+ self.store.store(frame=frame, embedding=emb, detections=dets, ts=ts,
+ robot_id=self.robot_id, run_id=self.run_id,
+ source=self.mode, source_id=self.source_id)
+
+ def _loop(self) -> None:
+ while not self._stop.is_set():
+ try:
+ self.tick()
+ except Exception:
+ pass
+ self._stop.wait(self._dt)
+
+ def start(self) -> None:
+ try:
+ self.source.start()
+ except Exception:
+ pass
+ self._stop.clear()
+ self._t = threading.Thread(target=self._loop, daemon=True,
+ name=f"perception-{self.source_id}")
+ self._t.start()
+
+ def stop(self) -> None:
+ self._stop.set()
+ try:
+ self.source.stop()
+ except Exception:
+ pass
+
+
+def export_dataset(store: Any, query: Any, out_dir: str, by: str = "label",
+ k: int = 500, since: float | None = None,
+ until: float | None = None) -> dict:
+ """Curate a labeled dataset from a search (Foxglove 'curate datasets to train
+ models', on our sealed substrate): search the all-time index, pull each hit's
+ full frame from its MCAP, write images/ + labels.jsonl + dataset.json. Every
+ image traces back to a sealed run, so the dataset's provenance is verifiable."""
+ import json
+ import time
+ from pathlib import Path
+ from roborun.observations import get_frame
+ from roborun.run_series import _find_mcap
+
+ hits = search(store, query, by=by, k=k, since=since, until=until)
+ out = Path(out_dir)
+ (out / "images").mkdir(parents=True, exist_ok=True)
+ manifest = []
+ for i, h in enumerate(hits):
+ img = None
+ fr = h.get("frame_ref")
+ if fr and fr.get("run_id") and fr.get("topic") and fr.get("log_time"):
+ mcap = _find_mcap(fr["run_id"], h.get("robot_id"))
+ if mcap is not None:
+ img = get_frame(mcap, fr["topic"], int(fr["log_time"]))
+ if img is None and h.get("id"):
+ img = store.get_thumbnail(h["id"]) # fall back to the stored thumb
+ if img is None:
+ continue
+ name = f"{i:05d}.jpg"
+ (out / "images" / name).write_bytes(img)
+ manifest.append({"image": f"images/{name}",
+ "detections": h.get("detections"), "ts": h.get("ts"),
+ "robot_id": h.get("robot_id"), "source": h.get("source"),
+ "run_id": (fr or {}).get("run_id")})
+ (out / "labels.jsonl").write_text("\n".join(json.dumps(m) for m in manifest))
+ (out / "dataset.json").write_text(json.dumps(
+ {"query": str(query), "by": by, "count": len(manifest),
+ "created": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())}, indent=2))
+
+ # Provenance manifest — make the training set's integrity independently
+ # verifiable (the robot-data analogue of C2PA Content Credentials; matters as
+ # the EU AI Act + ISO push provenance toward a compliance requirement). Each
+ # image traces to a sealed, RFC-3161-timestamped run; the seal is the proof.
+ runs = {}
+ for m in manifest:
+ rid = m.get("run_id")
+ if not rid or rid in runs:
+ continue
+ mcap = _find_mcap(rid, m.get("robot_id"))
+ seal = mcap.with_suffix(".seal") if mcap is not None else None
+ if seal is not None and seal.exists():
+ try:
+ s = json.loads(seal.read_text())
+ runs[rid] = {"merkle_root": s.get("merkle_root"),
+ "sealed_at": s.get("sealed_at"),
+ "anchor": (s.get("anchor") or {}).get("status"),
+ "robot_id": s.get("robot_id")}
+ except Exception:
+ runs[rid] = {"merkle_root": None, "note": "unreadable seal"}
+ else:
+ runs[rid] = {"merkle_root": None, "note": "no seal (unsealed run)"}
+ sealed = sum(1 for r in runs.values() if r.get("merkle_root"))
+ (out / "provenance.json").write_text(json.dumps({
+ "schema": "roborun-dataset-provenance/1",
+ "images": len(manifest), "source_runs": len(runs),
+ "sealed_runs": sealed,
+ "verifiable": sealed == len(runs) and len(runs) > 0,
+ "note": "each image traces to a source run; sealed runs carry a Merkle root "
+ "+ RFC 3161 timestamp — verify with `python -m roborun.recorder verify`.",
+ "runs": runs,
+ }, indent=2))
+ return {"ok": True, "count": len(manifest), "dir": str(out),
+ "sealed_runs": sealed, "source_runs": len(runs)}
+
+
+def search(store: Any, query: Any, by: str = "clip", k: int = 10,
+ since: float | None = None, until: float | None = None,
+ robot_id: str | None = None, source_id: str | None = None) -> list[dict]:
+ """Find anything/anyone across ALL recorded history, in any mode.
+ by="clip" semantic (query: text → CLIP, or an embedding)
+ by="label" YOLO label · by="near" place · by="time" just a window
+ `since`/`until` (unix seconds) bound the time window — "who was here last week"."""
+ if by == "clip" and isinstance(query, str):
+ emb = text_embedder()
+ if emb is not None:
+ query = emb(query)
+ return store.recall(query, by=by, k=k, since=since, until=until,
+ robot_id=robot_id, source_id=source_id)
diff --git a/roborun/sim_backend.py b/roborun/sim_backend.py
new file mode 100644
index 0000000..e55097e
--- /dev/null
+++ b/roborun/sim_backend.py
@@ -0,0 +1,74 @@
+"""SimBackend — sense a running MuJoCo sim through the handle contract.
+
+Today `robot.move()` reaches the sim (`set_cmd_vel`) but `pose()/lidar()/see()`
+read only the arena or ROS — so a MuJoCo-only session is blind through the handle
+(LOCAL_SIM_SPEC gap). This backend closes that: it reads `MjData` directly and
+exposes the same schema the arena/ros_telemetry provide:
+
+ pose() -> {x, z, heading, y} handle frame (x fwd, z = -mujoco_y, CCW yaw)
+ lidar() -> 36 floats, metres, [0] dead ahead, CCW (mj_ray raycasts)
+ move(forward, strafe, turn, climb) -> set_cmd_vel on the runner
+ state() -> joints + sim_time (mirror of SimulatorRunner.get_telemetry)
+
+Pure read over `mujoco`; no rendering. Wrap the running `SimulatorRunner`.
+"""
+from __future__ import annotations
+
+import math
+from typing import Any
+
+HANDLE_SECTORS = 36
+
+
+class SimBackend:
+ def __init__(self, runner: Any) -> None:
+ self._runner = runner # SimulatorRunner
+
+ def is_active(self) -> bool:
+ return bool(getattr(self._runner, "is_running", False))
+
+ # ── primitives ────────────────────────────────────────────────────────
+ def pose(self) -> dict[str, float] | None:
+ d = getattr(self._runner, "_data", None)
+ if d is None or len(d.qpos) < 7:
+ return None
+ x, y, z = float(d.qpos[0]), float(d.qpos[1]), float(d.qpos[2])
+ w, qx, qy, qz = (float(v) for v in d.qpos[3:7])
+ heading = math.atan2(2 * (w * qz + qx * qy), 1 - 2 * (qy * qy + qz * qz))
+ # handle frame: x forward, z = -mujoco_y (CCW), y = height (aerial)
+ return {"x": x, "z": -y, "heading": heading, "y": z}
+
+ def lidar(self, max_range: float = 10.0, eye_h: float = 0.4) -> list[float]:
+ import mujoco
+ import numpy as np
+ m = getattr(self._runner, "_model", None)
+ d = getattr(self._runner, "_data", None)
+ if m is None or d is None:
+ return [max_range] * HANDLE_SECTORS
+ px, py, pz = float(d.qpos[0]), float(d.qpos[1]), eye_h
+ w, qx, qy, qz = (float(v) for v in d.qpos[3:7])
+ yaw = math.atan2(2 * (w * qz + qx * qy), 1 - 2 * (qy * qy + qz * qz))
+ out: list[float] = []
+ geomid = np.zeros(1, dtype=np.int32)
+ for k in range(HANDLE_SECTORS):
+ a = yaw + (2 * math.pi * k / HANDLE_SECTORS) # CCW, [0] dead ahead
+ vec = np.array([math.cos(a), math.sin(a), 0.0], dtype=np.float64)
+ pnt = np.array([px, py, pz], dtype=np.float64)
+ dist = mujoco.mj_ray(m, d, pnt, vec, None, 1, -1, geomid)
+ out.append(float(dist) if dist >= 0 else max_range)
+ return out
+
+ def see(self, label: str | None = None) -> list:
+ # MuJoCo scenes here have no labelled semantic objects; ground-truth
+ # `see()` arrives with the level/scene metadata (LOCAL_SIM Phase 1).
+ return []
+
+ def move(self, forward: float = 0.0, strafe: float = 0.0,
+ turn: float = 0.0, climb: float = 0.0) -> None:
+ self._runner.set_cmd_vel(forward, strafe, turn)
+
+ def state(self) -> dict[str, Any]:
+ try:
+ return self._runner.get_telemetry()
+ except Exception:
+ return {}
diff --git a/roborun/simulator.py b/roborun/simulator.py
index dbab0d7..1f28ec0 100644
--- a/roborun/simulator.py
+++ b/roborun/simulator.py
@@ -208,6 +208,8 @@ def __init__(self) -> None:
self._model: mujoco.MjModel | None = None
self._data: mujoco.MjData | None = None
self._policy: _Go1Policy | _G1Policy | None = None
+ # set on start(); init here so get_state() works on an idle runner
+ self._drone_ctrl: _DroneController | None = None
self._lock = RLock()
self._should_stop = Event()
self._should_reset = Event()
@@ -236,7 +238,8 @@ def list_robots(self) -> list[dict]:
})
return robots
- def start(self, robot_id: str = "unitree_go1", width: int = 960, height: int = 540) -> dict[str, Any]:
+ def start(self, robot_id: str = "unitree_go1", width: int = 960, height: int = 540,
+ render: bool = True) -> dict[str, Any]:
if self.is_running:
return {"ok": True, "already_running": True}
@@ -285,12 +288,14 @@ def start(self, robot_id: str = "unitree_go1", width: int = 960, height: int = 5
self._should_stop.clear()
self._state = "running"
+ self._render = render
self._thread = Thread(
- target=self._sim_loop, args=(width, height),
+ target=self._sim_loop, args=(width, height, render),
daemon=True, name="SimulatorRunner",
)
self._thread.start()
- return {"ok": True, "robot": robot_id, "resolution": f"{width}x{height}", "has_policy": self._policy is not None}
+ return {"ok": True, "robot": robot_id, "resolution": f"{width}x{height}",
+ "has_policy": self._policy is not None, "render": render}
def stop(self) -> dict[str, Any]:
self._should_stop.set()
@@ -375,16 +380,21 @@ def set_altitude(self, alt: float) -> dict[str, Any]:
return {"ok": True}
return {"ok": False, "error": "Not a drone"}
- def _sim_loop(self, width: int, height: int) -> None:
+ def _sim_loop(self, width: int, height: int, render: bool = True) -> None:
fps_window: list[float] = []
target_fps = 30.0
- renderer = mujoco.Renderer(self._model, height=height, width=width)
- camera = mujoco.MjvCamera()
- camera.type = mujoco.mjtCamera.mjCAMERA_TRACKING
- camera.trackbodyid = 0
- camera.distance = 3.0 if not self._drone_ctrl else 6.0
- camera.elevation = -20.0 if not self._drone_ctrl else -35.0
- camera.azimuth = 180.0
+ # Headless (render=False) skips the Renderer + per-step JPEG entirely —
+ # full physics SPS for training/scenarios (PERF #9 / LOCAL_SIM Phase 1).
+ renderer = None
+ camera = None
+ if render:
+ renderer = mujoco.Renderer(self._model, height=height, width=width)
+ camera = mujoco.MjvCamera()
+ camera.type = mujoco.mjtCamera.mjCAMERA_TRACKING
+ camera.trackbodyid = 0
+ camera.distance = 3.0 if not self._drone_ctrl else 6.0
+ camera.elevation = -20.0 if not self._drone_ctrl else -35.0
+ camera.azimuth = 180.0
telemetry_counter = 0
@@ -437,30 +447,31 @@ def _sim_loop(self, width: int, height: int) -> None:
mujoco.mj_step(self._model, self._data)
self._sim_time = self._data.time
- quat = self._data.qpos[3:7]
- w, x, y, z = quat
- yaw = np.arctan2(2 * (w * z + x * y), 1 - 2 * (y * y + z * z))
- camera.lookat[:] = self._data.qpos[0:3]
- camera.azimuth = 180.0 + np.degrees(yaw)
- renderer.update_scene(self._data, camera)
- frame_rgb = renderer.render().copy()
- frame_bgr = frame_rgb[:, :, ::-1]
-
- annotated = self._annotate(frame_bgr)
- ok, buf = cv2.imencode(".jpg", annotated, [cv2.IMWRITE_JPEG_QUALITY, 85])
- if ok:
- FRAME_PATH.write_bytes(buf.tobytes())
-
- # Depth rendering
- try:
- renderer.enable_depth_rendering(True)
+ if render:
+ quat = self._data.qpos[3:7]
+ w, x, y, z = quat
+ yaw = np.arctan2(2 * (w * z + x * y), 1 - 2 * (y * y + z * z))
+ camera.lookat[:] = self._data.qpos[0:3]
+ camera.azimuth = 180.0 + np.degrees(yaw)
renderer.update_scene(self._data, camera)
- depth = renderer.render().copy()
- renderer.enable_depth_rendering(False)
- from roborun.depth import DepthProcessor
- DepthProcessor.get().update(depth, frame_rgb)
- except Exception:
- pass
+ frame_rgb = renderer.render().copy()
+ frame_bgr = frame_rgb[:, :, ::-1]
+
+ annotated = self._annotate(frame_bgr)
+ ok, buf = cv2.imencode(".jpg", annotated, [cv2.IMWRITE_JPEG_QUALITY, 85])
+ if ok:
+ FRAME_PATH.write_bytes(buf.tobytes())
+
+ # Depth rendering
+ try:
+ renderer.enable_depth_rendering(True)
+ renderer.update_scene(self._data, camera)
+ depth = renderer.render().copy()
+ renderer.enable_depth_rendering(False)
+ from roborun.depth import DepthProcessor
+ DepthProcessor.get().update(depth, frame_rgb)
+ except Exception:
+ pass
# Push telemetry every ~5 frames (~6Hz)
telemetry_counter += 1
@@ -496,13 +507,15 @@ def _sim_loop(self, width: int, height: int) -> None:
fps_window.pop(0)
self._fps = 1.0 / (sum(fps_window) / len(fps_window)) if fps_window else 0
- sleep_dur = max(0, (1.0 / target_fps) - elapsed)
- if sleep_dur > 0:
- time.sleep(sleep_dur)
+ if render: # headless runs at full SPS, no frame-pacing
+ sleep_dur = max(0, (1.0 / target_fps) - elapsed)
+ if sleep_dur > 0:
+ time.sleep(sleep_dur)
except Exception:
pass
finally:
- renderer.close()
+ if renderer is not None:
+ renderer.close()
self._state = "idle"
def _annotate(self, frame: np.ndarray) -> np.ndarray:
diff --git a/roborun/sources.py b/roborun/sources.py
new file mode 100644
index 0000000..b863b26
--- /dev/null
+++ b/roborun/sources.py
@@ -0,0 +1,116 @@
+"""Source inventory: what can see and what can move, right now.
+
+One place that answers "what's available?" — the webcam, a connected
+robot's camera, and rosbridge servers discovered on the local network —
+so UIs offer sources instead of making the user remember ports. The LAN
+scan is a plain TCP probe of :9090 across the machine's /24: cheap,
+parallel, and exactly what `roborun connect ` would need anyway.
+"""
+from __future__ import annotations
+
+import socket
+import threading
+import time
+from typing import Any
+
+ROSBRIDGE_PORT = 9090
+_SCAN_TTL = 60.0 # results stay fresh this long
+_scan_lock = threading.Lock()
+_scan: dict[str, Any] = {"scanning": False, "found": [], "scanned_at": 0.0}
+
+
+def _local_subnet() -> list[str]:
+ """Hosts on this machine's /24 (best effort, no extra deps)."""
+ try:
+ s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
+ s.connect(("8.8.8.8", 80)) # no traffic sent; just routes
+ ip = s.getsockname()[0]
+ s.close()
+ except Exception:
+ return []
+ base = ip.rsplit(".", 1)[0]
+ return [f"{base}.{i}" for i in range(1, 255) if f"{base}.{i}" != ip]
+
+
+def _probe(host: str, port: int = ROSBRIDGE_PORT, timeout: float = 0.6) -> bool:
+ """True only if :port speaks the rosbridge websocket protocol — not just
+ any service that happens to listen on 9090. A plain TCP open lets through
+ every unrelated :9090 service (the source of confusing false positives);
+ the websocket handshake only completes against a real websocket server,
+ which is what rosbridge is."""
+ try:
+ with socket.create_connection((host, port), timeout=timeout):
+ pass
+ except Exception:
+ return False
+ try:
+ from websocket import create_connection
+ ws = create_connection(f"ws://{host}:{port}", timeout=timeout)
+ ws.close()
+ return True
+ except Exception:
+ return False
+
+
+def _scan_worker() -> None:
+ found: list[dict] = []
+ if _probe("127.0.0.1"):
+ found.append({"host": "127.0.0.1", "port": ROSBRIDGE_PORT, "local": True})
+ hosts = _local_subnet()
+ sem = threading.Semaphore(64)
+ lock = threading.Lock()
+
+ def check(h: str) -> None:
+ with sem:
+ if _probe(h):
+ with lock:
+ found.append({"host": h, "port": ROSBRIDGE_PORT, "local": False})
+
+ threads = [threading.Thread(target=check, args=(h,), daemon=True) for h in hosts]
+ for t in threads:
+ t.start()
+ for t in threads:
+ t.join(timeout=5.0)
+ with _scan_lock:
+ _scan.update(scanning=False, found=found, scanned_at=time.time())
+
+
+def network_scan(force: bool = False) -> dict[str, Any]:
+ """Cached LAN rosbridge inventory; kicks a background rescan when stale."""
+ with _scan_lock:
+ stale = time.time() - _scan["scanned_at"] > _SCAN_TTL
+ if (force or stale) and not _scan["scanning"]:
+ _scan["scanning"] = True
+ threading.Thread(target=_scan_worker, daemon=True).start()
+ return dict(_scan)
+
+
+def inventory() -> dict[str, Any]:
+ """Everything a source picker needs, without side effects: peeking
+ must never start a pipeline or open the camera."""
+ from roborun.routes import _singletons
+
+ wc = _singletons._webcam # peek — do not instantiate
+ webcam = {"available": True, "on": bool(wc and wc.is_running)}
+
+ robot: dict[str, Any] = {"connected": False}
+ try:
+ from roborun.rosbridge import get_client
+ client = get_client(auto_connect=False)
+ if client and client.is_connected:
+ from roborun.ros_camera import get_ros_camera
+ from roborun.ros_telemetry import get_bridge
+ cam = get_ros_camera().state()
+ b = get_bridge()
+ robot = {
+ "connected": True,
+ "host": client.health.get("host"),
+ "type": b.robot_type.value if b.robot_type else None,
+ "camera_topic": cam.get("topic"),
+ "camera_active": cam.get("active", False),
+ }
+ except Exception:
+ pass
+
+ return {"ok": True, "webcam": webcam, "robot": robot,
+ "network": network_scan()}
diff --git a/roborun/spatial.py b/roborun/spatial.py
new file mode 100644
index 0000000..6f73b7a
--- /dev/null
+++ b/roborun/spatial.py
@@ -0,0 +1,74 @@
+"""Spatial fusion — put every sensing into one environment frame (specs 05/09).
+
+Detections and clouds are captured at a sensor pose (a robot's odometry, or a
+fixed camera's registered extrinsics). These helpers transform local readings
+into the environment's world frame and build the geometry the payoff view draws
+(camera frusta). Pure math, no deps beyond stdlib — unit-testable.
+"""
+from __future__ import annotations
+
+import math
+from typing import Any
+
+
+def transform_point(local: dict, pose: dict) -> dict:
+ """A point in a sensor's local frame → the environment world frame, given the
+ sensor pose {x,y[,z],yaw}. 2D rotation about yaw + translation."""
+ yaw = float(pose.get("yaw", 0.0))
+ c, s = math.cos(yaw), math.sin(yaw)
+ lx, ly = float(local.get("x", 0.0)), float(local.get("y", 0.0))
+ return {"x": float(pose.get("x", 0.0)) + c * lx - s * ly,
+ "y": float(pose.get("y", 0.0)) + s * lx + c * ly,
+ "z": float(pose.get("z", 0.0)) + float(local.get("z", 0.0))}
+
+
+def sensor_pose(camera: dict | None, robot_pose: dict | None) -> dict:
+ """The world pose to anchor a sensor's detections (spec 05 P1):
+ a fixed camera uses its registered placement; a robot camera uses the robot's
+ live pose. Falls back to the origin."""
+ if camera and camera.get("kind") == "fixed" and camera.get("placement"):
+ return camera["placement"]
+ if robot_pose:
+ return robot_pose
+ return {"x": 0.0, "y": 0.0, "z": 0.0, "yaw": 0.0}
+
+
+def camera_frustum(placement: dict, fov: float = 1.2, length: float = 3.0) -> list[dict]:
+ """The two far corners + apex of a camera's view cone, in world coords — what
+ the payoff view draws to show where each camera looks (spec 09 P4)."""
+ yaw = float(placement.get("yaw", 0.0))
+ apex = {"x": float(placement.get("x", 0.0)), "y": float(placement.get("y", 0.0))}
+ left = transform_point({"x": length, "y": math.tan(fov / 2) * length}, placement)
+ right = transform_point({"x": length, "y": -math.tan(fov / 2) * length}, placement)
+ return [apex, {"x": left["x"], "y": left["y"]}, {"x": right["x"], "y": right["y"]}]
+
+
+def cluster_tracks(observations: list[dict], radius: float = 1.5) -> list[dict]:
+ """Promote per-frame detections to environment-level object tracks (spec 05
+ P3): merge same-label detections within `radius` (env-frame) into one track
+ with a running-mean centroid, count, and first/last seen. `observations` are
+ SpatialMemoryStore rows ({x,y,ts,detections[]})."""
+ tracks: list[dict[str, Any]] = []
+ for r in observations:
+ x, y, ts = r.get("x"), r.get("y"), (r.get("ts") or 0.0)
+ if x is None or y is None:
+ continue
+ for d in (r.get("detections") or []):
+ label = d.get("label") or "object"
+ hit = None
+ for t in tracks:
+ if t["label"] == label and math.hypot(t["x"] - x, t["y"] - y) <= radius:
+ hit = t
+ break
+ if hit:
+ hit["count"] += 1
+ n = hit["count"]
+ hit["x"] += (x - hit["x"]) / n
+ hit["y"] += (y - hit["y"]) / n
+ hit["last_ts"] = max(hit["last_ts"], ts)
+ hit["first_ts"] = min(hit["first_ts"], ts)
+ else:
+ tracks.append({"label": label, "x": float(x), "y": float(y),
+ "count": 1, "first_ts": ts, "last_ts": ts})
+ tracks.sort(key=lambda t: -t["count"])
+ return tracks
diff --git a/roborun/spatial_memory.py b/roborun/spatial_memory.py
index 460f1ed..545a8d5 100644
--- a/roborun/spatial_memory.py
+++ b/roborun/spatial_memory.py
@@ -25,6 +25,7 @@
from __future__ import annotations
import json
+import os
import sqlite3
import time
import uuid
@@ -35,8 +36,28 @@
if TYPE_CHECKING: # numpy only backs the CLIP paths; the SQLite index runs without it
import numpy as np
+def _default_db_path() -> Path:
+ """The searchable index lives alongside the runs, honoring ROBORUN_STATE_DIR
+ so sim/robot/production deployments can place all data where they want.
+
+ When a project/environment is active (platform spec 06/07), the index is
+ scoped under it — so search in one project never sees another's data. Legacy
+ flat path when nothing is selected."""
+ try:
+ from roborun import projects
+ dr = projects.data_root()
+ if dr is not None:
+ return dr / "spatial_memory.db"
+ except Exception:
+ pass
+ # Same root as the recorder (recorder.runs_root) so the searchable index and
+ # the MCAP runs live together — not split between CWD-relative and ~/.roborun.
+ base = os.environ.get("ROBORUN_STATE_DIR")
+ return (Path(base) if base else Path.home() / ".roborun") / "spatial_memory.db"
+
+
DB_DIR = Path(".roborun")
-DB_PATH = DB_DIR / "spatial_memory.db"
+DB_PATH = DB_DIR / "spatial_memory.db" # legacy default; __init__ uses _default_db_path()
THUMB_SIZE = (320, 240)
THUMB_QUALITY = 70
@@ -55,6 +76,7 @@
thumbnail BLOB,
embedding BLOB,
source TEXT,
+ source_id TEXT,
metadata TEXT
);
CREATE TABLE IF NOT EXISTS detections (
@@ -72,7 +94,7 @@
"""
_OBS_COLS = ("id, robot_id, run_id, ts, x, y, z, frame_id, frame_topic, "
- "frame_log_time, source, metadata")
+ "frame_log_time, source, source_id, metadata")
class SpatialMemoryStore:
@@ -84,7 +106,7 @@ def __init__(
s3_prefix: str = "roborun/memories/",
s3_endpoint: str | None = None,
) -> None:
- self._db_path = Path(db_path) if db_path else DB_PATH
+ self._db_path = Path(db_path) if db_path else _default_db_path()
self._db_path.parent.mkdir(parents=True, exist_ok=True)
self._lock = RLock()
self._conn = sqlite3.connect(str(self._db_path), check_same_thread=False)
@@ -92,9 +114,14 @@ def __init__(
self._conn.execute("PRAGMA journal_mode=WAL")
self._conn.executescript(_SCHEMA)
self._migrate_v1()
+ self._migrate_source_id()
self._conn.commit()
self._emb_cache: np.ndarray | None = None
self._id_cache: list[str] = []
+ # embeddings grouped by dimension: {dim: (ids, normalized_matrix)}. A
+ # query is only compared against vectors of its own dimension, so a mix
+ # of (e.g.) placeholder 3-d and real 512-d CLIP vectors never crashes.
+ self._emb_by_dim: dict[int, tuple[list[str], "np.ndarray"]] = {}
self._cache_dirty = True
self._s3 = None
@@ -110,6 +137,15 @@ def __init__(
except ImportError:
pass
+ def _migrate_source_id(self) -> None:
+ """Additively add the multi-camera source_id column to older dbs."""
+ cols = {r[1] for r in self._conn.execute("PRAGMA table_info(observations)")}
+ if "source_id" not in cols:
+ self._conn.execute("ALTER TABLE observations ADD COLUMN source_id TEXT")
+ # index created here (after the column is guaranteed to exist)
+ self._conn.execute("CREATE INDEX IF NOT EXISTS idx_obs_source "
+ "ON observations(source_id, ts DESC)")
+
def _migrate_v1(self) -> None:
"""Copy rows from the old `memories` table (detections as JSON) once."""
tables = {r[0] for r in self._conn.execute(
@@ -155,6 +191,7 @@ def store(
frame_topic: str | None = None,
frame_log_time: int | None = None,
source: str | None = None,
+ source_id: str | None = None,
thumbnail: bytes | None = None,
) -> str:
mid = str(uuid.uuid4())[:12]
@@ -186,12 +223,12 @@ def store(
with self._lock:
self._conn.execute(
"INSERT INTO observations (id, robot_id, run_id, ts, x, y, z, "
- "frame_id, frame_topic, frame_log_time, thumbnail, embedding, source, metadata) "
- "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
+ "frame_id, frame_topic, frame_log_time, thumbnail, embedding, source, source_id, metadata) "
+ "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
(mid, robot_id, run_id, ts or time.time(), x, y, z,
None, frame_topic, frame_log_time,
None if s3_stored else thumb_blob,
- emb_blob, source, meta_json),
+ emb_blob, source, source_id, meta_json),
)
for d in detections or []:
self._insert_detection(mid, d)
@@ -199,7 +236,76 @@ def store(
self._cache_dirty = True
return mid
- # ── CLIP (numpy cosine; embedded ANN is the documented later step) ────
+ # ── CLIP: numpy cosine by default, sqlite-vec ANN past a threshold ────
+
+ def _ann_available(self) -> bool:
+ try:
+ import sqlite_vec # noqa: F401
+ return True
+ except Exception:
+ return False
+
+ def _ann_threshold(self) -> int:
+ return int(os.environ.get("ROBORUN_ANN_THRESHOLD", "100000"))
+
+ def _ensure_vec_table(self, dim: int) -> bool:
+ """(Re)build the vec0 ANN index from observations (normalized → L2 on
+ unit vectors is monotonic with cosine). Returns True if usable."""
+ import numpy as np
+ import sqlite_vec
+ try:
+ self._conn.enable_load_extension(True)
+ sqlite_vec.load(self._conn)
+ self._conn.enable_load_extension(False)
+ except Exception:
+ return False
+ if getattr(self, "_vec_dim", None) != dim:
+ self._conn.execute("DROP TABLE IF EXISTS vec_obs")
+ self._conn.execute(
+ f"CREATE VIRTUAL TABLE vec_obs USING vec0(obs_id TEXT, emb float[{dim}])")
+ self._vec_dim = dim
+ self._vec_built = False
+ if not getattr(self, "_vec_built", False) or self._cache_dirty:
+ self._conn.execute("DELETE FROM vec_obs")
+ rows = self._conn.execute(
+ "SELECT id, embedding FROM observations WHERE embedding IS NOT NULL"
+ ).fetchall()
+ for r in rows:
+ v = np.frombuffer(r["embedding"], dtype=np.float32)
+ if v.shape[0] != dim:
+ continue
+ n = np.linalg.norm(v) or 1.0
+ self._conn.execute("INSERT INTO vec_obs(obs_id, emb) VALUES (?, ?)",
+ (r["id"], (v / n).astype(np.float32).tobytes()))
+ self._conn.commit()
+ self._vec_built = True
+ return True
+
+ def _search_clip_ann(self, qvec, top_k: int, robot_id: str | None) -> list[dict]:
+ import numpy as np
+ q = qvec.astype(np.float32).flatten()
+ dim = q.shape[0]
+ if not self._ensure_vec_table(dim):
+ raise RuntimeError("vec table unavailable")
+ n = np.linalg.norm(q) or 1.0
+ q = (q / n).astype(np.float32)
+ hits = self._conn.execute(
+ "SELECT obs_id, distance FROM vec_obs WHERE emb MATCH ? "
+ "ORDER BY distance LIMIT ?", (q.tobytes(), top_k * 3)).fetchall()
+ ids = [h["obs_id"] for h in hits]
+ rows = self._fetch_rows(ids)
+ dets = self._fetch_detections(ids)
+ out = []
+ for h in hits:
+ row = rows.get(h["obs_id"])
+ if row is None or (robot_id and row["robot_id"] != robot_id):
+ continue
+ # L2 on unit vectors d² = 2(1-cos) → cos = 1 - d²/2
+ out.append(self._row_to_dict(row, dets.get(row["id"], []),
+ score=float(1.0 - h["distance"] ** 2 / 2)))
+ if len(out) >= top_k:
+ break
+ return out
def _rebuild_cache(self) -> None:
rows = self._conn.execute(
@@ -211,25 +317,36 @@ def _rebuild_cache(self) -> None:
self._cache_dirty = False
return
import numpy as np
- ids, vecs = [], []
+ buckets: dict[int, tuple[list[str], list]] = {}
for r in rows:
+ v = np.frombuffer(r["embedding"], dtype=np.float32)
+ ids, vecs = buckets.setdefault(int(v.shape[0]), ([], []))
ids.append(r["id"])
- vecs.append(np.frombuffer(r["embedding"], dtype=np.float32))
- self._id_cache = ids
- self._emb_cache = np.vstack(vecs)
- norms = np.linalg.norm(self._emb_cache, axis=1, keepdims=True)
- norms[norms == 0] = 1
- self._emb_cache = self._emb_cache / norms
+ vecs.append(v)
+ self._emb_by_dim = {}
+ for dim, (ids, vecs) in buckets.items():
+ m = np.vstack(vecs)
+ norms = np.linalg.norm(m, axis=1, keepdims=True)
+ norms[norms == 0] = 1
+ self._emb_by_dim[dim] = (ids, m / norms)
self._cache_dirty = False
def search_clip(
self, query_embedding: np.ndarray, top_k: int = 10, robot_id: str | None = None
) -> list[dict]:
with self._lock:
+ # sqlite-vec ANN past the threshold; numpy is the guaranteed fallback.
+ count = self._conn.execute(
+ "SELECT COUNT(*) FROM observations WHERE embedding IS NOT NULL"
+ ).fetchone()[0]
+ if count >= self._ann_threshold() and self._ann_available():
+ try:
+ return self._search_clip_ann(query_embedding, top_k, robot_id)
+ except Exception:
+ pass # fall through to numpy
+
if self._cache_dirty:
self._rebuild_cache()
- if self._emb_cache is None or len(self._id_cache) == 0:
- return []
import numpy as np
qvec = query_embedding.astype(np.float32).flatten()
@@ -237,15 +354,21 @@ def search_clip(
if qnorm > 0:
qvec = qvec / qnorm
- scores = self._emb_cache @ qvec
+ # compare only against embeddings of the query's dimension
+ bucket = self._emb_by_dim.get(int(qvec.shape[0]))
+ if not bucket:
+ return []
+ id_cache, emb_cache = bucket
+
+ scores = emb_cache @ qvec
top_idx = np.argsort(scores)[::-1][:top_k * 3]
- candidates = [self._id_cache[i] for i in top_idx]
+ candidates = [id_cache[i] for i in top_idx]
rows = self._fetch_rows(candidates)
dets = self._fetch_detections(candidates)
results = []
for i in top_idx:
- row = rows.get(self._id_cache[i])
+ row = rows.get(id_cache[i])
if row is None:
continue
if robot_id and row["robot_id"] != robot_id:
@@ -288,6 +411,28 @@ def search_yolo(
matching_detections=matching))
return results
+ def search_uncertain(self, label: str | None = None, lo: float = 0.3,
+ hi: float = 0.6, top_k: int = 50,
+ robot_id: str | None = None) -> list[dict]:
+ """Active learning: observations whose detections sit in the uncertain
+ confidence band [lo, hi] — confident enough to be real, unsure enough to be
+ worth a human label. 'Curate first, annotate smarter.'"""
+ with self._lock:
+ where = ["d.score >= ?", "d.score <= ?"]
+ params: list[Any] = [lo, hi]
+ if label:
+ where.append("d.label = ?"); params.append(label.lower().strip())
+ if robot_id:
+ where.append("o.robot_id = ?"); params.append(robot_id)
+ sql = (f"SELECT DISTINCT {', '.join('o.' + c.strip() for c in _OBS_COLS.split(','))}, "
+ f"MIN(ABS(d.score - ?)) AS unc "
+ f"FROM observations o JOIN detections d ON d.obs_id = o.id "
+ f"WHERE {' AND '.join(where)} GROUP BY o.id ORDER BY unc ASC LIMIT ?")
+ mid = (lo + hi) / 2
+ rows = self._conn.execute(sql, [mid] + params + [top_k]).fetchall()
+ dets = self._fetch_detections([r["id"] for r in rows])
+ return [self._row_to_dict(r, dets.get(r["id"], [])) for r in rows]
+
def search_nearby(
self,
x: float,
@@ -318,6 +463,175 @@ def search_nearby(
return [self._row_to_dict(r, dets.get(r["id"], []),
distance=self._dist(r, x, y, z)) for r in rows]
+ # ── analytics aggregations (for the dashboard) ────────────────────────
+ def label_histogram(self, top: int = 20, since: float | None = None) -> list[dict]:
+ with self._lock:
+ sql = ("SELECT d.label, COUNT(*) c FROM detections d "
+ "JOIN observations o ON o.id = d.obs_id ")
+ params: list[Any] = []
+ if since is not None:
+ sql += "WHERE o.ts >= ? "
+ params.append(since)
+ sql += "GROUP BY d.label ORDER BY c DESC LIMIT ?"
+ params.append(top)
+ return [{"label": r[0], "count": r[1]}
+ for r in self._conn.execute(sql, params).fetchall()]
+
+ def counts_over_time(self, bucket_s: float = 3600.0,
+ buckets: int = 24) -> list[dict]:
+ with self._lock:
+ now = time.time()
+ start = now - bucket_s * buckets
+ rows = self._conn.execute(
+ "SELECT CAST((ts - ?) / ? AS INT) b, COUNT(*) c FROM observations "
+ "WHERE ts >= ? GROUP BY b ORDER BY b", (start, bucket_s, start)
+ ).fetchall()
+ by = {int(r[0]): r[1] for r in rows}
+ return [{"t": start + i * bucket_s, "count": by.get(i, 0)}
+ for i in range(buckets)]
+
+ def robots_breakdown(self) -> list[dict]:
+ """Per-robot fleet view: observation count, last-seen, top label."""
+ with self._lock:
+ rows = self._conn.execute(
+ "SELECT robot_id, COUNT(*) c, MAX(ts) last FROM observations "
+ "GROUP BY robot_id ORDER BY c DESC").fetchall()
+ out = []
+ for r in rows:
+ top = self._conn.execute(
+ "SELECT d.label FROM detections d JOIN observations o ON o.id=d.obs_id "
+ "WHERE o.robot_id=? GROUP BY d.label ORDER BY COUNT(*) DESC LIMIT 1",
+ (r[0],)).fetchone()
+ out.append({"robot_id": r[0], "observations": r[1],
+ "last_seen": r[2], "top_label": top[0] if top else None})
+ return out
+
+ def source_breakdown(self) -> list[dict]:
+ with self._lock:
+ rows = self._conn.execute(
+ "SELECT COALESCE(source,'?') s, COUNT(*) c FROM observations "
+ "GROUP BY s ORDER BY c DESC").fetchall()
+ return [{"source": r[0], "count": r[1]} for r in rows]
+
+ # ── time-range search ─────────────────────────────────────────────────
+ def search_time(self, since: float | None = None, until: float | None = None,
+ top_k: int = 20, robot_id: str | None = None) -> list[dict]:
+ with self._lock:
+ where, params = ["1=1"], [] # type: ignore[var-annotated]
+ if since is not None:
+ where.append("ts >= ?"); params.append(since)
+ if until is not None:
+ where.append("ts <= ?"); params.append(until)
+ if robot_id:
+ where.append("robot_id = ?"); params.append(robot_id)
+ sql = (f"SELECT {_OBS_COLS} FROM observations WHERE {' AND '.join(where)} "
+ f"ORDER BY ts DESC LIMIT ?")
+ params.append(top_k)
+ rows = self._conn.execute(sql, params).fetchall()
+ dets = self._fetch_detections([r["id"] for r in rows])
+ return [self._row_to_dict(r, dets.get(r["id"], [])) for r in rows]
+
+ # ── unified retrieval: "RAG for everything" (PERCEPTION_DATA_SPEC) ──────
+ def recall(self, query: Any = None, by: str = "clip", k: int = 10,
+ robot_id: str | None = None, source_id: str | None = None,
+ **kw: Any) -> list[dict]:
+ """One entry over every index the agent/behaviors call:
+ by="clip" → CLIP semantic (query: text str or np.ndarray embedding)
+ by="label" → indexed YOLO label (query: str)
+ by="near" → spatial (kw: x, y[, z, radius])
+ by="time" → time range (kw: since, until — unix seconds)
+ `source_id` filters to one camera angle. Returns Observation dicts
+ (frame_ref → pull full frame from MCAP)."""
+ by = (by or "clip").lower()
+ since, until = kw.pop("since", None), kw.pop("until", None)
+ # over-fetch when post-filtering (camera / time window) so k survive it
+ kk = k * 4 if (source_id or since is not None or until is not None) else k
+ if by == "label":
+ rows = self.search_yolo(str(query), top_k=kk, robot_id=robot_id)
+ elif by == "near":
+ rows = self.search_nearby(float(kw["x"]), float(kw["y"]),
+ kw.get("z"), float(kw.get("radius", 2.0)),
+ top_k=kk, robot_id=robot_id)
+ elif by == "time":
+ rows = self.search_time(kw.get("since"), kw.get("until"),
+ top_k=kk, robot_id=robot_id)
+ elif by == "uncertain":
+ rows = self.search_uncertain(label=(str(query) if query else None),
+ lo=float(kw.get("lo", 0.3)),
+ hi=float(kw.get("hi", 0.6)),
+ top_k=kk, robot_id=robot_id)
+ elif by == "clip":
+ import numpy as np
+ emb = query
+ if not isinstance(emb, np.ndarray):
+ from roborun.models import CLIPMatcher
+ emb = CLIPMatcher().embed_text(str(query))
+ rows = self.search_clip(emb, top_k=kk, robot_id=robot_id)
+ else:
+ raise ValueError(f"unknown recall mode {by!r} (clip|label|near|time)")
+ if since is not None:
+ rows = [r for r in rows if (r.get("ts") or 0) >= since]
+ if until is not None:
+ rows = [r for r in rows if (r.get("ts") or 0) <= until]
+ if source_id:
+ rows = [r for r in rows if r.get("source_id") == source_id]
+ return rows[:k]
+
+ def recall_combined(self, text: Any = None, label: str | None = None,
+ near: dict | None = None, since: float | None = None,
+ until: float | None = None, k: int = 10,
+ robot_id: str | None = None,
+ source_id: str | None = None) -> list[dict]:
+ """Unified retrieval (PERCEPTION_DATA_SPEC / platform spec 06 P2): combine
+ CLIP + label + spatial + time into ONE ranked result — "a person, near the
+ elevator, after 3pm." The strongest signal ranks; the rest AND-filter.
+ Scoped to the active project's index (the store path is per-project)."""
+ import math
+ kk = k * 8 # over-fetch; AND-filters thin the set
+ # base ranking: semantic > label > spatial > time
+ if text is not None and str(text).strip():
+ import numpy as np
+ emb = text
+ if not isinstance(emb, np.ndarray):
+ from roborun.models import CLIPMatcher
+ emb = CLIPMatcher().embed_text(str(text))
+ rows = self.search_clip(emb, top_k=kk, robot_id=robot_id)
+ elif label:
+ rows = self.search_yolo(str(label), top_k=kk, robot_id=robot_id)
+ elif near:
+ rows = self.search_nearby(float(near["x"]), float(near["y"]),
+ near.get("z"), float(near.get("radius", 2.0)),
+ top_k=kk, robot_id=robot_id)
+ else:
+ rows = self.search_time(since, until, top_k=kk, robot_id=robot_id)
+ # AND-filters (only applied when not already the base signal)
+ if label and (text is not None and str(text).strip()):
+ lab = str(label).lower()
+ rows = [r for r in rows
+ if any(lab in (d.get("label", "").lower())
+ for d in (r.get("detections") or []))]
+ if near and (text is not None or label):
+ x, y, rad = float(near["x"]), float(near["y"]), float(near.get("radius", 2.0))
+ rows = [r for r in rows
+ if r.get("x") is not None and r.get("y") is not None
+ and math.hypot(r["x"] - x, r["y"] - y) <= rad]
+ if since is not None:
+ rows = [r for r in rows if (r.get("ts") or 0) >= since]
+ if until is not None:
+ rows = [r for r in rows if (r.get("ts") or 0) <= until]
+ if source_id:
+ rows = [r for r in rows if r.get("source_id") == source_id]
+ return rows[:k]
+
+ def object_tracks(self, radius: float = 1.5, since: float | None = None,
+ robot_id: str | None = None, limit: int = 800) -> list[dict]:
+ """Environment-level object tracks (spec 05 P3): cluster recent detections
+ by label + env-frame proximity into things-seen-at-a-place. Scoped to the
+ active project's index."""
+ from roborun.spatial import cluster_tracks
+ rows = self.search_time(since=since, until=None, top_k=limit, robot_id=robot_id)
+ return cluster_tracks(rows, radius=radius)
+
def list_memories(
self, limit: int = 50, robot_id: str | None = None, since: float | None = None,
source: str | None = None, run_id: str | None = None,
@@ -465,6 +779,7 @@ def _row_to_dict(row: sqlite3.Row, detections: list[dict], **extra: Any) -> dict
"log_time": row["frame_log_time"]}
if row["frame_topic"] else None),
"source": row["source"],
+ "source_id": (row["source_id"] if "source_id" in row.keys() else None),
"metadata": json.loads(row["metadata"]) if row["metadata"] else {},
}
d.update(extra)
diff --git a/roborun/swarm/README.md b/roborun/swarm/README.md
new file mode 100644
index 0000000..bc17629
--- /dev/null
+++ b/roborun/swarm/README.md
@@ -0,0 +1,83 @@
+# Fleet communication demos
+
+One quadruped is the arena sandbox. A *fleet* of them is the hard part: how do
+robots split a job and share what they find when the radio between them is
+imperfect? This package is the runnable answer, and the exact twin of the live
+sandbox at **`/fleet`** (`roborun/web/fleet.js`).
+
+## The assumptions (the knobs)
+
+Every robot is limited in the three ways a real one is:
+
+| knob | what it means | sandbox label |
+|---|---|---|
+| **radio range** | two robots exchange a message only while within this many metres | *Radio range* |
+| **airtime** | messages a robot may send per second — its slice of a shared band | *Airtime* |
+| **onboard memory** | map cells it can remember (oldest forgotten first) | *Onboard memory* |
+| **inbox depth** | unread messages it queues while finishing its current move | *Inbox depth* |
+| **link reliability** | best-case delivery odds; messages drop toward the edge of range | *Link reliability* |
+
+Plus the rule that ties them together: a robot does **one thing at a time** — it
+commits to a goal cell and only re-tasks once it arrives.
+
+### Data points and the base station
+
+The swarm doesn't just map — it finds **data points** and relays them back to a
+**base station** (the "main server" / sink). A discovered datum isn't done when
+found, only when it reaches base. Robots route it there with **greedy geographic
+routing**: hand it to an in-range neighbour closer to base, deliver straight to
+base if it's in reach, or carry it like a **data mule** until a downhill peer
+appears. Short range forces multi-hop relays — the swarm acting as one antenna.
+
+```python
+run_episode("gossip", range_m=11, targets=10)["data_home"] # e.g. "10/10"
+run_episode("auction", base=False) # no sink, find-only
+```
+
+### Delivered vs. dropped, and overlap
+
+A message is **delivered** when it lands in a neighbour's inbox; it's **dropped**
+when the radio link failed (more likely near the edge of range) *or* the
+receiver's inbox was full. **Overlap** ("re-walks") counts cells a second robot
+re-mapped — wasted work that better coordination avoids. The live sandbox at
+`/fleet` shows all of this, plus a **hover-to-inspect** view of what any single
+robot knows (sensed firsthand vs. only heard over the radio), and a CONCEPTS tab
+that explains the libp2p/gossipsub and stigmergy ideas behind it.
+
+## The strategies (`strategies.py`)
+
+| id | name | idea |
+|---|---|---|
+| `independent` | Lone wolves | no radio; map your own nearest-unknown cell |
+| `gossip` | Gossip | broadcast what you just mapped; skip ground a neighbour has |
+| `auction` | Claim & yield | announce a claim on your next cell, yield to a closer robot |
+| `leader` | One commander | lowest-id robot in range hands out non-overlapping targets |
+
+## Run it
+
+```bash
+python -m roborun.swarm # compare all four, headless
+python -m roborun.swarm auction # one strategy
+```
+
+```python
+from roborun.swarm import run_episode
+run_episode("auction", count=8, range_m=10, memory=40)
+```
+
+## What you'll see
+
+Claim-and-yield finishes the map first: claiming *intent* (not just sharing the
+map) de-conflicts who explores where. Plain gossip can backfire — once robots
+agree on the same map they pick the same nearest frontier and clump. A single
+commander is tidy until a robot drifts out of its range and goes quiet. Shrink
+the radio range or starve memory/airtime and every strategy degrades — which is
+the whole point.
+
+## On a real robot
+
+These are framework-free on purpose so they run in CI, but the shape is the
+on-robot one: a strategy is a per-tick policy over a `radio` (send/recv with
+range + airtime caps) and a `goto`. Wire `comms.Fleet` to real
+rosbridge/DDS peers and the same four functions drive hardware — the same
+"the file is the product" contract the single-robot behaviours use.
diff --git a/roborun/swarm/__init__.py b/roborun/swarm/__init__.py
new file mode 100644
index 0000000..64c20ce
--- /dev/null
+++ b/roborun/swarm/__init__.py
@@ -0,0 +1,18 @@
+"""Fleet communication demos — coordinating many quadrupeds over an imperfect
+radio, the runnable twin of the in-browser sandbox at ``/fleet``.
+
+ from roborun.swarm import Fleet, STRATEGIES, run_episode
+
+The model lives in ``comms.py`` (radio range, airtime, onboard memory, one goal
+at a time); the four strategies live in ``strategies.py``. Run a headless
+episode with::
+
+ python -m roborun.swarm # compare all four strategies
+ python -m roborun.swarm gossip # just one, verbose
+"""
+from .comms import Fleet, Robot, Message, WORLD, CG, NCELL
+from .strategies import STRATEGIES
+from .runner import run_episode
+
+__all__ = ["Fleet", "Robot", "Message", "STRATEGIES", "run_episode",
+ "WORLD", "CG", "NCELL"]
diff --git a/roborun/swarm/__main__.py b/roborun/swarm/__main__.py
new file mode 100644
index 0000000..e2a4b6f
--- /dev/null
+++ b/roborun/swarm/__main__.py
@@ -0,0 +1,3 @@
+from .runner import main
+
+main()
diff --git a/roborun/swarm/comms.py b/roborun/swarm/comms.py
new file mode 100644
index 0000000..801fd7b
--- /dev/null
+++ b/roborun/swarm/comms.py
@@ -0,0 +1,334 @@
+"""The fleet communication model — the same assumptions the browser sandbox
+runs (roborun/web/fleet.js), as plain, runnable Python.
+
+Three things make multi-robot coordination hard, and all three are knobs here:
+
+* **radio range** — two robots can exchange a message only while within
+ ``range`` metres. Past it, they're on their own.
+* **airtime** — a robot may send at most ``airtime`` messages per second
+ (its slice of a shared band); the rest wait.
+* **onboard memory**— a robot remembers at most ``memory`` mapped cells and a
+ backlog of at most ``inbox`` unread messages; overflow is
+ dropped, not magically kept.
+
+And the robot can chase **one goal at a time** — it commits to a cell and only
+re-tasks once it arrives. A strategy is just a policy over this model; see
+``strategies.py``. This module is deliberately framework-free so it runs
+headless (``python -m roborun.swarm``) and in CI.
+"""
+from __future__ import annotations
+
+import math
+import random
+from dataclasses import dataclass, field
+
+WORLD = 56.0 # metres across the square field
+CG = 28 # coverage cells per side
+CELL = WORLD / CG
+SENSE = 3.2 # metres mapped around a robot as it walks
+NCELL = CG * CG
+
+
+def cell_xy(c: int) -> tuple[float, float]:
+ return (c % CG + 0.5) * CELL, (c // CG + 0.5) * CELL
+
+
+@dataclass
+class Message:
+ kind: str # "cells" | "claim" | "request" | "assign"
+ sender: int
+ payload: dict = field(default_factory=dict)
+
+
+@dataclass
+class Robot:
+ id: int
+ x: float
+ y: float
+ color: str = "#00d47e"
+ speed: float = 3.0
+ heading: float = math.pi / 2
+ goal: int = -1 # cell index, -1 = free
+ known: set = field(default_factory=set) # cells I believe are mapped (in memory now)
+ order: list = field(default_factory=list) # eviction order for memory
+ ever_known: set = field(default_factory=set) # lifetime record — never evicted
+ fresh: list = field(default_factory=list) # mapped since last broadcast
+ inbox: list = field(default_factory=list)
+ budget: float = 0.0
+ is_leader: bool = False
+ assigned: int = -1
+ sent: int = 0
+ payload: set = field(default_factory=set) # data points carried, not yet at base
+
+ # ── the robot's view of itself, used by the strategies ──────────────
+ def arrived(self) -> bool:
+ if self.goal < 0:
+ return True
+ gx, gy = cell_xy(self.goal)
+ return math.hypot(gx - self.x, gy - self.y) < CELL * 0.6
+
+ def just_mapped(self) -> list:
+ f, self.fresh = self.fresh, []
+ return f
+
+ def note_mapped(self, cells) -> None:
+ for c in (cells if hasattr(cells, "__iter__") else [cells]):
+ self._remember(c)
+
+ def _remember(self, c: int, fresh: bool = False) -> None:
+ self.ever_known.add(c) # lifetime record, never forgotten
+ if c in self.known:
+ return
+ self.known.add(c)
+ self.order.append(c)
+ if fresh:
+ self.fresh.append(c)
+
+ def forgotten(self) -> int:
+ """Cells it once knew but has since evicted because memory filled up."""
+ return len(self.ever_known) - len(self.known)
+
+
+class Fleet:
+ """Owns the field, the robots, and the imperfect radio between them."""
+
+ def __init__(self, count=6, range_m=14.0, airtime=4.0, memory=60,
+ inbox=8, reliability=0.85, seed=0, targets=8, base=True):
+ self.range = float(range_m)
+ self.airtime = float(airtime)
+ self.memory = int(memory)
+ self.inbox_cap = int(inbox)
+ self.reliability = float(reliability)
+ self.rng = random.Random(seed)
+ self.covered_by = [0] * NCELL # bitmask of robots that mapped cell
+ self.claims: dict[int, tuple[int, float]] = {} # cell -> (owner, dist)
+ self.metrics = dict(sent=0, recv=0, drop=0, redundant=0,
+ data_found=0, data_delivered=0)
+ self.t = 0.0
+ # the base station (the "main server") sits at one edge; robots deploy
+ # beside it and must relay discovered data back to it, hop by hop
+ self.base = bool(base)
+ self.base_xy = (WORLD * 0.5, WORLD * 0.055)
+ self.base_delivered: set = set()
+ self.targets = [] # data points to find: [x, y, found]
+ for _ in range(int(targets)):
+ self.targets.append([4 + self.rng.random() * (WORLD - 8),
+ WORLD * 0.25 + self.rng.random() * (WORLD * 0.7), False])
+ self.robots = []
+ for i in range(count):
+ x = WORLD * 0.5 + (self.rng.random() - 0.5) * 8
+ y = WORLD * 0.12 + (self.rng.random() - 0.5) * 6
+ self.robots.append(Robot(id=i, x=x, y=y))
+ self._sense()
+
+ # ── coverage / memory ──────────────────────────────────────────────
+ def _sense(self) -> None:
+ rad2 = SENSE * SENSE
+ for r in self.robots:
+ for c in self._cells_near(r.x, r.y, SENSE):
+ bit = 1 << r.id
+ if not (self.covered_by[c] & bit):
+ if self.covered_by[c]:
+ self.metrics["redundant"] += 1 # someone already had it
+ self.covered_by[c] |= bit
+ r._remember(c, fresh=True)
+ for tid, tg in enumerate(self.targets): # discover data points in range
+ if (tg[0] - r.x) ** 2 + (tg[1] - r.y) ** 2 <= rad2:
+ if not tg[2]:
+ tg[2] = True
+ self.metrics["data_found"] += 1
+ if self.base and tid not in self.base_delivered:
+ r.payload.add(tid)
+ self._evict(r)
+
+ def _evict(self, r: Robot) -> None:
+ while len(r.order) > self.memory: # finite memory forgets ground
+ old = r.order.pop(0)
+ r.known.discard(old)
+
+ @staticmethod
+ def _cells_near(x, y, rad):
+ r2 = rad * rad
+ gx0, gx1 = max(0, int((x - rad) / CELL)), min(CG - 1, int((x + rad) / CELL))
+ gy0, gy1 = max(0, int((y - rad) / CELL)), min(CG - 1, int((y + rad) / CELL))
+ out = []
+ for gy in range(gy0, gy1 + 1):
+ for gx in range(gx0, gx1 + 1):
+ c = gy * CG + gx
+ cxx, cyy = cell_xy(c)
+ if (cxx - x) ** 2 + (cyy - y) ** 2 <= r2:
+ out.append(c)
+ return out
+
+ # ── radio ──────────────────────────────────────────────────────────
+ def neighbors(self, r: Robot):
+ out = []
+ for o in self.robots:
+ if o is r:
+ continue
+ d = math.hypot(o.x - r.x, o.y - r.y)
+ if d <= self.range:
+ out.append((o, d))
+ return out
+
+ def _deliver(self, frm: Robot, to: Robot, msg: Message, d: float) -> None:
+ p = self.reliability * (1 - (d / self.range) ** 2) # lossy toward the edge
+ if self.rng.random() >= p:
+ self.metrics["drop"] += 1
+ return
+ if len(to.inbox) >= self.inbox_cap: # inbox overflow
+ self.metrics["drop"] += 1
+ return
+ to.inbox.append(msg)
+ self.metrics["recv"] += 1
+
+ def broadcast(self, r: Robot, kind: str, **payload) -> bool:
+ if r.budget < 1:
+ return False
+ r.budget -= 1
+ r.sent += 1
+ self.metrics["sent"] += 1
+ for o, d in self.neighbors(r):
+ self._deliver(r, o, Message(kind, r.id, payload), d)
+ return True
+
+ def unicast(self, r: Robot, to: Robot, kind: str, **payload) -> bool:
+ if r.budget < 1:
+ return False
+ r.budget -= 1
+ r.sent += 1
+ self.metrics["sent"] += 1
+ d = math.hypot(to.x - r.x, to.y - r.y)
+ if d <= self.range:
+ self._deliver(r, to, Message(kind, r.id, payload), d)
+ return True
+
+ def recv(self, r: Robot, kind: str | None = None):
+ """Drain the inbox (limited per tick by airtime — backlog overflows)."""
+ proc = max(2, math.ceil(self.airtime))
+ out, keep = [], []
+ for m in r.inbox[:proc]:
+ (out if (kind is None or m.kind == kind) else keep).append(m)
+ r.inbox = keep + r.inbox[proc:]
+ return out
+
+ # ── goal helpers used by the strategies ────────────────────────────
+ def nearest_unknown(self, r: Robot, avoid_claims=False) -> int:
+ best, bd = -1, 1e9
+ for c in range(NCELL):
+ if c in r.known:
+ continue
+ cxx, cyy = cell_xy(c)
+ d = math.hypot(cxx - r.x, cyy - r.y)
+ if avoid_claims:
+ cl = self.claims.get(c)
+ if cl and cl[0] != r.id and cl[1] <= d:
+ continue
+ if d < bd:
+ best, bd = c, d
+ if best < 0 and r.order:
+ best = r.order[self.rng.randrange(len(r.order))]
+ return best
+
+ def reserve(self, cell: int, owner: int, dist: float) -> None:
+ cur = self.claims.get(cell)
+ if cur is None or dist < cur[1]:
+ self.claims[cell] = (owner, dist)
+
+ def goto(self, r: Robot, cell: int) -> None:
+ r.goal = cell
+
+ def leader_in_range(self, r: Robot):
+ for o, _ in self.neighbors(r):
+ if o.is_leader:
+ return o
+ return None
+
+ # ── physics-free motion + leader election ──────────────────────────
+ def _elect_leaders(self) -> None:
+ parent = list(range(len(self.robots)))
+
+ def find(a):
+ while parent[a] != a:
+ parent[a] = parent[parent[a]]
+ a = parent[a]
+ return a
+
+ for r in self.robots:
+ for o in self.robots:
+ if o.id <= r.id:
+ continue
+ if math.hypot(o.x - r.x, o.y - r.y) <= self.range:
+ ra, rb = find(r.id), find(o.id)
+ if ra != rb:
+ parent[max(ra, rb)] = min(ra, rb)
+ for r in self.robots:
+ r.is_leader = find(r.id) == r.id
+
+ def _move(self, dt: float) -> None:
+ for r in self.robots:
+ if r.goal < 0:
+ continue
+ gx, gy = cell_xy(r.goal)
+ dx, dy = gx - r.x, gy - r.y
+ d = math.hypot(dx, dy)
+ if d < 1e-3:
+ continue
+ want = math.atan2(dy, dx)
+ dh = (want - r.heading + math.pi) % (2 * math.pi) - math.pi
+ r.heading += max(-3 * dt, min(3 * dt, dh))
+ step = min(d, r.speed * dt * max(0.2, math.cos(dh)))
+ r.x = min(WORLD - 0.4, max(0.4, r.x + math.cos(r.heading) * step))
+ r.y = min(WORLD - 0.4, max(0.4, r.y + math.sin(r.heading) * step))
+
+ def relay_data(self) -> None:
+ """Greedy geographic routing: each robot holding data hands it to an
+ in-range neighbour closer to base, delivers straight to base if it's in
+ range, or carries it like a data mule until a downhill peer appears."""
+ bx, by = self.base_xy
+
+ def dB(n) -> float:
+ return math.hypot(getattr(n, "x", bx) - bx, getattr(n, "y", by) - by)
+
+ for r in self.robots:
+ if not r.payload or r.budget < 1:
+ continue
+ if dB(r) <= self.range: # base in reach: upload
+ r.budget -= 1
+ r.sent += 1
+ self.metrics["sent"] += 1
+ for tid in list(r.payload):
+ if tid not in self.base_delivered:
+ self.base_delivered.add(tid)
+ self.metrics["data_delivered"] += 1
+ r.payload.clear()
+ continue
+ best, best_d = None, dB(r) # else hand off downhill
+ for o, _ in self.neighbors(r):
+ if dB(o) < best_d:
+ best, best_d = o, dB(o)
+ if best is not None:
+ r.budget -= 1
+ r.sent += 1
+ self.metrics["sent"] += 1
+ best.payload |= r.payload
+ r.payload.clear()
+
+ def coverage(self) -> float:
+ return sum(1 for m in self.covered_by if m) / NCELL
+
+ def step(self, dt: float, policy) -> None:
+ """Advance the world one tick: refill airtime, run the policy on every
+ robot, relay data toward base, move, then sense. ``policy(fleet, robot)``
+ is a strategy."""
+ for r in self.robots:
+ r.budget = min(self.airtime, r.budget + self.airtime * dt)
+ if getattr(policy, "needs_leaders", False):
+ self._elect_leaders()
+ for r in self.robots:
+ policy(self, r)
+ if self.base:
+ self.relay_data()
+ self._move(dt)
+ self._sense()
+ self.t += dt
diff --git a/roborun/swarm/runner.py b/roborun/swarm/runner.py
new file mode 100644
index 0000000..7739359
--- /dev/null
+++ b/roborun/swarm/runner.py
@@ -0,0 +1,60 @@
+"""Headless episode runner — drive a Fleet under a strategy until the field is
+mapped (or time runs out) and report the coverage / overlap / radio numbers.
+
+This is the demo: it shows, in plain numbers, what the sandbox shows visually —
+that better coordination trades airtime and message drops for less wasted
+re-walking, and that all of it degrades as the radio range shrinks.
+"""
+from __future__ import annotations
+
+from .comms import Fleet, NCELL
+from .strategies import STRATEGIES
+
+
+def run_episode(strategy: str, *, count=6, range_m=14.0, airtime=4.0, memory=60,
+ inbox=8, reliability=0.85, seed=0, targets=8, base=True,
+ dt=0.2, max_t=240.0) -> dict:
+ policy = STRATEGIES[strategy]
+ fleet = Fleet(count=count, range_m=range_m, airtime=airtime, memory=memory,
+ inbox=inbox, reliability=reliability, seed=seed,
+ targets=targets, base=base)
+ steps = int(max_t / dt)
+ for _ in range(steps):
+ fleet.step(dt, policy)
+ if fleet.coverage() >= 0.999 and len(fleet.base_delivered) >= len(fleet.targets):
+ break
+ m = fleet.metrics
+ return {
+ "strategy": strategy,
+ "time_s": round(fleet.t, 1),
+ "coverage": round(fleet.coverage() * 100, 1),
+ "redundant": m["redundant"],
+ "sent": m["sent"],
+ "delivered": m["recv"],
+ "dropped": m["drop"],
+ "loss_pct": round(m["drop"] / max(1, m["drop"] + m["recv"]) * 100, 1),
+ "data_home": f"{m['data_delivered']}/{len(fleet.targets)}",
+ }
+
+
+def main(argv: list[str] | None = None) -> None:
+ import sys
+ argv = sys.argv[1:] if argv is None else argv
+ which = [argv[0]] if argv and argv[0] in STRATEGIES else list(STRATEGIES)
+ print(f"{'strategy':<13} {'cover':>6} {'time':>7} {'re-walks':>9} "
+ f"{'sent':>6} {'deliv':>6} {'dropped':>8} {'loss':>6} {'data→base':>10}")
+ print("-" * 80)
+ for name in which:
+ r = run_episode(name)
+ print(f"{r['strategy']:<13} {r['coverage']:>5}% {r['time_s']:>6}s "
+ f"{r['redundant']:>9} {r['sent']:>6} {r['delivered']:>6} "
+ f"{r['dropped']:>8} {r['loss_pct']:>5}% {r['data_home']:>10}")
+ print("\nclaim-and-yield finishes first: claiming *intent* de-conflicts who "
+ "goes where. Sharing only the map (gossip) can backfire — robots agree "
+ "on the same nearest frontier and clump. A lone commander is tidy but "
+ "fragile once a robot drifts out of radio range. All of it degrades as "
+ "range shrinks and airtime/memory tighten — try it live at /fleet.")
+
+
+if __name__ == "__main__":
+ main()
diff --git a/roborun/swarm/strategies.py b/roborun/swarm/strategies.py
new file mode 100644
index 0000000..a7e5d1a
--- /dev/null
+++ b/roborun/swarm/strategies.py
@@ -0,0 +1,115 @@
+"""The four fleet coordination strategies, one function each.
+
+A strategy is a policy over the comms model in ``comms.py`` — exactly what the
+browser sandbox runs, and the shape a real on-robot behaviour would take once a
+``robot.radio`` is wired. Each is called once per robot per tick. They differ
+only in *what they say on the radio* and *how they choose the next cell*; the
+range / airtime / memory limits do the rest.
+
+These are the JS twins of the strategy cards in roborun/web/fleet.js — keep the
+two in sync if you change the coordination logic.
+"""
+from __future__ import annotations
+
+from .comms import Fleet, Robot, cell_xy
+import math
+
+
+def lone_wolves(fleet: Fleet, r: Robot) -> None:
+ """No radio at all. Map the nearest patch *you* haven't seen. Robust, but
+ two robots cheerfully re-walk the same ground — watch the overlap count."""
+ if r.arrived():
+ fleet.goto(r, fleet.nearest_unknown(r))
+
+
+def gossip(fleet: Fleet, r: Robot) -> None:
+ """Tell neighbours what you just mapped; believe what they tell you. Map
+ knowledge floods hop-by-hop, so robots stop chasing ground a neighbour
+ already covered. Bounded by airtime and range."""
+ for m in fleet.recv(r, "cells"):
+ r.note_mapped(m.payload["cells"])
+ fresh = r.just_mapped()
+ if fresh:
+ fleet.broadcast(r, "cells", cells=fresh[:12])
+ if r.arrived():
+ fleet.goto(r, fleet.nearest_unknown(r))
+
+
+def claim_and_yield(fleet: Fleet, r: Robot) -> None:
+ """A tiny market. Before committing to a cell, announce a claim with your
+ distance; if a closer robot already claimed it, yield and pick another. No
+ central server — just neighbours settling overlaps. Tightest coverage while
+ everyone stays linked."""
+ for m in fleet.recv(r):
+ if m.kind == "cells":
+ r.note_mapped(m.payload["cells"])
+ elif m.kind == "claim":
+ fleet.reserve(m.payload["cell"], m.sender, m.payload["dist"])
+ fresh = r.just_mapped()
+ if fresh:
+ fleet.broadcast(r, "cells", cells=fresh[:12])
+ if r.arrived():
+ c = fleet.nearest_unknown(r, avoid_claims=True)
+ if c >= 0:
+ cx, cy = cell_xy(c)
+ dist = math.hypot(cx - r.x, cy - r.y)
+ fleet.reserve(c, r.id, dist)
+ fleet.broadcast(r, "claim", cell=c, dist=dist)
+ fleet.goto(r, c)
+
+
+def one_commander(fleet: Fleet, r: Robot) -> None:
+ """The lowest-id robot in radio reach is the leader and hands out
+ non-overlapping targets. Centralised and tidy — but a robot that drifts out
+ of the leader's range gets no orders and falls back to lone-wolf."""
+ for m in fleet.recv(r):
+ if m.kind == "cells":
+ r.note_mapped(m.payload["cells"])
+ elif m.kind == "assign":
+ r.assigned = m.payload["cell"]
+ elif m.kind == "request" and r.is_leader:
+ c = _leader_pick(fleet, fleet.robots[m.sender])
+ if c >= 0:
+ fleet.reserve(c, m.sender, 0.0)
+ fleet.unicast(r, fleet.robots[m.sender], "assign", cell=c)
+ fresh = r.just_mapped()
+ if fresh:
+ fleet.broadcast(r, "cells", cells=fresh[:12])
+ if r.arrived():
+ if r.is_leader:
+ fleet.goto(r, _leader_pick(fleet, r))
+ else:
+ leader = fleet.leader_in_range(r)
+ if leader:
+ fleet.unicast(r, leader, "request")
+ tgt = r.assigned if (r.assigned >= 0 and r.assigned not in r.known) \
+ else fleet.nearest_unknown(r, avoid_claims=True)
+ fleet.goto(r, tgt)
+ else:
+ fleet.goto(r, fleet.nearest_unknown(r)) # orphaned → on my own
+
+
+one_commander.needs_leaders = True # tells Fleet.step to run leader election
+
+
+def _leader_pick(fleet: Fleet, requester: Robot) -> int:
+ best, bd = -1, 1e9
+ for c in range(len(fleet.covered_by)):
+ if c in requester.known:
+ continue
+ cl = fleet.claims.get(c)
+ if cl and cl[0] != requester.id:
+ continue
+ cx, cy = cell_xy(c)
+ d = math.hypot(cx - requester.x, cy - requester.y)
+ if d < bd:
+ best, bd = c, d
+ return best
+
+
+STRATEGIES = {
+ "independent": lone_wolves,
+ "gossip": gossip,
+ "auction": claim_and_yield,
+ "leader": one_commander,
+}
diff --git a/roborun/synthetic_camera.py b/roborun/synthetic_camera.py
new file mode 100644
index 0000000..880d5bb
--- /dev/null
+++ b/roborun/synthetic_camera.py
@@ -0,0 +1,90 @@
+"""Synthetic camera (PERCEPTION_DATA_SPEC) — a software camera with no hardware.
+
+A drop-in for `WebcamPipeline` (same `start/stop/snapshot/get_detections` surface)
+that renders a procedural scene with **sensor noise + lens distortion**, and emits
+ground-truth detections for the objects it drew. Lets the full multi-camera +
+perception + record path run with no physical camera *and* no model download —
+useful for CI, demos, and as a deterministic test source.
+"""
+from __future__ import annotations
+
+import threading
+import time
+from typing import Any
+
+import numpy as np
+
+
+class SyntheticCamera:
+ def __init__(self, width: int = 320, height: int = 240, fps: int = 15,
+ noise: float = 6.0, distortion: float = 0.0,
+ objects: list[dict] | None = None, label: str = "box") -> None:
+ self.w, self.h, self.fps = width, height, fps
+ self.noise, self.distortion = noise, distortion
+ self.label = label
+ # objects: [{label, w, h, speed}] moving left→right; default one box
+ self._objs = objects or [{"label": label, "w": 40, "h": 40, "speed": 4}]
+ self._frame: np.ndarray | None = None
+ self._dets: list[dict] = []
+ self._lock = threading.Lock()
+ self._stop = threading.Event()
+ self._t: threading.Thread | None = None
+ self._i = 0
+
+ def start(self, *a: Any, **kw: Any) -> dict:
+ self._stop.clear()
+ self._t = threading.Thread(target=self._loop, daemon=True)
+ self._t.start()
+ return {"ok": True, "synthetic": True, "resolution": f"{self.w}x{self.h}"}
+
+ def stop(self, *a: Any, **kw: Any) -> dict:
+ self._stop.set()
+ return {"ok": True}
+
+ def snapshot(self) -> np.ndarray | None:
+ with self._lock:
+ return None if self._frame is None else self._frame.copy()
+
+ def get_detections(self) -> list[dict]:
+ with self._lock:
+ return list(self._dets)
+
+ def get_clip_matches(self) -> list[dict]:
+ return []
+
+ def _render(self) -> tuple[np.ndarray, list[dict]]:
+ import cv2
+ img = np.full((self.h, self.w, 3), 28, np.uint8)
+ dets = []
+ for k, o in enumerate(self._objs):
+ x = (self._i * o["speed"]) % (self.w - o["w"])
+ y = self.h // 2 - o["h"] // 2
+ color = [(60, 200, 90), (200, 90, 60), (90, 120, 220)][k % 3]
+ cv2.rectangle(img, (x, y), (x + o["w"], y + o["h"]), color, -1)
+ dets.append({"label": o["label"], "score": 0.95,
+ "bbox": [x, y, x + o["w"], y + o["h"]]})
+ # lens distortion (barrel) then sensor noise — realism knobs
+ if self.distortion:
+ img = self._barrel(img, self.distortion)
+ if self.noise:
+ img = np.clip(img.astype(np.int16) +
+ np.random.normal(0, self.noise, img.shape).astype(np.int16),
+ 0, 255).astype(np.uint8)
+ return img, dets
+
+ @staticmethod
+ def _barrel(img: np.ndarray, k: float) -> np.ndarray:
+ import cv2
+ h, w = img.shape[:2]
+ cam = np.array([[w, 0, w / 2], [0, w, h / 2], [0, 0, 1]], np.float32)
+ dist = np.array([k, 0, 0, 0], np.float32)
+ return cv2.undistort(img, cam, dist)
+
+ def _loop(self) -> None:
+ dt = 1.0 / max(1, self.fps)
+ while not self._stop.is_set():
+ img, dets = self._render()
+ with self._lock:
+ self._frame, self._dets = img, dets
+ self._i += 1
+ time.sleep(dt)
diff --git a/roborun/transport/__init__.py b/roborun/transport/__init__.py
index 7e045d4..680daa6 100644
--- a/roborun/transport/__init__.py
+++ b/roborun/transport/__init__.py
@@ -46,6 +46,14 @@
"services": True, "actions": True, "params": True,
"types": "any (rclpy)",
},
+ "gazebo": {
+ # gz-sim publishes standard ROS 2 topics → sensing/actuation flow over
+ # the existing transports; gz adds deterministic clock control + spawn.
+ "discovery": True, "subscribe": True, "publish": True,
+ "services": True, "actions": True, "params": True,
+ "clock_control": True, "spawn": True,
+ "types": "any (ros_gz bridge)",
+ },
}
diff --git a/roborun/tui.py b/roborun/tui.py
new file mode 100644
index 0000000..c35e2e0
--- /dev/null
+++ b/roborun/tui.py
@@ -0,0 +1,184 @@
+"""Headless + terminal-UI run modes.
+
+`roborun run` (a.k.a. `headless`) boots the full robot runtime — telemetry,
+ROS bridge, recorder, and behavior hot-reload — with NO web UI, and streams the
+see/move/ask event loop to stdout. Zero extra dependencies.
+
+`roborun tui` renders the same runtime as a live full-screen dashboard
+(events · behaviors · vision). Needs `rich` (pip install 'ros-agent[tui]');
+falls back to a pointer at `roborun run` if it isn't installed.
+
+Both reuse roborun.server.start_runtime() and the roborun.events bus, so the
+robot runs identically with or without a browser pointed at it.
+"""
+from __future__ import annotations
+
+import queue as _queue
+import time as _t
+
+# Glyphs per event type (roborun.events.EVENT_TYPES). Web-side glyphs live in
+# deck.js; these are the terminal twins.
+_GLYPH = {
+ "mcp_tool": "🔧", "detection": "👁", "ros": "🤖", "agent": "🧠",
+ "system": "•", "task": "▶", "frame": "📷", "notify": "🔔",
+}
+
+
+def _fmt_event(evt: dict) -> str:
+ ts = _t.strftime("%H:%M:%S", _t.localtime(evt.get("ts", 0)))
+ g = _GLYPH.get(evt.get("type", ""), "·")
+ src = evt.get("source", "")
+ title = evt.get("title", "")
+ # dim timestamp, bold source — readable on any terminal, degrades to plain
+ return f" \033[2m{ts}\033[0m {g} \033[1m{src}\033[0m {title}"
+
+
+def run_headless(argv: list[str]) -> int:
+ """Drive behaviors with no web UI; stream live events to the terminal."""
+ from roborun.server import start_runtime
+ from roborun.events import subscribe, unsubscribe, recent
+
+ print("\n RoboRun — headless run (no web UI). Ctrl-C to stop.\n")
+ start_runtime(announce=print)
+ print("\n Telemetry WS: ws://127.0.0.1:8766 · behaviors hot-reload from ./behaviors/")
+ print(" Edit a behaviors/*.py file and save — the running policy changes live.\n")
+ print(" ── live events ───────────────────────────────────────────────\n")
+
+ q = subscribe()
+ try:
+ for evt in recent(15):
+ print(_fmt_event(evt))
+ while True:
+ try:
+ evt = q.get(timeout=1.0)
+ except _queue.Empty:
+ continue
+ print(_fmt_event(evt))
+ except KeyboardInterrupt:
+ print("\n Stopping.\n")
+ return 0
+ finally:
+ unsubscribe(q)
+
+
+def build_dashboard(log, accent: str = "green"):
+ """Build the full-screen dashboard Layout from the current event `log` plus
+ live behavior/vision state. Module-level (not closed over run_tui) so it is
+ unit-testable without a TTY. Requires `rich`."""
+ from rich.layout import Layout
+ from rich.panel import Panel
+ from rich.table import Table
+ from rich.text import Text
+
+ tbl = Table(expand=True, box=None, pad_edge=False)
+ tbl.add_column("behavior", style="bold")
+ tbl.add_column("runs", justify="right")
+ tbl.add_column("errs", justify="right")
+ tbl.add_column("", justify="right")
+ try:
+ from roborun.behaviors import BehaviorRunner
+ sts = BehaviorRunner.get().statuses()
+ except Exception:
+ sts = []
+ if not sts:
+ tbl.add_row("[dim]no behaviors loaded[/dim]", "", "", "")
+ for s in sts:
+ on = "[green]● on[/green]" if s.get("enabled") else "[dim]○ off[/dim]"
+ errs = s.get("errors", 0)
+ tbl.add_row(str(s.get("name", "")), str(s.get("runs", 0)),
+ f"[red]{errs}[/red]" if errs else "0", on)
+ behaviors = Panel(tbl, title="behaviors · ./behaviors/*.py (hot-reload)",
+ border_style=accent, title_align="left")
+
+ labels: dict[str, int] = {}
+ try:
+ from roborun.routes._singletons import get_webcam
+ for d in get_webcam().get_detections():
+ lb = d.get("label", "?")
+ labels[lb] = labels.get(lb, 0) + 1
+ except Exception:
+ pass
+ vbody = Text()
+ if labels:
+ for lb, n in sorted(labels.items(), key=lambda x: -x[1]):
+ vbody.append(f" {lb}", style="bold")
+ vbody.append(f" ×{n}\n", style="dim")
+ else:
+ vbody.append(" no detections — start a camera or sim\n", style="dim")
+ vision = Panel(vbody, title="vision · robot.see()", border_style=accent, title_align="left")
+
+ ebody = Text()
+ for evt in list(log)[-40:]:
+ ts = _t.strftime("%H:%M:%S", _t.localtime(evt.get("ts", 0)))
+ ebody.append(f"{ts} ", style="dim")
+ ebody.append(f"{_GLYPH.get(evt.get('type', ''), '·')} ")
+ ebody.append(f"{evt.get('source', '')} ", style="bold")
+ ebody.append(f"{evt.get('title', '')}\n")
+ events = Panel(ebody, title="live events · see / move / ask",
+ border_style=accent, title_align="left")
+
+ head = Text(
+ f" RoboRun headless · ws://127.0.0.1:8766 · {len(log)} events · Ctrl-C to quit",
+ style=f"bold {accent}")
+ layout = Layout()
+ layout.split_column(
+ Layout(Panel(head, border_style=accent), size=3, name="head"),
+ Layout(name="body"),
+ )
+ layout["body"].split_row(Layout(name="left"), Layout(name="right", ratio=2))
+ layout["left"].split_column(behaviors, vision)
+ layout["right"].update(events)
+ return layout
+
+
+def run_tui(argv: list[str]) -> int:
+ """Full-screen terminal dashboard over the same runtime."""
+ try:
+ from rich.live import Live
+ except ImportError:
+ print("\n The TUI dashboard needs `rich`: pip install 'ros-agent[tui]'")
+ print(" (or run roborun run for the no-deps headless event stream.)\n")
+ return 1
+
+ from collections import deque
+ from roborun.server import start_runtime
+ from roborun.events import subscribe, unsubscribe, recent
+
+ # announce into nothing — Live owns the screen; boot status shows up as events
+ start_runtime(announce=lambda m: None)
+ log: deque = deque(recent(60), maxlen=300)
+ q = subscribe()
+
+ with Live(build_dashboard(log), refresh_per_second=5, screen=True) as live:
+ try:
+ while True:
+ try:
+ while True:
+ log.append(q.get_nowait())
+ except _queue.Empty:
+ pass
+ live.update(build_dashboard(log))
+ _t.sleep(0.2)
+ except KeyboardInterrupt:
+ pass
+ finally:
+ unsubscribe(q)
+ return 0
+
+
+def demo() -> None:
+ """Self-check: the dashboard renders to a string buffer without a TTY."""
+ import io
+ from collections import deque
+ from rich.console import Console
+ log = deque([{"type": "system", "source": "server", "title": "roborun started", "ts": _t.time()},
+ {"type": "detection", "source": "camera", "title": "person ×1", "ts": _t.time()}])
+ out = io.StringIO()
+ Console(file=out, width=120, height=30).print(build_dashboard(log))
+ text = out.getvalue()
+ assert "behaviors" in text and "live events" in text and "roborun started" in text, text[:400]
+ print("tui.demo OK — dashboard renders")
+
+
+if __name__ == "__main__":
+ demo()
diff --git a/roborun/video.py b/roborun/video.py
new file mode 100644
index 0000000..4cd05ad
--- /dev/null
+++ b/roborun/video.py
@@ -0,0 +1,47 @@
+"""H.264 encoding for the camera MCAP channel (RECORD_EVERYTHING/PERF #5).
+
+Per-frame JPEG is 10–50× larger than a codec stream. This wraps PyAV's libx264
+into a tiny push API that yields encoded packets, so the recorder can write a
+`foxglove.CompressedVideo` channel instead of `CompressedImage`. Optional dep
+(`av`); callers fall back to JPEG when it's absent.
+"""
+from __future__ import annotations
+
+from fractions import Fraction
+from typing import Iterator
+
+
+def available() -> bool:
+ try:
+ import av # noqa: F401
+ return True
+ except Exception:
+ return False
+
+
+class H264Encoder:
+ """Push BGR frames, get H.264 packet bytes. `flush()` drains the tail."""
+
+ def __init__(self, width: int, height: int, fps: int = 30) -> None:
+ import av
+ self._cc = av.CodecContext.create("libx264", "w")
+ self._cc.width = width
+ self._cc.height = height
+ self._cc.pix_fmt = "yuv420p"
+ self._cc.time_base = Fraction(1, fps)
+ self._cc.options = {"preset": "veryfast", "tune": "zerolatency",
+ "g": str(fps * 2)} # ~2s GOP for excerptable clips
+ self._av = av
+ self._pts = 0
+ self.width, self.height = width, height
+
+ def add(self, frame_bgr) -> Iterator[bytes]:
+ f = self._av.VideoFrame.from_ndarray(frame_bgr, format="bgr24")
+ f.pts = self._pts
+ self._pts += 1
+ for pkt in self._cc.encode(f):
+ yield bytes(pkt)
+
+ def flush(self) -> Iterator[bytes]:
+ for pkt in self._cc.encode(): # None flushes
+ yield bytes(pkt)
diff --git a/roborun/vla.py b/roborun/vla.py
new file mode 100644
index 0000000..1a60b14
--- /dev/null
+++ b/roborun/vla.py
@@ -0,0 +1,67 @@
+"""VLA adapter — make robot foundation models (GR00T, OpenVLA, RT-X) native.
+
+A VLA maps (image, instruction) → action. This wires any such model into the
+handle so it runs like any behavior, against the same safety-clamped `move()`,
+recorded into the same sealed run. The *model* is pluggable and optional (you
+bring GR00T/OpenVLA via HF, or a stub) — RoboRun bundles only the thin adapter.
+
+ from roborun.vla import register_vla, VLAPolicy
+ register_vla("groot", lambda jpeg, instr: {...action...}) # your model
+
+ @behavior(hz=5)
+ def vla_drive(robot):
+ VLAPolicy("groot").step(robot, "go to the charging dock")
+"""
+from __future__ import annotations
+
+from typing import Any, Callable
+
+# action keys the adapter maps onto robot.move(); a VLA may emit a subset
+_ACTION_KEYS = ("forward", "strafe", "turn", "climb")
+
+_REGISTRY: dict[str, Callable[[bytes, str], dict]] = {}
+
+
+def register_vla(name: str, model_fn: Callable[[bytes, str], dict]) -> None:
+ """Register a VLA: `model_fn(image_jpeg, instruction) -> {forward,strafe,
+ turn,climb}` (any subset, each in [-1,1]). The model is the caller's."""
+ _REGISTRY[name] = model_fn
+
+
+def list_vla() -> list[str]:
+ return sorted(_REGISTRY)
+
+
+def load_vla(name: str) -> Callable[[bytes, str], dict]:
+ """Resolve a registered VLA, or try a known loader (GR00T/OpenVLA via HF if
+ installed). Raises with a clear, actionable message otherwise."""
+ if name in _REGISTRY:
+ return _REGISTRY[name]
+ raise RuntimeError(
+ f"VLA {name!r} not registered. register_vla({name!r}, fn) with a model "
+ f"that maps (jpeg, instruction)->action — e.g. GR00T N1 or OpenVLA from "
+ f"HuggingFace. RoboRun ships the adapter, not the 3B weights.")
+
+
+class VLAPolicy:
+ """Drives the robot from a VLA, one inference per tick. Actuation passes the
+ same clamps + estop as everything else; nothing bypasses safety."""
+
+ def __init__(self, model: str | Callable[[bytes, str], dict]) -> None:
+ self._fn = model if callable(model) else load_vla(model)
+
+ def infer(self, image_jpeg: bytes, instruction: str) -> dict:
+ action = self._fn(image_jpeg, instruction) or {}
+ return {k: float(action.get(k, 0.0)) for k in _ACTION_KEYS}
+
+ def step(self, robot: Any, instruction: str) -> dict:
+ """Grab the current frame, infer, and move. Returns the action taken
+ (or a no-op {} if there's no frame yet)."""
+ jpeg = robot.frame_jpeg() if hasattr(robot, "frame_jpeg") else None
+ if not jpeg:
+ robot.stop()
+ return {}
+ a = self.infer(jpeg, instruction)
+ robot.move(forward=a["forward"], strafe=a["strafe"],
+ turn=a["turn"], climb=a["climb"])
+ return a
diff --git a/roborun/web/analytics.html b/roborun/web/analytics.html
new file mode 100644
index 0000000..c758071
--- /dev/null
+++ b/roborun/web/analytics.html
@@ -0,0 +1,154 @@
+
+
+
+
+
+
+RoboRun · Analytics
+
+
+
+
+
+
+ A live overview of everything your robots have seen and done — what they detected, how often, which robots are active, and how your behaviors score. Updates every few seconds.
+
+
+ Detections by label (all time)
+ Observations · last 24h
+ Suite pass-rate
+ Data by source (sim / robot / production)
+ Fleet activity
+
+
+
+
+
diff --git a/roborun/web/arena.html b/roborun/web/arena.html
index 0baa50d..706f8ad 100644
--- a/roborun/web/arena.html
+++ b/roborun/web/arena.html
@@ -1,30 +1,31 @@
+
RoboRun Arena
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ◈ROBOT
+ LIVE
+ —
+
+ ALT—
+ SPEED—
+ HDG—
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ SOURCE — WHAT YOU'RE CONTROLLING✕
+
+ a robot on your wifi appears here automatically
+
+
+
+
+ TACTICALlidar · trail
+
+
+
+
+ BEHAVIOR—⤢✕
+
+
+
+
+ —
+
+
+
+
+ TIMELINElive decisions & sightings
+
+
+
+
+
+
+
+
+ ⚠ Deploy to the connected robot?
+
+
+
+
+
+
+
+
+
+
+← Dashboard
+
@@ -211,6 +581,8 @@
+
+
+
MISSION✕
@@ -249,17 +642,28 @@
STATUS✕
- ⏱ 0.0s
+ ⏱ 0.0s
behaviors: searching…
-
- cmd —
- room —
- odometer 0.0 m
- x 0.0 · z 0.0
+
+
+ cmd—
+ room—
+ odometer0.0 m
+ pose—
+
+
+
+
+
+ SPEEDm/s · last 14s✕
+
+
+
+ now0.0 m/s
+ peak0.0 m/s
+
@@ -291,24 +695,24 @@
+
+
+
- ROBORUN ARENA
- Code a robot in Python, right here. Beat the chamber.
- The same file drives a real robot.
-
- 01 · WRITEa ~10-line policy against pose / lidar / see / move —
- or just press RUN, a starter is loaded
- 02 · WATCHit drives the robot at 10 Hz through real physics —
- camera, lidar cloud, live map
- 03 · SHIProborun connect <robot-ip> runs the same file
- on a Go2, a drone, anything ROS
-
- Pick a robot, pick a task.
- Grab the wheel anytime with WASD (counts as practice, not autonomous).
+ PICK A ROBOT & TASK
+ Swap the sim level. Grab the wheel anytime with WASD.
+ Want to set up a project, connect a real robot, or run a fleet?
+ open Setup → · ← Dashboard
- your policy is real python on a real robot handle —
- the same code drives ROS 1/2 robots · every run is a merkle-sealed recording
diff --git a/roborun/web/arena.js b/roborun/web/arena.js
index 73175cf..90ec1f6 100644
--- a/roborun/web/arena.js
+++ b/roborun/web/arena.js
@@ -9,6 +9,25 @@
import * as THREE from "three";
import { initPhysics, createWorld } from "./physics.js";
+// Embedded in Studio? Strip chrome that points back at the old standalone pages
+// (Studio owns navigation / setup / fleet as its own tabs). Standalone /sim keeps it.
+if (window.self !== window.top) {
+ const strip = () => {
+ // Studio owns navigation — drop the arena's standalone nav chrome, keep its
+ // real sim controls (LEVELS / camera / CONNECT AGENT / reset / REC).
+ ["ck-home", "ck-views", "tb-views", "projChip"].forEach((id) =>
+ document.getElementById(id)?.remove());
+ document.querySelectorAll('a[href="/setup"], .server-only').forEach((el) => el.remove());
+ // the picker subtitle references old standalone pages — match it by content
+ document.querySelectorAll(".sub").forEach((s) => {
+ if (/set up a project|run a fleet|open Setup/i.test(s.textContent || ""))
+ s.innerHTML = "Swap the sim level. Grab the wheel anytime with WASD.";
+ });
+ };
+ if (document.readyState === "loading") document.addEventListener("DOMContentLoaded", strip);
+ else strip();
+}
+
/* ════════════════ levels ════════════════ */
const COLORS = { red: 0xd84a4a, blue: 0x4a7ad8, green: 0x44b86a,
yellow: 0xd8b54a, purple: 0x9a5ad8 };
@@ -723,7 +742,8 @@ function senseDetections() {
bbox: [cx - size / 4, 360 - size / 2, cx + size / 4, 360 + size / 2],
distance: +dist.toFixed(2) });
dets.push({ p, dist, ppos });
- if (!p.seen) { p.seen = true; postEvent("detection", `sighted: ${p.label}`, {}); }
+ if (!p.seen) { p.seen = true; postEvent("detection", `sighted: ${p.label}`, {});
+ simEvent("yolo", `sighted ${p.label} · ${dist.toFixed(1)}m`); }
}
currentDets = dets;
if (bot.type === "dog" || bot.type === "biped") {
@@ -915,8 +935,26 @@ function loadLevel(i) {
else if (w.type === "rings") setChips(
w.order.map((id) => ({ id: `r${id}`, label: `ring ${id + 1}`, done: false })));
postEvent("arena", `level loaded: ${LV.name}`, { robot: LV.robot });
+ if (MODE === "robot" && levelGroup) levelGroup.visible = false;
+ // Route: /sim?level= — a path+query route, consistent with /run?id=
+ // (NOT a #hash). Makes levels bookmarkable and the browser BACK button work.
+ if (!window._navFromHistory && MODE !== "wasm") {
+ try {
+ const cur = new URLSearchParams(location.search).get("level");
+ if (cur !== LV.name)
+ history.pushState({ level: levelIndex }, "", "/sim?level=" + encodeURIComponent(LV.name));
+ } catch (_) {}
+ }
}
levelSel.addEventListener("change", () => loadLevel(+levelSel.value));
+window.addEventListener("popstate", () => {
+ const nm = new URLSearchParams(location.search).get("level");
+ const idx = nm ? LEVELS.findIndex((l) => l.name === nm) : -1;
+ if (idx >= 0 && idx !== levelIndex) {
+ window._navFromHistory = true;
+ try { loadLevel(idx); } finally { window._navFromHistory = false; }
+ }
+});
function winChamber(detail) {
won = true;
@@ -1250,16 +1288,777 @@ async function api(path, body) {
let MODE = "detect"; // "server" | "wasm"
let wasmRT = null, wasmLoading = false;
async function detectMode() {
+ // resolve runtime discovery (sets window.ROBORUN_RUNTIME) before deciding
+ try { await fetch("/api/health"); } catch {}
+ const rt = window.ROBORUN_RUNTIME || {};
+ const iAmRuntime = rt.live && !rt.remote; // this page IS the roborun server
+ const pinned = localStorage.getItem("roborun.source");
+
+ // The menu is the front door: a freshly opened page ALWAYS lands on the
+ // selector, even when a robot is already connected (it just shows up as a
+ // live card you can pick). We only skip straight into a robot when the user
+ // explicitly pinned one before — a deliberate, sticky choice, never the default.
+ if (iAmRuntime && pinned === "robot") {
+ try {
+ const ctl = new AbortController();
+ const t = setTimeout(() => ctl.abort(), 1500);
+ const r = await fetch("/api/ros/health", { signal: ctl.signal });
+ clearTimeout(t);
+ if (r.ok && (await r.json()).connected) { enterRobotMode(); return; }
+ } catch {}
+ }
+
+ // otherwise: determine the sim backend now (server vs in-browser wasm) so a
+ // menu pick enters instantly, then show the ROBORUN ARENA menu (the launcher)
try {
const ctl = new AbortController();
const t = setTimeout(() => ctl.abort(), 1500);
const r = await fetch("/api/arena/cmd", { signal: ctl.signal });
clearTimeout(t);
- if (r.ok) { MODE = "server"; return; }
+ if (r.ok) MODE = "server";
+ } catch {}
+ if (MODE !== "server") {
+ MODE = "wasm"; document.body.classList.add("wasm-mode"); bootWasm();
+ }
+ if (pinned === "sim") { enterSimCockpit(); return; } // user pinned the sim
+ // came from Setup with a chosen level (/sim?level=…)? it's already loaded —
+ // don't make the user pick a robot & task all over again.
+ if (!new URLSearchParams(location.search).get("level")) showStart();
+}
+
+/* ── robot mode: same arena, the world is the robot's telemetry ─────────
+ Pose, lidar cloud, and detections come from the connected robot via the
+ runtime (SIM_SPEC handle contract: {x, z, heading} + 36-sector scan).
+ The level disappears, the accumulated point cloud *is* the map, the
+ EYES panel shows the camera frames robot.see() runs YOLO on, and WASD
+ publishes real cmd_vel. Sim → robot without changing pages. */
+const RT_BASE = () => (window.ROBORUN_RUNTIME && window.ROBORUN_RUNTIME.base) || "";
+let lastMoveSent = 0;
+let robotPolicyName = "";
+const TYPE_GLYPH = { drone: "✈", quadruped: "◈", humanoid: "⬡", arm: "⚙", webcam_only: "◉" };
+const ckTrail = []; // recent poses, world frame, for the tactical map
+let ckPose = null; // latest robot pose, shared across pollers
+const ckObjects = new Map(); // track_id → world-tracked object (moving vs stationary)
+// sim cockpit runs fully client-side — its timeline must reflect the SIM's
+// own perception and decisions, never the server log (which, on a
+// robot-connected runtime, is dominated by the real robot)
+const simLog = [];
+const simCloud = []; // accumulated sim-lidar world points (the generated map)
+function simEvent(source, title) {
+ simLog.push({ source, title, ts: Date.now() / 1000 });
+ if (simLog.length > 40) simLog.shift();
+}
+const CK_HFOV = 1.0472; // camera horizontal FOV (rad) — matches the X3 cam
+const CLASS_H = { person: 1.7, car: 1.5, truck: 2.6, bus: 3.0, bicycle: 1.1,
+ motorcycle: 1.2, "dog": 0.6, "cat": 0.3, "chair": 0.9 };
+const CLASS_COLOR = { person: "#00d47e", car: "#e0a030", truck: "#e0a030",
+ bus: "#e0a030", bicycle: "#40a0e0", motorcycle: "#40a0e0" };
+const ckColor = (label) => CLASS_COLOR[label] || "#9fb0bd";
+
+/* ── the cockpit: one view for every source ────────────────────────────
+ A robot's camera and the sim's 3D render are both just "the stream"; the
+ tactical map, timeline and telemetry are derived from inputs (camera,
+ point cloud, pose) that BOTH sources provide. src = "robot" | "sim". */
+let COCKPIT = null;
+
+/* ── robot DECK mode: the connected robot drives the same multi-panel deck
+ the sim uses. Its pose places the bot, its lidar builds the ROBOT MAP and
+ the 3D world (VIEW panels render it from any angle), its camera fills the
+ EYES panel, its behavior lives in POLICY. Same deck, real telemetry. */
+let robotCloudSeen = 0, robotDeckOn = false;
+async function enterRobotDeck() {
+ MODE = "robot"; robotDeckOn = true;
+ if (levelGroup) levelGroup.visible = false; // no sim walls
+ cloudOn = true; cloud.visible = true;
+ cloudReset(); occ.fill(0);
+ if (!scene.getObjectByName("robotGrid")) {
+ const grid = new THREE.GridHelper(120, 120, 0x1a2a38, 0x0f1820);
+ grid.name = "robotGrid"; grid.position.y = 0.01; scene.add(grid);
+ }
+ // keep LEVELS visible as the way back to the picker — never trap the user in
+ // the robot deck with no door out (relabelled so it reads as "the menu")
+ const lvl = $("btnLevels"); if (lvl) { lvl.style.display = ""; lvl.textContent = "⊞ MENU"; }
+ for (const sel of ['button[data-panel="p-runs"]', 'button[data-panel="p-brief"]']) {
+ const b = document.querySelector(sel); if (b) b.style.display = "none";
+ }
+ const eyesBtn = $("btnEyes"); if (eyesBtn) eyesBtn.style.display = "";
+ $("p-runs").classList.add("hidden");
+ // repurpose MISSION → robot identity / connection
+ const brief = $("p-brief");
+ if (brief) {
+ brief.querySelector(".p-head b").textContent = "ROBOT";
+ const sel = brief.querySelector("#levelSel"); if (sel) sel.style.display = "none";
+ $("briefTitle").textContent = "CONNECTED ROBOT";
+ $("briefText").innerHTML = "live over rosbridge — its pose, lidar map, camera and " +
+ "behavior fill this deck. The same policy file drives the sim and this robot.";
+ const rooms = $("rooms"); if (rooms) rooms.innerHTML = "";
+ }
+ // EYES camera feed (frame-polled, deterministic) — dock it via the layout
+ // so it lands in the same deck slot the sim uses, not floating at 0,0
+ layout["p-eyes"].hidden = false; applyLayout();
+ let camSrc = "robot";
+ $("eyesSrc").addEventListener("change", (e) => { camSrc = e.target.value; });
+ (function pump() {
+ const img = new Image();
+ img.onload = () => { $("robotEyesImg").src = img.src; setTimeout(pump, 90); };
+ img.onerror = () => setTimeout(pump, 400);
+ img.src = `${RT_BASE()}/api/camera/frame?source=${camSrc}&t=${Date.now()}`;
+ })();
+ // POLICY edits the robot's running behavior
+ wireRobotPolicy();
+ // robot type → body, then start telemetry
+ try {
+ const r = await (await fetch("/api/ros/cloud")).json();
+ const ty = r.robot_type === "drone" ? "drone"
+ : r.robot_type === "humanoid" ? "biped" : "dog";
+ if (bot.type !== ty) { bot.type = ty; buildBody(ty); }
+ } catch {}
+ if (mainCamSel) mainCamSel.value = "chase";
+ pollRobotDeck();
+ pollRobotEyesDets();
+}
+
+async function wireRobotPolicy() {
+ // load the running behavior into the deck's POLICY editor; RUN deploys it
+ try {
+ const r = await (await fetch("/api/behaviors")).json();
+ const live = (r.behaviors || []).find((b) => b.enabled &&
+ !["fix_camera", "heartbeat", "player_policy"].includes(b.name));
+ if (live) {
+ robotPolicyName = live.name;
+ const src = await api("/api/behaviors/read", { name: live.name });
+ if (src.ok) setCode(src.source);
+ policyStatus(`editing ${live.name} — live on the robot · RUN deploys`, "ok");
+ } else policyStatus("no behavior running — write one and RUN", "");
+ } catch {}
+}
+
+async function pollRobotDeck() {
+ if (!robotDeckOn) return;
+ try {
+ const r = await (await fetch("/api/ros/cloud")).json();
+ const p = r.pose;
+ if (p) {
+ bot.pos.x = p.x; bot.pos.z = p.z; bot.alt = (p.y ?? 0);
+ bot.heading = p.heading || 0;
+ if (bot.group) {
+ bot.group.position.set(bot.pos.x, bot.type === "drone" ? bot.alt : (bodySpec?.standH || 0.4), bot.pos.z);
+ bot.group.rotation.y = bot.heading;
+ }
+ }
+ // lidar → occupancy map + 3D cloud (the deck's ROBOT MAP + scene)
+ if (r.lidar && r.lidar.length) { lastLidar = r.lidar; integrateLidar(lastLidar); }
+ cloudCommit(); drawMap();
+ // status panel
+ $("teleOdo") && ($("teleOdo").textContent = `${odo.toFixed(1)} m`);
+ if ($("telePose")) $("telePose").textContent = bot.type === "drone"
+ ? `x ${bot.pos.x.toFixed(1)} · z ${bot.pos.z.toFixed(1)} · alt ${bot.alt.toFixed(1)}`
+ : `x ${bot.pos.x.toFixed(1)} · z ${bot.pos.z.toFixed(1)} · θ ${bot.heading.toFixed(2)}`;
+ const link = $("link"); if (link) { link.textContent = `robot: ${r.robot_type || "live"}`; link.className = "link on"; }
+ } catch {}
+ setTimeout(pollRobotDeck, 120);
+}
+
+async function pollRobotEyesDets() {
+ if (!robotDeckOn) return;
+ try {
+ const r = await (await fetch("/api/robot/detections")).json();
+ const ov = $("robotEyesOv"); if (ov) {
+ ov.innerHTML = "";
+ const host = ov.parentElement.getBoundingClientRect();
+ const iw = r.w || 16, ih = r.h || 9;
+ const scale = Math.max(host.width / iw, host.height / ih);
+ const dw = iw * scale, dh = ih * scale, ox = (host.width - dw) / 2, oy = (host.height - dh) / 2;
+ for (const d of (r.detections || [])) {
+ const box = document.createElement("div");
+ box.style.cssText = `position:absolute;border:1.5px solid #00d47e;border-radius:2px;` +
+ `left:${ox + d.x * dw}px;top:${oy + d.y * dh}px;width:${d.w * dw}px;height:${d.h * dh}px`;
+ box.innerHTML = `` +
+ `${d.label} ${(d.conf * 100).toFixed(0)}%`;
+ ov.appendChild(box);
+ }
+ }
+ } catch {}
+ setTimeout(pollRobotEyesDets, 250);
+}
+
+let simArmed = false; // the sim policy only drives after you DEPLOY — no
+ // surprise dog spinning in circles on arrival
+function enterRobotMode() { enterRobotDeck(); } // robot drives the deck
+function enterSimCockpit() { /* sim uses the deck directly; nothing to enter */ }
+
+function enterCockpit(src) {
+ COCKPIT = src;
+ if (src === "robot") MODE = "robot";
+ document.body.classList.add("cockpit", "src-" + src);
+ const st = document.getElementById("start");
+ if (st) st.classList.remove("show");
+
+ // shared: syntax-highlighted policy editor
+ const code = $("ck-code"), hl = $("ck-hl");
+ const sync = () => { hl.firstChild.innerHTML = ckHighlight(code.value);
+ hl.scrollTop = code.scrollTop; hl.scrollLeft = code.scrollLeft; };
+ code.addEventListener("input", sync);
+ code.addEventListener("scroll", () => { hl.scrollTop = code.scrollTop; hl.scrollLeft = code.scrollLeft; });
+ code.addEventListener("keydown", (e) => { // tab inserts spaces
+ if (e.key === "Tab") { e.preventDefault();
+ const s = code.selectionStart, en = code.selectionEnd;
+ code.value = code.value.slice(0, s) + " " + code.value.slice(en);
+ code.selectionStart = code.selectionEnd = s + 4; sync(); }
+ });
+ window.__ckSyncCode = sync;
+
+ // shared: policy slide-in + source picker
+ const pol = $("ck-policy");
+ $("ck-policy-btn").addEventListener("click", () => pol.classList.toggle("open"));
+ $("ck-policy-close").addEventListener("click", () => pol.classList.remove("open"));
+ $("ck-policy-expand").addEventListener("click", () => pol.classList.toggle("wide"));
+ $("ck-hold").addEventListener("click", () => ckSetPaused(!ckPaused));
+ $("ck-deploy").addEventListener("click", ckDeploy);
+ $("ck-stop").addEventListener("click", ckStop);
+ const srcBtn = $("ck-source-btn"), srcMenu = $("ck-sources");
+ srcBtn.addEventListener("click", () => { srcMenu.classList.add("open"); buildSourceMenu(); });
+ $("ck-src-close").addEventListener("click", () => srcMenu.classList.remove("open"));
+ srcMenu.addEventListener("click", (e) => { // click backdrop to close
+ if (e.target === srcMenu) srcMenu.classList.remove("open"); });
+ document.addEventListener("keydown", (e) => {
+ if (e.key === "Escape") srcMenu.classList.remove("open"); });
+
+ ckTimeline();
+
+ if (src === "robot") {
+ // stream = the robot camera, polled one frame at a time (deterministic)
+ let camSource = "robot";
+ $("ck-src").addEventListener("change", (e) => { camSource = e.target.value; });
+ // Honest feed state: operators must know if the video is live or frozen
+ // (teleop UX — a stale feed at >300ms is unsafe to drive against).
+ const setLive = (ok) => {
+ const el = $("ck-live"); if (!el) return;
+ const dot = el.querySelector(".dot"), txt = el.querySelector("span:last-child");
+ if (dot) dot.style.background = ok ? "#00d47e" : "#d4a030";
+ if (txt) txt.textContent = ok ? "LIVE" : "NO SIGNAL";
+ };
+ (function pumpCam() {
+ const img = new Image();
+ img.onload = () => { $("ck-cam").src = img.src; setLive(true); setTimeout(pumpCam, 90); };
+ img.onerror = () => { setLive(false); setTimeout(pumpCam, 400); };
+ img.src = `${RT_BASE()}/api/camera/frame?source=${camSource}&t=${Date.now()}`;
+ })();
+ fetch("/api/sources").then((r) => r.json()).then((s) => {
+ const h = (s.robot && s.robot.host) || "127.0.0.1";
+ $("ck-where").textContent = `${h === "127.0.0.1" ? "local" : "network"} · rosbridge ${h}`;
+ }).catch(() => {});
+ // a leftover sim sandbox policy (player_policy) would fall through to the
+ // real robot once no arena browser is feeding state — disable it on entry
+ api("/api/behaviors/disable", { name: "player_policy" }).catch(() => {});
+ loadRobotBehavior(); pollRobot(); pollRobotDetections();
+ ckSetPaused(false, true); // the robot's behavior is running; label reads PAUSE
+ } else {
+ // stream = the sim's 3D render. POV is "what the robot sees"; the game
+ // loop keeps rendering it full-screen behind the cockpit chrome.
+ // The sim is driven by player_policy; disable the webcam follow behavior so
+ // it doesn't fight the sandbox policy for the arena (or spam "no actuator").
+ api("/api/behaviors/disable", { name: "follow_person" }).catch(() => {});
+ $("ck-src").style.display = "none";
+ // chase cam: a stable 3rd-person view of the robot in its world, so a
+ // turning policy doesn't whip the whole screen around like POV does
+ if (mainCamSel) mainCamSel.value = "chase";
+ $("ck-where").textContent = "browser sim · rapier physics";
+ $("ck-type").textContent = (LV?.robot || bot.type || "robot").toUpperCase();
+ $("ck-glyph").textContent = TYPE_GLYPH[bot.type] || "◈";
+ $("ck-code").value = getCode(); sync();
+ $("ck-pname").textContent = "player_policy";
+ cloudOn = false; cloud.visible = false; // lidar lives in the 2D map, not sprayed on the scene
+ ckSetPaused(true, true); // start calm; label reads RESUME
+ // if this tab closes, make sure the sim's policy can't be left enabled
+ // and fall through to drive a real robot once the arena goes inactive
+ addEventListener("beforeunload", () => {
+ try {
+ fetch("/api/behaviors/disable", { method: "POST", keepalive: true,
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ name: "player_policy" }) });
+ } catch {}
+ });
+ ckPStat("paused — edit the policy, then DEPLOY to run", "");
+ $("ck-levels").addEventListener("click", () => showStart());
+ pollSimCockpit();
+ }
+}
+function $(id) { return document.getElementById(id); }
+
+/* sim cockpit data: the same panels the robot fills, fed from sim state.
+ Telemetry from the sim body, the tactical map from sightings + trail. */
+function pollSimCockpit() {
+ const now = performance.now();
+ // keep identity fresh — picking a new robot from LEVELS changes bot.type
+ $("ck-type").textContent = (LV?.robot || bot.type || "robot").toUpperCase();
+ $("ck-glyph").textContent = TYPE_GLYPH[bot.type] || "◈";
+ // trail + speed
+ if (ckTrail.length) { const prev = ckTrail[ckTrail.length - 1];
+ const sp = Math.hypot(bot.pos.x - prev.x, bot.pos.z - prev.z) /
+ Math.max(0.001, (now - (prev.t || now)) / 1000);
+ $("ck-spd").textContent = (isFinite(sp) ? sp : 0).toFixed(1); }
+ ckTrail.push({ x: bot.pos.x, z: bot.pos.z, t: now });
+ if (ckTrail.length > 240) ckTrail.shift();
+ const hdg = ((bot.heading * 180 / Math.PI) % 360 + 360) % 360;
+ $("ck-hdg").textContent = hdg.toFixed(0) + "°";
+ const altChip = $("ck-alt").parentElement;
+ if (bot.type === "drone") { $("ck-alt").textContent = bot.alt.toFixed(1) + "m"; altChip.style.display = ""; }
+ else altChip.style.display = "none";
+ $("ck-osd-tl").innerHTML = `POS ${bot.pos.x.toFixed(1)}, ${bot.pos.z.toFixed(1)}` +
+ (bot.type === "drone" ? `
ALT ${bot.alt.toFixed(2)} m` : "");
+ $("ck-osd-tr").innerHTML = `HDG ${hdg.toFixed(0)}°`;
+ // objects from the sim robot's OWN client-side perception (currentDets:
+ // world-located raycast detections) — never the shared server sightings
+ ckObjects.clear();
+ for (const d of currentDets) {
+ const pp = d.ppos || (d.p && (d.p.kind === "crate" ? d.p.mesh.position : d.p.pos));
+ if (!pp) continue;
+ const label = (d.p && d.p.label || "object").split(" ")[0];
+ ckObjects.set((d.p && d.p.id != null ? d.p.id : label) + "", {
+ label, wx: pp.x, wz: pp.z, dist: d.dist, moving: false, followed: false, last: now });
+ }
+ $("ck-osd-bl").innerHTML = ckObjects.size
+ ? `SENSING ${ckObjects.size} object(s) in view`
+ : `SCANNING`;
+ // accumulate the sim lidar into a world-frame map (the dog has a 36-ray
+ // scanner; [0] is dead ahead, CCW) — this is the generated map building up
+ for (let i = 0; i < lastLidar.length; i++) {
+ const r = lastLidar[i];
+ if (r == null || r >= 7.9) continue; // 8m = no return
+ const a = bot.heading + (i / lastLidar.length) * Math.PI * 2;
+ simCloud.push([bot.pos.x + Math.cos(a) * r, bot.pos.z - Math.sin(a) * r, r]);
+ }
+ if (simCloud.length > 4000) simCloud.splice(0, simCloud.length - 4000);
+ drawTacticalMap({ pose: { x: bot.pos.x, z: bot.pos.z, y: bot.alt, heading: bot.heading },
+ points: simCloud, lidar: lastLidar, robot_type: bot.type });
+ setTimeout(pollSimCockpit, 120);
+}
+
+/* lightweight Python highlighter for the policy underlay. Tokenizes in one
+ pass so strings/comments swallow keywords inside them. */
+const CK_KW = new Set(("def return if elif else for while in and or not is None True False " +
+ "import from as with try except finally class lambda yield break continue pass global " +
+ "nonlocal raise assert del await async").split(" "));
+const CK_BUILTIN = new Set("robot self range len min max abs int float str print round sum".split(" "));
+function ckEsc(s) { return s.replace(/&/g, "&").replace(//g, ">"); }
+function ckHighlight(src) {
+ let out = "", i = 0, n = src.length;
+ while (i < n) {
+ const c = src[i];
+ if (c === "#") { let j = i; while (j < n && src[j] !== "\n") j++;
+ out += `${ckEsc(src.slice(i, j))}`; i = j; continue; }
+ if (c === '"' || c === "'") {
+ const triple = src.slice(i, i + 3);
+ if (triple === '"""' || triple === "'''") { // docstring / block string
+ let j = i + 3; while (j < n && src.slice(j, j + 3) !== triple) j++;
+ out += `${ckEsc(src.slice(i, j + 3))}`; i = j + 3; continue;
+ }
+ const q = c; let j = i + 1;
+ while (j < n && src[j] !== q && src[j] !== "\n") { if (src[j] === "\\") j++; j++; }
+ out += `${ckEsc(src.slice(i, j + 1))}`; i = j + 1; continue; }
+ if (c === "@") { let j = i + 1; while (j < n && /[\w.]/.test(src[j])) j++;
+ out += `${ckEsc(src.slice(i, j))}`; i = j; continue; }
+ if (/[A-Za-z_]/.test(c)) { let j = i; while (j < n && /[\w]/.test(src[j])) j++;
+ const w = src.slice(i, j);
+ const prev = src.slice(0, i).trimEnd();
+ const cls = CK_KW.has(w) ? "tk-kw"
+ : (prev.endsWith("def") || prev.endsWith("class")) ? "tk-def"
+ : CK_BUILTIN.has(w) ? "tk-bn" : null;
+ out += cls ? `${w}` : ckEsc(w); i = j; continue; }
+ if (/[0-9]/.test(c)) { let j = i; while (j < n && /[\d.]/.test(src[j])) j++;
+ out += `${ckEsc(src.slice(i, j))}`; i = j; continue; }
+ out += ckEsc(c); i++;
+ }
+ return out;
+}
+
+/* the source picker: every robot / sim this runtime can reach, in one menu.
+ A connected robot, a sim arena, and any rosbridge discovered on the LAN —
+ pick one and the page switches to it. Answers "I don't want the drone,
+ give me something else even though ROS is connected." */
+async function buildSourceMenu() {
+ const list = $("ck-src-list");
+ list.innerHTML = '…' +
+ '';
+ let s = {};
+ try { s = await (await fetch("/api/sources")).json(); } catch {}
+ const rows = [];
+ // the connected robot (active)
+ if (s.robot && s.robot.connected) {
+ rows.push({ ic: TYPE_GLYPH[s.robot.type] || "◈",
+ nm: (s.robot.type || "robot").replace("_", " ").toUpperCase(),
+ sub: `rosbridge ${s.robot.host}`, badge: "live", active: true,
+ onPick: () => $("ck-sources").classList.remove("open") });
+ }
+ // other rosbridges on the network
+ for (const f of ((s.network && s.network.found) || [])) {
+ if (s.robot && s.robot.connected && f.host === s.robot.host) continue;
+ rows.push({ ic: "◈", nm: "ROBOT", sub: `rosbridge ${f.host}:${f.port}`, badge: "go",
+ onPick: async () => { await api("/api/ros/connect", { host: f.host, port: f.port });
+ localStorage.removeItem("roborun.source"); location.reload(); } });
+ }
+ // the browser sim arena — leave the robot without disconnecting it
+ rows.push({ ic: "◐", nm: "SIM ARENA", sub: "browser physics · code & test policies",
+ onPick: () => { localStorage.setItem("roborun.source", "sim"); location.reload(); } });
+
+ list.innerHTML = "";
+ for (const r of rows) {
+ const el = document.createElement("div");
+ el.className = "ck-source" + (r.active ? " active" : "");
+ el.innerHTML = `${r.ic}` +
+ (r.badge ? `${r.badge === "go" ? "CONNECT" : "LIVE"}` : "");
+ el.addEventListener("click", r.onPick);
+ list.appendChild(el);
+ }
+}
+
+async function loadRobotBehavior() {
+ try {
+ const r = await (await fetch("/api/behaviors")).json();
+ const live = (r.behaviors || []).find((b) =>
+ b.enabled && !["fix_camera", "heartbeat", "player_policy"].includes(b.name));
+ if (!live) { ckPStat("no behavior running — write one and DEPLOY", ""); return; }
+ robotPolicyName = live.name;
+ $("ck-pname").textContent = live.name;
+ const src = await api("/api/behaviors/read", { name: live.name });
+ if (src.ok) { $("ck-code").value = src.source;
+ if (window.__ckSyncCode) window.__ckSyncCode();
+ ckPStat(`live: ${live.name}`, "ok"); }
+ } catch {}
+}
+function ckPStat(msg, cls) {
+ const el = $("ck-pstat"); el.textContent = msg; el.className = "stat " + (cls || "");
+}
+// the HOLD button is a clear pause/resume toggle: PAUSE stops the policy
+// (the robot/sim holds), RESUME runs it again. Label reflects current state.
+let ckPaused = false;
+function ckSetPaused(paused, silent) {
+ ckPaused = paused;
+ const btn = $("ck-hold");
+ if (btn) { btn.innerHTML = paused ? "▶ RESUME" : "⏸ PAUSE";
+ btn.classList.toggle("danger", !paused); }
+ if (silent) return;
+ if (COCKPIT === "sim") {
+ simArmed = !paused;
+ if (!paused) setCode($("ck-code").value);
+ try { document.getElementById(paused ? "btnStop" : "btnRun").click(); } catch {}
+ ckPStat(paused ? "paused — DEPLOY or RESUME to run" : "running in the sim", paused ? "" : "ok");
+ } else {
+ const name = robotPolicyName || "robot_policy";
+ api(paused ? "/api/behaviors/disable" : "/api/behaviors/enable", { name }).catch(() => {});
+ ckPStat(paused ? `${name} paused — robot holds` : `${name} running`, paused ? "" : "ok");
+ }
+}
+
+// styled confirm — a cockpit modal instead of the native browser popup
+function ckConfirm(title, msg) {
+ return new Promise((resolve) => {
+ const m = $("ck-confirm");
+ $("ck-confirm-ttl").textContent = title;
+ $("ck-confirm-msg").textContent = msg;
+ m.classList.add("open");
+ const done = (v) => { m.classList.remove("open");
+ $("ck-confirm-yes").onclick = null; $("ck-confirm-no").onclick = null;
+ m.onclick = null; resolve(v); };
+ $("ck-confirm-yes").onclick = () => done(true);
+ $("ck-confirm-no").onclick = () => done(false);
+ m.onclick = (e) => { if (e.target === m) done(false); };
+ });
+}
+
+async function ckDeploy() {
+ const source = $("ck-code").value;
+ if (COCKPIT === "sim") {
+ // the sim runs the same policy format; hand it to the game's run path
+ setCode(source);
+ document.getElementById("btnRun").click();
+ simArmed = true;
+ ckSetPaused(false, true);
+ ckPStat("running in the sim — edit & DEPLOY to iterate", "ok");
+ return;
+ }
+ const name = robotPolicyName || "robot_policy";
+ if (!source.includes("@behavior")) { ckPStat("needs an @behavior function", "err"); return; }
+ const ok = await ckConfirm(`⚠ Deploy "${name}" to the CONNECTED ROBOT?`,
+ "It hot-reloads and starts moving real hardware immediately.");
+ if (!ok) { ckPStat("deploy cancelled", ""); return; }
+ ckPStat("saving…", "");
+ const w = await api("/api/behaviors/write", { name, source });
+ if (!w.ok) { ckPStat(w.error || "write failed", "err"); return; }
+ await api("/api/behaviors/enable", { name });
+ robotPolicyName = name; $("ck-pname").textContent = name;
+ ckPStat(`live on the robot — DEPLOY applies edits`, "ok");
+}
+async function ckStop() {
+ if (COCKPIT === "sim") { simArmed = false; document.getElementById("btnStop").click();
+ ckSetPaused(true, true); ckPStat("paused — DEPLOY to run", ""); return; }
+ const name = robotPolicyName || "robot_policy";
+ await api("/api/behaviors/disable", { name }).catch(() => {});
+ ckSetPaused(true, true); ckPStat(`${name} stopped — robot holds`, "");
+}
+
+async function pollRobot() {
+ try {
+ const r = await (await fetch("/api/ros/cloud")).json();
+ const p = r.pose;
+ ckPose = p || ckPose;
+ if (p) {
+ const now = performance.now();
+ if (ckTrail.length) {
+ const prev = ckTrail[ckTrail.length - 1];
+ const d = Math.hypot(p.x - prev.x, p.z - prev.z);
+ const dtspeed = d / Math.max(0.001, (now - (prev.t || now)) / 1000);
+ $("ck-spd").textContent = (isFinite(dtspeed) ? dtspeed : 0).toFixed(1);
+ }
+ ckTrail.push({ x: p.x, z: p.z, t: now });
+ if (ckTrail.length > 240) ckTrail.shift();
+ const isDrone = r.robot_type === "drone";
+ const altChip = $("ck-alt").parentElement;
+ if (isDrone) {
+ $("ck-alt").textContent = (p.y ?? 0).toFixed(1) + "m";
+ altChip.style.display = "";
+ altChip.classList.toggle("warn", (p.y ?? 0) > 8);
+ } else { altChip.style.display = "none"; }
+ const hdg = ((p.heading || 0) * 180 / Math.PI + 360) % 360;
+ $("ck-hdg").textContent = hdg.toFixed(0) + "°";
+ $("ck-osd-tl").innerHTML =
+ `POS ${p.x.toFixed(1)}, ${p.z.toFixed(1)}` +
+ (isDrone ? `
ALT ${(p.y ?? 0).toFixed(2)} m` : "");
+ $("ck-osd-tr").innerHTML = `HDG ${hdg.toFixed(0)}°`;
+ }
+ if (r.robot_type) {
+ $("ck-type").textContent = r.robot_type.replace("_", " ").toUpperCase();
+ $("ck-glyph").textContent = TYPE_GLYPH[r.robot_type] || "◈";
+ // the map shows what the robot actually senses
+ $("ck-map-tag").textContent = (r.lidar && r.lidar.length) ? "lidar · trail" : "trail";
+ }
+ drawTacticalMap(r);
} catch {}
- MODE = "wasm";
- document.body.classList.add("wasm-mode");
- bootWasm();
+ setTimeout(pollRobot, 150);
+}
+
+/* range + bearing of a detection, projected to a world (x,z) point using
+ the drone's pose. Distance comes from how tall the object stands in the
+ frame against its real-world height — the same monocular cue the follow
+ behavior leans on, good to roughly ±20%. */
+function projectDetection(d, pose, aspect) {
+ const realH = CLASS_H[d.label] || 1.4;
+ const vfov = 2 * Math.atan(Math.tan(CK_HFOV / 2) * aspect);
+ const dist = Math.max(0.6, Math.min(60, realH / (2 * Math.tan(vfov / 2) * Math.max(0.02, d.h))));
+ const cxn = d.x + d.w / 2;
+ const bearing = (pose.heading || 0) + (0.5 - cxn) * CK_HFOV;
+ return { dist, bearing,
+ wx: pose.x + Math.cos(bearing) * dist,
+ wz: pose.z - Math.sin(bearing) * dist };
+}
+
+function drawTacticalMap(r) {
+ const cv = $("ck-mapcv"); if (!cv) return;
+ const ctx = cv.getContext("2d"); const W = cv.width, H = cv.height;
+ const cx = W / 2, cy = H / 2, R = Math.min(W, H) / 2 - 10;
+ ctx.clearRect(0, 0, W, H);
+ ctx.fillStyle = "#070b0e"; ctx.fillRect(0, 0, W, H);
+ const rx = r.pose?.x || 0, rz = r.pose?.z || 0;
+ // auto-scale to hold the trail and every tracked object in view
+ let span = 6;
+ for (const t of ckTrail) span = Math.max(span, Math.abs(t.x - rx), Math.abs(t.z - rz));
+ for (const o of ckObjects.values()) span = Math.max(span, Math.abs(o.wx - rx) + 1, Math.abs(o.wz - rz) + 1);
+ const sc = R / span;
+ // range rings + their real distance, so the map reads in meters
+ ctx.font = "8px ui-monospace, Menlo, monospace";
+ for (let i = 1; i <= 3; i++) {
+ ctx.strokeStyle = "rgba(42,58,70,.5)"; ctx.lineWidth = 1;
+ ctx.beginPath(); ctx.arc(cx, cy, R * i / 3, 0, 7); ctx.stroke();
+ ctx.fillStyle = "rgba(90,107,120,.7)";
+ ctx.fillText((span * i / 3).toFixed(0) + "m", cx + 2, cy - R * i / 3 + 9);
+ }
+ // occupancy map: bin accumulated lidar into world cells so nearby returns
+ // merge into solid walls — a built-up map, not a scatter of dots (the more
+ // hits land in a cell, the more confidently it's a wall, so the brighter)
+ const pts = r.points || [];
+ if (pts.length) {
+ const CELL = 0.3; // metres per cell
+ const cells = new Map();
+ for (const pt of pts) {
+ const k = Math.round(pt[0] / CELL) + "," + Math.round(pt[1] / CELL);
+ cells.set(k, (cells.get(k) || 0) + 1);
+ }
+ const s = Math.max(2, CELL * sc);
+ for (const [k, n] of cells) {
+ const c = k.indexOf(",");
+ const gx = +k.slice(0, c) * CELL, gz = +k.slice(c + 1) * CELL;
+ const px = cx + (gx - rx) * sc, py = cy + (gz - rz) * sc;
+ if (px < 0 || px >= W || py < 0 || py >= H) continue;
+ ctx.fillStyle = `rgba(0,212,126,${Math.min(0.9, 0.3 + n * 0.14)})`;
+ ctx.fillRect(px - s / 2, py - s / 2, s, s);
+ }
+ }
+ // travel trail
+ ctx.strokeStyle = "rgba(0,212,126,.5)"; ctx.lineWidth = 1.5; ctx.beginPath();
+ ckTrail.forEach((t, i) => { const px = cx + (t.x - rx) * sc, py = cy + (t.z - rz) * sc;
+ i ? ctx.lineTo(px, py) : ctx.moveTo(px, py); });
+ ctx.stroke();
+ // tracked objects — stationary as hollow squares, movers filled with a
+ // heading tail, the followed target ringed
+ const now = performance.now();
+ for (const o of ckObjects.values()) {
+ const px = cx + (o.wx - rx) * sc, py = cy + (o.wz - rz) * sc;
+ if (px < 2 || px > W - 2 || py < 2 || py > H - 2) continue;
+ const col = ckColor(o.label);
+ if (o.followed) {
+ const pulse = 0.5 + 0.5 * Math.sin(now / 240);
+ ctx.strokeStyle = col; ctx.globalAlpha = 0.4 + 0.6 * pulse; ctx.lineWidth = 1.2;
+ ctx.beginPath(); ctx.arc(px, py, 6 + pulse * 2, 0, 7); ctx.stroke();
+ ctx.globalAlpha = 1;
+ }
+ if (o.moving) {
+ ctx.fillStyle = col; ctx.beginPath(); ctx.arc(px, py, 2.6, 0, 7); ctx.fill();
+ if (o.vx !== undefined) { ctx.strokeStyle = col; ctx.lineWidth = 1; ctx.beginPath();
+ ctx.moveTo(px, py); ctx.lineTo(px + o.vx * sc * 1.2, py + o.vz * sc * 1.2); ctx.stroke(); }
+ } else {
+ ctx.strokeStyle = col; ctx.globalAlpha = 0.8; ctx.lineWidth = 1.2;
+ ctx.strokeRect(px - 2.5, py - 2.5, 5, 5); ctx.globalAlpha = 1;
+ }
+ }
+ // robot + heading wedge, always on top
+ const hd = r.pose?.heading || 0;
+ ctx.fillStyle = "#fff"; ctx.beginPath(); ctx.arc(cx, cy, 3.5, 0, 7); ctx.fill();
+ ctx.strokeStyle = "rgba(255,255,255,.85)"; ctx.lineWidth = 1.5; ctx.beginPath();
+ ctx.moveTo(cx, cy); ctx.lineTo(cx + Math.cos(hd) * 15, cy - Math.sin(hd) * 15); ctx.stroke();
+}
+
+async function pollRobotDetections() {
+ try {
+ const r = await (await fetch("/api/robot/detections")).json();
+ const ov = $("ck-overlay"); ov.innerHTML = "";
+ const iw = r.w || 16, ih = r.h || 9, vw = innerWidth, vh = innerHeight;
+ const scale = Math.max(vw / iw, vh / ih);
+ const dw = iw * scale, dh = ih * scale, ox = (vw - dw) / 2, oy = (vh - dh) / 2;
+ const pose = ckPose, aspect = ih / iw, now = performance.now();
+ const dets = r.detections || [];
+ // pick the followed target the way follow_person_drone does: the
+ // highest-confidence person
+ let followed = null;
+ for (const d of dets) if (d.label === "person" &&
+ (!followed || d.conf > followed.conf)) followed = d;
+
+ const seen = new Set();
+ for (const d of dets) {
+ const proj = pose ? projectDetection(d, pose, aspect) : null;
+ // draw the camera box, with live distance when we can estimate it
+ const box = document.createElement("div"); box.className = "ck-det";
+ box.style.left = (ox + d.x * dw) + "px"; box.style.top = (oy + d.y * dh) + "px";
+ box.style.width = (d.w * dw) + "px"; box.style.height = (d.h * dh) + "px";
+ box.style.borderColor = ckColor(d.label);
+ const tag = box.appendChild(document.createElement("span"));
+ tag.className = "tag"; tag.style.background = ckColor(d.label);
+ tag.textContent = `${d.label} ${(d.conf * 100).toFixed(0)}%` +
+ (proj ? ` · ${proj.dist.toFixed(1)}m` : "");
+ ov.appendChild(box);
+
+ // world-track it: id by track_id when present, else by class+bearing
+ if (proj && pose) {
+ const id = d.track_id != null ? "t" + d.track_id
+ : d.label + Math.round((d.x + d.w / 2) * 8);
+ seen.add(id);
+ let o = ckObjects.get(id);
+ if (!o) { o = { label: d.label, hist: [] }; ckObjects.set(id, o); }
+ o.wx = proj.wx; o.wz = proj.wz; o.dist = proj.dist; o.last = now;
+ o.followed = (d === followed);
+ o.hist.push({ x: proj.wx, z: proj.wz, t: now });
+ if (o.hist.length > 14) o.hist.shift();
+ // moving vs stationary: displacement across the history window
+ if (o.hist.length >= 6) {
+ const a = o.hist[0], b = o.hist[o.hist.length - 1];
+ const dt = Math.max(0.001, (b.t - a.t) / 1000);
+ const sp = Math.hypot(b.x - a.x, b.z - a.z) / dt;
+ o.moving = sp > 0.35;
+ o.vx = (b.x - a.x) / dt; o.vz = (b.z - a.z) / dt;
+ }
+ }
+ }
+ // age out objects we haven't seen for ~1.5s
+ for (const [id, o] of ckObjects) if (now - o.last > 1500) ckObjects.delete(id);
+
+ // followed-target readout in the lower-left OSD
+ if (followed && pose) {
+ const proj = projectDetection(followed, pose, aspect);
+ const o = [...ckObjects.values()].find((x) => x.followed);
+ const motion = o && o.moving ? "MOVING" : "STATIONARY";
+ $("ck-osd-bl").innerHTML =
+ `TRACK person · ${proj.dist.toFixed(1)} m · ${motion}`;
+ } else {
+ $("ck-osd-bl").innerHTML = `SCANNING`;
+ }
+ } catch {}
+ setTimeout(pollRobotDetections, 200);
+}
+
+/* the timeline: the robot's stream of decisions and sightings, rendered as
+ it happens. Same idea as the sim arena's event feed — one consistent
+ "what is happening" surface, whatever the source is. */
+const CK_SRC_COLOR = { follow_person_drone: "#00d47e", fix_camera: "#40a0e0",
+ ros: "#e0a030", frame: "#9fb0bd", camera: "#9fb0bd", system: "#6b7b88",
+ yolo: "#00d47e", policy: "#40a0e0" };
+let _simMoveAcc = 0;
+async function ckTimeline() {
+ try {
+ let rows;
+ if (COCKPIT === "sim") {
+ // the sim's OWN perception + decisions, isolated from any robot on
+ // this runtime — never the shared server log
+ rows = simLog.filter((e) => (e.title || "").trim());
+ } else {
+ const evs = (await (await fetch("/api/run/events")).json()).events || [];
+ rows = evs.filter((e) => e.source !== "arena" && (e.title || "").trim() &&
+ !/level loaded|behaviors:/.test(e.title));
+ }
+ const list = $("ck-tl-list");
+ const fresh = rows.slice(Math.max(0, rows.length - 7));
+ list.innerHTML = "";
+ for (const e of fresh) {
+ const row = document.createElement("div"); row.className = "ck-tl-row";
+ const t = e.ts ? new Date(e.ts * 1000) : new Date();
+ const hhmmss = t.toTimeString().slice(0, 8);
+ const col = CK_SRC_COLOR[e.source] || "#6b7b88";
+ row.innerHTML = `${hhmmss}` +
+ `` +
+ `${(e.title || "").replace(/`;
+ list.appendChild(row);
+ }
+ } catch {}
+ setTimeout(ckTimeline, 600);
+}
+
+/* ── network robots (sim modes): a rosbridge on the wifi is a source ───── */
+async function pollNetworkRobots() {
+ const chip = $("ck-net");
+ // this chip exists for ONE job: in the sim cockpit, offer to jump to a
+ // connected robot. The menu has the ROS card; the robot cockpit is already
+ // there. Anywhere but the sim cockpit, it must stay hidden.
+ if (COCKPIT !== "sim") { chip.style.display = "none"; setTimeout(pollNetworkRobots, 8000); return; }
+ try {
+ const r = await (await fetch("/api/sources")).json();
+ if (r.robot && r.robot.connected) {
+ chip.textContent = "✈ robot connected · enter cockpit →";
+ chip.style.display = "block";
+ chip.onclick = () => { localStorage.removeItem("roborun.source"); location.reload(); };
+ } else { chip.style.display = "none"; }
+ } catch {}
+ setTimeout(pollNetworkRobots, 8000);
+}
+
+function sendRobotMove(cmd) {
+ const now = performance.now();
+ if (now - lastMoveSent < 120) return;
+ lastMoveSent = now;
+ api("/api/ros/move", {
+ linear_x: cmd.forward || 0, linear_y: cmd.strafe || 0,
+ linear_z: cmd.climb || 0, angular_z: cmd.turn || 0,
+ }).catch(() => {});
}
async function bootWasm() {
if (wasmRT || wasmLoading) return;
@@ -1269,7 +2068,7 @@ async function bootWasm() {
wasmRT = await mod.loadWasmRuntime((m) => policyStatus(m, ""));
linked = true;
policyStatus("in-browser python ready — press RUN", "ok");
- startAttemptRecording();
+ // no silent auto-record — recording is opt-in via the ● Record button
} catch (e) {
policyStatus(`python runtime failed to load: ${e.message || e}`, "err");
}
@@ -1284,7 +2083,7 @@ let serverSightings = [];
async function pollSightings() {
if (MODE === "wasm" && wasmRT) {
try { serverSightings = wasmRT.sightings() || []; } catch {}
- } else if (linked) {
+ } else if (linked || MODE === "robot") {
try {
const r = await (await fetch("/api/sightings")).json();
serverSightings = r.sightings || [];
@@ -1294,6 +2093,12 @@ async function pollSightings() {
}
async function pollCmd() {
const el = document.getElementById("link");
+ if (MODE === "robot") {
+ el.textContent = "robot: live — behaviors run on the runtime";
+ el.className = "link on";
+ setTimeout(pollCmd, 2000);
+ return;
+ }
if (MODE === "wasm") {
// cmd arrives synchronously from each tick in pushState
el.textContent = wasmRT ? "policy: in-browser python"
@@ -1306,10 +2111,13 @@ async function pollCmd() {
if (MODE === "server") {
try {
const r = await (await fetch("/api/arena/cmd")).json();
- serverCmd = r.cmd;
- serverAnswer = r.answer;
- serverIntent = r.intent || null;
- if (!linked) { linked = true; startAttemptRecording(); }
+ // keep the arena link warm even while paused; just hold the cmd at zero
+ if (COCKPIT === "sim" && !simArmed) {
+ serverCmd = { forward: 0, strafe: 0, turn: 0, climb: 0, grip: 0 };
+ } else {
+ serverCmd = r.cmd; serverAnswer = r.answer; serverIntent = r.intent || null;
+ }
+ if (!linked) { linked = true; } // no silent auto-record (opt-in via ● Record)
} catch { linked = false; }
}
el.textContent = linked ? "behaviors: linked"
@@ -1329,8 +2137,14 @@ function currentState() {
};
}
async function pushState() {
+ if (MODE === "robot") { // the robot is the source of truth;
+ setTimeout(pushState, 1000); // feeding the arena backend would
+ return; // reroute see()/move() to the sim
+ }
if (MODE === "wasm" && wasmRT) {
- try {
+ if (COCKPIT === "sim" && !simArmed) {
+ serverCmd = { forward: 0, strafe: 0, turn: 0, climb: 0, grip: 0 }; // holds until DEPLOY
+ } else try {
const r = wasmRT.tick(currentState());
serverCmd = r.cmd;
serverAnswer = r.answer;
@@ -1492,6 +2306,131 @@ function buildStartScreen() {
card.addEventListener("pointerleave", () => { p.hoverTarget = 0; });
previews.push(p);
}
+ // Embedded in Studio, Fleet and Real-robot (ROS) are their own top-level tabs,
+ // so don't duplicate them inside the single-robot Arena picker. Standalone /sim
+ // still shows them as the only entry points.
+ if (window.self === window.top) {
+ buildRosCard(); // the real-robot path sits alongside the sim robots
+ buildFleetCard(); // …and the many-robot path, on the right
+ }
+}
+
+/* the FLEET card: one quadruped is the sandbox; a fleet of them is the hard
+ part — sharing what each finds over a radio that only reaches so far. The
+ card is just the door; every knob (robots, radio, memory, the coordination
+ algorithm, even an LLM-written one) lives inside the sandbox itself. */
+function buildFleetCard() {
+ const grid = document.getElementById("startGrid");
+ const card = document.createElement("div");
+ card.className = "bot-card fleet-card";
+ card.innerHTML = `◈◈◈
+ FLEETMANY QUADRUPEDS
+ a swarm covering a field over an imperfect radio — tune the
+ range, memory and coordination algorithm, or have an LLM write a better one
+
+
+ `;
+ grid.appendChild(card);
+ card.querySelector(".fleet-go").addEventListener("click", () => {
+ // hosted (static) builds have no /fleet route; the file sits beside arena
+ location.href = (MODE === "wasm") ? "./fleet.html" : "/fleet";
+ });
+}
+
+/* the ROS card: a complete system shows the real-robot path whether or not a
+ robot is connected right now. Connected → enter its cockpit; on the network
+ → connect; otherwise → type an IP. */
+function buildRosCard() {
+ const grid = document.getElementById("startGrid");
+ const card = document.createElement("div");
+ card.className = "bot-card ros-card";
+ card.innerHTML = `◈
+ ROSGAZEBO · ISAAC · HARDWARE
+ connect anything on rosbridge — a Gazebo or Isaac sim, or a real robot
+ looking for sources…`;
+ grid.appendChild(card);
+ refreshRosCard();
+}
+let rosScanning = false;
+async function refreshRosCard() {
+ const tasks = document.getElementById("ros-tasks");
+ if (!tasks) return;
+ let s = null;
+ try { s = await (await fetch("/api/sources")).json(); } catch {}
+ tasks.innerHTML = "";
+ const enterRobot = () => { startEl.classList.remove("show"); enterRobotMode(); };
+ const connectAnd = async (host, port) => {
+ const r = await api("/api/ros/connect", { host, port: port || 9090 });
+ if (r && r.ok !== false) enterRobot();
+ };
+ if (!s) { // no runtime reachable at all
+ tasks.innerHTML = `start roborun, then point a ` +
+ `Gazebo or Isaac sim — or a real robot — at it over rosbridge. The same ` +
+ `policy file drives them all.`;
+ return;
+ }
+
+ // 1) the live robot, if one is already connected on rosbridge
+ const connectedHost = s.robot && s.robot.connected ? s.robot.host : null;
+ if (connectedHost) {
+ const ty = s.robot.type;
+ const label = (ty && ty !== "webcam_only") ? ty.toUpperCase() : "ROSBRIDGE";
+ const btn = document.createElement("button");
+ btn.innerHTML = `▶ ${label} · ${connectedHost}LIVE`;
+ btn.addEventListener("click", enterRobot);
+ tasks.appendChild(btn);
+ }
+
+ // 2) one view per robot the scan found on the network — pick any to enter it
+ const found = ((s.network && s.network.found) || []).filter((f) => f.host !== connectedHost);
+ for (const f of found) {
+ const btn = document.createElement("button");
+ btn.innerHTML = `▶ ${f.local ? "THIS MACHINE" : "ROBOT"} · ${f.host}:${f.port}` +
+ `rosbridge`;
+ btn.addEventListener("click", () => connectAnd(f.host, f.port));
+ tasks.appendChild(btn);
+ }
+
+ // 3) the scan button — on a hosted page this is what trips the browser's
+ // "access devices on your network" permission, then loads every robot found
+ const scanBtn = document.createElement("button");
+ scanBtn.className = "ros-scan";
+ scanBtn.innerHTML = rosScanning ? "⟳ scanning the network…"
+ : (found.length ? "⚲ rescan network for robots" : "⚲ Allow network scan to load robots");
+ scanBtn.disabled = rosScanning;
+ scanBtn.addEventListener("click", () => scanNetwork());
+ tasks.appendChild(scanBtn);
+ if (!connectedHost && !found.length && !rosScanning) {
+ const hint = document.createElement("div"); hint.className = "ros-hint";
+ hint.innerHTML = "lets the browser see robots on your wifi — or enter a rosbridge IP below.";
+ tasks.appendChild(hint);
+ }
+
+ // 4) manual IP, always available as a fallback
+ const row = document.createElement("div"); row.className = "ros-connect";
+ row.innerHTML = `` +
+ ``;
+ tasks.appendChild(row);
+ document.getElementById("ros-go").addEventListener("click", () => {
+ const ip = document.getElementById("ros-ip").value.trim(); if (ip) connectAnd(ip, 9090);
+ });
+}
+
+/* Kick a forced LAN scan and poll until it settles, then re-render the card
+ with one entry per robot found. The POST to the local runtime is also what
+ triggers the browser's local-network permission prompt on the hosted site. */
+async function scanNetwork() {
+ if (rosScanning) return;
+ rosScanning = true; refreshRosCard();
+ try { await api("/api/sources/scan", {}); } catch {}
+ const t0 = Date.now();
+ while (Date.now() - t0 < 7000) {
+ await new Promise((r) => setTimeout(r, 700));
+ let s = null;
+ try { s = await (await fetch("/api/sources")).json(); } catch {}
+ if (s && s.network && !s.network.scanning) break;
+ }
+ rosScanning = false; refreshRosCard();
}
let startRaf = 0, startLastT = 0;
function animateStart(t) {
@@ -1510,17 +2449,20 @@ function animateStart(t) {
if (startEl.classList.contains("show")) startRaf = requestAnimationFrame(animateStart);
}
function showStart() {
+ if (COCKPIT === "robot") return; // never over a live robot cockpit
startEl.classList.add("show");
+ refreshRosCard(); // keep the real-robot options current
cancelAnimationFrame(startRaf);
startRaf = requestAnimationFrame(animateStart);
}
function enterLevel(i) {
startEl.classList.remove("show");
- loadLevel(i);
- policyStatus("starter policy loaded — edit it (or don't), then press ▶ RUN · WASD grabs the wheel anytime", "ok");
+ loadLevel(i); // the sim runs in the deck directly (3D scene + panels)
+ policyStatus("starter loaded — edit it, then RUN · WASD grabs the wheel anytime", "ok");
}
buildStartScreen();
-showStart();
+// splash shows after mode detection — a connected robot boots into its
+// cockpit, not into "pick a robot, pick a task"
document.getElementById("startClose").addEventListener("click", () => startEl.classList.remove("show"));
startEl.addEventListener("click", (e) => { if (e.target === startEl) startEl.classList.remove("show"); });
document.getElementById("btnLevels").addEventListener("click", showStart);
@@ -1559,17 +2501,29 @@ document.getElementById("btnRun").addEventListener("click", async () => {
if (!source.includes("@behavior")) {
// words, not code: compile the mission into a policy first
policyStatus("✨ compiling mission via LLM… (~10s)", "");
- const c = await api("/api/behaviors/compile",
- { mission: source, context: LV.title + ": " + LV.brief });
+ const ctx = MODE === "robot"
+ ? "a real connected robot — be conservative with speeds"
+ : LV.title + ": " + LV.brief;
+ const c = await api("/api/behaviors/compile", { mission: source, context: ctx });
if (!c.ok) { policyStatus(c.error, "err"); return; }
source = c.source;
setCode(source); // language in, code out — inspect/edit it
}
+ // sandbox writes player_policy; robot mode edits the robot's own
+ // behavior file, and never without saying so
+ const name = MODE === "robot" ? robotPolicyName : "player_policy";
+ if (MODE === "robot" && !await ckConfirm(`⚠ Deploy "${name}" to the connected robot?`,
+ "It hot-reloads and starts moving hardware immediately.")) {
+ policyStatus("deploy cancelled", "");
+ return;
+ }
policyStatus("saving…", "");
- const w = await api("/api/behaviors/write", { name: "player_policy", source });
+ const w = await api("/api/behaviors/write", { name, source });
if (!w.ok) { policyStatus(w.error, "err"); return; }
- await api("/api/behaviors/enable", { name: "player_policy" });
- policyStatus("running — hot reload applies edits on every RUN", "ok");
+ await api("/api/behaviors/enable", { name });
+ policyStatus(MODE === "robot"
+ ? `live on the robot as ${name} — RUN applies edits`
+ : "running — hot reload applies edits on every RUN", "ok");
} catch { policyStatus("no server — run `roborun` first", "err"); }
});
document.getElementById("btnStop").addEventListener("click", async () => {
@@ -1579,14 +2533,58 @@ document.getElementById("btnStop").addEventListener("click", async () => {
return;
}
try {
- await api("/api/behaviors/disable", { name: "player_policy" });
- policyStatus("stopped", "");
+ const name = MODE === "robot" ? robotPolicyName : "player_policy";
+ await api("/api/behaviors/disable", { name });
+ policyStatus(MODE === "robot" ? `${name} stopped — robot holds` : "stopped", "");
} catch {}
});
/* ════════════════ telemetry ════════════════ */
let odo = 0;
const prevPos = new THREE.Vector3();
+
+/* live velocity sparkline (the SPEED panel) — sampled from the odometer
+ delta each telemetry tick (~8 Hz), held ~14 s. Antioch's velocity-under-
+ the-view, on our own loop. */
+const speedHist = []; // m/s samples, newest last
+let _spkOdo = 0, _spkT = 0, _spdPeak = 0;
+function updateSpeedSpark() {
+ const now = performance.now();
+ if (_spkT) {
+ const sp = (odo - _spkOdo) / Math.max(0.001, (now - _spkT) / 1000);
+ const v = isFinite(sp) ? sp : 0;
+ speedHist.push(v);
+ if (speedHist.length > 120) speedHist.shift();
+ if (v > _spdPeak) _spdPeak = v;
+ const nowEl = document.getElementById("teleSpdNow");
+ if (nowEl) nowEl.textContent = v.toFixed(1);
+ const maxEl = document.getElementById("teleSpdMax");
+ if (maxEl) maxEl.textContent = _spdPeak.toFixed(1);
+ }
+ _spkOdo = odo; _spkT = now;
+ drawSpeedSpark();
+}
+function drawSpeedSpark() {
+ const cv = document.getElementById("teleSpark");
+ if (!cv || !cv.clientWidth) return;
+ const dpr = window.devicePixelRatio || 1;
+ const w = cv.clientWidth, h = cv.clientHeight || 64;
+ if (cv.width !== Math.round(w * dpr)) { cv.width = Math.round(w * dpr); cv.height = Math.round(h * dpr); }
+ const g = cv.getContext("2d");
+ g.setTransform(dpr, 0, 0, dpr, 0, 0);
+ g.clearRect(0, 0, w, h);
+ const d = speedHist;
+ const max = Math.max(0.5, ...d);
+ g.strokeStyle = "rgba(120,175,144,.14)";
+ g.beginPath(); g.moveTo(0, h - 1); g.lineTo(w, h - 1); g.stroke();
+ if (d.length < 2) return;
+ const X = i => i / (d.length - 1) * w, Y = v => h - (v / max) * (h - 6) - 3;
+ g.beginPath();
+ d.forEach((v, i) => { const x = X(i), y = Y(v); i ? g.lineTo(x, y) : g.moveTo(x, y); });
+ g.strokeStyle = "#00d47e"; g.lineWidth = 1.5; g.stroke();
+ g.lineTo(w, h); g.lineTo(0, h); g.closePath();
+ g.fillStyle = "rgba(0,212,126,.10)"; g.fill();
+}
function currentRoom() {
for (const r of LV.rooms || []) {
const [x1, z1, x2, z2] = r.rect;
@@ -1598,17 +2596,18 @@ function updateTelemetry() {
const it = document.getElementById("intent");
it.textContent = serverIntent
? `▸ ${serverIntent.verb} — ${serverIntent.detail}` : "";
- document.getElementById("teleRoom").textContent = `room ${currentRoom()}`;
- document.getElementById("teleOdo").textContent = `odometer ${odo.toFixed(1)} m`;
+ document.getElementById("teleRoom").textContent = `${currentRoom()}`;
+ document.getElementById("teleOdo").textContent = `${odo.toFixed(1)} m`;
+ updateSpeedSpark();
document.getElementById("telePose").textContent = bot.type === "drone"
? `x ${bot.pos.x.toFixed(1)} · z ${bot.pos.z.toFixed(1)} · alt ${bot.alt.toFixed(1)}`
: `x ${bot.pos.x.toFixed(1)} · z ${bot.pos.z.toFixed(1)} · θ ${bot.heading.toFixed(2)}`;
}
/* ════════════════ panels ════════════════ */
-const LAYOUT_KEY = "arena-layout-v3";
-const PANEL_IDS = ["p-brief", "p-policy", "p-status", "p-map", "p-view1", "p-view2",
- "p-runs"];
+const LAYOUT_KEY = "arena-layout-v5"; // v5: wider right rail + SPEED telemetry panel; reflow stale layouts
+const PANEL_IDS = ["p-brief", "p-policy", "p-status", "p-map", "p-tele", "p-view1", "p-view2",
+ "p-runs", "p-eyes"];
let zTop = 100;
function defaultLayout() {
if (innerWidth < 760) {
@@ -1626,20 +2625,27 @@ function defaultLayout() {
const w = innerWidth, h = innerHeight;
const GAP = 10, TOP = 52;
const L = Math.min(440, Math.max(340, Math.round(w * 0.3)));
- const R = Math.min(290, Math.max(230, Math.round(w * 0.18)));
- const briefH = 185, statusH = 168;
- const mapH = Math.max(180, Math.round((h - TOP - statusH - 3 * GAP) * 0.45));
+ const R = Math.min(360, Math.max(300, Math.round(w * 0.2)));
+ const briefH = 185, statusH = 196, teleH = 136;
+ // right rail: status, map, speed, robot-cam — each readable, not cramped
+ const mapH = Math.max(170, Math.round((h - TOP - statusH - teleH - 4 * GAP) * 0.5));
+ const rTele = TOP + statusH + mapH + 2 * GAP;
+ const rView = rTele + teleH + GAP;
return {
"p-brief": { l: GAP, t: TOP, w: L, h: briefH, hidden: false },
"p-policy": { l: GAP, t: TOP + briefH + GAP, w: L,
h: h - TOP - briefH - 2 * GAP - 4, hidden: false },
"p-status": { l: w - R - GAP, t: TOP, w: R, h: statusH, hidden: false },
"p-map": { l: w - R - GAP, t: TOP + statusH + GAP, w: R, h: mapH, hidden: false },
- "p-view2": { l: w - R - GAP, t: TOP + statusH + mapH + 2 * GAP, w: R,
- h: h - TOP - statusH - mapH - 3 * GAP - 4, hidden: false },
+ "p-tele": { l: w - R - GAP, t: rTele, w: R, h: teleH, hidden: false },
+ "p-view2": { l: w - R - GAP, t: rView, w: R,
+ h: Math.max(150, h - rView - GAP - 4), hidden: false },
"p-view1": { l: Math.round(w / 2 - 170), t: h - 244, w: 340, h: 230, hidden: true },
"p-runs": { l: Math.round(w / 2 - 230), t: TOP + 30, w: 460,
h: Math.min(440, h - TOP - 60), hidden: true },
+ // EYES (the connected robot's camera) docks where the sim shows its POV,
+ // so a ROS robot lands in the exact same deck — hidden until robot mode.
+ "p-eyes": { l: Math.round(w / 2 - 180), t: h - 264, w: 360, h: 248, hidden: true },
};
}
function loadLayout() {
@@ -1662,6 +2668,10 @@ function applyLayout() {
for (const id of PANEL_IDS) {
const el = document.getElementById(id), st = layout[id];
if (!el || !st) continue;
+ // EYES is the *real robot's* camera; in the sim there's no camera, so it
+ // would just sit black. It's a robot-deck-only panel — never show it (or
+ // its toolbar toggle) outside robot mode, even if a saved layout had it open.
+ const robotOnly = id === "p-eyes" && MODE !== "robot";
const w = Math.min(st.w, innerWidth - 12);
const h = Math.min(st.h, innerHeight - TOP - 8);
// clamp AND write back, so a layout saved offscreen heals itself
@@ -1671,10 +2681,12 @@ function applyLayout() {
el.style.top = `${st.t}px`;
el.style.width = `${w}px`;
el.style.height = `${h}px`;
- el.classList.toggle("hidden", !!st.hidden);
+ el.classList.toggle("hidden", robotOnly || !!st.hidden);
document.querySelector(`#toolbar [data-panel="${id}"]`)
- ?.classList.toggle("on", !st.hidden);
+ ?.classList.toggle("on", !robotOnly && !st.hidden);
}
+ const eb = document.getElementById("btnEyes");
+ if (eb) eb.style.display = MODE === "robot" ? "" : "none";
}
function initPanels() {
for (const id of PANEL_IDS) {
@@ -1888,15 +2900,27 @@ function frame(now) {
adaptQuality(dt);
renderer.shadowMap.needsUpdate = true;
const cmd = keyboardCmd() || serverCmd;
- updateMovers(dt);
- updateBody(dt, cmd);
- syncProps();
- tickChamber(dt, serverAnswer);
+ if (MODE === "robot") {
+ const k = keyboardCmd(); // WASD drives the real robot
+ if (k) sendRobotMove(k);
+ // pose/cloud/lidar arrive from telemetry in pollRobot()
+ } else {
+ updateMovers(dt);
+ updateBody(dt, cmd);
+ syncProps();
+ tickChamber(dt, serverAnswer);
+ // log the sim policy's decisions into the sim-local timeline (~1/s)
+ if (COCKPIT === "sim") {
+ _simMoveAcc = (_simMoveAcc || 0) + dt;
+ if (_simMoveAcc > 1.0) { _simMoveAcc = 0;
+ simEvent("policy", `move fwd=${(cmd.forward || 0).toFixed(2)} turn=${(cmd.turn || 0).toFixed(2)}`); }
+ }
+ }
odo += prevPos.distanceTo(bot.pos);
prevPos.copy(bot.pos);
senseTick += dt;
- if (senseTick > 0.12) {
+ if (MODE !== "robot" && senseTick > 0.12) {
senseTick = 0;
lastLidar = senseLidar();
integrateLidar(lastLidar);
@@ -1931,15 +2955,21 @@ function frame(now) {
orbitAngle += dt * 0.25;
if (!won) clockEl.textContent = `${((now - t0) / 1000).toFixed(1)}s`;
- cmdEl.textContent = `cmd f=${(cmd.forward || 0).toFixed(2)} t=${(cmd.turn || 0).toFixed(2)} · cam ${mainCamSel.value}`;
+ cmdEl.textContent = `f=${(cmd.forward || 0).toFixed(2)} t=${(cmd.turn || 0).toFixed(2)} · cam ${mainCamSel.value}`;
renderViews();
compositeRecordFrame();
requestAnimationFrame(frame);
}
await initPhysics(); // rapier WASM, once per page
-loadLevel(0);
-detectMode(); pollCmd(); pushState(); pollSightings(); renderRuns();
+// open the level named in /sim?level= if present, else the first
+{
+ const _q = new URLSearchParams(location.search).get("level");
+ const _hi = _q ? LEVELS.findIndex((l) => l.name === _q) : -1;
+ window._navFromHistory = true;
+ try { loadLevel(_hi >= 0 ? _hi : 0); } finally { window._navFromHistory = false; }
+}
+detectMode().then(() => pollNetworkRobots()); pollCmd(); pushState(); pollSightings(); renderRuns();
requestAnimationFrame(frame);
/* harness hook — scripts/e2e_arena.mjs drives the page without the UI */
diff --git a/roborun/web/browser.html b/roborun/web/browser.html
new file mode 100644
index 0000000..242c40a
--- /dev/null
+++ b/roborun/web/browser.html
@@ -0,0 +1,242 @@
+
+
+
+
+
+
+RoboRun · Data Browser
+
+
+
+
+
+
+
+ DATA BROWSER
+ Every recorded run in the active scope — sim, robot, fleet — with its streams.
+ Open one to scrub the synced telemetry, camera, map and lidar.
+
+
+
+
+ Spatial map
+
+
+ loading…
+
+
+
+
+ Runs
+
+
+ loading…
+
+
+
+
+ Backends — what this install can run
+
+
+ loading…
+
+
+
+
+
diff --git a/roborun/web/deck.html b/roborun/web/deck.html
index e481aeb..46baf55 100644
--- a/roborun/web/deck.html
+++ b/roborun/web/deck.html
@@ -1,6 +1,7 @@
+
RoboRun Flight Deck: ROS robot control, live vision, tamper-evident black box
diff --git a/roborun/web/fleet-sim.html b/roborun/web/fleet-sim.html
new file mode 100644
index 0000000..8e687dd
--- /dev/null
+++ b/roborun/web/fleet-sim.html
@@ -0,0 +1,75 @@
+
+
+
+
+
+
+RoboRun · Fleet Sim
+
+
+
+
+
+
+
+ Layer 2: real Rapier physics. N robots with real bodies + collisions in a multi-floor warehouse, collecting jointly into the active environment. Coordination strategies live in the Swarm Lab.
+
+
+ 🐝FLEET SIM● Rapier physics
+
+
+
+
+
+
+
+
+
+
+ robot (real physics body)
+ detected item
+ undetected item
+ ⇅ elevator (between floors)
+ — walls / rooms
+
+
+
+
+
+ drag to orbit · scroll to zoom
+
+
+
+
+
diff --git a/roborun/web/fleet.html b/roborun/web/fleet.html
new file mode 100644
index 0000000..080c22f
--- /dev/null
+++ b/roborun/web/fleet.html
@@ -0,0 +1,423 @@
+
+
+
+
+
+RoboRun · Swarm Lab
+
+
+
+
+
+
+
+
+ ◈◈◈SWARM LAB
+ — layer 1: coordination algorithms. How a swarm decides where to spread & search and what to share — abstract, no physics. To watch robots move with real physics, open the Fleet Sim →
+
+
+
+
+
+
+
+ Control panel
+
+ The world
+
+ Environment
+
+ where the swarm searches — obstacles block movement and break line-of-sight.
+
+
+ Robots6
+
+
+
+ Data points to find8
+
+ things to discover and relay back to base — the green tower.
+
+
+
+ What each robot can hear
+
+ Radio range14 m
+
+ two robots talk only while this close. Past it, they're on their own.
+
+
+ Link reliability85%
+
+ best-case delivery odds; messages fail more toward the edge of range.
+
+
+ What each robot can handle
+
+ Airtime4 / s
+
+ messages a robot can send per second — its slice of the band.
+
+
+ Onboard memory60
+
+ cells it remembers. Full memory forgets old ground — and may re-walk it.
+
+
+ Inbox depth8
+
+ messages queued while it finishes its move. Full inbox → new messages are dropped.
+
+
+ Coordination strategy
+
+
+
+
+
+
+ Live coordination
+
+ N robots · 1 lossy radio
+
+
+
+ fleet booting…
+
+
+ mapped once
+ overlap (wasted)
+ radio link
+ link at its limit
+ data → base
+ obstacle
+
+
+
+
+
+ Fleet telemetry
+ hover a tile
+
+
+ Ground mapped ?
+ 0%
+ Ground mapped — share of searchable cells at least one robot has sensed. The mission is done at 100%.
+ Overlap ?0
+ Overlapping searches — cells a second robot re-walked (amber on the map). Wasted effort: it happens when robots don't know what teammates already covered.
+ Linked now ?0/0
+ Connectivity — robots that currently have at least one neighbour in radio range. The rest are islands and can't share.
+ Delivered ?0
+ Delivered — messages that actually arrived in a teammate's inbox and were read.
+ Dropped ?0
+ Dropped — messages that were sent but never landed: the radio link failed (likely near the edge of range), or the receiver's inbox was full. High drop % = radio overloaded or robots too far apart.
+ Data → base ?0/0
+ Data delivered — discovered data points relayed all the way back to the base station (the sink). A find isn't "done" until it reaches base.
+ Elapsed ?0.0s
+ Wall-clock since the last reset.
+
+
+
+
+
+
+
+
+
+ ⤢
+
+
+
+
+ Gossip
+
+
+
+
+
+
+ Delivered vs. dropped
+ Every message a robot sends either arrives in a neighbour's inbox
+ (delivered) or it doesn't (dropped). A message drops for two
+ reasons: the radio link failed — far more likely near the edge of range — or it arrived
+ but the receiver's inbox was already full (it's busy finishing one job at a time). A high
+ drop rate means your radio is overloaded or the swarm is spread too thin.
+ Overlapping searches
+ The amber tiles were mapped by two or more robots — duplicated,
+ wasted work. It happens whenever robots don't know what teammates have done: no radio, dropped
+ updates, or stale memory. Better coordination keeps tiles green-once.
+ How do we optimise it?
+ Cranking range, airtime and memory up always helps — but real radios can't just
+ scale. The software wins come from being smarter with the bytes you have: share intent
+ (claim where you're going), not just raw map; route data toward a sink instead of flooding;
+ elect local leaders to split the area. Slide range down and switch strategies to feel the trade-offs.
+ Is this libp2p?
+ Same family of ideas. libp2p is the real-world peer-to-peer stack (it powers IPFS, Ethereum,
+ and many robot fleets): gossipsub floods updates to peers, a Kademlia DHT finds
+ who-has-what, and multi-hop routing relays to a server. This sandbox is a toy model of gossipsub +
+ geographic routing — libp2p is the batteries-included, production version of exactly this.
+ So what is "swarm intelligence"?
+ No robot sees the whole field. Coverage emerges from local rules — map near you, tell your
+ neighbours, avoid claimed ground, carry data toward base. That's stigmergy: simple local
+ interactions producing smart global behaviour, with no central brain required. It scales
+ because every rule stays local — each robot only ever reasons about who it can currently hear.
+ The base station (the "main server")
+ The green tower is the sink. A discovered data point is relayed
+ hop by hop toward it (greedy geographic routing): each robot hands its data to a neighbour
+ that's closer to base, or carries it like a data mule until one is. Watch data reach base
+ even when no single robot can see base alone — that's the swarm acting as one antenna.
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/roborun/web/fleet.js b/roborun/web/fleet.js
new file mode 100644
index 0000000..6b197e4
--- /dev/null
+++ b/roborun/web/fleet.js
@@ -0,0 +1,801 @@
+/* RoboRun · FLEET — a teaching sandbox for swarm coordination over an
+ imperfect radio. Many quadrupeds cover a field together, discover data
+ points, and relay them back to a base station — with only the radio range,
+ airtime, memory and one-job-at-a-time limits a real robot has.
+
+ Hover a robot to see exactly what *it* knows (what it sensed itself vs what
+ it only heard from peers). Read the CONCEPTS tab for what delivered/dropped/
+ overlap mean and how libp2p-style gossip + routing build swarm intelligence.
+
+ No physics engine, no three.js: the comms model is the lesson, so the robots
+ are kinematic and the whole thing stays readable. The four strategies mirror
+ roborun/swarm/*.py. */
+
+const cv = document.getElementById("field");
+const ctx = cv.getContext("2d");
+const $ = (id) => document.getElementById(id);
+
+/* ── world + coverage grid ───────────────────────────────────────────── */
+const WORLD = 56; // metres across the (square) field
+const CG = 28; // coverage cells per side → 2 m cells
+const CELL = WORLD / CG;
+const SENSE = 3.2; // metres a robot maps around itself as it walks
+const NCELL = CG * CG;
+const cx = (i) => (i % CG + 0.5) * CELL; // cell index → world centre
+const cy = (i) => ((i / CG | 0) + 0.5) * CELL;
+
+/* ── config (mirrored from sliders / URL) ────────────────────────────── */
+const cfg = { count: 6, range: 14, loss: 85, bw: 4, mem: 60, buf: 8,
+ targets: 8, base: true, env: "open", strat: "gossip" };
+(function fromURL() {
+ const q = new URLSearchParams(location.search);
+ for (const k of ["count", "range", "loss", "bw", "mem", "buf", "targets"])
+ if (q.has(k)) cfg[k] = +q.get(k);
+ if (q.has("strat")) cfg.strat = q.get("strat");
+ if (q.has("env")) cfg.env = q.get("env");
+})();
+
+/* ── environments: obstacles block movement AND line-of-sight ─────────── */
+const ENV = {
+ open: { obstacles: [] },
+ obstacles: { obstacles: [
+ [10, 12, 7, 7], [34, 9, 9, 6], [20, 26, 8, 8], [40, 30, 7, 10],
+ [8, 34, 8, 7], [30, 42, 9, 7] ] },
+ rooms: { obstacles: [
+ // a coarse building: wall segments as thin rectangles + a couple blocks
+ [14, 16, 2, 22], [14, 16, 18, 2], [30, 16, 2, 14], [14, 36, 14, 2],
+ [38, 14, 2, 26], [38, 38, 12, 2], [22, 30, 2, 14] ] },
+};
+let obstacles = []; // [x, y, w, h] world rects
+let coverable = new Uint8Array(NCELL); // 1 where a cell is open ground
+let NCOVER = NCELL;
+let basePos = { x: WORLD / 2, y: WORLD * 0.055 };
+
+function inObstacle(x, y, pad = 0) {
+ for (const [ox, oy, ow, oh] of obstacles)
+ if (x >= ox - pad && x <= ox + ow + pad && y >= oy - pad && y <= oy + oh + pad) return true;
+ return false;
+}
+// segment vs axis-aligned rect (slab method) — for line-of-sight through walls
+function segHitsRect(ax, ay, bx, by, ox, oy, ow, oh) {
+ let t0 = 0, t1 = 1; const dx = bx - ax, dy = by - ay;
+ for (const [p, q] of [[-dx, ax - ox], [dx, ox + ow - ax], [-dy, ay - oy], [dy, oy + oh - ay]]) {
+ if (p === 0) { if (q < 0) return false; }
+ else { const r = q / p; if (p < 0) { if (r > t1) return false; if (r > t0) t0 = r; }
+ else { if (r < t0) return false; if (r < t1) t1 = r; } }
+ }
+ return t0 <= t1;
+}
+function los(ax, ay, bx, by) {
+ for (const [ox, oy, ow, oh] of obstacles)
+ if (segHitsRect(ax, ay, bx, by, ox, oy, ow, oh)) return false;
+ return true;
+}
+
+function buildEnv(name) {
+ obstacles = (ENV[name] || ENV.open).obstacles.map((r) => r.slice());
+ coverable = new Uint8Array(NCELL); NCOVER = 0;
+ for (let c = 0; c < NCELL; c++) {
+ if (!inObstacle(cx(c), cy(c))) { coverable[c] = 1; NCOVER++; }
+ }
+ basePos = { x: WORLD / 2, y: WORLD * 0.055 };
+}
+
+/* ── strategies ──────────────────────────────────────────────────────── */
+const STRATS = [
+ { id: "independent", nm: "Lone wolves",
+ ds: "Radio off. Every robot maps on its own.",
+ prose: "No coordination at all — the baseline. Each robot just walks to the nearest patch it personally hasn't seen. It's robust (nothing to break) but two robots happily search the same ground because neither knows what the other did. Watch the overlap count climb." },
+ { id: "gossip", nm: "Gossip",
+ ds: "Tell neighbours what you mapped; skip what they've covered.",
+ prose: "Each robot broadcasts what it just mapped to anyone in range, and believes what it hears — map knowledge floods hop-by-hop, libp2p gossipsub-style. Robots stop re-walking ground a neighbour already covered… but with no claims, two linked robots often pick the SAME nearest frontier and clump. Bounded by airtime and range." },
+ { id: "auction", nm: "Claim & yield",
+ ds: "Call dibs on your next cell; yield to a closer robot.",
+ prose: "A tiny market. Before committing to a cell, a robot announces a claim with its distance; if a closer robot already claimed it, it yields and picks another. Sharing INTENT (not just the map) de-conflicts who-goes-where, so it usually finishes first — as long as everyone stays linked." },
+ { id: "leader", nm: "One commander",
+ ds: "A leader hands out non-overlapping targets.",
+ prose: "The lowest-id robot reachable in each radio cluster becomes the leader and hands out non-overlapping targets. Centralised and tidy — but a robot that drifts out of the leader's range gets no orders and falls back to lone-wolf. A lesson in single-point-of-failure vs. range." },
+ { id: "custom", nm: "✨ Your algorithm",
+ ds: "Write it yourself — or have your local LLM draft one.",
+ prose: "" },
+];
+const CUSTOM_TEMPLATE = `// Per-robot policy — runs ~5x/sec for EVERY robot.
+// r = this robot, H = helpers. Edit this, then press Run.
+for (const m of H.inbox(r)) { // read what neighbours told me
+ if (m.kind === 'cells') H.learn(r, m.cells);
+ if (m.kind === 'claim') H.reserve(r, m.cell, m.by, m.dist);
+}
+H.shareMapped(r); // tell neighbours what I just sensed
+if (H.arrived(r)) { // free? pick one new goal (one job at a time)
+ const cell = H.nearestUnknown(r, true); // skip ground a closer robot claimed
+ if (cell >= 0) H.broadcastClaim(r, cell);
+ H.goto(r, cell);
+}`;
+const CUSTOM_API = `r.id, r.x, r.y this robot's id and position (metres)
+r.payload Set of data ids it is carrying toward base
+H.arrived(r) true when free to choose a new goal
+H.inbox(r) drained messages: {kind:'cells',cells} | {kind:'claim',cell,by,dist}
+H.learn(r, cells) merge map cells a peer told you about
+H.reserve(r,cell,by,dist) record a peer's claim on a cell
+H.shareMapped(r) broadcast cells you just sensed (uses 1 airtime)
+H.broadcastClaim(r,cell) announce you are taking this cell
+H.claimedByOther(r,cell) did a closer robot already claim it?
+H.nearestUnknown(r, avoidClaims) nearest unmapped cell, or -1
+H.goto(r, cell) commit to a cell (robot moves to ONE goal at a time)
+H.neighborCount(r) robots in radio range right now
+H.knows(r, cell) already mapped (sensed or heard)?`;
+const CODE = {
+ independent: `@behavior(hz=5)
+def explore(robot):
+ if robot.arrived(): # one move at a time
+ robot.goto(robot.nearest_unknown())`,
+ gossip: `@behavior(hz=5)
+def explore(robot):
+ for cell in robot.radio.recv(): # drain inbox (capped by memory)
+ robot.note_mapped(cell) # merge — now I skip it too
+ fresh = robot.just_mapped()
+ if fresh:
+ robot.radio.broadcast(fresh) # one slice of airtime
+ if robot.arrived():
+ robot.goto(robot.nearest_unknown())`,
+ auction: `@behavior(hz=5)
+def explore(robot):
+ for m in robot.radio.recv():
+ if m.kind == "cells": robot.note_mapped(m.cells)
+ if m.kind == "claim": robot.reserve(m.cell, m.by, m.dist)
+ if robot.arrived():
+ cell = robot.nearest_unclaimed() # skip a closer bot's cell
+ robot.radio.broadcast_claim(cell) # call dibs
+ robot.goto(cell)`,
+ leader: `@behavior(hz=5)
+def explore(robot):
+ leader = robot.radio.leader_in_range() # None if I drifted off
+ if robot.is_leader:
+ for req in robot.radio.recv("request"):
+ robot.radio.assign(req.sender, robot.pick_cell_for(req.sender))
+ elif leader and robot.arrived():
+ robot.radio.request(leader)
+ robot.goto(robot.assigned_cell())
+ elif robot.arrived():
+ robot.goto(robot.nearest_unknown()) # out of range → solo`,
+};
+
+/* ── state ───────────────────────────────────────────────────────────── */
+const PALETTE = ["#00d47e", "#40a0e0", "#e0a030", "#d84a4a", "#a060f0", "#56b6c2",
+ "#e06aa0", "#7ad07a", "#d0d040", "#6a90ff", "#ff9a5a", "#5ad0c0",
+ "#c678dd", "#9fb0bd", "#40e0a0", "#e0e0e0"];
+let coveredMask = new Int32Array(NCELL); // bitmask of robots that mapped a cell
+let bots = [], targets = [], base = null;
+const msgs = []; // in-flight visuals
+let claims = new Map();
+let metrics, t0, paused = false, lastT = 0, hoverBot = null, hoverXY = null;
+let customFn = null; // compiled user/LLM strategy: (r, H) => void
+const rng = () => Math.random();
+
+/* the stable surface a custom (or LLM-written) policy gets — same primitives
+ the built-in strategies use, nothing else, so user code can't reach into the
+ sim's guts. Each helper takes the robot first. */
+const H = {
+ arrived: (r) => arrived(r),
+ inbox: (r) => recv(r),
+ learn: (r, cells) => { if (cells) for (const c of cells) remember(r, c, "heard"); },
+ reserve: (r, cell, by, dist) => { const cur = claims.get(cell); if (!cur || dist < cur.dist) claims.set(cell, { by, dist }); },
+ shareMapped: (r) => { if (r.fresh.length) { const ok = broadcast(r, "cells", { cells: r.fresh.slice(0, 12) }); r.fresh = []; return ok; } return false; },
+ broadcastClaim: (r, cell) => { if (cell < 0) return false; const dist = Math.hypot(cx(cell) - r.x, cy(cell) - r.y);
+ claims.set(cell, { by: r.id, dist }); return broadcast(r, "claim", { cell, by: r.id, dist }); },
+ claimedByOther: (r, cell) => { const cl = claims.get(cell); return !!(cl && cl.by !== r.id); },
+ nearestUnknown: (r, avoid) => nearestUnknown(r, !!avoid),
+ goto: (r, cell) => setGoal(r, cell),
+ neighborCount: (r) => (r.neighbors || neighbors(r)).length,
+ knows: (r, c) => knows(r, c),
+};
+
+function spawnFleet() {
+ buildEnv(cfg.env);
+ coveredMask = new Int32Array(NCELL);
+ claims = new Map(); msgs.length = 0;
+ metrics = { sent: 0, recv: 0, drop: 0, redundant: 0, dataFound: 0, dataDelivered: 0 };
+ t0 = performance.now();
+ base = { delivered: new Set() };
+ // robots deploy from beside the base station and fan out
+ bots = [];
+ for (let i = 0; i < cfg.count; i++) {
+ let x, y, tries = 0;
+ do { x = basePos.x + (rng() - 0.5) * 12; y = basePos.y + 3 + rng() * 6; tries++; }
+ while (inObstacle(x, y, 0.6) && tries < 40);
+ bots.push({
+ id: i, x: clamp(x), y: clamp(y), heading: Math.PI / 2,
+ color: PALETTE[i % PALETTE.length], speed: 3.0 + rng() * 0.6,
+ goal: null, gcell: -1,
+ seen: new Set(), heard: new Set(), order: [], fresh: [],
+ everSeen: new Set(), everKnown: new Set(), // lifetime record (never evicted)
+ inbox: [], budget: cfg.bw, sent: 0, isLeader: false, assigned: -1,
+ payload: new Set(), // data points carried but not yet at base
+ });
+ }
+ // scatter data points on open ground
+ targets = [];
+ for (let i = 0; i < cfg.targets; i++) {
+ let x, y, tries = 0;
+ do { x = 4 + rng() * (WORLD - 8); y = WORLD * 0.25 + rng() * (WORLD * 0.7); tries++; }
+ while (inObstacle(x, y, 1) && tries < 60);
+ targets.push({ id: i, x, y, found: false });
+ }
+ sense(0);
+}
+const clamp = (v) => Math.max(0.5, Math.min(WORLD - 0.5, v));
+const knows = (r, c) => r.seen.has(c) || r.heard.has(c);
+
+/* ── sensing: map cells, discover data points, track overlap + memory ─── */
+function sense() {
+ for (const r of bots) {
+ const rc2 = SENSE * SENSE;
+ const gx0 = Math.max(0, (r.x - SENSE) / CELL | 0), gx1 = Math.min(CG - 1, (r.x + SENSE) / CELL | 0);
+ const gy0 = Math.max(0, (r.y - SENSE) / CELL | 0), gy1 = Math.min(CG - 1, (r.y + SENSE) / CELL | 0);
+ for (let gy = gy0; gy <= gy1; gy++) for (let gx = gx0; gx <= gx1; gx++) {
+ const c = gy * CG + gx; if (!coverable[c]) continue;
+ if ((cx(c) - r.x) ** 2 + (cy(c) - r.y) ** 2 > rc2) continue;
+ const bit = 1 << r.id;
+ if (!(coveredMask[c] & bit)) {
+ if (coveredMask[c] !== 0) metrics.redundant++;
+ coveredMask[c] |= bit;
+ }
+ remember(r, c, "seen");
+ }
+ // discover data points in sensing range
+ for (const tg of targets) {
+ if ((tg.x - r.x) ** 2 + (tg.y - r.y) ** 2 <= rc2) {
+ if (!tg.found) { tg.found = true; metrics.dataFound++; }
+ if (cfg.base && !base.delivered.has(tg.id)) r.payload.add(tg.id);
+ }
+ }
+ }
+}
+function remember(r, c, via) {
+ r.everKnown.add(c); // lifetime record, never forgotten
+ if (via === "seen") r.everSeen.add(c);
+ if (r.seen.has(c)) return;
+ if (via === "seen") { r.seen.add(c); r.heard.delete(c); r.fresh.push(c); }
+ else if (!r.heard.has(c)) r.heard.add(c);
+ else return;
+ r.order.push(c);
+ while (r.order.length > cfg.mem) { // finite memory forgets ground
+ const old = r.order.shift(); r.seen.delete(old); r.heard.delete(old);
+ }
+}
+
+/* ── radio ───────────────────────────────────────────────────────────── */
+function neighbors(r) {
+ const out = [];
+ for (const o of bots) {
+ if (o === r) continue;
+ const d = Math.hypot(o.x - r.x, o.y - r.y);
+ if (d <= cfg.range && los(r.x, r.y, o.x, o.y)) out.push({ o, d });
+ }
+ return out;
+}
+const KCOLOR = { cells: "#00d47e", claim: "#e0a030", request: "#40a0e0", assign: "#40a0e0", data: "#7ad07a" };
+function visMsg(ax, ay, bx, by, kind, dropped) {
+ if (msgs.length < 240) msgs.push({ ax, ay, bx, by, t: 0, life: 0.3,
+ color: KCOLOR[kind] || "#9fb0bd", dropped });
+}
+function deliver(from, to, kind, data, d) {
+ const p = (cfg.loss / 100) * (1 - (d / cfg.range) ** 2);
+ const ok = rng() < p;
+ visMsg(from.x, from.y, to.x ?? basePos.x, to.y ?? basePos.y, kind, !ok);
+ if (!ok) { metrics.drop++; return false; }
+ if (to.inbox && to.inbox.length >= cfg.buf) { metrics.drop++; return false; }
+ if (to.inbox) to.inbox.push({ kind, ...data, from: from.id, d });
+ metrics.recv++; return true;
+}
+function broadcast(r, kind, data) {
+ if (r.budget < 1) return false;
+ r.budget -= 1; r.sent++; metrics.sent++;
+ for (const { o, d } of r.neighbors) deliver(r, o, kind, data, d);
+ return true;
+}
+function unicast(r, to, d, kind, data) {
+ if (r.budget < 1) return false;
+ r.budget -= 1; r.sent++; metrics.sent++;
+ return deliver(r, to, kind, data, d);
+}
+function recv(r) {
+ const proc = Math.max(2, Math.ceil(cfg.bw));
+ return r.inbox.splice(0, proc);
+}
+
+/* ── goal selection ──────────────────────────────────────────────────── */
+function nearestUnknown(r, avoidClaims) {
+ let best = -1, bd = 1e9;
+ for (let c = 0; c < NCELL; c++) {
+ if (!coverable[c] || knows(r, c)) continue;
+ const d = Math.hypot(cx(c) - r.x, cy(c) - r.y);
+ if (avoidClaims) { const cl = claims.get(c); if (cl && cl.by !== r.id && cl.dist <= d) continue; }
+ if (d < bd) { bd = d; best = c; }
+ }
+ if (best < 0 && r.order.length) best = r.order[(rng() * r.order.length) | 0];
+ return best;
+}
+function setGoal(r, c) {
+ if (c < 0) { r.goal = null; r.gcell = -1; return; }
+ r.gcell = c; r.goal = { x: cx(c), y: cy(c) };
+}
+function arrived(r) { return !r.goal || Math.hypot(r.goal.x - r.x, r.goal.y - r.y) < CELL * 0.6; }
+
+/* ── per-strategy thinking ───────────────────────────────────────────── */
+function think() {
+ if (cfg.strat === "custom") {
+ if (customFn) for (const r of bots) {
+ r.neighbors = neighbors(r);
+ try { customFn(r, H); }
+ catch (e) { paused = true; $("custom-stat").textContent = "runtime error: " + e.message;
+ $("custom-stat").className = "err"; $("btn-play").textContent = "▶ RESUME"; break; }
+ }
+ if (cfg.base) relayData();
+ return;
+ }
+ if (cfg.strat === "leader") electLeaders();
+ for (const r of bots) {
+ r.neighbors = neighbors(r);
+ if (cfg.strat === "independent") {
+ if (arrived(r)) setGoal(r, nearestUnknown(r, false));
+ } else if (cfg.strat === "gossip") {
+ for (const m of recv(r)) if (m.cells) for (const c of m.cells) remember(r, c, "heard");
+ if (r.fresh.length) { broadcast(r, "cells", { cells: r.fresh.slice(0, 12) }); r.fresh = []; }
+ if (arrived(r)) setGoal(r, nearestUnknown(r, false));
+ } else if (cfg.strat === "auction") {
+ for (const m of recv(r)) {
+ if (m.kind === "cells") for (const c of m.cells) remember(r, c, "heard");
+ if (m.kind === "claim") { const cur = claims.get(m.cell);
+ if (!cur || m.dist < cur.dist) claims.set(m.cell, { by: m.by, dist: m.dist }); }
+ }
+ if (r.fresh.length) { broadcast(r, "cells", { cells: r.fresh.slice(0, 12) }); r.fresh = []; }
+ if (arrived(r)) {
+ const c = nearestUnknown(r, true);
+ if (c >= 0) { const dist = Math.hypot(cx(c) - r.x, cy(c) - r.y);
+ claims.set(c, { by: r.id, dist }); broadcast(r, "claim", { cell: c, by: r.id, dist }); }
+ setGoal(r, c);
+ }
+ } else if (cfg.strat === "leader") {
+ for (const m of recv(r)) {
+ if (m.kind === "cells") for (const c of m.cells) remember(r, c, "heard");
+ if (m.kind === "assign") r.assigned = m.cell;
+ if (m.kind === "request" && r.isLeader) {
+ const c = leaderPick(r, m.from);
+ if (c >= 0) claims.set(c, { by: m.from, dist: 0 });
+ const tgt = bots[m.from]; if (tgt) unicast(r, tgt, m.d, "assign", { cell: c });
+ }
+ }
+ if (r.fresh.length) { broadcast(r, "cells", { cells: r.fresh.slice(0, 12) }); r.fresh = []; }
+ if (arrived(r)) {
+ if (r.isLeader) setGoal(r, leaderPick(r, r.id));
+ else { const leader = r.neighbors.find((nb) => nb.o.isLeader);
+ if (leader) { unicast(r, leader.o, leader.d, "request", {});
+ setGoal(r, r.assigned >= 0 && !knows(r, r.assigned) ? r.assigned : nearestUnknown(r, true)); }
+ else setGoal(r, nearestUnknown(r, false)); }
+ }
+ }
+ }
+ if (cfg.base) relayData();
+}
+function electLeaders() {
+ const parent = bots.map((_, i) => i);
+ const find = (a) => { while (parent[a] !== a) { parent[a] = parent[parent[a]]; a = parent[a]; } return a; };
+ for (const r of bots) for (const o of bots) {
+ if (o.id <= r.id) continue;
+ if (Math.hypot(o.x - r.x, o.y - r.y) <= cfg.range && los(r.x, r.y, o.x, o.y)) {
+ const ra = find(r.id), rb = find(o.id); if (ra !== rb) parent[Math.max(ra, rb)] = Math.min(ra, rb);
+ }
+ }
+ const leaderOf = bots.map((_, i) => find(i));
+ for (const r of bots) r.isLeader = leaderOf[r.id] === r.id;
+}
+function leaderPick(leader, forId) {
+ const tgt = bots[forId] || leader; let best = -1, bd = 1e9;
+ for (let c = 0; c < NCELL; c++) {
+ if (!coverable[c] || leader.seen.has(c) || leader.heard.has(c)) continue;
+ const cl = claims.get(c); if (cl && cl.by !== forId) continue;
+ const d = Math.hypot(cx(c) - tgt.x, cy(c) - tgt.y);
+ if (d < bd) { bd = d; best = c; }
+ }
+ return best;
+}
+
+/* ── data relay: greedy geographic routing toward the base station ────── */
+function relayData() {
+ const dB = (n) => Math.hypot(n.x - basePos.x, n.y - basePos.y);
+ for (const r of bots) {
+ if (!r.payload.size || r.budget < 1) continue;
+ const myD = dB(r);
+ if (myD <= cfg.range && los(r.x, r.y, basePos.x, basePos.y)) {
+ // base in reach: upload everything
+ r.budget -= 1; r.sent++; metrics.sent++;
+ visMsg(r.x, r.y, basePos.x, basePos.y, "data", false);
+ for (const id of r.payload) if (!base.delivered.has(id)) { base.delivered.add(id); metrics.dataDelivered++; }
+ r.payload.clear();
+ continue;
+ }
+ // else hand off to the in-range neighbour strictly closer to base
+ let best = null, bestD = myD;
+ for (const { o } of (r.neighbors || neighbors(r))) { const od = dB(o); if (od < bestD) { bestD = od; best = o; } }
+ if (best) {
+ r.budget -= 1; r.sent++; metrics.sent++;
+ visMsg(r.x, r.y, best.x, best.y, "data", false);
+ for (const id of r.payload) best.payload.add(id);
+ r.payload.clear(); // custody transfer downhill
+ }
+ // else: carry it like a data mule until a downhill neighbour appears
+ }
+}
+
+/* ── motion with obstacle avoidance ──────────────────────────────────── */
+function move(dt) {
+ for (const r of bots) {
+ if (!r.goal) continue;
+ const dx = r.goal.x - r.x, dy = r.goal.y - r.y, d = Math.hypot(dx, dy);
+ if (d < 1e-3) continue;
+ let want = Math.atan2(dy, dx);
+ // steer around obstacles: if the path just ahead is blocked, veer
+ const probe = 2.2;
+ if (inObstacle(r.x + Math.cos(want) * probe, r.y + Math.sin(want) * probe, 0.3)) {
+ const left = !inObstacle(r.x + Math.cos(want - 0.9) * probe, r.y + Math.sin(want - 0.9) * probe, 0.3);
+ want += left ? -0.9 : 0.9;
+ }
+ let dh = (want - r.heading + Math.PI) % (2 * Math.PI) - Math.PI;
+ r.heading += Math.max(-3 * dt, Math.min(3 * dt, dh));
+ const step = Math.min(d, r.speed * dt * Math.max(0.2, Math.cos(dh)));
+ let nx = r.x + Math.cos(r.heading) * step, ny = r.y + Math.sin(r.heading) * step;
+ if (inObstacle(nx, ny, 0.3)) { // slide along the wall it hit
+ if (!inObstacle(nx, r.y, 0.3)) ny = r.y;
+ else if (!inObstacle(r.x, ny, 0.3)) nx = r.x;
+ else { nx = r.x; ny = r.y; r.goal = null; } // boxed in — retask
+ }
+ r.x = clamp(nx); r.y = clamp(ny);
+ }
+}
+
+/* ── one tick ────────────────────────────────────────────────────────── */
+function step(dt) {
+ for (const r of bots) r.budget = Math.min(cfg.bw, r.budget + cfg.bw * dt);
+ think(); move(dt); sense();
+ for (let i = msgs.length - 1; i >= 0; i--) { msgs[i].t += dt; if (msgs[i].t >= msgs[i].life) msgs.splice(i, 1); }
+}
+
+/* ── view transform ──────────────────────────────────────────────────── */
+// The canvas fills #canvas-wrap inside the app shell (it is NOT the full
+// viewport), so the view is sized from the canvas's own client box. The world
+// square is centred within that box; ox/oy/s carry the device-pixel scale.
+const dpr = () => Math.min(2, devicePixelRatio || 1);
+let VIEW = { s: 1, ox: 0, oy: 0 };
+function resize() {
+ const k = dpr();
+ const cw = cv.clientWidth || 120, ch = cv.clientHeight || 120;
+ cv.width = Math.round(cw * k); cv.height = Math.round(ch * k);
+ const pad = 18; // breathing room inside the stage
+ const availW = Math.max(60, cw - pad * 2), availH = Math.max(60, ch - pad * 2);
+ const s = Math.max(4, Math.min(availW, availH) / WORLD);
+ VIEW.s = s * k;
+ VIEW.ox = (pad + (availW - WORLD * s) / 2) * k;
+ VIEW.oy = (pad + (availH - WORLD * s) / 2) * k;
+}
+addEventListener("resize", resize);
+// keep the canvas matched to its container as the layout reflows
+if (typeof ResizeObserver !== "undefined") new ResizeObserver(resize).observe(cv);
+const wx = (x) => VIEW.ox + x * VIEW.s, wy = (y) => VIEW.oy + y * VIEW.s;
+// pointer events arrive in viewport coords → subtract the canvas box origin
+const screenToWorld = (clientX, clientY) => {
+ const k = dpr();
+ const rect = cv.getBoundingClientRect();
+ return { x: ((clientX - rect.left) * k - VIEW.ox) / VIEW.s,
+ y: ((clientY - rect.top) * k - VIEW.oy) / VIEW.s };
+};
+
+/* ── rendering ───────────────────────────────────────────────────────── */
+function draw() {
+ ctx.clearRect(0, 0, cv.width, cv.height);
+ const s = VIEW.s, k = dpr();
+ ctx.fillStyle = "#0c1116"; ctx.fillRect(wx(0), wy(0), WORLD * s, WORLD * s);
+
+ // coverage heat
+ for (let c = 0; c < NCELL; c++) {
+ const m = coveredMask[c]; if (!m) continue;
+ let bits = 0, v = m; while (v) { bits += v & 1; v >>= 1; }
+ ctx.fillStyle = bits > 1 ? "rgba(224,160,48,.17)" : "rgba(0,212,126,.10)";
+ ctx.fillRect(wx(cx(c) - CELL / 2), wy(cy(c) - CELL / 2), CELL * s + 1, CELL * s + 1);
+ }
+ // hovered robot's knowledge, three tiers: faint = it once knew but has since
+ // forgotten (memory full); filled = sensed firsthand & still in memory;
+ // outline = heard from a peer & still in memory.
+ if (hoverBot) {
+ for (const c of hoverBot.everKnown) { // forgotten footprint
+ if (hoverBot.seen.has(c) || hoverBot.heard.has(c)) continue;
+ ctx.fillStyle = hoverBot.color + "14";
+ ctx.fillRect(wx(cx(c) - CELL / 2), wy(cy(c) - CELL / 2), CELL * s + 1, CELL * s + 1);
+ }
+ for (const c of hoverBot.seen) { ctx.fillStyle = hoverBot.color + "44";
+ ctx.fillRect(wx(cx(c) - CELL / 2), wy(cy(c) - CELL / 2), CELL * s + 1, CELL * s + 1); }
+ ctx.strokeStyle = hoverBot.color + "88"; ctx.lineWidth = 1;
+ for (const c of hoverBot.heard) ctx.strokeRect(wx(cx(c) - CELL / 2) + 1, wy(cy(c) - CELL / 2) + 1, CELL * s - 1, CELL * s - 1);
+ }
+
+ // grid + obstacles + border
+ ctx.strokeStyle = "rgba(31,42,51,.5)"; ctx.lineWidth = 1; ctx.beginPath();
+ for (let i = 0; i <= CG; i += 2) { ctx.moveTo(wx(i * CELL), wy(0)); ctx.lineTo(wx(i * CELL), wy(WORLD));
+ ctx.moveTo(wx(0), wy(i * CELL)); ctx.lineTo(wx(WORLD), wy(i * CELL)); }
+ ctx.stroke();
+ for (const [ox, oy, ow, oh] of obstacles) {
+ ctx.fillStyle = "#2e3c4a"; ctx.fillRect(wx(ox), wy(oy), ow * s, oh * s);
+ ctx.strokeStyle = "#3e4e5e"; ctx.lineWidth = 1; ctx.strokeRect(wx(ox), wy(oy), ow * s, oh * s);
+ }
+ ctx.strokeStyle = "rgba(42,58,70,.9)"; ctx.lineWidth = 1.5; ctx.strokeRect(wx(0), wy(0), WORLD * s, WORLD * s);
+
+ // radio links
+ if (cfg.strat !== "independent") {
+ for (let i = 0; i < bots.length; i++) for (let j = i + 1; j < bots.length; j++) {
+ const a = bots[i], b = bots[j], d = Math.hypot(a.x - b.x, a.y - b.y);
+ if (d > cfg.range || !los(a.x, a.y, b.x, b.y)) continue;
+ const edge = d / cfg.range;
+ ctx.strokeStyle = edge > 0.8 ? "rgba(224,160,48,.32)" : `rgba(0,212,126,${0.26 * (1 - edge) + 0.06})`;
+ ctx.lineWidth = 1; ctx.beginPath(); ctx.moveTo(wx(a.x), wy(a.y)); ctx.lineTo(wx(b.x), wy(b.y)); ctx.stroke();
+ }
+ }
+
+ // base station coverage ring + data points
+ if (cfg.base) drawBase(s, k);
+ drawTargets(s, k);
+
+ // goal threads + sensing rings
+ for (const r of bots) {
+ if (r.goal) { ctx.strokeStyle = r.color + "33"; ctx.lineWidth = 1; ctx.setLineDash([3, 4]);
+ ctx.beginPath(); ctx.moveTo(wx(r.x), wy(r.y)); ctx.lineTo(wx(r.goal.x), wy(r.goal.y)); ctx.stroke(); ctx.setLineDash([]); }
+ ctx.strokeStyle = r.color + (hoverBot === r ? "66" : "22"); ctx.lineWidth = hoverBot === r ? 1.5 : 1;
+ ctx.beginPath(); ctx.arc(wx(r.x), wy(r.y), SENSE * s, 0, 7); ctx.stroke();
+ }
+ for (const r of bots) drawBot(r, s, k);
+
+ // messages in flight
+ for (const m of msgs) {
+ const f = m.t / m.life, x = m.ax + (m.bx - m.ax) * f, y = m.ay + (m.by - m.ay) * f;
+ if (m.dropped) { ctx.fillStyle = "#d84a4a"; ctx.globalAlpha = 1 - f;
+ ctx.font = `${Math.round(11 * k)}px ui-monospace`; ctx.fillText("✕", wx(m.ax) - 4, wy(m.ay) - 6 - f * 12 * k); ctx.globalAlpha = 1; }
+ else { ctx.fillStyle = m.color; ctx.globalAlpha = 0.9 * (1 - f * 0.5);
+ ctx.beginPath(); ctx.arc(wx(x), wy(y), 2.6 * k, 0, 7); ctx.fill(); ctx.globalAlpha = 1; }
+ }
+}
+function drawBase(s, k) {
+ const x = wx(basePos.x), y = wy(basePos.y);
+ ctx.strokeStyle = "rgba(0,212,126,.18)"; ctx.lineWidth = 1; ctx.setLineDash([2, 5]);
+ ctx.beginPath(); ctx.arc(x, y, cfg.range * s, 0, 7); ctx.stroke(); ctx.setLineDash([]);
+ ctx.fillStyle = "#00d47e"; const r = 6 * k;
+ ctx.beginPath(); ctx.moveTo(x, y - r * 1.4); ctx.lineTo(x + r, y + r); ctx.lineTo(x - r, y + r); ctx.closePath(); ctx.fill();
+ ctx.fillStyle = "#06080a"; ctx.fillRect(x - r * 0.3, y - r * 0.2, r * 0.6, r * 0.7);
+ ctx.fillStyle = "rgba(0,212,126,.85)"; ctx.font = `${Math.round(9 * k)}px ui-monospace`;
+ ctx.textAlign = "center"; ctx.fillText("BASE", x, y + r * 2.6); ctx.textAlign = "left";
+}
+function drawTargets(s, k) {
+ for (const tg of targets) {
+ const x = wx(tg.x), y = wy(tg.y), done = base && base.delivered.has(tg.id);
+ if (done) { ctx.fillStyle = "#00d47e"; ctx.beginPath(); ctx.arc(x, y, 3.5 * k, 0, 7); ctx.fill();
+ ctx.strokeStyle = "rgba(0,212,126,.35)"; ctx.lineWidth = 1; ctx.beginPath(); ctx.arc(x, y, 6 * k, 0, 7); ctx.stroke(); }
+ else if (tg.found) { ctx.fillStyle = "#e0a030"; ctx.beginPath(); ctx.arc(x, y, 3.5 * k, 0, 7); ctx.fill(); }
+ else { ctx.strokeStyle = "#5a6b78"; ctx.lineWidth = 1.2 * k; ctx.beginPath(); ctx.arc(x, y, 3.5 * k, 0, 7); ctx.stroke();
+ ctx.fillStyle = "rgba(90,107,120,.25)"; ctx.fill(); }
+ }
+}
+function drawBot(r, s, k) {
+ const x = wx(r.x), y = wy(r.y), sz = 6.5 * k;
+ ctx.save(); ctx.translate(x, y); ctx.rotate(r.heading);
+ ctx.fillStyle = r.color; ctx.beginPath();
+ ctx.moveTo(sz * 1.3, 0); ctx.lineTo(-sz * 0.8, sz * 0.8); ctx.lineTo(-sz * 0.4, 0); ctx.lineTo(-sz * 0.8, -sz * 0.8);
+ ctx.closePath(); ctx.fill(); ctx.restore();
+ if (hoverBot === r) { ctx.strokeStyle = "#fff"; ctx.lineWidth = 1.5 * k; ctx.beginPath(); ctx.arc(x, y, sz * 1.5, 0, 7); ctx.stroke(); }
+ if (r.isLeader && cfg.strat === "leader") { ctx.strokeStyle = "#ffd24a"; ctx.lineWidth = 1.5 * k;
+ ctx.beginPath(); ctx.arc(x, y, sz * 1.8, 0, 7); ctx.stroke(); }
+ if (r.payload.size) { ctx.fillStyle = "#7ad07a"; ctx.beginPath(); ctx.arc(x + sz, y - sz, 2.4 * k, 0, 7); ctx.fill(); }
+ const fill = r.inbox.length / cfg.buf;
+ if (fill > 0.01) { ctx.fillStyle = fill > 0.9 ? "#d84a4a" : "#e0a030";
+ ctx.fillRect(x - sz, y - sz * 2.2, sz * 2 * Math.min(1, fill), 2 * k); }
+}
+
+/* ── hover inspector ─────────────────────────────────────────────────── */
+function updateInspector() {
+ const el = $("inspect");
+ if (!hoverBot || !hoverXY) { el.classList.remove("on"); return; }
+ const r = hoverBot, nb = (r.neighbors || neighbors(r)).length;
+ const mapped = r.seen.size, heard = r.heard.size, inMem = mapped + heard;
+ const everKnown = r.everKnown.size, forgotten = Math.max(0, everKnown - inMem);
+ const pct = Math.round(inMem / Math.max(1, NCOVER) * 100);
+ const note = nb ? `linked to ${nb} peer${nb > 1 ? "s" : ""} — sharing what it sees`
+ : "out of radio range — searching solo, no one to tell";
+ el.innerHTML =
+ `` +
+ `robot ${r.id}` +
+ `${r.isLeader && cfg.strat === "leader" ? 'LEADER' : ''}` +
+ `IN MEMORY NOW · ${inMem}/${cfg.mem} cells` +
+ `sensed firsthand${mapped}` +
+ `heard from peers${heard}` +
+ `EVER KNOWN · its whole history` +
+ `cells discovered${everKnown}` +
+ `forgotten (memory full)${forgotten}` +
+ `RIGHT NOW` +
+ `inbox${r.inbox.length} / ${cfg.buf}` +
+ `messages sent${r.sent}` +
+ (cfg.base ? `data carried${r.payload.size}` : "") +
+ `${note}. Filled = sensed firsthand, outlined = heard over radio, faint = once knew but forgot.`;
+ el.classList.add("on");
+ const w = 232, pad = 14;
+ let lx = hoverXY.x + 18, ly = hoverXY.y + 14;
+ if (lx + w > innerWidth - pad) lx = hoverXY.x - w - 18;
+ if (ly + 200 > innerHeight - pad) ly = innerHeight - 210;
+ el.style.left = Math.max(pad, lx) + "px"; el.style.top = Math.max(pad, ly) + "px";
+}
+
+/* ── HUD ─────────────────────────────────────────────────────────────── */
+let tickerDone = "";
+function setV(id, v, cls) { const e = $(id); e.textContent = v; e.className = "v" + (cls ? " " + cls : ""); }
+function updateHUD() {
+ let covered = 0; for (let c = 0; c < NCELL; c++) if (coveredMask[c]) covered++;
+ const pct = Math.round(covered / Math.max(1, NCOVER) * 100);
+ $("m-cover").textContent = pct + "%"; $("cover-fill").style.width = Math.min(100, pct) + "%";
+ setV("m-redundant", metrics.redundant, metrics.redundant > covered * 0.6 ? "bad" : metrics.redundant > covered * 0.3 ? "warn" : "");
+ let linked = 0; for (const r of bots) if (neighbors(r).length) linked++;
+ $("m-linked").textContent = `${linked}/${bots.length}`;
+ $("m-recv").textContent = metrics.recv;
+ const lossPct = Math.round(metrics.drop / Math.max(1, metrics.drop + metrics.recv) * 100);
+ setV("m-drop", `${metrics.drop} · ${lossPct}%`, lossPct > 45 ? "bad" : lossPct > 25 ? "warn" : "");
+ const dataTotal = targets.length;
+ const dataNum = cfg.base ? metrics.dataDelivered : metrics.dataFound;
+ setV("m-data", `${dataNum}/${dataTotal}`, "");
+ $("m-time").textContent = ((performance.now() - t0) / 1000).toFixed(1) + "s";
+ if (pct >= 99 && !tickerDone) {
+ tickerDone = `field mapped in ${((performance.now() - t0) / 1000).toFixed(1)}s · ` +
+ `${metrics.redundant} overlapping re-walks · ${metrics.drop} messages dropped` +
+ (cfg.base ? ` · ${metrics.dataDelivered}/${dataTotal} data home` : "");
+ $("ticker").textContent = tickerDone;
+ }
+}
+
+/* ── loop ────────────────────────────────────────────────────────────── */
+function loop(now) {
+ const dt = Math.min(0.05, (now - lastT) / 1000 || 0.016); lastT = now;
+ if (!paused) { step(dt); updateHUD(); }
+ draw(); updateInspector();
+ requestAnimationFrame(loop);
+}
+
+/* ── teaching panel ──────────────────────────────────────────────────── */
+const HL_KW = new Set(["def", "for", "in", "if", "elif", "else", "return", "from", "import",
+ "None", "True", "False", "and", "or", "not", "while", "is", "with", "as"]);
+const hlEsc = (s) => s.replace(/&/g, "&").replace(//g, ">");
+function highlight(src) {
+ let out = "", i = 0; const n = src.length;
+ while (i < n) { const c = src[i];
+ if (c === "#") { let j = i; while (j < n && src[j] !== "\n") j++; out += `${hlEsc(src.slice(i, j))}`; i = j; continue; }
+ if (c === '"' || c === "'") { const q = c; let j = i + 1; while (j < n && src[j] !== q && src[j] !== "\n") { if (src[j] === "\\") j++; j++; }
+ out += `${hlEsc(src.slice(i, j + 1))}`; i = j + 1; continue; }
+ if (c === "@") { let j = i + 1; while (j < n && /[\w.]/.test(src[j])) j++; out += `${hlEsc(src.slice(i, j))}`; i = j; continue; }
+ if (/[A-Za-z_]/.test(c)) { let j = i; while (j < n && /\w/.test(src[j])) j++; const w = src.slice(i, j), prev = src.slice(0, i).trimEnd();
+ const cls = HL_KW.has(w) ? "tk-kw" : prev.endsWith("def") ? "tk-def" : null; out += cls ? `${w}` : hlEsc(w); i = j; continue; }
+ if (/[0-9]/.test(c)) { let j = i; while (j < n && /[\d.]/.test(src[j])) j++; out += `${hlEsc(src.slice(i, j))}`; i = j; continue; }
+ out += hlEsc(c); i++; }
+ return out;
+}
+function showStrategyDoc() {
+ const custom = cfg.strat === "custom";
+ $("builtin-ui").style.display = custom ? "none" : "";
+ $("custom-ui").style.display = custom ? "" : "none";
+ if (custom) {
+ if (!$("custom-code").value.trim()) $("custom-code").value = CUSTOM_TEMPLATE;
+ $("custom-api").textContent = CUSTOM_API;
+ return;
+ }
+ const s = STRATS.find((x) => x.id === cfg.strat) || STRATS[1];
+ $("s-name").textContent = s.nm; $("s-prose").textContent = s.prose;
+ $("s-code").firstChild.innerHTML = highlight(CODE[cfg.strat]);
+}
+/* compile + run whatever is in the editor */
+function runCustom() {
+ const body = $("custom-code").value;
+ const stat = $("custom-stat");
+ try { customFn = new Function("r", "H", body); }
+ catch (e) { customFn = null; stat.textContent = "compile error: " + e.message; stat.className = "err"; return; }
+ cfg.strat = "custom";
+ for (const c of $("strats").children) c.classList.toggle("on", c.dataset.strat === "custom");
+ paused = false; $("btn-play").textContent = "⏸ PAUSE"; $("btn-play").classList.remove("hot");
+ restart();
+ stat.textContent = "running your algorithm"; stat.className = "ok";
+}
+/* ask the local runtime's LLM to draft a policy from the goal box */
+async function generateCustom() {
+ const goal = $("custom-goal").value.trim();
+ const stat = $("custom-stat"), btn = $("custom-llm");
+ if (!goal) { stat.textContent = "describe what you want first"; stat.className = "err"; return; }
+ stat.textContent = "asking your local LLM…"; stat.className = ""; btn.disabled = true;
+ try {
+ const res = await fetch("/api/fleet/strategy", { method: "POST",
+ headers: { "Content-Type": "application/json" }, body: JSON.stringify({ goal }) });
+ const j = await res.json();
+ if (!res.ok || !j.ok) throw new Error(j.error || res.statusText);
+ $("custom-code").value = j.code;
+ stat.textContent = `drafted by ${j.model || "the LLM"} — review it, then ▶ Run`; stat.className = "ok";
+ } catch (e) {
+ stat.textContent = "no LLM reachable — run `roborun` locally with an API key, or write it by hand";
+ stat.className = "err";
+ } finally { btn.disabled = false; }
+}
+
+/* ── wiring ──────────────────────────────────────────────────────────── */
+function syncLabels() {
+ $("v-count").textContent = cfg.count; $("v-range").textContent = cfg.range + " m";
+ $("v-loss").textContent = cfg.loss + "%"; $("v-bw").textContent = cfg.bw + " / s";
+ $("v-mem").textContent = cfg.mem; $("v-buf").textContent = cfg.buf; $("v-targets").textContent = cfg.targets;
+}
+function bindSlider(id, key, fmt, respawn) {
+ const el = $(id); el.value = cfg[key];
+ el.addEventListener("input", () => { cfg[key] = +el.value; syncLabels(); if (respawn) restart(); });
+}
+function restart() { spawnFleet(); tickerDone = ""; $("ticker").textContent =
+ `running · ${(STRATS.find((s) => s.id === cfg.strat) || STRATS[1]).nm.toLowerCase()} · ${cfg.env}`; }
+
+function buildStrats() {
+ const host = $("strats"); host.innerHTML = "";
+ for (const s of STRATS) {
+ const el = document.createElement("button");
+ el.className = "strat" + (s.id === cfg.strat ? " on" : "");
+ el.dataset.strat = s.id;
+ el.innerHTML = `${s.nm}${s.ds}`;
+ el.addEventListener("click", () => { cfg.strat = s.id;
+ for (const c of host.children) c.classList.remove("on"); el.classList.add("on");
+ showStrategyDoc(); switchPane("strategy");
+ // custom waits for ▶ Run (so an empty/broken editor doesn't run); others restart now
+ if (s.id === "custom") { if (customFn) restart(); else { $("ticker").textContent = "write or generate an algorithm, then ▶ Run"; } }
+ else restart();
+ });
+ host.appendChild(el);
+ }
+}
+function switchPane(name) {
+ for (const t of document.querySelectorAll(".tab")) t.classList.toggle("on", t.dataset.pane === name);
+ for (const p of document.querySelectorAll(".pane")) p.classList.toggle("on", p.id === "pane-" + name);
+}
+for (const t of document.querySelectorAll(".tab")) t.addEventListener("click", () => switchPane(t.dataset.pane));
+$("teach-exp").addEventListener("click", () => {
+ const t = $("teach"), on = t.classList.toggle("expanded"); $("scrim").classList.toggle("on", on);
+ $("teach-exp").textContent = on ? "⤡" : "⤢";
+});
+$("scrim").addEventListener("click", () => { $("teach").classList.remove("expanded"); $("scrim").classList.remove("on"); $("teach-exp").textContent = "⤢"; });
+$("btn-learn").addEventListener("click", () => { switchPane("concepts");
+ if (!$("teach").classList.contains("expanded")) $("teach-exp").click(); });
+
+$("btn-play").addEventListener("click", () => { paused = !paused;
+ $("btn-play").textContent = paused ? "▶ RESUME" : "⏸ PAUSE"; $("btn-play").classList.toggle("hot", paused); });
+$("btn-reset").addEventListener("click", restart);
+$("custom-run").addEventListener("click", runCustom);
+$("custom-llm").addEventListener("click", generateCustom);
+$("custom-goal").addEventListener("keydown", (e) => { if (e.key === "Enter") generateCustom(); });
+
+$("s-env").value = cfg.env;
+$("s-env").addEventListener("change", () => { cfg.env = $("s-env").value; restart(); });
+$("s-base").checked = cfg.base;
+$("s-base").addEventListener("change", () => { cfg.base = $("s-base").checked; restart(); });
+bindSlider("s-count", "count", null, true);
+bindSlider("s-targets", "targets", null, true);
+bindSlider("s-range", "range");
+bindSlider("s-loss", "loss");
+bindSlider("s-bw", "bw");
+bindSlider("s-mem", "mem");
+bindSlider("s-buf", "buf");
+
+// hover the field to inspect a robot
+cv.addEventListener("pointermove", (e) => {
+ hoverXY = { x: e.clientX, y: e.clientY };
+ const w = screenToWorld(e.clientX, e.clientY);
+ let best = null, bd = 2.6;
+ for (const r of bots) { const d = Math.hypot(r.x - w.x, r.y - w.y); if (d < bd) { bd = d; best = r; } }
+ hoverBot = best; cv.style.cursor = best ? "pointer" : "default";
+});
+cv.addEventListener("pointerleave", () => { hoverBot = null; hoverXY = null; });
+
+syncLabels(); buildStrats(); showStrategyDoc(); resize(); spawnFleet();
+$("ticker").textContent = `running · ${(STRATS.find((s) => s.id === cfg.strat) || STRATS[1]).nm.toLowerCase()} · ${cfg.env}`;
+requestAnimationFrame(loop);
diff --git a/roborun/web/home.html b/roborun/web/home.html
new file mode 100644
index 0000000..ba546a2
--- /dev/null
+++ b/roborun/web/home.html
@@ -0,0 +1,271 @@
+
+
+
+
+
+
+RoboRun · Home
+
+
+
+
+
+
+
+ Environments+ new
+ loading environments…
+ Data overviewexplore →
+
+ Recent runsbrowse all →
+
+
+
+
+
diff --git a/roborun/web/projects.html b/roborun/web/projects.html
new file mode 100644
index 0000000..2051d59
--- /dev/null
+++ b/roborun/web/projects.html
@@ -0,0 +1,254 @@
+
+
+
+
+
+
+RoboRun · Projects
+
+
+
+
+
+
+
+ Projects & environments
+ A project owns its data and timeline and can span environments. The active scope (set here or from the top bar) decides where everything you record — cockpit, scenarios, search — gets stored. scratch is the throwaway default so playing around never muddles real work.
+
+
+
+
+
+
+ New project
+ creates a project and switches you into it
+
+
+
+
+
+
+
+
+ Your projects
+
+
+ loading…
+
+
+
+
+
diff --git a/roborun/web/rapier-fleet.js b/roborun/web/rapier-fleet.js
new file mode 100644
index 0000000..bee95cf
--- /dev/null
+++ b/roborun/web/rapier-fleet.js
@@ -0,0 +1,346 @@
+/* rapier-fleet.js — a REAL multi-floor fleet: N robots in one Rapier physics
+ * warehouse, stacked floors connected by elevators.
+ *
+ * One Rapier world; each floor is a ground slab + walls at its own Y level (6 m
+ * apart, so floors are physically independent). N kinematic-capsule robots with
+ * a character controller collide with walls AND each other, navigate room to
+ * room, and ride elevators between floors. Every detection POSTs to
+ * /api/fleet/observe → indexed into the active project/environment store. One
+ * top-down canvas per floor renders the true physics positions.
+ */
+import RAPIER from "@dimforge/rapier3d-compat";
+import * as THREE from "three";
+import { initPhysics } from "./physics.js";
+
+const $ = (s) => document.querySelector(s);
+const COLORS = ["#00d47e", "#4090e0", "#d4a030", "#e0563f", "#a070e0", "#40c0c0", "#e090c0", "#90c040"];
+const LABELS = ["pallet", "forklift", "shelf", "crate", "barrel", "agv", "worker", "bin"];
+const H = 1 / 60, FLOOR_GAP = 6;
+const floorY = (f) => f * FLOOR_GAP;
+
+const S = { world: null, ctl: null, robots: [], floors: 3, size: 50, playing: false,
+ speed: 1, n: 8, floorData: [], elevators: [], detected: new Set(), found: 0,
+ totalItems: 0, t0: performance.now(), canvases: [], ready: false };
+
+// ── deterministic warehouse layout per floor + elevators ───────────────────
+function buildLayout() {
+ const sz = S.size, cols = 4, rows = 3, cw = sz / cols, ch = sz / rows;
+ S.floorData = []; S.elevators = []; S.totalItems = 0;
+ let seed = 7; const rng = () => { seed = (seed * 1103515245 + 12345) & 0x7fffffff; return seed / 0x7fffffff; };
+ for (let f = 0; f < S.floors; f++) {
+ const walls = [], items = [], rooms = [];
+ walls.push([0, 0, sz, 0], [sz, 0, sz, sz], [sz, sz, 0, sz], [0, sz, 0, 0]);
+ for (let c = 1; c < cols; c++) { const x = c * cw; for (let r = 0; r < rows; r++) walls.push([x, r * ch + 2.2, x, (r + 1) * ch - 2.2]); }
+ for (let r = 1; r < rows; r++) { const z = r * ch; for (let c = 0; c < cols; c++) walls.push([c * cw + 2.2, z, (c + 1) * cw - 2.2, z]); }
+ for (let c = 0; c < cols; c++) for (let r = 0; r < rows; r++) {
+ rooms.push({ x: c * cw, z: r * ch, w: cw, h: ch });
+ const n = 1 + (rng() < 0.6 ? 1 : 0);
+ for (let k = 0; k < n; k++) items.push({ label: LABELS[Math.floor(rng() * LABELS.length)],
+ x: c * cw + 2 + rng() * (cw - 4), z: r * ch + 2 + rng() * (ch - 4) });
+ }
+ S.floorData.push({ walls, items, rooms }); S.totalItems += items.length;
+ }
+ const sx = sz / 2, sz2 = sz / 2; // shared elevator shaft at the center
+ for (let f = 0; f < S.floors - 1; f++) S.elevators.push({ x: sx, z: sz2, from: f, to: f + 1 });
+}
+const elevatorsOn = (f) => S.elevators.filter(e => e.from === f || e.to === f);
+const SHAFT = () => ({ x: S.size / 2, z: S.size / 2 });
+
+// ── build the stacked Rapier world ─────────────────────────────────────────
+async function build() {
+ S.ready = false;
+ await initPhysics();
+ buildLayout();
+ try { S.world && S.world.free(); } catch {}
+ const w = new RAPIER.World({ x: 0, y: -9.81, z: 0 });
+ w.timestep = H;
+ for (let f = 0; f < S.floors; f++) {
+ const fy = floorY(f);
+ const g = w.createRigidBody(RAPIER.RigidBodyDesc.fixed());
+ w.createCollider(RAPIER.ColliderDesc.cuboid(S.size, 0.1, S.size)
+ .setTranslation(S.size / 2, fy - 0.1, S.size / 2).setFriction(0.8), g);
+ for (const [x1, z1, x2, z2] of S.floorData[f].walls) {
+ const cx = (x1 + x2) / 2, cz = (z1 + z2) / 2;
+ const hx = Math.max(Math.abs(x2 - x1) / 2, 0.12), hz = Math.max(Math.abs(z2 - z1) / 2, 0.12);
+ const b = w.createRigidBody(RAPIER.RigidBodyDesc.fixed().setTranslation(cx, fy + 0.6, cz));
+ w.createCollider(RAPIER.ColliderDesc.cuboid(hx, 0.6, hz), b);
+ }
+ }
+ S.world = w;
+ S.ctl = w.createCharacterController(0.02);
+ S.ctl.setApplyImpulsesToDynamicBodies(false);
+ init3D();
+ buildScene();
+ spawnRobots();
+ S.ready = true;
+}
+
+function spawnRobots() {
+ for (const rb of S.robots) { try { S.world.removeRigidBody(rb.body); } catch {} }
+ S.robots = []; S.found = 0; S.detected = new Set(); S.elapsed = 0;
+ const per = Math.ceil(Math.sqrt(S.n));
+ for (let i = 0; i < S.n; i++) {
+ const gx = i % per, gz = Math.floor(i / per);
+ const x = 3 + gx * 1.4 + Math.random() * .4, z = 3 + gz * 1.4 + Math.random() * .4;
+ const body = S.world.createRigidBody(
+ RAPIER.RigidBodyDesc.kinematicPositionBased().setTranslation(x, floorY(0) + 0.3, z));
+ const col = S.world.createCollider(RAPIER.ColliderDesc.capsule(0.14, 0.3), body);
+ const rb = { id: "r" + i, body, col, color: COLORS[i % COLORS.length], floor: 0,
+ heading: Math.random() * 6.28, seen: 0, trail: [], inElev: 0, dest: null, target: null };
+ pickTarget(rb); S.robots.push(rb);
+ }
+ build3DRobots();
+}
+
+function pickTarget(rb) {
+ const elevs = elevatorsOn(rb.floor);
+ if (elevs.length && Math.random() < 0.25) {
+ const e = elevs[Math.floor(Math.random() * elevs.length)];
+ rb.target = { x: e.x, z: e.z, elevator: e };
+ } else {
+ const fd = S.floorData[rb.floor], rm = fd.rooms[Math.floor(Math.random() * fd.rooms.length)];
+ rb.target = { x: rm.x + rm.w / 2 + (Math.random() - .5) * (rm.w - 6),
+ z: rm.z + rm.h / 2 + (Math.random() - .5) * (rm.h - 6) };
+ }
+}
+
+function detect(rb) {
+ const cur = rb.body.translation(), items = S.floorData[rb.floor].items;
+ for (let k = 0; k < items.length; k++) {
+ const it = items[k];
+ if (Math.hypot(it.x - cur.x, it.z - cur.z) < 2.2) {
+ const key = rb.floor + ":" + k;
+ if (!S.detected.has(key)) { S.detected.add(key); S.found++; report(rb, it); }
+ rb.seen++;
+ }
+ }
+}
+function report(rb, it) {
+ const p = rb.body.translation();
+ fetch("/api/fleet/observe", { method: "POST", headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ robot_id: rb.id, pose: { x: p.x, y: p.z },
+ detections: [{ label: it.label, score: 0.9, bbox: [0, 0, 12, 12] }] }) }).catch(() => {});
+}
+
+function stepOnce() {
+ const v = 1.6;
+ for (const rb of S.robots) {
+ if (rb.inElev > 0) { // riding the elevator
+ rb.inElev -= H;
+ if (rb.inElev <= 0 && rb.dest != null) {
+ rb.floor = rb.dest; rb.dest = null;
+ const s = SHAFT();
+ rb.body.setTranslation({ x: s.x, y: floorY(rb.floor) + 0.3, z: s.z }, true);
+ rb.trail = []; pickTarget(rb);
+ }
+ continue;
+ }
+ const cur = rb.body.translation();
+ let dx = rb.target.x - cur.x, dz = rb.target.z - cur.z;
+ const d = Math.hypot(dx, dz);
+ if (d < 1.0) {
+ if (rb.target.elevator) { // step into the shaft → other floor
+ const e = rb.target.elevator;
+ rb.dest = (e.from === rb.floor) ? e.to : e.from; rb.inElev = 0.8;
+ } else { detect(rb); pickTarget(rb); }
+ continue;
+ }
+ dx /= d; dz /= d; rb.heading = Math.atan2(dz, dx);
+ S.ctl.computeColliderMovement(rb.col, { x: dx * v * H, y: 0, z: dz * v * H });
+ const mv = S.ctl.computedMovement();
+ rb.body.setNextKinematicTranslation({ x: cur.x + mv.x, y: cur.y, z: cur.z + mv.z });
+ const lp = rb.trail[rb.trail.length - 1];
+ if (!lp || Math.hypot(cur.x - lp.x, cur.z - lp.z) > 1.2) {
+ rb.trail.push({ x: cur.x, z: cur.z }); if (rb.trail.length > 16) rb.trail.shift();
+ }
+ detect(rb);
+ }
+ S.world.step();
+}
+
+// ── render: a real 3D warehouse (three.js), same world as the cockpit ───────
+// The Rapier world is already 3D (x,z floor plane, y up, floors stacked
+// FLOOR_GAP apart). We render the true physics positions in three.js with an
+// orbit camera — no flat 2D top-down. Items light up green on detection.
+const V = { scene: null, camera: null, renderer: null, statics: null, robots: null,
+ itemMeshes: [], center: new THREE.Vector3(), theta: 0.9, phi: 1.04, r: 78,
+ drag: false, px: 0, py: 0, idle: 0, inited: false };
+const GREEN = 0x00d47e, DIM = 0x35563f;
+
+function init3D() {
+ if (V.inited) return;
+ const cv = $("#stage"); if (!cv) return;
+ V.renderer = new THREE.WebGLRenderer({ canvas: cv, antialias: true });
+ V.renderer.setPixelRatio(Math.min(2, window.devicePixelRatio || 1));
+ V.scene = new THREE.Scene();
+ V.scene.background = new THREE.Color(0x070a07);
+ V.scene.fog = new THREE.Fog(0x070a07, 120, 260);
+ V.camera = new THREE.PerspectiveCamera(46, 1, 0.5, 600);
+ V.scene.add(new THREE.AmbientLight(0xb8d8c4, 0.7));
+ V.scene.add(new THREE.HemisphereLight(0x88c0a0, 0x0a140d, 0.5));
+ const key = new THREE.DirectionalLight(0xffffff, 1.0); key.position.set(40, 90, 20); V.scene.add(key);
+ const fill = new THREE.DirectionalLight(0x4a90e0, 0.4); fill.position.set(-30, 40, -25); V.scene.add(fill);
+ V.statics = new THREE.Group(); V.scene.add(V.statics);
+ V.robots = new THREE.Group(); V.scene.add(V.robots);
+ // orbit controls (manual — no addons needed)
+ cv.addEventListener("pointerdown", e => { V.drag = true; V.touched = true; V.px = e.clientX; V.py = e.clientY; cv.setPointerCapture(e.pointerId); });
+ cv.addEventListener("pointerup", e => { V.drag = false; try { cv.releasePointerCapture(e.pointerId); } catch {} });
+ cv.addEventListener("pointermove", e => {
+ if (!V.drag) return; V.idle = 0;
+ V.theta -= (e.clientX - V.px) * 0.006; V.py != null && (V.phi = Math.max(0.18, Math.min(1.45, V.phi - (e.clientY - V.py) * 0.006)));
+ V.px = e.clientX; V.py = e.clientY;
+ });
+ cv.addEventListener("wheel", e => { e.preventDefault(); V.r = Math.max(42, Math.min(190, V.r * (1 + e.deltaY * 0.0012))); }, { passive: false });
+ V.inited = true;
+}
+
+function buildScene() {
+ if (!V.statics) return;
+ V.statics.clear(); V.itemMeshes = [];
+ const sz = S.size, c = sz / 2;
+ // aim a touch above the lower floors so the whole stack sits centred in frame
+ V.center.set(c, floorY(Math.max(0, S.floors - 1)) * 0.3 + 1, c);
+ V.r = 64 + sz * 0.62;
+ const wallMat = new THREE.MeshStandardMaterial({ color: 0x3c5a45, transparent: true, opacity: 0.72, roughness: 0.85 });
+ const slabMat = new THREE.MeshStandardMaterial({ color: 0x0d160f, transparent: true, opacity: 0.55, roughness: 1 });
+ for (let f = 0; f < S.floors; f++) {
+ const fy = floorY(f), fd = S.floorData[f];
+ const slab = new THREE.Mesh(new THREE.BoxGeometry(sz, 0.2, sz), slabMat);
+ slab.position.set(c, fy - 0.1, c); V.statics.add(slab);
+ const grid = new THREE.GridHelper(sz, 12, 0x244031, 0x18271d);
+ grid.position.set(c, fy + 0.02, c); grid.material.transparent = true; grid.material.opacity = 0.5; V.statics.add(grid);
+ for (const [x1, z1, x2, z2] of fd.walls) {
+ const cx = (x1 + x2) / 2, cz = (z1 + z2) / 2;
+ const hx = Math.max(Math.abs(x2 - x1), 0.24), hz = Math.max(Math.abs(z2 - z1), 0.24);
+ const wall = new THREE.Mesh(new THREE.BoxGeometry(hx, 1.4, hz), wallMat);
+ wall.position.set(cx, fy + 0.7, cz); V.statics.add(wall);
+ }
+ const itemRow = [];
+ for (let k = 0; k < fd.items.length; k++) {
+ const it = fd.items[k];
+ const m = new THREE.Mesh(new THREE.CylinderGeometry(0.5, 0.5, 0.7, 8),
+ new THREE.MeshStandardMaterial({ color: DIM, roughness: 0.8 }));
+ m.position.set(it.x, fy + 0.35, it.z); V.statics.add(m); itemRow.push(m);
+ }
+ V.itemMeshes[f] = itemRow;
+ for (const e of elevatorsOn(f)) {
+ if (e.from !== f) continue; // draw the shaft once, spanning the two floors
+ const span = FLOOR_GAP;
+ const shaft = new THREE.Mesh(new THREE.BoxGeometry(3.4, span, 3.4),
+ new THREE.MeshStandardMaterial({ color: 0x4a90e0, transparent: true, opacity: 0.16, roughness: 0.4 }));
+ shaft.position.set(e.x, fy + span / 2, e.z); V.statics.add(shaft);
+ const edges = new THREE.LineSegments(new THREE.EdgesGeometry(shaft.geometry),
+ new THREE.LineBasicMaterial({ color: 0x4a90e0, transparent: true, opacity: 0.55 }));
+ edges.position.copy(shaft.position); V.statics.add(edges);
+ }
+ }
+ renderFloorTags();
+}
+
+function build3DRobots() {
+ if (!V.robots) return;
+ V.robots.clear();
+ for (const rb of S.robots) {
+ const col = new THREE.Color(rb.color);
+ const g = new THREE.Group();
+ const body = new THREE.Mesh(new THREE.CapsuleGeometry(0.55, 0.8, 4, 10),
+ new THREE.MeshStandardMaterial({ color: col, roughness: 0.45, emissive: col, emissiveIntensity: 0.4 }));
+ body.rotation.z = Math.PI / 2; // lie the capsule along heading
+ g.add(body);
+ const nose = new THREE.Mesh(new THREE.ConeGeometry(0.42, 0.95, 10),
+ new THREE.MeshStandardMaterial({ color: 0xffffff, emissive: col, emissiveIntensity: 0.7 }));
+ nose.rotation.z = -Math.PI / 2; nose.position.x = 1.05; g.add(nose);
+ V.robots.add(g); rb.mesh = g;
+ }
+}
+
+function draw() {
+ if (!V.renderer) return;
+ // keep the drawing buffer matched to the element size
+ const cv = V.renderer.domElement, w = cv.clientWidth || 800, h = cv.clientHeight || 560;
+ if (cv.width !== Math.round(w * V.renderer.getPixelRatio()) || cv.height !== Math.round(h * V.renderer.getPixelRatio())) {
+ V.renderer.setSize(w, h, false); V.camera.aspect = w / h; V.camera.updateProjectionMatrix();
+ }
+ // robots → true physics transforms (hidden while inside the elevator shaft)
+ for (const rb of S.robots) {
+ if (!rb.mesh) continue;
+ const t = rb.body.translation();
+ rb.mesh.position.set(t.x, t.y + 0.05, t.z);
+ rb.mesh.rotation.y = -rb.heading + Math.PI / 2;
+ rb.mesh.visible = rb.inElev <= 0;
+ }
+ // items light up green once detected
+ for (let f = 0; f < S.floors; f++) {
+ const row = V.itemMeshes[f] || [];
+ for (let k = 0; k < row.length; k++) {
+ const on = S.detected.has(f + ":" + k), mat = row[k].material;
+ const want = on ? GREEN : DIM;
+ if (mat.color.getHex() !== want) {
+ mat.color.setHex(want); mat.emissive = new THREE.Color(on ? GREEN : 0x000000);
+ mat.emissiveIntensity = on ? 0.6 : 0; row[k].scale.setScalar(on ? 1.35 : 1);
+ }
+ }
+ }
+ // orbit camera: gentle attract-spin only until the user first grabs it —
+ // once they've positioned it, it stays exactly where they left it.
+ if (!V.drag && !V.touched) { V.idle++; if (V.idle > 90) V.theta += 0.0016; }
+ const sp = V.phi, st = V.theta;
+ V.camera.position.set(
+ V.center.x + V.r * Math.sin(sp) * Math.cos(st),
+ V.center.y + V.r * Math.cos(sp),
+ V.center.z + V.r * Math.sin(sp) * Math.sin(st));
+ V.camera.lookAt(V.center);
+ V.renderer.render(V.scene, V.camera);
+}
+
+function renderFloorTags() {
+ const host = $("#floorTags"); if (!host) return;
+ let html = "";
+ for (let f = S.floors - 1; f >= 0; f--) html += `Floor ${f} `;
+ host.innerHTML = html;
+}
+function hud() {
+ const cov = S.totalItems ? Math.round(S.found / S.totalItems * 100) : 0;
+ const el = S.elapsed || 0; // sim time — only advances while playing
+ const tile = (n, l) => `${n}${l}`;
+ $("#kpis").innerHTML = tile(S.robots.length, "robots") + tile(S.found + "/" + S.totalItems, "found") +
+ tile(cov + "%", "coverage") + tile(el.toFixed(0) + "s", "elapsed");
+ const cnt = new Array(S.floors).fill(0);
+ for (const rb of S.robots) if (rb.inElev <= 0) cnt[rb.floor]++;
+ for (let f = 0; f < S.floors; f++) { const fb = document.getElementById("fb" + f); if (fb) fb.textContent = "· " + cnt[f] + " bots"; }
+}
+
+let last = performance.now(), acc = 0;
+function loop(now) {
+ if (S.ready) {
+ const real = Math.min(0.05, (now - last) / 1000); last = now;
+ if (S.playing) {
+ S.elapsed = (S.elapsed || 0) + real; // count sim time only while running
+ acc += real * S.speed; let guard = 0;
+ while (acc >= H && guard++ < 240) { stepOnce(); acc -= H; }
+ }
+ draw(); hud();
+ } else last = now;
+ requestAnimationFrame(loop);
+}
+
+// ── controls + boot ────────────────────────────────────────────────────────
+function wire() {
+ $("#n") && ($("#n").oninput = e => { S.n = +e.target.value; $("#nlab").textContent = S.n; if (S.ready) spawnRobots(); });
+ $("#fl") && ($("#fl").oninput = e => { S.floors = +e.target.value; $("#flab").textContent = S.floors; build(); });
+ $("#spd") && ($("#spd").oninput = e => { S.speed = +e.target.value / 10; $("#slab").textContent = S.speed.toFixed(1) + "×"; });
+ $("#play") && ($("#play").onclick = () => { S.playing = !S.playing; $("#play").textContent = S.playing ? "⏸ pause" : "▶ play"; });
+ $("#reset") && ($("#reset").onclick = () => { if (S.ready) spawnRobots(); });
+ fetch("/api/projects/active").then(r => r.json()).then(d => {
+ const s = $("#scope"); if (!s) return;
+ const where = d.active ? `${d.active.project} / ${d.active.environment}` : `scratch`;
+ s.innerHTML = `Layer 2: real Rapier physics. ${S.floors} floors of warehouse, real bodies + collisions + elevators — robots collecting jointly into ${where}; every detection lands in its search + spatial map. ` +
+ `Coordination strategies (how they decide where to search) live in the Swarm Lab →`;
+ }).catch(() => {});
+}
+
+wire();
+window.__fleet = { S, V }; // harness hook (status/positions for tests)
+build().then(() => requestAnimationFrame(loop)).catch(e => {
+ const s = $("#scope"); if (s) s.innerHTML = `Rapier failed to load: ${e}`;
+});
diff --git a/roborun/web/run-detail.js b/roborun/web/run-detail.js
new file mode 100644
index 0000000..106a190
--- /dev/null
+++ b/roborun/web/run-detail.js
@@ -0,0 +1,72 @@
+/* Shared run-detail component — the scored record rendered the Antioch way:
+ * METADATA / PARAMETERS / RESULTS / EVALUATION tree / TAGS.
+ *
+ * One renderer, two entry points: run.html (with the replay scrubber + charts
+ * alongside) and timeline.html (in its right pane). Pure DOM string → innerHTML;
+ * styling comes from ui.css (.kv/.chip/.pill/.seclabel). No data invented — a
+ * section only renders when the record actually carries it.
+ */
+(function () {
+ const esc = (s) => String(s == null ? "" : s)
+ .replace(/&/g, "&").replace(//g, ">");
+ const when = (s) => esc((s || "").replace("T", " ").replace("Z", "").split(".")[0]);
+ const num = (v) => (typeof v === "number" ? (Number.isInteger(v) ? v : v.toFixed(2)) : esc(v));
+
+ function chips(obj) {
+ const e = Object.entries(obj || {}).filter(([k]) => !k.startsWith("_"));
+ if (!e.length) return "";
+ return `${e.map(([k, v]) =>
+ `${esc(k)}${num(v)}`).join("")}`;
+ }
+
+ function evalTree(ev) {
+ const groups = Object.entries(ev || {});
+ if (!groups.length) return "";
+ return `${groups.map(([g, vs]) => {
+ const subs = vs && typeof vs === "object"
+ ? Object.entries(vs).map(([k, v]) =>
+ `${esc(k)}${num(v)}`).join("")
+ : `${num(vs)}`;
+ return `${esc(g)}${subs}`;
+ }).join("")}`;
+ }
+
+ // el: container; rec: scenario record; opts.actions: extra HTML for the action row
+ window.renderRunRecord = function (el, rec, opts) {
+ opts = opts || {};
+ if (!rec) { el.innerHTML = `No scored record for this run.`; return; }
+ const meta = [
+ ["run id", rec.run_id], ["scenario", rec.name], ["suite", rec.suite],
+ ["robot", rec.robot], ["seed", rec.seed],
+ ["duration", rec.duration_s != null ? rec.duration_s + "s" : null],
+ ["started", when(rec.started)], ["ended", when(rec.ended)],
+ ].filter(([, v]) => v != null && v !== "");
+ const metaHTML = meta.map(([k, v]) =>
+ `${esc(k)}${esc(v)}`).join("");
+ const tags = (rec.tags || []).map((t) => `${esc(t)}`).join("");
+ const params = chips(rec.params), results = chips(rec.metrics), tree = evalTree(rec.evaluation);
+
+ const sub = [rec.suite && ("suite " + rec.suite), rec.robot && ("robot " + rec.robot)]
+ .filter(Boolean).map(esc).join(" · ");
+
+ el.innerHTML = `
+
+ ${esc(rec.name || rec.run_id || "run")}
+ ${rec.outcome ? `${esc(rec.outcome)}` : ""}
+ ${sub ? `${sub}` : ""}
+ ${opts.actions ? `${opts.actions}` : ""}
+
+ ${rec.reason ? `${esc(rec.reason)}` : ""}
+
+
+ Metadata${metaHTML}
+ ${params ? `Parameters${params}` : ""}
+
+
+ ${results ? `Results${results}` : `Resultsno metrics recorded`}
+ ${tree ? `Evaluation${tree}` : ""}
+ ${tags ? `Tags${tags}` : ""}
+
+ `;
+ };
+})();
diff --git a/roborun/web/run.html b/roborun/web/run.html
new file mode 100644
index 0000000..a1b7ecb
--- /dev/null
+++ b/roborun/web/run.html
@@ -0,0 +1,262 @@
+
+
+
+
+
+
+RoboRun · Run
+
+
+
+
+
+
+
+
+ Run replay
+
+
+
+ loading…
+
+
+
+ Telemetry — one clock across every panel
+
+ Robot trajectory
+ Velocity linear angular
+ Obstacle clearance
+ LiDAR scan
+ Event log
+
+
+
+
+
diff --git a/roborun/web/runtime-base.js b/roborun/web/runtime-base.js
new file mode 100644
index 0000000..c904995
--- /dev/null
+++ b/roborun/web/runtime-base.js
@@ -0,0 +1,148 @@
+/* Runtime discovery: one UI, two power levels.
+ *
+ * Served by roborun itself, /api/* is same-origin and everything just works.
+ * Served from a static host (GitHub Pages, `python -m http.server -d site`),
+ * the same page probes for a local roborun runtime on 127.0.0.1:8765 and, if
+ * one answers, routes every /api call to it — the demo page becomes the live
+ * cockpit. No runtime anywhere → the in-browser arena (Pyodide) carries on.
+ *
+ * Loaded before arena.js/deck.js; wraps window.fetch so existing relative
+ * "/api/…" calls need no changes. Queues /api calls until probing settles.
+ */
+(() => {
+ const LOCAL = "http://127.0.0.1:8765";
+ const state = { base: "", live: false, remote: false };
+ window.ROBORUN_RUNTIME = state;
+
+ const origFetch = window.fetch.bind(window);
+
+ const probe = async (base) => {
+ try {
+ const ctl = new AbortController();
+ const t = setTimeout(() => ctl.abort(), 1500);
+ const r = await origFetch(base + "/api/health", { signal: ctl.signal });
+ clearTimeout(t);
+ return r.ok;
+ } catch {
+ return false;
+ }
+ };
+
+ const resolved = (async () => {
+ if (await probe("")) {
+ state.live = true;
+ } else if (location.port !== "8765" && await probe(LOCAL)) {
+ state.base = LOCAL;
+ state.live = state.remote = true;
+ }
+ document.dispatchEvent(new CustomEvent("roborun-runtime", { detail: state }));
+ return state;
+ })();
+
+ window.fetch = async (input, init) => {
+ if (typeof input === "string" && input.startsWith("/api")) {
+ await resolved;
+ return origFetch(state.base + input, init);
+ }
+ return origFetch(input, init);
+ };
+
+ // ── status badge ─────────────────────────────────────────────────────
+ const badge = () => {
+ const el = document.createElement("div");
+ el.id = "runtime-badge";
+ el.style.cssText =
+ "position:fixed;right:12px;bottom:12px;z-index:9999;padding:4px 10px;" +
+ "border-radius:12px;font:11px/1.6 ui-monospace,Menlo,monospace;" +
+ "background:#11161bcc;border:1px solid #2a333d;color:#8a96a3;" +
+ "backdrop-filter:blur(4px);cursor:default;user-select:none;";
+ document.body.appendChild(el);
+
+ const render = () => {
+ if (state.live) {
+ el.style.color = "#00d47e";
+ el.style.borderColor = "#00d47e55";
+ el.textContent = state.remote ? "● live — local runtime :8765" : "● live";
+ el.style.cursor = "default";
+ el.onclick = null;
+ } else {
+ el.style.color = "#8a96a3";
+ el.textContent = "○ demo — run `roborun` to go live";
+ }
+ };
+
+ resolved.then(render);
+
+ // demo mode: keep listening so starting `roborun` upgrades the page
+ const recheck = async () => {
+ if (state.live) return;
+ if (await probe(LOCAL)) {
+ el.style.color = "#00d47e";
+ el.style.borderColor = "#00d47e55";
+ el.style.cursor = "pointer";
+ el.textContent = "● runtime found — click to go live";
+ el.onclick = () => location.reload();
+ } else {
+ setTimeout(recheck, 5000);
+ }
+ };
+ resolved.then(() => { if (!state.live) setTimeout(recheck, 5000); });
+ };
+
+ // Skip the floating badge when embedded (Studio hosts these pages in an
+ // iframe and owns its own live/scope chrome) — same guard as web/shell.js.
+ if (window.self === window.top) {
+ if (document.body) badge();
+ else document.addEventListener("DOMContentLoaded", badge);
+ }
+})();
+
+/* ── paint(el, html): write innerHTML only when it actually changed ──────
+ * The dashboards poll every few seconds and rebuild whole lists. Assigning
+ * innerHTML unconditionally tears down + rebuilds the DOM on every tick —
+ * that's the flicker, and it also drops hover/focus/selection. Routing the
+ * polled renders through this no-ops when the markup is identical (the
+ * common case), so the page only repaints on a real change.
+ */
+window.paint = (el, html) => {
+ if (!el || el.__lastHTML === html) return false;
+ el.__lastHTML = html;
+ el.innerHTML = html;
+ return true;
+};
+
+/* ── usage analytics (Vercel Web Analytics — events only, never video) ──
+ * Lives here, not in arena.js, so it survives arena rewrites and covers
+ * every page at once. Counts plays + connect-intent, broken down by
+ * visitor and country in the Vercel dashboard. No recording, no storage,
+ * no cost. Skipped on localhost so dev runs don't pollute the numbers.
+ */
+(() => {
+ const host = location.hostname;
+ if (host === "localhost" || host === "127.0.0.1" || host === "") return;
+
+ window.va = window.va || function () { (window.vaq = window.vaq || []).push(arguments); };
+ const s = document.createElement("script");
+ s.defer = true;
+ s.src = "/_vercel/insights/script.js";
+ document.head.appendChild(s);
+
+ const ev = (name, data) => {
+ try { window.va("event", data ? { name, data } : { name }); } catch {}
+ };
+
+ const wire = () => {
+ document.getElementById("btnRun")
+ ?.addEventListener("click", () => ev("play"), true);
+ document.getElementById("btnConnect")
+ ?.addEventListener("click", () => ev("connect_intent"), true);
+ // start-screen task buttons are generated at runtime — catch by delegation
+ document.addEventListener("click", (e) => {
+ const b = e.target.closest?.("#startGrid button");
+ if (b) ev("level_start", { task: b.textContent.trim().slice(0, 40) });
+ }, true);
+ };
+ if (document.readyState === "loading")
+ document.addEventListener("DOMContentLoaded", wire);
+ else wire();
+})();
diff --git a/roborun/web/scenarios.html b/roborun/web/scenarios.html
new file mode 100644
index 0000000..933177e
--- /dev/null
+++ b/roborun/web/scenarios.html
@@ -0,0 +1,307 @@
+
+
+
+
+
+
+RoboRun · Scenarios
+
+
+
+
+
+
+ SCENARIOS
+
+
+ Test your robot's behaviors and see how they score. Click a scenario to run it — every run is recorded and tamper-proof. Results group into suites with a live pass-rate.
+
+ Run a scenario
+ loading…
+
+ Suites
+ loading…
+
+
+ Runs
+
+
+
+ Result Scenario Suite
+ Seed
+ Measurements Scores Dur When
+ loading…
+
+
+
+
+
+
diff --git a/roborun/web/search.html b/roborun/web/search.html
new file mode 100644
index 0000000..aaadb71
--- /dev/null
+++ b/roborun/web/search.html
@@ -0,0 +1,286 @@
+
+
+
+
+
+
+RoboRun · Search over time
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ⌕
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/roborun/web/setup.html b/roborun/web/setup.html
new file mode 100644
index 0000000..acf432e
--- /dev/null
+++ b/roborun/web/setup.html
@@ -0,0 +1,296 @@
+
+
+
+
+
+
+RoboRun · Set up
+
+
+
+
+
+
+ Set up a robot or simulation in four quick steps — one at a time.
+
+
+
+
+ Project where the data lives
+ Everything you record scopes to a project. Reuse one, make a new one, or use the throwaway scratch space.
+
+
+ or
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/roborun/web/shell.js b/roborun/web/shell.js
new file mode 100644
index 0000000..110cf0d
--- /dev/null
+++ b/roborun/web/shell.js
@@ -0,0 +1,160 @@
+/* shell.js — the persistent app shell (sidebar + top bar) for every dashboard
+ * page. The project/environment switcher in the top bar is the spine: it scopes
+ * all data (the server scopes via the active project). Pages just load this
+ * script; the shell removes their bespoke header/nav, wraps their , and
+ * provides one consistent chrome. The cockpit (/sim) does NOT use the shell.
+ */
+(function () {
+ const PATH = location.pathname.replace(/\/$/, "") || "/";
+ const NAV = [
+ { items: [{ icon: "⌂", label: "Home", href: "/" }] },
+ { items: [{ icon: "◇", label: "Studio (new)", href: "/studio/live" }] },
+ { group: "Build", items: [
+ { icon: "▣", label: "Sims & Robots", href: "/setup" },
+ { icon: "▦", label: "Cockpit", href: "/sim" }] },
+ { group: "Data", items: [
+ { icon: "⊞", label: "Data Browser", href: "/browser" },
+ { icon: "✦", label: "Scenarios", href: "/scenarios" },
+ { icon: "🕓", label: "Timeline", href: "/timeline" },
+ { icon: "⌕", label: "Search", href: "/search" },
+ { icon: "📊", label: "Analytics", href: "/analytics" }] },
+ { group: "Fleet", items: [
+ { icon: "◈", label: "Swarm Lab", href: "/fleet" }, // layer 1: coordination algorithms (abstract)
+ { icon: "🐝", label: "Fleet Sim · 3D", href: "/fleet-sim" }] }, // layer 2: real Rapier physics (3D)
+ { items: [{ icon: "⚙", label: "Projects", href: "/projects" }] },
+ ];
+ const isActive = (href) => href === "/" ? PATH === "/" :
+ (PATH === href || PATH.startsWith(href + "/"));
+
+ function sidebarHTML() {
+ return NAV.map(sec =>
+ (sec.group ? `` : "") +
+ sec.items.map(it =>
+ `
+ ${it.icon}${it.label}`).join("")
+ ).join("");
+ }
+
+ function build() {
+ // Embedded in Studio (or any iframe host)? Skip the shell chrome so the
+ // page doesn't render a second sidebar/topbar inside the frame — the host
+ // already owns navigation. Also re-point this page's links to old standalone
+ // routes at the matching Studio tab (in the TOP window), so a cross-link
+ // lands on a Studio page instead of loading a standalone page in the frame.
+ if (window.self !== window.top) {
+ const MAP = { "/": "/studio/live", "/sim": "/studio/sims", "/fleet-sim": "/studio/sims",
+ "/fleet": "/studio/swarm", "/browser": "/studio/runs", "/timeline": "/studio/runs",
+ "/run": "/studio/runs", "/search": "/studio/search", "/scenarios": "/studio/scenarios",
+ "/analytics": "/studio/analytics", "/setup": "/studio/sims" };
+ const retarget = () => document.querySelectorAll('a[href^="/"]').forEach((a) => {
+ const dest = MAP[a.getAttribute("href").split("?")[0]];
+ if (dest) { a.setAttribute("href", dest); a.setAttribute("target", "_top"); }
+ });
+ retarget();
+ // re-apply after late renders
+ new MutationObserver(retarget).observe(document.body, { childList: true, subtree: true });
+ return;
+ }
+ // drop any bespoke page header/nav — the shell owns navigation
+ document.querySelectorAll("body > header, body > nav").forEach(h => h.remove());
+ // capture ALL remaining page content (main + any stray bars/divs), not just
+ // , so nothing gets orphaned outside the shell
+ const content = [...document.body.children].filter(el =>
+ el.tagName !== "SCRIPT" && el.id !== "runtime-badge");
+
+ const title = (document.title || "RoboRun").replace(/^RoboRun\s*[·|-]\s*/i, "");
+ const shell = document.createElement("div");
+ shell.className = "app-shell";
+ shell.innerHTML = `
+
+
+
+ ${title}
+
+
+
+
+
+ + New
+
+
+
+ `;
+ document.body.insertBefore(shell, document.body.firstChild);
+ const host = shell.querySelector("#appMain");
+ content.forEach(el => host.appendChild(el));
+
+ wireScope();
+ wireLive();
+ }
+
+ // ── project / environment switcher ───────────────────────────────────────
+ async function wireScope() {
+ const btn = document.getElementById("scopeBtn");
+ const menu = document.getElementById("scopeMenu");
+ async function refresh() {
+ try {
+ const a = await (await fetch("/api/projects/active")).json();
+ btn.textContent = a.active ? `◆ ${a.active.project} / ${a.active.environment} ▾` : "◇ scratch ▾";
+ btn.classList.toggle("scoped", !!a.active);
+ } catch { btn.textContent = "○ offline ▾"; }
+ }
+ async function openMenu() {
+ let html = "";
+ try {
+ const d = await (await fetch("/api/projects")).json();
+ const act = d.active;
+ if (!d.projects || !d.projects.length) {
+ html = `No projects yet — everything is in scratch.`;
+ } else {
+ for (const p of d.projects) {
+ const envs = await (await fetch("/api/environments?project=" + encodeURIComponent(p.id))).json();
+ html += `${p.name}`;
+ html += (envs.environments || []).map(e => {
+ const on = act && act.project === p.id && act.environment === e.id;
+ return `
+ ${e.name} ${e.backend}·${e.mode}`;
+ }).join("");
+ }
+ }
+ } catch { html = `○ run roborun to go live.`; }
+ html += ``;
+ menu.innerHTML = html;
+ menu.style.display = "block";
+ menu.querySelectorAll(".scope-env").forEach(a => a.onclick = async () => {
+ await fetch("/api/projects/active", { method: "POST", headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ project: a.dataset.p, environment: a.dataset.e }) });
+ location.reload();
+ });
+ const clr = document.getElementById("scopeClear");
+ if (clr) clr.onclick = async () => {
+ await fetch("/api/projects/active/clear", { method: "POST", headers: { "Content-Type": "application/json" }, body: "{}" });
+ location.reload();
+ };
+ }
+ btn.onclick = (e) => { e.stopPropagation(); if (menu.style.display === "none") openMenu(); else menu.style.display = "none"; };
+ document.addEventListener("click", () => { menu.style.display = "none"; });
+ menu.addEventListener("click", e => e.stopPropagation());
+ refresh();
+ }
+
+ function wireLive() {
+ const el = document.getElementById("shellLive");
+ const render = (s) => {
+ if (s && s.live) { el.innerHTML = '● live' + (s.remote ? " · :8765" : ""); }
+ else { el.innerHTML = '○ try the sim · run roborun'; }
+ };
+ if (window.ROBORUN_RUNTIME) render(window.ROBORUN_RUNTIME);
+ document.addEventListener("roborun-runtime", e => render(e.detail));
+ // fallback probe
+ fetch("/api/health").then(r => render({ live: r.ok })).catch(() => render({ live: false }));
+ }
+
+ if (document.readyState === "loading") document.addEventListener("DOMContentLoaded", build);
+ else build();
+})();
diff --git a/roborun/web/studio/assets/index-DkPVSSaS.js b/roborun/web/studio/assets/index-DkPVSSaS.js
new file mode 100644
index 0000000..b195002
--- /dev/null
+++ b/roborun/web/studio/assets/index-DkPVSSaS.js
@@ -0,0 +1,65 @@
+var Pg=Object.defineProperty;var Ig=(a,s,o)=>s in a?Pg(a,s,{enumerable:!0,configurable:!0,writable:!0,value:o}):a[s]=o;var bl=(a,s,o)=>Ig(a,typeof s!="symbol"?s+"":s,o);(function(){const s=document.createElement("link").relList;if(s&&s.supports&&s.supports("modulepreload"))return;for(const r of document.querySelectorAll('link[rel="modulepreload"]'))u(r);new MutationObserver(r=>{for(const d of r)if(d.type==="childList")for(const m of d.addedNodes)m.tagName==="LINK"&&m.rel==="modulepreload"&&u(m)}).observe(document,{childList:!0,subtree:!0});function o(r){const d={};return r.integrity&&(d.integrity=r.integrity),r.referrerPolicy&&(d.referrerPolicy=r.referrerPolicy),r.crossOrigin==="use-credentials"?d.credentials="include":r.crossOrigin==="anonymous"?d.credentials="omit":d.credentials="same-origin",d}function u(r){if(r.ep)return;r.ep=!0;const d=o(r);fetch(r.href,d)}})();function e1(a){return a&&a.__esModule&&Object.prototype.hasOwnProperty.call(a,"default")?a.default:a}var ld={exports:{}},Kc={};/**
+ * @license React
+ * react-jsx-runtime.production.js
+ *
+ * Copyright (c) Meta Platforms, Inc. and affiliates.
+ *
+ * This source code is licensed under the MIT license found in the
+ * LICENSE file in the root directory of this source tree.
+ */var cv;function t1(){if(cv)return Kc;cv=1;var a=Symbol.for("react.transitional.element"),s=Symbol.for("react.fragment");function o(u,r,d){var m=null;if(d!==void 0&&(m=""+d),r.key!==void 0&&(m=""+r.key),"key"in r){d={};for(var g in r)g!=="key"&&(d[g]=r[g])}else d=r;return r=d.ref,{$$typeof:a,type:u,key:m,ref:r!==void 0?r:null,props:d}}return Kc.Fragment=s,Kc.jsx=o,Kc.jsxs=o,Kc}var sv;function l1(){return sv||(sv=1,ld.exports=t1()),ld.exports}var p=l1(),nd={exports:{}},Ne={};/**
+ * @license React
+ * react.production.js
+ *
+ * Copyright (c) Meta Platforms, Inc. and affiliates.
+ *
+ * This source code is licensed under the MIT license found in the
+ * LICENSE file in the root directory of this source tree.
+ */var ov;function n1(){if(ov)return Ne;ov=1;var a=Symbol.for("react.transitional.element"),s=Symbol.for("react.portal"),o=Symbol.for("react.fragment"),u=Symbol.for("react.strict_mode"),r=Symbol.for("react.profiler"),d=Symbol.for("react.consumer"),m=Symbol.for("react.context"),g=Symbol.for("react.forward_ref"),y=Symbol.for("react.suspense"),v=Symbol.for("react.memo"),E=Symbol.for("react.lazy"),b=Symbol.for("react.activity"),_=Symbol.iterator;function N(A){return A===null||typeof A!="object"?null:(A=_&&A[_]||A["@@iterator"],typeof A=="function"?A:null)}var q={isMounted:function(){return!1},enqueueForceUpdate:function(){},enqueueReplaceState:function(){},enqueueSetState:function(){}},k=Object.assign,G={};function w(A,Q,X){this.props=A,this.context=Q,this.refs=G,this.updater=X||q}w.prototype.isReactComponent={},w.prototype.setState=function(A,Q){if(typeof A!="object"&&typeof A!="function"&&A!=null)throw Error("takes an object of state variables to update or a function which returns an object of state variables.");this.updater.enqueueSetState(this,A,Q,"setState")},w.prototype.forceUpdate=function(A){this.updater.enqueueForceUpdate(this,A,"forceUpdate")};function Y(){}Y.prototype=w.prototype;function O(A,Q,X){this.props=A,this.context=Q,this.refs=G,this.updater=X||q}var $=O.prototype=new Y;$.constructor=O,k($,w.prototype),$.isPureReactComponent=!0;var ie=Array.isArray;function ee(){}var ne={H:null,A:null,T:null,S:null},he=Object.prototype.hasOwnProperty;function ze(A,Q,X){var ae=X.ref;return{$$typeof:a,type:A,key:Q,ref:ae!==void 0?ae:null,props:X}}function _e(A,Q){return ze(A.type,Q,A.props)}function le(A){return typeof A=="object"&&A!==null&&A.$$typeof===a}function Ee(A){var Q={"=":"=0",":":"=2"};return"$"+A.replace(/[=:]/g,function(X){return Q[X]})}var re=/\/+/g;function be(A,Q){return typeof A=="object"&&A!==null&&A.key!=null?Ee(""+A.key):Q.toString(36)}function ue(A){switch(A.status){case"fulfilled":return A.value;case"rejected":throw A.reason;default:switch(typeof A.status=="string"?A.then(ee,ee):(A.status="pending",A.then(function(Q){A.status==="pending"&&(A.status="fulfilled",A.value=Q)},function(Q){A.status==="pending"&&(A.status="rejected",A.reason=Q)})),A.status){case"fulfilled":return A.value;case"rejected":throw A.reason}}throw A}function L(A,Q,X,ae,xe){var ve=typeof A;(ve==="undefined"||ve==="boolean")&&(A=null);var pe=!1;if(A===null)pe=!0;else switch(ve){case"bigint":case"string":case"number":pe=!0;break;case"object":switch(A.$$typeof){case a:case s:pe=!0;break;case E:return pe=A._init,L(pe(A._payload),Q,X,ae,xe)}}if(pe)return xe=xe(A),pe=ae===""?"."+be(A,0):ae,ie(xe)?(X="",pe!=null&&(X=pe.replace(re,"$&/")+"/"),L(xe,Q,X,"",function(et){return et})):xe!=null&&(le(xe)&&(xe=_e(xe,X+(xe.key==null||A&&A.key===xe.key?"":(""+xe.key).replace(re,"$&/")+"/")+pe)),Q.push(xe)),1;pe=0;var ft=ae===""?".":ae+":";if(ie(A))for(var Ge=0;Ge>>1,ge=L[Ae];if(0>>1;Aer(X,me))aer(xe,X)?(L[Ae]=xe,L[ae]=me,Ae=ae):(L[Ae]=X,L[Q]=me,Ae=Q);else if(aer(xe,me))L[Ae]=xe,L[ae]=me,Ae=ae;else break e}}return K}function r(L,K){var me=L.sortIndex-K.sortIndex;return me!==0?me:L.id-K.id}if(a.unstable_now=void 0,typeof performance=="object"&&typeof performance.now=="function"){var d=performance;a.unstable_now=function(){return d.now()}}else{var m=Date,g=m.now();a.unstable_now=function(){return m.now()-g}}var y=[],v=[],E=1,b=null,_=3,N=!1,q=!1,k=!1,G=!1,w=typeof setTimeout=="function"?setTimeout:null,Y=typeof clearTimeout=="function"?clearTimeout:null,O=typeof setImmediate<"u"?setImmediate:null;function $(L){for(var K=o(v);K!==null;){if(K.callback===null)u(v);else if(K.startTime<=L)u(v),K.sortIndex=K.expirationTime,s(y,K);else break;K=o(v)}}function ie(L){if(k=!1,$(L),!q)if(o(y)!==null)q=!0,ee||(ee=!0,Ee());else{var K=o(v);K!==null&&ue(ie,K.startTime-L)}}var ee=!1,ne=-1,he=5,ze=-1;function _e(){return G?!0:!(a.unstable_now()-zeL&&_e());){var Ae=b.callback;if(typeof Ae=="function"){b.callback=null,_=b.priorityLevel;var ge=Ae(b.expirationTime<=L);if(L=a.unstable_now(),typeof ge=="function"){b.callback=ge,$(L),K=!0;break t}b===o(y)&&u(y),$(L)}else u(y);b=o(y)}if(b!==null)K=!0;else{var A=o(v);A!==null&&ue(ie,A.startTime-L),K=!1}}break e}finally{b=null,_=me,N=!1}K=void 0}}finally{K?Ee():ee=!1}}}var Ee;if(typeof O=="function")Ee=function(){O(le)};else if(typeof MessageChannel<"u"){var re=new MessageChannel,be=re.port2;re.port1.onmessage=le,Ee=function(){be.postMessage(null)}}else Ee=function(){w(le,0)};function ue(L,K){ne=w(function(){L(a.unstable_now())},K)}a.unstable_IdlePriority=5,a.unstable_ImmediatePriority=1,a.unstable_LowPriority=4,a.unstable_NormalPriority=3,a.unstable_Profiling=null,a.unstable_UserBlockingPriority=2,a.unstable_cancelCallback=function(L){L.callback=null},a.unstable_forceFrameRate=function(L){0>L||125Ae?(L.sortIndex=me,s(v,L),o(y)===null&&L===o(v)&&(k?(Y(ne),ne=-1):k=!0,ue(ie,me-Ae))):(L.sortIndex=ge,s(y,L),q||N||(q=!0,ee||(ee=!0,Ee()))),L},a.unstable_shouldYield=_e,a.unstable_wrapCallback=function(L){var K=_;return function(){var me=_;_=K;try{return L.apply(this,arguments)}finally{_=me}}}})(ud)),ud}var dv;function i1(){return dv||(dv=1,id.exports=a1()),id.exports}var cd={exports:{}},xl={};/**
+ * @license React
+ * react-dom.production.js
+ *
+ * Copyright (c) Meta Platforms, Inc. and affiliates.
+ *
+ * This source code is licensed under the MIT license found in the
+ * LICENSE file in the root directory of this source tree.
+ */var hv;function u1(){if(hv)return xl;hv=1;var a=Od();function s(y){var v="https://react.dev/errors/"+y;if(1"u"||typeof __REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE!="function"))try{__REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE(a)}catch(s){console.error(s)}}return a(),cd.exports=u1(),cd.exports}/**
+ * @license React
+ * react-dom-client.production.js
+ *
+ * Copyright (c) Meta Platforms, Inc. and affiliates.
+ *
+ * This source code is licensed under the MIT license found in the
+ * LICENSE file in the root directory of this source tree.
+ */var pv;function s1(){if(pv)return Jc;pv=1;var a=i1(),s=Od(),o=c1();function u(e){var t="https://react.dev/errors/"+e;if(1ge||(e.current=Ae[ge],Ae[ge]=null,ge--)}function X(e,t){ge++,Ae[ge]=e.current,e.current=t}var ae=A(null),xe=A(null),ve=A(null),pe=A(null);function ft(e,t){switch(X(ve,t),X(xe,e),X(ae,null),t.nodeType){case 9:case 11:e=(e=t.documentElement)&&(e=e.namespaceURI)?Dp(e):0;break;default:if(e=t.tagName,t=t.namespaceURI)t=Dp(t),e=Mp(t,e);else switch(e){case"svg":e=1;break;case"math":e=2;break;default:e=0}}Q(ae),X(ae,e)}function Ge(){Q(ae),Q(xe),Q(ve)}function et(e){e.memoizedState!==null&&X(pe,e);var t=ae.current,l=Mp(t,e.type);t!==l&&(X(xe,e),X(ae,l))}function El(e){xe.current===e&&(Q(ae),Q(xe)),pe.current===e&&(Q(pe),Qc._currentValue=me)}var Ql,Tl;function hl(e){if(Ql===void 0)try{throw Error()}catch(l){var t=l.stack.trim().match(/\n( *(at )?)/);Ql=t&&t[1]||"",Tl=-1)":-1i||z[n]!==B[i]){var J=`
+`+z[n].replace(" at new "," at ");return e.displayName&&J.includes("")&&(J=J.replace("",e.displayName)),J}while(1<=n&&0<=i);break}}}finally{On=!1,Error.prepareStackTrace=l}return(l=e?e.displayName||e.name:"")?hl(l):""}function vi(e,t){switch(e.tag){case 26:case 27:case 5:return hl(e.type);case 16:return hl("Lazy");case 13:return e.child!==t&&t!==null?hl("Suspense Fallback"):hl("Suspense");case 19:return hl("SuspenseList");case 0:case 15:return Un(e.type,!1);case 11:return Un(e.type.render,!1);case 1:return Un(e.type,!0);case 31:return hl("Activity");default:return""}}function yi(e){try{var t="",l=null;do t+=vi(e,l),l=e,e=e.return;while(e);return t}catch(n){return`
+Error generating stack: `+n.message+`
+`+n.stack}}var gn=Object.prototype.hasOwnProperty,kt=a.unstable_scheduleCallback,Hn=a.unstable_cancelCallback,Sn=a.unstable_shouldYield,ea=a.unstable_requestPaint,Te=a.unstable_now,oe=a.unstable_getCurrentPriorityLevel,tt=a.unstable_ImmediatePriority,zt=a.unstable_UserBlockingPriority,bn=a.unstable_NormalPriority,ta=a.unstable_LowPriority,Zl=a.unstable_IdlePriority,la=a.log,jl=a.unstable_setDisableYieldValue,Ft=null,ut=null;function Lt(e){if(typeof la=="function"&&jl(e),ut&&typeof ut.setStrictMode=="function")try{ut.setStrictMode(Ft,e)}catch{}}var nl=Math.clz32?Math.clz32:na,$i=Math.log,Na=Math.LN2;function na(e){return e>>>=0,e===0?32:31-($i(e)/Na|0)|0}var aa=256,al=262144,wa=4194304;function Ln(e){var t=e&42;if(t!==0)return t;switch(e&-e){case 1:return 1;case 2:return 2;case 4:return 4;case 8:return 8;case 16:return 16;case 32:return 32;case 64:return 64;case 128:return 128;case 256:case 512:case 1024:case 2048:case 4096:case 8192:case 16384:case 32768:case 65536:case 131072:return e&261888;case 262144:case 524288:case 1048576:case 2097152:return e&3932160;case 4194304:case 8388608:case 16777216:case 33554432:return e&62914560;case 67108864:return 67108864;case 134217728:return 134217728;case 268435456:return 268435456;case 536870912:return 536870912;case 1073741824:return 0;default:return e}}function Wi(e,t,l){var n=e.pendingLanes;if(n===0)return 0;var i=0,c=e.suspendedLanes,f=e.pingedLanes;e=e.warmLanes;var x=n&134217727;return x!==0?(n=x&~c,n!==0?i=Ln(n):(f&=x,f!==0?i=Ln(f):l||(l=x&~e,l!==0&&(i=Ln(l))))):(x=n&~c,x!==0?i=Ln(x):f!==0?i=Ln(f):l||(l=n&~e,l!==0&&(i=Ln(l)))),i===0?0:t!==0&&t!==i&&(t&c)===0&&(c=i&-i,l=t&-t,c>=l||c===32&&(l&4194048)!==0)?t:i}function gi(e,t){return(e.pendingLanes&~(e.suspendedLanes&~e.pingedLanes)&t)===0}function ur(e,t){switch(e){case 1:case 2:case 4:case 8:case 64:return t+250;case 16:case 32:case 128:case 256:case 512:case 1024:case 2048:case 4096:case 8192:case 16384:case 32768:case 65536:case 131072:case 262144:case 524288:case 1048576:case 2097152:return t+5e3;case 4194304:case 8388608:case 16777216:case 33554432:return-1;case 67108864:case 134217728:case 268435456:case 536870912:case 1073741824:return-1;default:return-1}}function os(){var e=wa;return wa<<=1,(wa&62914560)===0&&(wa=4194304),e}function $u(e){for(var t=[],l=0;31>l;l++)t.push(e);return t}function kl(e,t){e.pendingLanes|=t,t!==268435456&&(e.suspendedLanes=0,e.pingedLanes=0,e.warmLanes=0)}function Pt(e,t,l,n,i,c){var f=e.pendingLanes;e.pendingLanes=l,e.suspendedLanes=0,e.pingedLanes=0,e.warmLanes=0,e.expiredLanes&=l,e.entangledLanes&=l,e.errorRecoveryDisabledLanes&=l,e.shellSuspendCounter=0;var x=e.entanglements,z=e.expirationTimes,B=e.hiddenUpdates;for(l=f&~l;0"u")return null;try{return e.activeElement||e.body}catch{return e.body}}var cr=/[\n"\\]/g;function zl(e){return e.replace(cr,function(t){return"\\"+t.charCodeAt(0).toString(16)+" "})}function lu(e,t,l,n,i,c,f,x){e.name="",f!=null&&typeof f!="function"&&typeof f!="symbol"&&typeof f!="boolean"?e.type=f:e.removeAttribute("type"),t!=null?f==="number"?(t===0&&e.value===""||e.value!=t)&&(e.value=""+_l(t)):e.value!==""+_l(t)&&(e.value=""+_l(t)):f!=="submit"&&f!=="reset"||e.removeAttribute("value"),t!=null?nu(e,f,_l(t)):l!=null?nu(e,f,_l(l)):n!=null&&e.removeAttribute("value"),i==null&&c!=null&&(e.defaultChecked=!!c),i!=null&&(e.checked=i&&typeof i!="function"&&typeof i!="symbol"),x!=null&&typeof x!="function"&&typeof x!="symbol"&&typeof x!="boolean"?e.name=""+_l(x):e.removeAttribute("name")}function ds(e,t,l,n,i,c,f,x){if(c!=null&&typeof c!="function"&&typeof c!="symbol"&&typeof c!="boolean"&&(e.type=c),t!=null||l!=null){if(!(c!=="submit"&&c!=="reset"||t!=null)){eu(e);return}l=l!=null?""+_l(l):"",t=t!=null?""+_l(t):l,x||t===e.value||(e.value=t),e.defaultValue=t}n=n??i,n=typeof n!="function"&&typeof n!="symbol"&&!!n,e.checked=x?e.checked:!!n,e.defaultChecked=!!n,f!=null&&typeof f!="function"&&typeof f!="symbol"&&typeof f!="boolean"&&(e.name=f),eu(e)}function nu(e,t,l){t==="number"&&tu(e.ownerDocument)===e||e.defaultValue===""+l||(e.defaultValue=""+l)}function qn(e,t,l,n){if(e=e.options,t){t={};for(var i=0;i"u"||typeof window.document>"u"||typeof window.document.createElement>"u"),cu=!1;if(vl)try{var qa={};Object.defineProperty(qa,"passive",{get:function(){cu=!0}}),window.addEventListener("test",qa,qa),window.removeEventListener("test",qa,qa)}catch{cu=!1}var Rl=null,Vn=null,ji=null;function ic(){if(ji)return ji;var e,t=Vn,l=t.length,n,i="value"in Rl?Rl.value:Rl.textContent,c=i.length;for(e=0;e=Za),hc=" ",fa=!1;function Ri(e,t){switch(e){case"keyup":return Ts.indexOf(t.keyCode)!==-1;case"keydown":return t.keyCode!==229;case"keypress":case"mousedown":case"focusout":return!0;default:return!1}}function wt(e){return e=e.detail,typeof e=="object"&&"data"in e?e.data:null}var da=!1;function Rt(e,t){switch(e){case"compositionend":return wt(t);case"keypress":return t.which!==32?null:(fa=!0,hc);case"textInput":return e=t.data,e===hc&&fa?null:e;default:return null}}function js(e,t){if(da)return e==="compositionend"||!su&&Ri(e,t)?(e=ic(),ji=Vn=Rl=null,da=!1,e):null;switch(e){case"paste":return null;case"keypress":if(!(t.ctrlKey||t.altKey||t.metaKey)||t.ctrlKey&&t.altKey){if(t.char&&1=t)return{node:l,offset:t-e};e=n}e:{for(;l;){if(l.nextSibling){l=l.nextSibling;break e}l=l.parentNode}l=void 0}l=Re(l)}}function ct(e,t){return e&&t?e===t?!0:e&&e.nodeType===3?!1:t&&t.nodeType===3?ct(e,t.parentNode):"contains"in e?e.contains(t):e.compareDocumentPosition?!!(e.compareDocumentPosition(t)&16):!1:!1}function yt(e){e=e!=null&&e.ownerDocument!=null&&e.ownerDocument.defaultView!=null?e.ownerDocument.defaultView:window;for(var t=tu(e.document);t instanceof e.HTMLIFrameElement;){try{var l=typeof t.contentWindow.location.href=="string"}catch{l=!1}if(l)e=t.contentWindow;else break;t=tu(e.document)}return t}function Ct(e){var t=e&&e.nodeName&&e.nodeName.toLowerCase();return t&&(t==="input"&&(e.type==="text"||e.type==="search"||e.type==="tel"||e.type==="url"||e.type==="password")||t==="textarea"||e.contentEditable==="true")}var Pe=vl&&"documentMode"in document&&11>=document.documentMode,nt=null,ul=null,Ot=null,yl=!1;function Ul(e,t,l){var n=l.window===l?l.document:l.nodeType===9?l:l.ownerDocument;yl||nt==null||nt!==tu(n)||(n=nt,"selectionStart"in n&&Ct(n)?n={start:n.selectionStart,end:n.selectionEnd}:(n=(n.ownerDocument&&n.ownerDocument.defaultView||window).getSelection(),n={anchorNode:n.anchorNode,anchorOffset:n.anchorOffset,focusNode:n.focusNode,focusOffset:n.focusOffset}),Ot&&we(Ot,n)||(Ot=n,n=po(ul,"onSelect"),0>=f,i-=f,kn=1<<32-nl(t)+i|l<Ue?(Ye=ye,ye=null):Ye=ye.sibling;var Xe=V(M,ye,H[Ue],F);if(Xe===null){ye===null&&(ye=Ye);break}e&&ye&&Xe.alternate===null&&t(M,ye),D=c(Xe,D,Ue),ke===null?je=Xe:ke.sibling=Xe,ke=Xe,ye=Ye}if(Ue===H.length)return l(M,ye),Ve&&pa(M,Ue),je;if(ye===null){for(;UeUe?(Ye=ye,ye=null):Ye=ye.sibling;var hi=V(M,ye,Xe.value,F);if(hi===null){ye===null&&(ye=Ye);break}e&&ye&&hi.alternate===null&&t(M,ye),D=c(hi,D,Ue),ke===null?je=hi:ke.sibling=hi,ke=hi,ye=Ye}if(Xe.done)return l(M,ye),Ve&&pa(M,Ue),je;if(ye===null){for(;!Xe.done;Ue++,Xe=H.next())Xe=P(M,Xe.value,F),Xe!==null&&(D=c(Xe,D,Ue),ke===null?je=Xe:ke.sibling=Xe,ke=Xe);return Ve&&pa(M,Ue),je}for(ye=n(ye);!Xe.done;Ue++,Xe=H.next())Xe=Z(ye,M,Ue,Xe.value,F),Xe!==null&&(e&&Xe.alternate!==null&&ye.delete(Xe.key===null?Ue:Xe.key),D=c(Xe,D,Ue),ke===null?je=Xe:ke.sibling=Xe,ke=Xe);return e&&ye.forEach(function(Fg){return t(M,Fg)}),Ve&&pa(M,Ue),je}function rt(M,D,H,F){if(typeof H=="object"&&H!==null&&H.type===k&&H.key===null&&(H=H.props.children),typeof H=="object"&&H!==null){switch(H.$$typeof){case N:e:{for(var je=H.key;D!==null;){if(D.key===je){if(je=H.type,je===k){if(D.tag===7){l(M,D.sibling),F=i(D,H.props.children),F.return=M,M=F;break e}}else if(D.elementType===je||typeof je=="object"&&je!==null&&je.$$typeof===he&&Hi(je)===D.type){l(M,D.sibling),F=i(D,H.props),bc(F,H),F.return=M,M=F;break e}l(M,D);break}else t(M,D);D=D.sibling}H.type===k?(F=Mi(H.props.children,M.mode,F,H.key),F.return=M,M=F):(F=Ms(H.type,H.key,H.props,null,M.mode,F),bc(F,H),F.return=M,M=F)}return f(M);case q:e:{for(je=H.key;D!==null;){if(D.key===je)if(D.tag===4&&D.stateNode.containerInfo===H.containerInfo&&D.stateNode.implementation===H.implementation){l(M,D.sibling),F=i(D,H.children||[]),F.return=M,M=F;break e}else{l(M,D);break}else t(M,D);D=D.sibling}F=Sr(H,M.mode,F),F.return=M,M=F}return f(M);case he:return H=Hi(H),rt(M,D,H,F)}if(ue(H))return de(M,D,H,F);if(Ee(H)){if(je=Ee(H),typeof je!="function")throw Error(u(150));return H=je.call(H),Ce(M,D,H,F)}if(typeof H.then=="function")return rt(M,D,Bs(H),F);if(H.$$typeof===O)return rt(M,D,Os(M,H),F);Ys(M,H)}return typeof H=="string"&&H!==""||typeof H=="number"||typeof H=="bigint"?(H=""+H,D!==null&&D.tag===6?(l(M,D.sibling),F=i(D,H),F.return=M,M=F):(l(M,D),F=gr(H,M.mode,F),F.return=M,M=F),f(M)):l(M,D)}return function(M,D,H,F){try{Sc=0;var je=rt(M,D,H,F);return gu=null,je}catch(ye){if(ye===yu||ye===Hs)throw ye;var ke=Jl(29,ye,null,M.mode);return ke.lanes=F,ke.return=M,ke}finally{}}}var Bi=xh(!0),Eh=xh(!1),Wa=!1;function Mr(e){e.updateQueue={baseState:e.memoizedState,firstBaseUpdate:null,lastBaseUpdate:null,shared:{pending:null,lanes:0,hiddenCallbacks:null},callbacks:null}}function Nr(e,t){e=e.updateQueue,t.updateQueue===e&&(t.updateQueue={baseState:e.baseState,firstBaseUpdate:e.firstBaseUpdate,lastBaseUpdate:e.lastBaseUpdate,shared:e.shared,callbacks:null})}function Fa(e){return{lane:e,tag:0,payload:null,callback:null,next:null}}function Pa(e,t,l){var n=e.updateQueue;if(n===null)return null;if(n=n.shared,($e&2)!==0){var i=n.pending;return i===null?t.next=t:(t.next=i.next,i.next=t),n.pending=t,t=Ds(e),ih(e,null,l),t}return Cs(e,n,t,l),Ds(e)}function xc(e,t,l){if(t=t.updateQueue,t!==null&&(t=t.shared,(l&4194048)!==0)){var n=t.lanes;n&=e.pendingLanes,l|=n,t.lanes=l,ia(e,l)}}function wr(e,t){var l=e.updateQueue,n=e.alternate;if(n!==null&&(n=n.updateQueue,l===n)){var i=null,c=null;if(l=l.firstBaseUpdate,l!==null){do{var f={lane:l.lane,tag:l.tag,payload:l.payload,callback:null,next:null};c===null?i=c=f:c=c.next=f,l=l.next}while(l!==null);c===null?i=c=t:c=c.next=t}else i=c=t;l={baseState:n.baseState,firstBaseUpdate:i,lastBaseUpdate:c,shared:n.shared,callbacks:n.callbacks},e.updateQueue=l;return}e=l.lastBaseUpdate,e===null?l.firstBaseUpdate=t:e.next=t,l.lastBaseUpdate=t}var Or=!1;function Ec(){if(Or){var e=vu;if(e!==null)throw e}}function Tc(e,t,l,n){Or=!1;var i=e.updateQueue;Wa=!1;var c=i.firstBaseUpdate,f=i.lastBaseUpdate,x=i.shared.pending;if(x!==null){i.shared.pending=null;var z=x,B=z.next;z.next=null,f===null?c=B:f.next=B,f=z;var J=e.alternate;J!==null&&(J=J.updateQueue,x=J.lastBaseUpdate,x!==f&&(x===null?J.firstBaseUpdate=B:x.next=B,J.lastBaseUpdate=z))}if(c!==null){var P=i.baseState;f=0,J=B=z=null,x=c;do{var V=x.lane&-536870913,Z=V!==x.lane;if(Z?(Be&V)===V:(n&V)===V){V!==0&&V===pu&&(Or=!0),J!==null&&(J=J.next={lane:0,tag:x.tag,payload:x.payload,callback:null,next:null});e:{var de=e,Ce=x;V=t;var rt=l;switch(Ce.tag){case 1:if(de=Ce.payload,typeof de=="function"){P=de.call(rt,P,V);break e}P=de;break e;case 3:de.flags=de.flags&-65537|128;case 0:if(de=Ce.payload,V=typeof de=="function"?de.call(rt,P,V):de,V==null)break e;P=b({},P,V);break e;case 2:Wa=!0}}V=x.callback,V!==null&&(e.flags|=64,Z&&(e.flags|=8192),Z=i.callbacks,Z===null?i.callbacks=[V]:Z.push(V))}else Z={lane:V,tag:x.tag,payload:x.payload,callback:x.callback,next:null},J===null?(B=J=Z,z=P):J=J.next=Z,f|=V;if(x=x.next,x===null){if(x=i.shared.pending,x===null)break;Z=x,x=Z.next,Z.next=null,i.lastBaseUpdate=Z,i.shared.pending=null}}while(!0);J===null&&(z=P),i.baseState=z,i.firstBaseUpdate=B,i.lastBaseUpdate=J,c===null&&(i.shared.lanes=0),ni|=f,e.lanes=f,e.memoizedState=P}}function Th(e,t){if(typeof e!="function")throw Error(u(191,e));e.call(t)}function jh(e,t){var l=e.callbacks;if(l!==null)for(e.callbacks=null,e=0;ec?c:8;var f=L.T,x={};L.T=x,Ir(e,!1,t,l);try{var z=i(),B=L.S;if(B!==null&&B(x,z),z!==null&&typeof z=="object"&&typeof z.then=="function"){var J=qy(z,n);zc(e,t,J,Il(e))}else zc(e,t,n,Il(e))}catch(P){zc(e,t,{then:function(){},status:"rejected",reason:P},Il())}finally{K.p=c,f!==null&&x.types!==null&&(f.types=x.types),L.T=f}}function Xy(){}function Fr(e,t,l,n){if(e.tag!==5)throw Error(u(476));var i=lm(e).queue;tm(e,i,t,me,l===null?Xy:function(){return nm(e),l(n)})}function lm(e){var t=e.memoizedState;if(t!==null)return t;t={memoizedState:me,baseState:me,baseQueue:null,queue:{pending:null,lanes:0,dispatch:null,lastRenderedReducer:Sa,lastRenderedState:me},next:null};var l={};return t.next={memoizedState:l,baseState:l,baseQueue:null,queue:{pending:null,lanes:0,dispatch:null,lastRenderedReducer:Sa,lastRenderedState:l},next:null},e.memoizedState=t,e=e.alternate,e!==null&&(e.memoizedState=t),t}function nm(e){var t=lm(e);t.next===null&&(t=e.alternate.memoizedState),zc(e,t.next.queue,{},Il())}function Pr(){return ol(Qc)}function am(){return Ht().memoizedState}function im(){return Ht().memoizedState}function Ky(e){for(var t=e.return;t!==null;){switch(t.tag){case 24:case 3:var l=Il();e=Fa(l);var n=Pa(t,e,l);n!==null&&(Gl(n,t,l),xc(n,t,l)),t={cache:Ar()},e.payload=t;return}t=t.return}}function Jy(e,t,l){var n=Il();l={lane:n,revertLane:0,gesture:null,action:l,hasEagerState:!1,eagerState:null,next:null},$s(e)?cm(t,l):(l=vr(e,t,l,n),l!==null&&(Gl(l,e,n),sm(l,t,n)))}function um(e,t,l){var n=Il();zc(e,t,l,n)}function zc(e,t,l,n){var i={lane:n,revertLane:0,gesture:null,action:l,hasEagerState:!1,eagerState:null,next:null};if($s(e))cm(t,i);else{var c=e.alternate;if(e.lanes===0&&(c===null||c.lanes===0)&&(c=t.lastRenderedReducer,c!==null))try{var f=t.lastRenderedState,x=c(f,l);if(i.hasEagerState=!0,i.eagerState=x,Me(x,f))return Cs(e,t,i,0),dt===null&&Rs(),!1}catch{}finally{}if(l=vr(e,t,i,n),l!==null)return Gl(l,e,n),sm(l,t,n),!0}return!1}function Ir(e,t,l,n){if(n={lane:2,revertLane:Nf(),gesture:null,action:n,hasEagerState:!1,eagerState:null,next:null},$s(e)){if(t)throw Error(u(479))}else t=vr(e,l,n,2),t!==null&&Gl(t,e,2)}function $s(e){var t=e.alternate;return e===Oe||t!==null&&t===Oe}function cm(e,t){bu=Vs=!0;var l=e.pending;l===null?t.next=t:(t.next=l.next,l.next=t),e.pending=t}function sm(e,t,l){if((l&4194048)!==0){var n=t.lanes;n&=e.pendingLanes,l|=n,t.lanes=l,ia(e,l)}}var Ac={readContext:ol,use:ks,useCallback:Dt,useContext:Dt,useEffect:Dt,useImperativeHandle:Dt,useLayoutEffect:Dt,useInsertionEffect:Dt,useMemo:Dt,useReducer:Dt,useRef:Dt,useState:Dt,useDebugValue:Dt,useDeferredValue:Dt,useTransition:Dt,useSyncExternalStore:Dt,useId:Dt,useHostTransitionStatus:Dt,useFormState:Dt,useActionState:Dt,useOptimistic:Dt,useMemoCache:Dt,useCacheRefresh:Dt};Ac.useEffectEvent=Dt;var om={readContext:ol,use:ks,useCallback:function(e,t){return Dl().memoizedState=[e,t===void 0?null:t],e},useContext:ol,useEffect:Xh,useImperativeHandle:function(e,t,l){l=l!=null?l.concat([e]):null,Ks(4194308,4,Wh.bind(null,t,e),l)},useLayoutEffect:function(e,t){return Ks(4194308,4,e,t)},useInsertionEffect:function(e,t){Ks(4,2,e,t)},useMemo:function(e,t){var l=Dl();t=t===void 0?null:t;var n=e();if(Yi){Lt(!0);try{e()}finally{Lt(!1)}}return l.memoizedState=[n,t],n},useReducer:function(e,t,l){var n=Dl();if(l!==void 0){var i=l(t);if(Yi){Lt(!0);try{l(t)}finally{Lt(!1)}}}else i=t;return n.memoizedState=n.baseState=i,e={pending:null,lanes:0,dispatch:null,lastRenderedReducer:e,lastRenderedState:i},n.queue=e,e=e.dispatch=Jy.bind(null,Oe,e),[n.memoizedState,e]},useRef:function(e){var t=Dl();return e={current:e},t.memoizedState=e},useState:function(e){e=Xr(e);var t=e.queue,l=um.bind(null,Oe,t);return t.dispatch=l,[e.memoizedState,l]},useDebugValue:$r,useDeferredValue:function(e,t){var l=Dl();return Wr(l,e,t)},useTransition:function(){var e=Xr(!1);return e=tm.bind(null,Oe,e.queue,!0,!1),Dl().memoizedState=e,[!1,e]},useSyncExternalStore:function(e,t,l){var n=Oe,i=Dl();if(Ve){if(l===void 0)throw Error(u(407));l=l()}else{if(l=t(),dt===null)throw Error(u(349));(Be&127)!==0||Dh(n,t,l)}i.memoizedState=l;var c={value:l,getSnapshot:t};return i.queue=c,Xh(Nh.bind(null,n,c,e),[e]),n.flags|=2048,Eu(9,{destroy:void 0},Mh.bind(null,n,c,l,t),null),l},useId:function(){var e=Dl(),t=dt.identifierPrefix;if(Ve){var l=Xn,n=kn;l=(n&~(1<<32-nl(n)-1)).toString(32)+l,t="_"+t+"R_"+l,l=Qs++,0<\/script>",c=c.removeChild(c.firstChild);break;case"select":c=typeof n.is=="string"?f.createElement("select",{is:n.is}):f.createElement("select"),n.multiple?c.multiple=!0:n.size&&(c.size=n.size);break;default:c=typeof n.is=="string"?f.createElement(i,{is:n.is}):f.createElement(i)}}c[Xt]=t,c[pl]=n;e:for(f=t.child;f!==null;){if(f.tag===5||f.tag===6)c.appendChild(f.stateNode);else if(f.tag!==4&&f.tag!==27&&f.child!==null){f.child.return=f,f=f.child;continue}if(f===t)break e;for(;f.sibling===null;){if(f.return===null||f.return===t)break e;f=f.return}f.sibling.return=f.return,f=f.sibling}t.stateNode=c;e:switch(fl(c,i,n),i){case"button":case"input":case"select":case"textarea":n=!!n.autoFocus;break e;case"img":n=!0;break e;default:n=!1}n&&xa(t)}}return St(t),mf(t,t.type,e===null?null:e.memoizedProps,t.pendingProps,l),null;case 6:if(e&&t.stateNode!=null)e.memoizedProps!==n&&xa(t);else{if(typeof n!="string"&&t.stateNode===null)throw Error(u(166));if(e=ve.current,hu(t)){if(e=t.stateNode,l=t.memoizedProps,n=null,i=sl,i!==null)switch(i.tag){case 27:case 5:n=i.memoizedProps}e[Xt]=t,e=!!(e.nodeValue===l||n!==null&&n.suppressHydrationWarning===!0||Rp(e.nodeValue,l)),e||Ja(t,!0)}else e=vo(e).createTextNode(n),e[Xt]=t,t.stateNode=e}return St(t),null;case 31:if(l=t.memoizedState,e===null||e.memoizedState!==null){if(n=hu(t),l!==null){if(e===null){if(!n)throw Error(u(318));if(e=t.memoizedState,e=e!==null?e.dehydrated:null,!e)throw Error(u(557));e[Xt]=t}else Ni(),(t.flags&128)===0&&(t.memoizedState=null),t.flags|=4;St(t),e=!1}else l=Tr(),e!==null&&e.memoizedState!==null&&(e.memoizedState.hydrationErrors=l),e=!0;if(!e)return t.flags&256?(Wl(t),t):(Wl(t),null);if((t.flags&128)!==0)throw Error(u(558))}return St(t),null;case 13:if(n=t.memoizedState,e===null||e.memoizedState!==null&&e.memoizedState.dehydrated!==null){if(i=hu(t),n!==null&&n.dehydrated!==null){if(e===null){if(!i)throw Error(u(318));if(i=t.memoizedState,i=i!==null?i.dehydrated:null,!i)throw Error(u(317));i[Xt]=t}else Ni(),(t.flags&128)===0&&(t.memoizedState=null),t.flags|=4;St(t),i=!1}else i=Tr(),e!==null&&e.memoizedState!==null&&(e.memoizedState.hydrationErrors=i),i=!0;if(!i)return t.flags&256?(Wl(t),t):(Wl(t),null)}return Wl(t),(t.flags&128)!==0?(t.lanes=l,t):(l=n!==null,e=e!==null&&e.memoizedState!==null,l&&(n=t.child,i=null,n.alternate!==null&&n.alternate.memoizedState!==null&&n.alternate.memoizedState.cachePool!==null&&(i=n.alternate.memoizedState.cachePool.pool),c=null,n.memoizedState!==null&&n.memoizedState.cachePool!==null&&(c=n.memoizedState.cachePool.pool),c!==i&&(n.flags|=2048)),l!==e&&l&&(t.child.flags|=8192),eo(t,t.updateQueue),St(t),null);case 4:return Ge(),e===null&&Hf(t.stateNode.containerInfo),St(t),null;case 10:return ya(t.type),St(t),null;case 19:if(Q(Ut),n=t.memoizedState,n===null)return St(t),null;if(i=(t.flags&128)!==0,c=n.rendering,c===null)if(i)Cc(n,!1);else{if(Mt!==0||e!==null&&(e.flags&128)!==0)for(e=t.child;e!==null;){if(c=Gs(e),c!==null){for(t.flags|=128,Cc(n,!1),e=c.updateQueue,t.updateQueue=e,eo(t,e),t.subtreeFlags=0,e=l,l=t.child;l!==null;)uh(l,e),l=l.sibling;return X(Ut,Ut.current&1|2),Ve&&pa(t,n.treeForkCount),t.child}e=e.sibling}n.tail!==null&&Te()>io&&(t.flags|=128,i=!0,Cc(n,!1),t.lanes=4194304)}else{if(!i)if(e=Gs(c),e!==null){if(t.flags|=128,i=!0,e=e.updateQueue,t.updateQueue=e,eo(t,e),Cc(n,!0),n.tail===null&&n.tailMode==="hidden"&&!c.alternate&&!Ve)return St(t),null}else 2*Te()-n.renderingStartTime>io&&l!==536870912&&(t.flags|=128,i=!0,Cc(n,!1),t.lanes=4194304);n.isBackwards?(c.sibling=t.child,t.child=c):(e=n.last,e!==null?e.sibling=c:t.child=c,n.last=c)}return n.tail!==null?(e=n.tail,n.rendering=e,n.tail=e.sibling,n.renderingStartTime=Te(),e.sibling=null,l=Ut.current,X(Ut,i?l&1|2:l&1),Ve&&pa(t,n.treeForkCount),e):(St(t),null);case 22:case 23:return Wl(t),Hr(),n=t.memoizedState!==null,e!==null?e.memoizedState!==null!==n&&(t.flags|=8192):n&&(t.flags|=8192),n?(l&536870912)!==0&&(t.flags&128)===0&&(St(t),t.subtreeFlags&6&&(t.flags|=8192)):St(t),l=t.updateQueue,l!==null&&eo(t,l.retryQueue),l=null,e!==null&&e.memoizedState!==null&&e.memoizedState.cachePool!==null&&(l=e.memoizedState.cachePool.pool),n=null,t.memoizedState!==null&&t.memoizedState.cachePool!==null&&(n=t.memoizedState.cachePool.pool),n!==l&&(t.flags|=2048),e!==null&&Q(Ui),null;case 24:return l=null,e!==null&&(l=e.memoizedState.cache),t.memoizedState.cache!==l&&(t.flags|=2048),ya(Yt),St(t),null;case 25:return null;case 30:return null}throw Error(u(156,t.tag))}function Iy(e,t){switch(xr(t),t.tag){case 1:return e=t.flags,e&65536?(t.flags=e&-65537|128,t):null;case 3:return ya(Yt),Ge(),e=t.flags,(e&65536)!==0&&(e&128)===0?(t.flags=e&-65537|128,t):null;case 26:case 27:case 5:return El(t),null;case 31:if(t.memoizedState!==null){if(Wl(t),t.alternate===null)throw Error(u(340));Ni()}return e=t.flags,e&65536?(t.flags=e&-65537|128,t):null;case 13:if(Wl(t),e=t.memoizedState,e!==null&&e.dehydrated!==null){if(t.alternate===null)throw Error(u(340));Ni()}return e=t.flags,e&65536?(t.flags=e&-65537|128,t):null;case 19:return Q(Ut),null;case 4:return Ge(),null;case 10:return ya(t.type),null;case 22:case 23:return Wl(t),Hr(),e!==null&&Q(Ui),e=t.flags,e&65536?(t.flags=e&-65537|128,t):null;case 24:return ya(Yt),null;case 25:return null;default:return null}}function wm(e,t){switch(xr(t),t.tag){case 3:ya(Yt),Ge();break;case 26:case 27:case 5:El(t);break;case 4:Ge();break;case 31:t.memoizedState!==null&&Wl(t);break;case 13:Wl(t);break;case 19:Q(Ut);break;case 10:ya(t.type);break;case 22:case 23:Wl(t),Hr(),e!==null&&Q(Ui);break;case 24:ya(Yt)}}function Dc(e,t){try{var l=t.updateQueue,n=l!==null?l.lastEffect:null;if(n!==null){var i=n.next;l=i;do{if((l.tag&e)===e){n=void 0;var c=l.create,f=l.inst;n=c(),f.destroy=n}l=l.next}while(l!==i)}}catch(x){it(t,t.return,x)}}function ti(e,t,l){try{var n=t.updateQueue,i=n!==null?n.lastEffect:null;if(i!==null){var c=i.next;n=c;do{if((n.tag&e)===e){var f=n.inst,x=f.destroy;if(x!==void 0){f.destroy=void 0,i=t;var z=l,B=x;try{B()}catch(J){it(i,z,J)}}}n=n.next}while(n!==c)}}catch(J){it(t,t.return,J)}}function Om(e){var t=e.updateQueue;if(t!==null){var l=e.stateNode;try{jh(t,l)}catch(n){it(e,e.return,n)}}}function Um(e,t,l){l.props=qi(e.type,e.memoizedProps),l.state=e.memoizedState;try{l.componentWillUnmount()}catch(n){it(e,t,n)}}function Mc(e,t){try{var l=e.ref;if(l!==null){switch(e.tag){case 26:case 27:case 5:var n=e.stateNode;break;case 30:n=e.stateNode;break;default:n=e.stateNode}typeof l=="function"?e.refCleanup=l(n):l.current=n}}catch(i){it(e,t,i)}}function Kn(e,t){var l=e.ref,n=e.refCleanup;if(l!==null)if(typeof n=="function")try{n()}catch(i){it(e,t,i)}finally{e.refCleanup=null,e=e.alternate,e!=null&&(e.refCleanup=null)}else if(typeof l=="function")try{l(null)}catch(i){it(e,t,i)}else l.current=null}function Hm(e){var t=e.type,l=e.memoizedProps,n=e.stateNode;try{e:switch(t){case"button":case"input":case"select":case"textarea":l.autoFocus&&n.focus();break e;case"img":l.src?n.src=l.src:l.srcSet&&(n.srcset=l.srcSet)}}catch(i){it(e,e.return,i)}}function pf(e,t,l){try{var n=e.stateNode;xg(n,e.type,l,t),n[pl]=t}catch(i){it(e,e.return,i)}}function Lm(e){return e.tag===5||e.tag===3||e.tag===26||e.tag===27&&si(e.type)||e.tag===4}function vf(e){e:for(;;){for(;e.sibling===null;){if(e.return===null||Lm(e.return))return null;e=e.return}for(e.sibling.return=e.return,e=e.sibling;e.tag!==5&&e.tag!==6&&e.tag!==18;){if(e.tag===27&&si(e.type)||e.flags&2||e.child===null||e.tag===4)continue e;e.child.return=e,e=e.child}if(!(e.flags&2))return e.stateNode}}function yf(e,t,l){var n=e.tag;if(n===5||n===6)e=e.stateNode,t?(l.nodeType===9?l.body:l.nodeName==="HTML"?l.ownerDocument.body:l).insertBefore(e,t):(t=l.nodeType===9?l.body:l.nodeName==="HTML"?l.ownerDocument.body:l,t.appendChild(e),l=l._reactRootContainer,l!=null||t.onclick!==null||(t.onclick=Al));else if(n!==4&&(n===27&&si(e.type)&&(l=e.stateNode,t=null),e=e.child,e!==null))for(yf(e,t,l),e=e.sibling;e!==null;)yf(e,t,l),e=e.sibling}function to(e,t,l){var n=e.tag;if(n===5||n===6)e=e.stateNode,t?l.insertBefore(e,t):l.appendChild(e);else if(n!==4&&(n===27&&si(e.type)&&(l=e.stateNode),e=e.child,e!==null))for(to(e,t,l),e=e.sibling;e!==null;)to(e,t,l),e=e.sibling}function Bm(e){var t=e.stateNode,l=e.memoizedProps;try{for(var n=e.type,i=t.attributes;i.length;)t.removeAttributeNode(i[0]);fl(t,n,l),t[Xt]=e,t[pl]=l}catch(c){it(e,e.return,c)}}var Ea=!1,Vt=!1,gf=!1,Ym=typeof WeakSet=="function"?WeakSet:Set,ll=null;function eg(e,t){if(e=e.containerInfo,Yf=To,e=yt(e),Ct(e)){if("selectionStart"in e)var l={start:e.selectionStart,end:e.selectionEnd};else e:{l=(l=e.ownerDocument)&&l.defaultView||window;var n=l.getSelection&&l.getSelection();if(n&&n.rangeCount!==0){l=n.anchorNode;var i=n.anchorOffset,c=n.focusNode;n=n.focusOffset;try{l.nodeType,c.nodeType}catch{l=null;break e}var f=0,x=-1,z=-1,B=0,J=0,P=e,V=null;t:for(;;){for(var Z;P!==l||i!==0&&P.nodeType!==3||(x=f+i),P!==c||n!==0&&P.nodeType!==3||(z=f+n),P.nodeType===3&&(f+=P.nodeValue.length),(Z=P.firstChild)!==null;)V=P,P=Z;for(;;){if(P===e)break t;if(V===l&&++B===i&&(x=f),V===c&&++J===n&&(z=f),(Z=P.nextSibling)!==null)break;P=V,V=P.parentNode}P=Z}l=x===-1||z===-1?null:{start:x,end:z}}else l=null}l=l||{start:0,end:0}}else l=null;for(qf={focusedElem:e,selectionRange:l},To=!1,ll=t;ll!==null;)if(t=ll,e=t.child,(t.subtreeFlags&1028)!==0&&e!==null)e.return=t,ll=e;else for(;ll!==null;){switch(t=ll,c=t.alternate,e=t.flags,t.tag){case 0:if((e&4)!==0&&(e=t.updateQueue,e=e!==null?e.events:null,e!==null))for(l=0;l title"))),fl(c,n,l),c[Xt]=e,Nt(c),n=c;break e;case"link":var f=kp("link","href",i).get(n+(l.href||""));if(f){for(var x=0;xrt&&(f=rt,rt=Ce,Ce=f);var M=Se(x,Ce),D=Se(x,rt);if(M&&D&&(Z.rangeCount!==1||Z.anchorNode!==M.node||Z.anchorOffset!==M.offset||Z.focusNode!==D.node||Z.focusOffset!==D.offset)){var H=P.createRange();H.setStart(M.node,M.offset),Z.removeAllRanges(),Ce>rt?(Z.addRange(H),Z.extend(D.node,D.offset)):(H.setEnd(D.node,D.offset),Z.addRange(H))}}}}for(P=[],Z=x;Z=Z.parentNode;)Z.nodeType===1&&P.push({element:Z,left:Z.scrollLeft,top:Z.scrollTop});for(typeof x.focus=="function"&&x.focus(),x=0;xl?32:l,L.T=null,l=_f,_f=null;var c=ii,f=Aa;if(Kt=0,Au=ii=null,Aa=0,($e&6)!==0)throw Error(u(331));var x=$e;if($e|=4,Wm(c.current),Km(c,c.current,f,l),$e=x,Lc(0,!1),ut&&typeof ut.onPostCommitFiberRoot=="function")try{ut.onPostCommitFiberRoot(Ft,c)}catch{}return!0}finally{K.p=i,L.T=n,mp(e,t)}}function vp(e,t,l){t=sn(l,t),t=nf(e.stateNode,t,2),e=Pa(e,t,2),e!==null&&(kl(e,2),Jn(e))}function it(e,t,l){if(e.tag===3)vp(e,e,l);else for(;t!==null;){if(t.tag===3){vp(t,e,l);break}else if(t.tag===1){var n=t.stateNode;if(typeof t.type.getDerivedStateFromError=="function"||typeof n.componentDidCatch=="function"&&(ai===null||!ai.has(n))){e=sn(l,e),l=ym(2),n=Pa(t,l,2),n!==null&&(gm(l,n,t,e),kl(n,2),Jn(n));break}}t=t.return}}function Cf(e,t,l){var n=e.pingCache;if(n===null){n=e.pingCache=new ng;var i=new Set;n.set(t,i)}else i=n.get(t),i===void 0&&(i=new Set,n.set(t,i));i.has(l)||(xf=!0,i.add(l),e=sg.bind(null,e,t,l),t.then(e,e))}function sg(e,t,l){var n=e.pingCache;n!==null&&n.delete(t),e.pingedLanes|=e.suspendedLanes&l,e.warmLanes&=~l,dt===e&&(Be&l)===l&&(Mt===4||Mt===3&&(Be&62914560)===Be&&300>Te()-ao?($e&2)===0&&Ru(e,0):Ef|=l,zu===Be&&(zu=0)),Jn(e)}function yp(e,t){t===0&&(t=os()),e=Di(e,t),e!==null&&(kl(e,t),Jn(e))}function og(e){var t=e.memoizedState,l=0;t!==null&&(l=t.retryLane),yp(e,l)}function rg(e,t){var l=0;switch(e.tag){case 31:case 13:var n=e.stateNode,i=e.memoizedState;i!==null&&(l=i.retryLane);break;case 19:n=e.stateNode;break;case 22:n=e.stateNode._retryCache;break;default:throw Error(u(314))}n!==null&&n.delete(t),yp(e,l)}function fg(e,t){return kt(e,t)}var fo=null,Du=null,Df=!1,ho=!1,Mf=!1,ci=0;function Jn(e){e!==Du&&e.next===null&&(Du===null?fo=Du=e:Du=Du.next=e),ho=!0,Df||(Df=!0,hg())}function Lc(e,t){if(!Mf&&ho){Mf=!0;do for(var l=!1,n=fo;n!==null;){if(e!==0){var i=n.pendingLanes;if(i===0)var c=0;else{var f=n.suspendedLanes,x=n.pingedLanes;c=(1<<31-nl(42|e)+1)-1,c&=i&~(f&~x),c=c&201326741?c&201326741|1:c?c|2:0}c!==0&&(l=!0,xp(n,c))}else c=Be,c=Wi(n,n===dt?c:0,n.cancelPendingCommit!==null||n.timeoutHandle!==-1),(c&3)===0||gi(n,c)||(l=!0,xp(n,c));n=n.next}while(l);Mf=!1}}function dg(){gp()}function gp(){ho=Df=!1;var e=0;ci!==0&&Tg()&&(e=ci);for(var t=Te(),l=null,n=fo;n!==null;){var i=n.next,c=Sp(n,t);c===0?(n.next=null,l===null?fo=i:l.next=i,i===null&&(Du=l)):(l=n,(e!==0||(c&3)!==0)&&(ho=!0)),n=i}Kt!==0&&Kt!==5||Lc(e),ci!==0&&(ci=0)}function Sp(e,t){for(var l=e.suspendedLanes,n=e.pingedLanes,i=e.expirationTimes,c=e.pendingLanes&-62914561;0x)break;var J=z.transferSize,P=z.initiatorType;J&&Cp(P)&&(z=z.responseEnd,f+=J*(z"u"?null:document;function Gp(e,t,l){var n=Mu;if(n&&typeof t=="string"&&t){var i=zl(t);i='link[rel="'+e+'"][href="'+i+'"]',typeof l=="string"&&(i+='[crossorigin="'+l+'"]'),qp.has(i)||(qp.add(i),e={rel:e,crossOrigin:l,href:t},n.querySelector(i)===null&&(t=n.createElement("link"),fl(t,"link",e),Nt(t),n.head.appendChild(t)))}}function Ng(e){Ra.D(e),Gp("dns-prefetch",e,null)}function wg(e,t){Ra.C(e,t),Gp("preconnect",e,t)}function Og(e,t,l){Ra.L(e,t,l);var n=Mu;if(n&&e&&t){var i='link[rel="preload"][as="'+zl(t)+'"]';t==="image"&&l&&l.imageSrcSet?(i+='[imagesrcset="'+zl(l.imageSrcSet)+'"]',typeof l.imageSizes=="string"&&(i+='[imagesizes="'+zl(l.imageSizes)+'"]')):i+='[href="'+zl(e)+'"]';var c=i;switch(t){case"style":c=Nu(e);break;case"script":c=wu(e)}mn.has(c)||(e=b({rel:"preload",href:t==="image"&&l&&l.imageSrcSet?void 0:e,as:t},l),mn.set(c,e),n.querySelector(i)!==null||t==="style"&&n.querySelector(Gc(c))||t==="script"&&n.querySelector(Vc(c))||(t=n.createElement("link"),fl(t,"link",e),Nt(t),n.head.appendChild(t)))}}function Ug(e,t){Ra.m(e,t);var l=Mu;if(l&&e){var n=t&&typeof t.as=="string"?t.as:"script",i='link[rel="modulepreload"][as="'+zl(n)+'"][href="'+zl(e)+'"]',c=i;switch(n){case"audioworklet":case"paintworklet":case"serviceworker":case"sharedworker":case"worker":case"script":c=wu(e)}if(!mn.has(c)&&(e=b({rel:"modulepreload",href:e},t),mn.set(c,e),l.querySelector(i)===null)){switch(n){case"audioworklet":case"paintworklet":case"serviceworker":case"sharedworker":case"worker":case"script":if(l.querySelector(Vc(c)))return}n=l.createElement("link"),fl(n,"link",e),Nt(n),l.head.appendChild(n)}}}function Hg(e,t,l){Ra.S(e,t,l);var n=Mu;if(n&&e){var i=nn(n).hoistableStyles,c=Nu(e);t=t||"default";var f=i.get(c);if(!f){var x={loading:0,preload:null};if(f=n.querySelector(Gc(c)))x.loading=5;else{e=b({rel:"stylesheet",href:e,"data-precedence":t},l),(l=mn.get(c))&&Kf(e,l);var z=f=n.createElement("link");Nt(z),fl(z,"link",e),z._p=new Promise(function(B,J){z.onload=B,z.onerror=J}),z.addEventListener("load",function(){x.loading|=1}),z.addEventListener("error",function(){x.loading|=2}),x.loading|=4,go(f,t,n)}f={type:"stylesheet",instance:f,count:1,state:x},i.set(c,f)}}}function Lg(e,t){Ra.X(e,t);var l=Mu;if(l&&e){var n=nn(l).hoistableScripts,i=wu(e),c=n.get(i);c||(c=l.querySelector(Vc(i)),c||(e=b({src:e,async:!0},t),(t=mn.get(i))&&Jf(e,t),c=l.createElement("script"),Nt(c),fl(c,"link",e),l.head.appendChild(c)),c={type:"script",instance:c,count:1,state:null},n.set(i,c))}}function Bg(e,t){Ra.M(e,t);var l=Mu;if(l&&e){var n=nn(l).hoistableScripts,i=wu(e),c=n.get(i);c||(c=l.querySelector(Vc(i)),c||(e=b({src:e,async:!0,type:"module"},t),(t=mn.get(i))&&Jf(e,t),c=l.createElement("script"),Nt(c),fl(c,"link",e),l.head.appendChild(c)),c={type:"script",instance:c,count:1,state:null},n.set(i,c))}}function Vp(e,t,l,n){var i=(i=ve.current)?yo(i):null;if(!i)throw Error(u(446));switch(e){case"meta":case"title":return null;case"style":return typeof l.precedence=="string"&&typeof l.href=="string"?(t=Nu(l.href),l=nn(i).hoistableStyles,n=l.get(t),n||(n={type:"style",instance:null,count:0,state:null},l.set(t,n)),n):{type:"void",instance:null,count:0,state:null};case"link":if(l.rel==="stylesheet"&&typeof l.href=="string"&&typeof l.precedence=="string"){e=Nu(l.href);var c=nn(i).hoistableStyles,f=c.get(e);if(f||(i=i.ownerDocument||i,f={type:"stylesheet",instance:null,count:0,state:{loading:0,preload:null}},c.set(e,f),(c=i.querySelector(Gc(e)))&&!c._p&&(f.instance=c,f.state.loading=5),mn.has(e)||(l={rel:"preload",as:"style",href:l.href,crossOrigin:l.crossOrigin,integrity:l.integrity,media:l.media,hrefLang:l.hrefLang,referrerPolicy:l.referrerPolicy},mn.set(e,l),c||Yg(i,e,l,f.state))),t&&n===null)throw Error(u(528,""));return f}if(t&&n!==null)throw Error(u(529,""));return null;case"script":return t=l.async,l=l.src,typeof l=="string"&&t&&typeof t!="function"&&typeof t!="symbol"?(t=wu(l),l=nn(i).hoistableScripts,n=l.get(t),n||(n={type:"script",instance:null,count:0,state:null},l.set(t,n)),n):{type:"void",instance:null,count:0,state:null};default:throw Error(u(444,e))}}function Nu(e){return'href="'+zl(e)+'"'}function Gc(e){return'link[rel="stylesheet"]['+e+"]"}function Qp(e){return b({},e,{"data-precedence":e.precedence,precedence:null})}function Yg(e,t,l,n){e.querySelector('link[rel="preload"][as="style"]['+t+"]")?n.loading=1:(t=e.createElement("link"),n.preload=t,t.addEventListener("load",function(){return n.loading|=1}),t.addEventListener("error",function(){return n.loading|=2}),fl(t,"link",l),Nt(t),e.head.appendChild(t))}function wu(e){return'[src="'+zl(e)+'"]'}function Vc(e){return"script[async]"+e}function Zp(e,t,l){if(t.count++,t.instance===null)switch(t.type){case"style":var n=e.querySelector('style[data-href~="'+zl(l.href)+'"]');if(n)return t.instance=n,Nt(n),n;var i=b({},l,{"data-href":l.href,"data-precedence":l.precedence,href:null,precedence:null});return n=(e.ownerDocument||e).createElement("style"),Nt(n),fl(n,"style",i),go(n,l.precedence,e),t.instance=n;case"stylesheet":i=Nu(l.href);var c=e.querySelector(Gc(i));if(c)return t.state.loading|=4,t.instance=c,Nt(c),c;n=Qp(l),(i=mn.get(i))&&Kf(n,i),c=(e.ownerDocument||e).createElement("link"),Nt(c);var f=c;return f._p=new Promise(function(x,z){f.onload=x,f.onerror=z}),fl(c,"link",n),t.state.loading|=4,go(c,l.precedence,e),t.instance=c;case"script":return c=wu(l.src),(i=e.querySelector(Vc(c)))?(t.instance=i,Nt(i),i):(n=l,(i=mn.get(c))&&(n=b({},l),Jf(n,i)),e=e.ownerDocument||e,i=e.createElement("script"),Nt(i),fl(i,"link",n),e.head.appendChild(i),t.instance=i);case"void":return null;default:throw Error(u(443,t.type))}else t.type==="stylesheet"&&(t.state.loading&4)===0&&(n=t.instance,t.state.loading|=4,go(n,l.precedence,e));return t.instance}function go(e,t,l){for(var n=l.querySelectorAll('link[rel="stylesheet"][data-precedence],style[data-precedence]'),i=n.length?n[n.length-1]:null,c=i,f=0;f title"):null)}function qg(e,t,l){if(l===1||t.itemProp!=null)return!1;switch(e){case"meta":case"title":return!0;case"style":if(typeof t.precedence!="string"||typeof t.href!="string"||t.href==="")break;return!0;case"link":if(typeof t.rel!="string"||typeof t.href!="string"||t.href===""||t.onLoad||t.onError)break;switch(t.rel){case"stylesheet":return e=t.disabled,typeof t.precedence=="string"&&e==null;default:return!0}case"script":if(t.async&&typeof t.async!="function"&&typeof t.async!="symbol"&&!t.onLoad&&!t.onError&&t.src&&typeof t.src=="string")return!0}return!1}function Kp(e){return!(e.type==="stylesheet"&&(e.state.loading&3)===0)}function Gg(e,t,l,n){if(l.type==="stylesheet"&&(typeof n.media!="string"||matchMedia(n.media).matches!==!1)&&(l.state.loading&4)===0){if(l.instance===null){var i=Nu(n.href),c=t.querySelector(Gc(i));if(c){t=c._p,t!==null&&typeof t=="object"&&typeof t.then=="function"&&(e.count++,e=bo.bind(e),t.then(e,e)),l.state.loading|=4,l.instance=c,Nt(c);return}c=t.ownerDocument||t,n=Qp(n),(i=mn.get(i))&&Kf(n,i),c=c.createElement("link"),Nt(c);var f=c;f._p=new Promise(function(x,z){f.onload=x,f.onerror=z}),fl(c,"link",n),l.instance=c}e.stylesheets===null&&(e.stylesheets=new Map),e.stylesheets.set(l,t),(t=l.state.preload)&&(l.state.loading&3)===0&&(e.count++,l=bo.bind(e),t.addEventListener("load",l),t.addEventListener("error",l))}}var $f=0;function Vg(e,t){return e.stylesheets&&e.count===0&&Eo(e,e.stylesheets),0$f?50:800)+t);return e.unsuspend=l,function(){e.unsuspend=null,clearTimeout(n),clearTimeout(i)}}:null}function bo(){if(this.count--,this.count===0&&(this.imgCount===0||!this.waitingForImages)){if(this.stylesheets)Eo(this,this.stylesheets);else if(this.unsuspend){var e=this.unsuspend;this.unsuspend=null,e()}}}var xo=null;function Eo(e,t){e.stylesheets=null,e.unsuspend!==null&&(e.count++,xo=new Map,t.forEach(Qg,e),xo=null,bo.call(e))}function Qg(e,t){if(!(t.state.loading&4)){var l=xo.get(e);if(l)var n=l.get(null);else{l=new Map,xo.set(e,l);for(var i=e.querySelectorAll("link[data-precedence],style[data-precedence]"),c=0;c"u"||typeof __REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE!="function"))try{__REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE(a)}catch(s){console.error(s)}}return a(),ad.exports=s1(),ad.exports}var r1=o1();/**
+ * react-router v7.18.0
+ *
+ * Copyright (c) Remix Software Inc.
+ *
+ * This source code is licensed under the MIT license found in the
+ * LICENSE.md file in the root directory of this source tree.
+ *
+ * @license MIT
+ */var Ud=/^(?:[a-z][a-z0-9+.-]*:|[\\/]{2})/i,S0=/^[\\/]{2}/;function f1(a,s){return s+a.replace(/\\/g,"/")}var yv="popstate";function gv(a){return typeof a=="object"&&a!=null&&"pathname"in a&&"search"in a&&"hash"in a&&"state"in a&&"key"in a}function d1(a={}){function s(u,r){var v;let d=(v=r.state)==null?void 0:v.masked,{pathname:m,search:g,hash:y}=d||u.location;return Ed("",{pathname:m,search:g,hash:y},r.state&&r.state.usr||null,r.state&&r.state.key||"default",d?{pathname:u.location.pathname,search:u.location.search,hash:u.location.hash}:void 0)}function o(u,r){return typeof r=="string"?r:as(r)}return m1(s,o,null,a)}function _t(a,s){if(a===!1||a===null||typeof a>"u")throw new Error(s)}function Fn(a,s){if(!a){typeof console<"u"&&console.warn(s);try{throw new Error(s)}catch{}}}function h1(){return Math.random().toString(36).substring(2,10)}function Sv(a,s){return{usr:a.state,key:a.key,idx:s,masked:a.mask?{pathname:a.pathname,search:a.search,hash:a.hash}:void 0}}function Ed(a,s,o=null,u,r){return{pathname:typeof a=="string"?a:a.pathname,search:"",hash:"",...typeof s=="string"?Qu(s):s,state:o,key:s&&s.key||u||h1(),mask:r}}function as({pathname:a="/",search:s="",hash:o=""}){return s&&s!=="?"&&(a+=s.charAt(0)==="?"?s:"?"+s),o&&o!=="#"&&(a+=o.charAt(0)==="#"?o:"#"+o),a}function Qu(a){let s={};if(a){let o=a.indexOf("#");o>=0&&(s.hash=a.substring(o),a=a.substring(0,o));let u=a.indexOf("?");u>=0&&(s.search=a.substring(u),a=a.substring(0,u)),a&&(s.pathname=a)}return s}function m1(a,s,o,u={}){let{window:r=document.defaultView,v5Compat:d=!1}=u,m=r.history,g="POP",y=null,v=E();v==null&&(v=0,m.replaceState({...m.state,idx:v},""));function E(){return(m.state||{idx:null}).idx}function b(){g="POP";let G=E(),w=G==null?null:G-v;v=G,y&&y({action:g,location:k.location,delta:w})}function _(G,w){g="PUSH";let Y=gv(G)?G:Ed(k.location,G,w);v=E()+1;let O=Sv(Y,v),$=k.createHref(Y.mask||Y);try{m.pushState(O,"",$)}catch(ie){if(ie instanceof DOMException&&ie.name==="DataCloneError")throw ie;r.location.assign($)}d&&y&&y({action:g,location:k.location,delta:1})}function N(G,w){g="REPLACE";let Y=gv(G)?G:Ed(k.location,G,w);v=E();let O=Sv(Y,v),$=k.createHref(Y.mask||Y);m.replaceState(O,"",$),d&&y&&y({action:g,location:k.location,delta:0})}function q(G){return p1(r,G)}let k={get action(){return g},get location(){return a(r,m)},listen(G){if(y)throw new Error("A history only accepts one active listener");return r.addEventListener(yv,b),y=G,()=>{r.removeEventListener(yv,b),y=null}},createHref(G){return s(r,G)},createURL:q,encodeLocation(G){let w=q(G);return{pathname:w.pathname,search:w.search,hash:w.hash}},push:_,replace:N,go(G){return m.go(G)}};return k}function p1(a,s,o=!1){let u="http://localhost";a&&(u=a.location.origin!=="null"?a.location.origin:a.location.href),_t(u,"No window.location.(origin|href) available to create URL");let r=typeof s=="string"?s:as(s);return r=r.replace(/ $/,"%20"),!o&&S0.test(r)&&(r=u+r),new URL(r,u)}function b0(a,s,o="/"){return v1(a,s,o,!1)}function v1(a,s,o,u,r){let d=typeof s=="string"?Qu(s):s,m=Ma(d.pathname||"/",o);if(m==null)return null;let g=y1(a),y=null,v=R1(m);for(let E=0;y==null&&E{let E={relativePath:v===void 0?m.path||"":v,caseSensitive:m.caseSensitive===!0,childrenIndex:g,route:m};if(E.relativePath.startsWith("/")){if(!E.relativePath.startsWith(u)&&y)return;_t(E.relativePath.startsWith(u),`Absolute route path "${E.relativePath}" nested under path "${u}" is not valid. An absolute child route path must start with the combined path of all its parent routes.`),E.relativePath=E.relativePath.slice(u.length)}let b=wn([u,E.relativePath]),_=o.concat(E);m.children&&m.children.length>0&&(_t(m.index!==!0,`Index routes must not have child routes. Please remove all child routes from route path "${b}".`),x0(m.children,s,_,b,y)),!(m.path==null&&!m.index)&&s.push({path:b,score:_1(b,m.index),routesMeta:_.map((N,q)=>{let[k,G]=j0(N.relativePath,N.caseSensitive,q===_.length-1);return{...N,matcher:k,compiledParams:G}})})};return a.forEach((m,g)=>{var y;if(m.path===""||!((y=m.path)!=null&&y.includes("?")))d(m,g);else for(let v of E0(m.path))d(m,g,!0,v)}),s}function E0(a){let s=a.split("/");if(s.length===0)return[];let[o,...u]=s,r=o.endsWith("?"),d=o.replace(/\?$/,"");if(u.length===0)return r?[d,""]:[d];let m=E0(u.join("/")),g=[];return g.push(...m.map(y=>y===""?d:[d,y].join("/"))),r&&g.push(...m),g.map(y=>a.startsWith("/")&&y===""?"/":y)}function g1(a){a.sort((s,o)=>s.score!==o.score?o.score-s.score:z1(s.routesMeta.map(u=>u.childrenIndex),o.routesMeta.map(u=>u.childrenIndex)))}var S1=/^:[\w-]+$/,b1=3,x1=2,E1=1,T1=10,j1=-2,bv=a=>a==="*";function _1(a,s){let o=a.split("/"),u=o.length;return o.some(bv)&&(u+=j1),s&&(u+=x1),o.filter(r=>!bv(r)).reduce((r,d)=>r+(S1.test(d)?b1:d===""?E1:T1),u)}function z1(a,s){return a.length===s.length&&a.slice(0,-1).every((u,r)=>u===s[r])?a[a.length-1]-s[s.length-1]:0}function A1(a,s,o=!1){let{routesMeta:u}=a,r={},d="/",m=[];for(let g=0;g{if(E==="*"){let q=g[_]||"";m=d.slice(0,d.length-q.length).replace(/(.)\/+$/,"$1")}const N=g[_];return b&&!N?v[E]=void 0:v[E]=(N||"").replace(/%2F/g,"/"),v},{}),pathname:d,pathnameBase:m,pattern:a}}function j0(a,s=!1,o=!0){Fn(a==="*"||!a.endsWith("*")||a.endsWith("/*"),`Route path "${a}" will be treated as if it were "${a.replace(/\*$/,"/*")}" because the \`*\` character must always follow a \`/\` in the pattern. To get rid of this warning, please change the route path to "${a.replace(/\*$/,"/*")}".`);let u=[],r="^"+a.replace(/\/*\*?$/,"").replace(/^\/*/,"/").replace(/[\\.*+^${}|()[\]]/g,"\\$&").replace(/\/:([\w-]+)(\?)?/g,(m,g,y,v,E)=>{if(u.push({paramName:g,isOptional:y!=null}),y){let b=E.charAt(v+m.length);return b&&b!=="/"?"/([^\\/]*)":"(?:/([^\\/]*))?"}return"/([^\\/]+)"}).replace(/\/([\w-]+)\?(\/|$)/g,"(/$1)?$2");return a.endsWith("*")?(u.push({paramName:"*"}),r+=a==="*"||a==="/*"?"(.*)$":"(?:\\/(.+)|\\/*)$"):o?r+="\\/*$":a!==""&&a!=="/"&&(r+="(?:(?=\\/|$))"),[new RegExp(r,s?void 0:"i"),u]}function R1(a){try{return a.split("/").map(s=>decodeURIComponent(s).replace(/\//g,"%2F")).join("/")}catch(s){return Fn(!1,`The URL path "${a}" could not be decoded because it is a malformed URL segment. This is probably due to a bad percent encoding (${s}).`),a}}function Ma(a,s){if(s==="/")return a;if(!a.toLowerCase().startsWith(s.toLowerCase()))return null;let o=s.endsWith("/")?s.length-1:s.length,u=a.charAt(o);return u&&u!=="/"?null:a.slice(o)||"/"}function C1(a,s="/"){let{pathname:o,search:u="",hash:r=""}=typeof a=="string"?Qu(a):a,d;return o?(o=z0(o),o.startsWith("/")?d=xv(o.substring(1),"/"):d=xv(o,s)):d=s,{pathname:d,search:N1(u),hash:w1(r)}}function xv(a,s){let o=Vo(s).split("/");return a.split("/").forEach(r=>{r===".."?o.length>1&&o.pop():r!=="."&&o.push(r)}),o.length>1?o.join("/"):"/"}function sd(a,s,o,u){return`Cannot include a '${a}' character in a manually specified \`to.${s}\` field [${JSON.stringify(u)}]. Please separate it out to the \`to.${o}\` field. Alternatively you may provide the full path as a string in and the router will parse it for you.`}function D1(a){return a.filter((s,o)=>o===0||s.route.path&&s.route.path.length>0)}function _0(a){let s=D1(a);return s.map((o,u)=>u===s.length-1?o.pathname:o.pathnameBase)}function Hd(a,s,o,u=!1){let r;typeof a=="string"?r=Qu(a):(r={...a},_t(!r.pathname||!r.pathname.includes("?"),sd("?","pathname","search",r)),_t(!r.pathname||!r.pathname.includes("#"),sd("#","pathname","hash",r)),_t(!r.search||!r.search.includes("#"),sd("#","search","hash",r)));let d=a===""||r.pathname==="",m=d?"/":r.pathname,g;if(m==null)g=o;else{let b=s.length-1;if(!u&&m.startsWith("..")){let _=m.split("/");for(;_[0]==="..";)_.shift(),b-=1;r.pathname=_.join("/")}g=b>=0?s[b]:"/"}let y=C1(r,g),v=m&&m!=="/"&&m.endsWith("/"),E=(d||m===".")&&o.endsWith("/");return!y.pathname.endsWith("/")&&(v||E)&&(y.pathname+="/"),y}var z0=a=>a.replace(/[\\/]{2,}/g,"/"),wn=a=>z0(a.join("/")),Vo=a=>a.replace(/\/+$/,""),M1=a=>Vo(a).replace(/^\/*/,"/"),N1=a=>!a||a==="?"?"":a.startsWith("?")?a:"?"+a,w1=a=>!a||a==="#"?"":a.startsWith("#")?a:"#"+a,O1=class{constructor(a,s,o,u=!1){this.status=a,this.statusText=s||"",this.internal=u,o instanceof Error?(this.data=o.toString(),this.error=o):this.data=o}};function U1(a){return a!=null&&typeof a.status=="number"&&typeof a.statusText=="string"&&typeof a.internal=="boolean"&&"data"in a}function H1(a){let s=a.map(o=>o.route.path).filter(Boolean);return wn(s)||"/"}var A0=typeof window<"u"&&typeof window.document<"u"&&typeof window.document.createElement<"u";function R0(a,s){let o=a;if(typeof o!="string"||!Ud.test(o))return{absoluteURL:void 0,isExternal:!1,to:o};let u=o,r=!1;if(A0)try{let d=new URL(window.location.href),m=S0.test(o)?new URL(f1(o,d.protocol)):new URL(o),g=Ma(m.pathname,s);m.origin===d.origin&&g!=null?o=g+m.search+m.hash:r=!0}catch{Fn(!1,` contains an invalid URL which will probably break when clicked - please update to a valid URL path.`)}return{absoluteURL:u,isExternal:r,to:o}}Object.getOwnPropertyNames(Object.prototype).sort().join("\0");var C0=["POST","PUT","PATCH","DELETE"];new Set(C0);var L1=["GET",...C0];new Set(L1);var B1=["about:","blob:","chrome:","chrome-untrusted:","content:","data:","devtools:","file:","filesystem:","javascript:"];function Y1(a){try{return B1.includes(new URL(a).protocol)}catch{return!1}}var Zu=R.createContext(null);Zu.displayName="DataRouter";var $o=R.createContext(null);$o.displayName="DataRouterState";var D0=R.createContext(!1);function q1(){return R.useContext(D0)}var M0=R.createContext({isTransitioning:!1});M0.displayName="ViewTransition";var G1=R.createContext(new Map);G1.displayName="Fetchers";var V1=R.createContext(null);V1.displayName="Await";var yn=R.createContext(null);yn.displayName="Navigation";var us=R.createContext(null);us.displayName="Location";var Pn=R.createContext({outlet:null,matches:[],isDataRoute:!1});Pn.displayName="Route";var Ld=R.createContext(null);Ld.displayName="RouteError";var N0="REACT_ROUTER_ERROR",Q1="REDIRECT",Z1="ROUTE_ERROR_RESPONSE";function k1(a){if(a.startsWith(`${N0}:${Q1}:{`))try{let s=JSON.parse(a.slice(28));if(typeof s=="object"&&s&&typeof s.status=="number"&&typeof s.statusText=="string"&&typeof s.location=="string"&&typeof s.reloadDocument=="boolean"&&typeof s.replace=="boolean")return s}catch{}}function X1(a){if(a.startsWith(`${N0}:${Z1}:{`))try{let s=JSON.parse(a.slice(40));if(typeof s=="object"&&s&&typeof s.status=="number"&&typeof s.statusText=="string")return new O1(s.status,s.statusText,s.data)}catch{}}function K1(a,{relative:s}={}){_t(cs(),"useHref() may be used only in the context of a component.");let{basename:o,navigator:u}=R.useContext(yn),{hash:r,pathname:d,search:m}=ss(a,{relative:s}),g=d;return o!=="/"&&(g=d==="/"?o:wn([o,d])),u.createHref({pathname:g,search:m,hash:r})}function cs(){return R.useContext(us)!=null}function In(){return _t(cs(),"useLocation() may be used only in the context of a component."),R.useContext(us).location}var w0="You should call navigate() in a React.useEffect(), not when your component is first rendered.";function O0(a){R.useContext(yn).static||R.useLayoutEffect(a)}function ku(){let{isDataRoute:a}=R.useContext(Pn);return a?sS():J1()}function J1(){_t(cs(),"useNavigate() may be used only in the context of a component.");let a=R.useContext(Zu),{basename:s,navigator:o}=R.useContext(yn),{matches:u}=R.useContext(Pn),{pathname:r}=In(),d=JSON.stringify(_0(u)),m=R.useRef(!1);return O0(()=>{m.current=!0}),R.useCallback((y,v={})=>{if(Fn(m.current,w0),!m.current)return;if(typeof y=="number"){o.go(y);return}let E=Hd(y,JSON.parse(d),r,v.relative==="path");a==null&&s!=="/"&&(E.pathname=E.pathname==="/"?s:wn([s,E.pathname])),(v.replace?o.replace:o.push)(E,v.state,v)},[s,o,d,r,a])}var $1=R.createContext(null);function W1(a){let s=R.useContext(Pn).outlet;return R.useMemo(()=>s&&R.createElement($1.Provider,{value:a},s),[s,a])}function ss(a,{relative:s}={}){let{matches:o}=R.useContext(Pn),{pathname:u}=In(),r=JSON.stringify(_0(o));return R.useMemo(()=>Hd(a,JSON.parse(r),u,s==="path"),[a,r,u,s])}function F1(a,s){return U0(a,s)}function U0(a,s,o){var G;_t(cs(),"useRoutes() may be used only in the context of a component.");let{navigator:u}=R.useContext(yn),{matches:r}=R.useContext(Pn),d=r[r.length-1],m=d?d.params:{},g=d?d.pathname:"/",y=d?d.pathnameBase:"/",v=d&&d.route;{let w=v&&v.path||"";L0(g,!v||w.endsWith("*")||w.endsWith("*?"),`You rendered descendant (or called \`useRoutes()\`) at "${g}" (under ) but the parent route path has no trailing "*". This means if you navigate deeper, the parent won't match anymore and therefore the child routes will never render.
+
+Please change the parent to .`)}let E=In(),b;if(s){let w=typeof s=="string"?Qu(s):s;_t(y==="/"||((G=w.pathname)==null?void 0:G.startsWith(y)),`When overriding the location using \`\` or \`useRoutes(routes, location)\`, the location pathname must begin with the portion of the URL pathname that was matched by all parent routes. The current pathname base is "${y}" but pathname "${w.pathname}" was given in the \`location\` prop.`),b=w}else b=E;let _=b.pathname||"/",N=_;if(y!=="/"){let w=y.replace(/^\//,"").split("/");N="/"+_.replace(/^\//,"").split("/").slice(w.length).join("/")}let q=o&&o.state.matches.length?o.state.matches.map(w=>Object.assign(w,{route:o.manifest[w.route.id]||w.route})):b0(a,{pathname:N});Fn(v||q!=null,`No routes matched location "${b.pathname}${b.search}${b.hash}" `),Fn(q==null||q[q.length-1].route.element!==void 0||q[q.length-1].route.Component!==void 0||q[q.length-1].route.lazy!==void 0,`Matched leaf route at location "${b.pathname}${b.search}${b.hash}" does not have an element or Component. This means it will render an with a null value by default resulting in an "empty" page.`);let k=lS(q&&q.map(w=>Object.assign({},w,{params:Object.assign({},m,w.params),pathname:wn([y,u.encodeLocation?u.encodeLocation(w.pathname.replace(/%/g,"%25").replace(/\?/g,"%3F").replace(/#/g,"%23")).pathname:w.pathname]),pathnameBase:w.pathnameBase==="/"?y:wn([y,u.encodeLocation?u.encodeLocation(w.pathnameBase.replace(/%/g,"%25").replace(/\?/g,"%3F").replace(/#/g,"%23")).pathname:w.pathnameBase])})),r,o);return s&&k?R.createElement(us.Provider,{value:{location:{pathname:"/",search:"",hash:"",state:null,key:"default",mask:void 0,...b},navigationType:"POP"}},k):k}function P1(){let a=cS(),s=U1(a)?`${a.status} ${a.statusText}`:a instanceof Error?a.message:JSON.stringify(a),o=a instanceof Error?a.stack:null,u="rgba(200,200,200, 0.5)",r={padding:"0.5rem",backgroundColor:u},d={padding:"2px 4px",backgroundColor:u},m=null;return console.error("Error handled by React Router default ErrorBoundary:",a),m=R.createElement(R.Fragment,null,R.createElement("p",null,"💿 Hey developer 👋"),R.createElement("p",null,"You can provide a way better UX than this when your app throws errors by providing your own ",R.createElement("code",{style:d},"ErrorBoundary")," or"," ",R.createElement("code",{style:d},"errorElement")," prop on your route.")),R.createElement(R.Fragment,null,R.createElement("h2",null,"Unexpected Application Error!"),R.createElement("h3",{style:{fontStyle:"italic"}},s),o?R.createElement("pre",{style:r},o):null,m)}var I1=R.createElement(P1,null),H0=class extends R.Component{constructor(a){super(a),this.state={location:a.location,revalidation:a.revalidation,error:a.error}}static getDerivedStateFromError(a){return{error:a}}static getDerivedStateFromProps(a,s){return s.location!==a.location||s.revalidation!=="idle"&&a.revalidation==="idle"?{error:a.error,location:a.location,revalidation:a.revalidation}:{error:a.error!==void 0?a.error:s.error,location:s.location,revalidation:a.revalidation||s.revalidation}}componentDidCatch(a,s){this.props.onError?this.props.onError(a,s):console.error("React Router caught the following error during render",a)}render(){let a=this.state.error;if(this.context&&typeof a=="object"&&a&&"digest"in a&&typeof a.digest=="string"){const o=X1(a.digest);o&&(a=o)}let s=a!==void 0?R.createElement(Pn.Provider,{value:this.props.routeContext},R.createElement(Ld.Provider,{value:a,children:this.props.component})):this.props.children;return this.context?R.createElement(eS,{error:a},s):s}};H0.contextType=D0;var od=new WeakMap;function eS({children:a,error:s}){let{basename:o}=R.useContext(yn);if(typeof s=="object"&&s&&"digest"in s&&typeof s.digest=="string"){let u=k1(s.digest);if(u){let r=od.get(s);if(r)throw r;let d=R0(u.location,o),m=d.absoluteURL||d.to;if(Y1(m))throw new Error("Invalid redirect location");if(A0&&!od.get(s))if(d.isExternal||u.reloadDocument)window.location.href=m;else{const g=Promise.resolve().then(()=>window.__reactRouterDataRouter.navigate(d.to,{replace:u.replace}));throw od.set(s,g),g}return R.createElement("meta",{httpEquiv:"refresh",content:`0;url=${m}`})}}return a}function tS({routeContext:a,match:s,children:o}){let u=R.useContext(Zu);return u&&u.static&&u.staticContext&&(s.route.errorElement||s.route.ErrorBoundary)&&(u.staticContext._deepestRenderedBoundaryId=s.route.id),R.createElement(Pn.Provider,{value:a},o)}function lS(a,s=[],o){let u=o==null?void 0:o.state;if(a==null){if(!u)return null;if(u.errors)a=u.matches;else if(s.length===0&&!u.initialized&&u.matches.length>0)a=u.matches;else return null}let r=a,d=u==null?void 0:u.errors;if(d!=null){let E=r.findIndex(b=>b.route.id&&(d==null?void 0:d[b.route.id])!==void 0);_t(E>=0,`Could not find a matching route for errors on route IDs: ${Object.keys(d).join(",")}`),r=r.slice(0,Math.min(r.length,E+1))}let m=!1,g=-1;if(o&&u){m=u.renderFallback;for(let E=0;E=0?r=r.slice(0,g+1):r=[r[0]];break}}}}let y=o==null?void 0:o.onError,v=u&&y?(E,b)=>{var _,N;y(E,{location:u.location,params:((N=(_=u.matches)==null?void 0:_[0])==null?void 0:N.params)??{},pattern:H1(u.matches),errorInfo:b})}:void 0;return r.reduceRight((E,b,_)=>{let N,q=!1,k=null,G=null;u&&(N=d&&b.route.id?d[b.route.id]:void 0,k=b.route.errorElement||I1,m&&(g<0&&_===0?(L0("route-fallback",!1,"No `HydrateFallback` element provided to render during initial hydration"),q=!0,G=null):g===_&&(q=!0,G=b.route.hydrateFallbackElement||null)));let w=s.concat(r.slice(0,_+1)),Y=()=>{let O;return N?O=k:q?O=G:b.route.Component?O=R.createElement(b.route.Component,null):b.route.element?O=b.route.element:O=E,R.createElement(tS,{match:b,routeContext:{outlet:E,matches:w,isDataRoute:u!=null},children:O})};return u&&(b.route.ErrorBoundary||b.route.errorElement||_===0)?R.createElement(H0,{location:u.location,revalidation:u.revalidation,component:k,error:N,children:Y(),routeContext:{outlet:null,matches:w,isDataRoute:!0},onError:v}):Y()},null)}function Bd(a){return`${a} must be used within a data router. See https://reactrouter.com/en/main/routers/picking-a-router.`}function nS(a){let s=R.useContext(Zu);return _t(s,Bd(a)),s}function aS(a){let s=R.useContext($o);return _t(s,Bd(a)),s}function iS(a){let s=R.useContext(Pn);return _t(s,Bd(a)),s}function Yd(a){let s=iS(a),o=s.matches[s.matches.length-1];return _t(o.route.id,`${a} can only be used on routes that contain a unique "id"`),o.route.id}function uS(){return Yd("useRouteId")}function cS(){var u;let a=R.useContext(Ld),s=aS("useRouteError"),o=Yd("useRouteError");return a!==void 0?a:(u=s.errors)==null?void 0:u[o]}function sS(){let{router:a}=nS("useNavigate"),s=Yd("useNavigate"),o=R.useRef(!1);return O0(()=>{o.current=!0}),R.useCallback(async(r,d={})=>{Fn(o.current,w0),o.current&&(typeof r=="number"?await a.navigate(r):await a.navigate(r,{fromRouteId:s,...d}))},[a,s])}var Ev={};function L0(a,s,o){!s&&!Ev[a]&&(Ev[a]=!0,Fn(!1,o))}R.memo(oS);function oS({routes:a,manifest:s,future:o,state:u,isStatic:r,onError:d}){return U0(a,void 0,{manifest:s,state:u,isStatic:r,onError:d})}function rS(a){return W1(a.context)}function Cn(a){_t(!1,"A is only ever to be used as the child of element, never rendered directly. Please wrap your in a .")}function fS({basename:a="/",children:s=null,location:o,navigationType:u="POP",navigator:r,static:d=!1,useTransitions:m}){_t(!cs(),"You cannot render a inside another . You should never have more than one in your app.");let g=a.replace(/^\/*/,"/"),y=R.useMemo(()=>({basename:g,navigator:r,static:d,useTransitions:m,future:{}}),[g,r,d,m]);typeof o=="string"&&(o=Qu(o));let{pathname:v="/",search:E="",hash:b="",state:_=null,key:N="default",mask:q}=o,k=R.useMemo(()=>{let G=Ma(v,g);return G==null?null:{location:{pathname:G,search:E,hash:b,state:_,key:N,mask:q},navigationType:u}},[g,v,E,b,_,N,u,q]);return Fn(k!=null,` is not able to match the URL "${v}${E}${b}" because it does not start with the basename, so the won't render anything.`),k==null?null:R.createElement(yn.Provider,{value:y},R.createElement(us.Provider,{children:s,value:k}))}function dS({children:a,location:s}){return F1(Td(a),s)}function Td(a,s=[]){let o=[];return R.Children.forEach(a,(u,r)=>{if(!R.isValidElement(u))return;let d=[...s,r];if(u.type===R.Fragment){o.push.apply(o,Td(u.props.children,d));return}_t(u.type===Cn,`[${typeof u.type=="string"?u.type:u.type.name}] is not a component. All component children of must be a or `),_t(!u.props.index||!u.props.children,"An index route cannot have child routes.");let m={id:u.props.id||d.join("-"),caseSensitive:u.props.caseSensitive,element:u.props.element,Component:u.props.Component,index:u.props.index,path:u.props.path,middleware:u.props.middleware,loader:u.props.loader,action:u.props.action,hydrateFallbackElement:u.props.hydrateFallbackElement,HydrateFallback:u.props.HydrateFallback,errorElement:u.props.errorElement,ErrorBoundary:u.props.ErrorBoundary,hasErrorBoundary:u.props.hasErrorBoundary===!0||u.props.ErrorBoundary!=null||u.props.errorElement!=null,shouldRevalidate:u.props.shouldRevalidate,handle:u.props.handle,lazy:u.props.lazy};u.props.children&&(m.children=Td(u.props.children,d)),o.push(m)}),o}var Lo="get",Bo="application/x-www-form-urlencoded";function Wo(a){return typeof HTMLElement<"u"&&a instanceof HTMLElement}function hS(a){return Wo(a)&&a.tagName.toLowerCase()==="button"}function mS(a){return Wo(a)&&a.tagName.toLowerCase()==="form"}function pS(a){return Wo(a)&&a.tagName.toLowerCase()==="input"}function vS(a){return!!(a.metaKey||a.altKey||a.ctrlKey||a.shiftKey)}function yS(a,s){return a.button===0&&(!s||s==="_self")&&!vS(a)}var Mo=null;function gS(){if(Mo===null)try{new FormData(document.createElement("form"),0),Mo=!1}catch{Mo=!0}return Mo}var SS=new Set(["application/x-www-form-urlencoded","multipart/form-data","text/plain"]);function rd(a){return a!=null&&!SS.has(a)?(Fn(!1,`"${a}" is not a valid \`encType\` for \`