Merge pull request 'feat: add scenario watchlist' (#36) from codex/issue-26-scenario-watchlist into codex/production-intelligence-terminal
Reviewed-on: #36
This commit was merged in pull request #36.
This commit is contained in:
33
README.md
33
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
|
||||
|
||||
@@ -1652,6 +1652,14 @@ function renderRight(){
|
||||
deltaRows.push(`<div class="delta-row"><span class="delta-badge down">▼</span><span class="delta-label">${s.label||s.key}</span><span class="delta-val">${s.from}→${s.to} (${val})</span></div>`);
|
||||
}
|
||||
const deltaHtml = hasDelta ? deltaRows.join('') : `<div style="padding:12px;text-align:center;color:var(--dim);font-family:var(--mono);font-size:10px">${t('delta.noChanges','No changes since last sweep')}</div>`;
|
||||
const scenarioItems = (D.scenarios?.items || []).filter(s => s.enabled || s.state !== 'dormant').slice(0,4);
|
||||
const scenarioHtml = scenarioItems.length ? scenarioItems.map(s => `
|
||||
<div class="signal-row">
|
||||
<strong>${s.name} <span class="delta-badge ${s.changed?'new':''}">${(s.state||'dormant').toUpperCase()}</span></strong>
|
||||
<p>${s.description || ''}</p>
|
||||
<div class="layer-sub">${s.confidence || 0}% confidence · score ${s.score || 0}${s.lastTriggerTime ? ' · ' + getAge(s.lastTriggerTime) : ''}</div>
|
||||
</div>
|
||||
`).join('') : `<div style="padding:12px;text-align:center;color:var(--dim);font-family:var(--mono);font-size:10px">No active scenario watchlist items</div>`;
|
||||
|
||||
document.getElementById('rightRail').innerHTML=`
|
||||
<div class="g-panel right-actions">
|
||||
@@ -1667,6 +1675,10 @@ function renderRight(){
|
||||
<div class="sec-head"><h3>${t('panels.crossSourceSignals','Cross-Source Signals')}</h3><span class="badge">${t('badges.worldview','WORLDVIEW')}</span></div>
|
||||
${signals}
|
||||
</div>
|
||||
<div class="g-panel right-scenarios">
|
||||
<div class="sec-head"><h3>Scenario Watchlist</h3><span class="badge">${D.scenarios?.available===false?'CONFIG':'LIVE'}</span></div>
|
||||
${scenarioHtml}
|
||||
</div>
|
||||
${mobile ? '' : buildOsintPanel('right-osint', 260)}
|
||||
<div class="g-panel right-core">
|
||||
<div class="sec-head"><h3>${t('panels.signalCore','Signal Core')}</h3><span class="badge">${t('badges.hotMetrics','HOT METRICS')}</span></div>
|
||||
|
||||
212
lib/scenarios.mjs
Normal file
212
lib/scenarios.mjs
Normal file
@@ -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));
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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/);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user