diff --git a/.env.example b/.env.example index 674e882..3ea7f45 100644 --- a/.env.example +++ b/.env.example @@ -1,49 +1,49 @@ -# ============================================ -# Crucix Intelligence Engine — Configuration -# ============================================ -# Copy this file to .env and fill in your keys. -# Keys are optional — sources without keys degrade gracefully. -# -# IMPORTANT: Do NOT put comments on the same line as values. -# Docker env_file treats inline comments as part of the value. +# Intelligence Terminal / Crucix configuration +# Copy to .env. Keep comments on separate lines; Docker env_file treats inline comments as values. -# === OSINT Source API Keys === - -# Federal Reserve Economic Data (free: fred.stlouisfed.org/docs/api) -FRED_API_KEY= -# NASA FIRMS fire data (free: firms.modaps.eosdis.nasa.gov/api) -FIRMS_MAP_KEY= -# Energy Information Administration (free: api.eia.gov/register) -EIA_API_KEY= -# Maritime AIS data (aisstream.io) -AISSTREAM_API_KEY= -# Armed Conflict Location & Event Data (acleddata.com/user/register) -ACLED_EMAIL= -# OAuth2 password grant (API keys deprecated Sept 2025) -ACLED_PASSWORD= -# Cloudflare Radar internet outages & traffic anomalies (free: dash.cloudflare.com/profile/api-tokens, Account Analytics Read) -CLOUDFLARE_API_TOKEN= - -# === Server Configuration === - -# Dashboard server port +# Server PORT=3117 -# Auto-refresh interval (minutes) REFRESH_INTERVAL_MINUTES=15 +AUTO_OPEN_BROWSER=false +STALE_DATA_MAX_AGE_MINUTES=60 +SWEEP_TOKEN= +BRIEF_VERBOSITY=standard -# === LLM Layer (optional) === -# Enables AI-enhanced trade ideas and breaking news Telegram alerts. -# Provider options: anthropic | openai | gemini | codex | openrouter | minimax | mistral | ollama | grok -LLM_PROVIDER= -# Not needed for codex (uses ~/.codex/auth.json) or ollama (local) +# LLM layer +# Providers: openrouter | openai-compatible | lmstudio | ollama | openai | anthropic | gemini | mistral | minimax | grok | codex +LLM_PROVIDER=openrouter +LLM_BASE_URL=https://openrouter.ai/api/v1 LLM_API_KEY= -# Optional override. Each provider has a sensible default: -# anthropic: claude-sonnet-4-6 | openai: gpt-5.4 | gemini: gemini-3.1-pro | codex: gpt-5.3-codex | openrouter: openrouter/auto | minimax: MiniMax-M2.5 | ollama: llama3.1:8b | grok: grok-4-latest -LLM_MODEL= -# Ollama base URL (only needed if not using default http://localhost:11434) -OLLAMA_BASE_URL= +LLM_MODEL=openrouter/free +LLM_TEMPERATURE=0.2 +LLM_MAX_TOKENS=2000 +LLM_TIMEOUT_MS=90000 +OPENROUTER_SITE_URL=https://git.wilkensxl.de/MrSphay/intelligence-terminal +OPENROUTER_APP_NAME=Intelligence Terminal -# === Telegram Alerts (optional, requires LLM) === -# Create a bot via @BotFather, get chat ID via @userinfobot +# Local OpenAI-compatible examples +# LM Studio: LLM_PROVIDER=lmstudio, LLM_BASE_URL=http://host.docker.internal:1234/v1, LLM_MODEL=local-model +# Ollama: LLM_PROVIDER=ollama, LLM_BASE_URL=http://host.docker.internal:11434, LLM_MODEL=llama3.1:8b +# Generic: LLM_PROVIDER=openai-compatible, LLM_BASE_URL=http://host.docker.internal:8000/v1, LLM_MODEL=your-model + +# Core OSINT / market source keys +FRED_API_KEY= +FIRMS_MAP_KEY= +EIA_API_KEY= +AISSTREAM_API_KEY= +ACLED_EMAIL= +ACLED_PASSWORD= +CLOUDFLARE_API_TOKEN= +BLS_API_KEY= + +# Telegram bot and alerts TELEGRAM_BOT_TOKEN= TELEGRAM_CHAT_ID= +TELEGRAM_POLL_INTERVAL=5000 +TELEGRAM_CHANNELS= + +# Discord bot/webhook +DISCORD_BOT_TOKEN= +DISCORD_CHANNEL_ID= +DISCORD_GUILD_ID= +DISCORD_WEBHOOK_URL= diff --git a/.gitea/workflows/build.yml b/.gitea/workflows/build.yml new file mode 100644 index 0000000..188020f --- /dev/null +++ b/.gitea/workflows/build.yml @@ -0,0 +1,42 @@ +name: Build + +on: + push: + branches: + - master + - main + - codex/production-intelligence-terminal + pull_request: + +jobs: + test-and-image: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Node + uses: actions/setup-node@v4 + with: + node-version: "22" + cache: npm + + - name: Install dependencies + run: npm ci + + - name: Unit tests + run: npm run test:unit + + - name: Compose config + run: docker compose config + + - name: Build Docker image + run: docker build -t git.wilkensxl.de/mrsphay/intelligence-terminal:${{ github.sha }} . + + - name: Publish Docker image + if: ${{ secrets.REGISTRY_TOKEN != '' }} + run: | + echo "${{ secrets.REGISTRY_TOKEN }}" | docker login git.wilkensxl.de -u "${{ github.repository_owner }}" --password-stdin + docker tag git.wilkensxl.de/mrsphay/intelligence-terminal:${{ github.sha }} git.wilkensxl.de/mrsphay/intelligence-terminal:latest + docker push git.wilkensxl.de/mrsphay/intelligence-terminal:${{ github.sha }} + docker push git.wilkensxl.de/mrsphay/intelligence-terminal:latest diff --git a/README.md b/README.md index a05f962..16d5db8 100644 --- a/README.md +++ b/README.md @@ -97,13 +97,105 @@ The dashboard opens automatically at `http://localhost:3117` and immediately beg ### Docker ```bash -git clone https://github.com/calesthio/Crucix.git -cd Crucix -cp .env.example .env # add your API keys -docker compose up -d +docker pull git.wilkensxl.de/mrsphay/intelligence-terminal:latest ``` -Dashboard at `http://localhost:3117`. Sweep data persists in `./runs/` via volume mount. Includes a health check endpoint. +Dashboard at `http://localhost:3117`. Sweep data persists in `./runs/` via volume mount. The container disables browser auto-open by default, exposes `/api/health` and `/api/metrics`, and is suitable for Dockge/Pangolin. + +#### Dockge / Pangolin Compose + +```yaml +services: + intelligence-terminal: + image: git.wilkensxl.de/mrsphay/intelligence-terminal:latest + container_name: intelligence-terminal + env_file: + - path: .env + required: false + environment: + PORT: ${PORT:-3117} + AUTO_OPEN_BROWSER: "false" + ports: + - "${PORT:-3117}:${PORT:-3117}" + volumes: + - ./runs:/app/runs + restart: unless-stopped + healthcheck: + test: ["CMD", "node", "-e", "fetch('http://127.0.0.1:'+(process.env.PORT||3117)+'/api/health').then(r=>{if(![200,503].includes(r.status))process.exit(1);return r.json()}).then(j=>{if(j.status==='error')process.exit(1)}).catch(()=>process.exit(1))"] + interval: 60s + timeout: 10s + start_period: 45s + retries: 3 +``` + +#### Required `.env` Example + +```env +PORT=3117 +REFRESH_INTERVAL_MINUTES=15 +AUTO_OPEN_BROWSER=false +STALE_DATA_MAX_AGE_MINUTES=60 +SWEEP_TOKEN= +BRIEF_VERBOSITY=standard + +LLM_PROVIDER=openrouter +LLM_BASE_URL=https://openrouter.ai/api/v1 +LLM_API_KEY= +LLM_MODEL=openrouter/free +LLM_TEMPERATURE=0.2 +LLM_MAX_TOKENS=2000 +LLM_TIMEOUT_MS=90000 +OPENROUTER_SITE_URL=https://git.wilkensxl.de/MrSphay/intelligence-terminal +OPENROUTER_APP_NAME=Intelligence Terminal + +FRED_API_KEY= +FIRMS_MAP_KEY= +EIA_API_KEY= +AISSTREAM_API_KEY= +ACLED_EMAIL= +ACLED_PASSWORD= +CLOUDFLARE_API_TOKEN= +BLS_API_KEY= + +TELEGRAM_BOT_TOKEN= +TELEGRAM_CHAT_ID= +DISCORD_BOT_TOKEN= +DISCORD_CHANNEL_ID= +DISCORD_GUILD_ID= +DISCORD_WEBHOOK_URL= +``` + +Local LLM examples: + +```env +# LM Studio +LLM_PROVIDER=lmstudio +LLM_BASE_URL=http://host.docker.internal:1234/v1 +LLM_MODEL=local-model + +# Ollama +LLM_PROVIDER=ollama +LLM_BASE_URL=http://host.docker.internal:11434 +LLM_MODEL=llama3.1:8b + +# Generic OpenAI-compatible endpoint +LLM_PROVIDER=openai-compatible +LLM_BASE_URL=http://host.docker.internal:8000/v1 +LLM_API_KEY= +LLM_MODEL=your-model +``` + +For Pangolin or another reverse proxy, forward HTTP traffic to `intelligence-terminal:3117` (or the `PORT` you set). Missing API keys do not crash sweeps; affected sources are reported as degraded in `/api/health`. + +#### Build And Publish Your Gitea Image + +```bash +docker login git.wilkensxl.de +docker build -t git.wilkensxl.de/mrsphay/intelligence-terminal:latest . +docker tag git.wilkensxl.de/mrsphay/intelligence-terminal:latest git.wilkensxl.de/mrsphay/intelligence-terminal:20260516 +docker push git.wilkensxl.de/mrsphay/intelligence-terminal:latest +docker push git.wilkensxl.de/mrsphay/intelligence-terminal:20260516 +``` --- @@ -186,12 +278,26 @@ 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 LLM Layer -Connect any of 8 LLM providers for enhanced analysis: +Connect cloud or local OpenAI-compatible 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, Mistral, Grok +- Providers: OpenRouter, OpenAI-compatible APIs, LM Studio, Ollama, OpenAI, Anthropic Claude, Google Gemini, OpenAI Codex, MiniMax, Mistral, Grok - Graceful fallback — when LLM is unavailable, a rule-based engine takes over alert evaluation. LLM failures never crash the sweep cycle. +Primary env keys: + +```env +LLM_PROVIDER=openrouter +LLM_BASE_URL=https://openrouter.ai/api/v1 +LLM_API_KEY= +LLM_MODEL=openrouter/free +LLM_TEMPERATURE=0.2 +LLM_MAX_TOKENS=2000 +LLM_TIMEOUT_MS=90000 +``` + +OpenRouter also supports model ids ending in `:free`; use the model id shown in your OpenRouter account when you want a specific free model. + --- ## API Keys Setup diff --git a/dashboard/public/jarvis.html b/dashboard/public/jarvis.html index 05a5c7a..921b3fd 100644 --- a/dashboard/public/jarvis.html +++ b/dashboard/public/jarvis.html @@ -75,6 +75,14 @@ html,body{height:100%;background:var(--bg);color:var(--text);font-family:var(--s /* LEFT RAIL */ .layer-item{display:flex;align-items:center;justify-content:space-between;padding:8px;border:1px solid rgba(255,255,255,0.04);background:rgba(255,255,255,0.02);margin-bottom:4px} +.layer-item{cursor:pointer;transition:border-color .15s ease,background .15s ease,opacity .15s ease} +.layer-item:hover{border-color:rgba(100,240,200,0.24);background:rgba(100,240,200,0.04)} +.layer-item.focused{border-color:rgba(100,240,200,0.6);background:rgba(100,240,200,0.08)} +.layer-item.hidden-layer{opacity:.45;border-color:rgba(255,95,99,0.18)} +.layer-mode{font-family:var(--mono);font-size:8px;color:var(--dim);margin-top:2px;text-transform:uppercase} +.sensor-actions{display:flex;gap:6px;align-items:center} +.mini-btn{border:1px solid rgba(100,240,200,0.18);background:rgba(100,240,200,0.04);color:var(--dim);font-family:var(--mono);font-size:9px;padding:3px 6px;cursor:pointer} +.mini-btn:hover{color:var(--accent);border-color:rgba(100,240,200,0.4)} .layer-left{display:flex;align-items:center;gap:8px} .ldot{width:10px;height:10px;border-radius:50%;flex-shrink:0} .ldot.air{background:var(--accent);box-shadow:0 0 6px rgba(100,240,200,0.4)} @@ -394,8 +402,23 @@ let globeInitialized = false; let flightsVisible = true; let lowPerfMode = localStorage.getItem('crucix_low_perf') === 'true'; let isFlat = shouldStartFlat(); +let layerModes = JSON.parse(localStorage.getItem('crucix_layer_modes') || '{}'); +let spaceDisplayMode = localStorage.getItem('crucix_space_display') || 'icons'; let currentRegion = 'world'; let flatSvg, flatProjection, flatPath, flatG, flatZoom, flatW, flatH; + +const layerTypeMap = { + air: ['air'], + thermal: ['thermal'], + sdr: ['sdr'], + maritime: ['maritime'], + nuke: ['nuke', 'radiation'], + conflict: ['conflict'], + health: ['health'], + news: ['news', 'gdelt'], + osint: ['osint'], + space: ['space'], +}; const signalGuideItems = [ { term:'No Callsign', @@ -602,6 +625,44 @@ function renderTopbar(){ } // === LEFT RAIL === +function layerMode(key){ return layerModes[key] || 'normal'; } +function layerModeLabel(key){ return layerMode(key) === 'focus' ? 'focused' : layerMode(key) === 'hidden' ? 'hidden' : 'normal'; } +function setLayerMode(key, mode){ + if(mode === 'normal') delete layerModes[key]; else layerModes[key] = mode; + localStorage.setItem('crucix_layer_modes', JSON.stringify(layerModes)); + renderLeftRail(); + if(isFlat && flatG){ flatG.selectAll('*').remove(); drawFlatMap(); } + else plotMarkers(); +} +function cycleLayerMode(key, event){ + const current = layerMode(key); + const next = event && (event.shiftKey || event.ctrlKey || event.metaKey) + ? (current === 'hidden' ? 'normal' : 'hidden') + : (current === 'focus' ? 'normal' : 'focus'); + setLayerMode(key, next); +} +function resetLayerModes(){ + layerModes = {}; + localStorage.removeItem('crucix_layer_modes'); + renderLeftRail(); + if(isFlat && flatG){ flatG.selectAll('*').remove(); drawFlatMap(); } + else plotMarkers(); +} +function toggleSpaceDisplay(){ + spaceDisplayMode = spaceDisplayMode === 'icons' ? 'orbits' : 'icons'; + localStorage.setItem('crucix_space_display', spaceDisplayMode); + renderLeftRail(); + if(isFlat && flatG){ flatG.selectAll('*').remove(); drawFlatMap(); } + else plotMarkers(); +} +function shouldShowType(type){ + const focused = Object.entries(layerModes).filter(([,mode]) => mode === 'focus').flatMap(([key]) => layerTypeMap[key] || []); + const hidden = Object.entries(layerModes).filter(([,mode]) => mode === 'hidden').flatMap(([key]) => layerTypeMap[key] || []); + if(hidden.includes(type)) return false; + if(focused.length > 0) return focused.includes(type); + return true; +} + function renderLeftRail(){ const totalAir=D.air.reduce((s,a)=>s+a.total,0); const totalThermal=D.thermal.reduce((s,t)=>s+t.det,0); @@ -610,16 +671,16 @@ function renderLeftRail(){ const conflictEvents = D.acled?.totalEvents || 0; const conflictFatal = D.acled?.totalFatalities || 0; const layers=[ - {name:t('layers.airActivity','Air Activity'),count:totalAir,dot:'air',sub:`${D.air.length} ${t('layers.theaters','theaters')}`}, - {name:t('layers.thermalSpikes','Thermal Spikes'),count:totalThermal.toLocaleString(),dot:'thermal',sub:`${totalNight.toLocaleString()} ${t('layers.nightDet','night det.')}`}, - {name:t('layers.sdrCoverage','SDR Coverage'),count:D.sdr.total,dot:'sdr',sub:`${D.sdr.online} ${t('layers.online','online')}`}, - {name:t('layers.maritimeWatch','Maritime Watch'),count:D.chokepoints.length,dot:'maritime',sub:t('layers.chokepoints','chokepoints')}, - {name:t('layers.nuclearSites','Nuclear Sites'),count:D.nuke.length,dot:'nuke',sub:t('layers.monitors','monitors')}, - {name:t('layers.conflictEvents','Conflict Events'),count:conflictEvents,dot:'thermal',sub:`${conflictFatal.toLocaleString()} ${t('layers.fatalities','fatalities')}`}, - {name:t('layers.healthWatch','Health Watch'),count:D.who.length,dot:'health',sub:t('layers.whoAlerts','WHO alerts')}, - {name:t('layers.worldNews','World News'),count:newsCount,dot:'news',sub:t('layers.rssGeolocated','RSS geolocated')}, - {name:t('layers.osintFeed','OSINT Feed'),count:D.tg.posts,dot:'incident',sub:`${D.tg.urgent.length} ${t('badges.urgent','urgent').toLowerCase()}`}, - {name:t('layers.spaceActivity','Satellites'),count:D.space?.militarySats||0,dot:'space',sub:`${D.space?.totalNewObjects||0} ${t('space.newLast30d','new (30d)')}`} + {key:'air',name:t('layers.airActivity','Air Activity'),count:totalAir,dot:'air',sub:`${D.air.length} ${t('layers.theaters','theaters')}`}, + {key:'thermal',name:t('layers.thermalSpikes','Thermal Spikes'),count:totalThermal.toLocaleString(),dot:'thermal',sub:`${totalNight.toLocaleString()} ${t('layers.nightDet','night det.')}`}, + {key:'sdr',name:t('layers.sdrCoverage','SDR Coverage'),count:D.sdr.total,dot:'sdr',sub:`${D.sdr.online} ${t('layers.online','online')}`}, + {key:'maritime',name:t('layers.maritimeWatch','Maritime Watch'),count:D.chokepoints.length,dot:'maritime',sub:t('layers.chokepoints','chokepoints')}, + {key:'nuke',name:t('layers.nuclearSites','Nuclear Sites'),count:D.nuke.length,dot:'nuke',sub:t('layers.monitors','monitors')}, + {key:'conflict',name:t('layers.conflictEvents','Conflict Events'),count:conflictEvents,dot:'thermal',sub:`${conflictFatal.toLocaleString()} ${t('layers.fatalities','fatalities')}`}, + {key:'health',name:t('layers.healthWatch','Health Watch'),count:D.who.length,dot:'health',sub:t('layers.whoAlerts','WHO alerts')}, + {key:'news',name:t('layers.worldNews','World News'),count:newsCount,dot:'news',sub:t('layers.rssGeolocated','RSS geolocated')}, + {key:'osint',name:t('layers.osintFeed','OSINT Feed'),count:D.tg.posts,dot:'incident',sub:`${D.tg.urgent.length} ${t('badges.urgent','urgent').toLowerCase()}`}, + {key:'space',name:t('layers.spaceActivity','Satellites'),count:D.space?.militarySats||0,dot:'space',sub:`${D.space?.totalNewObjects||0} ${t('space.newLast30d','new (30d)')}`} ]; const allNormal=D.nuke.every(s=>!s.anom); const nukeHtml=D.nuke.map(s=>`