feat: harden intelligence runtime and llm providers
This commit is contained in:
193
server.mjs
193
server.mjs
@@ -16,6 +16,8 @@ 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;
|
||||
@@ -30,6 +32,9 @@ for (const dir of [RUNS_DIR, MEMORY_DIR, join(MEMORY_DIR, 'cold')]) {
|
||||
// === 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();
|
||||
@@ -37,6 +42,8 @@ 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);
|
||||
@@ -44,6 +51,7 @@ 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');
|
||||
|
||||
@@ -84,6 +92,7 @@ if (telegramAlerter.isConfigured) {
|
||||
|
||||
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 || {};
|
||||
@@ -181,6 +190,7 @@ if (discordAlerter.isConfigured) {
|
||||
|
||||
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 || {};
|
||||
@@ -261,25 +271,34 @@ app.get('/api/data', (req, res) => {
|
||||
|
||||
// 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({
|
||||
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,
|
||||
generatedAt: new Date().toISOString(),
|
||||
fetch: getFetchMetrics(),
|
||||
sources: currentData?.sourceHealth || currentData?.health || [],
|
||||
news: currentData?.newsMeta || {},
|
||||
llm: getLLMStatus(),
|
||||
memory: intelligenceStore.status(),
|
||||
});
|
||||
});
|
||||
|
||||
app.post('/api/sweep', express.json(), (req, res) => {
|
||||
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 && token !== config.sweepToken) return res.status(401).json({ error: 'Invalid sweep token' });
|
||||
if (!config.sweepToken && !local) return res.status(403).json({ error: 'Manual sweep is local-only unless SWEEP_TOKEN is set' });
|
||||
if (sweepInProgress) return res.status(409).json({ status: 'already_running', sweepStartedAt });
|
||||
runSweepCycle().catch(err => console.error('[Crucix] API-triggered sweep failed:', err.message));
|
||||
res.status(202).json({ status: 'accepted' });
|
||||
});
|
||||
|
||||
// API: available locales
|
||||
app.get('/api/locales', (req, res) => {
|
||||
res.json({
|
||||
@@ -308,6 +327,101 @@ function broadcast(data) {
|
||||
}
|
||||
}
|
||||
|
||||
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 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),
|
||||
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) {
|
||||
@@ -323,6 +437,7 @@ async function runSweepCycle() {
|
||||
console.log(`${'='.repeat(60)}`);
|
||||
|
||||
try {
|
||||
lastSweepError = null;
|
||||
// 1. Run the full briefing sweep
|
||||
const rawData = await fullBriefing();
|
||||
|
||||
@@ -333,6 +448,18 @@ async function runSweepCycle() {
|
||||
// 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);
|
||||
@@ -380,6 +507,8 @@ async function runSweepCycle() {
|
||||
memory.pruneAlertedSignals();
|
||||
|
||||
currentData = synthesized;
|
||||
lastSuccessfulSweepTime = lastSweepTime;
|
||||
intelligenceStore.recordRun(currentData, delta);
|
||||
|
||||
// 6. Push to all connected browsers
|
||||
broadcast({ type: 'update', data: currentData });
|
||||
@@ -391,6 +520,7 @@ async function runSweepCycle() {
|
||||
|
||||
} catch (err) {
|
||||
console.error('[Crucix] Sweep failed:', err.message);
|
||||
lastSweepError = err.message;
|
||||
broadcast({ type: 'sweep_error', error: err.message });
|
||||
} finally {
|
||||
sweepInProgress = false;
|
||||
@@ -433,21 +563,36 @@ async function start() {
|
||||
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);
|
||||
});
|
||||
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;
|
||||
console.log('[Crucix] Loaded existing data from runs/latest.json — dashboard ready instantly');
|
||||
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');
|
||||
|
||||
Reference in New Issue
Block a user