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.
- package/index.mjs +397 -0
- 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
|
+
}
|