ape-claw 0.1.3 → 0.1.5

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.
@@ -3,6 +3,7 @@
3
3
  */
4
4
 
5
5
  import fs from "node:fs";
6
+ import os from "node:os";
6
7
  import path from "node:path";
7
8
  import { ROOT as PROJECT_ROOT } from "../../lib/paths.mjs";
8
9
  import { getStorage } from "../storage/index.mjs";
@@ -21,6 +22,15 @@ function safeVersion(v) {
21
22
  return s;
22
23
  }
23
24
 
25
+ function yamlSafe(v) {
26
+ const s = String(v || "");
27
+ if (!s) return "\"\"";
28
+ if (/[:{}\[\]#&*!|>'"%@`,\n]/.test(s) || s.startsWith(" ") || s.endsWith(" ")) {
29
+ return `"${s.replace(/\\/g, "\\\\").replace(/"/g, "\\\"")}"`;
30
+ }
31
+ return s;
32
+ }
33
+
24
34
  function resolveHumanizerSlug(allSlugs) {
25
35
  const preferred = ["clawhub-humanizer", "clawhub-humanizer-2", "clawhub-afrexai-humanizer"];
26
36
  for (const s of preferred) {
@@ -103,13 +113,21 @@ function upsertUserSkill(store, auth, skillcard, sourceUrl, createdAt) {
103
113
  function installOpenClawSkillCard(skillcard, fallbackSlug = "") {
104
114
  const slugValue = toSlug(skillcard?.slug || fallbackSlug || skillcard?.name || "");
105
115
  if (!slugValue) throw new Error("invalid slug for OpenClaw install");
106
- const skillDir = path.join(PROJECT_ROOT, ".cursor", "skills", slugValue);
116
+ const skillDir = path.join(os.homedir(), ".openclaw", "skills", slugValue);
107
117
  fs.mkdirSync(skillDir, { recursive: true });
108
118
  const doc = String(skillcard?.documentation_md || "").trim();
109
119
  const name = String(skillcard?.name || slugValue).trim();
110
120
  const version = String(skillcard?.version || "1.0.0").trim();
111
121
  const description = String(skillcard?.description || "").trim();
112
- const content = doc || `---\nname: ${name}\nversion: ${version}\ndescription: ${description}\n---\n\n# ${name}\n\n${description}\n`;
122
+ if (Buffer.byteLength(doc, "utf8") > 300_000) throw new Error("documentation_md too large");
123
+ const frontmatter = `---\nname: ${slugValue}\nversion: ${yamlSafe(version)}\ndescription: ${yamlSafe(description.slice(0, 300))}\n---\n`;
124
+ let content;
125
+ if (doc) {
126
+ const stripped = doc.replace(/^---[\s\S]*?---\s*/, "").trim();
127
+ content = `${frontmatter}\n${stripped}\n`;
128
+ } else {
129
+ content = `${frontmatter}\n# ${name}\n\n${description}\n`;
130
+ }
113
131
  fs.writeFileSync(path.join(skillDir, "SKILL.md"), content, "utf8");
114
132
  return { slug: slugValue, skillDir };
115
133
  }
@@ -117,7 +135,7 @@ function installOpenClawSkillCard(skillcard, fallbackSlug = "") {
117
135
  function uninstallOpenClawSkillBySlug(slug) {
118
136
  const s = toSlug(slug || "");
119
137
  if (!s) return { removed: null, missing: null };
120
- const skillDir = path.join(PROJECT_ROOT, ".cursor", "skills", s);
138
+ const skillDir = path.join(os.homedir(), ".openclaw", "skills", s);
121
139
  const skillMd = path.join(skillDir, "SKILL.md");
122
140
  if (!fs.existsSync(skillMd)) {
123
141
  return { removed: null, missing: { slug: s, reason: "SKILL.md not found" } };
@@ -137,7 +155,7 @@ export function handleSkillsSearch(req, res, reqUrl) {
137
155
  const sourceFilter = String(reqUrl.searchParams.get("source") || "").trim().toLowerCase();
138
156
  const vettedFilter = String(reqUrl.searchParams.get("vetted") || "").trim();
139
157
  const page = Math.max(1, Number(reqUrl.searchParams.get("page") || 1));
140
- const limit = Math.min(5000, Math.max(1, Number(reqUrl.searchParams.get("limit") || 50)));
158
+ const limit = Math.min(15000, Math.max(1, Number(reqUrl.searchParams.get("limit") || 50)));
141
159
  let results = store.getMergedSkillIndex();
142
160
  if (sourceFilter && ["seed", "bundled", "imported", "user"].includes(sourceFilter)) results = results.filter((s) => s.source === sourceFilter);
143
161
  if (vettedFilter === "1") results = results.filter((s) => s.vettedOk === true);
@@ -152,8 +170,23 @@ export function handleSkillsSearch(req, res, reqUrl) {
152
170
  const total = results.length;
153
171
  const pages = Math.ceil(total / limit);
154
172
  const start = (page - 1) * limit;
155
- res.writeHead(200, { "content-type": "application/json; charset=utf-8" });
156
- return res.end(JSON.stringify({ ok: true, total, page, limit, pages, results: results.slice(start, start + limit) }));
173
+ const paginatedResults = results.slice(start, start + limit);
174
+ const slim = reqUrl.searchParams.get("slim") === "1";
175
+ const payload = slim
176
+ ? paginatedResults.map((s) => ({
177
+ name: s.name, slug: s.slug, description: s.description,
178
+ riskTier: s.riskTier, source: s.source, vettedOk: s.vettedOk,
179
+ fileName: s.fileName || null,
180
+ onchainTokenId: s.onchainTokenId || null,
181
+ onchainMintTx: s.onchainMintTx || null,
182
+ onchainPublishTx: s.onchainPublishTx || null,
183
+ }))
184
+ : paginatedResults;
185
+ res.writeHead(200, {
186
+ "content-type": "application/json; charset=utf-8",
187
+ "cache-control": "public, max-age=120, s-maxage=300, stale-while-revalidate=600",
188
+ });
189
+ return res.end(JSON.stringify({ ok: true, total, page, limit, pages, results: payload }));
157
190
  } catch (err) {
158
191
  res.writeHead(500, { "content-type": "application/json; charset=utf-8" });
159
192
  return res.end(JSON.stringify({ ok: false, error: err.message || "search failed" }));
@@ -21,6 +21,7 @@ const SKILLCARDS_USER_INDEX_PATH = path.join(SKILLCARDS_USER_DIR, "index.json");
21
21
  const SKILLCARDS_SEED_DIR = path.join(PROJECT_ROOT, "skillcards", "seed");
22
22
  const SKILLCARDS_BUNDLED_DIR = path.join(PROJECT_ROOT, "data", "skills");
23
23
  const SKILLCARDS_IMPORTED_INDEX_PATH = path.join(PROJECT_ROOT, "skillcards", "imported", "index.json");
24
+ const SKILLCARDS_SNAPSHOT_PATH = path.join(PROJECT_ROOT, "data", "skills-search.json");
24
25
 
25
26
  const MERGED_INDEX_CACHE_TTL_MS = 60_000;
26
27
  let mergedSkillIndexCache = { data: null, expiresAt: 0 };
@@ -146,6 +147,36 @@ function buildMergedSkillIndex() {
146
147
  }
147
148
  } catch { /* skip */ }
148
149
 
150
+ // If all live sources are empty, load from the pre-built snapshot so the
151
+ // API never returns 0 skills (covers deployment misconfigs, missing volumes, etc).
152
+ if (merged.length === 0) {
153
+ try {
154
+ if (fs.existsSync(SKILLCARDS_SNAPSHOT_PATH)) {
155
+ const snap = JSON.parse(fs.readFileSync(SKILLCARDS_SNAPSHOT_PATH, "utf8"));
156
+ const results = Array.isArray(snap?.results) ? snap.results : [];
157
+ for (const s of results) {
158
+ if (s && typeof s === "object" && (s.name || s.slug)) {
159
+ merged.push({
160
+ name: String(s.name || s.slug || "").trim(),
161
+ slug: String(s.slug || "").trim(),
162
+ description: String(s.description || "").trim(),
163
+ fileName: String(s.fileName || "").trim() || null,
164
+ source: String(s.source || "imported").trim(),
165
+ vettedOk: Boolean(s.vettedOk),
166
+ importOk: Boolean(s.importOk !== false),
167
+ riskTier: Number(s.riskTier ?? 2),
168
+ sourceUrl: String(s.sourceUrl || "").trim() || null,
169
+ provenance: s.provenance || { publisher: "snapshot", signed: false },
170
+ onchainTokenId: s.onchainTokenId || null,
171
+ onchainMintTx: s.onchainMintTx || null,
172
+ onchainPublishTx: s.onchainPublishTx || null,
173
+ });
174
+ }
175
+ }
176
+ }
177
+ } catch { /* snapshot unavailable */ }
178
+ }
179
+
149
180
  // Deduplicate by slug while preserving source precedence:
150
181
  // seed < imported < user (later entries override earlier ones).
151
182
  const bySlug = new Map();
@@ -96,6 +96,7 @@ if (!fs.existsSync(SKILLCARDS_USER_INDEX_PATH)) {
96
96
  const SKILLCARDS_SEED_DIR = path.join(ROOT, "skillcards", "seed");
97
97
  const SKILLCARDS_BUNDLED_DIR = path.join(ROOT, "data", "skills");
98
98
  const SKILLCARDS_IMPORTED_INDEX_PATH = path.join(ROOT, "skillcards", "imported", "index.json");
99
+ const SKILLCARDS_SNAPSHOT_PATH = path.join(ROOT, "data", "skills-search.json");
99
100
 
100
101
  // Cache for merged skill index (60 seconds TTL)
101
102
  let mergedSkillIndexCache = { data: null, expiresAt: 0 };
@@ -232,6 +233,37 @@ function buildMergedSkillIndex() {
232
233
  // Skip if user index doesn't exist or can't be read
233
234
  }
234
235
 
236
+ // If all live sources are empty, load from the pre-built snapshot so the
237
+ // API never returns 0 skills (covers path misconfigs, missing symlinks, etc).
238
+ if (merged.length === 0) {
239
+ try {
240
+ if (fs.existsSync(SKILLCARDS_SNAPSHOT_PATH)) {
241
+ const snap = JSON.parse(fs.readFileSync(SKILLCARDS_SNAPSHOT_PATH, "utf8"));
242
+ const results = Array.isArray(snap?.results) ? snap.results : [];
243
+ for (const s of results) {
244
+ if (s && typeof s === "object" && (s.name || s.slug)) {
245
+ merged.push({
246
+ name: String(s.name || s.slug || "").trim(),
247
+ slug: String(s.slug || "").trim(),
248
+ description: String(s.description || "").trim(),
249
+ fileName: String(s.fileName || "").trim() || null,
250
+ source: String(s.source || "imported").trim(),
251
+ vettedOk: Boolean(s.vettedOk),
252
+ importOk: Boolean(s.importOk !== false),
253
+ riskTier: Number(s.riskTier ?? 2),
254
+ sourceUrl: String(s.sourceUrl || "").trim() || null,
255
+ provenance: s.provenance || { publisher: "snapshot", signed: false },
256
+ onchainTokenId: s.onchainTokenId || null,
257
+ onchainMintTx: s.onchainMintTx || null,
258
+ onchainPublishTx: s.onchainPublishTx || null,
259
+ });
260
+ }
261
+ }
262
+ console.log(`[skills] Loaded ${merged.length} skills from snapshot fallback`);
263
+ }
264
+ } catch { /* snapshot unavailable */ }
265
+ }
266
+
235
267
  // Deduplicate by slug while preserving source precedence:
236
268
  // seed < imported < user (later entries override earlier ones).
237
269
  const bySlug = new Map();
@@ -840,7 +872,7 @@ const server = http.createServer((req, res) => {
840
872
  const sourceFilter = String(reqUrl.searchParams.get("source") || "").trim().toLowerCase();
841
873
  const vettedFilter = String(reqUrl.searchParams.get("vetted") || "").trim();
842
874
  const page = Math.max(1, Number(reqUrl.searchParams.get("page") || 1));
843
- const limit = Math.min(5000, Math.max(1, Number(reqUrl.searchParams.get("limit") || 50)));
875
+ const limit = Math.min(15000, Math.max(1, Number(reqUrl.searchParams.get("limit") || 50)));
844
876
 
845
877
  let results = getMergedSkillIndex();
846
878
 
@@ -871,7 +903,22 @@ const server = http.createServer((req, res) => {
871
903
  const end = start + limit;
872
904
  const paginatedResults = results.slice(start, end);
873
905
 
874
- res.writeHead(200, { "content-type": "application/json; charset=utf-8" });
906
+ const slim = reqUrl.searchParams.get("slim") === "1";
907
+ const payload = slim
908
+ ? paginatedResults.map((s) => ({
909
+ name: s.name, slug: s.slug, description: s.description,
910
+ riskTier: s.riskTier, source: s.source, vettedOk: s.vettedOk,
911
+ fileName: s.fileName || null,
912
+ onchainTokenId: s.onchainTokenId || null,
913
+ onchainMintTx: s.onchainMintTx || null,
914
+ onchainPublishTx: s.onchainPublishTx || null,
915
+ }))
916
+ : paginatedResults;
917
+
918
+ res.writeHead(200, {
919
+ "content-type": "application/json; charset=utf-8",
920
+ "cache-control": "public, max-age=120, s-maxage=300, stale-while-revalidate=600",
921
+ });
875
922
  return res.end(
876
923
  JSON.stringify({
877
924
  ok: true,
@@ -879,7 +926,7 @@ const server = http.createServer((req, res) => {
879
926
  page,
880
927
  limit,
881
928
  pages,
882
- results: paginatedResults,
929
+ results: payload,
883
930
  }),
884
931
  );
885
932
  } catch (err) {
@@ -921,6 +968,42 @@ const server = http.createServer((req, res) => {
921
968
  return res.end(JSON.stringify({ ok: false, error: err.message || "get failed" }));
922
969
  }
923
970
  }
971
+ // Path-based skill lookup: /api/skills/<slug> (used by CLI and frontend)
972
+ if (pathname.startsWith("/api/skills/") && req.method === "GET" &&
973
+ pathname !== "/api/skills/search" && pathname !== "/api/skills/get" && pathname !== "/api/skills/stats") {
974
+ try {
975
+ const slug = decodeURIComponent(pathname.slice("/api/skills/".length)).trim();
976
+ if (!slug) {
977
+ res.writeHead(400, { "content-type": "application/json; charset=utf-8" });
978
+ return res.end(JSON.stringify({ ok: false, error: "missing slug" }));
979
+ }
980
+ const all = getMergedSkillIndex();
981
+ const match = all.find((s) => s.slug === slug);
982
+ if (!match) {
983
+ res.writeHead(404, { "content-type": "application/json; charset=utf-8" });
984
+ return res.end(JSON.stringify({ ok: false, error: "skill not found" }));
985
+ }
986
+ let fullCard = null;
987
+ const bucketDirs = {
988
+ seed: SKILLCARDS_SEED_DIR,
989
+ bundled: SKILLCARDS_BUNDLED_DIR,
990
+ imported: path.join(ROOT, "skillcards", "imported"),
991
+ user: SKILLCARDS_USER_DIR,
992
+ };
993
+ const baseDir = bucketDirs[match.source];
994
+ if (baseDir && match.fileName) {
995
+ try {
996
+ const fp = path.join(baseDir, match.fileName);
997
+ if (fs.existsSync(fp)) fullCard = JSON.parse(fs.readFileSync(fp, "utf8"));
998
+ } catch { /* index metadata is still returned below */ }
999
+ }
1000
+ res.writeHead(200, { "content-type": "application/json; charset=utf-8" });
1001
+ return res.end(JSON.stringify({ ok: true, skill: match, card: fullCard }));
1002
+ } catch (err) {
1003
+ res.writeHead(500, { "content-type": "application/json; charset=utf-8" });
1004
+ return res.end(JSON.stringify({ ok: false, error: err.message || "get failed" }));
1005
+ }
1006
+ }
924
1007
  if (pathname === "/api/skills/stats" && req.method === "GET") {
925
1008
  try {
926
1009
  const all = getMergedSkillIndex();
@@ -937,7 +1020,10 @@ const server = http.createServer((req, res) => {
937
1020
  riskTier: s.riskTier, description: String(s.description || "").slice(0, 150),
938
1021
  onchainTokenId: s.onchainTokenId ?? null,
939
1022
  }));
940
- res.writeHead(200, { "content-type": "application/json; charset=utf-8" });
1023
+ res.writeHead(200, {
1024
+ "content-type": "application/json; charset=utf-8",
1025
+ "cache-control": "public, max-age=120, s-maxage=300, stale-while-revalidate=600",
1026
+ });
941
1027
  return res.end(JSON.stringify({ ok: true, total: all.length, seed, bundled, imported, user, vetted, onchain, recent }));
942
1028
  } catch (err) {
943
1029
  res.writeHead(500, { "content-type": "application/json; charset=utf-8" });
@@ -39,7 +39,7 @@ a{color:var(--neon-cyan);text-decoration:none}
39
39
 
40
40
  /* ── Layout ─────────────────────────────────────────────── */
41
41
  .forge-app{
42
- display:flex;flex-direction:column;min-height:100vh;position:relative;z-index:3;
42
+ display:flex;flex-direction:column;height:100vh;height:100dvh;position:relative;z-index:3;overflow:hidden;
43
43
  }
44
44
 
45
45
  /* ── Header ─────────────────────────────────────────────── */
@@ -63,7 +63,7 @@ a{color:var(--neon-cyan);text-decoration:none}
63
63
 
64
64
  /* ── Viewport ───────────────────────────────────────────── */
65
65
  .forge-viewport{
66
- position:relative;flex:1;min-height:400px;background:#000;overflow:hidden;
66
+ position:relative;flex:1 1 0%;min-height:0;background:#000;overflow:hidden;
67
67
  }
68
68
  .forge-viewport canvas{display:block;width:100%;height:100%}
69
69
  #forgeLabelLayer{position:absolute;inset:0;pointer-events:none;overflow:hidden}
@@ -196,6 +196,19 @@ a{color:var(--neon-cyan);text-decoration:none}
196
196
  .forge-chat .chat-msg-name{font-size:.72rem;font-weight:700;color:var(--accent)}
197
197
  .forge-chat .chat-msg-time{font-family:var(--font-mono);font-size:.58rem;color:var(--dim);margin-left:auto}
198
198
  .forge-chat .chat-msg-text{font-size:.75rem;line-height:1.5;color:var(--text);word-break:break-word}
199
+ .forge-chat .chat-msg-text strong{color:#eefad6;font-weight:700}
200
+ .forge-chat .chat-msg-text code{
201
+ font-family:var(--font-mono);font-size:.68rem;
202
+ background:rgba(99,215,255,.08);border:1px solid rgba(99,215,255,.2);
203
+ color:#bce9ff;padding:1px 4px;border-radius:4px;
204
+ }
205
+ .forge-chat .chat-msg-text pre{
206
+ margin:6px 0;padding:8px;border-radius:6px;
207
+ background:rgba(0,0,0,.35);border:1px solid var(--border);
208
+ overflow-x:auto;
209
+ }
210
+ .forge-chat .chat-msg-text ul{margin:6px 0 6px 14px;padding:0}
211
+ .forge-chat .chat-msg-text li{margin:2px 0}
199
212
  .forge-chat-input-bar{
200
213
  display:flex;gap:8px;padding:10px 12px;border-top:1px solid var(--border);
201
214
  background:linear-gradient(180deg,rgba(207,255,4,.02),transparent),var(--surface2);
@@ -441,6 +454,21 @@ a{color:var(--neon-cyan);text-decoration:none}
441
454
  border-radius:6px;padding:40px 36px;text-align:center;position:relative;
442
455
  box-shadow:0 0 80px rgba(207,255,4,.06),var(--panel-shadow);
443
456
  }
457
+ .forge-offline-btn-agent{
458
+ background:linear-gradient(135deg,var(--accent),#a0d800);color:#0c0c0c;
459
+ font-size:.85rem;padding:11px 28px;border:none;cursor:pointer;
460
+ box-shadow:0 0 20px rgba(207,255,4,.25),0 0 40px rgba(207,255,4,.1);
461
+ animation:forge-agent-pulse 2s ease-in-out infinite;
462
+ }
463
+ .forge-offline-btn-agent:hover{
464
+ background:linear-gradient(135deg,#e2ff40,#cfff04);
465
+ box-shadow:0 0 28px rgba(207,255,4,.4),0 0 60px rgba(207,255,4,.15);
466
+ transform:translateY(-1px);
467
+ }
468
+ @keyframes forge-agent-pulse{
469
+ 0%,100%{box-shadow:0 0 20px rgba(207,255,4,.25),0 0 40px rgba(207,255,4,.1)}
470
+ 50%{box-shadow:0 0 28px rgba(207,255,4,.4),0 0 56px rgba(207,255,4,.18)}
471
+ }
444
472
  .forge-offline-icon{font-size:3.5rem;margin-bottom:12px;filter:drop-shadow(0 0 18px rgba(207,255,4,.35))}
445
473
  .forge-offline-title{
446
474
  font-family:var(--font-display);font-weight:900;font-size:1.6rem;letter-spacing:.06em;
@@ -95,6 +95,7 @@
95
95
  <span class="icon">&#x1F4AC;</span> Agent Chat
96
96
  <span class="badge" id="forgeChatBadge">0</span>
97
97
  </div>
98
+ <div class="forge-chat-agent-indicator" id="forgeChatAgentIndicator" style="display:none;padding:4px 10px;font-size:11px;opacity:.7;font-family:'JetBrains Mono',monospace;">&#x1F99E; Talking to The Clawllector (OpenClaw agent)</div>
98
99
  <div class="forge-chat-messages" id="forgeChatMessages">
99
100
  <div class="chat-empty">
100
101
  <div class="chat-empty-icon">&#x1F4AC;</div>
@@ -102,8 +103,8 @@
102
103
  </div>
103
104
  </div>
104
105
  <div class="forge-chat-input-bar">
105
- <input type="text" id="forgeChatInput" placeholder="Message your agent..." maxlength="500" disabled>
106
- <button class="chat-send-btn" id="forgeChatSendBtn" type="button" disabled>Send</button>
106
+ <input type="text" id="forgeChatInput" placeholder="Message your agent..." maxlength="500">
107
+ <button class="chat-send-btn" id="forgeChatSendBtn" type="button">Send</button>
107
108
  </div>
108
109
  <div class="chat-input-meta">
109
110
  <span class="chat-counter" id="forgeChatCounter">0/500</span>
@@ -178,7 +179,7 @@
178
179
  <div class="forge-offline-divider"></div>
179
180
  <p class="forge-offline-desc">
180
181
  The Forge renders <strong>your</strong> installed skills as attachments on a 3D robot.
181
- It discovers skills from your local Cursor &amp; OpenClaw directories, so it must be
182
+ It discovers skills from your local OpenClaw directories, so it must be
182
183
  hosted on your machine.
183
184
  </p>
184
185
  <div class="forge-offline-steps">
@@ -200,7 +201,7 @@
200
201
  <span class="forge-offline-step-num">3</span>
201
202
  <div>
202
203
  <strong>Open the Forge</strong>
203
- <code>http://localhost:8799/forge</code>
204
+ <code>http://localhost:8787/forge</code>
204
205
  <span class="forge-offline-hint">Your installed skills appear as 3D attachments on the robot automatically</span>
205
206
  </div>
206
207
  </div>
@@ -208,9 +209,10 @@
208
209
  <div class="forge-offline-divider"></div>
209
210
  <p class="forge-offline-note">
210
211
  The Forge reads skills installed via <code>npx ape-claw skill install</code>.<br>
211
- Skills are synced to <code>~/.cursor/skills/</code> and <code>~/.openclaw/workspace/skills/</code> automatically.
212
+ Skills are installed to <code>~/.openclaw/skills/</code> and synced to <code>~/.openclaw/workspace/skills/</code> automatically. <strong>Requires <a href="https://openclaw.ai" style="color:var(--accent)">OpenClaw</a>.</strong>
212
213
  </p>
213
214
  <div class="forge-offline-links">
215
+ <button class="forge-offline-btn forge-offline-btn-agent" id="forgeTryAgent" type="button">&#x1F99E; Try Our Agent</button>
214
216
  <a class="forge-offline-btn" href="/skills">Browse Skills</a>
215
217
  <a class="forge-offline-btn forge-offline-btn-secondary" href="https://github.com/simplefarmer69/ape-claw" target="_blank" rel="noopener">GitHub</a>
216
218
  </div>
@@ -221,12 +223,23 @@
221
223
  (async function detectLocal() {
222
224
  try {
223
225
  const r = await fetch("/api/health", { signal: AbortSignal.timeout(4000) });
224
- if (r.ok) return; // local server running — load the forge normally
226
+ if (r.ok) return;
225
227
  } catch {}
226
- // Not local — show the offline gate and hide the forge app
227
228
  document.getElementById("forgeOfflineGate").style.display = "";
228
229
  const app = document.querySelector(".forge-app");
229
230
  if (app) app.style.display = "none";
231
+
232
+ document.getElementById("forgeTryAgent").addEventListener("click", function() {
233
+ document.getElementById("forgeOfflineGate").style.display = "none";
234
+ if (app) app.style.display = "";
235
+ const chatInput = document.getElementById("forgeChatInput");
236
+ if (chatInput) {
237
+ chatInput.disabled = false;
238
+ chatInput.focus();
239
+ }
240
+ const chatBtn = document.getElementById("forgeChatSendBtn");
241
+ if (chatBtn) chatBtn.disabled = false;
242
+ });
230
243
  })();
231
244
  </script>
232
245
 
@@ -7,6 +7,7 @@
7
7
  * floating disconnected in space.
8
8
  */
9
9
  import * as THREE from "three";
10
+ import { RoundedBoxGeometry } from "three/addons/geometries/RoundedBoxGeometry.js";
10
11
  import { MAT } from "./forge-scene.js";
11
12
 
12
13
  /* ══════════════════════════════════════════════════════════
@@ -95,9 +96,12 @@ const RIVET_C = 0xd0d8e0;
95
96
 
96
97
  function armorPlate(mat, w, h, d) {
97
98
  const group = new THREE.Group();
98
- const body = new THREE.Mesh(new THREE.BoxGeometry(w, h, d), mat);
99
+ const minDim = Math.min(w, h, d);
100
+ const bodyGeo = minDim > 0.08
101
+ ? new RoundedBoxGeometry(w, h, d, 2, minDim * 0.12)
102
+ : new THREE.BoxGeometry(w, h, d);
103
+ const body = new THREE.Mesh(bodyGeo, mat);
99
104
  group.add(body);
100
- // Raised edge frame
101
105
  const frameMat = mat.clone();
102
106
  frameMat.color = new THREE.Color(FRAME_C);
103
107
  frameMat.emissiveIntensity = (frameMat.emissiveIntensity || 0) * 0.4;
@@ -106,14 +110,12 @@ function armorPlate(mat, w, h, d) {
106
110
  group.add(new THREE.Mesh(new THREE.BoxGeometry(w + bw * 2, bw, d + bw), frameMat).translateY(-h / 2));
107
111
  group.add(new THREE.Mesh(new THREE.BoxGeometry(bw, h, d + bw), frameMat).translateX(w / 2));
108
112
  group.add(new THREE.Mesh(new THREE.BoxGeometry(bw, h, d + bw), frameMat).translateX(-w / 2));
109
- // Center glow line
110
113
  const glowMat = mat.clone();
111
114
  glowMat.emissiveIntensity = (glowMat.emissiveIntensity || 0.5) * 2.5;
112
115
  glowMat.transparent = true; glowMat.opacity = 0.7;
113
116
  group.add(new THREE.Mesh(new THREE.BoxGeometry(w * 0.6, 0.015, d * 0.3), glowMat).translateZ(d / 2 + 0.005));
114
- // Mounting rivets
115
- const rMat = new THREE.MeshStandardMaterial({ color: RIVET_C, metalness: 0.9, roughness: 0.15 });
116
- const rGeo = new THREE.SphereGeometry(0.018, 5, 3);
117
+ const rMat = new THREE.MeshPhysicalMaterial({ color: RIVET_C, metalness: 0.95, roughness: 0.08, clearcoat: 1.0, clearcoatRoughness: 0.0 });
118
+ const rGeo = new THREE.SphereGeometry(0.018, 8, 6);
117
119
  for (const [rx, ry] of [[-1, -1], [1, -1], [-1, 1], [1, 1]]) {
118
120
  const rv = new THREE.Mesh(rGeo, rMat);
119
121
  rv.position.set(rx * w * 0.4, ry * h * 0.35, d / 2 + 0.01);
@@ -125,21 +127,18 @@ function armorPlate(mat, w, h, d) {
125
127
  function hexPlate(mat, r, h) {
126
128
  const group = new THREE.Group();
127
129
  group.add(new THREE.Mesh(new THREE.CylinderGeometry(r, r, h, 6), mat));
128
- // Inner hex detail (recessed)
129
130
  const innerMat = mat.clone();
130
131
  innerMat.emissiveIntensity = (innerMat.emissiveIntensity || 0.5) * 1.8;
131
132
  innerMat.transparent = true; innerMat.opacity = 0.6;
132
133
  const inner = new THREE.Mesh(new THREE.CylinderGeometry(r * 0.55, r * 0.55, h + 0.01, 6), innerMat);
133
134
  group.add(inner);
134
- // Center glow dot
135
135
  const dotMat = mat.clone();
136
136
  dotMat.emissiveIntensity = (dotMat.emissiveIntensity || 0.5) * 3;
137
- group.add(new THREE.Mesh(new THREE.SphereGeometry(r * 0.18, 8, 6), dotMat).translateY(h / 2 + 0.01));
138
- // Edge ring
137
+ group.add(new THREE.Mesh(new THREE.SphereGeometry(r * 0.18, 12, 8), dotMat).translateY(h / 2 + 0.01));
139
138
  const edgeMat = mat.clone();
140
139
  edgeMat.emissiveIntensity = (edgeMat.emissiveIntensity || 0.5) * 2;
141
140
  edgeMat.transparent = true; edgeMat.opacity = 0.5;
142
- const edge = new THREE.Mesh(new THREE.TorusGeometry(r, r * 0.06, 6, 6), edgeMat);
141
+ const edge = new THREE.Mesh(new THREE.TorusGeometry(r, r * 0.06, 10, 6), edgeMat);
143
142
  edge.rotation.x = Math.PI / 2; edge.position.y = h / 2 + 0.005;
144
143
  group.add(edge);
145
144
  return group;
@@ -147,25 +146,21 @@ function hexPlate(mat, r, h) {
147
146
 
148
147
  function energyModule(mat, r) {
149
148
  const group = new THREE.Group();
150
- const core = new THREE.Mesh(new THREE.OctahedronGeometry(r * 0.6), mat);
149
+ const core = new THREE.Mesh(new THREE.OctahedronGeometry(r * 0.6, 1), mat);
151
150
  group.add(core);
152
- // Inner glow sphere
153
151
  const gMat = mat.clone();
154
152
  gMat.emissiveIntensity = (gMat.emissiveIntensity || 1) * 3;
155
153
  gMat.transparent = true; gMat.opacity = 0.5;
156
- group.add(new THREE.Mesh(new THREE.SphereGeometry(r * 0.3, 10, 8), gMat));
157
- // Primary ring
158
- const ring = new THREE.Mesh(new THREE.TorusGeometry(r, r * 0.1, 8, 24), mat.clone());
154
+ group.add(new THREE.Mesh(new THREE.SphereGeometry(r * 0.3, 16, 12), gMat));
155
+ const ring = new THREE.Mesh(new THREE.TorusGeometry(r, r * 0.1, 12, 32), mat.clone());
159
156
  ring.material.transparent = true; ring.material.opacity = 0.6;
160
157
  ring.rotation.x = Math.PI / 2;
161
158
  group.add(ring);
162
- // Secondary ring (orthogonal)
163
- const ring2 = new THREE.Mesh(new THREE.TorusGeometry(r * 0.8, r * 0.06, 6, 18), mat.clone());
159
+ const ring2 = new THREE.Mesh(new THREE.TorusGeometry(r * 0.8, r * 0.06, 10, 24), mat.clone());
164
160
  ring2.material.transparent = true; ring2.material.opacity = 0.4;
165
161
  ring2.rotation.z = Math.PI / 2;
166
162
  group.add(ring2);
167
- // Mounting bracket (small arm)
168
- const bMat = new THREE.MeshStandardMaterial({ color: FRAME_C, metalness: 0.5, roughness: 0.5 });
163
+ const bMat = new THREE.MeshPhysicalMaterial({ color: FRAME_C, metalness: 0.6, roughness: 0.4, clearcoat: 0.3, clearcoatRoughness: 0.4 });
169
164
  group.add(new THREE.Mesh(new THREE.BoxGeometry(0.03, r * 1.2, 0.03), bMat).translateX(-r * 0.9));
170
165
  return group;
171
166
  }
@@ -195,24 +190,20 @@ function bladeFin(mat, h, w) {
195
190
  transparent: true, opacity: 0.6,
196
191
  blending: THREE.AdditiveBlending,
197
192
  })));
198
- // Mount base
199
- const bMat = new THREE.MeshStandardMaterial({ color: FRAME_C, metalness: 0.5, roughness: 0.5 });
200
- group.add(new THREE.Mesh(new THREE.CylinderGeometry(0.025, 0.04, 0.06, 6), bMat).translateY(-h / 2 - 0.03));
193
+ const bMat = new THREE.MeshPhysicalMaterial({ color: FRAME_C, metalness: 0.6, roughness: 0.4, clearcoat: 0.3, clearcoatRoughness: 0.4 });
194
+ group.add(new THREE.Mesh(new THREE.CylinderGeometry(0.025, 0.04, 0.06, 8), bMat).translateY(-h / 2 - 0.03));
201
195
  return group;
202
196
  }
203
197
 
204
198
  function turretModule(mat, r) {
205
199
  const group = new THREE.Group();
206
- // Barrel
207
- const bMat = new THREE.MeshStandardMaterial({ color: FRAME_C, metalness: 0.6, roughness: 0.4 });
208
- group.add(new THREE.Mesh(new THREE.CylinderGeometry(r * 0.18, r * 0.22, r * 2.5, 8), bMat).rotateX(Math.PI / 2).translateY(r * 0.6));
209
- // Mount base
210
- group.add(new THREE.Mesh(new THREE.CylinderGeometry(r * 0.6, r * 0.7, r * 0.4, 8), mat));
211
- // Glow ring around base
200
+ const bMat = new THREE.MeshPhysicalMaterial({ color: FRAME_C, metalness: 0.7, roughness: 0.3, clearcoat: 0.4, clearcoatRoughness: 0.3 });
201
+ group.add(new THREE.Mesh(new THREE.CylinderGeometry(r * 0.18, r * 0.22, r * 2.5, 12), bMat).rotateX(Math.PI / 2).translateY(r * 0.6));
202
+ group.add(new THREE.Mesh(new THREE.CylinderGeometry(r * 0.6, r * 0.7, r * 0.4, 12), mat));
212
203
  const gMat = mat.clone();
213
204
  gMat.emissiveIntensity = (gMat.emissiveIntensity || 0.5) * 2.5;
214
205
  gMat.transparent = true; gMat.opacity = 0.5;
215
- const ring = new THREE.Mesh(new THREE.TorusGeometry(r * 0.65, r * 0.06, 6, 16), gMat);
206
+ const ring = new THREE.Mesh(new THREE.TorusGeometry(r * 0.65, r * 0.06, 10, 24), gMat);
216
207
  ring.rotation.x = Math.PI / 2;
217
208
  group.add(ring);
218
209
  return group;
@@ -220,17 +211,14 @@ function turretModule(mat, r) {
220
211
 
221
212
  function sensorDome(mat, r) {
222
213
  const group = new THREE.Group();
223
- group.add(new THREE.Mesh(new THREE.SphereGeometry(r, 12, 8, 0, Math.PI * 2, 0, Math.PI / 2), mat));
224
- // Base ring
225
- const bMat = new THREE.MeshStandardMaterial({ color: FRAME_C, metalness: 0.5, roughness: 0.5 });
226
- group.add(new THREE.Mesh(new THREE.CylinderGeometry(r * 1.1, r * 1.1, r * 0.15, 10), bMat));
227
- // Antenna spike
228
- group.add(new THREE.Mesh(new THREE.ConeGeometry(r * 0.08, r * 1.2, 5), mat.clone()).translateY(r * 0.9));
229
- // Glow band
214
+ group.add(new THREE.Mesh(new THREE.SphereGeometry(r, 24, 16, 0, Math.PI * 2, 0, Math.PI / 2), mat));
215
+ const bMat = new THREE.MeshPhysicalMaterial({ color: FRAME_C, metalness: 0.6, roughness: 0.4, clearcoat: 0.3, clearcoatRoughness: 0.4 });
216
+ group.add(new THREE.Mesh(new THREE.CylinderGeometry(r * 1.1, r * 1.1, r * 0.15, 16), bMat));
217
+ group.add(new THREE.Mesh(new THREE.ConeGeometry(r * 0.08, r * 1.2, 8), mat.clone()).translateY(r * 0.9));
230
218
  const gMat = mat.clone();
231
219
  gMat.emissiveIntensity = (gMat.emissiveIntensity || 0.5) * 2.5;
232
220
  gMat.transparent = true; gMat.opacity = 0.5;
233
- const ring = new THREE.Mesh(new THREE.TorusGeometry(r * 0.7, r * 0.04, 6, 16), gMat);
221
+ const ring = new THREE.Mesh(new THREE.TorusGeometry(r * 0.7, r * 0.04, 10, 24), gMat);
234
222
  ring.rotation.x = Math.PI / 2; ring.position.y = r * 0.3;
235
223
  group.add(ring);
236
224
  return group;