Merge branch 'master' into feat/docker-ghcr-publish

This commit is contained in:
R4V3N
2026-03-18 06:21:45 +01:00
20 changed files with 1040 additions and 83 deletions

View File

@@ -31,12 +31,12 @@ REFRESH_INTERVAL_MINUTES=15
# === LLM Layer (optional) === # === LLM Layer (optional) ===
# Enables AI-enhanced trade ideas and breaking news Telegram alerts. # Enables AI-enhanced trade ideas and breaking news Telegram alerts.
# Provider options: anthropic | openai | gemini | codex # Provider options: anthropic | openai | gemini | codex | openrouter | minimax
LLM_PROVIDER= LLM_PROVIDER=
# Not needed for codex (uses ~/.codex/auth.json) # Not needed for codex (uses ~/.codex/auth.json)
LLM_API_KEY= LLM_API_KEY=
# Optional override. Each provider has a sensible default: # 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 # 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
LLM_MODEL= LLM_MODEL=
# === Telegram Alerts (optional, requires LLM) === # === Telegram Alerts (optional, requires LLM) ===

3
.gitignore vendored
View File

@@ -43,3 +43,6 @@ npm-debug.log*
# Local maintainer notes # Local maintainer notes
MAINTAINER_DECISIONS.local.md MAINTAINER_DECISIONS.local.md
# Local deploy config
dashboard/public/vercel.json

View File

