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.
@@ -0,0 +1,820 @@
1
+ /**
2
+ * Routes: POST /api/forge/chat, GET /api/forge/status
3
+ *
4
+ * Real OpenClaw agent wrapper for the Forge page.
5
+ * - Auto-detects LLM provider from environment (Perplexity, OpenAI, Anthropic, Ollama, Groq, or any OpenAI-compatible endpoint)
6
+ * - Loads skills from ~/.openclaw/skills/ at startup
7
+ * - Registered ClawBot identity (FORGE_AGENT_ID / FORGE_AGENT_TOKEN)
8
+ * - Fetches live telemetry snapshot on each request
9
+ * - Streams responses back to the browser via SSE
10
+ * - Posts conversations to chat log + emits telemetry events
11
+ */
12
+
13
+ import fs from "node:fs";
14
+ import os from "node:os";
15
+ import path from "node:path";
16
+ import { execSync } from "node:child_process";
17
+ import { getStorage } from "../storage/index.mjs";
18
+ import { collectBody } from "../middleware/body-limit.mjs";
19
+ import { CLAWBOTS_PATH } from "../../lib/paths.mjs";
20
+ import { registerClawbot, verifyClawbot } from "../../lib/clawbots.mjs";
21
+ import logger from "../logger.mjs";
22
+
23
+ const SKILL_RESCAN_INTERVAL_MS = 5 * 60 * 1000;
24
+ const MAX_HISTORY_TURNS = 10;
25
+ const MAX_MESSAGE_LENGTH = 500;
26
+
27
+ const FORGE_AGENT_ID = String(process.env.FORGE_AGENT_ID || "the-clawllector").trim();
28
+ const FORGE_AGENT_TOKEN = String(process.env.FORGE_AGENT_TOKEN || "").trim();
29
+ const FORGE_AGENT_DISPLAY_NAME = String(process.env.FORGE_AGENT_NAME || "The Clawllector").trim();
30
+
31
+ const RATE_LIMIT_WINDOW_MS = 60_000;
32
+ const RATE_LIMIT_MAX = 10;
33
+ const rateBuckets = new Map();
34
+
35
+ let cachedSkills = { apeClawFull: "", summaries: [], loadedAt: 0 };
36
+ let runtimeAgentToken = FORGE_AGENT_TOKEN;
37
+ let runtimeAgentVerified = false;
38
+
39
+ /* ══════════════════════════════════════════════════════════
40
+ LLM Provider Detection
41
+ Auto-detects from env vars. Priority:
42
+ 1. Explicit FORGE_LLM_* overrides (any OpenAI-compatible endpoint)
43
+ 2. PERPLEXITY_API_KEY
44
+ 3. OPENAI_API_KEY
45
+ 4. ANTHROPIC_API_KEY
46
+ 5. GROQ_API_KEY
47
+ 6. TOGETHER_API_KEY
48
+ 7. OLLAMA_HOST / OLLAMA_BASE_URL (no key needed)
49
+ ══════════════════════════════════════════════════════════ */
50
+
51
+ const PROVIDER_DEFAULTS = {
52
+ custom: { url: null, model: "gpt-4o" },
53
+ perplexity: { url: "https://api.perplexity.ai/chat/completions", model: "sonar-pro" },
54
+ openai: { url: "https://api.openai.com/v1/chat/completions", model: "gpt-4o" },
55
+ anthropic: { url: "https://api.anthropic.com/v1/messages", model: "claude-sonnet-4-20250514" },
56
+ groq: { url: "https://api.groq.com/openai/v1/chat/completions", model: "llama-3.3-70b-versatile" },
57
+ together: { url: "https://api.together.xyz/v1/chat/completions", model: "meta-llama/Llama-3.3-70B-Instruct-Turbo" },
58
+ ollama: { url: "http://localhost:11434/v1/chat/completions", model: "llama3.2" },
59
+ };
60
+
61
+ function detectLlmProvider() {
62
+ const explicit = (process.env.FORGE_LLM_API_URL || "").trim();
63
+ const explicitKey = (process.env.FORGE_LLM_API_KEY || "").trim();
64
+ const explicitModel = (process.env.FORGE_LLM_MODEL || process.env.FORGE_AGENT_MODEL || "").trim();
65
+
66
+ if (explicit) {
67
+ return {
68
+ provider: "custom",
69
+ apiUrl: explicit,
70
+ apiKey: explicitKey,
71
+ model: explicitModel || PROVIDER_DEFAULTS.custom.model,
72
+ isAnthropic: explicit.includes("anthropic.com"),
73
+ };
74
+ }
75
+
76
+ const perplexity = (process.env.PERPLEXITY_API_KEY || "").trim();
77
+ if (perplexity) {
78
+ return {
79
+ provider: "perplexity",
80
+ apiUrl: PROVIDER_DEFAULTS.perplexity.url,
81
+ apiKey: perplexity,
82
+ model: explicitModel || PROVIDER_DEFAULTS.perplexity.model,
83
+ isAnthropic: false,
84
+ };
85
+ }
86
+
87
+ const openai = (process.env.OPENAI_API_KEY || "").trim();
88
+ if (openai) {
89
+ return {
90
+ provider: "openai",
91
+ apiUrl: PROVIDER_DEFAULTS.openai.url,
92
+ apiKey: openai,
93
+ model: explicitModel || PROVIDER_DEFAULTS.openai.model,
94
+ isAnthropic: false,
95
+ };
96
+ }
97
+
98
+ const anthropic = (process.env.ANTHROPIC_API_KEY || "").trim();
99
+ if (anthropic) {
100
+ return {
101
+ provider: "anthropic",
102
+ apiUrl: PROVIDER_DEFAULTS.anthropic.url,
103
+ apiKey: anthropic,
104
+ model: explicitModel || PROVIDER_DEFAULTS.anthropic.model,
105
+ isAnthropic: true,
106
+ };
107
+ }
108
+
109
+ const groq = (process.env.GROQ_API_KEY || "").trim();
110
+ if (groq) {
111
+ return {
112
+ provider: "groq",
113
+ apiUrl: PROVIDER_DEFAULTS.groq.url,
114
+ apiKey: groq,
115
+ model: explicitModel || PROVIDER_DEFAULTS.groq.model,
116
+ isAnthropic: false,
117
+ };
118
+ }
119
+
120
+ const together = (process.env.TOGETHER_API_KEY || "").trim();
121
+ if (together) {
122
+ return {
123
+ provider: "together",
124
+ apiUrl: PROVIDER_DEFAULTS.together.url,
125
+ apiKey: together,
126
+ model: explicitModel || PROVIDER_DEFAULTS.together.model,
127
+ isAnthropic: false,
128
+ };
129
+ }
130
+
131
+ const ollamaHost = (process.env.OLLAMA_HOST || process.env.OLLAMA_BASE_URL || "").trim();
132
+ if (ollamaHost) {
133
+ const base = ollamaHost.replace(/\/+$/, "");
134
+ return {
135
+ provider: "ollama",
136
+ apiUrl: `${base}/v1/chat/completions`,
137
+ apiKey: "",
138
+ model: explicitModel || PROVIDER_DEFAULTS.ollama.model,
139
+ isAnthropic: false,
140
+ };
141
+ }
142
+
143
+ return null;
144
+ }
145
+
146
+ const llmConfig = detectLlmProvider();
147
+
148
+ /* ══════════════════════════════════════════════════════════
149
+ Skill Loader — reads from ~/.openclaw/skills/ and fallback
150
+ ══════════════════════════════════════════════════════════ */
151
+
152
+ function parseFrontmatter(content) {
153
+ const match = content.match(/^---\n([\s\S]*?)\n---\s*/);
154
+ if (!match) return { meta: {}, body: content };
155
+ const meta = {};
156
+ for (const line of match[1].split("\n")) {
157
+ const idx = line.indexOf(":");
158
+ if (idx > 0) {
159
+ const key = line.slice(0, idx).trim();
160
+ let val = line.slice(idx + 1).trim();
161
+ if (val.startsWith('"') && val.endsWith('"')) val = val.slice(1, -1);
162
+ meta[key] = val;
163
+ }
164
+ }
165
+ return { meta, body: content.slice(match[0].length) };
166
+ }
167
+
168
+ function loadSkillsFromDir(dir) {
169
+ const skills = [];
170
+ if (!fs.existsSync(dir)) return skills;
171
+ let entries;
172
+ try { entries = fs.readdirSync(dir); } catch { return skills; }
173
+ for (const name of entries) {
174
+ const skillMd = path.join(dir, name, "SKILL.md");
175
+ if (!fs.existsSync(skillMd)) continue;
176
+ try {
177
+ const raw = fs.readFileSync(skillMd, "utf8");
178
+ const { meta, body } = parseFrontmatter(raw);
179
+ skills.push({
180
+ slug: name,
181
+ name: meta.name || name,
182
+ description: meta.description || "",
183
+ version: meta.version || "",
184
+ fullContent: raw,
185
+ body,
186
+ });
187
+ } catch {}
188
+ }
189
+ return skills;
190
+ }
191
+
192
+ function findOpenClawBundledSkillsDir() {
193
+ try {
194
+ const bin = execSync("which openclaw", { encoding: "utf8" }).trim();
195
+ if (bin) {
196
+ const resolved = fs.realpathSync(bin);
197
+ const pkgDir = path.resolve(path.dirname(resolved), "..", "lib", "node_modules", "openclaw", "skills");
198
+ if (fs.existsSync(pkgDir)) return pkgDir;
199
+ const altDir = path.resolve(path.dirname(resolved), "..", "node_modules", "openclaw", "skills");
200
+ if (fs.existsSync(altDir)) return altDir;
201
+ }
202
+ } catch {}
203
+ const globalPaths = [
204
+ "/usr/local/lib/node_modules/openclaw/skills",
205
+ "/usr/lib/node_modules/openclaw/skills",
206
+ path.join(os.homedir(), ".npm-global", "lib", "node_modules", "openclaw", "skills"),
207
+ ];
208
+ for (const p of globalPaths) {
209
+ if (fs.existsSync(p)) return p;
210
+ }
211
+ return null;
212
+ }
213
+
214
+ let _openclawBundledDir = undefined;
215
+
216
+ function refreshSkillCache() {
217
+ const now = Date.now();
218
+ if (now - cachedSkills.loadedAt < SKILL_RESCAN_INTERVAL_MS) return;
219
+
220
+ const seen = new Set();
221
+ const allSkills = [];
222
+
223
+ function addSkillsFrom(dir) {
224
+ for (const s of loadSkillsFromDir(dir)) {
225
+ if (!seen.has(s.slug)) {
226
+ seen.add(s.slug);
227
+ allSkills.push(s);
228
+ }
229
+ }
230
+ }
231
+
232
+ addSkillsFrom(path.join(os.homedir(), ".openclaw", "skills"));
233
+
234
+ if (_openclawBundledDir === undefined) {
235
+ _openclawBundledDir = findOpenClawBundledSkillsDir();
236
+ if (_openclawBundledDir) logger.info({ dir: _openclawBundledDir }, "Found OpenClaw bundled skills");
237
+ }
238
+ if (_openclawBundledDir) addSkillsFrom(_openclawBundledDir);
239
+
240
+ addSkillsFrom(path.join(process.cwd(), "data", "forge-skills"));
241
+
242
+ addSkillsFrom(path.join(process.cwd(), ".cursor", "skills"));
243
+
244
+ const skills = allSkills;
245
+
246
+ const apeClaw = skills.find((s) => s.slug === "ape-claw");
247
+ const summaries = skills
248
+ .filter((s) => s.slug !== "ape-claw")
249
+ .map((s) => `- **${s.name}**: ${s.description || "(no description)"}`)
250
+ .slice(0, 80);
251
+
252
+ cachedSkills = {
253
+ apeClawFull: apeClaw?.fullContent || "",
254
+ summaries,
255
+ loadedAt: now,
256
+ };
257
+
258
+ logger.info({ skillCount: skills.length, source: skills.length > 0 ? "openclaw" : "fallback" }, "Forge agent skills loaded");
259
+ }
260
+
261
+ function ensureForgeAgentIdentity() {
262
+ if (runtimeAgentToken) {
263
+ const check = verifyClawbot({ agentId: FORGE_AGENT_ID, agentToken: runtimeAgentToken });
264
+ if (check.verified) {
265
+ runtimeAgentVerified = true;
266
+ return;
267
+ }
268
+ logger.warn({ reason: check.reason }, "FORGE_AGENT_TOKEN provided but verification failed");
269
+ }
270
+
271
+ try {
272
+ const reg = registerClawbot({ agentId: FORGE_AGENT_ID, displayName: FORGE_AGENT_DISPLAY_NAME });
273
+ runtimeAgentToken = reg.token;
274
+ runtimeAgentVerified = true;
275
+ logger.info({ agentId: reg.agentId }, "Forge agent auto-registered as clawbot");
276
+ } catch (err) {
277
+ runtimeAgentVerified = false;
278
+ logger.warn(
279
+ { err: err?.message || String(err) },
280
+ "Forge agent auto-registration unavailable; set FORGE_AGENT_TOKEN for verified identity",
281
+ );
282
+ }
283
+ }
284
+
285
+ /* ══════════════════════════════════════════════════════════
286
+ Telemetry Snapshot — direct storage access
287
+ ══════════════════════════════════════════════════════════ */
288
+
289
+ function getTelemetrySnapshot() {
290
+ const store = getStorage();
291
+ const snapshot = {};
292
+
293
+ try {
294
+ const events = store.getEventBacklog(15);
295
+ snapshot.recentEvents = events.slice(-15).map((e) => {
296
+ const ts = e.ts ? new Date(e.ts).toLocaleString() : "?";
297
+ return `[${ts}] ${e.eventType} by ${e.agentId || "unknown"}${e.command ? ` (${e.command})` : ""}${e.ok === false ? " FAILED" : ""}`;
298
+ });
299
+ } catch { snapshot.recentEvents = []; }
300
+
301
+ try {
302
+ const chatEntries = store.readChatEntries();
303
+ const messages = chatEntries
304
+ .filter((e) => e.type === "message")
305
+ .slice(-10);
306
+ snapshot.recentChat = messages.map((m) => `[${m.agentName || m.agentId || "?"}] ${(m.text || "").slice(0, 120)}`);
307
+ } catch { snapshot.recentChat = []; }
308
+
309
+ try {
310
+ if (fs.existsSync(CLAWBOTS_PATH)) {
311
+ const raw = JSON.parse(fs.readFileSync(CLAWBOTS_PATH, "utf8"));
312
+ const agents = raw.agents || {};
313
+ const bots = Object.entries(agents).map(([id, a]) => ({
314
+ id, name: a.name || id, enabled: a.enabled !== false,
315
+ }));
316
+ snapshot.clawbots = bots;
317
+ } else {
318
+ snapshot.clawbots = [];
319
+ }
320
+ } catch { snapshot.clawbots = []; }
321
+
322
+ try {
323
+ const all = store.getMergedSkillIndex();
324
+ const onchain = all.filter((s) => s.onchainTokenId != null).length;
325
+ const seed = all.filter((s) => s.source === "seed").length;
326
+ const imported = all.filter((s) => s.source === "imported").length;
327
+ const user = all.filter((s) => s.source === "user").length;
328
+ snapshot.skillStats = { total: all.length, seed, imported, user, onchain };
329
+ } catch { snapshot.skillStats = { total: 0 }; }
330
+
331
+ try {
332
+ const userIdx = store.getUserSkillsIndex();
333
+ const userSkills = Array.isArray(userIdx?.skills) ? userIdx.skills : [];
334
+ snapshot.userInstalledSkills = userSkills.length;
335
+ } catch { snapshot.userInstalledSkills = 0; }
336
+
337
+ try { snapshot.quoteSpend = store.getQuotesSpendToday(); } catch { snapshot.quoteSpend = 0; }
338
+ try { snapshot.bridgeSpend = store.getBridgeSpendToday(); } catch { snapshot.bridgeSpend = 0; }
339
+
340
+ try {
341
+ const podWs = store.findPodWorkspaceDir();
342
+ if (podWs) {
343
+ const stopped = fs.existsSync(path.join(podWs, "stop.flag"));
344
+ const hasAgents = fs.existsSync(path.join(podWs, "AGENTS.md"));
345
+ let lastHeartbeat = null;
346
+ const hbPath = path.join(podWs, "state", "last-heartbeat.json");
347
+ if (fs.existsSync(hbPath)) {
348
+ try { lastHeartbeat = JSON.parse(fs.readFileSync(hbPath, "utf8"))?.timestamp || null; } catch {}
349
+ }
350
+ snapshot.pod = { status: hasAgents ? (stopped ? "stopped" : "running") : "not-initialized", lastHeartbeat };
351
+ } else {
352
+ snapshot.pod = { status: "not-initialized" };
353
+ }
354
+ } catch { snapshot.pod = { status: "unknown" }; }
355
+
356
+ return snapshot;
357
+ }
358
+
359
+ function formatTelemetryContext(snapshot) {
360
+ const lines = ["## Live Telemetry (real-time data from the backend)"];
361
+
362
+ const bots = snapshot.clawbots || [];
363
+ const activeNames = bots.filter((b) => b.enabled).map((b) => b.name).slice(0, 20);
364
+ lines.push(`- Registered clawbots: ${bots.length} total${activeNames.length ? ` (${activeNames.join(", ")})` : ""}`);
365
+
366
+ const ss = snapshot.skillStats || {};
367
+ lines.push(`- Skills library: ${ss.total || 0} total (${ss.seed || 0} seed, ${ss.imported || 0} imported, ${ss.user || 0} user), ${ss.onchain || 0} onchain`);
368
+ lines.push(`- User-installed skill records: ${snapshot.userInstalledSkills || 0}`);
369
+
370
+ lines.push(`- Daily spend: NFT quotes ${snapshot.quoteSpend || 0} APE / Bridge ${snapshot.bridgeSpend || 0} APE`);
371
+ lines.push(`- Pod status: ${snapshot.pod?.status || "unknown"}${snapshot.pod?.lastHeartbeat ? `, last heartbeat ${snapshot.pod.lastHeartbeat}` : ""}`);
372
+
373
+ if (snapshot.recentEvents?.length) {
374
+ lines.push("", "### Recent telemetry events");
375
+ for (const e of snapshot.recentEvents.slice(-10)) lines.push(` ${e}`);
376
+ }
377
+
378
+ if (snapshot.recentChat?.length) {
379
+ lines.push("", "### Recent forge chat");
380
+ for (const m of snapshot.recentChat.slice(-5)) lines.push(` ${m}`);
381
+ }
382
+
383
+ return lines.join("\n");
384
+ }
385
+
386
+ /* ══════════════════════════════════════════════════════════
387
+ System Prompt Builder
388
+ ══════════════════════════════════════════════════════════ */
389
+
390
+ function buildSystemPrompt(snapshot) {
391
+ refreshSkillCache();
392
+
393
+ const providerLabel = llmConfig ? `${llmConfig.provider} (${llmConfig.model})` : "unknown";
394
+
395
+ const parts = [
396
+ `You are ${FORGE_AGENT_DISPLAY_NAME}, a real OpenClaw agent with the ape-claw skill set installed. You are a registered ClawBot (agentId: ${FORGE_AGENT_ID}).`,
397
+ "",
398
+ "## Identity",
399
+ `- Registered ClawBot: ${FORGE_AGENT_ID}`,
400
+ "- Framework: OpenClaw (openclaw.ai) — a personal AI assistant framework that runs on your machine",
401
+ `- LLM provider: ${providerLabel}`,
402
+ `- Skills installed: ${cachedSkills.summaries.length + (cachedSkills.apeClawFull ? 1 : 0)}`,
403
+ "",
404
+ ];
405
+
406
+ if (cachedSkills.apeClawFull) {
407
+ parts.push("## Ape-Claw Skill Knowledge (your primary skill)");
408
+ parts.push(cachedSkills.apeClawFull);
409
+ parts.push("");
410
+ }
411
+
412
+ parts.push(formatTelemetryContext(snapshot));
413
+
414
+ if (cachedSkills.summaries.length) {
415
+ parts.push("", "## Other Installed Skills");
416
+ parts.push(...cachedSkills.summaries);
417
+ }
418
+
419
+ parts.push("", "## Behavior");
420
+ parts.push("- You have REAL access to live backend data — cite it when users ask about activity, bots, skills, or spend.");
421
+ parts.push("- Guide users through getting started with ApeClaw and OpenClaw.");
422
+ parts.push("- Reference specific CLI commands from the ape-claw skill (e.g. `npx ape-claw skill install`, `npx ape-claw doctor --json`).");
423
+ parts.push("- Be direct, concise, and knowledgeable about ApeChain, NFTs, and Web3.");
424
+ parts.push("- When asked about skills, reference the real library stats and help users find what they need.");
425
+ parts.push("- Do NOT hallucinate capabilities — stick to what ApeClaw actually ships.");
426
+ parts.push("- Keep responses focused and practical. Use short paragraphs.");
427
+ parts.push("- Prefer plain conversational output with light formatting only (bold, short bullets).");
428
+ parts.push("- Do NOT include citation markers like [1], [2], or footnote-style references unless the user explicitly asks for sources.");
429
+
430
+ return parts.join("\n");
431
+ }
432
+
433
+ function normalizeConversationHistory(history) {
434
+ const cleaned = [];
435
+ for (const turn of history) {
436
+ const role = turn?.role === "assistant" ? "assistant" : "user";
437
+ const content = String(turn?.content || "").trim();
438
+ if (!content) continue;
439
+ cleaned.push({ role, content: content.slice(0, 2000) });
440
+ }
441
+
442
+ // Perplexity/OpenAI-compatible endpoints expect strict user/assistant alternation.
443
+ // Start from user, skip invalid out-of-order turns.
444
+ const alternating = [];
445
+ let expectedRole = "user";
446
+ for (const turn of cleaned) {
447
+ if (turn.role !== expectedRole) continue;
448
+ alternating.push(turn);
449
+ expectedRole = expectedRole === "user" ? "assistant" : "user";
450
+ }
451
+
452
+ // If history ends with a user turn, drop it because we'll append a fresh user message.
453
+ if (alternating.length && alternating[alternating.length - 1].role === "user") {
454
+ alternating.pop();
455
+ }
456
+
457
+ return alternating;
458
+ }
459
+
460
+ /* ══════════════════════════════════════════════════════════
461
+ Actions — post to chat log + emit telemetry
462
+ ══════════════════════════════════════════════════════════ */
463
+
464
+ function postToChat(room, text, role) {
465
+ try {
466
+ const store = getStorage();
467
+ const msg = {
468
+ id: `msg_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`,
469
+ type: "message",
470
+ agentId: role === "user" ? "forge-visitor" : FORGE_AGENT_ID,
471
+ agentName: role === "user" ? "Forge Visitor" : FORGE_AGENT_DISPLAY_NAME,
472
+ identityProvider: role === "user" ? "anonymous" : (runtimeAgentVerified ? "clawbot" : "clawbot-unverified"),
473
+ identityMeta: role === "user" ? {} : { verified: runtimeAgentVerified, source: "forge-agent" },
474
+ room,
475
+ text: text.slice(0, 500),
476
+ replyTo: null,
477
+ reactions: {},
478
+ reactionUsers: {},
479
+ ts: new Date().toISOString(),
480
+ };
481
+ store.appendChat(msg);
482
+ } catch (err) {
483
+ logger.warn({ err }, "Failed to post forge agent chat");
484
+ }
485
+ }
486
+
487
+ function emitTelemetryEvent(userMessage, responseLength) {
488
+ try {
489
+ const store = getStorage();
490
+ store.appendEvent({
491
+ v: 1,
492
+ ts: new Date().toISOString(),
493
+ eventType: "forge-agent-response",
494
+ agentId: FORGE_AGENT_ID,
495
+ sessionId: "forge-web",
496
+ traceId: `forge_${Date.now()}`,
497
+ command: "forge-chat",
498
+ dryRun: false,
499
+ chainId: 33139,
500
+ payload: { messageLength: userMessage.length, responseLength, provider: llmConfig?.provider },
501
+ result: { ok: true },
502
+ ok: true,
503
+ error: null,
504
+ source: "forge-agent",
505
+ });
506
+ } catch (err) {
507
+ logger.warn({ err }, "Failed to emit forge agent telemetry");
508
+ }
509
+ }
510
+
511
+ /* ══════════════════════════════════════════════════════════
512
+ Rate Limiting (IP-based, for unauthenticated visitors)
513
+ ══════════════════════════════════════════════════════════ */
514
+
515
+ function checkForgeRateLimit(req) {
516
+ const xff = String(req.headers["x-forwarded-for"] || "").trim();
517
+ const ip = xff ? xff.split(",")[0].trim() : String(req.socket?.remoteAddress || "unknown");
518
+ const now = Date.now();
519
+ const bucket = rateBuckets.get(ip);
520
+ if (!bucket || now - bucket.windowStart > RATE_LIMIT_WINDOW_MS) {
521
+ rateBuckets.set(ip, { windowStart: now, count: 1 });
522
+ return null;
523
+ }
524
+ bucket.count++;
525
+ if (bucket.count > RATE_LIMIT_MAX) {
526
+ return { retryAfterMs: RATE_LIMIT_WINDOW_MS - (now - bucket.windowStart) };
527
+ }
528
+ return null;
529
+ }
530
+
531
+ setInterval(() => {
532
+ const cutoff = Date.now() - RATE_LIMIT_WINDOW_MS * 2;
533
+ for (const [ip, bucket] of rateBuckets) {
534
+ if (bucket.windowStart < cutoff) rateBuckets.delete(ip);
535
+ }
536
+ }, 120_000).unref();
537
+
538
+ /* ══════════════════════════════════════════════════════════
539
+ LLM Streaming — OpenAI-compatible (covers most providers)
540
+ ══════════════════════════════════════════════════════════ */
541
+
542
+ async function streamOpenAICompatible(messages, res) {
543
+ const headers = { "content-type": "application/json" };
544
+ if (llmConfig.apiKey) headers["authorization"] = `Bearer ${llmConfig.apiKey}`;
545
+
546
+ async function requestOnce() {
547
+ return fetch(llmConfig.apiUrl, {
548
+ method: "POST",
549
+ headers,
550
+ body: JSON.stringify({ model: llmConfig.model, messages, stream: true }),
551
+ });
552
+ }
553
+ let upstream = await requestOnce();
554
+ if (!upstream.ok && (upstream.status === 429 || upstream.status >= 500)) {
555
+ const retryAfter = Number(upstream.headers.get("retry-after") || 0);
556
+ const waitMs = Number.isFinite(retryAfter) && retryAfter > 0 ? Math.min(retryAfter * 1000, 4000) : 500;
557
+ await new Promise((resolve) => setTimeout(resolve, waitMs));
558
+ upstream = await requestOnce();
559
+ }
560
+
561
+ if (!upstream.ok) {
562
+ const errText = await upstream.text().catch(() => "");
563
+ logger.error({ status: upstream.status, body: errText.slice(0, 500), provider: llmConfig.provider }, "LLM API error");
564
+ res.writeHead(502, { "content-type": "application/json" });
565
+ const retryAfter = Number(upstream.headers.get("retry-after") || 0) || undefined;
566
+ return res.end(JSON.stringify({
567
+ error: `upstream API error (${llmConfig.provider} ${upstream.status})`,
568
+ status: upstream.status,
569
+ provider: llmConfig.provider,
570
+ retryAfter,
571
+ }));
572
+ }
573
+
574
+ res.writeHead(200, { "content-type": "text/event-stream", "cache-control": "no-cache", "connection": "keep-alive" });
575
+
576
+ let fullResponse = "";
577
+ const reader = upstream.body.getReader();
578
+ const decoder = new TextDecoder();
579
+ let buffer = "";
580
+
581
+ while (true) {
582
+ const { done, value } = await reader.read();
583
+ if (done) break;
584
+
585
+ buffer += decoder.decode(value, { stream: true });
586
+ const lines = buffer.split("\n");
587
+ buffer = lines.pop() || "";
588
+
589
+ for (const line of lines) {
590
+ if (!line.startsWith("data: ")) continue;
591
+ const data = line.slice(6).trim();
592
+ if (data === "[DONE]") { res.write("data: [DONE]\n\n"); continue; }
593
+ try {
594
+ const chunk = JSON.parse(data);
595
+ const text = chunk.choices?.[0]?.delta?.content || "";
596
+ if (text) {
597
+ fullResponse += text;
598
+ res.write(`data: ${JSON.stringify({ text })}\n\n`);
599
+ }
600
+ } catch {}
601
+ }
602
+ }
603
+
604
+ if (buffer.trim()) {
605
+ const remaining = buffer.trim();
606
+ if (remaining.startsWith("data: ") && remaining.slice(6).trim() !== "[DONE]") {
607
+ try {
608
+ const chunk = JSON.parse(remaining.slice(6).trim());
609
+ const text = chunk.choices?.[0]?.delta?.content || "";
610
+ if (text) { fullResponse += text; res.write(`data: ${JSON.stringify({ text })}\n\n`); }
611
+ } catch {}
612
+ }
613
+ }
614
+
615
+ if (!res.writableEnded) { res.write("data: [DONE]\n\n"); res.end(); }
616
+ return fullResponse;
617
+ }
618
+
619
+ /* ══════════════════════════════════════════════════════════
620
+ LLM Streaming — Anthropic Messages API
621
+ ══════════════════════════════════════════════════════════ */
622
+
623
+ async function streamAnthropic(systemPrompt, messages, res) {
624
+ const anthropicMessages = messages
625
+ .filter((m) => m.role !== "system")
626
+ .map((m) => ({ role: m.role, content: m.content }));
627
+
628
+ async function requestOnce() {
629
+ return fetch(llmConfig.apiUrl, {
630
+ method: "POST",
631
+ headers: {
632
+ "content-type": "application/json",
633
+ "x-api-key": llmConfig.apiKey,
634
+ "anthropic-version": "2023-06-01",
635
+ },
636
+ body: JSON.stringify({
637
+ model: llmConfig.model,
638
+ max_tokens: 2048,
639
+ system: systemPrompt,
640
+ messages: anthropicMessages,
641
+ stream: true,
642
+ }),
643
+ });
644
+ }
645
+ let upstream = await requestOnce();
646
+ if (!upstream.ok && (upstream.status === 429 || upstream.status >= 500)) {
647
+ const retryAfter = Number(upstream.headers.get("retry-after") || 0);
648
+ const waitMs = Number.isFinite(retryAfter) && retryAfter > 0 ? Math.min(retryAfter * 1000, 4000) : 500;
649
+ await new Promise((resolve) => setTimeout(resolve, waitMs));
650
+ upstream = await requestOnce();
651
+ }
652
+
653
+ if (!upstream.ok) {
654
+ const errText = await upstream.text().catch(() => "");
655
+ logger.error({ status: upstream.status, body: errText.slice(0, 500), provider: "anthropic" }, "Anthropic API error");
656
+ res.writeHead(502, { "content-type": "application/json" });
657
+ const retryAfter = Number(upstream.headers.get("retry-after") || 0) || undefined;
658
+ return res.end(JSON.stringify({
659
+ error: `upstream API error (anthropic ${upstream.status})`,
660
+ status: upstream.status,
661
+ provider: "anthropic",
662
+ retryAfter,
663
+ }));
664
+ }
665
+
666
+ res.writeHead(200, { "content-type": "text/event-stream", "cache-control": "no-cache", "connection": "keep-alive" });
667
+
668
+ let fullResponse = "";
669
+ const reader = upstream.body.getReader();
670
+ const decoder = new TextDecoder();
671
+ let buffer = "";
672
+
673
+ while (true) {
674
+ const { done, value } = await reader.read();
675
+ if (done) break;
676
+
677
+ buffer += decoder.decode(value, { stream: true });
678
+ const lines = buffer.split("\n");
679
+ buffer = lines.pop() || "";
680
+
681
+ for (const line of lines) {
682
+ if (line.startsWith("event: ")) continue;
683
+ if (!line.startsWith("data: ")) continue;
684
+ const data = line.slice(6).trim();
685
+ if (!data) continue;
686
+ try {
687
+ const evt = JSON.parse(data);
688
+ if (evt.type === "content_block_delta" && evt.delta?.text) {
689
+ fullResponse += evt.delta.text;
690
+ res.write(`data: ${JSON.stringify({ text: evt.delta.text })}\n\n`);
691
+ }
692
+ if (evt.type === "message_stop") {
693
+ res.write("data: [DONE]\n\n");
694
+ }
695
+ } catch {}
696
+ }
697
+ }
698
+
699
+ if (!res.writableEnded) { res.write("data: [DONE]\n\n"); res.end(); }
700
+ return fullResponse;
701
+ }
702
+
703
+ /* ══════════════════════════════════════════════════════════
704
+ Init — called at server startup
705
+ ══════════════════════════════════════════════════════════ */
706
+
707
+ export function initForgeAgent() {
708
+ if (!llmConfig) {
709
+ logger.warn(
710
+ "No LLM provider detected — forge agent will return 503. " +
711
+ "Set one of: PERPLEXITY_API_KEY, OPENAI_API_KEY, ANTHROPIC_API_KEY, GROQ_API_KEY, " +
712
+ "TOGETHER_API_KEY, OLLAMA_HOST, or FORGE_LLM_API_URL",
713
+ );
714
+ } else {
715
+ logger.info({ provider: llmConfig.provider, model: llmConfig.model, hasKey: Boolean(llmConfig.apiKey) }, "LLM provider detected");
716
+ }
717
+ ensureForgeAgentIdentity();
718
+ refreshSkillCache();
719
+ logger.info(
720
+ {
721
+ agentId: FORGE_AGENT_ID,
722
+ verified: runtimeAgentVerified,
723
+ provider: llmConfig?.provider || "none",
724
+ model: llmConfig?.model,
725
+ skills: cachedSkills.summaries.length + (cachedSkills.apeClawFull ? 1 : 0),
726
+ },
727
+ "Forge agent initialized",
728
+ );
729
+ }
730
+
731
+ /* ══════════════════════════════════════════════════════════
732
+ Status — GET /api/forge/status
733
+ ══════════════════════════════════════════════════════════ */
734
+
735
+ export function handleForgeStatus(req, res) {
736
+ res.writeHead(200, { "content-type": "application/json" });
737
+ res.end(JSON.stringify({
738
+ configured: Boolean(llmConfig),
739
+ provider: llmConfig?.provider || null,
740
+ agentId: FORGE_AGENT_ID,
741
+ agentName: FORGE_AGENT_DISPLAY_NAME,
742
+ verified: runtimeAgentVerified,
743
+ model: llmConfig?.model || null,
744
+ skills: cachedSkills.summaries.length + (cachedSkills.apeClawFull ? 1 : 0),
745
+ }));
746
+ }
747
+
748
+ /* ══════════════════════════════════════════════════════════
749
+ Handler — POST /api/forge/chat
750
+ ══════════════════════════════════════════════════════════ */
751
+
752
+ export async function handleForgeChat(req, res) {
753
+ if (!llmConfig) {
754
+ res.writeHead(503, { "content-type": "application/json" });
755
+ return res.end(JSON.stringify({
756
+ error: "Forge agent not configured. Set one of: PERPLEXITY_API_KEY, OPENAI_API_KEY, ANTHROPIC_API_KEY, GROQ_API_KEY, TOGETHER_API_KEY, OLLAMA_HOST, or FORGE_LLM_API_URL",
757
+ }));
758
+ }
759
+
760
+ const rl = checkForgeRateLimit(req);
761
+ if (rl) {
762
+ res.writeHead(429, { "content-type": "application/json" });
763
+ return res.end(JSON.stringify({ error: "rate limited", retryAfterMs: rl.retryAfterMs }));
764
+ }
765
+
766
+ const raw = await collectBody(req, res);
767
+ if (raw === null) return;
768
+
769
+ let body;
770
+ try { body = JSON.parse(raw); } catch {
771
+ res.writeHead(400, { "content-type": "application/json" });
772
+ return res.end(JSON.stringify({ error: "invalid JSON body" }));
773
+ }
774
+
775
+ const userMessage = String(body.message || body.text || "").trim();
776
+ if (!userMessage || userMessage.length > MAX_MESSAGE_LENGTH) {
777
+ res.writeHead(400, { "content-type": "application/json" });
778
+ return res.end(JSON.stringify({ error: `message must be 1-${MAX_MESSAGE_LENGTH} characters` }));
779
+ }
780
+
781
+ const history = Array.isArray(body.history) ? body.history.slice(-MAX_HISTORY_TURNS) : [];
782
+
783
+ const snapshot = getTelemetrySnapshot();
784
+ const systemPrompt = buildSystemPrompt(snapshot);
785
+
786
+ const messages = [{ role: "system", content: systemPrompt }];
787
+ const normalizedHistory = normalizeConversationHistory(history);
788
+ for (const turn of normalizedHistory) {
789
+ messages.push(turn);
790
+ }
791
+ messages.push({ role: "user", content: userMessage });
792
+
793
+ postToChat("forge", userMessage, "user");
794
+
795
+ try {
796
+ let fullResponse;
797
+
798
+ if (llmConfig.isAnthropic) {
799
+ fullResponse = await streamAnthropic(systemPrompt, messages, res);
800
+ } else {
801
+ fullResponse = await streamOpenAICompatible(messages, res);
802
+ }
803
+
804
+ if (fullResponse) {
805
+ postToChat("forge", fullResponse.slice(0, 500), "agent");
806
+ emitTelemetryEvent(userMessage, fullResponse.length);
807
+ }
808
+ } catch (err) {
809
+ logger.error({ err, provider: llmConfig.provider }, "Forge agent stream error");
810
+ if (!res.headersSent) {
811
+ res.writeHead(500, { "content-type": "application/json" });
812
+ return res.end(JSON.stringify({ error: "internal error" }));
813
+ }
814
+ if (!res.writableEnded) {
815
+ res.write(`data: ${JSON.stringify({ text: "\n\n[Connection interrupted]" })}\n\n`);
816
+ res.write("data: [DONE]\n\n");
817
+ res.end();
818
+ }
819
+ }
820
+ }