${t('panels.signalCore','Signal Core')}
${t('badges.hotMetrics','HOT METRICS')}
diff --git a/docs/sources/README.md b/docs/sources/README.md
index 008b1f5..e8549a4 100644
--- a/docs/sources/README.md
+++ b/docs/sources/README.md
@@ -16,3 +16,4 @@ Source docs:
- [Telegram](telegram.md)
- [FIRMS](firms.md)
- [Maritime](maritime.md)
+- [Reddit](reddit.md)
diff --git a/docs/sources/opensky.md b/docs/sources/opensky.md
index 19ee63f..bd3334e 100644
--- a/docs/sources/opensky.md
+++ b/docs/sources/opensky.md
@@ -6,4 +6,4 @@ Provides public aircraft state data for regional air-activity hotspots.
- Failure modes: timeouts, `HTTP 429`, and empty regions.
- Behavior: source health is marked degraded on API errors. The dashboard may use the most recent non-empty air snapshot from `runs/` and marks it in `airMeta.fallback`.
- Test: start a sweep and inspect `/api/health` plus `airMeta` from `/api/data`.
-- Operator note: Crucix does not bypass OpenSky throttles. Increase `REFRESH_INTERVAL_MINUTES` if the source degrades repeatedly.
+- Operator note: Intelligence Terminal does not bypass OpenSky throttles. Increase `REFRESH_INTERVAL_MINUTES` if the source degrades repeatedly.
diff --git a/docs/sources/reddit.md b/docs/sources/reddit.md
new file mode 100644
index 0000000..c7ce6e4
--- /dev/null
+++ b/docs/sources/reddit.md
@@ -0,0 +1,33 @@
+# Reddit Source
+
+Reddit is used as a social sentiment input for selected geopolitical and market subreddits.
+
+## Configuration
+
+Create a Reddit script app at:
+
+```text
+https://www.reddit.com/prefs/apps/
+```
+
+Then set:
+
+```env
+REDDIT_CLIENT_ID=
+REDDIT_CLIENT_SECRET=
+```
+
+## Runtime Behavior
+
+- The source uses the OAuth client credentials flow and then reads `https://oauth.reddit.com`.
+- Unauthenticated `reddit.com/.../hot.json` scraping is intentionally disabled.
+- Missing credentials return `status: no_credentials` and are surfaced as source degradation.
+- OAuth failures return `status: auth_failed` without logging or returning the client secret.
+- Subreddit fetch failures return `status: degraded` with per-subreddit errors.
+
+## Test
+
+```bash
+node apis/sources/reddit.mjs
+npm run test:unit
+```
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/package-lock.json b/package-lock.json
index b803cdd..7016c81 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,11 +1,11 @@
{
- "name": "crucix",
+ "name": "intelligence-terminal",
"version": "2.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
- "name": "crucix",
+ "name": "intelligence-terminal",
"version": "2.0.0",
"license": "AGPL-3.0-only",
"dependencies": {
diff --git a/package.json b/package.json
index 7b68f9f..09bc405 100644
--- a/package.json
+++ b/package.json
@@ -1,7 +1,7 @@
{
- "name": "crucix",
+ "name": "intelligence-terminal",
"version": "2.0.0",
- "description": "Local intelligence engine - 27 OSINT sources, live dashboard, public demo at crucix.live, auto-refresh, optional LLM layer.",
+ "description": "Docker-first local intelligence terminal with 27 OSINT sources, live dashboard, source health, auto-refresh, and optional LLM layer.",
"type": "module",
"scripts": {
"start": "node server.mjs",
@@ -12,7 +12,7 @@
"brief:save": "node apis/save-briefing.mjs",
"diag": "node diag.mjs",
"test": "npm run test:unit",
- "test:unit": "node --test test/llm-openrouter.test.mjs test/llm-ollama.test.mjs test/llm-openai-compatible.test.mjs test/fetch-utils.test.mjs test/acled-source.test.mjs",
+ "test:unit": "node --test test/llm-openrouter.test.mjs test/llm-ollama.test.mjs test/llm-openai-compatible.test.mjs test/fetch-utils.test.mjs test/reddit-source.test.mjs test/acled-source.test.mjs",
"compose:config": "docker compose config",
"clean": "node scripts/clean.mjs",
"fresh-start": "npm run clean && npm start"
@@ -23,7 +23,7 @@
"dashboard",
"geopolitical"
],
- "author": "Crucix",
+ "author": "Intelligence Terminal contributors",
"license": "AGPL-3.0-only",
"engines": {
"node": ">=22",
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..6a39267 100644
--- a/test/fetch-utils.test.mjs
+++ b/test/fetch-utils.test.mjs
@@ -1,9 +1,11 @@
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 () => {
const originalFetch = globalThis.fetch;
+ const source = 'unit-html-once';
globalThis.fetch = async () => ({
ok: true,
status: 200,
@@ -11,9 +13,72 @@ test('safeFetch reports HTML as degraded JSON response', async () => {
text: async () => 'not json',
});
try {
- const data = await safeFetch('https://example.test/json', { retries: 0, source: 'unit' });
+ const data = await safeFetch('https://example.test/json', { retries: 0, source });
assert.match(data.error, /Expected JSON/);
- assert.ok(getFetchMetrics().bySource.unit.requests >= 1);
+ const bucket = getFetchMetrics().bySource[source];
+ assert.equal(bucket.requests, 1);
+ assert.equal(bucket.ok, 0);
+ assert.equal(bucket.failed, 1);
+ assert.equal(bucket.lastStatus, 200);
+ } finally {
+ globalThis.fetch = originalFetch;
+ }
+});
+
+test('safeFetch records HTTP failure once with status and bytes', async () => {
+ const originalFetch = globalThis.fetch;
+ const source = 'unit-http-failure-once';
+ globalThis.fetch = async () => ({
+ ok: false,
+ status: 503,
+ headers: { get: () => 'application/json' },
+ text: async () => 'service unavailable',
+ });
+ try {
+ const data = await safeFetch('https://example.test/fail', { retries: 0, source });
+ assert.match(data.error, /HTTP 503/);
+ const bucket = getFetchMetrics().bySource[source];
+ assert.equal(bucket.requests, 1);
+ assert.equal(bucket.ok, 0);
+ assert.equal(bucket.failed, 1);
+ assert.equal(bucket.lastStatus, 503);
+ assert.equal(bucket.bytes, 'service unavailable'.length);
+ assert.match(bucket.lastError, /HTTP 503/);
+ } finally {
+ globalThis.fetch = originalFetch;
+ }
+});
+
+test('safeFetch retry metrics count one record per attempt', async () => {
+ const originalFetch = globalThis.fetch;
+ const source = 'unit-retry-attempts';
+ let calls = 0;
+ globalThis.fetch = async () => {
+ calls += 1;
+ if (calls === 1) {
+ return {
+ ok: false,
+ status: 502,
+ headers: { get: () => 'application/json' },
+ text: async () => 'bad gateway',
+ };
+ }
+ return {
+ ok: true,
+ status: 200,
+ headers: { get: () => 'application/json' },
+ text: async () => '{"ok":true}',
+ };
+ };
+ try {
+ const data = await safeFetch('https://example.test/retry', { retries: 1, source });
+ assert.equal(data.ok, true);
+ assert.equal(calls, 2);
+ const bucket = getFetchMetrics().bySource[source];
+ assert.equal(bucket.requests, 2);
+ assert.equal(bucket.ok, 1);
+ assert.equal(bucket.failed, 1);
+ assert.equal(bucket.lastStatus, 200);
} finally {
globalThis.fetch = originalFetch;
}
@@ -34,3 +99,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/);
+});
diff --git a/test/reddit-source.test.mjs b/test/reddit-source.test.mjs
new file mode 100644
index 0000000..1e61620
--- /dev/null
+++ b/test/reddit-source.test.mjs
@@ -0,0 +1,109 @@
+import test from 'node:test';
+import assert from 'node:assert/strict';
+import { briefing, getHot, getRedditConfig, getToken } from '../apis/sources/reddit.mjs';
+
+test('Reddit reports missing OAuth credentials without network access', async () => {
+ let calls = 0;
+ const data = await briefing({
+ env: {},
+ delayMs: 0,
+ fetchImpl: async () => {
+ calls++;
+ throw new Error('unexpected network access');
+ },
+ });
+
+ assert.equal(calls, 0);
+ assert.equal(data.status, 'no_credentials');
+ assert.equal(data.error, 'missing_reddit_oauth_credentials');
+ assert.deepEqual(data.missing, ['REDDIT_CLIENT_ID', 'REDDIT_CLIENT_SECRET']);
+});
+
+test('Reddit hot posts require OAuth token and never use public JSON fallback', async () => {
+ const originalFetch = globalThis.fetch;
+ let calledUrl = null;
+ globalThis.fetch = async url => {
+ calledUrl = url;
+ throw new Error('unexpected public fallback');
+ };
+
+ try {
+ const data = await getHot('worldnews');
+ assert.equal(calledUrl, null);
+ assert.equal(data.status, 'no_credentials');
+ assert.equal(data.error, 'reddit_oauth_required');
+ } finally {
+ globalThis.fetch = originalFetch;
+ }
+});
+
+test('Reddit classifies OAuth HTTP failure without exposing secrets', async () => {
+ const result = await getToken({
+ env: { REDDIT_CLIENT_ID: 'client-id', REDDIT_CLIENT_SECRET: 'client-secret' },
+ fetchImpl: async () => ({
+ ok: false,
+ status: 401,
+ text: async () => 'invalid client',
+ }),
+ });
+
+ assert.equal(result.ok, false);
+ assert.equal(result.status, 'auth_failed');
+ assert.equal(result.error, 'reddit_oauth_http_401');
+ assert.doesNotMatch(JSON.stringify(result), /client-secret/);
+});
+
+test('Reddit fetches hot posts through oauth.reddit.com when configured', async () => {
+ const originalFetch = globalThis.fetch;
+ const urls = [];
+ globalThis.fetch = async url => {
+ urls.push(String(url));
+ if (String(url).includes('/api/v1/access_token')) {
+ return {
+ ok: true,
+ status: 200,
+ json: async () => ({ access_token: 'test-token' }),
+ };
+ }
+ return {
+ ok: true,
+ status: 200,
+ headers: { get: () => 'application/json' },
+ text: async () => JSON.stringify({
+ data: {
+ children: [
+ {
+ data: {
+ title: 'Market stress headline',
+ score: 42,
+ num_comments: 7,
+ url: 'https://example.test/post',
+ created_utc: 1700000000,
+ },
+ },
+ ],
+ },
+ }),
+ };
+ };
+
+ try {
+ const data = await briefing({
+ env: { REDDIT_CLIENT_ID: 'client-id', REDDIT_CLIENT_SECRET: 'client-secret' },
+ subreddits: ['worldnews'],
+ delayMs: 0,
+ });
+
+ assert.equal(data.status, 'ok');
+ assert.equal(data.subreddits.worldnews[0].title, 'Market stress headline');
+ assert.ok(urls.some(url => url === 'https://www.reddit.com/api/v1/access_token'));
+ assert.ok(urls.some(url => url.startsWith('https://oauth.reddit.com/r/worldnews/hot')));
+ assert.equal(urls.some(url => url.includes('hot.json')), false);
+ } finally {
+ globalThis.fetch = originalFetch;
+ }
+});
+
+test('Reddit config reports partial credential state', () => {
+ assert.deepEqual(getRedditConfig({ REDDIT_CLIENT_ID: 'id' }).missing, ['REDDIT_CLIENT_SECRET']);
+});