diff --git a/CHANGELOG.md b/CHANGELOG.md index e8915b417..590d19872 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,8 +1,8 @@ # Changelog - Add tools for language-model clients and a local MCP server. `npm run mcp` - serves earthquake, active-fire and recent-launch queries over stdio to - clients such as Claude Code, reading from a running app. Tools are defined + serves earthquake, active-fire, launch, aircraft and satellite queries 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 bd68e4879..706298590 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -6,8 +6,9 @@ and `gods-eye-view/tools/mcp` exposes a composed catalog over the Model Context Protocol. `npm run mcp` serves Core's tools over stdio to a local MCP client, reading from a running app's `/api` routes (default `http://localhost:4173`). -The first queries are `get_earthquakes`, `get_active_fires` and -`get_recent_launches`. Tools reuse the layers' portable source factories, take a +Queries cover earthquakes, active fires, recent launches, aircraft (in an +area, by identifier, tracks, and type and route lookups) and satellites (next +pass over a point, and those overhead now). 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 f955f32ba..747d19d2e 100644 --- a/docs/TOOLS.md +++ b/docs/TOOLS.md @@ -87,3 +87,9 @@ public feeds the sources already use. | `get_earthquakes` | `earthquakes` | USGS M2.5+ events in the last 24 hours, strongest first | | `get_active_fires` | `fires` | NASA FIRMS detections in an area, highest radiative power first | | `get_recent_launches` | `launches` | Launch Library 2 launches in the last 30 days, newest first | +| `aircraft_in_area` | `aircraft` | Aircraft in an area, nearest first; `military: true` reads the `military` feed | +| `find_aircraft` | `aircraft` | Aircraft anywhere by callsign, ICAO address or registration | +| `get_aircraft_track` | `aircraft` | Recent positions of one aircraft, thinned to 200 points | +| `get_aircraft_info` | `aircraft` | Aircraft type and registration, and flight route, from adsbdb | +| `next_satellite_pass` | `satellites` | Next pass over a point (default the ISS), with naked-eye visibility | +| `satellites_overhead` | `satellites` | Satellites in a CelesTrak group above a point now, highest first | diff --git a/scripts/format-scope.json b/scripts/format-scope.json index b2358d5ca..9d9e71c17 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -869,5 +869,7 @@ "src/tools/area.test.mjs", "src/tools/queries/queries.test.mjs", "src/tools/mcp/mcp.test.mjs", - "src/tools/localServer.test.mjs" + "src/tools/localServer.test.mjs", + "src/tools/queries/aviation.test.mjs", + "src/tools/queries/space.test.mjs" ] diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index 2bb2e1121..503fb0255 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -3086,9 +3086,12 @@ "src/tools/results.js", "src/tools/places.js", "src/tools/queries/hazards.js", - "src/tools/queries/space.js" + "src/tools/queries/space.js", + "src/tools/queries/aviation.js", + "src/sources/tle.js", + "src/data/satellitePass.js" ], - "external": [] + "external": ["satellite.js"] }, "tools-mcp": { "exports": ["./tools/mcp"], diff --git a/server/mcp/services.js b/server/mcp/services.js index 07a492692..b828ef1ef 100644 --- a/server/mcp/services.js +++ b/server/mcp/services.js @@ -6,6 +6,11 @@ import { createUsgsEarthquakeSource } from '../../src/layers/earthquakes/source.js'; import { createFirmsSource } from '../../src/layers/firms/source.js'; import { createLaunchSource } from '../../src/layers/launches/source.js'; +import { createSatelliteSource } from '../../src/layers/satellites/source.js'; +import { + createAdsbLolSource, + createOpenSkySource, +} from '../../src/sources/live/standalone.js'; import { createGeocodePlaceService } from '../../src/tools/places.js'; export const DEFAULT_API_BASE = 'http://localhost:4173'; @@ -34,6 +39,9 @@ export function createLocalToolServices(options = {}) { earthquakes: createUsgsEarthquakeSource({ fetchImpl }), fires: createFirmsSource({ fetchImpl }), launches: createLaunchSource({ fetchImpl }), + aircraft: createOpenSkySource({ fetchImpl }), + military: createAdsbLolSource({ fetchImpl }), + satellites: createSatelliteSource({ fetchImpl }), places: createGeocodePlaceService({ fetchImpl }), }; } diff --git a/src/tools/area.js b/src/tools/area.js index 35b27315f..4f0f5c55a 100644 --- a/src/tools/area.js +++ b/src/tools/area.js @@ -104,6 +104,20 @@ export function distanceKm(a, b) { return 2 * EARTH_RADIUS_KM * Math.asin(Math.min(1, Math.sqrt(h))); } +/** The center of a resolved area, handling boxes across the antimeridian. */ +export function areaCenter(area) { + if (area.center) return { lat: area.center.lat, lon: area.center.lon }; + const width = + area.west <= area.east + ? area.east - area.west + : area.east + 360 - area.west; + const lon = area.west + width / 2; + return { + lat: (area.south + area.north) / 2, + lon: lon > 180 ? lon - 360 : lon, + }; +} + /** Whether a point lies inside a resolved area, including radius areas. */ export function areaContains(area, point) { if (!Number.isFinite(point?.lat) || !Number.isFinite(point?.lon)) diff --git a/src/tools/catalog.js b/src/tools/catalog.js index a6e3db530..e289665b6 100644 --- a/src/tools/catalog.js +++ b/src/tools/catalog.js @@ -33,6 +33,30 @@ export class ToolError extends Error { } } +// Live sources report failures with these codes (src/sources/live/contract.js). +const SOURCE_ERROR_CODES = { + limited: 'retry_later', + denied: 'unavailable', + unavailable: 'unavailable', + malformed: 'malformed', + unsupported: 'unsupported', +}; + +/** Translate a source failure into a tool error; other errors pass through. */ +export function fromSourceError(error) { + const code = + error?.name === 'LiveSourceError' && + Object.hasOwn(SOURCE_ERROR_CODES, error.code) + ? SOURCE_ERROR_CODES[error.code] + : null; + if (!code) return error; + const retryAfterSeconds = + code === 'retry_later' && Number.isFinite(error.retryAfterMs) + ? Math.ceil(error.retryAfterMs / 1000) + : null; + return new ToolError(code, error.message, { retryAfterSeconds }); +} + /** * Validate and freeze a tool definition. * @@ -114,7 +138,12 @@ export function composeCatalog({ const problems = validateValue(tool.inputSchema, args); if (problems.length) throw new ToolError('invalid_arguments', problems.join('; ')); - const result = await tool.run(args, { services, signal }); + let result; + try { + result = await tool.run(args, { services, signal }); + } catch (error) { + throw fromSourceError(error); + } if (!result || typeof result.summary !== 'string' || !result.data) throw new TypeError(`${tool.name} returned no summary or data`); return result; diff --git a/src/tools/catalog.test.mjs b/src/tools/catalog.test.mjs index 997bf002f..4e2a5d105 100644 --- a/src/tools/catalog.test.mjs +++ b/src/tools/catalog.test.mjs @@ -31,10 +31,10 @@ test('definitions are validated, frozen and read-only by default', () => { echo('x', { inputSchema: { type: 'object', - properties: { a: { type: 'string', pattern: '.' } }, + properties: { a: { type: 'string', format: 'email' } }, }, }), - /unsupported keyword pattern/, + /unsupported keyword format/, ); assert.throws( () => echo('x', { inputSchema: { type: 'string' } }), @@ -129,6 +129,45 @@ test('tool errors only use known codes', () => { ); }); +test('only live source errors are translated', async () => { + const { fromSourceError } = await import('./catalog.js'); + const plain = new Error('x'); + assert.equal(fromSourceError(plain), plain); + const denied = Object.assign(new Error('refused'), { + name: 'LiveSourceError', + code: 'denied', + }); + const translated = fromSourceError(denied); + assert.equal(translated.code, 'unavailable'); + assert.equal(translated.retryAfterSeconds, null); + const odd = Object.assign(new Error('?'), { + name: 'LiveSourceError', + code: 'toString', + }); + assert.equal(fromSourceError(odd), odd); +}); + +test('patterns are validated and must compile', () => { + const schema = { + type: 'object', + properties: { id: { type: 'string', pattern: '^[a-f]{2}$' } }, + }; + assert.deepEqual(validateValue(schema, { id: 'ab' }), []); + assert.deepEqual(validateValue(schema, { id: 'xy' }), [ + 'arguments.id must match ^[a-f]{2}$', + ]); + assert.throws( + () => + echo('x', { + inputSchema: { + type: 'object', + properties: { id: { type: 'string', pattern: '(' } }, + }, + }), + SyntaxError, + ); +}); + test('the validator checks every supported keyword', () => { const schema = { type: 'object', diff --git a/src/tools/index.js b/src/tools/index.js index c8502261b..84d2aaa1e 100644 --- a/src/tools/index.js +++ b/src/tools/index.js @@ -3,8 +3,18 @@ * surface that exposes them. See docs/TOOLS.md. */ +import { + aircraftInArea, + findAircraft, + getAircraftInfo, + getAircraftTrack, +} from './queries/aviation.js'; import { getActiveFires, getEarthquakes } from './queries/hazards.js'; -import { getRecentLaunches } from './queries/space.js'; +import { + getRecentLaunches, + nextSatellitePass, + satellitesOverhead, +} from './queries/space.js'; export { defineTool, @@ -12,7 +22,13 @@ export { ToolError, TOOL_ERROR_CODES, } from './catalog.js'; -export { AREA_SCHEMA, resolveArea, areaContains, distanceKm } from './area.js'; +export { + AREA_SCHEMA, + resolveArea, + areaCenter, + areaContains, + distanceKm, +} from './area.js'; export { LIMIT_SCHEMA, DEFAULT_LIMIT, MAX_LIMIT, capRows } from './results.js'; export { createGeocodePlaceService, placeFromGeocodeResult } from './places.js'; @@ -21,4 +37,10 @@ export const coreTools = Object.freeze([ getEarthquakes, getActiveFires, getRecentLaunches, + aircraftInArea, + findAircraft, + getAircraftTrack, + getAircraftInfo, + nextSatellitePass, + satellitesOverhead, ]); diff --git a/src/tools/localServer.test.mjs b/src/tools/localServer.test.mjs index 7b193aac4..42425ea39 100644 --- a/src/tools/localServer.test.mjs +++ b/src/tools/localServer.test.mjs @@ -68,7 +68,17 @@ test('the stdio server answers newline-delimited requests using only its data so assert.equal(byId.get(null).error.code, -32700); assert.deepEqual( byId.get(2).result.tools.map((tool) => tool.name), - ['get_earthquakes', 'get_active_fires', 'get_recent_launches'], + [ + 'get_earthquakes', + 'get_active_fires', + 'get_recent_launches', + 'aircraft_in_area', + 'find_aircraft', + 'get_aircraft_track', + 'get_aircraft_info', + 'next_satellite_pass', + 'satellites_overhead', + ], ); assert.equal( byId.get(3).result.content[0].text, diff --git a/src/tools/queries/aviation.js b/src/tools/queries/aviation.js new file mode 100644 index 000000000..34f3d26de --- /dev/null +++ b/src/tools/queries/aviation.js @@ -0,0 +1,306 @@ +/** Aviation queries over the live aircraft sources (src/sources/live). */ + +import { defineTool, ToolError } from '../catalog.js'; +import { + AREA_SCHEMA, + areaCenter, + areaContains, + distanceKm, + resolveArea, +} from '../area.js'; +import { LIMIT_SCHEMA, capRows, countNoun, isoTime } from '../results.js'; + +const ICAO24_SCHEMA = Object.freeze({ + type: 'string', + pattern: '^[0-9a-fA-F]{6}$', + description: 'ICAO 24-bit address as six hex characters, such as "a1b2c3".', +}); +const CALLSIGN_SCHEMA = Object.freeze({ + type: 'string', + pattern: '^[A-Za-z0-9]{2,8}$', + description: 'Flight callsign, such as "UAL123".', +}); +const MILITARY_SCHEMA = Object.freeze({ + type: 'boolean', + description: 'Use the military aircraft feed instead of the general feed.', +}); +const MAX_TRACK_POINTS = 200; + +const round = (value, digits) => + Number.isFinite(value) ? Number(value.toFixed(digits)) : null; + +/** The common row for an aircraft record from any live aircraft source. */ +function aircraftRow(record, from) { + const point = { lat: record.latitude, lon: record.longitude }; + return { + id: record.id, + callsign: record.callsign || null, + lat: round(record.latitude, 5), + lon: round(record.longitude, 5), + altitude_m: round(record.baroAltitudeM ?? record.ellipsoidAltitudeM, 0), + on_ground: record.onGround === true, + speed_mps: round(record.speedMps, 1), + course_deg: round(record.courseDeg, 0), + vertical_rate_mps: round(record.verticalRateMps, 1), + type_code: record.typeCode || null, + registration: record.registration || null, + operator: record.operator || null, + origin_country: record.originCountry || null, + last_contact: isoTime(record.contactTimeMs ?? record.positionTimeMs), + ...(from ? { distance_km: round(distanceKm(from, point), 1) } : {}), + }; +} + +function snapshotInfo(snapshot) { + return { + source: snapshot.source ?? null, + coverage: snapshot.coverage ?? null, + observed_at: isoTime(snapshot.observedAtMs), + freshness: snapshot.freshness ?? 'unknown', + }; +} + +function feedFor(services, military) { + if (!military) return services.aircraft; + if (!services.military) + throw new ToolError( + 'unsupported', + 'The military aircraft feed is not available here', + ); + return services.military; +} + +export const aircraftInArea = defineTool({ + name: 'aircraft_in_area', + title: 'Aircraft in an area', + description: + 'Aircraft currently reported inside an area, nearest the center first. ' + + 'Optionally military aircraft only, airborne only, or within an altitude band.', + inputSchema: { + type: 'object', + properties: { + area: AREA_SCHEMA, + military: MILITARY_SCHEMA, + airborne_only: { type: 'boolean' }, + min_altitude_m: { type: 'number', minimum: -500, maximum: 30000 }, + max_altitude_m: { type: 'number', minimum: -500, maximum: 30000 }, + limit: LIMIT_SCHEMA, + }, + required: ['area'], + additionalProperties: false, + }, + requires: ['aircraft'], + async run(args, { services, signal }) { + const area = await resolveArea(args.area, { services, signal }); + const center = areaCenter(area); + const snapshot = await feedFor(services, args.military).getSnapshot( + { latitude: center.lat, longitude: center.lon }, + { signal }, + ); + const altitude = (record) => + record.baroAltitudeM ?? record.ellipsoidAltitudeM; + const rows = snapshot.records + .filter( + (record) => + areaContains(area, { lat: record.latitude, lon: record.longitude }) && + !(args.airborne_only && record.onGround) && + (args.min_altitude_m == null || + altitude(record) >= args.min_altitude_m) && + (args.max_altitude_m == null || + altitude(record) <= args.max_altitude_m), + ) + .map((record) => aircraftRow(record, center)) + .sort((a, b) => a.distance_km - b.distance_km); + const kind = args.military ? 'military aircraft' : 'aircraft'; + return { + summary: + `${rows.length} ${kind} in ${area.label}` + + (snapshot.freshness === 'stale' ? ' (data may be stale).' : '.'), + data: { ...capRows(rows, args.limit), ...snapshotInfo(snapshot) }, + }; + }, +}); + +export const findAircraft = defineTool({ + name: 'find_aircraft', + title: 'Find an aircraft', + description: + 'Find aircraft currently reported anywhere by callsign, ICAO 24-bit ' + + 'address or registration. Registrations are known only for aircraft in ' + + 'the military feed.', + inputSchema: { + type: 'object', + properties: { + callsign: CALLSIGN_SCHEMA, + icao24: ICAO24_SCHEMA, + registration: { type: 'string', minLength: 2, maxLength: 12 }, + limit: LIMIT_SCHEMA, + }, + additionalProperties: false, + }, + requires: ['aircraft'], + async run(args, { services, signal }) { + const given = ['callsign', 'icao24', 'registration'].filter( + (key) => args[key] != null, + ); + if (given.length !== 1) + throw new ToolError( + 'invalid_arguments', + 'Give exactly one of callsign, icao24 or registration', + ); + const [key] = given; + const wanted = args[key].trim().toUpperCase(); + const field = { + callsign: 'callsign', + icao24: 'id', + registration: 'registration', + }[key]; + const feeds = [services.aircraft, services.military].filter(Boolean); + const snapshots = await Promise.all( + feeds.map((feed) => feed.getSnapshot({}, { signal })), + ); + const seen = new Set(); + const rows = snapshots + .flatMap((snapshot) => snapshot.records) + .filter( + (record) => + String(record[field] || '') + .trim() + .toUpperCase() === wanted, + ) + .filter((record) => !seen.has(record.id) && seen.add(record.id)) + .map((record) => aircraftRow(record)); + return { + summary: rows.length + ? `Found ${countNoun(rows.length, 'aircraft', 'aircraft')} with ${key} ${wanted}.` + : `No aircraft with ${key} ${wanted} is currently reported.`, + data: capRows(rows, args.limit), + }; + }, +}); + +export const getAircraftTrack = defineTool({ + name: 'get_aircraft_track', + title: 'Aircraft track', + description: + 'Recent positions of one aircraft by ICAO 24-bit address, oldest first. ' + + `Long tracks are evenly thinned to at most ${MAX_TRACK_POINTS} points.`, + inputSchema: { + type: 'object', + properties: { icao24: ICAO24_SCHEMA, military: MILITARY_SCHEMA }, + required: ['icao24'], + additionalProperties: false, + }, + requires: ['aircraft'], + async run(args, { services, signal }) { + const icao24 = args.icao24.toLowerCase(); + const track = await feedFor(services, args.military).getTrack(icao24, { + signal, + }); + const points = [...track.records].sort( + (a, b) => a.observedAtMs - b.observedAtMs, + ); + const kept = thin(points, MAX_TRACK_POINTS); + const first = points[0]; + const last = points.at(-1); + return { + summary: points.length + ? `${countNoun(points.length, 'position')} for ${icao24} from ${isoTime(first.observedAtMs)} to ${isoTime(last.observedAtMs)}.` + : `No recent track is available for ${icao24}.`, + data: { + icao24, + total: points.length, + returned: kept.length, + thinned: kept.length < points.length, + points: kept.map((point) => ({ + time: isoTime(point.observedAtMs), + lat: round(point.latitude, 5), + lon: round(point.longitude, 5), + altitude_m: round(point.baroAltitudeM, 0), + on_ground: point.onGround === true, + })), + }, + }; + }, +}); + +/** Keep the first and last items and evenly spaced items between them. */ +function thin(items, max) { + if (items.length <= max) return items; + const step = (items.length - 1) / (max - 1); + return Array.from( + { length: max }, + (_, index) => items[Math.round(index * step)], + ); +} + +export const getAircraftInfo = defineTool({ + name: 'get_aircraft_info', + title: 'Aircraft type and route', + description: + 'Look up an aircraft type and registration by ICAO 24-bit address, and ' + + 'a flight route by callsign, from adsbdb. Give either or both.', + inputSchema: { + type: 'object', + properties: { icao24: ICAO24_SCHEMA, callsign: CALLSIGN_SCHEMA }, + additionalProperties: false, + }, + requires: ['aircraft'], + async run(args, { services, signal }) { + if (args.icao24 == null && args.callsign == null) + throw new ToolError('invalid_arguments', 'Give icao24, callsign or both'); + const lookup = (kind, id) => + id == null + ? null + : services.aircraft.getEnrichment({ kind, id }, { signal }); + const icao24 = args.icao24?.toLowerCase(); + const callsign = args.callsign?.toUpperCase(); + const [type, route] = await Promise.all([ + lookup('type', icao24), + lookup('route', callsign), + ]); + const aircraft = type?.found + ? { + icao24, + type_code: type.typeCode ?? null, + type_name: type.typeName ?? null, + registration: type.registration ?? null, + } + : null; + const airport = (value) => + value + ? { + code: value.code || null, + name: value.name || null, + lat: value.lat ?? null, + lon: value.lon ?? null, + } + : null; + const flight = route?.found + ? { + callsign, + airline: route.airline ?? null, + origin: airport(route.origin), + destination: airport(route.destination), + } + : null; + const parts = []; + if (icao24) + parts.push( + aircraft + ? `${icao24} is ${aircraft.type_name || aircraft.type_code || 'an aircraft of unknown type'}${aircraft.registration ? ` (${aircraft.registration})` : ''}` + : `no type is known for ${icao24}`, + ); + if (callsign) + parts.push( + flight + ? `${callsign} flies ${flight.origin?.code || flight.origin?.name} to ${flight.destination?.code || flight.destination?.name}${flight.airline ? ` (${flight.airline})` : ''}` + : `no route is known for ${callsign}`, + ); + const summary = parts.join('; '); + return { + summary: `${summary.charAt(0).toUpperCase()}${summary.slice(1)}.`, + data: { aircraft, route: flight }, + }; + }, +}); diff --git a/src/tools/queries/aviation.test.mjs b/src/tools/queries/aviation.test.mjs new file mode 100644 index 000000000..671226e0d --- /dev/null +++ b/src/tools/queries/aviation.test.mjs @@ -0,0 +1,276 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { composeCatalog, coreTools } from '../index.js'; +import { LiveSourceError } from '../../sources/live/contract.js'; + +const record = (id, latitude, longitude, extra = {}) => ({ + id, + reference: id, + latitude, + longitude, + callsign: null, + originCountry: null, + positionTimeMs: Date.UTC(2026, 0, 1), + contactTimeMs: Date.UTC(2026, 0, 1, 0, 0, 5), + baroAltitudeM: 10000, + ellipsoidAltitudeM: null, + onGround: false, + speedMps: 230.04, + courseDeg: 90.4, + verticalRateMps: 0, + category: null, + typeCode: null, + registration: null, + operator: null, + ...extra, +}); +const snapshot = (records, extra = {}) => ({ + records, + complete: true, + source: 'Test feed', + coverage: 'test coverage', + observedAtMs: Date.UTC(2026, 0, 1), + freshness: 'current', + ...extra, +}); +const feed = (records, { track = [], enrichment = {}, ...extra } = {}) => { + const calls = []; + return { + calls, + async getSnapshot(query) { + calls.push(query); + return snapshot(records, extra); + }, + async getTrack(reference) { + calls.push(reference); + return { records: track, complete: false }; + }, + async getEnrichment({ kind, id }) { + calls.push(`${kind}:${id}`); + return enrichment[`${kind}:${id}`] ?? { found: false }; + }, + }; +}; +const civil = [ + record('aaa111', 37.7, -122.4, { + callsign: 'UAL123', + originCountry: 'United States', + }), + record('bbb222', 37.58, -122.28, { onGround: true, baroAltitudeM: 0 }), + record('ccc333', 51.5, -0.1, { callsign: 'BAW1' }), +]; +const military = [ + record('ae0001', 37.6, -122.3, { + callsign: 'RCH123', + registration: '07-7175', + typeCode: 'C17', + }), +]; +const bay = { lat: 37.6, lon: -122.3, radius_km: 60 }; + +test('aircraft in an area are filtered, nearest first, with source details', async () => { + const aircraft = feed(civil); + const catalog = composeCatalog({ tools: coreTools, services: { aircraft } }); + const result = await catalog.call('aircraft_in_area', { area: bay }); + assert.equal(result.summary, '2 aircraft in 60 km around 37.600, -122.300.'); + assert.deepEqual( + result.data.rows.map((row) => row.id), + ['bbb222', 'aaa111'], + ); + assert.deepEqual(aircraft.calls[0], { latitude: 37.6, longitude: -122.3 }); + assert.deepEqual(result.data.rows[1], { + id: 'aaa111', + callsign: 'UAL123', + lat: 37.7, + lon: -122.4, + altitude_m: 10000, + on_ground: false, + speed_mps: 230, + course_deg: 90, + vertical_rate_mps: 0, + type_code: null, + registration: null, + operator: null, + origin_country: 'United States', + last_contact: '2026-01-01T00:00:05.000Z', + distance_km: 14.2, + }); + assert.equal(result.data.source, 'Test feed'); + assert.equal(result.data.observed_at, '2026-01-01T00:00:00.000Z'); + + const airborne = await catalog.call('aircraft_in_area', { + area: bay, + airborne_only: true, + }); + assert.deepEqual( + airborne.data.rows.map((row) => row.id), + ['aaa111'], + ); + const low = await catalog.call('aircraft_in_area', { + area: bay, + max_altitude_m: 100, + }); + assert.deepEqual( + low.data.rows.map((row) => row.id), + ['bbb222'], + ); +}); + +test('the military feed is used on request and reported when missing', async () => { + const catalog = composeCatalog({ + tools: coreTools, + services: { + aircraft: feed(civil), + military: feed(military, { freshness: 'stale' }), + }, + }); + const result = await catalog.call('aircraft_in_area', { + area: bay, + military: true, + }); + assert.equal( + result.summary, + '1 military aircraft in 60 km around 37.600, -122.300 (data may be stale).', + ); + assert.equal(result.data.rows[0].registration, '07-7175'); + const civilOnly = composeCatalog({ + tools: coreTools, + services: { aircraft: feed(civil) }, + }); + await assert.rejects( + civilOnly.call('aircraft_in_area', { area: bay, military: true }), + (error) => error.code === 'unsupported', + ); +}); + +test('aircraft are found by exactly one identifier across feeds', async () => { + const catalog = composeCatalog({ + tools: coreTools, + services: { aircraft: feed(civil), military: feed(military) }, + }); + const byCallsign = await catalog.call('find_aircraft', { + callsign: 'ual123', + }); + assert.equal(byCallsign.summary, 'Found 1 aircraft with callsign UAL123.'); + assert.deepEqual( + byCallsign.data.rows.map((row) => row.id), + ['aaa111'], + ); + assert.equal(byCallsign.data.rows[0].distance_km, undefined); + const byRegistration = await catalog.call('find_aircraft', { + registration: '07-7175', + }); + assert.deepEqual( + byRegistration.data.rows.map((row) => row.id), + ['ae0001'], + ); + const none = await catalog.call('find_aircraft', { icao24: 'ABCDEF' }); + assert.equal( + none.summary, + 'No aircraft with icao24 ABCDEF is currently reported.', + ); + await assert.rejects(catalog.call('find_aircraft', {}), /exactly one/); + await assert.rejects( + catalog.call('find_aircraft', { callsign: 'UAL123', icao24: 'aaa111' }), + /exactly one/, + ); + await assert.rejects( + catalog.call('find_aircraft', { icao24: 'xyz' }), + /must match/, + ); +}); + +test('tracks are ordered and thinned to 200 points', async () => { + const start = Date.UTC(2026, 0, 1); + const track = Array.from({ length: 450 }, (_, index) => ({ + latitude: 37 + index / 1000, + longitude: -122, + observedAtMs: start + (449 - index) * 1000, + baroAltitudeM: 1000, + onGround: false, + })); + const aircraft = feed(civil, { track }); + const catalog = composeCatalog({ tools: coreTools, services: { aircraft } }); + const result = await catalog.call('get_aircraft_track', { icao24: 'AAA111' }); + assert.equal(aircraft.calls[0], 'aaa111'); + assert.equal( + result.summary, + '450 positions for aaa111 from 2026-01-01T00:00:00.000Z to 2026-01-01T00:07:29.000Z.', + ); + assert.deepEqual( + { + total: result.data.total, + returned: result.data.returned, + thinned: result.data.thinned, + }, + { total: 450, returned: 200, thinned: true }, + ); + assert.equal(result.data.points[0].time, '2026-01-01T00:00:00.000Z'); + assert.equal(result.data.points.at(-1).time, '2026-01-01T00:07:29.000Z'); + const empty = composeCatalog({ + tools: coreTools, + services: { aircraft: feed(civil) }, + }); + assert.equal( + (await empty.call('get_aircraft_track', { icao24: 'aaa111' })).summary, + 'No recent track is available for aaa111.', + ); +}); + +test('aircraft info combines type and route lookups', async () => { + const aircraft = feed(civil, { + enrichment: { + 'type:aaa111': { + found: true, + typeCode: 'B738', + typeName: 'Boeing 737-800', + registration: 'N12345', + }, + 'route:UAL123': { + found: true, + airline: 'United Airlines', + origin: { code: 'SFO', name: 'San Francisco', lat: 37.6, lon: -122.4 }, + destination: { code: 'EWR', name: 'Newark', lat: 40.7, lon: -74.2 }, + }, + }, + }); + const catalog = composeCatalog({ tools: coreTools, services: { aircraft } }); + const both = await catalog.call('get_aircraft_info', { + icao24: 'AAA111', + callsign: 'ual123', + }); + assert.equal( + both.summary, + 'Aaa111 is Boeing 737-800 (N12345); UAL123 flies SFO to EWR (United Airlines).', + ); + assert.equal(both.data.route.destination.name, 'Newark'); + const unknown = await catalog.call('get_aircraft_info', { callsign: 'XYZ9' }); + assert.equal(unknown.summary, 'No route is known for XYZ9.'); + assert.deepEqual(unknown.data, { aircraft: null, route: null }); + await assert.rejects( + catalog.call('get_aircraft_info', {}), + /icao24, callsign or both/, + ); +}); + +test('live source failures become tool errors with retry guidance', async () => { + const limited = { + getSnapshot: async () => { + throw new LiveSourceError('limited', 'OpenSky rate limited', { + retryAfterMs: 45000, + }); + }, + }; + const catalog = composeCatalog({ + tools: coreTools, + services: { aircraft: limited }, + }); + await assert.rejects( + catalog.call('aircraft_in_area', { area: bay }), + (error) => + error.name === 'ToolError' && + error.code === 'retry_later' && + error.retryAfterSeconds === 45 && + error.message === 'OpenSky rate limited', + ); +}); diff --git a/src/tools/queries/space.js b/src/tools/queries/space.js index 371d8ecbd..298ec104a 100644 --- a/src/tools/queries/space.js +++ b/src/tools/queries/space.js @@ -1,7 +1,13 @@ -/** Space queries: launches. */ +/** Space queries: launches, satellite passes and satellites overhead. */ +import { twoline2satrec } from 'satellite.js'; +import { + findNextSatellitePass, + lookAnglesAt, +} from '../../data/satellitePass.js'; +import { parseTleText, tleCatalogNumber } from '../../sources/tle.js'; import { defineTool, ToolError } from '../catalog.js'; -import { LIMIT_SCHEMA, capRows, countNoun } from '../results.js'; +import { LIMIT_SCHEMA, capRows, countNoun, isoTime } from '../results.js'; const text = (value) => (typeof value === 'string' && value ? value : null); @@ -47,3 +53,215 @@ export const getRecentLaunches = defineTool({ }; }, }); + +// Groups served by the satellite catalog route; `stations` includes the ISS. +const GROUPS = [ + 'stations', + 'visual', + 'gps-ops', + 'glo-ops', + 'galileo', + 'geo', + 'starlink', +]; +const ISS_NORAD = 25544; +const COMPASS = [ + 'N', + 'NNE', + 'NE', + 'ENE', + 'E', + 'ESE', + 'SE', + 'SSE', + 'S', + 'SSW', + 'SW', + 'WSW', + 'W', + 'WNW', + 'NW', + 'NNW', +]; +const OBSERVER = { + lat: { + type: 'number', + minimum: -90, + maximum: 90, + description: 'Observer latitude.', + }, + lon: { + type: 'number', + minimum: -180, + maximum: 180, + description: 'Observer longitude.', + }, +}; +const GROUP_SCHEMA = { + type: 'string', + enum: GROUPS, + description: 'CelesTrak group to search (default "stations").', +}; + +const compass = (degrees) => + COMPASS[Math.round((((degrees % 360) + 360) % 360) / 22.5) % 16]; +const round = (value, digits) => + Number.isFinite(value) ? Number(value.toFixed(digits)) : null; +const nowMs = (services) => services.clock?.now() ?? Date.now(); + +async function readCatalog(services, group, signal) { + const result = await services.satellites.readGroup(group, { signal }); + if (!result.ok) + throw new ToolError( + 'unavailable', + `The ${group} satellite catalog is unavailable (HTTP ${result.status})`, + ); + return parseTleText(result.text).flatMap((entry) => { + try { + return [ + { + ...entry, + norad: tleCatalogNumber(entry.line1), + satrec: twoline2satrec(entry.line1, entry.line2), + }, + ]; + } catch { + return []; + } + }); +} + +function matchSatellite(entries, wanted) { + if (wanted == null) + return entries.find((entry) => entry.norad === ISS_NORAD) ?? null; + const text = String(wanted).trim().toUpperCase(); + const number = /^\d+$/.test(text) ? Number(text) : null; + return ( + entries.find((entry) => number != null && entry.norad === number) ?? + entries.find((entry) => entry.name.toUpperCase() === text) ?? + entries.find((entry) => entry.name.toUpperCase().includes(text)) ?? + null + ); +} + +export const nextSatellitePass = defineTool({ + name: 'next_satellite_pass', + title: 'Next satellite pass', + description: + 'When a satellite next rises over an observer, with rise, peak and set ' + + 'times, peak elevation, rise direction and naked-eye visibility. Defaults ' + + 'to the International Space Station.', + inputSchema: { + type: 'object', + properties: { + ...OBSERVER, + satellite: { + type: 'string', + minLength: 1, + maxLength: 80, + description: + 'Satellite name or NORAD catalog number (default the ISS).', + }, + group: GROUP_SCHEMA, + min_elevation_deg: { type: 'number', minimum: 0, maximum: 80 }, + visible_only: { + type: 'boolean', + description: 'Only passes visible to the naked eye.', + }, + hours: { type: 'number', minimum: 1, maximum: 72 }, + }, + required: ['lat', 'lon'], + additionalProperties: false, + }, + requires: ['satellites'], + async run(args, { services, signal }) { + const group = args.group ?? 'stations'; + const entries = await readCatalog(services, group, signal); + const satellite = matchSatellite(entries, args.satellite); + if (!satellite) + throw new ToolError( + 'invalid_arguments', + `No satellite matching "${args.satellite ?? 'ISS'}" is in the ${group} group`, + ); + const hours = args.hours ?? 24; + const pass = findNextSatellitePass({ + satrec: satellite.satrec, + latDeg: args.lat, + lonDeg: args.lon, + fromMs: nowMs(services), + minElevDeg: args.min_elevation_deg ?? 10, + horizonHours: hours, + requireVisible: args.visible_only === true, + }); + const name = satellite.name; + if (!pass) + return { + summary: `${name} has no ${args.visible_only ? 'visible ' : ''}pass over this location in the next ${hours} hours.`, + data: { satellite: name, norad: satellite.norad, pass: null }, + }; + return { + summary: + `${name} next rises ${isoTime(pass.riseMs)} in the ${compass(pass.riseAzDeg)}, ` + + `peaking at ${Math.round(pass.maxElevDeg)}°` + + `${pass.visible ? '; visible to the naked eye' : ''}.`, + data: { + satellite: name, + norad: satellite.norad, + pass: { + rise: isoTime(pass.riseMs), + peak: isoTime(pass.maxElevMs), + set: isoTime(pass.setMs), + max_elevation_deg: round(pass.maxElevDeg, 1), + rise_azimuth_deg: round(pass.riseAzDeg, 0), + rise_direction: compass(pass.riseAzDeg), + visible: pass.visible, + }, + }, + }; + }, +}); + +export const satellitesOverhead = defineTool({ + name: 'satellites_overhead', + title: 'Satellites overhead', + description: + 'Satellites in a group that are above an observer right now, highest first, ' + + 'with elevation and direction.', + inputSchema: { + type: 'object', + properties: { + ...OBSERVER, + group: GROUP_SCHEMA, + min_elevation_deg: { type: 'number', minimum: 0, maximum: 90 }, + limit: LIMIT_SCHEMA, + }, + required: ['lat', 'lon'], + additionalProperties: false, + }, + requires: ['satellites'], + async run(args, { services, signal }) { + const group = args.group ?? 'stations'; + const minimum = args.min_elevation_deg ?? 10; + const at = nowMs(services); + const rows = (await readCatalog(services, group, signal)) + .flatMap((entry) => { + const look = lookAnglesAt(entry.satrec, at, args.lat, args.lon); + return look && look.elevDeg >= minimum + ? [ + { + name: entry.name, + norad: entry.norad, + elevation_deg: round(look.elevDeg, 1), + azimuth_deg: round(look.azDeg, 0), + direction: compass(look.azDeg), + }, + ] + : []; + }) + .sort((a, b) => b.elevation_deg - a.elevation_deg); + return { + summary: `${countNoun(rows.length, 'satellite')} from the ${group} group ${rows.length === 1 ? 'is' : 'are'} at least ${minimum}° above this location at ${isoTime(at)}.`, + data: { ...capRows(rows, args.limit), group, at: isoTime(at) }, + }; + }, +}); diff --git a/src/tools/queries/space.test.mjs b/src/tools/queries/space.test.mjs new file mode 100644 index 000000000..460da8319 --- /dev/null +++ b/src/tools/queries/space.test.mjs @@ -0,0 +1,154 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { composeCatalog, coreTools } from '../index.js'; +import { parseTleText, tleCatalogNumber } from '../../sources/tle.js'; + +// Element sets published for 1 January 2024. +const TLE = `ISS (ZARYA) +1 25544U 98067A 24001.50000000 .00016717 00000-0 30270-3 0 9994 +2 25544 51.6416 247.4627 0006703 130.5360 325.0288 15.50377579432414 +CSS (TIANHE) +1 48274U 21035A 24001.50000000 .00020137 00000-0 24481-3 0 9993 +2 48274 41.4697 175.8913 0005643 312.8370 141.6893 15.61287398150000 +`; +const CLOCK = { now: () => Date.UTC(2024, 0, 1, 12) }; +const satellites = (text = TLE, ok = true) => ({ + calls: [], + async readGroup(group) { + this.calls.push(group); + return { ok, status: ok ? 200 : 503, text }; + }, +}); + +test('TLE text parses into named entries with catalog numbers', () => { + const entries = parseTleText(`${TLE}\nBROKEN\nnot a line\nnor this\n`); + assert.deepEqual( + entries.map((entry) => entry.name), + ['ISS (ZARYA)', 'CSS (TIANHE)'], + ); + assert.equal(tleCatalogNumber(entries[0].line1), 25544); + assert.equal(tleCatalogNumber('1 xx'), null); +}); + +test('the next pass defaults to the ISS and reports times, peak and direction', async () => { + const catalog = composeCatalog({ + tools: coreTools, + services: { satellites: satellites(), clock: CLOCK }, + }); + const result = await catalog.call('next_satellite_pass', { + lat: 40.7, + lon: -74, + }); + const { pass } = result.data; + assert.equal(result.data.satellite, 'ISS (ZARYA)'); + assert.equal(result.data.norad, 25544); + assert.ok(Date.parse(pass.rise) >= CLOCK.now()); + assert.ok(Date.parse(pass.rise) < Date.parse(pass.peak)); + assert.ok(Date.parse(pass.peak) < Date.parse(pass.set)); + assert.ok(pass.max_elevation_deg >= 10 && pass.max_elevation_deg <= 90); + assert.match(pass.rise_direction, /^[NESW]{1,3}$/); + assert.match( + result.summary, + new RegExp( + `^ISS \\(ZARYA\\) next rises ${pass.rise} in the ${pass.rise_direction}, peaking at \\d+°`, + ), + ); +}); + +test('satellites are matched by name or catalog number within the group', async () => { + const source = satellites(); + const catalog = composeCatalog({ + tools: coreTools, + services: { satellites: source, clock: CLOCK }, + }); + const byName = await catalog.call('next_satellite_pass', { + lat: 0, + lon: 0, + satellite: 'tianhe', + }); + assert.equal(byName.data.norad, 48274); + const byNumber = await catalog.call('next_satellite_pass', { + lat: 0, + lon: 0, + satellite: '48274', + group: 'visual', + }); + assert.equal(byNumber.data.satellite, 'CSS (TIANHE)'); + assert.deepEqual(source.calls, ['stations', 'visual']); + await assert.rejects( + catalog.call('next_satellite_pass', { + lat: 0, + lon: 0, + satellite: 'Hubble', + }), + /No satellite matching "Hubble" is in the stations group/, + ); + await assert.rejects( + catalog.call('next_satellite_pass', { lat: 0, lon: 0, group: 'weather' }), + /must be one of/, + ); +}); + +test('a pass that never comes is a normal answer, and catalog failures are unavailable', async () => { + const catalog = composeCatalog({ + tools: coreTools, + services: { satellites: satellites(), clock: CLOCK }, + }); + // The ISS orbit is inclined 51.6°, so it never rises 10° above the pole. + const none = await catalog.call('next_satellite_pass', { + lat: 89.9, + lon: 0, + hours: 6, + }); + assert.equal(none.data.pass, null); + assert.equal( + none.summary, + 'ISS (ZARYA) has no pass over this location in the next 6 hours.', + ); + const down = composeCatalog({ + tools: coreTools, + services: { satellites: satellites('', false), clock: CLOCK }, + }); + await assert.rejects( + down.call('next_satellite_pass', { lat: 0, lon: 0 }), + (error) => error.code === 'unavailable' && /HTTP 503/.test(error.message), + ); +}); + +test('satellites overhead are those above the elevation, highest first', async () => { + const source = satellites(); + const services = { satellites: source, clock: CLOCK }; + const catalog = composeCatalog({ tools: coreTools, services }); + const pass = ( + await catalog.call('next_satellite_pass', { lat: 40.7, lon: -74 }) + ).data.pass; + const atPeak = { + satellites: source, + clock: { now: () => Date.parse(pass.peak) }, + }; + const overhead = await composeCatalog({ + tools: coreTools, + services: atPeak, + }).call('satellites_overhead', { + lat: 40.7, + lon: -74, + }); + assert.equal(overhead.data.rows[0].name, 'ISS (ZARYA)'); + assert.ok( + Math.abs(overhead.data.rows[0].elevation_deg - pass.max_elevation_deg) < 1, + ); + assert.equal(overhead.data.at, pass.peak); + const strict = await composeCatalog({ + tools: coreTools, + services: atPeak, + }).call('satellites_overhead', { + lat: 40.7, + lon: -74, + min_elevation_deg: 90, + }); + assert.equal(strict.data.total, 0); + assert.match( + strict.summary, + /^0 satellites from the stations group are at least 90° above/, + ); +}); diff --git a/src/tools/schema.js b/src/tools/schema.js index 02fc44396..942d6ff1f 100644 --- a/src/tools/schema.js +++ b/src/tools/schema.js @@ -2,7 +2,7 @@ * Validates tool arguments against the JSON Schema subset tool definitions use: * object, array, string, number, integer and boolean types; required, * properties, additionalProperties, items, enum, minimum, maximum, minLength, - * maxLength, minItems and maxItems. Unsupported keywords are rejected when a + * maxLength, pattern, minItems and maxItems. Unsupported keywords are rejected when a * tool is defined, so a schema can never promise checks that are not made. */ @@ -19,6 +19,7 @@ const KEYWORDS = new Set([ 'maximum', 'minLength', 'maxLength', + 'pattern', 'minItems', 'maxItems', 'default', @@ -42,6 +43,7 @@ export function assertSupportedSchema(schema, path = 'schema') { } if (!TYPES.has(schema.type)) throw new TypeError(`${path} has unsupported type ${schema.type}`); + if (schema.pattern !== undefined) new RegExp(schema.pattern, 'u'); for (const [name, child] of Object.entries(schema.properties || {})) assertSupportedSchema(child, `${path}.${name}`); if (schema.items) assertSupportedSchema(schema.items, `${path}[]`); @@ -99,6 +101,11 @@ export function validateValue(schema, value, path = 'arguments') { fail(`must be at least ${schema.minLength} characters`); if (schema.maxLength != null && value.length > schema.maxLength) fail(`must be at most ${schema.maxLength} characters`); + if ( + schema.pattern !== undefined && + !new RegExp(schema.pattern, 'u').test(value) + ) + fail(`must match ${schema.pattern}`); break; case 'number': case 'integer':