ape-claw 0.1.0

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.
Files changed (114) hide show
  1. package/.cursor/skills/ape-claw/SKILL.md +322 -0
  2. package/LICENSE +21 -0
  3. package/README.md +826 -0
  4. package/allowlists/opensea-slug-overrides.json +13 -0
  5. package/allowlists/recommended.apechain.json +322 -0
  6. package/config/clawbots.example.json +3 -0
  7. package/config/policy.example.json +27 -0
  8. package/data/starter-pack-bundle.json +1 -0
  9. package/data/starter-pack.json +495 -0
  10. package/docs/ACP_BOUNTIES.md +108 -0
  11. package/docs/APECLAW_V2_ALPHA.md +206 -0
  12. package/docs/AUTONOMY_AND_SUBSTRATE.md +69 -0
  13. package/docs/CLAWBOTS_AND_INVITES.md +102 -0
  14. package/docs/CLI_GUIDE.md +124 -0
  15. package/docs/CONTRIBUTING.md +130 -0
  16. package/docs/DASHBOARD_GUIDE.md +108 -0
  17. package/docs/GLOBAL_BACKEND.md +145 -0
  18. package/docs/ONCHAIN_V2_GUIDE.md +140 -0
  19. package/docs/PRODUCT_OVERVIEW.md +127 -0
  20. package/docs/README.md +40 -0
  21. package/docs/SKILLCARDS_AND_IMPORTER.md +147 -0
  22. package/docs/STARTER_PACK.md +297 -0
  23. package/docs/SUPPORTED_NETWORKS.md +58 -0
  24. package/docs/TELEMETRY_AND_EVENTS.md +103 -0
  25. package/docs/THE_POD_RUNNER.md +198 -0
  26. package/docs/V1_WORKFLOWS.md +108 -0
  27. package/docs/V2_ONCHAIN_SKILLS.md +157 -0
  28. package/docs/WEB4_PLAN_STATUS.md +95 -0
  29. package/docs/WEB4_SWARM_MODEL.md +104 -0
  30. package/docs/archive/AUTONOMY_AND_SUBSTRATE.md +66 -0
  31. package/docs/archive/WEB4_PLAN_STATUS.md +93 -0
  32. package/docs/archive/WEB4_SWARM_MODEL.md +98 -0
  33. package/docs/developer/01-architecture.md +345 -0
  34. package/docs/developer/02-contracts.md +1034 -0
  35. package/docs/developer/03-writing-modules.md +513 -0
  36. package/docs/developer/04-skillcard-spec.md +336 -0
  37. package/docs/developer/05-backend-api.md +1079 -0
  38. package/docs/developer/06-telemetry.md +798 -0
  39. package/docs/developer/07-testing.md +546 -0
  40. package/docs/developer/08-contributing.md +211 -0
  41. package/docs/operator/01-quickstart.md +49 -0
  42. package/docs/operator/02-dashboard.md +174 -0
  43. package/docs/operator/03-cli-reference.md +818 -0
  44. package/docs/operator/04-skills-library.md +169 -0
  45. package/docs/operator/05-pod-operations.md +314 -0
  46. package/docs/operator/06-deployment.md +299 -0
  47. package/docs/operator/07-safety-and-policy.md +311 -0
  48. package/docs/operator/08-troubleshooting.md +457 -0
  49. package/docs/operator/09-env-reference.md +238 -0
  50. package/docs/social/STARTER_PACK_THREAD.md +209 -0
  51. package/package.json +77 -0
  52. package/skillcards/import-sources.json +93 -0
  53. package/skillcards/seed/acp-bounty-poll.v1.json +38 -0
  54. package/skillcards/seed/acp-bounty-post.v1.json +55 -0
  55. package/skillcards/seed/acp-browse.v1.json +41 -0
  56. package/skillcards/seed/acp-fulfill-and-route.v1.json +56 -0
  57. package/skillcards/seed/apeclaw-bridge-relay.v1.json +46 -0
  58. package/skillcards/seed/apeclaw-nft-autobuy.v1.json +60 -0
  59. package/skillcards/seed/apeclaw-receipt-recorder.v1.json +64 -0
  60. package/skillcards/seed/humanizer.v1.json +74 -0
  61. package/skillcards/seed/otherside-navigator.v1.json +116 -0
  62. package/skillcards/seed/stonkbrokers-launcher.v1.json +280 -0
  63. package/skillcards/seed/walkie-p2p.v1.json +66 -0
  64. package/src/cli/index.mjs +8 -0
  65. package/src/cli.mjs +1929 -0
  66. package/src/lib/bridge-relay.mjs +294 -0
  67. package/src/lib/clawbots.mjs +94 -0
  68. package/src/lib/io.mjs +36 -0
  69. package/src/lib/market.mjs +233 -0
  70. package/src/lib/nft-opensea.mjs +159 -0
  71. package/src/lib/paths.mjs +17 -0
  72. package/src/lib/pod-init.mjs +40 -0
  73. package/src/lib/policy.mjs +112 -0
  74. package/src/lib/rpc.mjs +49 -0
  75. package/src/lib/telemetry.mjs +92 -0
  76. package/src/lib/v2-onchain-abi.mjs +294 -0
  77. package/src/lib/v2-skillcard.mjs +27 -0
  78. package/src/server/index.mjs +169 -0
  79. package/src/server/logger.mjs +21 -0
  80. package/src/server/middleware/auth.mjs +90 -0
  81. package/src/server/middleware/body-limit.mjs +35 -0
  82. package/src/server/middleware/cors.mjs +33 -0
  83. package/src/server/middleware/rate-limit.mjs +44 -0
  84. package/src/server/routes/chat.mjs +178 -0
  85. package/src/server/routes/clawbots.mjs +182 -0
  86. package/src/server/routes/events.mjs +95 -0
  87. package/src/server/routes/health.mjs +72 -0
  88. package/src/server/routes/pod.mjs +64 -0
  89. package/src/server/routes/quotes.mjs +161 -0
  90. package/src/server/routes/skills.mjs +239 -0
  91. package/src/server/routes/static.mjs +161 -0
  92. package/src/server/routes/v2.mjs +48 -0
  93. package/src/server/sse.mjs +73 -0
  94. package/src/server/storage/file-backend.mjs +295 -0
  95. package/src/server/storage/index.mjs +37 -0
  96. package/src/server/storage/sqlite-backend.mjs +380 -0
  97. package/src/telemetry-server.mjs +1604 -0
  98. package/ui/css/dashboard.css +792 -0
  99. package/ui/css/skills.css +689 -0
  100. package/ui/docs.html +840 -0
  101. package/ui/favicon-180.png +0 -0
  102. package/ui/favicon-192.png +0 -0
  103. package/ui/favicon-32.png +0 -0
  104. package/ui/favicon-lobster.png +0 -0
  105. package/ui/favicon.svg +10 -0
  106. package/ui/index.html +2957 -0
  107. package/ui/js/dashboard.js +1766 -0
  108. package/ui/js/skills.js +1621 -0
  109. package/ui/pod.html +909 -0
  110. package/ui/shared/motion.css +286 -0
  111. package/ui/shared/motion.js +170 -0
  112. package/ui/shared/sidebar-nav.css +379 -0
  113. package/ui/shared/sidebar-nav.js +137 -0
  114. package/ui/skills.html +2879 -0
