Chandra Kiran commited on
Commit
52ba0e1
·
unverified ·
1 Parent(s): 7f9f21f

Brand the app, strict per-tester GPU shares, free prompt enhancement

Browse files

Web app, RosDiff by Orcanion: the logo on the splash (short for returning
visitors, skippable), sign-in, header and favicon; a 10-step beginner tour with
the LiDAR simulator example; the routing line on every result (tier, reason,
Why?); the simulator RosDiff picked as the main button plus "Use another
simulator" (MuJoCo, MuJoCo Warp, Isaac Sim; Gazebo, Webots and PyBullet listed
as not available yet); an estimate card before costly tasks; Helpful / Not
helpful with an optional note on scenes, packages and jobs; a usage dialog with
the rupee budget, the GPU share and, for admins, the team.

GPU budget: MONTHLY_CREDIT_LIMIT_USD (now 29.98) is split equally between
COMPUTE_TESTERS (4), so $7.495 each. A job is admitted only if its worst case
(run until timeout) fits in both the tester's share and the team limit, and
running jobs count at their worst case, so parallel jobs cannot overshoot.

Prompt enhancement: each scene and code request is first rewritten into a
precise brief by OpenCode with a free OpenCode Zen model (ENHANCE_MODEL, default
opencode/deepseek-v4-flash-free), never through OpenRouter. It is logged as an
exempt call, rejected if it drops any number the tester wrote, and skipped on
any failure. Routing and estimates still use the tester's own words.

.env.example CHANGED
@@ -26,6 +26,12 @@ OPENCODE_BIN=opencode
26
  OPENCODE_TIMEOUT=600
27
  CODE_MAX_ATTEMPTS=2
28
 
 
 
 
 
 
 
29
  # --- Simulation runs: POST /simulate/run (disabled until the backend and R2 are configured)
30
  SIM_BACKEND=modal # "modal" (default) or "runpod"
31
 
@@ -58,8 +64,10 @@ USD_INR=88 # exchange rate used to convert OpenRouter's d
58
  CONFIRM_ABOVE_INR=10 # ask the tester before a task estimated to cost more than this
59
  ADMIN_TESTERS= # comma-separated tester names who see the team budget
60
 
61
- # --- Spend: every job's cost is estimated from its run time; new jobs are refused past the monthly limit
62
- MONTHLY_CREDIT_LIMIT_USD=28 # Modal's free credit is $30/month; 0 = no limit
 
 
63
  STORAGE_LIMIT_GB=8 # new jobs are refused while the R2 bucket holds more (free tier: 10 GB)
64
  # JOB_PRICES=t4=0.00019,l4=0.00025,cpu=0.00004,runpod=0.00031 # USD per second of container time
65
  # JOB_OVERHEAD_S=45 # billed start-up + idle seconds added to each job
 
26
  OPENCODE_TIMEOUT=600
27
  CODE_MAX_ATTEMPTS=2
28
 
29
+ # --- Prompt enhancement: OpenCode + a free OpenCode Zen model (never OpenRouter, never billed; best effort)
30
+ PROMPT_ENHANCE=true
31
+ ENHANCE_MODEL=opencode/deepseek-v4-flash-free
32
+ ENHANCE_TIMEOUT=90
33
+ ZEN_API_KEY= # only if the free Zen model asks for a key (opencode.ai/zen)
34
+
35
  # --- Simulation runs: POST /simulate/run (disabled until the backend and R2 are configured)
36
  SIM_BACKEND=modal # "modal" (default) or "runpod"
37
 
 
64
  CONFIRM_ABOVE_INR=10 # ask the tester before a task estimated to cost more than this
65
  ADMIN_TESTERS= # comma-separated tester names who see the team budget
66
 
67
+ # --- GPU spend: each job's worst case must fit in the tester's equal share and the team's monthly limit
68
+ MONTHLY_CREDIT_LIMIT_USD=29.98 # the Modal credit for the month; 0 = no limit
69
+ COMPUTE_TESTERS=4 # the credit is split equally: 29.98 / 4 = $7.495 per tester
70
+ # COMPUTE_PER_TESTER_USD= # set to override the equal share
71
  STORAGE_LIMIT_GB=8 # new jobs are refused while the R2 bucket holds more (free tier: 10 GB)
72
  # JOB_PRICES=t4=0.00019,l4=0.00025,cpu=0.00004,runpod=0.00031 # USD per second of container time
73
  # JOB_OVERHEAD_S=45 # billed start-up + idle seconds added to each job
README.md CHANGED
@@ -22,11 +22,43 @@ No payments, no tiers, no signup — a handful of API keys, one model, and a log
22
  Robots: Unitree **G1** and **H1** humanoids, Unitree **Go2** quadruped, **Franka Panda** arm (all from MuJoCo
23
  Menagerie). Generated ROS 2 code only ever runs inside the sandbox (no network, no secrets).
24
 
25
- The model is **GLM-5.3 through OpenRouter** by default (`z-ai/glm-5.3`, a fraction of the price of frontier
26
- models, strong at code). Any OpenAI-compatible endpoint works: set `LLM_BASE_URL`, `LLM_MODEL` and `LLM_API_KEY`
27
- (e.g. `deepseek/deepseek-v4-pro` on OpenRouter, or Z.ai's own API). `CODE_LLM_MODEL` sets a different
28
- model for ROS 2 code, what OpenCode uses: default `deepseek/deepseek-v4-flash` (79.0% SWE-bench Verified, about
29
- $0.20 per million output tokens); `deepseek/deepseek-v4-pro` (80.6%) is the step up. Everyone uses the same models and key.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
30
 
31
  ## How it works
32
 
@@ -179,9 +211,9 @@ for download (it opens in any Foxglove app).
179
  (active workers 0, max workers 2, execution timeout 600 s, the four `R2_*` variables set on the endpoint). Put
180
  the endpoint ID and an API key in `.env` as `RUNPOD_ENDPOINT_ID` / `RUNPOD_API_KEY`.
181
  4. **Report links**: set `REPORT_SECRET` (`openssl rand -hex 32`) and `PUBLIC_BASE_URL` (the address testers use).
182
- 5. **Spend limit**: every job's cost is estimated from its run time (`JOB_PRICES`, `JOB_OVERHEAD_S`). Once this
183
- month's estimate reaches `MONTHLY_CREDIT_LIMIT_USD` (default $28, under Modal's $30 credit) new jobs are
184
- refused with HTTP 402; everything else keeps working. Likewise, while the R2 bucket holds more than `STORAGE_LIMIT_GB`
185
  (default 8, under R2's free 10 GB) new jobs are refused with HTTP 507 until old files expire. The estimate is not Modal's bill, so also set a budget
186
  or usage alert in Modal's workspace settings (*Usage and Billing*). On RunPod, set a spend notification.
187
 
@@ -190,8 +222,8 @@ for download (it opens in any Foxglove app).
190
 
191
  ### Usage visibility
192
 
193
- Stage 1 runs are founder-funded for at most 5 testers. There are no per-tester caps, only the shared monthly
194
- limit above and visibility:
195
 
196
  - every `/simulate/run` call is a row in the `runs` table: tester, time, source generation, duration, backend + job
197
  ID, status, GPU time (RunPod's billed figure, or the worker's own timing on Modal)
 
22
  Robots: Unitree **G1** and **H1** humanoids, Unitree **Go2** quadruped, **Franka Panda** arm (all from MuJoCo
23
  Menagerie). Generated ROS 2 code only ever runs inside the sandbox (no network, no secrets).
24
 
25
+ ### Models, budgets and prompt enhancement
26
+
27
+ Testers never pick a model. **Automatic routing** (`api/routing.py`) rates every request's complexity and sends it
28
+ to the cheapest capable tier; a draft that fails validation is repaired one tier up. Defaults, all on OpenRouter
29
+ (override with `MODEL_REGISTRY`):
30
+
31
+ | Tier | Model | Used for |
32
+ |---|---|---|
33
+ | low | `deepseek/deepseek-v4-flash` | small ROS 2 nodes |
34
+ | mid | `deepseek/deepseek-v4-pro` | most scenes, packages with several parts |
35
+ | high | `z-ai/glm-5.3` | articulated scenes, Nav2 / MoveIt / control stacks, escalated repairs |
36
+
37
+ The web app shows the choice as information ("AI Routing: automatic · Standard tier · Why?"), plus the simulator
38
+ it picked (MuJoCo, MuJoCo Warp for "how often does it…" questions, Isaac Sim for photoreal renders) with a
39
+ **Use another simulator** menu. Gazebo, Webots and PyBullet are listed as not available yet.
40
+
41
+ **Prompt enhancement (free).** Before generating, the tester's words are rewritten into a precise brief by
42
+ OpenCode with a free OpenCode Zen model (`ENHANCE_MODEL`, default `opencode/deepseek-v4-flash-free`). It never
43
+ goes through OpenRouter and costs nothing; the call is logged as exempt. The brief must keep every number the
44
+ tester wrote, or it is thrown away, and any failure (OpenCode missing, timeout, a rotated-out free model) just
45
+ means the original text is used. Routing and cost estimates still use the tester's own words. The app shows the
46
+ brief under "✨ Enhanced prompt". It needs `opencode` installed on the API server (`npm i -g opencode-ai`);
47
+ `PROMPT_ENHANCE=false` switches it off, `ZEN_API_KEY` supplies a Zen key if the free model asks for one.
48
+
49
+ **AI budget (rupees).** One pool, `BUDGET_TOTAL_INR` (₹500), and an equal allocation per tester,
50
+ `BUDGET_PER_USER_INR` (₹125). It covers every billable OpenRouter call: coding, simulation AI, automatic repairs
51
+ ("debugging") and testing. Costs come from OpenRouter's reported usage (list prices otherwise). Free and
52
+ OpenRouter-exempt calls are recorded but not deducted. Tasks whose worst case is above `CONFIRM_ABOVE_INR` show
53
+ an estimate and wait for "Run". Testers see their balance in the top bar and under *My usage*; testers listed in
54
+ `ADMIN_TESTERS` also see the whole team.
55
+
56
+ **GPU budget (dollars).** Simulations, sweeps, renders and build checks run on Modal's credit,
57
+ `MONTHLY_CREDIT_LIMIT_USD` (default $29.98), split equally between `COMPUTE_TESTERS` (4): $7.495 each per month
58
+ (`COMPUTE_PER_TESTER_USD` overrides the share). The limit is strict: a job starts only if its **worst case**
59
+ (the cost of running until its timeout) still fits in both the tester's share and the team's limit, and jobs
60
+ still running count at that worst case, so parallel jobs can never overshoot. Finished jobs count at their
61
+ measured cost.
62
 
63
  ## How it works
64
 
 
211
  (active workers 0, max workers 2, execution timeout 600 s, the four `R2_*` variables set on the endpoint). Put
212
  the endpoint ID and an API key in `.env` as `RUNPOD_ENDPOINT_ID` / `RUNPOD_API_KEY`.
213
  4. **Report links**: set `REPORT_SECRET` (`openssl rand -hex 32`) and `PUBLIC_BASE_URL` (the address testers use).
214
+ 5. **Spend limit**: every job's cost is estimated from its run time (`JOB_PRICES`, `JOB_OVERHEAD_S`). A new job is
215
+ refused with HTTP 402 unless its worst case still fits in the tester's equal share and in
216
+ `MONTHLY_CREDIT_LIMIT_USD` (default $29.98; see *GPU budget* above); everything else keeps working. Likewise, while the R2 bucket holds more than `STORAGE_LIMIT_GB`
217
  (default 8, under R2's free 10 GB) new jobs are refused with HTTP 507 until old files expire. The estimate is not Modal's bill, so also set a budget
218
  or usage alert in Modal's workspace settings (*Usage and Billing*). On RunPod, set a spend notification.
219
 
 
222
 
223
  ### Usage visibility
224
 
225
+ Stage 1 runs are founder-funded for 4 testers, each with an equal GPU share (above) and an equal AI budget. On top
226
+ of that:
227
 
228
  - every `/simulate/run` call is a row in the `runs` table: tester, time, source generation, duration, backend + job
229
  ID, status, GPU time (RunPod's billed figure, or the worker's own timing on Modal)
api/config.py CHANGED
@@ -53,6 +53,9 @@ class Settings:
53
  public_base_url: str # how testers reach this API (for report links); empty = use the request's host
54
  # Spend: estimated from each job's run time; new jobs are refused once the month's estimate reaches the limit.
55
  monthly_credit_usd: float # 0 = no limit
 
 
 
56
  storage_limit_gb: float # new jobs are refused while the R2 bucket holds more than this (0 = no limit)
57
  job_prices: dict # USD per second by hardware: t4, l4, cpu, runpod
58
  job_overhead_s: float # billed seconds per job beyond the worker's own timing (start-up, idle shutdown)
@@ -68,6 +71,20 @@ class Settings:
68
  admin_testers: tuple # testers who see the team budget view
69
  hf_namespace: str # Hugging Face user/org that dataset exports are pushed to (with the worker's HF_TOKEN)
70
  foxglove_org: str # optional Foxglove organization slug for the embedded viewer
 
 
 
 
 
 
 
 
 
 
 
 
 
 
71
 
72
  @property
73
  def interfaces_file(self) -> Path:
@@ -122,7 +139,9 @@ def get_settings() -> Settings:
122
  run_burst_warn=int(_env("RUN_BURST_WARN", "10")),
123
  report_secret=_env("REPORT_SECRET", ""),
124
  public_base_url=_env("PUBLIC_BASE_URL", "").rstrip("/"),
125
- monthly_credit_usd=float(_env("MONTHLY_CREDIT_LIMIT_USD", "28")),
 
 
126
  storage_limit_gb=float(_env("STORAGE_LIMIT_GB", "8")),
127
  job_prices=_prices(_env("JOB_PRICES", "")),
128
  job_overhead_s=float(_env("JOB_OVERHEAD_S", "45")),
@@ -135,4 +154,8 @@ def get_settings() -> Settings:
135
  confirm_above_inr=float(_env("CONFIRM_ABOVE_INR", "10")),
136
  admin_testers=tuple(n.strip() for n in _env("ADMIN_TESTERS", "").split(",") if n.strip()),
137
  foxglove_org=_env("FOXGLOVE_ORG", ""),
 
 
 
 
138
  )
 
53
  public_base_url: str # how testers reach this API (for report links); empty = use the request's host
54
  # Spend: estimated from each job's run time; new jobs are refused once the month's estimate reaches the limit.
55
  monthly_credit_usd: float # 0 = no limit
56
+ # ...and each tester gets an equal share of it, enforced before every job against the job's worst case.
57
+ compute_testers: int # how many testers share the compute credit
58
+ compute_per_tester_usd: float # 0 = monthly_credit_usd / compute_testers
59
  storage_limit_gb: float # new jobs are refused while the R2 bucket holds more than this (0 = no limit)
60
  job_prices: dict # USD per second by hardware: t4, l4, cpu, runpod
61
  job_overhead_s: float # billed seconds per job beyond the worker's own timing (start-up, idle shutdown)
 
71
  admin_testers: tuple # testers who see the team budget view
72
  hf_namespace: str # Hugging Face user/org that dataset exports are pushed to (with the worker's HF_TOKEN)
73
  foxglove_org: str # optional Foxglove organization slug for the embedded viewer
74
+ # Prompt enhancement through OpenCode with a free OpenCode Zen model (never OpenRouter, never billed).
75
+ prompt_enhance: bool
76
+ enhance_model: str # "<opencode provider>/<model>"
77
+ enhance_timeout: float
78
+ zen_api_key: str # OpenCode Zen key, if the free model asks for one
79
+
80
+ @property
81
+ def compute_share_usd(self) -> float:
82
+ """Each tester's monthly GPU/CPU allowance in dollars (0 = no per-tester limit)."""
83
+ if self.compute_per_tester_usd > 0:
84
+ return self.compute_per_tester_usd
85
+ if self.monthly_credit_usd > 0 and self.compute_testers > 0:
86
+ return round(self.monthly_credit_usd / self.compute_testers, 6)
87
+ return 0.0
88
 
89
  @property
90
  def interfaces_file(self) -> Path:
 
139
  run_burst_warn=int(_env("RUN_BURST_WARN", "10")),
140
  report_secret=_env("REPORT_SECRET", ""),
141
  public_base_url=_env("PUBLIC_BASE_URL", "").rstrip("/"),
142
+ monthly_credit_usd=float(_env("MONTHLY_CREDIT_LIMIT_USD", "29.98")),
143
+ compute_testers=int(_env("COMPUTE_TESTERS", "4")),
144
+ compute_per_tester_usd=float(_env("COMPUTE_PER_TESTER_USD", "0")),
145
  storage_limit_gb=float(_env("STORAGE_LIMIT_GB", "8")),
146
  job_prices=_prices(_env("JOB_PRICES", "")),
147
  job_overhead_s=float(_env("JOB_OVERHEAD_S", "45")),
 
154
  confirm_above_inr=float(_env("CONFIRM_ABOVE_INR", "10")),
155
  admin_testers=tuple(n.strip() for n in _env("ADMIN_TESTERS", "").split(",") if n.strip()),
156
  foxglove_org=_env("FOXGLOVE_ORG", ""),
157
+ prompt_enhance=_env("PROMPT_ENHANCE", "true").lower() in ("1", "true", "yes"),
158
+ enhance_model=_env("ENHANCE_MODEL", "opencode/deepseek-v4-flash-free"),
159
+ enhance_timeout=float(_env("ENHANCE_TIMEOUT", "90")),
160
+ zen_api_key=_env("ZEN_API_KEY", ""),
161
  )
