diff --git a/CHANGELOG.md b/CHANGELOG.md index fffa762de..10428d684 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,9 @@ - Add tools for language-model clients and a local MCP server. `npm run mcp` serves earthquake, active-fire, launch, aircraft, satellite, camera, radio, - place, routing, bike-share and transit queries over stdio to clients such as Claude Code, reading + place, routing, bike-share, transit, weather, cyclone, fire-perimeter, + terrain, installation and map-feature queries, plus a situation brief, over + stdio to clients such as Claude Code, reading from a running app. Tools are defined once in `gods-eye-view/tools`, reuse the layers' source factories, and are exposed through the protocol adapter in `gods-eye-view/tools/mcp`. diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index fdd1f9260..bc1a8c2e1 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -10,7 +10,9 @@ Queries cover earthquakes, active fires, recent launches, aircraft (in an area, by identifier, tracks, and type and route lookups), satellites (next pass over a point, and those overhead now), public cameras (including a camera's current image), radio stations, place search, routing, bike-share -stations and transit vehicles. Tools reuse the layers' portable source factories, take a +stations, transit vehicles, weather, regional briefs, tropical cyclones, fire +perimeters, terrain height, military installations and map features, plus a +combined situation brief and the app's heads-up display caption. Tools reuse the layers' portable source factories, take a shared `area` argument (place name, bounding box, or point and radius) and cap lists at 25 rows by default. The app does not import the tools, so its runtime behavior is unchanged. See [tools and the MCP server](TOOLS.md). diff --git a/docs/TOOLS.md b/docs/TOOLS.md index cf373d003..699fbcd9d 100644 --- a/docs/TOOLS.md +++ b/docs/TOOLS.md @@ -49,7 +49,10 @@ Services are the portable source factories the layers already use, such as plus a `places` service with `resolve(name, { signal })`. Sources request relative `/api/...` paths through an injected `fetchImpl`, so the same tool code runs wherever an application routes those paths. -The `bikeshare` service is `{ systems, getStations }`: the system registry and +The `weather`, `regional`, `terrain`, `summary` and `features` services are the +application request services from `gods-eye-view/application/requests`. +`situation_brief` runs each section whose services are supplied and marks the +others unavailable. The `bikeshare` service is `{ systems, getStations }`: the system registry and the GBFS source. `createGeocodePlaceService` resolves place names through `/api/geocode`; `createPlaceSearchService` searches `/api/google/*` and reports when no search key is configured; `createRouteService` plans routes through `/api/route`. @@ -106,3 +109,12 @@ public feeds the sources already use. | `plan_route` | `routing` | Walking, driving or cycling route over OpenStreetMap, with a simplified path | | `get_bike_share` | `bikeshare` | Live GBFS stations in an area, with bikes and docks available | | `get_transit_vehicles` | `transit` | Live GTFS-Realtime vehicle positions in an area, optionally one route | +| `get_weather` | `weather` | Current conditions at a place or point | +| `get_regional_brief` | `regional` | What and where a location is, its weather and recent headlines | +| `get_cyclones` | `cyclones` | Active NHC/CPHC tropical cyclones, optionally in an area | +| `get_fire_perimeters` | `perimeters` | Mapped WFIGS wildfire perimeters in an area, largest first | +| `get_terrain_height` | `terrain` | Ground, geoid and ellipsoid heights at up to 20 points | +| `find_military_installations` | `installations` | OpenStreetMap military sites in an area of at most 10° per side | +| `get_map_features` | `features` | Administrative areas, named places or monuments at a location (needs Overpass) | +| `situation_brief` | `weather` | Weather, earthquakes, fires, aircraft and cyclones for an area, by section | +| `get_hud_caption` | `weather`, `summary` | The app's heads-up display caption for an area | diff --git a/scripts/format-scope.json b/scripts/format-scope.json index 685971b2a..484c19214 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -875,5 +875,6 @@ "src/tools/queries/media.test.mjs", "src/tools/queries/places.test.mjs", "src/layers/transit/source.test.mjs", - "src/tools/queries/mobility.test.mjs" + "src/tools/queries/mobility.test.mjs", + "src/tools/queries/environment.test.mjs" ] diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index 834b2f8d2..0b9e5025c 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -3094,6 +3094,8 @@ "src/tools/queries/media.js", "src/tools/queries/places.js", "src/tools/queries/mobility.js", + "src/tools/queries/environment.js", + "src/tools/queries/brief.js", "src/sources/gbfsStations.js", "src/sources/tle.js", "src/data/satellitePass.js" diff --git a/server/mcp/services.js b/server/mcp/services.js index 401d7c1ea..d65e9e177 100644 --- a/server/mcp/services.js +++ b/server/mcp/services.js @@ -6,12 +6,16 @@ import { GBFS_CITY_REGISTRY } from '../../src/layers/bikeshare/registry.js'; import { createBikeshareSource } from '../../src/layers/bikeshare/source.js'; import { createCctvSource } from '../../src/layers/cctv/source.js'; +import { createCycloneSource } from '../../src/layers/cyclones/source.js'; import { createUsgsEarthquakeSource } from '../../src/layers/earthquakes/source.js'; import { createFirmsSource } from '../../src/layers/firms/source.js'; +import { createInstallationSource } from '../../src/layers/installations/source.js'; import { createLaunchSource } from '../../src/layers/launches/source.js'; +import { createWfigsPerimeterSource } from '../../src/layers/perimeters/source.js'; import { createRadioSource } from '../../src/layers/radio/source.js'; import { createSatelliteSource } from '../../src/layers/satellites/source.js'; import { createTransitSource } from '../../src/layers/transit/source.js'; +import { createApplicationRequestServices } from '../../src/services/requests.js'; import { createAdsbLolSource, createOpenSkySource, @@ -44,6 +48,7 @@ export function createApiFetch({ /** Construct every service Core's tools read, backed by the local server. */ export function createLocalToolServices(options = {}) { const fetchImpl = createApiFetch(options); + const requests = createApplicationRequestServices({ fetchImpl }); return { earthquakes: createUsgsEarthquakeSource({ fetchImpl }), fires: createFirmsSource({ fetchImpl }), @@ -60,6 +65,17 @@ export function createLocalToolServices(options = {}) { getStations: createBikeshareSource({ fetchImpl }).getStations, }, transit: createTransitSource({ fetchImpl }), + weather: requests.weather, + regional: requests.regional, + terrain: requests.terrain, + summary: requests.summary, + features: requests.features, + cyclones: createCycloneSource({ fetchImpl }), + perimeters: createWfigsPerimeterSource({ fetchImpl }), + installations: createInstallationSource({ + fetchImpl, + tileFetchImpl: fetchImpl, + }), places: createGeocodePlaceService({ fetchImpl }), }; } diff --git a/src/tools/index.js b/src/tools/index.js index bcc0e1992..3a753f425 100644 --- a/src/tools/index.js +++ b/src/tools/index.js @@ -9,6 +9,16 @@ import { getAircraftInfo, getAircraftTrack, } from './queries/aviation.js'; +import { getHudCaption, situationBrief } from './queries/brief.js'; +import { + findMilitaryInstallations, + getCyclones, + getFirePerimeters, + getMapFeatures, + getRegionalBrief, + getTerrainHeight, + getWeather, +} from './queries/environment.js'; import { getActiveFires, getEarthquakes } from './queries/hazards.js'; import { getBikeShare, getTransitVehicles } from './queries/mobility.js'; import { placesNearby, planRoute, searchPlaces } from './queries/places.js'; @@ -63,4 +73,13 @@ export const coreTools = Object.freeze([ planRoute, getBikeShare, getTransitVehicles, + getWeather, + getRegionalBrief, + getCyclones, + getFirePerimeters, + getTerrainHeight, + findMilitaryInstallations, + getMapFeatures, + situationBrief, + getHudCaption, ]); diff --git a/src/tools/localServer.test.mjs b/src/tools/localServer.test.mjs index c971823d1..19e09516b 100644 --- a/src/tools/localServer.test.mjs +++ b/src/tools/localServer.test.mjs @@ -4,6 +4,7 @@ import { PassThrough } from 'node:stream'; import { createLocalMcpServer } from '../../server/mcp/server.js'; import { createApiFetch } from '../../server/mcp/services.js'; import { parseArgs, serveStdio } from '../../server/mcp/stdio.js'; +import { coreTools } from './index.js'; const usgs = { type: 'FeatureCollection', @@ -66,27 +67,10 @@ test('the stdio server answers newline-delimited requests using only its data so assert.equal(responses.length, 4); assert.equal(byId.get(1).result.serverInfo.name, 'gods-eye-view'); assert.equal(byId.get(null).error.code, -32700); + // Every Core tool's services are composed locally. assert.deepEqual( byId.get(2).result.tools.map((tool) => tool.name), - [ - 'get_earthquakes', - 'get_active_fires', - 'get_recent_launches', - 'aircraft_in_area', - 'find_aircraft', - 'get_aircraft_track', - 'get_aircraft_info', - 'next_satellite_pass', - 'satellites_overhead', - 'find_cctv_cameras', - 'get_cctv_snapshot', - 'find_radio_stations', - 'search_places', - 'places_nearby', - 'plan_route', - 'get_bike_share', - 'get_transit_vehicles', - ], + coreTools.map((tool) => tool.name), ); assert.equal( byId.get(3).result.content[0].text, diff --git a/src/tools/queries/brief.js b/src/tools/queries/brief.js new file mode 100644 index 000000000..57d0d4cef --- /dev/null +++ b/src/tools/queries/brief.js @@ -0,0 +1,139 @@ +/** Composite queries that combine other tools' answers for one area. */ + +import { defineTool, ToolError } from '../catalog.js'; +import { AREA_SCHEMA, areaCenter, resolveArea } from '../area.js'; +import { aircraftInArea } from './aviation.js'; +import { getCyclones, getWeather } from './environment.js'; +import { getActiveFires, getEarthquakes } from './hazards.js'; + +const SECTION_LIMIT = 5; + +/** Brief sections: the tool each one reuses and the services it needs. */ +const SECTIONS = [ + { + key: 'weather', + tool: getWeather, + args: (area, center) => ({ + location: { lat: center.lat, lon: center.lon }, + }), + }, + { + key: 'earthquakes', + tool: getEarthquakes, + args: (area) => ({ area: area.argument, limit: SECTION_LIMIT }), + }, + { + key: 'fires', + tool: getActiveFires, + args: (area) => ({ area: area.argument, limit: SECTION_LIMIT }), + }, + { + key: 'aircraft', + tool: aircraftInArea, + args: (area) => ({ area: area.argument, limit: SECTION_LIMIT }), + }, + { + key: 'cyclones', + tool: getCyclones, + args: (area) => ({ area: area.argument }), + }, +]; + +/** + * Run every section whose services are supplied. A failing section is + * reported as unavailable instead of failing the brief. + */ +async function buildBrief(args, { services, signal }) { + const resolved = await resolveArea(args.area, { services, signal }); + // Sections receive the resolved box so a place name is looked up once. + const area = { + ...resolved, + argument: resolved.center + ? { + lat: resolved.center.lat, + lon: resolved.center.lon, + radius_km: resolved.center.radiusKm, + } + : { + bbox: [resolved.west, resolved.south, resolved.east, resolved.north], + }, + }; + const center = areaCenter(resolved); + const sections = SECTIONS.filter(({ tool }) => + tool.requires.every((key) => services[key] != null), + ); + const results = await Promise.allSettled( + sections.map(({ tool, args: build }) => + tool.run(build(area, center), { services, signal }), + ), + ); + signal?.throwIfAborted(); + const brief = {}; + const lines = []; + sections.forEach(({ key }, index) => { + const result = results[index]; + if (result.status === 'fulfilled') { + brief[key] = { summary: result.value.summary, data: result.value.data }; + lines.push(result.value.summary); + } else { + const known = result.reason instanceof ToolError; + brief[key] = { + unavailable: true, + reason: known ? result.reason.message : 'unavailable right now', + }; + } + }); + return { area: resolved, center, brief, lines }; +} + +export const situationBrief = defineTool({ + name: 'situation_brief', + title: 'Situation brief', + description: + 'One overview of an area: current weather, recent earthquakes, active ' + + 'fires, aircraft overhead and tropical cyclones, each summarized with ' + + 'its top items. Sections that are unavailable are marked as such.', + inputSchema: { + type: 'object', + properties: { area: AREA_SCHEMA }, + required: ['area'], + additionalProperties: false, + }, + requires: ['weather'], + async run(args, context) { + const { area, brief, lines } = await buildBrief(args, context); + return { + summary: `Situation in ${area.label}: ${lines.join(' ')}`, + data: { area: area.label, sections: brief }, + }; + }, +}); + +export const getHudCaption = defineTool({ + name: 'get_hud_caption', + title: 'Heads-up display caption', + description: + "The short heads-up display caption God's Eye View would show for an " + + 'area, written by the app from the same overview situation_brief gives.', + inputSchema: { + type: 'object', + properties: { area: AREA_SCHEMA }, + required: ['area'], + additionalProperties: false, + }, + requires: ['weather', 'summary'], + async run(args, context) { + const { area, center, lines } = await buildBrief(args, context); + const response = await context.services.summary.summarize( + { location: area.label, center, observations: lines }, + { signal: context.signal }, + ); + const caption = response?.data?.summary; + if (!response?.ok || typeof caption !== 'string' || !caption) + throw new ToolError('unavailable', 'The caption service did not answer'); + return { + summary: caption, + data: { area: area.label, caption }, + }; + }, +}); diff --git a/src/tools/queries/environment.js b/src/tools/queries/environment.js new file mode 100644 index 000000000..8e053f2e4 --- /dev/null +++ b/src/tools/queries/environment.js @@ -0,0 +1,438 @@ +/** + * Environment queries: weather, regional briefs, cyclones, fire perimeters, + * terrain height, mapped military sites and map features. + */ + +import { defineTool, ToolError } from '../catalog.js'; +import { + AREA_SCHEMA, + POINT_SCHEMA, + areaCenter, + areaContains, + distanceKm, + resolveArea, + resolvePoint, +} from '../area.js'; +import { LIMIT_SCHEMA, capRows, countNoun, isoTime } from '../results.js'; + +const MAX_TERRAIN_POINTS = 20; +const MAX_SITE_BOX_DEGREES = 10; +const FEATURE_KINDS = { + administrative_areas: 'getAdministrativeAreas', + named_places: 'getEnclosingAreas', + monuments: 'getMonuments', +}; + +const round = (value, digits) => + Number.isFinite(value) ? Number(value.toFixed(digits)) : null; + +// WMO weather interpretation codes used by the weather provider. +const WEATHER_CODES = { + 0: 'clear sky', + 1: 'mainly clear', + 2: 'partly cloudy', + 3: 'overcast', + 45: 'fog', + 48: 'depositing rime fog', + 51: 'light drizzle', + 53: 'drizzle', + 55: 'dense drizzle', + 56: 'light freezing drizzle', + 57: 'freezing drizzle', + 61: 'light rain', + 63: 'rain', + 65: 'heavy rain', + 66: 'light freezing rain', + 67: 'freezing rain', + 71: 'light snow', + 73: 'snow', + 75: 'heavy snow', + 77: 'snow grains', + 80: 'light rain showers', + 81: 'rain showers', + 82: 'violent rain showers', + 85: 'light snow showers', + 86: 'snow showers', + 95: 'thunderstorm', + 96: 'thunderstorm with light hail', + 99: 'thunderstorm with heavy hail', +}; + +function weatherRow(weather) { + if (!weather) return null; + return { + observed_at: weather.observedAt ?? null, + conditions: WEATHER_CODES[weather.weatherCode] ?? null, + weather_code: weather.weatherCode ?? null, + temperature_c: weather.temperatureC ?? null, + feels_like_c: weather.apparentTemperatureC ?? null, + precipitation_mm: weather.precipitationMm ?? null, + cloud_cover_pct: weather.cloudCoverPct ?? null, + wind_kph: weather.windKph ?? null, + wind_direction_deg: weather.windDirectionDeg ?? null, + visibility_m: weather.visibilityM ?? null, + }; +} + +function describeWeather(row) { + if (!row) return 'no current weather'; + const parts = [ + row.conditions, + Number.isFinite(row.temperature_c) && `${Math.round(row.temperature_c)}°C`, + Number.isFinite(row.wind_kph) && `wind ${Math.round(row.wind_kph)} km/h`, + ].filter(Boolean); + return parts.join(', ') || 'conditions unknown'; +} + +export const getWeather = defineTool({ + name: 'get_weather', + title: 'Current weather', + description: + 'Current weather at a location: conditions, temperature, feels-like, ' + + 'precipitation, cloud cover, wind and visibility.', + inputSchema: { + type: 'object', + properties: { location: POINT_SCHEMA }, + required: ['location'], + additionalProperties: false, + }, + requires: ['weather'], + async run(args, { services, signal }) { + const point = await resolvePoint(args.location, { services, signal }); + const payload = await services.weather.getConditions(point.lat, point.lon, { + signal, + }); + const row = weatherRow(payload?.weather); + return { + summary: `Weather at ${point.label}: ${describeWeather(row)}.`, + data: { location: point, weather: row }, + }; + }, +}); + +export const getRegionalBrief = defineTool({ + name: 'get_regional_brief', + title: 'Regional brief', + description: + 'What and where a location is, its current weather, and recent news ' + + 'headlines for the region.', + inputSchema: { + type: 'object', + properties: { location: POINT_SCHEMA }, + required: ['location'], + additionalProperties: false, + }, + requires: ['regional'], + async run(args, { services, signal }) { + const point = await resolvePoint(args.location, { services, signal }); + const brief = await services.regional.getBrief(point.lat, point.lon, { + signal, + }); + const place = brief?.place ?? null; + const articles = (brief?.articles || []).slice(0, 10).map((article) => ({ + title: article.title ?? null, + url: article.url ?? null, + source: article.domain ?? null, + published_at: article.publishedAt ?? null, + })); + const weather = weatherRow(brief?.weather); + const label = place?.label || point.label; + return { + summary: `${label}: ${describeWeather(weather)}; ${countNoun(articles.length, 'recent headline')}.`, + data: { + location: point, + place: place && { + label: place.label ?? null, + locality: place.locality ?? null, + region: place.region ?? null, + country: place.country ?? null, + kind: place.kind ?? null, + }, + weather, + news_source: brief?.newsSource ?? null, + articles, + }, + }; + }, +}); + +export const getCyclones = defineTool({ + name: 'get_cyclones', + title: 'Tropical cyclones', + description: + 'Active tropical cyclones from the NOAA National Hurricane Center and ' + + 'Central Pacific Hurricane Center, optionally limited to an area.', + inputSchema: { + type: 'object', + properties: { area: AREA_SCHEMA }, + additionalProperties: false, + }, + requires: ['cyclones'], + async run(args, { services, signal }) { + const area = args.area + ? await resolveArea(args.area, { services, signal }) + : null; + const snapshot = await services.cyclones.getSnapshot({ signal }); + if (snapshot.unavailable) + throw new ToolError( + 'unavailable', + 'Cyclone advisories are unavailable right now', + ); + const storms = snapshot.storms + .filter( + (storm) => + !area || + areaContains(area, { + lat: storm.position.latitude, + lon: storm.position.longitude, + }), + ) + .map((storm) => ({ + id: storm.id, + name: storm.name, + classification: storm.classification, + basin: storm.basin, + lat: storm.position.latitude, + lon: storm.position.longitude, + position_at: storm.positionAt, + wind_kt: storm.windKt, + pressure_hpa: storm.pressureHpa, + movement: storm.movement ?? null, + advisory: storm.advisoryNumber, + issued_at: storm.issuedAt, + advisory_url: storm.advisoryUrl, + })) + .sort((a, b) => (b.wind_kt ?? 0) - (a.wind_kt ?? 0)); + const where = area ? ` in ${area.label}` : ''; + return { + summary: + `${countNoun(storms.length, 'active tropical cyclone')}${where}` + + (storms[0] + ? `; strongest ${storms[0].name} at ${storms[0].wind_kt} kt.` + : '.'), + data: { + storms, + coverage: snapshot.coverage ?? null, + source: snapshot.source ?? null, + attribution: snapshot.attribution ?? null, + stale: snapshot.stale === true, + }, + }; + }, +}); + +export const getFirePerimeters = defineTool({ + name: 'get_fire_perimeters', + title: 'Wildfire perimeters', + description: + 'Mapped wildfire perimeters from the US WFIGS service in an area, ' + + 'largest first, with size, containment, cause and personnel.', + inputSchema: { + type: 'object', + properties: { area: AREA_SCHEMA, limit: LIMIT_SCHEMA }, + required: ['area'], + additionalProperties: false, + }, + requires: ['perimeters'], + async run(args, { services, signal }) { + const area = await resolveArea(args.area, { services, signal }); + const rows = (await services.perimeters.getSnapshot({ signal })) + .map((fire) => ({ fire, point: polygonCenter(fire.polygons) })) + .filter(({ point }) => point && areaContains(area, point)) + .map(({ fire, point }) => ({ + id: fire.stableId, + name: fire.name, + acres: round(fire.acres, 0), + contained_pct: fire.containedPct, + state: fire.state, + county: fire.county, + category: fire.category, + cause: fire.cause, + behavior: fire.behavior, + personnel: fire.personnel, + discovered: isoTime(fire.discoveredTime), + updated: isoTime(fire.updatedTime), + lat: round(point.lat, 5), + lon: round(point.lon, 5), + })) + .sort((a, b) => (b.acres ?? 0) - (a.acres ?? 0)); + return { + summary: `${countNoun(rows.length, 'mapped wildfire perimeter')} in ${area.label}.`, + data: capRows(rows, args.limit), + }; + }, +}); + +/** The average vertex of a perimeter's outer rings, enough to place it in an area. */ +function polygonCenter(polygons) { + let lat = 0; + let lon = 0; + let count = 0; + for (const polygon of polygons || []) { + for (const [x, y] of polygon?.[0] || []) { + if (!Number.isFinite(x) || !Number.isFinite(y)) continue; + lon += x; + lat += y; + count += 1; + } + } + return count ? { lat: lat / count, lon: lon / count } : null; +} + +export const getTerrainHeight = defineTool({ + name: 'get_terrain_height', + title: 'Terrain height', + description: + 'Ground elevation above sea level at up to 20 points, with the geoid ' + + 'and ellipsoid heights.', + inputSchema: { + type: 'object', + properties: { + points: { + type: 'array', + minItems: 1, + maxItems: MAX_TERRAIN_POINTS, + items: { + type: 'object', + properties: { + lat: { type: 'number', minimum: -90, maximum: 90 }, + lon: { type: 'number', minimum: -180, maximum: 180 }, + }, + required: ['lat', 'lon'], + additionalProperties: false, + }, + }, + }, + required: ['points'], + additionalProperties: false, + }, + requires: ['terrain'], + async run(args, { services, signal }) { + const results = await services.terrain.getHeights(args.points, { signal }); + if (!Array.isArray(results) || results.length !== args.points.length) + throw new ToolError('malformed', 'Terrain heights were incomplete'); + const rows = args.points.map((point, index) => ({ + lat: point.lat, + lon: point.lon, + elevation_m: round(results[index]?.elevation, 1), + geoid_m: round(results[index]?.geoid, 1), + ellipsoid_m: round(results[index]?.ellipsoid, 1), + })); + const first = rows[0]; + return { + summary: + rows.length === 1 + ? `Ground elevation is ${first.elevation_m ?? 'unknown'} m at ${first.lat}, ${first.lon}.` + : `Ground elevation at ${countNoun(rows.length, 'point')}.`, + data: { points: rows }, + }; + }, +}); + +export const findMilitaryInstallations = defineTool({ + name: 'find_military_installations', + title: 'Military installations', + description: + 'Military installations mapped in OpenStreetMap within an area of at ' + + `most ${MAX_SITE_BOX_DEGREES}° on each side, nearest the center first.`, + inputSchema: { + type: 'object', + properties: { area: AREA_SCHEMA, limit: LIMIT_SCHEMA }, + required: ['area'], + additionalProperties: false, + }, + requires: ['installations'], + async run(args, { services, signal }) { + const area = await resolveArea(args.area, { services, signal }); + if ( + area.west > area.east || + area.north - area.south > MAX_SITE_BOX_DEGREES || + area.east - area.west > MAX_SITE_BOX_DEGREES + ) + throw new ToolError( + 'invalid_arguments', + `The area must be at most ${MAX_SITE_BOX_DEGREES}° on each side and not cross the antimeridian`, + ); + const center = areaCenter(area); + const result = await services.installations.getMappedSites(area, { + signal, + }); + const rows = (result.records || []) + .map((site) => ({ + site, + point: { lat: site.latitude, lon: site.longitude }, + })) + .filter(({ point }) => areaContains(area, point)) + .map(({ site, point }) => ({ + id: site.id, + name: site.name ?? null, + kind: site.kind ?? null, + class: site.class ?? null, + lat: round(point.lat, 5), + lon: round(point.lon, 5), + distance_km: round(distanceKm(center, point), 1), + })) + .sort((a, b) => a.distance_km - b.distance_km); + return { + summary: `${countNoun(rows.length, 'mapped military installation')} in ${area.label}.`, + data: { ...capRows(rows, args.limit), source: result.source ?? null }, + }; + }, +}); + +export const getMapFeatures = defineTool({ + name: 'get_map_features', + title: 'Map features', + description: + 'Named OpenStreetMap features at a location: the administrative areas ' + + 'containing it, named parks, land uses and water nearby, or monuments ' + + 'and memorials within 2.5 km.', + inputSchema: { + type: 'object', + properties: { + location: POINT_SCHEMA, + kind: { type: 'string', enum: Object.keys(FEATURE_KINDS) }, + limit: LIMIT_SCHEMA, + }, + required: ['location', 'kind'], + additionalProperties: false, + }, + requires: ['features'], + async run(args, { services, signal }) { + const point = await resolvePoint(args.location, { services, signal }); + const features = await services.features[FEATURE_KINDS[args.kind]]( + { lat: point.lat, lon: point.lon }, + { signal }, + ); + if (features?.unavailable) + throw new ToolError( + 'unavailable', + 'Map feature lookups need an Overpass server configured for this app', + ); + if (!Array.isArray(features)) + throw new ToolError( + 'retry_later', + 'The map feature service did not answer', + ); + const rows = features + .map((feature) => ({ + id: feature.id, + name: + feature.names?.english || + feature.names?.primary || + feature.names?.official || + null, + category: feature.category, + admin_level: feature.level === 99 ? null : feature.level, + lat: round(feature.point?.lat, 5), + lon: round(feature.point?.lon, 5), + })) + .filter((row) => row.name) + .sort((a, b) => (a.admin_level ?? 99) - (b.admin_level ?? 99)); + const kind = args.kind.replace('_', ' '); + return { + summary: `${countNoun(rows.length, 'named feature')} (${kind}) at ${point.label}.`, + data: { location: point, ...capRows(rows, args.limit) }, + }; + }, +}); diff --git a/src/tools/queries/environment.test.mjs b/src/tools/queries/environment.test.mjs new file mode 100644 index 000000000..4c9337a96 --- /dev/null +++ b/src/tools/queries/environment.test.mjs @@ -0,0 +1,455 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { composeCatalog, coreTools, ToolError } from '../index.js'; + +const conditions = { + observedAt: '2026-10-01T19:15:00.000Z', + temperatureC: 26.3, + apparentTemperatureC: 32, + precipitationMm: 0, + cloudCoverPct: 100, + windKph: 5.4, + windDirectionDeg: 318, + visibilityM: 15400, + weatherCode: 3, +}; +const weather = { + calls: [], + async getConditions(latitude, longitude) { + this.calls.push([latitude, longitude]); + return { status: 'ready', weather: conditions }; + }, +}; +const places = { + resolve: async (name) => + name === 'Austin' + ? { + name: 'Austin, Texas', + bounds: { west: -97.94, south: 30.1, east: -97.56, north: 30.52 }, + } + : null, +}; +const catalogWith = (services) => + composeCatalog({ tools: coreTools, services }); + +test('weather describes current conditions at a point or place', async () => { + const catalog = catalogWith({ weather, places }); + const result = await catalog.call('get_weather', { + location: { place: 'Austin' }, + }); + assert.equal( + result.summary, + 'Weather at Austin, Texas: overcast, 26°C, wind 5 km/h.', + ); + assert.deepEqual(result.data.weather, { + observed_at: '2026-10-01T19:15:00.000Z', + conditions: 'overcast', + weather_code: 3, + temperature_c: 26.3, + feels_like_c: 32, + precipitation_mm: 0, + cloud_cover_pct: 100, + wind_kph: 5.4, + wind_direction_deg: 318, + visibility_m: 15400, + }); + const [latitude, longitude] = weather.calls.at(-1); + assert.ok(Math.abs(latitude - 30.31) < 1e-9 && longitude === -97.75); +}); + +test('the regional brief returns place, weather and at most ten headlines', async () => { + const regional = { + async getBrief() { + return { + place: { + label: 'Edwards Plateau', + locality: null, + region: 'Edwards Plateau', + country: null, + kind: 'natural', + }, + weather: conditions, + newsSource: 'Google News RSS', + articles: Array.from({ length: 12 }, (_, index) => ({ + title: `Story ${index}`, + url: `https://news.example/${index}`, + domain: 'news.example', + publishedAt: '2026-10-01T00:00:00Z', + })), + }; + }, + }; + const result = await catalogWith({ regional }).call('get_regional_brief', { + location: { lat: 30.27, lon: -97.74 }, + }); + assert.equal( + result.summary, + 'Edwards Plateau: overcast, 26°C, wind 5 km/h; 10 recent headlines.', + ); + assert.equal(result.data.articles.length, 10); + assert.deepEqual(result.data.articles[0], { + title: 'Story 0', + url: 'https://news.example/0', + source: 'news.example', + published_at: '2026-10-01T00:00:00Z', + }); +}); + +const storm = (id, name, latitude, longitude, windKt) => ({ + id, + name, + classification: 'Hurricane', + basin: 'Atlantic', + position: { latitude, longitude }, + positionAt: '2026-10-01T18:00:00.000Z', + advisoryNumber: '12', + issuedAt: '2026-10-01T15:00:00.000Z', + windKt, + pressureHpa: 980, + movement: { towardDeg: 300, speedKt: 10 }, + advisoryUrl: 'https://www.nhc.noaa.gov/text/X.shtml', +}); + +test('cyclones are listed strongest first and can be limited to an area', async () => { + const cyclones = { + async getSnapshot() { + return { + source: 'NOAA NHC / CPHC', + coverage: 'Atlantic and eastern/central North Pacific', + stale: false, + unavailable: false, + storms: [ + storm('a', 'Alpha', 25, -70, 65), + storm('b', 'Beta', 15, -110, 120), + ], + }; + }, + }; + const all = await catalogWith({ cyclones }).call('get_cyclones', {}); + assert.equal( + all.summary, + '2 active tropical cyclones; strongest Beta at 120 kt.', + ); + assert.deepEqual( + all.data.storms.map((row) => row.id), + ['b', 'a'], + ); + const atlantic = await catalogWith({ cyclones }).call('get_cyclones', { + area: { bbox: [-100, 0, -10, 50] }, + }); + assert.equal( + atlantic.summary, + '1 active tropical cyclone in the requested box; strongest Alpha at 65 kt.', + ); + const down = { getSnapshot: async () => ({ unavailable: true, storms: [] }) }; + await assert.rejects( + catalogWith({ cyclones: down }).call('get_cyclones', {}), + (error) => error.code === 'unavailable', + ); +}); + +test('fire perimeters are placed by their outline and sorted by size', async () => { + const ring = (lon, lat) => [ + [ + [lon, lat], + [lon + 0.1, lat], + [lon + 0.1, lat + 0.1], + [lon, lat + 0.1], + ], + ]; + const perimeters = { + async getSnapshot() { + return [ + { + stableId: 'small', + name: 'Small Fire', + acres: 120.4, + containedPct: 80, + state: 'US-CA', + county: 'Kern', + category: 'WF', + cause: 'Human', + behavior: null, + personnel: 20, + discoveredTime: Date.UTC(2026, 8, 1), + updatedTime: null, + polygons: [ring(-118.5, 35.2)], + }, + { + stableId: 'big', + name: 'Big Fire', + acres: 5000, + containedPct: 10, + polygons: [ring(-118.8, 35.4)], + }, + { + stableId: 'far', + name: 'Far Fire', + acres: 9000, + polygons: [ring(-80, 30)], + }, + { stableId: 'empty', name: 'No Outline', acres: 1, polygons: [] }, + ]; + }, + }; + const result = await catalogWith({ perimeters }).call('get_fire_perimeters', { + area: { bbox: [-120, 34, -117, 36] }, + }); + assert.equal( + result.summary, + '2 mapped wildfire perimeters in the requested box.', + ); + assert.deepEqual( + result.data.rows.map((row) => row.id), + ['big', 'small'], + ); + assert.deepEqual(result.data.rows[1], { + id: 'small', + name: 'Small Fire', + acres: 120, + contained_pct: 80, + state: 'US-CA', + county: 'Kern', + category: 'WF', + cause: 'Human', + behavior: null, + personnel: 20, + discovered: '2026-09-01T00:00:00.000Z', + updated: null, + lat: 35.25, + lon: -118.45, + }); +}); + +test('terrain heights are returned in request order', async () => { + const terrain = { + async getHeights(points) { + return points.map((point) => ({ + ...point, + elevation: point.lat * 100, + geoid: -30.04, + ellipsoid: point.lat * 100 - 30.04, + })); + }, + }; + const one = await catalogWith({ terrain }).call('get_terrain_height', { + points: [{ lat: 27.9881, lon: 86.925 }], + }); + assert.equal(one.summary, 'Ground elevation is 2798.8 m at 27.9881, 86.925.'); + const many = await catalogWith({ terrain }).call('get_terrain_height', { + points: [ + { lat: 1, lon: 1 }, + { lat: 2, lon: 2 }, + ], + }); + assert.deepEqual( + many.data.points.map((point) => point.elevation_m), + [100, 200], + ); + const short = { getHeights: async () => [] }; + await assert.rejects( + catalogWith({ terrain: short }).call('get_terrain_height', { + points: [{ lat: 1, lon: 1 }], + }), + (error) => error.code === 'malformed', + ); + await assert.rejects( + catalogWith({ terrain }).call('get_terrain_height', { + points: Array(21).fill({ lat: 0, lon: 0 }), + }), + /at most 20 items/, + ); +}); + +test('military installations need a bounded area and are nearest first', async () => { + const requested = []; + const installations = { + async getMappedSites(box) { + requested.push(box); + return { + source: 'OpenStreetMap', + records: [ + { + id: 'osm:1', + kind: 'installation', + class: 'base', + name: 'Naval Base Point Loma', + latitude: 32.6941, + longitude: -117.2494, + }, + { + id: 'osm:2', + kind: 'installation', + class: 'airfield', + name: 'North Island', + latitude: 32.7, + longitude: -117.2, + }, + ], + }; + }, + }; + const catalog = catalogWith({ installations }); + const result = await catalog.call('find_military_installations', { + area: { lat: 32.7, lon: -117.2, radius_km: 20 }, + }); + assert.equal( + result.summary, + '2 mapped military installations in 20 km around 32.700, -117.200.', + ); + assert.deepEqual( + result.data.rows.map((row) => row.id), + ['osm:2', 'osm:1'], + ); + assert.equal(result.data.source, 'OpenStreetMap'); + assert.ok(requested[0].north - requested[0].south < 1); + await assert.rejects( + catalog.call('find_military_installations', { + area: { bbox: [-130, 20, -100, 50] }, + }), + /at most 10° on each side/, + ); +}); + +test('map features name what is at a location and report an unconfigured server', async () => { + const features = { + async getAdministrativeAreas() { + return [ + { + id: 1, + category: 'administrative', + names: { primary: 'Texas' }, + level: 4, + point: null, + }, + { + id: 2, + category: 'administrative', + names: { primary: 'Travis County', english: 'Travis County' }, + level: 6, + point: null, + }, + { id: 3, category: 'administrative', names: {}, level: 8, point: null }, + ]; + }, + getEnclosingAreas: async () => null, + getMonuments: async () => ({ + unavailable: true, + code: 'OVERPASS_NOT_CONFIGURED', + }), + }; + const catalog = catalogWith({ features }); + const result = await catalog.call('get_map_features', { + location: { lat: 30.27, lon: -97.74 }, + kind: 'administrative_areas', + }); + assert.equal( + result.summary, + '2 named features (administrative areas) at 30.2700, -97.7400.', + ); + assert.deepEqual( + result.data.rows.map((row) => [row.name, row.admin_level]), + [ + ['Texas', 4], + ['Travis County', 6], + ], + ); + await assert.rejects( + catalog.call('get_map_features', { + location: { lat: 30.27, lon: -97.74 }, + kind: 'monuments', + }), + (error) => error.code === 'unavailable' && /Overpass/.test(error.message), + ); + await assert.rejects( + catalog.call('get_map_features', { + location: { lat: 30.27, lon: -97.74 }, + kind: 'named_places', + }), + (error) => error.code === 'retry_later', + ); +}); + +test('the situation brief combines available sections and marks failures', async () => { + const services = { + weather, + earthquakes: { + getSnapshot: async () => [ + { + stableId: 'q', + usgsId: 'us1', + lat: 30.3, + lon: -97.7, + mag: 3.1, + place: 'near Austin', + time: 0, + depthKm: 5, + }, + ], + }, + fires: { getSnapshot: async () => ({ keyRequired: true }) }, + cyclones: { + getSnapshot: async () => { + throw new Error('upstream down'); + }, + }, + places, + }; + const result = await catalogWith(services).call('situation_brief', { + area: { place: 'Austin' }, + }); + assert.equal( + result.summary, + 'Situation in Austin, Texas: Weather at 30.3100, -97.7500: overcast, 26°C, wind 5 km/h. ' + + '1 earthquake of M2.5+ in the last 24 hours in the requested box; strongest M3.1 near Austin.', + ); + assert.deepEqual(Object.keys(result.data.sections), [ + 'weather', + 'earthquakes', + 'fires', + 'cyclones', + ]); + assert.deepEqual(result.data.sections.fires, { + unavailable: true, + reason: 'Fire detections need a NASA FIRMS key configured for this server', + }); + assert.deepEqual(result.data.sections.cyclones, { + unavailable: true, + reason: 'unavailable right now', + }); + assert.equal(result.data.sections.aircraft, undefined); +}); + +test('the HUD caption summarizes the brief through the summary service', async () => { + const sent = []; + const summary = { + async summarize(context) { + sent.push(context); + return { + ok: true, + status: 200, + data: { summary: 'Overcast skies over Austin', error: null }, + }; + }, + }; + const result = await catalogWith({ weather, summary, places }).call( + 'get_hud_caption', + { area: { place: 'Austin' } }, + ); + assert.equal(result.summary, 'Overcast skies over Austin'); + assert.equal(sent[0].location, 'Austin, Texas'); + assert.match(sent[0].observations[0], /^Weather at /); + const failing = { + summarize: async () => ({ + ok: false, + status: 502, + data: { summary: null }, + }), + }; + await assert.rejects( + catalogWith({ weather, summary: failing, places }).call('get_hud_caption', { + area: { place: 'Austin' }, + }), + (error) => error instanceof ToolError && error.code === 'unavailable', + ); +});