@@ -0,0 +1,1604 @@
1
+ import http from "node:http";
2
+ import fs from "node:fs";
3
+ import path from "node:path";
4
+ import { createHash, randomUUID } from "node:crypto";
5
+ import { createPublicClient, getContract, http as viemHttp, keccak256, toHex } from "viem";
6
+ import { ROOT as PROJECT_ROOT, STATE_DIR, EVENTS_PATH, ALLOWLIST_PATH, POLICY_PATH, OPENSEA_OVERRIDES_PATH, CLAWBOTS_PATH, CHAT_PATH, INVITES_PATH } from "./lib/paths.mjs";
7
+ import { ensureDir } from "./lib/io.mjs";
8
+ import { verifyClawbot, registerClawbot } from "./lib/clawbots.mjs";
9
+ import { ReceiptRegistry_ABI } from "./lib/v2-onchain-abi.mjs";
10
+ import { handleCorsPreflightOrSetHeaders } from "./server/middleware/cors.mjs";
11
+ import { checkRateLimit } from "./server/middleware/rate-limit.mjs";
12
+ import { collectBody as collectBodyLimited } from "./server/middleware/body-limit.mjs";
13
+
14
+ const PORT = Number(process.env.APE_CLAW_UI_PORT || 8787);
15
+ const BIND_HOST = String(process.env.APE_CLAW_BIND_HOST || "").trim();
16
+ const ROOT = PROJECT_ROOT;
17
+ const UI_PATH = path.join(ROOT, "ui", "index.html");
18
+ const clients = new Set();
19
+ const OPENSEA_API_BASE = "https://api.opensea.io/api/v2";
20
+ const MOLTBOOK_API_BASE = String(process.env.MOLTBOOK_API_BASE || "https://www.moltbook.com/api/v1").replace(/\/+$/, "");
21
+ const MOLTBOOK_APP_KEY = String(process.env.MOLTBOOK_APP_KEY || "").trim();
22
+ const REGISTRATION_KEY = String(process.env.APE_CLAW_REGISTRATION_KEY || "").trim();
23
+ const OPEN_REGISTRATION = /^(1|true|yes|on)$/i.test(String(process.env.APE_CLAW_OPEN_REGISTRATION || "").trim());
24
+ const REGISTRATION_COOLDOWN_MS = Math.max(
25
+ 0,
26
+ Number(process.env.APE_CLAW_REGISTRATION_COOLDOWN_MS || 10000),
27
+ );
28
+ const registrationByIp = new Map();
29
+ const INVITE_TTL_MS = Math.max(60_000, Number(process.env.APE_CLAW_INVITE_TTL_MS || 24 * 60 * 60 * 1000));
30
+ const INVITE_MAX_USES = Math.max(1, Number(process.env.APE_CLAW_INVITE_MAX_USES || 5));
31
+ const ICON_CACHE_TTL_MS = 10 * 60 * 1000;
32
+ let allowlistIconCache = { expiresAt: 0, data: null, inFlight: null };
33
+
34
+ function resolveV2DeploymentRecord() {
35
+ // Best-effort: used only for read-only UX helpers (no signing).
36
+ try {
37
+ const dir = path.join(STATE_DIR, "v2-deployments");
38
+ if (!fs.existsSync(dir)) return null;
39
+ const entries = fs.readdirSync(dir).filter((f) => f.endsWith(".json"));
40
+ if (!entries.length) return null;
41
+ // Choose the most recently modified record (better for local dev).
42
+ let pick = entries[0];
43
+ let best = -1;
44
+ for (const f of entries) {
45
+ try {
46
+ const st = fs.statSync(path.join(dir, f));
47
+ const mt = Number(st.mtimeMs || 0);
48
+ if (mt > best) { best = mt; pick = f; }
49
+ } catch {}
50
+ }
51
+ const raw = JSON.parse(fs.readFileSync(path.join(dir, pick), "utf8"));
52
+ return raw && typeof raw === "object" ? raw : null;
53
+ } catch {
54
+ return null;
55
+ }
56
+ }
57
+
58
+ function resolveV2ReceiptReadConfig() {
59
+ const fromEnvRpc = String(process.env.APE_CLAW_V2_RPC_URL || process.env.RPC_URL_33139 || "").trim();
60
+ const fromEnvReceipts = String(process.env.APE_CLAW_V2_RECEIPT_REGISTRY || "").trim();
61
+ const rec = resolveV2DeploymentRecord();
62
+ const receiptsAddress = fromEnvReceipts || String(rec?.receipts || "").trim();
63
+ let rpcUrl = fromEnvRpc;
64
+ let inferredRpc = false;
65
+ if (!rpcUrl && rec && Number(rec.chainId) === 31337) {
66
+ // Local Hardhat node default (only if we detect a local deployment record).
67
+ rpcUrl = "http://127.0.0.1:8545";
68
+ inferredRpc = true;
69
+ }
70
+ if (!rpcUrl || !receiptsAddress) {
71
+ return {
72
+ ok: false,
73
+ rpcUrl: rpcUrl || "",
74
+ receiptsAddress: receiptsAddress || "",
75
+ inferredRpc,
76
+ reason: "missing v2 config (set APE_CLAW_V2_RPC_URL and APE_CLAW_V2_RECEIPT_REGISTRY, or run contracts seed locally)",
77
+ };
78
+ }
79
+ return { ok: true, rpcUrl, receiptsAddress, inferredRpc };
80
+ }
81
+
82
+ ensureDir(path.dirname(EVENTS_PATH));
83
+ if (!fs.existsSync(EVENTS_PATH)) fs.writeFileSync(EVENTS_PATH, "");
84
+ if (!fs.existsSync(CHAT_PATH)) fs.writeFileSync(CHAT_PATH, "");
85
+ ensureDir(path.dirname(INVITES_PATH));
86
+
87
+ // User-submitted SkillCards (stored server-side; no secrets).
88
+ const SKILLCARDS_USER_DIR = path.join(STATE_DIR, "skillcards-user");
89
+ const SKILLCARDS_USER_INDEX_PATH = path.join(SKILLCARDS_USER_DIR, "index.json");
90
+ ensureDir(SKILLCARDS_USER_DIR);
91
+ if (!fs.existsSync(SKILLCARDS_USER_INDEX_PATH)) {
92
+ fs.writeFileSync(SKILLCARDS_USER_INDEX_PATH, JSON.stringify({ skills: [] }, null, 2));
93
+ }
94
+
95
+ // SkillCards paths
96
+ const SKILLCARDS_SEED_DIR = path.join(ROOT, "skillcards", "seed");
97
+ const SKILLCARDS_IMPORTED_INDEX_PATH = path.join(ROOT, "skillcards", "imported", "index.json");
98
+
99
+ // Cache for merged skill index (60 seconds TTL)
100
+ let mergedSkillIndexCache = { data: null, expiresAt: 0 };
101
+ const MERGED_INDEX_CACHE_TTL_MS = 60 * 1000;
102
+
103
+ function buildMergedSkillIndex() {
104
+ const merged = [];
105
+
106
+ // 1. Read seed skills from skillcards/seed/*.json
107
+ let seedTokenId = 1;
108
+ try {
109
+ if (fs.existsSync(SKILLCARDS_SEED_DIR)) {
110
+ const seedFiles = fs.readdirSync(SKILLCARDS_SEED_DIR).filter((f) => f.endsWith(".json")).sort();
111
+ for (const fileName of seedFiles) {
112
+ try {
113
+ const filePath = path.join(SKILLCARDS_SEED_DIR, fileName);
114
+ const raw = fs.readFileSync(filePath, "utf8");
115
+ const skill = JSON.parse(raw);
116
+ if (skill && typeof skill === "object" && skill.name && skill.slug) {
117
+ merged.push({
118
+ name: String(skill.name || "").trim(),
119
+ slug: String(skill.slug || "").trim(),
120
+ description: String(skill.description || "").trim(),
121
+ source: "seed",
122
+ vettedOk: true,
123
+ importOk: true,
124
+ riskTier: Number(skill?.constraints?.riskTier ?? skill?.riskTier ?? 2),
125
+ sourceUrl: String(skill?.provenance?.sourceUrl || "").trim() || null,
126
+ provenance: skill.provenance || { publisher: "apeclaw", signed: false },
127
+ onchainTokenId: String(seedTokenId),
128
+ });
129
+ seedTokenId++;
130
+ }
131
+ } catch {
132
+ // Skip malformed seed files
133
+ }
134
+ }
135
+ }
136
+ } catch {
137
+ // Skip if seed directory doesn't exist or can't be read
138
+ }
139
+
140
+ // 2. Read imported skills from skillcards/imported/index.json
141
+ try {
142
+ if (fs.existsSync(SKILLCARDS_IMPORTED_INDEX_PATH)) {
143
+ const raw = fs.readFileSync(SKILLCARDS_IMPORTED_INDEX_PATH, "utf8");
144
+ const index = JSON.parse(raw);
145
+ const imported = Array.isArray(index?.imported) ? index.imported : [];
146
+ for (const item of imported) {
147
+ if (item && typeof item === "object" && item.name && item.slug) {
148
+ merged.push({
149
+ name: String(item.name || "").trim(),
150
+ slug: String(item.slug || "").trim(),
151
+ description: String(item.description || "").trim(),
152
+ fileName: String(item.fileName || "").trim() || null,
153
+ source: "imported",
154
+ vettedOk: Boolean(item.vettedOk),
155
+ importOk: Boolean(item.importOk),
156
+ riskTier: Number(item.riskTier ?? 2),
157
+ sourceUrl: String(item.sourceUrl || "").trim() || null,
158
+ provenance: item.provenance || { publisher: "imported", signed: false },
159
+ onchainTokenId: item.onchainTokenId || null,
160
+ onchainMintTx: item.onchainMintTx || null,
161
+ onchainPublishTx: item.onchainPublishTx || null,
162
+ });
163
+ }
164
+ }
165
+ }
166
+ } catch {
167
+ // Skip if imported index doesn't exist or can't be read
168
+ }
169
+
170
+ // 3. Read user skills from state/skillcards-user/*.json files
171
+ try {
172
+ if (fs.existsSync(SKILLCARDS_USER_INDEX_PATH)) {
173
+ const raw = fs.readFileSync(SKILLCARDS_USER_INDEX_PATH, "utf8");
174
+ const index = JSON.parse(raw);
175
+ const userSkills = Array.isArray(index?.skills) ? index.skills : [];
176
+ for (const item of userSkills) {
177
+ if (item && typeof item === "object" && item.name && item.slug) {
178
+ merged.push({
179
+ name: String(item.name || "").trim(),
180
+ slug: String(item.slug || "").trim(),
181
+ description: String(item.description || "").trim(),
182
+ source: "user",
183
+ vettedOk: false, // User skills are not vetted by default
184
+ importOk: true,
185
+ riskTier: Number(item.riskTier ?? 2),
186
+ sourceUrl: String(item.sourceUrl || "").trim() || null,
187
+ provenance: { publisher: "user", signed: false, addedBy: item.addedBy, addedByAgentId: item.addedByAgentId },
188
+ });
189
+ }
190
+ }
191
+ }
192
+ } catch {
193
+ // Skip if user index doesn't exist or can't be read
194
+ }
195
+
196
+ return merged;
197
+ }
198
+
199
+ function getMergedSkillIndex() {
200
+ const now = Date.now();
201
+ if (mergedSkillIndexCache.data && mergedSkillIndexCache.expiresAt > now) {
202
+ return mergedSkillIndexCache.data;
203
+ }
204
+ const index = buildMergedSkillIndex();
205
+ mergedSkillIndexCache = {
206
+ data: index,
207
+ expiresAt: now + MERGED_INDEX_CACHE_TTL_MS,
208
+ };
209
+ return index;
210
+ }
211
+
212
+ function safeVersion(v) {
213
+ const s = String(v || "").trim();
214
+ if (!s) return "";
215
+ // Keep filenames safe and predictable.
216
+ if (!/^[0-9]+(\.[0-9]+){0,3}([\-+][0-9A-Za-z._-]+)?$/.test(s)) return "";
217
+ return s;
218
+ }
219
+
220
+ function atomicWriteJson(filePath, data) {
221
+ ensureDir(path.dirname(filePath));
222
+ const tmp = `${filePath}.${randomUUID().slice(0, 8)}.tmp`;
223
+ fs.writeFileSync(tmp, JSON.stringify(data, null, 2));
224
+ fs.renameSync(tmp, filePath);
225
+ }
226
+
227
+ function requireSkillWriteAuth(req) {
228
+ // Allow either admin key OR an authenticated clawbot.
229
+ const adminKey = String(req.headers["x-registration-key"] || "").trim();
230
+ if (adminKey && REGISTRATION_KEY && adminKey === REGISTRATION_KEY) {
231
+ return { ok: true, mode: "admin", agentId: null };
232
+ }
233
+ const agentId = String(req.headers["x-agent-id"] || "").trim();
234
+ const agentToken = String(req.headers["x-agent-token"] || "").trim();
235
+ if (agentId && agentToken) {
236
+ try {
237
+ const v = verifyClawbot({ agentId, agentToken });
238
+ if (v?.verified) return { ok: true, mode: "agent", agentId };
239
+ } catch {}
240
+ }
241
+ return { ok: false, mode: "none", agentId: null };
242
+ }
243
+
244
+ function sendSse(res, evt) {
245
+ res.write(`data: ${JSON.stringify(evt)}\n\n`);
246
+ }
247
+
248
+ function sendBacklog(res) {
249
+ const raw = fs.readFileSync(EVENTS_PATH, "utf8");
250
+ const lines = raw.trim() ? raw.trim().split("\n") : [];
251
+ const events = lines.slice(-1000).map((l) => {
252
+ try {
253
+ return JSON.parse(l);
254
+ } catch {
255
+ return null;
256
+ }
257
+ }).filter(Boolean);
258
+ res.writeHead(200, { "content-type": "application/json; charset=utf-8" });
259
+ res.end(JSON.stringify({ events }));
260
+ }
261
+
262
+ function serveJson(res, filePath) {
263
+ if (!fs.existsSync(filePath)) {
264
+ res.writeHead(404, { "content-type": "application/json" });
265
+ return res.end(JSON.stringify({ error: "not found" }));
266
+ }
267
+ const raw = fs.readFileSync(filePath, "utf8");
268
+ res.writeHead(200, { "content-type": "application/json; charset=utf-8" });
269
+ return res.end(raw);
270
+ }
271
+
272
+ function toSlug(input) {
273
+ return String(input || "")
274
+ .toLowerCase()
275
+ .trim()
276
+ .replace(/®/g, "")
277
+ .replace(/[^a-z0-9]+/g, "-")
278
+ .replace(/^-+|-+$/g, "");
279
+ }
280
+
281
+ function unique(items) {
282
+ return [...new Set(items.filter(Boolean))];
283
+ }
284
+
285
+ function buildSlugCandidates(item, overrides = {}) {
286
+ const raw = String(item?.name || "");
287
+ const base = toSlug(raw);
288
+ const fromOverride = overrides[raw] || overrides[base] || [];
289
+ return unique([
290
+ item?.slug,
291
+ base,
292
+ base.replace(/-on-apechain$/, ""),
293
+ base.replace(/-on-ape$/, ""),
294
+ base.replace(/-/g, ""),
295
+ ...(Array.isArray(fromOverride) ? fromOverride : [fromOverride]),
296
+ ]);
297
+ }
298
+
299
+ async function fetchJson(url, headers = {}) {
300
+ const res = await fetch(url, { headers });
301
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
302
+ return res.json();
303
+ }
304
+
305
+ function extractCollectionImage(payload) {
306
+ const c = payload?.collection || payload || {};
307
+ return (
308
+ c?.image_url ||
309
+ c?.imageUrl ||
310
+ c?.banner_image_url ||
311
+ c?.bannerImageUrl ||
312
+ null
313
+ );
314
+ }
315
+
316
+ async function resolveCollectionIcon(item, headers, overrides) {
317
+ const candidates = buildSlugCandidates(item, overrides);
318
+ for (const slug of candidates) {
319
+ try {
320
+ const data = await fetchJson(`${OPENSEA_API_BASE}/collections/${encodeURIComponent(slug)}`, headers);
321
+ const imageUrl = extractCollectionImage(data);
322
+ if (imageUrl) return { imageUrl, openseaSlug: slug };
323
+ } catch {
324
+ // continue slug variants
325
+ }
326
+ try {
327
+ const nftData = await fetchJson(
328
+ `${OPENSEA_API_BASE}/collection/${encodeURIComponent(slug)}/nfts?limit=1`,
329
+ headers,
330
+ );
331
+ const first = Array.isArray(nftData?.nfts) ? nftData.nfts[0] : null;
332
+ const imageUrl = first?.image_url || first?.display_image_url || first?.imageUrl || null;
333
+ if (imageUrl) return { imageUrl, openseaSlug: slug };
334
+ } catch {
335
+ // continue slug variants
336
+ }
337
+ }
338
+ return { imageUrl: null, openseaSlug: null };
339
+ }
340
+
341
+ async function mapWithConcurrency(items, limit, mapper) {
342
+ const out = new Array(items.length);
343
+ let i = 0;
344
+ async function worker() {
345
+ while (i < items.length) {
346
+ const idx = i++;
347
+ out[idx] = await mapper(items[idx], idx);
348
+ }
349
+ }
350
+ const workers = Array.from({ length: Math.max(1, Math.min(limit, items.length)) }, () => worker());
351
+ await Promise.all(workers);
352
+ return out;
353
+ }
354
+
355
+ async function getAllowlistWithIcons() {
356
+ const now = Date.now();
357
+ if (allowlistIconCache.data && allowlistIconCache.expiresAt > now) return allowlistIconCache.data;
358
+ if (allowlistIconCache.inFlight) return allowlistIconCache.inFlight;
359
+
360
+ allowlistIconCache.inFlight = (async () => {
361
+ const raw = fs.readFileSync(ALLOWLIST_PATH, "utf8");
362
+ const allowlist = JSON.parse(raw);
363
+ const key = process.env.OPENSEA_API_KEY || "";
364
+ if (!key) {
365
+ const plain = allowlist.map((c) => ({ ...c, imageUrl: null, openseaSlug: c.slug || null }));
366
+ allowlistIconCache = { expiresAt: now + ICON_CACHE_TTL_MS, data: plain, inFlight: null };
367
+ return plain;
368
+ }
369
+ let overrides = {};
370
+ if (fs.existsSync(OPENSEA_OVERRIDES_PATH)) {
371
+ try {
372
+ overrides = JSON.parse(fs.readFileSync(OPENSEA_OVERRIDES_PATH, "utf8"));
373
+ } catch {
374
+ overrides = {};
375
+ }
376
+ }
377
+ const headers = { "x-api-key": key, accept: "application/json" };
378
+ const enriched = await mapWithConcurrency(allowlist, 8, async (item) => {
379
+ const icon = await resolveCollectionIcon(item, headers, overrides);
380
+ return {
381
+ ...item,
382
+ imageUrl: icon.imageUrl,
383
+ openseaSlug: icon.openseaSlug || item.slug || null,
384
+ };
385
+ });
386
+ allowlistIconCache = { expiresAt: Date.now() + ICON_CACHE_TTL_MS, data: enriched, inFlight: null };
387
+ return enriched;
388
+ })();
389
+
390
+ try {
391
+ return await allowlistIconCache.inFlight;
392
+ } finally {
393
+ if (allowlistIconCache.inFlight && allowlistIconCache.expiresAt < Date.now()) {
394
+ allowlistIconCache.inFlight = null;
395
+ }
396
+ }
397
+ }
398
+
399
+ // ── Chat helpers ────────────────────────────────────────
400
+
401
+ const chatClients = new Set();
402
+
403
+ function sendChatSse(res, msg) {
404
+ res.write(`data: ${JSON.stringify(msg)}\n\n`);
405
+ }
406
+
407
+ function normalizeRoomName(input) {
408
+ const raw = String(input || "general")
409
+ .toLowerCase()
410
+ .trim()
411
+ .replace(/[^a-z0-9_-]/g, "-")
412
+ .replace(/-+/g, "-")
413
+ .replace(/^-|-$/g, "");
414
+ return raw || "general";
415
+ }
416
+
417
+ function broadcastChat(msg) {
418
+ for (const client of chatClients) {
419
+ try {
420
+ const wantsRoom = normalizeRoomName(client.room || "all");
421
+ if (wantsRoom !== "all" && wantsRoom !== normalizeRoomName(msg.room)) continue;
422
+ sendChatSse(client.res, msg);
423
+ } catch {
424
+ chatClients.delete(client);
425
+ }
426
+ }
427
+ }
428
+
429
+ function readChatEntries() {
430
+ if (!fs.existsSync(CHAT_PATH)) return [];
431
+ const raw = fs.readFileSync(CHAT_PATH, "utf8").trim();
432
+ if (!raw) return [];
433
+ const lines = raw.split("\n");
434
+ return lines
435
+ .map((l) => {
436
+ try {
437
+ return JSON.parse(l);
438
+ } catch {
439
+ return null;
440
+ }
441
+ })
442
+ .filter(Boolean);
443
+ }
444
+
445
+ function materializeChatMessages(entries, room = "all") {
446
+ const targetRoom = normalizeRoomName(room);
447
+ const byId = new Map();
448
+ const ordered = [];
449
+
450
+ for (const e of entries) {
451
+ const type = String(e.type || "message");
452
+ if (type !== "message") continue;
453
+ const msg = {
454
+ id: e.id,
455
+ type: "message",
456
+ agentId: e.agentId,
457
+ agentName: e.agentName,
458
+ identityProvider: e.identityProvider,
459
+ identityMeta: e.identityMeta || {},
460
+ room: normalizeRoomName(e.room || "general"),
461
+ text: e.text,
462
+ ts: e.ts,
463
+ replyTo: e.replyTo || null,
464
+ reactions: {},
465
+ reactionUsers: {},
466
+ };
467
+ byId.set(msg.id, msg);
468
+ ordered.push(msg);
469
+ }
470
+
471
+ for (const e of entries) {
472
+ if (String(e.type || "") !== "reaction") continue;
473
+ const msg = byId.get(e.messageId);
474
+ if (!msg) continue;
475
+ const emoji = String(e.emoji || "").trim();
476
+ const agentId = String(e.agentId || "").trim();
477
+ if (!emoji || !agentId) continue;
478
+ const current = new Set(msg.reactionUsers[emoji] || []);
479
+ if (current.has(agentId)) current.delete(agentId);
480
+ else current.add(agentId);
481
+ msg.reactionUsers[emoji] = [...current];
482
+ msg.reactions[emoji] = msg.reactionUsers[emoji].length;
483
+ }
484
+
485
+ const roomFiltered = targetRoom === "all"
486
+ ? ordered
487
+ : ordered.filter((m) => normalizeRoomName(m.room || "general") === targetRoom);
488
+ return roomFiltered;
489
+ }
490
+
491
+ function readChatMessages(limit = 100, room = "all") {
492
+ const entries = readChatEntries();
493
+ const filtered = materializeChatMessages(entries, room);
494
+ return filtered.slice(-limit);
495
+ }
496
+
497
+ function readChatRooms(limit = 50) {
498
+ const parsed = materializeChatMessages(readChatEntries(), "all");
499
+ const byRoom = new Map();
500
+ for (const m of parsed) {
501
+ const room = normalizeRoomName(m.room || "general");
502
+ const prev = byRoom.get(room) || {
503
+ room,
504
+ count: 0,
505
+ lastTs: null,
506
+ lastMessage: "",
507
+ participants: new Set(),
508
+ };
509
+ prev.count += 1;
510
+ prev.lastTs = m.ts || prev.lastTs;
511
+ prev.lastMessage = m.text || prev.lastMessage;
512
+ if (m.agentId) prev.participants.add(m.agentId);
513
+ byRoom.set(room, prev);
514
+ }
515
+ return [...byRoom.values()]
516
+ .map((r) => ({
517
+ room: r.room,
518
+ count: r.count,
519
+ lastTs: r.lastTs,
520
+ lastMessage: r.lastMessage,
521
+ participants: r.participants.size,
522
+ }))
523
+ .sort((a, b) => String(b.lastTs || "").localeCompare(String(a.lastTs || "")))
524
+ .slice(0, limit);
525
+ }
526
+
527
+ function appendChatMessage(msg) {
528
+ fs.appendFileSync(CHAT_PATH, JSON.stringify(msg) + "\n");
529
+ }
530
+
531
+ function appendTelemetryEvent(evt) {
532
+ fs.appendFileSync(EVENTS_PATH, JSON.stringify(evt) + "\n");
533
+ }
534
+
535
+ function sha256(input) {
536
+ return createHash("sha256").update(String(input)).digest("hex");
537
+ }
538
+
539
+ function readInvites() {
540
+ try {
541
+ if (!fs.existsSync(INVITES_PATH)) return { invites: {} };
542
+ const raw = fs.readFileSync(INVITES_PATH, "utf8");
543
+ const parsed = JSON.parse(raw);
544
+ if (!parsed || typeof parsed !== "object") return { invites: {} };
545
+ if (!parsed.invites || typeof parsed.invites !== "object") return { invites: {} };
546
+ return parsed;
547
+ } catch {
548
+ return { invites: {} };
549
+ }
550
+ }
551
+
552
+ function writeInvites(data) {
553
+ try {
554
+ ensureDir(path.dirname(INVITES_PATH));
555
+ fs.writeFileSync(INVITES_PATH, JSON.stringify(data, null, 2));
556
+ } catch {
557
+ // ignore write failures (best-effort)
558
+ }
559
+ }
560
+
561
+ function mintInvite({ ttlMs = INVITE_TTL_MS, uses = 1 } = {}) {
562
+ const safeUses = Math.max(1, Math.min(INVITE_MAX_USES, Number(uses) || 1));
563
+ const safeTtl = Math.max(60_000, Math.min(7 * 24 * 60 * 60 * 1000, Number(ttlMs) || INVITE_TTL_MS));
564
+ const token = `inv_${randomUUID().replace(/-/g, "")}`;
565
+ const tokenHash = sha256(token);
566
+ const now = Date.now();
567
+ const invites = readInvites();
568
+ invites.invites[tokenHash] = {
569
+ createdAt: new Date(now).toISOString(),
570
+ expiresAt: new Date(now + safeTtl).toISOString(),
571
+ usesRemaining: safeUses,
572
+ };
573
+ writeInvites(invites);
574
+ return { token, tokenHash, expiresAt: invites.invites[tokenHash].expiresAt, usesRemaining: safeUses };
575
+ }
576
+
577
+ function consumeInvite(inviteToken) {
578
+ const token = String(inviteToken || "").trim();
579
+ if (!token) return { ok: false, reason: "missing invite" };
580
+ const tokenHash = sha256(token);
581
+ const invites = readInvites();
582
+ const row = invites.invites?.[tokenHash];
583
+ if (!row) return { ok: false, reason: "invite not found" };
584
+ const now = Date.now();
585
+ const exp = new Date(row.expiresAt || 0).getTime();
586
+ if (!exp || exp <= now) return { ok: false, reason: "invite expired" };
587
+ const remaining = Number(row.usesRemaining || 0);
588
+ if (remaining <= 0) return { ok: false, reason: "invite exhausted" };
589
+ invites.invites[tokenHash] = { ...row, usesRemaining: remaining - 1, lastUsedAt: new Date(now).toISOString() };
590
+ writeInvites(invites);
591
+ return { ok: true };
592
+ }
593
+
594
+ function clientIpFromReq(req) {
595
+ const xff = String(req.headers["x-forwarded-for"] || "").trim();
596
+ if (xff) return xff.split(",")[0].trim();
597
+ return String(req.socket?.remoteAddress || "").trim() || "unknown";
598
+ }
599
+
600
+ async function resolveChatAuth(req, body) {
601
+ const agentId = body.agentId || req.headers["x-agent-id"] || "";
602
+ const agentToken = body.agentToken || req.headers["x-agent-token"] || "";
603
+ const identityToken = body.identityToken || req.headers["x-moltbook-identity"] || "";
604
+
605
+ if (identityToken) {
606
+ const identity = await verifyMoltbookIdentity(identityToken);
607
+ if (!identity.verified) return { ok: false, status: 403, error: "identity verify failed", reason: identity.reason };
608
+ const agent = identity.agent || {};
609
+ return {
610
+ ok: true,
611
+ auth: {
612
+ id: String(agent.name || agent.id || "moltbook-agent"),
613
+ name: String(agent.name || agent.id || "Moltbook Agent"),
614
+ provider: "moltbook",
615
+ meta: {
616
+ karma: Number(agent.karma || 0),
617
+ claimed: Boolean(agent.is_claimed),
618
+ },
619
+ },
620
+ };
621
+ }
622
+
623
+ if (!agentId || !agentToken) {
624
+ return { ok: false, status: 401, error: "missing credentials: provide agentId+agentToken or identityToken" };
625
+ }
626
+ const verification = verifyClawbot({ agentId, agentToken });
627
+ if (!verification.verified) {
628
+ return { ok: false, status: 403, error: "not verified", reason: verification.reason };
629
+ }
630
+ return {
631
+ ok: true,
632
+ auth: {
633
+ id: agentId,
634
+ name: verification.agent?.name || agentId,
635
+ provider: "clawbot",
636
+ meta: {},
637
+ },
638
+ };
639
+ }
640
+
641
+ async function readRequestBody(req, res) {
642
+ const raw = await collectBodyLimited(req, res);
643
+ if (raw === null) return null;
644
+ return JSON.parse(raw);
645
+ }
646
+
647
+ async function verifyMoltbookIdentity(identityToken) {
648
+ const token = String(identityToken || "").trim();
649
+ if (!token) return { verified: false, reason: "missing identity token" };
650
+ if (!MOLTBOOK_APP_KEY) return { verified: false, reason: "MOLTBOOK_APP_KEY not configured on backend" };
651
+ try {
652
+ const r = await fetch(`${MOLTBOOK_API_BASE}/agents/verify-identity`, {
653
+ method: "POST",
654
+ headers: {
655
+ "content-type": "application/json",
656
+ "x-moltbook-app-key": MOLTBOOK_APP_KEY,
657
+ },
658
+ body: JSON.stringify({ token }),
659
+ });
660
+ const data = await r.json().catch(() => ({}));
661
+ if (!r.ok) return { verified: false, reason: data?.error || `identity verify failed (${r.status})` };
662
+ if (!data?.valid || !data?.agent) return { verified: false, reason: "identity token invalid" };
663
+ return { verified: true, agent: data.agent };
664
+ } catch (err) {
665
+ return { verified: false, reason: err.message || "identity verification request failed" };
666
+ }
667
+ }
668
+
669
+ // Rate limit tiers
670
+ const RL_READ = { limit: 60, windowMs: 60_000, keyPrefix: "read" };
671
+ const RL_WRITE = { limit: 10, windowMs: 60_000, keyPrefix: "write" };
672
+ const RL_AUTH = { limit: 5, windowMs: 60_000, keyPrefix: "auth" };
673
+
674
+ // ── Pod workspace helpers ────────────────────────────────────────
675
+
676
+ function findPodWorkspaceDir() {
677
+ // Check paths in order: env var, ./pod-workspace, ./pod
678
+ const envDir = process.env.APE_CLAW_POD_DIR;
679
+ if (envDir) {
680
+ const p = path.resolve(envDir);
681
+ if (fs.existsSync(p) && fs.statSync(p).isDirectory()) return p;
682
+ }
683
+ const podWorkspace = path.join(ROOT, "pod-workspace");
684
+ if (fs.existsSync(podWorkspace) && fs.statSync(podWorkspace).isDirectory()) return podWorkspace;
685
+ const pod = path.join(ROOT, "pod");
686
+ if (fs.existsSync(pod) && fs.statSync(pod).isDirectory()) return pod;
687
+ return null;
688
+ }
689
+
690
+ function getPodStatus() {
691
+ const workspacePath = findPodWorkspaceDir();
692
+ if (!workspacePath) {
693
+ return {
694
+ ok: true,
695
+ status: "not-initialized",
696
+ workspacePath: null,
697
+ };
698
+ }
699
+
700
+ const agentsMdPath = path.join(workspacePath, "AGENTS.md");
701
+ const tasksPath = path.join(workspacePath, "memory", "active-tasks.md");
702
+ const stopFlagPath = path.join(workspacePath, "stop.flag");
703
+ const heartbeatPath = path.join(workspacePath, "state", "last-heartbeat.json");
704
+
705
+ const hasAgentsMd = fs.existsSync(agentsMdPath);
706
+ const hasTasks = fs.existsSync(tasksPath);
707
+ const stopped = fs.existsSync(stopFlagPath);
708
+
709
+ let lastHeartbeat = null;
710
+ if (fs.existsSync(heartbeatPath)) {
711
+ try {
712
+ const raw = fs.readFileSync(heartbeatPath, "utf8");
713
+ const data = JSON.parse(raw);
714
+ lastHeartbeat = data?.timestamp || data?.ts || null;
715
+ } catch {
716
+ // ignore parse errors
717
+ }
718
+ }
719
+
720
+ let status = "not-initialized";
721
+ if (hasAgentsMd) {
722
+ status = stopped ? "stopped" : "running";
723
+ }
724
+
725
+ return {
726
+ ok: true,
727
+ status,
728
+ workspacePath,
729
+ hasAgentsMd,
730
+ hasTasks,
731
+ stopped,
732
+ lastHeartbeat,
733
+ };
734
+ }
735
+
736
+ const server = http.createServer((req, res) => {
737
+ if (!req.url) return res.end("bad request");
738
+ const reqUrl = new URL(req.url, `http://localhost:${PORT}`);
739
+ const pathname = reqUrl.pathname;
740
+
741
+ // ── CORS (handles OPTIONS preflight automatically)
742
+ if (handleCorsPreflightOrSetHeaders(req, res)) return;
743
+
744
+ // ── Rate limiting for API routes
745
+ if (pathname.startsWith("/api/")) {
746
+ const isAuth = pathname.startsWith("/api/clawbots/register") || pathname.startsWith("/api/clawbots/verify");
747
+ const isWrite = req.method === "POST" || req.method === "PATCH";
748
+ const rl = isAuth ? RL_AUTH : isWrite ? RL_WRITE : RL_READ;
749
+ if (checkRateLimit(req, res, rl)) return;
750
+ }
751
+
752
+ if (pathname === "/events") {
753
+ res.writeHead(200, {
754
+ "content-type": "text/event-stream",
755
+ "cache-control": "no-cache",
756
+ connection: "keep-alive",
757
+ });
758
+ res.write("\n");
759
+ clients.add(res);
760
+ req.on("close", () => clients.delete(res));
761
+ return;
762
+ }
763
+ if (pathname === "/events/backlog") return sendBacklog(res);
764
+ if (pathname === "/api/allowlist") {
765
+ getAllowlistWithIcons()
766
+ .then((data) => {
767
+ res.writeHead(200, { "content-type": "application/json; charset=utf-8" });
768
+ res.end(JSON.stringify(data));
769
+ })
770
+ .catch((err) => {
771
+ res.writeHead(500, { "content-type": "application/json; charset=utf-8" });
772
+ res.end(JSON.stringify({ error: err.message }));
773
+ });
774
+ return;
775
+ }
776
+ if (pathname === "/api/policy") {
777
+ return serveJson(res, POLICY_PATH);
778
+ }
779
+ if (pathname === "/api/skillcards/user" && req.method === "GET") {
780
+ try {
781
+ const raw = JSON.parse(fs.readFileSync(SKILLCARDS_USER_INDEX_PATH, "utf8"));
782
+ const skills = Array.isArray(raw?.skills) ? raw.skills : [];
783
+ res.writeHead(200, { "content-type": "application/json; charset=utf-8" });
784
+ return res.end(JSON.stringify({ ok: true, skills }));
785
+ } catch (err) {
786
+ res.writeHead(500, { "content-type": "application/json; charset=utf-8" });
787
+ return res.end(JSON.stringify({ ok: false, error: err.message || "failed to load index" }));
788
+ }
789
+ }
790
+ if (pathname === "/api/skills/search" && req.method === "GET") {
791
+ try {
792
+ const query = String(reqUrl.searchParams.get("q") || "").trim().toLowerCase();
793
+ const sourceFilter = String(reqUrl.searchParams.get("source") || "").trim().toLowerCase();
794
+ const vettedFilter = String(reqUrl.searchParams.get("vetted") || "").trim();
795
+ const page = Math.max(1, Number(reqUrl.searchParams.get("page") || 1));
796
+ const limit = Math.min(5000, Math.max(1, Number(reqUrl.searchParams.get("limit") || 50)));
797
+
798
+ let results = getMergedSkillIndex();
799
+
800
+ // Filter by source
801
+ if (sourceFilter && ["seed", "imported", "user"].includes(sourceFilter)) {
802
+ results = results.filter((s) => s.source === sourceFilter);
803
+ }
804
+
805
+ // Filter by vetted
806
+ if (vettedFilter === "1") {
807
+ results = results.filter((s) => s.vettedOk === true);
808
+ }
809
+
810
+ // Filter by search query (case-insensitive substring match on name, slug, description)
811
+ if (query) {
812
+ results = results.filter((s) => {
813
+ const name = String(s.name || "").toLowerCase();
814
+ const slug = String(s.slug || "").toLowerCase();
815
+ const desc = String(s.description || "").toLowerCase();
816
+ return name.includes(query) || slug.includes(query) || desc.includes(query);
817
+ });
818
+ }
819
+
820
+ // Paginate
821
+ const total = results.length;
822
+ const pages = Math.ceil(total / limit);
823
+ const start = (page - 1) * limit;
824
+ const end = start + limit;
825
+ const paginatedResults = results.slice(start, end);
826
+
827
+ res.writeHead(200, { "content-type": "application/json; charset=utf-8" });
828
+ return res.end(
829
+ JSON.stringify({
830
+ ok: true,
831
+ total,
832
+ page,
833
+ limit,
834
+ pages,
835
+ results: paginatedResults,
836
+ }),
837
+ );
838
+ } catch (err) {
839
+ res.writeHead(500, { "content-type": "application/json; charset=utf-8" });
840
+ return res.end(JSON.stringify({ ok: false, error: err.message || "search failed" }));
841
+ }
842
+ }
843
+ if (pathname === "/api/skills/get" && req.method === "GET") {
844
+ try {
845
+ const slug = String(reqUrl.searchParams.get("slug") || "").trim();
846
+ if (!slug) {
847
+ res.writeHead(400, { "content-type": "application/json; charset=utf-8" });
848
+ return res.end(JSON.stringify({ ok: false, error: "missing slug" }));
849
+ }
850
+ const all = getMergedSkillIndex();
851
+ const match = all.find((s) => s.slug === slug);
852
+ if (!match) {
853
+ res.writeHead(404, { "content-type": "application/json; charset=utf-8" });
854
+ return res.end(JSON.stringify({ ok: false, error: "skill not found" }));
855
+ }
856
+ let fullCard = null;
857
+ const bucketDirs = {
858
+ seed: SKILLCARDS_SEED_DIR,
859
+ imported: path.join(ROOT, "skillcards", "imported"),
860
+ user: SKILLCARDS_USER_DIR,
861
+ };
862
+ const baseDir = bucketDirs[match.source];
863
+ if (baseDir && match.fileName) {
864
+ try {
865
+ const fp = path.join(baseDir, match.fileName);
866
+ if (fs.existsSync(fp)) fullCard = JSON.parse(fs.readFileSync(fp, "utf8"));
867
+ } catch { /* index metadata is still returned below */ }
868
+ }
869
+ res.writeHead(200, { "content-type": "application/json; charset=utf-8" });
870
+ return res.end(JSON.stringify({ ok: true, skill: match, card: fullCard }));
871
+ } catch (err) {
872
+ res.writeHead(500, { "content-type": "application/json; charset=utf-8" });
873
+ return res.end(JSON.stringify({ ok: false, error: err.message || "get failed" }));
874
+ }
875
+ }
876
+ if (pathname === "/api/skills/stats" && req.method === "GET") {
877
+ try {
878
+ const all = getMergedSkillIndex();
879
+ const seed = all.filter((s) => s.source === "seed").length;
880
+ const imported = all.filter((s) => s.source === "imported").length;
881
+ const user = all.filter((s) => s.source === "user").length;
882
+ const vetted = all.filter((s) => s.vettedOk === true).length;
883
+ const onchain = all.filter((s) => s.onchainTokenId != null).length;
884
+ let recent = all.filter((s) => s.addedAt).sort((a, b) => new Date(b.addedAt) - new Date(a.addedAt));
885
+ if (recent.length === 0) recent = all.slice(-20).reverse();
886
+ recent = recent.slice(0, 10).map((s) => ({
887
+ name: s.name, slug: s.slug, source: s.source, addedAt: s.addedAt,
888
+ riskTier: s.riskTier, description: String(s.description || "").slice(0, 150),
889
+ onchainTokenId: s.onchainTokenId ?? null,
890
+ }));
891
+ res.writeHead(200, { "content-type": "application/json; charset=utf-8" });
892
+ return res.end(JSON.stringify({ ok: true, total: all.length, seed, imported, user, vetted, onchain, recent }));
893
+ } catch (err) {
894
+ res.writeHead(500, { "content-type": "application/json; charset=utf-8" });
895
+ return res.end(JSON.stringify({ ok: false, error: err.message || "stats failed" }));
896
+ }
897
+ }
898
+ if (pathname === "/api/skillcards/user/auth-check" && req.method === "GET") {
899
+ const auth = requireSkillWriteAuth(req);
900
+ res.writeHead(auth.ok ? 200 : 401, { "content-type": "application/json; charset=utf-8" });
901
+ return res.end(JSON.stringify({
902
+ ok: auth.ok,
903
+ mode: auth.mode,
904
+ agentId: auth.agentId,
905
+ }));
906
+ }
907
+ if (pathname === "/api/skillcards/user/add" && req.method === "POST") {
908
+ const auth = requireSkillWriteAuth(req);
909
+ if (!auth.ok) {
910
+ res.writeHead(401, { "content-type": "application/json; charset=utf-8" });
911
+ return res.end(JSON.stringify({ ok: false, error: "unauthorized (set x-agent-id/x-agent-token or x-registration-key)" }));
912
+ }
913
+ readRequestBody(req, res).then((body) => {
914
+ if (body === null) return;
915
+ const skillcard = body?.skillcard || body?.card || body;
916
+ if (!skillcard || typeof skillcard !== "object") throw new Error("missing skillcard object");
917
+
918
+ const name = String(skillcard.name || "").trim();
919
+ if (!name) throw new Error("skillcard.name required");
920
+ const slug = toSlug(skillcard.slug || name);
921
+ if (!slug) throw new Error("skillcard.slug required");
922
+
923
+ const version = safeVersion(skillcard.version || "1.0.0");
924
+ if (!version) throw new Error("skillcard.version invalid (expected semver-ish)");
925
+
926
+ const desc = String(skillcard.description || "").trim();
927
+ const riskTierRaw = Number(skillcard?.constraints?.riskTier ?? skillcard?.riskTier ?? 2);
928
+ const riskTier = Number.isFinite(riskTierRaw) ? Math.max(1, Math.min(3, Math.round(riskTierRaw))) : 2;
929
+ const createdAt = new Date().toISOString();
930
+ const sourceUrl = String(body?.sourceUrl || skillcard?.provenance?.sourceUrl || "").trim();
931
+
932
+ // Persist SkillCard JSON.
933
+ const fileName = `${slug}.v${version}.json`;
934
+ const filePath = path.join(SKILLCARDS_USER_DIR, fileName);
935
+ const payload = { ...skillcard, slug, version, name, description: desc };
936
+ fs.writeFileSync(filePath, JSON.stringify(payload, null, 2));
937
+
938
+ // Update index (append-only by default; replace if exact file exists).
939
+ const idx = JSON.parse(fs.readFileSync(SKILLCARDS_USER_INDEX_PATH, "utf8"));
940
+ const skills = Array.isArray(idx?.skills) ? idx.skills : [];
941
+ const entry = {
942
+ fileName,
943
+ name,
944
+ slug,
945
+ version,
946
+ description: desc,
947
+ riskTier,
948
+ sourceUrl,
949
+ createdAt,
950
+ addedBy: auth.mode,
951
+ addedByAgentId: auth.agentId,
952
+ };
953
+ const next = skills.filter((s) => String(s?.fileName || "") !== fileName);
954
+ next.unshift(entry);
955
+ atomicWriteJson(SKILLCARDS_USER_INDEX_PATH, { skills: next });
956
+
957
+ res.writeHead(200, { "content-type": "application/json; charset=utf-8" });
958
+ return res.end(JSON.stringify({ ok: true, entry, fileHref: `/skillcards/user/${encodeURIComponent(fileName)}` }));
959
+ }).catch((err) => {
960
+ res.writeHead(400, { "content-type": "application/json; charset=utf-8" });
961
+ return res.end(JSON.stringify({ ok: false, error: err.message || "invalid request" }));
962
+ });
963
+ return;
964
+ }
965
+ if (pathname === "/api/skillcards/user/delete" && req.method === "POST") {
966
+ const auth = requireSkillWriteAuth(req);
967
+ if (!auth.ok) {
968
+ res.writeHead(401, { "content-type": "application/json; charset=utf-8" });
969
+ return res.end(JSON.stringify({ ok: false, error: "unauthorized" }));
970
+ }
971
+ readRequestBody(req, res).then((body) => {
972
+ if (body === null) return;
973
+ const fileName = String(body?.fileName || "").trim();
974
+ if (!fileName || fileName.includes("..") || fileName.includes("/") || fileName.includes("\\")) {
975
+ throw new Error("invalid fileName");
976
+ }
977
+ const filePath = path.join(SKILLCARDS_USER_DIR, fileName);
978
+ if (fs.existsSync(filePath)) {
979
+ fs.unlinkSync(filePath);
980
+ }
981
+ const idx = JSON.parse(fs.readFileSync(SKILLCARDS_USER_INDEX_PATH, "utf8"));
982
+ const skills = Array.isArray(idx?.skills) ? idx.skills : [];
983
+ const next = skills.filter((s) => String(s?.fileName || "") !== fileName);
984
+ atomicWriteJson(SKILLCARDS_USER_INDEX_PATH, { skills: next });
985
+ res.writeHead(200, { "content-type": "application/json; charset=utf-8" });
986
+ return res.end(JSON.stringify({ ok: true }));
987
+ }).catch((err) => {
988
+ res.writeHead(400, { "content-type": "application/json; charset=utf-8" });
989
+ return res.end(JSON.stringify({ ok: false, error: err.message || "invalid request" }));
990
+ });
991
+ return;
992
+ }
993
+ if (pathname === "/api/skillcards/user/mark-onchain" && req.method === "POST") {
994
+ const auth = requireSkillWriteAuth(req);
995
+ if (!auth.ok) {
996
+ res.writeHead(401, { "content-type": "application/json; charset=utf-8" });
997
+ return res.end(JSON.stringify({ ok: false, error: "unauthorized" }));
998
+ }
999
+ readRequestBody(req, res).then((body) => {
1000
+ if (body === null) return;
1001
+ const fileName = String(body?.fileName || "").trim();
1002
+ if (!fileName || fileName.includes("..") || fileName.includes("/") || fileName.includes("\\")) {
1003
+ throw new Error("invalid fileName");
1004
+ }
1005
+ const skillIdNum = Number(body?.skillId);
1006
+ if (!Number.isFinite(skillIdNum) || skillIdNum <= 0) throw new Error("invalid skillId");
1007
+ const txHash = String(body?.txHash || "").trim();
1008
+ const idx = JSON.parse(fs.readFileSync(SKILLCARDS_USER_INDEX_PATH, "utf8"));
1009
+ const skills = Array.isArray(idx?.skills) ? idx.skills : [];
1010
+ let found = false;
1011
+ const next = skills.map((s) => {
1012
+ if (String(s?.fileName || "") !== fileName) return s;
1013
+ found = true;
1014
+ return {
1015
+ ...s,
1016
+ onchain: {
1017
+ skillId: Math.floor(skillIdNum),
1018
+ txHash,
1019
+ markedAt: new Date().toISOString(),
1020
+ },
1021
+ };
1022
+ });
1023
+ if (!found) throw new Error("skill not found");
1024
+ atomicWriteJson(SKILLCARDS_USER_INDEX_PATH, { skills: next });
1025
+ res.writeHead(200, { "content-type": "application/json; charset=utf-8" });
1026
+ return res.end(JSON.stringify({ ok: true }));
1027
+ }).catch((err) => {
1028
+ res.writeHead(400, { "content-type": "application/json; charset=utf-8" });
1029
+ return res.end(JSON.stringify({ ok: false, error: err.message || "invalid request" }));
1030
+ });
1031
+ return;
1032
+ }
1033
+
1034
+ // Read-only v2 helper: fetch a receipt by traceId (no signing).
1035
+ if (pathname === "/api/v2/receipt/get" && req.method === "GET") {
1036
+ const traceId = String(reqUrl.searchParams.get("traceId") || reqUrl.searchParams.get("trace") || "").trim();
1037
+ if (!traceId) {
1038
+ res.writeHead(400, { "content-type": "application/json; charset=utf-8" });
1039
+ return res.end(JSON.stringify({ ok: false, error: "missing traceId" }));
1040
+ }
1041
+ const cfg = resolveV2ReceiptReadConfig();
1042
+ if (!cfg.ok) {
1043
+ res.writeHead(501, { "content-type": "application/json; charset=utf-8" });
1044
+ return res.end(JSON.stringify({ ok: false, error: cfg.reason, inferredRpc: cfg.inferredRpc || false }));
1045
+ }
1046
+ (async () => {
1047
+ const publicClient = createPublicClient({ transport: viemHttp(cfg.rpcUrl) });
1048
+ const receipts = getContract({
1049
+ address: cfg.receiptsAddress,
1050
+ abi: ReceiptRegistry_ABI,
1051
+ client: { public: publicClient },
1052
+ });
1053
+ const traceIdHash = keccak256(toHex(traceId));
1054
+ const isRecorded = await receipts.read.isRecorded([traceIdHash]);
1055
+ const receipt = isRecorded ? await receipts.read.getReceipt([traceIdHash]) : null;
1056
+ const result = {
1057
+ ok: true,
1058
+ traceId,
1059
+ traceIdHash,
1060
+ isRecorded: Boolean(isRecorded),
1061
+ receipt,
1062
+ };
1063
+ res.writeHead(200, { "content-type": "application/json; charset=utf-8" });
1064
+ // viem returns bigint for uints; JSON.stringify would throw.
1065
+ res.end(JSON.stringify(result, (_k, v) => (typeof v === "bigint" ? v.toString() : v)));
1066
+ })().catch((err) => {
1067
+ // Avoid crashing the whole server if the client disconnected or we already replied.
1068
+ if (res.headersSent || res.writableEnded) return;
1069
+ res.writeHead(502, { "content-type": "application/json; charset=utf-8" });
1070
+ res.end(JSON.stringify({ ok: false, error: err?.message || "receipt read failed" }));
1071
+ });
1072
+ return;
1073
+ }
1074
+
1075
+ // Read-only v2 helper: return latest known deployment record + receipt read config.
1076
+ // This is used to auto-fill UI inputs in local/dev without copy-pasting addresses.
1077
+ if (pathname === "/api/v2/config" && req.method === "GET") {
1078
+ const rec = resolveV2DeploymentRecord();
1079
+ const v2Cfg = resolveV2ReceiptReadConfig();
1080
+ res.writeHead(200, { "content-type": "application/json; charset=utf-8" });
1081
+ return res.end(JSON.stringify({
1082
+ ok: true,
1083
+ deployment: rec,
1084
+ receiptsRead: v2Cfg,
1085
+ // Include podVault and agentAccount at top level for convenience
1086
+ podVault: rec?.podVault || null,
1087
+ agentAccount: rec?.agentAccount || null,
1088
+ // Also include as record for backward compatibility
1089
+ record: rec,
1090
+ ts: new Date().toISOString(),
1091
+ }, (_k, v) => (typeof v === "bigint" ? v.toString() : v)));
1092
+ }
1093
+
1094
+ // ── Pod status endpoint ────────────────────────────────────────
1095
+ if (pathname === "/api/pod/status" && req.method === "GET") {
1096
+ const status = getPodStatus();
1097
+ res.writeHead(200, { "content-type": "application/json; charset=utf-8" });
1098
+ return res.end(JSON.stringify(status));
1099
+ }
1100
+
1101
+ // ── Pod stop endpoint ─────────────────────────────────────────
1102
+ if (pathname === "/api/pod/stop" && req.method === "POST") {
1103
+ const auth = requireSkillWriteAuth(req);
1104
+ if (!auth.ok) {
1105
+ res.writeHead(401, { "content-type": "application/json; charset=utf-8" });
1106
+ return res.end(JSON.stringify({ ok: false, error: "unauthorized (set x-registration-key or x-agent-id/x-agent-token)" }));
1107
+ }
1108
+ const workspacePath = findPodWorkspaceDir();
1109
+ if (!workspacePath) {
1110
+ res.writeHead(404, { "content-type": "application/json; charset=utf-8" });
1111
+ return res.end(JSON.stringify({ ok: false, error: "pod workspace not found" }));
1112
+ }
1113
+ const stopFlagPath = path.join(workspacePath, "stop.flag");
1114
+ try {
1115
+ ensureDir(workspacePath);
1116
+ fs.writeFileSync(stopFlagPath, new Date().toISOString() + "\n");
1117
+ res.writeHead(200, { "content-type": "application/json; charset=utf-8" });
1118
+ return res.end(JSON.stringify({ ok: true, action: "stop", flagPath: stopFlagPath }));
1119
+ } catch (err) {
1120
+ res.writeHead(500, { "content-type": "application/json; charset=utf-8" });
1121
+ return res.end(JSON.stringify({ ok: false, error: err.message || "failed to create stop flag" }));
1122
+ }
1123
+ }
1124
+
1125
+ if (pathname === "/api/health") {
1126
+ const v2Cfg = resolveV2ReceiptReadConfig();
1127
+ const payload = {
1128
+ ok: true,
1129
+ service: "ape-claw-telemetry",
1130
+ port: PORT,
1131
+ root: ROOT,
1132
+ paths: {
1133
+ events: EVENTS_PATH,
1134
+ chat: CHAT_PATH,
1135
+ policy: POLICY_PATH,
1136
+ allowlist: ALLOWLIST_PATH,
1137
+ clawbots: CLAWBOTS_PATH,
1138
+ invites: INVITES_PATH,
1139
+ skillcardsUserIndex: SKILLCARDS_USER_INDEX_PATH,
1140
+ },
1141
+ counts: {
1142
+ eventsBytes: fs.existsSync(EVENTS_PATH) ? fs.statSync(EVENTS_PATH).size : 0,
1143
+ chatBytes: fs.existsSync(CHAT_PATH) ? fs.statSync(CHAT_PATH).size : 0,
1144
+ },
1145
+ identity: {
1146
+ moltbookEnabled: Boolean(MOLTBOOK_APP_KEY),
1147
+ moltbookApiBase: MOLTBOOK_API_BASE,
1148
+ registrationEnabled: Boolean(REGISTRATION_KEY),
1149
+ openRegistration: OPEN_REGISTRATION,
1150
+ registrationCooldownMs: REGISTRATION_COOLDOWN_MS,
1151
+ inviteTtlMs: INVITE_TTL_MS,
1152
+ inviteMaxUses: INVITE_MAX_USES,
1153
+ },
1154
+ v2: {
1155
+ rpcUrl: v2Cfg.ok ? v2Cfg.rpcUrl : (v2Cfg.rpcUrl || null),
1156
+ receiptRegistry: v2Cfg.ok ? v2Cfg.receiptsAddress : (v2Cfg.receiptsAddress || null),
1157
+ inferredRpc: Boolean(v2Cfg.inferredRpc),
1158
+ configured: Boolean(v2Cfg.ok),
1159
+ },
1160
+ ts: new Date().toISOString(),
1161
+ };
1162
+ res.writeHead(200, { "content-type": "application/json; charset=utf-8" });
1163
+ return res.end(JSON.stringify(payload));
1164
+ }
1165
+ if (pathname === "/api/clawbots") {
1166
+ if (!fs.existsSync(CLAWBOTS_PATH)) {
1167
+ res.writeHead(200, { "content-type": "application/json; charset=utf-8" });
1168
+ return res.end(JSON.stringify({ count: 0, clawbots: [], sharedKeyConfigured: false }));
1169
+ }
1170
+ try {
1171
+ const raw = JSON.parse(fs.readFileSync(CLAWBOTS_PATH, "utf8"));
1172
+ const agents = raw.agents || {};
1173
+ const clawbots = Object.entries(agents).map(([id, a]) => ({
1174
+ agentId: id,
1175
+ name: a.name || id,
1176
+ enabled: a.enabled !== false,
1177
+ createdAt: a.createdAt || null,
1178
+ }));
1179
+ res.writeHead(200, { "content-type": "application/json; charset=utf-8" });
1180
+ const sharedKeyConfigured = Boolean(raw.sharedOpenseaApiKey || process.env.APE_CLAW_SHARED_OPENSEA_KEY);
1181
+ return res.end(JSON.stringify({ count: clawbots.length, clawbots, sharedKeyConfigured }));
1182
+ } catch (err) {
1183
+ res.writeHead(500, { "content-type": "application/json; charset=utf-8" });
1184
+ return res.end(JSON.stringify({ error: err.message }));
1185
+ }
1186
+ }
1187
+
1188
+ // Serve individual SkillCard JSON files (read-only, public).
1189
+ // Supports: /skillcards/user/<file>, /skillcards/imported/<file>, /skillcards/seed/<file>
1190
+ if (pathname.startsWith("/skillcards/") && req.method === "GET") {
1191
+ const segments = pathname.slice("/skillcards/".length).split("/");
1192
+ const bucket = segments[0];
1193
+ const fileName = segments.length > 1 ? decodeURIComponent(segments.slice(1).join("/")) : "";
1194
+ const ALLOWED_BUCKETS = {
1195
+ user: SKILLCARDS_USER_DIR,
1196
+ imported: path.join(ROOT, "skillcards", "imported"),
1197
+ seed: SKILLCARDS_SEED_DIR,
1198
+ };
1199
+ const baseDir = ALLOWED_BUCKETS[bucket];
1200
+ if (!baseDir || !fileName || fileName.includes("..") || fileName.includes("\\")) {
1201
+ res.writeHead(400, { "content-type": "application/json; charset=utf-8" });
1202
+ return res.end(JSON.stringify({ error: "invalid file" }));
1203
+ }
1204
+ const filePath = path.join(baseDir, fileName);
1205
+ if (!fs.existsSync(filePath) || !fs.statSync(filePath).isFile()) {
1206
+ res.writeHead(404, { "content-type": "application/json; charset=utf-8" });
1207
+ return res.end(JSON.stringify({ error: "not found" }));
1208
+ }
1209
+ res.writeHead(200, { "content-type": "application/json; charset=utf-8" });
1210
+ return fs.createReadStream(filePath).pipe(res);
1211
+ }
1212
+ if (pathname === "/api/clawbots/verify" && req.method === "POST") {
1213
+ // Verify credentials against backend clawbots.json (server-authoritative).
1214
+ // Returns shared OpenSea key only for verified bots (if configured).
1215
+ const headerAgentId = String(req.headers["x-agent-id"] || "").trim();
1216
+ const headerAgentToken = String(req.headers["x-agent-token"] || "").trim();
1217
+ if (!headerAgentId || !headerAgentToken) {
1218
+ res.writeHead(401, { "content-type": "application/json; charset=utf-8" });
1219
+ return res.end(JSON.stringify({ ok: false, error: "missing credentials: x-agent-id + x-agent-token are required" }));
1220
+ }
1221
+ const verification = verifyClawbot({ agentId: headerAgentId, agentToken: headerAgentToken });
1222
+ if (!verification.verified) {
1223
+ res.writeHead(403, { "content-type": "application/json; charset=utf-8" });
1224
+ return res.end(JSON.stringify({ ok: false, error: "not verified", reason: verification.reason }));
1225
+ }
1226
+ res.writeHead(200, { "content-type": "application/json; charset=utf-8" });
1227
+ return res.end(JSON.stringify({
1228
+ ok: true,
1229
+ verified: true,
1230
+ agent: verification.agent,
1231
+ sharedOpenseaApiKey: verification.sharedOpenseaApiKey || "",
1232
+ }));
1233
+ }
1234
+ if (pathname === "/api/invites/create" && req.method === "POST") {
1235
+ readRequestBody(req, res).then((body) => {
1236
+ if (body === null) return;
1237
+ if (!REGISTRATION_KEY) {
1238
+ res.writeHead(503, { "content-type": "application/json; charset=utf-8" });
1239
+ return res.end(JSON.stringify({ error: "invite creation disabled: backend missing APE_CLAW_REGISTRATION_KEY" }));
1240
+ }
1241
+ const providedKey = String(req.headers["x-registration-key"] || "").trim();
1242
+ if (!providedKey || providedKey !== REGISTRATION_KEY) {
1243
+ res.writeHead(403, { "content-type": "application/json; charset=utf-8" });
1244
+ return res.end(JSON.stringify({ error: "invalid registration key" }));
1245
+ }
1246
+ const ttlMs = body?.ttlMs;
1247
+ const uses = body?.uses;
1248
+ const invite = mintInvite({ ttlMs, uses });
1249
+ res.writeHead(200, { "content-type": "application/json; charset=utf-8" });
1250
+ return res.end(JSON.stringify({
1251
+ ok: true,
1252
+ invite: invite.token,
1253
+ expiresAt: invite.expiresAt,
1254
+ usesRemaining: invite.usesRemaining,
1255
+ note: "Share this invite privately. It can be redeemed via clawbot register --invite <token>.",
1256
+ }));
1257
+ }).catch(() => {
1258
+ res.writeHead(400, { "content-type": "application/json; charset=utf-8" });
1259
+ return res.end(JSON.stringify({ error: "invalid JSON body" }));
1260
+ });
1261
+ return;
1262
+ }
1263
+ if (pathname === "/api/clawbots/register" && req.method === "POST") {
1264
+ readRequestBody(req, res).then((body) => {
1265
+ if (body === null) return;
1266
+ const inviteToken = String(body?.invite || "").trim();
1267
+ const inviteOk = inviteToken ? consumeInvite(inviteToken) : { ok: false, reason: "missing invite" };
1268
+
1269
+ if (!REGISTRATION_KEY && !OPEN_REGISTRATION && !inviteOk.ok) {
1270
+ res.writeHead(503, { "content-type": "application/json; charset=utf-8" });
1271
+ return res.end(JSON.stringify({
1272
+ error: "registration is disabled: use an invite, set APE_CLAW_REGISTRATION_KEY, or enable APE_CLAW_OPEN_REGISTRATION",
1273
+ }));
1274
+ }
1275
+ const hasValidKey = (() => {
1276
+ if (!REGISTRATION_KEY) return false;
1277
+ const providedKey = String(req.headers["x-registration-key"] || "").trim();
1278
+ return Boolean(providedKey) && providedKey === REGISTRATION_KEY;
1279
+ })();
1280
+ if (!OPEN_REGISTRATION && !hasValidKey && !inviteOk.ok) {
1281
+ res.writeHead(403, { "content-type": "application/json; charset=utf-8" });
1282
+ return res.end(JSON.stringify({ error: "registration not allowed (missing invite or invalid registration key)" }));
1283
+ }
1284
+ if (OPEN_REGISTRATION && !hasValidKey && REGISTRATION_COOLDOWN_MS > 0) {
1285
+ const ip = clientIpFromReq(req);
1286
+ const now = Date.now();
1287
+ const last = Number(registrationByIp.get(ip) || 0);
1288
+ if (last && now - last < REGISTRATION_COOLDOWN_MS) {
1289
+ const waitMs = REGISTRATION_COOLDOWN_MS - (now - last);
1290
+ res.writeHead(429, { "content-type": "application/json; charset=utf-8" });
1291
+ return res.end(JSON.stringify({
1292
+ error: "registration rate limited",
1293
+ retryAfterMs: waitMs,
1294
+ }));
1295
+ }
1296
+ registrationByIp.set(ip, now);
1297
+ }
1298
+
1299
+ const agentId = String(body?.agentId || "").trim();
1300
+ const displayName = String(body?.name || agentId || "").trim();
1301
+ if (!agentId) {
1302
+ res.writeHead(400, { "content-type": "application/json; charset=utf-8" });
1303
+ return res.end(JSON.stringify({ error: "agentId is required" }));
1304
+ }
1305
+ try {
1306
+ const reg = registerClawbot({ agentId, displayName });
1307
+ const result = {
1308
+ registered: true,
1309
+ agentId: reg.agentId,
1310
+ name: reg.displayName,
1311
+ token: reg.token,
1312
+ note: "Save this token — it is shown only once. Use as APE_CLAW_AGENT_TOKEN or --agent-token.",
1313
+ };
1314
+ res.writeHead(200, { "content-type": "application/json; charset=utf-8" });
1315
+ return res.end(JSON.stringify(result));
1316
+ } catch (err) {
1317
+ res.writeHead(400, { "content-type": "application/json; charset=utf-8" });
1318
+ return res.end(JSON.stringify({ error: err.message || "registration failed" }));
1319
+ }
1320
+ }).catch(() => {
1321
+ res.writeHead(400, { "content-type": "application/json; charset=utf-8" });
1322
+ return res.end(JSON.stringify({ error: "invalid JSON body" }));
1323
+ });
1324
+ return;
1325
+ }
1326
+ if (pathname === "/api/events" && req.method === "POST") {
1327
+ readRequestBody(req, res).then((body) => {
1328
+ if (body === null) return;
1329
+ const eventType = String(body?.eventType || "").trim();
1330
+ if (!eventType) {
1331
+ res.writeHead(400, { "content-type": "application/json; charset=utf-8" });
1332
+ return res.end(JSON.stringify({ error: "eventType is required" }));
1333
+ }
1334
+
1335
+ const headerAgentId = String(req.headers["x-agent-id"] || "").trim();
1336
+ const headerAgentToken = String(req.headers["x-agent-token"] || "").trim();
1337
+ if (!headerAgentId || !headerAgentToken) {
1338
+ res.writeHead(401, { "content-type": "application/json; charset=utf-8" });
1339
+ return res.end(JSON.stringify({ error: "missing credentials: x-agent-id + x-agent-token are required" }));
1340
+ }
1341
+ const verification = verifyClawbot({ agentId: headerAgentId, agentToken: headerAgentToken });
1342
+ if (!verification.verified) {
1343
+ res.writeHead(403, { "content-type": "application/json; charset=utf-8" });
1344
+ return res.end(JSON.stringify({ error: "not verified", reason: verification.reason }));
1345
+ }
1346
+
1347
+ const evt = {
1348
+ v: Number(body?.v || 1),
1349
+ ts:
1350
+ typeof body?.ts === "string"
1351
+ ? body.ts
1352
+ : (typeof body?.ts === "number" && Number.isFinite(body.ts))
1353
+ ? new Date(body.ts * 1000).toISOString()
1354
+ : new Date().toISOString(),
1355
+ eventType,
1356
+ agentId: headerAgentId,
1357
+ sessionId: String(body?.sessionId || "remote-session"),
1358
+ traceId: String(body?.traceId || `trace_${Date.now()}`),
1359
+ command: String(body?.command || ""),
1360
+ dryRun: Boolean(body?.dryRun),
1361
+ chainId: Number(body?.chainId || 33139),
1362
+ payload: (body?.payload || body?.data) && typeof (body?.payload || body?.data) === "object" ? (body?.payload || body?.data) : {},
1363
+ result: body?.result && typeof body.result === "object" ? body.result : {},
1364
+ ok: body?.ok !== false,
1365
+ error: body?.error || null,
1366
+ ...(body?.source ? { source: String(body.source) } : {}),
1367
+ };
1368
+ appendTelemetryEvent(evt);
1369
+ for (const c of clients) sendSse(c, evt);
1370
+
1371
+ res.writeHead(200, { "content-type": "application/json; charset=utf-8" });
1372
+ return res.end(JSON.stringify({ ok: true, event: evt }));
1373
+ }).catch(() => {
1374
+ res.writeHead(400, { "content-type": "application/json; charset=utf-8" });
1375
+ return res.end(JSON.stringify({ error: "invalid JSON body" }));
1376
+ });
1377
+ return;
1378
+ }
1379
+ // ── Chat SSE stream ────────────────────────────────
1380
+ if (pathname === "/api/chat/stream") {
1381
+ const room = normalizeRoomName(reqUrl.searchParams.get("room") || "all");
1382
+ res.writeHead(200, {
1383
+ "content-type": "text/event-stream",
1384
+ "cache-control": "no-cache",
1385
+ connection: "keep-alive",
1386
+ });
1387
+ res.write("\n");
1388
+ const client = { res, room };
1389
+ chatClients.add(client);
1390
+ req.on("close", () => chatClients.delete(client));
1391
+ return;
1392
+ }
1393
+
1394
+ // ── Chat: GET recent messages ─────────────────────
1395
+ if (pathname === "/api/chat" && req.method === "GET") {
1396
+ const room = normalizeRoomName(reqUrl.searchParams.get("room") || "all");
1397
+ const limit = Math.max(1, Math.min(500, Number(reqUrl.searchParams.get("limit") || 100)));
1398
+ const messages = readChatMessages(limit, room);
1399
+ res.writeHead(200, { "content-type": "application/json; charset=utf-8" });
1400
+ return res.end(JSON.stringify({ room, limit, messages }));
1401
+ }
1402
+
1403
+ // ── Chat: GET room directory ──────────────────────
1404
+ if (pathname === "/api/chat/rooms" && req.method === "GET") {
1405
+ const limit = Math.max(1, Math.min(200, Number(reqUrl.searchParams.get("limit") || 50)));
1406
+ const rooms = readChatRooms(limit);
1407
+ res.writeHead(200, { "content-type": "application/json; charset=utf-8" });
1408
+ return res.end(JSON.stringify({ count: rooms.length, rooms }));
1409
+ }
1410
+
1411
+ // ── Chat: POST new message ────────────────────────
1412
+ if (pathname === "/api/chat" && req.method === "POST") {
1413
+ readRequestBody(req, res).then(async (body) => {
1414
+ if (body === null) return;
1415
+ const room = normalizeRoomName(body.room || reqUrl.searchParams.get("room") || "general");
1416
+ const text = String(body.text || "").trim();
1417
+ const replyTo = String(body.replyTo || "").trim();
1418
+
1419
+ if (!text || text.length > 500) {
1420
+ res.writeHead(400, { "content-type": "application/json" });
1421
+ return res.end(JSON.stringify({ error: "message must be 1-500 characters" }));
1422
+ }
1423
+ const authRes = await resolveChatAuth(req, body);
1424
+ if (!authRes.ok) {
1425
+ res.writeHead(authRes.status || 403, { "content-type": "application/json" });
1426
+ return res.end(JSON.stringify({ error: authRes.error, reason: authRes.reason }));
1427
+ }
1428
+ const auth = authRes.auth;
1429
+
1430
+ if (replyTo) {
1431
+ const existing = materializeChatMessages(readChatEntries(), room);
1432
+ const parent = existing.find((m) => m.id === replyTo);
1433
+ if (!parent) {
1434
+ res.writeHead(400, { "content-type": "application/json" });
1435
+ return res.end(JSON.stringify({ error: "reply target not found in this room" }));
1436
+ }
1437
+ }
1438
+
1439
+ const msg = {
1440
+ id: `msg_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`,
1441
+ type: "message",
1442
+ agentId: auth.id,
1443
+ agentName: auth.name,
1444
+ identityProvider: auth.provider,
1445
+ identityMeta: auth.meta,
1446
+ room,
1447
+ text,
1448
+ replyTo: replyTo || null,
1449
+ reactions: {},
1450
+ reactionUsers: {},
1451
+ ts: new Date().toISOString(),
1452
+ };
1453
+
1454
+ appendChatMessage(msg);
1455
+ broadcastChat(msg);
1456
+
1457
+ res.writeHead(200, { "content-type": "application/json; charset=utf-8" });
1458
+ return res.end(JSON.stringify({ ok: true, message: msg }));
1459
+ }).catch((err) => {
1460
+ res.writeHead(400, { "content-type": "application/json" });
1461
+ return res.end(JSON.stringify({ error: "invalid JSON body" }));
1462
+ });
1463
+ return;
1464
+ }
1465
+
1466
+ // ── Chat: POST reaction toggle ─────────────────────
1467
+ if (pathname === "/api/chat/react" && req.method === "POST") {
1468
+ readRequestBody(req, res).then(async (body) => {
1469
+ if (body === null) return;
1470
+ const room = normalizeRoomName(body.room || reqUrl.searchParams.get("room") || "general");
1471
+ const messageId = String(body.messageId || "").trim();
1472
+ const emoji = String(body.emoji || "").trim().slice(0, 8);
1473
+ if (!messageId || !emoji) {
1474
+ res.writeHead(400, { "content-type": "application/json" });
1475
+ return res.end(JSON.stringify({ error: "messageId and emoji are required" }));
1476
+ }
1477
+
1478
+ const authRes = await resolveChatAuth(req, body);
1479
+ if (!authRes.ok) {
1480
+ res.writeHead(authRes.status || 403, { "content-type": "application/json" });
1481
+ return res.end(JSON.stringify({ error: authRes.error, reason: authRes.reason }));
1482
+ }
1483
+ const auth = authRes.auth;
1484
+
1485
+ const existing = materializeChatMessages(readChatEntries(), room);
1486
+ const parent = existing.find((m) => m.id === messageId);
1487
+ if (!parent) {
1488
+ res.writeHead(404, { "content-type": "application/json" });
1489
+ return res.end(JSON.stringify({ error: "message not found in this room" }));
1490
+ }
1491
+
1492
+ const evt = {
1493
+ id: `react_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`,
1494
+ type: "reaction",
1495
+ room,
1496
+ messageId,
1497
+ emoji,
1498
+ agentId: auth.id,
1499
+ agentName: auth.name,
1500
+ ts: new Date().toISOString(),
1501
+ };
1502
+ appendChatMessage(evt);
1503
+ broadcastChat(evt);
1504
+ res.writeHead(200, { "content-type": "application/json; charset=utf-8" });
1505
+ return res.end(JSON.stringify({ ok: true, reaction: evt }));
1506
+ }).catch(() => {
1507
+ res.writeHead(400, { "content-type": "application/json" });
1508
+ return res.end(JSON.stringify({ error: "invalid JSON body" }));
1509
+ });
1510
+ return;
1511
+ }
1512
+
1513
+ // ── Local UX rewrites (match Vercel routes) ───────────
1514
+ // In production, Vercel rewrites /ui, /docs, /pod, /skills to static HTML files.
1515
+ // For local dev, implement the same behavior so URLs are consistent.
1516
+ const REWRITES = {
1517
+ "/ui": "/ui/index.html",
1518
+ "/app": "/ui/index.html",
1519
+ "/docs": "/ui/docs.html",
1520
+ "/pod": "/ui/pod.html",
1521
+ "/skills": "/ui/skills.html",
1522
+ "/favicon-lobster.png": "/ui/favicon-lobster.png",
1523
+ "/ui/favicon.svg": "/ui/favicon.svg",
1524
+ "/ui/favicon-32.png": "/ui/favicon-32.png",
1525
+ "/ui/favicon-180.png": "/ui/favicon-180.png",
1526
+ "/ui/favicon-192.png": "/ui/favicon-192.png",
1527
+ };
1528
+ const cleanPath = String(pathname || "").replace(/\/+$/, "").toLowerCase() || pathname;
1529
+ const rewrite = REWRITES[pathname] || REWRITES[String(pathname || "").replace(/\/+$/, "")] || REWRITES[cleanPath] || "";
1530
+ if (rewrite) {
1531
+ const p = path.join(ROOT, rewrite);
1532
+ if (!fs.existsSync(p)) {
1533
+ res.writeHead(404);
1534
+ return res.end(`missing: ${rewrite}`);
1535
+ }
1536
+ const ext = path.extname(p).toLowerCase();
1537
+ const mime = ext === ".html" ? "text/html; charset=utf-8"
1538
+ : ext === ".png" ? "image/png"
1539
+ : "application/octet-stream";
1540
+ res.writeHead(200, { "content-type": mime });
1541
+ return fs.createReadStream(p).pipe(res);
1542
+ }
1543
+
1544
+ if (pathname === "/" || pathname === "/index.html") {
1545
+ // Prefer the marketing landing page at repo root.
1546
+ const landingPath = path.join(ROOT, "index.html");
1547
+ const p = fs.existsSync(landingPath) ? landingPath : UI_PATH;
1548
+ if (!fs.existsSync(p)) {
1549
+ res.writeHead(404);
1550
+ return res.end("index.html not found");
1551
+ }
1552
+ res.writeHead(200, { "content-type": "text/html; charset=utf-8" });
1553
+ return fs.createReadStream(p).pipe(res);
1554
+ }
1555
+
1556
+ // ── Static file serving for local dev ────────────────
1557
+ const MIME_TYPES = {
1558
+ ".html": "text/html", ".css": "text/css", ".js": "application/javascript",
1559
+ ".json": "application/json", ".png": "image/png", ".jpg": "image/jpeg",
1560
+ ".svg": "image/svg+xml", ".ico": "image/x-icon", ".webp": "image/webp",
1561
+ ".md": "text/plain; charset=utf-8", ".txt": "text/plain; charset=utf-8",
1562
+ ".woff2": "font/woff2",
1563
+ };
1564
+ const safePath = decodeURIComponent(pathname);
1565
+ if (!safePath.includes("..") && !safePath.includes("~")) {
1566
+ const filePath = path.join(ROOT, safePath);
1567
+ if (fs.existsSync(filePath) && fs.statSync(filePath).isFile()) {
1568
+ const ext = path.extname(filePath).toLowerCase();
1569
+ const mime = MIME_TYPES[ext] || "application/octet-stream";
1570
+ res.writeHead(200, { "content-type": mime });
1571
+ return fs.createReadStream(filePath).pipe(res);
1572
+ }
1573
+ }
1574
+
1575
+ res.writeHead(404);
1576
+ res.end("not found");
1577
+ });
1578
+
1579
+ let lastSize = fs.statSync(EVENTS_PATH).size;
1580
+ fs.watchFile(EVENTS_PATH, { interval: 500 }, () => {
1581
+ const stat = fs.statSync(EVENTS_PATH);
1582
+ if (stat.size <= lastSize) return;
1583
+ const fd = fs.openSync(EVENTS_PATH, "r");
1584
+ const buf = Buffer.alloc(stat.size - lastSize);
1585
+ fs.readSync(fd, buf, 0, buf.length, lastSize);
1586
+ fs.closeSync(fd);
1587
+ lastSize = stat.size;
1588
+ const chunk = buf.toString("utf8");
1589
+ const lines = chunk.split("\n").map((l) => l.trim()).filter(Boolean);
1590
+ for (const line of lines) {
1591
+ try {
1592
+ const evt = JSON.parse(line);
1593
+ for (const c of clients) sendSse(c, evt);
1594
+ } catch {
1595
+ // ignore malformed lines
1596
+ }
1597
+ }
1598
+ });
1599
+
1600
+ server.listen(PORT, BIND_HOST || undefined, () => {
1601
+ console.log(`ape-claw telemetry server listening on http://localhost:${PORT}`);
1602
+ console.log(`SSE stream: http://localhost:${PORT}/events`);
1603
+ });
1604
+