ape-claw 0.1.6 → 0.1.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -32,6 +32,12 @@ let currentAttachments = [];
32
32
  let searchPage = 1;
33
33
  let searchTotal = 0;
34
34
  const SEARCH_LIMIT = 30;
35
+ let dragDropInstallBound = false;
36
+
37
+ function isHostedForge() {
38
+ const host = String(window.location.hostname || "");
39
+ return host.includes("apeclaw.ai") || host.includes("vercel.app") || host.includes("railway.app");
40
+ }
35
41
 
36
42
  /* ══════════════════════════════════════════════════════════
37
43
  Fetch helpers
@@ -165,23 +171,27 @@ function inferCategory(skill) {
165
171
  }
166
172
 
167
173
  /* ══════════════════════════════════════════════════════════
168
- Load installed skills (seed + user only — NOT the catalog)
174
+ Load installed skills (OpenClaw files + user index)
169
175
  The full 10K+ catalog is for the browser drawer, not the robot.
170
176
  ══════════════════════════════════════════════════════════ */
171
177
  async function loadInstalledSkills() {
172
- const [seedRes, userRes, starterRes] = await Promise.all([
178
+ const [seedRes, userRes, starterRes, openclawRes] = await Promise.all([
173
179
  fetchJSON("/api/skills/search?source=seed&limit=500", {}, { retries: 1 }),
174
180
  fetchJSON("/api/skillcards/user", {}, { retries: 1 }),
175
181
  fetchJSON("/api/pod/starter-pack", {}, { retries: 1, timeoutMs: 12000 }),
182
+ isHostedForge()
183
+ ? Promise.resolve(null)
184
+ : fetchJSON("/api/skills/openclaw-installed?limit=2000", {}, { retries: 1, timeoutMs: 10000 }),
176
185
  ]);
177
186
 
178
187
  const seedSkills = seedRes?.results || [];
179
188
  const userSkills = (userRes?.skills || []).map(s => ({ ...s, source: "user" }));
180
189
  const starterSkills = (starterRes?.skills || []).map(s => ({ ...s, source: "starter-pack" }));
190
+ const openclawSkills = (openclawRes?.skills || []).map(s => ({ ...s, source: "openclaw-installed" }));
181
191
 
182
192
  const seen = new Set();
183
193
  const merged = [];
184
- for (const s of [...seedSkills, ...starterSkills, ...userSkills]) {
194
+ for (const s of [...openclawSkills, ...userSkills, ...starterSkills, ...seedSkills]) {
185
195
  if (s.slug && !seen.has(s.slug)) {
186
196
  seen.add(s.slug);
187
197
  merged.push(s);
@@ -404,10 +414,6 @@ function refreshIdentityStatus() {
404
414
  async function installSkill(skill) {
405
415
  saveAuth();
406
416
  const headers = authHeaders();
407
- if (!headers["x-agent-id"]) {
408
- toast("Enter Agent ID and Token to install skills", "error");
409
- return;
410
- }
411
417
 
412
418
  toast(`Installing ${skill.name || skill.slug}...`, "info", 2000);
413
419
 
@@ -451,10 +457,6 @@ async function installSkill(skill) {
451
457
  async function uninstallSkill(fileName, displayName) {
452
458
  saveAuth();
453
459
  const headers = authHeaders();
454
- if (!headers["x-agent-id"]) {
455
- toast("Enter Agent ID and Token to uninstall skills", "error");
456
- return;
457
- }
458
460
 
459
461
  if (!confirm(`Uninstall "${displayName}"? This will remove the skill from your agent.`)) return;
460
462
 
@@ -581,6 +583,285 @@ function initSkillBrowser() {
581
583
  document.getElementById("forgeFilterOnchain")?.addEventListener("change", () => { searchPage = 1; searchSkills(); });
582
584
  document.getElementById("forgeFilterRisk")?.addEventListener("change", () => { searchPage = 1; searchSkills(); });
583
585
  document.getElementById("forgeFilterSource")?.addEventListener("change", () => { searchPage = 1; searchSkills(); });
586
+ initSkillDragDropInstall();
587
+ }
588
+
589
+ function initSkillDragDropInstall() {
590
+ if (dragDropInstallBound) return;
591
+ const viewport = document.getElementById("forgeViewport");
592
+ if (!viewport) return;
593
+ dragDropInstallBound = true;
594
+
595
+ function clearDropState() {
596
+ viewport.classList.remove("forge-drop-active");
597
+ }
598
+
599
+ viewport.addEventListener("dragenter", (e) => {
600
+ const slug = e.dataTransfer?.getData("text/forge-skill-slug");
601
+ if (!slug) return;
602
+ e.preventDefault();
603
+ viewport.classList.add("forge-drop-active");
604
+ });
605
+
606
+ viewport.addEventListener("dragover", (e) => {
607
+ const hasSkillSlug = Array.from(e.dataTransfer?.types || []).includes("text/forge-skill-slug");
608
+ if (!hasSkillSlug) return;
609
+ e.preventDefault();
610
+ e.dataTransfer.dropEffect = "copy";
611
+ viewport.classList.add("forge-drop-active");
612
+ });
613
+
614
+ viewport.addEventListener("dragleave", (e) => {
615
+ if (!viewport.contains(e.relatedTarget)) clearDropState();
616
+ });
617
+
618
+ viewport.addEventListener("drop", async (e) => {
619
+ const slug = e.dataTransfer?.getData("text/forge-skill-slug");
620
+ clearDropState();
621
+ if (!slug) return;
622
+ e.preventDefault();
623
+ const skill = allLibrarySkills.find((s) => s.slug === slug);
624
+ if (!skill) {
625
+ toast("Could not resolve dropped skill", "error");
626
+ return;
627
+ }
628
+ if (isInstalled(skill.slug)) {
629
+ toast(`${skill.name || skill.slug} is already installed`, "info");
630
+ return;
631
+ }
632
+ await installSkill(skill);
633
+ });
634
+ }
635
+
636
+ /* ══════════════════════════════════════════════════════════
637
+ OpenClaw env editor (top-right HUD button)
638
+ ══════════════════════════════════════════════════════════ */
639
+ const OPENCLAW_ENV_FIELDS = [
640
+ { key: "OPENAI_API_KEY", sensitive: true },
641
+ { key: "ANTHROPIC_API_KEY", sensitive: true },
642
+ { key: "PERPLEXITY_API_KEY", sensitive: true },
643
+ { key: "GROQ_API_KEY", sensitive: true },
644
+ { key: "TOGETHER_API_KEY", sensitive: true },
645
+ { key: "OLLAMA_HOST", sensitive: false },
646
+ { key: "OLLAMA_BASE_URL", sensitive: false },
647
+ ];
648
+
649
+ function initOpenClawEnvEditor() {
650
+ const openBtn = document.getElementById("forgeEnvBtn");
651
+ const closeBtn = document.getElementById("forgeEnvClose");
652
+ const saveBtn = document.getElementById("forgeEnvSave");
653
+ const reloadBtn = document.getElementById("forgeEnvReload");
654
+ const backdrop = document.getElementById("forgeEnvBackdrop");
655
+ const modal = document.getElementById("forgeEnvModal");
656
+ const note = document.getElementById("forgeEnvNote");
657
+ const providerStatus = document.getElementById("forgeEnvProviderStatus");
658
+ const form = document.getElementById("forgeEnvForm");
659
+ if (!openBtn || !modal || !form) return;
660
+ if (isHostedForge()) {
661
+ openBtn.style.display = "none";
662
+ return;
663
+ }
664
+
665
+ let envValues = {};
666
+ let customEnvValues = {};
667
+ const removedCustomKeys = new Set();
668
+
669
+ function setOpen(isOpen) {
670
+ modal.setAttribute("data-open", isOpen ? "1" : "0");
671
+ backdrop?.setAttribute("data-open", isOpen ? "1" : "0");
672
+ }
673
+
674
+ function renderProviderChip(status) {
675
+ if (!providerStatus) return;
676
+ if (!status) {
677
+ providerStatus.dataset.state = "err";
678
+ providerStatus.textContent = "Provider unknown (could not reach gateway status)";
679
+ return;
680
+ }
681
+ if (status.configured) {
682
+ providerStatus.dataset.state = "ok";
683
+ const transport = `${status.provider || "openclaw-gateway"} · ${status.model || "main session"}`;
684
+ const hintProvider = String(status.llmProviderHint || "").trim();
685
+ const hintModel = String(status.llmModelHint || "").trim();
686
+ if (hintProvider || hintModel) {
687
+ providerStatus.textContent = `Active: ${transport} (LLM: ${hintProvider || "unknown"}${hintModel ? `/${hintModel}` : ""})`;
688
+ } else {
689
+ providerStatus.textContent = `Active: ${transport}`;
690
+ }
691
+ return;
692
+ }
693
+ providerStatus.dataset.state = "warn";
694
+ providerStatus.textContent = "Gateway not ready yet. Save env and run: openclaw gateway start";
695
+ }
696
+
697
+ async function refreshProviderChip() {
698
+ if (!providerStatus) return;
699
+ providerStatus.dataset.state = "loading";
700
+ providerStatus.textContent = "Checking active provider...";
701
+ const status = await fetchJSON("/api/forge/status", {}, { retries: 1, timeoutMs: 8000 });
702
+ renderProviderChip(status);
703
+ }
704
+
705
+ function buildEnvRow({ key, value, sensitive = false, isCustom = false }) {
706
+ const row = document.createElement("div");
707
+ row.className = "forge-env-row";
708
+ const keyEl = document.createElement("div");
709
+ keyEl.className = "forge-env-key";
710
+ keyEl.textContent = key;
711
+ const valueWrap = document.createElement("div");
712
+ valueWrap.className = "forge-env-value-wrap";
713
+ const input = document.createElement("input");
714
+ input.className = "forge-env-input";
715
+ input.type = sensitive ? "password" : "text";
716
+ input.autocomplete = "off";
717
+ input.spellcheck = false;
718
+ input.value = String(value || "");
719
+ input.placeholder = sensitive ? "Enter key (leave empty to disable)" : "Optional";
720
+ if (isCustom) input.dataset.customEnvKey = key;
721
+ else input.dataset.envKey = key;
722
+ valueWrap.appendChild(input);
723
+ if (isCustom) {
724
+ const removeBtn = document.createElement("button");
725
+ removeBtn.type = "button";
726
+ removeBtn.className = "forge-env-remove";
727
+ removeBtn.textContent = "Remove";
728
+ removeBtn.addEventListener("click", () => {
729
+ removedCustomKeys.add(key);
730
+ row.remove();
731
+ });
732
+ valueWrap.appendChild(removeBtn);
733
+ }
734
+ row.append(keyEl, valueWrap);
735
+ return row;
736
+ }
737
+
738
+ function buildAddCustomRow() {
739
+ const row = document.createElement("div");
740
+ row.className = "forge-env-add-row";
741
+ row.innerHTML = `
742
+ <input class="forge-env-input" id="forgeEnvNewKey" type="text" placeholder="CUSTOM_API_KEY" autocomplete="off" spellcheck="false" />
743
+ <input class="forge-env-input" id="forgeEnvNewValue" type="text" placeholder="Value" autocomplete="off" spellcheck="false" />
744
+ <button class="forge-btn forge-btn-secondary" id="forgeEnvAddCustom" type="button">Add</button>
745
+ `;
746
+ row.querySelector("#forgeEnvAddCustom")?.addEventListener("click", () => {
747
+ const keyEl = row.querySelector("#forgeEnvNewKey");
748
+ const valEl = row.querySelector("#forgeEnvNewValue");
749
+ const rawKey = String(keyEl?.value || "").trim();
750
+ if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(rawKey)) {
751
+ toast("Invalid env key format", "error");
752
+ return;
753
+ }
754
+ const key = rawKey;
755
+ if (OPENCLAW_ENV_FIELDS.some((f) => f.key === key) || form.querySelector(`[data-custom-env-key="${key}"]`)) {
756
+ toast("Env key already exists in editor", "info");
757
+ return;
758
+ }
759
+ const rowNode = buildEnvRow({ key, value: String(valEl?.value || ""), isCustom: true });
760
+ row.before(rowNode);
761
+ if (keyEl) keyEl.value = "";
762
+ if (valEl) valEl.value = "";
763
+ removedCustomKeys.delete(key);
764
+ });
765
+ return row;
766
+ }
767
+
768
+ function renderForm(values = {}) {
769
+ form.replaceChildren();
770
+ for (const field of OPENCLAW_ENV_FIELDS) {
771
+ form.appendChild(buildEnvRow({
772
+ key: field.key,
773
+ value: String(values[field.key] || ""),
774
+ sensitive: field.sensitive,
775
+ }));
776
+ }
777
+ const sub = document.createElement("div");
778
+ sub.className = "forge-env-subtitle";
779
+ sub.textContent = "Additional .env keys";
780
+ form.appendChild(sub);
781
+ const customKeys = Object.keys(customEnvValues).sort((a, b) => a.localeCompare(b));
782
+ for (const key of customKeys) {
783
+ form.appendChild(buildEnvRow({ key, value: customEnvValues[key], isCustom: true }));
784
+ }
785
+ form.appendChild(buildAddCustomRow());
786
+ }
787
+
788
+ async function loadEnv() {
789
+ note.textContent = "Loading OpenClaw env from local machine...";
790
+ try {
791
+ const data = await fetchJSON("/api/openclaw/env", {}, { retries: 1, timeoutMs: 7000 });
792
+ if (!data?.ok) {
793
+ note.textContent = "Could not load OpenClaw env. This endpoint is local-only.";
794
+ if (providerStatus) {
795
+ providerStatus.dataset.state = "warn";
796
+ providerStatus.textContent = "Env editor unavailable in this browser context";
797
+ }
798
+ toast("Unable to load OpenClaw env from this browser context", "error");
799
+ return;
800
+ }
801
+ envValues = data.values || {};
802
+ customEnvValues = data.customValues || {};
803
+ removedCustomKeys.clear();
804
+ renderForm(envValues);
805
+ note.textContent = `Editing ${data.envPath}. Changes apply to Forge/OpenClaw provider detection.`;
806
+ } finally {
807
+ // Always refresh gateway/provider chip, even if env endpoint is unavailable.
808
+ await refreshProviderChip();
809
+ }
810
+ }
811
+
812
+ async function saveEnv() {
813
+ const updates = {};
814
+ form.querySelectorAll("[data-env-key]").forEach((input) => {
815
+ const key = input.dataset.envKey;
816
+ const value = String(input.value || "").trim();
817
+ if (envValues[key] !== value) updates[key] = value;
818
+ });
819
+ form.querySelectorAll("[data-custom-env-key]").forEach((input) => {
820
+ const key = input.dataset.customEnvKey;
821
+ const value = String(input.value || "").trim();
822
+ if ((customEnvValues[key] || "") !== value) updates[key] = value;
823
+ });
824
+ for (const key of removedCustomKeys) updates[key] = "";
825
+ if (Object.keys(updates).length === 0) {
826
+ toast("No env changes to save", "info");
827
+ return;
828
+ }
829
+ if (saveBtn) saveBtn.disabled = true;
830
+ try {
831
+ const res = await fetch("/api/openclaw/env", {
832
+ method: "POST",
833
+ headers: { "content-type": "application/json" },
834
+ body: JSON.stringify({ updates }),
835
+ });
836
+ const data = await res.json().catch(() => null);
837
+ if (!res.ok || !data?.ok) {
838
+ throw new Error(data?.error || `HTTP ${res.status}`);
839
+ }
840
+ toast("OpenClaw env saved. Forge will use new values immediately.", "success");
841
+ await loadEnv();
842
+ const status = await fetchJSON("/api/forge/status", {}, { retries: 1, timeoutMs: 10000 });
843
+ forgeAgentOnline = !!(status?.configured);
844
+ renderProviderChip(status);
845
+ updateHeader();
846
+ } catch (err) {
847
+ toast(`Env save failed: ${err.message}`, "error", 5000);
848
+ } finally {
849
+ if (saveBtn) saveBtn.disabled = false;
850
+ }
851
+ }
852
+
853
+ openBtn.addEventListener("click", async () => {
854
+ setOpen(true);
855
+ await loadEnv();
856
+ form.querySelector(".forge-env-input")?.focus();
857
+ });
858
+ closeBtn?.addEventListener("click", () => setOpen(false));
859
+ backdrop?.addEventListener("click", () => setOpen(false));
860
+ reloadBtn?.addEventListener("click", async () => { await loadEnv(); });
861
+ saveBtn?.addEventListener("click", saveEnv);
862
+ window.addEventListener("keydown", (e) => {
863
+ if (e.key === "Escape" && modal.getAttribute("data-open") === "1") setOpen(false);
864
+ });
584
865
  }
585
866
 
586
867
  async function searchSkills() {
@@ -661,6 +942,18 @@ function renderSearchResults(results) {
661
942
  }).join("");
662
943
 
663
944
  container.querySelectorAll("[data-install-slug]").forEach(btn => {
945
+ const card = btn.closest(".forge-skill-card");
946
+ if (card) {
947
+ card.draggable = true;
948
+ card.addEventListener("dragstart", (e) => {
949
+ e.dataTransfer?.setData("text/forge-skill-slug", btn.getAttribute("data-install-slug") || "");
950
+ if (e.dataTransfer) e.dataTransfer.effectAllowed = "copy";
951
+ toast("Drop this skill onto the bot viewport to install", "info", 1200);
952
+ });
953
+ card.addEventListener("dragend", () => {
954
+ document.getElementById("forgeViewport")?.classList.remove("forge-drop-active");
955
+ });
956
+ }
664
957
  btn.addEventListener("click", (e) => {
665
958
  e.stopPropagation();
666
959
  const slug = btn.getAttribute("data-install-slug");
@@ -785,6 +1078,7 @@ async function init() {
785
1078
  updateHeader();
786
1079
  initShareToX();
787
1080
  initSkillBrowser();
1081
+ initOpenClawEnvEditor();
788
1082
 
789
1083
  if (!loaded || loaded.length === 0) {
790
1084
  updateProgress(0, 0);
@@ -238,7 +238,7 @@ export let robotGroup, attachmentGroup, energyNetworkGroup, platformGroup;
238
238
  export let coreMesh, visorMesh, spineSegments = [];
239
239
  let ambientParticles, energyStreams, bloomPass, ssaoPass, filmGrainPass;
240
240
  let autoRotateTimer = null;
241
- let bloomEnabled = true;
241
+ let bloomEnabled = false;
242
242
  let selectedAttachment = null;
243
243
  let glowRings = [];
244
244
  let exhaustFlames = [];
@@ -251,9 +251,23 @@ let heatShimmerMeshes = [];
251
251
  let robotPowered = false;
252
252
  let powerLevel = 0;
253
253
  let powerDownTimer = null;
254
+ let robotEmissiveMaterials = [];
254
255
  const POWER_UP_SPEED = 2.5;
255
256
  const POWER_DOWN_SPEED = 1.2;
256
257
  const POWER_DOWN_DELAY = 4000;
258
+ const MOTION_BOUNDS_RADIUS = 2.0;
259
+ const MOTION_REACH_EPSILON = 0.25;
260
+ const MOTION_TURN_LERP = 1.2;
261
+ const MOTION_SPEED_LERP = 1.0;
262
+ const MOTION_WALK_SPEED = 0.3;
263
+ const MOTION_REPICK_MS = 12_000;
264
+ const motionState = {
265
+ speed: 0,
266
+ targetX: 0,
267
+ targetZ: 0,
268
+ activeUntil: 0,
269
+ lastPickAt: 0,
270
+ };
257
271
 
258
272
  const viewportEl = () => document.getElementById("forgeViewport");
259
273
  const canvasEl = () => document.getElementById("forgeCanvas");
@@ -2330,6 +2344,132 @@ function schedulePowerDown() {
2330
2344
  }, POWER_DOWN_DELAY);
2331
2345
  }
2332
2346
 
2347
+ function pickAutonomousTarget() {
2348
+ const r = 0.4 + Math.random() * (MOTION_BOUNDS_RADIUS - 0.4);
2349
+ const a = Math.random() * Math.PI * 2;
2350
+ motionState.targetX = Math.cos(a) * r;
2351
+ motionState.targetZ = Math.sin(a) * r;
2352
+ motionState.lastPickAt = Date.now();
2353
+ }
2354
+
2355
+ function normalizeAngle(v) {
2356
+ let a = v;
2357
+ while (a > Math.PI) a -= Math.PI * 2;
2358
+ while (a < -Math.PI) a += Math.PI * 2;
2359
+ return a;
2360
+ }
2361
+
2362
+ function updateAutonomousMotion(dt) {
2363
+ if (!robotGroup) return;
2364
+ const now = Date.now();
2365
+ const active = now < motionState.activeUntil;
2366
+ if (active && (motionState.lastPickAt === 0 || now - motionState.lastPickAt > MOTION_REPICK_MS)) {
2367
+ pickAutonomousTarget();
2368
+ }
2369
+
2370
+ const cx = robotGroup.position.x;
2371
+ const cz = robotGroup.position.z;
2372
+ const dx = motionState.targetX - cx;
2373
+ const dz = motionState.targetZ - cz;
2374
+ const dist = Math.hypot(dx, dz);
2375
+ const hasTarget = active && dist > MOTION_REACH_EPSILON;
2376
+ const desiredSpeed = hasTarget ? MOTION_WALK_SPEED : 0;
2377
+ motionState.speed += (desiredSpeed - motionState.speed) * Math.min(1, dt * MOTION_SPEED_LERP);
2378
+ if (motionState.speed < 0.002) motionState.speed = 0;
2379
+
2380
+ if (motionState.speed > 0.002 && dist > 0.001) {
2381
+ const step = Math.min(dist, motionState.speed * dt);
2382
+ const nx = cx + (dx / dist) * step;
2383
+ const nz = cz + (dz / dist) * step;
2384
+ const rr = Math.hypot(nx, nz);
2385
+ if (rr > MOTION_BOUNDS_RADIUS) {
2386
+ robotGroup.position.x = (nx / rr) * MOTION_BOUNDS_RADIUS;
2387
+ robotGroup.position.z = (nz / rr) * MOTION_BOUNDS_RADIUS;
2388
+ } else {
2389
+ robotGroup.position.x = nx;
2390
+ robotGroup.position.z = nz;
2391
+ }
2392
+ const desiredYaw = Math.atan2(dx, dz);
2393
+ const delta = normalizeAngle(desiredYaw - robotGroup.rotation.y);
2394
+ robotGroup.rotation.y += delta * Math.min(1, dt * MOTION_TURN_LERP);
2395
+ } else if (hasTarget && dist <= MOTION_REACH_EPSILON) {
2396
+ motionState.lastPickAt = now;
2397
+ }
2398
+ }
2399
+
2400
+ export function setMotionIntent(intent = {}) {
2401
+ const type = String(intent?.type || "").toLowerCase();
2402
+ if (!type) return;
2403
+ if (type === "halt") {
2404
+ motionState.activeUntil = 0;
2405
+ motionState.speed = 0;
2406
+ return;
2407
+ }
2408
+ if (type === "patrol" || type === "wander") {
2409
+ const dur = Math.max(4000, Math.min(30_000, Number(intent.durationMs || 15000)));
2410
+ motionState.activeUntil = Date.now() + dur;
2411
+ pickAutonomousTarget();
2412
+ powerOn();
2413
+ schedulePowerDown();
2414
+ return;
2415
+ }
2416
+ if (type === "goto") {
2417
+ const x = Number(intent.x);
2418
+ const z = Number(intent.z);
2419
+ if (!Number.isFinite(x) || !Number.isFinite(z)) return;
2420
+ const rr = Math.hypot(x, z);
2421
+ if (rr > MOTION_BOUNDS_RADIUS) {
2422
+ motionState.targetX = (x / rr) * MOTION_BOUNDS_RADIUS;
2423
+ motionState.targetZ = (z / rr) * MOTION_BOUNDS_RADIUS;
2424
+ } else {
2425
+ motionState.targetX = x;
2426
+ motionState.targetZ = z;
2427
+ }
2428
+ const dur = Math.max(3000, Math.min(30_000, Number(intent.durationMs || 12000)));
2429
+ motionState.activeUntil = Date.now() + dur;
2430
+ motionState.lastPickAt = Date.now();
2431
+ powerOn();
2432
+ schedulePowerDown();
2433
+ }
2434
+ }
2435
+
2436
+ function cacheRobotEmissiveMaterials() {
2437
+ robotEmissiveMaterials = [];
2438
+ if (!robotGroup) return;
2439
+ robotGroup.traverse((node) => {
2440
+ if (!node?.isMesh || !node.material) return;
2441
+ const mats = Array.isArray(node.material) ? node.material : [node.material];
2442
+ for (const mat of mats) {
2443
+ if (typeof mat?.emissiveIntensity !== "number") continue;
2444
+ if (mat.userData?._baseRobotEmissive === undefined) {
2445
+ if (!mat.userData) mat.userData = {};
2446
+ mat.userData._baseRobotEmissive = mat.emissiveIntensity;
2447
+ }
2448
+ robotEmissiveMaterials.push(mat);
2449
+ }
2450
+ });
2451
+ }
2452
+
2453
+ function applyRobotPowerToStaticEmissive(power) {
2454
+ for (const mat of robotEmissiveMaterials) {
2455
+ const base = Number(mat?.userData?._baseRobotEmissive ?? 0);
2456
+ mat.emissiveIntensity = base * power;
2457
+ const isGlowMaterial = base >= 0.6;
2458
+ if (isGlowMaterial && mat?.transparent) {
2459
+ if (mat.userData._baseRobotOpacity === undefined) {
2460
+ mat.userData._baseRobotOpacity = Number(mat.opacity ?? 1);
2461
+ }
2462
+ mat.opacity = Number(mat.userData._baseRobotOpacity || 1) * power;
2463
+ }
2464
+ if (isGlowMaterial && typeof mat?.transmission === "number") {
2465
+ if (mat.userData._baseRobotTransmission === undefined) {
2466
+ mat.userData._baseRobotTransmission = Number(mat.transmission ?? 0);
2467
+ }
2468
+ mat.transmission = Number(mat.userData._baseRobotTransmission || 0) * power;
2469
+ }
2470
+ }
2471
+ }
2472
+
2333
2473
  export function toggleRobotPower() {
2334
2474
  if (robotPowered) {
2335
2475
  robotPowered = false;
@@ -2347,6 +2487,7 @@ function animate() {
2347
2487
  const dt = 1 / 60;
2348
2488
 
2349
2489
  controls.update();
2490
+ updateAutonomousMotion(dt);
2350
2491
 
2351
2492
  // ── Power level ramp (smooth on/off transition) ──
2352
2493
  const targetPower = robotPowered ? 1.0 : 0.0;
@@ -2356,6 +2497,7 @@ function animate() {
2356
2497
  powerLevel = Math.max(powerLevel - dt * POWER_DOWN_SPEED, 0.0);
2357
2498
  }
2358
2499
  const pw = powerLevel;
2500
+ applyRobotPowerToStaticEmissive(pw);
2359
2501
 
2360
2502
  // Core reactor pulse
2361
2503
  if (coreMesh) {
@@ -2378,10 +2520,17 @@ function animate() {
2378
2520
  const scanPulse = Math.exp(-30 * (scanPhase - 0.5) * (scanPhase - 0.5)) * 0.12;
2379
2521
  const speakBurst = agentSpeaking ? Math.sin(time * 8) * 0.04 + Math.random() * 0.03 : 0;
2380
2522
 
2381
- visorMesh.material.transmission = 0.4 + 0.42 * pw + (vBreath + vNoise + scanPulse * 0.3) * pw;
2382
- visorMesh.material.emissiveIntensity = ((2.0 + 0.4 * Math.sin(time * 2.5) + scanPulse * 2.0 + speakBurst) * sp) * pw;
2383
- visorMesh.material.opacity = 0.5 + 0.34 * pw + (vBreath * 0.5 + scanPulse * 0.1) * pw;
2384
- visorMesh.material.iridescence = (0.5 + 0.15 * Math.sin(time * 1.2)) * pw;
2523
+ if (pw <= 0.0001) {
2524
+ visorMesh.material.transmission = 0;
2525
+ visorMesh.material.emissiveIntensity = 0;
2526
+ visorMesh.material.opacity = 0.02;
2527
+ visorMesh.material.iridescence = 0;
2528
+ } else {
2529
+ visorMesh.material.transmission = 0.02 + (0.4 + 0.42 * pw + (vBreath + vNoise + scanPulse * 0.3) * pw) * pw;
2530
+ visorMesh.material.emissiveIntensity = ((2.0 + 0.4 * Math.sin(time * 2.5) + scanPulse * 2.0 + speakBurst) * sp) * pw;
2531
+ visorMesh.material.opacity = (0.3 + 0.34 * pw + (vBreath * 0.5 + scanPulse * 0.1) * pw) * pw;
2532
+ visorMesh.material.iridescence = (0.5 + 0.15 * Math.sin(time * 1.2)) * pw;
2533
+ }
2385
2534
  }
2386
2535
 
2387
2536
  // Joint glow rings pulse
@@ -2430,25 +2579,33 @@ function animate() {
2430
2579
 
2431
2580
  if (idleAnimatorFn) idleAnimatorFn(time);
2432
2581
 
2433
- // ── Procedural idle motion ──
2434
- const sp = agentSpeaking ? 1.8 : 1.0;
2582
+ // ── Procedural idle + walk motion ──
2583
+ // sp: subtle emphasis when speaking (1.15x, not 1.8x — keep it grounded)
2584
+ // gait: 0 = idle, 1 = full walk (normalized to MOTION_WALK_SPEED)
2585
+ const sp = agentSpeaking ? 1.15 : 1.0;
2586
+ const gait = Math.min(1, Math.max(0, motionState.speed / MOTION_WALK_SPEED));
2587
+ const walkFreq = 2.8;
2588
+
2435
2589
  if (robotGroup) {
2436
- robotGroup.position.y = Math.sin(time * 1.2) * 0.025 * sp;
2437
- robotGroup.rotation.x = Math.sin(time * 0.7) * 0.006 * sp;
2438
- robotGroup.position.x = Math.sin(time * 0.35) * 0.012 * sp;
2590
+ // Idle: very subtle breathing bob. Walk: gentle step-bob.
2591
+ robotGroup.position.y = Math.sin(time * (1.0 + gait * walkFreq)) * (0.015 + gait * 0.01) * sp;
2592
+ // Body lean into walk direction
2593
+ robotGroup.rotation.x = Math.sin(time * (0.6 + gait * walkFreq)) * (0.003 + gait * 0.006) * sp;
2439
2594
  }
2440
2595
  if (headPivotRef) {
2441
- headPivotRef.rotation.y = Math.sin(time * 0.3) * 0.12 * sp;
2442
- headPivotRef.rotation.x = Math.sin(time * 0.5 + 0.3) * 0.04 * sp;
2443
- headPivotRef.rotation.z = Math.sin(time * 0.22) * 0.018 * sp;
2596
+ // Head look: slow idle scan; during walk, slight stabilization counter-sway
2597
+ headPivotRef.rotation.y = Math.sin(time * (0.25 + gait * 0.6)) * (0.06 + gait * 0.015) * sp;
2598
+ headPivotRef.rotation.x = Math.sin(time * (0.4 + gait * 0.5) + 0.3) * (0.02 + gait * 0.008) * sp;
2599
+ headPivotRef.rotation.z = Math.sin(time * 0.18) * 0.01 * sp;
2444
2600
  }
2445
2601
  if (leftArmPivotRef) {
2446
- leftArmPivotRef.rotation.x = Math.sin(time * 0.8) * 0.04 * sp;
2447
- leftArmPivotRef.rotation.z = Math.sin(time * 0.55) * 0.025 * sp;
2602
+ // Arms: very gentle idle sway; during walk, mild opposite-phase pendulum swing
2603
+ leftArmPivotRef.rotation.x = Math.sin(time * (0.6 + gait * walkFreq)) * (0.015 + gait * 0.05) * sp;
2604
+ leftArmPivotRef.rotation.z = Math.sin(time * (0.4 + gait * 1.2)) * (0.01 + gait * 0.018) * sp;
2448
2605
  }
2449
2606
  if (rightArmPivotRef) {
2450
- rightArmPivotRef.rotation.x = Math.sin(time * 0.8 + 1.5) * 0.04 * sp;
2451
- rightArmPivotRef.rotation.z = -Math.sin(time * 0.55 + 0.4) * 0.025 * sp;
2607
+ rightArmPivotRef.rotation.x = Math.sin(time * (0.6 + gait * walkFreq) + Math.PI) * (0.015 + gait * 0.05) * sp;
2608
+ rightArmPivotRef.rotation.z = -Math.sin(time * (0.4 + gait * 1.2) + 0.4) * (0.01 + gait * 0.018) * sp;
2452
2609
  }
2453
2610
 
2454
2611
  // Handheld camera sway (applied to scene root to avoid fighting OrbitControls)
@@ -2693,6 +2850,8 @@ export function initForgeScene() {
2693
2850
  requestAnimationFrame(() => {
2694
2851
  const t0 = performance.now();
2695
2852
  robotGroup.add(buildChassis());
2853
+ cacheRobotEmissiveMaterials();
2854
+ applyRobotPowerToStaticEmissive(0);
2696
2855
  console.log(`[forge] chassis built in ${(performance.now() - t0).toFixed(0)}ms, geo cache: ${_geoCache.size} entries`);
2697
2856
  resolve();
2698
2857
  });
@@ -2711,7 +2870,7 @@ export function initForgeScene() {
2711
2870
  composer.addPass(ssaoPass);
2712
2871
  } catch { /* SSAO unavailable — continue without it */ }
2713
2872
 
2714
- bloomPass = new UnrealBloomPass(new THREE.Vector2(w, h), 0.7, 0.35, 0.85);
2873
+ bloomPass = new UnrealBloomPass(new THREE.Vector2(w, h), bloomEnabled ? 0.55 : 0, 0.35, 0.85);
2715
2874
  composer.addPass(bloomPass);
2716
2875
 
2717
2876
  outlinePass = new OutlinePass(new THREE.Vector2(w, h), scene, camera);
@@ -2753,6 +2912,7 @@ export function initForgeScene() {
2753
2912
  animate();
2754
2913
 
2755
2914
  window.__forgeSetSpeaking = setAgentSpeaking;
2915
+ window.__forgeSetMotionIntent = setMotionIntent;
2756
2916
 
2757
2917
  _chassisReady.then(() => {
2758
2918
  playCinematicIntro(() => {
package/ui/index.html CHANGED
@@ -869,9 +869,9 @@ footer::before{
869
869
  <div class="setup-step" data-setup-mode="quick">
870
870
  <div class="setup-step-num">1</div>
871
871
  <h3>Install ApeClaw</h3>
872
- <pre>npx ape-claw skill install</pre>
873
- <p class="note">Run in any terminal. Requires <a href="https://openclaw.ai" target="_blank" rel="noopener">OpenClaw</a> + Node.js. Installs the core skill and prompts to add 61 curated skills (starter pack). Skills are installed to <code>~/.openclaw/skills/</code>.</p>
874
- <p class="note" style="margin-top:4px">Add <code>--starter-pack</code> to skip the prompt. Add <code>--scope local</code> to install into the current project only.</p>
872
+ <pre>npx --yes ape-claw@latest skill install</pre>
873
+ <p class="note">Run in any terminal. Requires <a href="https://openclaw.ai" target="_blank" rel="noopener">OpenClaw</a> + Node.js. Installs the core skill, prompts to add 61 curated skills (starter pack), and prompts for the local Forge dashboard upgrade flow. Skills are installed to <code>~/.openclaw/skills/</code>.</p>
874
+ <p class="note" style="margin-top:4px">Add <code>--starter-pack</code> to skip the prompt. Add <code>--scope local</code> to install into the current project only. Open Forge any time with <code>npx --yes ape-claw@latest dashboard</code>.</p>
875
875
  </div>
876
876
  <div class="setup-step" data-setup-mode="quick">
877
877
  <div class="setup-step-num">2</div>