.env.example DELETED
@@ -1,58 +0,0 @@
1
- # Reachy Mini Website server env vars
2
- #
3
- # Copy this file to `.env` and fill in the values for local dev.
4
- # In production (HF Space), set these from the Space's "Settings →
5
- # Variables and secrets" panel, NOT from a committed `.env`.
6
- # (`.env` is gitignored.)
7
-
8
- # -----------------------------------------------------------------------------
9
- # Server
10
- # -----------------------------------------------------------------------------
11
- # Port the Express server listens on. Defaults to 7860 (HF Space convention).
12
- # PORT=7860
13
-
14
- # -----------------------------------------------------------------------------
15
- # OAuth (used by /api/oauth-config and the in-iframe sign-in flow)
16
- # -----------------------------------------------------------------------------
17
- # Set in the Space when `hf_oauth: true` is in README.md.
18
- # OAUTH_CLIENT_ID=
19
- # OAUTH_SCOPES=openid profile
20
-
21
- # -----------------------------------------------------------------------------
22
- # HF Inference Providers (used by /api/js-apps category inference)
23
- # -----------------------------------------------------------------------------
24
- # Required for category inference. A standard READ token is enough -
25
- # Inference Providers access is on by default for FREE/PRO tokens.
26
- # Without this, /api/js-apps still works but every entry will have
27
- # `categories: null` (the route logs a warning at startup).
28
- HF_TOKEN=
29
-
30
- # Dataset where the inferred-categories cache is persisted.
31
- # Defaults to `tfrere/reachy-mini-app-categories` (per-user namespace,
32
- # auto-created on first commit). Override to e.g.
33
- # `pollen-robotics/reachy-mini-app-categories` once the org dataset
34
- # exists and the HF_TOKEN has write access to it.
35
- # HF_CATEGORIES_DATASET=tfrere/reachy-mini-app-categories
36
-
37
- # -----------------------------------------------------------------------------
38
- # OpenAI Realtime ephemeral keys (used by /api/openai/ephemeral)
39
- # -----------------------------------------------------------------------------
40
- # Master OpenAI API key. Used SERVER-SIDE only to mint short-lived
41
- # (~1 minute) `client_secret.value` tokens for the Reachy Mini mobile
42
- # shell's voice conversation feature. NEVER expose this to a client
43
- # bundle. The mobile client never sees this value: it posts its HF
44
- # Bearer token, gets back an ephemeral session key scoped to a single
45
- # OpenAI Realtime session.
46
- OPENAI_API_KEY=
47
-
48
- # Optional: pin the Realtime model id. Defaults to
49
- # `gpt-4o-realtime-preview-2024-12-17`. Bumping this requires
50
- # coordinating with the mobile shell, which is built against a
51
- # specific protocol version.
52
- # OPENAI_REALTIME_MODEL=gpt-4o-realtime-preview-2024-12-17
53
-
54
- # Optional: max ephemeral mints per HF user per rolling hour.
55
- # Defaults to 60. The in-memory sliding-window limiter resets on
56
- # Space restart; v1 takes the trade-off, replace with a shared KV
57
- # the day we need multi-replica fairness.
58
- # OPENAI_EPHEMERAL_RATE_LIMIT_PER_HOUR=60
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
.gitignore CHANGED
@@ -22,5 +22,3 @@ dist-ssr
22
  *.njsproj
23
  *.sln
24
  *.sw?
25
-
26
- .env
 
22
  *.njsproj
23
  *.sln
24
  *.sw?
 
 
docs/APP_ICON_CONVENTION.md DELETED
@@ -1,113 +0,0 @@
1
- # App icon convention
2
-
3
- > Status: convention v1
4
- > Audience: authors shipping a Reachy Mini app to the Hugging Face Hub
5
- > Implemented by: `reachy-mini-website` catalog server (this repo) +
6
- > `reachy_mini_mobile_app`, `reachy_mini_desktop_app`
7
- > Source of truth: `server/index.js` → `findIconUrl()`
8
-
9
- This document specifies how a Reachy Mini app declares a custom icon.
10
- Apps that don't follow it keep working - the surface falls back to the
11
- front-matter `emoji:` glyph, which is the existing behaviour.
12
-
13
- ---
14
-
15
- ## 1. The convention in three lines
16
-
17
- To ship a custom icon for your Reachy Mini app:
18
-
19
- 1. Commit `icon.svg` (preferred) **or** `icon.png` at the root of your
20
- Hugging Face Space repository.
21
- 2. That's it. Within ~5 minutes (the catalog cache TTL) the mobile
22
- shell, the desktop app and the website surface your icon
23
- automatically, replacing the README front-matter emoji.
24
- 3. If both files are present, `icon.svg` wins.
25
-
26
- No README change required. No tag to add. No PR to file against this
27
- repo. The catalog server scans the file list once per refresh and
28
- publishes a resolved URL on the app entry; every client consumes it.
29
-
30
- ---
31
-
32
- ## 2. Why a file convention and not `cardData.thumbnail`
33
-
34
- HF Spaces support a `thumbnail:` field in README front-matter, but:
35
-
36
- - `thumbnail` is full-bleed marketing artwork (typically 1200x630),
37
- not a square avatar. Scaling it to a 22 px or 44 px tile produces
38
- muddy thumbnails.
39
- - We want app authors to ship a dedicated, optimised glyph they
40
- control without learning the HF metadata schema.
41
- - SVG support means the icon scales cleanly across every mount point
42
- (rail tile, pinned grid, iframe header) from a single asset.
43
-
44
- `thumbnail:` keeps its existing role (banner artwork on the Space's
45
- HF page) and is not consulted by this resolution path.
46
-
47
- ---
48
-
49
- ## 3. Format & dimension recommendations
50
-
51
- | Property | Recommended | Hard requirement |
52
- |----------|-------------|------------------|
53
- | Format | `icon.svg` (vector) | `icon.svg` or `icon.png` |
54
- | Aspect ratio | 1:1 (square) | Renderers crop with `object-fit: contain`, but non-square icons render with letterboxing - prefer a true square |
55
- | Min PNG size | 256x256 | None enforced. PNGs below 64x64 will look soft on the pinned grid (44 px on retina ≈ 88 effective px) |
56
- | Background | Transparent OR solid colour | None - your call. Renderers don't add their own plate, so an icon with no background renders directly on the tile colour |
57
- | Padding | Bake ~10% inner padding into the asset | None - but icons that bleed edge-to-edge will touch the tile's rounded corners |
58
- | Light/dark variants | Single asset that works on both | None - if you must, ship two SVGs and use `prefers-color-scheme` inside the SVG via CSS |
59
-
60
- ### Style notes
61
-
62
- - **Iconic, not photographic.** A solid filled silhouette reads at
63
- 22 px; a screenshot doesn't.
64
- - **High contrast against `background.paper`.** The mobile app paints
65
- the tile background with the surface colour (very light grey on
66
- light, near-black on dark). A pure white icon disappears on light.
67
- - **No drop shadow** baked into the asset. The renderer doesn't add
68
- one either, and a baked shadow won't scale across sizes.
69
-
70
- ---
71
-
72
- ## 4. How resolution works (for the curious)
73
-
74
- 1. The catalog server calls
75
- `https://huggingface.co/api/spaces?filter=reachy_mini&full=true`.
76
- With `full=true`, the HF Hub returns `siblings: [{ rfilename: ... }]`
77
- for every Space - the complete file list.
78
- 2. For each app, `findIconUrl()` (in `server/index.js`) scans the
79
- list for root-level filenames matching `ICON_CANDIDATES` in order
80
- (`icon.svg` → `icon.png`).
81
- 3. The first match becomes:
82
-
83
- ```
84
- https://huggingface.co/spaces/<author>/<repo>/resolve/main/<filename>
85
- ```
86
-
87
- `resolve/main/` (not `raw/main/`) so LFS pointers follow through
88
- transparently and the `Content-Type` is set from the extension,
89
- which `<img>` needs.
90
- 4. The URL is published on the app entry as a top-level `iconUrl`
91
- field. `null` when neither candidate exists.
92
- 5. Clients (`reachy_mini_mobile_app`, `reachy_mini_desktop_app`) read
93
- `iconUrl` and render an `<img>` when present, falling back to the
94
- front-matter emoji otherwise. A runtime image load failure
95
- re-falls-back to the emoji without a refresh.
96
-
97
- The whole resolution path is server-side, behind the 5-minute catalog
98
- cache. Adding 100 more apps adds zero per-client probes.
99
-
100
- ---
101
-
102
- ## 5. Adding new icon formats
103
-
104
- If you need to support a new format (say, `icon.webp`), edit
105
- `ICON_CANDIDATES` in `server/index.js`:
106
-
107
- ```js
108
- const ICON_CANDIDATES = ['icon.svg', 'icon.png', 'icon.webp'];
109
- ```
110
-
111
- Order matters - the first hit wins, so put the preferred format first.
112
- Bumping the catalog cache (POST `/api/js-apps/refresh-categories` or
113
- just wait 5 minutes) picks up the new resolution rule.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
package.json CHANGED
@@ -18,7 +18,6 @@
18
  "@mui/icons-material": "^7.3.6",
19
  "@mui/material": "^7.3.6",
20
  "@react-spring/web": "^10.0.3",
21
- "compression": "^1.8.1",
22
  "express": "^4.21.2",
23
  "framer-motion": "^12.23.26",
24
  "fuse.js": "^7.1.0",
 
18
  "@mui/icons-material": "^7.3.6",
19
  "@mui/material": "^7.3.6",
20
  "@react-spring/web": "^10.0.3",
 
21
  "express": "^4.21.2",
22
  "framer-motion": "^12.23.26",
23
  "fuse.js": "^7.1.0",