api/enhance.py ADDED
@@ -0,0 +1,156 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Prompt enhancement: turn a tester's short request into a precise brief before generation.
2
+
3
+ It runs through OpenCode with a free OpenCode Zen model (ENHANCE_MODEL, default opencode/deepseek-v4-flash-free),
4
+ never through OpenRouter, so it costs nothing and is recorded in the ledger as exempt. It is best effort: when
5
+ OpenCode is missing, slow or returns something unusable, generation simply goes ahead with the original text.
6
+
7
+ The enhancer may only make the request clearer and more complete. It keeps every number, name and constraint the
8
+ tester gave, and it must not add goals of its own; the routing tier and the cost estimate are still decided from the
9
+ tester's own words.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import logging
15
+ import re
16
+ import time
17
+ from dataclasses import asdict, dataclass
18
+
19
+ from .opencode_runner import OpenCodeError, OpenCodeWorkspace, find_opencode
20
+
21
+ log = logging.getLogger("rosdiff.enhance")
22
+
23
+ MAX_CHARS = 3000
24
+
25
+ SCENE_BRIEF = """You improve requests for a MuJoCo scene generator used by robotics students.
26
+ Rewrite the request below as a precise scene brief:
27
+ - keep every number, unit, object, colour and constraint exactly as given;
28
+ - where a size, position or mass is missing, choose a sensible value and state it (metres, kilograms);
29
+ - say where each object is relative to the robot (the robot stands at the origin, facing +x);
30
+ - mention what should be tested, if the request implies it (e.g. walking up the steps, being pushed);
31
+ - do not add unrelated objects or goals, and do not write MJCF or code.
32
+ Robot: {robot}.
33
+ Reply with only the rewritten brief inside <prompt></prompt>, at most 150 words.
34
+
35
+ Request:
36
+ {text}"""
37
+
38
+ CODE_BRIEF = """You improve requests for a ROS 2 package generator used by robotics students.
39
+ Rewrite the request below as a precise package brief:
40
+ - keep every topic, message type, parameter, rate and threshold exactly as given;
41
+ - where one is missing, choose the standard ROS 2 choice and state it (topic names, message types, QoS, rates);
42
+ - list the node(s), what each subscribes to and publishes, parameters with defaults, and the launch file;
43
+ - do not add features the request did not ask for, and do not write code.
44
+ Target: ROS 2 {distro}, {language}.
45
+ Reply with only the rewritten brief inside <prompt></prompt>, at most 180 words.
46
+
47
+ Request:
48
+ {text}"""
49
+
50
+
51
+ @dataclass
52
+ class Enhanced:
53
+ original: str
54
+ text: str # what generation uses: the enhanced brief, or the original when enhancement was skipped
55
+ used: bool
56
+ model: str | None = None
57
+ note: str | None = None # why it was skipped
58
+ usage: dict | None = None
59
+ ms: int = 0
60
+
61
+ def to_dict(self) -> dict:
62
+ return asdict(self)
63
+
64
+
65
+ def _extract(events: list[dict]) -> str:
66
+ texts = []
67
+ for e in events:
68
+ part = e.get("part") or {}
69
+ if (e.get("type") == "text" or part.get("type") == "text") and isinstance(part.get("text"), str):
70
+ texts.append(part["text"])
71
+ raw = "\n".join(texts).strip()
72
+ m = re.search(r"<prompt>(.*?)</prompt>", raw, re.S)
73
+ return (m.group(1) if m else raw).strip()
74
+
75
+
76
+ def _plausible(original: str, brief: str) -> str | None:
77
+ """Reason to reject the brief, or None. Every number the tester wrote must survive the rewrite."""
78
+ if len(brief) < 10:
79
+ return "empty answer"
80
+ if len(brief) > MAX_CHARS:
81
+ return "answer too long"
82
+ lost = [n for n in re.findall(r"\d+(?:\.\d+)?", original) if n not in brief]
83
+ if lost:
84
+ return f"the rewrite dropped the value(s) {', '.join(lost[:3])}"
85
+ return None
86
+
87
+
88
+ def enhance(settings, kind: str, text: str, **ctx) -> Enhanced:
89
+ """kind: 'scene' (ctx: robot) or 'code' (ctx: distro, language)."""
90
+ if not settings.prompt_enhance:
91
+ return Enhanced(text, text, False, note="switched off (PROMPT_ENHANCE=false)")
92
+ if not find_opencode(settings):
93
+ return Enhanced(text, text, False, note="OpenCode is not installed on the server")
94
+ if kind == "scene":
95
+ message = SCENE_BRIEF.format(robot=ctx.get("robot") or "none", text=text)
96
+ else:
97
+ lang = "C++" if ctx.get("language") == "cpp" else "Python"
98
+ message = CODE_BRIEF.format(distro=ctx.get("distro", "humble"), language=lang, text=text)
99
+ provider, _, model = settings.enhance_model.partition("/")
100
+ start = time.monotonic()
101
+ try:
102
+ with OpenCodeWorkspace(settings, config=enhance_config(settings), timeout=settings.enhance_timeout) as ws:
103
+ run = ws.run(message, context="", model=model, provider=provider)
104
+ except OpenCodeError as e:
105
+ log.warning("prompt enhancement skipped: %s", e)
106
+ return Enhanced(text, text, False, settings.enhance_model, str(e), ms=int((time.monotonic() - start) * 1000))
107
+ ms = int((time.monotonic() - start) * 1000)
108
+ if run.error:
109
+ log.warning("prompt enhancement failed: %s", run.error)
110
+ return Enhanced(text, text, False, settings.enhance_model, run.error, run.usage(), ms)
111
+ brief = _extract(run.events)
112
+ if why := _plausible(text, brief):
113
+ return Enhanced(text, text, False, settings.enhance_model, why, run.usage(), ms)
114
+ return Enhanced(text, brief, True, settings.enhance_model, None, run.usage(), ms)
115
+
116
+
117
+ def enhance_config(settings) -> dict:
118
+ """Only the free Zen model, with no tools: the enhancer reads and answers, nothing else."""
119
+ provider, _, model = settings.enhance_model.partition("/")
120
+ options = {"apiKey": "{env:ZEN_API_KEY}"} if settings.zen_api_key else {}
121
+ return {
122
+ "$schema": "https://opencode.ai/config.json",
123
+ "model": settings.enhance_model,
124
+ "provider": {provider: {"options": options, "models": {model: {"name": model}}}},
125
+ "permission": {
126
+ "edit": "deny",
127
+ "bash": "deny",
128
+ "webfetch": "deny",
129
+ "websearch": "deny",
130
+ "external_directory": "deny",
131
+ },
132
+ "autoupdate": False,
133
+ "share": "disabled",
134
+ }
135
+
136
+
137
+ def record(logs, tester: str, generation_id: str | None, category: str, e: Enhanced) -> None:
138
+ """Free calls are still recorded (as exempt), so the ledger shows everything RosDiff asked a model."""
139
+ if not e.model or e.usage is None:
140
+ return
141
+ logs.record_usage(
142
+ tester=tester,
143
+ generation_id=generation_id,
144
+ category=category,
145
+ model=e.model,
146
+ tier="enhance",
147
+ provider="opencode-zen",
148
+ input_tokens=int(e.usage.get("input_tokens") or 0),
149
+ output_tokens=int(e.usage.get("output_tokens") or 0),
150
+ cost_usd=0.0,
151
+ cost_inr=0.0,
152
+ billable=0,
153
+ exempt_reason="opencode_zen_free",
154
+ estimated=0,
155
+ estimate_inr=None,
156
+ )
api/jobs.py CHANGED
@@ -93,6 +93,40 @@ def month_spend(settings, logs) -> float:
93
  return round(sum(estimated_cost(settings, j) for j in logs.jobs_since(month_start())), 4)
94
 
95
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
96
  _STORAGE_CACHE: dict = {}
97
  STORAGE_CHECK_EVERY_S = 300
98
 
