679 lines
28 KiB
JavaScript
679 lines
28 KiB
JavaScript
#!/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';
|
|
import { getFetchMetrics } from './apis/utils/fetch.mjs';
|
|
import { IntelligenceStore } from './lib/intelligence-store.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 lastSuccessfulSweepTime = null;
|
|
let lastSweepError = null;
|
|
let bootstrapDataLoaded = false;
|
|
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);
|
|
const intelligenceStore = new IntelligenceStore(join(RUNS_DIR, 'intelligence.db'));
|
|
await intelligenceStore.init();
|
|
|
|
// === 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})`);
|
|
else if (config.llm.provider) console.warn(`[Crucix] LLM provider "${config.llm.provider}" is not configured; LLM features disabled`);
|
|
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.';
|
|
return buildBrief(currentData);
|
|
|
|
const tg = currentData.tg || {};
|
|
const energy = currentData.energy || {};
|
|
const metals = currentData.metals || {};
|
|
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 || metals.gold || metals.silver) {
|
|
sections.push(`📊 VIX: ${vix?.value || '--'} | WTI: $${energy.wti || '--'} | Brent: $${energy.brent || '--'}`);
|
|
sections.push(` Gold: $${metals.gold || '--'} | Silver: $${metals.silver || '--'}${hy ? ` | HY Spread: ${hy.value}` : ''}`);
|
|
sections.push(` 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.';
|
|
return buildBrief(currentData);
|
|
|
|
const tg = currentData.tg || {};
|
|
const energy = currentData.energy || {};
|
|
const metals = currentData.metals || {};
|
|
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 || metals.gold || metals.silver) {
|
|
sections.push(`📊 VIX: ${vix?.value || '--'} | WTI: $${energy.wti || '--'} | Brent: $${energy.brent || '--'}`);
|
|
sections.push(` Gold: $${metals.gold || '--'} | Silver: $${metals.silver || '--'}${hy ? ` | HY Spread: ${hy.value}` : ''}`);
|
|
sections.push(` 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) => {
|
|
const health = buildHealth();
|
|
const httpStatus = health.status === 'error' ? 500 : health.status === 'starting' ? 503 : 200;
|
|
res.status(httpStatus).json(health);
|
|
});
|
|
|
|
// API: network/source/LLM metrics
|
|
app.get('/api/metrics', (req, res) => {
|
|
res.json({
|
|
generatedAt: new Date().toISOString(),
|
|
fetch: getFetchMetrics(),
|
|
sources: currentData?.sourceHealth || currentData?.health || [],
|
|
news: currentData?.newsMeta || {},
|
|
llm: getLLMStatus(),
|
|
memory: intelligenceStore.status(),
|
|
});
|
|
});
|
|
|
|
app.get('/api/memory/search', (req, res) => {
|
|
if (!canRunTerminalAction(req)) return res.status(403).json({ error: 'Memory queries disabled or unauthorized' });
|
|
res.json(intelligenceStore.queryMemory({
|
|
q: req.query.q || '',
|
|
limit: req.query.limit || 25,
|
|
}));
|
|
});
|
|
|
|
app.get('/api/memory/predictions', (req, res) => {
|
|
if (!canRunTerminalAction(req)) return res.status(403).json({ error: 'Memory queries disabled or unauthorized' });
|
|
res.json(intelligenceStore.listPredictions({
|
|
state: req.query.state || null,
|
|
limit: req.query.limit || 25,
|
|
}));
|
|
});
|
|
|
|
app.post('/api/sweep', express.json(), (req, res) => {
|
|
if (!canRunTerminalAction(req)) return res.status(403).json({ error: 'Terminal actions disabled or unauthorized' });
|
|
triggerSweep(res);
|
|
});
|
|
|
|
app.post('/api/action', express.json(), async (req, res) => {
|
|
if (!canRunTerminalAction(req)) return res.status(403).json({ error: 'Terminal actions disabled or unauthorized' });
|
|
const action = String(req.body?.action || req.query.action || '').toLowerCase();
|
|
|
|
if (action === 'status') {
|
|
return res.json({ ok: true, action, health: buildHealth() });
|
|
}
|
|
|
|
if (action === 'brief') {
|
|
if (!currentData) return res.status(503).json({ ok: false, action, error: 'No data yet — first sweep in progress' });
|
|
return res.json({ ok: true, action, text: buildBrief(currentData) });
|
|
}
|
|
|
|
if (action === 'memory') {
|
|
return res.json({
|
|
ok: true,
|
|
action,
|
|
memory: intelligenceStore.status(),
|
|
recentEvents: intelligenceStore.queryMemory({ q: req.body?.q || '', limit: 8 }).results,
|
|
predictions: intelligenceStore.listPredictions({ limit: 8 }).predictions,
|
|
});
|
|
}
|
|
|
|
if (action === 'sweep') {
|
|
return triggerSweep(res);
|
|
}
|
|
|
|
res.status(400).json({ ok: false, error: 'Unknown action', actions: ['status', 'brief', 'memory', 'sweep'] });
|
|
});
|
|
|
|
// 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); }
|
|
}
|
|
}
|
|
|
|
function dataAgeMs() {
|
|
const ts = currentData?.meta?.timestamp || lastSuccessfulSweepTime || lastSweepTime;
|
|
const ms = ts ? Date.now() - new Date(ts).getTime() : null;
|
|
return Number.isFinite(ms) ? ms : null;
|
|
}
|
|
|
|
function canRunTerminalAction(req) {
|
|
const remote = req.ip || '';
|
|
const local = remote.includes('127.0.0.1') || remote === '::1' || remote === '::ffff:127.0.0.1';
|
|
const token = req.get('x-crucix-token') || req.query.token || req.body?.token;
|
|
if (config.sweepToken) return token === config.sweepToken;
|
|
return Boolean(config.terminalActionsEnabled || local);
|
|
}
|
|
|
|
function triggerSweep(res) {
|
|
if (sweepInProgress) return res.status(409).json({ ok: true, status: 'already_running', sweepStartedAt });
|
|
runSweepCycle().catch(err => console.error('[Crucix] API-triggered sweep failed:', err.message));
|
|
return res.status(202).json({ ok: true, status: 'accepted' });
|
|
}
|
|
|
|
function getLLMStatus() {
|
|
if (!config.llm.provider) return { state: 'disabled' };
|
|
if (!llmProvider) return { state: 'misconfigured', provider: config.llm.provider };
|
|
return typeof llmProvider.status === 'object'
|
|
? llmProvider.status
|
|
: { state: llmProvider.isConfigured ? 'configured' : 'misconfigured', provider: llmProvider.name, model: llmProvider.model };
|
|
}
|
|
|
|
function buildHealth() {
|
|
const ageMs = dataAgeMs();
|
|
const stale = ageMs != null && ageMs > config.staleDataMaxAgeMinutes * 60 * 1000;
|
|
const sourcesFailed = currentData?.meta?.sourcesFailed || 0;
|
|
const sourcesDegraded = currentData?.meta?.sourcesDegraded || 0;
|
|
const status = lastSweepError
|
|
? 'error'
|
|
: !currentData
|
|
? 'starting'
|
|
: stale
|
|
? 'stale'
|
|
: (sourcesFailed > 0 || sourcesDegraded > 0)
|
|
? 'degraded'
|
|
: 'healthy';
|
|
return {
|
|
status,
|
|
uptime: Math.floor((Date.now() - startTime) / 1000),
|
|
lastSweep: lastSweepTime,
|
|
lastSuccessfulSweep: lastSuccessfulSweepTime,
|
|
nextSweep: lastSweepTime
|
|
? new Date(new Date(lastSweepTime).getTime() + config.refreshIntervalMinutes * 60000).toISOString()
|
|
: null,
|
|
dataAgeSeconds: ageMs == null ? null : Math.floor(ageMs / 1000),
|
|
stale,
|
|
bootstrapDataLoaded,
|
|
sweepInProgress,
|
|
sweepStartedAt,
|
|
lastSweepError,
|
|
sourcesOk: currentData?.meta?.sourcesOk || 0,
|
|
sourcesDegraded,
|
|
sourcesFailed,
|
|
sourceHealth: currentData?.sourceHealth || currentData?.health || [],
|
|
llm: getLLMStatus(),
|
|
telegramEnabled: !!(config.telegram.botToken && config.telegram.chatId),
|
|
discordEnabled: !!(config.discord?.botToken || config.discord?.webhookUrl),
|
|
terminalActionsEnabled: Boolean(config.terminalActionsEnabled || config.sweepToken),
|
|
refreshIntervalMinutes: config.refreshIntervalMinutes,
|
|
language: currentLanguage,
|
|
memory: intelligenceStore.status(),
|
|
};
|
|
}
|
|
|
|
function buildBrief(data) {
|
|
const verbosity = config.telegram.briefVerbosity || 'standard';
|
|
const delta = memory.getLastDelta();
|
|
const sourceRows = (data.sourceHealth || data.health || []).slice(0, verbosity === 'audit' ? 12 : 6);
|
|
const degraded = sourceRows.filter(s => s.status && s.status !== 'ok');
|
|
const evidence = [
|
|
...(data.newsFeed || []).filter(n => n.url).slice(0, 4),
|
|
...(data.news || []).filter(n => n.url).slice(0, 4),
|
|
].slice(0, verbosity === 'compact' ? 3 : 6);
|
|
const ideas = (data.ideas || []).slice(0, verbosity === 'compact' ? 2 : 4);
|
|
const vix = data.fred?.find(f => f.id === 'VIXCLS');
|
|
const id = `evt-${Buffer.from(`${data.meta?.timestamp || Date.now()}-${delta?.summary?.direction || 'mixed'}`).toString('base64url').slice(0, 10)}`;
|
|
|
|
const lines = [
|
|
'*CRUCIX BRIEF*',
|
|
`_${new Date().toISOString().replace('T', ' ').substring(0, 19)} UTC_`,
|
|
`Event ID: \`${id}\``,
|
|
'',
|
|
`Direction: *${(delta?.summary?.direction || 'mixed').toUpperCase()}* | Changes: ${delta?.summary?.totalChanges || 0} | Critical: ${delta?.summary?.criticalChanges || 0}`,
|
|
`VIX: ${vix?.value || '--'} | WTI: $${data.energy?.wti || '--'} | Gold: $${data.metals?.gold || '--'} | Sources: ${data.meta?.sourcesOk || 0}/${data.meta?.sourcesQueried || 0} OK`,
|
|
'',
|
|
'*Source Integrity*',
|
|
degraded.length
|
|
? degraded.map(s => `- ${s.name || s.n}: ${s.status || 'degraded'}${s.error || s.message ? ` (${String(s.error || s.message).slice(0, 80)})` : ''}`).join('\n')
|
|
: '- Strong: no degraded source in the sampled health set',
|
|
'',
|
|
'*Top Evidence*',
|
|
evidence.length
|
|
? evidence.map((n, idx) => `${idx + 1}. ${n.source || 'source'}: ${n.headline || n.title || 'link'}\n${n.url}`).join('\n')
|
|
: '- No direct links available in the current sweep',
|
|
];
|
|
|
|
if (ideas.length) {
|
|
lines.push('', '*Why This Matters*');
|
|
for (const idea of ideas) lines.push(`- ${idea.title}: ${(idea.rationale || idea.text || '').slice(0, 140)}`);
|
|
}
|
|
lines.push('', '*What To Do Next*', '- Open the dashboard, verify the evidence links, and compare source health before acting.');
|
|
return lines.join('\n');
|
|
}
|
|
|
|
// === 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 {
|
|
lastSweepError = null;
|
|
// 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);
|
|
synthesized.meta = {
|
|
...synthesized.meta,
|
|
generatedAt: new Date().toISOString(),
|
|
stale: false,
|
|
bootstrap: false,
|
|
};
|
|
synthesized.sourceHealth = Object.entries(rawData.timing || {}).map(([name, info]) => ({
|
|
name,
|
|
status: info.status || 'unknown',
|
|
ms: info.ms || 0,
|
|
error: info.error || rawData.errors?.find(e => e.name === name)?.error || null,
|
|
}));
|
|
|
|
// 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;
|
|
lastSuccessfulSweepTime = lastSweepTime;
|
|
intelligenceStore.recordRun(currentData, delta);
|
|
|
|
// 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);
|
|
lastSweepError = 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}`);
|
|
|
|
if (config.autoOpenBrowser) {
|
|
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);
|
|
});
|
|
} else {
|
|
console.log('[Crucix] Auto-open browser disabled (AUTO_OPEN_BROWSER=false)');
|
|
}
|
|
|
|
// 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);
|
|
data.meta = {
|
|
...data.meta,
|
|
generatedAt: new Date().toISOString(),
|
|
stale: true,
|
|
bootstrap: true,
|
|
bootstrapReason: 'Loaded from runs/latest.json while initial sweep starts',
|
|
};
|
|
data.sourceHealth = Object.entries(existing.timing || {}).map(([name, info]) => ({
|
|
name,
|
|
status: info.status || 'bootstrap',
|
|
ms: info.ms || 0,
|
|
error: info.error || null,
|
|
}));
|
|
currentData = data;
|
|
bootstrapDataLoaded = true;
|
|
console.log('[Crucix] Loaded existing data from runs/latest.json as stale bootstrap; initial sweep will refresh it');
|
|
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);
|
|
});
|