@@ -4,6 +4,11 @@
**Your own intelligence terminal. 27 sources. One command. Zero cloud.** **Your own intelligence terminal. 27 sources. One command. Zero cloud.**
## [Visit The Live Site: crucix.live](https://www.crucix.live/)
[![Live Website](https://img.shields.io/badge/live-crucix.live-00d4ff?style=for-the-badge)](https://www.crucix.live/)
[![Open Demo](https://img.shields.io/badge/open-live%20dashboard-0b1220?style=for-the-badge&logo=googlechrome&logoColor=white)](https://www.crucix.live/)
[![Node.js 22+](https://img.shields.io/badge/node-22%2B-brightgreen)](#quick-start) [![Node.js 22+](https://img.shields.io/badge/node-22%2B-brightgreen)](#quick-start)
[![License: AGPL v3](https://img.shields.io/badge/license-AGPLv3-blue.svg)](LICENSE) [![License: AGPL v3](https://img.shields.io/badge/license-AGPLv3-blue.svg)](LICENSE)
[![Dependencies](https://img.shields.io/badge/dependencies-1%20(express)-orange)](#architecture) [![Dependencies](https://img.shields.io/badge/dependencies-1%20(express)-orange)](#architecture)
@@ -27,10 +32,15 @@
</div> </div>
> **Live website:** [https://www.crucix.live/](https://www.crucix.live/)
> Explore the public demo first, then clone the repo to run Crucix locally.
Crucix pulls satellite fire detection, flight tracking, radiation monitoring, satellite constellation tracking, economic indicators, live market prices, conflict data, sanctions lists, and social sentiment from 27 open-source intelligence feeds — in parallel, every 15 minutes — and renders everything on a single self-contained Jarvis-style dashboard. Crucix pulls satellite fire detection, flight tracking, radiation monitoring, satellite constellation tracking, economic indicators, live market prices, conflict data, sanctions lists, and social sentiment from 27 open-source intelligence feeds — in parallel, every 15 minutes — and renders everything on a single self-contained Jarvis-style dashboard.
Hook it up to an LLM and it becomes a **two-way intelligence assistant** — pushing multi-tier alerts to Telegram and Discord when something meaningful changes, responding to commands like `/brief` and `/sweep` from your phone, and generating actionable trade ideas grounded in real cross-domain data. Your own analyst that watches the world while you sleep. Hook it up to an LLM and it becomes a **two-way intelligence assistant** — pushing multi-tier alerts to Telegram and Discord when something meaningful changes, responding to commands like `/brief` and `/sweep` from your phone, and generating actionable trade ideas grounded in real cross-domain data. Your own analyst that watches the world while you sleep.
Try the live demo first at [https://www.crucix.live/](https://www.crucix.live/), then clone the repo when you want the full local stack.
No cloud. No telemetry. No subscriptions. Just `node server.mjs` and you're running. No cloud. No telemetry. No subscriptions. Just `node server.mjs` and you're running.
--- ---
@@ -50,7 +60,7 @@ It was built for anyone who wants to understand what's actually happening in the
```bash ```bash
# 1. Clone the repo # 1. Clone the repo
git clone https://github.com/calesthio/Crucix.git git clone https://github.com/calesthio/Crucix.git
cd crucix cd Crucix
# 2. Install dependencies (just Express) # 2. Install dependencies (just Express)
npm install npm install
@@ -76,7 +86,7 @@ The dashboard opens automatically at `http://localhost:3117` and immediately beg
```bash ```bash
git clone https://github.com/calesthio/Crucix.git git clone https://github.com/calesthio/Crucix.git
cd crucix cd Crucix
cp .env.example .env # add your API keys cp .env.example .env # add your API keys
docker compose up -d docker compose up -d
``` ```
@@ -148,10 +158,10 @@ Alerts are delivered as rich embeds with color-coded sidebars: red for FLASH, ye
**Optional dependency:** The full bot requires `discord.js`. Install it with `npm install discord.js`. If it's not installed, Crucix automatically falls back to webhook-only mode. **Optional dependency:** The full bot requires `discord.js`. Install it with `npm install discord.js`. If it's not installed, Crucix automatically falls back to webhook-only mode.
### Optional LLM Layer ### Optional LLM Layer
Connect any of 4 LLM providers for enhanced analysis: Connect any of 6 LLM providers for enhanced analysis:
- **AI trade ideas** — quantitative analyst producing 5-8 actionable ideas citing specific data - **AI trade ideas** — quantitative analyst producing 5-8 actionable ideas citing specific data
- **Smarter alert evaluation** — LLM classifies signals into FLASH/PRIORITY/ROUTINE tiers with cross-domain correlation and confidence scoring - **Smarter alert evaluation** — LLM classifies signals into FLASH/PRIORITY/ROUTINE tiers with cross-domain correlation and confidence scoring
- Providers: Anthropic Claude, OpenAI, Google Gemini, OpenAI Codex (ChatGPT subscription) - Providers: Anthropic Claude, OpenAI, Google Gemini, OpenRouter (Unified API), OpenAI Codex (ChatGPT subscription), MiniMax
- Graceful fallback — when LLM is unavailable, a rule-based engine takes over alert evaluation. LLM failures never crash the sweep cycle. - Graceful fallback — when LLM is unavailable, a rule-based engine takes over alert evaluation. LLM failures never crash the sweep cycle.
--- ---
@@ -184,14 +194,16 @@ These three unlock the most valuable economic and satellite data. Each takes abo
### LLM Provider (optional, for AI-enhanced ideas) ### LLM Provider (optional, for AI-enhanced ideas)
Set `LLM_PROVIDER` to one of: `anthropic`, `openai`, `gemini`, `codex` Set `LLM_PROVIDER` to one of: `anthropic`, `openai`, `gemini`, `codex`, `openrouter`, `minimax`
| Provider | Key Required | Default Model | | Provider | Key Required | Default Model |
|----------|-------------|---------------| |----------|-------------|---------------|
| `anthropic` | `LLM_API_KEY` | claude-sonnet-4-6 | | `anthropic` | `LLM_API_KEY` | claude-sonnet-4-6 |
| `openai` | `LLM_API_KEY` | gpt-5.4 | | `openai` | `LLM_API_KEY` | gpt-5.4 |
| `gemini` | `LLM_API_KEY` | gemini-3.1-pro | | `gemini` | `LLM_API_KEY` | gemini-3.1-pro |
| `openrouter` | `LLM_API_KEY` | openrouter/auto |
| `codex` | None (uses `~/.codex/auth.json`) | gpt-5.3-codex | | `codex` | None (uses `~/.codex/auth.json`) | gpt-5.3-codex |
| `minimax` | `LLM_API_KEY` | MiniMax-M2.5 |
For Codex, run `npx @openai/codex login` to authenticate via your ChatGPT subscription. For Codex, run `npx @openai/codex login` to authenticate via your ChatGPT subscription.
@@ -261,12 +273,14 @@ crucix/
│ └── jarvis.html # Self-contained Jarvis HUD │ └── jarvis.html # Self-contained Jarvis HUD
├── lib/ ├── lib/
│ ├── llm/ # LLM abstraction (4 providers, raw fetch, no SDKs) │ ├── llm/ # LLM abstraction (5 providers, raw fetch, no SDKs)
│ │ ├── provider.mjs # Base class │ │ ├── provider.mjs # Base class
│ │ ├── anthropic.mjs # Claude │ │ ├── anthropic.mjs # Claude
│ │ ├── openai.mjs # GPT │ │ ├── openai.mjs # GPT
│ │ ├── gemini.mjs # Gemini │ │ ├── gemini.mjs # Gemini
│ │ ├── openrouter.mjs # OpenRouter (Unified API)
│ │ ├── codex.mjs # Codex (ChatGPT subscription) │ │ ├── codex.mjs # Codex (ChatGPT subscription)
│ │ ├── minimax.mjs # MiniMax (M2.5, 204K context)
│ │ ├── ideas.mjs # LLM-powered trade idea generation │ │ ├── ideas.mjs # LLM-powered trade idea generation
│ │ └── index.mjs # Factory: createLLMProvider() │ │ └── index.mjs # Factory: createLLMProvider()
│ ├── delta/ # Change tracking between sweeps │ ├── delta/ # Change tracking between sweeps
@@ -368,7 +382,7 @@ All settings are in `.env` with sensible defaults:
|----------|---------|-------------| |----------|---------|-------------|
| `PORT` | `3117` | Dashboard server port | | `PORT` | `3117` | Dashboard server port |
| `REFRESH_INTERVAL_MINUTES` | `15` | Auto-refresh interval | | `REFRESH_INTERVAL_MINUTES` | `15` | Auto-refresh interval |
| `LLM_PROVIDER` | disabled | `anthropic`, `openai`, `gemini`, or `codex` | | `LLM_PROVIDER` | disabled | `anthropic`, `openai`, `gemini`, `codex`, `openrouter`, or `minimax` |
| `LLM_API_KEY` | — | API key (not needed for codex) | | `LLM_API_KEY` | — | API key (not needed for codex) |
| `LLM_MODEL` | per-provider default | Override model selection | | `LLM_MODEL` | per-provider default | Override model selection |
| `TELEGRAM_BOT_TOKEN` | disabled | For Telegram alerts + bot commands | | `TELEGRAM_BOT_TOKEN` | disabled | For Telegram alerts + bot commands |

View File

@@ -14,16 +14,16 @@ const RADNET_AUX = `${BASE}/RADNET_AUX`;
// Key US cities with RadNet monitoring stations // Key US cities with RadNet monitoring stations
const MONITORING_STATIONS = { const MONITORING_STATIONS = {
washingtonDC: { label: 'Washington, DC', state: 'DC' }, washingtonDC: { label: 'Washington, DC', state: 'DC', lat: 38.9, lon: -77.0 },
newYork: { label: 'New York, NY', state: 'NY' }, newYork: { label: 'New York, NY', state: 'NY', lat: 40.7, lon: -74.0 },
losAngeles: { label: 'Los Angeles, CA', state: 'CA' }, losAngeles: { label: 'Los Angeles, CA', state: 'CA', lat: 34.1, lon: -118.2 },
chicago: { label: 'Chicago, IL', state: 'IL' }, chicago: { label: 'Chicago, IL', state: 'IL', lat: 41.9, lon: -87.6 },
seattle: { label: 'Seattle, WA', state: 'WA' }, seattle: { label: 'Seattle, WA', state: 'WA', lat: 47.6, lon: -122.3 },
denver: { label: 'Denver, CO', state: 'CO' }, denver: { label: 'Denver, CO', state: 'CO', lat: 39.7, lon: -105.0 },
honolulu: { label: 'Honolulu, HI', state: 'HI' }, honolulu: { label: 'Honolulu, HI', state: 'HI', lat: 21.3, lon: -157.9 },
anchorage: { label: 'Anchorage, AK', state: 'AK' }, anchorage: { label: 'Anchorage, AK', state: 'AK', lat: 61.2, lon: -149.9 },
miami: { label: 'Miami, FL', state: 'FL' }, miami: { label: 'Miami, FL', state: 'FL', lat: 25.8, lon: -80.2 },
sanFrancisco: { label: 'San Francisco, CA', state: 'CA' }, sanFrancisco: { label: 'San Francisco, CA', state: 'CA', lat: 37.8, lon: -122.4 },
}; };
// Analyte types that indicate concerning radiation // Analyte types that indicate concerning radiation
@@ -76,8 +76,15 @@ export async function getResultsByAnalyte(analyte, opts = {}) {
); );
} }
// Lookup coords by city name or state
const CITY_COORDS = Object.fromEntries(
Object.values(MONITORING_STATIONS).map(s => [s.label.split(',')[0].toUpperCase(), s])
);
// Compact a reading for briefing output // Compact a reading for briefing output
function compactReading(r) { function compactReading(r) {
const city = (r.ANA_CITY || r.LOCATION || '').toUpperCase().trim();
const station = CITY_COORDS[city];
return { return {
location: r.ANA_CITY || r.LOCATION || 'Unknown', location: r.ANA_CITY || r.LOCATION || 'Unknown',
state: r.ANA_STATE || r.STATE || null, state: r.ANA_STATE || r.STATE || null,
@@ -86,6 +93,8 @@ function compactReading(r) {
unit: r.RESULT_UNIT || r.ANA_UNIT || null, unit: r.RESULT_UNIT || r.ANA_UNIT || null,
collectDate: r.COLLECT_DATE || r.SAMPLE_DATE || null, collectDate: r.COLLECT_DATE || r.SAMPLE_DATE || null,
medium: r.SAMPLE_TYPE || r.MEDIUM || null, medium: r.SAMPLE_TYPE || r.MEDIUM || null,
lat: station?.lat || null,
lon: station?.lon || null,
}; };
} }

View File

@@ -104,11 +104,26 @@ export async function briefing() {
keywords.some(k => a.title?.toLowerCase().includes(k)) keywords.some(k => a.title?.toLowerCase().includes(k))
); );
// Geo events — get mapped event locations (separate API, respects rate limit)
await delay(5500);
let geoPoints = [];
try {
const geo = await geoEvents('conflict OR military OR protest OR crisis', { maxPoints: 30, timespan: '24h' });
geoPoints = (geo?.features || []).filter(f => f.geometry?.coordinates).map(f => ({
lat: f.geometry.coordinates[1],
lon: f.geometry.coordinates[0],
name: f.properties?.name || f.properties?.html || '',
count: f.properties?.count || 1,
type: f.properties?.type || 'event',
}));
} catch (e) { /* geo endpoint optional — don't break briefing */ }
return { return {
source: 'GDELT', source: 'GDELT',
timestamp: new Date().toISOString(), timestamp: new Date().toISOString(),
totalArticles: articles.length, totalArticles: articles.length,
allArticles: articles, allArticles: articles,
geoPoints,
conflicts: categorize(['military', 'conflict', 'war', 'strike', 'missile', 'attack', 'bomb', 'troops']), conflicts: categorize(['military', 'conflict', 'war', 'strike', 'missile', 'attack', 'bomb', 'troops']),
economy: categorize(['economy', 'recession', 'inflation', 'market', 'sanctions', 'tariff', 'trade', 'gdp']), economy: categorize(['economy', 'recession', 'inflation', 'market', 'sanctions', 'tariff', 'trade', 'gdp']),
health: categorize(['pandemic', 'outbreak', 'epidemic', 'disease', 'virus', 'health']), health: categorize(['pandemic', 'outbreak', 'epidemic', 'disease', 'virus', 'health']),

View File

@@ -57,15 +57,33 @@ export async function briefing() {
wildfires: fire.length, wildfires: fire.length,
other: other.length, other: other.length,
}, },
topAlerts: features.slice(0, 15).map(f => ({ topAlerts: features.slice(0, 15).map(f => {
event: f.properties?.event, // Extract centroid from GeoJSON geometry
severity: f.properties?.severity, let lat = null, lon = null;
urgency: f.properties?.urgency, const geo = f.geometry;
headline: f.properties?.headline, if (geo?.type === 'Polygon' && geo.coordinates?.[0]?.length) {
areas: f.properties?.areaDesc, const coords = geo.coordinates[0];
onset: f.properties?.onset, lat = coords.reduce((s, c) => s + c[1], 0) / coords.length;
expires: f.properties?.expires, lon = coords.reduce((s, c) => s + c[0], 0) / coords.length;
})), } else if (geo?.type === 'MultiPolygon' && geo.coordinates?.length) {
const coords = geo.coordinates[0][0];
lat = coords.reduce((s, c) => s + c[1], 0) / coords.length;
lon = coords.reduce((s, c) => s + c[0], 0) / coords.length;
} else if (geo?.type === 'Point') {
[lon, lat] = geo.coordinates;
}
return {
event: f.properties?.event,
severity: f.properties?.severity,
urgency: f.properties?.urgency,
headline: f.properties?.headline,
areas: f.properties?.areaDesc,
onset: f.properties?.onset,
expires: f.properties?.expires,
lat: lat != null ? +lat.toFixed(3) : null,
lon: lon != null ? +lon.toFixed(3) : null,
};
}),
}; };
} }

View File

@@ -57,6 +57,10 @@ const HOTSPOTS = {
baltics: { lamin: 53, lomin: 19, lamax: 60, lomax: 29, label: 'Baltic Region' }, baltics: { lamin: 53, lomin: 19, lamax: 60, lomax: 29, label: 'Baltic Region' },
southChinaSea: { lamin: 5, lomin: 105, lamax: 23, lomax: 122, label: 'South China Sea' }, southChinaSea: { lamin: 5, lomin: 105, lamax: 23, lomax: 122, label: 'South China Sea' },
koreanPeninsula: { lamin: 33, lomin: 124, lamax: 43, lomax: 132, label: 'Korean Peninsula' }, koreanPeninsula: { lamin: 33, lomin: 124, lamax: 43, lomax: 132, label: 'Korean Peninsula' },
caribbean: { lamin: 18, lomin: -90, lamax: 30, lomax: -72, label: 'Caribbean' },
gulfOfGuinea: { lamin: -2, lomin: -5, lamax: 8, lomax: 10, label: 'Gulf of Guinea' },
capeRoute: { lamin: -38, lomin: 12, lamax: -28, lomax: 24, label: 'Cape Route' },
hornOfAfrica: { lamin: 5, lomin: 40, lamax: 15, lomax: 55, label: 'Horn of Africa' },
}; };
// Briefing — check hotspot regions for flight activity // Briefing — check hotspot regions for flight activity

View File

@@ -7,7 +7,7 @@ export default {
refreshIntervalMinutes: parseInt(process.env.REFRESH_INTERVAL_MINUTES) || 15, refreshIntervalMinutes: parseInt(process.env.REFRESH_INTERVAL_MINUTES) || 15,
llm: { llm: {
provider: process.env.LLM_PROVIDER || null, // anthropic | openai | gemini | codex provider: process.env.LLM_PROVIDER || null, // anthropic | openai | gemini | codex | openrouter | minimax
apiKey: process.env.LLM_API_KEY || null, apiKey: process.env.LLM_API_KEY || null,
model: process.env.LLM_MODEL || null, model: process.env.LLM_MODEL || null,
}, },

View File

@@ -9,6 +9,9 @@ import { readFileSync, writeFileSync } from 'fs';
import { dirname, join } from 'path'; import { dirname, join } from 'path';
import { fileURLToPath } from 'url'; import { fileURLToPath } from 'url';
import { exec } from 'child_process'; 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';
const __dirname = dirname(fileURLToPath(import.meta.url)); const __dirname = dirname(fileURLToPath(import.meta.url));
const ROOT = join(__dirname, '..'); const ROOT = join(__dirname, '..');
@@ -125,7 +128,7 @@ export async function fetchAllNews() {
const feeds = [ const feeds = [
['http://feeds.bbci.co.uk/news/world/rss.xml', 'BBC'], ['http://feeds.bbci.co.uk/news/world/rss.xml', 'BBC'],
['https://rss.nytimes.com/services/xml/rss/nyt/World.xml', 'NYT'], ['https://rss.nytimes.com/services/xml/rss/nyt/World.xml', 'NYT'],
['https://feeds.aljazeera.com/xml/rss/all.xml', 'Al Jazeera'], ['https://www.aljazeera.com/xml/rss/all.xml', 'Al Jazeera'],
['https://rss.nytimes.com/services/xml/rss/nyt/Americas.xml', 'NYT Americas'], ['https://rss.nytimes.com/services/xml/rss/nyt/Americas.xml', 'NYT Americas'],
['https://rss.nytimes.com/services/xml/rss/nyt/AsiaPacific.xml', 'NYT Asia'], ['https://rss.nytimes.com/services/xml/rss/nyt/AsiaPacific.xml', 'NYT Asia'],
['https://feeds.bbci.co.uk/news/technology/rss.xml', 'BBC Tech'], ['https://feeds.bbci.co.uk/news/technology/rss.xml', 'BBC Tech'],
@@ -364,16 +367,54 @@ export async function synthesize(data) {
const defense = (data.sources.USAspending?.recentDefenseContracts || []).slice(0, 5).map(c => ({ 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) recipient: c.recipient?.substring(0, 40), amount: c.amount, desc: c.description?.substring(0, 80)
})); }));
const noaa = { totalAlerts: data.sources.NOAA?.totalSevereAlerts || 0 }; 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 // Space/CelesTrak satellite data
const spaceData = data.sources.Space || {}; 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 = { const space = {
totalNewObjects: spaceData.totalNewObjects || 0, totalNewObjects: spaceData.totalNewObjects || 0,
militarySats: spaceData.militarySatellites || 0, militarySats: spaceData.militarySatellites || 0,
militaryByCountry: spaceData.militaryByCountry || {}, militaryByCountry: spaceData.militaryByCountry || {},
constellations: spaceData.constellations || {}, constellations: spaceData.constellations || {},
iss: spaceData.iss || null, iss: spaceData.iss || null,
issPosition: issPos,
stationPositions: spaceStations.slice(0, 5),
recentLaunches: (spaceData.recentLaunches || []).slice(0, 10).map(l => ({ recentLaunches: (spaceData.recentLaunches || []).slice(0, 10).map(l => ({
name: l.name, country: l.country, epoch: l.epoch, name: l.name, country: l.country, epoch: l.epoch,
apogee: l.apogee, perigee: l.perigee, type: l.objectType apogee: l.apogee, perigee: l.perigee, type: l.objectType
@@ -395,7 +436,7 @@ export async function synthesize(data) {
})) }))
}; };
// GDELT news articles // GDELT news articles + geo events
const gdeltData = data.sources.GDELT || {}; const gdeltData = data.sources.GDELT || {};
const gdelt = { const gdelt = {
totalArticles: gdeltData.totalArticles || 0, totalArticles: gdeltData.totalArticles || 0,
@@ -403,7 +444,10 @@ export async function synthesize(data) {
economy: (gdeltData.economy || []).length, economy: (gdeltData.economy || []).length,
health: (gdeltData.health || []).length, health: (gdeltData.health || []).length,
crisis: (gdeltData.crisis || []).length, crisis: (gdeltData.crisis || []).length,
topTitles: (gdeltData.allArticles || []).slice(0, 5).map(a => a.title?.substring(0, 80)) 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]) => ({ const health = Object.entries(data.sources).map(([name, src]) => ({
@@ -454,7 +498,7 @@ export async function synthesize(data) {
meta: data.crucix, air, thermal, tSignals, chokepoints, nuke, nukeSignals, meta: data.crucix, air, thermal, tSignals, chokepoints, nuke, nukeSignals,
sdr: { total: sdrNet.totalReceivers || 0, online: sdrNet.online || 0, zones: sdrZones }, sdr: { total: sdrNet.totalReceivers || 0, online: sdrNet.online || 0, zones: sdrZones },
tg: { posts: tgData.totalPosts || 0, urgent: tgUrgent, topPosts: tgTop }, tg: { posts: tgData.totalPosts || 0, urgent: tgUrgent, topPosts: tgTop },
who, fred, energy, bls, treasury, gscpi, defense, noaa, acled, gdelt, space, health, news, who, fred, energy, bls, treasury, gscpi, defense, noaa, epa, acled, gdelt, space, health, news,
markets, // Live Yahoo Finance market data markets, // Live Yahoo Finance market data
ideas: [], ideasSource: 'disabled', ideas: [], ideasSource: 'disabled',
// newsFeed for ticker (merged RSS + GDELT + Telegram) // newsFeed for ticker (merged RSS + GDELT + Telegram)
@@ -511,11 +555,42 @@ function buildNewsFeed(rssNews, gdeltData, tgUrgent, tgTop) {
} }
// === CLI Mode: inject into HTML file === // === 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() { async function cliInject() {
const data = JSON.parse(readFileSync(join(ROOT, 'runs/latest.json'), 'utf8')); 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...'); console.log('Fetching RSS news feeds...');
const V2 = await synthesize(data); 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`); console.log(`Generated ${V2.ideas.length} leverageable ideas`);
const json = JSON.stringify(V2); const json = JSON.stringify(V2);
@@ -523,12 +598,15 @@ async function cliInject() {
console.log('Size:', json.length, 'bytes | Air:', V2.air.length, '| Thermal:', V2.thermal.length, 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); '| News:', V2.news.length, '| Ideas:', V2.ideas.length, '| Sources:', V2.health.length);
const htmlPath = join(ROOT, 'dashboard/public/jarvis.html'); const htmlPath = htmlOverride || join(ROOT, 'dashboard/public/jarvis.html');
let html = readFileSync(htmlPath, 'utf8'); let html = readFileSync(htmlPath, 'utf8');
html = html.replace(/^(let|const) D = .*;\s*$/m, 'let D = ' + json + ';'); // 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); writeFileSync(htmlPath, html);
console.log('Data injected into jarvis.html!'); console.log('Data injected into jarvis.html!');
if (!shouldOpen) return;
// Auto-open dashboard in default browser // Auto-open dashboard in default browser
// NOTE: On Windows, `start` in PowerShell is an alias for Start-Service, not cmd's start. // 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. // We must use `cmd /c start ""` to ensure it works in both cmd.exe and PowerShell.
@@ -542,7 +620,8 @@ async function cliInject() {
} }
// Run CLI if invoked directly // Run CLI if invoked directly
const isMain = process.argv[1] && fileURLToPath(import.meta.url).includes(process.argv[1].replace(/\\/g, '/')); const isMain = process.argv[1]
&& fileURLToPath(import.meta.url).replace(/\\/g, '/') === process.argv[1].replace(/\\/g, '/');
if (isMain) { if (isMain) {
cliInject(); await cliInject();
} }

View File

@@ -298,12 +298,12 @@ let flightsVisible = true;
let isFlat = true; let isFlat = true;
let flatSvg, flatProjection, flatPath, flatG, flatZoom, flatW, flatH; let flatSvg, flatProjection, flatPath, flatG, flatZoom, flatW, flatH;
const regionPOV = { const regionPOV = {
world: { lat: 20, lng: 20, altitude: 2.5 }, world: { lat: 20, lng: 20, altitude: 1.8 },
americas: { lat: 15, lng: -80, altitude: 1.6 }, americas: { lat: 35, lng: -95, altitude: 1.0 },
europe: { lat: 50, lng: 15, altitude: 1.2 }, europe: { lat: 50, lng: 15, altitude: 1.0 },
middleEast: { lat: 28, lng: 45, altitude: 1.4 }, middleEast: { lat: 28, lng: 45, altitude: 1.1 },
asiaPacific: { lat: 25, lng: 110, altitude: 1.6 }, asiaPacific: { lat: 25, lng: 110, altitude: 1.2 },
africa: { lat: 5, lng: 20, altitude: 1.5 } africa: { lat: 5, lng: 20, altitude: 1.2 }
}; };
// === TOPBAR === // === TOPBAR ===
@@ -414,7 +414,7 @@ function initMap(){
.pointRadius(d => d.size || 0.3) .pointRadius(d => d.size || 0.3)
.pointColor(d => d.color) .pointColor(d => d.color)
.pointLabel(d => `<b>${d.popHead||''}</b><br><span style="opacity:0.7">${d.popMeta||''}</span>`) .pointLabel(d => `<b>${d.popHead||''}</b><br><span style="opacity:0.7">${d.popMeta||''}</span>`)
.onPointClick((pt, ev) => { showPopup(ev, pt.popHead, pt.popText, pt.popMeta); }) .onPointClick((pt, ev) => { showPopup(ev, pt.popHead, pt.popText, pt.popMeta, pt.lat, pt.lng, pt.alt); })
.onPointHover(pt => { document.getElementById('globeViz').style.cursor = pt ? 'pointer' : 'grab'; }) .onPointHover(pt => { document.getElementById('globeViz').style.cursor = pt ? 'pointer' : 'grab'; })
// Arcs layer (flight corridors) // Arcs layer (flight corridors)
.arcColor(d => d.color) .arcColor(d => d.color)
@@ -502,7 +502,7 @@ function initMap(){
// Legend // Legend
document.getElementById('mapLegend').innerHTML= document.getElementById('mapLegend').innerHTML=
[{c:'#64f0c8',l:'Air Traffic'},{c:'#ff5f63',l:'Thermal/Fire'},{c:'rgba(255,120,80,0.8)',l:'Conflict'},{c:'#44ccff',l:'SDR Receiver'}, [{c:'#64f0c8',l:'Air Traffic'},{c:'#ff5f63',l:'Thermal/Fire'},{c:'rgba(255,120,80,0.8)',l:'Conflict'},{c:'#44ccff',l:'SDR Receiver'},
{c:'#ffe082',l:'Nuclear Site'},{c:'#b388ff',l:'Chokepoint'},{c:'#ffb84c',l:'OSINT Event'},{c:'#69f0ae',l:'Health Alert'},{c:'#81d4fa',l:'World News'}] {c:'#ffe082',l:'Nuclear Site'},{c:'#b388ff',l:'Chokepoint'},{c:'#ffb84c',l:'OSINT Event'},{c:'#69f0ae',l:'Health Alert'},{c:'#81d4fa',l:'World News'},{c:'#ff9800',l:'Weather Alert'},{c:'#cddc39',l:'EPA RadNet'},{c:'#ffffff',l:'Space Station'},{c:'#6495ed',l:'GDELT Event'}]
.map(x=>`<div class="leg-item"><div class="leg-dot" style="background:${x.c}"></div>${x.l}</div>`).join(''); .map(x=>`<div class="leg-item"><div class="leg-dot" style="background:${x.c}"></div>${x.l}</div>`).join('');
} }
@@ -511,12 +511,12 @@ function plotMarkers(){
const labels = []; const labels = [];
// === Air hotspots (green) === // === Air hotspots (green) ===
const airCoords=[{lat:30,lon:44},{lat:24,lon:120},{lat:49,lon:32},{lat:57,lon:24},{lat:14,lon:114},{lat:37,lon:127}]; const airCoords=[{lat:30,lon:44},{lat:24,lon:120},{lat:49,lon:32},{lat:57,lon:24},{lat:14,lon:114},{lat:37,lon:127},{lat:25,lon:-80},{lat:4,lon:2},{lat:-34,lon:18},{lat:10,lon:51}];
D.air.forEach((a,i)=>{ D.air.forEach((a,i)=>{
const c=airCoords[i]; if(!c) return; const c=airCoords[i]; if(!c) return;
points.push({ points.push({
lat:c.lat, lng:c.lon, size:0.25+a.total/200, alt:0.015, lat:c.lat, lng:c.lon, size:0.25+a.total/200, alt:0.015,
color:'rgba(100,240,200,0.8)', type:'air', color:'rgba(100,240,200,0.8)', type:'air', priority:1,
label: a.region.replace(' Region','')+' '+a.total, label: a.region.replace(' Region','')+' '+a.total,
popHead: a.region, popMeta: 'Air Activity', popHead: a.region, popMeta: 'Air Activity',
popText: `${a.total} aircraft tracked<br>No callsign: ${a.noCallsign}<br>High altitude: ${a.highAlt}<br>Top: ${a.top.slice(0,3).map(t=>t[0]+' ('+t[1]+')').join(', ')}` popText: `${a.total} aircraft tracked<br>No callsign: ${a.noCallsign}<br>High altitude: ${a.highAlt}<br>Top: ${a.top.slice(0,3).map(t=>t[0]+' ('+t[1]+')').join(', ')}`
@@ -529,7 +529,7 @@ function plotMarkers(){
t.fires.forEach(f=>{ t.fires.forEach(f=>{
points.push({ points.push({
lat:f.lat, lng:f.lon, size:0.12+Math.min(f.frp/200,0.3), alt:0.008, lat:f.lat, lng:f.lon, size:0.12+Math.min(f.frp/200,0.3), alt:0.008,
color:'rgba(255,95,99,0.7)', type:'thermal', color:'rgba(255,95,99,0.7)', type:'thermal', priority:3,
popHead:'Thermal Detection', popMeta:'FIRMS Satellite', popHead:'Thermal Detection', popMeta:'FIRMS Satellite',
popText:`Region: ${t.region}<br>FRP: ${f.frp.toFixed(1)} MW<br>Total: ${t.det.toLocaleString()}<br>Night: ${t.night.toLocaleString()}` popText:`Region: ${t.region}<br>FRP: ${f.frp.toFixed(1)} MW<br>Total: ${t.det.toLocaleString()}<br>Night: ${t.night.toLocaleString()}`
}); });
@@ -540,7 +540,7 @@ function plotMarkers(){
D.chokepoints.forEach(cp=>{ D.chokepoints.forEach(cp=>{
points.push({ points.push({
lat:cp.lat, lng:cp.lon, size:0.35, alt:0.02, lat:cp.lat, lng:cp.lon, size:0.35, alt:0.02,
color:'rgba(179,136,255,0.8)', type:'maritime', color:'rgba(179,136,255,0.8)', type:'maritime', priority:1,
popHead:cp.label, popMeta:'Maritime Intelligence', popText:cp.note popHead:cp.label, popMeta:'Maritime Intelligence', popText:cp.note
}); });
labels.push({lat:cp.lat, lng:cp.lon+1.5, text:cp.label, size:0.3, color:'rgba(179,136,255,0.6)'}); labels.push({lat:cp.lat, lng:cp.lon+1.5, text:cp.label, size:0.3, color:'rgba(179,136,255,0.6)'});
@@ -552,7 +552,7 @@ function plotMarkers(){
const c=nukeCoords[i]; if(!c) return; const c=nukeCoords[i]; if(!c) return;
points.push({ points.push({
lat:c.lat, lng:c.lon, size:0.3, alt:0.012, lat:c.lat, lng:c.lon, size:0.3, alt:0.012,
color: n.anom ? 'rgba(255,95,99,0.9)' : 'rgba(255,224,130,0.8)', type:'nuke', color: n.anom ? 'rgba(255,95,99,0.9)' : 'rgba(255,224,130,0.8)', type:'nuke', priority:2,
popHead:n.site, popMeta:'Radiation Monitoring', popHead:n.site, popMeta:'Radiation Monitoring',
popText:`Status: ${n.anom?'ANOMALY':'Normal'}<br>Avg CPM: ${n.cpm?.toFixed(1)||'No data'}<br>Readings: ${n.n}` popText:`Status: ${n.anom?'ANOMALY':'Normal'}<br>Avg CPM: ${n.cpm?.toFixed(1)||'No data'}<br>Readings: ${n.n}`
}); });
@@ -563,7 +563,7 @@ function plotMarkers(){
z.receivers.forEach(r=>{ z.receivers.forEach(r=>{
points.push({ points.push({
lat:r.lat, lng:r.lon, size:0.15, alt:0.005, lat:r.lat, lng:r.lon, size:0.15, alt:0.005,
color:'rgba(68,204,255,0.6)', type:'sdr', color:'rgba(68,204,255,0.6)', type:'sdr', priority:3,
popHead:'SDR Receiver', popMeta:'KiwiSDR Network', popHead:'SDR Receiver', popMeta:'KiwiSDR Network',
popText:`${r.name}<br>Zone: ${z.region}<br>${z.count} in zone` popText:`${r.name}<br>Zone: ${z.region}<br>${z.count} in zone`
}); });
@@ -576,7 +576,7 @@ function plotMarkers(){
const post=D.tg.urgent[o.idx]; if(!post) return; const post=D.tg.urgent[o.idx]; if(!post) return;
points.push({ points.push({
lat:o.lat, lng:o.lon, size:0.3, alt:0.018, lat:o.lat, lng:o.lon, size:0.3, alt:0.018,
color:'rgba(255,184,76,0.8)', type:'osint', color:'rgba(255,184,76,0.8)', type:'osint', priority:2,
popHead:(post.channel||'').toUpperCase(), popMeta:`${post.views?.toLocaleString()||'?'} views`, popHead:(post.channel||'').toUpperCase(), popMeta:`${post.views?.toLocaleString()||'?'} views`,
popText:cleanText(post.text?.substring(0,200)||'') popText:cleanText(post.text?.substring(0,200)||'')
}); });
@@ -588,7 +588,7 @@ function plotMarkers(){
const c=whoGeo[i]; if(!c) return; const c=whoGeo[i]; if(!c) return;
points.push({ points.push({
lat:c.lat, lng:c.lon, size:0.25, alt:0.01, lat:c.lat, lng:c.lon, size:0.25, alt:0.01,
color:'rgba(105,240,174,0.7)', type:'health', color:'rgba(105,240,174,0.7)', type:'health', priority:2,
popHead:w.title, popMeta:'WHO Outbreak', popText:w.summary||'' popHead:w.title, popMeta:'WHO Outbreak', popText:w.summary||''
}); });
}); });
@@ -597,12 +597,53 @@ function plotMarkers(){
(D.news||[]).forEach(n=>{ (D.news||[]).forEach(n=>{
points.push({ points.push({
lat:n.lat, lng:n.lon, size:0.2, alt:0.008, lat:n.lat, lng:n.lon, size:0.2, alt:0.008,
color:'rgba(129,212,250,0.7)', type:'news', color:'rgba(129,212,250,0.7)', type:'news', priority:3,
popHead:n.source+' NEWS', popMeta:n.region+' · '+getAge(n.date), popHead:n.source+' NEWS', popMeta:n.region+' · '+getAge(n.date),
popText:cleanText(n.title) popText:cleanText(n.title)
}); });
}); });
// === NOAA severe weather alerts (orange) ===
(D.noaa?.alerts||[]).forEach(a=>{
points.push({
lat:a.lat, lng:a.lon, size:0.22, alt:0.01,
color:'rgba(255,152,0,0.8)', type:'weather', priority:2,
popHead:a.event, popMeta:'NOAA/NWS · '+a.severity,
popText:a.headline||''
});
});
// === EPA RadNet stations (lime green) ===
(D.epa?.stations||[]).forEach(s=>{
points.push({
lat:s.lat, lng:s.lon, size:0.18, alt:0.006,
color:'rgba(205,220,57,0.7)', type:'radiation', priority:3,
popHead:'RadNet: '+s.location, popMeta:'EPA Radiation Monitor',
popText:`${s.analyte||'--'}: ${s.result||'--'} ${s.unit||''}<br>State: ${s.state}`
});
});
// === ISS + Space Stations (bright white, pulsing) ===
(D.space?.stationPositions||[]).forEach(s=>{
points.push({
lat:s.lat, lng:s.lon, size:0.4, alt:0.04,
color:'rgba(255,255,255,0.95)', type:'space', priority:1,
popHead:s.name, popMeta:'Space Station (approx.)',
popText:`Orbital position estimate<br>Lat: ${s.lat}° Lon: ${s.lon}°`
});
labels.push({lat:s.lat, lng:s.lon+3, text:s.name.split('(')[0].trim(), size:0.35, color:'rgba(255,255,255,0.7)'});
});
// === GDELT geo events (steel blue) ===
(D.gdelt?.geoPoints||[]).forEach(g=>{
points.push({
lat:g.lat, lng:g.lon, size:0.15+Math.min(g.count/50,0.2), alt:0.007,
color:'rgba(100,149,237,0.6)', type:'gdelt', priority:3,
popHead:'GDELT Event', popMeta:g.count+' reports',
popText:g.name||'Global event detection'
});
});
// Set points on globe // Set points on globe
globe.pointsData(points); globe.pointsData(points);
globe.labelsData(labels); globe.labelsData(labels);
@@ -625,7 +666,9 @@ function plotMarkers(){
const airCoordsFlight = [ const airCoordsFlight = [
{region:'Middle East',lat:30,lon:44}, {region:'Taiwan Strait',lat:24,lon:120}, {region:'Middle East',lat:30,lon:44}, {region:'Taiwan Strait',lat:24,lon:120},
{region:'Ukraine Region',lat:49,lon:32}, {region:'Baltic Region',lat:57,lon:24}, {region:'Ukraine Region',lat:49,lon:32}, {region:'Baltic Region',lat:57,lon:24},
{region:'South China Sea',lat:14,lon:114}, {region:'Korean Peninsula',lat:37,lon:127} {region:'South China Sea',lat:14,lon:114}, {region:'Korean Peninsula',lat:37,lon:127},
{region:'Caribbean',lat:25,lon:-80}, {region:'Gulf of Guinea',lat:4,lon:2},
{region:'Cape Route',lat:-34,lon:18}, {region:'Horn of Africa',lat:10,lon:51}
]; ];
const globalHubs = [ const globalHubs = [
{lat:40.6,lon:-73.8},{lat:51.5,lon:-0.5},{lat:25.3,lon:55.4}, {lat:40.6,lon:-73.8},{lat:51.5,lon:-0.5},{lat:25.3,lon:55.4},
@@ -667,14 +710,41 @@ function plotMarkers(){
}); });
}); });
globe.arcsData(arcs); globe.arcsData(arcs);
// Zoom-aware marker sizing: scale markers and labels with camera altitude
const onGlobeZoom = () => {
const alt = globe.pointOfView().altitude;
const sf = Math.max(0.6, Math.min(2.5, 1.5 / alt));
globe.pointRadius(d => (d.size || 0.3) * sf);
// Hide labels when zoomed far out to reduce clutter
const showLabels = alt < 1.8;
globe.labelSize(d => showLabels ? (d.size || 0.4) : 0);
// Scale arc strokes with zoom
globe.arcStroke(d => (d.stroke || 0.4) * Math.max(0.5, Math.min(1.5, 1.2 / alt)));
// Priority-based point visibility: hide low-priority markers when zoomed out
if(alt > 2.0){
globe.pointsData(points.filter(p => (p.priority||3) <= 1));
} else if(alt > 1.2){
globe.pointsData(points.filter(p => (p.priority||3) <= 2));
} else {
globe.pointsData(points);
}
};
if(typeof globe.onZoom==='function') globe.onZoom(onGlobeZoom);
} }
function showPopup(event,head,text,meta){ function showPopup(event,head,text,meta,lat,lng,alt){
const popup=document.getElementById('mapPopup'); const popup=document.getElementById('mapPopup');
const container=document.getElementById('mapContainer'); const container=document.getElementById('mapContainer');
const rect=container.getBoundingClientRect(); const rect=container.getBoundingClientRect();
let left, top; let left, top;
if(event && event.clientX != null){ if(!isFlat && lat!=null && globe && typeof globe.getScreenCoords==='function'){
const sc=globe.getScreenCoords(lat,lng,alt||0.01);
if(!sc||isNaN(sc.x)||isNaN(sc.y)||sc.x<0||sc.y<0||sc.x>rect.width||sc.y>rect.height){
if(event&&event.clientX!=null){left=event.clientX-rect.left+10;top=event.clientY-rect.top-10;}
else return;
} else {left=sc.x+10;top=sc.y-10;}
} else if(event && event.clientX != null){
left=event.clientX - rect.left + 10; left=event.clientX - rect.left + 10;
top=event.clientY - rect.top - 10; top=event.clientY - rect.top - 10;
} else { } else {
@@ -711,7 +781,7 @@ function toggleFlights() {
// === FLAT/GLOBE TOGGLE === // === FLAT/GLOBE TOGGLE ===
const flatRegionBounds = { const flatRegionBounds = {
world:[[-180,-60],[180,80]], americas:[[-170,-56],[-30,72]], europe:[[-12,34],[45,72]], world:[[-180,-60],[180,80]], americas:[[-130,10],[-60,55]], europe:[[-12,34],[45,72]],
middleEast:[[24,10],[65,45]], asiaPacific:[[60,-12],[180,55]], africa:[[-20,-36],[55,38]] middleEast:[[24,10],[65,45]], asiaPacific:[[60,-12],[180,55]], africa:[[-20,-36],[55,38]]
}; };
@@ -745,7 +815,15 @@ function initFlatMap(){
flatG.attr('transform',event.transform); flatG.attr('transform',event.transform);
const k=event.transform.k; const k=event.transform.k;
flatG.selectAll('.marker-circle').attr('r',function(){return +this.dataset.baseR/Math.sqrt(k)}); flatG.selectAll('.marker-circle').attr('r',function(){return +this.dataset.baseR/Math.sqrt(k)});
flatG.selectAll('.marker-label').style('font-size',Math.max(7,9/Math.sqrt(k))+'px'); flatG.selectAll('.marker-label').style('font-size',Math.max(7,9/Math.sqrt(k))+'px')
.style('display',k>=2.5?'block':'none');
// Priority-based visibility: hide low-priority markers at low zoom
flatG.selectAll('[data-priority]').style('display',function(){
const p=+this.dataset.priority;
if(p<=1) return 'block';
if(p<=2) return k>=2?'block':'none';
return k>=3.5?'block':'none';
});
}); });
flatSvg.call(flatZoom); flatSvg.call(flatZoom);
drawFlatMap(); drawFlatMap();
@@ -765,58 +843,69 @@ function drawFlatMap(){
function plotFlatMarkers(){ function plotFlatMarkers(){
const mg=flatG.append('g').attr('class','markers'); const mg=flatG.append('g').attr('class','markers');
const proj=flatProjection; const proj=flatProjection;
const addPt=(lat,lon,r,fill,stroke,onClick)=>{ const addPt=(lat,lon,r,fill,stroke,onClick,priority)=>{
const[x,y]=proj([lon,lat]);if(!x||!y)return null; const[x,y]=proj([lon,lat]);if(!x||!y)return null;
const g=mg.append('g').attr('transform',`translate(${x},${y})`).style('cursor','pointer'); const g=mg.append('g').attr('transform',`translate(${x},${y})`).style('cursor','pointer').attr('data-priority',priority||3);
if(onClick) g.on('click',ev=>{ev.stopPropagation();onClick(ev)}); if(onClick) g.on('click',ev=>{ev.stopPropagation();onClick(ev)});
g.append('circle').attr('class','marker-circle').attr('r',r).attr('data-base-r',r).attr('fill',fill).attr('stroke',stroke).attr('stroke-width',0.8); g.append('circle').attr('class','marker-circle').attr('r',r).attr('data-base-r',r).attr('fill',fill).attr('stroke',stroke).attr('stroke-width',0.8);
return g; return g;
}; };
// Air // Air
const airCoords=[{lat:30,lon:44},{lat:24,lon:120},{lat:49,lon:32},{lat:57,lon:24},{lat:14,lon:114},{lat:37,lon:127}]; const airCoords=[{lat:30,lon:44},{lat:24,lon:120},{lat:49,lon:32},{lat:57,lon:24},{lat:14,lon:114},{lat:37,lon:127},{lat:25,lon:-80},{lat:4,lon:2},{lat:-34,lon:18},{lat:10,lon:51}];
D.air.forEach((a,i)=>{ D.air.forEach((a,i)=>{
const c=airCoords[i];if(!c)return; const c=airCoords[i];if(!c)return;
const g=addPt(c.lat,c.lon,4+a.total/40,'rgba(100,240,200,0.7)','rgba(100,240,200,0.3)', const g=addPt(c.lat,c.lon,4+a.total/40,'rgba(100,240,200,0.7)','rgba(100,240,200,0.3)',
ev=>showPopup(ev,a.region,`${a.total} aircraft<br>No callsign: ${a.noCallsign}<br>High alt: ${a.highAlt}`,'Air Activity')); ev=>showPopup(ev,a.region,`${a.total} aircraft<br>No callsign: ${a.noCallsign}<br>High alt: ${a.highAlt}`,'Air Activity'),1);
if(g) g.append('text').attr('class','marker-label').attr('x',10).attr('y',3).attr('fill','var(--dim)').attr('font-size','9px').attr('font-family','var(--mono)').text(a.region.replace(' Region','')+' '+a.total); if(g) g.append('text').attr('class','marker-label').attr('x',10).attr('y',3).attr('fill','var(--dim)').attr('font-size','9px').attr('font-family','var(--mono)').text(a.region.replace(' Region','')+' '+a.total);
}); });
// Thermal // Thermal
D.thermal.forEach(t=>t.fires.forEach(f=>{ D.thermal.forEach(t=>t.fires.forEach(f=>{
addPt(f.lat,f.lon,2+Math.min(f.frp/50,5),'rgba(255,95,99,0.6)','rgba(255,95,99,0.2)', addPt(f.lat,f.lon,2+Math.min(f.frp/50,5),'rgba(255,95,99,0.6)','rgba(255,95,99,0.2)',
ev=>showPopup(ev,'Thermal',`${t.region}<br>FRP: ${f.frp.toFixed(1)} MW`,'FIRMS')); ev=>showPopup(ev,'Thermal',`${t.region}<br>FRP: ${f.frp.toFixed(1)} MW`,'FIRMS'),3);
})); }));
// Chokepoints // Chokepoints
D.chokepoints.forEach(cp=>{ D.chokepoints.forEach(cp=>{
const[x,y]=proj([cp.lon,cp.lat]);if(!x||!y)return; const[x,y]=proj([cp.lon,cp.lat]);if(!x||!y)return;
const g=mg.append('g').attr('transform',`translate(${x},${y})`).style('cursor','pointer') const g=mg.append('g').attr('transform',`translate(${x},${y})`).style('cursor','pointer').attr('data-priority',1)
.on('click',ev=>{ev.stopPropagation();showPopup(ev,cp.label,cp.note,'Maritime')}); .on('click',ev=>{ev.stopPropagation();showPopup(ev,cp.label,cp.note,'Maritime')});
g.append('rect').attr('x',-4).attr('y',-4).attr('width',8).attr('height',8).attr('fill','rgba(179,136,255,0.7)').attr('stroke','rgba(179,136,255,0.3)').attr('stroke-width',0.5).attr('transform','rotate(45)'); g.append('rect').attr('x',-4).attr('y',-4).attr('width',8).attr('height',8).attr('fill','rgba(179,136,255,0.7)').attr('stroke','rgba(179,136,255,0.3)').attr('stroke-width',0.5).attr('transform','rotate(45)');
g.append('text').attr('class','marker-label').attr('x',8).attr('y',3).attr('fill','var(--dim)').attr('font-size','8px').attr('font-family','var(--mono)').text(cp.label); g.append('text').attr('class','marker-label').attr('x',8).attr('y',3).attr('fill','var(--dim)').attr('font-size','8px').attr('font-family','var(--mono)').text(cp.label);
}); });
// Nuclear // Nuclear
const nukeCoords=[{lat:47.5,lon:34.6},{lat:51.4,lon:30.1},{lat:28.8,lon:50.9},{lat:39.8,lon:125.8},{lat:37.4,lon:141},{lat:31.0,lon:35.1}]; const nukeCoords=[{lat:47.5,lon:34.6},{lat:51.4,lon:30.1},{lat:28.8,lon:50.9},{lat:39.8,lon:125.8},{lat:37.4,lon:141},{lat:31.0,lon:35.1}];
D.nuke.forEach((n,i)=>{const c=nukeCoords[i];if(!c)return;addPt(c.lat,c.lon,4,'rgba(255,224,130,0.7)','rgba(255,224,130,0.3)',ev=>showPopup(ev,n.site,`CPM: ${n.cpm?.toFixed(1)||'--'}`,'Radiation'))}); D.nuke.forEach((n,i)=>{const c=nukeCoords[i];if(!c)return;addPt(c.lat,c.lon,4,'rgba(255,224,130,0.7)','rgba(255,224,130,0.3)',ev=>showPopup(ev,n.site,`CPM: ${n.cpm?.toFixed(1)||'--'}`,'Radiation'),2)});
// SDR // SDR
D.sdr.zones.forEach(z=>z.receivers.forEach(r=>{addPt(r.lat,r.lon,2.5,'rgba(68,204,255,0.5)','rgba(68,204,255,0.2)',ev=>showPopup(ev,'SDR',`${r.name}<br>${z.region}`,'KiwiSDR'))})); D.sdr.zones.forEach(z=>z.receivers.forEach(r=>{addPt(r.lat,r.lon,2.5,'rgba(68,204,255,0.5)','rgba(68,204,255,0.2)',ev=>showPopup(ev,'SDR',`${r.name}<br>${z.region}`,'KiwiSDR'),3)}));
// OSINT // OSINT
const osintGeo=[{lat:45,lon:41,idx:0},{lat:48,lon:37,idx:1},{lat:48.5,lon:37.5,idx:2},{lat:45,lon:40.2,idx:3},{lat:50.6,lon:36.6,idx:5},{lat:48.5,lon:35,idx:6}]; const osintGeo=[{lat:45,lon:41,idx:0},{lat:48,lon:37,idx:1},{lat:48.5,lon:37.5,idx:2},{lat:45,lon:40.2,idx:3},{lat:50.6,lon:36.6,idx:5},{lat:48.5,lon:35,idx:6}];
osintGeo.forEach(o=>{const p=D.tg.urgent[o.idx];if(!p)return;addPt(o.lat,o.lon,4,'rgba(255,184,76,0.7)','rgba(255,184,76,0.3)',ev=>showPopup(ev,(p.channel||'').toUpperCase(),cleanText(p.text?.substring(0,200)||''),`${p.views||'?'} views`))}); osintGeo.forEach(o=>{const p=D.tg.urgent[o.idx];if(!p)return;addPt(o.lat,o.lon,4,'rgba(255,184,76,0.7)','rgba(255,184,76,0.3)',ev=>showPopup(ev,(p.channel||'').toUpperCase(),cleanText(p.text?.substring(0,200)||''),`${p.views||'?'} views`),2)});
// WHO // WHO
const whoGeo=[{lat:0.3,lon:32.6},{lat:-6.2,lon:106.8},{lat:-4.3,lon:15.3},{lat:35,lon:105},{lat:12.5,lon:105},{lat:35,lon:105},{lat:28,lon:84},{lat:24,lon:45},{lat:30,lon:70},{lat:-0.8,lon:11.6}]; const whoGeo=[{lat:0.3,lon:32.6},{lat:-6.2,lon:106.8},{lat:-4.3,lon:15.3},{lat:35,lon:105},{lat:12.5,lon:105},{lat:35,lon:105},{lat:28,lon:84},{lat:24,lon:45},{lat:30,lon:70},{lat:-0.8,lon:11.6}];
D.who.slice(0,10).forEach((w,i)=>{const c=whoGeo[i];if(!c)return;addPt(c.lat,c.lon,3.5,'rgba(105,240,174,0.6)','rgba(105,240,174,0.2)',ev=>showPopup(ev,w.title,w.summary||'','WHO'))}); D.who.slice(0,10).forEach((w,i)=>{const c=whoGeo[i];if(!c)return;addPt(c.lat,c.lon,3.5,'rgba(105,240,174,0.6)','rgba(105,240,174,0.2)',ev=>showPopup(ev,w.title,w.summary||'','WHO'),2)});
// News // News
(D.news||[]).forEach(n=>{addPt(n.lat,n.lon,3,'rgba(129,212,250,0.6)','rgba(129,212,250,0.2)',ev=>showPopup(ev,n.source+' NEWS',cleanText(n.title),n.region))}); (D.news||[]).forEach(n=>{addPt(n.lat,n.lon,3,'rgba(129,212,250,0.6)','rgba(129,212,250,0.2)',ev=>showPopup(ev,n.source+' NEWS',cleanText(n.title),n.region),3)});
// NOAA weather
(D.noaa?.alerts||[]).forEach(a=>{addPt(a.lat,a.lon,4,'rgba(255,152,0,0.7)','rgba(255,152,0,0.3)',ev=>showPopup(ev,a.event,a.headline||'','NOAA/NWS'),2)});
// EPA RadNet
(D.epa?.stations||[]).forEach(s=>{addPt(s.lat,s.lon,3,'rgba(205,220,57,0.6)','rgba(205,220,57,0.2)',ev=>showPopup(ev,'RadNet: '+s.location,`${s.analyte||'--'}: ${s.result||'--'} ${s.unit||''}`,'EPA'),3)});
// Space stations
(D.space?.stationPositions||[]).forEach(s=>{
const g=addPt(s.lat,s.lon,5,'rgba(255,255,255,0.9)','rgba(255,255,255,0.4)',ev=>showPopup(ev,s.name,'Orbital position estimate','Space Station'),1);
if(g) g.append('text').attr('class','marker-label').attr('x',8).attr('y',3).attr('fill','rgba(255,255,255,0.7)').attr('font-size','8px').attr('font-family','var(--mono)').text(s.name.split('(')[0].trim());
});
// GDELT geo events
(D.gdelt?.geoPoints||[]).forEach(g=>{addPt(g.lat,g.lon,2.5,'rgba(100,149,237,0.5)','rgba(100,149,237,0.2)',ev=>showPopup(ev,'GDELT Event',g.name||'','GDELT · '+g.count+' reports'),3)});
// ACLED // ACLED
(D.acled?.deadliestEvents||[]).filter(e=>e.lat&&e.lon).forEach(e=>{ (D.acled?.deadliestEvents||[]).filter(e=>e.lat&&e.lon).forEach(e=>{
const[x,y]=proj([e.lon,e.lat]);if(!x||!y)return; const[x,y]=proj([e.lon,e.lat]);if(!x||!y)return;
const r=Math.max(4,Math.min(14,2+Math.log2(Math.max(e.fatalities,1))*1.5)); const r=Math.max(4,Math.min(14,2+Math.log2(Math.max(e.fatalities,1))*1.5));
const g=mg.append('g').attr('transform',`translate(${x},${y})`).style('cursor','pointer') const g=mg.append('g').attr('transform',`translate(${x},${y})`).style('cursor','pointer').attr('data-priority',1)
.on('click',ev=>{ev.stopPropagation();showPopup(ev,e.type||'CONFLICT',`${e.fatalities} fatalities<br>${e.location}, ${e.country}`,'ACLED')}); .on('click',ev=>{ev.stopPropagation();showPopup(ev,e.type||'CONFLICT',`${e.fatalities} fatalities<br>${e.location}, ${e.country}`,'ACLED')});
g.append('circle').attr('class','conflict-ring marker-circle').attr('r',r).attr('data-base-r',r).attr('fill','none').attr('stroke','rgba(255,120,80,0.7)').attr('stroke-width',1.5); g.append('circle').attr('class','conflict-ring marker-circle').attr('r',r).attr('data-base-r',r).attr('fill','none').attr('stroke','rgba(255,120,80,0.7)').attr('stroke-width',1.5);
g.append('circle').attr('r',r*0.4).attr('fill','rgba(255,120,80,0.3)'); g.append('circle').attr('r',r*0.4).attr('fill','rgba(255,120,80,0.3)');
}); });
// Flight corridors // Flight corridors
const airCoordsFlight=[{lat:30,lon:44},{lat:24,lon:120},{lat:49,lon:32},{lat:57,lon:24},{lat:14,lon:114},{lat:37,lon:127}]; const airCoordsFlight=[{lat:30,lon:44},{lat:24,lon:120},{lat:49,lon:32},{lat:57,lon:24},{lat:14,lon:114},{lat:37,lon:127},{lat:25,lon:-80},{lat:4,lon:2},{lat:-34,lon:18},{lat:10,lon:51}];
const hubs=[{lat:40.6,lon:-73.8},{lat:51.5,lon:-0.5},{lat:25.3,lon:55.4},{lat:1.4,lon:103.8},{lat:-33.9,lon:151.2},{lat:-23.4,lon:-46.5}]; const hubs=[{lat:40.6,lon:-73.8},{lat:51.5,lon:-0.5},{lat:25.3,lon:55.4},{lat:1.4,lon:103.8},{lat:-33.9,lon:151.2},{lat:-23.4,lon:-46.5}];
const cG=flatG.append('g').attr('class','corridors-layer'); const cG=flatG.append('g').attr('class','corridors-layer');
for(let i=0;i<D.air.length;i++){for(let j=i+1;j<D.air.length;j++){ for(let i=0;i<D.air.length;i++){for(let j=i+1;j<D.air.length;j++){
@@ -1202,9 +1291,10 @@ function init(){
} }
document.addEventListener('DOMContentLoaded', () => { document.addEventListener('DOMContentLoaded', () => {
const isServer = location.protocol !== 'file:'; const hasInlineData = !!(D && D.meta);
const canProbeApi = location.protocol !== 'file:';
if (isServer) { if (canProbeApi && !hasInlineData) {
// Server mode: always fetch live data from API (ignore any stale inline D) // Server mode: always fetch live data from API (ignore any stale inline D)
fetch('/api/data') fetch('/api/data')
.then(r => r.json()) .then(r => r.json())
@@ -1213,7 +1303,7 @@ document.addEventListener('DOMContentLoaded', () => {
// Should not reach here — server routes to loading.html when no data // Should not reach here — server routes to loading.html when no data
if (D && D.meta) { init(); connectSSE(); } if (D && D.meta) { init(); connectSSE(); }
}); });
} else if (D && D.meta) { } else if (hasInlineData) {
// File mode: use inline data // File mode: use inline data
init(); init();
} }

View File

@@ -1,4 +1,3 @@
version: '3.8'
services: services:
crucix: crucix:
build: . build: .

View File

@@ -2,14 +2,18 @@
import { AnthropicProvider } from './anthropic.mjs'; import { AnthropicProvider } from './anthropic.mjs';
import { OpenAIProvider } from './openai.mjs'; import { OpenAIProvider } from './openai.mjs';
import { OpenRouterProvider } from './openrouter.mjs';
import { GeminiProvider } from './gemini.mjs'; import { GeminiProvider } from './gemini.mjs';
import { CodexProvider } from './codex.mjs'; import { CodexProvider } from './codex.mjs';
import { MiniMaxProvider } from './minimax.mjs';
export { LLMProvider } from './provider.mjs'; export { LLMProvider } from './provider.mjs';
export { AnthropicProvider } from './anthropic.mjs'; export { AnthropicProvider } from './anthropic.mjs';
export { OpenAIProvider } from './openai.mjs'; export { OpenAIProvider } from './openai.mjs';
export { OpenRouterProvider } from './openrouter.mjs';
export { GeminiProvider } from './gemini.mjs'; export { GeminiProvider } from './gemini.mjs';
export { CodexProvider } from './codex.mjs'; export { CodexProvider } from './codex.mjs';
export { MiniMaxProvider } from './minimax.mjs';
/** /**
* Create an LLM provider based on config. * Create an LLM provider based on config.
@@ -26,10 +30,14 @@ export function createLLMProvider(llmConfig) {
return new AnthropicProvider({ apiKey, model }); return new AnthropicProvider({ apiKey, model });
case 'openai': case 'openai':
return new OpenAIProvider({ apiKey, model }); return new OpenAIProvider({ apiKey, model });
case 'openrouter':
return new OpenRouterProvider({ apiKey, model });
case 'gemini': case 'gemini':
return new GeminiProvider({ apiKey, model }); return new GeminiProvider({ apiKey, model });
case 'codex': case 'codex':
return new CodexProvider({ model }); return new CodexProvider({ model });
case 'minimax':
return new MiniMaxProvider({ apiKey, model });
default: default:
console.warn(`[LLM] Unknown provider "${provider}". LLM features disabled.`); console.warn(`[LLM] Unknown provider "${provider}". LLM features disabled.`);
return null; return null;

51
lib/llm/minimax.mjs Normal file
View File

@@ -0,0 +1,51 @@
// MiniMax Provider — raw fetch, no SDK
// Uses MiniMax's OpenAI-compatible Chat Completions API
import { LLMProvider } from './provider.mjs';
export class MiniMaxProvider extends LLMProvider {
constructor(config) {
super(config);
this.name = 'minimax';
this.apiKey = config.apiKey;
this.model = config.model || 'MiniMax-M2.5';
}
get isConfigured() { return !!this.apiKey; }
async complete(systemPrompt, userMessage, opts = {}) {
const res = await fetch('https://api.minimax.io/v1/chat/completions', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${this.apiKey}`,
},
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 || 60000),
});
if (!res.ok) {
const err = await res.text().catch(() => '');
throw new Error(`MiniMax 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,
};
}
}

52
lib/llm/openrouter.mjs Normal file
View File

@@ -0,0 +1,52 @@
// OpenRouter Provider — raw fetch, no SDK
import { LLMProvider } from './provider.mjs';
export class OpenRouterProvider extends LLMProvider {
constructor(config) {
super(config);
this.name = 'openrouter';
this.apiKey = config.apiKey;
this.model = config.model || 'openrouter/auto';
}
get isConfigured() { return !!this.apiKey; }
async complete(systemPrompt, userMessage, opts = {}) {
const res = await fetch('https://openrouter.ai/api/v1/chat/completions', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${this.apiKey}`,
'HTTP-Referer': 'https://github.com/calesthio/Crucix',
'X-Title': 'Crucix',
},
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 || 60000),
});
if (!res.ok) {
const err = await res.text().catch(() => '');
throw new Error(`OpenRouter 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,
};
}
}

332
package-lock.json generated
View File

@@ -7,12 +7,221 @@
"": { "": {
"name": "crucix", "name": "crucix",
"version": "2.0.0", "version": "2.0.0",
"license": "ISC", "license": "AGPL-3.0-only",
"dependencies": { "dependencies": {
"express": "^5.1.0" "express": "^5.1.0"
}, },
"engines": { "engines": {
"node": ">=22" "node": ">=22",
"npm": ">=10"
},
"optionalDependencies": {
"discord.js": "^14.25.1"
}
},
"node_modules/@discordjs/builders": {
"version": "1.13.1",
"resolved": "https://registry.npmjs.org/@discordjs/builders/-/builders-1.13.1.tgz",
"integrity": "sha512-cOU0UDHc3lp/5nKByDxkmRiNZBpdp0kx55aarbiAfakfKJHlxv/yFW1zmIqCAmwH5CRlrH9iMFKJMpvW4DPB+w==",
"license": "Apache-2.0",
"optional": true,
"dependencies": {
"@discordjs/formatters": "^0.6.2",
"@discordjs/util": "^1.2.0",
"@sapphire/shapeshift": "^4.0.0",
"discord-api-types": "^0.38.33",
"fast-deep-equal": "^3.1.3",
"ts-mixer": "^6.0.4",
"tslib": "^2.6.3"
},
"engines": {
"node": ">=16.11.0"
},
"funding": {
"url": "https://github.com/discordjs/discord.js?sponsor"
}
},
"node_modules/@discordjs/collection": {
"version": "1.5.3",
"resolved": "https://registry.npmjs.org/@discordjs/collection/-/collection-1.5.3.tgz",
"integrity": "sha512-SVb428OMd3WO1paV3rm6tSjM4wC+Kecaa1EUGX7vc6/fddvw/6lg90z4QtCqm21zvVe92vMMDt9+DkIvjXImQQ==",
"license": "Apache-2.0",
"optional": true,
"engines": {
"node": ">=16.11.0"
}
},
"node_modules/@discordjs/formatters": {
"version": "0.6.2",
"resolved": "https://registry.npmjs.org/@discordjs/formatters/-/formatters-0.6.2.tgz",
"integrity": "sha512-y4UPwWhH6vChKRkGdMB4odasUbHOUwy7KL+OVwF86PvT6QVOwElx+TiI1/6kcmcEe+g5YRXJFiXSXUdabqZOvQ==",
"license": "Apache-2.0",
"optional": true,
"dependencies": {
"discord-api-types": "^0.38.33"
},
"engines": {
"node": ">=16.11.0"
},
"funding": {
"url": "https://github.com/discordjs/discord.js?sponsor"
}
},
"node_modules/@discordjs/rest": {
"version": "2.6.0",
"resolved": "https://registry.npmjs.org/@discordjs/rest/-/rest-2.6.0.tgz",
"integrity": "sha512-RDYrhmpB7mTvmCKcpj+pc5k7POKszS4E2O9TYc+U+Y4iaCP+r910QdO43qmpOja8LRr1RJ0b3U+CqVsnPqzf4w==",
"license": "Apache-2.0",
"optional": true,
"dependencies": {
"@discordjs/collection": "^2.1.1",
"@discordjs/util": "^1.1.1",
"@sapphire/async-queue": "^1.5.3",
"@sapphire/snowflake": "^3.5.3",
"@vladfrangu/async_event_emitter": "^2.4.6",
"discord-api-types": "^0.38.16",
"magic-bytes.js": "^1.10.0",
"tslib": "^2.6.3",
"undici": "6.21.3"
},
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/discordjs/discord.js?sponsor"
}
},
"node_modules/@discordjs/rest/node_modules/@discordjs/collection": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/@discordjs/collection/-/collection-2.1.1.tgz",
"integrity": "sha512-LiSusze9Tc7qF03sLCujF5iZp7K+vRNEDBZ86FT9aQAv3vxMLihUvKvpsCWiQ2DJq1tVckopKm1rxomgNUc9hg==",
"license": "Apache-2.0",
"optional": true,
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/discordjs/discord.js?sponsor"
}
},
"node_modules/@discordjs/util": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/@discordjs/util/-/util-1.2.0.tgz",
"integrity": "sha512-3LKP7F2+atl9vJFhaBjn4nOaSWahZ/yWjOvA4e5pnXkt2qyXRCHLxoBQy81GFtLGCq7K9lPm9R517M1U+/90Qg==",
"license": "Apache-2.0",
"optional": true,
"dependencies": {
"discord-api-types": "^0.38.33"
},
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/discordjs/discord.js?sponsor"
}
},
"node_modules/@discordjs/ws": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/@discordjs/ws/-/ws-1.2.3.tgz",
"integrity": "sha512-wPlQDxEmlDg5IxhJPuxXr3Vy9AjYq5xCvFWGJyD7w7Np8ZGu+Mc+97LCoEc/+AYCo2IDpKioiH0/c/mj5ZR9Uw==",
"license": "Apache-2.0",
"optional": true,
"dependencies": {
"@discordjs/collection": "^2.1.0",
"@discordjs/rest": "^2.5.1",
"@discordjs/util": "^1.1.0",
"@sapphire/async-queue": "^1.5.2",
"@types/ws": "^8.5.10",
"@vladfrangu/async_event_emitter": "^2.2.4",
"discord-api-types": "^0.38.1",
"tslib": "^2.6.2",
"ws": "^8.17.0"
},
"engines": {
"node": ">=16.11.0"
},
"funding": {
"url": "https://github.com/discordjs/discord.js?sponsor"
}
},
"node_modules/@discordjs/ws/node_modules/@discordjs/collection": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/@discordjs/collection/-/collection-2.1.1.tgz",
"integrity": "sha512-LiSusze9Tc7qF03sLCujF5iZp7K+vRNEDBZ86FT9aQAv3vxMLihUvKvpsCWiQ2DJq1tVckopKm1rxomgNUc9hg==",
"license": "Apache-2.0",
"optional": true,
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/discordjs/discord.js?sponsor"
}
},
"node_modules/@sapphire/async-queue": {
"version": "1.5.5",
"resolved": "https://registry.npmjs.org/@sapphire/async-queue/-/async-queue-1.5.5.tgz",
"integrity": "sha512-cvGzxbba6sav2zZkH8GPf2oGk9yYoD5qrNWdu9fRehifgnFZJMV+nuy2nON2roRO4yQQ+v7MK/Pktl/HgfsUXg==",
"license": "MIT",
"optional": true,
"engines": {
"node": ">=v14.0.0",
"npm": ">=7.0.0"
}
},
"node_modules/@sapphire/shapeshift": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/@sapphire/shapeshift/-/shapeshift-4.0.0.tgz",
"integrity": "sha512-d9dUmWVA7MMiKobL3VpLF8P2aeanRTu6ypG2OIaEv/ZHH/SUQ2iHOVyi5wAPjQ+HmnMuL0whK9ez8I/raWbtIg==",
"license": "MIT",
"optional": true,
"dependencies": {
"fast-deep-equal": "^3.1.3",
"lodash": "^4.17.21"
},
"engines": {
"node": ">=v16"
}
},
"node_modules/@sapphire/snowflake": {
"version": "3.5.3",
"resolved": "https://registry.npmjs.org/@sapphire/snowflake/-/snowflake-3.5.3.tgz",
"integrity": "sha512-jjmJywLAFoWeBi1W7994zZyiNWPIiqRRNAmSERxyg93xRGzNYvGjlZ0gR6x0F4gPRi2+0O6S71kOZYyr3cxaIQ==",
"license": "MIT",
"optional": true,
"engines": {
"node": ">=v14.0.0",
"npm": ">=7.0.0"
}
},
"node_modules/@types/node": {
"version": "25.5.0",
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.5.0.tgz",
"integrity": "sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw==",
"license": "MIT",
"optional": true,
"dependencies": {
"undici-types": "~7.18.0"
}
},
"node_modules/@types/ws": {
"version": "8.18.1",
"resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz",
"integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==",
"license": "MIT",
"optional": true,
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@vladfrangu/async_event_emitter": {
"version": "2.4.7",
"resolved": "https://registry.npmjs.org/@vladfrangu/async_event_emitter/-/async_event_emitter-2.4.7.tgz",
"integrity": "sha512-Xfe6rpCTxSxfbswi/W/Pz7zp1WWSNn4A0eW4mLkQUewCrXXtMj31lCg+iQyTkh/CkusZSq9eDflu7tjEDXUY6g==",
"license": "MIT",
"optional": true,
"engines": {
"node": ">=v14.0.0",
"npm": ">=7.0.0"
} }
}, },
"node_modules/accepts": { "node_modules/accepts": {
@@ -156,6 +365,44 @@
"node": ">= 0.8" "node": ">= 0.8"
} }
}, },
"node_modules/discord-api-types": {
"version": "0.38.42",
"resolved": "https://registry.npmjs.org/discord-api-types/-/discord-api-types-0.38.42.tgz",
"integrity": "sha512-qs1kya7S84r5RR8m9kgttywGrmmoHaRifU1askAoi+wkoSefLpZP6aGXusjNw5b0jD3zOg3LTwUa3Tf2iHIceQ==",
"license": "MIT",
"optional": true,
"workspaces": [
"scripts/actions/documentation"
]
},
"node_modules/discord.js": {
"version": "14.25.1",
"resolved": "https://registry.npmjs.org/discord.js/-/discord.js-14.25.1.tgz",
"integrity": "sha512-2l0gsPOLPs5t6GFZfQZKnL1OJNYFcuC/ETWsW4VtKVD/tg4ICa9x+jb9bkPffkMdRpRpuUaO/fKkHCBeiCKh8g==",
"license": "Apache-2.0",
"optional": true,
"dependencies": {
"@discordjs/builders": "^1.13.0",
"@discordjs/collection": "1.5.3",
"@discordjs/formatters": "^0.6.2",
"@discordjs/rest": "^2.6.0",
"@discordjs/util": "^1.2.0",
"@discordjs/ws": "^1.2.3",
"@sapphire/snowflake": "3.5.3",
"discord-api-types": "^0.38.33",
"fast-deep-equal": "3.1.3",
"lodash.snakecase": "4.1.1",
"magic-bytes.js": "^1.10.0",
"tslib": "^2.6.3",
"undici": "6.21.3"
},
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/discordjs/discord.js?sponsor"
}
},
"node_modules/dunder-proto": { "node_modules/dunder-proto": {
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
@@ -273,6 +520,13 @@
"url": "https://opencollective.com/express" "url": "https://opencollective.com/express"
} }
}, },
"node_modules/fast-deep-equal": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
"license": "MIT",
"optional": true
},
"node_modules/finalhandler": { "node_modules/finalhandler": {
"version": "2.1.1", "version": "2.1.1",
"resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz",
@@ -451,6 +705,27 @@
"integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/lodash": {
"version": "4.17.23",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz",
"integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==",
"license": "MIT",
"optional": true
},
"node_modules/lodash.snakecase": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/lodash.snakecase/-/lodash.snakecase-4.1.1.tgz",
"integrity": "sha512-QZ1d4xoBHYUeuouhEq3lk3Uq7ldgyFXGBhg04+oRLnIz8o9T65Eh+8YdroUwn846zchkA9yDsDl5CVVaV2nqYw==",
"license": "MIT",
"optional": true
},
"node_modules/magic-bytes.js": {
"version": "1.13.0",
"resolved": "https://registry.npmjs.org/magic-bytes.js/-/magic-bytes.js-1.13.0.tgz",
"integrity": "sha512-afO2mnxW7GDTXMm5/AoN1WuOcdoKhtgXjIvHmobqTD1grNplhGdv3PFOyjCVmrnOZBIT/gD/koDKpYG+0mvHcg==",
"license": "MIT",
"optional": true
},
"node_modules/math-intrinsics": { "node_modules/math-intrinsics": {
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
@@ -788,6 +1063,20 @@
"node": ">=0.6" "node": ">=0.6"
} }
}, },
"node_modules/ts-mixer": {
"version": "6.0.4",
"resolved": "https://registry.npmjs.org/ts-mixer/-/ts-mixer-6.0.4.tgz",
"integrity": "sha512-ufKpbmrugz5Aou4wcr5Wc1UUFWOLhq+Fm6qa6P0w0K5Qw2yhaUoiWszhCVuNQyNwrlGiscHOmqYoAox1PtvgjA==",
"license": "MIT",
"optional": true
},
"node_modules/tslib": {
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"license": "0BSD",
"optional": true
},
"node_modules/type-is": { "node_modules/type-is": {
"version": "2.0.1", "version": "2.0.1",
"resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.1.tgz", "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.1.tgz",
@@ -802,6 +1091,23 @@
"node": ">= 0.6" "node": ">= 0.6"
} }
}, },
"node_modules/undici": {
"version": "7.24.4",
"resolved": "https://registry.npmjs.org/undici/-/undici-7.24.4.tgz",
"integrity": "sha512-BM/JzwwaRXxrLdElV2Uo6cTLEjhSb3WXboncJamZ15NgUURmvlXvxa6xkwIOILIjPNo9i8ku136ZvWV0Uly8+w==",
"license": "MIT",
"optional": true,
"engines": {
"node": ">=20.18.1"
}
},
"node_modules/undici-types": {
"version": "7.18.2",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz",
"integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==",
"license": "MIT",
"optional": true
},
"node_modules/unpipe": { "node_modules/unpipe": {
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",
@@ -825,6 +1131,28 @@
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
"license": "ISC" "license": "ISC"
},
"node_modules/ws": {
"version": "8.19.0",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz",
"integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==",
"license": "MIT",
"optional": true,
"engines": {
"node": ">=10.0.0"
},
"peerDependencies": {
"bufferutil": "^4.0.1",
"utf-8-validate": ">=5.0.2"
},
"peerDependenciesMeta": {
"bufferutil": {
"optional": true
},
"utf-8-validate": {
"optional": true
}
}
} }
} }
} }

View File

@@ -1,7 +1,7 @@
{ {
"name": "crucix", "name": "crucix",
"version": "2.0.0", "version": "2.0.0",
"description": "Local intelligence engine 26 OSINT sources, live dashboard, auto-refresh, optional LLM layer.", "description": "Local intelligence engine - 27 OSINT sources, live dashboard, public demo at crucix.live, auto-refresh, optional LLM layer.",
"type": "module", "type": "module",
"scripts": { "scripts": {
"start": "node server.mjs", "start": "node server.mjs",
@@ -14,7 +14,12 @@
"clean": "node scripts/clean.mjs", "clean": "node scripts/clean.mjs",
"fresh-start": "npm run clean && npm start" "fresh-start": "npm run clean && npm start"
}, },
"keywords": ["osint", "intelligence", "dashboard", "geopolitical"], "keywords": [
"osint",
"intelligence",
"dashboard",
"geopolitical"
],
"author": "Crucix", "author": "Crucix",
"license": "AGPL-3.0-only", "license": "AGPL-3.0-only",
"engines": { "engines": {
@@ -25,6 +30,7 @@
"express": "^5.1.0" "express": "^5.1.0"
}, },
"optionalDependencies": { "optionalDependencies": {
"discord.js": "^14.25.0" "discord.js": "^14.25.1" },
} "overrides": {
"undici": "^7.24.4" }
} }

View File

@@ -0,0 +1,30 @@
// MiniMax provider — integration test (calls real API)
// Requires MINIMAX_API_KEY environment variable
// Run: MINIMAX_API_KEY=sk-... node --test test/llm-minimax-integration.test.mjs
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import { MiniMaxProvider } from '../lib/llm/minimax.mjs';
const API_KEY = process.env.MINIMAX_API_KEY;
describe('MiniMax integration', { skip: !API_KEY && 'MINIMAX_API_KEY not set' }, () => {
it('should complete a prompt with MiniMax-M2.5', async () => {
const provider = new MiniMaxProvider({ apiKey: API_KEY, model: 'MiniMax-M2.5' });
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: 30000 }
);
assert.ok(result.text.length > 0, 'Response text should not be empty');
assert.ok(result.usage.inputTokens > 0, 'Should report input tokens');
assert.ok(result.usage.outputTokens > 0, 'Should report output tokens');
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}`);
});
});

144
test/llm-minimax.test.mjs Normal file
View File

@@ -0,0 +1,144 @@
// MiniMax 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 { MiniMaxProvider } from '../lib/llm/minimax.mjs';
import { createLLMProvider } from '../lib/llm/index.mjs';
// ─── Unit Tests ───
describe('MiniMaxProvider', () => {
it('should set defaults correctly', () => {
const provider = new MiniMaxProvider({ apiKey: 'sk-test' });
assert.equal(provider.name, 'minimax');
assert.equal(provider.model, 'MiniMax-M2.5');
assert.equal(provider.isConfigured, true);
});
it('should accept custom model', () => {
const provider = new MiniMaxProvider({ apiKey: 'sk-test', model: 'MiniMax-M2.5-highspeed' });
assert.equal(provider.model, 'MiniMax-M2.5-highspeed');
});
it('should report not configured without API key', () => {
const provider = new MiniMaxProvider({});
assert.equal(provider.isConfigured, false);
});
it('should throw on API error', async () => {
const provider = new MiniMaxProvider({ apiKey: 'sk-test' });
const originalFetch = globalThis.fetch;
globalThis.fetch = mock.fn(() =>
Promise.resolve({ ok: false, status: 401, text: () => Promise.resolve('Unauthorized') })
);
try {
await assert.rejects(
() => provider.complete('system', 'user'),
(err) => {
assert.match(err.message, /MiniMax API 401/);
return true;
}
);
} finally {
globalThis.fetch = originalFetch;
}
});
it('should parse successful response', async () => {
const provider = new MiniMaxProvider({ apiKey: 'sk-test' });
const mockResponse = {
choices: [{ message: { content: 'Hello from MiniMax' } }],
usage: { prompt_tokens: 10, completion_tokens: 5 },
model: 'MiniMax-M2.5',
};
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 MiniMax');
assert.equal(result.usage.inputTokens, 10);
assert.equal(result.usage.outputTokens, 5);
assert.equal(result.model, 'MiniMax-M2.5');
} finally {
globalThis.fetch = originalFetch;
}
});
it('should send correct request format', async () => {
const provider = new MiniMaxProvider({ apiKey: 'sk-test-key', model: 'MiniMax-M2.5' });
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: 'MiniMax-M2.5',
}),
});
});
try {
await provider.complete('system prompt', 'user message', { maxTokens: 2048 });
assert.equal(capturedUrl, 'https://api.minimax.io/v1/chat/completions');
assert.equal(capturedOpts.method, 'POST');
const headers = capturedOpts.headers;
assert.equal(headers['Content-Type'], 'application/json');
assert.equal(headers['Authorization'], 'Bearer sk-test-key');
const body = JSON.parse(capturedOpts.body);
assert.equal(body.model, 'MiniMax-M2.5');
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 MiniMaxProvider({ apiKey: 'sk-test' });
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;
}
});
});
// ─── Factory Tests ───
describe('createLLMProvider — minimax', () => {
it('should create MiniMaxProvider for provider=minimax', () => {
const provider = createLLMProvider({ provider: 'minimax', apiKey: 'sk-test', model: null });
assert.ok(provider instanceof MiniMaxProvider);
assert.equal(provider.name, 'minimax');
assert.equal(provider.isConfigured, true);
});
it('should be case-insensitive', () => {
const provider = createLLMProvider({ provider: 'MiniMax', apiKey: 'sk-test', model: null });
assert.ok(provider instanceof MiniMaxProvider);
});
it('should return null for empty provider', () => {
const provider = createLLMProvider({ provider: null, apiKey: 'sk-test', model: null });
assert.equal(provider, null);
});
});