@@ -133,14 +167,22 @@ def submit(
133
  function = spec.function.format(**job_input) if "{" in spec.function else spec.function
134
  if not s.backend.supports(function):
135
  raise JobError(501, f"{spec.label} is not available on the {s.backend.name} backend (use SIM_BACKEND=modal)")
136
- limit = s.settings.monthly_credit_usd
137
- if limit > 0:
138
- spent = month_spend(s.settings, s.logs)
139
- if spent >= limit:
 
 
140
  raise JobError(
141
  402,
142
- f"this month's compute budget is used up (estimated ${spent:.2f} of ${limit:.2f}); "
143
- "ask the Orcanion team to raise MONTHLY_CREDIT_LIMIT_USD",
 
 
 
 
 
 
144
  )
145
  if s.settings.storage_limit_gb > 0:
146
  used = stored_gb(s)
@@ -155,7 +197,6 @@ def submit(
155
  if today >= s.settings.isaac_daily_limit:
156
  raise JobError(429, f"the daily limit of {s.settings.isaac_daily_limit} Isaac Sim renders is reached")
157
 
158
- hardware = "runpod" if s.backend.name == "runpod" else spec.hardware
159
  s.logs.record_run(
160
  run_id=run_id,
161
  tester=tester,
 
93
  return round(sum(estimated_cost(settings, j) for j in logs.jobs_since(month_start())), 4)
94
 
95
 
96
+ def worst_cost(settings, kind: str, hardware: str | None = None) -> float:
97
+ """The most a job of this kind can be billed: the backend kills it at its timeout."""
98
+ spec = KINDS[kind]
99
+ return job_cost(settings, hardware or spec.hardware, spec.timeout_s, kind)
100
+
101
+
102
+ def committed_cost(settings, job: dict) -> float:
103
+ """What a job counts against a limit: its recorded cost once finished, its worst case while it may still run.
104
+
105
+ Counting running jobs at their worst case is what makes the limits strict: several jobs started at once can
106
+ never add up to more than the allowance, however long they end up running."""
107
+ if job.get("cost_usd") is not None or job.get("status") in TERMINAL:
108
+ return estimated_cost(settings, job)
109
+ kind = job.get("kind") or "sim"
110
+ return worst_cost(settings, kind if kind in KINDS else "sim", job.get("hardware"))
111
+
112
+
113
+ def compute_usage(settings, logs, tester: str | None = None) -> dict:
114
+ """This month's GPU/CPU spend (committed, in USD) for one tester and for the team, against their limits."""
115
+ jobs = logs.jobs_since(month_start())
116
+ team = round(sum(committed_cost(settings, j) for j in jobs), 4)
117
+ mine = round(sum(committed_cost(settings, j) for j in jobs if j["tester"] == tester), 4) if tester else None
118
+ share = settings.compute_share_usd
119
+ return {
120
+ "currency": "USD",
121
+ "used": mine,
122
+ "share": share or None,
123
+ "remaining": round(max(share - mine, 0.0), 4) if share and mine is not None else None,
124
+ "team_used": team,
125
+ "team_limit": settings.monthly_credit_usd or None,
126
+ "testers": settings.compute_testers,
127
+ }
128
+
129
+
130
  _STORAGE_CACHE: dict = {}
131
  STORAGE_CHECK_EVERY_S = 300
132
 
 
167
  function = spec.function.format(**job_input) if "{" in spec.function else spec.function
168
  if not s.backend.supports(function):
169
  raise JobError(501, f"{spec.label} is not available on the {s.backend.name} backend (use SIM_BACKEND=modal)")
170
+ hardware = "runpod" if s.backend.name == "runpod" else spec.hardware
171
+ worst = worst_cost(s.settings, kind, hardware)
172
+ limit, share = s.settings.monthly_credit_usd, s.settings.compute_share_usd
173
+ if limit > 0 or share > 0:
174
+ use = compute_usage(s.settings, s.logs, tester)
175
+ if limit > 0 and use["team_used"] + worst > limit:
176
  raise JobError(
177
  402,
178
+ f"the team's GPU budget for this month is used up (${use['team_used']:.2f} of ${limit:.2f} committed; "
179
+ f"a {spec.label.lower()} can cost up to ${worst:.2f}). Please tell the Orcanion team.",
180
+ )
181
+ if share > 0 and use["used"] + worst > share:
182
+ raise JobError(
183
+ 402,
184
+ f"your GPU budget for this month is used up (${use['used']:.2f} of your ${share:.2f} committed; "
185
+ f"a {spec.label.lower()} can cost up to ${worst:.2f}). It resets on the 1st.",
186
  )
187
  if s.settings.storage_limit_gb > 0:
188
  used = stored_gb(s)
 
197
  if today >= s.settings.isaac_daily_limit:
198
  raise JobError(429, f"the daily limit of {s.settings.isaac_daily_limit} Isaac Sim renders is reached")
199
 
 
200
  s.logs.record_run(
201
  run_id=run_id,
202
  tester=tester,
api/main.py CHANGED
@@ -29,7 +29,10 @@ from . import budget
29
  from .auth import Allowlist, Tester
30
  from .codegen import DirectBackend, OpenCodeBackend, generate_code, mentioned_interfaces
31
  from .config import Settings, get_settings
32
- from .jobs import JobError, month_spend, refresh, start_isaac, start_review
 
 
 
33
  from .jobs import submit as submit_job
34
  from .llm import LLM, ChatClient, LLMError
35
  from .logstore import LogStore
@@ -56,6 +59,7 @@ class Services:
56
  backend: SimBackend | None = None # None: /simulate/run is disabled
57
  storage: Storage | None = None
58
  code_llm_factory: Callable[[], LLM] | None = None # direct ROS 2 code backend; None: same as llm_factory
 
59
 
60
 
61
  def default_services() -> Services:
@@ -82,6 +86,7 @@ class SimulateRequest(BaseModel):
82
  description: str = Field(..., min_length=10, max_length=4000, description="The scenario, in plain language")
83
  robot: Literal[ROBOT_NAMES] | None = Field("unitree_g1", description="Robot to include, or null for none")
84
  confirm_cost: bool = Field(False, description="Run even if the estimate is above CONFIRM_ABOVE_INR")
 
85
 
86
 
87
  class SimulateResponse(BaseModel):
@@ -95,6 +100,7 @@ class SimulateResponse(BaseModel):
95
  model: str
96
  how_to_run: str
97
  routing: dict | None = None # what RosDiff chose and why (tier, reason, simulator)
 
98
  cost: dict | None = None # rupees this task used, the estimate, and the remaining budget
99
 
100
 
@@ -107,6 +113,7 @@ class CodeRequest(BaseModel):
107
  False, description="Also build, lint, test and launch it in the ROS 2 sandbox (see /code/check)"
108
  )
109
  confirm_cost: bool = Field(False, description="Run even if the estimate is above CONFIRM_ABOVE_INR")
 
110
 
111
 
112
  class CodeResponse(BaseModel):
@@ -122,6 +129,7 @@ class CodeResponse(BaseModel):
122
  check: dict | None = None # the sandbox job, when requested: {id, status, report_url} or {error}
123
  routing: dict | None = None
124
  cost: dict | None = None
 
125
 
126
 
127
  class RunRequest(BaseModel):
@@ -208,10 +216,26 @@ def _check_budget(s: Services, tester: str, plan, confirmed: bool) -> None:
208
  raise HTTPException(status_code=e.status, detail={"message": str(e), **e.detail}) from e
209
 
210
 
211
- def _book(s: Services, tester: str, gen_id: str, plan, attempts) -> dict:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
212
  """Record every model call of a generation in the rupee ledger; return what this task cost."""
213
  registry = {m.id: m for m in load_registry()}
214
  before = budget.summary(s.logs, s.settings, tester)["used"]
 
 
215
  for i, a in enumerate(attempts):
216
  spec = registry.get(a.model) or ModelSpec(a.model or "unknown", "?", "unknown", 0.0, 0.0)
217
  budget.record_call(
@@ -294,6 +318,9 @@ def create_app(services: Services | None = None) -> FastAPI:
294
  "llm_configured": bool(s.settings.llm_api_key),
295
  "codegen_backend": s.settings.codegen_backend,
296
  "opencode_found": shutil.which(s.settings.opencode_bin) is not None,
 
 
 
297
  "interface_catalogue": s.settings.interfaces_file.is_file(),
298
  "index": {name: s.store.count(name) for name in ("mujoco", "ros2")},
299
  "robots": sorted(ROBOTS),
@@ -304,11 +331,16 @@ def create_app(services: Services | None = None) -> FastAPI:
304
  @app.post("/simulate/generate", response_model=SimulateResponse)
305
  def simulate(req: SimulateRequest, t: Tester = Depends(tester), s: Services = Depends(svc)) -> SimulateResponse:
306
  start = time.monotonic()
307
- plan = plan_scene(req.description, req.robot, s.settings)
308
  _check_budget(s, t.name, plan, req.confirm_cost)
309
- logged_request = {**req.model_dump(exclude={"confirm_cost"}), "routing": plan.to_dict()}
 
 
 
 
 
310
  try:
311
- result = generate_scene(req.description, req.robot, s.llm_factory(), s.store, s.settings, plan=plan)
312
  except LLMError as e:
313
  gen_id = s.logs.record(
314
  tester=t.name,
@@ -317,6 +349,7 @@ def create_app(services: Services | None = None) -> FastAPI:
317
  error=str(e),
318
  duration_ms=int((time.monotonic() - start) * 1000),
319
  )
 
320
  raise HTTPException(status_code=502, detail=f"model call failed (log id {gen_id}): {e}") from e
321
  gen_id = s.logs.record(
322
  tester=t.name,
@@ -341,7 +374,8 @@ def create_app(services: Services | None = None) -> FastAPI:
341
  model=result.model,
342
  how_to_run=_how_to_run(req.robot),
343
  routing=_routing_view(plan, result.attempts),
344
- cost=_book(s, t.name, gen_id, plan, result.attempts),
 
345
  )
346
 
347
  @app.post("/code/generate", response_model=CodeResponse)
@@ -354,12 +388,17 @@ def create_app(services: Services | None = None) -> FastAPI:
354
  req.request, req.distro, req.language, s.settings, len(mentioned_interfaces(req.request, catalogue))
355
  )
356
  _check_budget(s, t.name, plan, req.confirm_cost)
357
- logged_request = {**req.model_dump(exclude={"confirm_cost"}), "routing": plan.to_dict()}
 
 
 
 
 
358
  try:
359
  if s.settings.codegen_backend == "opencode":
360
  with OpenCodeWorkspace(s.settings) as ws:
361
  result = generate_code(
362
- req.request,
363
  req.distro,
364
  req.language,
365
  OpenCodeBackend(ws),
@@ -370,7 +409,7 @@ def create_app(services: Services | None = None) -> FastAPI:
370
  )
371
  else:
372
  result = generate_code(
373
- req.request,
374
  req.distro,
375
  req.language,
376
  DirectBackend((s.code_llm_factory or s.llm_factory)()),
@@ -387,6 +426,7 @@ def create_app(services: Services | None = None) -> FastAPI:
387
  error=str(e),
388
  duration_ms=int((time.monotonic() - start) * 1000),
389
  )
 
390
  raise HTTPException(status_code=502, detail=f"generation failed (log id {gen_id}): {e}") from e
391
  gen_id = s.logs.record(
392
  tester=t.name,
@@ -411,7 +451,8 @@ def create_app(services: Services | None = None) -> FastAPI:
411
  id=gen_id,
412
  check=check,
413
  routing=_routing_view(plan, result.attempts),
414
- cost=_book(s, t.name, gen_id, plan, result.attempts),
 
415
  valid=result.valid,
416
  files=result.files,
417
  validation=result.validation.to_dict(),
@@ -679,6 +720,7 @@ def create_app(services: Services | None = None) -> FastAPI:
679
  "budget": {
680
  "month_spend_usd_estimate": month_spend(s.settings, s.logs),
681
  "monthly_limit_usd": s.settings.monthly_credit_usd or None,
 
682
  },
683
  "jobs": [
684
  {
@@ -847,14 +889,19 @@ def create_app(services: Services | None = None) -> FastAPI:
847
  "tester": t.name,
848
  "ai": budget.summary(s.logs, s.settings, t.name),
849
  "compute": {
 
850
  "month_spend_usd_estimate": month_spend(s.settings, s.logs),
851
  "monthly_limit_usd": s.settings.monthly_credit_usd or None,
852
- "note": "Simulations and checks run on the cloud GPU credit, not the rupee AI budget.",
 
853
  },
854
  "admin": t.name in s.settings.admin_testers,
855
  }
856
  if out["admin"]:
857
- out["team"] = budget.team(s.logs, s.settings, [x.name for x in s.allowlist.testers()])
 
 
 
858
  return out
859
 
860
  @app.post("/feedback/{gen_id}")
 
29
  from .auth import Allowlist, Tester
30
  from .codegen import DirectBackend, OpenCodeBackend, generate_code, mentioned_interfaces
31
  from .config import Settings, get_settings
32
+ from .enhance import Enhanced
33
+ from .enhance import enhance as enhance_prompt
34
+ from .enhance import record as record_enhancement
35
+ from .jobs import JobError, compute_usage, month_spend, refresh, start_isaac, start_review
36
  from .jobs import submit as submit_job
37
  from .llm import LLM, ChatClient, LLMError
38
  from .logstore import LogStore
 
59
  backend: SimBackend | None = None # None: /simulate/run is disabled
60
  storage: Storage | None = None
61
  code_llm_factory: Callable[[], LLM] | None = None # direct ROS 2 code backend; None: same as llm_factory
62
+ enhancer: Callable[..., Enhanced] = enhance_prompt # free prompt enhancement through OpenCode Zen
63
 
64
 
65
  def default_services() -> Services:
 
86
  description: str = Field(..., min_length=10, max_length=4000, description="The scenario, in plain language")
87
  robot: Literal[ROBOT_NAMES] | None = Field("unitree_g1", description="Robot to include, or null for none")
88
  confirm_cost: bool = Field(False, description="Run even if the estimate is above CONFIRM_ABOVE_INR")
89
+ enhance: bool = Field(True, description="Sharpen the request first with a free OpenCode Zen model")
90
 
91
 
92
  class SimulateResponse(BaseModel):
 
100
  model: str
101
  how_to_run: str
102
  routing: dict | None = None # what RosDiff chose and why (tier, reason, simulator)
103
+ enhanced_prompt: dict | None = None # {used, text, model, note}: the brief the generator actually got
104
  cost: dict | None = None # rupees this task used, the estimate, and the remaining budget
105
 
106
 
 
113
  False, description="Also build, lint, test and launch it in the ROS 2 sandbox (see /code/check)"
114
  )
115
  confirm_cost: bool = Field(False, description="Run even if the estimate is above CONFIRM_ABOVE_INR")
116
+ enhance: bool = Field(True, description="Sharpen the request first with a free OpenCode Zen model")
117
 
118
 
119
  class CodeResponse(BaseModel):
 
129
  check: dict | None = None # the sandbox job, when requested: {id, status, report_url} or {error}
130
  routing: dict | None = None
131
  cost: dict | None = None
132
+ enhanced_prompt: dict | None = None
133
 
134
 
135
  class RunRequest(BaseModel):
 
216
  raise HTTPException(status_code=e.status, detail={"message": str(e), **e.detail}) from e
217
 
218
 
219
+ def _enhance(s: Services, wanted: bool, kind: str, text: str, **ctx) -> Enhanced:
220
+ if not wanted:
221
+ return Enhanced(text, text, False, note="not requested")
222
+ try:
223
+ return s.enhancer(s.settings, kind, text, **ctx)
224
+ except Exception as e: # enhancement is a nicety: never fail a generation because of it
225
+ log.warning("prompt enhancement crashed: %s", e)
226
+ return Enhanced(text, text, False, note="enhancer error")
227
+
228
+
229
+ def _enhanced_view(e: Enhanced) -> dict:
230
+ return {"used": e.used, "text": e.text if e.used else None, "model": e.model, "note": e.note, "ms": e.ms}
231
+
232
+
233
+ def _book(s: Services, tester: str, gen_id: str, plan, attempts, brief: Enhanced | None = None) -> dict:
234
  """Record every model call of a generation in the rupee ledger; return what this task cost."""
235
  registry = {m.id: m for m in load_registry()}
236
  before = budget.summary(s.logs, s.settings, tester)["used"]
237
+ if brief is not None:
238
+ record_enhancement(s.logs, tester, gen_id, plan.category, brief)
239
  for i, a in enumerate(attempts):
240
  spec = registry.get(a.model) or ModelSpec(a.model or "unknown", "?", "unknown", 0.0, 0.0)
241
  budget.record_call(
 
318
  "llm_configured": bool(s.settings.llm_api_key),
319
  "codegen_backend": s.settings.codegen_backend,
320
  "opencode_found": shutil.which(s.settings.opencode_bin) is not None,
321
+ "prompt_enhance": s.settings.prompt_enhance and shutil.which(s.settings.opencode_bin) is not None,
322
+ "enhance_model": s.settings.enhance_model,
323
+ "gpu_share_usd": s.settings.compute_share_usd or None,
324
  "interface_catalogue": s.settings.interfaces_file.is_file(),
325
  "index": {name: s.store.count(name) for name in ("mujoco", "ros2")},
326
  "robots": sorted(ROBOTS),
 
331
  @app.post("/simulate/generate", response_model=SimulateResponse)
332
  def simulate(req: SimulateRequest, t: Tester = Depends(tester), s: Services = Depends(svc)) -> SimulateResponse:
333
  start = time.monotonic()
334
+ plan = plan_scene(req.description, req.robot, s.settings) # routed on the tester's own words
335
  _check_budget(s, t.name, plan, req.confirm_cost)
336
+ brief = _enhance(s, req.enhance, "scene", req.description, robot=req.robot)
337
+ logged_request = {
338
+ **req.model_dump(exclude={"confirm_cost"}),
339
+ "routing": plan.to_dict(),
340
+ "enhanced": _enhanced_view(brief),
341
+ }
342
  try:
343
+ result = generate_scene(brief.text, req.robot, s.llm_factory(), s.store, s.settings, plan=plan)
344
  except LLMError as e:
345
  gen_id = s.logs.record(
346
  tester=t.name,
 
349
  error=str(e),
350
  duration_ms=int((time.monotonic() - start) * 1000),
351
  )
352
+ record_enhancement(s.logs, t.name, gen_id, "simulation_ai", brief)
353
  raise HTTPException(status_code=502, detail=f"model call failed (log id {gen_id}): {e}") from e
354
  gen_id = s.logs.record(
355
  tester=t.name,
 
374
  model=result.model,
375
  how_to_run=_how_to_run(req.robot),
376
  routing=_routing_view(plan, result.attempts),
377
+ cost=_book(s, t.name, gen_id, plan, result.attempts, brief),
378
+ enhanced_prompt=_enhanced_view(brief),
379
  )
380
 
381
  @app.post("/code/generate", response_model=CodeResponse)
 
388
  req.request, req.distro, req.language, s.settings, len(mentioned_interfaces(req.request, catalogue))
389
  )
390
  _check_budget(s, t.name, plan, req.confirm_cost)
391
+ brief = _enhance(s, req.enhance, "code", req.request, distro=req.distro, language=req.language)
392
+ logged_request = {
393
+ **req.model_dump(exclude={"confirm_cost"}),
394
+ "routing": plan.to_dict(),
395
+ "enhanced": _enhanced_view(brief),
396
+ }
397
  try:
398
  if s.settings.codegen_backend == "opencode":
399
  with OpenCodeWorkspace(s.settings) as ws:
400
  result = generate_code(
401
+ brief.text,
402
  req.distro,
403
  req.language,
404
  OpenCodeBackend(ws),
 
409
  )
410
  else:
411
  result = generate_code(
412
+ brief.text,
413
  req.distro,
414
  req.language,
415
  DirectBackend((s.code_llm_factory or s.llm_factory)()),
 
426
  error=str(e),
427
  duration_ms=int((time.monotonic() - start) * 1000),
428
  )
429
+ record_enhancement(s.logs, t.name, gen_id, "coding", brief)
430
  raise HTTPException(status_code=502, detail=f"generation failed (log id {gen_id}): {e}") from e
431
  gen_id = s.logs.record(
432
  tester=t.name,
 
451
  id=gen_id,
452
  check=check,
453
  routing=_routing_view(plan, result.attempts),
454
+ cost=_book(s, t.name, gen_id, plan, result.attempts, brief),
455
+ enhanced_prompt=_enhanced_view(brief),
456
  valid=result.valid,
457
  files=result.files,
458
  validation=result.validation.to_dict(),
 
720
  "budget": {
721
  "month_spend_usd_estimate": month_spend(s.settings, s.logs),
722
  "monthly_limit_usd": s.settings.monthly_credit_usd or None,
723
+ "mine": compute_usage(s.settings, s.logs, t.name),
724
  },
725
  "jobs": [
726
  {
 
889
  "tester": t.name,
890
  "ai": budget.summary(s.logs, s.settings, t.name),
891
  "compute": {
892
+ **compute_usage(s.settings, s.logs, t.name),
893
  "month_spend_usd_estimate": month_spend(s.settings, s.logs),
894
  "monthly_limit_usd": s.settings.monthly_credit_usd or None,
895
+ "note": "Simulations and checks run on the cloud GPU credit, split equally between testers; "
896
+ "it is separate from the rupee AI budget.",
897
  },
898
  "admin": t.name in s.settings.admin_testers,
899
  }
900
  if out["admin"]:
901
+ names = [x.name for x in s.allowlist.testers()]
902
+ out["team"] = budget.team(s.logs, s.settings, names)
903
+ for row in out["team"]:
904
+ row["gpu"] = compute_usage(s.settings, s.logs, row["tester"])["used"]
905
  return out
906
 
907
  @app.post("/feedback/{gen_id}")
api/opencode_runner.py CHANGED
@@ -99,11 +99,19 @@ def _router_models(settings: Settings) -> list[str]:
99
  return list(dict.fromkeys([settings.code_llm_model, *(m.id for m in load_registry())]))
100
 
101
 
 
 
 
 
102
  class OpenCodeWorkspace:
103
- """One request's workspace. Use as a context manager so it is always cleaned up."""
 
 
104
 
105
- def __init__(self, settings: Settings):
106
  self.settings = settings
 
 
107
  self.root = Path(tempfile.mkdtemp(prefix="rosdiff_opencode_"))
108
  self.ws = self.root / "ws"
109
  self.state = self.root / "state"
@@ -121,7 +129,8 @@ class OpenCodeWorkspace:
121
  env = {k: v for k, v in os.environ.items() if not k.startswith("OPENCODE_")}
122
  env.update(
123
  LLM_API_KEY=self.settings.llm_api_key,
124
- OPENCODE_CONFIG_CONTENT=json.dumps(opencode_config(self.settings)),
 
125
  OPENCODE_DISABLE_PROJECT_CONFIG="1",
126
  OPENCODE_DISABLE_AUTOUPDATE="1",
127
  OPENCODE_DISABLE_SHARE="1",
@@ -136,13 +145,11 @@ class OpenCodeWorkspace:
136
  )
137
  return env
138
 
139
- def run(self, message: str, context: str, model: str | None = None) -> OpenCodeRun:
140
  """First call starts a session; later calls continue it (used for repair rounds)."""
141
  binary = shutil.which(self.settings.opencode_bin) or self.settings.opencode_bin
142
  if not shutil.which(binary):
143
  raise OpenCodeError(f"OpenCode binary {self.settings.opencode_bin!r} not found (npm i -g opencode-ai)")
144
- context_file = self.root / f"context_{self.runs}.md"
145
- context_file.write_text(context)
146
  cmd = [
147
  binary,
148
  "run",
@@ -150,13 +157,16 @@ class OpenCodeWorkspace:
150
  "--dir",
151
  str(self.ws),
152
  "--model",
153
- f"llm/{model or self.settings.code_llm_model}",
154
  "--format",
155
  "json",
156
  ]
157
  if self.runs:
158
  cmd.append("--continue")
159
- cmd += ["-f", str(context_file)]
 
 
 
160
  self.runs += 1
161
  try:
162
  proc = subprocess.run(
@@ -165,10 +175,10 @@ class OpenCodeWorkspace:
165
  env=self._env(),
166
  capture_output=True,
167
  text=True,
168
- timeout=self.settings.opencode_timeout,
169
  )
170
  except subprocess.TimeoutExpired as e:
171
- raise OpenCodeError(f"OpenCode did not finish within {self.settings.opencode_timeout:.0f}s") from e
172
  events = []
173
  for line in proc.stdout.splitlines():
174
  line = line.strip()
 
99
  return list(dict.fromkeys([settings.code_llm_model, *(m.id for m in load_registry())]))
100
 
101
 
102
+ def find_opencode(settings: Settings) -> str | None:
103
+ return shutil.which(settings.opencode_bin)
104
+
105
+
106
  class OpenCodeWorkspace:
107
+ """One request's workspace. Use as a context manager so it is always cleaned up.
108
+
109
+ `config` replaces the code-generation config (the prompt enhancer uses a free Zen model with no tools)."""
110
 
111
+ def __init__(self, settings: Settings, config: dict | None = None, timeout: float | None = None):
112
  self.settings = settings
113
+ self.config = config
114
+ self.timeout = timeout or settings.opencode_timeout
115
  self.root = Path(tempfile.mkdtemp(prefix="rosdiff_opencode_"))
116
  self.ws = self.root / "ws"
117
  self.state = self.root / "state"
 
129
  env = {k: v for k, v in os.environ.items() if not k.startswith("OPENCODE_")}
130
  env.update(
131
  LLM_API_KEY=self.settings.llm_api_key,
132
+ ZEN_API_KEY=self.settings.zen_api_key,
133
+ OPENCODE_CONFIG_CONTENT=json.dumps(self.config or opencode_config(self.settings)),
134
  OPENCODE_DISABLE_PROJECT_CONFIG="1",
135
  OPENCODE_DISABLE_AUTOUPDATE="1",
136
  OPENCODE_DISABLE_SHARE="1",
 
145
  )
146
  return env
147
 
148
+ def run(self, message: str, context: str, model: str | None = None, provider: str = "llm") -> OpenCodeRun:
149
  """First call starts a session; later calls continue it (used for repair rounds)."""
150
  binary = shutil.which(self.settings.opencode_bin) or self.settings.opencode_bin
151
  if not shutil.which(binary):
152
  raise OpenCodeError(f"OpenCode binary {self.settings.opencode_bin!r} not found (npm i -g opencode-ai)")
 
 
153
  cmd = [
154
  binary,
155
  "run",
 
157
  "--dir",
158
  str(self.ws),
159
  "--model",
160
+ f"{provider}/{model or self.settings.code_llm_model}",
161
  "--format",
162
  "json",
163
  ]
164
  if self.runs:
165
  cmd.append("--continue")
166
+ if context:
167
+ context_file = self.root / f"context_{self.runs}.md"
168
+ context_file.write_text(context)
169
+ cmd += ["-f", str(context_file)]
170
  self.runs += 1
171
  try:
172
  proc = subprocess.run(
 
175
  env=self._env(),
176
  capture_output=True,
177
  text=True,
178
+ timeout=self.timeout,
179
  )
180
  except subprocess.TimeoutExpired as e:
181
+ raise OpenCodeError(f"OpenCode did not finish within {self.timeout:.0f}s") from e
182
  events = []
183
  for line in proc.stdout.splitlines():
184
  line = line.strip()
api/static/app/app.css CHANGED
@@ -6,7 +6,8 @@
6
  --border: #e4e4e8;
7
  --text: #17171c;
8
  --muted: #6b6b76;
9
- --accent: #6d5efc;
 
10
  --accent-soft: #efedff;
11
  --accent-text: #ffffff;
12
  --ok: #178a4f;
@@ -39,7 +40,7 @@
39
  --border: #2a2a31;
40
  --text: #ececf1;
41
  --muted: #9a9aa6;
42
- --accent: #8b80ff;
43
  --accent-soft: #25224a;
44
  --accent-text: #0e0e11;
45
  --ok: #4cc38a;
@@ -245,9 +246,11 @@ details.xml > summary { cursor: pointer; font-size: 13.5px; color: var(--muted);
245
  /* ---------- feedback ---------- */
246
  .feedback { display: inline-flex; gap: 2px; align-items: center; }
247
  .feedback .label { font-size: 12.5px; color: var(--muted); margin-right: 4px; }
248
- .fb { border: 1px solid transparent; background: none; padding: 4px 7px; border-radius: 8px; cursor: pointer; font-size: 15px; line-height: 1; }
249
  .fb:hover { background: var(--panel-2); }
250
  .fb.chosen { background: var(--accent-soft); border-color: var(--accent); }
 
 
251
 
252
  /* ---------- composer ---------- */
253
  .composer { padding: 10px 14px 14px; border-top: 1px solid var(--border); background: var(--bg); }
@@ -304,3 +307,86 @@ details.xml > summary { cursor: pointer; font-size: 13.5px; color: var(--muted);
304
  .thread { padding: 14px 12px; }
305
  .composer { padding: 8px 10px 12px; }
306
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
6
  --border: #e4e4e8;
7
  --text: #17171c;
8
  --muted: #6b6b76;
9
+ --accent: #5b5cf6;
10
+ --brand-grad: linear-gradient(135deg, #1fa2ff 0%, #4f5bff 45%, #9b6cff 100%);
11
  --accent-soft: #efedff;
12
  --accent-text: #ffffff;
13
  --ok: #178a4f;
 
40
  --border: #2a2a31;
41
  --text: #ececf1;
42
  --muted: #9a9aa6;
43
+ --accent: #7c83ff;
44
  --accent-soft: #25224a;
45
  --accent-text: #0e0e11;
46
  --ok: #4cc38a;
 
246
  /* ---------- feedback ---------- */
247
  .feedback { display: inline-flex; gap: 2px; align-items: center; }
248
  .feedback .label { font-size: 12.5px; color: var(--muted); margin-right: 4px; }
249
+ .fb { display: inline-flex; align-items: center; gap: 5px; color: var(--muted); border: 1px solid transparent; background: none; padding: 4px 8px; border-radius: 8px; cursor: pointer; font-size: 15px; line-height: 1; }
250
  .fb:hover { background: var(--panel-2); }
251
  .fb.chosen { background: var(--accent-soft); border-color: var(--accent); }
252
+ .fb-label { font-size: 12.5px; }
253
+ @media (max-width: 560px) { .fb-label { display: none; } }
254
 
255
  /* ---------- composer ---------- */
256
  .composer { padding: 10px 14px 14px; border-top: 1px solid var(--border); background: var(--bg); }
 
307
  .thread { padding: 14px 12px; }
308
  .composer { padding: 8px 10px 12px; }
309
  }
310
+
311
+ /* ---------- brand ---------- */
312
+ .logo-img { width: 28px; height: 28px; border-radius: 8px; display: block; }
313
+ .brand .by { font-weight: 500; font-size: 12.5px; color: var(--muted); }
314
+ .btn-primary, .send { background: var(--brand-grad); border-color: transparent; color: #fff; }
315
+ .btn-primary:hover { background: var(--brand-grad); filter: brightness(1.08); }
316
+ .login-logo { width: 84px; height: 84px; border-radius: 20px; display: block; margin: 0 auto 12px; box-shadow: 0 10px 40px rgb(79 91 255 / 35%); }
317
+ .login-brand { font-weight: 700; font-size: 19px; letter-spacing: -0.01em; margin-bottom: 14px; }
318
+ .login-brand span { font-weight: 500; color: var(--muted); font-size: 14px; }
319
+
320
+ /* ---------- startup animation ---------- */
321
+ .splash { position: fixed; inset: 0; z-index: 100; background: #050507; display: grid; place-items: center; transition: opacity .45s ease; }
322
+ .splash.done { opacity: 0; pointer-events: none; }
323
+ .splash-inner { text-align: center; }
324
+ .splash-logo { width: 168px; height: 168px; border-radius: 36px; opacity: 0; transform: scale(.82); filter: drop-shadow(0 0 0 rgb(79 91 255 / 0%));
325
+ animation: logo-in .9s cubic-bezier(.2,.8,.2,1) .1s forwards; }
326
+ .splash-name { color: #f2f3ff; font: 700 34px/1.2 var(--font); letter-spacing: -0.02em; margin-top: 18px; opacity: 0; transform: translateY(8px); animation: rise .6s ease .75s forwards; }
327
+ .splash-by { color: #9aa0c8; font: 500 15px var(--font); letter-spacing: .08em; text-transform: uppercase; margin-top: 6px; opacity: 0; transform: translateY(8px); animation: rise .6s ease 1.1s forwards; }
328
+ .splash.quick .splash-logo { animation-duration: .35s; animation-delay: 0s; }
329
+ .splash.quick .splash-name { animation-duration: .3s; animation-delay: .15s; }
330
+ .splash.quick .splash-by { animation-duration: .3s; animation-delay: .25s; }
331
+ @keyframes logo-in { to { opacity: 1; transform: scale(1); filter: drop-shadow(0 0 38px rgb(79 91 255 / 55%)); } }
332
+ @keyframes rise { to { opacity: 1; transform: none; } }
333
+ @media (prefers-reduced-motion: reduce) {
334
+ .splash-logo, .splash-name, .splash-by { animation: none; opacity: 1; transform: none; }
335
+ }
336
+
337
+ /* ---------- budget button ---------- */
338
+ button.budget { border: 1px solid transparent; background: none; cursor: pointer; padding: 4px 8px; border-radius: 99px; font: inherit; font-size: 12.5px; color: var(--muted); }
339
+ button.budget:hover { border-color: var(--border); background: var(--panel-2); }
340
+
341
+ /* ---------- routing line on result cards ---------- */
342
+ .routing { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; font-size: 12.5px; color: var(--muted); margin: 0 0 10px; }
343
+ .routing .auto { display: inline-flex; align-items: center; gap: 5px; padding: 2px 8px; border-radius: 99px; background: var(--accent-soft); color: var(--accent); font-weight: 600; }
344
+ .routing .auto::before { content: ""; width: 6px; height: 6px; border-radius: 50%; background: var(--brand-grad); }
345
+ .routing details { display: inline; }
346
+ .routing summary { cursor: pointer; list-style: none; text-decoration: underline dotted; text-underline-offset: 3px; }
347
+ .routing summary::-webkit-details-marker { display: none; }
348
+ .routing .why { display: block; margin-top: 6px; padding: 8px 10px; border-radius: 8px; background: var(--panel-2); color: var(--text); line-height: 1.5; }
349
+ .sim-menu { order: 99; flex-basis: 100%; display: grid; grid-template-columns: repeat(auto-fill, minmax(210px, 1fr)); gap: 2px; background: var(--panel-2); border: 1px solid var(--border); border-radius: 12px; padding: 6px; }
350
+ .sim-menu[hidden] { display: none; }
351
+ .sim-menu > .muted { grid-column: 1 / -1; }
352
+ .sim-opt { display: block; width: 100%; text-align: left; border: 0; background: none; padding: 8px 10px; border-radius: 8px; cursor: pointer; }
353
+ .sim-opt:hover:not(:disabled) { background: var(--panel); }
354
+ .sim-opt:disabled { opacity: .5; cursor: default; }
355
+ .sim-opt b { display: block; font-size: 14px; }
356
+ .sim-opt span { font-size: 12.5px; color: var(--muted); }
357
+ .sim-opt.chosen b::after { content: " ✓"; color: var(--accent); }
358
+
359
+ /* ---------- confirmation & dialogs ---------- */
360
+ .enhanced { margin: 0 0 10px; font-size: 13px; }
361
+ .enhanced summary { cursor: pointer; color: var(--muted); }
362
+ .enhanced .why { display: block; margin-top: 6px; padding: 8px 10px; border-radius: 8px; background: var(--panel-2); line-height: 1.5; white-space: pre-wrap; }
363
+ .confirm { display: flex; flex-direction: column; gap: 8px; }
364
+ .confirm .big { font-size: 20px; font-weight: 700; letter-spacing: -0.01em; }
365
+ .textarea { width: 100%; border: 1px solid var(--border); border-radius: 10px; background: var(--panel); color: var(--text); padding: 10px 12px; font: 14.5px/1.5 var(--font); resize: vertical; }
366
+ .textarea:focus { outline: none; border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-soft); }
367
+ .dialog-actions { display: flex; justify-content: flex-end; gap: 8px; margin-top: 12px; }
368
+ .usage-big { font-size: 26px; font-weight: 700; letter-spacing: -0.02em; }
369
+ .meter { height: 8px; border-radius: 99px; background: var(--panel-2); border: 1px solid var(--border); overflow: hidden; margin: 8px 0 14px; }
370
+ .meter span { display: block; height: 100%; background: var(--brand-grad); }
371
+ .usage-table { width: 100%; border-collapse: collapse; font-size: 14px; margin: 6px 0 14px; }
372
+ .usage-table td, .usage-table th { padding: 6px 4px; border-bottom: 1px solid var(--border); text-align: left; }
373
+ .usage-table td:last-child, .usage-table th:last-child { text-align: right; font-variant-numeric: tabular-nums; }
374
+
375
+ /* ---------- tour ---------- */
376
+ .tour { width: min(560px, calc(100% - 32px)); padding: 18px 22px 20px; }
377
+ .tour-top { display: flex; align-items: center; gap: 10px; }
378
+ .tour-top img { border-radius: 8px; }
379
+ .tour-count { font-size: 12.5px; color: var(--muted); margin-right: auto; }
380
+ .tour-slide { min-height: 210px; padding: 14px 2px 6px; }
381
+ .tour-slide .step { font-size: 12px; font-weight: 700; letter-spacing: .08em; text-transform: uppercase; background: var(--brand-grad); -webkit-background-clip: text; background-clip: text; color: transparent; }
382
+ .tour-slide h3 { font-size: 21px; margin: 6px 0 8px; letter-spacing: -0.015em; line-height: 1.25; }
383
+ .tour-slide p { margin: 0 0 10px; color: var(--muted); }
384
+ .tour-demo { border: 1px solid var(--border); border-radius: 12px; padding: 12px; background: var(--panel-2); font-size: 14px; }
385
+ .tour-demo .you { font-weight: 600; margin-bottom: 8px; }
386
+ .tour-demo .line { display: flex; gap: 8px; align-items: baseline; margin-top: 4px; }
387
+ .tour-demo .line b { color: var(--text); }
388
+ .tour-dots { display: flex; gap: 6px; justify-content: center; margin: 4px 0 14px; }
389
+ .tour-dots span { width: 7px; height: 7px; border-radius: 50%; background: var(--border); transition: background .2s, width .2s; }
390
+ .tour-dots span.on { width: 20px; border-radius: 99px; background: var(--accent); }
391
+ .tour-actions { display: flex; justify-content: space-between; }
392
+ @media (max-width: 860px) { .brand .by { display: none; } }
api/static/app/app.js CHANGED
@@ -76,7 +76,7 @@ function friendly(err) {
76
  const msg = typeof d === "string" ? d : d?.message || err.message;
77
  switch (err.status) {
78
  case 401: return "Your key isn't recognised. Sign out and sign in again.";
79
- case 402: return "This month's shared compute budget is used up. Please tell the Orcanion team.";
80
  case 429: return "Today's limit for this is reached. Try again tomorrow.";
81
  case 503: return "This part isn't switched on on the server yet.";
82
  case 507: return "Result storage is full for now. Old results are cleared automatically; try again later.";
@@ -97,6 +97,7 @@ const state = {
97
  distro: localStorage.getItem("rosdiff_distro") || "humble",
98
  lang: localStorage.getItem("rosdiff_lang") || "python",
99
  busy: { sim: false, code: false },
 
100
  };
101
  function remember(k, v) { try { localStorage.setItem(k, v); } catch { /* private mode */ } }
102
 
@@ -205,16 +206,21 @@ function workingCard(steps) {
205
  return card;
206
  }
207
 
208
- function feedbackBar(genId, current) {
209
- const options = [["works", "👍", "It worked"], ["partly", "😐", "Partly worked"], ["broken", "👎", "It didn't work"]];
210
- const wrap = h("div", { class: "feedback" }, h("span", { class: "label" }, "Did it work?"));
211
- for (const [rating, emoji, label] of options) {
212
- const b = h("button", { class: "fb" + (current === rating ? " chosen" : ""), type: "button", "aria-label": label, title: label, "aria-pressed": String(current === rating) }, emoji);
 
 
 
213
  b.addEventListener("click", async () => {
 
214
  try {
215
- await api(`/feedback/${genId}`, { method: "POST", body: { rating, note: "" } });
216
  $$(".fb", wrap).forEach((x) => { x.classList.toggle("chosen", x === b); x.setAttribute("aria-pressed", String(x === b)); });
217
- toast("Thanks, that helps a lot!");
 
218
  } catch (e) { toast(friendly(e)); }
219
  });
220
  wrap.append(b);
@@ -222,6 +228,87 @@ function feedbackBar(genId, current) {
222
  return wrap;
223
  }
224
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
225
  function errorCard(message) {
226
  return h("div", { class: "card" }, h("div", { class: "card-head" }, statusIcon("bad"), h("div", {}, h("div", { class: "title" }, "That didn't work"), h("div", { class: "sub" }, message))));
227
  }
@@ -241,6 +328,10 @@ function sceneCard(gen) {
241
  h("div", { class: "title" }, ok ? "Scene ready" : "Couldn't build a working scene"),
242
  h("div", { class: "sub" }, ok ? `Loads and simulates in MuJoCo · ${robotLabel(gen.robot)}` : "MuJoCo rejected every attempt")));
243
  const body = h("div", { class: "card-body" });
 
 
 
 
244
  if (ok) {
245
  body.append(h("div", { class: "facts" },
246
  h("span", { class: "pill" }, `${stats.nbody ?? "?"} bodies`),
@@ -255,27 +346,56 @@ function sceneCard(gen) {
255
  }
256
  const actions = h("div", { class: "card-actions" });
257
  const runs = h("div", { class: "runs" });
258
- if (ok) {
259
- const run = h("button", { class: "btn btn-primary btn-sm", type: "button" }, icon("play"), "Run simulation");
260
- run.addEventListener("click", () => startRun(gen, runs, run));
261
- actions.append(run);
262
- }
263
  if (gen.mjcf) {
264
  const dl = h("button", { class: "btn btn-sm", type: "button" }, icon("download"), "Download");
265
  dl.addEventListener("click", () => download(`scene_${gen.id}.xml`, new Blob([gen.mjcf], { type: "application/xml" })));
266
  actions.append(dl);
267
  }
268
- actions.append(h("span", { class: "spacer" }), feedbackBar(gen.id, gen.rating));
269
  const wrap = h("div", { style: "display:flex;flex-direction:column;gap:14px" }, h("div", { class: "card" }, head, body, actions), runs);
270
  return wrap;
271
  }
272
 
273
- async function startRun(gen, container, button) {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
274
  button.disabled = true;
275
  try {
276
- const job = await api("/simulate/run", { method: "POST", body: { generation_id: gen.id, duration_s: 10, controller: "hold" } });
277
- container.append(jobCard({ id: job.run_id, kind: "sim", status: job.status, report_url: job.report_url }));
278
- button.lastChild.textContent = "Run again";
 
 
 
 
 
279
  scrollDown("sim");
280
  } catch (e) {
281
  container.append(errorCard(friendly(e)));
@@ -284,6 +404,18 @@ async function startRun(gen, container, button) {
284
  }
285
  }
286
 
 
 
 
 
 
 
 
 
 
 
 
 
287
  // --- code
288
 
289
  function packageCard(gen) {
@@ -296,6 +428,10 @@ function packageCard(gen) {
296
  h("div", { class: "title" }, ok ? "Package ready" : names.length ? "Package written, with problems" : "Couldn't write the package"),
297
  h("div", { class: "sub" }, `${names.length} file(s) · ROS 2 ${gen.distro || ""} · ${gen.language === "cpp" ? "C++" : "Python"}`)));
298
  const body = h("div", { class: "card-body" });
 
 
 
 
299
  const errors = val.errors || [];
300
  if (errors.length) body.append(h("div", { class: "error-box" }, errors.slice(0, 6).join("\n")));
301
  if ((val.warnings || []).length) body.append(h("p", { class: "note" }, `${val.warnings.length} note(s): ${val.warnings.slice(0, 2).join("; ")}`));
@@ -332,7 +468,7 @@ function packageCard(gen) {
332
  });
333
  actions.append(check, zip, copy);
334
  }
335
- actions.append(h("span", { class: "spacer" }), feedbackBar(gen.id, gen.rating));
336
  return h("div", { style: "display:flex;flex-direction:column;gap:14px" }, h("div", { class: "card" }, head, body, actions), checks);
337
  }
338
  function score(path) {
@@ -366,10 +502,11 @@ const JOB_TEXT = {
366
  export: { wait: "Starting the export…", run: "Building the dataset…", title: "Dataset export" },
367
  };
368
 
369
- function jobCard(job) {
370
  const card = h("div", { class: "card" });
371
  card.dataset.job = job.id;
372
  const render = (j) => {
 
373
  const text = JOB_TEXT[j.kind] || JOB_TEXT.sim;
374
  card.replaceChildren();
375
  if (!TERMINAL.has(j.status)) {
@@ -387,7 +524,12 @@ function jobCard(job) {
387
  if (j.kind === "sim") card.append(...simResult(j));
388
  else if (j.kind === "ros_check") card.append(...checkResult(j));
389
  else if (j.kind === "sweep") card.append(...sweepResult(j));
390
- else card.append(h("div", { class: "card-head" }, statusIcon("ok"), h("div", {}, h("div", { class: "title" }, text.title), h("div", { class: "sub" }, j.summary?.headline || "Done"))), reportActions(j));
 
 
 
 
 
391
  };
392
  render(job);
393
  poll(job.id, render);
@@ -411,6 +553,7 @@ async function poll(id, render) {
411
  function reportActions(j, ...extra) {
412
  const bar = h("div", { class: "card-actions" }, ...extra);
413
  if (j.report_url) bar.append(h("a", { class: "btn btn-sm", href: j.report_url, target: "_blank", rel: "noopener" }, icon("external"), "Full report"));
 
414
  return bar;
415
  }
416
 
@@ -445,8 +588,9 @@ function simResult(j) {
445
  extra.append(jobCard({ id: job.id, kind: "sweep", status: job.status, report_url: job.report_url }));
446
  } catch (e) { extra.append(errorCard(friendly(e))); stress.disabled = false; }
447
  });
448
- const wrap = h("div", { style: "display:contents" });
449
- return [head, body, reportActions(j, stress), extra, wrap];
 
450
  }
451
 
452
  function checkResult(j) {
@@ -532,31 +676,47 @@ async function submit(pane) {
532
  const input = $(`#input-${pane}`);
533
  const text = input.value.trim();
534
  if (text.length < 10) { toast("Please describe it in a bit more detail (at least 10 characters)."); input.focus(); return; }
535
- state.busy[pane] = true;
536
- $(`#composer-${pane} .send`).disabled = true;
537
  const t = thread(pane);
538
  if (t.querySelector(".empty")) t.replaceChildren();
539
  const meta = pane === "sim" ? robotLabel(state.robot) : `ROS 2 ${cap(state.distro)} · ${state.lang === "cpp" ? "C++" : "Python"}`;
540
  t.append(userMessage(text, meta));
541
  input.value = "";
542
  autosize(input);
 
 
 
 
 
 
 
 
 
 
543
  const steps = pane === "sim"
544
- ? ["Reading the MuJoCo documentation…", "Writing the scene…", "Testing it in MuJoCo…", "Fixing anything MuJoCo complained about…", "Almost there…"]
545
- : ["Looking up the ROS 2 messages it needs…", "Writing the package…", "Checking every message type exists…", "Tidying up…", "Almost there…"];
546
  const working = workingCard(steps);
547
  t.append(working);
548
  scrollDown(pane);
549
  try {
550
  if (pane === "sim") {
551
- const r = await api("/simulate/generate", { method: "POST", body: { description: text, robot: state.robot } });
552
  working.replaceWith(sceneCard({ ...r, rating: null }));
553
  } else {
554
- const r = await api("/code/generate", { method: "POST", body: { request: text, distro: state.distro, language: state.lang } });
555
- working.replaceWith(packageCard({ ...r, distro: state.distro, language: state.lang, rating: null }));
556
  }
557
  loadHistory();
 
558
  } catch (e) {
559
- working.replaceWith(errorCard(friendly(e)));
 
 
 
 
 
 
 
560
  } finally {
561
  working.stop();
562
  state.busy[pane] = false;
@@ -603,6 +763,10 @@ async function loadHistory() {
603
  }
604
  }
605
 
 
 
 
 
606
  async function openGeneration(id, kind, item) {
607
  closeSidebar();
608
  switchPane(kind);
@@ -615,12 +779,12 @@ async function openGeneration(id, kind, item) {
615
  const content = [];
616
  if (g.kind === "code") {
617
  content.push(userMessage(req.request || "", `ROS 2 ${cap(req.distro || "")} · ${req.language === "cpp" ? "C++" : "Python"}`));
618
- const card = packageCard({ id: g.id, valid: g.valid, validation: g.validation, files: g.output || {}, distro: req.distro, language: req.language, rating: g.rating });
619
  content.push(card);
620
  for (const j of g.jobs) card.querySelector(".runs").append(jobCard(j));
621
  } else {
622
  content.push(userMessage(req.description || "", robotLabel(req.robot)));
623
- const card = sceneCard({ id: g.id, valid: g.valid, validation: g.validation, mjcf: g.output, robot: req.robot, error: g.error, rating: g.rating });
624
  content.push(card);
625
  for (const j of g.jobs) card.querySelector(".runs").append(jobCard(j));
626
  }
@@ -634,17 +798,14 @@ async function openGeneration(id, kind, item) {
634
 
635
  async function refreshBudget() {
636
  try {
637
- const { budget } = await api("/jobs");
638
- const used = budget.month_spend_usd_estimate || 0;
639
- const limit = budget.monthly_limit_usd;
640
- const el = $("#budget");
641
- if (!limit) { $("#budget-label").textContent = `$${used.toFixed(2)} used this month`; return; }
642
- const pct = Math.min(100, (100 * used) / limit);
643
- $("#budget-label").textContent = `$${used.toFixed(2)} of $${limit.toFixed(0)} this month`;
644
  $("#budget-fill").style.width = pct + "%";
 
645
  el.classList.toggle("high", pct >= 70 && pct < 100);
646
  el.classList.toggle("full", pct >= 100);
647
- el.title = "Cloud compute used this month by all testers together";
648
  } catch { /* not important */ }
649
  }
650
 
@@ -699,7 +860,9 @@ function setupChrome() {
699
  $("#user-btn").addEventListener("click", (e) => { e.stopPropagation(); menu.hidden = !menu.hidden; $("#user-btn").setAttribute("aria-expanded", String(!menu.hidden)); });
700
  document.addEventListener("click", () => { menu.hidden = true; $("#user-btn").setAttribute("aria-expanded", "false"); });
701
  $("#logout-btn").addEventListener("click", () => signOut(false));
702
- $("#help-btn").addEventListener("click", () => $("#help").showModal());
 
 
703
  }
704
 
705
  // ------------------------------------------------------------------ sign in / out
@@ -725,7 +888,9 @@ async function enter() {
725
  loadHistory();
726
  refreshBudget();
727
  setInterval(refreshBudget, 60000);
728
- if (!localStorage.getItem("rosdiff_seen_help")) { remember("rosdiff_seen_help", "1"); $("#help").showModal(); }
 
 
729
  }
730
 
731
  function setupLogin() {
@@ -744,12 +909,141 @@ function setupLogin() {
744
  });
745
  }
746
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
747
  setupLogin();
748
  setupChrome();
749
  setupComposer();
750
- if (state.key) {
751
- enter().catch(() => signOut(false));
752
- } else {
753
- $("#login").hidden = false;
754
- $("#key-input").focus();
755
- }
 
 
 
76
  const msg = typeof d === "string" ? d : d?.message || err.message;
77
  switch (err.status) {
78
  case 401: return "Your key isn't recognised. Sign out and sign in again.";
79
+ case 402: return /budget/i.test(msg || "") ? msg : "This month's shared compute credit is used up. Please tell the Orcanion team.";
80
  case 429: return "Today's limit for this is reached. Try again tomorrow.";
81
  case 503: return "This part isn't switched on on the server yet.";
82
  case 507: return "Result storage is full for now. Old results are cleared automatically; try again later.";
 
97
  distro: localStorage.getItem("rosdiff_distro") || "humble",
98
  lang: localStorage.getItem("rosdiff_lang") || "python",
99
  busy: { sim: false, code: false },
100
+ rated: {}, // job id -> helpful | not_helpful (this session)
101
  };
102
  function remember(k, v) { try { localStorage.setItem(k, v); } catch { /* private mode */ } }
103
 
 
206
  return card;
207
  }
208
 
209
+ // 👍 / 👎 on a scene, a package or a job, with an optional note. Stored with what RosDiff did for it.
210
+ function feedbackBar(type, id, current) {
211
+ const cur = current === "works" || current === "helpful" ? "helpful" : current ? "not_helpful" : null;
212
+ const wrap = h("div", { class: "feedback", role: "group", "aria-label": "Was this helpful?" });
213
+ const opts = [["helpful", "👍", "Helpful"], ["not_helpful", "👎", "Not helpful"]];
214
+ for (const [rating, emoji, label] of opts) {
215
+ const b = h("button", { class: "fb" + (cur === rating ? " chosen" : ""), type: "button", title: label, "aria-pressed": String(cur === rating) },
216
+ h("span", { "aria-hidden": "true" }, emoji), h("span", { class: "fb-label" }, label));
217
  b.addEventListener("click", async () => {
218
+ const note = await askNote(rating);
219
  try {
220
+ await api("/feedback", { method: "POST", body: { target_type: type, target_id: id, rating, note } });
221
  $$(".fb", wrap).forEach((x) => { x.classList.toggle("chosen", x === b); x.setAttribute("aria-pressed", String(x === b)); });
222
+ if (type === "job") state.rated[id] = rating;
223
+ toast(note ? "Thanks, we read every note!" : "Thanks for the feedback!");
224
  } catch (e) { toast(friendly(e)); }
225
  });
226
  wrap.append(b);
 
228
  return wrap;
229
  }
230
 
231
+ function askNote(rating) {
232
+ const d = $("#fb-dialog");
233
+ const ta = $("#fb-note");
234
+ ta.value = "";
235
+ $("#fb-title").textContent = rating === "helpful" ? "Glad it helped! Anything we could do better?" : "Tell us what went wrong or how we can improve";
236
+ return new Promise((resolve) => {
237
+ d.addEventListener("close", () => resolve(d.returnValue === "send" ? ta.value.trim() : ""), { once: true });
238
+ d.returnValue = "";
239
+ d.showModal();
240
+ ta.focus();
241
+ });
242
+ }
243
+ $("#fb-form").addEventListener("submit", (e) => { e.preventDefault(); $("#fb-dialog").close("send"); });
244
+ $("#fb-skip").addEventListener("click", () => $("#fb-dialog").close("skip"));
245
+
246
+ // ------------------------------------------------------------------ routing, simulators, cost
247
+
248
+ const TIER_TEXT = { low: "Fast", mid: "Standard", high: "High reasoning" };
249
+ const SIMS = [
250
+ { id: "mujoco", label: "MuJoCo", what: "One run with video and a 3D replay", verb: "Run in MuJoCo", on: true },
251
+ { id: "mujoco_warp", label: "MuJoCo Warp", what: "64 randomized copies at once on the GPU: how often does it work?", verb: "Stress test in MuJoCo Warp", on: true },
252
+ { id: "isaac_sim", label: "Isaac Sim", what: "Runs in MuJoCo, then a photoreal RTX render of the same motion", verb: "Run + render in Isaac Sim", on: true },
253
+ { id: "gazebo", label: "Gazebo", what: "Not available in RosDiff yet", on: false },
254
+ { id: "webots", label: "Webots", what: "Not available in RosDiff yet", on: false },
255
+ { id: "pybullet", label: "PyBullet", what: "Not available in RosDiff yet", on: false },
256
+ ];
257
+ const simInfo = (id) => SIMS.find((x) => x.id === id) || SIMS[0];
258
+
259
+ // "AI Routing: automatic · Standard tier · Why?" — information only; nobody has to pick a model.
260
+ // The sharper brief the generator actually worked from (made for free by OpenCode Zen).
261
+ function enhancedBlock(e) {
262
+ if (!e?.used || !e.text) return null;
263
+ return h("details", { class: "enhanced" }, h("summary", {}, "✨ Enhanced prompt"),
264
+ h("div", { class: "why" }, e.text, h("div", { class: "muted small", style: "margin-top:6px" }, "Written for free with OpenCode Zen; it doesn't use your budget.")));
265
+ }
266
+
267
+ function routingLine(r, cost) {
268
+ if (!r) return null;
269
+ const tiers = r.tiers_used?.length ? r.tiers_used : [r.tier];
270
+ const final = tiers[tiers.length - 1] in TIER_TEXT ? tiers[tiers.length - 1] : r.tier;
271
+ const models = [...new Set((r.models || []).filter(Boolean))];
272
+ const why = h("span", { class: "why" },
273
+ h("div", {}, `Reason: ${r.reason}${r.complexity ? ` (rated ${r.complexity})` : ""}.`),
274
+ h("div", {}, `Capability tier: ${TIER_TEXT[r.tier] || r.tier}` + (r.escalated ? ` → raised to ${TIER_TEXT[final]} to fix a failed check` : "")),
275
+ r.simulator ? h("div", {}, `Simulator: ${simInfo(r.simulator).label}, ${r.simulator_reason}.`) : null,
276
+ models.length ? h("div", { class: "muted small" }, `Model${models.length > 1 ? "s" : ""}: ${models.join(", ")}`) : null,
277
+ cost ? h("div", {}, `This task used ${rupees(cost.inr)} (estimated ${rupees(cost.estimate_inr[0])}–${rupees(cost.estimate_inr[1])}). You have ${rupees(cost.budget.remaining)} of ${rupees(cost.budget.allocation)} left.`) : null);
278
+ return h("div", { class: "routing" },
279
+ h("span", { class: "auto" }, "AI Routing: automatic"),
280
+ h("span", {}, `${TIER_TEXT[final] || final} tier`),
281
+ cost ? h("span", {}, `· used ${rupees(cost.inr)}`) : null,
282
+ h("details", {}, h("summary", {}, "Why?"), why));
283
+ }
284
+
285
+ // "Estimated usage: ₹8–₹12. Your remaining budget: ₹42 / ₹125. [Run]" — only for tasks above the threshold.
286
+ function confirmCard(detail, run, cancel) {
287
+ const [lo, hi] = detail.estimate_inr || [0, 0];
288
+ const b = detail.budget || {};
289
+ const go = h("button", { class: "btn btn-primary btn-sm", type: "button" }, icon("play"), "Run");
290
+ const no = h("button", { class: "btn btn-sm btn-ghost", type: "button" }, "Cancel");
291
+ const card = h("div", { class: "card" },
292
+ h("div", { class: "card-head" }, statusIcon("warn"),
293
+ h("div", {}, h("div", { class: "title" }, "Estimated usage"), h("div", { class: "sub" }, "This is a bigger task, so RosDiff checks with you first."))),
294
+ h("div", { class: "card-body confirm" },
295
+ h("div", {}, "This task is expected to consume approximately"),
296
+ h("div", { class: "big" }, `${rupees(lo)} – ${rupees(hi)}`),
297
+ h("div", { class: "muted small" }, `Your remaining budget: ${rupees(b.remaining ?? 0)} / ${rupees(b.allocation ?? 0)}. Only what it actually uses is charged.`)),
298
+ h("div", { class: "card-actions" }, go, no));
299
+ go.addEventListener("click", () => { go.disabled = no.disabled = true; run(); });
300
+ no.addEventListener("click", () => { go.disabled = no.disabled = true; cancel(); });
301
+ return card;
302
+ }
303
+
304
+ document.addEventListener("click", (e) => {
305
+ $$(".sim-menu").forEach((m) => {
306
+ if (m.hidden || m.contains(e.target) || m._owner.contains(e.target)) return;
307
+ m.hidden = true;
308
+ m._owner.setAttribute("aria-expanded", "false");
309
+ });
310
+ });
311
+
312
  function errorCard(message) {
313
  return h("div", { class: "card" }, h("div", { class: "card-head" }, statusIcon("bad"), h("div", {}, h("div", { class: "title" }, "That didn't work"), h("div", { class: "sub" }, message))));
314
  }
 
328
  h("div", { class: "title" }, ok ? "Scene ready" : "Couldn't build a working scene"),
329
  h("div", { class: "sub" }, ok ? `Loads and simulates in MuJoCo · ${robotLabel(gen.robot)}` : "MuJoCo rejected every attempt")));
330
  const body = h("div", { class: "card-body" });
331
+ const route = routingLine(gen.routing, gen.cost);
332
+ if (route) body.append(route);
333
+ const brief = enhancedBlock(gen.enhanced_prompt);
334
+ if (brief) body.append(brief);
335
  if (ok) {
336
  body.append(h("div", { class: "facts" },
337
  h("span", { class: "pill" }, `${stats.nbody ?? "?"} bodies`),
 
346
  }
347
  const actions = h("div", { class: "card-actions" });
348
  const runs = h("div", { class: "runs" });
349
+ if (ok) actions.append(...simPicker(gen, runs));
 
 
 
 
350
  if (gen.mjcf) {
351
  const dl = h("button", { class: "btn btn-sm", type: "button" }, icon("download"), "Download");
352
  dl.addEventListener("click", () => download(`scene_${gen.id}.xml`, new Blob([gen.mjcf], { type: "application/xml" })));
353
  actions.append(dl);
354
  }
355
+ actions.append(h("span", { class: "spacer" }), feedbackBar("generation", gen.id, gen.rating));
356
  const wrap = h("div", { style: "display:flex;flex-direction:column;gap:14px" }, h("div", { class: "card" }, head, body, actions), runs);
357
  return wrap;
358
  }
359
 
360
+ // The simulator RosDiff chose, as the main button, plus "Use another simulator".
361
+ function simPicker(gen, runs) {
362
+ const auto = gen.routing?.simulator || "mujoco";
363
+ let chosen = auto;
364
+ const run = h("button", { class: "btn btn-primary btn-sm", type: "button" }, icon("play"), h("span", {}, simInfo(chosen).verb));
365
+ run.addEventListener("click", () => startRun(gen, runs, run, chosen));
366
+ const menu = h("div", { class: "sim-menu", role: "menu", hidden: true },
367
+ h("div", { class: "muted small", style: "padding:6px 10px 4px" }, `RosDiff picked ${simInfo(auto).label} for this scene.`));
368
+ const other = h("button", { class: "btn btn-sm btn-ghost", type: "button", "aria-haspopup": "menu", "aria-expanded": "false" }, "Use another simulator");
369
+ const paint = () => $$(".sim-opt", menu).forEach((o) => o.classList.toggle("chosen", o.dataset.sim === chosen));
370
+ for (const sim of SIMS) {
371
+ const opt = h("button", { class: "sim-opt", type: "button", role: "menuitemradio", "data-sim": sim.id, disabled: !sim.on },
372
+ h("b", {}, sim.label + (sim.id === auto ? " · recommended" : "")), h("span", {}, sim.what));
373
+ opt.addEventListener("click", () => {
374
+ chosen = sim.id;
375
+ run.lastChild.textContent = sim.verb;
376
+ paint();
377
+ menu.hidden = true;
378
+ other.setAttribute("aria-expanded", "false");
379
+ });
380
+ menu.append(opt);
381
+ }
382
+ paint();
383
+ other.addEventListener("click", () => { menu.hidden = !menu.hidden; other.setAttribute("aria-expanded", String(!menu.hidden)); });
384
+ menu._owner = other;
385
+ return [run, other, menu]; // the menu opens as a full-width row of the card's action bar
386
+ }
387
+
388
+ async function startRun(gen, container, button, sim = "mujoco") {
389
  button.disabled = true;
390
  try {
391
+ if (sim === "mujoco_warp") {
392
+ const job = await api("/simulate/sweep", { method: "POST", body: { generation_id: gen.id, n_worlds: 64 } });
393
+ container.append(jobCard({ id: job.id, kind: "sweep", status: job.status, report_url: job.report_url }));
394
+ } else {
395
+ const job = await api("/simulate/run", { method: "POST", body: { generation_id: gen.id, duration_s: 10, controller: "hold" } });
396
+ const then = sim === "isaac_sim" ? (j) => startIsaac(j.id, container) : null;
397
+ container.append(jobCard({ id: job.run_id, kind: "sim", status: job.status, report_url: job.report_url }, then));
398
+ }
399
  scrollDown("sim");
400
  } catch (e) {
401
  container.append(errorCard(friendly(e)));
 
404
  }
405
  }
406
 
407
+ async function startIsaac(runId, container, button) {
408
+ if (button) button.disabled = true;
409
+ try {
410
+ const job = await api(`/simulate/run/${runId}/isaac`, { method: "POST" });
411
+ container.append(jobCard({ id: job.id, kind: "isaac", status: job.status, report_url: job.report_url }));
412
+ scrollDown("sim");
413
+ } catch (e) {
414
+ container.append(errorCard(friendly(e)));
415
+ if (button) button.disabled = false;
416
+ }
417
+ }
418
+
419
  // --- code
420
 
421
  function packageCard(gen) {
 
428
  h("div", { class: "title" }, ok ? "Package ready" : names.length ? "Package written, with problems" : "Couldn't write the package"),
429
  h("div", { class: "sub" }, `${names.length} file(s) · ROS 2 ${gen.distro || ""} · ${gen.language === "cpp" ? "C++" : "Python"}`)));
430
  const body = h("div", { class: "card-body" });
431
+ const route = routingLine(gen.routing, gen.cost);
432
+ if (route) body.append(route);
433
+ const brief = enhancedBlock(gen.enhanced_prompt);
434
+ if (brief) body.append(brief);
435
  const errors = val.errors || [];
436
  if (errors.length) body.append(h("div", { class: "error-box" }, errors.slice(0, 6).join("\n")));
437
  if ((val.warnings || []).length) body.append(h("p", { class: "note" }, `${val.warnings.length} note(s): ${val.warnings.slice(0, 2).join("; ")}`));
 
468
  });
469
  actions.append(check, zip, copy);
470
  }
471
+ actions.append(h("span", { class: "spacer" }), feedbackBar("generation", gen.id, gen.rating));
472
  return h("div", { style: "display:flex;flex-direction:column;gap:14px" }, h("div", { class: "card" }, head, body, actions), checks);
473
  }
474
  function score(path) {
 
502
  export: { wait: "Starting the export…", run: "Building the dataset…", title: "Dataset export" },
503
  };
504
 
505
+ function jobCard(job, onComplete) {
506
  const card = h("div", { class: "card" });
507
  card.dataset.job = job.id;
508
  const render = (j) => {
509
+ if (j.status === "COMPLETED" && onComplete) { const f = onComplete; onComplete = null; f(j); }
510
  const text = JOB_TEXT[j.kind] || JOB_TEXT.sim;
511
  card.replaceChildren();
512
  if (!TERMINAL.has(j.status)) {
 
524
  if (j.kind === "sim") card.append(...simResult(j));
525
  else if (j.kind === "ros_check") card.append(...checkResult(j));
526
  else if (j.kind === "sweep") card.append(...sweepResult(j));
527
+ else {
528
+ const body = h("div", { class: "card-body" });
529
+ if (j.media?.isaac_video) body.append(h("video", { controls: true, autoplay: true, muted: true, loop: true, playsinline: true, preload: "metadata", src: j.media.isaac_video }));
530
+ card.append(h("div", { class: "card-head" }, statusIcon("ok"), h("div", {}, h("div", { class: "title" }, text.title), h("div", { class: "sub" }, j.summary?.headline || "Done"))),
531
+ ...(body.childNodes.length ? [body] : []), reportActions(j));
532
+ }
533
  };
534
  render(job);
535
  poll(job.id, render);
 
553
  function reportActions(j, ...extra) {
554
  const bar = h("div", { class: "card-actions" }, ...extra);
555
  if (j.report_url) bar.append(h("a", { class: "btn btn-sm", href: j.report_url, target: "_blank", rel: "noopener" }, icon("external"), "Full report"));
556
+ if (TERMINAL.has(j.status)) bar.append(h("span", { class: "spacer" }), feedbackBar("job", j.id, state.rated[j.id]));
557
  return bar;
558
  }
559
 
 
588
  extra.append(jobCard({ id: job.id, kind: "sweep", status: job.status, report_url: job.report_url }));
589
  } catch (e) { extra.append(errorCard(friendly(e))); stress.disabled = false; }
590
  });
591
+ const isaac = h("button", { class: "btn btn-sm", type: "button", title: "Render this exact run photorealistically with NVIDIA Isaac Sim" }, icon("play"), "Isaac Sim render");
592
+ isaac.addEventListener("click", () => startIsaac(j.id, extra, isaac));
593
+ return [head, body, reportActions(j, stress, isaac), extra];
594
  }
595
 
596
  function checkResult(j) {
 
676
  const input = $(`#input-${pane}`);
677
  const text = input.value.trim();
678
  if (text.length < 10) { toast("Please describe it in a bit more detail (at least 10 characters)."); input.focus(); return; }
 
 
679
  const t = thread(pane);
680
  if (t.querySelector(".empty")) t.replaceChildren();
681
  const meta = pane === "sim" ? robotLabel(state.robot) : `ROS 2 ${cap(state.distro)} · ${state.lang === "cpp" ? "C++" : "Python"}`;
682
  t.append(userMessage(text, meta));
683
  input.value = "";
684
  autosize(input);
685
+ const body = pane === "sim"
686
+ ? { description: text, robot: state.robot }
687
+ : { request: text, distro: state.distro, language: state.lang };
688
+ await generate(pane, body);
689
+ }
690
+
691
+ async function generate(pane, body) {
692
+ state.busy[pane] = true;
693
+ $(`#composer-${pane} .send`).disabled = true;
694
+ const t = thread(pane);
695
  const steps = pane === "sim"
696
+ ? ["Sharpening your description…", "Choosing the right AI for this scene…", "Reading the MuJoCo documentation…", "Writing the scene…", "Testing it in MuJoCo…", "Fixing anything MuJoCo complained about…", "Almost there…"]
697
+ : ["Sharpening your request…", "Choosing the right AI for this package…", "Looking up the ROS 2 messages it needs…", "Writing the package…", "Checking every message type exists…", "Almost there…"];
698
  const working = workingCard(steps);
699
  t.append(working);
700
  scrollDown(pane);
701
  try {
702
  if (pane === "sim") {
703
+ const r = await api("/simulate/generate", { method: "POST", body });
704
  working.replaceWith(sceneCard({ ...r, rating: null }));
705
  } else {
706
+ const r = await api("/code/generate", { method: "POST", body });
707
+ working.replaceWith(packageCard({ ...r, distro: body.distro, language: body.language, rating: null }));
708
  }
709
  loadHistory();
710
+ refreshBudget();
711
  } catch (e) {
712
+ if (e instanceof ApiError && e.status === 409 && e.detail?.needs_confirmation) {
713
+ const card = confirmCard(e.detail,
714
+ () => { card.remove(); generate(pane, { ...body, confirm_cost: true }); },
715
+ () => card.replaceWith(h("p", { class: "note" }, "Cancelled. Nothing was charged.")));
716
+ working.replaceWith(card);
717
+ } else {
718
+ working.replaceWith(errorCard(friendly(e)));
719
+ }
720
  } finally {
721
  working.stop();
722
  state.busy[pane] = false;
 
763
  }
764
  }
765
 
766
+ // Older generations were made before routing existed; they simply show no routing line.
767
+ const savedRouting = (g) => (g.request?.routing ? { ...g.request.routing, models: [g.model] } : null);
768
+ const savedBrief = (g) => g.request?.enhanced || null;
769
+
770
  async function openGeneration(id, kind, item) {
771
  closeSidebar();
772
  switchPane(kind);
 
779
  const content = [];
780
  if (g.kind === "code") {
781
  content.push(userMessage(req.request || "", `ROS 2 ${cap(req.distro || "")} · ${req.language === "cpp" ? "C++" : "Python"}`));
782
+ const card = packageCard({ id: g.id, valid: g.valid, validation: g.validation, files: g.output || {}, distro: req.distro, language: req.language, rating: g.rating, routing: savedRouting(g), enhanced_prompt: savedBrief(g) });
783
  content.push(card);
784
  for (const j of g.jobs) card.querySelector(".runs").append(jobCard(j));
785
  } else {
786
  content.push(userMessage(req.description || "", robotLabel(req.robot)));
787
+ const card = sceneCard({ id: g.id, valid: g.valid, validation: g.validation, mjcf: g.output, robot: req.robot, error: g.error, rating: g.rating, routing: savedRouting(g), enhanced_prompt: savedBrief(g) });
788
  content.push(card);
789
  for (const j of g.jobs) card.querySelector(".runs").append(jobCard(j));
790
  }
 
798
 
799
  async function refreshBudget() {
800
  try {
801
+ const { ai } = await api("/me/usage");
802
+ const pct = Math.min(100, (100 * ai.used) / (ai.allocation || 1));
803
+ $("#budget-label").textContent = `${rupees(ai.remaining)} left`;
 
 
 
 
804
  $("#budget-fill").style.width = pct + "%";
805
+ const el = $("#budget");
806
  el.classList.toggle("high", pct >= 70 && pct < 100);
807
  el.classList.toggle("full", pct >= 100);
808
+ el.title = `Your AI budget: ${rupees(ai.used)} of ${rupees(ai.allocation)} used. Click for details.`;
809
  } catch { /* not important */ }
810
  }
811
 
 
860
  $("#user-btn").addEventListener("click", (e) => { e.stopPropagation(); menu.hidden = !menu.hidden; $("#user-btn").setAttribute("aria-expanded", String(!menu.hidden)); });
861
  document.addEventListener("click", () => { menu.hidden = true; $("#user-btn").setAttribute("aria-expanded", "false"); });
862
  $("#logout-btn").addEventListener("click", () => signOut(false));
863
+ $("#help-btn").addEventListener("click", () => openTour());
864
+ $("#usage-btn").addEventListener("click", openUsage);
865
+ $("#budget").addEventListener("click", openUsage);
866
  }
867
 
868
  // ------------------------------------------------------------------ sign in / out
 
888
  loadHistory();
889
  refreshBudget();
890
  setInterval(refreshBudget, 60000);
891
+ let toured = false;
892
+ try { toured = localStorage.getItem("rosdiff_seen_tour") === "1"; } catch { /* ignore */ }
893
+ if (!toured) { remember("rosdiff_seen_tour", "1"); openTour(); }
894
  }
895
 
896
  function setupLogin() {
 
909
  });
910
  }
911
 
912
+ // ------------------------------------------------------------------ startup animation
913
+
914
+ function splash() {
915
+ const el = $("#splash");
916
+ let seen = false;
917
+ try { seen = localStorage.getItem("rosdiff_seen_splash") === "1"; } catch { /* ignore */ }
918
+ const reduced = matchMedia("(prefers-reduced-motion: reduce)").matches;
919
+ if (seen) el.classList.add("quick");
920
+ const hold = reduced ? 400 : seen ? 800 : 2300;
921
+ return new Promise((resolve) => {
922
+ const finish = () => {
923
+ if (el.classList.contains("done")) return;
924
+ el.classList.add("done");
925
+ remember("rosdiff_seen_splash", "1");
926
+ setTimeout(() => { el.remove(); resolve(); }, 450);
927
+ };
928
+ setTimeout(finish, hold);
929
+ el.addEventListener("click", finish);
930
+ addEventListener("keydown", finish, { once: true });
931
+ });
932
+ }
933
+
934
+ // ------------------------------------------------------------------ beginner tour
935
+
936
+ const TOUR = [
937
+ ["Describe what you want to build.", "Type it in plain words, like you'd tell a teammate: a world for your robot on the left, a ROS 2 node on the right."],
938
+ ["RosDiff understands your project.", "First it sharpens your words into a precise brief (for free). Then it reads the relevant MuJoCo and ROS 2 documentation, the robot's real model and every message definition before it writes anything."],
939
+ ["RosDiff picks the right AI for the job.", "Small tasks go to a fast, low-cost model; hard ones to a stronger reasoning model. You never have to choose one."],
940
+ ["RosDiff picks the simulator.", null],
941
+ ["RosDiff sets up the tools.", "Robot models, ROS 2 (Humble or Jazzy), Nav2, MoveIt 2, ros2_control and the build tools are ready in the cloud. Nothing to install."],
942
+ ["RosDiff writes and modifies the code.", "You get a complete package: nodes, launch files, package.xml and build files, checked against the real message types."],
943
+ ["RosDiff runs it and tests it.", "“Run in MuJoCo” shows your robot in that world on a cloud GPU. “Build & run” compiles your package and starts it for 10 seconds."],
944
+ ["RosDiff learns from failures.", "If a draft fails its checks, RosDiff reads the errors and fixes them, moving up to a stronger AI model when it needs to."],
945
+ ["RosDiff manages the cost.", "Each tester has a small AI budget (see the top bar). Bigger tasks show an estimate first. Cloud GPU time has its own equal share per tester; see “My usage”."],
946
+ ["You can override tools any time.", "Want a different simulator? Use “Use another simulator” on any scene. And tell us 👍 or 👎 on every result: that's what this test is for."],
947
+ ];
948
+
949
+ function tourSlide(i) {
950
+ const [title, text] = TOUR[i];
951
+ const slide = h("div", {}, h("div", { class: "step" }, `Step ${i + 1}`), h("h3", {}, title));
952
+ if (text) slide.append(h("p", {}, text));
953
+ if (i === 3) {
954
+ slide.append(
955
+ h("p", {}, "RosDiff looks at your robot, the physics, the sensors and what you want to test, then chooses the simulator. For example:"),
956
+ h("div", { class: "tour-demo" },
957
+ h("div", { class: "you" }, "“Build and test a mobile robot with a LiDAR driving between obstacles.”"),
958
+ h("div", { class: "line" }, h("span", { class: "routing" }, h("span", { class: "auto" }, "Auto")), h("span", {}, h("b", {}, "MuJoCo"), " — contact-rich rigid-body physics with the real robot model and its range sensors.")),
959
+ h("div", { class: "line muted small" }, "Ask “how often does it crash?” and it switches to MuJoCo Warp (many variations at once); ask for photoreal camera images and Isaac Sim renders it.")),
960
+ h("p", { class: "small", style: "margin-top:10px" }, "RosDiff automatically selects the simulation environment from your project, robot, physics, sensors and testing needs."),
961
+ );
962
+ const tryIt = h("button", { class: "btn btn-sm", type: "button" }, "Try this example");
963
+ tryIt.addEventListener("click", () => {
964
+ $("#tour").close();
965
+ setRobot("unitree_go2");
966
+ switchPane("sim");
967
+ fillComposer("sim", "The Go2 robot dog with a LiDAR drives between four box obstacles 1 to 3 m ahead of it; test that it doesn't hit them.");
968
+ });
969
+ slide.append(tryIt);
970
+ }
971
+ return slide;
972
+ }
973
+
974
+ function openTour(start = 0) {
975
+ const dlg = $("#tour");
976
+ let i = start;
977
+ const dots = $("#tour-dots");
978
+ dots.replaceChildren(...TOUR.map(() => h("span")));
979
+ const show = () => {
980
+ $("#tour-slide").replaceChildren(tourSlide(i));
981
+ $("#tour-count").textContent = `${i + 1} / ${TOUR.length}`;
982
+ $$("span", dots).forEach((d, k) => d.classList.toggle("on", k === i));
983
+ $("#tour-back").style.visibility = i === 0 ? "hidden" : "visible";
984
+ $("#tour-next").textContent = i === TOUR.length - 1 ? "Start building" : "Next";
985
+ };
986
+ $("#tour-next").onclick = () => { if (i < TOUR.length - 1) { i++; show(); } else dlg.close(); };
987
+ $("#tour-back").onclick = () => { if (i > 0) { i--; show(); } };
988
+ $("#tour-close").onclick = () => dlg.close();
989
+ show();
990
+ dlg.showModal();
991
+ }
992
+
993
+ // ------------------------------------------------------------------ usage
994
+
995
+ const rupees = (x) => "₹" + (Math.round(x * 100) / 100).toLocaleString("en-IN", { minimumFractionDigits: 2, maximumFractionDigits: 2 });
996
+
997
+ async function openUsage() {
998
+ const body = $("#usage-body");
999
+ body.replaceChildren(h("p", { class: "muted" }, "Loading…"));
1000
+ $("#usage").showModal();
1001
+ try {
1002
+ const u = await api("/me/usage");
1003
+ const a = u.ai;
1004
+ const pct = Math.min(100, (100 * a.used) / (a.allocation || 1));
1005
+ const labels = { coding: "Coding", simulation_ai: "Simulation AI", debugging: "Debugging (automatic fixes)", testing: "Testing" };
1006
+ const rows = Object.entries(a.by_category).map(([k, v]) => h("tr", {}, h("td", {}, labels[k] || k), h("td", {}, rupees(v))));
1007
+ const parts = [
1008
+ h("div", { class: "muted small" }, "AI budget"),
1009
+ h("div", { class: "usage-big" }, `${rupees(a.remaining)} left`),
1010
+ h("div", { class: "muted small" }, `${rupees(a.used)} of ${rupees(a.allocation)} used`),
1011
+ h("div", { class: "meter" }, h("span", { style: `width:${pct}%` })),
1012
+ h("table", { class: "usage-table" }, h("tbody", {}, ...rows)),
1013
+ h("p", { class: "muted small" }, `Free calls (prompt enhancement on OpenCode Zen, OpenRouter-exempt models) are not counted: ${a.exempt_calls} so far. ` +
1014
+ `Team pool: ${rupees(a.pool.used)} of ${rupees(a.pool.total)} used.`),
1015
+ ];
1016
+ const c = u.compute;
1017
+ if (c.share) {
1018
+ const gpct = Math.min(100, (100 * c.used) / c.share);
1019
+ parts.push(h("div", { class: "muted small", style: "margin-top:14px" }, "Cloud GPU (simulations, stress tests, renders, build checks)"),
1020
+ h("div", { class: "usage-big" }, `$${c.remaining.toFixed(2)} left`),
1021
+ h("div", { class: "muted small" }, `$${c.used.toFixed(2)} of your $${c.share.toFixed(2)} this month (the $${c.team_limit} credit split equally between ${c.testers} testers). ` +
1022
+ "A job only starts if its longest possible run still fits."),
1023
+ h("div", { class: "meter" }, h("span", { style: `width:${gpct}%` })));
1024
+ }
1025
+ if (u.admin && u.team) {
1026
+ parts.push(h("div", { class: "muted small", style: "margin-top:10px" }, "Team (admin view)"),
1027
+ h("table", { class: "usage-table" },
1028
+ h("thead", {}, h("tr", {}, h("th", {}, "Tester"), h("th", {}, "AI used"), h("th", {}, "AI left"), h("th", {}, "GPU used"))),
1029
+ h("tbody", {}, ...u.team.map((m) => h("tr", {}, h("td", {}, m.tester), h("td", {}, rupees(m.used)), h("td", {}, rupees(m.remaining)), h("td", {}, `$${(m.gpu || 0).toFixed(2)}`))),
1030
+ h("tr", {}, h("td", {}, h("b", {}, "Total")), h("td", {}, h("b", {}, rupees(a.pool.used))), h("td", {}, h("b", {}, rupees(a.pool.remaining))),
1031
+ h("td", {}, h("b", {}, `$${(c.team_used || 0).toFixed(2)}`))))));
1032
+ }
1033
+ body.replaceChildren(...parts);
1034
+ } catch (e) {
1035
+ body.replaceChildren(h("p", { class: "error-text" }, friendly(e)));
1036
+ }
1037
+ }
1038
+
1039
  setupLogin();
1040
  setupChrome();
1041
  setupComposer();
1042
+ splash().then(() => {
1043
+ if (state.key) {
1044
+ enter().catch(() => signOut(false));
1045
+ } else {
1046
+ $("#login").hidden = false;
1047
+ $("#key-input").focus();
1048
+ }
1049
+ });
api/static/app/brand/rosdiff-192.png ADDED
api/static/app/brand/rosdiff-32.png ADDED
api/static/app/brand/rosdiff-512.webp ADDED
api/static/app/brand/rosdiff-64.png ADDED
api/static/app/index.html CHANGED
@@ -3,21 +3,34 @@
3
  <head>
4
  <meta charset="utf-8">
5
  <meta name="viewport" content="width=device-width, initial-scale=1">
6
- <title>RosDiff</title>
7
  <meta name="description" content="Describe a robot world or a ROS 2 package; RosDiff builds it and tests it.">
8
- <link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Crect width='32' height='32' rx='8' fill='%236d5efc'/%3E%3Cpath d='M9 22V10h7a4 4 0 0 1 0 8h-7m7 0 5 4' stroke='white' stroke-width='3' fill='none' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E">
 
 
9
  <link rel="preconnect" href="https://fonts.googleapis.com">
10
  <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
11
  <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
12
  <link rel="stylesheet" href="/static/app/app.css">
13
  </head>
14
  <body>
 
 
 
 
 
 
 
 
 
15
  <!-- Sign in -->
16
  <div id="login" class="login" hidden>
17
  <form class="login-card" id="login-form" autocomplete="off">
18
- <div class="brand brand-lg"><span class="logo" aria-hidden="true">R</span><span>RosDiff</span></div>
19
- <h1>Build robot worlds and ROS 2 code by describing them</h1>
20
- <p class="muted">Enter the tester key you were given. It starts with <code>rd_</code>.</p>
 
 
21
  <label class="sr-only" for="key-input">Tester key</label>
22
  <input id="key-input" class="input" type="password" placeholder="rd_…" spellcheck="false" required>
23
  <button class="btn btn-primary btn-block" type="submit">Sign in</button>
@@ -32,22 +45,23 @@
32
  <button class="icon-btn only-mobile" id="menu-btn" aria-label="Show history" aria-expanded="false">
33
  <svg viewBox="0 0 24 24" aria-hidden="true"><path d="M4 7h16M4 12h16M4 17h16"/></svg>
34
  </button>
35
- <div class="brand"><span class="logo" aria-hidden="true">R</span><span>RosDiff</span><span class="tag">testing</span></div>
36
  <nav class="mobile-tabs only-mobile" aria-label="Workspace">
37
  <button class="mtab active" data-pane="sim">Simulation</button>
38
  <button class="mtab" data-pane="code">Code</button>
39
  </nav>
40
  <div class="topbar-right">
41
- <div class="budget" id="budget" title="Compute used this month by all testers">
42
  <span class="budget-label" id="budget-label">—</span>
43
  <span class="budget-bar"><span id="budget-fill"></span></span>
44
- </div>
45
  <div class="user">
46
  <button class="user-chip" id="user-btn" aria-haspopup="menu" aria-expanded="false">
47
  <span class="avatar" id="avatar" aria-hidden="true">?</span><span id="user-name">tester</span>
48
  </button>
49
  <div class="menu" id="user-menu" role="menu" hidden>
50
- <button role="menuitem" id="help-btn">How it works</button>
 
51
  <button role="menuitem" id="logout-btn">Sign out</button>
52
  </div>
53
  </div>
@@ -72,7 +86,7 @@
72
  <span class="pane-icon sim" aria-hidden="true"><svg viewBox="0 0 24 24"><path d="M12 3 4 7.5v9L12 21l8-4.5v-9L12 3Zm0 0v18M4 7.5l8 4.5 8-4.5"/></svg></span>
73
  <div>
74
  <h2 id="sim-title">Simulation</h2>
75
- <p class="muted small">Describe a world. We build it in MuJoCo and test it.</p>
76
  </div>
77
  <button class="btn btn-ghost btn-sm" data-clear="sim" aria-label="Start a new simulation thread">New</button>
78
  </div>
@@ -95,7 +109,7 @@
95
  <span class="pane-icon code" aria-hidden="true"><svg viewBox="0 0 24 24"><path d="m8 7-5 5 5 5m8-10 5 5-5 5M14 4l-4 16"/></svg></span>
96
  <div>
97
  <h2 id="code-title">ROS 2 code</h2>
98
- <p class="muted small">Describe a node. We write the package and can build and run it.</p>
99
  </div>
100
  <button class="btn btn-ghost btn-sm" data-clear="code" aria-label="Start a new code thread">New</button>
101
  </div>
@@ -123,17 +137,42 @@
123
  </main>
124
  </div>
125
 
126
- <!-- How it works -->
127
- <dialog id="help" class="dialog" aria-labelledby="help-title">
128
- <h2 id="help-title">How RosDiff works</h2>
129
- <ol class="steps">
130
- <li><b>Simulation (left):</b> pick a robot, describe a world in plain words and press Enter. RosDiff writes the scene and checks that it loads in MuJoCo.</li>
131
- <li>Press <b>Run simulation</b> to watch the robot in that world on a cloud GPU. The video appears right in the chat after a minute or two.</li>
132
- <li><b>ROS 2 code (right):</b> describe a node or package. RosDiff writes it, then <b>Build &amp; run</b> compiles it and starts it for 10 seconds in a real ROS 2 setup.</li>
133
- <li>Tell us if a result actually worked with 👍 / 👎. That feedback is the whole point of this test.</li>
134
- </ol>
135
- <p class="muted small">Everything you make is saved under History. Results are kept for 30 days.</p>
136
- <form method="dialog"><button class="btn btn-primary">Got it</button></form>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
137
  </dialog>
138
 
139
  <div class="toast" id="toast" role="status" hidden></div>
 
3
  <head>
4
  <meta charset="utf-8">
5
  <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <title>RosDiff by Orcanion</title>
7
  <meta name="description" content="Describe a robot world or a ROS 2 package; RosDiff builds it and tests it.">
8
+ <link rel="icon" type="image/png" sizes="32x32" href="/static/app/brand/rosdiff-32.png">
9
+ <link rel="apple-touch-icon" href="/static/app/brand/rosdiff-192.png">
10
+ <meta name="theme-color" content="#050507">
11
  <link rel="preconnect" href="https://fonts.googleapis.com">
12
  <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
13
  <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
14
  <link rel="stylesheet" href="/static/app/app.css">
15
  </head>
16
  <body>
17
+ <!-- Startup animation (shortened for returning visitors; click or any key skips) -->
18
+ <div id="splash" class="splash" aria-hidden="true">
19
+ <div class="splash-inner">
20
+ <img class="splash-logo" src="/static/app/brand/rosdiff-512.webp" alt="" width="200" height="200">
21
+ <div class="splash-name">RosDiff</div>
22
+ <div class="splash-by">by Orcanion</div>
23
+ </div>
24
+ </div>
25
+
26
  <!-- Sign in -->
27
  <div id="login" class="login" hidden>
28
  <form class="login-card" id="login-form" autocomplete="off">
29
+ <img class="login-logo" src="/static/app/brand/rosdiff-192.png" alt="RosDiff logo" width="84" height="84">
30
+ <div class="login-brand">RosDiff <span>by Orcanion</span></div>
31
+ <h1>Tell RosDiff what you want to build</h1>
32
+ <p class="muted">It picks the AI, the simulator and the compute, then builds and tests it for you.</p>
33
+ <p class="muted small">Enter the tester key you were given. It starts with <code>rd_</code>.</p>
34
  <label class="sr-only" for="key-input">Tester key</label>
35
  <input id="key-input" class="input" type="password" placeholder="rd_…" spellcheck="false" required>
36
  <button class="btn btn-primary btn-block" type="submit">Sign in</button>
 
45
  <button class="icon-btn only-mobile" id="menu-btn" aria-label="Show history" aria-expanded="false">
46
  <svg viewBox="0 0 24 24" aria-hidden="true"><path d="M4 7h16M4 12h16M4 17h16"/></svg>
47
  </button>
48
+ <div class="brand"><img class="logo-img" src="/static/app/brand/rosdiff-64.png" alt="" width="28" height="28"><span>RosDiff</span><span class="by">by Orcanion</span><span class="tag">testing</span></div>
49
  <nav class="mobile-tabs only-mobile" aria-label="Workspace">
50
  <button class="mtab active" data-pane="sim">Simulation</button>
51
  <button class="mtab" data-pane="code">Code</button>
52
  </nav>
53
  <div class="topbar-right">
54
+ <button class="budget" id="budget" type="button" title="Your AI budget. Click for details.">
55
  <span class="budget-label" id="budget-label">—</span>
56
  <span class="budget-bar"><span id="budget-fill"></span></span>
57
+ </button>
58
  <div class="user">
59
  <button class="user-chip" id="user-btn" aria-haspopup="menu" aria-expanded="false">
60
  <span class="avatar" id="avatar" aria-hidden="true">?</span><span id="user-name">tester</span>
61
  </button>
62
  <div class="menu" id="user-menu" role="menu" hidden>
63
+ <button role="menuitem" id="usage-btn">My usage</button>
64
+ <button role="menuitem" id="help-btn">Take the tour</button>
65
  <button role="menuitem" id="logout-btn">Sign out</button>
66
  </div>
67
  </div>
 
86
  <span class="pane-icon sim" aria-hidden="true"><svg viewBox="0 0 24 24"><path d="M12 3 4 7.5v9L12 21l8-4.5v-9L12 3Zm0 0v18M4 7.5l8 4.5 8-4.5"/></svg></span>
87
  <div>
88
  <h2 id="sim-title">Simulation</h2>
89
+ <p class="muted small">Describe a world. RosDiff picks the simulator, builds it and tests it.</p>
90
  </div>
91
  <button class="btn btn-ghost btn-sm" data-clear="sim" aria-label="Start a new simulation thread">New</button>
92
  </div>
 
109
  <span class="pane-icon code" aria-hidden="true"><svg viewBox="0 0 24 24"><path d="m8 7-5 5 5 5m8-10 5 5-5 5M14 4l-4 16"/></svg></span>
110
  <div>
111
  <h2 id="code-title">ROS 2 code</h2>
112
+ <p class="muted small">Describe a node. RosDiff writes it, then builds and runs it for real.</p>
113
  </div>
114
  <button class="btn btn-ghost btn-sm" data-clear="code" aria-label="Start a new code thread">New</button>
115
  </div>
 
137
  </main>
138
  </div>
139
 
140
+ <!-- Beginner tour -->
141
+ <dialog id="tour" class="dialog tour" aria-labelledby="tour-title">
142
+ <div class="tour-top">
143
+ <img src="/static/app/brand/rosdiff-64.png" alt="" width="30" height="30">
144
+ <span class="tour-count" id="tour-count">1 / 10</span>
145
+ <button class="icon-btn" id="tour-close" aria-label="Close the tour">
146
+ <svg viewBox="0 0 24 24" aria-hidden="true"><path d="M7 7l10 10M17 7 7 17"/></svg>
147
+ </button>
148
+ </div>
149
+ <div id="tour-slide" class="tour-slide" aria-live="polite"></div>
150
+ <div class="tour-dots" id="tour-dots" aria-hidden="true"></div>
151
+ <div class="tour-actions">
152
+ <button class="btn btn-ghost" id="tour-back" type="button">Back</button>
153
+ <button class="btn btn-primary" id="tour-next" type="button">Next</button>
154
+ </div>
155
+ </dialog>
156
+
157
+ <!-- Usage -->
158
+ <dialog id="usage" class="dialog" aria-labelledby="usage-title">
159
+ <h2 id="usage-title">Your RosDiff usage</h2>
160
+ <div id="usage-body"></div>
161
+ <form method="dialog"><button class="btn btn-primary">Close</button></form>
162
+ </dialog>
163
+
164
+ <!-- Feedback note -->
165
+ <dialog id="fb-dialog" class="dialog" aria-labelledby="fb-title">
166
+ <form id="fb-form">
167
+ <h2 id="fb-title">Tell us what went wrong or how to improve</h2>
168
+ <p class="muted small">Optional, but it helps us a lot.</p>
169
+ <label class="sr-only" for="fb-note">Your feedback</label>
170
+ <textarea id="fb-note" class="textarea" rows="4" maxlength="4000" placeholder="e.g. The boxes were floating above the floor…"></textarea>
171
+ <div class="dialog-actions">
172
+ <button class="btn btn-ghost" type="button" id="fb-skip">Skip</button>
173
+ <button class="btn btn-primary" type="submit">Send</button>
174
+ </div>
175
+ </form>
176
  </dialog>
177
 
178
  <div class="toast" id="toast" role="status" hidden></div>
tests/conftest.py CHANGED
@@ -140,6 +140,7 @@ def settings(tmp_path, menagerie):
140
  mjcf_sim_seconds=0.2,
141
  codegen_backend="direct",
142
  auto_review=False,
 
143
  )
144
 
145
 
 
140
  mjcf_sim_seconds=0.2,
141
  codegen_backend="direct",
142
  auto_review=False,
143
+ prompt_enhance=False,
144
  )
145
 
146
 
tests/test_enhance.py ADDED
@@ -0,0 +1,107 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Prompt enhancement through OpenCode Zen (free): a fake `opencode` binary stands in, nothing leaves the machine."""
2
+
3
+ import json
4
+ import stat
5
+ from dataclasses import replace
6
+
7
+ from conftest import GOOD_SCENE
8
+ from fastapi.testclient import TestClient
9
+
10
+ from api import budget, keys
11
+ from api.auth import Allowlist
12
+ from api.enhance import Enhanced, enhance
13
+ from api.llm import ScriptedLLM
14
+ from api.logstore import LogStore
15
+ from api.main import Services, create_app
16
+
17
+
18
+ def fake_opencode(tmp_path, answer: str, exit_code: int = 0):
19
+ """An executable that records its argv/config and prints OpenCode-style JSON events."""
20
+ events = [
21
+ {"type": "text", "part": {"type": "text", "text": answer}},
22
+ {"type": "step_finish", "part": {"tokens": {"input": 400, "output": 120}, "cost": 0}},
23
+ ]
24
+ (tmp_path / "events.jsonl").write_text("\n".join(json.dumps(e) for e in events) + "\n")
25
+ script = tmp_path / "opencode"
26
+ script.write_text(
27
+ "#!/usr/bin/env python3\n"
28
+ "import json, os, sys\n"
29
+ f"d = {str(tmp_path)!r}\n"
30
+ "open(d + '/call.json', 'w').write(json.dumps({'argv': sys.argv, "
31
+ "'config': json.loads(os.environ['OPENCODE_CONFIG_CONTENT'])}))\n"
32
+ "print(open(d + '/events.jsonl').read())\n"
33
+ f"sys.exit({exit_code})\n"
34
+ )
35
+ script.chmod(script.stat().st_mode | stat.S_IEXEC)
36
+ return script
37
+
38
+
39
+ def on(settings, tmp_path, answer, **kw):
40
+ return replace(settings, prompt_enhance=True, opencode_bin=str(fake_opencode(tmp_path, answer, **kw)))
41
+
42
+
43
+ def test_enhances_with_the_free_zen_model_only(settings, tmp_path):
44
+ s = on(settings, tmp_path, "<prompt>A G1 humanoid 1.5 m in front of 3 steps, each 0.15 m high.</prompt>")
45
+ e = enhance(s, "scene", "robot in front of 3 steps 1.5 m away", robot="unitree_g1")
46
+ assert e.used and e.text.startswith("A G1 humanoid") and e.model == "opencode/deepseek-v4-flash-free"
47
+ assert e.usage == {"input_tokens": 400, "output_tokens": 120, "cost": 0.0} # Zen reports it as free
48
+ call = json.loads((tmp_path / "call.json").read_text())
49
+ assert call["argv"][call["argv"].index("--model") + 1] == "opencode/deepseek-v4-flash-free"
50
+ # only the Zen provider, and no tools: it cannot reach OpenRouter or touch files
51
+ assert set(call["config"]["provider"]) == {"opencode"}
52
+ assert all(v == "deny" for v in call["config"]["permission"].values())
53
+ assert "-f" not in call["argv"]
54
+
55
+
56
+ def test_falls_back_to_the_original_when_numbers_are_lost(settings, tmp_path):
57
+ s = on(settings, tmp_path, "<prompt>A robot in front of some steps.</prompt>")
58
+ e = enhance(s, "scene", "robot in front of 3 steps 1.5 m away", robot="unitree_g1")
59
+ assert not e.used and e.text == "robot in front of 3 steps 1.5 m away" and "dropped" in e.note
60
+
61
+
62
+ def test_falls_back_when_opencode_fails_or_is_missing(settings, tmp_path):
63
+ e = enhance(on(settings, tmp_path, "boom", exit_code=1), "code", "publish hello on /chatter every second")
64
+ assert not e.used and e.text == "publish hello on /chatter every second"
65
+ e = enhance(replace(settings, prompt_enhance=True, opencode_bin="no-such-opencode"), "code", "x" * 20)
66
+ assert not e.used and "not installed" in e.note
67
+ assert not enhance(settings, "code", "x" * 20).used # PROMPT_ENHANCE=false
68
+
69
+
70
+ def test_api_generates_from_the_brief_and_does_not_bill_it(settings, store):
71
+ key = keys.add(settings.testers_file, "alice")
72
+ llm = ScriptedLLM([f"```xml\n{GOOD_SCENE}\n```"])
73
+ seen = []
74
+
75
+ def fake_enhancer(settings, kind, text, **ctx):
76
+ seen.append((kind, text, ctx))
77
+ brief = text + " The slope is 2 m ahead; the crate weighs 5 kg."
78
+ return Enhanced(text, brief, True, "opencode/deepseek-v4-flash-free", usage={"input_tokens": 300})
79
+
80
+ services = Services(
81
+ settings=settings,
82
+ store=store,
83
+ logs=LogStore(settings.log_db),
84
+ allowlist=Allowlist(settings.testers_file),
85
+ llm_factory=lambda: llm,
86
+ enhancer=fake_enhancer,
87
+ )
88
+ client = TestClient(create_app(services))
89
+ r = client.post("/simulate/generate", json={"description": "G1 beside a 15 degree slope and a crate"},
90
+ headers={"X-API-Key": key}) # fmt: skip
91
+ assert r.status_code == 200, r.text
92
+ body = r.json()
93
+ assert seen == [("scene", "G1 beside a 15 degree slope and a crate", {"robot": "unitree_g1"})]
94
+ assert body["enhanced_prompt"]["used"] and "crate weighs 5 kg" in body["enhanced_prompt"]["text"]
95
+ assert "crate weighs 5 kg" in llm.calls[0][-1].content # the generator got the brief
96
+ row = services.logs.get(body["id"])
97
+ assert row["request"]["description"] == "G1 beside a 15 degree slope and a crate" # original kept
98
+ assert row["request"]["enhanced"]["used"]
99
+ zen = [u for u in services.logs.usage_rows() if u["provider"] == "opencode-zen"]
100
+ assert len(zen) == 1 and zen[0]["billable"] == 0 and zen[0]["exempt_reason"] == "opencode_zen_free"
101
+ ai = budget.summary(services.logs, settings, "alice")
102
+ assert ai["exempt_calls"] == 1 and ai["used"] == body["cost"]["inr"] # only the OpenRouter call is billed
103
+ # opting out skips it
104
+ llm.responses.append(f"```xml\n{GOOD_SCENE}\n```")
105
+ r = client.post("/simulate/generate", json={"description": "G1 beside a 15 degree slope", "enhance": False},
106
+ headers={"X-API-Key": key}) # fmt: skip
107
+ assert r.json()["enhanced_prompt"]["used"] is False and len(seen) == 1
tests/test_jobs.py CHANGED
@@ -5,7 +5,7 @@ import pytest
5
  from conftest import complete
6
 
7
  from api import jobs
8
- from api.jobs import JobError, job_cost, month_spend
9
 
10
 
11
  def start_run(client, key, gen_id):
@@ -27,20 +27,38 @@ def test_cost_is_recorded_when_a_job_ends(run_env):
27
 
28
  def test_monthly_limit_refuses_new_jobs(run_env):
29
  client, keys_, backend, services, gen_id = run_env
30
- services.settings = replace(services.settings, monthly_credit_usd=0.05)
 
31
  first = start_run(client, keys_["alice"], gen_id)
 
 
 
32
  complete(backend, "job-1", first)
33
- client.get(f"/jobs/{first}", headers={"X-API-Key": keys_["alice"]}) # records ~$0.018
34
  assert month_spend(services.settings, services.logs) < 0.05
 
35
  services.logs.update_run(first, cost_usd=0.06)
36
- r = client.post("/simulate/run", json={"generation_id": gen_id}, headers={"X-API-Key": keys_["bob"]})
37
- assert r.status_code == 404 # bob cannot use alice's generation; the limit applies to everyone below
 
 
 
 
 
 
 
 
 
 
38
  r = client.post("/simulate/run", json={"generation_id": gen_id}, headers={"X-API-Key": keys_["alice"]})
39
- assert r.status_code == 402 and "budget is used up" in r.json()["detail"]
40
  assert len(backend.submitted) == 1
41
- body = client.get("/jobs", headers={"X-API-Key": keys_["alice"]}).json()
42
- assert body["budget"] == {"month_spend_usd_estimate": 0.06, "monthly_limit_usd": 0.05}
43
- assert [j["id"] for j in body["jobs"]] == [first]
 
 
 
44
 
45
 
46
  def test_running_jobs_count_toward_the_budget(run_env):
 
5
  from conftest import complete
6
 
7
  from api import jobs
8
+ from api.jobs import JobError, job_cost, month_spend, worst_cost
9
 
10
 
11
  def start_run(client, key, gen_id):
 
27
 
28
  def test_monthly_limit_refuses_new_jobs(run_env):
29
  client, keys_, backend, services, gen_id = run_env
30
+ worst = worst_cost(services.settings, "sim", "runpod" if backend.name == "runpod" else None)
31
+ services.settings = replace(services.settings, monthly_credit_usd=worst + 0.05, compute_testers=1)
32
  first = start_run(client, keys_["alice"], gen_id)
33
+ # while it runs, the first job holds its worst case: a second one would not fit
34
+ r = client.post("/simulate/run", json={"generation_id": gen_id}, headers={"X-API-Key": keys_["alice"]})
35
+ assert r.status_code == 402 and "budget for this month is used up" in r.json()["detail"]
36
  complete(backend, "job-1", first)
37
+ client.get(f"/jobs/{first}", headers={"X-API-Key": keys_["alice"]}) # records ~$0.02: room again
38
  assert month_spend(services.settings, services.logs) < 0.05
39
+ start_run(client, keys_["alice"], gen_id)
40
  services.logs.update_run(first, cost_usd=0.06)
41
+ body = client.get("/jobs", headers={"X-API-Key": keys_["alice"]}).json()
42
+ assert body["budget"]["monthly_limit_usd"] == pytest.approx(worst + 0.05)
43
+ assert len(backend.submitted) == 2
44
+
45
+
46
+ def test_each_tester_gets_an_equal_strict_share(run_env):
47
+ client, keys_, backend, services, gen_id = run_env
48
+ worst = worst_cost(services.settings, "sim", "runpod" if backend.name == "runpod" else None)
49
+ # 4 testers share 4 * (1.5 jobs): each can have one job in flight, never two
50
+ services.settings = replace(services.settings, monthly_credit_usd=4 * 1.5 * worst, compute_testers=4)
51
+ assert services.settings.compute_share_usd == pytest.approx(1.5 * worst)
52
+ first = start_run(client, keys_["alice"], gen_id)
53
  r = client.post("/simulate/run", json={"generation_id": gen_id}, headers={"X-API-Key": keys_["alice"]})
54
+ assert r.status_code == 402 and "your GPU budget" in r.json()["detail"]
55
  assert len(backend.submitted) == 1
56
+ usage = client.get("/me/usage", headers={"X-API-Key": keys_["alice"]}).json()["compute"]
57
+ assert usage["used"] == pytest.approx(worst, abs=1e-3) and usage["share"] == pytest.approx(1.5 * worst)
58
+ complete(backend, "job-1", first)
59
+ client.get(f"/jobs/{first}", headers={"X-API-Key": keys_["alice"]})
60
+ start_run(client, keys_["alice"], gen_id) # the finished job now counts at its real (small) cost
61
+ assert len(backend.submitted) == 2
62
 
63
 
64
  def test_running_jobs_count_toward_the_budget(run_env):