feat: add ship tools

Add vessels_in_area, find_vessel and get_vessel_track over the live
vessel source, and a ships section in situation_brief. Without an AIS
key the source's own message reaches clients as an unavailable feed.
This commit is contained in:
Sameh Khamis committed 2026-10-01 12:32:09 -07:00
1 parent 0a74e36ce8
commit e6cbcc31ad
10 files changed
+393 -5

No files matched your search

+1 -1
View File
@@ -1,7 +1,7 @@
# Changelog
- Add tools for language-model clients and a local MCP server. `npm run mcp`
serves earthquake, active-fire, launch, aircraft, satellite, camera, radio,
serves earthquake, active-fire, launch, aircraft, ship, satellite, camera, radio,
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
+2 -1
View File
@@ -7,7 +7,8 @@ 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`).
Queries cover earthquakes, active fires, recent launches, aircraft (in an
area, by identifier, tracks, and type and route lookups), satellites (next
area, by identifier, tracks, and type and route lookups), ships (in an area,
by identifier, and tracks), 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, transit vehicles, weather, regional briefs, tropical cyclones, fire
+4 -1
View File
@@ -99,6 +99,9 @@ public feeds the sources already use.
| `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 |
| `vessels_in_area` | `vessels` | Ships reported by AIS in an area, nearest first, optionally by type |
| `find_vessel` | `vessels` | Ships anywhere by MMSI, IMO number or name |
| `get_vessel_track` | `vessels` | Recent positions of one ship, thinned to 200 points |
| `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 |
| `find_cctv_cameras` | `cctv` | Public cameras in an area, nearest first |
@@ -116,5 +119,5 @@ public feeds the sources already use.
| `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 |
| `situation_brief` | `weather` | Weather, earthquakes, fires, aircraft, ships and cyclones for an area, by section |
| `get_hud_caption` | `weather`, `summary` | The app's heads-up display caption for an area |
+2 -1
View File
@@ -876,5 +876,6 @@
"src/tools/queries/places.test.mjs",
"src/layers/transit/source.test.mjs",
"src/tools/queries/mobility.test.mjs",
"src/tools/queries/environment.test.mjs"
"src/tools/queries/environment.test.mjs",
"src/tools/queries/maritime.test.mjs"
]
+1
View File
@@ -3096,6 +3096,7 @@
"src/tools/queries/mobility.js",
"src/tools/queries/environment.js",
"src/tools/queries/brief.js",
"src/tools/queries/maritime.js",
"src/sources/gbfsStations.js",
"src/sources/tle.js",
"src/data/satellitePass.js"
+5
View File
@@ -18,6 +18,7 @@ import { createTransitSource } from '../../src/layers/transit/source.js';
import { createApplicationRequestServices } from '../../src/services/requests.js';
import {
createAdsbLolSource,
createAisStreamSource,
createOpenSkySource,
} from '../../src/sources/live/standalone.js';
import {
@@ -55,6 +56,10 @@ export function createLocalToolServices(options = {}) {
launches: createLaunchSource({ fetchImpl }),
aircraft: createOpenSkySource({ fetchImpl }),
military: createAdsbLolSource({ fetchImpl }),
vessels: createAisStreamSource({
fetchImpl,
origin: () => new URL(options.apiBase ?? DEFAULT_API_BASE).origin,
}),
satellites: createSatelliteSource({ fetchImpl }),
cctv: createCctvSource({ fetchImpl }),
radio: createRadioSource({ fetchImpl }),
+8
View File
@@ -20,6 +20,11 @@ import {
getWeather,
} from './queries/environment.js';
import { getActiveFires, getEarthquakes } from './queries/hazards.js';
import {
findVessel,
getVesselTrack,
vesselsInArea,
} from './queries/maritime.js';
import { getBikeShare, getTransitVehicles } from './queries/mobility.js';
import { placesNearby, planRoute, searchPlaces } from './queries/places.js';
import {
@@ -63,6 +68,9 @@ export const coreTools = Object.freeze([
findAircraft,
getAircraftTrack,
getAircraftInfo,
vesselsInArea,
findVessel,
getVesselTrack,
nextSatellitePass,
satellitesOverhead,
findCctvCameras,
+7 -1
View File
@@ -5,6 +5,7 @@ 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';
import { vesselsInArea } from './maritime.js';
const SECTION_LIMIT = 5;
@@ -32,6 +33,11 @@ const SECTIONS = [
tool: aircraftInArea,
args: (area) => ({ area: area.argument, limit: SECTION_LIMIT }),
},
{
key: 'vessels',
tool: vesselsInArea,
args: (area) => ({ area: area.argument, limit: SECTION_LIMIT }),
},
{
key: 'cyclones',
tool: getCyclones,
@@ -91,7 +97,7 @@ export const situationBrief = defineTool({
title: 'Situation brief',
description:
'One overview of an area: current weather, recent earthquakes, active ' +
'fires, aircraft overhead and tropical cyclones, each summarized with ' +
'fires, aircraft overhead, ships and tropical cyclones, each summarized with ' +
'its top items. Sections that are unavailable are marked as such.',
inputSchema: {
type: 'object',
+182
View File
@@ -0,0 +1,182 @@
/** Maritime queries over the live vessel source (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,
thinEvenly,
} from '../results.js';
const MMSI_SCHEMA = Object.freeze({
type: 'string',
pattern: '^\\d{5,10}$',
description: 'Maritime Mobile Service Identity, such as "366999712".',
});
const MAX_TRACK_POINTS = 200;
const round = (value, digits) =>
Number.isFinite(value) ? Number(value.toFixed(digits)) : null;
const text = (value) => (typeof value === 'string' && value ? value : null);
function vesselRow(record, from) {
const point = { lat: record.latitude, lon: record.longitude };
return {
mmsi: record.id,
name: text(record.name),
imo: text(record.imo),
type: text(record.type),
destination: text(record.destination),
lat: round(record.latitude, 5),
lon: round(record.longitude, 5),
speed_mps: round(record.speedMps, 1),
course_deg: round(record.courseDeg, 0),
heading_deg: round(record.headingDeg, 0),
observed_at: isoTime(record.observedAtMs),
...(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',
};
}
export const vesselsInArea = defineTool({
name: 'vessels_in_area',
title: 'Vessels in an area',
description:
'Ships currently reported by AIS inside an area, nearest the center ' +
'first, optionally filtered by vessel type.',
inputSchema: {
type: 'object',
properties: {
area: AREA_SCHEMA,
type: {
type: 'string',
minLength: 1,
maxLength: 40,
description: 'Vessel type to match, such as "tanker" or "cargo".',
},
limit: LIMIT_SCHEMA,
},
required: ['area'],
additionalProperties: false,
},
requires: ['vessels'],
async run(args, { services, signal }) {
const area = await resolveArea(args.area, { services, signal });
const center = areaCenter(area);
const snapshot = await services.vessels.getSnapshot({}, { signal });
const wanted = args.type?.toLowerCase();
const rows = snapshot.records
.filter(
(record) =>
areaContains(area, { lat: record.latitude, lon: record.longitude }) &&
(!wanted || String(record.type).toLowerCase().includes(wanted)),
)
.map((record) => vesselRow(record, center))
.sort((a, b) => a.distance_km - b.distance_km);
const kind = args.type ? `${args.type} vessel` : 'vessel';
return {
summary:
`${countNoun(rows.length, kind)} in ${area.label}` +
(snapshot.freshness === 'stale' ? ' (data may be stale).' : '.'),
data: { ...capRows(rows, args.limit), ...snapshotInfo(snapshot) },
};
},
});
export const findVessel = defineTool({
name: 'find_vessel',
title: 'Find a vessel',
description:
'Find ships currently reported anywhere by MMSI, IMO number or name. ' +
'Names match when they contain the given text.',
inputSchema: {
type: 'object',
properties: {
mmsi: MMSI_SCHEMA,
imo: { type: 'string', pattern: '^\\d{7}$' },
name: { type: 'string', minLength: 2, maxLength: 60 },
limit: LIMIT_SCHEMA,
},
additionalProperties: false,
},
requires: ['vessels'],
async run(args, { services, signal }) {
const given = ['mmsi', 'imo', 'name'].filter((key) => args[key] != null);
if (given.length !== 1)
throw new ToolError(
'invalid_arguments',
'Give exactly one of mmsi, imo or name',
);
const [key] = given;
const wanted = args[key].trim().toUpperCase();
const matches = {
mmsi: (record) => record.id === wanted,
imo: (record) => String(record.imo).replace(/^IMO/i, '') === wanted,
name: (record) => String(record.name).toUpperCase().includes(wanted),
}[key];
const snapshot = await services.vessels.getSnapshot({}, { signal });
const rows = snapshot.records
.filter(matches)
.map((record) => vesselRow(record));
return {
summary: rows.length
? `Found ${countNoun(rows.length, 'vessel')} with ${key} ${args[key]}.`
: `No vessel with ${key} ${args[key]} is currently reported.`,
data: { ...capRows(rows, args.limit), ...snapshotInfo(snapshot) },
};
},
});
export const getVesselTrack = defineTool({
name: 'get_vessel_track',
title: 'Vessel track',
description:
'Recent positions of one ship by MMSI, oldest first. Long tracks are ' +
`evenly thinned to at most ${MAX_TRACK_POINTS} points.`,
inputSchema: {
type: 'object',
properties: { mmsi: MMSI_SCHEMA },
required: ['mmsi'],
additionalProperties: false,
},
requires: ['vessels'],
async run(args, { services, signal }) {
const track = await services.vessels.getTrack(args.mmsi, { signal });
const points = track.records
.filter((point) => Number.isFinite(point.observedAtMs))
.sort((a, b) => a.observedAtMs - b.observedAtMs);
const kept = thinEvenly(points, MAX_TRACK_POINTS);
return {
summary: points.length
? `${countNoun(points.length, 'position')} for ${args.mmsi} from ${isoTime(points[0].observedAtMs)} to ${isoTime(points.at(-1).observedAtMs)}.`
: `No recent track is available for ${args.mmsi}.`,
data: {
mmsi: args.mmsi,
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),
})),
},
};
},
});
+181
View File
@@ -0,0 +1,181 @@
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 vessel = (id, latitude, longitude, extra = {}) => ({
id,
reference: id,
latitude,
longitude,
name: `SHIP ${id}`,
imo: '',
type: 'Cargo',
destination: '',
speedMps: 6.17,
courseDeg: 181.2,
headingDeg: 180,
observedAtMs: Date.UTC(2026, 0, 1),
altitudeDatum: 'sea-surface',
...extra,
});
const records = [
vessel('366999712', 37.81, -122.41, {
name: 'GOLDEN BEAR',
imo: '9123456',
type: 'Passenger',
}),
vessel('538001234', 37.75, -122.3, {
name: 'PACIFIC TRADER',
imo: 'IMO7654321',
type: 'Tanker',
destination: 'OAKLAND',
}),
vessel('235000001', 51.5, 1.5),
];
const vessels = (extra = {}) => ({
async getSnapshot() {
return {
records,
source: 'AISStream',
coverage: 'received AIS positions',
observedAtMs: Date.UTC(2026, 0, 1),
freshness: 'current',
...extra,
};
},
async getTrack(mmsi) {
return {
records: [
{
latitude: 37.8,
longitude: -122.4,
observedAtMs: Date.UTC(2026, 0, 1, 1),
},
{
latitude: 37.7,
longitude: -122.5,
observedAtMs: Date.UTC(2026, 0, 1, 0),
},
{ latitude: 37.6, longitude: -122.6, observedAtMs: null },
].filter(() => mmsi === '366999712'),
complete: false,
};
},
});
const bay = { lat: 37.78, lon: -122.4, radius_km: 20 };
test('vessels in an area are nearest first and filterable by type', async () => {
const catalog = composeCatalog({
tools: coreTools,
services: { vessels: vessels() },
});
const all = await catalog.call('vessels_in_area', { area: bay });
assert.equal(all.summary, '2 vessels in 20 km around 37.780, -122.400.');
assert.deepEqual(
all.data.rows.map((row) => row.mmsi),
['366999712', '538001234'],
);
assert.deepEqual(all.data.rows[0], {
mmsi: '366999712',
name: 'GOLDEN BEAR',
imo: '9123456',
type: 'Passenger',
destination: null,
lat: 37.81,
lon: -122.41,
speed_mps: 6.2,
course_deg: 181,
heading_deg: 180,
observed_at: '2026-01-01T00:00:00.000Z',
distance_km: 3.4,
});
assert.equal(all.data.source, 'AISStream');
const tankers = await catalog.call('vessels_in_area', {
area: bay,
type: 'tanker',
});
assert.equal(
tankers.summary,
'1 tanker vessel in 20 km around 37.780, -122.400.',
);
const stale = composeCatalog({
tools: coreTools,
services: { vessels: vessels({ freshness: 'stale' }) },
});
assert.match(
(await stale.call('vessels_in_area', { area: bay })).summary,
/\(data may be stale\)\.$/,
);
});
test('vessels are found by exactly one identifier', async () => {
const catalog = composeCatalog({
tools: coreTools,
services: { vessels: vessels() },
});
assert.deepEqual(
(await catalog.call('find_vessel', { mmsi: '366999712' })).data.rows.map(
(row) => row.name,
),
['GOLDEN BEAR'],
);
assert.deepEqual(
(await catalog.call('find_vessel', { imo: '7654321' })).data.rows.map(
(row) => row.mmsi,
),
['538001234'],
);
const byName = await catalog.call('find_vessel', { name: 'trader' });
assert.equal(byName.summary, 'Found 1 vessel with name trader.');
assert.equal(
(await catalog.call('find_vessel', { mmsi: '99999' })).summary,
'No vessel with mmsi 99999 is currently reported.',
);
await assert.rejects(
catalog.call('find_vessel', { mmsi: '366999712', name: 'xy' }),
/exactly one/,
);
await assert.rejects(
catalog.call('find_vessel', { mmsi: 'abc' }),
/must match/,
);
});
test('vessel tracks are ordered and drop untimed positions', async () => {
const catalog = composeCatalog({
tools: coreTools,
services: { vessels: vessels() },
});
const track = await catalog.call('get_vessel_track', { mmsi: '366999712' });
assert.equal(
track.summary,
'2 positions for 366999712 from 2026-01-01T00:00:00.000Z to 2026-01-01T01:00:00.000Z.',
);
assert.deepEqual(
track.data.points.map((point) => point.lat),
[37.7, 37.8],
);
assert.equal(
(await catalog.call('get_vessel_track', { mmsi: '235000001' })).summary,
'No recent track is available for 235000001.',
);
});
test('a missing AIS key surfaces as an unavailable feed', async () => {
const keyless = {
getSnapshot: async () => {
throw new LiveSourceError('unavailable', 'AISSTREAM_API_KEY not set');
},
};
const catalog = composeCatalog({
tools: coreTools,
services: { vessels: keyless },
});
await assert.rejects(
catalog.call('vessels_in_area', { area: bay }),
(error) =>
error.code === 'unavailable' &&
error.message === 'AISSTREAM_API_KEY not set',
);
});