View File

@@ -0,0 +1,17 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { createLLMProvider } from '../lib/llm/index.mjs';
test('OpenRouterProvider Integration Test', { skip: !process.env.LLM_API_KEY || process.env.LLM_PROVIDER !== 'openrouter' }, async (t) => {
await t.test('Performs live API call', async () => {
const provider = createLLMProvider({
provider: 'openrouter',
apiKey: process.env.LLM_API_KEY,
model: process.env.LLM_MODEL || 'openrouter/auto'
});
const result = await provider.complete('Reply with exactly "Hello".', 'Hi');
assert.ok(result.text.length > 0, 'Should return text');
assert.ok(result.usage.inputTokens > 0, 'Should return input token usage');
});
});

View File

@@ -0,0 +1,90 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { OpenRouterProvider } from '../lib/llm/openrouter.mjs';
import { createLLMProvider } from '../lib/llm/index.mjs';
test('OpenRouterProvider Unit Tests', async (t) => {
await t.test('initializes correctly', () => {
const provider = new OpenRouterProvider({ apiKey: 'test-key', model: 'openrouter/auto' });
assert.equal(provider.name, 'openrouter');
assert.equal(provider.apiKey, 'test-key');
assert.equal(provider.model, 'openrouter/auto');
assert.equal(provider.isConfigured, true);
});
await t.test('isConfigured is false without apiKey', () => {
const provider = new OpenRouterProvider({ apiKey: null });
assert.equal(provider.isConfigured, false);
});
await t.test('createLLMProvider factory returns OpenRouterProvider', () => {
const provider = createLLMProvider({ provider: 'openrouter', apiKey: 'test-key', model: 'test-model' });
assert.ok(provider instanceof OpenRouterProvider);
assert.equal(provider.apiKey, 'test-key');
assert.equal(provider.model, 'test-model');
});
await t.test('complete() returns expected result', async () => {
const provider = new OpenRouterProvider({ apiKey: 'test-key', model: 'test-model' });
// Mock the global fetch
const originalFetch = global.fetch;
global.fetch = async (url, options) => {
assert.equal(url, 'https://openrouter.ai/api/v1/chat/completions');
assert.equal(options.headers['Authorization'], 'Bearer test-key');
assert.equal(options.headers['X-Title'], 'Crucix');
assert.equal(options.headers['HTTP-Referer'], 'https://github.com/calesthio/Crucix');
const body = JSON.parse(options.body);
assert.equal(body.model, 'test-model');
assert.deepEqual(body.messages, [
{ role: 'system', content: 'You are a test.' },
{ role: 'user', content: 'Hello' }
]);
return {
ok: true,
json: async () => ({
choices: [{ message: { content: 'Test response' } }],
usage: { prompt_tokens: 10, completion_tokens: 5 },
model: 'test-model'
})
};
};
try {
const result = await provider.complete('You are a test.', 'Hello');
assert.equal(result.text, 'Test response');
assert.deepEqual(result.usage, { inputTokens: 10, outputTokens: 5 });
assert.equal(result.model, 'test-model');
} finally {
// Restore original fetch
global.fetch = originalFetch;
}
});
await t.test('complete() throws error on API failure', async () => {
const provider = new OpenRouterProvider({ apiKey: 'test-key', model: 'test-model' });
const originalFetch = global.fetch;
global.fetch = async () => {
return {
ok: false,
status: 401,
text: async () => 'Unauthorized access'
};
};
try {
await assert.rejects(
provider.complete('system', 'user'),
{
name: 'Error',
message: 'OpenRouter API 401: Unauthorized access'
}
);
} finally {
global.fetch = originalFetch;
}
});
});