diff --git a/src/world_intel_mcp/dashboard/app.py b/src/world_intel_mcp/dashboard/app.py
index 00d5577..6b1412d 100644
--- a/src/world_intel_mcp/dashboard/app.py
+++ b/src/world_intel_mcp/dashboard/app.py
@@ -33,6 +33,8 @@ from world_intel_mcp.sources import (
climate,
conflict,
intelligence,
+ space_weather,
+ ai_watch,
)
logger = logging.getLogger(__name__)
@@ -87,6 +89,8 @@ async def _fetch_overview() -> dict:
"displacement": displacement.fetch_displacement_summary(fetcher),
"risk_scores": intelligence.fetch_risk_scores(fetcher),
"signal_convergence": intelligence.fetch_signal_convergence(fetcher),
+ "space_weather": space_weather.fetch_space_weather(fetcher),
+ "ai_watch": ai_watch.fetch_ai_watch(fetcher),
}
gathered = await asyncio.gather(
@@ -114,16 +118,10 @@ async def _fetch_overview() -> dict:
# Routes
# ---------------------------------------------------------------------------
-_INDEX_HTML: str | None = None
-
-
async def index(request):
- """Serve the dashboard HTML page."""
- global _INDEX_HTML
- if _INDEX_HTML is None:
- html_path = Path(__file__).parent / "index.html"
- _INDEX_HTML = html_path.read_text()
- return HTMLResponse(_INDEX_HTML)
+ """Serve the dashboard HTML page (reloads on each request during dev)."""
+ html_path = Path(__file__).parent / "index.html"
+ return HTMLResponse(html_path.read_text())
async def api_overview(request):
diff --git a/src/world_intel_mcp/dashboard/index.html b/src/world_intel_mcp/dashboard/index.html
index cee2785..90d29cf 100644
--- a/src/world_intel_mcp/dashboard/index.html
+++ b/src/world_intel_mcp/dashboard/index.html
@@ -704,6 +704,13 @@ function updateHudStats(data) {
if (data.displacement && !data.displacement.error && data.displacement.global_totals) {
pills.push('
' + fmtBigPlain(data.displacement.global_totals.grand_total || 0) + 'Displaced
');
}
+ if (data.space_weather && !data.space_weather.error && data.space_weather.current_kp != null) {
+ var swKp = data.space_weather.current_kp;
+ pills.push('' + swKp.toFixed(0) + 'Kp
');
+ }
+ if (data.ai_watch && !data.ai_watch.error) {
+ pills.push('' + (data.ai_watch.count || 0) + 'AI Papers
');
+ }
$('#hudStats').innerHTML = safe(pills.join(''));
}
@@ -776,13 +783,17 @@ function updateDrawer(data) {
}
}
if (data.energy_prices && !data.energy_prices.error) {
- var prices = data.energy_prices.prices || data.energy_prices.data || data.energy_prices;
- var entries = Array.isArray(prices) ? prices : Object.entries(prices).map(function(e) { return Object.assign({name: e[0]}, typeof e[1] === 'object' ? e[1] : {value: e[1]}); });
- if (entries.length) {
- h += 'Energy
';
- entries.slice(0, 6).forEach(function(item) {
- var v = item.value || item.price || item.last_value;
- h += '| ' + esc((item.name || '?').replace(/_/g, ' ')) + ' | ' + (typeof v === 'number' ? fmtNum(v) : esc(String(v || '\u2014'))) + ' |
';
+ var ep = data.energy_prices;
+ var eRows = [];
+ if (ep.oil) {
+ if (ep.oil.brent && ep.oil.brent.price != null) eRows.push({name: 'Brent Crude', price: ep.oil.brent.price, date: ep.oil.brent.date});
+ if (ep.oil.wti && ep.oil.wti.price != null) eRows.push({name: 'WTI Crude', price: ep.oil.wti.price, date: ep.oil.wti.date});
+ }
+ if (ep.natural_gas && ep.natural_gas.price != null) eRows.push({name: 'Natural Gas', price: ep.natural_gas.price, date: ep.natural_gas.date});
+ if (eRows.length) {
+ h += 'Energy
| Commodity | Price | Date |
';
+ eRows.forEach(function(item) {
+ h += '| ' + esc(item.name) + ' | $' + fmtNum(item.price) + ' | ' + esc(item.date || '\u2014') + ' |
';
});
h += '
';
}
@@ -860,6 +871,25 @@ function updateDrawer(data) {
}
}
+ if (data.space_weather && !data.space_weather.error) {
+ var sw = data.space_weather;
+ h += 'Space Weather
';
+ var kpVal = sw.current_kp;
+ var kpCls = kpVal >= 7 ? ' crit' : kpVal >= 5 ? ' warn' : '';
+ h += '
' + (kpVal != null ? fmtNum(kpVal, 1) : '\u2014') + '
Kp Index
';
+ h += '
' + esc(sw.kp_level || '\u2014') + '
Geo Level
';
+ h += '
' + esc(sw.latest_flare_class || '\u2014') + '
X-Ray Flux
';
+ h += '
';
+ var swAlerts = sw.alerts || [];
+ if (swAlerts.length) {
+ h += '| Alert | Time |
';
+ swAlerts.slice(0, 5).forEach(function(a) {
+ h += '| ' + esc(trunc(a.message || '?', 50)) + ' | ' + esc(ago(a.issue_datetime)) + ' |
';
+ });
+ h += '
';
+ }
+ }
+
// ── INTELLIGENCE ──
h += 'INTELLIGENCE
';
if (data.trending_keywords && !data.trending_keywords.error) {
@@ -907,7 +937,7 @@ function updateDrawer(data) {
if (origins.length) {
h += '| Origin | Refugees | IDPs |
';
origins.slice(0, 8).forEach(function(o) {
- h += '| ' + esc(o.country_name || o.country || '?') + ' | ' + fmtBigPlain(o.refugees || 0) + ' | ' + fmtBigPlain(o.idps || 0) + ' |
';
+ h += '| ' + esc(o.country_name || o.country || '?') + ' | ' + fmtBigPlain(o.refugees || 0) + ' | ' + fmtBigPlain(o.internally_displaced || o.idps || 0) + ' |
';
});
h += '
';
}
@@ -931,14 +961,48 @@ function updateDrawer(data) {
if (anomalies.length) {
h += 'Climate Anomalies
| Zone | Temp | Precip |
';
anomalies.slice(0, 8).forEach(function(a) {
- var temp = a.temperature_anomaly || a.temp_anomaly || a.temp_deviation;
- var precip = a.precipitation_anomaly || a.precip_anomaly || a.precip_deviation;
- h += '| ' + esc(a.zone || a.name || a.region || '?') + ' | ' + (temp != null ? (temp > 0 ? '+' : '') + fmtNum(temp, 1) + '\u00B0C' : '\u2014') + ' | ' + (precip != null ? fmtNum(precip, 1) + 'mm' : '\u2014') + ' |
';
+ var temp = a.temp_anomaly_c != null ? a.temp_anomaly_c : (a.temperature_anomaly || a.temp_anomaly || a.temp_deviation);
+ var precip = a.precip_anomaly_pct != null ? a.precip_anomaly_pct : (a.precipitation_anomaly || a.precip_anomaly || a.precip_deviation);
+ h += '| ' + esc(a.zone || a.name || a.region || '?') + ' | ' + (temp != null ? (temp > 0 ? '+' : '') + fmtNum(temp, 1) + '\u00B0C' : '\u2014') + ' | ' + (precip != null ? (precip > 0 ? '+' : '') + fmtNum(precip, 0) + '%' : '\u2014') + ' |
';
});
h += '
';
}
}
+ // ── AGI WATCH ──
+ h += 'AGI WATCH
';
+ if (data.ai_watch && !data.ai_watch.error) {
+ var aiw = data.ai_watch;
+ var labTrend = aiw.lab_trending || [];
+ if (labTrend.length) {
+ h += 'Lab Activity
';
+ labTrend.slice(0, 12).forEach(function(l) {
+ h += '' + esc(l.lab) + ' (' + l.mentions + ')';
+ });
+ h += '
';
+ }
+ var byCat = aiw.by_category || {};
+ if (Object.keys(byCat).length) {
+ h += '';
+ for (var catKey in byCat) {
+ if (byCat.hasOwnProperty(catKey)) {
+ h += '
' + byCat[catKey] + '
' + esc(catKey) + '
';
+ }
+ }
+ h += '
';
+ }
+ var aiItems = aiw.items || [];
+ if (aiItems.length) {
+ h += '';
+ }
+ } else {
+ h += 'Loading AI feeds...
';
+ }
+
$('#drawerBody').innerHTML = safe(h);
}
@@ -949,7 +1013,7 @@ function updateTicker(data) {
var articles = data.news_feed.articles || data.news_feed.items || [];
if (!articles.length) return;
var items = articles.slice(0, 30).map(function(a) {
- return '' + esc(trunc(a.title || '?', 70)) + '' + esc(a.source || a.feed || '') + '';
+ return '' + esc(trunc(a.title || '?', 70)) + '' + esc(a.feed_name || a.source || a.feed || '') + '';
});
// Duplicate for seamless loop
var all = items.join('\u2022');
diff --git a/src/world_intel_mcp/sources/ai_watch.py b/src/world_intel_mcp/sources/ai_watch.py
new file mode 100644
index 0000000..4c51906
--- /dev/null
+++ b/src/world_intel_mcp/sources/ai_watch.py
@@ -0,0 +1,189 @@
+"""AI/AGI development tracking source for world-intel-mcp.
+
+Monitors the latest AI research publications, model releases, and
+industry developments via RSS feeds from arXiv, Hugging Face, and
+major AI news outlets. No API keys required.
+"""
+
+import asyncio
+import logging
+from datetime import datetime, timezone
+
+from ..fetcher import Fetcher
+
+try:
+ import feedparser
+except ImportError:
+ feedparser = None # type: ignore[assignment]
+
+logger = logging.getLogger("world-intel-mcp.sources.ai_watch")
+
+# ---------------------------------------------------------------------------
+# Feed sources
+# ---------------------------------------------------------------------------
+
+_AI_FEEDS: list[tuple[str, str, str]] = [
+ # (name, url, category)
+ ("arXiv cs.AI", "https://rss.arxiv.org/rss/cs.AI", "research"),
+ ("arXiv cs.LG", "https://rss.arxiv.org/rss/cs.LG", "research"),
+ ("arXiv cs.CL", "https://rss.arxiv.org/rss/cs.CL", "research"),
+ ("HuggingFace Blog", "https://huggingface.co/blog/feed.xml", "industry"),
+ ("The Gradient", "https://thegradient.pub/rss/", "analysis"),
+ ("Import AI", "https://importai.substack.com/feed", "newsletter"),
+]
+
+# Key AI labs to track mentions of
+_AI_LABS = [
+ "openai", "anthropic", "google", "deepmind", "meta", "mistral",
+ "xai", "cohere", "stability", "midjourney", "nvidia", "microsoft",
+ "apple", "hugging face", "databricks", "together", "groq",
+]
+
+_CACHE_TTL = 600 # 10 minutes
+
+
+# ---------------------------------------------------------------------------
+# Helpers
+# ---------------------------------------------------------------------------
+
+def _utc_now_iso() -> str:
+ return datetime.now(timezone.utc).isoformat()
+
+
+def _parse_published(entry: dict) -> str | None:
+ """Parse an RSS entry's published date to ISO 8601 UTC string."""
+ import time as _time
+
+ parsed_tuple = entry.get("published_parsed")
+ if parsed_tuple is not None:
+ try:
+ epoch = _time.mktime(parsed_tuple[:9])
+ dt = datetime.fromtimestamp(epoch, tz=timezone.utc)
+ return dt.strftime("%Y-%m-%dT%H:%M:%SZ")
+ except (ValueError, TypeError, OverflowError):
+ pass
+
+ updated_tuple = entry.get("updated_parsed")
+ if updated_tuple is not None:
+ try:
+ epoch = _time.mktime(updated_tuple[:9])
+ dt = datetime.fromtimestamp(epoch, tz=timezone.utc)
+ return dt.strftime("%Y-%m-%dT%H:%M:%SZ")
+ except (ValueError, TypeError, OverflowError):
+ pass
+
+ return entry.get("published") or entry.get("updated")
+
+
+def _extract_lab_mentions(text: str) -> list[str]:
+ """Extract AI lab names mentioned in text."""
+ lower = text.lower()
+ return [lab for lab in _AI_LABS if lab in lower]
+
+
+# ---------------------------------------------------------------------------
+# Public API
+# ---------------------------------------------------------------------------
+
+async def fetch_ai_watch(
+ fetcher: Fetcher,
+ limit: int = 50,
+) -> dict:
+ """Fetch latest AI/AGI developments from research and industry feeds.
+
+ Aggregates recent papers, blog posts, and announcements from key
+ AI sources, sorted by recency. Extracts lab mentions for trend
+ tracking.
+
+ Args:
+ fetcher: Shared HTTP fetcher with caching and circuit breaking.
+ limit: Maximum number of items to return.
+
+ Returns:
+ Dict with items list, lab mention counts, source counts, and metadata.
+ """
+ if feedparser is None:
+ return {
+ "error": "feedparser not installed — run: pip install feedparser",
+ "items": [],
+ "count": 0,
+ }
+
+ all_items: list[dict] = []
+
+ async def _fetch_feed(
+ name: str, url: str, category: str,
+ ) -> list[dict]:
+ safe_name = name.lower().replace(" ", "_").replace(".", "_")
+ xml_text = await fetcher.get_xml(
+ url,
+ source=f"ai_watch:{safe_name}",
+ cache_key=f"ai_watch:rss:{safe_name}",
+ cache_ttl=_CACHE_TTL,
+ )
+
+ if xml_text is None:
+ logger.debug("No data from AI feed %s", name)
+ return []
+
+ parsed = feedparser.parse(xml_text)
+ items: list[dict] = []
+
+ for entry in parsed.get("entries", [])[:30]:
+ title = entry.get("title", "")
+ summary = entry.get("summary") or entry.get("description") or ""
+ combined_text = f"{title} {summary}"
+
+ items.append({
+ "title": title,
+ "link": entry.get("link", ""),
+ "published": _parse_published(entry),
+ "summary": summary[:200] if len(summary) > 200 else summary,
+ "feed_name": name,
+ "category": category,
+ "lab_mentions": _extract_lab_mentions(combined_text),
+ })
+
+ return items
+
+ # Fetch all feeds in parallel
+ tasks = [_fetch_feed(name, url, cat) for name, url, cat in _AI_FEEDS]
+ results = await asyncio.gather(*tasks)
+ for items in results:
+ all_items.extend(items)
+
+ # Sort by published date descending
+ all_items.sort(
+ key=lambda item: item.get("published") or "",
+ reverse=True,
+ )
+ all_items = all_items[:limit]
+
+ # Compute lab mention counts
+ lab_counts: dict[str, int] = {}
+ for item in all_items:
+ for lab in item.get("lab_mentions", []):
+ lab_counts[lab] = lab_counts.get(lab, 0) + 1
+
+ # Sort by count descending
+ lab_trending = sorted(
+ [{"lab": k, "mentions": v} for k, v in lab_counts.items()],
+ key=lambda x: x["mentions"],
+ reverse=True,
+ )
+
+ # Count by category
+ by_category: dict[str, int] = {}
+ for item in all_items:
+ cat = item.get("category", "other")
+ by_category[cat] = by_category.get(cat, 0) + 1
+
+ return {
+ "items": all_items,
+ "count": len(all_items),
+ "lab_trending": lab_trending,
+ "by_category": by_category,
+ "feeds_used": len(_AI_FEEDS),
+ "source": "ai-watch",
+ "timestamp": _utc_now_iso(),
+ }
diff --git a/src/world_intel_mcp/sources/news.py b/src/world_intel_mcp/sources/news.py
index 14e274d..bd31376 100644
--- a/src/world_intel_mcp/sources/news.py
+++ b/src/world_intel_mcp/sources/news.py
@@ -27,10 +27,9 @@ logger = logging.getLogger("world-intel-mcp.sources.news")
_RSS_FEEDS: dict[str, list[tuple[str, str]]] = {
"geopolitics": [
- ("Reuters World", "https://feeds.reuters.com/Reuters/worldNews"),
- ("AP Top News", "https://rsshub.app/apnews/topics/apf-topnews"),
("BBC World", "https://feeds.bbci.co.uk/news/world/rss.xml"),
("Al Jazeera", "https://www.aljazeera.com/xml/rss/all.xml"),
+ ("AP Top News", "https://rsshub.app/apnews/topics/apf-topnews"),
],
"security": [
("BleepingComputer", "https://www.bleepingcomputer.com/feed/"),
@@ -51,7 +50,7 @@ _RSS_FEEDS: dict[str, list[tuple[str, str]]] = {
"military": [
("Defense One", "https://www.defenseone.com/rss/"),
("War on the Rocks", "https://warontherocks.com/feed/"),
- ("The War Zone", "https://www.thedrive.com/the-war-zone/feed"),
+ ("The War Zone", "https://www.twz.com/feed"),
],
"science": [
("Nature", "https://www.nature.com/nature.rss"),
@@ -188,7 +187,7 @@ async def fetch_news_feed(
safe_name = feed_name.lower().replace(" ", "_")
xml_text = await fetcher.get_xml(
url,
- source="rss",
+ source=f"rss:{safe_name}",
cache_key=f"news:rss:{safe_name}",
cache_ttl=300,
)
diff --git a/src/world_intel_mcp/sources/space_weather.py b/src/world_intel_mcp/sources/space_weather.py
new file mode 100644
index 0000000..0f851f0
--- /dev/null
+++ b/src/world_intel_mcp/sources/space_weather.py
@@ -0,0 +1,185 @@
+"""Space weather and solar activity source for world-intel-mcp.
+
+Provides real-time solar activity monitoring via NOAA's Space Weather
+Prediction Center (SWPC). No API key required.
+
+Data includes:
+- Solar flare activity (X-ray flux class)
+- Geomagnetic storm indices (Kp, Dst)
+- Solar wind speed and density
+- Coronal mass ejection (CME) alerts
+"""
+
+import logging
+from datetime import datetime, timezone
+
+from ..fetcher import Fetcher
+
+logger = logging.getLogger("world-intel-mcp.sources.space_weather")
+
+# ---------------------------------------------------------------------------
+# NOAA SWPC endpoints (all free, no API key)
+# ---------------------------------------------------------------------------
+
+_SWPC_BASE = "https://services.swpc.noaa.gov"
+
+# 3-day solar/geomagnetic forecast
+_FORECAST_URL = f"{_SWPC_BASE}/products/noaa-planetary-k-index-forecast.json"
+
+# Current planetary K-index (geomagnetic disturbance, 0-9)
+_KP_URL = f"{_SWPC_BASE}/products/noaa-planetary-k-index.json"
+
+# Recent solar flares (R1-R5 scale)
+_FLARE_URL = f"{_SWPC_BASE}/json/goes/primary/xrays-6-hour.json"
+
+# Solar wind real-time plasma data
+_PLASMA_URL = f"{_SWPC_BASE}/products/solar-wind/plasma-7-day.json"
+
+# Alerts and warnings
+_ALERTS_URL = f"{_SWPC_BASE}/products/alerts.json"
+
+_CACHE_TTL = 600 # 10 minutes
+
+
+# ---------------------------------------------------------------------------
+# Helpers
+# ---------------------------------------------------------------------------
+
+def _utc_now_iso() -> str:
+ return datetime.now(timezone.utc).isoformat()
+
+
+def _classify_kp(kp: float) -> str:
+ """Classify Kp index into storm level."""
+ if kp >= 9:
+ return "G5 Extreme"
+ elif kp >= 8:
+ return "G4 Severe"
+ elif kp >= 7:
+ return "G3 Strong"
+ elif kp >= 6:
+ return "G2 Moderate"
+ elif kp >= 5:
+ return "G1 Minor"
+ elif kp >= 4:
+ return "Active"
+ else:
+ return "Quiet"
+
+
+def _classify_xray(flux: float) -> str:
+ """Classify X-ray flux into flare class (A, B, C, M, X)."""
+ if flux >= 1e-4:
+ return f"X{flux / 1e-4:.1f}"
+ elif flux >= 1e-5:
+ return f"M{flux / 1e-5:.1f}"
+ elif flux >= 1e-6:
+ return f"C{flux / 1e-6:.1f}"
+ elif flux >= 1e-7:
+ return f"B{flux / 1e-7:.1f}"
+ else:
+ return "A"
+
+
+# ---------------------------------------------------------------------------
+# Public API
+# ---------------------------------------------------------------------------
+
+async def fetch_space_weather(fetcher: Fetcher) -> dict:
+ """Fetch current space weather conditions from NOAA SWPC.
+
+ Returns a composite view of solar and geomagnetic activity including
+ current Kp index, latest X-ray flux class, solar wind speed, and
+ any active alerts/warnings.
+
+ Args:
+ fetcher: Shared HTTP fetcher with caching and circuit breaking.
+
+ Returns:
+ Dict with current conditions, alerts, forecast, and metadata.
+ """
+ import asyncio
+
+ # Fetch all sources in parallel
+ kp_data, flare_data, alerts_data = await asyncio.gather(
+ fetcher.get_json(
+ _KP_URL,
+ source="swpc",
+ cache_key="space:kp",
+ cache_ttl=_CACHE_TTL,
+ ),
+ fetcher.get_json(
+ _FLARE_URL,
+ source="swpc",
+ cache_key="space:xray",
+ cache_ttl=_CACHE_TTL,
+ ),
+ fetcher.get_json(
+ _ALERTS_URL,
+ source="swpc",
+ cache_key="space:alerts",
+ cache_ttl=_CACHE_TTL,
+ ),
+ )
+
+ result: dict = {
+ "current_kp": None,
+ "kp_level": "Unknown",
+ "latest_flare_class": None,
+ "solar_wind_speed_km_s": None,
+ "alerts": [],
+ "kp_recent": [],
+ "source": "noaa-swpc",
+ "timestamp": _utc_now_iso(),
+ }
+
+ # --- Kp index ---
+ if kp_data and isinstance(kp_data, list) and len(kp_data) > 1:
+ # First row is header, rest are data [time_tag, Kp, ...]
+ try:
+ # Get most recent Kp reading
+ latest = kp_data[-1]
+ kp_val = float(latest[1])
+ result["current_kp"] = kp_val
+ result["kp_level"] = _classify_kp(kp_val)
+
+ # Last 8 readings (24 hours of 3-hourly data)
+ recent = []
+ for row in kp_data[-9:-1]: # skip header
+ if isinstance(row, list) and len(row) >= 2:
+ try:
+ recent.append({
+ "time": row[0],
+ "kp": float(row[1]),
+ })
+ except (ValueError, TypeError, IndexError):
+ pass
+ result["kp_recent"] = recent
+ except (ValueError, TypeError, IndexError) as exc:
+ logger.warning("Failed to parse Kp data: %s", exc)
+
+ # --- X-ray flux (flare activity) ---
+ if flare_data and isinstance(flare_data, list) and len(flare_data) > 1:
+ try:
+ # Last entry has the most recent flux reading
+ latest_flare = flare_data[-1]
+ if isinstance(latest_flare, dict):
+ flux = latest_flare.get("flux")
+ if flux is not None:
+ result["latest_flare_class"] = _classify_xray(float(flux))
+ except (ValueError, TypeError, KeyError) as exc:
+ logger.warning("Failed to parse X-ray flux: %s", exc)
+
+ # --- Alerts ---
+ if alerts_data and isinstance(alerts_data, list):
+ alerts = []
+ for alert in alerts_data[:10]:
+ if isinstance(alert, dict):
+ alerts.append({
+ "issue_datetime": alert.get("issue_datetime"),
+ "message": (alert.get("message") or "")[:200],
+ "product_id": alert.get("product_id"),
+ })
+ result["alerts"] = alerts
+
+ return result