Files
intelligence-terminal/dashboard/inject.mjs
MrSphay 900f43ba13
All checks were successful
Codex Template Compliance / template-compliance (pull_request) Successful in 5s
Build / test-and-image (pull_request) Successful in 1m4s
fix: make news geotagging deterministic
2026-05-17 18:54:35 +02:00

810 lines
34 KiB
JavaScript

#!/usr/bin/env node
// Crucix Dashboard Data Synthesizer
// Reads runs/latest.json, fetches RSS news, generates signal-based ideas,
// and injects everything into dashboard/public/jarvis.html
//
// Exports synthesize(), generateIdeas(), fetchAllNews() for use by server.mjs
import { existsSync, readFileSync, readdirSync, writeFileSync } from 'fs';
import { dirname, join } from 'path';
import { fileURLToPath } from 'url';
import { exec } from 'child_process';
import config from '../crucix.config.mjs';
import { createLLMProvider } from '../lib/llm/index.mjs';
import { generateLLMIdeas } from '../lib/llm/ideas.mjs';
import { safeFetchText } from '../apis/utils/fetch.mjs';
const __dirname = dirname(fileURLToPath(import.meta.url));
const ROOT = join(__dirname, '..');
// === Helpers ===
const cyrillic = /[\u0400-\u04FF]/;
function isEnglish(text) {
if (!text) return false;
return !cyrillic.test(text.substring(0, 80));
}
// === Geo-tagging keyword map ===
const geoKeywords = {
'Ukraine':[49,32],'Russia':[56,38],'Moscow':[55.7,37.6],'Kyiv':[50.4,30.5],
'China':[35,105],'Beijing':[39.9,116.4],'Iran':[32,53],'Tehran':[35.7,51.4],
'Israel':[31.5,35],'Gaza':[31.4,34.4],'Palestine':[31.9,35.2],
'Syria':[35,38],'Iraq':[33,44],'Saudi':[24,45],'Yemen':[15,48],'Lebanon':[34,36],
'India':[20,78],'Japan':[36,138],'Korea':[37,127],'Pyongyang':[39,125.7],
'Taiwan':[23.5,121],'Philippines':[13,122],'Myanmar':[20,96],
'Canada':[56,-96],'Mexico':[23,-102],'Brazil':[-14,-51],'Argentina':[-38,-63],
'Colombia':[4,-74],'Venezuela':[7,-66],'Cuba':[22,-80],'Chile':[-35,-71],
'Germany':[51,10],'France':[46,2],'UK':[54,-2],'Britain':[54,-2],'London':[51.5,-0.1],
'Spain':[40,-4],'Italy':[42,12],'Poland':[52,20],'NATO':[50,4],'EU':[50,4],
'Turkey':[39,35],'Greece':[39,22],'Romania':[46,25],'Finland':[64,26],'Sweden':[62,15],
'Africa':[0,20],'Nigeria':[10,8],'South Africa':[-30,25],'Kenya':[-1,38],
'Egypt':[27,30],'Libya':[27,17],'Sudan':[13,30],'Ethiopia':[9,38],
'Somalia':[5,46],'Congo':[-4,22],'Uganda':[1,32],'Morocco':[32,-6],
'Pakistan':[30,70],'Afghanistan':[33,65],'Bangladesh':[24,90],
'Australia':[-25,134],'Indonesia':[-2,118],'Thailand':[15,100],
'US':[39,-98],'America':[39,-98],'Washington':[38.9,-77],'Pentagon':[38.9,-77],
'Trump':[38.9,-77],'White House':[38.9,-77],
'Wall Street':[40.7,-74],'New York':[40.7,-74],'California':[37,-120],
'Nepal':[28,84],'Cambodia':[12.5,105],'Malawi':[-13.5,34],'Burundi':[-3.4,29.9],
'Oman':[21,57],'Netherlands':[52.1,5.3],'Gabon':[-0.8,11.6],
'Peru':[-10,-76],'Ecuador':[-2,-78],'Bolivia':[-17,-65],
'Singapore':[1.35,103.8],'Malaysia':[4.2,101.9],'Vietnam':[16,108],
'Algeria':[28,3],'Tunisia':[34,9],'Zimbabwe':[-20,30],'Mozambique':[-18,35],
// Americas expansion
'Texas':[31,-100],'Florida':[28,-82],'Chicago':[41.9,-87.6],'Los Angeles':[34,-118],
'San Francisco':[37.8,-122.4],'Seattle':[47.6,-122.3],'Miami':[25.8,-80.2],
'Toronto':[43.7,-79.4],'Ottawa':[45.4,-75.7],'Vancouver':[49.3,-123.1],
'São Paulo':[-23.5,-46.6],'Rio':[-22.9,-43.2],'Buenos Aires':[-34.6,-58.4],
'Bogotá':[4.7,-74.1],'Lima':[-12,-77],'Santiago':[-33.4,-70.7],
'Caracas':[10.5,-66.9],'Havana':[23.1,-82.4],'Panama':[9,-79.5],
'Guatemala':[14.6,-90.5],'Honduras':[14.1,-87.2],'El Salvador':[13.7,-89.2],
'Costa Rica':[10,-84],'Jamaica':[18.1,-77.3],'Haiti':[19,-72],
'Dominican':[18.5,-70],'Puerto Rico':[18.2,-66.5],
// More Asia-Pacific
'Sri Lanka':[7,80],'Hong Kong':[22.3,114.2],'Taipei':[25,121.5],
'Seoul':[37.6,127],'Osaka':[34.7,135.5],'Mumbai':[19.1,72.9],
'Delhi':[28.6,77.2],'Shanghai':[31.2,121.5],'Shenzhen':[22.5,114.1],
'Auckland':[-36.8,174.8],'Papua New Guinea':[-6.3,147],
// More Europe
'Berlin':[52.5,13.4],'Paris':[48.9,2.3],'Madrid':[40.4,-3.7],
'Rome':[41.9,12.5],'Warsaw':[52.2,21],'Prague':[50.1,14.4],
'Vienna':[48.2,16.4],'Budapest':[47.5,19.1],'Bucharest':[44.4,26.1],
'Kyiv':[50.4,30.5],'Oslo':[59.9,10.7],'Copenhagen':[55.7,12.6],
'Brussels':[50.8,4.4],'Zurich':[47.4,8.5],'Dublin':[53.3,-6.3],
'Lisbon':[38.7,-9.1],'Athens':[37.9,23.7],'Minsk':[53.9,27.6],
// More Africa
'Nairobi':[-1.3,36.8],'Lagos':[6.5,3.4],'Accra':[5.6,-0.2],
'Addis Ababa':[9,38.7],'Cape Town':[-33.9,18.4],'Johannesburg':[-26.2,28],
'Kinshasa':[-4.3,15.3],'Khartoum':[15.6,32.5],'Mogadishu':[2.1,45.3],
'Dakar':[14.7,-17.5],'Abuja':[9.1,7.5],
// Tech/Economy keywords with US locations
'Fed':[38.9,-77],'Congress':[38.9,-77],'Senate':[38.9,-77],
'Silicon Valley':[37.4,-122],'NASA':[28.6,-80.6],'Pentagon':[38.9,-77],
'IMF':[38.9,-77],'World Bank':[38.9,-77],'UN':[40.7,-74],
};
function escapeRegex(value) {
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
function geoKeywordRegex(keyword) {
const flags = keyword.length <= 3 && keyword === keyword.toUpperCase() ? 'u' : 'iu';
return new RegExp(`(^|[^\\p{L}\\p{N}])${escapeRegex(keyword)}(?=$|[^\\p{L}\\p{N}])`, flags);
}
const geoKeywordEntries = Object.entries(geoKeywords)
.sort((a, b) => b[0].length - a[0].length)
.map(([keyword, coords]) => ({ keyword, coords, pattern: geoKeywordRegex(keyword) }));
export function geoTagText(text) {
if (!text) return null;
for (const { keyword, coords, pattern } of geoKeywordEntries) {
if (pattern.test(text)) {
const [lat, lon] = coords;
return { lat, lon, region: keyword };
}
}
return null;
}
function stableHash(value) {
let hash = 2166136261;
for (let i = 0; i < value.length; i++) {
hash ^= value.charCodeAt(i);
hash = Math.imul(hash, 16777619);
}
return hash >>> 0;
}
export function stableGeoJitter(key, axis) {
const bucket = stableHash(`${axis}:${key}`) / 0xffffffff;
return (bucket - 0.5) * 2;
}
function newsGeoKey(item) {
return `${item.source || ''}|${item.title || ''}|${item.date || ''}|${item.url || ''}`;
}
function sanitizeExternalUrl(raw) {
if (!raw) return undefined;
try {
const url = new URL(raw);
return url.protocol === 'http:' || url.protocol === 'https:' ? url.toString() : undefined;
} catch {
return undefined;
}
}
function sumAirHotspots(hotspots = []) {
return hotspots.reduce((sum, hotspot) => sum + (hotspot.totalAircraft || 0), 0);
}
function summarizeAirHotspots(hotspots = []) {
return hotspots.map(h => ({
region: h.region,
total: h.totalAircraft || 0,
noCallsign: h.noCallsign || 0,
highAlt: h.highAltitude || 0,
top: Object.entries(h.byCountry || {}).sort((a, b) => b[1] - a[1]).slice(0, 5),
}));
}
function loadOpenSkyFallback(currentTimestamp) {
const runsDir = join(ROOT, 'runs');
if (!existsSync(runsDir)) return null;
const currentMs = currentTimestamp ? new Date(currentTimestamp).getTime() : NaN;
const files = readdirSync(runsDir)
.filter(name => /^briefing_.*\.json$/.test(name))
.sort()
.reverse();
for (const file of files) {
const filePath = join(runsDir, file);
try {
const prior = JSON.parse(readFileSync(filePath, 'utf8'));
const priorTimestamp = prior.sources?.OpenSky?.timestamp || prior.crucix?.timestamp || null;
if (priorTimestamp && Number.isFinite(currentMs) && new Date(priorTimestamp).getTime() >= currentMs) continue;
const hotspots = prior.sources?.OpenSky?.hotspots || [];
if (sumAirHotspots(hotspots) > 0) {
return { file, timestamp: priorTimestamp, hotspots };
}
} catch {
// Ignore unreadable historical runs and continue searching backward.
}
}
return null;
}
// === RSS Fetching ===
const rssHealth = {};
async function fetchRSS(url, source) {
const started = Date.now();
try {
const fetched = await safeFetchText(url, { signal: AbortSignal.timeout(8000), source: `RSS:${source}`, timeout: 12000, retries: 1 });
if (fetched.error) throw new Error(fetched.error);
const xml = fetched.text;
const items = [];
const itemRegex = /<item>([\s\S]*?)<\/item>/g;
let match;
while ((match = itemRegex.exec(xml)) !== null) {
const block = match[1];
const title = (block.match(/<title>(?:<!\[CDATA\[)?(.*?)(?:\]\]>)?<\/title>/)?.[1] || '').trim();
const link = sanitizeExternalUrl((block.match(/<link>(?:<!\[CDATA\[)?(.*?)(?:\]\]>)?<\/link>/)?.[1] || '').trim());
const pubDate = block.match(/<pubDate>(.*?)<\/pubDate>/)?.[1] || '';
if (title && title !== source) items.push({ title, date: pubDate, source, url: link || undefined });
}
rssHealth[source] = { status: items.length ? 'ok' : 'degraded', items: items.length, durationMs: Date.now() - started, url };
return items;
} catch (e) {
console.log(`RSS fetch failed (${source}):`, e.message);
rssHealth[source] = { status: 'failed', items: 0, durationMs: Date.now() - started, url, error: e.message };
return [];
}
}
const RSS_SOURCE_FALLBACKS = {
'SBS Australia': { lat: -35.2809, lon: 149.13, region: 'Australia' },
'Indian Express': { lat: 28.6139, lon: 77.209, region: 'India' },
'The Hindu': { lat: 13.0827, lon: 80.2707, region: 'India' },
'MercoPress': { lat: -34.9011, lon: -56.1645, region: 'South America' }
};
const REGIONAL_NEWS_SOURCES = ['MercoPress', 'Indian Express', 'The Hindu', 'SBS Australia'];
export async function fetchAllNews() {
for (const key of Object.keys(rssHealth)) delete rssHealth[key];
const feeds = [
// Global
['http://feeds.bbci.co.uk/news/world/rss.xml', 'BBC'],
['https://rss.nytimes.com/services/xml/rss/nyt/World.xml', 'NYT'],
['https://www.aljazeera.com/xml/rss/all.xml', 'Al Jazeera'],
// USA
['https://feeds.npr.org/1001/rss.xml', 'NPR'],
['https://feeds.bbci.co.uk/news/technology/rss.xml', 'BBC Tech'],
['http://feeds.bbci.co.uk/news/science_and_environment/rss.xml', 'BBC Science'],
['https://rss.nytimes.com/services/xml/rss/nyt/Americas.xml', 'NYT Americas'],
// Europe
['https://rss.dw.com/rdf/rss-en-all', 'DW'],
['https://www.france24.com/en/rss', 'France 24'],
['https://www.euronews.com/rss?format=mrss', 'Euronews'],
// Africa & Cameroon region
['https://rss.dw.com/rdf/rss-en-africa', 'DW Africa'],
['https://www.rfi.fr/en/rss', 'RFI'],
['https://www.africanews.com/feed/rss', 'Africa News'],
['https://rss.nytimes.com/services/xml/rss/nyt/Africa.xml', 'NYT Africa'],
// Asia-Pacific
['https://rss.nytimes.com/services/xml/rss/nyt/AsiaPacific.xml', 'NYT Asia'],
['https://www.sbs.com.au/news/topic/australia/feed', 'SBS Australia'],
// India
['https://indianexpress.com/section/india/feed/', 'Indian Express'],
['https://www.thehindu.com/news/national/feeder/default.rss', 'The Hindu'],
// South America
['https://en.mercopress.com/rss/latin-america', 'MercoPress'],
];
const results = await Promise.allSettled(
feeds.map(([url, source]) => fetchRSS(url, source))
);
const allNews = results
.filter(r => r.status === 'fulfilled')
.flatMap(r => r.value);
// De-duplicate and geo-tag
const seen = new Set();
const geoNews = [];
for (const item of allNews) {
const key = item.title.substring(0, 40).toLowerCase();
if (seen.has(key)) continue;
seen.add(key);
const geo = geoTagText(item.title) || RSS_SOURCE_FALLBACKS[item.source];
if (geo) {
geoNews.push({
title: item.title.substring(0, 100),
source: item.source,
date: item.date,
url: item.url,
lat: geo.lat + stableGeoJitter(newsGeoKey(item), 'lat'),
lon: geo.lon + stableGeoJitter(newsGeoKey(item), 'lon'),
region: geo.region
});
}
}
const cutoff = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000);
const filtered = geoNews.filter(n => !n.date || new Date(n.date) >= cutoff);
filtered.sort((a, b) => new Date(b.date || 0) - new Date(a.date || 0));
const selected = [];
const selectedKeys = new Set();
const keyFor = item => `${item.source}|${item.title}|${item.date}`;
const pushUnique = item => {
const key = keyFor(item);
if (selectedKeys.has(key)) return;
selected.push(item);
selectedKeys.add(key);
};
// Reserve a little space so newly-added regional feeds are not crowded out by larger globals.
for (const source of REGIONAL_NEWS_SOURCES) {
filtered.filter(item => item.source === source).slice(0, 2).forEach(pushUnique);
}
filtered.forEach(pushUnique);
return selected.slice(0, 50);
}
export function getRSSHealth() {
return { ...rssHealth };
}
// === Leverageable Ideas from Signals ===
export function generateIdeas(V2) {
const ideas = [];
const vix = V2.fred.find(f => f.id === 'VIXCLS');
const hy = V2.fred.find(f => f.id === 'BAMLH0A0HYM2');
const spread = V2.fred.find(f => f.id === 'T10Y2Y');
if (V2.tg.urgent.length > 3 && V2.energy.wti > 68) {
ideas.push({
title: 'Conflict-Energy Nexus Active',
text: `${V2.tg.urgent.length} urgent conflict signals with WTI at $${V2.energy.wti}. Geopolitical risk premium may expand. Consider energy exposure.`,
type: 'long', confidence: 'Medium', horizon: 'swing'
});
}
if (vix && vix.value > 20) {
ideas.push({
title: 'Elevated Volatility Regime',
text: `VIX at ${vix.value} — fear premium elevated. Portfolio hedges justified. Short-term equity upside is capped.`,
type: 'hedge', confidence: vix.value > 25 ? 'High' : 'Medium', horizon: 'tactical'
});
}
if (vix && vix.value > 20 && hy && hy.value > 3) {
ideas.push({
title: 'Safe Haven Demand Rising',
text: `VIX ${vix.value} + HY spread ${hy.value}% = risk-off building. Gold, treasuries, quality dividends may outperform.`,
type: 'hedge', confidence: 'Medium', horizon: 'tactical'
});
}
if (V2.energy.wtiRecent.length > 1) {
const latest = V2.energy.wtiRecent[0];
const oldest = V2.energy.wtiRecent[V2.energy.wtiRecent.length - 1];
const pct = ((latest - oldest) / oldest * 100).toFixed(1);
if (Math.abs(pct) > 3) {
ideas.push({
title: pct > 0 ? 'Oil Momentum Building' : 'Oil Under Pressure',
text: `WTI moved ${pct > 0 ? '+' : ''}${pct}% recently to $${V2.energy.wti}/bbl. ${pct > 0 ? 'Energy and commodity names benefit.' : 'Demand concerns may be emerging.'}`,
type: pct > 0 ? 'long' : 'watch', confidence: 'Medium', horizon: 'swing'
});
}
}
if (spread) {
ideas.push({
title: spread.value > 0 ? 'Yield Curve Normalizing' : 'Yield Curve Inverted',
text: `10Y-2Y spread at ${spread.value.toFixed(2)}. ${spread.value > 0 ? 'Recession signal fading — cyclical rotation possible.' : 'Inversion persists — defensive positioning warranted.'}`,
type: 'watch', confidence: 'Medium', horizon: 'strategic'
});
}
const debt = parseFloat(V2.treasury.totalDebt);
if (debt > 35e12) {
ideas.push({
title: 'Fiscal Trajectory Supports Hard Assets',
text: `National debt at $${(debt / 1e12).toFixed(1)}T. Long-term gold, bitcoin, and real asset appreciation thesis intact.`,
type: 'long', confidence: 'High', horizon: 'strategic'
});
}
const totalThermal = V2.thermal.reduce((s, t) => s + t.det, 0);
if (totalThermal > 30000 && V2.tg.urgent.length > 2) {
ideas.push({
title: 'Satellite Confirms Conflict Intensity',
text: `${totalThermal.toLocaleString()} thermal detections + ${V2.tg.urgent.length} urgent OSINT flags. Defense sector procurement may accelerate.`,
type: 'watch', confidence: 'Medium', horizon: 'swing'
});
}
// Yield Curve + Labor Interaction
const unemployment = V2.bls.find(b => b.id === 'LNS14000000' || b.id === 'UNRATE');
const payrolls = V2.bls.find(b => b.id === 'CES0000000001' || b.id === 'PAYEMS');
if (spread && unemployment && payrolls) {
const weakLabor = (unemployment.value > 4.3) || (payrolls.momChange && payrolls.momChange < -50);
if (spread.value > 0.3 && weakLabor) {
ideas.push({
title: 'Steepening Curve Meets Weak Labor',
text: `10Y-2Y at ${spread.value.toFixed(2)} + UE ${unemployment.value}%. Curve steepening with deteriorating employment = recession positioning warranted.`,
type: 'hedge', confidence: 'High', horizon: 'tactical'
});
}
}
// ACLED Conflict + Energy Momentum
const conflictEvents = V2.acled?.totalEvents || 0;
if (conflictEvents > 50 && V2.energy.wtiRecent.length > 1) {
const wtiMove = V2.energy.wtiRecent[0] - V2.energy.wtiRecent[V2.energy.wtiRecent.length - 1];
if (wtiMove > 2) {
ideas.push({
title: 'Conflict Fueling Energy Momentum',
text: `${conflictEvents} ACLED events this week + WTI up $${wtiMove.toFixed(1)}. Conflict-energy transmission channel active.`,
type: 'long', confidence: 'Medium', horizon: 'swing'
});
}
}
// Defense + Conflict Intensity
const totalFatalities = V2.acled?.totalFatalities || 0;
const totalThermalAll = V2.thermal.reduce((s, t) => s + t.det, 0);
if (totalFatalities > 500 && totalThermalAll > 20000) {
ideas.push({
title: 'Defense Procurement Acceleration Signal',
text: `${totalFatalities.toLocaleString()} conflict fatalities + ${totalThermalAll.toLocaleString()} thermal detections. Defense contractors may see accelerated procurement.`,
type: 'long', confidence: 'Medium', horizon: 'swing'
});
}
// HY Spread + VIX Divergence
if (hy && vix) {
const hyWide = hy.value > 3.5;
const vixLow = vix.value < 18;
const hyTight = hy.value < 2.5;
const vixHigh = vix.value > 25;
if (hyWide && vixLow) {
ideas.push({
title: 'Credit Stress Ignored by Equity Vol',
text: `HY spread ${hy.value.toFixed(1)}% (wide) but VIX only ${vix.value.toFixed(0)} (complacent). Equity may be underpricing credit deterioration.`,
type: 'watch', confidence: 'Medium', horizon: 'tactical'
});
} else if (hyTight && vixHigh) {
ideas.push({
title: 'Equity Fear Exceeds Credit Stress',
text: `VIX at ${vix.value.toFixed(0)} but HY spread only ${hy.value.toFixed(1)}%. Equity vol may be overshooting — credit markets aren't confirming.`,
type: 'watch', confidence: 'Medium', horizon: 'tactical'
});
}
}
// Supply Chain + Inflation Pipeline
const ppi = V2.bls.find(b => b.id === 'WPUFD49104' || b.id === 'PCU--PCU--');
const cpi = V2.bls.find(b => b.id === 'CUUR0000SA0' || b.id === 'CPIAUCSL');
if (ppi && cpi && V2.gscpi) {
const supplyPressure = V2.gscpi.value > 0.5;
const ppiRising = ppi.momChangePct > 0.3;
if (supplyPressure && ppiRising) {
ideas.push({
title: 'Inflation Pipeline Building Pressure',
text: `GSCPI at ${V2.gscpi.value.toFixed(2)} (${V2.gscpi.interpretation}) + PPI momentum +${ppi.momChangePct?.toFixed(1)}%. Input costs flowing through — CPI may follow.`,
type: 'long', confidence: 'Medium', horizon: 'strategic'
});
}
}
return ideas.slice(0, 8);
}
// === Synthesize raw sweep data into dashboard format ===
export async function synthesize(data) {
const liveAirHotspots = data.sources.OpenSky?.hotspots || [];
const airFallback = sumAirHotspots(liveAirHotspots) > 0
? null
: loadOpenSkyFallback(data.sources.OpenSky?.timestamp || data.crucix?.timestamp);
const effectiveAirHotspots = airFallback?.hotspots || liveAirHotspots;
const air = summarizeAirHotspots(effectiveAirHotspots);
const thermal = (data.sources.FIRMS?.hotspots || []).map(h => ({
region: h.region, det: h.totalDetections || 0, night: h.nightDetections || 0,
hc: h.highConfidence || 0,
fires: (h.highIntensity || []).slice(0, 8).map(f => ({ lat: f.lat, lon: f.lon, frp: f.frp || 0 }))
}));
const tSignals = data.sources.FIRMS?.signals || [];
const chokepoints = Object.values(data.sources.Maritime?.chokepoints || {}).map(c => ({
label: c.label || c.name, note: c.note || '', lat: c.lat || 0, lon: c.lon || 0
}));
const nuke = (data.sources.Safecast?.sites || []).map(s => ({
site: s.site, anom: s.anomaly || false, cpm: s.avgCPM, n: s.recentReadings || 0
}));
const nukeSignals = (data.sources.Safecast?.signals || []).filter(s => s);
const sdrData = data.sources.KiwiSDR || {};
const sdrNet = sdrData.network || {};
const sdrConflict = sdrData.conflictZones || {};
const sdrZones = Object.values(sdrConflict).map(z => ({
region: z.region, count: z.count || 0,
receivers: (z.receivers || []).slice(0, 5).map(r => ({ name: r.name || '', lat: r.lat || 0, lon: r.lon || 0 }))
}));
const tgData = data.sources.Telegram || {};
const tgUrgent = (tgData.urgentPosts || []).filter(p => isEnglish(p.text)).map(p => ({
channel: p.channel, text: p.text?.substring(0, 200), views: p.views, date: p.date, urgentFlags: p.urgentFlags || []
}));
const tgTop = (tgData.topPosts || []).filter(p => isEnglish(p.text)).map(p => ({
channel: p.channel, text: p.text?.substring(0, 200), views: p.views, date: p.date, urgentFlags: []
}));
const who = (data.sources.WHO?.diseaseOutbreakNews || []).slice(0, 10).map(w => ({
title: w.title?.substring(0, 120), date: w.date, summary: w.summary?.substring(0, 150)
}));
const fred = (data.sources.FRED?.indicators || []).map(f => ({
id: f.id, label: f.label, value: f.value, date: f.date,
recent: f.recent || [],
momChange: f.momChange, momChangePct: f.momChangePct
}));
const energyData = data.sources.EIA || {};
const oilPrices = energyData.oilPrices || {};
const wtiRecent = (oilPrices.wti?.recent || []).map(d => d.value);
const energy = {
wti: oilPrices.wti?.value, brent: oilPrices.brent?.value,
natgas: energyData.gasPrice?.value, crudeStocks: energyData.inventories?.crudeStocks?.value,
wtiRecent, signals: energyData.signals || []
};
const bls = data.sources.BLS?.indicators || [];
const treasuryData = data.sources.Treasury || {};
const debtArr = treasuryData.debt || [];
const treasury = { totalDebt: debtArr[0]?.totalDebt || '0', signals: treasuryData.signals || [] };
const gscpi = data.sources.GSCPI?.latest || null;
const defense = (data.sources.USAspending?.recentDefenseContracts || []).slice(0, 5).map(c => ({
recipient: c.recipient?.substring(0, 40), amount: c.amount, desc: c.description?.substring(0, 80)
}));
const noaa = {
totalAlerts: data.sources.NOAA?.totalSevereAlerts || 0,
alerts: (data.sources.NOAA?.topAlerts || []).filter(a => a.lat != null && a.lon != null).slice(0, 10).map(a => ({
event: a.event, severity: a.severity, headline: a.headline?.substring(0, 120),
lat: a.lat, lon: a.lon
}))
};
// EPA RadNet — pass through geo-tagged readings
const epaData = data.sources.EPA || {};
const epaStations = [];
const seenEpa = new Set();
for (const r of (epaData.readings || [])) {
if (r.lat == null || r.lon == null) continue;
const key = `${r.lat},${r.lon}`;
if (seenEpa.has(key)) continue;
seenEpa.add(key);
epaStations.push({ location: r.location, state: r.state, lat: r.lat, lon: r.lon, analyte: r.analyte, result: r.result, unit: r.unit });
}
const epa = { totalReadings: epaData.totalReadings || 0, stations: epaStations.slice(0, 10) };
// Space/CelesTrak satellite data
const spaceData = data.sources.Space || {};
// Approximate subsatellite position from TLE orbital elements
function estimateSatPosition(sat) {
if (!sat?.inclination || !sat?.epoch) return null;
const epoch = new Date(sat.epoch);
const now = new Date();
const elapsed = (now - epoch) / 1000;
const period = (sat.period || 92.7) * 60; // minutes to seconds
const orbits = elapsed / period;
const frac = orbits % 1;
const lat = sat.inclination * Math.sin(frac * 2 * Math.PI);
const lonShift = (elapsed / 86400) * 360;
const orbitLon = frac * 360;
const lon = ((orbitLon - lonShift) % 360 + 540) % 360 - 180;
return { lat: +lat.toFixed(2), lon: +lon.toFixed(2), name: sat.name };
}
const issPos = estimateSatPosition(spaceData.iss);
const spaceStations = (spaceData.spaceStations || []).map(s => estimateSatPosition(s)).filter(Boolean);
const space = {
totalNewObjects: spaceData.totalNewObjects || 0,
militarySats: spaceData.militarySatellites || 0,
militaryByCountry: spaceData.militaryByCountry || {},
constellations: spaceData.constellations || {},
iss: spaceData.iss || null,
issPosition: issPos,
stationPositions: spaceStations.slice(0, 5),
recentLaunches: (spaceData.recentLaunches || []).slice(0, 10).map(l => ({
name: l.name, country: l.country, epoch: l.epoch,
apogee: l.apogee, perigee: l.perigee, type: l.objectType
})),
launchByCountry: spaceData.launchByCountry || {},
signals: spaceData.signals || [],
};
// ACLED conflict events
const acledData = data.sources.ACLED || {};
const acled = acledData.error ? { totalEvents: 0, totalFatalities: 0, byRegion: {}, byType: {}, deadliestEvents: [] } : {
totalEvents: acledData.totalEvents || 0,
totalFatalities: acledData.totalFatalities || 0,
byRegion: acledData.byRegion || {},
byType: acledData.byType || {},
deadliestEvents: (acledData.deadliestEvents || []).slice(0, 15).map(e => ({
date: e.date, type: e.type, country: e.country, location: e.location,
fatalities: e.fatalities || 0, lat: e.lat || null, lon: e.lon || null
}))
};
// GDELT news articles + geo events
const gdeltData = data.sources.GDELT || {};
const gdelt = {
totalArticles: gdeltData.totalArticles || 0,
conflicts: (gdeltData.conflicts || []).length,
economy: (gdeltData.economy || []).length,
health: (gdeltData.health || []).length,
crisis: (gdeltData.crisis || []).length,
topTitles: (gdeltData.allArticles || []).slice(0, 5).map(a => a.title?.substring(0, 80)),
geoPoints: (gdeltData.geoPoints || []).slice(0, 20).map(p => ({
lat: p.lat, lon: p.lon, name: (p.name || '').substring(0, 80), count: p.count || 1
}))
};
const health = Object.entries(data.sources).map(([name, src]) => ({
n: name,
status: data.timing?.[name]?.status || (src.error ? 'degraded' : 'ok'),
err: Boolean(src.error || data.timing?.[name]?.status === 'error'),
stale: Boolean(src.stale),
message: src.error || src.message || data.timing?.[name]?.error || null,
ms: data.timing?.[name]?.ms || 0,
}));
// === Yahoo Finance live market data ===
const yfData = data.sources.YFinance || {};
const yfQuotes = yfData.quotes || {};
const markets = {
indexes: (yfData.indexes || []).map(q => ({
symbol: q.symbol, name: q.name, price: q.price,
change: q.change, changePct: q.changePct, history: q.history || []
})),
rates: (yfData.rates || []).map(q => ({
symbol: q.symbol, name: q.name, price: q.price,
change: q.change, changePct: q.changePct
})),
commodities: (yfData.commodities || []).map(q => ({
symbol: q.symbol, name: q.name, price: q.price,
change: q.change, changePct: q.changePct, history: q.history || []
})),
crypto: (yfData.crypto || []).map(q => ({
symbol: q.symbol, name: q.name, price: q.price,
change: q.change, changePct: q.changePct
})),
vix: yfQuotes['^VIX'] ? {
value: yfQuotes['^VIX'].price,
change: yfQuotes['^VIX'].change,
changePct: yfQuotes['^VIX'].changePct,
} : null,
timestamp: yfData.summary?.timestamp || null,
};
const yfGold = yfQuotes['GC=F'];
const yfSilver = yfQuotes['SI=F'];
const metals = {
gold: yfGold?.price,
goldChange: yfGold?.change,
goldChangePct: yfGold?.changePct,
goldRecent: yfGold?.history?.map(h => h.close) || [],
silver: yfSilver?.price,
silverChange: yfSilver?.change,
silverChangePct: yfSilver?.changePct,
silverRecent: yfSilver?.history?.map(h => h.close) || [],
};
// Override stale EIA prices with live Yahoo Finance data if available
const yfWti = yfQuotes['CL=F'];
const yfBrent = yfQuotes['BZ=F'];
const yfNatgas = yfQuotes['NG=F'];
if (yfWti?.price) energy.wti = yfWti.price;
if (yfBrent?.price) energy.brent = yfBrent.price;
if (yfNatgas?.price) energy.natgas = yfNatgas.price;
if (yfWti?.history?.length) energy.wtiRecent = yfWti.history.map(h => h.close);
// Fetch RSS
const news = await fetchAllNews();
const newsHealth = getRSSHealth();
const V2 = {
meta: data.crucix, air, thermal, tSignals, chokepoints, nuke, nukeSignals,
airMeta: {
fallback: Boolean(airFallback),
liveTotal: sumAirHotspots(liveAirHotspots),
timestamp: airFallback?.timestamp || data.sources.OpenSky?.timestamp || data.crucix?.timestamp || null,
source: airFallback ? 'OpenSky fallback' : 'OpenSky',
...(airFallback ? { fallbackFile: airFallback.file } : {}),
...(data.sources.OpenSky?.error ? { error: data.sources.OpenSky.error } : {}),
},
sdr: { total: sdrNet.totalReceivers || 0, online: sdrNet.online || 0, zones: sdrZones },
tg: { posts: tgData.totalPosts || 0, urgent: tgUrgent, topPosts: tgTop },
who, fred, energy, metals, bls, treasury, gscpi, defense, noaa, epa, acled, gdelt, space, health, news,
markets, // Live Yahoo Finance market data
newsMeta: {
rssHealth: newsHealth,
rssOk: Object.values(newsHealth).filter(s => s.status === 'ok').length,
rssFailed: Object.values(newsHealth).filter(s => s.status === 'failed').length,
},
ideas: [], ideasSource: 'disabled',
// newsFeed for ticker (merged RSS + GDELT + Telegram)
newsFeed: buildNewsFeed(news, gdeltData, tgUrgent, tgTop),
};
return V2;
}
// === Unified News Feed for Ticker ===
function buildNewsFeed(rssNews, gdeltData, tgUrgent, tgTop) {
const feed = [];
// RSS news
for (const n of rssNews) {
feed.push({
headline: n.title, source: n.source, type: 'rss',
timestamp: n.date, region: n.region, urgent: false, url: n.url
});
}
// GDELT top articles
for (const a of (gdeltData.allArticles || []).slice(0, 10)) {
if (a.title) {
const geo = geoTagText(a.title);
feed.push({
headline: a.title.substring(0, 100), source: 'GDELT', type: 'gdelt',
timestamp: new Date().toISOString(), region: geo?.region || 'Global', urgent: false, url: sanitizeExternalUrl(a.url)
});
}
}
// Telegram urgent
for (const p of tgUrgent.slice(0, 10)) {
const text = (p.text || '').replace(/[\u{1F1E0}-\u{1F1FF}]/gu, '').trim();
feed.push({
headline: text.substring(0, 100), source: p.channel?.toUpperCase() || 'TELEGRAM',
type: 'telegram', timestamp: p.date, region: 'OSINT', urgent: true
});
}
// Telegram top (non-urgent)
for (const p of tgTop.slice(0, 5)) {
const text = (p.text || '').replace(/[\u{1F1E0}-\u{1F1FF}]/gu, '').trim();
feed.push({
headline: text.substring(0, 100), source: p.channel?.toUpperCase() || 'TELEGRAM',
type: 'telegram', timestamp: p.date, region: 'OSINT', urgent: false
});
}
// Filter to last 30 days, sort by timestamp descending, limit to 50
const cutoff = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000);
const recent = feed.filter(item => !item.timestamp || new Date(item.timestamp) >= cutoff);
recent.sort((a, b) => new Date(b.timestamp || 0) - new Date(a.timestamp || 0));
const selected = [];
const selectedKeys = new Set();
const keyFor = item => `${item.type}|${item.source}|${item.headline}|${item.timestamp}`;
const pushUnique = item => {
const key = keyFor(item);
if (selectedKeys.has(key)) return;
selected.push(item);
selectedKeys.add(key);
};
for (const source of REGIONAL_NEWS_SOURCES) {
recent.filter(item => item.source === source).slice(0, 2).forEach(pushUnique);
}
recent.forEach(pushUnique);
return selected.slice(0, 50);
}
// === CLI Mode: inject into HTML file ===
function getCliArg(flag) {
const idx = process.argv.indexOf(flag);
return idx >= 0 ? process.argv[idx + 1] : null;
}
async function cliInject() {
const data = JSON.parse(readFileSync(join(ROOT, 'runs/latest.json'), 'utf8'));
const htmlOverride = getCliArg('--html');
const shouldOpen = !process.argv.includes('--no-open');
console.log('Fetching RSS news feeds...');
const V2 = await synthesize(data);
const llmProvider = createLLMProvider(config.llm);
if (llmProvider?.isConfigured) {
try {
console.log(`[LLM] Generating ideas via ${llmProvider.name}...`);
const llmIdeas = await generateLLMIdeas(llmProvider, V2, null, []);
if (llmIdeas?.length) {
V2.ideas = llmIdeas;
V2.ideasSource = 'llm';
console.log(`[LLM] Generated ${llmIdeas.length} ideas`);
} else {
V2.ideas = [];
V2.ideasSource = 'llm-failed';
console.log('[LLM] No ideas returned');
}
} catch (err) {
V2.ideas = [];
V2.ideasSource = 'llm-failed';
console.log('[LLM] Idea generation failed:', err.message);
}
} else {
V2.ideas = [];
V2.ideasSource = 'disabled';
}
console.log(`Generated ${V2.ideas.length} leverageable ideas`);
const json = JSON.stringify(V2);
console.log('\n--- Synthesis ---');
console.log('Size:', json.length, 'bytes | Air:', V2.air.length, '| Thermal:', V2.thermal.length,
'| News:', V2.news.length, '| Ideas:', V2.ideas.length, '| Sources:', V2.health.length);
const htmlPath = htmlOverride || join(ROOT, 'dashboard/public/jarvis.html');
let html = readFileSync(htmlPath, 'utf8');
// Use a replacer function so JSON is inserted literally even if it contains `$`.
html = html.replace(/^(let|const) D = .*;\s*$/m, () => 'let D = ' + json + ';');
writeFileSync(htmlPath, html);
console.log('Data injected into jarvis.html!');
if (!shouldOpen) return;
// Auto-open dashboard in default 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';
const dashUrl = htmlPath.replace(/\\/g, '/');
exec(`${openCmd} "${dashUrl}"`, (err) => {
if (err) console.log('Could not auto-open browser:', err.message);
else console.log('Dashboard opened in browser!');
});
}
// Run CLI if invoked directly
const isMain = process.argv[1]
&& fileURLToPath(import.meta.url).replace(/\\/g, '/') === process.argv[1].replace(/\\/g, '/');
if (isMain) {
await cliInject();
}