Resolve merge conflicts with Mistral provider

Include both Mistral and Ollama providers in factory,
config, and env docs.
This commit is contained in:
R4V3N
2026-03-20 22:37:35 +01:00
23 changed files with 2181 additions and 241 deletions

14
.dockerignore Normal file
View File

@@ -0,0 +1,14 @@
node_modules
npm-debug.log*
.git
.gitignore
.github
.omc
.env
.env.*
!.env.example
runs/
docs/
*.md
!README.md
LICENSE

View File

@@ -31,7 +31,7 @@ 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 | ollama
# Provider options: anthropic | openai | gemini | codex | openrouter | minimax | mistral | ollama
LLM_PROVIDER=
# Not needed for codex (uses ~/.codex/auth.json) or ollama (local)
LLM_API_KEY=

68
.github/workflows/docker-publish.yml vendored Normal file
View File

@@ -0,0 +1,68 @@
name: Build & Publish Docker Image
on:
push:
branches: [master]
tags: ['v*']
pull_request:
branches: [master]
env:
REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository }}
jobs:
build-and-push:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to GHCR
if: github.event_name != 'pull_request'
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Log in to Docker Hub
if: github.event_name != 'pull_request' && vars.DOCKERHUB_ENABLED == 'true'
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Extract metadata
id: meta
uses: docker/metadata-action@v5
with:
images: |
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
${{ vars.DOCKERHUB_ENABLED == 'true' && format('{0}/{1}', secrets.DOCKERHUB_USERNAME, 'crucix') || '' }}
tags: |
type=raw,value=latest,enable={{is_default_branch}}
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=sha,prefix=
- name: Build and push
uses: docker/build-push-action@v6
with:
context: .
platforms: linux/amd64,linux/arm64
push: ${{ github.event_name != 'pull_request' }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max

2
.gitignore vendored
View File

@@ -34,8 +34,6 @@ AGENTS.md
*.log
npm-debug.log*
# Docker
.dockerignore
# Package lock (optional — remove this line if you want deterministic installs)
# package-lock.json

View File

@@ -15,6 +15,11 @@
[![Sources](https://img.shields.io/badge/OSINT%20sources-27-cyan)](#data-sources-27)
[![Docker](https://img.shields.io/badge/docker-ready-blue?logo=docker)](#docker)
**Enter The Signal Network**
[![Signal Wire](https://img.shields.io/badge/Signal%20Wire-%40crucixmonitor-111111?style=for-the-badge&logo=x&logoColor=white)](https://x.com/crucixmonitor)
[![Ops Room](https://img.shields.io/badge/Ops%20Room-Discord-5865F2?style=for-the-badge&logo=discord&logoColor=white)](https://discord.gg/ChVy7SF4)
![Crucix Dashboard](docs/dashboard.png)
<details>
@@ -43,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
@@ -113,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 `PERF HIGH` / `PERF LOW` button in the top bar only changes rendering behavior - it does **not** remove data sources or reduce sweep coverage.
When you switch to **PERF LOW**, 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, `PERF LOW` 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)
@@ -161,7 +189,7 @@ Alerts are delivered as rich embeds with color-coded sidebars: red for FLASH, ye
Connect any of 6 LLM providers for enhanced analysis:
- **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
- Providers: Anthropic Claude, OpenAI, Google Gemini, OpenRouter (Unified API), OpenAI Codex (ChatGPT subscription), MiniMax
- Providers: Anthropic Claude, OpenAI, Google Gemini, OpenRouter (Unified API), OpenAI Codex (ChatGPT subscription), MiniMax, Mistral
- Graceful fallback — when LLM is unavailable, a rule-based engine takes over alert evaluation. LLM failures never crash the sweep cycle.
---
@@ -194,7 +222,7 @@ These three unlock the most valuable economic and satellite data. Each takes abo
### LLM Provider (optional, for AI-enhanced ideas)
Set `LLM_PROVIDER` to one of: `anthropic`, `openai`, `gemini`, `codex`, `openrouter`, `minimax`
Set `LLM_PROVIDER` to one of: `anthropic`, `openai`, `gemini`, `codex`, `openrouter`, `minimax`, `mistral`
| Provider | Key Required | Default Model |
|----------|-------------|---------------|
@@ -204,6 +232,7 @@ Set `LLM_PROVIDER` to one of: `anthropic`, `openai`, `gemini`, `codex`, `openrou
| `openrouter` | `LLM_API_KEY` | openrouter/auto |
| `codex` | None (uses `~/.codex/auth.json`) | gpt-5.3-codex |
| `minimax` | `LLM_API_KEY` | MiniMax-M2.5 |
| `mistral` | `LLM_API_KEY` | mistral-large-latest |
For Codex, run `npx @openai/codex login` to authenticate via your ChatGPT subscription.
@@ -281,6 +310,7 @@ crucix/
│ │ ├── openrouter.mjs # OpenRouter (Unified API)
│ │ ├── codex.mjs # Codex (ChatGPT subscription)
│ │ ├── minimax.mjs # MiniMax (M2.5, 204K context)
│ │ ├── mistral.mjs # Mistral AI
│ │ ├── ideas.mjs # LLM-powered trade idea generation
│ │ └── index.mjs # Factory: createLLMProvider()
│ ├── delta/ # Change tracking between sweeps
@@ -382,7 +412,7 @@ All settings are in `.env` with sensible defaults:
|----------|---------|-------------|
| `PORT` | `3117` | Dashboard server port |
| `REFRESH_INTERVAL_MINUTES` | `15` | Auto-refresh interval |
| `LLM_PROVIDER` | disabled | `anthropic`, `openai`, `gemini`, `codex`, `openrouter`, or `minimax` |
| `LLM_PROVIDER` | disabled | `anthropic`, `openai`, `gemini`, `codex`, `openrouter`, `minimax`, or `mistral` |
| `LLM_API_KEY` | — | API key (not needed for codex) |
| `LLM_MODEL` | per-provider default | Override model selection |
| `TELEGRAM_BOT_TOKEN` | disabled | For Telegram alerts + bot commands |
@@ -463,6 +493,8 @@ This is normal — the first sweep takes 3060 seconds to query all 27 sources
Expected behavior. Sources that require API keys will return structured errors if the key isn't set. The rest of the sweep continues normally. Check the Source Integrity section in the dashboard (or the server logs) to see which sources failed and why. The 3 most impactful free keys to add are `FRED_API_KEY`, `FIRMS_MAP_KEY`, and `EIA_API_KEY`.
OpenSky can also return `HTTP 429` when its public hotspots are queried too aggressively. Crucix does not try to evade that limit. Instead, it surfaces the throttle/error in source health and preserves the most recent non-empty air traffic snapshot from `runs/` so the dashboard flight layer does not suddenly go blank on a throttled sweep.
### Telegram bot not responding to commands
Make sure both `TELEGRAM_BOT_TOKEN` and `TELEGRAM_CHAT_ID` are set in `.env`. The bot only responds to messages from the configured chat ID (security measure). You should see `[Crucix] Telegram alerts enabled` and `[Crucix] Bot command polling started` in the server logs on startup. If not, double-check your token with `curl https://api.telegram.org/bot<YOUR_TOKEN>/getMe`.

View File

@@ -43,13 +43,22 @@ import { briefing as space } from './sources/space.mjs';
// === Tier 5: Live Market Data ===
import { briefing as yfinance } from './sources/yfinance.mjs';
const SOURCE_TIMEOUT_MS = 30_000; // 30s max per individual source
export async function runSource(name, fn, ...args) {
const start = Date.now();
let timer;
try {
const data = await fn(...args);
const dataPromise = fn(...args);
const timeoutPromise = new Promise((_, reject) => {
timer = setTimeout(() => reject(new Error(`Source ${name} timed out after ${SOURCE_TIMEOUT_MS / 1000}s`)), SOURCE_TIMEOUT_MS);
});
const data = await Promise.race([dataPromise, timeoutPromise]);
return { name, status: 'ok', durationMs: Date.now() - start, data };
} catch (e) {
return { name, status: 'error', durationMs: Date.now() - start, error: e.message };
} finally {
clearTimeout(timer);
}
}
@@ -57,7 +66,7 @@ export async function fullBriefing() {
console.error('[Crucix] Starting intelligence sweep — 27 sources...');
const start = Date.now();
const results = await Promise.allSettled([
const allPromises = [
// Tier 1: Core OSINT & Geopolitical
runSource('GDELT', gdelt),
runSource('OpenSky', opensky),
@@ -94,7 +103,11 @@ export async function fullBriefing() {
// Tier 5: Live Market Data
runSource('YFinance', yfinance),
]);
];
// Each runSource has its own 30s timeout, so allSettled will resolve
// within ~30s even if APIs hang. Global timeout is a safety net.
const results = await Promise.allSettled(allPromises);
const sources = results.map(r => r.status === 'fulfilled' ? r.value : { status: 'failed', error: r.reason?.message });
const totalMs = Date.now() - start;

View File

@@ -37,11 +37,15 @@ export async function getSeries(seriesIds, opts = {}) {
if (apiKey) payload.registrationkey = apiKey;
try {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), 15000);
const res = await fetch(base, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
signal: controller.signal,
});
clearTimeout(timer);
return await res.json();
} catch (e) {
return { error: e.message };

View File

@@ -69,6 +69,7 @@ export async function briefing() {
const results = await Promise.all(
hotspotEntries.map(async ([key, box]) => {
const data = await getFlightsInArea(box.lamin, box.lomin, box.lamax, box.lomax);
const error = data?.error || null;
const states = data?.states || [];
return {
region: box.label,
@@ -83,14 +84,25 @@ export async function briefing() {
// Flag potentially interesting (military often have no callsign or specific patterns)
noCallsign: states.filter(s => !s[1]?.trim()).length,
highAltitude: states.filter(s => s[7] && s[7] > 12000).length, // >12km altitude
...(error ? { error } : {}),
};
})
);
const hotspotErrors = results
.filter(r => r.error)
.map(r => ({ region: r.region, error: r.error }));
return {
source: 'OpenSky',
timestamp: new Date().toISOString(),
hotspots: results,
...(hotspotErrors.length ? {
error: hotspotErrors.length === results.length
? `OpenSky unavailable across all hotspots: ${hotspotErrors[0].error}`
: `OpenSky unavailable for ${hotspotErrors.length}/${results.length} hotspots`,
hotspotErrors,
} : {}),
};
}

View File

@@ -14,6 +14,7 @@ import { safeFetch } from '../utils/fetch.mjs';
const CHOKEPOINTS = {
straitOfHormuz: { label: 'Strait of Hormuz', lat: 26.5, lon: 56.5, note: '20% of world oil' },
suezCanal: { label: 'Suez Canal', lat: 30.5, lon: 32.3, note: '12% of world trade' },
straitOfGibraltar: { label: 'Strait of Gibraltar', lat: 36.0, lon: -5.7, note: 'Gateway to Mediterranean, ~10-20% global trade influence' },
straitOfMalacca: { label: 'Strait of Malacca', lat: 2.5, lon: 101.5, note: '25% of world trade' },
babElMandeb: { label: 'Bab el-Mandeb', lat: 12.6, lon: 43.3, note: 'Red Sea gateway' },
taiwanStrait: { label: 'Taiwan Strait', lat: 24.0, lon: 119.0, note: '88% of largest container ships' },

View File

@@ -51,7 +51,11 @@ export async function getOutbreakNews() {
return db - da;
});
return items.map(item => ({
// Filter to last 30 days only
const cutoff = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000);
const recent = items.filter(item => new Date(item.PublicationDate || 0) >= cutoff);
return recent.map(item => ({
title: item.Title,
date: item.PublicationDate,
donId: item.DonId || null,

View File

@@ -1,13 +1,13 @@
// 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 | ollama
provider: process.env.LLM_PROVIDER || null, // anthropic | openai | gemini | codex | openrouter | minimax | mistral | ollama
apiKey: process.env.LLM_API_KEY || null,
model: process.env.LLM_MODEL || null,
baseUrl: process.env.OLLAMA_BASE_URL || null,
@@ -23,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)
},

View File

@@ -5,7 +5,7 @@
//
// Exports synthesize(), generateIdeas(), fetchAllNews() for use by server.mjs
import { readFileSync, writeFileSync } from 'fs';
import { existsSync, readFileSync, readdirSync, writeFileSync } from 'fs';
import { dirname, join } from 'path';
import { fileURLToPath } from 'url';
import { exec } from 'child_process';
@@ -102,6 +102,49 @@ function sanitizeExternalUrl(raw) {
}
}
function sumAirHotspots(hotspots = []) {
return hotspots.reduce((sum, hotspot) => sum + (hotspot.totalAircraft || 0), 0);
}
function summarizeAirHotspots(hotspots = []) {
return hotspots.map(h => ({
region: h.region,
total: h.totalAircraft || 0,
noCallsign: h.noCallsign || 0,
highAlt: h.highAltitude || 0,
top: Object.entries(h.byCountry || {}).sort((a, b) => b[1] - a[1]).slice(0, 5),
}));
}
function loadOpenSkyFallback(currentTimestamp) {
const runsDir = join(ROOT, 'runs');
if (!existsSync(runsDir)) return null;
const currentMs = currentTimestamp ? new Date(currentTimestamp).getTime() : NaN;
const files = readdirSync(runsDir)
.filter(name => /^briefing_.*\.json$/.test(name))
.sort()
.reverse();
for (const file of files) {
const filePath = join(runsDir, file);
try {
const prior = JSON.parse(readFileSync(filePath, 'utf8'));
const priorTimestamp = prior.sources?.OpenSky?.timestamp || prior.crucix?.timestamp || null;
if (priorTimestamp && Number.isFinite(currentMs) && new Date(priorTimestamp).getTime() >= currentMs) continue;
const hotspots = prior.sources?.OpenSky?.hotspots || [];
if (sumAirHotspots(hotspots) > 0) {
return { file, timestamp: priorTimestamp, hotspots };
}
} catch {
// Ignore unreadable historical runs and continue searching backward.
}
}
return null;
}
// === RSS Fetching ===
async function fetchRSS(url, source) {
try {
@@ -124,15 +167,42 @@ 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
['http://feeds.bbci.co.uk/news/world/rss.xml', 'BBC'],
['https://rss.nytimes.com/services/xml/rss/nyt/World.xml', 'NYT'],
['https://www.aljazeera.com/xml/rss/all.xml', 'Al Jazeera'],
['https://rss.nytimes.com/services/xml/rss/nyt/Americas.xml', 'NYT Americas'],
['https://rss.nytimes.com/services/xml/rss/nyt/AsiaPacific.xml', 'NYT Asia'],
// USA
['https://feeds.npr.org/1001/rss.xml', 'NPR'],
['https://feeds.bbci.co.uk/news/technology/rss.xml', 'BBC Tech'],
['http://feeds.bbci.co.uk/news/science_and_environment/rss.xml', 'BBC Science'],
['https://rss.nytimes.com/services/xml/rss/nyt/Americas.xml', 'NYT Americas'],
// Europe
['https://rss.dw.com/rdf/rss-en-all', 'DW'],
['https://www.france24.com/en/rss', 'France 24'],
['https://www.euronews.com/rss?format=mrss', 'Euronews'],
// Africa & Cameroon region
['https://rss.dw.com/rdf/rss-en-africa', 'DW Africa'],
['https://www.rfi.fr/en/rss', 'RFI'],
['https://www.africanews.com/feed/rss', 'Africa News'],
['https://rss.nytimes.com/services/xml/rss/nyt/Africa.xml', 'NYT Africa'],
// Asia-Pacific
['https://rss.nytimes.com/services/xml/rss/nyt/AsiaPacific.xml', 'NYT Asia'],
['https://www.sbs.com.au/news/topic/australia/feed', 'SBS Australia'],
// India
['https://indianexpress.com/section/india/feed/', 'Indian Express'],
['https://www.thehindu.com/news/national/feeder/default.rss', 'The Hindu'],
// South America
['https://en.mercopress.com/rss/latin-america', 'MercoPress'],
];
const results = await Promise.allSettled(
@@ -150,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),
@@ -164,8 +234,26 @@ export async function fetchAllNews() {
}
}
geoNews.sort((a, b) => new Date(b.date || 0) - new Date(a.date || 0));
return geoNews.slice(0, 50);
const cutoff = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000);
const filtered = geoNews.filter(n => !n.date || new Date(n.date) >= cutoff);
filtered.sort((a, b) => new Date(b.date || 0) - new Date(a.date || 0));
const selected = [];
const selectedKeys = new Set();
const keyFor = item => `${item.source}|${item.title}|${item.date}`;
const pushUnique = item => {
const key = keyFor(item);
if (selectedKeys.has(key)) return;
selected.push(item);
selectedKeys.add(key);
};
// Reserve a little space so newly-added regional feeds are not crowded out by larger globals.
for (const source of REGIONAL_NEWS_SOURCES) {
filtered.filter(item => item.source === source).slice(0, 2).forEach(pushUnique);
}
filtered.forEach(pushUnique);
return selected.slice(0, 50);
}
// === Leverageable Ideas from Signals ===
@@ -311,11 +399,12 @@ export function generateIdeas(V2) {
// === Synthesize raw sweep data into dashboard format ===
export async function synthesize(data) {
const air = (data.sources.OpenSky?.hotspots || []).map(h => ({
region: h.region, total: h.totalAircraft || 0, noCallsign: h.noCallsign || 0,
highAlt: h.highAltitude || 0,
top: Object.entries(h.byCountry || {}).sort((a, b) => b[1] - a[1]).slice(0, 5)
}));
const liveAirHotspots = data.sources.OpenSky?.hotspots || [];
const airFallback = sumAirHotspots(liveAirHotspots) > 0
? null
: loadOpenSkyFallback(data.sources.OpenSky?.timestamp || data.crucix?.timestamp);
const effectiveAirHotspots = airFallback?.hotspots || liveAirHotspots;
const air = summarizeAirHotspots(effectiveAirHotspots);
const thermal = (data.sources.FIRMS?.hotspots || []).map(h => ({
region: h.region, det: h.totalDetections || 0, night: h.nightDetections || 0,
hc: h.highConfidence || 0,
@@ -496,6 +585,14 @@ export async function synthesize(data) {
const V2 = {
meta: data.crucix, air, thermal, tSignals, chokepoints, nuke, nukeSignals,
airMeta: {
fallback: Boolean(airFallback),
liveTotal: sumAirHotspots(liveAirHotspots),
timestamp: airFallback?.timestamp || data.sources.OpenSky?.timestamp || data.crucix?.timestamp || null,
source: airFallback ? 'OpenSky fallback' : 'OpenSky',
...(airFallback ? { fallbackFile: airFallback.file } : {}),
...(data.sources.OpenSky?.error ? { error: data.sources.OpenSky.error } : {}),
},
sdr: { total: sdrNet.totalReceivers || 0, online: sdrNet.online || 0, zones: sdrZones },
tg: { posts: tgData.totalPosts || 0, urgent: tgUrgent, topPosts: tgTop },
who, fred, energy, bls, treasury, gscpi, defense, noaa, epa, acled, gdelt, space, health, news,
@@ -549,9 +646,26 @@ function buildNewsFeed(rssNews, gdeltData, tgUrgent, tgTop) {
});
}
// Sort by timestamp descending, limit to 50
feed.sort((a, b) => new Date(b.timestamp || 0) - new Date(a.timestamp || 0));
return feed.slice(0, 50);
// Filter to last 30 days, sort by timestamp descending, limit to 50
const cutoff = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000);
const recent = feed.filter(item => !item.timestamp || new Date(item.timestamp) >= cutoff);
recent.sort((a, b) => new Date(b.timestamp || 0) - new Date(a.timestamp || 0));
const selected = [];
const selectedKeys = new Set();
const keyFor = item => `${item.type}|${item.source}|${item.headline}|${item.timestamp}`;
const pushUnique = item => {
const key = keyFor(item);
if (selectedKeys.has(key)) return;
selected.push(item);
selectedKeys.add(key);
};
for (const source of REGIONAL_NEWS_SOURCES) {
recent.filter(item => item.source === source).slice(0, 2).forEach(pushUnique);
}
recent.forEach(pushUnique);
return selected.slice(0, 50);
}
// === CLI Mode: inject into HTML file ===

File diff suppressed because it is too large Load Diff

View File

@@ -130,18 +130,26 @@ fetch('/api/health')
.catch(() => startCountdown(0));
// === SSE — wait for sweep to complete, then redirect ===
let redirected = false;
function goToDashboard() {
if (redirected) return;
redirected = true;
clearInterval(countdownInterval);
clearInterval(pollInterval);
barFill.style.transition = 'width 0.4s ease';
barFill.style.width = '100%';
etaText.textContent = '';
statusText.textContent = 'TERMINAL READY — LOADING DASHBOARD';
setTimeout(() => location.replace('/'), 800);
}
const es = new EventSource('/events');
es.onmessage = (e) => {
try {
const msg = JSON.parse(e.data);
if (msg.type === 'update') {
es.close();
clearInterval(countdownInterval);
barFill.style.transition = 'width 0.4s ease';
barFill.style.width = '100%';
etaText.textContent = '';
statusText.textContent = 'TERMINAL READY — LOADING DASHBOARD';
setTimeout(() => location.replace('/'), 800);
goToDashboard();
}
} catch {}
};
@@ -149,6 +157,13 @@ es.onerror = () => {
es.close();
setTimeout(() => location.reload(), 3000);
};
// === Fallback polling — in case SSE misses the update ===
const pollInterval = setInterval(() => {
fetch('/api/data').then(r => {
if (r.ok) goToDashboard();
}).catch(() => {});
}, 5000);
</script>
</body>
</html>

View File

@@ -4,6 +4,8 @@
import { createHash } from 'crypto';
const TELEGRAM_API = 'https://api.telegram.org';
/** Telegram Bot API limit for sendMessage text (bytes/characters). */
const TELEGRAM_MAX_TEXT = 4096;
// ─── Alert Tiers ────────────────────────────────────────────────────────────
// FLASH: Immediate action required — market-moving, time-critical (e.g. war escalation, flash crash)
@@ -38,6 +40,7 @@ export class TelegramAlerter {
this._lastUpdateId = 0; // For polling bot commands
this._commandHandlers = {}; // Registered command callbacks
this._pollingInterval = null;
this._botUsername = null;
}
get isConfigured() {
@@ -47,42 +50,70 @@ export class TelegramAlerter {
// ─── Core Messaging ─────────────────────────────────────────────────────
/**
* Send a message via Telegram Bot API.
* Send a message via Telegram Bot API. Splits at TELEGRAM_MAX_TEXT so long messages
* (e.g. /brief) are sent in multiple messages instead of being truncated or failing.
* @param {string} message - markdown-formatted message
* @param {object} opts - optional: { parseMode, disablePreview, replyToMessageId }
* @param {object} opts - optional: { parseMode, disablePreview, replyToMessageId, chatId }
* @returns {Promise<{ok: boolean, messageId?: number}>}
*/
async sendMessage(message, opts = {}) {
if (!this.isConfigured) return { ok: false };
const chatId = opts.chatId ?? this.chatId;
const parseMode = opts.parseMode || 'Markdown';
const chunks = this._chunkText(message, TELEGRAM_MAX_TEXT);
try {
const res = await fetch(`${TELEGRAM_API}/bot${this.botToken}/sendMessage`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
chat_id: this.chatId,
text: message,
parse_mode: opts.parseMode || 'Markdown',
disable_web_page_preview: opts.disablePreview !== false,
...(opts.replyToMessageId ? { reply_to_message_id: opts.replyToMessageId } : {}),
}),
signal: AbortSignal.timeout(15000),
});
let lastResult = { ok: false, messageId: undefined };
for (let i = 0; i < chunks.length; i++) {
const res = await fetch(`${TELEGRAM_API}/bot${this.botToken}/sendMessage`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
chat_id: chatId,
text: chunks[i],
parse_mode: parseMode,
disable_web_page_preview: opts.disablePreview !== false,
...(opts.replyToMessageId && i === 0 ? { reply_to_message_id: opts.replyToMessageId } : {}),
}),
signal: AbortSignal.timeout(15000),
});
if (!res.ok) {
const err = await res.text().catch(() => '');
console.error(`[Telegram] Send failed (${res.status}): ${err.substring(0, 200)}`);
return { ok: false };
if (!res.ok) {
const err = await res.text().catch(() => '');
console.error(`[Telegram] Send failed (${res.status}): ${err.substring(0, 200)}`);
return lastResult;
}
const data = await res.json();
lastResult = { ok: true, messageId: data.result?.message_id };
}
const data = await res.json();
return { ok: true, messageId: data.result?.message_id };
return lastResult;
} catch (err) {
console.error('[Telegram] Send error:', err.message);
return { ok: false };
}
}
/**
* Split text into chunks of at most maxLen. Prefer breaking at newlines to avoid
* splitting mid-Markdown.
*/
_chunkText(text, maxLen = TELEGRAM_MAX_TEXT) {
if (!text || text.length <= maxLen) return text ? [text] : [];
const chunks = [];
let start = 0;
while (start < text.length) {
let end = Math.min(start + maxLen, text.length);
if (end < text.length) {
const lastNewline = text.lastIndexOf('\n', end - 1);
if (lastNewline > start) end = lastNewline + 1;
}
chunks.push(text.slice(start, end));
start = end;
}
return chunks;
}
// Backward-compatible alias
async sendAlert(message) {
const result = await this.sendMessage(message);
@@ -286,6 +317,9 @@ export class TelegramAlerter {
if (this._pollingInterval) return; // Already polling
console.log('[Telegram] Bot command polling started');
this._initializeBotCommands().catch((err) => {
console.error('[Telegram] Command initialization failed:', err.message);
});
this._pollingInterval = setInterval(() => this._pollUpdates(), intervalMs);
// Initial poll
this._pollUpdates();
@@ -325,8 +359,8 @@ export class TelegramAlerter {
const msg = update.message;
if (!msg?.text) continue;
// Only process messages from the configured chat
const chatId = String(msg.chat?.id);
// Restrict command execution to the configured chat/group only.
if (chatId !== String(this.chatId)) continue;
await this._handleMessage(msg);
@@ -342,8 +376,11 @@ export class TelegramAlerter {
async _handleMessage(msg) {
const text = msg.text.trim();
const parts = text.split(/\s+/);
const command = parts[0].toLowerCase();
const rawCommand = parts[0].toLowerCase();
const command = this._normalizeCommand(rawCommand);
if (!command) return;
const args = parts.slice(1).join(' ');
const replyChatId = msg.chat?.id;
// Built-in commands
if (command === '/help') {
@@ -352,7 +389,7 @@ export class TelegramAlerter {
.join('\n');
await this.sendMessage(
`🤖 *CRUCIX BOT COMMANDS*\n\n${helpText}\n\n_Tip: Commands are case-insensitive_`,
{ replyToMessageId: msg.message_id }
{ chatId: replyChatId, replyToMessageId: msg.message_id }
);
return;
}
@@ -362,7 +399,7 @@ export class TelegramAlerter {
this._muteUntil = Date.now() + hours * 60 * 60 * 1000;
await this.sendMessage(
`🔇 Alerts muted for ${hours}h — until ${new Date(this._muteUntil).toLocaleTimeString()} UTC\nUse /unmute to resume.`,
{ replyToMessageId: msg.message_id }
{ chatId: replyChatId, replyToMessageId: msg.message_id }
);
return;
}
@@ -371,7 +408,7 @@ export class TelegramAlerter {
this._muteUntil = null;
await this.sendMessage(
`🔔 Alerts resumed. You'll receive the next signal evaluation.`,
{ replyToMessageId: msg.message_id }
{ chatId: replyChatId, replyToMessageId: msg.message_id }
);
return;
}
@@ -379,7 +416,7 @@ export class TelegramAlerter {
if (command === '/alerts') {
const recent = this._alertHistory.slice(-10);
if (recent.length === 0) {
await this.sendMessage('No recent alerts.', { replyToMessageId: msg.message_id });
await this.sendMessage('No recent alerts.', { chatId: replyChatId, replyToMessageId: msg.message_id });
return;
}
const lines = recent.map(a =>
@@ -387,7 +424,7 @@ export class TelegramAlerter {
);
await this.sendMessage(
`📋 *Recent Alerts (last ${recent.length})*\n\n${lines.join('\n')}`,
{ replyToMessageId: msg.message_id }
{ chatId: replyChatId, replyToMessageId: msg.message_id }
);
return;
}
@@ -398,19 +435,86 @@ export class TelegramAlerter {
try {
const response = await handler(args, msg.message_id);
if (response) {
await this.sendMessage(response, { replyToMessageId: msg.message_id });
await this.sendMessage(response, { chatId: replyChatId, replyToMessageId: msg.message_id });
}
} catch (err) {
console.error(`[Telegram] Command ${command} error:`, err.message);
await this.sendMessage(
`❌ Command failed: ${err.message}`,
{ replyToMessageId: msg.message_id }
{ chatId: replyChatId, replyToMessageId: msg.message_id }
);
}
}
// Unknown commands are silently ignored to avoid spamming
}
async _initializeBotCommands() {
await this._loadBotIdentity();
const botCommands = Object.entries(COMMANDS).map(([command, description]) => ({
command: command.replace('/', ''),
description: description.substring(0, 256),
}));
// Register commands only for the configured chat to avoid global discovery.
await this._setMyCommands(botCommands, this._buildConfiguredChatScope());
}
async _loadBotIdentity() {
const res = await fetch(`${TELEGRAM_API}/bot${this.botToken}/getMe`, {
signal: AbortSignal.timeout(10000),
});
if (!res.ok) {
const err = await res.text().catch(() => '');
throw new Error(`getMe failed (${res.status}): ${err.substring(0, 200)}`);
}
const data = await res.json();
if (!data.ok || !data.result?.username) {
throw new Error('getMe returned invalid bot profile');
}
this._botUsername = String(data.result.username).toLowerCase();
}
async _setMyCommands(commands, scope = null) {
const body = { commands };
if (scope) body.scope = scope;
const res = await fetch(`${TELEGRAM_API}/bot${this.botToken}/setMyCommands`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
signal: AbortSignal.timeout(10000),
});
if (!res.ok) {
const err = await res.text().catch(() => '');
throw new Error(`setMyCommands failed (${res.status}): ${err.substring(0, 200)}`);
}
const data = await res.json();
if (!data.ok) {
throw new Error(`setMyCommands rejected: ${JSON.stringify(data).substring(0, 200)}`);
}
}
_buildConfiguredChatScope() {
const chatId = Number(this.chatId);
if (!Number.isSafeInteger(chatId)) {
throw new Error(`TELEGRAM_CHAT_ID must be a numeric chat id, got: ${this.chatId}`);
}
return { type: 'chat', chat_id: chatId };
}
_normalizeCommand(rawCommand) {
if (!rawCommand.startsWith('/')) return null;
const atIdx = rawCommand.indexOf('@');
if (atIdx === -1) return rawCommand;
const command = rawCommand.substring(0, atIdx);
const mentionedBot = rawCommand.substring(atIdx + 1).toLowerCase();
if (!this._botUsername || mentionedBot === this._botUsername) return command;
return null;
}
// ─── Semantic Dedup ─────────────────────────────────────────────────────
/**

137
lib/i18n.mjs Normal file
View File

@@ -0,0 +1,137 @@
// Internationalization (i18n) Module
// Loads locale files and provides translation functions
import { readFileSync, existsSync } from 'fs';
import { fileURLToPath } from 'url';
import { dirname, join } from 'path';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const LOCALES_DIR = join(__dirname, '..', 'locales');
// Supported languages
const SUPPORTED_LOCALES = ['en', 'fr'];
const DEFAULT_LOCALE = 'en';
// Cache loaded locales
const localeCache = new Map();
/**
* Get the current language from environment
* @returns {string} Language code (e.g., 'en', 'fr')
*/
export function getLanguage() {
// CRUCIX_LANG takes priority to avoid conflict with Linux system LANGUAGE variable
const lang = (process.env.CRUCIX_LANG || process.env.LANGUAGE || process.env.LANG || DEFAULT_LOCALE)
.toLowerCase()
.slice(0, 2);
return SUPPORTED_LOCALES.includes(lang) ? lang : DEFAULT_LOCALE;
}
/**
* Load a locale file
* @param {string} lang - Language code
* @returns {object} Locale data
*/
function loadLocale(lang) {
if (localeCache.has(lang)) {
return localeCache.get(lang);
}
const localePath = join(LOCALES_DIR, `${lang}.json`);
if (!existsSync(localePath)) {
console.warn(`[i18n] Locale file not found: ${localePath}, falling back to ${DEFAULT_LOCALE}`);
return loadLocale(DEFAULT_LOCALE);
}
try {
const data = JSON.parse(readFileSync(localePath, 'utf-8'));
localeCache.set(lang, data);
return data;
} catch (err) {
console.error(`[i18n] Failed to load locale ${lang}:`, err.message);
if (lang !== DEFAULT_LOCALE) {
return loadLocale(DEFAULT_LOCALE);
}
return {};
}
}
/**
* Get the current locale data
* @returns {object} Current locale data
*/
export function getLocale() {
return loadLocale(getLanguage());
}
/**
* Translate a key path (e.g., 'dashboard.title')
* @param {string} keyPath - Dot-separated key path
* @param {object} params - Optional parameters for interpolation
* @returns {string} Translated string or key if not found
*/
export function t(keyPath, params = {}) {
const locale = getLocale();
const keys = keyPath.split('.');
let value = locale;
for (const key of keys) {
if (value && typeof value === 'object' && key in value) {
value = value[key];
} else {
console.warn(`[i18n] Missing translation: ${keyPath}`);
return keyPath;
}
}
if (typeof value !== 'string') {
return keyPath;
}
// Interpolate parameters: {param} -> value
return value.replace(/\{(\w+)\}/g, (_, key) => {
return params[key] !== undefined ? params[key] : `{${key}}`;
});
}
/**
* Get LLM system prompt in current language
* @returns {string} System prompt for LLM
*/
export function getLLMPrompt() {
const locale = getLocale();
// Use loadLocale('en') for fallback since getLocale() doesn't accept a language argument
const fallbackLocale = loadLocale('en');
return locale.llm?.systemPrompt || fallbackLocale.llm?.systemPrompt || '';
}
/**
* Get all supported locales info
* @returns {Array} Array of locale info objects
*/
export function getSupportedLocales() {
return SUPPORTED_LOCALES.map(code => {
const locale = loadLocale(code);
return {
code,
name: locale.meta?.name || code,
nativeName: locale.meta?.nativeName || code
};
});
}
/**
* Check if a language is supported
* @param {string} lang - Language code
* @returns {boolean}
*/
export function isSupported(lang) {
return SUPPORTED_LOCALES.includes(lang?.toLowerCase()?.slice(0, 2));
}
// Export current language on module load
export const currentLanguage = getLanguage();
console.log(`[i18n] Language: ${currentLanguage}`);

View File

@@ -1,21 +1,23 @@
// 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 { OllamaProvider } from './ollama.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";
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 { OllamaProvider } from './ollama.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";
/**
* Create an LLM provider based on config.
@@ -28,22 +30,26 @@ 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 'ollama':
case "mistral":
return new MistralProvider({ apiKey, model });
case "ollama":
return new OllamaProvider({ model, baseUrl: llmConfig.baseUrl });
default:
console.warn(`[LLM] Unknown provider "${provider}". LLM features disabled.`);
console.warn(
`[LLM] Unknown provider "${provider}". LLM features disabled.`,
);
return null;
}
}

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

@@ -0,0 +1,51 @@
// Mistral AI Provider — raw fetch, no SDK
// Uses Mistral's OpenAI-compatible Chat Completions API
import { LLMProvider } from './provider.mjs';
export class MistralProvider extends LLMProvider {
constructor(config) {
super(config);
this.name = 'mistral';
this.apiKey = config.apiKey;
this.model = config.model || 'mistral-large-latest';
}
get isConfigured() { return !!this.apiKey; }
async complete(systemPrompt, userMessage, opts = {}) {
const res = await fetch('https://api.mistral.ai/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(`Mistral 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,
};
}
}

359
locales/en.json Normal file
View File

@@ -0,0 +1,359 @@
{
"meta": {
"code": "en",
"name": "English",
"nativeName": "English"
},
"dashboard": {
"title": "CRUCIX — Intelligence Terminal",
"bootTitle": "CRUCIX INTELLIGENCE ENGINE",
"bootSubtitle": "Local Palantir · 31 Sources",
"waitingForSweep": "Waiting for first sweep...",
"sourcesOk": "Sources OK",
"lastSweep": "Last sweep",
"nextSweep": "Next sweep",
"sweep": "SWEEP",
"sources": "SOURCES",
"delta": "DELTA",
"highAlert": "HIGH ALERT",
"riskOff": "RISK-OFF",
"riskOn": "RISK-ON",
"mixed": "MIXED",
"terminalActive": "TERMINAL ACTIVE",
"perf": "PERF",
"perfLow": "LOW",
"perfHigh": "HIGH",
"guideBtn": "What Signals Mean"
},
"boot": {
"initializing": "INITIALIZING CRUCIX ENGINE v2.1.0",
"connecting": "CONNECTING {count} OSINT SOURCES...",
"sourceGroup1": "OPENSKY · FIRMS · KIWISDR · MARITIME",
"sourceGroup2": "FRED · BLS · EIA · TREASURY · GSCPI",
"sourceGroup3": "TELEGRAM · SAFECAST · EPA · WHO · OFAC",
"sourceGroup4": "GDELT · NOAA · PATENTS · BLUESKY · REDDIT",
"sourceGroup5": "USGS · ECB · CVE · COPERNICUS · CELESTRAK",
"sweepComplete": "SWEEP COMPLETE — {ok}/{total} SOURCES",
"ok": "OK",
"acledLayer": "ACLED CONFLICT LAYER",
"events": "EVENTS",
"degraded": "DEGRADED",
"flightCorridors": "FLIGHT CORRIDORS",
"active": "ACTIVE",
"dualProjection": "DUAL PROJECTION",
"ready": "READY",
"intelligenceSynthesis": "INTELLIGENCE SYNTHESIS"
},
"panels": {
"sensorGrid": "Sensor Grid",
"tradeIdeas": "Leverageable Ideas",
"osintFeed": "OSINT Feed",
"osintStream": "OSINT Stream",
"nuclearWatch": "Nuclear Watch",
"newsTicker": "Live News Ticker",
"sweepDelta": "Sweep Delta",
"macroMarkets": "Macro + Markets",
"healthAlerts": "Health Alerts",
"riskGauges": "Risk Gauges",
"crossSourceSignals": "Cross-Source Signals",
"signalCore": "Signal Core",
"seismicWatch": "Seismic Watch",
"cyberWatch": "Cyber Watch",
"spaceWatch": "Space Watch",
"europeAlerts": "Europe Alerts",
"ecbIndicators": "ECB Indicators"
},
"layers": {
"airActivity": "Air Activity",
"thermalSpikes": "Thermal Spikes",
"sdrCoverage": "SDR Coverage",
"maritimeWatch": "Maritime Watch",
"nuclearSites": "Nuclear Sites",
"conflictEvents": "Conflict Events",
"healthWatch": "Health Watch",
"worldNews": "World News",
"osintFeed": "OSINT Feed",
"theaters": "theaters",
"nightDet": "night det.",
"online": "online",
"chokepoints": "chokepoints",
"monitors": "monitors",
"fatalities": "fatalities",
"whoAlerts": "WHO alerts",
"rssGeolocated": "RSS geolocated",
"earthquakes": "Earthquakes",
"seismicEvents": "Seismic Events",
"cyberVulns": "Cyber Vulnerabilities",
"spaceActivity": "Space Activity",
"europeEmergency": "Europe Emergency"
},
"map": {
"worldNews": "World News",
"healthAlert": "Health Alert",
"chokepoint": "Chokepoint",
"nuclearSite": "Nuclear Site",
"osintEvent": "OSINT Event",
"thermalDetection": "Thermal Detection",
"aircraft": "Aircraft",
"rssGeolocated": "RSS geolocated",
"airTraffic": "Air Traffic",
"thermalFire": "Thermal/Fire",
"conflict": "Conflict",
"sdrReceiver": "SDR Receiver",
"scrollToZoom": "SCROLL TO ZOOM · DRAG TO PAN",
"globeMode": "GLOBE MODE",
"flatMode": "FLAT MODE",
"earthquake": "Earthquake",
"disaster": "Disaster",
"weatherAlert": "Weather Alert",
"epaRadNet": "EPA RadNet",
"spaceStation": "Space Station",
"gdeltEvent": "GDELT Event"
},
"ideas": {
"confidence": "Confidence",
"horizon": "Horizon",
"risk": "Risk",
"signals": "Signals",
"rationale": "Rationale",
"aiEnhanced": "AI ENHANCED",
"llmOff": "LLM OFF",
"pending": "PENDING",
"llmNotConfigured": "LLM NOT CONFIGURED",
"llmHelp": "Set LLM_PROVIDER + credentials in .env to enable AI-powered trade ideas",
"disclosure": "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."
},
"regions": {
"world": "World",
"americas": "Americas",
"europe": "Europe",
"middleEast": "Middle East",
"asiaPacific": "Asia Pacific",
"africa": "Africa"
},
"badges": {
"radiation": "RADIATION",
"live": "LIVE",
"delayed": "DELAYED",
"items": "ITEMS",
"urgent": "URGENT",
"worldview": "WORLDVIEW",
"hotMetrics": "HOT METRICS",
"stress": "STRESS",
"sweeping": "SWEEPING...",
"europe": "EUROPE",
"orbital": "ORBITAL"
},
"delta": {
"baseline": "BASELINE",
"escalation": "ESCALATION",
"deescalation": "DE-ESCALATION",
"stable": "STABLE",
"newSignals": "New Signals",
"resolved": "Resolved",
"noChanges": "No changes since last sweep",
"changes": "Changes",
"critical": "Critical",
"new": "NEW"
},
"metrics": {
"wtiCrude": "WTI Crude",
"brent": "Brent",
"natGas": "Nat Gas",
"vix": "VIX",
"fedFunds": "Fed Funds",
"gscpi": "GSCPI",
"cpiMom": "CPI MoM",
"unemployment": "Unemployment",
"hySpread": "HY Spread",
"usdIndex": "USD Index",
"joblessClaims": "Jobless Claims",
"mortgage30y": "30Y Mortgage",
"m2Supply": "M2 Supply",
"natDebt": "Nat. Debt",
"wti5day": "WTI 5-DAY",
"indexes": "INDEXES",
"crypto": "CRYPTO",
"energyMacro": "ENERGY + MACRO",
"vsPrior": "vs prior",
"ecbRate": "ECB Rate",
"eurusd": "EUR/USD",
"euM3": "EU M3",
"euHicp": "EU HICP",
"earthquakes7d": "Earthquakes (7d)",
"criticalCves": "Critical CVEs",
"spaceObjects": "Space Objects",
"starlink": "Starlink",
"europeAlerts": "EU Alerts"
},
"signalMetrics": {
"incidentTempo": "Incident Tempo",
"airTheaters": "Air Theaters",
"thermalSpikes": "Thermal Spikes",
"sdrNodes": "SDR Nodes",
"chokepoints": "Chokepoints",
"whoAlerts": "WHO Alerts"
},
"nuclear": {
"allSitesNormal": "ALL SITES NORMAL",
"anomalyDetected": "ANOMALY DETECTED",
"noData": "No data"
},
"seismic": {
"noRecentQuakes": "NO SIGNIFICANT QUAKES",
"majorQuake": "MAJOR QUAKE DETECTED",
"tsunamiRisk": "TSUNAMI RISK",
"mag": "M"
},
"cyber": {
"noAlerts": "NO CRITICAL CVES",
"criticalAlert": "CRITICAL VULNERABILITIES",
"cvss": "CVSS",
"critical": "CRITICAL"
},
"space": {
"noActivity": "NORMAL ACTIVITY",
"launchDetected": "LAUNCH ACTIVITY",
"objectsTracked": "objects tracked",
"newLast30d": "new (30d)",
"satellites": "sats",
"recentLaunches": "Recent Launches",
"totalTracked": "Total Tracked",
"byCountry": "BY COUNTRY",
"constellations": "CONSTELLATIONS",
"launches": "launches"
},
"europe": {
"noAlerts": "NO ACTIVE ALERTS",
"activeAlerts": "ACTIVE ALERTS",
"fires": "Fires",
"floods": "Floods",
"types": "types"
},
"time": {
"justNow": "just now",
"hoursAgo": "{hours}h ago",
"daysAgo": "{days}d ago"
},
"bot": {
"commands": {
"status": "Get current system health, last sweep time, source status",
"sweep": "Trigger a manual sweep cycle",
"brief": "Get a compact text summary of the latest intelligence",
"portfolio": "Show current positions and P&L (if Alpaca connected)",
"alerts": "Show recent alert history",
"mute": "Mute alerts for 1h (or /mute 2h, /mute 4h)",
"unmute": "Resume alerts",
"help": "Show available commands"
},
"messages": {
"alertsMuted": "🔇 Alerts muted for {hours}h — until {time} UTC",
"useUnmute": "Use /unmute to resume.",
"alertsResumed": "🔔 Alerts resumed. You'll receive the next signal evaluation.",
"sweepTriggered": "🚀 Manual sweep triggered. You'll receive alerts if anything significant is detected.",
"sweepInProgress": "🔄 Sweep already in progress. Please wait.",
"noDataYet": "⏳ No data yet — waiting for first sweep to complete.",
"noRecentAlerts": "No recent alerts.",
"recentAlerts": "📋 Recent Alerts (last {count})",
"commandsTip": "Tip: Commands are case-insensitive",
"commandFailed": "❌ Command failed: {error}",
"portfolioNotAvailable": "📊 Portfolio integration requires Alpaca MCP connection.\nUse the Crucix dashboard or Claude agent for portfolio queries."
},
"status": {
"title": "🖥️ CRUCIX STATUS",
"uptime": "Uptime",
"lastSweep": "Last sweep",
"nextSweep": "Next sweep",
"sweepInProgress": "Sweep in progress",
"yes": "🔄 Yes",
"no": "⏸️ No",
"sources": "Sources",
"failed": "failed",
"llm": "LLM",
"enabled": "✅",
"disabled": "❌ Disabled",
"sseClients": "SSE clients",
"dashboard": "Dashboard",
"pending": "pending",
"never": "never"
},
"brief": {
"title": "📋 CRUCIX BRIEF",
"direction": "Direction",
"changes": "changes",
"criticalChanges": "critical",
"osint": "📡 OSINT",
"urgentSignals": "urgent signals",
"totalPosts": "total posts",
"topIdeas": "💡 Top Ideas"
},
"alertTiers": {
"flash": "FLASH",
"priority": "PRIORITY",
"routine": "ROUTINE"
}
},
"alerts": {
"tiers": {
"flash": {
"label": "FLASH",
"description": "Immediate action required — market-moving, time-critical"
},
"priority": {
"label": "PRIORITY",
"description": "Important signal cluster — act within hours"
},
"routine": {
"label": "ROUTINE",
"description": "Noteworthy change — FYI, no urgency"
}
},
"confidence": {
"high": "HIGH",
"medium": "MEDIUM",
"low": "LOW"
},
"fields": {
"direction": "Direction",
"confidence": "Confidence",
"crossCorrelation": "Cross-Correlation",
"action": "Action",
"signals": "Signals",
"monitor": "Monitor"
},
"messages": {
"alertsMuted": "Alerts Muted",
"mutedUntil": "Alerts silenced for {hours}h — until {time} UTC.",
"useUnmuteToResume": "Use /unmute to resume.",
"alertsResumed": "Alerts Resumed",
"willReceiveNext": "You will receive the next signal evaluation."
},
"ruleBasedHeadlines": {
"nuclearAnomaly": "Nuclear Anomaly Detected",
"crossDomainSignals": "{count} Critical Cross-Domain Signals",
"escalatingSignals": "{count} Escalating Signals",
"osintSurge": "OSINT Surge: {count} New Urgent Posts",
"signalChangeDetected": "Signal Change Detected"
}
},
"discord": {
"commands": {
"status": "System health, last sweep time, source status",
"sweep": "Trigger a manual sweep cycle",
"brief": "Compact intelligence summary",
"portfolio": "Portfolio status (if Alpaca connected)",
"alerts": "Recent alert history",
"mute": "Mute alerts (default 1h)",
"unmute": "Resume alerts",
"muteHoursOption": "Hours to mute (default: 1)"
}
},
"llm": {
"systemPrompt": "You are a quantitative analyst at a macro intelligence firm. You receive structured OSINT + economic data from 25 sources and produce 5-8 actionable trade ideas.\n\nRules:\n- Each idea must cite specific data points from the input\n- Include entry rationale, risk factors, and time horizon\n- Blend geopolitical, economic, and market signals — cross-correlate across domains\n- Be specific: name instruments (tickers, futures, ETFs), not vague sectors\n- If delta shows significant changes, lead with those\n- Do NOT repeat ideas from the \"previous ideas\" list unless conditions have materially changed\n- Rate confidence: HIGH (multiple confirming signals), MEDIUM (thesis supported), LOW (speculative)\n\nOutput ONLY valid JSON array. Each object:\n{\n \"title\": \"Short title (max 10 words)\",\n \"type\": \"LONG|SHORT|HEDGE|WATCH|AVOID\",\n \"ticker\": \"Primary instrument\",\n \"confidence\": \"HIGH|MEDIUM|LOW\",\n \"rationale\": \"2-3 sentence explanation citing specific data\",\n \"risk\": \"Key risk factor\",\n \"horizon\": \"Intraday|Days|Weeks|Months\",\n \"signals\": [\"signal1\", \"signal2\"]\n}"
},
"api": {
"errors": {
"noDataYet": "No data yet — first sweep in progress"
}
}
}

359
locales/fr.json Normal file
View File

@@ -0,0 +1,359 @@
{
"meta": {
"code": "fr",
"name": "French",
"nativeName": "Français"
},
"dashboard": {
"title": "CRUCIX — Terminal de Renseignement",
"bootTitle": "CRUCIX MOTEUR DE RENSEIGNEMENT",
"bootSubtitle": "Palantir Local · 31 Sources",
"waitingForSweep": "En attente du premier scan...",
"sourcesOk": "Sources OK",
"lastSweep": "Dernier scan",
"nextSweep": "Prochain scan",
"sweep": "SCAN",
"sources": "SOURCES",
"delta": "DELTA",
"highAlert": "ALERTE HAUTE",
"riskOff": "RISK-OFF",
"riskOn": "RISK-ON",
"mixed": "MIXTE",
"terminalActive": "TERMINAL ACTIF",
"perf": "PERF",
"perfLow": "BAS",
"perfHigh": "HAUT",
"guideBtn": "Signification des Signaux"
},
"boot": {
"initializing": "INITIALISATION MOTEUR CRUCIX v2.1.0",
"connecting": "CONNEXION À {count} SOURCES OSINT...",
"sourceGroup1": "OPENSKY · FIRMS · KIWISDR · MARITIME",
"sourceGroup2": "FRED · BLS · EIA · TREASURY · GSCPI",
"sourceGroup3": "TELEGRAM · SAFECAST · EPA · OMS · OFAC",
"sourceGroup4": "GDELT · NOAA · BREVETS · BLUESKY · REDDIT",
"sourceGroup5": "USGS · BCE · CVE · COPERNICUS · CELESTRAK",
"sweepComplete": "SCAN TERMINÉ — {ok}/{total} SOURCES",
"ok": "OK",
"acledLayer": "COUCHE CONFLIT ACLED",
"events": "ÉVÉNEMENTS",
"degraded": "DÉGRADÉ",
"flightCorridors": "CORRIDORS AÉRIENS",
"active": "ACTIF",
"dualProjection": "DOUBLE PROJECTION",
"ready": "PRÊT",
"intelligenceSynthesis": "SYNTHÈSE RENSEIGNEMENT"
},
"panels": {
"sensorGrid": "Grille de Capteurs",
"tradeIdeas": "Idées de Trade",
"osintFeed": "Flux OSINT",
"osintStream": "Flux OSINT",
"nuclearWatch": "Surveillance Nucléaire",
"newsTicker": "Fil d'Actualités",
"sweepDelta": "Changements",
"macroMarkets": "Macro + Marchés",
"healthAlerts": "Alertes Santé",
"riskGauges": "Indicateurs de Risque",
"crossSourceSignals": "Signaux Multi-Sources",
"signalCore": "Noyau de Signaux",
"seismicWatch": "Surveillance Sismique",
"cyberWatch": "Surveillance Cyber",
"spaceWatch": "Surveillance Spatiale",
"europeAlerts": "Alertes Europe",
"ecbIndicators": "Indicateurs BCE"
},
"layers": {
"airActivity": "Activité Aérienne",
"thermalSpikes": "Pics Thermiques",
"sdrCoverage": "Couverture SDR",
"maritimeWatch": "Surveillance Maritime",
"nuclearSites": "Sites Nucléaires",
"conflictEvents": "Événements Conflits",
"healthWatch": "Surveillance Santé",
"worldNews": "Actualités Mondiales",
"osintFeed": "Flux OSINT",
"theaters": "théâtres",
"nightDet": "dét. nocturnes",
"online": "en ligne",
"chokepoints": "points strat.",
"monitors": "moniteurs",
"fatalities": "victimes",
"whoAlerts": "alertes OMS",
"rssGeolocated": "RSS géolocalisé",
"earthquakes": "Séismes",
"seismicEvents": "Événements Sismiques",
"cyberVulns": "Vulnérabilités Cyber",
"spaceActivity": "Activité Spatiale",
"europeEmergency": "Urgences Europe"
},
"map": {
"worldNews": "Actualités",
"healthAlert": "Alerte Santé",
"chokepoint": "Point Stratégique",
"nuclearSite": "Site Nucléaire",
"osintEvent": "Événement OSINT",
"thermalDetection": "Détection Thermique",
"aircraft": "Aéronef",
"rssGeolocated": "RSS géolocalisé",
"airTraffic": "Trafic Aérien",
"thermalFire": "Thermique/Feu",
"conflict": "Conflit",
"sdrReceiver": "Récepteur SDR",
"scrollToZoom": "MOLETTE POUR ZOOMER · GLISSER POUR DÉPLACER",
"globeMode": "MODE GLOBE",
"flatMode": "MODE PLAT",
"earthquake": "Séisme",
"disaster": "Catastrophe",
"weatherAlert": "Alerte Météo",
"epaRadNet": "EPA RadNet",
"spaceStation": "Station Spatiale",
"gdeltEvent": "Événement GDELT"
},
"ideas": {
"confidence": "Confiance",
"horizon": "Horizon",
"risk": "Risque",
"signals": "Signaux",
"rationale": "Analyse",
"aiEnhanced": "IA AMÉLIORÉE",
"llmOff": "LLM OFF",
"pending": "EN ATTENTE",
"llmNotConfigured": "LLM NON CONFIGURÉ",
"llmHelp": "Définir LLM_PROVIDER + identifiants dans .env pour activer les idées de trade IA",
"disclosure": "À TITRE INFORMATIF UNIQUEMENT. Ceci ne constitue pas un conseil financier, une recommandation d'achat ou de vente de titre, ni une sollicitation quelconque. Toutes les observations basées sur les signaux sont dérivées de données OSINT publiques et ne doivent pas être utilisées pour prendre des décisions d'investissement. Consultez un conseiller financier agréé avant tout investissement. Les performances passées ne garantissent pas les résultats futurs."
},
"regions": {
"world": "Monde",
"americas": "Amériques",
"europe": "Europe",
"middleEast": "Moyen-Orient",
"asiaPacific": "Asie-Pacifique",
"africa": "Afrique"
},
"badges": {
"radiation": "RADIATION",
"live": "EN DIRECT",
"delayed": "DIFFÉRÉ",
"items": "ÉLÉMENTS",
"urgent": "URGENT",
"worldview": "VUE GLOBALE",
"hotMetrics": "MÉTRIQUES CLÉS",
"stress": "STRESS",
"sweeping": "SCAN EN COURS...",
"europe": "EUROPE",
"orbital": "ORBITAL"
},
"delta": {
"baseline": "RÉFÉRENCE",
"escalation": "ESCALADE",
"deescalation": "DÉSESCALADE",
"stable": "STABLE",
"newSignals": "Nouveaux Signaux",
"resolved": "Résolus",
"noChanges": "Aucun changement depuis le dernier scan",
"changes": "Changements",
"critical": "Critiques",
"new": "NOUVEAU"
},
"metrics": {
"wtiCrude": "Pétrole WTI",
"brent": "Brent",
"natGas": "Gaz Naturel",
"vix": "VIX",
"fedFunds": "Taux Fed",
"gscpi": "GSCPI",
"cpiMom": "IPC MoM",
"unemployment": "Chômage",
"hySpread": "Spread HY",
"usdIndex": "Indice USD",
"joblessClaims": "Inscriptions Chômage",
"mortgage30y": "Hypothèque 30A",
"m2Supply": "Masse M2",
"natDebt": "Dette Nat.",
"wti5day": "WTI 5 JOURS",
"indexes": "INDICES",
"crypto": "CRYPTO",
"energyMacro": "ÉNERGIE + MACRO",
"vsPrior": "vs précédent",
"ecbRate": "Taux BCE",
"eurusd": "EUR/USD",
"euM3": "M3 UE",
"euHicp": "IPCH UE",
"earthquakes7d": "Séismes (7j)",
"criticalCves": "CVE Critiques",
"spaceObjects": "Objets Spatiaux",
"starlink": "Starlink",
"europeAlerts": "Alertes UE"
},
"signalMetrics": {
"incidentTempo": "Tempo Incidents",
"airTheaters": "Théâtres Aériens",
"thermalSpikes": "Pics Thermiques",
"sdrNodes": "Nœuds SDR",
"chokepoints": "Points Strat.",
"whoAlerts": "Alertes OMS"
},
"nuclear": {
"allSitesNormal": "TOUS LES SITES NORMAUX",
"anomalyDetected": "ANOMALIE DÉTECTÉE",
"noData": "Pas de données"
},
"seismic": {
"noRecentQuakes": "PAS DE SÉISME SIGNIFICATIF",
"majorQuake": "SÉISME MAJEUR DÉTECTÉ",
"tsunamiRisk": "RISQUE TSUNAMI",
"mag": "M"
},
"cyber": {
"noAlerts": "PAS DE CVE CRITIQUE",
"criticalAlert": "VULNÉRABILITÉS CRITIQUES",
"cvss": "CVSS",
"critical": "CRITIQUE"
},
"space": {
"noActivity": "ACTIVITÉ NORMALE",
"launchDetected": "ACTIVITÉ DE LANCEMENT",
"objectsTracked": "objets suivis",
"newLast30d": "nouv. (30j)",
"satellites": "sats",
"recentLaunches": "Lancements Récents",
"totalTracked": "Total Suivi",
"byCountry": "PAR PAYS",
"constellations": "CONSTELLATIONS",
"launches": "lancements"
},
"europe": {
"noAlerts": "PAS D'ALERTES ACTIVES",
"activeAlerts": "ALERTES ACTIVES",
"fires": "Incendies",
"floods": "Inondations",
"types": "types"
},
"time": {
"justNow": "à l'instant",
"hoursAgo": "il y a {hours}h",
"daysAgo": "il y a {days}j"
},
"bot": {
"commands": {
"status": "État du système, dernier scan, statut des sources",
"sweep": "Déclencher un scan manuel",
"brief": "Résumé compact des derniers renseignements",
"portfolio": "Positions et P&L (si Alpaca connecté)",
"alerts": "Historique des alertes récentes",
"mute": "Couper les alertes 1h (ou /mute 2h, /mute 4h)",
"unmute": "Reprendre les alertes",
"help": "Afficher les commandes disponibles"
},
"messages": {
"alertsMuted": "🔇 Alertes coupées pour {hours}h — jusqu'à {time} UTC",
"useUnmute": "Utilisez /unmute pour reprendre.",
"alertsResumed": "🔔 Alertes reprises. Vous recevrez la prochaine évaluation de signal.",
"sweepTriggered": "🚀 Scan manuel déclenché. Vous recevrez des alertes si quelque chose de significatif est détecté.",
"sweepInProgress": "🔄 Scan déjà en cours. Veuillez patienter.",
"noDataYet": "⏳ Pas encore de données — en attente du premier scan.",
"noRecentAlerts": "Pas d'alertes récentes.",
"recentAlerts": "📋 Alertes Récentes (les {count} dernières)",
"commandsTip": "Astuce : Les commandes ne sont pas sensibles à la casse",
"commandFailed": "❌ Commande échouée : {error}",
"portfolioNotAvailable": "📊 L'intégration portfolio nécessite la connexion Alpaca MCP.\nUtilisez le dashboard Crucix ou l'agent Claude pour les requêtes portfolio."
},
"status": {
"title": "🖥️ STATUT CRUCIX",
"uptime": "Disponibilité",
"lastSweep": "Dernier scan",
"nextSweep": "Prochain scan",
"sweepInProgress": "Scan en cours",
"yes": "🔄 Oui",
"no": "⏸️ Non",
"sources": "Sources",
"failed": "échouées",
"llm": "LLM",
"enabled": "✅",
"disabled": "❌ Désactivé",
"sseClients": "Clients SSE",
"dashboard": "Dashboard",
"pending": "en attente",
"never": "jamais"
},
"brief": {
"title": "📋 BRIEF CRUCIX",
"direction": "Direction",
"changes": "changements",
"criticalChanges": "critiques",
"osint": "📡 OSINT",
"urgentSignals": "signaux urgents",
"totalPosts": "posts totaux",
"topIdeas": "💡 Meilleures Idées"
},
"alertTiers": {
"flash": "FLASH",
"priority": "PRIORITÉ",
"routine": "ROUTINE"
}
},
"alerts": {
"tiers": {
"flash": {
"label": "FLASH",
"description": "Action immédiate requise — impact marché, temps critique"
},
"priority": {
"label": "PRIORITÉ",
"description": "Cluster de signaux important — agir dans les heures"
},
"routine": {
"label": "ROUTINE",
"description": "Changement notable — informatif, pas d'urgence"
}
},
"confidence": {
"high": "HAUTE",
"medium": "MOYENNE",
"low": "BASSE"
},
"fields": {
"direction": "Direction",
"confidence": "Confiance",
"crossCorrelation": "Corrélation Croisée",
"action": "Action",
"signals": "Signaux",
"monitor": "Surveiller"
},
"messages": {
"alertsMuted": "Alertes Coupées",
"mutedUntil": "Alertes suspendues pour {hours}h — jusqu'à {time} UTC.",
"useUnmuteToResume": "Utilisez /unmute pour reprendre.",
"alertsResumed": "Alertes Reprises",
"willReceiveNext": "Vous recevrez la prochaine évaluation de signal."
},
"ruleBasedHeadlines": {
"nuclearAnomaly": "Anomalie Nucléaire Détectée",
"crossDomainSignals": "{count} Signaux Critiques Multi-Domaines",
"escalatingSignals": "{count} Signaux en Escalade",
"osintSurge": "Surge OSINT : {count} Nouveaux Posts Urgents",
"signalChangeDetected": "Changement de Signal Détecté"
}
},
"discord": {
"commands": {
"status": "Santé système, dernier scan, statut sources",
"sweep": "Déclencher un scan manuel",
"brief": "Résumé compact de renseignement",
"portfolio": "Statut portfolio (si Alpaca connecté)",
"alerts": "Historique des alertes récentes",
"mute": "Couper les alertes (par défaut 1h)",
"unmute": "Reprendre les alertes",
"muteHoursOption": "Heures de coupure (par défaut : 1)"
}
},
"llm": {
"systemPrompt": "Tu es un analyste quantitatif dans une firme de renseignement macro. Tu reçois des données OSINT + économiques structurées de 25 sources et tu produis 5-8 idées de trade actionnables.\n\nRègles:\n- Chaque idée doit citer des données spécifiques de l'input\n- Inclure le rationnel d'entrée, les facteurs de risque et l'horizon temporel\n- Croiser les signaux géopolitiques, économiques et de marché\n- Être spécifique: nommer les instruments (tickers, futures, ETFs), pas des secteurs vagues\n- Si le delta montre des changements significatifs, commencer par ceux-là\n- NE PAS répéter les idées de la liste \"previous ideas\" sauf si les conditions ont matériellement changé\n- Évaluer la confiance: HIGH (signaux multiples confirmants), MEDIUM (thèse supportée), LOW (spéculatif)\n\nOutput UNIQUEMENT un tableau JSON valide. Chaque objet:\n{\n \"title\": \"Titre court en français (max 10 mots)\",\n \"type\": \"LONG|SHORT|HEDGE|WATCH|AVOID\",\n \"ticker\": \"Instrument principal\",\n \"confidence\": \"HIGH|MEDIUM|LOW\",\n \"rationale\": \"Explication 2-3 phrases en français citant les données spécifiques\",\n \"risk\": \"Facteur de risque principal en français\",\n \"horizon\": \"Intraday|Days|Weeks|Months\",\n \"signals\": [\"signal1\", \"signal2\"]\n}"
},
"api": {
"errors": {
"noDataYet": "Pas encore de données — premier scan en cours"
}
}
}

View File

@@ -8,6 +8,7 @@ import { dirname, join } from 'path';
import { fileURLToPath } from 'url';
import { exec } from 'child_process';
import config from './crucix.config.mjs';
import { getLocale, currentLanguage, getSupportedLocales } from './lib/i18n.mjs';
import { fullBriefing } from './apis/briefing.mjs';
import { synthesize, generateIdeas } from './dashboard/inject.mjs';
import { MemoryManager } from './lib/delta/index.mjs';
@@ -231,12 +232,20 @@ if (discordAlerter.isConfigured) {
const app = express();
app.use(express.static(join(ROOT, 'dashboard/public')));
// Serve loading page until first sweep completes, then the dashboard
// Serve loading page until first sweep completes, then the dashboard with injected locale
app.get('/', (req, res) => {
if (!currentData) {
res.sendFile(join(ROOT, 'dashboard/public/loading.html'));
} else {
res.sendFile(join(ROOT, 'dashboard/public/jarvis.html'));
const htmlPath = join(ROOT, 'dashboard/public/jarvis.html');
let html = readFileSync(htmlPath, 'utf-8');
// Inject locale data into the HTML
const locale = getLocale();
const localeScript = `<script>window.__CRUCIX_LOCALE__ = ${JSON.stringify(locale).replace(/<\/script>/gi, '<\\/script>')};</script>`;
html = html.replace('</head>', `${localeScript}\n</head>`);
res.type('html').send(html);
}
});
@@ -263,6 +272,15 @@ app.get('/api/health', (req, res) => {
llmProvider: config.llm.provider,
telegramEnabled: !!(config.telegram.botToken && config.telegram.chatId),
refreshIntervalMinutes: config.refreshIntervalMinutes,
language: currentLanguage,
});
});
// API: available locales
app.get('/api/locales', (req, res) => {
res.json({
current: currentLanguage,
supported: getSupportedLocales(),
});
});
@@ -408,7 +426,7 @@ async function start() {
process.exit(1);
});
server.on('listening', () => {
server.on('listening', async () => {
console.log(`[Crucix] Server running on http://localhost:${port}`);
// Auto-open browser
@@ -420,17 +438,18 @@ async function start() {
if (err) console.log('[Crucix] Could not auto-open browser:', err.message);
});
// Try to load existing data first for instant display
// Try to load existing data first for instant display (await so dashboard shows immediately)
try {
const existing = JSON.parse(readFileSync(join(RUNS_DIR, 'latest.json'), 'utf8'));
synthesize(existing).then(data => {
currentData = data;
console.log('[Crucix] Loaded existing data from runs/latest.json');
broadcast({ type: 'update', data: currentData });
}).catch(() => {});
} catch { /* no existing data */ }
const data = await synthesize(existing);
currentData = data;
console.log('[Crucix] Loaded existing data from runs/latest.json — dashboard ready instantly');
broadcast({ type: 'update', data: currentData });
} catch {
console.log('[Crucix] No existing data found — first sweep required');
}
// Run first sweep
// Run first sweep (refreshes data in background)
console.log('[Crucix] Running initial sweep...');
runSweepCycle().catch(err => {
console.error('[Crucix] Initial sweep failed:', err.message || err);

View File

@@ -0,0 +1,30 @@
// Mistral provider — integration test (calls real API)
// Requires MISTRAL_API_KEY environment variable
// Run: MISTRAL_API_KEY=sk-... node --test test/llm-minimax-integration.test.mjs
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import { MistralProvider } from '../lib/llm/mistral.mjs';
const API_KEY = process.env.MISTRAL_API_KEY;
describe('Mistral integration', { skip: !API_KEY && 'MISTRAL_API_KEY not set' }, () => {
it('should complete a prompt with mistral large latest', async () => {
const provider = new MistralProvider({ apiKey: API_KEY, model: 'mistral-large-latest' });
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-mistral.test.mjs Normal file
View File

@@ -0,0 +1,144 @@
// Mistral 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 { MistralProvider } from '../lib/llm/mistral.mjs';
import { createLLMProvider } from '../lib/llm/index.mjs';
// ─── Unit Tests ───
describe('MistralProvider', () => {
it('should set defaults correctly', () => {
const provider = new MistralProvider({ apiKey: 'sk-test' });
assert.equal(provider.name, 'mistral');
assert.equal(provider.model, 'mistral-large-latest');
assert.equal(provider.isConfigured, true);
});
it('should accept custom model', () => {
const provider = new MistralProvider({ apiKey: 'sk-test', model: 'mistral-small-2603' });
assert.equal(provider.model, 'mistral-small-2603');
});
it('should report not configured without API key', () => {
const provider = new MistralProvider({});
assert.equal(provider.isConfigured, false);
});
it('should throw on API error', async () => {
const provider = new MistralProvider({ 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, /Mistral API 401/);
return true;
}
);
} finally {
globalThis.fetch = originalFetch;
}
});
it('should parse successful response', async () => {
const provider = new MistralProvider({ apiKey: 'sk-test' });
const mockResponse = {
choices: [{ message: { content: 'Hello from Mistral' } }],
usage: { prompt_tokens: 10, completion_tokens: 5 },
model: 'mistral-large-latest',
};
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 Mistral');
assert.equal(result.usage.inputTokens, 10);
assert.equal(result.usage.outputTokens, 5);
assert.equal(result.model, 'mistral-large-latest');
} finally {
globalThis.fetch = originalFetch;
}
});
it('should send correct request format', async () => {
const provider = new MistralProvider({ apiKey: 'sk-test-key', model: 'mistral-large-latest' });
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: 'mistral-large-latest',
}),
});
});
try {
await provider.complete('system prompt', 'user message', { maxTokens: 2048 });
assert.equal(capturedUrl, 'https://api.mistral.ai/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, 'mistral-large-latest');
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 MistralProvider({ 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 — mistral', () => {
it('should create MistralProvider for provider=mistral', () => {
const provider = createLLMProvider({ provider: 'mistral', apiKey: 'sk-test', model: null });
assert.ok(provider instanceof MistralProvider);
assert.equal(provider.name, 'mistral');
assert.equal(provider.isConfigured, true);
});
it('should be case-insensitive', () => {
const provider = createLLMProvider({ provider: 'Mistral', apiKey: 'sk-test', model: null });
assert.ok(provider instanceof MistralProvider);
});
it('should return null for empty provider', () => {
const provider = createLLMProvider({ provider: null, apiKey: 'sk-test', model: null });
assert.equal(provider, null);
});
});