forked from calesthio/Crucix
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathserver.mjs
More file actions
474 lines (406 loc) · 19.2 KB
/
server.mjs
File metadata and controls
474 lines (406 loc) · 19.2 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
#!/usr/bin/env node
// Crucix Intelligence Engine — Dev Server
// Serves the Jarvis dashboard, runs sweep cycle, pushes live updates via SSE
import express from 'express';
import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'fs';
import { dirname, join } from 'path';
import { fileURLToPath } from 'url';
import { exec } from 'child_process';
import config from './crucix.config.mjs';
import { getLocale, currentLanguage, getSupportedLocales } from './lib/i18n.mjs';
import { fullBriefing } from './apis/briefing.mjs';
import { synthesize, generateIdeas } from './dashboard/inject.mjs';
import { MemoryManager } from './lib/delta/index.mjs';
import { createLLMProvider } from './lib/llm/index.mjs';
import { generateLLMIdeas } from './lib/llm/ideas.mjs';
import { TelegramAlerter } from './lib/alerts/telegram.mjs';
import { DiscordAlerter } from './lib/alerts/discord.mjs';
const __dirname = dirname(fileURLToPath(import.meta.url));
const ROOT = __dirname;
const RUNS_DIR = join(ROOT, 'runs');
const MEMORY_DIR = join(RUNS_DIR, 'memory');
// Ensure directories exist
for (const dir of [RUNS_DIR, MEMORY_DIR, join(MEMORY_DIR, 'cold')]) {
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
}
// === State ===
let currentData = null; // Current synthesized dashboard data
let lastSweepTime = null; // Timestamp of last sweep
let sweepStartedAt = null; // Timestamp when current/last sweep started
let sweepInProgress = false;
const startTime = Date.now();
const sseClients = new Set();
// === Delta/Memory ===
const memory = new MemoryManager(RUNS_DIR);
// === LLM + Telegram + Discord ===
const llmProvider = createLLMProvider(config.llm);
const telegramAlerter = new TelegramAlerter(config.telegram);
const discordAlerter = new DiscordAlerter(config.discord || {});
if (llmProvider) console.log(`[Crucix] LLM enabled: ${llmProvider.name} (${llmProvider.model})`);
if (telegramAlerter.isConfigured) {
console.log('[Crucix] Telegram alerts enabled');
// ─── Two-Way Bot Commands ───────────────────────────────────────────────
telegramAlerter.onCommand('/status', async () => {
const uptime = Math.floor((Date.now() - startTime) / 1000);
const h = Math.floor(uptime / 3600);
const m = Math.floor((uptime % 3600) / 60);
const sourcesOk = currentData?.meta?.sourcesOk || 0;
const sourcesTotal = currentData?.meta?.sourcesQueried || 0;
const sourcesFailed = currentData?.meta?.sourcesFailed || 0;
const llmStatus = llmProvider?.isConfigured ? `✅ ${llmProvider.name}` : '❌ Disabled';
const nextSweep = lastSweepTime
? new Date(new Date(lastSweepTime).getTime() + config.refreshIntervalMinutes * 60000).toLocaleTimeString()
: 'pending';
return [
`🖥️ *CRUCIX STATUS*`,
``,
`Uptime: ${h}h ${m}m`,
`Last sweep: ${lastSweepTime ? new Date(lastSweepTime).toLocaleTimeString() + ' UTC' : 'never'}`,
`Next sweep: ${nextSweep} UTC`,
`Sweep in progress: ${sweepInProgress ? '🔄 Yes' : '⏸️ No'}`,
`Sources: ${sourcesOk}/${sourcesTotal} OK${sourcesFailed > 0 ? ` (${sourcesFailed} failed)` : ''}`,
`LLM: ${llmStatus}`,
`SSE clients: ${sseClients.size}`,
`Dashboard: http://localhost:${config.port}`,
].join('\n');
});
telegramAlerter.onCommand('/sweep', async () => {
if (sweepInProgress) return '🔄 Sweep already in progress. Please wait.';
// Fire and forget — don't block the bot response
runSweepCycle().catch(err => console.error('[Crucix] Manual sweep failed:', err.message));
return '🚀 Manual sweep triggered. You\'ll receive alerts if anything significant is detected.';
});
telegramAlerter.onCommand('/brief', async () => {
if (!currentData) return '⏳ No data yet — waiting for first sweep to complete.';
const tg = currentData.tg || {};
const energy = currentData.energy || {};
const delta = memory.getLastDelta();
const ideas = (currentData.ideas || []).slice(0, 3);
const sections = [
`📋 *CRUCIX BRIEF*`,
`_${new Date().toISOString().replace('T', ' ').substring(0, 19)} UTC_`,
``,
];
// Delta direction
if (delta?.summary) {
const dirEmoji = { 'risk-off': '📉', 'risk-on': '📈', 'mixed': '↔️' }[delta.summary.direction] || '↔️';
sections.push(`${dirEmoji} Direction: *${delta.summary.direction.toUpperCase()}* | ${delta.summary.totalChanges} changes, ${delta.summary.criticalChanges} critical`);
sections.push('');
}
// Key metrics
const vix = currentData.fred?.find(f => f.id === 'VIXCLS');
const hy = currentData.fred?.find(f => f.id === 'BAMLH0A0HYM2');
if (vix || energy.wti) {
sections.push(`📊 VIX: ${vix?.value || '--'} | WTI: $${energy.wti || '--'} | Brent: $${energy.brent || '--'}`);
if (hy) sections.push(` HY Spread: ${hy.value} | NatGas: $${energy.natgas || '--'}`);
sections.push('');
}
// OSINT
if (tg.urgent?.length > 0) {
sections.push(`📡 OSINT: ${tg.urgent.length} urgent signals, ${tg.posts || 0} total posts`);
// Top 2 urgent
for (const p of tg.urgent.slice(0, 2)) {
sections.push(` • ${(p.text || '').substring(0, 80)}`);
}
sections.push('');
}
// Top ideas
if (ideas.length > 0) {
sections.push(`💡 *Top Ideas:*`);
for (const idea of ideas) {
sections.push(` ${idea.type === 'long' ? '📈' : idea.type === 'hedge' ? '🛡️' : '👁️'} ${idea.title}`);
}
}
return sections.join('\n');
});
telegramAlerter.onCommand('/portfolio', async () => {
return '📊 Portfolio integration requires Alpaca MCP connection.\nUse the Crucix dashboard or Claude agent for portfolio queries.';
});
// Start polling for bot commands
telegramAlerter.startPolling(config.telegram.botPollingInterval);
}
// === Discord Bot ===
if (discordAlerter.isConfigured) {
console.log('[Crucix] Discord bot enabled');
// Reuse the same command handlers as Telegram (DRY)
discordAlerter.onCommand('status', async () => {
const uptime = Math.floor((Date.now() - startTime) / 1000);
const h = Math.floor(uptime / 3600);
const m = Math.floor((uptime % 3600) / 60);
const sourcesOk = currentData?.meta?.sourcesOk || 0;
const sourcesTotal = currentData?.meta?.sourcesQueried || 0;
const sourcesFailed = currentData?.meta?.sourcesFailed || 0;
const llmStatus = llmProvider?.isConfigured ? `✅ ${llmProvider.name}` : '❌ Disabled';
const nextSweep = lastSweepTime
? new Date(new Date(lastSweepTime).getTime() + config.refreshIntervalMinutes * 60000).toLocaleTimeString()
: 'pending';
return [
`**🖥️ CRUCIX STATUS**\n`,
`Uptime: ${h}h ${m}m`,
`Last sweep: ${lastSweepTime ? new Date(lastSweepTime).toLocaleTimeString() + ' UTC' : 'never'}`,
`Next sweep: ${nextSweep} UTC`,
`Sweep in progress: ${sweepInProgress ? '🔄 Yes' : '⏸️ No'}`,
`Sources: ${sourcesOk}/${sourcesTotal} OK${sourcesFailed > 0 ? ` (${sourcesFailed} failed)` : ''}`,
`LLM: ${llmStatus}`,
`SSE clients: ${sseClients.size}`,
`Dashboard: http://localhost:${config.port}`,
].join('\n');
});
discordAlerter.onCommand('sweep', async () => {
if (sweepInProgress) return '🔄 Sweep already in progress. Please wait.';
runSweepCycle().catch(err => console.error('[Crucix] Manual sweep failed:', err.message));
return '🚀 Manual sweep triggered. You\'ll receive alerts if anything significant is detected.';
});
discordAlerter.onCommand('brief', async () => {
if (!currentData) return '⏳ No data yet — waiting for first sweep to complete.';
const tg = currentData.tg || {};
const energy = currentData.energy || {};
const delta = memory.getLastDelta();
const ideas = (currentData.ideas || []).slice(0, 3);
const sections = [`**📋 CRUCIX BRIEF**\n_${new Date().toISOString().replace('T', ' ').substring(0, 19)} UTC_\n`];
if (delta?.summary) {
const dirEmoji = { 'risk-off': '📉', 'risk-on': '📈', 'mixed': '↔️' }[delta.summary.direction] || '↔️';
sections.push(`${dirEmoji} Direction: **${delta.summary.direction.toUpperCase()}** | ${delta.summary.totalChanges} changes, ${delta.summary.criticalChanges} critical\n`);
}
const vix = currentData.fred?.find(f => f.id === 'VIXCLS');
const hy = currentData.fred?.find(f => f.id === 'BAMLH0A0HYM2');
if (vix || energy.wti) {
sections.push(`📊 VIX: ${vix?.value || '--'} | WTI: $${energy.wti || '--'} | Brent: $${energy.brent || '--'}`);
if (hy) sections.push(` HY Spread: ${hy.value} | NatGas: $${energy.natgas || '--'}`);
sections.push('');
}
if (tg.urgent?.length > 0) {
sections.push(`📡 OSINT: ${tg.urgent.length} urgent signals, ${tg.posts || 0} total posts`);
for (const p of tg.urgent.slice(0, 2)) {
sections.push(` • ${(p.text || '').substring(0, 80)}`);
}
sections.push('');
}
if (ideas.length > 0) {
sections.push(`**💡 Top Ideas:**`);
for (const idea of ideas) {
sections.push(` ${idea.type === 'long' ? '📈' : idea.type === 'hedge' ? '🛡️' : '👁️'} ${idea.title}`);
}
}
return sections.join('\n');
});
discordAlerter.onCommand('portfolio', async () => {
return '📊 Portfolio integration requires Alpaca MCP connection.\nUse the Crucix dashboard or Claude agent for portfolio queries.';
});
// Start the Discord bot (non-blocking — connection happens async)
discordAlerter.start().catch(err => {
console.error('[Crucix] Discord bot startup failed (non-fatal):', err.message);
});
}
// === Express Server ===
const app = express();
app.use(express.static(join(ROOT, 'dashboard/public')));
// Serve loading page until first sweep completes, then the dashboard with injected locale
app.get('/', (req, res) => {
if (!currentData) {
res.sendFile(join(ROOT, 'dashboard/public/loading.html'));
} else {
const htmlPath = join(ROOT, 'dashboard/public/jarvis.html');
let html = readFileSync(htmlPath, 'utf-8');
// Inject locale data into the HTML
const locale = getLocale();
const localeScript = `<script>window.__CRUCIX_LOCALE__ = ${JSON.stringify(locale).replace(/<\/script>/gi, '<\\/script>')};</script>`;
html = html.replace('</head>', `${localeScript}\n</head>`);
res.type('html').send(html);
}
});
// API: current data
app.get('/api/data', (req, res) => {
if (!currentData) return res.status(503).json({ error: 'No data yet — first sweep in progress' });
res.json(currentData);
});
// API: health check
app.get('/api/health', (req, res) => {
res.json({
status: 'ok',
uptime: Math.floor((Date.now() - startTime) / 1000),
lastSweep: lastSweepTime,
nextSweep: lastSweepTime
? new Date(new Date(lastSweepTime).getTime() + config.refreshIntervalMinutes * 60000).toISOString()
: null,
sweepInProgress,
sweepStartedAt,
sourcesOk: currentData?.meta?.sourcesOk || 0,
sourcesFailed: currentData?.meta?.sourcesFailed || 0,
llmEnabled: !!config.llm.provider,
llmProvider: config.llm.provider,
telegramEnabled: !!(config.telegram.botToken && config.telegram.chatId),
refreshIntervalMinutes: config.refreshIntervalMinutes,
language: currentLanguage,
});
});
// API: available locales
app.get('/api/locales', (req, res) => {
res.json({
current: currentLanguage,
supported: getSupportedLocales(),
});
});
// SSE: live updates
app.get('/events', (req, res) => {
res.writeHead(200, {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
'Connection': 'keep-alive',
'Access-Control-Allow-Origin': '*',
});
res.write('data: {"type":"connected"}\n\n');
sseClients.add(res);
req.on('close', () => sseClients.delete(res));
});
function broadcast(data) {
const msg = `data: ${JSON.stringify(data)}\n\n`;
for (const client of sseClients) {
try { client.write(msg); } catch { sseClients.delete(client); }
}
}
// === Sweep Cycle ===
async function runSweepCycle() {
if (sweepInProgress) {
console.log('[Crucix] Sweep already in progress, skipping');
return;
}
sweepInProgress = true;
sweepStartedAt = new Date().toISOString();
broadcast({ type: 'sweep_start', timestamp: sweepStartedAt });
console.log(`\n${'='.repeat(60)}`);
console.log(`[Crucix] Starting sweep at ${new Date().toLocaleTimeString()}`);
console.log(`${'='.repeat(60)}`);
try {
// 1. Run the full briefing sweep
const rawData = await fullBriefing();
// 2. Save to runs/latest.json
writeFileSync(join(RUNS_DIR, 'latest.json'), JSON.stringify(rawData, null, 2));
lastSweepTime = new Date().toISOString();
// 3. Synthesize into dashboard format
console.log('[Crucix] Synthesizing dashboard data...');
const synthesized = await synthesize(rawData);
// 4. Delta computation + memory
const delta = memory.addRun(synthesized);
synthesized.delta = delta;
// 5. LLM-powered trade ideas (LLM-only feature) — isolated so failures don't kill sweep
if (llmProvider?.isConfigured) {
try {
console.log('[Crucix] Generating LLM trade ideas...');
const previousIdeas = memory.getLastRun()?.ideas || [];
const llmIdeas = await generateLLMIdeas(llmProvider, synthesized, delta, previousIdeas);
if (llmIdeas) {
synthesized.ideas = llmIdeas;
synthesized.ideasSource = 'llm';
console.log(`[Crucix] LLM generated ${llmIdeas.length} ideas`);
} else {
synthesized.ideas = [];
synthesized.ideasSource = 'llm-failed';
}
} catch (llmErr) {
console.error('[Crucix] LLM ideas failed (non-fatal):', llmErr.message);
synthesized.ideas = [];
synthesized.ideasSource = 'llm-failed';
}
} else {
synthesized.ideas = [];
synthesized.ideasSource = 'disabled';
}
// 6. Alert evaluation — Telegram + Discord (LLM with rule-based fallback, multi-tier, semantic dedup)
if (delta?.summary?.totalChanges > 0) {
if (telegramAlerter.isConfigured) {
telegramAlerter.evaluateAndAlert(llmProvider, delta, memory).catch(err => {
console.error('[Crucix] Telegram alert error:', err.message);
});
}
if (discordAlerter.isConfigured) {
discordAlerter.evaluateAndAlert(llmProvider, delta, memory).catch(err => {
console.error('[Crucix] Discord alert error:', err.message);
});
}
}
// Prune old alerted signals
memory.pruneAlertedSignals();
currentData = synthesized;
// 6. Push to all connected browsers
broadcast({ type: 'update', data: currentData });
console.log(`[Crucix] Sweep complete — ${currentData.meta.sourcesOk}/${currentData.meta.sourcesQueried} sources OK`);
console.log(`[Crucix] ${currentData.ideas.length} ideas (${synthesized.ideasSource}) | ${currentData.news.length} news | ${currentData.newsFeed.length} feed items`);
if (delta?.summary) console.log(`[Crucix] Delta: ${delta.summary.totalChanges} changes, ${delta.summary.criticalChanges} critical, direction: ${delta.summary.direction}`);
console.log(`[Crucix] Next sweep at ${new Date(Date.now() + config.refreshIntervalMinutes * 60000).toLocaleTimeString()}`);
} catch (err) {
console.error('[Crucix] Sweep failed:', err.message);
broadcast({ type: 'sweep_error', error: err.message });
} finally {
sweepInProgress = false;
}
}
// === Startup ===
async function start() {
const port = config.port;
console.log(`
╔══════════════════════════════════════════════╗
║ CRUCIX INTELLIGENCE ENGINE ║
║ Local Palantir · 26 Sources ║
╠══════════════════════════════════════════════╣
║ Dashboard: http://localhost:${port}${' '.repeat(14 - String(port).length)}║
║ Health: http://localhost:${port}/api/health${' '.repeat(4 - String(port).length)}║
║ Refresh: Every ${config.refreshIntervalMinutes} min${' '.repeat(20 - String(config.refreshIntervalMinutes).length)}║
║ LLM: ${(config.llm.provider || 'disabled').padEnd(31)}║
║ Telegram: ${config.telegram.botToken ? 'enabled' : 'disabled'}${' '.repeat(config.telegram.botToken ? 24 : 23)}║
║ Discord: ${config.discord?.botToken ? 'enabled' : config.discord?.webhookUrl ? 'webhook only' : 'disabled'}${' '.repeat(config.discord?.botToken ? 24 : config.discord?.webhookUrl ? 20 : 23)}║
╚══════════════════════════════════════════════╝
`);
const server = app.listen(port);
server.on('error', (err) => {
if (err.code === 'EADDRINUSE') {
console.error(`\n[Crucix] FATAL: Port ${port} is already in use!`);
console.error(`[Crucix] A previous Crucix instance may still be running.`);
console.error(`[Crucix] Fix: taskkill /F /IM node.exe (Windows)`);
console.error(`[Crucix] kill $(lsof -ti:${port}) (macOS/Linux)`);
console.error(`[Crucix] Or change PORT in .env\n`);
} else {
console.error(`[Crucix] Server error:`, err.stack || err.message);
}
process.exit(1);
});
server.on('listening', async () => {
console.log(`[Crucix] Server running on http://localhost:${port}`);
// Auto-open browser
// NOTE: On Windows, `start` in PowerShell is an alias for Start-Service, not cmd's start.
// We must use `cmd /c start ""` to ensure it works in both cmd.exe and PowerShell.
const openCmd = process.platform === 'win32' ? 'cmd /c start ""' :
process.platform === 'darwin' ? 'open' : 'xdg-open';
exec(`${openCmd} "http://localhost:${port}"`, (err) => {
if (err) console.log('[Crucix] Could not auto-open browser:', err.message);
});
// Try to load existing data first for instant display (await so dashboard shows immediately)
try {
const existing = JSON.parse(readFileSync(join(RUNS_DIR, 'latest.json'), 'utf8'));
const data = await synthesize(existing);
currentData = data;
console.log('[Crucix] Loaded existing data from runs/latest.json — dashboard ready instantly');
broadcast({ type: 'update', data: currentData });
} catch {
console.log('[Crucix] No existing data found — first sweep required');
}
// Run first sweep (refreshes data in background)
console.log('[Crucix] Running initial sweep...');
runSweepCycle().catch(err => {
console.error('[Crucix] Initial sweep failed:', err.message || err);
});
// Schedule recurring sweeps
setInterval(runSweepCycle, config.refreshIntervalMinutes * 60 * 1000);
});
}
// Graceful error handling — log full stack traces for diagnosis
process.on('unhandledRejection', (err) => {
console.error('[Crucix] Unhandled rejection:', err?.stack || err?.message || err);
});
process.on('uncaughtException', (err) => {
console.error('[Crucix] Uncaught exception:', err?.stack || err?.message || err);
});
start().catch(err => {
console.error('[Crucix] FATAL — Server failed to start:', err?.stack || err?.message || err);
process.exit(1);
});