From 83c55df3a995b3d9bb61c5da15fe1d6a590b2812 Mon Sep 17 00:00:00 2001 From: MrSphay Date: Sun, 17 May 2026 14:49:05 +0200 Subject: [PATCH] feat: add scenario watchlist --- README.md | 33 ++++++ dashboard/public/jarvis.html | 12 ++ lib/scenarios.mjs | 212 +++++++++++++++++++++++++++++++++++ server.mjs | 9 ++ test/fetch-utils.test.mjs | 16 +++ 5 files changed, 282 insertions(+) create mode 100644 lib/scenarios.mjs diff --git a/README.md b/README.md index 74e69f3..db5309c 100644 --- a/README.md +++ b/README.md @@ -190,6 +190,39 @@ For Pangolin or another reverse proxy, forward HTTP traffic to `intelligence-ter The dashboard Terminal Actions panel can trigger `status`, `sweep`, and `brief` through `/api/action`. Leave `TERMINAL_ACTIONS_ENABLED=true` for a private home-server deployment. For an internet-exposed deployment, set `SWEEP_TOKEN` and pass it through trusted automation, or set `TERMINAL_ACTIONS_ENABLED=false` to disable browser-triggered actions. If you protect actions with `SWEEP_TOKEN`, the browser can send it from `localStorage.crucix_sweep_token`. +#### Scenario Watchlist + +Crucix can track operator hypotheses across sweeps with a runtime scenario file at `runs/scenarios.json`. On first run, the server creates three disabled starter examples: + +- Middle East energy shock +- Macro stress spillover +- Regional escalation risk + +Enable or add scenarios by editing `runs/scenarios.json`: + +```json +{ + "version": 1, + "scenarios": [ + { + "id": "middle-east-energy-shock", + "enabled": true, + "name": "Middle East energy shock", + "description": "Energy supply risk building from regional conflict.", + "regions": ["Middle East", "Iran", "Strait of Hormuz"], + "categories": ["osint", "energy", "maritime"], + "keywords": ["missile", "strike", "hormuz", "oil"], + "thresholds": { "watching": 2, "building": 4, "confirmed": 7 }, + "invalidation": "WTI normalizes and urgent regional signals fade." + } + ] +} +``` + +Malformed scenario config degrades safely: sweeps continue and the dashboard shows the watchlist as a config issue. Scenario state is persisted in `runs/scenario-state.json`; delete that file to reset state transitions without deleting definitions. + +Scenario states are `dormant`, `watching`, `building`, and `confirmed`. The dashboard shows active scenario state, confidence, score, and recent trigger time. Briefings include a `Scenario Watchlist` section when one or more scenarios change state. + #### Build And Publish Your Gitea Image ```bash diff --git a/dashboard/public/jarvis.html b/dashboard/public/jarvis.html index 5607403..39f2c7e 100644 --- a/dashboard/public/jarvis.html +++ b/dashboard/public/jarvis.html @@ -1652,6 +1652,14 @@ function renderRight(){ deltaRows.push(`
${s.label||s.key}${s.from}→${s.to} (${val})
`); } const deltaHtml = hasDelta ? deltaRows.join('') : `
${t('delta.noChanges','No changes since last sweep')}
`; + const scenarioItems = (D.scenarios?.items || []).filter(s => s.enabled || s.state !== 'dormant').slice(0,4); + const scenarioHtml = scenarioItems.length ? scenarioItems.map(s => ` +
+ ${s.name} ${(s.state||'dormant').toUpperCase()} +

${s.description || ''}

+
${s.confidence || 0}% confidence · score ${s.score || 0}${s.lastTriggerTime ? ' · ' + getAge(s.lastTriggerTime) : ''}
+
+ `).join('') : `
No active scenario watchlist items
`; document.getElementById('rightRail').innerHTML=`
@@ -1667,6 +1675,10 @@ function renderRight(){

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

${t('badges.worldview','WORLDVIEW')}
${signals}
+
+

Scenario Watchlist

${D.scenarios?.available===false?'CONFIG':'LIVE'}
+ ${scenarioHtml} +
${mobile ? '' : buildOsintPanel('right-osint', 260)}

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

${t('badges.hotMetrics','HOT METRICS')}
diff --git a/lib/scenarios.mjs b/lib/scenarios.mjs new file mode 100644 index 0000000..16b86bd --- /dev/null +++ b/lib/scenarios.mjs @@ -0,0 +1,212 @@ +import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs'; +import { join } from 'path'; + +const DEFAULT_SCENARIOS = [ + { + id: 'middle-east-energy-shock', + enabled: false, + name: 'Middle East energy shock', + description: 'Energy supply risk building from Middle East conflict or chokepoint pressure.', + regions: ['Middle East', 'Iran', 'Israel', 'Strait of Hormuz'], + categories: ['osint', 'energy', 'maritime'], + keywords: ['missile', 'strike', 'hormuz', 'oil', 'energy', 'blockade'], + thresholds: { watching: 2, building: 4, confirmed: 7 }, + invalidation: 'WTI normalizes and regional urgent signals fade for several sweeps.', + }, + { + id: 'macro-stress-spillover', + enabled: false, + name: 'Macro stress spillover', + description: 'Market stress spreads from volatility into credit, rates, or commodities.', + regions: ['US', 'Global'], + categories: ['macro', 'markets'], + keywords: ['vix', 'spread', 'credit', 'yield', 'inflation', 'gold'], + thresholds: { watching: 2, building: 4, confirmed: 6 }, + invalidation: 'VIX and credit stress both normalize while source health remains stable.', + }, + { + id: 'regional-escalation-risk', + enabled: false, + name: 'Regional escalation risk', + description: 'Local conflict signals broaden across adjacent regions or source categories.', + regions: ['Ukraine', 'Taiwan', 'Africa', 'Middle East'], + categories: ['conflict', 'thermal', 'osint', 'air'], + keywords: ['mobilization', 'intercept', 'drone', 'ballistic', 'fatalities', 'border'], + thresholds: { watching: 2, building: 5, confirmed: 8 }, + invalidation: 'No fresh cross-source escalation signals appear inside the configured horizon.', + }, +]; + +export function evaluateScenarios(data, delta, runsDir) { + const loaded = loadScenarioDefinitions(runsDir); + if (!loaded.ok) { + return { available: false, error: loaded.error, items: [], changed: [] }; + } + + const statePath = join(runsDir, 'scenario-state.json'); + const previous = readJson(statePath, {}); + const evaluatedAt = data.meta?.timestamp || new Date().toISOString(); + const corpus = buildCorpus(data, delta); + const items = loaded.scenarios.map(def => evaluateScenario(def, corpus, previous[def.id], evaluatedAt)); + const changed = items.filter(item => item.changed); + + writeJson(statePath, Object.fromEntries(items.map(item => [item.id, { + state: item.state, + score: item.score, + confidence: item.confidence, + lastTriggerTime: item.lastTriggerTime, + updatedAt: evaluatedAt, + }]))); + + return { + available: true, + path: loaded.path, + items, + changed, + }; +} + +export function loadScenarioDefinitions(runsDir) { + const path = join(runsDir, 'scenarios.json'); + try { + if (!existsSync(runsDir)) mkdirSync(runsDir, { recursive: true }); + if (!existsSync(path)) { + writeJson(path, { + version: 1, + scenarios: DEFAULT_SCENARIOS, + }); + } + const raw = JSON.parse(readFileSync(path, 'utf8')); + if (!raw || !Array.isArray(raw.scenarios)) throw new Error('scenarios must be an array'); + const scenarios = raw.scenarios + .map(normalizeScenario) + .filter(Boolean); + return { ok: true, path, scenarios }; + } catch (err) { + return { ok: false, path, error: err.message }; + } +} + +function normalizeScenario(input) { + if (!input || typeof input !== 'object') return null; + const id = String(input.id || input.name || '').trim().toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, ''); + const name = String(input.name || input.id || '').trim(); + if (!id || !name) return null; + const thresholds = input.thresholds || {}; + return { + id, + enabled: input.enabled === true, + name, + description: String(input.description || ''), + regions: arrayOfStrings(input.regions), + categories: arrayOfStrings(input.categories), + keywords: arrayOfStrings(input.keywords).map(s => s.toLowerCase()), + thresholds: { + watching: Number(thresholds.watching || 2), + building: Number(thresholds.building || 4), + confirmed: Number(thresholds.confirmed || 7), + }, + invalidation: String(input.invalidation || ''), + }; +} + +function evaluateScenario(def, corpus, previous, evaluatedAt) { + if (!def.enabled) { + return { + ...publicScenario(def), + state: 'dormant', + score: 0, + confidence: 0, + evidence: [], + changed: previous?.state && previous.state !== 'dormant', + lastTriggerTime: previous?.lastTriggerTime || null, + }; + } + + const evidence = []; + let score = 0; + for (const keyword of def.keywords) { + const hit = corpus.entries.find(entry => entry.text.includes(keyword)); + if (hit) { + score += 1; + evidence.push({ type: 'keyword', label: keyword, source: hit.source, text: hit.original.slice(0, 180) }); + } + } + for (const region of def.regions) { + const needle = region.toLowerCase(); + const hit = corpus.entries.find(entry => entry.text.includes(needle)); + if (hit) { + score += 1; + evidence.push({ type: 'region', label: region, source: hit.source, text: hit.original.slice(0, 180) }); + } + } + for (const category of def.categories) { + if (corpus.categories.has(category.toLowerCase())) { + score += 1; + evidence.push({ type: 'category', label: category, source: 'sweep', text: `${category} category active` }); + } + } + + const state = score >= def.thresholds.confirmed ? 'confirmed' + : score >= def.thresholds.building ? 'building' + : score >= def.thresholds.watching ? 'watching' + : 'dormant'; + const confidence = Math.min(100, Math.round((score / Math.max(1, def.thresholds.confirmed)) * 100)); + const changed = previous?.state ? previous.state !== state : state !== 'dormant'; + return { + ...publicScenario(def), + state, + score, + confidence, + evidence: evidence.slice(0, 6), + changed, + lastTriggerTime: state === 'dormant' ? (previous?.lastTriggerTime || null) : evaluatedAt, + }; +} + +function publicScenario(def) { + return { + id: def.id, + name: def.name, + description: def.description, + enabled: def.enabled, + invalidation: def.invalidation, + }; +} + +function buildCorpus(data, delta) { + const entries = []; + const categories = new Set(); + const push = (source, text, category) => { + if (!text) return; + entries.push({ source, original: String(text), text: String(text).toLowerCase() }); + if (category) categories.add(category); + }; + + for (const signal of data.tSignals || []) push('thermal', signal, 'thermal'); + for (const post of data.tg?.urgent || []) push(post.channel || 'telegram', post.text, 'osint'); + for (const item of data.newsFeed || []) push(item.source || 'news', item.headline || item.title, 'news'); + for (const item of data.news || []) push(item.source || 'news', item.headline || item.title, 'news'); + for (const item of data.acled?.deadliestEvents || []) push('ACLED', `${item.country || ''} ${item.location || ''} ${item.event_type || ''} ${item.fatalities || ''}`, 'conflict'); + for (const item of data.air || []) push('OpenSky', `${item.region} ${item.total} aircraft`, 'air'); + for (const item of data.chokepoints || []) push('Maritime', `${item.label} ${item.note}`, 'maritime'); + if (data.energy?.wti || data.energy?.brent) push('energy', `WTI ${data.energy.wti} Brent ${data.energy.brent}`, 'energy'); + if (data.markets?.vix || data.fred?.some(f => f.id === 'VIXCLS')) push('markets', 'VIX volatility market stress', 'markets'); + if (delta?.summary) push('delta', `${delta.summary.direction} ${delta.summary.totalChanges} changes ${delta.summary.criticalChanges} critical`, 'delta'); + for (const signal of delta?.signals?.new || []) push('delta', signal.label || signal.reason || signal.key, 'delta'); + for (const signal of delta?.signals?.escalated || []) push('delta', signal.label || signal.reason || signal.key, 'delta'); + + return { entries, categories }; +} + +function arrayOfStrings(value) { + return Array.isArray(value) ? value.map(v => String(v).trim()).filter(Boolean) : []; +} + +function readJson(path, fallback) { + try { return JSON.parse(readFileSync(path, 'utf8')); } catch { return fallback; } +} + +function writeJson(path, value) { + writeFileSync(path, JSON.stringify(value, null, 2)); +} diff --git a/server.mjs b/server.mjs index 95949f0..18c4afd 100644 --- a/server.mjs +++ b/server.mjs @@ -18,6 +18,7 @@ import { TelegramAlerter } from './lib/alerts/telegram.mjs'; import { DiscordAlerter } from './lib/alerts/discord.mjs'; import { getFetchMetrics } from './apis/utils/fetch.mjs'; import { IntelligenceStore } from './lib/intelligence-store.mjs'; +import { evaluateScenarios } from './lib/scenarios.mjs'; const __dirname = dirname(fileURLToPath(import.meta.url)); const ROOT = __dirname; @@ -447,6 +448,13 @@ function buildBrief(data) { lines.push('', '*Why This Matters*'); for (const idea of ideas) lines.push(`- ${idea.title}: ${(idea.rationale || idea.text || '').slice(0, 140)}`); } + const scenarioChanges = data.scenarios?.changed || []; + if (scenarioChanges.length) { + lines.push('', '*Scenario Watchlist*'); + for (const scenario of scenarioChanges.slice(0, 4)) { + lines.push(`- ${scenario.name}: ${scenario.state.toUpperCase()} (${scenario.confidence}% confidence)`); + } + } lines.push('', '*What To Do Next*', '- Open the dashboard, verify the evidence links, and compare source health before acting.'); return lines.join('\n'); } @@ -493,6 +501,7 @@ async function runSweepCycle() { // 4. Delta computation + memory const delta = memory.addRun(synthesized); synthesized.delta = delta; + synthesized.scenarios = evaluateScenarios(synthesized, delta, RUNS_DIR); // 5. LLM-powered trade ideas (LLM-only feature) — isolated so failures don't kill sweep if (llmProvider?.isConfigured) { diff --git a/test/fetch-utils.test.mjs b/test/fetch-utils.test.mjs index 2dcee45..8a69f73 100644 --- a/test/fetch-utils.test.mjs +++ b/test/fetch-utils.test.mjs @@ -1,5 +1,6 @@ import test from 'node:test'; import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; import { safeFetch, safeFetchText, getFetchMetrics } from '../apis/utils/fetch.mjs'; test('safeFetch reports HTML as degraded JSON response', async () => { @@ -34,3 +35,18 @@ test('safeFetchText returns text and byte count', async () => { globalThis.fetch = originalFetch; } }); + +test('scenario watchlist feature is wired into sweep, briefing, and dashboard', () => { + const scenarios = readFileSync(new URL('../lib/scenarios.mjs', import.meta.url), 'utf8'); + const server = readFileSync(new URL('../server.mjs', import.meta.url), 'utf8'); + const html = readFileSync(new URL('../dashboard/public/jarvis.html', import.meta.url), 'utf8'); + const readme = readFileSync(new URL('../README.md', import.meta.url), 'utf8'); + assert.match(scenarios, /DEFAULT_SCENARIOS/); + assert.match(scenarios, /runsDir, 'scenarios\.json'/); + assert.match(scenarios, /scenario-state\.json/); + assert.match(scenarios, /watching.*building.*confirmed/s); + assert.match(server, /evaluateScenarios\(synthesized, delta, RUNS_DIR\)/); + assert.match(server, /\*Scenario Watchlist\*/); + assert.match(html, /Scenario Watchlist/); + assert.match(readme, /runs\/scenarios\.json/); +});