mortgram-bridge 2.3.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 (2) hide show
  1. package/index.mjs +397 -0
  2. package/package.json +21 -0
package/index.mjs ADDED
@@ -0,0 +1,397 @@
1
+ #!/usr/bin/env node
2
+ // ═══════════════════════════════════════════════════════════════════
3
+ // MORTGRAM Gateway Bridge — Zero-config OpenClaw Event Forwarder
4
+ // ═══════════════════════════════════════════════════════════════════
5
+ // Connects to the OpenClaw Gateway WebSocket and forwards all agent
6
+ // events to the MORTGRAM dashboard API.
7
+ //
8
+ // Usage:
9
+ // npx mortgram-bridge --token YOUR_MORTGRAM_TOKEN
10
+ // node bridge/index.mjs --token YOUR_MORTGRAM_TOKEN
11
+ // ═══════════════════════════════════════════════════════════════════
12
+
13
+ import { WebSocket } from "ws";
14
+ import fs from "node:fs";
15
+ import path from "node:path";
16
+ import os from "node:os";
17
+ import crypto from "node:crypto";
18
+
19
+ // ─── CLI Args ─────────────────────────────────────────────────────
20
+ const args = process.argv.slice(2);
21
+ function getArg(name) {
22
+ const idx = args.indexOf(`--${name}`);
23
+ return idx !== -1 && args[idx + 1] ? args[idx + 1] : null;
24
+ }
25
+
26
+ const MORTGRAM_TOKEN = getArg("token") || process.env.MORTGRAM_API_KEY || process.env.MG_LIVE_KEY || "";
27
+ const MORTGRAM_API = getArg("api") || process.env.MORTGRAM_API_URL || "https://mortgram.online/api/ingest";
28
+ const GATEWAY_URL_OVERRIDE = getArg("gateway-url");
29
+ const AGENT_ID = getArg("agent-id") || "gateway-bridge";
30
+
31
+ if (!MORTGRAM_TOKEN) {
32
+ console.error("\x1b[31m[MORTGRAM] ERROR: No --token provided.\x1b[0m");
33
+ console.error("\x1b[33m Usage: npx mortgram-bridge --token YOUR_MORTGRAM_TOKEN\x1b[0m");
34
+ console.error("\x1b[33m Get your token from the MORTGRAM dashboard.\x1b[0m");
35
+ process.exit(1);
36
+ }
37
+
38
+ // ─── Auto-Detect OpenClaw Config ──────────────────────────────────
39
+ function loadOpenClawConfig() {
40
+ const configPaths = [
41
+ path.join(os.homedir(), ".openclaw", "openclaw.json"),
42
+ path.join(os.homedir(), ".clawdbot", "clawdbot.json"),
43
+ ];
44
+
45
+ for (const p of configPaths) {
46
+ try {
47
+ if (fs.existsSync(p)) {
48
+ const raw = JSON.parse(fs.readFileSync(p, "utf8"));
49
+ const port = raw?.gateway?.port || 18789;
50
+ const token = raw?.gateway?.auth?.token || "";
51
+ const mode = raw?.gateway?.auth?.mode || "token";
52
+ console.log(`\x1b[90m[MORTGRAM] Found OpenClaw config: ${p}\x1b[0m`);
53
+ console.log(`\x1b[90m[MORTGRAM] Gateway port: ${port}, auth: ${mode}\x1b[0m`);
54
+ return { port, token, mode };
55
+ }
56
+ } catch (e) {
57
+ // Skip invalid config
58
+ }
59
+ }
60
+
61
+ console.warn("\x1b[33m[MORTGRAM] No OpenClaw config found, using defaults.\x1b[0m");
62
+ return { port: 18789, token: "", mode: "token" };
63
+ }
64
+
65
+ const ocConfig = loadOpenClawConfig();
66
+ const GATEWAY_URL = GATEWAY_URL_OVERRIDE || `ws://127.0.0.1:${ocConfig.port}`;
67
+ const GATEWAY_TOKEN = ocConfig.token;
68
+
69
+ // ─── State ────────────────────────────────────────────────────────
70
+ let ws = null;
71
+ let connected = false;
72
+ let connectNonce = null;
73
+ let reconnectDelay = 500;
74
+ let eventCount = 0;
75
+ let lastAgentId = AGENT_ID;
76
+ let lastAgentName = "";
77
+ let heartbeatTimer = null;
78
+
79
+ // Tracking
80
+ let latestThought = "Bridge starting...";
81
+ let dailyCost = 0;
82
+ let totalTurns = 0;
83
+ let successfulTools = 0;
84
+
85
+ // ─── Retry Queue ──────────────────────────────────────────────────
86
+ const retryQueue = [];
87
+ const MAX_QUEUE = 50;
88
+ let retryTimer = null;
89
+
90
+ function drainRetryQueue() {
91
+ if (retryQueue.length === 0) {
92
+ retryTimer = null;
93
+ return;
94
+ }
95
+ const item = retryQueue.shift();
96
+ _sendPayload(item.payload, item.attempt + 1)
97
+ .then(() => {
98
+ // Success — continue draining
99
+ if (retryQueue.length > 0) {
100
+ retryTimer = setTimeout(drainRetryQueue, 200);
101
+ } else {
102
+ retryTimer = null;
103
+ }
104
+ })
105
+ .catch(() => {
106
+ // Still failing — put back and slow down
107
+ retryQueue.unshift({ ...item, attempt: item.attempt + 1 });
108
+ const delay = Math.min(3000 * Math.pow(1.5, item.attempt), 15000);
109
+ retryTimer = setTimeout(drainRetryQueue, delay);
110
+ });
111
+ }
112
+
113
+ // ─── MORTGRAM API ─────────────────────────────────────────────────
114
+ async function _sendPayload(payload, attempt = 0) {
115
+ const res = await fetch(MORTGRAM_API, {
116
+ method: "POST",
117
+ headers: {
118
+ "Content-Type": "application/json",
119
+ "x-mortgram-token": MORTGRAM_TOKEN
120
+ },
121
+ body: JSON.stringify(payload),
122
+ signal: AbortSignal.timeout(5000),
123
+ });
124
+ if (!res.ok) {
125
+ const body = await res.text();
126
+ throw new Error(`API ${res.status}: ${body}`);
127
+ }
128
+ }
129
+
130
+ async function sendToMortgram(type, content, metadata = {}) {
131
+ const payload = {
132
+ agent_id: lastAgentId,
133
+ type,
134
+ content,
135
+ metadata: {
136
+ ...metadata,
137
+ agent_name: lastAgentName || lastAgentId,
138
+ daily_cost: dailyCost,
139
+ efficiency: totalTurns > 0 ? (successfulTools / totalTurns) : 0,
140
+ latest_thought: latestThought,
141
+ status: "online",
142
+ timestamp: new Date().toISOString()
143
+ }
144
+ };
145
+
146
+ try {
147
+ await _sendPayload(payload);
148
+ } catch (e) {
149
+ console.error(`\x1b[31m[MORTGRAM] API Error: ${e.message} — queuing for retry\x1b[0m`);
150
+ if (retryQueue.length < MAX_QUEUE) {
151
+ retryQueue.push({ payload, attempt: 0 });
152
+ if (!retryTimer) {
153
+ retryTimer = setTimeout(drainRetryQueue, 3000);
154
+ }
155
+ } else {
156
+ console.warn(`\x1b[33m[MORTGRAM] Retry queue full (${MAX_QUEUE}), dropping event\x1b[0m`);
157
+ }
158
+ }
159
+ }
160
+
161
+ // ─── Event Processing ─────────────────────────────────────────────
162
+ function processEvent(evt) {
163
+ const { event, payload, seq } = evt;
164
+
165
+ // Skip internal/tick events
166
+ if (event === "tick" || event === "connect.challenge") return;
167
+
168
+ eventCount++;
169
+
170
+ // Extract agent info from various event shapes
171
+ const data = payload || {};
172
+ const agentId = data.agentId || data.agent_id || data.runId?.split("-")[0] || lastAgentId;
173
+ if (agentId !== lastAgentId && agentId !== "gateway-bridge") {
174
+ lastAgentId = agentId;
175
+ }
176
+ // Extract agent name if available
177
+ const agentName = data.agentName || data.agent_name || data.displayName || data.name || "";
178
+ if (agentName && agentName !== lastAgentName) {
179
+ lastAgentName = agentName;
180
+ }
181
+
182
+ let type = "log";
183
+ let content = `${event}`;
184
+
185
+ // Map OpenClaw events to MORTGRAM types
186
+ if (event.startsWith("agent.")) {
187
+ // Agent-level events
188
+ if (event === "agent.run.start" || event === "agent.start") {
189
+ type = "system";
190
+ content = `Agent session started: ${agentId}`;
191
+ totalTurns = 0;
192
+ successfulTools = 0;
193
+ } else if (event === "agent.run.end" || event === "agent.end") {
194
+ type = "system";
195
+ content = `Agent session ended: ${agentId}`;
196
+ } else if (event.includes("thought") || event.includes("reasoning") || event.includes("think")) {
197
+ totalTurns++;
198
+ latestThought = data.text || data.content || data.message || "Thinking...";
199
+ type = "thought";
200
+ content = latestThought;
201
+ } else if (event.includes("response") || event.includes("message") || event.includes("reply")) {
202
+ latestThought = (data.text || data.content || data.message || "").slice(0, 200) || "Response generated";
203
+ type = "thought";
204
+ content = latestThought;
205
+ } else if (event.includes("error")) {
206
+ type = "error";
207
+ content = data.message || data.text || data.error || "Agent error";
208
+ }
209
+ } else if (event.startsWith("tool.") || event.startsWith("exec.")) {
210
+ // Tool execution events
211
+ if (event.includes("result") || event.includes("end") || event.includes("done")) {
212
+ successfulTools++;
213
+ }
214
+ type = "action";
215
+ content = data.name || data.tool || data.command || event;
216
+ } else if (event.startsWith("session.")) {
217
+ if (event.includes("usage") || event.includes("cost")) {
218
+ dailyCost = data.total_cost || data.cost || data.totalCost || dailyCost;
219
+ type = "cost_update";
220
+ content = `Daily spend: $${dailyCost.toFixed(4)}`;
221
+ } else {
222
+ type = "system";
223
+ content = event;
224
+ }
225
+ } else if (event === "system.notify" || event === "system.presence") {
226
+ type = "system";
227
+ content = data.text || data.message || event;
228
+ } else if (event === "shutdown") {
229
+ type = "system";
230
+ content = `Gateway shutdown: ${data.reason || "unknown"}`;
231
+ }
232
+
233
+ // Forward to MORTGRAM
234
+ console.log(`\x1b[34m[MORTGRAM] 📡 #${eventCount} ${event}\x1b[0m`);
235
+ sendToMortgram(type, content, { event, seq, ...data });
236
+ }
237
+
238
+ // ─── Gateway WebSocket Connection ─────────────────────────────────
239
+ function sendConnectFrame() {
240
+ if (!ws || ws.readyState !== WebSocket.OPEN) return;
241
+
242
+ const id = crypto.randomUUID();
243
+ const connectParams = {
244
+ minProtocol: 3,
245
+ maxProtocol: 3,
246
+ client: {
247
+ id: "gateway-client",
248
+ displayName: "MORTGRAM Bridge",
249
+ version: "2.3.0",
250
+ platform: process.platform,
251
+ mode: "backend",
252
+ },
253
+ caps: ["tool-events"],
254
+ auth: GATEWAY_TOKEN ? { token: GATEWAY_TOKEN } : undefined,
255
+ role: "operator",
256
+ scopes: ["operator.admin"],
257
+ };
258
+
259
+ const frame = {
260
+ type: "req",
261
+ id,
262
+ method: "connect",
263
+ params: connectParams
264
+ };
265
+
266
+ ws.send(JSON.stringify(frame));
267
+ }
268
+
269
+ function connect() {
270
+ if (ws) {
271
+ try { ws.close(); } catch (_) { }
272
+ ws = null;
273
+ }
274
+
275
+ console.log(`\x1b[90m[MORTGRAM] Connecting to gateway: ${GATEWAY_URL}\x1b[0m`);
276
+
277
+ ws = new WebSocket(GATEWAY_URL, { maxPayload: 25 * 1024 * 1024 });
278
+
279
+ ws.on("open", () => {
280
+ console.log("\x1b[32m[MORTGRAM] WebSocket connected, sending handshake...\x1b[0m");
281
+ connectNonce = null;
282
+ // Send connect frame immediately — if gateway needs a challenge,
283
+ // it will respond and we resend in the message handler
284
+ sendConnectFrame();
285
+ // Fallback: resend after 100ms if challenge was needed
286
+ setTimeout(() => {
287
+ if (!connected) sendConnectFrame();
288
+ }, 100);
289
+ });
290
+
291
+ ws.on("message", (rawData) => {
292
+ try {
293
+ const msg = JSON.parse(rawData.toString());
294
+
295
+ // Event frame: has "event" field
296
+ if (msg.event) {
297
+ // Handle connect challenge
298
+ if (msg.event === "connect.challenge") {
299
+ connectNonce = msg.payload?.nonce || null;
300
+ sendConnectFrame();
301
+ return;
302
+ }
303
+
304
+ processEvent(msg);
305
+ return;
306
+ }
307
+
308
+ // Response frame: has "id" and "ok" fields
309
+ if (msg.id && typeof msg.ok !== "undefined") {
310
+ if (msg.ok) {
311
+ if (!connected) {
312
+ connected = true;
313
+ reconnectDelay = 500;
314
+ console.log("\x1b[32m[MORTGRAM] ✅ Ghost Bridge: LINK ACTIVE\x1b[0m");
315
+ console.log("\x1b[32m[MORTGRAM] Streaming all agent events to dashboard...\x1b[0m");
316
+
317
+ // Send initial heartbeat and READY signal to MORTGRAM
318
+ sendToMortgram("system", "Ghost Bridge connected", {
319
+ event: "bridge.connect",
320
+ gateway_url: GATEWAY_URL,
321
+ action: "AGENT_READY" // Triggers UI flip
322
+ });
323
+
324
+ // Start heartbeat pings to keep connection alive
325
+ if (heartbeatTimer) clearInterval(heartbeatTimer);
326
+ heartbeatTimer = setInterval(() => {
327
+ if (ws && ws.readyState === WebSocket.OPEN) {
328
+ try { ws.ping(); } catch (_) { /* noop */ }
329
+ }
330
+ }, 25000);
331
+ }
332
+ } else {
333
+ console.error(`\x1b[31m[MORTGRAM] Gateway rejected: ${msg.error?.message || "Unknown"}\x1b[0m`);
334
+ }
335
+ }
336
+ } catch (e) {
337
+ // Non-JSON message, ignore
338
+ }
339
+ });
340
+
341
+ ws.on("close", (code, reason) => {
342
+ const wasConnected = connected;
343
+ connected = false;
344
+ connectNonce = null;
345
+
346
+ // Stop heartbeat
347
+ if (heartbeatTimer) { clearInterval(heartbeatTimer); heartbeatTimer = null; }
348
+
349
+ const reasonText = reason?.toString() || "";
350
+ if (wasConnected) {
351
+ console.log(`\x1b[33m[MORTGRAM] Gateway disconnected (${code}): ${reasonText}\x1b[0m`);
352
+ sendToMortgram("system", "Ghost Bridge disconnected", { event: "bridge.disconnect" });
353
+ }
354
+
355
+ // Auto-reconnect with backoff
356
+ console.log(`\x1b[90m[MORTGRAM] Reconnecting in ${reconnectDelay / 1000}s...\x1b[0m`);
357
+ setTimeout(connect, reconnectDelay);
358
+ reconnectDelay = Math.min(reconnectDelay * 2, 30000);
359
+ });
360
+
361
+ ws.on("error", (err) => {
362
+ if (!connected) {
363
+ // Silently handle — reconnect will kick in via close event
364
+ }
365
+ });
366
+ }
367
+
368
+ // ─── Banner ───────────────────────────────────────────────────────
369
+ console.log("");
370
+ console.log("\x1b[35m ╔══════════════════════════════════════╗\x1b[0m");
371
+ console.log("\x1b[35m ║ MORTGRAM Ghost Bridge v2.3.0 ║\x1b[0m");
372
+ console.log("\x1b[35m ║ Zero-config Event Forwarder ║\x1b[0m");
373
+ console.log("\x1b[35m ╚══════════════════════════════════════╝\x1b[0m");
374
+ console.log("");
375
+ console.log(`\x1b[90m Gateway: ${GATEWAY_URL}\x1b[0m`);
376
+ console.log(`\x1b[90m Dashboard: ${MORTGRAM_API}\x1b[0m`);
377
+ console.log(`\x1b[90m Auth: ${GATEWAY_TOKEN ? "Token ✓" : "None"}\x1b[0m`);
378
+ console.log("");
379
+
380
+ // ─── Start ────────────────────────────────────────────────────────
381
+ connect();
382
+
383
+ // ─── Graceful Shutdown ────────────────────────────────────────────
384
+ function shutdown() {
385
+ console.log("\n\x1b[33m[MORTGRAM] Shutting down...\x1b[0m");
386
+ if (heartbeatTimer) clearInterval(heartbeatTimer);
387
+ sendToMortgram("system", "Ghost Bridge shutdown", { event: "bridge.shutdown" });
388
+ if (ws) ws.close();
389
+ setTimeout(() => process.exit(0), 500);
390
+ }
391
+
392
+ process.on("SIGINT", shutdown);
393
+ process.on("SIGTERM", shutdown);
394
+ // Windows: Ctrl+Break and console close
395
+ if (process.platform === "win32") {
396
+ process.on("SIGBREAK", shutdown);
397
+ }
package/package.json ADDED
@@ -0,0 +1,21 @@
1
+ {
2
+ "name": "mortgram-bridge",
3
+ "version": "2.3.0",
4
+ "description": "MORTGRAM Ghost Bridge — Zero-config OpenClaw event forwarder",
5
+ "type": "module",
6
+ "main": "index.mjs",
7
+ "bin": {
8
+ "mortgram-bridge": "./index.mjs"
9
+ },
10
+ "dependencies": {
11
+ "ws": "^8.18.0"
12
+ },
13
+ "keywords": [
14
+ "mortgram",
15
+ "openclaw",
16
+ "ai-agent",
17
+ "monitoring",
18
+ "observability"
19
+ ],
20
+ "license": "MIT"
21
+ }