mortgram-bridge 2.3.0 → 3.0.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 (4) hide show
  1. package/.env +2 -0
  2. package/index.mjs +146 -29
  3. package/package.json +4 -4
  4. package/phantom.mjs +459 -0
package/.env ADDED
@@ -0,0 +1,2 @@
1
+ MORTGRAM_API_KEY=MG_LIVE_sk_0bb5705dd55555ae49ddbee6335ff83fef538f12399936ae
2
+ MORTGRAM_API_URL=https://mortgram.online/api/ingest
package/index.mjs CHANGED
@@ -16,17 +16,59 @@ import path from "node:path";
16
16
  import os from "node:os";
17
17
  import crypto from "node:crypto";
18
18
 
19
- // ─── CLI Args ─────────────────────────────────────────────────────
19
+ // ─── CLI Args & Config ────────────────────────────────────────────
20
20
  const args = process.argv.slice(2);
21
21
  function getArg(name) {
22
22
  const idx = args.indexOf(`--${name}`);
23
23
  return idx !== -1 && args[idx + 1] ? args[idx + 1] : null;
24
24
  }
25
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";
26
+ // 1. Attempt to load .env manually (since we might not have dotenv)
27
+ function loadEnv() {
28
+ try {
29
+ const envPath = path.join(process.cwd(), "bridge", ".env");
30
+ if (fs.existsSync(envPath)) {
31
+ const content = fs.readFileSync(envPath, "utf8");
32
+ const env = {};
33
+ for (const line of content.split("\n")) {
34
+ const match = line.match(/^([^=]+)=(.*)$/);
35
+ if (match) {
36
+ const key = match[1].trim();
37
+ const val = match[2].trim().replace(/^["']|["']$/g, ""); // remove quotes
38
+ env[key] = val;
39
+ }
40
+ }
41
+ return env;
42
+ }
43
+ } catch (e) { }
44
+ return {};
45
+ }
46
+ const localEnv = loadEnv();
47
+
48
+ const MORTGRAM_TOKEN = getArg("token") || process.env.MORTGRAM_API_KEY || localEnv.MORTGRAM_API_KEY || process.env.MG_LIVE_KEY || "";
49
+ const MORTGRAM_API = getArg("api") || process.env.MORTGRAM_API_URL || localEnv.MORTGRAM_API_URL || "https://mortgram.online/api/ingest";
28
50
  const GATEWAY_URL_OVERRIDE = getArg("gateway-url");
29
- const AGENT_ID = getArg("agent-id") || "gateway-bridge";
51
+
52
+ // 2. Persistent Agent ID Logic
53
+ // Priority: CLI > .env > Hardware ID
54
+ function getStableId() {
55
+ // Try to find a stored ID in .env
56
+ // (Note: we just read it above, but let's check for AGENT_ID specifically)
57
+ if (process.env.AGENT_ID) return process.env.AGENT_ID;
58
+ if (localEnv.AGENT_ID) return localEnv.AGENT_ID;
59
+
60
+ // Generate Hardware ID
61
+ try {
62
+ const idString = `${os.hostname()}-${os.platform()}-${os.arch()}-${os.cpus().length}`;
63
+ const hash = crypto.createHash("sha256").update(idString).digest("hex").substring(0, 12);
64
+ return `agent-${hash}`; // e.g. agent-3f4a9b...
65
+ } catch (e) {
66
+ return "unknown-agent-" + Math.floor(Math.random() * 10000);
67
+ }
68
+ }
69
+
70
+ const GLOBAL_AGENT_ID = getArg("agent-id") || getStableId();
71
+ console.log(`\x1b[36m[MORTGRAM] 🆔 Agent Identity: ${GLOBAL_AGENT_ID}\x1b[0m`);
30
72
 
31
73
  if (!MORTGRAM_TOKEN) {
32
74
  console.error("\x1b[31m[MORTGRAM] ERROR: No --token provided.\x1b[0m");
@@ -72,13 +114,17 @@ let connected = false;
72
114
  let connectNonce = null;
73
115
  let reconnectDelay = 500;
74
116
  let eventCount = 0;
75
- let lastAgentId = AGENT_ID;
76
- let lastAgentName = "";
117
+ // We no longer track dynamic agent IDs. We enforce GLOBAL_AGENT_ID.
118
+ let lastAgentId = GLOBAL_AGENT_ID;
119
+ let lastAgentName = "Founder-Main"; // 🔒 Hardcoded for production
120
+ let lastSessionId = ""; // Track session ID for replays
77
121
  let heartbeatTimer = null;
122
+ let costTimer = null; // New timer for cost updates
78
123
 
79
124
  // Tracking
80
125
  let latestThought = "Bridge starting...";
81
126
  let dailyCost = 0;
127
+ let totalTokens = 0; // Accumulated token count from scraper
82
128
  let totalTurns = 0;
83
129
  let successfulTools = 0;
84
130
 
@@ -129,16 +175,17 @@ async function _sendPayload(payload, attempt = 0) {
129
175
 
130
176
  async function sendToMortgram(type, content, metadata = {}) {
131
177
  const payload = {
132
- agent_id: lastAgentId,
178
+ agent_id: GLOBAL_AGENT_ID, // 🔒 ALWAYS use the stable ID
133
179
  type,
134
180
  content,
135
181
  metadata: {
136
182
  ...metadata,
137
- agent_name: lastAgentName || lastAgentId,
183
+ agent_name: lastAgentName || GLOBAL_AGENT_ID,
138
184
  daily_cost: dailyCost,
139
185
  efficiency: totalTurns > 0 ? (successfulTools / totalTurns) : 0,
140
186
  latest_thought: latestThought,
141
187
  status: "online",
188
+ session_id: lastSessionId,
142
189
  timestamp: new Date().toISOString()
143
190
  }
144
191
  };
@@ -158,25 +205,45 @@ async function sendToMortgram(type, content, metadata = {}) {
158
205
  }
159
206
  }
160
207
 
208
+ // ─── Event Processing ─────────────────────────────────────────────
161
209
  // ─── Event Processing ─────────────────────────────────────────────
162
210
  function processEvent(evt) {
163
211
  const { event, payload, seq } = evt;
164
212
 
165
- // Skip internal/tick events
166
- if (event === "tick" || event === "connect.challenge") return;
213
+ // ═══ FILTERING GATE ═══
214
+ // 1a. Protocol-level noise always drop
215
+ if (event.startsWith("chat.")) return;
216
+ if (event.startsWith("connect.")) return;
217
+ if (event === "tick") return;
167
218
 
168
- eventCount++;
169
-
170
- // Extract agent info from various event shapes
219
+ // 1b. agentId Gate — ONLY forward events with an agent identity.
220
+ // If the payload has no agentId, agent_id, runId, or sessionId,
221
+ // it's gateway chatter (message IDs, chat IDs) → skip.
171
222
  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;
223
+ const hasAgentIdentity = !!(data.agentId || data.agent_id || data.runId || data.sessionId || evt.sessionId);
224
+
225
+ // Allow system-level events through even without agent ID
226
+ const isSystemEvent = event === "system.notify" || event === "system.presence" || event === "shutdown";
227
+ if (!hasAgentIdentity && !isSystemEvent) {
228
+ // Silent skip — this is a chat/message ID event with no agent context
229
+ return;
175
230
  }
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;
231
+
232
+ eventCount++;
233
+
234
+ // data already extracted above in agentId gate
235
+
236
+ // Extract agent name if available, just for metadata display
237
+ // const agentName = data.agentName || data.agent_name || data.displayName || data.name || "";
238
+ // if (agentName && agentName !== lastAgentName) {
239
+ // lastAgentName = agentName;
240
+ // }
241
+ // 🔒 Production Override: Always "Primary Observer"
242
+
243
+ // Extract session ID (critical for replays)
244
+ const sessionId = data.sessionId || data.session_id || data.runId || evt.sessionId || "";
245
+ if (sessionId && sessionId !== lastSessionId) {
246
+ lastSessionId = sessionId;
180
247
  }
181
248
 
182
249
  let type = "log";
@@ -187,24 +254,53 @@ function processEvent(evt) {
187
254
  // Agent-level events
188
255
  if (event === "agent.run.start" || event === "agent.start") {
189
256
  type = "system";
190
- content = `Agent session started: ${agentId}`;
257
+ content = `Agent session started: ${GLOBAL_AGENT_ID}`;
191
258
  totalTurns = 0;
192
259
  successfulTools = 0;
193
260
  } else if (event === "agent.run.end" || event === "agent.end") {
194
261
  type = "system";
195
- content = `Agent session ended: ${agentId}`;
262
+ content = `Agent session ended: ${GLOBAL_AGENT_ID}`;
196
263
  } else if (event.includes("thought") || event.includes("reasoning") || event.includes("think")) {
197
264
  totalTurns++;
198
265
  latestThought = data.text || data.content || data.message || "Thinking...";
199
266
  type = "thought";
200
267
  content = latestThought;
201
268
  } else if (event.includes("response") || event.includes("message") || event.includes("reply")) {
269
+ // Check if it's a "chat" message that slipped through?
270
+ // Usually agent.message is the agent speaking.
202
271
  latestThought = (data.text || data.content || data.message || "").slice(0, 200) || "Response generated";
203
272
  type = "thought";
204
273
  content = latestThought;
205
- } else if (event.includes("error")) {
206
- type = "error";
207
- content = data.message || data.text || data.error || "Agent error";
274
+ // Note: Original code had a bug here overwriting type/content immediately after.
275
+ // I'll fix that logic.
276
+ if (data.error) {
277
+ type = "error";
278
+ content = data.message || data.text || data.error || "Agent error";
279
+ }
280
+ }
281
+
282
+ // 🧠 Cost & Token Scanner
283
+ // Scan text for "Cost: $0.XX" or "Tokens: XXX"
284
+ const txt = data.text || data.content || data.message || "";
285
+ if (txt) {
286
+ // Regex to capture 'Cost: $0.00123' or 'cost=$1.23'
287
+ const costMatch = txt.match(/Cost:?\s*\$([0-9.]+)/i);
288
+ if (costMatch) {
289
+ const cost = parseFloat(costMatch[1]);
290
+ if (!isNaN(cost)) {
291
+ dailyCost += cost;
292
+ sendToMortgram("cost_update", `Daily spend: $${dailyCost.toFixed(4)}`, { daily_cost: dailyCost, total_tokens: totalTokens });
293
+ }
294
+ }
295
+
296
+ // Regex to capture 'Tokens: 1234' or 'tokens=5678' or 'token_count: 900'
297
+ const tokenMatch = txt.match(/Tokens?[_\s]*(?:count)?:?\s*([0-9,]+)/i);
298
+ if (tokenMatch) {
299
+ const tokens = parseInt(tokenMatch[1].replace(/,/g, ""), 10);
300
+ if (!isNaN(tokens)) {
301
+ totalTokens += tokens;
302
+ }
303
+ }
208
304
  }
209
305
  } else if (event.startsWith("tool.") || event.startsWith("exec.")) {
210
306
  // Tool execution events
@@ -214,7 +310,7 @@ function processEvent(evt) {
214
310
  type = "action";
215
311
  content = data.name || data.tool || data.command || event;
216
312
  } else if (event.startsWith("session.")) {
217
- if (event.includes("usage") || event.includes("cost")) {
313
+ if (event.includes("usage") || event.includes("cost") || event.includes("tokens")) {
218
314
  dailyCost = data.total_cost || data.cost || data.totalCost || dailyCost;
219
315
  type = "cost_update";
220
316
  content = `Daily spend: $${dailyCost.toFixed(4)}`;
@@ -328,6 +424,14 @@ function connect() {
328
424
  try { ws.ping(); } catch (_) { /* noop */ }
329
425
  }
330
426
  }, 25000);
427
+
428
+ // Start periodic cost updates (every 60s) for "Daily Burn"
429
+ if (costTimer) clearInterval(costTimer);
430
+ costTimer = setInterval(() => {
431
+ if (dailyCost > 0) {
432
+ sendToMortgram("cost_update", `Daily spend sync: $${dailyCost.toFixed(4)}`, { daily_cost: dailyCost });
433
+ }
434
+ }, 60000);
331
435
  }
332
436
  } else {
333
437
  console.error(`\x1b[31m[MORTGRAM] Gateway rejected: ${msg.error?.message || "Unknown"}\x1b[0m`);
@@ -384,9 +488,22 @@ connect();
384
488
  function shutdown() {
385
489
  console.log("\n\x1b[33m[MORTGRAM] Shutting down...\x1b[0m");
386
490
  if (heartbeatTimer) clearInterval(heartbeatTimer);
387
- sendToMortgram("system", "Ghost Bridge shutdown", { event: "bridge.shutdown" });
388
- if (ws) ws.close();
389
- setTimeout(() => process.exit(0), 500);
491
+ if (costTimer) clearInterval(costTimer);
492
+
493
+ // Send final offline status
494
+ sendToMortgram("status", "Bridge shutting down", {
495
+ status: "offline", // This maps to "inactive" in UI
496
+ event: "bridge.shutdown"
497
+ }).then(() => {
498
+ if (ws) ws.close();
499
+ console.log("\x1b[32m[MORTGRAM] Shutdown complete.\x1b[0m");
500
+ process.exit(0);
501
+ }).catch(() => {
502
+ process.exit(1);
503
+ });
504
+
505
+ // Fallback exit if network hangs
506
+ setTimeout(() => process.exit(0), 2000);
390
507
  }
391
508
 
392
509
  process.on("SIGINT", shutdown);
package/package.json CHANGED
@@ -1,11 +1,11 @@
1
1
  {
2
2
  "name": "mortgram-bridge",
3
- "version": "2.3.0",
4
- "description": "MORTGRAM Ghost Bridge — Zero-config OpenClaw event forwarder",
3
+ "version": "3.0.0",
4
+ "description": "MORTGRAM Phantom Hook — Zero-latency buffered event forwarder",
5
5
  "type": "module",
6
- "main": "index.mjs",
6
+ "main": "phantom.mjs",
7
7
  "bin": {
8
- "mortgram-bridge": "./index.mjs"
8
+ "mortgram-bridge": "./phantom.mjs"
9
9
  },
10
10
  "dependencies": {
11
11
  "ws": "^8.18.0"
package/phantom.mjs ADDED
@@ -0,0 +1,459 @@
1
+ #!/usr/bin/env node
2
+ // ═══════════════════════════════════════════════════════════════════
3
+ // MORTGRAM Phantom Hook v3.0 — Zero-latency Buffered Event Forwarder
4
+ // ═══════════════════════════════════════════════════════════════════
5
+ // Connects to the OpenClaw Gateway WebSocket and forwards agent/tool
6
+ // events in 500ms batches to the MORTGRAM dashboard API.
7
+ //
8
+ // Usage:
9
+ // npx mortgram-bridge --token YOUR_MORTGRAM_TOKEN
10
+ // node bridge/phantom.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 & Environment ───────────────────────────────────────
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
+ function loadEnv() {
27
+ try {
28
+ const envPath = path.join(process.cwd(), "bridge", ".env");
29
+ if (fs.existsSync(envPath)) {
30
+ const content = fs.readFileSync(envPath, "utf8");
31
+ const env = {};
32
+ for (const line of content.split("\n")) {
33
+ const match = line.match(/^([^=#]+)=(.*)$/);
34
+ if (match) {
35
+ env[match[1].trim()] = match[2].trim().replace(/^["']|["']$/g, "");
36
+ }
37
+ }
38
+ return env;
39
+ }
40
+ } catch (_) { }
41
+ return {};
42
+ }
43
+
44
+ const localEnv = loadEnv();
45
+ const env = (key) => getArg(key.toLowerCase().replace(/_/g, "-")) || process.env[key] || localEnv[key] || "";
46
+
47
+ // ─── Identity (strict env-based) ─────────────────────────────────
48
+ function buildAgentId() {
49
+ const explicit = env("MORTGRAM_AGENT_ID") || env("AGENT_ID");
50
+ if (explicit) return explicit;
51
+ // Deterministic hardware hash fallback
52
+ try {
53
+ const hw = `${os.hostname()}-${os.platform()}-${os.arch()}-${os.cpus().length}`;
54
+ return `agent-${crypto.createHash("sha256").update(hw).digest("hex").substring(0, 12)}`;
55
+ } catch (_) {
56
+ return `agent-${Date.now().toString(36)}`;
57
+ }
58
+ }
59
+
60
+ const AGENT_ID = buildAgentId();
61
+ const AGENT_NAME = env("MORTGRAM_AGENT_NAME") || env("AGENT_NAME") || "Founder-Main";
62
+ const API_TOKEN = getArg("token") || env("MORTGRAM_API_KEY") || env("MG_LIVE_KEY");
63
+ const API_URL = env("MORTGRAM_API_URL") || "https://mortgram.online/api/ingest";
64
+ const GW_OVERRIDE = getArg("gateway-url");
65
+
66
+ if (!API_TOKEN) {
67
+ console.error("\x1b[31m[PHANTOM] FATAL: No --token provided.\x1b[0m");
68
+ console.error("\x1b[33m Usage: npx mortgram-bridge --token YOUR_TOKEN\x1b[0m");
69
+ process.exit(1);
70
+ }
71
+
72
+ // ─── OpenClaw Config Auto-Detect ──────────────────────────────────
73
+ function detectGateway() {
74
+ const candidates = [
75
+ path.join(os.homedir(), ".openclaw", "openclaw.json"),
76
+ path.join(os.homedir(), ".clawdbot", "clawdbot.json"),
77
+ ];
78
+ for (const p of candidates) {
79
+ try {
80
+ if (fs.existsSync(p)) {
81
+ const raw = JSON.parse(fs.readFileSync(p, "utf8"));
82
+ const port = raw?.gateway?.port || 18789;
83
+ const token = raw?.gateway?.auth?.token || "";
84
+ console.log(`\x1b[90m[PHANTOM] Config found: ${p} (port ${port})\x1b[0m`);
85
+ return { port, token };
86
+ }
87
+ } catch (_) { }
88
+ }
89
+ return { port: 18789, token: "" };
90
+ }
91
+
92
+ const gwConfig = detectGateway();
93
+ const GATEWAY_URL = GW_OVERRIDE || `ws://127.0.0.1:${gwConfig.port}`;
94
+ const GATEWAY_TOKEN = gwConfig.token;
95
+
96
+ // ─── Usage Scraper ────────────────────────────────────────────────
97
+ // High-speed regex parser for OpenAI / Gemini / Anthropic cost data
98
+ class UsageScraper {
99
+ constructor() {
100
+ this.totalCost = 0;
101
+ this.totalTokens = 0;
102
+ this.model = "unknown";
103
+
104
+ // Ordered by specificity — first match wins per category
105
+ this.costPatterns = [
106
+ /[Cc]ost:?\s*\$([0-9]+(?:\.[0-9]+)?)/, // Cost: $0.0042
107
+ /total_cost["\s:=]+([0-9]+(?:\.[0-9]+)?)/, // "total_cost": 0.05
108
+ /estimated_cost["\s:=]+\$?([0-9]+(?:\.[0-9]+)?)/i, // estimated_cost=$0.12
109
+ ];
110
+ this.tokenPatterns = [
111
+ /total_tokens["\s:=]+([0-9,]+)/, // "total_tokens": 1234
112
+ /[Tt]okens?[_\s]*(?:count|used)?:?\s*([0-9,]+)/, // Tokens: 500
113
+ /usage[":\s{]+.*?total[_\s]*tokens[":\s]+([0-9,]+)/s, // usage.total_tokens
114
+ ];
115
+ this.modelPatterns = [
116
+ /model[":\s=]+["']?(gpt-4[^\s"',}]*)/i, // gpt-4o, gpt-4-turbo
117
+ /model[":\s=]+["']?(claude-[^\s"',}]*)/i, // claude-3-opus
118
+ /model[":\s=]+["']?(gemini-[^\s"',}]*)/i, // gemini-1.5-pro
119
+ ];
120
+ }
121
+
122
+ /** Scan a text blob and accumulate cost/token data. Returns true if anything was found. */
123
+ scan(text) {
124
+ if (!text || typeof text !== "string") return false;
125
+ let found = false;
126
+
127
+ for (const re of this.costPatterns) {
128
+ const m = text.match(re);
129
+ if (m) {
130
+ const v = parseFloat(m[1]);
131
+ if (!isNaN(v)) { this.totalCost += v; found = true; }
132
+ break;
133
+ }
134
+ }
135
+
136
+ for (const re of this.tokenPatterns) {
137
+ const m = text.match(re);
138
+ if (m) {
139
+ const v = parseInt(m[1].replace(/,/g, ""), 10);
140
+ if (!isNaN(v)) { this.totalTokens += v; found = true; }
141
+ break;
142
+ }
143
+ }
144
+
145
+ for (const re of this.modelPatterns) {
146
+ const m = text.match(re);
147
+ if (m) { this.model = m[1]; found = true; break; }
148
+ }
149
+
150
+ return found;
151
+ }
152
+
153
+ snapshot() {
154
+ return { daily_cost: this.totalCost, total_tokens: this.totalTokens, model: this.model };
155
+ }
156
+ }
157
+
158
+ const scraper = new UsageScraper();
159
+
160
+ // ─── Event Buffer (500ms batch window) ────────────────────────────
161
+ const FLUSH_INTERVAL_MS = 500;
162
+ let eventBuffer = [];
163
+ let flushTimer = null;
164
+ let sessionId = "";
165
+
166
+ function enqueue(type, content, extra = {}) {
167
+ eventBuffer.push({ type, content, extra, ts: Date.now() });
168
+ // Start flush timer if not already running
169
+ if (!flushTimer) {
170
+ flushTimer = setTimeout(flushBuffer, FLUSH_INTERVAL_MS);
171
+ }
172
+ }
173
+
174
+ async function flushBuffer() {
175
+ flushTimer = null;
176
+ if (eventBuffer.length === 0) return;
177
+
178
+ // Grab and clear
179
+ const batch = eventBuffer;
180
+ eventBuffer = [];
181
+
182
+ console.log(`\x1b[36m[PHANTOM] ⚡ Flushing batch of ${batch.length} events\x1b[0m`);
183
+
184
+ // Send each event (API expects individual payloads)
185
+ const usageSnap = scraper.snapshot();
186
+ for (const evt of batch) {
187
+ const payload = {
188
+ agent_id: AGENT_ID,
189
+ type: evt.type,
190
+ content: evt.content,
191
+ metadata: {
192
+ ...evt.extra,
193
+ agent_name: AGENT_NAME,
194
+ daily_cost: usageSnap.daily_cost,
195
+ total_tokens: usageSnap.total_tokens,
196
+ model: usageSnap.model,
197
+ status: "online",
198
+ session_id: sessionId,
199
+ timestamp: new Date(evt.ts).toISOString(),
200
+ },
201
+ };
202
+ try {
203
+ await sendToApi(payload);
204
+ } catch (err) {
205
+ console.error(`\x1b[31m[PHANTOM] Send failed: ${err.message}\x1b[0m`);
206
+ // Re-queue on failure (max once)
207
+ if (!evt._retried) {
208
+ evt._retried = true;
209
+ eventBuffer.push(evt);
210
+ if (!flushTimer) flushTimer = setTimeout(flushBuffer, 2000);
211
+ }
212
+ }
213
+ }
214
+ }
215
+
216
+ async function sendToApi(payload) {
217
+ const res = await fetch(API_URL, {
218
+ method: "POST",
219
+ headers: {
220
+ "Content-Type": "application/json",
221
+ "x-mortgram-token": API_TOKEN,
222
+ },
223
+ body: JSON.stringify(payload),
224
+ signal: AbortSignal.timeout(5000),
225
+ });
226
+ if (!res.ok) {
227
+ const body = await res.text();
228
+ throw new Error(`API ${res.status}: ${body}`);
229
+ }
230
+ }
231
+
232
+ // ─── Event Processing (strict filter) ─────────────────────────────
233
+ let eventCount = 0;
234
+
235
+ function processEvent(evt) {
236
+ const { event, payload } = evt;
237
+ const data = payload || {};
238
+
239
+ // ═══ STRICT FILTER ═══
240
+ // ONLY forward agent.* and tool.* events. Everything else is noise.
241
+ const isAgent = event.startsWith("agent.");
242
+ const isTool = event.startsWith("tool.") || event.startsWith("exec.");
243
+ if (!isAgent && !isTool) return;
244
+
245
+ eventCount++;
246
+
247
+ // Extract session ID if present
248
+ const sid = data.sessionId || data.session_id || data.runId || evt.sessionId || "";
249
+ if (sid) sessionId = sid;
250
+
251
+ // Scan all text fields for cost/token data
252
+ const textBlob = [data.text, data.content, data.message, data.output, data.stdout]
253
+ .filter(Boolean)
254
+ .join(" ");
255
+ scraper.scan(textBlob);
256
+
257
+ // Also deep-scan if there's a stringified JSON in the payload
258
+ if (typeof data === "object") {
259
+ try { scraper.scan(JSON.stringify(data)); } catch (_) { }
260
+ }
261
+
262
+ // Classify event type
263
+ let type = "log";
264
+ let content = event;
265
+
266
+ if (event === "agent.run.start" || event === "agent.start") {
267
+ type = "system";
268
+ content = `Agent session started: ${AGENT_NAME}`;
269
+ } else if (event === "agent.run.end" || event === "agent.end") {
270
+ type = "system";
271
+ content = `Agent session ended: ${AGENT_NAME}`;
272
+ } else if (event.includes("thought") || event.includes("reasoning") || event.includes("think")) {
273
+ type = "thought";
274
+ content = data.text || data.content || data.message || "Thinking...";
275
+ } else if (event.includes("response") || event.includes("message") || event.includes("reply")) {
276
+ type = "thought";
277
+ content = (data.text || data.content || data.message || "").slice(0, 300) || "Response generated";
278
+ if (data.error) {
279
+ type = "error";
280
+ content = data.error || data.message || "Agent error";
281
+ }
282
+ } else if (isTool) {
283
+ type = "action";
284
+ content = data.name || data.tool || data.command || event;
285
+ }
286
+
287
+ console.log(`\x1b[34m[PHANTOM] 📡 #${eventCount} ${event}\x1b[0m`);
288
+ enqueue(type, content, { event, seq: evt.seq });
289
+ }
290
+
291
+ // ─── WebSocket Connection ─────────────────────────────────────────
292
+ let ws = null;
293
+ let connected = false;
294
+ let reconnectDelay = 500;
295
+ let heartbeatTimer = null;
296
+ let costTimer = null;
297
+
298
+ function sendConnectFrame() {
299
+ if (!ws || ws.readyState !== WebSocket.OPEN) return;
300
+ const frame = {
301
+ type: "req",
302
+ id: crypto.randomUUID(),
303
+ method: "connect",
304
+ params: {
305
+ minProtocol: 3,
306
+ maxProtocol: 3,
307
+ client: {
308
+ id: "phantom-hook",
309
+ displayName: "MORTGRAM Phantom Hook",
310
+ version: "3.0.0",
311
+ platform: process.platform,
312
+ mode: "backend",
313
+ },
314
+ caps: ["tool-events"],
315
+ auth: GATEWAY_TOKEN ? { token: GATEWAY_TOKEN } : undefined,
316
+ role: "operator",
317
+ scopes: ["operator.admin"],
318
+ },
319
+ };
320
+ ws.send(JSON.stringify(frame));
321
+ }
322
+
323
+ function connect() {
324
+ if (ws) { try { ws.close(); } catch (_) { } ws = null; }
325
+
326
+ console.log(`\x1b[90m[PHANTOM] Connecting to ${GATEWAY_URL}...\x1b[0m`);
327
+ ws = new WebSocket(GATEWAY_URL, { maxPayload: 25 * 1024 * 1024 });
328
+
329
+ ws.on("open", () => {
330
+ console.log("\x1b[32m[PHANTOM] WebSocket open, sending handshake...\x1b[0m");
331
+ sendConnectFrame();
332
+ setTimeout(() => { if (!connected) sendConnectFrame(); }, 100);
333
+ });
334
+
335
+ ws.on("message", (rawData) => {
336
+ try {
337
+ const msg = JSON.parse(rawData.toString());
338
+
339
+ // Event frame
340
+ if (msg.event) {
341
+ if (msg.event === "connect.challenge") {
342
+ sendConnectFrame();
343
+ return;
344
+ }
345
+ processEvent(msg);
346
+ return;
347
+ }
348
+
349
+ // Response frame (connect ACK)
350
+ if (msg.id && typeof msg.ok !== "undefined") {
351
+ if (msg.ok && !connected) {
352
+ connected = true;
353
+ reconnectDelay = 500;
354
+ console.log("\x1b[32m[PHANTOM] ✅ Phantom Hook: LINK ACTIVE\x1b[0m");
355
+
356
+ // Send READY signal to API
357
+ enqueue("system", "Phantom Hook connected", {
358
+ event: "bridge.connect",
359
+ action: "AGENT_READY",
360
+ gateway_url: GATEWAY_URL,
361
+ });
362
+
363
+ // WebSocket keep-alive pings
364
+ if (heartbeatTimer) clearInterval(heartbeatTimer);
365
+ heartbeatTimer = setInterval(() => {
366
+ if (ws?.readyState === WebSocket.OPEN) {
367
+ try { ws.ping(); } catch (_) { }
368
+ }
369
+ }, 20000);
370
+
371
+ // Periodic cost sync every 30s
372
+ if (costTimer) clearInterval(costTimer);
373
+ costTimer = setInterval(() => {
374
+ const snap = scraper.snapshot();
375
+ if (snap.daily_cost > 0) {
376
+ enqueue("cost_update", `Daily spend: $${snap.daily_cost.toFixed(4)}`, snap);
377
+ }
378
+ }, 30000);
379
+
380
+ } else if (!msg.ok) {
381
+ console.error(`\x1b[31m[PHANTOM] Gateway rejected: ${msg.error?.message || "Unknown"}\x1b[0m`);
382
+ }
383
+ }
384
+ } catch (_) { /* non-JSON, ignore */ }
385
+ });
386
+
387
+ ws.on("close", (code, reason) => {
388
+ const wasConnected = connected;
389
+ connected = false;
390
+ if (heartbeatTimer) { clearInterval(heartbeatTimer); heartbeatTimer = null; }
391
+
392
+ if (wasConnected) {
393
+ console.log(`\x1b[33m[PHANTOM] Disconnected (${code}): ${reason?.toString() || ""}\x1b[0m`);
394
+ enqueue("system", "Phantom Hook disconnected", { event: "bridge.disconnect" });
395
+ }
396
+
397
+ console.log(`\x1b[90m[PHANTOM] Reconnecting in ${reconnectDelay / 1000}s...\x1b[0m`);
398
+ setTimeout(connect, reconnectDelay);
399
+ reconnectDelay = Math.min(reconnectDelay * 2, 30000);
400
+ });
401
+
402
+ ws.on("error", () => { /* reconnect via close handler */ });
403
+ }
404
+
405
+ // ─── Banner ───────────────────────────────────────────────────────
406
+ console.log("");
407
+ console.log("\x1b[35m ╔══════════════════════════════════════╗\x1b[0m");
408
+ console.log("\x1b[35m ║ MORTGRAM Phantom Hook v3.0.0 ║\x1b[0m");
409
+ console.log("\x1b[35m ║ Zero-latency Buffered Forwarder ║\x1b[0m");
410
+ console.log("\x1b[35m ╚══════════════════════════════════════╝\x1b[0m");
411
+ console.log("");
412
+ console.log(`\x1b[90m Agent ID: ${AGENT_ID}\x1b[0m`);
413
+ console.log(`\x1b[90m Agent Name: ${AGENT_NAME}\x1b[0m`);
414
+ console.log(`\x1b[90m Gateway: ${GATEWAY_URL}\x1b[0m`);
415
+ console.log(`\x1b[90m Dashboard: ${API_URL}\x1b[0m`);
416
+ console.log(`\x1b[90m Buffer: ${FLUSH_INTERVAL_MS}ms batching\x1b[0m`);
417
+ console.log("");
418
+
419
+ // ─── Start ────────────────────────────────────────────────────────
420
+ connect();
421
+
422
+ // ─── Graceful Shutdown ────────────────────────────────────────────
423
+ async function shutdown() {
424
+ console.log("\n\x1b[33m[PHANTOM] Shutting down...\x1b[0m");
425
+ if (heartbeatTimer) clearInterval(heartbeatTimer);
426
+ if (costTimer) clearInterval(costTimer);
427
+
428
+ // Flush remaining events
429
+ await flushBuffer();
430
+
431
+ // Send final offline ping
432
+ try {
433
+ await sendToApi({
434
+ agent_id: AGENT_ID,
435
+ type: "status",
436
+ content: "Phantom Hook shutting down",
437
+ metadata: {
438
+ agent_name: AGENT_NAME,
439
+ status: "offline",
440
+ session_id: sessionId,
441
+ event: "bridge.shutdown",
442
+ timestamp: new Date().toISOString(),
443
+ },
444
+ });
445
+ } catch (_) { /* best effort */ }
446
+
447
+ if (ws) ws.close();
448
+ console.log("\x1b[32m[PHANTOM] Shutdown complete.\x1b[0m");
449
+ process.exit(0);
450
+ }
451
+
452
+ process.on("SIGINT", shutdown);
453
+ process.on("SIGTERM", shutdown);
454
+ if (process.platform === "win32") {
455
+ process.on("SIGBREAK", shutdown);
456
+ }
457
+
458
+ // Fallback exit
459
+ setTimeout(() => { }, 2147483647); // Keep process alive