scripts/evaluate-prompt-v2.py DELETED
@@ -1,445 +0,0 @@
1
- #!/usr/bin/env python3
2
- """
3
- Prompt-v2 evaluation harness.
4
-
5
- Re-runs the LLM categorization on every JS app currently served by
6
- /api/js-apps with a tightened prompt, and prints a side-by-side
7
- diff against the live (v1) classifications.
8
-
9
- This file lives outside the server runtime - it never gets pushed
10
- to the Space. It's only meant to be hand-iterated until the diff
11
- looks right, then the chosen prompt is ported into server/categorize.js
12
- and server/categories.js.
13
-
14
- Run:
15
- python3 scripts/evaluate-prompt-v2.py
16
- """
17
- from __future__ import annotations
18
-
19
- import json
20
- import os
21
- import re
22
- import ssl
23
- import sys
24
- import time
25
- import urllib.error
26
- import urllib.request
27
- from pathlib import Path
28
- from typing import Any
29
-
30
- # Python 3.14 on macOS ships without the system CA bundle wired into
31
- # urllib by default - HF endpoints fail with CERTIFICATE_VERIFY_FAILED.
32
- # This script is dev-local only and only talks to huggingface.co, so
33
- # bypassing verification here is acceptable (would NEVER do this in
34
- # the server runtime).
35
- _SSL_CTX = ssl._create_unverified_context() # noqa: S323
36
-
37
- HF_INFERENCE_URL = "https://router.huggingface.co/v1/chat/completions"
38
- MODEL = "meta-llama/Llama-3.1-8B-Instruct"
39
- TEMPERATURE = 0
40
- MAX_TOKENS = 120
41
-
42
- README_MAX_CHARS = 3000
43
- MAX_CATEGORIES_PER_APP = 3
44
-
45
- JS_APPS_URL = "https://pollen-robotics-reachy-mini.hf.space/api/js-apps"
46
-
47
-
48
- # ──────────────────────────────────────────────────────────────────────
49
- # Taxonomy v2 - 9 slugs (added "games")
50
- # ──────────────────────────────────────────────────────────────────────
51
-
52
- CATEGORIES_V2: list[tuple[str, str]] = [
53
- (
54
- "music",
55
- "Music creation, playback, beats, songs, DJ mixing, instruments, "
56
- "blind-test music games. Requires actual music (rhythm/melody/song). "
57
- "NOT arbitrary audio (Morse code, alarms, TTS, sound effects).",
58
- ),
59
- (
60
- "dance",
61
- "Dance choreographies, motion replay, kinetic shows, "
62
- "recording/replaying robot movements, dance parties.",
63
- ),
64
- (
65
- "voice",
66
- "Reachy talks, listens, or holds a real-time voice conversation: "
67
- "TTS players, LLM-driven chat (OpenAI Realtime, Claude, Perplexity), "
68
- "wake-word demos, daily reports/news/weather read aloud.",
69
- ),
70
- (
71
- "storytelling",
72
- "Narrative stories WITH plot and characters: interactive fiction, "
73
- "bedtime tales, audio adventures, choose-your-own-adventure. "
74
- "NOT for daily reports, news, weather, or Q&A (use `voice`).",
75
- ),
76
- (
77
- "kids",
78
- "Apps that EXPLICITLY target children: the words kids / children / "
79
- "'for curious minds' / bedtime / 'learning for kids' must appear in "
80
- "the name or description, OR the app must be obviously kid-targeted. "
81
- "Combines with `storytelling`, `voice`, or `games`. Lifestyle, "
82
- "sports, weather, general conversation are NOT kids.",
83
- ),
84
- (
85
- "games",
86
- "Apps with a play loop: scores, rounds, win/lose conditions, "
87
- "quizzes, puzzles, sports simulations, dice/oracles (magic 8-ball), "
88
- "arcade-style mini-games.",
89
- ),
90
- (
91
- "vision",
92
- "Apps where Reachy's camera DRIVES behaviour: face/hand/pose "
93
- "tracking, image classification, gesture detection, visual mimicry. "
94
- "NOT for apps that merely stream or display the camera feed.",
95
- ),
96
- (
97
- "companion",
98
- "Apps with an EXPLICIT emotional/personality/buddy framing in the "
99
- "name or description (words like companion, buddy, mood, emotional, "
100
- "personality, pet, Tamagotchi). Being friendly is not enough.",
101
- ),
102
- (
103
- "dev-tools",
104
- "RESERVED slug — see DECISION ALGORITHM step 1 below. Use ONLY "
105
- "for pure technical artefacts (debug utilities, SDK probes, "
106
- "minimal protocol demos, dev-only test spaces) with no end-user "
107
- "experience. When used, it is the SOLE category — never combined.",
108
- ),
109
- ]
110
-
111
- ALLOWED = {slug for slug, _ in CATEGORIES_V2}
112
-
113
-
114
- # ──────────────────────────────────────────────────────────────────────
115
- # Few-shot examples - cover the main pitfalls of v1
116
- # ──────────────────────────────────────────────────────────────────────
117
-
118
- FEW_SHOT = [
119
- (
120
- "Reachy Morse",
121
- "Send Morse code through Reachy's speaker.",
122
- ["dev-tools"],
123
- "(STEP 1 veto: pure technical artefact. NOT music.)",
124
- ),
125
- (
126
- "WebRTC Demo",
127
- "Minimal WebRTC connection between Reachy and the browser.",
128
- ["dev-tools"],
129
- "(STEP 1 veto: protocol demo. NOT vision.)",
130
- ),
131
- (
132
- "TTS Reachy Mini",
133
- "Browser TTS that plays out of Reachy Mini's speaker.",
134
- ["voice"],
135
- "(USER-FACING speech output is voice, NOT dev-tools.)",
136
- ),
137
- (
138
- "Reachy Mochi - Emotional Companion",
139
- "Your pocket buddy that develops a mood and personality over time.",
140
- ["companion"],
141
- "(explicit emotional/companion framing)",
142
- ),
143
- (
144
- "Reachy Alive",
145
- "(README empty; name suggests autonomy and life-like presence)",
146
- ["companion"],
147
- "(USE THE NAME when the README is empty; 'alive' = companion-like)",
148
- ),
149
- (
150
- "Daily Surf Report",
151
- "Reachy reads today's surf report out loud.",
152
- ["voice"],
153
- "(NOT storytelling — a report has no narrative arc. "
154
- "NOT kids — surfing/sports are not kid-targeted.)",
155
- ),
156
- (
157
- "Music Quiz",
158
- "Play a blind test music game with a dancing Reachy.",
159
- ["music", "games", "dance"],
160
- "(multi-label: three slugs truly co-apply, ordered by relevance)",
161
- ),
162
- (
163
- "Mime Bot",
164
- "Reachy mimics your face live from your webcam.",
165
- ["vision"],
166
- "(NOT companion — mimicry is visual, no emotional framing.)",
167
- ),
168
- ]
169
-
170
-
171
- def build_system_prompt() -> str:
172
- taxonomy = "\n".join(f"- {slug}: {desc}" for slug, desc in CATEGORIES_V2)
173
- examples = "\n".join(
174
- f" - {name!r}: {desc!r}\n"
175
- f" → {{\"categories\": {json.dumps(cats)}}} {hint}"
176
- for name, desc, cats, hint in FEW_SHOT
177
- )
178
- return f"""You classify a Reachy Mini robot app into a CLOSED list of categories.
179
-
180
- OUTPUT FORMAT
181
- Return ONLY a single JSON object: {{"categories": ["slug1", "slug2"]}}.
182
- Pick 1 to {MAX_CATEGORIES_PER_APP} slugs, ordered from most to least relevant.
183
- Use the EXACT slug. No prose, no code fences, no commentary outside the JSON.
184
-
185
- DECISION ALGORITHM (apply in order)
186
-
187
- STEP 1 — `dev-tools` veto
188
- Is this app a PURE technical artefact with no user-facing experience
189
- beyond "here is how the SDK / API works"?
190
- Examples that pass the veto: WebRTC demo, SDK probe, debug utility,
191
- raw remote-control interface, dev-only test space.
192
- Examples that DO NOT pass the veto (they are user-facing apps):
193
- TTS players, voice chat, music apps, storytelling, companions —
194
- even when the README is dev-heavy.
195
- ─ YES → return {{"categories": ["dev-tools"]}} and STOP. Never combine.
196
- ─ NO → continue to STEP 2.
197
-
198
- STEP 2 — Pick 1 to {MAX_CATEGORIES_PER_APP} user-facing slugs from the
199
- list below. Choose the MOST SPECIFIC categories. Order from most to
200
- least relevant. Multi-label is encouraged when two categories truly
201
- co-apply (e.g. music-and-dance, kids storytelling, vision game).
202
- If the README is empty or very sparse, USE THE NAME AND DESCRIPTION
203
- as the primary signal — do not bail to an empty list just because the
204
- README is thin.
205
-
206
- STEP 3 — Strict slug rules (each must hold, or DO NOT use the slug)
207
- - `companion`: requires EXPLICIT emotional / personality / buddy framing
208
- (companion, buddy, friend, mood, emotional, personality, pet,
209
- Tamagotchi-like, "alive", "life companion"). Being friendly is not
210
- enough.
211
- - `music`: requires actual music — rhythm, melody, songs, beats, DJ
212
- sets, instruments, music quizzes. Arbitrary audio (Morse, alarms,
213
- TTS, sound effects) is NOT music.
214
- - `vision`: requires the camera to DRIVE behaviour (tracking,
215
- classification, mimicry). Merely streaming or displaying the camera
216
- (WebRTC demos, remote-control viewers) is NOT vision.
217
- - `storytelling`: requires a narrative ARC — plot, characters, scenes.
218
- Daily reports, news, weather, Q&A are NOT storytelling (they are
219
- `voice`).
220
- - `games`: requires a play loop — score, rounds, win/lose, puzzles,
221
- quizzes, dice/oracles, sports simulations.
222
- - `kids`: requires kid-targeted framing (kids/children/curious minds/
223
- bedtime/learning for kids) in the name or description. Lifestyle,
224
- sports, weather, general conversation are NOT kids.
225
-
226
- AVAILABLE CATEGORIES
227
- {taxonomy}
228
-
229
- REFERENCE EXAMPLES
230
- {examples}
231
-
232
- Do not include any text outside the JSON object."""
233
-
234
-
235
- def build_user_prompt(name: str, description: str, readme: str) -> str:
236
- return (
237
- f"App name: {name or '(unknown)'}\n"
238
- f"Short description: {description or '(none)'}\n\n"
239
- f"README excerpt:\n{readme or '(no README available)'}\n\n"
240
- f"Return the JSON now."
241
- )
242
-
243
-
244
- # ──────────────────────────────────────────────────────────────────────
245
- # README fetch + clean (mirrors server/categorize.js)
246
- # ─────────────────────────────────────────────────────────────��────────
247
-
248
- def fetch_readme(space_id: str) -> str:
249
- url = f"https://huggingface.co/spaces/{space_id}/raw/main/README.md"
250
- try:
251
- with urllib.request.urlopen(url, timeout=10, context=_SSL_CTX) as r:
252
- return r.read().decode("utf-8", errors="replace")
253
- except (urllib.error.URLError, urllib.error.HTTPError, TimeoutError):
254
- return ""
255
-
256
-
257
- def clean_readme(raw: str) -> str:
258
- if not raw:
259
- return ""
260
- txt = raw
261
- txt = re.sub(r"^---\n[\s\S]*?\n---\n?", "", txt)
262
- txt = re.sub(r"!\[[^\]]*\]\([^)]+\)", "", txt)
263
- txt = re.sub(r"<img\b[^>]*>", "", txt, flags=re.IGNORECASE)
264
- txt = re.sub(r"\[!\[[^\]]*\]\([^)]+\)\]\([^)]+\)", "", txt)
265
- txt = re.sub(r"</?[a-zA-Z][^>]*>", "", txt)
266
- txt = re.sub(r"\n{3,}", "\n\n", txt)
267
- if len(txt) > README_MAX_CHARS:
268
- cut = txt.rfind("\n\n", 0, README_MAX_CHARS)
269
- if cut > README_MAX_CHARS // 2:
270
- txt = txt[:cut]
271
- else:
272
- txt = txt[:README_MAX_CHARS]
273
- return txt.strip()
274
-
275
-
276
- # ──────────────────────────────────────────────────────────────────────
277
- # LLM call
278
- # ──────────────────────────────────────────────────────────────────────
279
-
280
- def call_llm(hf_token: str, system: str, user: str) -> str | None:
281
- body = json.dumps(
282
- {
283
- "model": MODEL,
284
- "messages": [
285
- {"role": "system", "content": system},
286
- {"role": "user", "content": user},
287
- ],
288
- "temperature": TEMPERATURE,
289
- "max_tokens": MAX_TOKENS,
290
- "response_format": {"type": "json_object"},
291
- }
292
- ).encode("utf-8")
293
- req = urllib.request.Request(
294
- HF_INFERENCE_URL,
295
- data=body,
296
- headers={
297
- "Authorization": f"Bearer {hf_token}",
298
- "Content-Type": "application/json",
299
- # Cloudflare in front of the router 403s the default
300
- # "Python-urllib/x.y" UA. Any reasonable UA passes.
301
- "User-Agent": "reachy-mini-prompt-eval/1.0",
302
- },
303
- method="POST",
304
- )
305
- try:
306
- with urllib.request.urlopen(req, timeout=30, context=_SSL_CTX) as r:
307
- data = json.loads(r.read().decode("utf-8"))
308
- return data.get("choices", [{}])[0].get("message", {}).get("content")
309
- except urllib.error.HTTPError as e:
310
- detail = e.read().decode("utf-8", errors="replace")[:200]
311
- print(f" ✗ LLM HTTP {e.code}: {detail}", file=sys.stderr)
312
- return None
313
- except Exception as e: # noqa: BLE001
314
- print(f" ✗ LLM error: {e}", file=sys.stderr)
315
- return None
316
-
317
-
318
- def extract_json_obj(text: str) -> dict[str, Any] | None:
319
- if not text:
320
- return None
321
- start = text.find("{")
322
- if start == -1:
323
- return None
324
- depth = 0
325
- for i in range(start, len(text)):
326
- c = text[i]
327
- if c == "{":
328
- depth += 1
329
- elif c == "}":
330
- depth -= 1
331
- if depth == 0:
332
- try:
333
- return json.loads(text[start : i + 1])
334
- except json.JSONDecodeError:
335
- return None
336
- return None
337
-
338
-
339
- def sanitize(raw: Any) -> list[str]:
340
- if not isinstance(raw, list):
341
- return []
342
- out: list[str] = []
343
- seen: set[str] = set()
344
- for v in raw:
345
- if not isinstance(v, str):
346
- continue
347
- slug = v.strip().lower()
348
- if not slug or slug in seen or slug not in ALLOWED:
349
- continue
350
- seen.add(slug)
351
- out.append(slug)
352
- if len(out) >= MAX_CATEGORIES_PER_APP:
353
- break
354
- return out
355
-
356
-
357
- # ──────────────────────────────────────────────────────────────────────
358
- # Main
359
- # ──────────────────────────────────────────────────────────────────────
360
-
361
- def read_hf_token() -> str:
362
- if os.environ.get("HF_TOKEN"):
363
- return os.environ["HF_TOKEN"]
364
- env_file = Path(__file__).resolve().parent.parent / ".env"
365
- if env_file.exists():
366
- for line in env_file.read_text().splitlines():
367
- m = re.match(r"^\s*HF_TOKEN\s*=\s*(.*?)\s*$", line)
368
- if m:
369
- v = m.group(1).strip().strip('"').strip("'")
370
- if v:
371
- return v
372
- raise SystemExit("HF_TOKEN not found in env or .env")
373
-
374
-
375
- def fetch_live_classifications() -> list[dict[str, Any]]:
376
- with urllib.request.urlopen(JS_APPS_URL, timeout=30, context=_SSL_CTX) as r:
377
- return json.load(r)["apps"]
378
-
379
-
380
- def main() -> int:
381
- hf_token = read_hf_token()
382
- apps = fetch_live_classifications()
383
- print(f"Loaded {len(apps)} JS apps from prod.\n")
384
-
385
- system = build_system_prompt()
386
- print(f"System prompt: {len(system)} chars, {system.count(chr(10))} lines.\n")
387
-
388
- results: list[dict[str, Any]] = []
389
-
390
- for i, app in enumerate(apps, 1):
391
- sid = app["id"]
392
- name = app.get("name") or sid.split("/")[-1]
393
- desc = (
394
- app.get("description")
395
- or (app.get("extra") or {}).get("cardData", {}).get("short_description")
396
- or ""
397
- )
398
- old_cats = app.get("categories") or []
399
-
400
- raw_readme = fetch_readme(sid)
401
- readme = clean_readme(raw_readme)
402
- user = build_user_prompt(name, desc, readme)
403
-
404
- reply = call_llm(hf_token, system, user)
405
- new_cats = sanitize((extract_json_obj(reply) or {}).get("categories"))
406
-
407
- changed = set(old_cats) != set(new_cats)
408
- marker = "Δ" if changed else " "
409
- print(
410
- f" {marker} ({i:>2}/{len(apps)}) {name[:36]:<37} "
411
- f"old=[{', '.join(old_cats)}]"
412
- + (f" → new=[{', '.join(new_cats)}]" if changed else "")
413
- )
414
-
415
- results.append(
416
- {
417
- "id": sid,
418
- "name": name,
419
- "old": old_cats,
420
- "new": new_cats,
421
- "changed": changed,
422
- }
423
- )
424
- time.sleep(0.25)
425
-
426
- print()
427
- print("─" * 80)
428
- print("DIFF (only changed entries)")
429
- print("─" * 80)
430
- for r in results:
431
- if not r["changed"]:
432
- continue
433
- print(
434
- f" {r['name'][:38]:<40} "
435
- f"[{', '.join(r['old']) or '∅'}] → [{', '.join(r['new']) or '∅'}]"
436
- )
437
-
438
- changed_count = sum(1 for r in results if r["changed"])
439
- print()
440
- print(f"{changed_count}/{len(results)} entries changed.")
441
- return 0
442
-
443
-
444
- if __name__ == "__main__":
445
- sys.exit(main())
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
server/categories.js DELETED
@@ -1,212 +0,0 @@
1
- /**
2
- * Predefined taxonomy for JS Reachy Mini apps.
3
- *
4
- * These slugs are the ONLY valid output values for the LLM
5
- * inference step (anything else is dropped at parse time) and
6
- * the values consumers (mobile shell, website) filter on.
7
- *
8
- * Why a closed list instead of free-form tags
9
- * ──────────────────────────────────────────
10
- * The HF Spaces catalog has no usable categorization for the
11
- * reachy_mini_js_app subset (only platform/SDK tags). We bridge
12
- * the gap by inferring categories with an LLM, but we have to
13
- * constrain the model's output: a closed list keeps category
14
- * pages stable, lets us pre-pick emojis/labels, and avoids the
15
- * "30 near-duplicate slugs" problem you'd get with free-form.
16
- *
17
- * Bumping the taxonomy
18
- * ────────────────────
19
- * Adding, removing or renaming a slug changes the meaning of
20
- * cached entries. Bump TAXONOMY_VERSION when you do that: the
21
- * cache layer compares each entry's `taxonomyVersion` against
22
- * the live one and recomputes stale ones on the next pass.
23
- */
24
-
25
- /**
26
- * Bump this when the slug list OR the descriptions change in a way
27
- * that affects the LLM output. The cache layer invalidates entries
28
- * whose taxonomyVersion is older than this and reclassifies them on
29
- * the next pass. We don't bump it for cosmetic edits (label / emoji)
30
- * since those don't reach the LLM.
31
- *
32
- * History:
33
- * - v1: initial 8-slug taxonomy.
34
- * - v2: added `games`, tightened `kids` + `dev-tools` descriptions,
35
- * switched the prompt to a DECISION ALGORITHM with few-shot.
36
- * - v3: switched from multi-label (up to 3 slugs) to single-label
37
- * (exactly 1 slug). Each app surfaces in exactly one category
38
- * section on the mobile shell - no duplicates across swipers.
39
- * - v4: renamed `dance` to `motion` (broader: marionette, replay,
40
- * choreography without music). Music-driven dance parties
41
- * now belong to `music` since music is what drives them.
42
- */
43
- export const TAXONOMY_VERSION = 4;
44
-
45
- /**
46
- * Canonical category list. Keep slugs short, kebab-case, and
47
- * memorable: they end up in URLs (e.g. `?cat=music`) and in
48
- * filter chips on mobile.
49
- *
50
- * The `description` field is the SOLE source of truth the LLM
51
- * sees - keep them factual, scope-bounded, and example-led so
52
- * the model has signal for both inclusion and exclusion.
53
- */
54
- export const CATEGORIES = [
55
- {
56
- slug: 'music',
57
- label: 'Music & Beats',
58
- emoji: '🎵',
59
- description:
60
- 'Music creation, playback, beats, songs, DJ mixing, instruments, ' +
61
- 'blind-test music games, AND music-driven dance parties (Reachy ' +
62
- 'dances to a song). Requires actual music (rhythm / melody / song). ' +
63
- 'Arbitrary audio (Morse code, alarms, TTS, sound effects) is NOT ' +
64
- 'music. Pure choreography without music belongs to `motion`.',
65
- },
66
- {
67
- slug: 'motion',
68
- label: 'Motion & Movement',
69
- emoji: '🦾',
70
- description:
71
- "Apps that drive Reachy's physical movement on its own: motion " +
72
- 'replay, marionette-style remote control of the body, kinetic ' +
73
- 'shows, choreographies WITHOUT music, expressive body language. ' +
74
- 'If the movement is synced to music, use `music` instead.',
75
- },
76
- {
77
- slug: 'voice',
78
- label: 'Voice & Conversation',
79
- emoji: '🗣️',
80
- description:
81
- 'Reachy talks, listens, or holds a real-time voice ' +
82
- 'conversation: TTS players, LLM-driven chat (OpenAI Realtime, ' +
83
- 'Claude, Perplexity), wake-word demos, daily reports / news / ' +
84
- 'weather read aloud.',
85
- },
86
- {
87
- slug: 'storytelling',
88
- label: 'Stories',
89
- emoji: '📖',
90
- description:
91
- 'Narrative stories WITH plot and characters: interactive ' +
92
- 'fiction, bedtime tales, audio adventures, choose-your-own-' +
93
- 'adventure. NOT for daily reports, news, weather, or Q&A ' +
94
- '(those are `voice`).',
95
- },
96
- {
97
- slug: 'kids',
98
- label: 'For Kids',
99
- emoji: '🧒',
100
- description:
101
- 'Apps that EXPLICITLY target children: the words kids / ' +
102
- "children / 'for curious minds' / bedtime / 'learning for kids' " +
103
- 'must appear in the name or description, OR the app must be ' +
104
- 'obviously kid-targeted. Combines with `storytelling`, `voice`, ' +
105
- 'or `games`. Lifestyle, sports, weather, generic personality / ' +
106
- 'narration / fun framings are NOT kids.',
107
- },
108
- {
109
- slug: 'games',
110
- label: 'Games & Play',
111
- emoji: '🎮',
112
- description:
113
- 'Apps with a play loop: scores, rounds, win/lose conditions, ' +
114
- 'quizzes, puzzles, sports simulations, dice/oracles (magic ' +
115
- '8-ball), arcade-style mini-games.',
116
- },
117
- {
118
- slug: 'vision',
119
- label: 'Vision & Camera',
120
- emoji: '👁️',
121
- description:
122
- "Apps where Reachy's camera DRIVES behaviour: face/hand/pose " +
123
- 'tracking, image classification, gesture detection, visual ' +
124
- 'mimicry. Merely streaming or displaying the camera feed ' +
125
- '(WebRTC demos, remote-control viewers) is NOT vision.',
126
- },
127
- {
128
- slug: 'companion',
129
- label: 'Companion',
130
- emoji: '🤝',
131
- description:
132
- 'Apps with an EXPLICIT emotional / personality / buddy framing ' +
133
- 'in the name or description (companion, buddy, friend, mood, ' +
134
- 'emotional, personality, pet, Tamagotchi-like, "alive", ' +
135
- '"life companion"). Being friendly is not enough.',
136
- },
137
- {
138
- slug: 'dev-tools',
139
- label: 'Dev & Demos',
140
- emoji: '🛠️',
141
- description:
142
- 'RESERVED slug - see DECISION ALGORITHM step 1 in the prompt. ' +
143
- 'Use ONLY for pure technical artefacts (debug utilities, SDK ' +
144
- 'probes, minimal protocol demos, dev-only test spaces) with no ' +
145
- 'end-user experience. When used, it is the SOLE category - ' +
146
- 'never combined with another slug.',
147
- },
148
- ];
149
-
150
- export const ALLOWED_SLUGS = new Set(CATEGORIES.map((c) => c.slug));
151
-
152
- export function isValidSlug(slug) {
153
- return ALLOWED_SLUGS.has(slug);
154
- }
155
-
156
- /**
157
- * Public projection of the taxonomy meant to be shipped to clients
158
- * (mobile shell, website filter chips). We strip the `description`
159
- * field on purpose: it is sized + worded for the LLM prompt and
160
- * carries no UI value (clients render `label` + `emoji`). Render
161
- * order is the index in `CATEGORIES`, surfaced as `order` so a
162
- * client that needs to re-sort (e.g. alphabetical view) keeps the
163
- * canonical order one field-away.
164
- *
165
- * The shape is intentionally minimal and stable:
166
- * `{ slug, label, emoji, order }`. Adding optional fields later
167
- * (e.g. `color`, `shortLabel`) is forward-compatible; renaming or
168
- * dropping one is a breaking change for any client mirror.
169
- */
170
- export function getPublicTaxonomy() {
171
- return CATEGORIES.map((c, index) => ({
172
- slug: c.slug,
173
- label: c.label,
174
- emoji: c.emoji,
175
- order: index,
176
- }));
177
- }
178
-
179
- /**
180
- * Render the taxonomy as a bulleted list for the LLM prompt.
181
- * Format mirrors what the model is asked to output (slug first)
182
- * to nudge it towards copying the exact string back.
183
- */
184
- export function buildLlmCategoryList() {
185
- return CATEGORIES.map((c) => `- ${c.slug}: ${c.description}`).join('\n');
186
- }
187
-
188
- /**
189
- * Sanitize a raw LLM-returned list of slugs:
190
- * - drop non-strings
191
- * - lowercase + trim
192
- * - drop unknown slugs (hallucinations)
193
- * - dedupe while preserving order (the model orders by relevance)
194
- * - cap to MAX_CATEGORIES
195
- *
196
- * Returns a fresh array; never mutates input.
197
- */
198
- export function sanitizeSlugs(raw, maxCategories = 3) {
199
- if (!Array.isArray(raw)) return [];
200
- const seen = new Set();
201
- const out = [];
202
- for (const v of raw) {
203
- if (typeof v !== 'string') continue;
204
- const slug = v.trim().toLowerCase();
205
- if (!slug || seen.has(slug)) continue;
206
- if (!ALLOWED_SLUGS.has(slug)) continue;
207
- seen.add(slug);
208
- out.push(slug);
209
- if (out.length >= maxCategories) break;
210
- }
211
- return out;
212
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
server/categorize.js DELETED
@@ -1,426 +0,0 @@
1
- /**
2
- * LLM-based category inference for JS Reachy Mini apps.
3
- *
4
- * Pipeline (`categorizeApp`)
5
- * ──────────────────────────
6
- * 1. Fetch the Space's README from HF Hub (raw)
7
- * 2. Strip frontmatter, images, badges, raw HTML, then truncate
8
- * 3. Call a chat LLM via HF Inference Providers (OpenAI-compatible)
9
- * with the predefined taxonomy + the app's name/description
10
- * 4. Parse JSON, validate against ALLOWED_SLUGS, keep up to 3
11
- *
12
- * Robustness contract
13
- * ───────────────────
14
- * `categorizeApp` NEVER throws on transient failure (network,
15
- * 429, malformed JSON). It returns `null`, which the cache layer
16
- * interprets as "not yet categorized; retry on the next pass".
17
- * Hard errors (HF_TOKEN missing) are signalled by a thrown
18
- * `HfTokenMissingError` so the caller can short-circuit the
19
- * whole batch.
20
- */
21
-
22
- import {
23
- buildLlmCategoryList,
24
- sanitizeSlugs,
25
- } from './categories.js';
26
-
27
- // HF Inference Providers - OpenAI-compatible router. Auto-routes
28
- // the request to whichever provider currently serves the model
29
- // (Together, Nebius, Fireworks, Sambanova...). The token must
30
- // have `Inference Providers` access (default for all PRO and
31
- // most FREE tokens since 2025).
32
- const HF_INFERENCE_URL = 'https://router.huggingface.co/v1/chat/completions';
33
-
34
- // 8B model: cheap, fast (~1 s per call), more than enough for a
35
- // closed-list multi-label classification with good descriptions.
36
- // If quality drifts we can swap to 70B without touching anything
37
- // else - the prompt is generic.
38
- const DEFAULT_MODEL = 'meta-llama/Llama-3.1-8B-Instruct';
39
-
40
- // README budget
41
- const README_MAX_CHARS = 3000;
42
-
43
- // Single-label classification: each app gets EXACTLY ONE slug -
44
- // the dominant one. The shape stays `string[]` for forward
45
- // compatibility (if we ever revert to multi-label, no API break),
46
- // but the array always contains 0 or 1 entry. Mobile chips and
47
- // "swipers per category" thus surface each app once and only once.
48
- const MAX_CATEGORIES_PER_APP = 1;
49
-
50
- // LLM call budget
51
- const LLM_TIMEOUT_MS = 30_000;
52
- const LLM_MAX_TOKENS = 120;
53
- const LLM_TEMPERATURE = 0;
54
-
55
- export class HfTokenMissingError extends Error {
56
- constructor() {
57
- super('HF_TOKEN env var is not set; cannot call HF Inference Providers.');
58
- this.name = 'HfTokenMissingError';
59
- }
60
- }
61
-
62
- /**
63
- * Fetch a Space's README from HF Hub. Returns the raw markdown
64
- * string, or `null` if the request fails (404, network, etc.) -
65
- * the caller falls back to "name + description only" in that case,
66
- * which is still enough signal for the LLM on most apps.
67
- */
68
- export async function fetchSpaceReadme(spaceId, { signal } = {}) {
69
- if (!spaceId || typeof spaceId !== 'string') return null;
70
- // The README of a HF Space lives at /spaces/<id>/raw/main/README.md.
71
- // The `raw` endpoint returns the file as-is (no Hub UI wrapping)
72
- // and is anonymous-friendly, so no auth is needed here.
73
- const url = `https://huggingface.co/spaces/${spaceId}/raw/main/README.md`;
74
- try {
75
- const res = await fetch(url, { signal });
76
- if (!res.ok) return null;
77
- return await res.text();
78
- } catch {
79
- return null;
80
- }
81
- }
82
-
83
- /**
84
- * Lightly clean a raw README so the LLM doesn't burn tokens on
85
- * boilerplate (HF frontmatter, badges, images) and so the actual
86
- * prose surfaces above the truncation budget.
87
- *
88
- * We keep transformations conservative: we never edit the
89
- * surrounding prose, we just delete decorative tokens. Anything
90
- * cosmetic-only that clearly isn't signal for classification
91
- * (badges, images, raw HTML).
92
- */
93
- export function cleanReadme(raw) {
94
- if (!raw || typeof raw !== 'string') return '';
95
- let txt = raw;
96
-
97
- // 1. Strip the YAML frontmatter at the very top (HF Spaces
98
- // ship a mandatory `---\n...metadata...\n---` block whose
99
- // fields are already exposed to us via the catalog payload,
100
- // so feeding them to the LLM is pure noise).
101
- txt = txt.replace(/^---\n[\s\S]*?\n---\n?/, '');
102
-
103
- // 2. Drop image markdown (`![alt](url)`) and HTML <img> tags.
104
- // Vision apps tend to load up READMEs with screenshots and
105
- // GIFs; the alt text is sometimes useful but more often it's
106
- // "demo.gif" - low signal/noise ratio.
107
- txt = txt.replace(/!\[[^\]]*\]\([^)]+\)/g, '');
108
- txt = txt.replace(/<img\b[^>]*>/gi, '');
109
-
110
- // 3. Strip shields.io / GitHub badges (markdown links that
111
- // wrap an image). They survive (2) only when nested.
112
- txt = txt.replace(/\[!\[[^\]]*\]\([^)]+\)\]\([^)]+\)/g, '');
113
-
114
- // 4. Generic HTML stripping. Most READMEs are pure markdown,
115
- // but some authors embed `<details>`, `<sub>`, `<center>`
116
- // blocks. Keep the inner text, drop the tags.
117
- txt = txt.replace(/<\/?[a-zA-Z][^>]*>/g, '');
118
-
119
- // 5. Collapse runs of blank lines so trimming doesn't waste
120
- // tokens on the gap.
121
- txt = txt.replace(/\n{3,}/g, '\n\n');
122
-
123
- // 6. Truncate. We slice at the paragraph boundary closest to
124
- // the budget so we don't end mid-sentence.
125
- if (txt.length > README_MAX_CHARS) {
126
- const cut = txt.lastIndexOf('\n\n', README_MAX_CHARS);
127
- txt = txt.slice(0, cut > README_MAX_CHARS / 2 ? cut : README_MAX_CHARS);
128
- }
129
-
130
- return txt.trim();
131
- }
132
-
133
- /**
134
- * Few-shot examples woven into the system prompt.
135
- *
136
- * Each entry encodes a pitfall the v1 prompt fell into during the
137
- * 24-app eval (see `scripts/evaluate-prompt-v2.py`). Keep this list
138
- * tight - past ~10 examples the model starts pattern-matching
139
- * literally on the example names rather than applying the rules.
140
- *
141
- * Format: [name, description, expected_slugs, brief_justification]
142
- */
143
- const FEW_SHOT_EXAMPLES = [
144
- [
145
- 'Reachy Morse',
146
- "Send Morse code through Reachy's speaker.",
147
- ['dev-tools'],
148
- '(STEP 1 veto: pure technical artefact. NOT music.)',
149
- ],
150
- [
151
- 'WebRTC Demo',
152
- 'Minimal WebRTC connection between Reachy and the browser.',
153
- ['dev-tools'],
154
- '(STEP 1 veto: protocol demo. NOT vision.)',
155
- ],
156
- [
157
- 'TTS Reachy Mini',
158
- "Browser TTS that plays out of Reachy Mini's speaker.",
159
- ['voice'],
160
- '(USER-FACING speech output is voice, NOT dev-tools.)',
161
- ],
162
- [
163
- 'Reachy Mochi - Emotional Companion',
164
- 'Your pocket buddy that develops a mood and personality over time.',
165
- ['companion'],
166
- '(explicit emotional/companion framing)',
167
- ],
168
- [
169
- 'Reachy Alive',
170
- '(README empty; name suggests autonomy and life-like presence)',
171
- ['companion'],
172
- "(USE THE NAME when the README is empty; 'alive' = companion-like)",
173
- ],
174
- [
175
- 'Daily Surf Report',
176
- "Reachy reads today's surf report out loud.",
177
- ['voice'],
178
- '(NOT storytelling - a report has no narrative arc. ' +
179
- 'NOT kids - surfing/sports are not kid-targeted.)',
180
- ],
181
- [
182
- 'Music Quiz',
183
- 'Play a blind test music game with a dancing Reachy.',
184
- ['music'],
185
- '(single dominant slug - music wins over games because the app ' +
186
- "is primarily a music blind-test; the dancing is a side effect " +
187
- 'of the music and is captured by `music` too)',
188
- ],
189
- [
190
- 'Mime Bot',
191
- 'Reachy mimics your face live from your webcam.',
192
- ['vision'],
193
- '(NOT companion - mimicry is visual, no emotional framing.)',
194
- ],
195
- ];
196
-
197
- function renderFewShot() {
198
- return FEW_SHOT_EXAMPLES.map(([name, desc, slugs, hint]) => {
199
- const slugsJson = JSON.stringify(slugs);
200
- return (
201
- ` - ${JSON.stringify(name)}: ${JSON.stringify(desc)}\n` +
202
- ` → {"categories": ${slugsJson}} ${hint}`
203
- );
204
- }).join('\n');
205
- }
206
-
207
- /**
208
- * Build the chat messages handed to the LLM.
209
- *
210
- * The system prompt is structured as a 3-step DECISION ALGORITHM
211
- * rather than a flat list of rules, because the 8B-class model we
212
- * use (Llama-3.1-8B-Instruct) follows imperative procedures more
213
- * reliably than soft constraints. The `dev-tools` veto in STEP 1
214
- * is what stops the model from silently combining it with other
215
- * slugs on user-facing apps.
216
- *
217
- * The few-shot examples below the rules cover the v1 pitfalls
218
- * (companion hallucinations, music-on-audio, kids-on-personas,
219
- * storytelling-on-reports). Six is the sweet spot - more starts
220
- * over-fitting on example wording.
221
- */
222
- function buildMessages({ name, description, readme }) {
223
- const taxonomy = buildLlmCategoryList();
224
- const examples = renderFewShot();
225
- const system = `You classify a Reachy Mini robot app into a CLOSED list of categories.
226
-
227
- OUTPUT FORMAT
228
- Return ONLY a single JSON object: {"categories": ["slug"]}.
229
- Pick EXACTLY ONE slug - the single dominant category that best
230
- captures the app's primary identity. Use the EXACT slug. The list
231
- always contains 0 or 1 entry.
232
- No prose, no code fences, no commentary outside the JSON.
233
-
234
- DECISION ALGORITHM (apply in order)
235
-
236
- STEP 1 - \`dev-tools\` veto
237
- Is this app a PURE technical artefact with no user-facing experience
238
- beyond "here is how the SDK / API works"?
239
- Examples that pass the veto: WebRTC demo, SDK probe, debug utility,
240
- raw remote-control interface, dev-only test space.
241
- Examples that DO NOT pass the veto (they are user-facing apps):
242
- TTS players, voice chat, music apps, storytelling, companions -
243
- even when the README is dev-heavy.
244
- - YES -> return {"categories": ["dev-tools"]} and STOP.
245
- - NO -> continue to STEP 2.
246
-
247
- STEP 2 - Pick the SINGLE most dominant user-facing slug from the list
248
- below. Choose the slug that captures the app's primary identity, not
249
- every aspect it touches. When two slugs feel equally fitting, pick the
250
- one that a user would name FIRST when describing the app in one word.
251
- Examples of tie-breaks:
252
- - music-driven dance party (Reachy dances to a song) -> \`music\`.
253
- The music is what drives the experience.
254
- - pure choreography / marionette / motion replay without music ->
255
- \`motion\`. The movement is the experience.
256
- - storytelling + kids app -> prefer \`kids\` if it explicitly targets
257
- children, \`storytelling\` otherwise.
258
- - vision + games app -> prefer \`games\` if there is a play loop,
259
- \`vision\` if it is mostly a perception demo.
260
- If the README is empty or very sparse, USE THE NAME AND DESCRIPTION
261
- as the primary signal - do not bail to an empty list just because the
262
- README is thin.
263
-
264
- STEP 3 - Strict slug rules (each must hold, or DO NOT use the slug)
265
- - \`companion\`: requires EXPLICIT emotional / personality / buddy
266
- framing (companion, buddy, friend, mood, emotional, personality,
267
- pet, Tamagotchi-like, "alive", "life companion"). Being friendly is
268
- not enough.
269
- - \`music\`: requires actual music - rhythm, melody, songs, beats, DJ
270
- sets, instruments, music quizzes. Arbitrary audio (Morse, alarms,
271
- TTS, sound effects) is NOT music.
272
- - \`vision\`: requires the camera to DRIVE behaviour (tracking,
273
- classification, mimicry). Merely streaming or displaying the camera
274
- (WebRTC demos, remote-control viewers) is NOT vision.
275
- - \`storytelling\`: requires a narrative ARC - plot, characters, scenes.
276
- Daily reports, news, weather, Q&A are NOT storytelling (they are
277
- \`voice\`).
278
- - \`games\`: requires a play loop - score, rounds, win/lose, puzzles,
279
- quizzes, dice/oracles, sports simulations.
280
- - \`kids\`: requires kid-targeted framing (kids/children/curious minds/
281
- bedtime/learning for kids) in the name or description. Lifestyle,
282
- sports, weather, general conversation are NOT kids.
283
-
284
- AVAILABLE CATEGORIES
285
- ${taxonomy}
286
-
287
- REFERENCE EXAMPLES
288
- ${examples}
289
-
290
- Do not include any text outside the JSON object.`;
291
-
292
- const user =
293
- `App name: ${name || '(unknown)'}\n` +
294
- `Short description: ${description || '(none)'}\n\n` +
295
- `README excerpt:\n${readme || '(no README available)'}\n\n` +
296
- 'Return the JSON now.';
297
-
298
- return [
299
- { role: 'system', content: system },
300
- { role: 'user', content: user },
301
- ];
302
- }
303
-
304
- /**
305
- * Best-effort JSON extraction. Some 8B models still wrap the
306
- * answer in ``` fences or prepend "Sure, here you go:". We grab
307
- * the first balanced `{...}` block and parse that.
308
- */
309
- function extractJsonObject(text) {
310
- if (!text || typeof text !== 'string') return null;
311
- const start = text.indexOf('{');
312
- if (start === -1) return null;
313
- let depth = 0;
314
- for (let i = start; i < text.length; i++) {
315
- const ch = text[i];
316
- if (ch === '{') depth++;
317
- else if (ch === '}') {
318
- depth--;
319
- if (depth === 0) {
320
- const slice = text.slice(start, i + 1);
321
- try {
322
- return JSON.parse(slice);
323
- } catch {
324
- return null;
325
- }
326
- }
327
- }
328
- }
329
- return null;
330
- }
331
-
332
- /**
333
- * Call the HF Inference Providers chat endpoint. Returns the
334
- * raw assistant message string, or `null` on any error.
335
- */
336
- async function callLlm({ messages, model, signal }) {
337
- const token = process.env.HF_TOKEN;
338
- if (!token) throw new HfTokenMissingError();
339
-
340
- const body = {
341
- model,
342
- messages,
343
- temperature: LLM_TEMPERATURE,
344
- max_tokens: LLM_MAX_TOKENS,
345
- // `response_format` is honoured by some providers (Nebius,
346
- // Together) but ignored by others. It's a free upgrade when
347
- // present, harmless otherwise; the JSON-extractor below is
348
- // the real safety net.
349
- response_format: { type: 'json_object' },
350
- };
351
-
352
- let res;
353
- try {
354
- res = await fetch(HF_INFERENCE_URL, {
355
- method: 'POST',
356
- headers: {
357
- 'Authorization': `Bearer ${token}`,
358
- 'Content-Type': 'application/json',
359
- },
360
- body: JSON.stringify(body),
361
- signal,
362
- });
363
- } catch (err) {
364
- console.warn(`[categorize] LLM fetch failed: ${err.message}`);
365
- return null;
366
- }
367
-
368
- if (!res.ok) {
369
- const detail = await res.text().catch(() => '');
370
- console.warn(
371
- `[categorize] LLM HTTP ${res.status}: ${detail.slice(0, 200)}`,
372
- );
373
- return null;
374
- }
375
-
376
- let json;
377
- try {
378
- json = await res.json();
379
- } catch {
380
- return null;
381
- }
382
- return json?.choices?.[0]?.message?.content ?? null;
383
- }
384
-
385
- /**
386
- * Public entry point.
387
- *
388
- * Returns a string[] of validated slugs (0-3 items), or `null`
389
- * on transient failure so the caller can mark the entry "needs
390
- * retry" without writing a misleading empty list.
391
- *
392
- * Treat an empty array `[]` as "the LLM looked and concluded
393
- * none fit" - that's a valid, cacheable outcome.
394
- */
395
- export async function categorizeApp({
396
- name,
397
- description,
398
- spaceId,
399
- model = DEFAULT_MODEL,
400
- } = {}) {
401
- if (!spaceId) return null;
402
-
403
- const ctrl = new AbortController();
404
- const timeoutId = setTimeout(() => ctrl.abort(), LLM_TIMEOUT_MS);
405
-
406
- try {
407
- const rawReadme = await fetchSpaceReadme(spaceId, { signal: ctrl.signal });
408
- const readme = cleanReadme(rawReadme);
409
-
410
- const messages = buildMessages({ name, description, readme });
411
- const reply = await callLlm({ messages, model, signal: ctrl.signal });
412
- if (reply == null) return null;
413
-
414
- const obj = extractJsonObject(reply);
415
- if (!obj || !Array.isArray(obj.categories)) {
416
- console.warn(
417
- `[categorize] ${spaceId}: malformed LLM reply (truncated): ` +
418
- `${reply.slice(0, 120)}`,
419
- );
420
- return null;
421
- }
422
- return sanitizeSlugs(obj.categories, MAX_CATEGORIES_PER_APP);
423
- } finally {
424
- clearTimeout(timeoutId);
425
- }
426
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
server/categoryCache.js DELETED
@@ -1,290 +0,0 @@
1
- /**
2
- * Persistent cache for inferred app categories, backed by a
3
- * HuggingFace dataset.
4
- *
5
- * Why a dataset (not a local file)
6
- * ────────────────────────────────
7
- * The website runs in a Docker HF Space. The container's
8
- * filesystem is wiped on every rebuild (and rebuilds happen
9
- * on every push, every model update, every Space restart).
10
- * Re-running 200 LLM calls every cold start would be wasteful
11
- * and slow the user-visible /api/js-apps for the first 30 s.
12
- *
13
- * Pushing the cache to a dataset gives us:
14
- * 1. Persistence across rebuilds and machine moves
15
- * 2. A versioned audit log of how categories evolve
16
- * 3. A single source of truth other tooling can consume
17
- * (the mobile shell could even read the dataset directly
18
- * if it ever wanted to bypass the website).
19
- *
20
- * Storage shape
21
- * ─────────────
22
- * <dataset>/categories.json
23
- *
24
- * {
25
- * "version": 1,
26
- * "taxonomyVersion": 1,
27
- * "updatedAt": "2026-05-10T11:08:42Z",
28
- * "entries": {
29
- * "<spaceId>": {
30
- * "lastModified": "2026-05-08T22:13:01Z",
31
- * "categories": ["storytelling", "kids", "voice"],
32
- * "categorizedAt": "2026-05-10T11:08:42Z",
33
- * "taxonomyVersion": 1
34
- * }
35
- * }
36
- * }
37
- *
38
- * In-memory tier
39
- * ──────────────
40
- * The Map<spaceId, entry> is the hot path. The dataset is
41
- * loaded once at boot and only flushed when entries actually
42
- * change (the warmup batch buffers writes and flushes once
43
- * at the end). All synchronous access goes through the Map.
44
- */
45
-
46
- import { commit, createRepo } from '@huggingface/hub';
47
-
48
- import { TAXONOMY_VERSION } from './categories.js';
49
-
50
- // Default location: a per-user dataset that the HF_TOKEN owner
51
- // definitely has write access to. Override with the env var
52
- // when promoting to the org-owned `pollen-robotics/...` dataset.
53
- const DEFAULT_DATASET = 'tfrere/reachy-mini-app-categories';
54
-
55
- const CACHE_FILE_PATH = 'categories.json';
56
- const CACHE_FORMAT_VERSION = 1;
57
-
58
- class CategoryCache {
59
- constructor() {
60
- this.entries = new Map();
61
- this.repoName = process.env.HF_CATEGORIES_DATASET || DEFAULT_DATASET;
62
- this.loaded = false;
63
- this.dirty = false;
64
- // Concurrency guard for `flush()` - we never want two
65
- // commit() calls fighting for the same parent commit.
66
- this.flushing = false;
67
- }
68
-
69
- /**
70
- * Load the dataset cache into memory. Best-effort: a missing
71
- * dataset, a 404, or a malformed JSON all collapse to "start
72
- * fresh, the warmup will repopulate". We never let cache load
73
- * failure block the server boot.
74
- */
75
- async load() {
76
- if (this.loaded) return;
77
- this.loaded = true;
78
-
79
- const url = `https://huggingface.co/datasets/${this.repoName}/resolve/main/${CACHE_FILE_PATH}`;
80
- try {
81
- const res = await fetch(url, {
82
- // Send the token even on a public dataset: it lets HF
83
- // bump our rate limit and keeps the path identical for
84
- // a future private dataset migration.
85
- headers: process.env.HF_TOKEN
86
- ? { Authorization: `Bearer ${process.env.HF_TOKEN}` }
87
- : undefined,
88
- });
89
- if (!res.ok) {
90
- if (res.status === 404) {
91
- console.log(
92
- `[CategoryCache] Dataset ${this.repoName} or ${CACHE_FILE_PATH} ` +
93
- `not found yet - starting empty.`,
94
- );
95
- } else {
96
- console.warn(
97
- `[CategoryCache] HTTP ${res.status} loading cache from ` +
98
- `${this.repoName}, starting empty.`,
99
- );
100
- }
101
- return;
102
- }
103
- const data = await res.json();
104
- const entries = data?.entries || {};
105
- let kept = 0;
106
- let staleTaxonomy = 0;
107
- for (const [id, raw] of Object.entries(entries)) {
108
- if (!raw || typeof raw !== 'object') continue;
109
- // Drop entries from a previous taxonomy: their slugs
110
- // may no longer exist or may have shifted meaning.
111
- // The warmup will re-run them.
112
- if (raw.taxonomyVersion !== TAXONOMY_VERSION) {
113
- staleTaxonomy++;
114
- continue;
115
- }
116
- this.entries.set(id, {
117
- lastModified: raw.lastModified || null,
118
- categories: Array.isArray(raw.categories) ? raw.categories : [],
119
- categorizedAt: raw.categorizedAt || null,
120
- taxonomyVersion: raw.taxonomyVersion,
121
- });
122
- kept++;
123
- }
124
- console.log(
125
- `[CategoryCache] Loaded ${kept} entries from ${this.repoName}` +
126
- (staleTaxonomy ? ` (dropped ${staleTaxonomy} stale taxonomy)` : ''),
127
- );
128
- } catch (err) {
129
- console.warn(
130
- `[CategoryCache] Load failed (${err.message}); starting empty.`,
131
- );
132
- }
133
- }
134
-
135
- get(spaceId) {
136
- return this.entries.get(spaceId) || null;
137
- }
138
-
139
- /**
140
- * Decide whether `spaceId` needs a fresh classification call.
141
- * It does when:
142
- * - we have no entry at all, OR
143
- * - the Space's `lastModified` has moved past our cached one
144
- * (the README may have changed - re-classify), OR
145
- * - the taxonomy version moved (handled at load() time, but
146
- * belt-and-braces for hot reloads).
147
- */
148
- needsCategorization(spaceId, lastModified) {
149
- const entry = this.entries.get(spaceId);
150
- if (!entry) return true;
151
- if (entry.taxonomyVersion !== TAXONOMY_VERSION) return true;
152
- if (lastModified && entry.lastModified !== lastModified) return true;
153
- return false;
154
- }
155
-
156
- set(spaceId, { categories, lastModified }) {
157
- if (!Array.isArray(categories)) return;
158
- const next = {
159
- lastModified: lastModified || null,
160
- categories: [...categories],
161
- categorizedAt: new Date().toISOString(),
162
- taxonomyVersion: TAXONOMY_VERSION,
163
- };
164
- const prev = this.entries.get(spaceId);
165
- // Skip the dirty flag if nothing actually changed - avoids
166
- // a useless commit when a refresh confirms the same labels.
167
- if (
168
- prev &&
169
- prev.lastModified === next.lastModified &&
170
- prev.taxonomyVersion === next.taxonomyVersion &&
171
- JSON.stringify(prev.categories) === JSON.stringify(next.categories)
172
- ) {
173
- return;
174
- }
175
- this.entries.set(spaceId, next);
176
- this.dirty = true;
177
- }
178
-
179
- /**
180
- * Persist the in-memory cache to the dataset (one commit, one
181
- * file). No-op if nothing has changed since the last flush.
182
- *
183
- * Auto-creates the dataset on first write if it doesn't exist
184
- * yet (so a brand-new `HF_CATEGORIES_DATASET` value bootstraps
185
- * cleanly without manual setup).
186
- */
187
- async flush() {
188
- if (!this.dirty || this.flushing) return;
189
- if (!process.env.HF_TOKEN) {
190
- console.warn('[CategoryCache] HF_TOKEN missing; skipping flush.');
191
- return;
192
- }
193
- this.flushing = true;
194
- try {
195
- const payload = this.serialize();
196
- const blob = new Blob([JSON.stringify(payload, null, 2)], {
197
- type: 'application/json',
198
- });
199
-
200
- const repo = { type: 'dataset', name: this.repoName };
201
- const credentials = { accessToken: process.env.HF_TOKEN };
202
-
203
- // First attempt: plain commit. If the dataset doesn't
204
- // exist yet, the SDK throws and we fall through to
205
- // create-then-commit. We never assume the dataset exists
206
- // - that lets a fresh deploy auto-bootstrap.
207
- try {
208
- await commit({
209
- repo,
210
- credentials,
211
- title: `Update categories (${this.entries.size} apps)`,
212
- operations: [
213
- {
214
- operation: 'addOrUpdate',
215
- path: CACHE_FILE_PATH,
216
- content: blob,
217
- },
218
- ],
219
- });
220
- } catch (err) {
221
- const msg = err?.message || '';
222
- const looksMissing =
223
- msg.includes('404') ||
224
- msg.toLowerCase().includes('not found') ||
225
- msg.toLowerCase().includes('does not exist');
226
- if (!looksMissing) throw err;
227
- console.log(
228
- `[CategoryCache] Dataset ${this.repoName} missing - creating it.`,
229
- );
230
- await createRepo({
231
- repo,
232
- credentials,
233
- private: false,
234
- // Re-using the same blob so the initial commit ships
235
- // the cache content (instead of an empty repo
236
- // followed by a no-op commit).
237
- files: [
238
- {
239
- path: CACHE_FILE_PATH,
240
- content: await blob.arrayBuffer(),
241
- },
242
- ],
243
- });
244
- }
245
-
246
- this.dirty = false;
247
- console.log(
248
- `[CategoryCache] Flushed ${this.entries.size} entries to ${this.repoName}`,
249
- );
250
- } catch (err) {
251
- // We deliberately swallow flush errors so a HF outage
252
- // doesn't break the running server. The next set() will
253
- // re-flag dirty=true and the next flush() will retry.
254
- console.error(
255
- `[CategoryCache] Flush failed: ${err?.message || err}`,
256
- );
257
- } finally {
258
- this.flushing = false;
259
- }
260
- }
261
-
262
- serialize() {
263
- const entries = {};
264
- for (const [id, entry] of this.entries) {
265
- entries[id] = entry;
266
- }
267
- return {
268
- version: CACHE_FORMAT_VERSION,
269
- taxonomyVersion: TAXONOMY_VERSION,
270
- updatedAt: new Date().toISOString(),
271
- entries,
272
- };
273
- }
274
-
275
- /**
276
- * Diagnostic snapshot for /api/js-apps's `categorization`
277
- * sub-payload. Lets the mobile shell decide whether to show
278
- * "loading categories..." or to render the chips immediately.
279
- */
280
- stats() {
281
- return {
282
- total: this.entries.size,
283
- dataset: this.repoName,
284
- taxonomyVersion: TAXONOMY_VERSION,
285
- };
286
- }
287
- }
288
-
289
- // Singleton: there's only one cache per server process.
290
- export const categoryCache = new CategoryCache();
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
server/index.js CHANGED
@@ -1,113 +1,12 @@
1
- import compression from 'compression';
2
  import express from 'express';
3
- import { existsSync, readFileSync } from 'fs';
4
  import path from 'path';
5
  import { fileURLToPath } from 'url';
6
 
7
- import { categorizeApp, HfTokenMissingError } from './categorize.js';
8
- import { categoryCache } from './categoryCache.js';
9
- import { getPublicTaxonomy } from './categories.js';
10
- import { mintEphemeralKeyHandler } from './openaiEphemeral.js';
11
-
12
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
13
 
14
- // Load `.env` from the repo root in dev. In production (HF Space)
15
- // the platform already injects the secrets as env vars, so this
16
- // loader silently no-ops. We avoid the `dotenv` dep on purpose -
17
- // the format is trivial, and reproducing it inline keeps the
18
- // runtime closure tiny.
19
- (function loadDotenv() {
20
- try {
21
- const envPath = path.join(__dirname, '..', '.env');
22
- if (!existsSync(envPath)) return;
23
- const text = readFileSync(envPath, 'utf8');
24
- for (const line of text.split(/\r?\n/)) {
25
- const m = line.match(/^\s*([A-Z0-9_]+)\s*=\s*(.*?)\s*$/i);
26
- if (!m) continue;
27
- const [, key, raw] = m;
28
- let value = raw;
29
- if (
30
- (value.startsWith('"') && value.endsWith('"')) ||
31
- (value.startsWith("'") && value.endsWith("'"))
32
- ) {
33
- value = value.slice(1, -1);
34
- }
35
- // Existing env wins (so `HF_TOKEN=foo node …` overrides .env).
36
- if (process.env[key] === undefined) process.env[key] = value;
37
- }
38
- } catch {
39
- /* best-effort - missing or malformed .env never blocks boot */
40
- }
41
- })();
42
-
43
  const app = express();
44
  const PORT = process.env.PORT || 7860;
45
 
46
- // gzip/brotli compression on every response. Critical for the
47
- // catalog endpoints (`/api/apps`, `/api/js-apps`) which return
48
- // ~40KB of JSON dominated by repeated keys ("apps", "id", "extra",
49
- // "cardData"…) - gzip cuts that to ~6KB on the wire. The Express
50
- // `compression` middleware:
51
- // - skips responses already encoded (no double-encoding),
52
- // - skips responses below the `threshold` (default 1KB - tiny
53
- // payloads stay verbatim since the gzip framing would dwarf
54
- // the savings),
55
- // - honours the client's `Accept-Encoding`, falling back to
56
- // identity when the client doesn't speak gzip/br.
57
- // No streaming endpoints in this server (every route ends in
58
- // `res.json()` or `res.sendFile()`), so compression is unconditionally
59
- // safe. The default `level: 6` is the right CPU/ratio trade-off for
60
- // JSON.
61
- app.use(compression());
62
-
63
- // JSON body parsing for the handful of POST routes that consume
64
- // structured payloads (currently `/api/openai/ephemeral`). The 8KB
65
- // cap is intentionally tiny because none of our endpoints accept
66
- // large bodies, and a tight limit drops obvious abuse early.
67
- app.use(express.json({ limit: '8kb' }));
68
-
69
- // CORS allowlist for cross-origin API consumers. Same-origin browser
70
- // calls from this Space stay unaffected. The mobile shell runs from
71
- // `https://tauri.localhost` (iOS WKWebView), `http://tauri.localhost`
72
- // (Android WebView), and the desktop dev preview from
73
- // `http://localhost:1422` (Vite). We do NOT use a wildcard origin
74
- // because every allowed call expects `Authorization: Bearer …`, and
75
- // `Access-Control-Allow-Origin: *` is incompatible with credentialed
76
- // CORS in any practical setup.
77
- const CORS_ALLOWED_ORIGINS = new Set([
78
- 'https://tauri.localhost',
79
- 'http://tauri.localhost',
80
- 'http://localhost:1422',
81
- 'http://localhost:1420',
82
- ]);
83
-
84
- app.use((req, res, next) => {
85
- const origin = req.headers.origin;
86
- if (origin && CORS_ALLOWED_ORIGINS.has(origin)) {
87
- res.setHeader('Access-Control-Allow-Origin', origin);
88
- res.setHeader('Vary', 'Origin');
89
- res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
90
- res.setHeader(
91
- 'Access-Control-Allow-Headers',
92
- 'Authorization, Content-Type',
93
- );
94
- // Expose `Age` so cross-origin JS clients (mobile shell, desktop
95
- // store, anything not running same-origin on this Space) can
96
- // read the server-side cache age. The header lives in the
97
- // CORS-safelisted set only for a hardcoded handful of fields;
98
- // `Age` is NOT in that set, so without this header browser
99
- // `fetch()` callers would see `null` from `headers.get('age')`.
100
- // We could also expose `ETag` here for clients that want to
101
- // do manual `If-None-Match` revalidation, but the browser
102
- // handles ETag transparently in its own HTTP cache, so JS
103
- // never needs to see it.
104
- res.setHeader('Access-Control-Expose-Headers', 'Age');
105
- res.setHeader('Access-Control-Max-Age', '600');
106
- }
107
- if (req.method === 'OPTIONS') return res.sendStatus(204);
108
- next();
109
- });
110
-
111
  // Cache configuration
112
  const CACHE_TTL_MS = 5 * 60 * 1000; // 5 minutes
113
  const OFFICIAL_APP_LIST_URL = 'https://huggingface.co/datasets/pollen-robotics/reachy-mini-official-app-store/raw/main/app-list.json';
@@ -115,141 +14,6 @@ const HF_SPACES_API = 'https://huggingface.co/api/spaces';
115
  // Note: HF API doesn't support pagination with filter=, so we use a high limit
116
  const HF_SPACES_LIMIT = 1000;
117
 
118
- /**
119
- * Standard HTTP caching for the catalog GET endpoints
120
- * (`/api/apps`, `/api/js-apps`).
121
- *
122
- * Why bake this into a helper instead of inlining the same two
123
- * `setHeader` calls in every route:
124
- * 1. Both endpoints share the same upstream cache state
125
- * (`appsCache.lastFetch`) so they SHOULD emit a coherent
126
- * `Age` value - any drift between routes would silently
127
- * mislead clients about cache staleness.
128
- * 2. The `Cache-Control` directives below were chosen carefully;
129
- * a future contributor copy-pasting one route to start a new
130
- * catalog projection should inherit them rather than rolling
131
- * their own.
132
- *
133
- * Cache-Control: `public, max-age=60, stale-while-revalidate=300`
134
- * - `public`: response is safe to store in shared caches (the
135
- * payload is identical for every caller, no per-user data).
136
- * - `max-age=60`: clients + intermediaries may serve this
137
- * response for up to 60 s without revalidating. The upstream
138
- * `appsCache` already deduplicates within a 5-minute window
139
- * server-side, so 60 s here means the network sees at most
140
- * 1 hit/minute per cache key per intermediate even under
141
- * burst load (10k mobile shells waking up at the same time).
142
- * - `stale-while-revalidate=300`: for a further 5 minutes after
143
- * the response goes stale, intermediaries may serve the
144
- * stale copy while revalidating in the background. This
145
- * absorbs sudden traffic spikes without ever blocking the
146
- * user on a cold-cache fetch.
147
- *
148
- * `Age` (RFC 7234 §5.1) replaces the `cacheAge` field we used to
149
- * pack into the response body. Pulling the age out of the body
150
- * was a strict prerequisite for ETag-based revalidation: Express's
151
- * default ETag is a hash of the response body, and a body that
152
- * carries a counter that increments every second produces a fresh
153
- * ETag every second, which makes `If-None-Match` permanently
154
- * negative and turns the ETag into dead weight. With `cacheAge`
155
- * promoted to a header, the body becomes a pure function of the
156
- * cache contents, the ETag becomes stable across requests that
157
- * hit the same cache snapshot, and clients sending `If-None-Match`
158
- * get cheap 304s instead of re-downloading 40 KB of JSON.
159
- */
160
- function setCatalogCacheHeaders(res, lastFetchMs) {
161
- res.setHeader(
162
- 'Cache-Control',
163
- 'public, max-age=60, stale-while-revalidate=300',
164
- );
165
- const ageSeconds = lastFetchMs
166
- ? Math.max(0, Math.round((Date.now() - lastFetchMs) / 1000))
167
- : 0;
168
- res.setHeader('Age', String(ageSeconds));
169
- }
170
-
171
- // Tag that gates the JS-only subset surfaced by /api/js-apps and
172
- // fed to the LLM categorizer. Mirrors the filter the mobile shell
173
- // applies today client-side; the route lets us retire that filter
174
- // from the mobile codebase down the line.
175
- const JS_APP_TAG = 'reachy_mini_js_app';
176
-
177
- // =====================================================================
178
- // App icon convention
179
- // =====================================================================
180
- //
181
- // Convention: an app commits `public/icon.svg` (preferred) or
182
- // `public/icon.png` in its HF Space repository. When present, the
183
- // mobile shell + desktop store render it as the app glyph instead
184
- // of the front-matter `emoji:` codepoint.
185
- //
186
- // Why `public/` and not the repo root?
187
- // - Vite already copies `public/*` verbatim to `dist/` at build,
188
- // where nginx serves it at `/icon.svg`. The same file is
189
- // therefore the favicon, the `mountHost({ appIconUrl })` value,
190
- // AND the store glyph - one source of truth, no `cp` script,
191
- // no risk of the two copies drifting apart.
192
- // - HF `resolve/main/public/icon.svg` works the same as
193
- // `resolve/main/icon.svg`: any path inside the repo is
194
- // reachable, so the catalog still grabs the bytes without
195
- // waking the Space's nginx.
196
- //
197
- // We resolve the icon ONCE at indexing time (here) rather than
198
- // probing per-client because:
199
- // 1. We already pull `siblings` from `?full=true` (one cheap
200
- // hub call returns the file list for every app), so the
201
- // lookup is a pure JS filter, no extra network.
202
- // 2. Clients see a single field (`iconUrl`) in the payload and
203
- // don't have to know about HF resolve URLs, LFS pointers,
204
- // or the candidate-order race ("SVG wins if both exist").
205
- // 3. The HF API caps probes at ~hub side; doing it server-side
206
- // keeps fanout under a 5-minute TTL behind ONE token, instead
207
- // of every mobile shell hammering `huggingface.co/resolve/`
208
- // to discover icons.
209
- //
210
- // Resolution order: `public/icon.svg` → `public/icon.png`. SVG
211
- // first because the same asset scales cleanly across every mount
212
- // point (small rail tile, larger pinned tile, iframe header) from
213
- // a single file. Extra formats can be added to `ICON_CANDIDATES`
214
- // if needed; order matters - the first match wins.
215
- const ICON_CANDIDATES = ['public/icon.svg', 'public/icon.png'];
216
-
217
- /**
218
- * Look for a standard app icon file at the conventional location.
219
- * Returns the absolute HF resolve URL when found, `null` otherwise.
220
- *
221
- * We hit `resolve/main/` (not `raw/main/`) so:
222
- * - LFS pointers follow transparently (large PNGs work).
223
- * - `Content-Type` comes from the extension, which `<img>` needs.
224
- * - The URL is cacheable cross-session by the browser, so
225
- * repeated mounts of the same app glyph don't re-fetch.
226
- */
227
- function findIconUrl(spaceId, siblings) {
228
- if (!spaceId || !Array.isArray(siblings)) return null;
229
- // Build a Set of repo-relative filenames for O(1) candidate
230
- // lookups. HF returns `siblings` as `[{ rfilename: "path/in/repo" }, ...]`;
231
- // we keep the full path because the convention now lives under
232
- // `public/` rather than at the repo root.
233
- const files = new Set();
234
- for (const s of siblings) {
235
- const name = s && typeof s.rfilename === 'string' ? s.rfilename : null;
236
- if (!name) continue;
237
- files.add(name);
238
- }
239
- for (const candidate of ICON_CANDIDATES) {
240
- if (files.has(candidate)) {
241
- return `https://huggingface.co/spaces/${spaceId}/resolve/main/${candidate}`;
242
- }
243
- }
244
- return null;
245
- }
246
-
247
- // Serialised LLM batch concurrency: we want at most one
248
- // categorization sweep running at a time, regardless of how many
249
- // /api/js-apps requests come in. The flag also prevents the
250
- // startup warm-up and an on-demand refresh from racing each other.
251
- let categorizationBatchRunning = false;
252
-
253
  // In-memory cache
254
  let appsCache = {
255
  data: null,
@@ -289,13 +53,6 @@ async function fetchAppsFromHF() {
289
  const author = spaceId.split('/')[0];
290
  const name = spaceId.split('/').pop();
291
 
292
- // Server-resolved icon URL. Looks for `public/icon.svg` or
293
- // `public/icon.png` via the `siblings` list returned by
294
- // `?full=true`. See `findIconUrl()` above for the rationale.
295
- // `null` when the author hasn't shipped one; clients fall
296
- // back to the front-matter emoji.
297
- const iconUrl = findIconUrl(spaceId, space.siblings);
298
-
299
  return {
300
  // Core fields (used by both website and desktop)
301
  id: spaceId,
@@ -304,8 +61,7 @@ async function fetchAppsFromHF() {
304
  url: `https://huggingface.co/spaces/${spaceId}`,
305
  source_kind: 'hf_space',
306
  isOfficial,
307
- iconUrl,
308
-
309
  // Extra metadata (desktop-compatible structure)
310
  extra: {
311
  id: spaceId,
@@ -329,86 +85,53 @@ async function fetchAppsFromHF() {
329
  };
330
  });
331
 
332
- console.log(`[Cache] Built ${allApps.length} raw app entries from HF.`);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
333
 
334
- // Sort: official first, then by likes. Dedup is route-specific
335
- // and applied downstream (see `dedupGlobalApps` and `dedupJsApps`).
336
- allApps.sort((a, b) => {
337
  if (a.isOfficial !== b.isOfficial) {
338
  return a.isOfficial ? -1 : 1;
339
  }
340
  return (b.extra.likes || 0) - (a.extra.likes || 0);
341
  });
342
 
343
- return allApps;
344
  } catch (err) {
345
  console.error('[Cache] Error fetching apps:', err);
346
  throw err;
347
  }
348
  }
349
 
350
- /**
351
- * Pick a winner among Spaces sharing the same repo name. Forks
352
- * keep the upstream name (e.g. several `reachy_mini_conversation_app`
353
- * from different authors); we surface only one in the store to
354
- * avoid drowning the original under a dozen near-identical tiles.
355
- *
356
- * Priority: 1) official, 2) oldest (likely original), 3) most likes
357
- * as tiebreaker.
358
- */
359
- function dedupAppsByName(apps) {
360
- const deduped = new Map();
361
- for (const app of apps) {
362
- const key = app.name.toLowerCase();
363
- const existing = deduped.get(key);
364
- if (!existing) {
365
- deduped.set(key, app);
366
- continue;
367
- }
368
- if (app.isOfficial && !existing.isOfficial) {
369
- deduped.set(key, app);
370
- continue;
371
- }
372
- if (existing.isOfficial) continue;
373
- const appDate = app.extra?.createdAt ? new Date(app.extra.createdAt).getTime() : Infinity;
374
- const existingDate = existing.extra?.createdAt ? new Date(existing.extra.createdAt).getTime() : Infinity;
375
- if (appDate < existingDate) {
376
- deduped.set(key, app);
377
- } else if (appDate === existingDate && (app.extra?.likes || 0) > (existing.extra?.likes || 0)) {
378
- deduped.set(key, app);
379
- }
380
- }
381
- return [...deduped.values()];
382
- }
383
-
384
- /**
385
- * Dedup applied to the full `/api/apps` payload (Python + JS + others
386
- * mixed). Same-name collisions across SDKs collapse here too, by design:
387
- * the showcase site favours a clean catalog over completeness, and
388
- * SDK-aware variants of the same idea live as separate Spaces only
389
- * by accident in practice.
390
- */
391
- function dedupGlobalApps(apps) {
392
- return dedupAppsByName(apps);
393
- }
394
-
395
- /**
396
- * Dedup applied to the `/api/js-apps` route only. We restrict the
397
- * comparison to entries already filtered to the JS subset, so a JS
398
- * Space (e.g. `tfrere/emotions`) does not lose a name fight against
399
- * an unrelated Python Space sharing the same repo name (e.g.
400
- * `RemiFabre/emotions`). The mobile shell only sees JS apps anyway,
401
- * so confining dedup to that scope is what matches the user model.
402
- */
403
- function dedupJsApps(jsApps) {
404
- return dedupAppsByName(jsApps);
405
- }
406
-
407
- // Get raw apps with caching. Dedup is NOT applied here - each
408
- // route owns its own dedup policy (see `dedupGlobalApps` and
409
- // `dedupJsApps`) so they can disagree without paying for two
410
- // upstream fetches.
411
- async function getRawApps() {
412
  const now = Date.now();
413
 
414
  // Return cache if valid
@@ -430,7 +153,7 @@ async function getRawApps() {
430
  const apps = await fetchAppsFromHF();
431
  appsCache.data = apps;
432
  appsCache.lastFetch = now;
433
- console.log(`[Cache] Cache updated with ${apps.length} raw entries`);
434
  return apps;
435
  } catch (err) {
436
  // On error, return stale cache if available
@@ -447,12 +170,11 @@ async function getRawApps() {
447
  // API endpoint
448
  app.get('/api/apps', async (req, res) => {
449
  try {
450
- const raw = await getRawApps();
451
- const apps = dedupGlobalApps(raw);
452
- setCatalogCacheHeaders(res, appsCache.lastFetch);
453
  res.json({
454
  apps,
455
  cached: true,
 
456
  count: apps.length,
457
  });
458
  } catch (err) {
@@ -461,273 +183,6 @@ app.get('/api/apps', async (req, res) => {
461
  }
462
  });
463
 
464
- // =====================================================================
465
- // JS apps + LLM-inferred categories
466
- // =====================================================================
467
- //
468
- // `/api/js-apps` is a curated view on the JS-only subset:
469
- // 1. Filter on the `reachy_mini_js_app` tag (the mobile-embeddable subset).
470
- // 2. Dedup name collisions among JS apps only (`dedupJsApps`),
471
- // so a JS app does not get knocked out by a same-named Python
472
- // Space surfaced through `/api/apps`.
473
- // 3. Enrich each entry with `categories` + `categories_source`,
474
- // sourced from a persistent dataset cache (see categoryCache.js).
475
- //
476
- // Categories are inferred lazily by an LLM from each Space's
477
- // README. The first request after a cold start may see entries
478
- // with `categories: null` while the warmup batch is still in
479
- // flight; subsequent requests pick them up as the cache fills.
480
-
481
- /**
482
- * Pull the JS-app subset out of the raw apps cache, dedup it
483
- * within the JS scope, and fold in cached categories. Pure,
484
- * synchronous-ish (the only async call is to `getRawApps()` which
485
- * has its own cache).
486
- */
487
- async function getJsApps() {
488
- const raw = await getRawApps();
489
- const jsApps = raw.filter((a) => {
490
- const tags = a?.extra?.tags;
491
- return Array.isArray(tags) && tags.includes(JS_APP_TAG);
492
- });
493
- const deduped = dedupJsApps(jsApps);
494
-
495
- return deduped.map((app) => {
496
- const cached = categoryCache.get(app.id);
497
- return {
498
- ...app,
499
- categories: cached ? cached.categories : null,
500
- categories_source: cached ? 'inferred' : null,
501
- categorized_at: cached ? cached.categorizedAt : null,
502
- };
503
- });
504
- }
505
-
506
- /**
507
- * Run one classification pass over `jsApps`. Skips entries whose
508
- * cache is still fresh (same `lastModified`, same taxonomy).
509
- *
510
- * Serial on purpose: HF Inference Providers don't love bursts
511
- * from a single token, and total throughput on ~50 apps stays
512
- * well under a minute. We slip a small jitter between calls to
513
- * smooth the curve further.
514
- */
515
- async function runCategorizationBatch(jsApps) {
516
- if (categorizationBatchRunning) {
517
- console.log('[Categorize] Batch already running, skipping.');
518
- return;
519
- }
520
- if (!process.env.HF_TOKEN) {
521
- console.warn(
522
- '[Categorize] HF_TOKEN not set; skipping batch. Set it in .env ' +
523
- 'or the Space secrets to enable category inference.',
524
- );
525
- return;
526
- }
527
-
528
- const todo = jsApps.filter((app) =>
529
- categoryCache.needsCategorization(app.id, app?.extra?.lastModified),
530
- );
531
-
532
- if (todo.length === 0) {
533
- console.log(
534
- `[Categorize] All ${jsApps.length} JS apps are already categorized.`,
535
- );
536
- return;
537
- }
538
-
539
- categorizationBatchRunning = true;
540
- console.log(
541
- `[Categorize] Starting batch: ${todo.length}/${jsApps.length} app(s) need classification.`,
542
- );
543
-
544
- let success = 0;
545
- let failed = 0;
546
- let aborted = false;
547
-
548
- for (let i = 0; i < todo.length; i++) {
549
- const app = todo[i];
550
- const desc =
551
- app.description ||
552
- app.extra?.cardData?.short_description ||
553
- '';
554
- try {
555
- const slugs = await categorizeApp({
556
- spaceId: app.id,
557
- name: app.name,
558
- description: desc,
559
- });
560
- if (slugs == null) {
561
- failed++;
562
- console.log(
563
- `[Categorize] (${i + 1}/${todo.length}) ${app.id}: transient failure, will retry next pass`,
564
- );
565
- } else {
566
- categoryCache.set(app.id, {
567
- categories: slugs,
568
- lastModified: app.extra?.lastModified || null,
569
- });
570
- success++;
571
- console.log(
572
- `[Categorize] (${i + 1}/${todo.length}) ${app.id}: ${
573
- slugs.length ? slugs.join(', ') : '(no fit)'
574
- }`,
575
- );
576
- }
577
- } catch (err) {
578
- if (err instanceof HfTokenMissingError) {
579
- console.warn(
580
- '[Categorize] HF_TOKEN missing mid-batch; aborting cleanly.',
581
- );
582
- aborted = true;
583
- break;
584
- }
585
- failed++;
586
- console.warn(
587
- `[Categorize] (${i + 1}/${todo.length}) ${app.id}: error - ${err.message}`,
588
- );
589
- }
590
-
591
- // 250 ms cooldown between calls. Below this, the HF Provider
592
- // router occasionally rate-limits a hot token.
593
- await new Promise((resolve) => setTimeout(resolve, 250));
594
- }
595
-
596
- console.log(
597
- `[Categorize] Batch done: ${success} ok, ${failed} failed${aborted ? ' (aborted)' : ''}.`,
598
- );
599
- // Persist the new entries even if some failed - partial
600
- // progress is strictly better than none, and the failed
601
- // entries will be retried on the next pass.
602
- await categoryCache.flush();
603
-
604
- categorizationBatchRunning = false;
605
- }
606
-
607
- /**
608
- * Wrap the diagnostic snapshot for the API payload. Lets
609
- * consumers (mobile shell, website) decide whether to show
610
- * "loading categories..." or render chips immediately.
611
- */
612
- function buildCategorizationStats(jsApps) {
613
- let withCategories = 0;
614
- for (const app of jsApps) {
615
- if (app.categories && app.categories.length >= 0 && app.categories_source) {
616
- withCategories++;
617
- }
618
- }
619
- return {
620
- enabled: !!process.env.HF_TOKEN,
621
- total: jsApps.length,
622
- classified: withCategories,
623
- pending: jsApps.length - withCategories,
624
- inProgress: categorizationBatchRunning,
625
- // Authoritative taxonomy shipped alongside the apps so the
626
- // mobile shell (and any future client) doesn't have to mirror
627
- // the slug list by hand. Pairs with `taxonomyVersion` from
628
- // `categoryCache.stats()` so clients can detect drift between
629
- // the catalog payload and a stale on-device cache.
630
- taxonomy: getPublicTaxonomy(),
631
- ...categoryCache.stats(),
632
- };
633
- }
634
-
635
- app.get('/api/js-apps', async (req, res) => {
636
- try {
637
- const apps = await getJsApps();
638
-
639
- // Background top-up: if any entry is still uncategorized
640
- // (or a Space's lastModified moved since we last looked),
641
- // fire off a batch. We DO NOT await it - the response goes
642
- // out immediately with whatever the cache currently knows.
643
- const needsWork = apps.some(
644
- (a) =>
645
- !a.categories_source ||
646
- categoryCache.needsCategorization(a.id, a.extra?.lastModified),
647
- );
648
- if (needsWork) {
649
- // `void` to make it crystal clear we don't expect a value;
650
- // the batch logs its own progress.
651
- void runCategorizationBatch(apps).catch((err) => {
652
- console.error('[Categorize] Background batch crashed:', err);
653
- });
654
- }
655
-
656
- setCatalogCacheHeaders(res, appsCache.lastFetch);
657
- res.json({
658
- apps,
659
- cached: true,
660
- count: apps.length,
661
- categorization: buildCategorizationStats(apps),
662
- });
663
- } catch (err) {
664
- console.error('[API] /api/js-apps error:', err);
665
- res.status(500).json({ error: 'Failed to fetch JS apps' });
666
- }
667
- });
668
-
669
- // =====================================================================
670
- // Public taxonomy endpoint
671
- // =====================================================================
672
- //
673
- // Standalone read-only projection of the closed category taxonomy
674
- // (`server/categories.js`). Lets clients consume the slug list,
675
- // labels and emojis without paying the cost of a full apps fetch -
676
- // useful for early UI scaffolding (filter chips, empty states) and
677
- // for tooling that lints app metadata against the live taxonomy.
678
- //
679
- // `/api/js-apps` ALSO embeds the same payload under
680
- // `categorization.taxonomy`, so a mobile shell that fetches the
681
- // catalog never needs a second round-trip. This endpoint exists
682
- // for the "I just want the categories" use case.
683
- //
684
- // Cache headers: 5 minutes, same TTL as the catalog. The taxonomy
685
- // is stable across many catalog refreshes (it only moves when we
686
- // bump `TAXONOMY_VERSION`), but co-aligning the TTLs keeps the
687
- // reasoning simple - a client that polls both gets a coherent view.
688
- app.get('/api/categories', (_req, res) => {
689
- res.set('Cache-Control', 'public, max-age=300');
690
- const stats = categoryCache.stats();
691
- res.json({
692
- taxonomy: getPublicTaxonomy(),
693
- taxonomyVersion: stats.taxonomyVersion,
694
- });
695
- });
696
-
697
- // Manual trigger for a categorization sweep, useful when
698
- // hand-tuning the taxonomy or testing the LLM prompt without
699
- // waiting for the next /api/js-apps hit.
700
- app.post('/api/js-apps/refresh-categories', async (req, res) => {
701
- try {
702
- const apps = await getJsApps();
703
- void runCategorizationBatch(apps).catch((err) => {
704
- console.error('[Categorize] Manual batch crashed:', err);
705
- });
706
- res.json({
707
- ok: true,
708
- message: `Categorization batch kicked off for ${apps.length} JS apps.`,
709
- stats: buildCategorizationStats(apps),
710
- });
711
- } catch (err) {
712
- res.status(500).json({ error: 'Failed to trigger refresh' });
713
- }
714
- });
715
-
716
- // =====================================================================
717
- // OpenAI Realtime ephemeral keys
718
- // =====================================================================
719
- //
720
- // Per-user mint endpoint backing the Reachy Mini mobile shell's
721
- // voice conversation. The mobile client posts its HF Bearer token,
722
- // we validate it via `whoami-v2`, rate-limit per HF user, and
723
- // proxy a `POST /v1/realtime/sessions` to OpenAI with the master
724
- // `OPENAI_API_KEY` from this Space's secrets. The short-lived
725
- // `client_secret.value` is forwarded back to the client.
726
- //
727
- // See `server/openaiEphemeral.js` for the full design notes
728
- // (auth, caching, rate-limit shape, error mapping).
729
- app.post('/api/openai/ephemeral', mintEphemeralKeyHandler);
730
-
731
  // OAuth config endpoint - expose public OAuth variables to the frontend
732
  // (Docker Spaces don't auto-inject window.huggingface.variables like static Spaces)
733
  app.get('/api/oauth-config', (req, res) => {
@@ -758,7 +213,7 @@ app.get('/api/health', (req, res) => {
758
  app.post('/api/refresh', async (req, res) => {
759
  try {
760
  appsCache.lastFetch = null; // Invalidate cache
761
- const apps = await getRawApps();
762
  res.json({ success: true, count: apps.length });
763
  } catch (err) {
764
  res.status(500).json({ error: 'Failed to refresh cache' });
@@ -780,31 +235,8 @@ app.get('*', (req, res) => {
780
  async function warmCache() {
781
  console.log('[Startup] Pre-warming cache...');
782
  try {
783
- const apps = await getRawApps();
784
  console.log('[Startup] Cache warmed successfully');
785
-
786
- // Categorization warm-up: fire the JS-app batch in the
787
- // background so the first /api/js-apps caller doesn't
788
- // shoulder the cold-start cost. Order: load the dataset
789
- // cache first (cheap, one HTTP call), then run the batch
790
- // for stale entries only.
791
- void (async () => {
792
- try {
793
- await categoryCache.load();
794
- const jsApps = dedupJsApps(
795
- apps.filter((a) => {
796
- const tags = a?.extra?.tags;
797
- return Array.isArray(tags) && tags.includes(JS_APP_TAG);
798
- }),
799
- );
800
- console.log(
801
- `[Startup] Found ${jsApps.length} JS apps; checking categories...`,
802
- );
803
- await runCategorizationBatch(jsApps);
804
- } catch (err) {
805
- console.error('[Startup] Categorization warm-up failed:', err);
806
- }
807
- })();
808
  } catch (err) {
809
  console.error('[Startup] Failed to warm cache:', err);
810
  }
 
 
1
  import express from 'express';
 
2
  import path from 'path';
3
  import { fileURLToPath } from 'url';
4
 
 
 
 
 
 
5
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
6
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
7
  const app = express();
8
  const PORT = process.env.PORT || 7860;
9
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
10
  // Cache configuration
11
  const CACHE_TTL_MS = 5 * 60 * 1000; // 5 minutes
12
  const OFFICIAL_APP_LIST_URL = 'https://huggingface.co/datasets/pollen-robotics/reachy-mini-official-app-store/raw/main/app-list.json';
 
14
  // Note: HF API doesn't support pagination with filter=, so we use a high limit
15
  const HF_SPACES_LIMIT = 1000;
16
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
17
  // In-memory cache
18
  let appsCache = {
19
  data: null,
 
53
  const author = spaceId.split('/')[0];
54
  const name = spaceId.split('/').pop();
55
 
 
 
 
 
 
 
 
56
  return {
57
  // Core fields (used by both website and desktop)
58
  id: spaceId,
 
61
  url: `https://huggingface.co/spaces/${spaceId}`,
62
  source_kind: 'hf_space',
63
  isOfficial,
64
+
 
65
  // Extra metadata (desktop-compatible structure)
66
  extra: {
67
  id: spaceId,
 
85
  };
86
  });
87
 
88
+ // Deduplicate by name: forks keep the same repo name (e.g. 4 spaces
89
+ // named "reachy_mini_conversation_app" from different authors).
90
+ // Priority: 1) official, 2) oldest (original), 3) most likes as tiebreaker.
91
+ const deduped = new Map();
92
+ for (const app of allApps) {
93
+ const key = app.name.toLowerCase();
94
+ const existing = deduped.get(key);
95
+ if (!existing) {
96
+ deduped.set(key, app);
97
+ continue;
98
+ }
99
+ // Official always wins
100
+ if (app.isOfficial && !existing.isOfficial) {
101
+ deduped.set(key, app);
102
+ continue;
103
+ }
104
+ if (existing.isOfficial) continue;
105
+ // Oldest wins (the original is created before its forks)
106
+ const appDate = app.extra.createdAt ? new Date(app.extra.createdAt).getTime() : Infinity;
107
+ const existingDate = existing.extra.createdAt ? new Date(existing.extra.createdAt).getTime() : Infinity;
108
+ if (appDate < existingDate) {
109
+ deduped.set(key, app);
110
+ } else if (appDate === existingDate && (app.extra.likes || 0) > (existing.extra.likes || 0)) {
111
+ deduped.set(key, app);
112
+ }
113
+ }
114
+ const uniqueApps = [...deduped.values()];
115
+
116
+ console.log(`[Cache] Deduplicated ${allApps.length} → ${uniqueApps.length} apps (removed ${allApps.length - uniqueApps.length} forks with duplicate names)`);
117
 
118
+ // Sort: official first, then by likes
119
+ uniqueApps.sort((a, b) => {
 
120
  if (a.isOfficial !== b.isOfficial) {
121
  return a.isOfficial ? -1 : 1;
122
  }
123
  return (b.extra.likes || 0) - (a.extra.likes || 0);
124
  });
125
 
126
+ return uniqueApps;
127
  } catch (err) {
128
  console.error('[Cache] Error fetching apps:', err);
129
  throw err;
130
  }
131
  }
132
 
133
+ // Get apps with caching
134
+ async function getApps() {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
135
  const now = Date.now();
136
 
137
  // Return cache if valid
 
153
  const apps = await fetchAppsFromHF();
154
  appsCache.data = apps;
155
  appsCache.lastFetch = now;
156
+ console.log(`[Cache] Cache updated with ${apps.length} apps`);
157
  return apps;
158
  } catch (err) {
159
  // On error, return stale cache if available
 
170
  // API endpoint
171
  app.get('/api/apps', async (req, res) => {
172
  try {
173
+ const apps = await getApps();
 
 
174
  res.json({
175
  apps,
176
  cached: true,
177
+ cacheAge: appsCache.lastFetch ? Math.round((Date.now() - appsCache.lastFetch) / 1000) : 0,
178
  count: apps.length,
179
  });
180
  } catch (err) {
 
183
  }
184
  });
185
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
186
  // OAuth config endpoint - expose public OAuth variables to the frontend
187
  // (Docker Spaces don't auto-inject window.huggingface.variables like static Spaces)
188
  app.get('/api/oauth-config', (req, res) => {
 
213
  app.post('/api/refresh', async (req, res) => {
214
  try {
215
  appsCache.lastFetch = null; // Invalidate cache
216
+ const apps = await getApps();
217
  res.json({ success: true, count: apps.length });
218
  } catch (err) {
219
  res.status(500).json({ error: 'Failed to refresh cache' });
 
235
  async function warmCache() {
236
  console.log('[Startup] Pre-warming cache...');
237
  try {
238
+ await getApps();
239
  console.log('[Startup] Cache warmed successfully');
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
240
  } catch (err) {
241
  console.error('[Startup] Failed to warm cache:', err);
242
  }
server/openaiEphemeral.js DELETED
@@ -1,268 +0,0 @@
1
- /**
2
- * Mint per-user OpenAI Realtime ephemeral session keys.
3
- *
4
- * Why this module exists
5
- * ----------------------
6
- * The Reachy Mini mobile shell historically baked a long-lived
7
- * OpenAI API key into the bundle (`VITE_OPENAI_API_KEY`), which
8
- * violates OpenAI's terms of service and leaks the key the moment
9
- * anyone extracts the IPA/APK. This module is the server-side
10
- * replacement:
11
- *
12
- * - The master `OPENAI_API_KEY` stays in this Space's secrets,
13
- * never reachable by any client.
14
- * - The mobile shell asks for a short-lived (~1 minute)
15
- * `client_secret.value` per voice conversation, signed with
16
- * the user's Hugging Face token so we can identify + rate-limit
17
- * per HF user.
18
- * - Each ephemeral key is scoped to a single OpenAI Realtime
19
- * session, so a leak only loses ~60 seconds of model access.
20
- *
21
- * Wire format (matches OpenAI's Realtime API)
22
- * -------------------------------------------
23
- * POST /api/openai/ephemeral
24
- * Authorization: Bearer <hf_token>
25
- * Content-Type: application/json
26
- * Body: { "model"?: string, "voice"?: string }
27
- *
28
- * 200 -> the full payload from
29
- * `POST https://api.openai.com/v1/realtime/client_secrets`,
30
- * forwarded as-is. The client uses `payload.value` (the
31
- * `ek_…` ephemeral token) for the `POST /v1/realtime/calls`
32
- * WebRTC handshake.
33
- * 401 -> missing/invalid HF token
34
- * 429 -> per-user rate limit hit
35
- * 502 -> OpenAI upstream failed (key bad, model down, ...)
36
- * 503 -> OPENAI_API_KEY missing on the Space
37
- *
38
- * Why we trust HF for auth
39
- * ------------------------
40
- * The mobile shell already requires a Hugging Face sign-in to
41
- * pair with a Reachy Mini robot, so the HF token is a free
42
- * identity primitive: every legitimate caller already has one,
43
- * and HF can revoke it from their side. We resolve the token via
44
- * `whoami-v2` once per 5 minutes (cached) and use the returned
45
- * `name` as the rate-limit bucket key.
46
- */
47
-
48
- // GA endpoint. The legacy Beta endpoint
49
- // (`POST /v1/realtime/sessions`) was retired on 2026-05-07
50
- // alongside the `OpenAI-Beta: realtime=v1` header, and only
51
- // accepted preview models (`gpt-4o-realtime-preview-*`). The GA
52
- // endpoint takes a `session` envelope with a required `type`
53
- // discriminator and returns the ephemeral key at the top level
54
- // (`{ value, expires_at, session }`).
55
- const OPENAI_CLIENT_SECRETS_URL =
56
- 'https://api.openai.com/v1/realtime/client_secrets';
57
- const HF_WHOAMI_URL = 'https://huggingface.co/api/whoami-v2';
58
-
59
- // In-memory whoami cache. HF's whoami round-trip is ~150ms, and
60
- // caching it keeps the mint endpoint snappy without breaking
61
- // revocation in practice: HF's own token-revocation cache is
62
- // already eventually consistent, and our 5-minute staleness sits
63
- // well inside that.
64
- const whoamiCache = new Map();
65
- const WHOAMI_TTL_MS = 5 * 60 * 1000;
66
-
67
- // In-memory rate limiter. Per-user sliding window over 1 hour.
68
- // HF Spaces typically restart every deploy, so the limiter
69
- // implicitly resets then; that's acceptable for v1. If we ever
70
- // need durability or multi-replica fairness, swap the Map for a
71
- // shared KV (Redis, Upstash, ...) without changing the rest of
72
- // the module.
73
- const rateLimits = new Map();
74
- const RATE_LIMIT_WINDOW_MS = 60 * 60 * 1000;
75
-
76
- const DEFAULT_RATE_LIMIT_PER_HOUR = 60;
77
- // Match what the Reachy Mini mobile shell is built against today
78
- // (`features/conversation/engine/settings.ts:DEFAULT_MODEL` and
79
- // `DEFAULT_VOICE`). Bumping these requires coordinating with the
80
- // mobile client because the GA WebRTC handshake (`/v1/realtime/calls`)
81
- // negotiates the session shape against this same configuration.
82
- const DEFAULT_REALTIME_MODEL = 'gpt-realtime-2';
83
- const DEFAULT_REALTIME_VOICE = 'cedar';
84
-
85
- function getRateLimitMax() {
86
- const raw = process.env.OPENAI_EPHEMERAL_RATE_LIMIT_PER_HOUR;
87
- const parsed = raw ? Number.parseInt(raw, 10) : NaN;
88
- return Number.isFinite(parsed) && parsed > 0
89
- ? parsed
90
- : DEFAULT_RATE_LIMIT_PER_HOUR;
91
- }
92
-
93
- function getDefaultModel() {
94
- const raw = process.env.OPENAI_REALTIME_MODEL;
95
- return raw && raw.trim() !== '' ? raw.trim() : DEFAULT_REALTIME_MODEL;
96
- }
97
-
98
- class HttpError extends Error {
99
- constructor(status, message) {
100
- super(message);
101
- this.status = status;
102
- }
103
- }
104
-
105
- /**
106
- * Resolve `Bearer <token>` -> the HF user object, with a 5-minute
107
- * cache. Throws `HttpError(401)` on a rejected token so the route
108
- * can surface a clean 401 to the caller.
109
- */
110
- async function verifyHfToken(token) {
111
- const now = Date.now();
112
- const cached = whoamiCache.get(token);
113
- if (cached && cached.exp > now) return cached.user;
114
-
115
- let r;
116
- try {
117
- r = await fetch(HF_WHOAMI_URL, {
118
- headers: { Authorization: `Bearer ${token}` },
119
- });
120
- } catch (err) {
121
- // Network blip: surface a 502 so the client can retry. We
122
- // explicitly do NOT cache a failure, so a transient outage
123
- // doesn't lock the user out for 5 minutes.
124
- throw new HttpError(502, `hf whoami network error: ${err.message}`);
125
- }
126
-
127
- if (r.status === 401 || r.status === 403) {
128
- throw new HttpError(401, 'invalid hf token');
129
- }
130
- if (!r.ok) {
131
- throw new HttpError(502, `hf whoami returned ${r.status}`);
132
- }
133
-
134
- const user = await r.json().catch(() => null);
135
- // `name` is the canonical HF identifier across users + orgs;
136
- // `id` is the numeric backstop in case the schema ever shifts.
137
- if (
138
- !user ||
139
- (typeof user.name !== 'string' && typeof user.id !== 'string')
140
- ) {
141
- throw new HttpError(502, 'hf whoami returned malformed user');
142
- }
143
-
144
- whoamiCache.set(token, { user, exp: now + WHOAMI_TTL_MS });
145
- return user;
146
- }
147
-
148
- /**
149
- * Enforce the sliding-window rate limit for `userId`. Throws
150
- * `HttpError(429)` on overflow. Mutates `rateLimits` to record
151
- * the current mint timestamp.
152
- */
153
- function checkRateLimit(userId) {
154
- const now = Date.now();
155
- const windowStart = now - RATE_LIMIT_WINDOW_MS;
156
- const history = rateLimits.get(userId) || [];
157
- const recent = history.filter((t) => t > windowStart);
158
- if (recent.length >= getRateLimitMax()) {
159
- throw new HttpError(
160
- 429,
161
- `rate limit exceeded (${getRateLimitMax()}/hour)`,
162
- );
163
- }
164
- recent.push(now);
165
- rateLimits.set(userId, recent);
166
- }
167
-
168
- /**
169
- * Express handler for `POST /api/openai/ephemeral`. Stateless from
170
- * the caller's perspective: the client posts an HF Bearer token,
171
- * gets back the OpenAI Realtime session payload, uses it once.
172
- */
173
- export async function mintEphemeralKeyHandler(req, res) {
174
- try {
175
- if (!process.env.OPENAI_API_KEY) {
176
- return res
177
- .status(503)
178
- .json({ error: 'OPENAI_API_KEY not configured on this Space' });
179
- }
180
-
181
- const auth = req.headers.authorization || '';
182
- const match = auth.match(/^Bearer\s+(.+)$/i);
183
- if (!match) {
184
- return res.status(401).json({ error: 'missing bearer token' });
185
- }
186
- const hfToken = match[1].trim();
187
- if (!hfToken) {
188
- return res.status(401).json({ error: 'empty bearer token' });
189
- }
190
-
191
- const user = await verifyHfToken(hfToken);
192
- const userId = user.name || String(user.id);
193
- checkRateLimit(userId);
194
-
195
- // Caller may override model/voice for A/B tests, but the
196
- // defaults match what the mobile shell is built against. We
197
- // intentionally do NOT forward arbitrary fields from the
198
- // request body to OpenAI: only the two we validated.
199
- const body = req.body || {};
200
- const model =
201
- typeof body.model === 'string' && body.model.trim() !== ''
202
- ? body.model.trim()
203
- : getDefaultModel();
204
- const voice =
205
- typeof body.voice === 'string' && body.voice.trim() !== ''
206
- ? body.voice.trim()
207
- : DEFAULT_REALTIME_VOICE;
208
-
209
- // GA body shape: the session config sits under `session`, with
210
- // a required `type` discriminator (`"realtime"` for the voice
211
- // pipeline, `"transcription"` for transcription-only). The
212
- // mobile shell only needs `realtime`.
213
- const openaiBody = {
214
- session: {
215
- type: 'realtime',
216
- model,
217
- // The GA schema expects `audio.output.voice`. We mirror
218
- // the minimal shape: clients can still issue
219
- // `session.update` events over the data channel after
220
- // connect to tweak modalities, tools, instructions, etc.
221
- audio: {
222
- output: { voice },
223
- },
224
- },
225
- };
226
-
227
- let openaiRes;
228
- try {
229
- openaiRes = await fetch(OPENAI_CLIENT_SECRETS_URL, {
230
- method: 'POST',
231
- headers: {
232
- Authorization: `Bearer ${process.env.OPENAI_API_KEY}`,
233
- 'Content-Type': 'application/json',
234
- },
235
- body: JSON.stringify(openaiBody),
236
- });
237
- } catch (err) {
238
- console.error('[openai] mint network error:', err);
239
- return res.status(502).json({ error: 'openai network error' });
240
- }
241
-
242
- if (!openaiRes.ok) {
243
- const text = await openaiRes.text().catch(() => '');
244
- console.error(
245
- `[openai] mint failed for ${userId}: ${openaiRes.status} ${text}`,
246
- );
247
- return res.status(502).json({
248
- error: 'openai mint failed',
249
- upstreamStatus: openaiRes.status,
250
- });
251
- }
252
-
253
- const payload = await openaiRes.json();
254
- // We log the user id and the chosen model but NEVER the
255
- // client_secret. The secret stays on the wire to the
256
- // requesting client only.
257
- console.log(
258
- `[openai] minted ephemeral for ${userId} (model=${model}, voice=${voice})`,
259
- );
260
- return res.json(payload);
261
- } catch (err) {
262
- if (err instanceof HttpError) {
263
- return res.status(err.status).json({ error: err.message });
264
- }
265
- console.error('[openai] unexpected mint error:', err);
266
- return res.status(500).json({ error: 'internal error' });
267
- }
268
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
src/context/AppsContext.jsx CHANGED
@@ -105,17 +105,7 @@ export function AppsProvider({ children }) {
105
  if (response.ok) {
106
  const data = await response.json();
107
  allApps = data.apps;
108
- // Cache age moved from response body to the standard
109
- // `Age` HTTP header (RFC 7234 §5.1) so the body stays
110
- // byte-stable across same-cache-window requests and
111
- // ETag-based 304s work. `headers.get('age')` is null
112
- // on same-origin if the server didn't set it (older
113
- // deploy of the website Space): degrade gracefully to
114
- // a `?` rather than poisoning the log with `null`.
115
- const age = response.headers.get('age');
116
- console.log(
117
- `[AppsContext] Fetched ${allApps.length} apps from server cache (age: ${age ?? '?'}s)`,
118
- );
119
  } else {
120
  throw new Error('Server API not available');
121
  }
 
105
  if (response.ok) {
106
  const data = await response.json();
107
  allApps = data.apps;
108
+ console.log(`[AppsContext] Fetched ${allApps.length} apps from server cache (age: ${data.cacheAge}s)`);
 
 
 
 
 
 
 
 
 
 
109
  } else {
110
  throw new Error('Server API not available');
111
  }
src/pages/Buy.jsx CHANGED
@@ -10,7 +10,6 @@ import {
10
  CardContent,
11
  Chip,
12
  Stack,
13
- Link,
14
  Accordion,
15
  AccordionSummary,
16
  AccordionDetails,
@@ -26,7 +25,6 @@ import CheckIcon from '@mui/icons-material/Check';
26
  import CloseIcon from '@mui/icons-material/Close';
27
  import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
28
  import OpenInNewIcon from '@mui/icons-material/OpenInNew';
29
- import PublicIcon from '@mui/icons-material/Public';
30
  import LocalShippingIcon from '@mui/icons-material/LocalShipping';
31
  import InventoryIcon from '@mui/icons-material/Inventory';
32
  import SupportAgentIcon from '@mui/icons-material/SupportAgent';
@@ -40,24 +38,22 @@ const products = {
40
  wireless: {
41
  name: 'Reachy Mini',
42
  tagline: 'The complete experience',
43
- price: 499,
44
  badge: 'Wireless',
45
  badgeColor: '#0ea5e9',
46
- description: 'Self-contained robot with on-board compute. Works wirelessly or wired, perfect for standalone projects and demos. <strong>Lead time: up to 90 days after purchase</strong> (many orders ship sooner).',
47
- buyLink: 'https://store.pollen-robotics.com/products/reachy-mini-wireless-version',
48
- seeedLink: 'https://www.seeedstudio.com/Reachy-Mini-Wireless-Kit-p-6724.html',
49
  image: '/assets/reachy-wireless.png',
50
  featured: true,
51
  },
52
  lite: {
53
  name: 'Reachy Mini Lite',
54
  tagline: 'Perfect to get started',
55
- price: 399,
56
  badge: 'Lite',
57
  badgeColor: '#f59e0b',
58
- description: 'Connect to your computer via USB. Same expressive robot, powered by your machine. Ideal for development and learning. <strong>Lead time: up to 90 days after purchase</strong> (many orders ship sooner).',
59
- buyLink: 'https://store.pollen-robotics.com/products/reachy-mini-lite-version',
60
- seeedLink: 'https://www.seeedstudio.com/Reachy-Mini-Lite-Kit-p-6702.html',
61
  image: '/assets/reachy-lite.png',
62
  featured: false,
63
  },
@@ -72,7 +68,7 @@ const comparisonFeatures = [
72
  { name: 'Camera', wireless: 'Wide angle', lite: 'Wide angle' },
73
  { name: 'Microphones', wireless: '4 microphones array', lite: '4 microphones array' },
74
  { name: 'Speaker', wireless: '5W speaker', lite: '5W speaker' },
75
- { name: 'On-board Compute', wireless: 'Raspberry Pi CM 4 (16GB storage)', lite: false },
76
  { name: 'Accelerometer', wireless: 'Built-in IMU', lite: false },
77
  { name: 'Wi-Fi Connectivity', wireless: 'Wi-Fi', lite: false },
78
  { name: 'Standalone Mode', wireless: true, lite: false },
@@ -94,7 +90,7 @@ const boxContents = [
94
  const faqItems = [
95
  {
96
  question: 'What is the difference between Wireless and Lite?',
97
- answer: 'The Wireless version includes a Raspberry Pi CM 4 built-in, allowing it to run standalone without a computer. The Lite version connects to your Mac, Linux, or Windows computer via USB and uses your computer for processing. Both versions have the same mechanical design and audio/video capabilities.',
98
  },
99
  {
100
  question: 'How long does assembly take?',
@@ -102,7 +98,7 @@ const faqItems = [
102
  },
103
  {
104
  question: 'What about customs and import taxes?',
105
- answer: 'All orders ship DAP (Delivered At Place). Shipping costs and any local import duties, taxes, or customs fees are the responsibility of the buyer and are not included in the product price.',
106
  },
107
  {
108
  question: 'Can I upgrade from Lite to Wireless later?',
@@ -342,7 +338,7 @@ function ProductCardsSection() {
342
  <Stack spacing={1} sx={{ mb: 3 }}>
343
  {key === 'wireless' ? (
344
  <>
345
- <FeatureRow icon="✓" text="On-board Raspberry Pi CM 4" highlight />
346
  <FeatureRow icon="✓" text="Wi-Fi + USB connectivity" highlight />
347
  <FeatureRow icon="✓" text="Built-in IMU" highlight />
348
  </>
@@ -371,31 +367,6 @@ function ProductCardsSection() {
371
  >
372
  Buy {product.badge} — ${product.price}
373
  </Button>
374
-
375
- <Typography
376
- variant="body2"
377
- sx={{
378
- mt: 2,
379
- textAlign: 'center',
380
- fontSize: 13,
381
- fontWeight: 600,
382
- color: 'text.secondary',
383
- }}
384
- >
385
- Outside EU/UK & US/Canada?
386
- </Typography>
387
- <Button
388
- variant="outlined"
389
- size="medium"
390
- fullWidth
391
- href={product.seeedLink}
392
- target="_blank"
393
- rel="noopener noreferrer"
394
- startIcon={<PublicIcon />}
395
- sx={{ mt: 1 }}
396
- >
397
- Order via Seeed Studio
398
- </Button>
399
  </CardContent>
400
  </Card>
401
  </Grid>
@@ -408,27 +379,16 @@ function ProductCardsSection() {
408
  variant="body1"
409
  sx={{ fontWeight: 600, color: 'text.primary' }}
410
  >
411
- Lead time: up to 90 days after purchase (many orders ship sooner)
412
  </Typography>
413
  <Typography
414
  variant="body2"
415
  color="text.secondary"
416
  sx={{ maxWidth: 600, mx: 'auto', lineHeight: 1.7, mt: 1 }}
417
  >
418
- <strong>Shipping and import duties:</strong> all orders ship DAP (Delivered At Place).
419
- <br />
420
- Shipping costs and any local import duties, taxes, or customs fees are the responsibility of the buyer and are not included in the product price.
421
  <br />
422
- Our store ships to EU/UK and US/Canada. For other countries, order
423
- from our official distributor{' '}
424
- <Link
425
- href="https://www.seeedstudio.com/Reachy-Mini-Wireless-Kit-p-6724.html"
426
- target="_blank"
427
- rel="noopener noreferrer"
428
- >
429
- Seeed Studio
430
- </Link>
431
- .
432
  </Typography>
433
  </Box>
434
  </Container>
@@ -485,13 +445,13 @@ function ComparisonSection() {
485
  <TableCell align="center" sx={{ fontWeight: 700, fontSize: 15, py: 2.5 }}>
486
  <Stack direction="row" spacing={1} alignItems="center" justifyContent="center">
487
  <Chip label="Wireless" size="small" sx={{ bgcolor: '#0ea5e920', color: '#0ea5e9', fontWeight: 600 }} />
488
- <Typography fontWeight={700}>$499</Typography>
489
  </Stack>
490
  </TableCell>
491
  <TableCell align="center" sx={{ fontWeight: 700, fontSize: 15, py: 2.5 }}>
492
  <Stack direction="row" spacing={1} alignItems="center" justifyContent="center">
493
  <Chip label="Lite" size="small" sx={{ bgcolor: '#f59e0b20', color: '#f59e0b', fontWeight: 600 }} />
494
- <Typography fontWeight={700}>$399</Typography>
495
  </Stack>
496
  </TableCell>
497
  </TableRow>
@@ -682,7 +642,7 @@ function FinalCTASection() {
682
  '&:hover': { bgcolor: '#e68600' },
683
  }}
684
  >
685
- Buy Wireless — $499
686
  </Button>
687
  <Button
688
  variant="outlined"
@@ -700,7 +660,7 @@ function FinalCTASection() {
700
  },
701
  }}
702
  >
703
- Buy Lite — $399
704
  </Button>
705
  </Stack>
706
  </Container>
 
10
  CardContent,
11
  Chip,
12
  Stack,
 
13
  Accordion,
14
  AccordionSummary,
15
  AccordionDetails,
 
25
  import CloseIcon from '@mui/icons-material/Close';
26
  import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
27
  import OpenInNewIcon from '@mui/icons-material/OpenInNew';
 
28
  import LocalShippingIcon from '@mui/icons-material/LocalShipping';
29
  import InventoryIcon from '@mui/icons-material/Inventory';
30
  import SupportAgentIcon from '@mui/icons-material/SupportAgent';
 
38
  wireless: {
39
  name: 'Reachy Mini',
40
  tagline: 'The complete experience',
41
+ price: 449,
42
  badge: 'Wireless',
43
  badgeColor: '#0ea5e9',
44
+ description: 'Self-contained robot with on-board compute. Works wirelessly or wired, perfect for standalone projects and demos. <strong>Ships in 90 days</strong>.',
45
+ buyLink: 'https://buy.stripe.com/9B65kFfFlaKFbY34W873G03',
 
46
  image: '/assets/reachy-wireless.png',
47
  featured: true,
48
  },
49
  lite: {
50
  name: 'Reachy Mini Lite',
51
  tagline: 'Perfect to get started',
52
+ price: 299,
53
  badge: 'Lite',
54
  badgeColor: '#f59e0b',
55
+ description: 'Connect to your computer via USB. Same expressive robot, powered by your machine. Ideal for development and learning. <strong>Ships in 90 days</strong>.',
56
+ buyLink: 'https://buy.stripe.com/6oUfZj78P1a5e6b0FS73G02',
 
57
  image: '/assets/reachy-lite.png',
58
  featured: false,
59
  },
 
68
  { name: 'Camera', wireless: 'Wide angle', lite: 'Wide angle' },
69
  { name: 'Microphones', wireless: '4 microphones array', lite: '4 microphones array' },
70
  { name: 'Speaker', wireless: '5W speaker', lite: '5W speaker' },
71
+ { name: 'On-board Compute', wireless: 'Raspberry Pi 4 (16GB storage)', lite: false },
72
  { name: 'Accelerometer', wireless: 'Built-in IMU', lite: false },
73
  { name: 'Wi-Fi Connectivity', wireless: 'Wi-Fi', lite: false },
74
  { name: 'Standalone Mode', wireless: true, lite: false },
 
90
  const faqItems = [
91
  {
92
  question: 'What is the difference between Wireless and Lite?',
93
+ answer: 'The Wireless version includes a Raspberry Pi 4 built-in, allowing it to run standalone without a computer. The Lite version connects to your Mac, Linux, or Windows computer via USB and uses your computer for processing. Both versions have the same mechanical design and audio/video capabilities.',
94
  },
95
  {
96
  question: 'How long does assembly take?',
 
98
  },
99
  {
100
  question: 'What about customs and import taxes?',
101
+ answer: 'EU/UK and US/Canada orders ship duty-paid (DDP) no surprise fees on delivery. Other destinations ship DAP, meaning local import duties and taxes may apply upon delivery.',
102
  },
103
  {
104
  question: 'Can I upgrade from Lite to Wireless later?',
 
338
  <Stack spacing={1} sx={{ mb: 3 }}>
339
  {key === 'wireless' ? (
340
  <>
341
+ <FeatureRow icon="✓" text="On-board Raspberry Pi 4" highlight />
342
  <FeatureRow icon="✓" text="Wi-Fi + USB connectivity" highlight />
343
  <FeatureRow icon="✓" text="Built-in IMU" highlight />
344
  </>
 
367
  >
368
  Buy {product.badge} — ${product.price}
369
  </Button>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
370
  </CardContent>
371
  </Card>
372
  </Grid>
 
379
  variant="body1"
380
  sx={{ fontWeight: 600, color: 'text.primary' }}
381
  >
382
+ Current Lead time: 90 days after purchase
383
  </Typography>
384
  <Typography
385
  variant="body2"
386
  color="text.secondary"
387
  sx={{ maxWidth: 600, mx: 'auto', lineHeight: 1.7, mt: 1 }}
388
  >
389
+ <strong>Import duties:</strong> EU/UK + US/Canada ship duty-paid (DDP).
 
 
390
  <br />
391
+ Other destinations may incur local import duties/taxes on delivery (DAP).
 
 
 
 
 
 
 
 
 
392
  </Typography>
393
  </Box>
394
  </Container>
 
445
  <TableCell align="center" sx={{ fontWeight: 700, fontSize: 15, py: 2.5 }}>
446
  <Stack direction="row" spacing={1} alignItems="center" justifyContent="center">
447
  <Chip label="Wireless" size="small" sx={{ bgcolor: '#0ea5e920', color: '#0ea5e9', fontWeight: 600 }} />
448
+ <Typography fontWeight={700}>$449</Typography>
449
  </Stack>
450
  </TableCell>
451
  <TableCell align="center" sx={{ fontWeight: 700, fontSize: 15, py: 2.5 }}>
452
  <Stack direction="row" spacing={1} alignItems="center" justifyContent="center">
453
  <Chip label="Lite" size="small" sx={{ bgcolor: '#f59e0b20', color: '#f59e0b', fontWeight: 600 }} />
454
+ <Typography fontWeight={700}>$299</Typography>
455
  </Stack>
456
  </TableCell>
457
  </TableRow>
 
642
  '&:hover': { bgcolor: '#e68600' },
643
  }}
644
  >
645
+ Buy Wireless — $449
646
  </Button>
647
  <Button
648
  variant="outlined"
 
660
  },
661
  }}
662
  >
663
+ Buy Lite — $299
664
  </Button>
665
  </Stack>
666
  </Container>
src/pages/Download.jsx CHANGED
@@ -18,7 +18,6 @@ import CheckCircleIcon from '@mui/icons-material/CheckCircle';
18
  import OpenInNewIcon from '@mui/icons-material/OpenInNew';
19
  import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
20
  import ExpandLessIcon from '@mui/icons-material/ExpandLess';
21
- import DesktopWindowsIcon from '@mui/icons-material/DesktopWindows';
22
 
23
  import Layout from '../components/Layout';
24
 
@@ -66,11 +65,6 @@ function detectPlatform() {
66
  return 'darwin-aarch64';
67
  }
68
 
69
- function isMobileDevice() {
70
- const ua = navigator.userAgent;
71
- return /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(ua);
72
- }
73
-
74
  // Format date
75
  function formatDate(dateString) {
76
  const date = new Date(dateString);
@@ -180,9 +174,6 @@ function parseReleasePlatforms(assets) {
180
  const name = asset.name.toLowerCase();
181
  const url = asset.browser_download_url;
182
 
183
- // Skip signature files entirely
184
- if (name.endsWith('.sig')) return;
185
-
186
  // macOS Apple Silicon - prefer .dmg
187
  if (name.includes('arm64.dmg')) {
188
  platforms['darwin-aarch64'] = { url };
@@ -190,13 +181,13 @@ function parseReleasePlatforms(assets) {
190
  platforms['darwin-aarch64'] = { url };
191
  }
192
 
193
- // Windows - .msi
194
  if (name.endsWith('.msi')) {
195
  platforms['windows-x86_64'] = { url };
196
  }
197
 
198
  // Linux - .deb
199
- if (name.endsWith('.deb')) {
200
  platforms['linux-x86_64'] = { url };
201
  }
202
  });
@@ -321,13 +312,11 @@ export default function Download() {
321
  const [detectedPlatform, setDetectedPlatform] = useState(null);
322
  const [loading, setLoading] = useState(true);
323
  const [showAllReleases, setShowAllReleases] = useState(false);
324
- const [isMobile, setIsMobile] = useState(false);
325
 
326
  const [error, setError] = useState(null);
327
 
328
  useEffect(() => {
329
  setDetectedPlatform(detectPlatform());
330
- setIsMobile(isMobileDevice());
331
 
332
  // Fetch latest release info from GitHub API
333
  async function fetchReleases() {
@@ -532,97 +521,67 @@ export default function Download() {
532
  </Typography>
533
  </Stack>
534
 
535
- {/* Primary download button or mobile notice */}
536
- {isMobile ? (
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
537
  <Box
538
  sx={{
539
- mt: 2,
540
- p: 3,
541
- background: 'linear-gradient(135deg, rgba(255, 149, 0, 0.1) 0%, rgba(139, 92, 246, 0.08) 100%)',
542
- border: '1px solid rgba(255, 149, 0, 0.3)',
543
- borderRadius: 3,
544
  maxWidth: 500,
545
  mx: 'auto',
546
  }}
547
  >
548
- <DesktopWindowsIcon sx={{ fontSize: 40, color: 'rgba(255,255,255,0.5)', mb: 1.5 }} />
549
- <Typography
550
- variant="body1"
551
- sx={{ color: 'rgba(255,255,255,0.9)', fontWeight: 600, mb: 1 }}
552
- >
553
- Desktop only
554
- </Typography>
555
- <Typography
556
- variant="body2"
557
- sx={{ color: 'rgba(255,255,255,0.6)' }}
558
- >
559
- Reachy Mini Control is a desktop application available for macOS, Windows, and Linux. Please visit this page from a computer to download it.
560
- </Typography>
561
- </Box>
562
- ) : (
563
- <>
564
- <Button
565
- variant="contained"
566
- size="large"
567
- href={currentUrl}
568
- startIcon={<DownloadIcon />}
569
- sx={{
570
- px: 6,
571
- py: 2,
572
- fontSize: 17,
573
- fontWeight: 600,
574
- borderRadius: 3,
575
- background: 'linear-gradient(135deg, #FF9500 0%, #764ba2 100%)',
576
- boxShadow: '0 8px 32px rgba(255, 149, 0, 0.35)',
577
- transition: 'all 0.3s ease',
578
- '&:hover': {
579
- boxShadow: '0 12px 48px rgba(59, 130, 246, 0.5)',
580
- transform: 'translateY(-2px)',
581
- },
582
- }}
583
- >
584
- Download for {currentPlatform?.name}
585
- </Button>
586
-
587
  <Typography
588
  variant="body2"
589
  sx={{
590
- color: 'rgba(255,255,255,0.4)',
591
- mt: 2,
592
- fontSize: 13,
593
  }}
594
  >
595
- {currentPlatform?.subtitle} • {currentPlatform?.format?.replace('.', '').toUpperCase()} package
 
 
 
596
  </Typography>
597
-
598
- {/* Beta Warning for Windows and Linux */}
599
- {(detectedPlatform?.startsWith('windows') || detectedPlatform?.includes('linux')) && (
600
- <Box
601
- sx={{
602
- mt: 3,
603
- p: 2.5,
604
- background: 'linear-gradient(135deg, rgba(59, 130, 246, 0.1) 0%, rgba(139, 92, 246, 0.08) 100%)',
605
- border: '1px solid rgba(59, 130, 246, 0.3)',
606
- borderRadius: 2,
607
- maxWidth: 500,
608
- mx: 'auto',
609
- }}
610
- >
611
- <Typography
612
- variant="body2"
613
- sx={{
614
- color: 'rgba(255,255,255,0.8)',
615
- fontWeight: 500,
616
- }}
617
- >
618
- {detectedPlatform?.startsWith('windows')
619
- ? <>⚠️ Windows version is currently in Beta - installation requires <strong style={{ color: 'rgba(255,255,255,0.9)' }}>administrator privileges</strong>.</>
620
- : <>⚠️ Linux version is currently in Beta - please report any issues on <a href="https://github.com/pollen-robotics/reachy-mini-desktop-app/issues" target="_blank" rel="noopener noreferrer" style={{ color: '#3b82f6', textDecoration: 'underline' }}>GitHub</a> or <a href="https://discord.gg/HDrGY9eJHt" target="_blank" rel="noopener noreferrer" style={{ color: '#3b82f6', textDecoration: 'underline' }}>Discord</a>.</>
621
- }
622
- </Typography>
623
- </Box>
624
- )}
625
- </>
626
  )}
627
 
628
  {/* App screenshot */}
@@ -641,37 +600,35 @@ export default function Download() {
641
  />
642
  </Box>
643
 
644
- {/* All platforms - hidden on mobile */}
645
- {!isMobile && (
646
- <Box sx={{ mb: 8 }}>
647
- <Typography
648
- variant="overline"
649
- sx={{
650
- color: 'rgba(255,255,255,0.4)',
651
- display: 'block',
652
- textAlign: 'center',
653
- mb: 3,
654
- letterSpacing: 2,
655
- }}
656
- >
657
- Available for all platforms
658
- </Typography>
659
 
660
- <Grid container spacing={2}>
661
- {['darwin-aarch64', 'windows-x86_64', 'linux-x86_64'].map((key) => (
662
- <Grid size={{ xs: 12, sm: 4 }} key={key}>
663
- <PlatformCard
664
- platformKey={key}
665
- url={releaseData?.platforms[key]?.url}
666
- isActive={key === detectedPlatform}
667
- onClick={() => setDetectedPlatform(key)}
668
- />
669
- </Grid>
670
- ))}
671
- </Grid>
672
-
673
- </Box>
674
- )}
675
 
676
  {/* Features / What's included */}
677
  <Box
 
18
  import OpenInNewIcon from '@mui/icons-material/OpenInNew';
19
  import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
20
  import ExpandLessIcon from '@mui/icons-material/ExpandLess';
 
21
 
22
  import Layout from '../components/Layout';
23
 
 
65
  return 'darwin-aarch64';
66
  }
67
 
 
 
 
 
 
68
  // Format date
69
  function formatDate(dateString) {
70
  const date = new Date(dateString);
 
174
  const name = asset.name.toLowerCase();
175
  const url = asset.browser_download_url;
176
 
 
 
 
177
  // macOS Apple Silicon - prefer .dmg
178
  if (name.includes('arm64.dmg')) {
179
  platforms['darwin-aarch64'] = { url };
 
181
  platforms['darwin-aarch64'] = { url };
182
  }
183
 
184
+ // Windows - .msi (exclude .sig signature files)
185
  if (name.endsWith('.msi')) {
186
  platforms['windows-x86_64'] = { url };
187
  }
188
 
189
  // Linux - .deb
190
+ if (name.includes('amd64.deb')) {
191
  platforms['linux-x86_64'] = { url };
192
  }
193
  });
 
312
  const [detectedPlatform, setDetectedPlatform] = useState(null);
313
  const [loading, setLoading] = useState(true);
314
  const [showAllReleases, setShowAllReleases] = useState(false);
 
315
 
316
  const [error, setError] = useState(null);
317
 
318
  useEffect(() => {
319
  setDetectedPlatform(detectPlatform());
 
320
 
321
  // Fetch latest release info from GitHub API
322
  async function fetchReleases() {
 
521
  </Typography>
522
  </Stack>
523
 
524
+ {/* Primary download button */}
525
+ <Button
526
+ variant="contained"
527
+ size="large"
528
+ href={currentUrl}
529
+ startIcon={<DownloadIcon />}
530
+ sx={{
531
+ px: 6,
532
+ py: 2,
533
+ fontSize: 17,
534
+ fontWeight: 600,
535
+ borderRadius: 3,
536
+ background: 'linear-gradient(135deg, #FF9500 0%, #764ba2 100%)',
537
+ boxShadow: '0 8px 32px rgba(255, 149, 0, 0.35)',
538
+ transition: 'all 0.3s ease',
539
+ '&:hover': {
540
+ boxShadow: '0 12px 48px rgba(59, 130, 246, 0.5)',
541
+ transform: 'translateY(-2px)',
542
+ },
543
+ }}
544
+ >
545
+ Download for {currentPlatform?.name}
546
+ </Button>
547
+
548
+ <Typography
549
+ variant="body2"
550
+ sx={{
551
+ color: 'rgba(255,255,255,0.4)',
552
+ mt: 2,
553
+ fontSize: 13,
554
+ }}
555
+ >
556
+ {currentPlatform?.subtitle} • {currentPlatform?.format?.replace('.', '').toUpperCase()} package
557
+ </Typography>
558
+
559
+ {/* Beta Warning for Windows and Linux */}
560
+ {(detectedPlatform?.startsWith('windows') || detectedPlatform?.includes('linux')) && (
561
  <Box
562
  sx={{
563
+ mt: 3,
564
+ p: 2.5,
565
+ background: 'linear-gradient(135deg, rgba(59, 130, 246, 0.1) 0%, rgba(139, 92, 246, 0.08) 100%)',
566
+ border: '1px solid rgba(59, 130, 246, 0.3)',
567
+ borderRadius: 2,
568
  maxWidth: 500,
569
  mx: 'auto',
570
  }}
571
  >
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
572
  <Typography
573
  variant="body2"
574
  sx={{
575
+ color: 'rgba(255,255,255,0.8)',
576
+ fontWeight: 500,
 
577
  }}
578
  >
579
+ {detectedPlatform?.startsWith('windows')
580
+ ? <>⚠️ Windows version is currently in Beta — installation requires <strong style={{ color: 'rgba(255,255,255,0.9)' }}>administrator privileges</strong>.</>
581
+ : <>⚠️ Linux version is currently in Beta — please report any issues on <a href="https://github.com/pollen-robotics/reachy-mini-desktop-app/issues" target="_blank" rel="noopener noreferrer" style={{ color: '#3b82f6', textDecoration: 'underline' }}>GitHub</a> or <a href="https://discord.gg/HDrGY9eJHt" target="_blank" rel="noopener noreferrer" style={{ color: '#3b82f6', textDecoration: 'underline' }}>Discord</a>.</>
582
+ }
583
  </Typography>
584
+ </Box>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
585
  )}
586
 
587
  {/* App screenshot */}
 
600
  />
601
  </Box>
602
 
603
+ {/* All platforms */}
604
+ <Box sx={{ mb: 8 }}>
605
+ <Typography
606
+ variant="overline"
607
+ sx={{
608
+ color: 'rgba(255,255,255,0.4)',
609
+ display: 'block',
610
+ textAlign: 'center',
611
+ mb: 3,
612
+ letterSpacing: 2,
613
+ }}
614
+ >
615
+ Available for all platforms
616
+ </Typography>
 
617
 
618
+ <Grid container spacing={2}>
619
+ {['darwin-aarch64', 'windows-x86_64', 'linux-x86_64'].map((key) => (
620
+ <Grid size={{ xs: 12, sm: 4 }} key={key}>
621
+ <PlatformCard
622
+ platformKey={key}
623
+ url={releaseData?.platforms[key]?.url}
624
+ isActive={key === detectedPlatform}
625
+ onClick={() => setDetectedPlatform(key)}
626
+ />
627
+ </Grid>
628
+ ))}
629
+ </Grid>
630
+
631
+ </Box>
 
632
 
633
  {/* Features / What's included */}
634
  <Box
src/pages/GettingStarted.jsx CHANGED
@@ -1,4 +1,4 @@
1
- import { useState, useEffect } from 'react';
2
  import { Link as RouterLink, useLocation } from 'react-router-dom';
3
  import {
4
  Box,
@@ -18,7 +18,6 @@ import {
18
  } from '@mui/material';
19
  import OpenInNewIcon from '@mui/icons-material/OpenInNew';
20
  import DownloadIcon from '@mui/icons-material/Download';
21
- import DesktopWindowsIcon from '@mui/icons-material/DesktopWindows';
22
  import WifiIcon from '@mui/icons-material/Wifi';
23
  import UsbIcon from '@mui/icons-material/Usb';
24
  import CheckCircleIcon from '@mui/icons-material/CheckCircle';
@@ -142,11 +141,6 @@ function YouTubeEmbed({ videoId, title, version = 'wireless' }) {
142
  );
143
  }
144
 
145
- function isMobileDevice() {
146
- const ua = navigator.userAgent;
147
- return /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(ua);
148
- }
149
-
150
  export default function GettingStarted() {
151
  const location = useLocation();
152
  const params = new URLSearchParams(location.search);
@@ -154,11 +148,6 @@ export default function GettingStarted() {
154
  const [version, setVersion] = useState(
155
  urlVersion === 'lite' ? 'lite' : 'wireless'
156
  );
157
- const [isMobile, setIsMobile] = useState(false);
158
-
159
- useEffect(() => {
160
- setIsMobile(isMobileDevice());
161
- }, []);
162
 
163
  return (
164
  <Layout transparentHeader>
@@ -339,45 +328,23 @@ export default function GettingStarted() {
339
  <Typography variant="caption" sx={{ display: 'block', mb: 2, color: 'warning.main' }}>
340
  Desktop App available for macOS (Apple Silicon), Windows & Linux (beta).
341
  </Typography>
342
- {isMobile ? (
343
- <Box
344
- sx={{
345
- p: 2,
346
- bgcolor: 'action.hover',
347
- borderRadius: 2,
348
- border: '1px solid',
349
- borderColor: 'divider',
350
- display: 'flex',
351
- alignItems: 'center',
352
- gap: 1.5,
353
- }}
354
- >
355
- <DesktopWindowsIcon sx={{ color: 'text.secondary', fontSize: 20 }} />
356
- <Typography variant="body2" color="text.secondary">
357
- The desktop app can only be downloaded from a computer.
358
- </Typography>
359
- </Box>
360
- ) : (
361
- <>
362
- <Button
363
- variant="contained"
364
- component={RouterLink}
365
- to="/download"
366
- startIcon={<DownloadIcon/>}
367
- >
368
- Download Desktop App
369
- </Button>
370
-
371
- <Button
372
- variant="outlined"
373
- href="https://huggingface.co/docs/reachy_mini/SDK/installation"
374
- target="_blank"
375
- startIcon={<OpenInNewIcon/>}
376
- >
377
- Alternative: Python SDK
378
- </Button>
379
- </>
380
- )}
381
 
382
  </StepContent>
383
  </Step>
@@ -433,7 +400,7 @@ export default function GettingStarted() {
433
 
434
  <Typography variant="body1" color="text.secondary" sx={{ mb: 4, maxWidth: 600, mx: 'auto' }}>
435
  Follow our visual guide to put together your Reachy Mini.
436
- Most people finish in <strong>2-3 hours</strong> - our record is 43 minutes! 🏆
437
  </Typography>
438
 
439
  <Box
@@ -512,45 +479,23 @@ export default function GettingStarted() {
512
  <Typography variant="caption" sx={{ display: 'block', mb: 2, color: 'warning.main' }}>
513
  Desktop App available for macOS (Apple Silicon), Windows & Linux (beta).
514
  </Typography>
515
- {isMobile ? (
516
- <Box
517
- sx={{
518
- p: 2,
519
- bgcolor: 'action.hover',
520
- borderRadius: 2,
521
- border: '1px solid',
522
- borderColor: 'divider',
523
- display: 'flex',
524
- alignItems: 'center',
525
- gap: 1.5,
526
- }}
527
- >
528
- <DesktopWindowsIcon sx={{ color: 'text.secondary', fontSize: 20 }} />
529
- <Typography variant="body2" color="text.secondary">
530
- The desktop app can only be downloaded from a computer.
531
- </Typography>
532
- </Box>
533
- ) : (
534
- <>
535
- <Button
536
- variant="contained"
537
- component={RouterLink}
538
- to="/download"
539
- startIcon={<DownloadIcon/>}
540
- >
541
- Download Desktop App
542
- </Button>
543
-
544
- <Button
545
- variant="outlined"
546
- href="https://huggingface.co/docs/reachy_mini/SDK/installation"
547
- target="_blank"
548
- startIcon={<OpenInNewIcon/>}
549
- >
550
- Alternative: Python SDK
551
- </Button>
552
- </>
553
- )}
554
  </StepContent>
555
  </Step>
556
  <Step active completed={false}>
 
1
+ import { useState } from 'react';
2
  import { Link as RouterLink, useLocation } from 'react-router-dom';
3
  import {
4
  Box,
 
18
  } from '@mui/material';
19
  import OpenInNewIcon from '@mui/icons-material/OpenInNew';
20
  import DownloadIcon from '@mui/icons-material/Download';
 
21
  import WifiIcon from '@mui/icons-material/Wifi';
22
  import UsbIcon from '@mui/icons-material/Usb';
23
  import CheckCircleIcon from '@mui/icons-material/CheckCircle';
 
141
  );
142
  }
143
 
 
 
 
 
 
144
  export default function GettingStarted() {
145
  const location = useLocation();
146
  const params = new URLSearchParams(location.search);
 
148
  const [version, setVersion] = useState(
149
  urlVersion === 'lite' ? 'lite' : 'wireless'
150
  );
 
 
 
 
 
151
 
152
  return (
153
  <Layout transparentHeader>
 
328
  <Typography variant="caption" sx={{ display: 'block', mb: 2, color: 'warning.main' }}>
329
  Desktop App available for macOS (Apple Silicon), Windows & Linux (beta).
330
  </Typography>
331
+ <Button
332
+ variant="contained"
333
+ component={RouterLink}
334
+ to="/download"
335
+ startIcon={<DownloadIcon/>}
336
+ >
337
+ Download Desktop App
338
+ </Button>
339
+
340
+ <Button
341
+ variant="outlined"
342
+ href="https://huggingface.co/docs/reachy_mini/SDK/installation"
343
+ target="_blank"
344
+ startIcon={<OpenInNewIcon/>}
345
+ >
346
+ Alternative: Python SDK
347
+ </Button>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
348
 
349
  </StepContent>
350
  </Step>
 
400
 
401
  <Typography variant="body1" color="text.secondary" sx={{ mb: 4, maxWidth: 600, mx: 'auto' }}>
402
  Follow our visual guide to put together your Reachy Mini.
403
+ Most people finish in <strong>2-3 hours</strong> our record is 43 minutes! 🏆
404
  </Typography>
405
 
406
  <Box
 
479
  <Typography variant="caption" sx={{ display: 'block', mb: 2, color: 'warning.main' }}>
480
  Desktop App available for macOS (Apple Silicon), Windows & Linux (beta).
481
  </Typography>
482
+ <Button
483
+ variant="contained"
484
+ component={RouterLink}
485
+ to="/download"
486
+ startIcon={<DownloadIcon/>}
487
+ >
488
+ Download Desktop App
489
+ </Button>
490
+
491
+ <Button
492
+ variant="outlined"
493
+ href="https://huggingface.co/docs/reachy_mini/SDK/installation"
494
+ target="_blank"
495
+ startIcon={<OpenInNewIcon/>}
496
+ >
497
+ Alternative: Python SDK
498
+ </Button>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
499
  </StepContent>
500
  </Step>
501
  <Step active completed={false}>
src/pages/Home.jsx CHANGED
@@ -11,7 +11,6 @@ import {
11
  } from "@mui/material";
12
  import { useSpring, animated } from "@react-spring/web";
13
  import OpenInNewIcon from "@mui/icons-material/OpenInNew";
14
- import PublicIcon from "@mui/icons-material/Public";
15
  import ArrowForwardIcon from "@mui/icons-material/ArrowForward";
16
 
17
  import Layout from "../components/Layout";
@@ -658,7 +657,7 @@ function ProductsSection() {
658
  WebkitTextFillColor: "transparent",
659
  }}
660
  >
661
- $499
662
  </Typography>
663
 
664
  <Stack
@@ -666,7 +665,7 @@ function ProductsSection() {
666
  sx={{ mb: 4, textAlign: "left", maxWidth: 280, mx: "auto" }}
667
  >
668
  {[
669
- "Raspberry Pi CM 4 on-board",
670
  "Wi-Fi + USB",
671
  "Camera, 4 mics, speaker",
672
  "Accelerometer",
@@ -684,30 +683,12 @@ function ProductsSection() {
684
  variant="contained"
685
  size="large"
686
  fullWidth
687
- href="https://store.pollen-robotics.com/products/reachy-mini-wireless-version"
688
  target="_blank"
689
  sx={{ py: 1.5 }}
690
  >
691
  Order Now
692
  </Button>
693
-
694
- <Typography
695
- sx={{ mt: 2, fontSize: 13, fontWeight: 600, color: "text.secondary" }}
696
- >
697
- Outside EU/UK & US/Canada?
698
- </Typography>
699
- <Button
700
- variant="outlined"
701
- size="medium"
702
- fullWidth
703
- href="https://www.seeedstudio.com/Reachy-Mini-Wireless-Kit-p-6724.html"
704
- target="_blank"
705
- rel="noopener noreferrer"
706
- startIcon={<PublicIcon />}
707
- sx={{ mt: 1 }}
708
- >
709
- Order via Seeed Studio
710
- </Button>
711
  </Box>
712
  </Grid>
713
 
@@ -737,7 +718,7 @@ function ProductsSection() {
737
  color: "text.primary",
738
  }}
739
  >
740
- $399
741
  </Typography>
742
 
743
  <Stack
@@ -763,30 +744,12 @@ function ProductsSection() {
763
  variant="outlined"
764
  size="large"
765
  fullWidth
766
- href="https://store.pollen-robotics.com/products/reachy-mini-lite-version"
767
  target="_blank"
768
  sx={{ py: 1.5 }}
769
  >
770
  Order Now
771
  </Button>
772
-
773
- <Typography
774
- sx={{ mt: 2, fontSize: 13, fontWeight: 600, color: "text.secondary" }}
775
- >
776
- Outside EU/UK & US/Canada?
777
- </Typography>
778
- <Button
779
- variant="outlined"
780
- size="medium"
781
- fullWidth
782
- href="https://www.seeedstudio.com/Reachy-Mini-Lite-Kit-p-6702.html"
783
- target="_blank"
784
- rel="noopener noreferrer"
785
- startIcon={<PublicIcon />}
786
- sx={{ mt: 1 }}
787
- >
788
- Order via Seeed Studio
789
- </Button>
790
  </Box>
791
  </Grid>
792
  </Grid>
@@ -800,7 +763,7 @@ function ProductsSection() {
800
  fontWeight: 600,
801
  }}
802
  >
803
- Lead time: up to 90 days after purchase (many orders ship sooner)
804
  </Typography>
805
  <Typography
806
  variant="body2"
@@ -812,20 +775,9 @@ function ProductsSection() {
812
  lineHeight: 1.7,
813
  }}
814
  >
815
- <strong>Shipping and import duties:</strong> all orders ship DAP (Delivered At Place).
816
- <br />
817
- Shipping costs and any local import duties, taxes, or customs fees are the responsibility of the buyer and are not included in the product price.
818
  <br />
819
- Our store ships to EU/UK and US/Canada. For other countries, order
820
- from our official distributor{" "}
821
- <Link
822
- href="https://www.seeedstudio.com/Reachy-Mini-Wireless-Kit-p-6724.html"
823
- target="_blank"
824
- rel="noopener noreferrer"
825
- >
826
- Seeed Studio
827
- </Link>
828
- .
829
  </Typography>
830
  </Box>
831
 
 
11
  } from "@mui/material";
12
  import { useSpring, animated } from "@react-spring/web";
13
  import OpenInNewIcon from "@mui/icons-material/OpenInNew";
 
14
  import ArrowForwardIcon from "@mui/icons-material/ArrowForward";
15
 
16
  import Layout from "../components/Layout";
 
657
  WebkitTextFillColor: "transparent",
658
  }}
659
  >
660
+ $449
661
  </Typography>
662
 
663
  <Stack
 
665
  sx={{ mb: 4, textAlign: "left", maxWidth: 280, mx: "auto" }}
666
  >
667
  {[
668
+ "Raspberry Pi 4 on-board",
669
  "Wi-Fi + USB",
670
  "Camera, 4 mics, speaker",
671
  "Accelerometer",
 
683
  variant="contained"
684
  size="large"
685
  fullWidth
686
+ href="https://buy.stripe.com/9B65kFfFlaKFbY34W873G03"
687
  target="_blank"
688
  sx={{ py: 1.5 }}
689
  >
690
  Order Now
691
  </Button>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
692
  </Box>
693
  </Grid>
694
 
 
718
  color: "text.primary",
719
  }}
720
  >
721
+ $299
722
  </Typography>
723
 
724
  <Stack
 
744
  variant="outlined"
745
  size="large"
746
  fullWidth
747
+ href="https://buy.stripe.com/6oUfZj78P1a5e6b0FS73G02"
748
  target="_blank"
749
  sx={{ py: 1.5 }}
750
  >
751
  Order Now
752
  </Button>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
753
  </Box>
754
  </Grid>
755
  </Grid>
 
763
  fontWeight: 600,
764
  }}
765
  >
766
+ Current Lead time: 90 days after purchase
767
  </Typography>
768
  <Typography
769
  variant="body2"
 
775
  lineHeight: 1.7,
776
  }}
777
  >
778
+ <strong>Import duties:</strong> EU/UK + US/Canada ship duty-paid (DDP).
 
 
779
  <br />
780
+ Other destinations may incur local import duties/taxes on delivery (DAP).
 
 
 
 
 
 
 
 
 
781
  </Typography>
782
  </Box>
783
 
yarn.lock CHANGED
@@ -1031,7 +1031,7 @@ browserslist@^4.24.0:
1031
  node-releases "^2.0.27"
1032
  update-browserslist-db "^1.2.0"
1033
 
1034
- bytes@3.1.2, bytes@~3.1.2:
1035
  version "3.1.2"
1036
  resolved "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz"
1037
  integrity sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==
@@ -1124,26 +1124,6 @@ comma-separated-tokens@^2.0.0:
1124
  resolved "https://registry.npmjs.org/comma-separated-tokens/-/comma-separated-tokens-2.0.3.tgz"
1125
  integrity sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==
1126
 
1127
- compressible@~2.0.18:
1128
- version "2.0.18"
1129
- resolved "https://registry.yarnpkg.com/compressible/-/compressible-2.0.18.tgz#af53cca6b070d4c3c0750fbd77286a6d7cc46fba"
1130
- integrity sha512-AF3r7P5dWxL8MxyITRMlORQNaOA2IkAFaTr4k7BUumjPtRpGDTZpl0Pb1XCO6JeDCBdp126Cgs9sMxqSjgYyRg==
1131
- dependencies:
1132
- mime-db ">= 1.43.0 < 2"
1133
-
1134
- compression@^1.8.1:
1135
- version "1.8.1"
1136
- resolved "https://registry.yarnpkg.com/compression/-/compression-1.8.1.tgz#4a45d909ac16509195a9a28bd91094889c180d79"
1137
- integrity sha512-9mAqGPHLakhCLeNyxPkK4xVo746zQ/czLH1Ky+vkitMnWfWZps8r0qXuwhwizagCRttsL4lfG4pIOvaWLpAP0w==
1138
- dependencies:
1139
- bytes "3.1.2"
1140
- compressible "~2.0.18"
1141
- debug "2.6.9"
1142
- negotiator "~0.6.4"
1143
- on-headers "~1.1.0"
1144
- safe-buffer "5.2.1"
1145
- vary "~1.1.2"
1146
-
1147
  concat-map@0.0.1:
1148
  version "0.0.1"
1149
  resolved "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz"
@@ -2471,11 +2451,6 @@ mime-db@1.52.0:
2471
  resolved "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz"
2472
  integrity sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==
2473
 
2474
- "mime-db@>= 1.43.0 < 2":
2475
- version "1.54.0"
2476
- resolved "https://registry.yarnpkg.com/mime-db/-/mime-db-1.54.0.tgz#cddb3ee4f9c64530dff640236661d42cb6a314f5"
2477
- integrity sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==
2478
-
2479
  mime-types@~2.1.24, mime-types@~2.1.34:
2480
  version "2.1.35"
2481
  resolved "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz"
@@ -2532,11 +2507,6 @@ negotiator@0.6.3:
2532
  resolved "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz"
2533
  integrity sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==
2534
 
2535
- negotiator@~0.6.4:
2536
- version "0.6.4"
2537
- resolved "https://registry.yarnpkg.com/negotiator/-/negotiator-0.6.4.tgz#777948e2452651c570b712dd01c23e262713fff7"
2538
- integrity sha512-myRT3DiWPHqho5PrJaIRyaMv2kgYf0mUVgBNOYMuCH5Ki1yEiQaf/ZJuQ62nvpc44wL5WDbTX7yGJi1Neevw8w==
2539
-
2540
  node-releases@^2.0.27:
2541
  version "2.0.27"
2542
  resolved "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz"
@@ -2559,11 +2529,6 @@ on-finished@~2.4.1:
2559
  dependencies:
2560
  ee-first "1.1.1"
2561
 
2562
- on-headers@~1.1.0:
2563
- version "1.1.0"
2564
- resolved "https://registry.yarnpkg.com/on-headers/-/on-headers-1.1.0.tgz#59da4f91c45f5f989c6e4bcedc5a3b0aed70ff65"
2565
- integrity sha512-737ZY3yNnXy37FHkQxPzt4UZ2UWPWiCZWLvFZ4fu5cueciegX0zGPnrlY6bwRg4FdQOe9YU8MkmJwGhoMybl8A==
2566
-
2567
  optionator@^0.9.3:
2568
  version "0.9.4"
2569
  resolved "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz"
 
1031
  node-releases "^2.0.27"
1032
  update-browserslist-db "^1.2.0"
1033
 
1034
+ bytes@~3.1.2:
1035
  version "3.1.2"
1036
  resolved "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz"
1037
  integrity sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==
 
1124
  resolved "https://registry.npmjs.org/comma-separated-tokens/-/comma-separated-tokens-2.0.3.tgz"
1125
  integrity sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==
1126
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1127
  concat-map@0.0.1:
1128
  version "0.0.1"
1129
  resolved "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz"
 
2451
  resolved "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz"
2452
  integrity sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==
2453
 
 
 
 
 
 
2454
  mime-types@~2.1.24, mime-types@~2.1.34:
2455
  version "2.1.35"
2456
  resolved "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz"
 
2507
  resolved "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz"
2508
  integrity sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==
2509
 
 
 
 
 
 
2510
  node-releases@^2.0.27:
2511
  version "2.0.27"
2512
  resolved "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz"
 
2529
  dependencies:
2530
  ee-first "1.1.1"
2531
 
 
 
 
 
 
2532
  optionator@^0.9.3:
2533
  version "0.9.4"
2534
  resolved "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz"