diff --git a/.env.example b/.env.example index d69966f..a547224 100644 --- a/.env.example +++ b/.env.example @@ -21,6 +21,8 @@ AISSTREAM_API_KEY= ACLED_EMAIL= # OAuth2 password grant (API keys deprecated Sept 2025) ACLED_PASSWORD= +# Cloudflare Radar internet outages & traffic anomalies (free: dash.cloudflare.com/profile/api-tokens, Account Analytics Read) +CLOUDFLARE_API_TOKEN= # === Server Configuration === @@ -31,13 +33,15 @@ REFRESH_INTERVAL_MINUTES=15 # === LLM Layer (optional) === # Enables AI-enhanced trade ideas and breaking news Telegram alerts. -# Provider options: anthropic | openai | gemini | codex | openrouter | minimax | mistral | grok +# Provider options: anthropic | openai | gemini | codex | openrouter | minimax | mistral | ollama | grok LLM_PROVIDER= -# Not needed for codex (uses ~/.codex/auth.json) +# Not needed for codex (uses ~/.codex/auth.json) or ollama (local) LLM_API_KEY= # Optional override. Each provider has a sensible default: -# anthropic: claude-sonnet-4-6 | openai: gpt-5.4 | gemini: gemini-3.1-pro | codex: gpt-5.3-codex | openrouter: openrouter/auto | minimax: MiniMax-M2.5 | grok: grok-3 +# anthropic: claude-sonnet-4-6 | openai: gpt-5.4 | gemini: gemini-3.1-pro | codex: gpt-5.3-codex | openrouter: openrouter/auto | minimax: MiniMax-M2.5 | ollama: llama3.1:8b | grok: grok-3 LLM_MODEL= +# Ollama base URL (only needed if not using default http://localhost:11434) +OLLAMA_BASE_URL= # === Telegram Alerts (optional, requires LLM) === # Create a bot via @BotFather, get chat ID via @userinfobot diff --git a/README.md b/README.md index dbc5716..f0814a7 100644 --- a/README.md +++ b/README.md @@ -48,6 +48,13 @@ Try the live demo first at [https://www.crucix.live/](https://www.crucix.live/), No cloud. No telemetry. No subscriptions. Just `node server.mjs` and you're running. +## Token / Asset Warning + +> [!WARNING] +> **Crucix has not launched any official token, coin, NFT, airdrop, presale, or other blockchain-based asset.** +> Any token or digital asset using the Crucix name, logo, or branding is not affiliated with or endorsed by Crucix. +> Do not buy it, promote it, connect a wallet to claim it, sign transactions, or send funds based on third-party posts, DMs, or websites. + --- ## Why This Exists @@ -118,6 +125,22 @@ A self-contained Jarvis-style HUD with: - **Space watch** — CelesTrak satellite tracking: recent launches, ISS, military constellations, Starlink/OneWeb counts - **Leverageable ideas** — AI-generated trade ideas (with LLM) or signal-correlated ideas (without) +### Performance Modes +The `VISUALS FULL` / `VISUALS LITE` button in the top bar only changes rendering behavior - it does **not** remove data sources or reduce sweep coverage. + +When you switch to **VISUALS LITE**, the dashboard: +- Disables decorative background effects such as the radial/grid overlays and scanlines +- Removes expensive blur/backdrop-filter effects on panels and overlays +- Stops non-essential animations like the logo ring blink, conflict rings, and corridor flow effects +- Disables globe auto-rotation and turns off animated flight-arc dashes +- Converts the horizontal news ticker and OSINT stream into static, scrollable lists instead of continuously animated marquees + +Mobile-specific behavior: +- On mobile, `VISUALS LITE` also forces the dashboard into **flat map mode** if you are currently on the globe +- Future mobile loads will continue to start flat while low-perf mode is enabled + +The preference is saved in browser local storage, so the UI will remember your last setting. + ### Auto-Refresh The server runs a sweep cycle every 15 minutes (configurable). Each cycle: 1. Queries all 27 sources in parallel (~30s) @@ -520,6 +543,18 @@ For bugs and feature requests, please use GitHub Issues so discussion stays visi --- +## Star History + + + + + + Star History Chart + + + +--- + ## License AGPL-3.0 diff --git a/apis/briefing.mjs b/apis/briefing.mjs index 94e4173..1ae2cff 100644 --- a/apis/briefing.mjs +++ b/apis/briefing.mjs @@ -43,6 +43,10 @@ import { briefing as space } from './sources/space.mjs'; // === Tier 5: Live Market Data === import { briefing as yfinance } from './sources/yfinance.mjs'; +// === Tier 6: Cyber & Infrastructure === +import { briefing as cisaKev } from './sources/cisa-kev.mjs'; +import { briefing as cloudflareRadar } from './sources/cloudflare-radar.mjs'; + const SOURCE_TIMEOUT_MS = 30_000; // 30s max per individual source export async function runSource(name, fn, ...args) { @@ -63,7 +67,7 @@ export async function runSource(name, fn, ...args) { } export async function fullBriefing() { - console.error('[Crucix] Starting intelligence sweep — 27 sources...'); + console.error('[Crucix] Starting intelligence sweep — 29 sources...'); const start = Date.now(); const allPromises = [ @@ -103,6 +107,10 @@ export async function fullBriefing() { // Tier 5: Live Market Data runSource('YFinance', yfinance), + + // Tier 6: Cyber & Infrastructure + runSource('CISA-KEV', cisaKev), + runSource('Cloudflare-Radar', cloudflareRadar), ]; // Each runSource has its own 30s timeout, so allSettled will resolve diff --git a/apis/sources/cisa-kev.mjs b/apis/sources/cisa-kev.mjs new file mode 100644 index 0000000..dcac626 --- /dev/null +++ b/apis/sources/cisa-kev.mjs @@ -0,0 +1,144 @@ +// CISA KEV — Known Exploited Vulnerabilities Catalog +// No auth required. Tracks CVEs actively exploited in the wild. +// Federal agencies must patch these within due dates — useful signal +// for cybersecurity posture and active threat landscape. + +import { safeFetch } from '../utils/fetch.mjs'; + +const KEV_URL = 'https://www.cisa.gov/sites/default/files/feeds/known_exploited_vulnerabilities.json'; + +function summarizeVulnerabilities(vulns) { + if (!vulns.length) return {}; + + // Recent additions (last 30 days) + const thirtyDaysAgo = new Date(Date.now() - 30 * 86400_000); + const recent = vulns.filter(v => { + const added = new Date(v.dateAdded); + return !isNaN(added) && added >= thirtyDaysAgo; + }); + + // Group by vendor + const byVendor = {}; + for (const v of vulns) { + const vendor = v.vendorProject || 'Unknown'; + byVendor[vendor] = (byVendor[vendor] || 0) + 1; + } + + // Top vendors sorted by count + const topVendors = Object.entries(byVendor) + .sort((a, b) => b[1] - a[1]) + .slice(0, 15) + .map(([vendor, count]) => ({ vendor, count })); + + // Ransomware-linked + const ransomwareLinked = vulns.filter(v => v.knownRansomwareCampaignUse === 'Known'); + + // Overdue (due date has passed) + const now = new Date(); + const overdue = vulns.filter(v => { + const due = new Date(v.dueDate); + return !isNaN(due) && due < now; + }); + + // Group recent by product for signal detection + const recentByProduct = {}; + for (const v of recent) { + const key = `${v.vendorProject} ${v.product}`; + if (!recentByProduct[key]) recentByProduct[key] = []; + recentByProduct[key].push(v); + } + + const hotProducts = Object.entries(recentByProduct) + .filter(([, vs]) => vs.length >= 2) + .sort((a, b) => b[1].length - a[1].length) + .slice(0, 10) + .map(([product, vs]) => ({ + product, + count: vs.length, + cves: vs.map(v => v.cveID) + })); + + return { + totalInCatalog: vulns.length, + recentAdditions: recent.length, + ransomwareLinked: ransomwareLinked.length, + overdueCount: overdue.length, + topVendors, + hotProducts, + }; +} + +export async function briefing() { + const data = await safeFetch(KEV_URL, { timeout: 20000 }); + + if (data.error) { + return { + source: 'CISA-KEV', + timestamp: new Date().toISOString(), + error: data.error, + }; + } + + const vulns = data.vulnerabilities || []; + const catalogVersion = data.catalogVersion || null; + const dateReleased = data.dateReleased || null; + + const summary = summarizeVulnerabilities(vulns); + + // Get the 20 most recently added + const sorted = [...vulns] + .sort((a, b) => new Date(b.dateAdded) - new Date(a.dateAdded)); + + const recentEntries = sorted.slice(0, 20).map(v => ({ + cveID: v.cveID, + vendorProject: v.vendorProject, + product: v.product, + vulnerabilityName: v.vulnerabilityName, + dateAdded: v.dateAdded, + dueDate: v.dueDate, + shortDescription: (v.shortDescription || '').substring(0, 300), + knownRansomwareCampaignUse: v.knownRansomwareCampaignUse, + })); + + // Signals — actionable intelligence + const signals = []; + + if (summary.recentAdditions > 5) { + signals.push({ + severity: 'high', + signal: `${summary.recentAdditions} new KEV entries in last 30 days — elevated exploit activity`, + }); + } + + if (summary.hotProducts?.length > 0) { + const top = summary.hotProducts[0]; + signals.push({ + severity: 'medium', + signal: `${top.product} has ${top.count} actively exploited CVEs recently added`, + }); + } + + const ransomwareRecent = recentEntries.filter(v => v.knownRansomwareCampaignUse === 'Known'); + if (ransomwareRecent.length > 0) { + signals.push({ + severity: 'critical', + signal: `${ransomwareRecent.length} recently added CVEs linked to ransomware campaigns`, + }); + } + + return { + source: 'CISA-KEV', + timestamp: new Date().toISOString(), + catalogVersion, + dateReleased, + summary, + vulnerabilities: recentEntries, + signals, + }; +} + +// Run standalone +if (process.argv[1]?.endsWith('cisa-kev.mjs')) { + const data = await briefing(); + console.log(JSON.stringify(data, null, 2)); +} diff --git a/apis/sources/cloudflare-radar.mjs b/apis/sources/cloudflare-radar.mjs new file mode 100644 index 0000000..f03453a --- /dev/null +++ b/apis/sources/cloudflare-radar.mjs @@ -0,0 +1,224 @@ +// Cloudflare Radar — Internet traffic anomalies and outages +// Requires a free Cloudflare API token (CLOUDFLARE_API_TOKEN). +// Get one at: https://dash.cloudflare.com/profile/api-tokens +// Create a custom token with Account → Account Analytics → Read permission. +// +// Monitors internet outages, traffic anomalies, and attack trends +// that correlate with conflict, censorship, and infrastructure disruption. + +import { safeFetch } from '../utils/fetch.mjs'; +import '../utils/env.mjs'; + +const RADAR_BASE = 'https://api.cloudflare.com/client/v4/radar'; + +// Countries of intelligence interest for internet monitoring +const WATCHLIST_COUNTRIES = [ + 'RU', 'UA', 'CN', 'IR', 'KP', 'SY', 'MM', 'ET', 'SD', + 'YE', 'AF', 'IQ', 'LB', 'PS', 'TW', 'BY', 'VE', 'CU' +]; + +function getAuthHeaders() { + const token = process.env.CLOUDFLARE_API_TOKEN; + if (!token) return null; + return { Authorization: `Bearer ${token}` }; +} + +async function fetchAnnotations() { + const headers = getAuthHeaders(); + if (!headers) return { error: 'no_credentials' }; + + // Cloudflare Radar Annotations — internet outages and government shutdowns + const url = `${RADAR_BASE}/annotations/outages?dateRange=30d&format=json`; + const data = await safeFetch(url, { timeout: 15000, headers }); + + if (data.error) return { error: data.error }; + + const annotations = data.result?.annotations || []; + return annotations.map(a => ({ + id: a.id, + description: (a.description || '').substring(0, 500), + startDate: a.startDate, + endDate: a.endDate, + linkedUrl: a.linkedUrl || null, + scope: a.scope || null, + asns: a.asns || [], + locations: a.locations || [], + eventType: a.eventType || 'outage', + })); +} + +async function fetchAttackSummary() { + const headers = getAuthHeaders(); + if (!headers) return { error: 'no_credentials' }; + + // Layer 3 DDoS attack summaries by protocol and vector + // API requires a dimension: /summary/{dimension} + const [byProtocol, byVector] = await Promise.all([ + safeFetch(`${RADAR_BASE}/attacks/layer3/summary/protocol?dateRange=7d&format=json`, { timeout: 15000, headers }), + safeFetch(`${RADAR_BASE}/attacks/layer3/summary/vector?dateRange=7d&format=json`, { timeout: 15000, headers }), + ]); + + const result = {}; + + if (!byProtocol.error && byProtocol.result) { + result.byProtocol = byProtocol.result.summary_0 || byProtocol.result; + } + if (!byVector.error && byVector.result) { + result.byVector = byVector.result.summary_0 || byVector.result; + } + + if (!result.byProtocol && !result.byVector) { + return { error: byProtocol.error || byVector.error || 'No attack data returned' }; + } + + return result; +} + +async function fetchTrafficAnomalies() { + const headers = getAuthHeaders(); + if (!headers) return { error: 'no_credentials' }; + + // Traffic anomalies — significant deviations from normal patterns + const url = `${RADAR_BASE}/traffic_anomalies?dateRange=7d&format=json&limit=50`; + const data = await safeFetch(url, { timeout: 15000, headers }); + + if (data.error) return { error: data.error }; + + const anomalies = data.result?.trafficAnomalies || []; + return anomalies.map(a => ({ + startDate: a.startDate, + endDate: a.endDate, + type: a.type || 'unknown', + status: a.status, + asnDetails: a.asnDetails || null, + locationDetails: a.locationDetails || null, + visibleInAllDataSources: a.visibleInAllDataSources || false, + })); +} + +function buildSignals(outages, anomalies) { + const signals = []; + + if (!Array.isArray(outages)) return signals; + + // Check for outages in watchlist countries + const watchlistOutages = outages.filter(o => { + const locations = o.locations || []; + return locations.some(l => WATCHLIST_COUNTRIES.includes(l)); + }); + + if (watchlistOutages.length > 0) { + const countries = [...new Set(watchlistOutages.flatMap(o => o.locations))].filter(l => WATCHLIST_COUNTRIES.includes(l)); + signals.push({ + severity: 'high', + signal: `Internet outages detected in ${countries.join(', ')} — possible government shutdown or infrastructure attack`, + }); + } + + // Multiple outages in same country = sustained disruption + const locationCounts = {}; + for (const o of outages) { + for (const loc of (o.locations || [])) { + locationCounts[loc] = (locationCounts[loc] || 0) + 1; + } + } + + const repeated = Object.entries(locationCounts) + .filter(([, count]) => count >= 3) + .map(([loc]) => loc); + + if (repeated.length > 0) { + signals.push({ + severity: 'medium', + signal: `Sustained internet disruptions in ${repeated.join(', ')} — ${repeated.length} locations with 3+ outage events in 30 days`, + }); + } + + // Traffic anomalies + if (Array.isArray(anomalies) && anomalies.length > 10) { + signals.push({ + severity: 'medium', + signal: `${anomalies.length} traffic anomalies detected globally in last 7 days — elevated internet instability`, + }); + } + + return signals; +} + +export async function briefing() { + if (!process.env.CLOUDFLARE_API_TOKEN) { + return { + source: 'Cloudflare-Radar', + timestamp: new Date().toISOString(), + status: 'no_credentials', + message: 'Set CLOUDFLARE_API_TOKEN in .env. Get a free token at https://dash.cloudflare.com/profile/api-tokens with Account → Account Analytics → Read permission.', + }; + } + + const [outages, attacks, anomalies] = await Promise.all([ + fetchAnnotations(), + fetchAttackSummary(), + fetchTrafficAnomalies(), + ]); + + // Handle complete failure + if (outages?.error && attacks?.error && anomalies?.error) { + return { + source: 'Cloudflare-Radar', + timestamp: new Date().toISOString(), + error: outages.error || attacks.error || anomalies.error, + }; + } + + const outageList = Array.isArray(outages) ? outages : []; + const anomalyList = Array.isArray(anomalies) ? anomalies : []; + + // Separate active vs resolved outages + const now = new Date(); + const activeOutages = outageList.filter(o => !o.endDate || new Date(o.endDate) > now); + const recentResolved = outageList.filter(o => o.endDate && new Date(o.endDate) <= now).slice(0, 10); + + // Group outages by location + const outagesByLocation = {}; + for (const o of outageList) { + for (const loc of (o.locations || ['unknown'])) { + if (!outagesByLocation[loc]) outagesByLocation[loc] = []; + outagesByLocation[loc].push(o); + } + } + + const topAffectedLocations = Object.entries(outagesByLocation) + .sort((a, b) => b[1].length - a[1].length) + .slice(0, 15) + .map(([location, events]) => ({ + location, + eventCount: events.length, + activeCount: events.filter(e => !e.endDate || new Date(e.endDate) > now).length, + })); + + const signals = buildSignals(outageList, anomalyList); + + return { + source: 'Cloudflare-Radar', + timestamp: new Date().toISOString(), + outages: { + total: outageList.length, + active: activeOutages.length, + activeEvents: activeOutages.slice(0, 20), + recentResolved: recentResolved, + topAffectedLocations, + }, + anomalies: { + total: anomalyList.length, + events: anomalyList.slice(0, 20), + }, + attacks: attacks?.error ? { error: attacks.error } : attacks, + signals, + }; +} + +// Run standalone +if (process.argv[1]?.endsWith('cloudflare-radar.mjs')) { + const data = await briefing(); + console.log(JSON.stringify(data, null, 2)); +} diff --git a/crucix.config.mjs b/crucix.config.mjs index aa35312..887d760 100644 --- a/crucix.config.mjs +++ b/crucix.config.mjs @@ -1,15 +1,16 @@ // Crucix Configuration — all settings with env var overrides -import './apis/utils/env.mjs'; // Load .env first +import "./apis/utils/env.mjs"; // Load .env first export default { port: parseInt(process.env.PORT) || 3117, refreshIntervalMinutes: parseInt(process.env.REFRESH_INTERVAL_MINUTES) || 15, llm: { - provider: process.env.LLM_PROVIDER || null, // anthropic | openai | gemini | codex | openrouter | minimax | mistral | grok + provider: process.env.LLM_PROVIDER || null, // anthropic | openai | gemini | codex | openrouter | minimax | mistral | ollama | grok apiKey: process.env.LLM_API_KEY || null, model: process.env.LLM_MODEL || null, + baseUrl: process.env.OLLAMA_BASE_URL || null, }, telegram: { @@ -22,7 +23,7 @@ export default { discord: { botToken: process.env.DISCORD_BOT_TOKEN || null, channelId: process.env.DISCORD_CHANNEL_ID || null, - guildId: process.env.DISCORD_GUILD_ID || null, // Server ID (for instant slash command registration) + guildId: process.env.DISCORD_GUILD_ID || null, // Server ID (for instant slash command registration) webhookUrl: process.env.DISCORD_WEBHOOK_URL || null, // Fallback: webhook-only alerts (no bot needed) }, diff --git a/dashboard/inject.mjs b/dashboard/inject.mjs index bb6b32f..962da19 100644 --- a/dashboard/inject.mjs +++ b/dashboard/inject.mjs @@ -167,6 +167,14 @@ async function fetchRSS(url, source) { } } +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() { const feeds = [ // Global @@ -189,6 +197,12 @@ export async function fetchAllNews() { ['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( @@ -206,7 +220,7 @@ export async function fetchAllNews() { const key = item.title.substring(0, 40).toLowerCase(); if (seen.has(key)) continue; seen.add(key); - const geo = geoTagText(item.title); + const geo = geoTagText(item.title) || RSS_SOURCE_FALLBACKS[item.source]; if (geo) { geoNews.push({ title: item.title.substring(0, 100), @@ -223,7 +237,23 @@ export async function fetchAllNews() { 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)); - return filtered.slice(0, 50); + + 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); } // === Leverageable Ideas from Signals === @@ -620,7 +650,22 @@ function buildNewsFeed(rssNews, gdeltData, tgUrgent, tgTop) { 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)); - return recent.slice(0, 50); + + 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 === diff --git a/dashboard/public/jarvis.html b/dashboard/public/jarvis.html index 46a52b0..0be7865 100644 --- a/dashboard/public/jarvis.html +++ b/dashboard/public/jarvis.html @@ -157,7 +157,7 @@ html,body{height:100%;background:var(--bg);color:var(--text);font-family:var(--s .lower{display:flex;flex-wrap:wrap;gap:10px;margin-top:10px;align-items:flex-start} .lower .g-panel{min-width:0;box-sizing:border-box} .lower .lp-ticker{flex:1.2 1 240px;max-width:380px} -.lower .lp-delta{flex:1 1 200px;max-width:300px} +.right-delta .delta-list{max-height:200px} .lower .lp-macro{flex:2.5 1 360px} .lower .lp-ideas{flex:1.5 1 300px} .lower-wide{width:100%} @@ -233,6 +233,9 @@ html,body{height:100%;background:var(--bg);color:var(--text);font-family:var(--s .tk-src.dw{color:#ef9a9a;border-color:rgba(239,154,154,0.3)} .tk-src.eu{color:#ce93d8;border-color:rgba(206,147,216,0.3)} .tk-src.af{color:#a5d6a7;border-color:rgba(165,214,167,0.3)} +.tk-src.sa{color:#ffab91;border-color:rgba(255,171,145,0.3)} +.tk-src.ind{color:#ffcc80;border-color:rgba(255,204,128,0.3)} +.tk-src.anz{color:#80cbc4;border-color:rgba(128,203,196,0.3)} .tk-src.us{color:#90caf9;border-color:rgba(144,202,249,0.3)} .tk-src.other{color:#b0bec5;border-color:rgba(176,190,197,0.2)} .tk-head{font-size:11px;line-height:1.35;color:#c8d8d2;margin-top:3px} @@ -274,7 +277,7 @@ body.low-perf .ticker-wrap::-webkit-scrollbar-thumb{background:rgba(100,240,200, .top-left,.top-center,.top-right{width:100%} .top-center{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:6px} .map-region-bar{display:none} - .top-right{gap:6px} + .top-right{gap:6px ; flex-wrap: wrap; justify-content: center;} .region-btn,.meta-pill,.alert-badge,.guide-btn{font-size:10px} .grid{display:flex;flex-direction:column} #centerCol{order:1} @@ -284,7 +287,7 @@ body.low-perf .ticker-wrap::-webkit-scrollbar-thumb{background:rgba(100,240,200, .map-hint{font-size:8px;right:8px} .map-legend{left:8px;right:8px;bottom:8px;gap:4px} .leg-item{font-size:8px} - .lower .lp-ticker,.lower .lp-osint,.lower .lp-delta,.lower .lp-macro,.lower .lp-ideas{flex:1 1 100%;max-width:none} + .lower .lp-ticker,.lower .lp-osint,.lower .lp-macro,.lower .lp-ideas{flex:1 1 100%;max-width:none} .metrics-row{grid-template-columns:repeat(2,1fr)} .src-grid{grid-template-columns:repeat(2,1fr)} .glossary-panel{top:auto;right:0;left:0;bottom:0;width:100%;max-height:min(72vh,720px);border-left:none;border-right:none;border-bottom:none} @@ -544,7 +547,7 @@ function togglePerfMode(){ localStorage.setItem('crucix_low_perf', String(lowPerfMode)); document.body.classList.toggle('low-perf', lowPerfMode); const perfStatus = document.getElementById('perfStatus'); - if(perfStatus) perfStatus.textContent = lowPerfMode ? 'LOW' : 'HIGH'; + if(perfStatus) perfStatus.textContent = lowPerfMode ? 'LITE' : 'FULL'; if(globe){ globe.controls().autoRotate = !lowPerfMode; globe.arcDashAnimateTime(lowPerfMode ? 0 : 2000); @@ -588,7 +591,7 @@ function renderTopbar(){ ${mobile ? `
${getRegionControlsMarkup()}
` : ''}
- + ${t('dashboard.sweep','SWEEP')} ${(D.meta.totalDurationMs/1000).toFixed(1)}s ${d} ${timeStr} ${t('dashboard.sources','SOURCES')} ${D.meta.sourcesOk}/${D.meta.sourcesQueried} @@ -1396,6 +1399,9 @@ function renderLower(){ const sl = s.toLowerCase(); // Africa-focused sources first (before generic DW/NYT) if (sl.includes('dw africa') || sl.includes('africa news') || sl.includes('nyt africa') || sl.includes('rfi')) return 'af'; + if (sl.includes('mercopress')) return 'sa'; + if (sl.includes('indian express') || sl.includes('the hindu')) return 'ind'; + if (sl.includes('sbs')) return 'anz'; if (sl.includes('bbc')) return 'bbc'; if (sl.includes('jazeera') || sl.includes('alj')) return 'alj'; if (sl.includes('gdelt')) return 'gdelt'; @@ -1431,29 +1437,6 @@ function renderLower(){
Set LLM_PROVIDER + credentials in .env to enable AI-powered trade ideas
`; - // DELTA PANEL — what changed since last sweep - const delta = D.delta || {}; - const ds = delta.summary || {}; - const hasDelta = ds.totalChanges > 0; - const dirEmoji = {'risk-off':'▲','risk-on':'▼','mixed':'◆'}[ds.direction]||'◆'; - const dirClass = {'risk-off':'up','risk-on':'down','mixed':''}[ds.direction]||''; - const escalated = (delta.signals?.escalated || []).slice(0,6); - const deescalated = (delta.signals?.deescalated || []).slice(0,4); - const newSigs = (delta.signals?.new || []).slice(0,4); - const deltaRows = []; - for(const s of newSigs){ - deltaRows.push(`
NEW${s.reason||s.label||s.key}
`); - } - for(const s of escalated){ - const sev = s.severity==='critical'?'style="color:var(--warn);font-weight:600"':s.severity==='high'?'style="color:#ffab40"':''; - const val = s.pctChange!==undefined?`${s.pctChange>0?'+':''}${s.pctChange}%`:`${s.change>0?'+':''}${s.change}`; - deltaRows.push(`
${s.label}${s.from}→${s.to} (${val})
`); - } - for(const s of deescalated){ - const val = s.pctChange!==undefined?`${s.pctChange}%`:`${s.change}`; - deltaRows.push(`
${s.label||s.key}${s.from}→${s.to} (${val})
`); - } - const deltaHtml = hasDelta ? deltaRows.join('') : '
No changes since last sweep
'; const tickerPanel = `

${t('panels.newsTicker','Live News Ticker')}

${feed.length} ${t('badges.items','ITEMS')}
@@ -1489,17 +1472,7 @@ function renderLower(){ ${ideasHtml}
FOR INFORMATIONAL PURPOSES ONLY. This is not financial advice, a recommendation to buy or sell any security, or a solicitation of any kind. All signal-based observations are derived from publicly available OSINT data and should not be relied upon for investment decisions. Consult a licensed financial advisor before making any investment. Past performance does not guarantee future results.
`; - const deltaPanel = `
-

${t('panels.sweepDelta','Sweep Delta')}

${dirEmoji} ${ds.direction?t('delta.'+ds.direction,ds.direction.toUpperCase()):t('delta.baseline','BASELINE')}
- ${hasDelta?`
- ${t('delta.changes','Changes')}: ${ds.totalChanges} - ${t('delta.critical','Critical')}: ${ds.criticalChanges||0} - ${ds.signalBreakdown?`${t('delta.new','New')}: ${ds.signalBreakdown.new} ↑${ds.signalBreakdown.escalated} ↓${ds.signalBreakdown.deescalated}`:''} -
`:''} -
${deltaHtml}
-
`; - - document.getElementById('lowerGrid').innerHTML=`${tickerPanel}${osintPanel}${macroPanel}${ideasPanel}${deltaPanel}`; + document.getElementById('lowerGrid').innerHTML=`${tickerPanel}${osintPanel}${macroPanel}${ideasPanel}`; } // === RIGHT RAIL === @@ -1518,6 +1491,30 @@ function renderRight(){ {l:'WHO Alerts',v:D.who.length,p:40} ]; + // DELTA PANEL — what changed since last sweep + const delta = D.delta || {}; + const ds = delta.summary || {}; + const hasDelta = ds.totalChanges > 0; + const dirEmoji = {'risk-off':'▲','risk-on':'▼','mixed':'◆'}[ds.direction]||'◆'; + const dirClass = {'risk-off':'up','risk-on':'down','mixed':''}[ds.direction]||''; + const escalated = (delta.signals?.escalated || []).slice(0,6); + const deescalated = (delta.signals?.deescalated || []).slice(0,4); + const newSigs = (delta.signals?.new || []).slice(0,4); + const deltaRows = []; + for(const s of newSigs){ + deltaRows.push(`
NEW${s.reason||s.label||s.key}
`); + } + for(const s of escalated){ + const sev = s.severity==='critical'?'style="color:var(--warn);font-weight:600"':s.severity==='high'?'style="color:#ffab40"':''; + const val = s.pctChange!==undefined?`${s.pctChange>0?'+':''}${s.pctChange}%`:`${s.change>0?'+':''}${s.change}`; + deltaRows.push(`
${s.label}${s.from}→${s.to} (${val})
`); + } + for(const s of deescalated){ + const val = s.pctChange!==undefined?`${s.pctChange}%`:`${s.change}`; + deltaRows.push(`
${s.label||s.key}${s.from}→${s.to} (${val})
`); + } + const deltaHtml = hasDelta ? deltaRows.join('') : `
${t('delta.noChanges','No changes since last sweep')}
`; + document.getElementById('rightRail').innerHTML=`

${t('panels.crossSourceSignals','Cross-Source Signals')}

${t('badges.worldview','WORLDVIEW')}
@@ -1527,6 +1524,15 @@ function renderRight(){

${t('panels.signalCore','Signal Core')}

${t('badges.hotMetrics','HOT METRICS')}
${signalMetrics.map(s=>`
${s.l}
${s.v}
`).join('')} +
+
+

${t('panels.sweepDelta','Sweep Delta')}

${dirEmoji} ${ds.direction?t('delta.'+ds.direction,ds.direction.toUpperCase()):t('delta.baseline','BASELINE')}
+ ${hasDelta?`
+ ${t('delta.changes','Changes')}: ${ds.totalChanges} + ${t('delta.critical','Critical')}: ${ds.criticalChanges||0} + ${ds.signalBreakdown?`${t('delta.new','New')}: ${ds.signalBreakdown.new} ↑${ds.signalBreakdown.escalated} ↓${ds.signalBreakdown.deescalated}`:''} +
`:''} +
${deltaHtml}
`; } diff --git a/lib/llm/index.mjs b/lib/llm/index.mjs index 096279c..21fd64a 100644 --- a/lib/llm/index.mjs +++ b/lib/llm/index.mjs @@ -1,24 +1,25 @@ // LLM Factory — creates the configured provider or returns null -import { AnthropicProvider } from './anthropic.mjs'; -import { OpenAIProvider } from './openai.mjs'; -import { OpenRouterProvider } from './openrouter.mjs'; -import { GeminiProvider } from './gemini.mjs'; -import { CodexProvider } from './codex.mjs'; -import { MiniMaxProvider } from './minimax.mjs'; -import { MistralProvider } from './mistral.mjs'; -import { GrokProvider } from './grok.mjs'; - -export { LLMProvider } from './provider.mjs'; -export { AnthropicProvider } from './anthropic.mjs'; -export { OpenAIProvider } from './openai.mjs'; -export { OpenRouterProvider } from './openrouter.mjs'; -export { GeminiProvider } from './gemini.mjs'; -export { CodexProvider } from './codex.mjs'; -export { MiniMaxProvider } from './minimax.mjs'; -export { MistralProvider } from './mistral.mjs'; -export { GrokProvider } from './grok.mjs'; +import { AnthropicProvider } from "./anthropic.mjs"; +import { OpenAIProvider } from "./openai.mjs"; +import { OpenRouterProvider } from "./openrouter.mjs"; +import { GeminiProvider } from "./gemini.mjs"; +import { CodexProvider } from "./codex.mjs"; +import { MiniMaxProvider } from "./minimax.mjs"; +import { MistralProvider } from "./mistral.mjs"; +import { OllamaProvider } from "./ollama.mjs"; +import { GrokProvider } from "./grok.mjs"; +export { LLMProvider } from "./provider.mjs"; +export { AnthropicProvider } from "./anthropic.mjs"; +export { OpenAIProvider } from "./openai.mjs"; +export { OpenRouterProvider } from "./openrouter.mjs"; +export { GeminiProvider } from "./gemini.mjs"; +export { CodexProvider } from "./codex.mjs"; +export { MiniMaxProvider } from "./minimax.mjs"; +export { MistralProvider } from "./mistral.mjs"; +export { OllamaProvider } from "./ollama.mjs"; +export { GrokProvider } from "./grok.mjs"; /** * Create an LLM provider based on config. @@ -31,24 +32,28 @@ export function createLLMProvider(llmConfig) { const { provider, apiKey, model } = llmConfig; switch (provider.toLowerCase()) { - case 'anthropic': + case "anthropic": return new AnthropicProvider({ apiKey, model }); - case 'openai': + case "openai": return new OpenAIProvider({ apiKey, model }); - case 'openrouter': + case "openrouter": return new OpenRouterProvider({ apiKey, model }); - case 'gemini': + case "gemini": return new GeminiProvider({ apiKey, model }); - case 'codex': + case "codex": return new CodexProvider({ model }); - case 'minimax': + case "minimax": return new MiniMaxProvider({ apiKey, model }); - case 'mistral': + case "mistral": return new MistralProvider({ apiKey, model }); + case "ollama": + return new OllamaProvider({ model, baseUrl: llmConfig.baseUrl }); case 'grok': return new GrokProvider({ apiKey, model }); default: - console.warn(`[LLM] Unknown provider "${provider}". LLM features disabled.`); + console.warn( + `[LLM] Unknown provider "${provider}". LLM features disabled.`, + ); return null; } } diff --git a/lib/llm/ollama.mjs b/lib/llm/ollama.mjs new file mode 100644 index 0000000..5bb509a --- /dev/null +++ b/lib/llm/ollama.mjs @@ -0,0 +1,49 @@ +// Ollama Provider — raw fetch, no SDK +// Uses Ollama's OpenAI-compatible Chat Completions API +// No API key required — fully local inference + +import { LLMProvider } from './provider.mjs'; + +export class OllamaProvider extends LLMProvider { + constructor(config) { + super(config); + this.name = 'ollama'; + this.baseUrl = (config.baseUrl || 'http://localhost:11434').replace(/\/+$/, ''); + this.model = config.model || 'llama3.1:8b'; + } + + get isConfigured() { return !!this.model; } + + async complete(systemPrompt, userMessage, opts = {}) { + const res = await fetch(`${this.baseUrl}/v1/chat/completions`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + model: this.model, + max_tokens: opts.maxTokens || 4096, + messages: [ + { role: 'system', content: systemPrompt }, + { role: 'user', content: userMessage }, + ], + }), + signal: AbortSignal.timeout(opts.timeout || 120000), + }); + + if (!res.ok) { + const err = await res.text().catch(() => ''); + throw new Error(`Ollama API ${res.status}: ${err.substring(0, 200)}`); + } + + const data = await res.json(); + const text = data.choices?.[0]?.message?.content || ''; + + return { + text, + usage: { + inputTokens: data.usage?.prompt_tokens || 0, + outputTokens: data.usage?.completion_tokens || 0, + }, + model: data.model || this.model, + }; + } +} diff --git a/test/llm-ollama-integration.test.mjs b/test/llm-ollama-integration.test.mjs new file mode 100644 index 0000000..c953543 --- /dev/null +++ b/test/llm-ollama-integration.test.mjs @@ -0,0 +1,45 @@ +// Ollama provider — integration test (calls real Ollama instance) +// Requires a running Ollama server with a model pulled +// Run: OLLAMA_MODEL=llama3.1:8b node --test test/llm-ollama-integration.test.mjs + +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import { OllamaProvider } from '../lib/llm/ollama.mjs'; + +const BASE_URL = process.env.OLLAMA_BASE_URL || 'http://localhost:11434'; +const MODEL = process.env.OLLAMA_MODEL || 'llama3.1:8b'; + +// Check if Ollama is reachable and the requested model is installed +let ollamaAvailable = false; +let skipReason = 'Ollama not reachable'; +try { + const res = await fetch(`${BASE_URL}/api/tags`, { signal: AbortSignal.timeout(3000) }); + if (res.ok) { + const { models = [] } = await res.json(); + const installed = models.some((m) => m.name === MODEL || m.name.startsWith(`${MODEL}:`)); + if (installed) { + ollamaAvailable = true; + } else { + skipReason = `Model "${MODEL}" not installed (available: ${models.map((m) => m.name).join(', ') || 'none'})`; + } + } +} catch { /* not available */ } + +describe('Ollama integration', { skip: !ollamaAvailable && skipReason }, () => { + it('should complete a prompt via local Ollama', async () => { + const provider = new OllamaProvider({ model: MODEL, baseUrl: BASE_URL }); + assert.equal(provider.isConfigured, true); + + const result = await provider.complete( + 'You are a helpful assistant. Respond in exactly one sentence.', + 'What is 2+2?', + { maxTokens: 128, timeout: 60000 } + ); + + assert.ok(result.text.length > 0, 'Response text should not be empty'); + assert.ok(result.model, 'Should report model name'); + console.log(` Response: ${result.text}`); + console.log(` Tokens: ${result.usage.inputTokens} in / ${result.usage.outputTokens} out`); + console.log(` Model: ${result.model}`); + }); +}); diff --git a/test/llm-ollama.test.mjs b/test/llm-ollama.test.mjs new file mode 100644 index 0000000..f1b31e1 --- /dev/null +++ b/test/llm-ollama.test.mjs @@ -0,0 +1,170 @@ +// Ollama provider — unit tests +// Uses Node.js built-in test runner (node:test) — no extra dependencies + +import { describe, it, mock, beforeEach } from 'node:test'; +import assert from 'node:assert/strict'; +import { OllamaProvider } from '../lib/llm/ollama.mjs'; +import { createLLMProvider } from '../lib/llm/index.mjs'; + +// ─── Unit Tests ─── + +describe('OllamaProvider', () => { + it('should set defaults correctly', () => { + const provider = new OllamaProvider({}); + assert.equal(provider.name, 'ollama'); + assert.equal(provider.model, 'llama3.1:8b'); + assert.equal(provider.baseUrl, 'http://localhost:11434'); + assert.equal(provider.isConfigured, true); + }); + + it('should accept custom model and base URL', () => { + const provider = new OllamaProvider({ model: 'qwen2.5:14b', baseUrl: 'http://192.168.1.10:11434' }); + assert.equal(provider.model, 'qwen2.5:14b'); + assert.equal(provider.baseUrl, 'http://192.168.1.10:11434'); + }); + + it('should strip trailing slashes from base URL', () => { + const provider = new OllamaProvider({ baseUrl: 'http://localhost:11434/' }); + assert.equal(provider.baseUrl, 'http://localhost:11434'); + }); + + it('should throw on API error', async () => { + const provider = new OllamaProvider({}); + const originalFetch = globalThis.fetch; + globalThis.fetch = mock.fn(() => + Promise.resolve({ ok: false, status: 404, text: () => Promise.resolve('model not found') }) + ); + try { + await assert.rejects( + () => provider.complete('system', 'user'), + (err) => { + assert.match(err.message, /Ollama API 404/); + return true; + } + ); + } finally { + globalThis.fetch = originalFetch; + } + }); + + it('should parse successful response', async () => { + const provider = new OllamaProvider({}); + const mockResponse = { + choices: [{ message: { content: 'Hello from Ollama' } }], + usage: { prompt_tokens: 12, completion_tokens: 8 }, + model: 'llama3.1:8b', + }; + const originalFetch = globalThis.fetch; + globalThis.fetch = mock.fn(() => + Promise.resolve({ ok: true, json: () => Promise.resolve(mockResponse) }) + ); + try { + const result = await provider.complete('You are helpful.', 'Say hello'); + assert.equal(result.text, 'Hello from Ollama'); + assert.equal(result.usage.inputTokens, 12); + assert.equal(result.usage.outputTokens, 8); + assert.equal(result.model, 'llama3.1:8b'); + } finally { + globalThis.fetch = originalFetch; + } + }); + + it('should send correct request format', async () => { + const provider = new OllamaProvider({ model: 'qwen2.5:14b', baseUrl: 'http://myhost:11434' }); + let capturedUrl, capturedOpts; + const originalFetch = globalThis.fetch; + globalThis.fetch = mock.fn((url, opts) => { + capturedUrl = url; + capturedOpts = opts; + return Promise.resolve({ + ok: true, + json: () => Promise.resolve({ + choices: [{ message: { content: 'ok' } }], + usage: { prompt_tokens: 1, completion_tokens: 1 }, + model: 'qwen2.5:14b', + }), + }); + }); + try { + await provider.complete('system prompt', 'user message', { maxTokens: 2048 }); + assert.equal(capturedUrl, 'http://myhost:11434/v1/chat/completions'); + assert.equal(capturedOpts.method, 'POST'); + const headers = capturedOpts.headers; + assert.equal(headers['Content-Type'], 'application/json'); + assert.equal(headers['Authorization'], undefined); + const body = JSON.parse(capturedOpts.body); + assert.equal(body.model, 'qwen2.5:14b'); + assert.equal(body.max_tokens, 2048); + assert.equal(body.messages[0].role, 'system'); + assert.equal(body.messages[0].content, 'system prompt'); + assert.equal(body.messages[1].role, 'user'); + assert.equal(body.messages[1].content, 'user message'); + } finally { + globalThis.fetch = originalFetch; + } + }); + + it('should handle empty response gracefully', async () => { + const provider = new OllamaProvider({}); + const originalFetch = globalThis.fetch; + globalThis.fetch = mock.fn(() => + Promise.resolve({ + ok: true, + json: () => Promise.resolve({ choices: [], usage: {} }), + }) + ); + try { + const result = await provider.complete('sys', 'user'); + assert.equal(result.text, ''); + assert.equal(result.usage.inputTokens, 0); + assert.equal(result.usage.outputTokens, 0); + } finally { + globalThis.fetch = originalFetch; + } + }); + + it('should use longer default timeout than cloud providers', async () => { + const provider = new OllamaProvider({}); + let capturedOpts; + const originalFetch = globalThis.fetch; + globalThis.fetch = mock.fn((url, opts) => { + capturedOpts = opts; + return Promise.resolve({ + ok: true, + json: () => Promise.resolve({ + choices: [{ message: { content: 'ok' } }], + usage: { prompt_tokens: 1, completion_tokens: 1 }, + }), + }); + }); + try { + await provider.complete('sys', 'user'); + assert.ok(capturedOpts.signal, 'Should have an abort signal'); + } finally { + globalThis.fetch = originalFetch; + } + }); +}); + +// ─── Factory Tests ─── + +describe('createLLMProvider — ollama', () => { + it('should create OllamaProvider for provider=ollama', () => { + const provider = createLLMProvider({ provider: 'ollama', apiKey: null, model: null }); + assert.ok(provider instanceof OllamaProvider); + assert.equal(provider.name, 'ollama'); + assert.equal(provider.isConfigured, true); + }); + + it('should be case-insensitive', () => { + const provider = createLLMProvider({ provider: 'Ollama', apiKey: null, model: null }); + assert.ok(provider instanceof OllamaProvider); + }); + + it('should pass baseUrl from config', () => { + const provider = createLLMProvider({ provider: 'ollama', apiKey: null, model: 'mistral:7b', baseUrl: 'http://gpu-box:11434' }); + assert.ok(provider instanceof OllamaProvider); + assert.equal(provider.baseUrl, 'http://gpu-box:11434'); + assert.equal(provider.model, 'mistral:7b'); + }); +});