File size: 9,165 Bytes
761afbd
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
#!/usr/bin/env node
// Sync canonical Reachy Mini docs from the daemon repo at build time.
//
// Why: the vibe-coder ships an agent skill that teaches an LLM how to build a
// Reachy Mini browser app. The robot knowledge and the JS SDK surface live in
// `pollen-robotics/reachy_mini`. Hand-copying them here drifts. Instead we pull
// the canonical markdown from the daemon `main` branch on every build and we
// resolve the canonical SDK pin from the npm dist-tags, then substitute it into
// the generated app template (SKILL.md).
//
// This runs inside the Docker `backend-builder` stage (via `npm run build`) and
// locally (`npm run sync-docs`). It is network-tolerant: if the registry or
// raw.githubusercontent is unreachable, it keeps the committed snapshot and
// exits 0 so the build never breaks.

import { mkdir, writeFile, readFile, readdir } from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";

const __dirname = path.dirname(fileURLToPath(import.meta.url));
const SKILL_DIR = path.resolve(
  __dirname,
  "../src/agent/skills/reachy-mini-app",
);
const DAEMON_DIR = path.join(SKILL_DIR, "daemon");
const SKILL_HUB = path.join(SKILL_DIR, "SKILL.md");

const DAEMON_REPO = "pollen-robotics/reachy_mini";
// Defaults to `main` (the durable canonical ref). Override with
// DAEMON_DOCS_REF to track a branch/tag/SHA - e.g. an unmerged docs PR that
// already carries the modern bare-HTML + CDN guidance.
const DAEMON_REF = process.env.DAEMON_DOCS_REF || "main";
const RAW_BASE = `https://raw.githubusercontent.com/${DAEMON_REPO}/${DAEMON_REF}`;

const SDK_PKG = "@pollen-robotics/reachy-mini-sdk";
const NPM_REGISTRY = `https://registry.npmjs.org/${SDK_PKG}`;
const JSDELIVR_META = `https://data.jsdelivr.com/v1/packages/npm/${SDK_PKG}`;

// Canonical daemon docs to mirror. `remote` is relative to the repo root,
// `local` is the filename written under `daemon/` (auto-discovered by the
// skill-loader, exposed to the agent via `read_skill_doc`).
const DOCS = [
  // The single source of truth for building a JS app. Its bare-HTML + CDN
  // section is the pattern this vibe-coder uses; its robotics best-practices
  // section covers safe teardown.
  { remote: "ts/APP_CREATION_GUIDE.md", local: "APP_CREATION_GUIDE.md" },
  // JS SDK runtime reference - the authoritative method/event names.
  { remote: "docs/source/SDK/javascript-sdk.md", local: "javascript-sdk.md" },
  // Top-level orientation hub.
  { remote: "AGENTS.md", local: "AGENTS.md" },
  // Robot-knowledge skills (behaviour, motion, interaction, low-level access).
  { remote: "skills/motion-philosophy.md", local: "motion-philosophy.md" },
  { remote: "skills/interaction-patterns.md", local: "interaction-patterns.md" },
  { remote: "skills/control-loops.md", local: "control-loops.md" },
  { remote: "skills/rest-api.md", local: "rest-api.md" },
  { remote: "skills/safe-torque.md", local: "safe-torque.md" },
];

const FETCH_TIMEOUT_MS = 15_000;

async function fetchText(url) {
  const ctrl = new AbortController();
  const timer = setTimeout(() => ctrl.abort(), FETCH_TIMEOUT_MS);
  try {
    const res = await fetch(url, { signal: ctrl.signal });
    if (!res.ok) throw new Error(`HTTP ${res.status}`);
    return await res.text();
  } finally {
    clearTimeout(timer);
  }
}

async function fetchJson(url) {
  return JSON.parse(await fetchText(url));
}

// ── semver (prerelease-aware) ───────────────────────────────────────────────
// Minimal comparator: numeric core dominates; a prerelease ranks below its own
// release but we only ever compare tags against each other, so the standard
// semver precedence rules are enough.
function parseSemver(v) {
  const m = /^(\d+)\.(\d+)\.(\d+)(?:-(.+))?$/.exec(String(v).trim());
  if (!m) return null;
  return {
    major: Number(m[1]),
    minor: Number(m[2]),
    patch: Number(m[3]),
    prerelease: m[4] ?? null,
  };
}

function comparePrerelease(a, b) {
  // No prerelease ranks higher than a prerelease (1.8.0 > 1.8.0-rc1).
  if (a === null && b === null) return 0;
  if (a === null) return 1;
  if (b === null) return -1;
  const as = a.split(".");
  const bs = b.split(".");
  for (let i = 0; i < Math.max(as.length, bs.length); i++) {
    const x = as[i];
    const y = bs[i];
    if (x === undefined) return -1;
    if (y === undefined) return 1;
    const xn = /^\d+$/.test(x);
    const yn = /^\d+$/.test(y);
    if (xn && yn) {
      const d = Number(x) - Number(y);
      if (d !== 0) return d < 0 ? -1 : 1;
    } else if (xn !== yn) {
      return xn ? -1 : 1; // numeric identifiers rank lower than alphanumeric
    } else if (x !== y) {
      return x < y ? -1 : 1;
    }
  }
  return 0;
}

