import { CII_RISK_SCORE_CACHE_KEYS } from '../../_cii-risk-cache-keys.js'; import { buildAlertDigest, buildWeeklyTrends } from '../../../shared/analysis-alert-digest'; import { anomaliesToDigestInput, buildDigestInputs, earthquakesToExposureEvents, type ExposureEvent, firesToExposureEvents, ucdpEventsToExposureEvents, } from '../../../shared/analysis-composite-adapters'; import { getEntityIndex as getSharedEntityIndex } from '../../../shared/entity-extraction-core.js'; import { FocalPointCore, generateAgentSafeAIContext } from '../../../shared/analysis-focal-points'; import { getHotspotCountryScore } from '../../../shared/hotspot-country-map'; import { computeEscalationScore, countMilitaryNearHotspot, } from '../../../shared/analysis-hotspot-escalation'; import { GeoConvergenceEngine, getLocationName } from '../../../shared/analysis-geo-convergence'; import { buildDependencyGraph, calculateCascade, getGraphStats, } from '../../../shared/analysis-infrastructure-cascade'; import { applyVesselCountsToPostures, crossSourceSignalsToSignalSummary, earthquakesToGeoEvents, filterFocalPointsByCountry, insightsToFocalClusters, MCP_CASCADE_WATERWAYS, MCP_GEO_PLACES, militaryFlightsToGeoEvents, militaryFlightsToSurgeInputs, riskScoresToCiiLookup, submarineCablesToCableInputs, surgeHistoryToActivityHistory, theaterPostureVesselCounts, unrestEventsToGeoEvents, usniVesselsToGeoEvents, } from '../../../shared/analysis-mcp-adapters'; import { getTheaterPostureSummaries, MilitarySurgeEngine, POSTURE_THEATERS, recalcPostureWithVessels, } from '../../../shared/analysis-military-surge'; import { computeBoundedExposure, computeExposure, getRadiusForEventType, listCountryPopulations, } from '../../../shared/analysis-population-exposure'; import { INTEL_HOTSPOTS } from '../../../shared/geo-data'; import { readJsonBatchFromUpstashWithStatus } from '../../_upstash-json.js'; import { evaluateFreshness } from '../freshness'; import { McpSourceUnavailableError } from '../source-unavailable'; import type { FreshnessCheck, ToolDef } from '../types'; type PayloadValidator = (value: unknown) => boolean; function hasArrayField(value: unknown, field: string): boolean { return !!value && typeof value === 'object' && !Array.isArray(value) && Object.prototype.hasOwnProperty.call(value, field) && Array.isArray((value as Record)[field]); } function hasObjectField(value: unknown, field: string): boolean { const fieldValue = value && typeof value === 'object' && !Array.isArray(value) ? (value as Record)[field] : null; return !!fieldValue && typeof fieldValue === 'object' && !Array.isArray(fieldValue); } const ANALYSIS_PAYLOAD_VALIDATORS: Readonly> = { 'unrest:events:v1': (value) => hasArrayField(value, 'events'), 'military:flights:v1': (value) => hasArrayField(value, 'flights'), 'seismology:earthquakes:v1': (value) => hasArrayField(value, 'earthquakes'), 'usni-fleet:sebuf:v1': (value) => hasArrayField(value, 'vessels'), 'news:insights:v1': (value) => hasArrayField(value, 'topStories'), 'intelligence:cross-source-signals:v1': (value) => hasArrayField(value, 'signals'), [CII_RISK_SCORE_CACHE_KEYS.live]: (value) => hasArrayField(value, 'ciiScores'), 'infrastructure:submarine-cables:v1': (value) => hasArrayField(value, 'cables'), 'theater-posture:sebuf:v1': (value) => hasArrayField(value, 'theaters'), 'military:surges:v1': (value) => Array.isArray(value) || hasArrayField(value, 'surges'), 'military:surges:history:v1': (value) => hasArrayField(value, 'history'), 'wildfire:fires:v1': (value) => hasArrayField(value, 'fireDetections'), 'conflict:ucdp-events:v1': (value) => hasArrayField(value, 'events'), 'cable-health-v1': (value) => hasObjectField(value, 'cables'), 'infra:outages:v1': (value) => hasArrayField(value, 'outages'), 'temporal:anomalies:v1': (value) => hasArrayField(value, 'anomalies'), 'thermal:escalation:v1': (value) => hasArrayField(value, 'clusters'), 'supply_chain:shipping_stress:v1': (value) => { if (!value || typeof value !== 'object' || Array.isArray(value)) return false; const record = value as Record; return Object.prototype.hasOwnProperty.call(record, 'stressScore') && Object.prototype.hasOwnProperty.call(record, 'stressLevel'); }, }; /** * Read data caches and freshness metadata in one parallel round while keeping * payload and metadata positions structurally separate. */ async function readCachesWithFreshness( keys: readonly string[], checks: FreshnessCheck[], ): Promise<{ payloads: unknown[]; freshness: { cached_at: string | null; stale: boolean; unavailable_inputs: string[]; failed_inputs: string[]; }; }> { const results = await readJsonBatchFromUpstashWithStatus([ ...keys, ...checks.map((check) => check.key), ]); const payloadReads = results.slice(0, keys.length).map((result, index) => { const validator = ANALYSIS_PAYLOAD_VALIDATORS[keys[index] ?? '']; if (result.status === 'hit' && validator && !validator(result.value)) { return { status: 'error' as const, value: null }; } return result; }); const metaReads = results.slice(keys.length); const payloads = payloadReads.map((result) => result.value); const unavailablePayloads = keys.filter( (_key, index) => payloadReads[index]?.status !== 'hit' || payloadReads[index]?.value === null, ); const unavailableMetadata = checks .filter((_check, index) => metaReads[index]?.status !== 'hit' || metaReads[index]?.value === null) .map((check) => check.key); const failedPayloads = keys.filter((_key, index) => payloadReads[index]?.status === 'error'); const failedMetadata = checks .filter((_check, index) => metaReads[index]?.status === 'error') .map((check) => check.key); const unavailableInputs = [...unavailablePayloads, ...unavailableMetadata]; const failedInputs = [...failedPayloads, ...failedMetadata]; const evaluated = evaluateFreshness(checks, metaReads.map((result) => result.value)); return { payloads, freshness: { ...evaluated, stale: evaluated.stale || unavailableInputs.length > 0, unavailable_inputs: unavailableInputs, failed_inputs: failedInputs, }, }; } const ANALYSIS_CACHE_STATUS_PROPERTIES = { unavailable_inputs: { type: 'array', items: { type: 'string' }, description: 'Required cache keys that were missing or unreadable; their contribution is not treated as quiet.', }, failed_inputs: { type: 'array', items: { type: 'string' }, description: 'Subset of unavailable_inputs whose Redis read failed rather than returning a genuine miss.', }, } as const; type AnalysisFreshness = Awaited>['freshness']; function requireAnyInput( payloads: unknown[], freshness: AnalysisFreshness, message: string, ): void { if (payloads.every((value) => value === null)) { throw new McpSourceUnavailableError( message, freshness.unavailable_inputs, freshness.failed_inputs, ); } } function resolveLimit(raw: unknown, fallback: number): number { if (raw === undefined || raw === null) return fallback; const parsed = Math.round(Number(raw)); if (!Number.isFinite(parsed)) return fallback; if (parsed <= 0) return Number.POSITIVE_INFINITY; return parsed; } export const ANALYSIS_TOOLS: ToolDef[] = [ { name: 'get_signal_convergence', _outputBudgetBytes: 65536, description: 'Geographic signal convergence: grid cells where protests, military activity, naval movements, and earthquakes co-occur. ' + 'The same multi-domain convergence engine the dashboard map runs, executed server-side over the seeded feeds: unrest events, ' + 'tracked military flights, USNI fleet positions (region centroids), and USGS earthquakes are bucketed into one-degree cells ' + 'over a 24-hour window, and any cell where enough distinct domains overlap becomes an alert scored by breadth and volume. ' + 'Each alert carries coordinates, the contributing domains, a reverse-geocoded location name from the curated hotspot/' + 'chokepoint/conflict-zone gazetteer, and the total event count. Pass lat/lon/radius_km together to narrow to one area, or ' + 'min_domains to tighten the co-occurrence bar. An empty alert list with fresh inputs means nothing is converging — signal in itself.', inputSchema: { type: 'object', properties: { lat: { type: 'number', minimum: -90, maximum: 90, description: 'Latitude of the area of interest; requires lon and radius_km as well.' }, lon: { type: 'number', minimum: -180, maximum: 180, description: 'Longitude of the area of interest; requires lat and radius_km as well.' }, radius_km: { type: 'number', exclusiveMinimum: 0, maximum: 20000, description: 'Positive radius in km around lat/lon to keep alerts for; requires lat and lon (maximum 20,000).' }, min_domains: { type: 'number', minimum: 2, maximum: 5, description: 'Distinct signal domains required per cell, 2-5 (default 3); 5 is a compatibility safety threshold that yields no alerts while four domains are ingested.', }, }, required: [], }, outputSchema: { type: 'object', properties: { cached_at: { type: ['string', 'null'], description: 'Oldest fetch time across the contributing feeds.' }, stale: { type: 'boolean', description: 'True when any contributing feed is older than its freshness budget.' }, ...ANALYSIS_CACHE_STATUS_PROPERTIES, data: { type: 'object', properties: { alerts: { type: 'array', items: { type: 'object', properties: { cellId: { type: 'string' }, lat: { type: 'number' }, lon: { type: 'number' }, location: { type: 'string' }, types: { type: 'array', items: { type: 'string' } }, totalEvents: { type: 'number' }, score: { type: 'number' }, }, }, }, cell_count: { type: 'number' }, min_domains: { type: 'number' }, feeds: { type: 'object', description: 'Per-feed ingested event counts (0 = feed empty or unavailable).' }, }, required: [], }, error: { type: 'string', description: 'Present only on a user-input failure; the envelope keys are still returned.' }, }, required: ['cached_at', 'stale', 'data'], }, annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false }, _execute: async (params) => { const lat = typeof params.lat === 'number' ? params.lat : null; const lon = typeof params.lon === 'number' ? params.lon : null; const radiusKm = typeof params.radius_km === 'number' ? params.radius_km : null; const provided = [lat, lon, radiusKm].filter((v) => v !== null).length; if (provided > 0 && provided < 3) { // Envelope always present: the declared outputSchema marks cached_at/ // stale/data required, so a bare {error} would violate the published // contract a strict MCP client validates against. return { cached_at: null, stale: false, data: { alerts: [], cell_count: 0, min_domains: 0, feeds: {} }, error: 'lat, lon, and radius_km must be provided together (all three or none).', }; } if ( provided === 3 && ( !Number.isFinite(lat) || !Number.isFinite(lon) || !Number.isFinite(radiusKm) || lat! < -90 || lat! > 90 || lon! < -180 || lon! > 180 || radiusKm! <= 0 || radiusKm! > 20_000 ) ) { return { cached_at: null, stale: false, data: { alerts: [], cell_count: 0, min_domains: 0, feeds: {} }, error: 'lat must be within [-90, 90], lon within [-180, 180], and radius_km within (0, 20000].', }; } const minDomains = Math.min(5, Math.max(2, Math.round(Number(params.min_domains ?? 3)) || 3)); const keys = ['unrest:events:v1', 'military:flights:v1', 'seismology:earthquakes:v1', 'usni-fleet:sebuf:v1']; const checks: FreshnessCheck[] = [ { key: 'seed-meta:unrest:events', maxStaleMin: 120 }, { key: 'seed-meta:military:flights', maxStaleMin: 30 }, { key: 'seed-meta:seismology:earthquakes', maxStaleMin: 30 }, { key: 'seed-meta:military:usni-fleet', maxStaleMin: 720 }, ]; const { payloads: [unrest, flights, quakes, fleet], freshness } = await readCachesWithFreshness(keys, checks); requireAnyInput( [unrest, flights, quakes, fleet], freshness, 'No convergence input feeds are available', ); const now = Date.now(); const engine = new GeoConvergenceEngine({ convergenceThreshold: minDomains, now: () => now }); const feeds = { protests: unrestEventsToGeoEvents(unrest, { now }), military_flights: militaryFlightsToGeoEvents(flights, { now }), earthquakes: earthquakesToGeoEvents(quakes, { now }), naval_vessels: usniVesselsToGeoEvents(fleet, { now }), }; engine.ingestEvents(feeds.protests, 'protest'); engine.ingestEvents(feeds.military_flights, 'military_flight'); engine.ingestEvents(feeds.earthquakes, 'earthquake'); engine.ingestEvents(feeds.naval_vessels, 'military_vessel'); let alerts = engine.detect(new Set()); if (lat !== null && lon !== null && radiusKm !== null) { const toRad = (d: number) => (d * Math.PI) / 180; alerts = alerts.filter((alert) => { const dLat = toRad(alert.lat - lat); const dLon = toRad(alert.lon - lon); const a = Math.sin(dLat / 2) ** 2 + Math.cos(toRad(lat)) * Math.cos(toRad(alert.lat)) * Math.sin(dLon / 2) ** 2; return 6371 * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)) <= radiusKm; }); } return { ...freshness, data: { alerts: alerts.map((alert) => ({ ...alert, location: getLocationName(alert.lat, alert.lon, MCP_GEO_PLACES), })), cell_count: engine.cellCount(), min_domains: minDomains, feeds: Object.fromEntries(Object.entries(feeds).map(([name, events]) => [name, events.length])), }, }; }, _coverageKeys: ['unrest:events:v1', 'military:flights:v1', 'seismology:earthquakes:v1', 'usni-fleet:sebuf:v1'], _apiPaths: [], }, { name: 'get_focal_points', _outputBudgetBytes: 65536, description: 'Focal-point detection: entities where news coverage and live map signals converge, ranked by multi-signal score. ' + 'Runs the dashboard focal-point engine server-side: seeded news story clusters are entity-matched against the curated ' + 'registry of countries, companies, and organizations, then cross-referenced with cross-source escalation signals mapped ' + 'to countries through the same entity index. Each focal point reports its urgency band, news and signal scores, a ' + 'correlation bonus when headlines and map signals name the same entity, supporting headlines, and a generated narrative. ' + 'The response also carries an application-authored ai_context block suitable for grounding follow-up analysis; source ' + 'headlines remain separate in the focal-point evidence, plus mapping-coverage counters ' + 'so a thin result is distinguishable from an outage. Filter to one country with country_code; cap the list with limit.', inputSchema: { type: 'object', properties: { country_code: { type: 'string', description: 'Filter focal points to one country (ISO-2) and entities the registry relates to it.' }, limit: { type: 'number', description: 'Cap the focal point list (default 10, pass 0 for no cap).' }, }, required: [], }, outputSchema: { type: 'object', properties: { cached_at: { type: ['string', 'null'], description: 'Oldest fetch time across the contributing feeds.' }, stale: { type: 'boolean', description: 'True when any contributing feed is older than its freshness budget.' }, ...ANALYSIS_CACHE_STATUS_PROPERTIES, data: { type: 'object', properties: { focal_points: { type: 'array', items: { type: 'object' } }, ai_context: { type: 'string' }, coverage: { type: 'object', properties: { clusters: { type: 'number' }, signals_total: { type: 'number' }, signals_mapped: { type: 'number' }, signals_unmapped: { type: 'number' }, }, }, }, required: [], }, }, required: ['cached_at', 'stale', 'data'], }, annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false }, _execute: async (params) => { const limit = resolveLimit(params.limit, 10); const keys = ['news:insights:v1', 'intelligence:cross-source-signals:v1', CII_RISK_SCORE_CACHE_KEYS.live]; const checks: FreshnessCheck[] = [ { key: 'seed-meta:news:insights', maxStaleMin: 30 }, { key: 'seed-meta:intelligence:cross-source-signals', maxStaleMin: 30 }, { key: 'seed-meta:intelligence:risk-scores', maxStaleMin: 30, minRecordCount: 3 }, ]; const { payloads: [insights, crossSource, riskScores], freshness } = await readCachesWithFreshness(keys, checks); requireAnyInput( [insights, crossSource, riskScores], freshness, 'No focal-point input feeds are available', ); const index = getSharedEntityIndex(); const clusters = insightsToFocalClusters(insights); const mapping = crossSourceSignalsToSignalSummary(crossSource, index); const summary = new FocalPointCore(index).analyze(clusters, mapping.summary); const ciiLookup = riskScoresToCiiLookup(riskScores); let points = summary.focalPoints; const countryCode = typeof params.country_code === 'string' ? params.country_code : ''; if (countryCode) points = filterFocalPointsByCountry(points, countryCode, index); const selectedPoints = points.slice(0, limit); return { ...freshness, data: { focal_points: selectedPoints.map((point) => ({ ...point, ciiScore: point.entityType === 'country' ? ciiLookup(point.entityId) : null, })), ai_context: generateAgentSafeAIContext(selectedPoints), coverage: { clusters: clusters.length, signals_total: mapping.signalsTotal, signals_mapped: mapping.signalsMapped, signals_unmapped: mapping.signalsUnmapped, }, }, }; }, _coverageKeys: ['news:insights:v1', 'intelligence:cross-source-signals:v1', CII_RISK_SCORE_CACHE_KEYS.live], _apiPaths: [], }, { name: 'simulate_infrastructure_cascade', _outputBudgetBytes: 131072, description: 'Infrastructure cascade simulation: what fails downstream when a cable, chokepoint, pipeline, or port is disrupted. ' + 'Builds the dashboard dependency graph server-side from the seeded TeleGeography submarine-cable table plus the curated ' + 'pipeline, port, and maritime-chokepoint registries, then runs breadth-first failure propagation from the chosen source ' + 'node. Results include every affected node with its degraded capacity share, per-country impact categories, redundancy ' + 'candidates, and graph statistics. Call with no source_id to receive the catalog of simulatable node ids grouped by ' + 'type; disruption_level scales the initial failure from partial (0.1) to total (1, the default). Chained capacity math ' + 'multiplies along paths, so distant impacts shrink realistically instead of cascading at full strength.', inputSchema: { type: 'object', properties: { source_id: { type: 'string', description: 'Node id to disrupt (see the no-argument catalog for valid ids).' }, disruption_level: { type: 'number', description: 'Initial failure severity between 0.1 and 1 (default 1).' }, }, required: [], }, outputSchema: { type: 'object', properties: { cached_at: { type: ['string', 'null'], description: 'Fetch time of the seeded cable table.' }, stale: { type: 'boolean', description: 'True when the cable table is older than its freshness budget.' }, ...ANALYSIS_CACHE_STATUS_PROPERTIES, data: { type: 'object', properties: { catalog: { type: ['object', 'null'], description: 'Node ids by type; present only when source_id is omitted.' }, cascade: { type: ['object', 'null'], description: 'Cascade result; present only when source_id is given.' }, stats: { type: 'object' }, }, required: [], }, error: { type: 'string', description: 'Present only on a user-input failure; the envelope keys are still returned.' }, known_id_sample: { type: 'array', items: { type: 'string' }, description: 'Sample of valid node ids; present only alongside an unknown-source_id error.' }, }, required: ['cached_at', 'stale', 'data'], }, annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false }, _execute: async (params) => { const checks: FreshnessCheck[] = [ { key: 'seed-meta:infrastructure:submarine-cables', maxStaleMin: 25200 }, ]; const { payloads: [cablesPayload], freshness } = await readCachesWithFreshness( ['infrastructure:submarine-cables:v1'], checks, ); const cables = submarineCablesToCableInputs(cablesPayload); const graph = buildDependencyGraph({ cables, waterways: MCP_CASCADE_WATERWAYS }); const stats = getGraphStats(graph); const sourceId = typeof params.source_id === 'string' ? params.source_id.trim() : ''; if (!sourceId) { const catalog: Record> = {}; for (const node of graph.nodes.values()) { if (node.type === 'country') continue; (catalog[node.type] ??= []).push({ id: node.id, name: node.name }); } return { ...freshness, data: { catalog, cascade: null, stats } }; } if (!graph.nodes.has(sourceId)) { if (cablesPayload === null && sourceId.startsWith('cable:')) { throw new McpSourceUnavailableError( 'The submarine-cable catalog is unavailable', freshness.unavailable_inputs, freshness.failed_inputs, ); } const sample = [...graph.nodes.keys()].filter((id) => !id.startsWith('country-')).slice(0, 12); return { ...freshness, data: { catalog: null, cascade: null, stats }, error: `unknown source_id "${sourceId}" — call without source_id for the full catalog`, known_id_sample: sample, }; } const rawLevel = Number(params.disruption_level ?? 1); const disruptionLevel = Math.min(1, Math.max(0.1, Number.isFinite(rawLevel) ? rawLevel : 1)); const cascade = calculateCascade(graph, sourceId, disruptionLevel); return { ...freshness, data: { catalog: null, cascade, stats } }; }, _coverageKeys: ['infrastructure:submarine-cables:v1'], _apiPaths: [], }, { name: 'get_military_surge', _outputBudgetBytes: 65536, description: 'Military surge watch: theater aircraft postures, foreign-presence detections, and seeder-computed surge alerts. ' + 'Runs the dashboard military-surge engine server-side over the seeded flight snapshot: per-theater posture summaries ' + 'count fighters, tankers, AWACS, reconnaissance, transports, bombers, and drones inside each theater boundary, with ' + 'trend context recovered from the persisted surge history and tracked-vessel counts merged from the theater-posture ' + 'cache. Foreign-presence detection flags operators flying far from their home region above per-operator thresholds. ' + 'The seeded_surges block carries the surge alerts the flights seeder computed against its own persisted baselines — ' + 'reported separately because that variant uses different thresholds than the snapshot engine. Filter with theater.', inputSchema: { type: 'object', properties: { theater: { type: 'string', description: 'Filter to one theater by id or name substring (case-insensitive).' }, }, required: [], }, outputSchema: { type: 'object', properties: { cached_at: { type: ['string', 'null'], description: 'Oldest fetch time across the contributing feeds.' }, stale: { type: 'boolean', description: 'True when any contributing feed is older than its freshness budget.' }, ...ANALYSIS_CACHE_STATUS_PROPERTIES, data: { type: 'object', properties: { postures: { type: 'array', items: { type: 'object' } }, foreign_presence: { type: 'array', items: { type: 'object' } }, seeded_surges: { type: 'array', items: { type: 'object' } }, seeded_surges_available: { type: 'boolean' }, history_available: { type: 'boolean' }, cii_available: { type: 'boolean' }, flight_count: { type: 'number' }, }, required: [], }, }, required: ['cached_at', 'stale', 'data'], }, annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false }, _execute: async (params) => { const keys = [ 'military:flights:v1', 'theater-posture:sebuf:v1', 'military:surges:v1', 'military:surges:history:v1', CII_RISK_SCORE_CACHE_KEYS.live, ]; const checks: FreshnessCheck[] = [ { key: 'seed-meta:military:flights', maxStaleMin: 30 }, { key: 'seed-meta:theater-posture', maxStaleMin: 60 }, { key: 'seed-meta:military-surges', maxStaleMin: 30 }, { key: 'seed-meta:intelligence:risk-scores', maxStaleMin: 30, minRecordCount: 3 }, ]; const { payloads: [flightsPayload, posturePayload, surgesPayload, historyPayload, riskScores], freshness, } = await readCachesWithFreshness(keys, checks); requireAnyInput( [flightsPayload, posturePayload, surgesPayload], freshness, 'No primary military feeds are available', ); const flights = militaryFlightsToSurgeInputs(flightsPayload); const history = surgeHistoryToActivityHistory(historyPayload); const postures = flightsPayload !== null || posturePayload !== null ? getTheaterPostureSummaries(flights, history) : []; if (postures.length > 0) { applyVesselCountsToPostures(postures, theaterPostureVesselCounts(posturePayload)); recalcPostureWithVessels(postures, riskScoresToCiiLookup(riskScores)); } const engine = new MilitarySurgeEngine(); const foreignPresence = engine.detectForeignMilitaryPresence(flights).map((alert) => ({ id: alert.id, operator: alert.operator, operatorCountry: alert.operatorCountry, region: alert.region.name, region_id: alert.region.id, aircraftCount: alert.aircraftCount, })); const seededSurges = Array.isArray((surgesPayload as { surges?: unknown[] } | null)?.surges) ? ((surgesPayload as { surges: unknown[] }).surges as Array>) : []; const theaterFilter = typeof params.theater === 'string' ? params.theater.trim().toLowerCase() : ''; const matchesTheater = (id: unknown, name?: unknown, shortName?: unknown) => !theaterFilter || [id, name, shortName].some( (value) => typeof value === 'string' && value.toLowerCase().includes(theaterFilter), ); const matchedRegionIds = new Set( POSTURE_THEATERS .filter((theater) => matchesTheater(theater.id, theater.name, theater.shortName)) .flatMap((theater) => theater.regions), ); return { ...freshness, data: { postures: postures.filter((p) => matchesTheater(p.theaterId, p.theaterName, p.shortName)), foreign_presence: theaterFilter ? foreignPresence.filter((alert) => matchedRegionIds.has(alert.region_id) || alert.region_id.toLowerCase().includes(theaterFilter) || alert.region.toLowerCase().includes(theaterFilter)) : foreignPresence, seeded_surges: seededSurges.filter((surge) => matchesTheater(surge.theaterId, surge.theater)), seeded_surges_available: surgesPayload !== null, history_available: historyPayload !== null, cii_available: riskScores !== null, flight_count: flights.length, }, }; }, _coverageKeys: [ 'military:flights:v1', 'theater-posture:sebuf:v1', 'military:surges:v1', 'military:surges:history:v1', CII_RISK_SCORE_CACHE_KEYS.live, ], _apiPaths: [], }, { name: 'get_population_exposure', _outputBudgetBytes: 65536, description: 'Population exposure: estimated people within the impact radius of active earthquakes, wildfires, and conflict events. ' + 'Uses the same country-density approximation the dashboard ships — the nearest priority-country centroid supplies a ' + 'population density that is multiplied over the event-type radius disc (50 km for conflict, 100 km for earthquakes and ' + 'floods, 30 km for fires). Three modes: events (the default) enriches the current seeded event feeds and ranks them by ' + 'exposed population; point estimates exposure around an arbitrary lat/lon; countries returns the priority-country ' + 'population table itself. Estimates are deliberately coarse screening numbers — there is no city-level population ' + 'dataset behind them — so treat them as ranking signals, not casualty projections.', inputSchema: { type: 'object', properties: { mode: { type: 'string', enum: ['events', 'point', 'countries'], description: 'events enriches live feeds (default); point takes lat/lon; countries lists the population table.' }, event_source: { type: 'string', enum: ['earthquakes', 'wildfires', 'conflicts', 'all'], description: 'Which event feeds to enrich in events mode (default all).' }, lat: { type: 'number', description: 'Latitude for point mode.' }, lon: { type: 'number', description: 'Longitude for point mode.' }, radius_km: { type: 'number', description: 'Radius in km for point mode (default 50, clamped to 1000).' }, limit: { type: 'number', description: 'Cap the enriched event list in events mode (default 20, pass 0 for no cap).' }, }, required: [], }, outputSchema: { type: 'object', properties: { cached_at: { type: ['string', 'null'], description: 'Oldest fetch time across the feeds read; null in point and countries modes.' }, stale: { type: 'boolean', description: 'True when any contributing feed is older than its freshness budget.' }, ...ANALYSIS_CACHE_STATUS_PROPERTIES, data: { type: 'object', properties: { events: { type: ['array', 'null'], items: { type: 'object' } }, exposure: { type: ['object', 'null'] }, countries: { type: ['array', 'null'], items: { type: 'object' } }, }, required: [], }, error: { type: 'string', description: 'Present only on a user-input failure; the envelope keys are still returned.' }, }, required: ['cached_at', 'stale', 'data'], }, annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false }, _execute: async (params) => { const mode = typeof params.mode === 'string' ? params.mode : 'events'; if (mode === 'countries') { return { cached_at: null, stale: false, data: { events: null, exposure: null, countries: listCountryPopulations() } }; } if (mode === 'point') { const lat = typeof params.lat === 'number' ? params.lat : null; const lon = typeof params.lon === 'number' ? params.lon : null; if (lat === null || lon === null) { return { cached_at: null, stale: false, data: { events: null, exposure: null, countries: null }, error: 'point mode requires numeric lat and lon.', }; } // Out-of-range coordinates would still resolve to a nearest centroid // by Euclidean distance and return a real-looking estimate for a place // that does not exist (lat 999 attributes to Mali). if (lat < -90 || lat > 90 || lon < -180 || lon > 180) { return { cached_at: null, stale: false, data: { events: null, exposure: null, countries: null }, error: `lat must be within [-90, 90] and lon within [-180, 180] (received lat=${lat}, lon=${lon}).`, }; } const radiusKm = Math.max(1, Number(params.radius_km ?? 50) || 50); return { cached_at: null, stale: false, data: { events: null, exposure: computeBoundedExposure(lat, lon, radiusKm), countries: null }, }; } const source = typeof params.event_source === 'string' ? params.event_source : 'all'; const limit = resolveLimit(params.limit, 20); const wants = (name: string) => source === 'all' || source === name; const reads: Array<{ key: string; check: FreshnessCheck; adapt: (payload: unknown, limit: number) => ExposureEvent[]; }> = []; if (wants('earthquakes')) { reads.push({ key: 'seismology:earthquakes:v1', check: { key: 'seed-meta:seismology:earthquakes', maxStaleMin: 30 }, adapt: (payload, cap) => earthquakesToExposureEvents(payload, cap), }); } if (wants('wildfires')) { reads.push({ key: 'wildfire:fires:v1', check: { key: 'seed-meta:wildfire:fires', maxStaleMin: 360 }, adapt: (payload, cap) => firesToExposureEvents(payload, cap), }); } if (wants('conflicts')) { reads.push({ key: 'conflict:ucdp-events:v1', check: { key: 'seed-meta:conflict:ucdp-events', maxStaleMin: 420 }, adapt: (payload, cap) => ucdpEventsToExposureEvents(payload, cap), }); } const { payloads, freshness } = await readCachesWithFreshness( reads.map((read) => read.key), reads.map((read) => read.check), ); requireAnyInput( payloads, freshness, 'No event feeds are available for exposure enrichment', ); const enriched = reads .flatMap((read, i) => read.adapt(payloads[i], Number.POSITIVE_INFINITY)) .map((event) => { const radius = getRadiusForEventType(event.type); const exposure = computeExposure(event.lat, event.lon, radius); return { ...event, ...exposure }; }) .sort((a, b) => b.exposedPopulation - a.exposedPopulation) .slice(0, limit); return { ...freshness, data: { events: enriched, exposure: null, countries: null } }; }, _coverageKeys: ['seismology:earthquakes:v1', 'wildfire:fires:v1', 'conflict:ucdp-events:v1'], _apiPaths: ['GET /api/displacement/v1/get-population-exposure'], }, { name: 'get_alert_digest', _outputBudgetBytes: 131072, description: 'Cross-domain alert digest: everything that tripped a threshold today, in one rollup. ' + 'Sweeps seven seeded domains — country instability bands, military surge alerts, submarine-cable health, ongoing ' + 'internet outages, temporal anomalies, thermal escalation zones, and shipping stress — and reports each trip with the ' + 'severity vocabulary its own producer already uses; no thresholds are invented by this tool. Domains with data but no ' + 'trips are listed as quiet, and domains whose caches are unavailable are listed separately so silence is never mistaken ' + 'for calm. The weekly view adds direction, volatility, and anomaly flags derived from the persisted military-activity ' + 'history plus the current temporal-anomaly snapshot. This is the fastest single call for what changed today.', inputSchema: { type: 'object', properties: { view: { type: 'string', enum: ['today', 'weekly'], description: 'today lists current threshold trips (default); weekly adds trend context.' }, }, required: [], }, outputSchema: { type: 'object', properties: { cached_at: { type: ['string', 'null'], description: 'Oldest fetch time across the contributing feeds.' }, stale: { type: 'boolean', description: 'True when any contributing feed is older than its freshness budget.' }, ...ANALYSIS_CACHE_STATUS_PROPERTIES, data: { type: 'object', properties: { tripped: { type: 'array', items: { type: 'object' } }, quiet: { type: 'array', items: { type: 'string' } }, unavailable: { type: 'array', items: { type: 'string' } }, weekly: { type: ['object', 'null'], properties: { trends: { type: 'array', items: { type: 'object' } }, current_anomalies: { type: 'array', items: { type: 'object' } }, history_available: { type: 'boolean' }, note: { type: 'string' }, }, }, }, required: [], }, }, required: ['cached_at', 'stale', 'data'], }, annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false }, _execute: async (params) => { const view = typeof params.view === 'string' ? params.view : 'today'; const keys: string[] = [ CII_RISK_SCORE_CACHE_KEYS.live, 'military:surges:v1', 'cable-health-v1', 'infra:outages:v1', 'temporal:anomalies:v1', 'thermal:escalation:v1', 'supply_chain:shipping_stress:v1', ]; if (view === 'weekly') keys.push('military:surges:history:v1'); const checks: FreshnessCheck[] = [ { key: 'seed-meta:intelligence:risk-scores', maxStaleMin: 30, minRecordCount: 3 }, { key: 'seed-meta:military-surges', maxStaleMin: 30 }, { key: 'seed-meta:cable-health', maxStaleMin: 90 }, { key: 'seed-meta:infra:outages', maxStaleMin: 30 }, { key: 'seed-meta:temporal:anomalies', maxStaleMin: 45 }, { key: 'seed-meta:thermal:escalation', maxStaleMin: 360 }, { key: 'seed-meta:supply_chain:shipping_stress', maxStaleMin: 45 }, ]; const { payloads: [riskScores, surges, cableHealth, outages, temporal, thermal, stress, historyPayload], freshness, } = await readCachesWithFreshness(keys, checks); requireAnyInput( [riskScores, surges, cableHealth, outages, temporal, thermal, stress], freshness, 'No digest input feeds are available', ); const now = Date.now(); const digest = buildAlertDigest( buildDigestInputs({ riskScores, surges, cableHealth, outages, temporal, thermal, stress }), now, ); let weekly: Record | null = null; const unavailable = [...digest.unavailable]; if (view === 'weekly') { const historyAvailable = historyPayload !== null; if (!historyAvailable) unavailable.push('military_history'); const activity = surgeHistoryToActivityHistory(historyPayload); const series = [...activity.entries()].map(([theaterId, points]) => ({ domain: `military:${theaterId}`, points: points.map((point) => ({ t: point.timestamp, value: point.totalMilitary })), })); weekly = { trends: buildWeeklyTrends(series, now), current_anomalies: anomaliesToDigestInput(temporal), history_available: historyAvailable, note: 'weekly trends derive from the persisted military-activity history; other domains publish no whole-feed history caches yet', }; } return { ...freshness, data: { tripped: digest.tripped, quiet: digest.quiet, unavailable, weekly }, }; }, _coverageKeys: [ CII_RISK_SCORE_CACHE_KEYS.live, 'military:surges:v1', 'cable-health-v1', 'infra:outages:v1', 'temporal:anomalies:v1', 'thermal:escalation:v1', 'supply_chain:shipping_stress:v1', ], _apiPaths: [], }, { name: 'get_hotspot_escalation', _outputBudgetBytes: 65536, description: 'Hotspot escalation scores: the 29 curated intelligence hotspots ranked by dynamic escalation on a 1-5 scale. ' + 'Runs a reduced server snapshot of the dashboard escalation engine: for each curated hotspot, news pressure (keyword matches over the ' + 'seeded story clusters), country instability, geographic signal convergence (protests, military flights, earthquakes ' + 'gridded around the hotspot), and nearby military activity are normalized to 0-100 components, weighted 35/25/25/15, ' + 'and blended 30/70 with the curated static baseline into a 1-5 composite. Server runs do not have the browser session ' + 'inputs for breaking-news flags, news velocity, score history, or vessel positions; input_availability names those ' + 'omissions explicitly, while unavailable_inputs reports missing server-side feeds.', inputSchema: { type: 'object', properties: { hotspot_id: { type: 'string', description: 'Return only this curated hotspot id (see any full response for the id list).' }, limit: { type: 'number', description: 'Cap the ranked hotspot list (default 29, the full curated set; pass 0 for no cap).' }, }, required: [], }, outputSchema: { type: 'object', properties: { cached_at: { type: ['string', 'null'], description: 'Oldest fetch time across the contributing feeds.' }, stale: { type: 'boolean', description: 'True when any contributing feed is older than its freshness budget.' }, ...ANALYSIS_CACHE_STATUS_PROPERTIES, data: { type: 'object', properties: { hotspots: { type: 'array', items: { type: 'object', properties: { hotspotId: { type: 'string' }, name: { type: 'string' }, lat: { type: 'number' }, lon: { type: 'number' }, staticBaseline: { type: 'number' }, dynamicScore: { type: 'number' }, combinedScore: { type: 'number', description: 'Composite escalation on the documented 1-5 scale.' }, components: { type: 'object' }, trend: { type: 'string' }, }, }, }, input_availability: { type: 'object', properties: { news_pressure: { type: 'boolean' }, country_instability: { type: 'boolean' }, geo_convergence: { type: 'boolean' }, military_flights: { type: 'boolean' }, breaking_news: { type: 'boolean' }, news_velocity: { type: 'boolean' }, military_vessels: { type: 'boolean' }, score_history: { type: 'boolean' }, }, }, }, required: [], }, error: { type: 'string', description: 'Present only on a user-input failure; the envelope keys are still returned.' }, known_ids: { type: 'array', items: { type: 'string' }, description: 'All curated hotspot ids; present only alongside an unknown-hotspot_id error.' }, }, required: ['cached_at', 'stale', 'data'], }, annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false }, _execute: async (params) => { // Validated against the static curated list BEFORE any cache read: an // unknown id is knowable without spending five Redis round-trips. const hotspotIdFilter = typeof params.hotspot_id === 'string' ? params.hotspot_id.trim() : ''; const targets = hotspotIdFilter ? INTEL_HOTSPOTS.filter((hotspot) => hotspot.id === hotspotIdFilter) : INTEL_HOTSPOTS; if (hotspotIdFilter && targets.length === 0) { return { cached_at: null, stale: false, data: { hotspots: [] }, error: `unknown hotspot_id "${hotspotIdFilter}"`, known_ids: INTEL_HOTSPOTS.map((hotspot) => hotspot.id), }; } const keys = ['news:insights:v1', CII_RISK_SCORE_CACHE_KEYS.live, 'military:flights:v1', 'unrest:events:v1', 'seismology:earthquakes:v1']; const checks: FreshnessCheck[] = [ { key: 'seed-meta:news:insights', maxStaleMin: 30 }, { key: 'seed-meta:intelligence:risk-scores', maxStaleMin: 30, minRecordCount: 3 }, { key: 'seed-meta:military:flights', maxStaleMin: 30 }, { key: 'seed-meta:unrest:events', maxStaleMin: 120 }, { key: 'seed-meta:seismology:earthquakes', maxStaleMin: 30 }, ]; const { payloads: [insights, riskScores, flightsPayload, unrest, quakes], freshness } = await readCachesWithFreshness(keys, checks); requireAnyInput( [insights, riskScores, flightsPayload, unrest, quakes], freshness, 'No hotspot-escalation input feeds are available', ); const now = Date.now(); const clusters = insightsToFocalClusters(insights); const ciiLookup = riskScoresToCiiLookup(riskScores); const flights = militaryFlightsToSurgeInputs(flightsPayload); const geoEngine = new GeoConvergenceEngine({ now: () => now }); geoEngine.ingestEvents(unrestEventsToGeoEvents(unrest, { now }), 'protest'); geoEngine.ingestEvents(militaryFlightsToGeoEvents(flightsPayload, { now }), 'military_flight'); geoEngine.ingestEvents(earthquakesToGeoEvents(quakes, { now }), 'earthquake'); const scored = targets.map((hotspot) => { const keywords = (hotspot.keywords ?? []).map((keyword) => keyword.toLowerCase()); const matchesKeyword = (title: string) => { const lower = title.toLowerCase(); return keywords.some((keyword) => lower.includes(keyword)); }; const newsMatches = clusters.filter( (cluster) => matchesKeyword(cluster.primaryTitle) || (cluster.allItems ?? []).some((item) => matchesKeyword(item.title)), ).length; const nearby = geoEngine.alertsNear(hotspot.lat, hotspot.lon, 300); const ciiScore = getHotspotCountryScore(hotspot.id, ciiLookup); const score = computeEscalationScore( hotspot, { newsMatches, hasBreaking: false, newsVelocity: 0, ciiScore, geoAlertScore: nearby?.score ?? 0, geoAlertTypes: nearby?.types ?? 0, flightsNearby: countMilitaryNearHotspot(hotspot, flights, []).flights, vesselsNearby: 0, }, { now, previousHistory: [] }, ); return { hotspotId: score.hotspotId, name: hotspot.name, lat: hotspot.lat, lon: hotspot.lon, staticBaseline: score.staticBaseline, dynamicScore: score.dynamicScore, combinedScore: score.combinedScore, components: score.components, trend: score.trend, }; }); const limit = resolveLimit(params.limit, INTEL_HOTSPOTS.length); scored.sort((a, b) => b.combinedScore - a.combinedScore || b.dynamicScore - a.dynamicScore); return { ...freshness, data: { hotspots: scored.slice(0, limit), input_availability: { news_pressure: insights !== null, country_instability: riskScores !== null, geo_convergence: [unrest, flightsPayload, quakes].every((value) => value !== null), military_flights: flightsPayload !== null, breaking_news: false, news_velocity: false, military_vessels: false, score_history: false, }, }, }; }, _coverageKeys: ['news:insights:v1', CII_RISK_SCORE_CACHE_KEYS.live, 'military:flights:v1', 'unrest:events:v1', 'seismology:earthquakes:v1'], _apiPaths: [], }, ];