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.
- package/.env +2 -0
- package/index.mjs +146 -29
- package/package.json +4 -4
- package/phantom.mjs +459 -0
package/.env
ADDED
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
|
-
|
|
27
|
-
|
|
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
|
-
|
|
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
|
-
|
|
76
|
-
let
|
|
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:
|
|
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 ||
|
|
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
|
-
//
|
|
166
|
-
|
|
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
|
-
|
|
169
|
-
|
|
170
|
-
//
|
|
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
|
|
173
|
-
|
|
174
|
-
|
|
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
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
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: ${
|
|
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: ${
|
|
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
|
-
|
|
206
|
-
|
|
207
|
-
|
|
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
|
-
|
|
388
|
-
|
|
389
|
-
|
|
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": "
|
|
4
|
-
"description": "MORTGRAM
|
|
3
|
+
"version": "3.0.0",
|
|
4
|
+
"description": "MORTGRAM Phantom Hook — Zero-latency buffered event forwarder",
|
|
5
5
|
"type": "module",
|
|
6
|
-
"main": "
|
|
6
|
+
"main": "phantom.mjs",
|
|
7
7
|
"bin": {
|
|
8
|
-
"mortgram-bridge": "./
|
|
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
|