function compareSemver(a, b) {
  const pa = parseSemver(a);
  const pb = parseSemver(b);
  if (!pa && !pb) return 0;
  if (!pa) return -1;
  if (!pb) return 1;
  for (const key of ["major", "minor", "patch"]) {
    if (pa[key] !== pb[key]) return pa[key] < pb[key] ? -1 : 1;
  }
  return comparePrerelease(pa.prerelease, pb.prerelease);
}

function semverMax(...versions) {
  return versions
    .filter(Boolean)
    .reduce((best, v) => (best && compareSemver(best, v) >= 0 ? best : v), null);
}

// ── canonical pin resolution ────────────────────────────────────────────────
// Canonical = max(latest, rc): always the newest published release, including
// a release candidate when one is ahead of the stable `latest`.
async function resolveCanonicalPin() {
  let tags = null;
  try {
    const meta = await fetchJson(NPM_REGISTRY);
    tags = meta?.["dist-tags"] ?? null;
  } catch (err) {
    console.warn(`[sync-docs] npm registry unreachable (${err.message}), trying jsDelivr`);
  }
  if (!tags) {
    try {
      const meta = await fetchJson(JSDELIVR_META);
      tags = meta?.tags ?? null;
    } catch (err) {
      console.warn(`[sync-docs] jsDelivr unreachable (${err.message})`);
    }
  }
  if (!tags) return null;
  const pin = semverMax(tags.latest, tags.rc);
  if (pin) {
    console.log(
      `[sync-docs] dist-tags latest=${tags.latest} rc=${tags.rc} -> canonical pin ${pin}`,
    );
  }
  return pin;
}

// ── doc sync ────────────────────────────────────────────────────────────────
function buildHeader(remote, pin) {
  const stamp = new Date().toISOString().slice(0, 10);
  const pinLine = pin ? ` Β· canonical SDK pin: \`${pin}\`` : "";
  return (
    `> **Auto-fetched** from [\`${DAEMON_REPO}@${DAEMON_REF}\`](https://github.com/${DAEMON_REPO}/blob/${DAEMON_REF}/${remote}) ` +
    `on ${stamp}${pinLine}.\n` +
    `> Do not edit by hand - run \`npm run sync-docs\` to refresh.\n\n`
  );
}

async function syncDocs(pin) {
  await mkdir(DAEMON_DIR, { recursive: true });
  let fetched = 0;
  let kept = 0;
  for (const { remote, local } of DOCS) {
    const dest = path.join(DAEMON_DIR, local);
    try {
      const body = await fetchText(`${RAW_BASE}/${remote}`);
      await writeFile(dest, buildHeader(remote, pin) + body, "utf-8");
      fetched++;
      console.log(`[sync-docs] βœ“ ${remote} -> daemon/${local}`);
    } catch (err) {
      // Keep whatever snapshot is already committed; never break the build.
      console.warn(
        `[sync-docs] βœ— ${remote} (${err.message}) - keeping committed snapshot`,
      );
      kept++;
    }
  }
  return { fetched, kept };
}

// ── pin substitution in SKILL.md ────────────────────────────────────────────
// Rewrites every `@pollen-robotics/reachy-mini-sdk@<version>` occurrence (the
// CDN import and the API reference) to the resolved canonical pin.
async function substitutePin(pin) {
  if (!pin) {
    console.warn("[sync-docs] no canonical pin resolved - skipping SKILL.md pin update");
    return;
  }
  let hub;
  try {
    hub = await readFile(SKILL_HUB, "utf-8");
  } catch (err) {
    console.warn(`[sync-docs] cannot read SKILL.md (${err.message}) - skipping pin update`);
    return;
  }
  const pinRe = /(@pollen-robotics\/reachy-mini-sdk@)[^/"'\s]+/g;
  const before = hub;
  hub = hub.replace(pinRe, `$1${pin}`);
  if (hub === before) {
    console.log("[sync-docs] SKILL.md already pinned to the canonical version");
    return;
  }
  await writeFile(SKILL_HUB, hub, "utf-8");
  console.log(`[sync-docs] SKILL.md SDK pin updated to ${pin}`);
}

async function main() {
  console.log("[sync-docs] syncing canonical daemon docs + SDK pin…");
  const pin = await resolveCanonicalPin();
  const { fetched, kept } = await syncDocs(pin);
  await substitutePin(pin);
  console.log(`[sync-docs] done (fetched ${fetched}, kept ${kept}/${DOCS.length}).`);
}

main().catch((err) => {
  // Last-resort guard: still exit 0 so a transient failure never breaks build.
  console.warn(`[sync-docs] unexpected error: ${err?.stack || err}`);
  process.exit(0);
});