mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 10:24:02 +00:00
style: format extracted local services
This commit is contained in:
1 parent
233285fe12
commit
5223b6df5b
33 files changed
+1785
-830
No files matched your search
@@ -83,5 +83,36 @@
|
||||
"server/providers/radio/transport.js",
|
||||
"src/tooling/mediaProviders.test.mjs",
|
||||
"server/standalone/api-not-found.js",
|
||||
"src/tooling/previewServing.test.mjs"
|
||||
"src/tooling/previewServing.test.mjs",
|
||||
"server/providers/common/request.js",
|
||||
"server/providers/common/source-root.js",
|
||||
"server/providers/local.js",
|
||||
"server/providers/military-installations.js",
|
||||
"server/providers/military-installations/cache.js",
|
||||
"server/providers/military-installations/constants.js",
|
||||
"server/providers/military-installations/query.js",
|
||||
"server/providers/openai.js",
|
||||
"server/providers/openai/constants.js",
|
||||
"server/providers/openai/debug-log.js",
|
||||
"server/providers/openai/hud-summary.js",
|
||||
"server/providers/openai/instructions.js",
|
||||
"server/providers/openai/rate-limit.js",
|
||||
"server/providers/openai/realtime.js",
|
||||
"server/providers/openai/tools.js",
|
||||
"server/providers/overpass.js",
|
||||
"server/providers/overpass/cache.js",
|
||||
"server/providers/overpass/constants.js",
|
||||
"server/providers/overpass/geometry.js",
|
||||
"server/providers/overpass/query.js",
|
||||
"server/providers/overpass/transport.js",
|
||||
"server/providers/regional.js",
|
||||
"server/providers/regional/briefing.js",
|
||||
"server/providers/regional/http.js",
|
||||
"server/providers/regional/news.js",
|
||||
"server/providers/regional/place.js",
|
||||
"server/providers/regional/query.js",
|
||||
"server/providers/regional/weather-effects.js",
|
||||
"server/providers/regional/weather.js",
|
||||
"server/standalone/key-setup.js",
|
||||
"src/tooling/localServices.test.mjs"
|
||||
]
|
||||
+39
-120
@@ -10,47 +10,27 @@
|
||||
"src/data/localGeojsonCore.js",
|
||||
"src/data/localGeojsonLod.js"
|
||||
],
|
||||
"external": [
|
||||
"cesium"
|
||||
]
|
||||
"external": ["cesium"]
|
||||
},
|
||||
"application": {
|
||||
"exports": [
|
||||
"./application"
|
||||
],
|
||||
"modules": [
|
||||
"src/app/application.js"
|
||||
],
|
||||
"exports": ["./application"],
|
||||
"modules": ["src/app/application.js"],
|
||||
"external": []
|
||||
},
|
||||
"viewer": {
|
||||
"exports": [
|
||||
"./application/viewer"
|
||||
],
|
||||
"modules": [
|
||||
"src/app/viewer.js"
|
||||
],
|
||||
"external": [
|
||||
"cesium"
|
||||
]
|
||||
"exports": ["./application/viewer"],
|
||||
"modules": ["src/app/viewer.js"],
|
||||
"external": ["cesium"]
|
||||
},
|
||||
"vite-build": {
|
||||
"runtime": "node",
|
||||
"exports": [
|
||||
"./build/vite"
|
||||
],
|
||||
"modules": [
|
||||
"build/vite.js"
|
||||
],
|
||||
"external": [
|
||||
"vite-plugin-cesium"
|
||||
]
|
||||
"exports": ["./build/vite"],
|
||||
"modules": ["build/vite.js"],
|
||||
"external": ["vite-plugin-cesium"]
|
||||
},
|
||||
"live-providers": {
|
||||
"runtime": "node",
|
||||
"exports": [
|
||||
"./server/providers/live"
|
||||
],
|
||||
"exports": ["./server/providers/live"],
|
||||
"modules": [
|
||||
"server/providers/live.js",
|
||||
"server/providers/common/http.js",
|
||||
@@ -65,24 +45,16 @@
|
||||
"src/data/aisStreamAdapter.js",
|
||||
"src/data/aisWatchdog.js"
|
||||
],
|
||||
"external": [
|
||||
"ws"
|
||||
]
|
||||
"external": ["ws"]
|
||||
},
|
||||
"adsb-lol-source": {
|
||||
"exports": [
|
||||
"./sources/adsb-lol"
|
||||
],
|
||||
"modules": [
|
||||
"src/data/adsbLolFallback.js"
|
||||
],
|
||||
"exports": ["./sources/adsb-lol"],
|
||||
"modules": ["src/data/adsbLolFallback.js"],
|
||||
"external": []
|
||||
},
|
||||
"place-providers": {
|
||||
"runtime": "node",
|
||||
"exports": [
|
||||
"./server/providers/places"
|
||||
],
|
||||
"exports": ["./server/providers/places"],
|
||||
"modules": [
|
||||
"server/providers/places.js",
|
||||
"server/providers/places/google.js",
|
||||
@@ -97,19 +69,13 @@
|
||||
"external": []
|
||||
},
|
||||
"place-payloads": {
|
||||
"exports": [
|
||||
"./sources/places"
|
||||
],
|
||||
"modules": [
|
||||
"src/data/placeProviderPayloads.js"
|
||||
],
|
||||
"exports": ["./sources/places"],
|
||||
"modules": ["src/data/placeProviderPayloads.js"],
|
||||
"external": []
|
||||
},
|
||||
"space-providers": {
|
||||
"runtime": "node",
|
||||
"exports": [
|
||||
"./server/providers/space"
|
||||
],
|
||||
"exports": ["./server/providers/space"],
|
||||
"modules": [
|
||||
"server/providers/space.js",
|
||||
"server/providers/space/celestrak.js",
|
||||
@@ -120,19 +86,13 @@
|
||||
"external": []
|
||||
},
|
||||
"space-requests": {
|
||||
"exports": [
|
||||
"./sources/space"
|
||||
],
|
||||
"modules": [
|
||||
"src/data/spaceProviderRequests.js"
|
||||
],
|
||||
"exports": ["./sources/space"],
|
||||
"modules": ["src/data/spaceProviderRequests.js"],
|
||||
"external": []
|
||||
},
|
||||
"terrain-provider": {
|
||||
"runtime": "node",
|
||||
"exports": [
|
||||
"./server/providers/terrain"
|
||||
],
|
||||
"exports": ["./server/providers/terrain"],
|
||||
"modules": [
|
||||
"server/providers/terrain.js",
|
||||
"src/data/terrainHeightsProxy.js"
|
||||
@@ -140,70 +100,41 @@
|
||||
"external": []
|
||||
},
|
||||
"terrain-source": {
|
||||
"exports": [
|
||||
"./sources/terrain"
|
||||
],
|
||||
"modules": [
|
||||
"src/data/terrainHeightsProxy.js"
|
||||
],
|
||||
"exports": ["./sources/terrain"],
|
||||
"modules": ["src/data/terrainHeightsProxy.js"],
|
||||
"external": []
|
||||
},
|
||||
"traffic-provider": {
|
||||
"runtime": "node",
|
||||
"exports": [
|
||||
"./server/providers/traffic"
|
||||
],
|
||||
"modules": [
|
||||
"server/providers/traffic.js",
|
||||
"src/data/tomtomTiles.js"
|
||||
],
|
||||
"exports": ["./server/providers/traffic"],
|
||||
"modules": ["server/providers/traffic.js", "src/data/tomtomTiles.js"],
|
||||
"external": []
|
||||
},
|
||||
"traffic-source": {
|
||||
"exports": [
|
||||
"./sources/traffic"
|
||||
],
|
||||
"modules": [
|
||||
"src/data/tomtomTiles.js"
|
||||
],
|
||||
"exports": ["./sources/traffic"],
|
||||
"modules": ["src/data/tomtomTiles.js"],
|
||||
"external": []
|
||||
},
|
||||
"firms-provider": {
|
||||
"runtime": "node",
|
||||
"exports": [
|
||||
"./server/providers/firms"
|
||||
],
|
||||
"modules": [
|
||||
"server/providers/firms.js",
|
||||
"src/data/firmsCsv.js"
|
||||
],
|
||||
"exports": ["./server/providers/firms"],
|
||||
"modules": ["server/providers/firms.js", "src/data/firmsCsv.js"],
|
||||
"external": []
|
||||
},
|
||||
"gbfs-provider": {
|
||||
"runtime": "node",
|
||||
"exports": [
|
||||
"./server/providers/gbfs"
|
||||
],
|
||||
"modules": [
|
||||
"server/providers/gbfs.js",
|
||||
"src/data/gbfsSource.js"
|
||||
],
|
||||
"exports": ["./server/providers/gbfs"],
|
||||
"modules": ["server/providers/gbfs.js", "src/data/gbfsSource.js"],
|
||||
"external": []
|
||||
},
|
||||
"gbfs-source": {
|
||||
"exports": [
|
||||
"./sources/gbfs"
|
||||
],
|
||||
"modules": [
|
||||
"src/data/gbfsSource.js"
|
||||
],
|
||||
"exports": ["./sources/gbfs"],
|
||||
"modules": ["src/data/gbfsSource.js"],
|
||||
"external": []
|
||||
},
|
||||
"cctv-provider": {
|
||||
"runtime": "node",
|
||||
"exports": [
|
||||
"./server/providers/cctv"
|
||||
],
|
||||
"exports": ["./server/providers/cctv"],
|
||||
"modules": [
|
||||
"server/providers/cctv.js",
|
||||
"server/providers/cctv/catalog.js",
|
||||
@@ -220,9 +151,7 @@
|
||||
},
|
||||
"radio-provider": {
|
||||
"runtime": "node",
|
||||
"exports": [
|
||||
"./server/providers/radio"
|
||||
],
|
||||
"exports": ["./server/providers/radio"],
|
||||
"modules": [
|
||||
"server/providers/radio.js",
|
||||
"server/providers/radio/catalog.js",
|
||||
@@ -236,9 +165,7 @@
|
||||
},
|
||||
"overpass-provider": {
|
||||
"runtime": "node",
|
||||
"exports": [
|
||||
"./server/providers/overpass"
|
||||
],
|
||||
"exports": ["./server/providers/overpass"],
|
||||
"modules": [
|
||||
"server/providers/common/geo.js",
|
||||
"server/providers/common/http.js",
|
||||
@@ -257,9 +184,7 @@
|
||||
},
|
||||
"military-installations-provider": {
|
||||
"runtime": "node",
|
||||
"exports": [
|
||||
"./server/providers/military-installations"
|
||||
],
|
||||
"exports": ["./server/providers/military-installations"],
|
||||
"modules": [
|
||||
"server/providers/common/http.js",
|
||||
"server/providers/common/query.js",
|
||||
@@ -276,9 +201,7 @@
|
||||
},
|
||||
"regional-provider": {
|
||||
"runtime": "node",
|
||||
"exports": [
|
||||
"./server/providers/regional"
|
||||
],
|
||||
"exports": ["./server/providers/regional"],
|
||||
"modules": [
|
||||
"server/providers/common/http.js",
|
||||
"server/providers/common/query.js",
|
||||
@@ -297,9 +220,7 @@
|
||||
},
|
||||
"openai-provider": {
|
||||
"runtime": "node",
|
||||
"exports": [
|
||||
"./server/providers/openai"
|
||||
],
|
||||
"exports": ["./server/providers/openai"],
|
||||
"modules": [
|
||||
"server/providers/common/rate-limit.js",
|
||||
"server/providers/common/request.js",
|
||||
@@ -319,9 +240,7 @@
|
||||
},
|
||||
"standalone-key-setup": {
|
||||
"runtime": "node",
|
||||
"exports": [
|
||||
"./server/standalone/key-setup"
|
||||
],
|
||||
"exports": ["./server/standalone/key-setup"],
|
||||
"modules": [
|
||||
"scripts/pinokio-environment.mjs",
|
||||
"server/providers/common/source-root.js",
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
|
||||
/** Read a request body with a hard byte cap; throws { code:'BODY_TOO_LARGE' } past the cap. */
|
||||
async function readRequestBodyCapped(req, maxBytes) {
|
||||
const chunks = [];
|
||||
|
||||
@@ -2,6 +2,9 @@ import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
/** Resolve defaultSourceRoot for ESM context. */
|
||||
const defaultSourceRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../../..');
|
||||
const defaultSourceRoot = path.resolve(
|
||||
path.dirname(fileURLToPath(import.meta.url)),
|
||||
'../../..',
|
||||
);
|
||||
|
||||
export { defaultSourceRoot };
|
||||
+31
-22
@@ -22,33 +22,42 @@ import { keySetupEndpoint } from '../standalone/key-setup.js';
|
||||
/** Construct the local provider plugins in their established order. */
|
||||
function localProviderPlugins() {
|
||||
return [
|
||||
openSkyProxy(),
|
||||
celestrakProxy(),
|
||||
tomtomProxy(),
|
||||
firmsProxy(),
|
||||
rocketLaunchesProxy(),
|
||||
terrainHeightsProxy(),
|
||||
adsbdbProxy(),
|
||||
overpassProxy(),
|
||||
militaryInstallationsProxy(),
|
||||
regionalBriefProxy(),
|
||||
weatherEffectsProxy(),
|
||||
cctvProxy({ sourceRoot: defaultSourceRoot }),
|
||||
radioBrowserProxy(),
|
||||
gbfsProxy(),
|
||||
adsbLolProxy(),
|
||||
aisLiveProxy(),
|
||||
trackBackfillProxies(),
|
||||
openAiRealtimeProxy(),
|
||||
googlePlacesContextProxy(),
|
||||
keySetupEndpoint(),
|
||||
openSkyProxy(),
|
||||
celestrakProxy(),
|
||||
tomtomProxy(),
|
||||
firmsProxy(),
|
||||
rocketLaunchesProxy(),
|
||||
terrainHeightsProxy(),
|
||||
adsbdbProxy(),
|
||||
overpassProxy(),
|
||||
militaryInstallationsProxy(),
|
||||
regionalBriefProxy(),
|
||||
weatherEffectsProxy(),
|
||||
cctvProxy({ sourceRoot: defaultSourceRoot }),
|
||||
radioBrowserProxy(),
|
||||
gbfsProxy(),
|
||||
adsbLolProxy(),
|
||||
aisLiveProxy(),
|
||||
trackBackfillProxies(),
|
||||
openAiRealtimeProxy(),
|
||||
googlePlacesContextProxy(),
|
||||
keySetupEndpoint(),
|
||||
];
|
||||
}
|
||||
|
||||
export { localProviderPlugins };
|
||||
|
||||
export { CCTV_FRAME_FETCH_TIMEOUT_MS, fetchCctvImageFromUpstream } from './cctv.js';
|
||||
export { createRadioProxyMiddleware, isPublicRadioAddress, normalizeRadioBrowserStation, publicRadioStation, publicRadioHttpsUrl } from './radio.js';
|
||||
export {
|
||||
CCTV_FRAME_FETCH_TIMEOUT_MS,
|
||||
fetchCctvImageFromUpstream,
|
||||
} from './cctv.js';
|
||||
export {
|
||||
createRadioProxyMiddleware,
|
||||
isPublicRadioAddress,
|
||||
normalizeRadioBrowserStation,
|
||||
publicRadioStation,
|
||||
publicRadioHttpsUrl,
|
||||
} from './radio.js';
|
||||
export { LL2_CACHE_TTL_MS, launchLibraryRequestHeaders } from './space.js';
|
||||
export { googlePlacesContextProxy } from './places.js';
|
||||
export { googleServerApiKey } from './places.js';
|
||||
|
||||
@@ -1,11 +1,31 @@
|
||||
import { makeRateLimiter, clientKey } from './common/rate-limit.js';
|
||||
import { MILITARY_INSTALLATION_ELEMENT_CAP, MILITARY_INSTALLATION_MAX_RESPONSE_BYTES, MILITARY_INSTALLATION_DISK_TTL_MS, MILITARY_INSTALLATION_STALE_MS } from './military-installations/constants.js';
|
||||
import {
|
||||
MILITARY_INSTALLATION_ELEMENT_CAP,
|
||||
MILITARY_INSTALLATION_MAX_RESPONSE_BYTES,
|
||||
MILITARY_INSTALLATION_DISK_TTL_MS,
|
||||
MILITARY_INSTALLATION_STALE_MS,
|
||||
} from './military-installations/constants.js';
|
||||
import { fetchOverpassPayload } from './overpass/transport.js';
|
||||
import { _militaryInstallationCache, trimMilitaryInstallationCache, writeMilitaryInstallationDisk, resolveMilitaryInstallationTier, readMilitaryInstallationDisk } from './military-installations/cache.js';
|
||||
import { validMilitaryInstallationBox, quantizeMilitaryInstallationBox, militaryInstallationCacheKey, militaryInstallationFailureReason } from './military-installations/query.js';
|
||||
import {
|
||||
_militaryInstallationCache,
|
||||
trimMilitaryInstallationCache,
|
||||
writeMilitaryInstallationDisk,
|
||||
resolveMilitaryInstallationTier,
|
||||
readMilitaryInstallationDisk,
|
||||
} from './military-installations/cache.js';
|
||||
import {
|
||||
validMilitaryInstallationBox,
|
||||
quantizeMilitaryInstallationBox,
|
||||
militaryInstallationCacheKey,
|
||||
militaryInstallationFailureReason,
|
||||
} from './military-installations/query.js';
|
||||
import { coalesceProxyRequest } from './common/http.js';
|
||||
|
||||
const _militaryInstallationsRateLimiter = makeRateLimiter({ windowMs: 60_000, max: 90, globalMax: 300 });
|
||||
const _militaryInstallationsRateLimiter = makeRateLimiter({
|
||||
windowMs: 60_000,
|
||||
max: 90,
|
||||
globalMax: 300,
|
||||
});
|
||||
|
||||
const _militaryInstallationInFlight = new Map();
|
||||
|
||||
@@ -17,12 +37,23 @@ function militaryInstallationsProxy() {
|
||||
`data=${encodeURIComponent(ql)}`,
|
||||
MILITARY_INSTALLATION_MAX_RESPONSE_BYTES,
|
||||
);
|
||||
if (upstream.status >= 400 || upstream.rateLimited || upstream.runtimeError) {
|
||||
throw Object.assign(new Error('Mapped installation upstream unavailable'), {
|
||||
installationReason: upstream.rateLimited ? 'rate_limited'
|
||||
: upstream.status === 504 ? 'timeout'
|
||||
: upstream.runtimeError ? 'query_failed' : 'unavailable',
|
||||
});
|
||||
if (
|
||||
upstream.status >= 400 ||
|
||||
upstream.rateLimited ||
|
||||
upstream.runtimeError
|
||||
) {
|
||||
throw Object.assign(
|
||||
new Error('Mapped installation upstream unavailable'),
|
||||
{
|
||||
installationReason: upstream.rateLimited
|
||||
? 'rate_limited'
|
||||
: upstream.status === 504
|
||||
? 'timeout'
|
||||
: upstream.runtimeError
|
||||
? 'query_failed'
|
||||
: 'unavailable',
|
||||
},
|
||||
);
|
||||
}
|
||||
const parsed = JSON.parse(upstream.body);
|
||||
const elements = Array.isArray(parsed?.elements)
|
||||
@@ -53,7 +84,10 @@ function militaryInstallationsProxy() {
|
||||
return;
|
||||
}
|
||||
if (!_militaryInstallationsRateLimiter(clientKey(req))) {
|
||||
res.writeHead(429, { 'Content-Type': 'application/json', 'Retry-After': '5' });
|
||||
res.writeHead(429, {
|
||||
'Content-Type': 'application/json',
|
||||
'Retry-After': '5',
|
||||
});
|
||||
res.end(JSON.stringify({ error: 'Rate limit exceeded' }));
|
||||
return;
|
||||
}
|
||||
@@ -61,7 +95,11 @@ function militaryInstallationsProxy() {
|
||||
const requested = validMilitaryInstallationBox(url.searchParams);
|
||||
if (!requested) {
|
||||
res.writeHead(400, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ error: 'A non-dateline bbox no larger than 10 degrees is required' }));
|
||||
res.end(
|
||||
JSON.stringify({
|
||||
error: 'A non-dateline bbox no larger than 10 degrees is required',
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
// Query the SNAPPED box, not the raw viewport: neighbouring views then
|
||||
@@ -70,7 +108,9 @@ function militaryInstallationsProxy() {
|
||||
// response, so a truncated tile can never starve the actual viewport. It
|
||||
// is keyed separately so exact and snapped answers never collide.
|
||||
const exact = url.searchParams.get('exact') === '1';
|
||||
const box = exact ? requested : quantizeMilitaryInstallationBox(requested);
|
||||
const box = exact
|
||||
? requested
|
||||
: quantizeMilitaryInstallationBox(requested);
|
||||
// Key at the precision the query actually uses (see militaryInstallationCacheKey).
|
||||
const key = exact
|
||||
? `exact:${militaryInstallationCacheKey(box, 5)}`
|
||||
@@ -81,7 +121,8 @@ function militaryInstallationsProxy() {
|
||||
cacheKey: key,
|
||||
memoryCache: _militaryInstallationCache,
|
||||
inFlight: _militaryInstallationInFlight,
|
||||
readDisk: () => readMilitaryInstallationDisk(key, MILITARY_INSTALLATION_DISK_TTL_MS),
|
||||
readDisk: () =>
|
||||
readMilitaryInstallationDisk(key, MILITARY_INSTALLATION_DISK_TTL_MS),
|
||||
now,
|
||||
});
|
||||
if (preflight.source !== 'UPSTREAM') {
|
||||
@@ -89,8 +130,14 @@ function militaryInstallationsProxy() {
|
||||
_militaryInstallationCache.set(key, preflight.entry);
|
||||
trimMilitaryInstallationCache();
|
||||
}
|
||||
res.writeHead(200, { 'Content-Type': 'application/json', 'Cache-Control': 'public, max-age=60', 'X-Military-Installations': preflight.source });
|
||||
res.end(JSON.stringify({ ...preflight.entry.payload, status: 'cached' }));
|
||||
res.writeHead(200, {
|
||||
'Content-Type': 'application/json',
|
||||
'Cache-Control': 'public, max-age=60',
|
||||
'X-Military-Installations': preflight.source,
|
||||
});
|
||||
res.end(
|
||||
JSON.stringify({ ...preflight.entry.payload, status: 'cached' }),
|
||||
);
|
||||
return;
|
||||
}
|
||||
const request = coalesceProxyRequest(
|
||||
@@ -108,7 +155,11 @@ function militaryInstallationsProxy() {
|
||||
res.end(JSON.stringify(payload));
|
||||
} catch (error) {
|
||||
if (cached && now - cached.cachedAt <= MILITARY_INSTALLATION_STALE_MS) {
|
||||
res.writeHead(200, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store', 'X-Military-Installations': 'STALE' });
|
||||
res.writeHead(200, {
|
||||
'Content-Type': 'application/json',
|
||||
'Cache-Control': 'no-store',
|
||||
'X-Military-Installations': 'STALE',
|
||||
});
|
||||
res.end(JSON.stringify({ ...cached.payload, status: 'stale' }));
|
||||
return;
|
||||
}
|
||||
@@ -116,12 +167,24 @@ function militaryInstallationsProxy() {
|
||||
// layer (the same serve-stale rule the Overpass proxy applies).
|
||||
const stale = await readMilitaryInstallationDisk(key, Infinity);
|
||||
if (stale) {
|
||||
res.writeHead(200, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store', 'X-Military-Installations': 'STALE-DISK' });
|
||||
res.writeHead(200, {
|
||||
'Content-Type': 'application/json',
|
||||
'Cache-Control': 'no-store',
|
||||
'X-Military-Installations': 'STALE-DISK',
|
||||
});
|
||||
res.end(JSON.stringify({ ...stale.payload, status: 'stale' }));
|
||||
return;
|
||||
}
|
||||
res.writeHead(503, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' });
|
||||
res.end(JSON.stringify({ error: 'Mapped installation context is temporarily unavailable', reason: militaryInstallationFailureReason(error) }));
|
||||
res.writeHead(503, {
|
||||
'Content-Type': 'application/json',
|
||||
'Cache-Control': 'no-store',
|
||||
});
|
||||
res.end(
|
||||
JSON.stringify({
|
||||
error: 'Mapped installation context is temporarily unavailable',
|
||||
reason: militaryInstallationFailureReason(error),
|
||||
}),
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,4 +1,10 @@
|
||||
import { MILITARY_INSTALLATION_CACHE_MS, MILITARY_INSTALLATION_ELEMENT_CAP, MILITARY_INSTALLATION_DISK_TTL_MS, MILITARY_INSTALLATION_DISK_DIR, MILITARY_INSTALLATION_MAX_CACHE } from './constants.js';
|
||||
import {
|
||||
MILITARY_INSTALLATION_CACHE_MS,
|
||||
MILITARY_INSTALLATION_ELEMENT_CAP,
|
||||
MILITARY_INSTALLATION_DISK_TTL_MS,
|
||||
MILITARY_INSTALLATION_DISK_DIR,
|
||||
MILITARY_INSTALLATION_MAX_CACHE,
|
||||
} from './constants.js';
|
||||
import path from 'node:path';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { promises as fsp } from 'node:fs';
|
||||
@@ -32,10 +38,13 @@ async function resolveMilitaryInstallationTier({
|
||||
cacheMs = MILITARY_INSTALLATION_CACHE_MS,
|
||||
}) {
|
||||
const cached = memoryCache.get(cacheKey);
|
||||
if (cached && now - cached.cachedAt <= cacheMs) return { source: 'HIT', entry: cached };
|
||||
if (cached && now - cached.cachedAt <= cacheMs)
|
||||
return { source: 'HIT', entry: cached };
|
||||
if (inFlight.has(cacheKey)) return { source: 'UPSTREAM', entry: null };
|
||||
const disk = await readDisk();
|
||||
return disk ? { source: 'DISK', entry: disk } : { source: 'UPSTREAM', entry: null };
|
||||
return disk
|
||||
? { source: 'DISK', entry: disk }
|
||||
: { source: 'UPSTREAM', entry: null };
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -51,8 +60,11 @@ async function resolveMilitaryInstallationTier({
|
||||
* @returns {?{payload: object, cachedAt: number}}
|
||||
*/
|
||||
function migrateMilitaryInstallationEntry(entry) {
|
||||
if (!entry?.payload || typeof entry.payload.saturated === 'boolean') return entry;
|
||||
const elements = Array.isArray(entry.payload.elements) ? entry.payload.elements : [];
|
||||
if (!entry?.payload || typeof entry.payload.saturated === 'boolean')
|
||||
return entry;
|
||||
const elements = Array.isArray(entry.payload.elements)
|
||||
? entry.payload.elements
|
||||
: [];
|
||||
return {
|
||||
...entry,
|
||||
payload: {
|
||||
@@ -68,13 +80,24 @@ function militaryInstallationDiskFresh(
|
||||
maxAgeMs = MILITARY_INSTALLATION_DISK_TTL_MS,
|
||||
now = Date.now(),
|
||||
) {
|
||||
if (!entry || !Number.isFinite(entry.cachedAt) || !Array.isArray(entry.payload?.elements)) return false;
|
||||
if (
|
||||
!entry ||
|
||||
!Number.isFinite(entry.cachedAt) ||
|
||||
!Array.isArray(entry.payload?.elements)
|
||||
)
|
||||
return false;
|
||||
return now - entry.cachedAt <= maxAgeMs;
|
||||
}
|
||||
|
||||
/** Cache key -> stable disk-cache file path. */
|
||||
function militaryInstallationDiskPath(cacheKey, dir = MILITARY_INSTALLATION_DISK_DIR) {
|
||||
return path.join(dir, `${createHash('sha1').update(cacheKey).digest('hex')}.json`);
|
||||
function militaryInstallationDiskPath(
|
||||
cacheKey,
|
||||
dir = MILITARY_INSTALLATION_DISK_DIR,
|
||||
) {
|
||||
return path.join(
|
||||
dir,
|
||||
`${createHash('sha1').update(cacheKey).digest('hex')}.json`,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -88,7 +111,9 @@ async function readMilitaryInstallationDisk(
|
||||
dir = MILITARY_INSTALLATION_DISK_DIR,
|
||||
) {
|
||||
try {
|
||||
const entry = JSON.parse(await fsp.readFile(militaryInstallationDiskPath(cacheKey, dir), 'utf8'));
|
||||
const entry = JSON.parse(
|
||||
await fsp.readFile(militaryInstallationDiskPath(cacheKey, dir), 'utf8'),
|
||||
);
|
||||
if (!militaryInstallationDiskFresh(entry, maxAgeMs)) return null;
|
||||
return migrateMilitaryInstallationEntry(entry);
|
||||
} catch {
|
||||
@@ -117,7 +142,10 @@ async function writeMilitaryInstallationDisk(
|
||||
await fsp.rename(temp, target);
|
||||
return true;
|
||||
} catch (err) {
|
||||
console.warn('[Installations Proxy] disk cache write failed:', err?.message || err);
|
||||
console.warn(
|
||||
'[Installations Proxy] disk cache write failed:',
|
||||
err?.message || err,
|
||||
);
|
||||
await fsp.rm(temp, { force: true }).catch(() => {});
|
||||
return false;
|
||||
}
|
||||
@@ -131,4 +159,13 @@ function trimMilitaryInstallationCache() {
|
||||
}
|
||||
}
|
||||
|
||||
export { _militaryInstallationCache, trimMilitaryInstallationCache, writeMilitaryInstallationDisk, resolveMilitaryInstallationTier, readMilitaryInstallationDisk, migrateMilitaryInstallationEntry, militaryInstallationDiskFresh, militaryInstallationDiskPath };
|
||||
export {
|
||||
_militaryInstallationCache,
|
||||
trimMilitaryInstallationCache,
|
||||
writeMilitaryInstallationDisk,
|
||||
resolveMilitaryInstallationTier,
|
||||
readMilitaryInstallationDisk,
|
||||
migrateMilitaryInstallationEntry,
|
||||
militaryInstallationDiskFresh,
|
||||
militaryInstallationDiskPath,
|
||||
};
|
||||
@@ -33,7 +33,11 @@ const MILITARY_INSTALLATION_ELEMENT_CAP = 700;
|
||||
const MILITARY_INSTALLATION_DISK_TTL_MS = 30 * 86_400_000;
|
||||
|
||||
/** Disk-cache directory for mapped installation payloads. */
|
||||
const MILITARY_INSTALLATION_DISK_DIR = path.join(process.cwd(), '.gev-cache', 'military-installations');
|
||||
const MILITARY_INSTALLATION_DISK_DIR = path.join(
|
||||
process.cwd(),
|
||||
'.gev-cache',
|
||||
'military-installations',
|
||||
);
|
||||
|
||||
/**
|
||||
* Cache-key grid step in degrees (~5.5 km).
|
||||
@@ -45,4 +49,13 @@ const MILITARY_INSTALLATION_DISK_DIR = path.join(process.cwd(), '.gev-cache', 'm
|
||||
*/
|
||||
const MILITARY_INSTALLATION_BBOX_STEP_DEG = 0.05;
|
||||
|
||||
export { MILITARY_INSTALLATION_ELEMENT_CAP, MILITARY_INSTALLATION_MAX_RESPONSE_BYTES, MILITARY_INSTALLATION_DISK_TTL_MS, MILITARY_INSTALLATION_STALE_MS, MILITARY_INSTALLATION_CACHE_MS, MILITARY_INSTALLATION_DISK_DIR, MILITARY_INSTALLATION_MAX_CACHE, MILITARY_INSTALLATION_BBOX_STEP_DEG };
|
||||
export {
|
||||
MILITARY_INSTALLATION_ELEMENT_CAP,
|
||||
MILITARY_INSTALLATION_MAX_RESPONSE_BYTES,
|
||||
MILITARY_INSTALLATION_DISK_TTL_MS,
|
||||
MILITARY_INSTALLATION_STALE_MS,
|
||||
MILITARY_INSTALLATION_CACHE_MS,
|
||||
MILITARY_INSTALLATION_DISK_DIR,
|
||||
MILITARY_INSTALLATION_MAX_CACHE,
|
||||
MILITARY_INSTALLATION_BBOX_STEP_DEG,
|
||||
};
|
||||
@@ -7,12 +7,17 @@ import { requiredFiniteQueryNumber } from '../common/query.js';
|
||||
* @param {number} [stepDeg]
|
||||
* @returns {{south:number, west:number, north:number, east:number}}
|
||||
*/
|
||||
function quantizeMilitaryInstallationBox(box, stepDeg = MILITARY_INSTALLATION_BBOX_STEP_DEG) {
|
||||
function quantizeMilitaryInstallationBox(
|
||||
box,
|
||||
stepDeg = MILITARY_INSTALLATION_BBOX_STEP_DEG,
|
||||
) {
|
||||
// Round the ratio first: 29.9999/0.05 lands a hair under an exact grid line
|
||||
// in binary floating point, which would otherwise snap a whole cell too far.
|
||||
const snap = (value, grow) => {
|
||||
const cells = Number((value / stepDeg).toFixed(9));
|
||||
return Number(((grow > 0 ? Math.ceil(cells) : Math.floor(cells)) * stepDeg).toFixed(6));
|
||||
return Number(
|
||||
((grow > 0 ? Math.ceil(cells) : Math.floor(cells)) * stepDeg).toFixed(6),
|
||||
);
|
||||
};
|
||||
return {
|
||||
south: Math.max(-90, snap(box.south, -1)),
|
||||
@@ -46,15 +51,35 @@ function validMilitaryInstallationBox(params) {
|
||||
const north = requiredFiniteQueryNumber(params, 'north');
|
||||
const east = requiredFiniteQueryNumber(params, 'east');
|
||||
if (![south, west, north, east].every(Number.isFinite)) return null;
|
||||
if (south < -90 || north > 90 || west < -180 || east > 180 || south >= north || west >= east) return null;
|
||||
if (
|
||||
south < -90 ||
|
||||
north > 90 ||
|
||||
west < -180 ||
|
||||
east > 180 ||
|
||||
south >= north ||
|
||||
west >= east
|
||||
)
|
||||
return null;
|
||||
if (north - south > 10 || east - west > 10) return null;
|
||||
return { south, west, north, east };
|
||||
}
|
||||
|
||||
/** Safe, evidence-based reason for an installation upstream failure. */
|
||||
function militaryInstallationFailureReason(error) {
|
||||
if (['rate_limited', 'timeout', 'query_failed'].includes(error?.installationReason)) return error.installationReason;
|
||||
return ['AbortError', 'TimeoutError'].includes(error?.name) ? 'timeout' : 'unavailable';
|
||||
if (
|
||||
['rate_limited', 'timeout', 'query_failed'].includes(
|
||||
error?.installationReason,
|
||||
)
|
||||
)
|
||||
return error.installationReason;
|
||||
return ['AbortError', 'TimeoutError'].includes(error?.name)
|
||||
? 'timeout'
|
||||
: 'unavailable';
|
||||
}
|
||||
|
||||
export { validMilitaryInstallationBox, quantizeMilitaryInstallationBox, militaryInstallationCacheKey, militaryInstallationFailureReason };
|
||||
export {
|
||||
validMilitaryInstallationBox,
|
||||
quantizeMilitaryInstallationBox,
|
||||
militaryInstallationCacheKey,
|
||||
militaryInstallationFailureReason,
|
||||
};
|
||||
@@ -9,13 +9,22 @@ import { createRealtimeTokenHandler } from './openai/realtime.js';
|
||||
* Keeps OPENAI_API_KEY server-side while the browser connects to the
|
||||
* Realtime API over WebRTC with a short-lived secret.
|
||||
*/
|
||||
function openAiRealtimeProxy({ sourceRoot = defaultSourceRoot, annotationGuidance } = {}) {
|
||||
function openAiRealtimeProxy({
|
||||
sourceRoot = defaultSourceRoot,
|
||||
annotationGuidance,
|
||||
} = {}) {
|
||||
function install(middlewares) {
|
||||
middlewares.use('/api/openai/hud-summary', handleHudSummary);
|
||||
|
||||
middlewares.use('/api/realtime/debug-log', createDebugLogHandler({ sourceRoot }));
|
||||
middlewares.use(
|
||||
'/api/realtime/debug-log',
|
||||
createDebugLogHandler({ sourceRoot }),
|
||||
);
|
||||
|
||||
middlewares.use('/api/realtime/token', createRealtimeTokenHandler({ annotationGuidance }));
|
||||
middlewares.use(
|
||||
'/api/realtime/token',
|
||||
createRealtimeTokenHandler({ annotationGuidance }),
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
@@ -16,4 +16,12 @@ const OPENAI_REALTIME_CONTEXT_RETENTION_DEFAULT = 0.5;
|
||||
|
||||
const OPENAI_HUD_SUMMARY_MODEL_DEFAULT = 'gpt-5-nano';
|
||||
|
||||
export { OPENAI_HUD_SUMMARY_MODEL_DEFAULT, OPENAI_REALTIME_MODEL_MINI_DEFAULT, OPENAI_REALTIME_MODEL_DEFAULT, OPENAI_REALTIME_VOICE_DEFAULT, OPENAI_REALTIME_REASONING_DEFAULT, OPENAI_REALTIME_CONTEXT_TOKENS_DEFAULT, OPENAI_REALTIME_CONTEXT_RETENTION_DEFAULT };
|
||||
export {
|
||||
OPENAI_HUD_SUMMARY_MODEL_DEFAULT,
|
||||
OPENAI_REALTIME_MODEL_MINI_DEFAULT,
|
||||
OPENAI_REALTIME_MODEL_DEFAULT,
|
||||
OPENAI_REALTIME_VOICE_DEFAULT,
|
||||
OPENAI_REALTIME_REASONING_DEFAULT,
|
||||
OPENAI_REALTIME_CONTEXT_TOKENS_DEFAULT,
|
||||
OPENAI_REALTIME_CONTEXT_RETENTION_DEFAULT,
|
||||
};
|
||||
@@ -6,32 +6,42 @@ import fs from 'node:fs';
|
||||
const REALTIME_DEBUG_LOG_MAX_BYTES = 8 * 1024 * 1024;
|
||||
|
||||
function createDebugLogHandler({ sourceRoot = defaultSourceRoot } = {}) {
|
||||
const REALTIME_DEBUG_LOG_DIR = path.join(sourceRoot, '.gev-logs');
|
||||
const REALTIME_DEBUG_LOG_FILE = path.join(REALTIME_DEBUG_LOG_DIR, 'realtime-conversations.jsonl');
|
||||
return async (req, res) => {
|
||||
if (req.method !== 'POST') {
|
||||
res.statusCode = 405;
|
||||
res.setHeader('Content-Type', 'application/json');
|
||||
res.end(JSON.stringify({ error: 'Method not allowed' }));
|
||||
return;
|
||||
}
|
||||
const REALTIME_DEBUG_LOG_DIR = path.join(sourceRoot, '.gev-logs');
|
||||
const REALTIME_DEBUG_LOG_FILE = path.join(
|
||||
REALTIME_DEBUG_LOG_DIR,
|
||||
'realtime-conversations.jsonl',
|
||||
);
|
||||
return async (req, res) => {
|
||||
if (req.method !== 'POST') {
|
||||
res.statusCode = 405;
|
||||
res.setHeader('Content-Type', 'application/json');
|
||||
res.end(JSON.stringify({ error: 'Method not allowed' }));
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const body = await readRequestBody(req, REALTIME_DEBUG_LOG_MAX_BYTES);
|
||||
const record = JSON.parse(body || '{}');
|
||||
fs.mkdirSync(REALTIME_DEBUG_LOG_DIR, { recursive: true });
|
||||
fs.appendFileSync(REALTIME_DEBUG_LOG_FILE, `${JSON.stringify({
|
||||
try {
|
||||
const body = await readRequestBody(req, REALTIME_DEBUG_LOG_MAX_BYTES);
|
||||
const record = JSON.parse(body || '{}');
|
||||
fs.mkdirSync(REALTIME_DEBUG_LOG_DIR, { recursive: true });
|
||||
fs.appendFileSync(
|
||||
REALTIME_DEBUG_LOG_FILE,
|
||||
`${JSON.stringify({
|
||||
loggedAt: new Date().toISOString(),
|
||||
...record,
|
||||
})}\n`);
|
||||
res.statusCode = 204;
|
||||
res.end();
|
||||
} catch (error) {
|
||||
res.statusCode = 400;
|
||||
res.setHeader('Content-Type', 'application/json');
|
||||
res.end(JSON.stringify({ error: error?.message || 'Failed to write Realtime debug log' }));
|
||||
}
|
||||
};
|
||||
})}\n`,
|
||||
);
|
||||
res.statusCode = 204;
|
||||
res.end();
|
||||
} catch (error) {
|
||||
res.statusCode = 400;
|
||||
res.setHeader('Content-Type', 'application/json');
|
||||
res.end(
|
||||
JSON.stringify({
|
||||
error: error?.message || 'Failed to write Realtime debug log',
|
||||
}),
|
||||
);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export { createDebugLogHandler };
|
||||
@@ -9,7 +9,7 @@ function extractOpenAiResponseText(data) {
|
||||
}
|
||||
if (!Array.isArray(data?.output)) return '';
|
||||
return data.output
|
||||
.flatMap((item) => Array.isArray(item?.content) ? item.content : [])
|
||||
.flatMap((item) => (Array.isArray(item?.content) ? item.content : []))
|
||||
.map((part) => part?.text || part?.output_text || '')
|
||||
.join(' ')
|
||||
.trim();
|
||||
@@ -26,65 +26,75 @@ function toFiveWordHudSummary(value) {
|
||||
}
|
||||
|
||||
async function handleHudSummary(req, res) {
|
||||
if (req.method !== 'POST') {
|
||||
res.statusCode = 405;
|
||||
res.setHeader('Content-Type', 'application/json');
|
||||
res.end(JSON.stringify({ error: 'Method not allowed' }));
|
||||
return;
|
||||
}
|
||||
if (req.method !== 'POST') {
|
||||
res.statusCode = 405;
|
||||
res.setHeader('Content-Type', 'application/json');
|
||||
res.end(JSON.stringify({ error: 'Method not allowed' }));
|
||||
return;
|
||||
}
|
||||
|
||||
const apiKey = process.env.OPENAI_API_KEY;
|
||||
const keyless = keylessHudSummaryResponse(apiKey);
|
||||
if (keyless) {
|
||||
res.statusCode = keyless.statusCode;
|
||||
res.setHeader('Content-Type', 'application/json; charset=utf-8');
|
||||
res.setHeader('Cache-Control', 'no-store');
|
||||
res.end(JSON.stringify(keyless.payload));
|
||||
return;
|
||||
}
|
||||
const apiKey = process.env.OPENAI_API_KEY;
|
||||
const keyless = keylessHudSummaryResponse(apiKey);
|
||||
if (keyless) {
|
||||
res.statusCode = keyless.statusCode;
|
||||
res.setHeader('Content-Type', 'application/json; charset=utf-8');
|
||||
res.setHeader('Cache-Control', 'no-store');
|
||||
res.end(JSON.stringify(keyless.payload));
|
||||
return;
|
||||
}
|
||||
|
||||
// Opt-in per-IP throttle (GEV_RATELIMIT_OPENAI_PER_MIN). Keyless HUD
|
||||
// fallback has no provider cost and resolves above without consuming a
|
||||
// paid-endpoint quota slot.
|
||||
if (!enforceOptInRateLimit(openAiRateLimiter(), req, res)) return;
|
||||
// Opt-in per-IP throttle (GEV_RATELIMIT_OPENAI_PER_MIN). Keyless HUD
|
||||
// fallback has no provider cost and resolves above without consuming a
|
||||
// paid-endpoint quota slot.
|
||||
if (!enforceOptInRateLimit(openAiRateLimiter(), req, res)) return;
|
||||
|
||||
try {
|
||||
const body = await readRequestBody(req, 64 * 1024);
|
||||
const context = JSON.parse(body || '{}');
|
||||
const response = await fetch('https://api.openai.com/v1/responses', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${apiKey}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: process.env.OPENAI_HUD_SUMMARY_MODEL || OPENAI_HUD_SUMMARY_MODEL_DEFAULT,
|
||||
instructions: [
|
||||
"Write one concise intelligence-HUD summary for God's Eye View.",
|
||||
'Use only the supplied place, street, nearby-place, and enabled-layer text labels.',
|
||||
'Prefer the clearest named place and include a relevant enabled layer only when useful.',
|
||||
'Do not infer from coordinates or invent a place.',
|
||||
'Output exactly five words with no title, punctuation, markdown, or introductory phrase.',
|
||||
].join(' '),
|
||||
input: JSON.stringify(context),
|
||||
reasoning: { effort: 'minimal' },
|
||||
max_output_tokens: 100,
|
||||
}),
|
||||
});
|
||||
const data = await response.json().catch(() => ({}));
|
||||
const summary = toFiveWordHudSummary(extractOpenAiResponseText(data));
|
||||
res.statusCode = response.ok && summary ? 200 : response.status || 502;
|
||||
res.setHeader('Content-Type', 'application/json; charset=utf-8');
|
||||
res.setHeader('Cache-Control', 'no-store');
|
||||
res.end(JSON.stringify({
|
||||
summary: summary || null,
|
||||
error: response.ok ? null : data.error?.message || 'OpenAI HUD summary request failed',
|
||||
}));
|
||||
} catch (error) {
|
||||
res.statusCode = 502;
|
||||
res.setHeader('Content-Type', 'application/json');
|
||||
res.end(JSON.stringify({ error: error?.message || 'OpenAI HUD summary request failed' }));
|
||||
}
|
||||
}
|
||||
try {
|
||||
const body = await readRequestBody(req, 64 * 1024);
|
||||
const context = JSON.parse(body || '{}');
|
||||
const response = await fetch('https://api.openai.com/v1/responses', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${apiKey}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model:
|
||||
process.env.OPENAI_HUD_SUMMARY_MODEL ||
|
||||
OPENAI_HUD_SUMMARY_MODEL_DEFAULT,
|
||||
instructions: [
|
||||
"Write one concise intelligence-HUD summary for God's Eye View.",
|
||||
'Use only the supplied place, street, nearby-place, and enabled-layer text labels.',
|
||||
'Prefer the clearest named place and include a relevant enabled layer only when useful.',
|
||||
'Do not infer from coordinates or invent a place.',
|
||||
'Output exactly five words with no title, punctuation, markdown, or introductory phrase.',
|
||||
].join(' '),
|
||||
input: JSON.stringify(context),
|
||||
reasoning: { effort: 'minimal' },
|
||||
max_output_tokens: 100,
|
||||
}),
|
||||
});
|
||||
const data = await response.json().catch(() => ({}));
|
||||
const summary = toFiveWordHudSummary(extractOpenAiResponseText(data));
|
||||
res.statusCode = response.ok && summary ? 200 : response.status || 502;
|
||||
res.setHeader('Content-Type', 'application/json; charset=utf-8');
|
||||
res.setHeader('Cache-Control', 'no-store');
|
||||
res.end(
|
||||
JSON.stringify({
|
||||
summary: summary || null,
|
||||
error: response.ok
|
||||
? null
|
||||
: data.error?.message || 'OpenAI HUD summary request failed',
|
||||
}),
|
||||
);
|
||||
} catch (error) {
|
||||
res.statusCode = 502;
|
||||
res.setHeader('Content-Type', 'application/json');
|
||||
res.end(
|
||||
JSON.stringify({
|
||||
error: error?.message || 'OpenAI HUD summary request failed',
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export { handleHudSummary };
|
||||
@@ -1,71 +1,73 @@
|
||||
const DEFAULT_ANNOTATION_GUIDANCE =
|
||||
'Use a single annotate_map call with several annotations when you are describing multiple related places at once. Set flyTo true only when the user is not already looking at the place; if every mark in a call lands off-screen the app auto-frames them, so when unsure leave flyTo false. Do NOT say out loud that you are drawing, highlighting, or annotating — just speak naturally about the places while the marks appear. ANNOTATIONS ACCUMULATE AND PERSIST — keep adding marks as you explore; you can fly around, change topic, and jump between far-apart places and the marks STAY, so the user can build up the map and show people things. Do NOT clear on your own initiative: never pass clearPrevious, and call clear_annotations ONLY when the user EXPLICITLY asks to clear or reset the map.';
|
||||
|
||||
const DEFAULT_ANNOTATION_GUIDANCE = 'Use a single annotate_map call with several annotations when you are describing multiple related places at once. Set flyTo true only when the user is not already looking at the place; if every mark in a call lands off-screen the app auto-frames them, so when unsure leave flyTo false. Do NOT say out loud that you are drawing, highlighting, or annotating — just speak naturally about the places while the marks appear. ANNOTATIONS ACCUMULATE AND PERSIST — keep adding marks as you explore; you can fly around, change topic, and jump between far-apart places and the marks STAY, so the user can build up the map and show people things. Do NOT clear on your own initiative: never pass clearPrevious, and call clear_annotations ONLY when the user EXPLICITLY asks to clear or reset the map.';
|
||||
|
||||
function realtimeInstructions(annotationGuidance = DEFAULT_ANNOTATION_GUIDANCE) {
|
||||
return [
|
||||
"You are GEV Voice Control, a concise voice controller for a Cesium geospatial app called God's Eye View.",
|
||||
'Have a natural spoken conversation with the user while the mic session is active.',
|
||||
'Do not require a wake phrase. Treat direct commands like "zoom into London" or "open datacenters" as GEV control requests.',
|
||||
'Only control the app by calling the provided tools. Never invent tool names or arguments.',
|
||||
'Call tools only for clear GEV control, navigation, visual-style, layer, or app-state requests. For ordinary conversation, answer normally without tools.',
|
||||
'For requests to open, show, reveal, or focus a menu/panel, call set_panel_open or show_data_layers_menu. "Open Context" means only set_panel_open{panelId:"global-context-panel",open:true}; it does not activate a Context sub-mode. "Open Contacts" means set_context_mode{mode:"contacts"}; that action expands the parent Context panel before activating Contacts.',
|
||||
'For requests like "show me the datacenter layers", open the data layers menu and focus the matching layer row; do not enable the layer unless the user asks to turn it on.',
|
||||
'For questions like "what am I looking at?", "what is in view?", "what is this?", "that selected thing", nearby datacenter, dam, cable, ship, or current view contents, call get_entity_context first, then answer from the returned scene/entity context.',
|
||||
'For "what is this aircraft?" answers, read the callsign, operator, registration, type, and route only from get_entity_context selected.properties. Treat route, routeOrigin, and routeDestination as the only authoritative route fields. Every aircraft identity answer MUST explicitly cover operator, type, and route. When a route is present, repeat its endpoint codes exactly; do not expand airport codes into city names. For a missing field say exactly "Operator details are unavailable", "Aircraft type is unavailable", or "Route details are unavailable" as applicable. Never silently omit missing enrichment or infer it from the callsign.',
|
||||
'While a camera motion or route flight is active, a bare "stop" means move_camera{motion:stop} — NOT control_scene and NOT stop_tracking (those need explicit words like "stop the scene" / "stop tracking"). If move_camera stop returns stopped:false and an entity is being tracked, call stop_tracking next — the user means "stop whatever is moving". Flying somewhere while tracking automatically stops the tracking (the result says so): mention it briefly.',
|
||||
'For camera-motion requests — "orbit around this", "pan left", "tilt up", "stop moving" — call move_camera. For "fly the route" over a drawn route, call fly_route. Confirm with the RESULTING state ("Orbiting slowly", "Flying the route").',
|
||||
'analyst_query ANSWERS questions; it never moves the camera or starts tracking. For requests to FOLLOW or TRACK a specific aircraft/ship, call track_entity (get_entity_context first when the target is ambiguous), never analyst_query as the final or only action. For "follow/track the nearest aircraft", first call analyst_query with the aircraft layer(s), sortBy=distance, and limit=1, then call track_entity with the returned aircraft identity in the same turn. The lookup alone does not fulfill a follow/track command.',
|
||||
'For a request to enable an aircraft layer and SELECT or FIND the nearest/closest aircraft near a named place — for example, "Turn on flights and select the closest aircraft to Austin" — call select_nearest_aircraft once. It atomically turns on the requested aircraft layer first, waits for location arrival, refreshes that layer for the destination viewport, filters out landed/on-ground records, and selects the nearest airborne result. A healthy fallback feed is valid data: report the returned feed source briefly, never call it an enable failure. Do not also call fly_to_location, set_layer_visibility, analyst_query, track_entity, set_context_mode, or control_cockpit for the same request. SELECT/FIND never implies Contacts or Cockpit unless the user explicitly asks for either mode.',
|
||||
'For ANALYTICAL questions about layer data — how many / which / fastest / highest / biggest / nearest flights, ships, fires, or earthquakes ("how many flights over Texas", "biggest fire near LA", "which ships are headed to Oakland", "anything above 40,000 feet") — call analyst_query, not get_entity_context. Narrate the count plus two or three notable examples by name, and reflect the result\'s coverage note honestly: the answer covers data loaded by enabled layers, not the whole world. If the needed layer is disabled, say so and offer to enable it. For follow-ups about the same set ("which of THOSE is closest?"), call analyst_query with followUp=true and only the new filter/sort.',
|
||||
'COUNTING CONTRACT — what "near" means. (1) While Contacts is ACTIVE, "near / nearby / how many aircraft" means the Contacts window: answer from contactsWindow in the tool result — those are the exact numbers on the user\'s panel. set_context_mode, analyst_query, and get_current_view_state carry it after Contacts settles. For "Open Contacts and tell me how many aircraft are within 250 km", call set_context_mode{mode:"contacts"} first and answer from contactsWindow.aircraft; do not answer from a pre-Contacts analyst query. analyst_query\'s own count measures currently-loaded records and is usually lower; never give it as the window count. CENTER PRECEDENCE for a nearby/how-many ask, in order: an explicit place in the question ("over Texas", "near Austin") always wins and ignores Contacts state; else the CONTACTS SUBJECT when Contacts is active and has one — a selected datacenter, dam, fire, or cable does NOT silently become the center; else an entity the user explicitly names ("around this datacenter"); else the current view, said aloud ("nothing is selected, so this is the current view"). With Contacts active but NO subject yet, use the view and say so; never read an empty panel. (2) With Contacts OFF, "nearby" means in view; "near <place>" means a radius around that place. (3) EVERY count names its scope in words — "42 in your window", "8 in view", "about 30 within 250 km of Austin" — never a bare number; analyst_query returns scopeLabel for exactly this. Two different numbers with named scopes are not a contradiction; say both if asked. (4) State counts VERBATIM — never estimate, round, or hedge ("a few", "less than a dozen"): if a tool returns 46, say 46. (5) When it matters, add once: counts cover loaded data, and the flights layer loads where you look.',
|
||||
'While Cockpit is active, navigate with control_cockpit (next/previous, optionally targetLayer or aircraftClass). track_entity and fly_to_location are REFUSED by design while Cockpit owns the camera — that refusal is correct, not an error to retry. To go somewhere else, exit Cockpit first. control_cockpit enter establishes Contacts itself, so do not call set_context_mode before or after it.',
|
||||
'When the target layer is unknown, OMIT layerId in track_entity so it searches all enabled layers. Passing the wrong layerId ("flights" for a military contact) returns "Nothing matched" even though the contact is loaded.',
|
||||
'If get_entity_context has no selected object or overlay entities, use its basemap context: Google Photorealistic 3D Tiles/Cesium source, center target coordinates, reverse-geocoded place, camera altitude, active style, and enabled layers. Do not say there is nothing unless the basemap target is also unavailable.',
|
||||
'If basemap context includes knownLandmarks, prefer the nearest known landmark by name for "what am I looking at" answers. For example, if knownLandmarks includes Eiffel Tower, say Eiffel Tower.',
|
||||
'At local zoom, use basemap nearbyPlaces, place.labels, viewportPlaces.visibleLabels, and viewportPlaces.streetLabels to identify the building, premises, roads, and named places visible around the screen target.',
|
||||
'If basemap context includes viewportPlaces, prefer dominantCountry, dominantRegion, and dominantLocality over raw coordinates.',
|
||||
'When basemap context includes viewportSamples or an inferred country, trust that over a single reverse-geocoded address. If most samples indicate Iran, say Iran, not the United States.',
|
||||
'When a viewport screenshot is attached after get_entity_context, read clearly legible street, building, and place labels from it and combine them with structured label context. Respect scene viewScale: at global/continental/regional scale, avoid naming a precise street/city from one center pixel.',
|
||||
'Do not mention disabled layers or stale selections.',
|
||||
'When a request requires a tool call, do not speak in the same response as the tool call. Call the tool first.',
|
||||
'When a single user request contains MULTIPLE changes (e.g. "switch to operator layout, use balanced detection at density 50, and switch to Bing aerial"), call ALL the corresponding tools — multiple tool calls in sequence — before speaking. Never confirm a partial subset. If a later tool fails, say which parts succeeded and which failed.',
|
||||
'After receiving tool output, speak exactly one short confirmation. Do not repeat the confirmation.',
|
||||
'For "show/open/turn on" layer requests, enable the matching layer. For "hide/close/turn off", disable it.',
|
||||
// INSTRUCTION-ONLY mapping for the two globe-scale named views.
|
||||
//
|
||||
// Both are BROADER than the first-run tiles on purpose. A person
|
||||
// naming layers out loud has chosen them; a tile is a first
|
||||
// impression handed to a stranger. So voice keeps fires in the
|
||||
// environmental view and keeps infrastructure entirely, while the
|
||||
// launcher's ENVIRONMENTAL tile is quakes-only and has no
|
||||
// infrastructure tile at all. See src/firstRunExperience.js for why.
|
||||
//
|
||||
// Fully expressible with tools that already exist, so
|
||||
// GEV_REALTIME_TOOLS is deliberately untouched — deleting this one
|
||||
// string is the whole rollback.
|
||||
'NAMED VIEWS are shorthand for tool calls you already have — there is no "mode" tool for them. Treat ONLY these as the shorthand: "infrastructure mode" / "the infrastructure view" / "show me global infrastructure" means three set_layer_visibility calls (local-datacenters, local-dams, telegeography-submarine-cables) plus zoom_to_globe; "environmental mode" / "earth watch" / "active events", said as the name of a view, means set_layer_visibility for local-firms and earthquakes plus zoom_to_globe. Anything vaguer is NOT this shorthand — an open-ended question about the world or the news is an ordinary question: answer it, or use analyst_query over the layers already on. Never switch a whole view on to answer a question nobody asked to see. When you do run one, make every call before speaking, then give one confirmation naming the resulting state; if the fires layer comes back unavailable because no FIRMS key is configured, say so plainly — the earthquakes still loaded. "Live contacts" and "space missions" are NOT this pattern: they stay set_context_mode{mode:"contacts"} and set_context_mode{mode:"space-missions"}.',
|
||||
'For visual filter requests, call set_visual_style with one of the allowed style IDs.',
|
||||
'Disambiguation table — basemap vs layer vs style: basemap switching requires an explicit stack name — "Bing aerial" means set_map_stack bing-aerial, "aerial with labels" means bing-labels, "OSM"/"road map" means osm, "Esri"/"Esri imagery" means esri-imagery, "Google 3D"/"photorealistic" means photoreal. Any mention of "satellite" or "satellites" ALWAYS means the satellites DATA LAYER via set_layer_visibility, never a basemap. "surveillance"/"night vision"/"thermal" are visual STYLES via set_visual_style.',
|
||||
'HUD requests ("hud on/off", "switch to operator/minimal/tactical layout") use set_hud. Detection requests ("detection on", "dense mode", "balanced mode", "sparse mode", "set density to 25", "use weighted allocation") use set_detection. Density snaps to 0/25/50/75/100 and derives Sparse/Balanced/Dense; panoptic is a legacy alias for Dense.',
|
||||
'Bloom/sharpen requests use set_post_processing. Scene requests ("play orbital watch", "stop the scene", "what scenes are there") use control_scene. CCTV camera requests ("next camera", "nearest camera", "select the Congress camera", "show coverage") use control_cctv — the CCTV layer must be enabled first.',
|
||||
'Radio playback requests use control_radio. "Turn on/start the radio" means action=play; action=enable only reveals Radio markers and must be reserved for explicit "show/enable the Radio layer/markers" requests. After a prepared playback result, briefly confirm any other completed actions and say "Turning on the radio"—never claim it is already playing. The client keeps Radio muted until playback is verified, then closes voice before restoring Radio volume. Examples: "play news near Austin" → select category=news locationId=austin; "play US news" → select category=news country=US; "Radio volume 30" → volume; pause/resume/stop/next/previous use the matching action. Radio selection never moves the camera.',
|
||||
'"Track/follow <something specific>" (a callsign, ship name, satellite name) uses track_entity. "Take me to the biggest fire" uses track_entity with query "biggest fire" (the fires layer must be enabled). Bare "orbit" means camera orbit of the current landmark. "Stop following/tracking" uses stop_tracking.',
|
||||
'"Show me which planes are overhead"/"frame the ships"/"show me the satellites above" use frame_overhead with the matching target.',
|
||||
"After frame_overhead, speak ONLY from the tool result's count field — e.g. 'Framed fourteen aircraft, labels on'; never reassess or second-guess the count aloud.",
|
||||
'Confirmations echo the RESULTING state, never the request: "HUD operator layout", "Density twenty-five percent", "Bing aerial imagery", "Tracking UAL428", "Framed fourteen aircraft". On ok=false, state the failure plainly: "Nothing matched UAL999", "No ships within 120 kilometers". Never claim an action without ok=true in the tool result.',
|
||||
'For destination requests such as "take me to Italy", "go to NYC", or "show me the Eiffel Tower", call fly_to_location. Prefer known city IDs when available; otherwise pass the plain place query.',
|
||||
'Navigation-only requests ("take me to X", "go to X", "fly to X") are NOT descriptions: call fly_to_location alone and do NOT also call annotate_map, unless the user explicitly asks to mark the place or you go on to explain specific places there. Never drop a point pin on a region-scale natural feature (a mountain range, desert, sea, or forest) — a single point in the middle of the Rockies is meaningless. If the user explicitly asks to mark such a region, prefer type=area.',
|
||||
'For country and city destinations, omit rangeM so GEV frames the whole country or city in view. For landmarks and buildings, omit rangeM so GEV chooses a close landmark view.',
|
||||
'Only supply rangeM when the user asks for a particular numeric height, distance, closer view, or wider view.',
|
||||
'For relative requests such as "zoom out a little", "pull back", "zoom in more", or "get closer", always call adjust_camera_zoom. But "globe view", "whole earth", "the whole planet", or "zoom all the way out" is an ABSOLUTE framing: call zoom_to_globe once instead — repeated adjust_camera_zoom calls can never reach the globe. Never claim the camera moved without the tool returning ok=true.',
|
||||
'Keep spoken confirmations short, e.g. "Opening datacenters" or "Flying to London".',
|
||||
'WHITEBOARD THE WORLD: whenever you describe or explain a specific place, building, campus, district, boundary, or a spatial relationship between places, call annotate_map to mark it visually as you talk — like sketching on the map. To call out a specific building, campus, compound, park, or district, use type=area (it traces and encloses the real footprint — a building gets a glowing volume, a district gets a draped outline). Use type=highlight only for a transient pulse on a precise spot that has no meaningful footprint, and type=pin to drop a labeled marker. Examples: "what is the Palace of Fine Arts?" → an AREA on it; "the old military base next to it" → an AREA on the Presidio; "ILM is right here" → a pin; "it sits next to the Marina" → an arrow from one to the other. Prefer place NAMES so the app resolves real positions and outlines; never invent coordinates or pixel locations.',
|
||||
'On every annotation, also set entityKind to what the thing IS when you know it: building (one structure), compound (campus/grounds/mall/park), district (neighborhood/area of a city), street (a named road), or point_feature (a monument, statue, memorial, plaque, fountain, or other small point landmark). entityKind is a FACT about the target, independent of the mark type you chose — monuments and statues are point_feature even when you use type=area; the app then anchors them as precise points instead of guessing at a footprint.',
|
||||
annotationGuidance,
|
||||
'If an annotate_map result has partial:true or any failedLabels, do not pretend those places appeared — briefly work into your narration that you could not pinpoint them (e.g. "I couldn\'t place X"). If a route comes back as a direct line (no street route was found), describe it as a straight-line distance, not a walking/driving time. If an annotate_map result has capped:true, the map is full — ASK the user whether to clear before drawing more; do not clear unprompted. outlinePending:true is NOT a failure, but it is also NOT an outline: the anchor mark is placed and the boundary is still being traced in the background. Narrate it in progress — e.g. "tracing the boundary now" — and NEVER state the outline is already drawn or visible; it may yet come back as just a point. A later system item of type map_annotation_outline reports the final outcome per mark (status resolved or failed, with its label): use it to quietly confirm, or to correct yourself if you implied a boundary that stayed a point — an honest miss beats a misleading guess.',
|
||||
'PREFER NAMES. Only when you cannot name or geocode a place but you can clearly SEE the exact spot in the most recent viewport screenshot, fall back to screenX/screenY (normalized 0..1 from that image) to point at it; the app converts the pixel to a real world point. Never use screenX/screenY for something you could name.',
|
||||
'PATHS vs DISTANCES: for "walking/driving route from A to B" (or through several stops), use type=route with the ordered points and the matching mode (walking/driving/cycling) — the app draws the real street-following path on the map and reports distance and travel time, which you can read aloud. For "how far is X from Y", "is it nearby", or "X is next to Y", use type=arrow between the two — it draws a floating connector and shows the straight-line distance. Do NOT use route for a simple distance/proximity question.',
|
||||
].join('\n');
|
||||
function realtimeInstructions(
|
||||
annotationGuidance = DEFAULT_ANNOTATION_GUIDANCE,
|
||||
) {
|
||||
return [
|
||||
"You are GEV Voice Control, a concise voice controller for a Cesium geospatial app called God's Eye View.",
|
||||
'Have a natural spoken conversation with the user while the mic session is active.',
|
||||
'Do not require a wake phrase. Treat direct commands like "zoom into London" or "open datacenters" as GEV control requests.',
|
||||
'Only control the app by calling the provided tools. Never invent tool names or arguments.',
|
||||
'Call tools only for clear GEV control, navigation, visual-style, layer, or app-state requests. For ordinary conversation, answer normally without tools.',
|
||||
'For requests to open, show, reveal, or focus a menu/panel, call set_panel_open or show_data_layers_menu. "Open Context" means only set_panel_open{panelId:"global-context-panel",open:true}; it does not activate a Context sub-mode. "Open Contacts" means set_context_mode{mode:"contacts"}; that action expands the parent Context panel before activating Contacts.',
|
||||
'For requests like "show me the datacenter layers", open the data layers menu and focus the matching layer row; do not enable the layer unless the user asks to turn it on.',
|
||||
'For questions like "what am I looking at?", "what is in view?", "what is this?", "that selected thing", nearby datacenter, dam, cable, ship, or current view contents, call get_entity_context first, then answer from the returned scene/entity context.',
|
||||
'For "what is this aircraft?" answers, read the callsign, operator, registration, type, and route only from get_entity_context selected.properties. Treat route, routeOrigin, and routeDestination as the only authoritative route fields. Every aircraft identity answer MUST explicitly cover operator, type, and route. When a route is present, repeat its endpoint codes exactly; do not expand airport codes into city names. For a missing field say exactly "Operator details are unavailable", "Aircraft type is unavailable", or "Route details are unavailable" as applicable. Never silently omit missing enrichment or infer it from the callsign.',
|
||||
'While a camera motion or route flight is active, a bare "stop" means move_camera{motion:stop} — NOT control_scene and NOT stop_tracking (those need explicit words like "stop the scene" / "stop tracking"). If move_camera stop returns stopped:false and an entity is being tracked, call stop_tracking next — the user means "stop whatever is moving". Flying somewhere while tracking automatically stops the tracking (the result says so): mention it briefly.',
|
||||
'For camera-motion requests — "orbit around this", "pan left", "tilt up", "stop moving" — call move_camera. For "fly the route" over a drawn route, call fly_route. Confirm with the RESULTING state ("Orbiting slowly", "Flying the route").',
|
||||
'analyst_query ANSWERS questions; it never moves the camera or starts tracking. For requests to FOLLOW or TRACK a specific aircraft/ship, call track_entity (get_entity_context first when the target is ambiguous), never analyst_query as the final or only action. For "follow/track the nearest aircraft", first call analyst_query with the aircraft layer(s), sortBy=distance, and limit=1, then call track_entity with the returned aircraft identity in the same turn. The lookup alone does not fulfill a follow/track command.',
|
||||
'For a request to enable an aircraft layer and SELECT or FIND the nearest/closest aircraft near a named place — for example, "Turn on flights and select the closest aircraft to Austin" — call select_nearest_aircraft once. It atomically turns on the requested aircraft layer first, waits for location arrival, refreshes that layer for the destination viewport, filters out landed/on-ground records, and selects the nearest airborne result. A healthy fallback feed is valid data: report the returned feed source briefly, never call it an enable failure. Do not also call fly_to_location, set_layer_visibility, analyst_query, track_entity, set_context_mode, or control_cockpit for the same request. SELECT/FIND never implies Contacts or Cockpit unless the user explicitly asks for either mode.',
|
||||
'For ANALYTICAL questions about layer data — how many / which / fastest / highest / biggest / nearest flights, ships, fires, or earthquakes ("how many flights over Texas", "biggest fire near LA", "which ships are headed to Oakland", "anything above 40,000 feet") — call analyst_query, not get_entity_context. Narrate the count plus two or three notable examples by name, and reflect the result\'s coverage note honestly: the answer covers data loaded by enabled layers, not the whole world. If the needed layer is disabled, say so and offer to enable it. For follow-ups about the same set ("which of THOSE is closest?"), call analyst_query with followUp=true and only the new filter/sort.',
|
||||
'COUNTING CONTRACT — what "near" means. (1) While Contacts is ACTIVE, "near / nearby / how many aircraft" means the Contacts window: answer from contactsWindow in the tool result — those are the exact numbers on the user\'s panel. set_context_mode, analyst_query, and get_current_view_state carry it after Contacts settles. For "Open Contacts and tell me how many aircraft are within 250 km", call set_context_mode{mode:"contacts"} first and answer from contactsWindow.aircraft; do not answer from a pre-Contacts analyst query. analyst_query\'s own count measures currently-loaded records and is usually lower; never give it as the window count. CENTER PRECEDENCE for a nearby/how-many ask, in order: an explicit place in the question ("over Texas", "near Austin") always wins and ignores Contacts state; else the CONTACTS SUBJECT when Contacts is active and has one — a selected datacenter, dam, fire, or cable does NOT silently become the center; else an entity the user explicitly names ("around this datacenter"); else the current view, said aloud ("nothing is selected, so this is the current view"). With Contacts active but NO subject yet, use the view and say so; never read an empty panel. (2) With Contacts OFF, "nearby" means in view; "near <place>" means a radius around that place. (3) EVERY count names its scope in words — "42 in your window", "8 in view", "about 30 within 250 km of Austin" — never a bare number; analyst_query returns scopeLabel for exactly this. Two different numbers with named scopes are not a contradiction; say both if asked. (4) State counts VERBATIM — never estimate, round, or hedge ("a few", "less than a dozen"): if a tool returns 46, say 46. (5) When it matters, add once: counts cover loaded data, and the flights layer loads where you look.',
|
||||
'While Cockpit is active, navigate with control_cockpit (next/previous, optionally targetLayer or aircraftClass). track_entity and fly_to_location are REFUSED by design while Cockpit owns the camera — that refusal is correct, not an error to retry. To go somewhere else, exit Cockpit first. control_cockpit enter establishes Contacts itself, so do not call set_context_mode before or after it.',
|
||||
'When the target layer is unknown, OMIT layerId in track_entity so it searches all enabled layers. Passing the wrong layerId ("flights" for a military contact) returns "Nothing matched" even though the contact is loaded.',
|
||||
'If get_entity_context has no selected object or overlay entities, use its basemap context: Google Photorealistic 3D Tiles/Cesium source, center target coordinates, reverse-geocoded place, camera altitude, active style, and enabled layers. Do not say there is nothing unless the basemap target is also unavailable.',
|
||||
'If basemap context includes knownLandmarks, prefer the nearest known landmark by name for "what am I looking at" answers. For example, if knownLandmarks includes Eiffel Tower, say Eiffel Tower.',
|
||||
'At local zoom, use basemap nearbyPlaces, place.labels, viewportPlaces.visibleLabels, and viewportPlaces.streetLabels to identify the building, premises, roads, and named places visible around the screen target.',
|
||||
'If basemap context includes viewportPlaces, prefer dominantCountry, dominantRegion, and dominantLocality over raw coordinates.',
|
||||
'When basemap context includes viewportSamples or an inferred country, trust that over a single reverse-geocoded address. If most samples indicate Iran, say Iran, not the United States.',
|
||||
'When a viewport screenshot is attached after get_entity_context, read clearly legible street, building, and place labels from it and combine them with structured label context. Respect scene viewScale: at global/continental/regional scale, avoid naming a precise street/city from one center pixel.',
|
||||
'Do not mention disabled layers or stale selections.',
|
||||
'When a request requires a tool call, do not speak in the same response as the tool call. Call the tool first.',
|
||||
'When a single user request contains MULTIPLE changes (e.g. "switch to operator layout, use balanced detection at density 50, and switch to Bing aerial"), call ALL the corresponding tools — multiple tool calls in sequence — before speaking. Never confirm a partial subset. If a later tool fails, say which parts succeeded and which failed.',
|
||||
'After receiving tool output, speak exactly one short confirmation. Do not repeat the confirmation.',
|
||||
'For "show/open/turn on" layer requests, enable the matching layer. For "hide/close/turn off", disable it.',
|
||||
// INSTRUCTION-ONLY mapping for the two globe-scale named views.
|
||||
//
|
||||
// Both are BROADER than the first-run tiles on purpose. A person
|
||||
// naming layers out loud has chosen them; a tile is a first
|
||||
// impression handed to a stranger. So voice keeps fires in the
|
||||
// environmental view and keeps infrastructure entirely, while the
|
||||
// launcher's ENVIRONMENTAL tile is quakes-only and has no
|
||||
// infrastructure tile at all. See src/firstRunExperience.js for why.
|
||||
//
|
||||
// Fully expressible with tools that already exist, so
|
||||
// GEV_REALTIME_TOOLS is deliberately untouched — deleting this one
|
||||
// string is the whole rollback.
|
||||
'NAMED VIEWS are shorthand for tool calls you already have — there is no "mode" tool for them. Treat ONLY these as the shorthand: "infrastructure mode" / "the infrastructure view" / "show me global infrastructure" means three set_layer_visibility calls (local-datacenters, local-dams, telegeography-submarine-cables) plus zoom_to_globe; "environmental mode" / "earth watch" / "active events", said as the name of a view, means set_layer_visibility for local-firms and earthquakes plus zoom_to_globe. Anything vaguer is NOT this shorthand — an open-ended question about the world or the news is an ordinary question: answer it, or use analyst_query over the layers already on. Never switch a whole view on to answer a question nobody asked to see. When you do run one, make every call before speaking, then give one confirmation naming the resulting state; if the fires layer comes back unavailable because no FIRMS key is configured, say so plainly — the earthquakes still loaded. "Live contacts" and "space missions" are NOT this pattern: they stay set_context_mode{mode:"contacts"} and set_context_mode{mode:"space-missions"}.',
|
||||
'For visual filter requests, call set_visual_style with one of the allowed style IDs.',
|
||||
'Disambiguation table — basemap vs layer vs style: basemap switching requires an explicit stack name — "Bing aerial" means set_map_stack bing-aerial, "aerial with labels" means bing-labels, "OSM"/"road map" means osm, "Esri"/"Esri imagery" means esri-imagery, "Google 3D"/"photorealistic" means photoreal. Any mention of "satellite" or "satellites" ALWAYS means the satellites DATA LAYER via set_layer_visibility, never a basemap. "surveillance"/"night vision"/"thermal" are visual STYLES via set_visual_style.',
|
||||
'HUD requests ("hud on/off", "switch to operator/minimal/tactical layout") use set_hud. Detection requests ("detection on", "dense mode", "balanced mode", "sparse mode", "set density to 25", "use weighted allocation") use set_detection. Density snaps to 0/25/50/75/100 and derives Sparse/Balanced/Dense; panoptic is a legacy alias for Dense.',
|
||||
'Bloom/sharpen requests use set_post_processing. Scene requests ("play orbital watch", "stop the scene", "what scenes are there") use control_scene. CCTV camera requests ("next camera", "nearest camera", "select the Congress camera", "show coverage") use control_cctv — the CCTV layer must be enabled first.',
|
||||
'Radio playback requests use control_radio. "Turn on/start the radio" means action=play; action=enable only reveals Radio markers and must be reserved for explicit "show/enable the Radio layer/markers" requests. After a prepared playback result, briefly confirm any other completed actions and say "Turning on the radio"—never claim it is already playing. The client keeps Radio muted until playback is verified, then closes voice before restoring Radio volume. Examples: "play news near Austin" → select category=news locationId=austin; "play US news" → select category=news country=US; "Radio volume 30" → volume; pause/resume/stop/next/previous use the matching action. Radio selection never moves the camera.',
|
||||
'"Track/follow <something specific>" (a callsign, ship name, satellite name) uses track_entity. "Take me to the biggest fire" uses track_entity with query "biggest fire" (the fires layer must be enabled). Bare "orbit" means camera orbit of the current landmark. "Stop following/tracking" uses stop_tracking.',
|
||||
'"Show me which planes are overhead"/"frame the ships"/"show me the satellites above" use frame_overhead with the matching target.',
|
||||
"After frame_overhead, speak ONLY from the tool result's count field — e.g. 'Framed fourteen aircraft, labels on'; never reassess or second-guess the count aloud.",
|
||||
'Confirmations echo the RESULTING state, never the request: "HUD operator layout", "Density twenty-five percent", "Bing aerial imagery", "Tracking UAL428", "Framed fourteen aircraft". On ok=false, state the failure plainly: "Nothing matched UAL999", "No ships within 120 kilometers". Never claim an action without ok=true in the tool result.',
|
||||
'For destination requests such as "take me to Italy", "go to NYC", or "show me the Eiffel Tower", call fly_to_location. Prefer known city IDs when available; otherwise pass the plain place query.',
|
||||
'Navigation-only requests ("take me to X", "go to X", "fly to X") are NOT descriptions: call fly_to_location alone and do NOT also call annotate_map, unless the user explicitly asks to mark the place or you go on to explain specific places there. Never drop a point pin on a region-scale natural feature (a mountain range, desert, sea, or forest) — a single point in the middle of the Rockies is meaningless. If the user explicitly asks to mark such a region, prefer type=area.',
|
||||
'For country and city destinations, omit rangeM so GEV frames the whole country or city in view. For landmarks and buildings, omit rangeM so GEV chooses a close landmark view.',
|
||||
'Only supply rangeM when the user asks for a particular numeric height, distance, closer view, or wider view.',
|
||||
'For relative requests such as "zoom out a little", "pull back", "zoom in more", or "get closer", always call adjust_camera_zoom. But "globe view", "whole earth", "the whole planet", or "zoom all the way out" is an ABSOLUTE framing: call zoom_to_globe once instead — repeated adjust_camera_zoom calls can never reach the globe. Never claim the camera moved without the tool returning ok=true.',
|
||||
'Keep spoken confirmations short, e.g. "Opening datacenters" or "Flying to London".',
|
||||
'WHITEBOARD THE WORLD: whenever you describe or explain a specific place, building, campus, district, boundary, or a spatial relationship between places, call annotate_map to mark it visually as you talk — like sketching on the map. To call out a specific building, campus, compound, park, or district, use type=area (it traces and encloses the real footprint — a building gets a glowing volume, a district gets a draped outline). Use type=highlight only for a transient pulse on a precise spot that has no meaningful footprint, and type=pin to drop a labeled marker. Examples: "what is the Palace of Fine Arts?" → an AREA on it; "the old military base next to it" → an AREA on the Presidio; "ILM is right here" → a pin; "it sits next to the Marina" → an arrow from one to the other. Prefer place NAMES so the app resolves real positions and outlines; never invent coordinates or pixel locations.',
|
||||
'On every annotation, also set entityKind to what the thing IS when you know it: building (one structure), compound (campus/grounds/mall/park), district (neighborhood/area of a city), street (a named road), or point_feature (a monument, statue, memorial, plaque, fountain, or other small point landmark). entityKind is a FACT about the target, independent of the mark type you chose — monuments and statues are point_feature even when you use type=area; the app then anchors them as precise points instead of guessing at a footprint.',
|
||||
annotationGuidance,
|
||||
'If an annotate_map result has partial:true or any failedLabels, do not pretend those places appeared — briefly work into your narration that you could not pinpoint them (e.g. "I couldn\'t place X"). If a route comes back as a direct line (no street route was found), describe it as a straight-line distance, not a walking/driving time. If an annotate_map result has capped:true, the map is full — ASK the user whether to clear before drawing more; do not clear unprompted. outlinePending:true is NOT a failure, but it is also NOT an outline: the anchor mark is placed and the boundary is still being traced in the background. Narrate it in progress — e.g. "tracing the boundary now" — and NEVER state the outline is already drawn or visible; it may yet come back as just a point. A later system item of type map_annotation_outline reports the final outcome per mark (status resolved or failed, with its label): use it to quietly confirm, or to correct yourself if you implied a boundary that stayed a point — an honest miss beats a misleading guess.',
|
||||
'PREFER NAMES. Only when you cannot name or geocode a place but you can clearly SEE the exact spot in the most recent viewport screenshot, fall back to screenX/screenY (normalized 0..1 from that image) to point at it; the app converts the pixel to a real world point. Never use screenX/screenY for something you could name.',
|
||||
'PATHS vs DISTANCES: for "walking/driving route from A to B" (or through several stops), use type=route with the ordered points and the matching mode (walking/driving/cycling) — the app draws the real street-following path on the map and reports distance and travel time, which you can read aloud. For "how far is X from Y", "is it nearby", or "X is next to Y", use type=arrow between the two — it draws a floating connector and shows the straight-line distance. Do NOT use route for a simple distance/proximity question.',
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
export { realtimeInstructions };
|
||||
@@ -9,7 +9,10 @@ let _openAiRateLimiter;
|
||||
|
||||
/** OpenAI cost endpoints (realtime/token + hud-summary). Null = unlimited (default). */
|
||||
function openAiRateLimiter() {
|
||||
if (_openAiRateLimiter === undefined) _openAiRateLimiter = makeOptInRateLimiter(process.env.GEV_RATELIMIT_OPENAI_PER_MIN);
|
||||
if (_openAiRateLimiter === undefined)
|
||||
_openAiRateLimiter = makeOptInRateLimiter(
|
||||
process.env.GEV_RATELIMIT_OPENAI_PER_MIN,
|
||||
);
|
||||
return _openAiRateLimiter;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,89 +1,117 @@
|
||||
import { enforceOptInRateLimit, openAiRateLimiter } from './rate-limit.js';
|
||||
import { resolveVoiceModel, isKnownVoiceTier } from '../../../src/voice/voiceCost.js';
|
||||
import { OPENAI_REALTIME_MODEL_MINI_DEFAULT, OPENAI_REALTIME_MODEL_DEFAULT, OPENAI_REALTIME_VOICE_DEFAULT, OPENAI_REALTIME_REASONING_DEFAULT, OPENAI_REALTIME_CONTEXT_TOKENS_DEFAULT, OPENAI_REALTIME_CONTEXT_RETENTION_DEFAULT } from './constants.js';
|
||||
import {
|
||||
resolveVoiceModel,
|
||||
isKnownVoiceTier,
|
||||
} from '../../../src/voice/voiceCost.js';
|
||||
import {
|
||||
OPENAI_REALTIME_MODEL_MINI_DEFAULT,
|
||||
OPENAI_REALTIME_MODEL_DEFAULT,
|
||||
OPENAI_REALTIME_VOICE_DEFAULT,
|
||||
OPENAI_REALTIME_REASONING_DEFAULT,
|
||||
OPENAI_REALTIME_CONTEXT_TOKENS_DEFAULT,
|
||||
OPENAI_REALTIME_CONTEXT_RETENTION_DEFAULT,
|
||||
} from './constants.js';
|
||||
import { realtimeInstructions } from './instructions.js';
|
||||
import { GEV_REALTIME_TOOLS } from './tools.js';
|
||||
|
||||
function createRealtimeTokenHandler({ annotationGuidance } = {}) {
|
||||
return async (req, res) => {
|
||||
if (req.method !== 'GET' && req.method !== 'POST') {
|
||||
res.statusCode = 405;
|
||||
res.setHeader('Content-Type', 'application/json');
|
||||
res.end(JSON.stringify({ error: 'Method not allowed' }));
|
||||
return;
|
||||
}
|
||||
return async (req, res) => {
|
||||
if (req.method !== 'GET' && req.method !== 'POST') {
|
||||
res.statusCode = 405;
|
||||
res.setHeader('Content-Type', 'application/json');
|
||||
res.end(JSON.stringify({ error: 'Method not allowed' }));
|
||||
return;
|
||||
}
|
||||
|
||||
// Opt-in per-IP throttle (GEV_RATELIMIT_OPENAI_PER_MIN). No-op when unset.
|
||||
if (!enforceOptInRateLimit(openAiRateLimiter(), req, res)) return;
|
||||
// Opt-in per-IP throttle (GEV_RATELIMIT_OPENAI_PER_MIN). No-op when unset.
|
||||
if (!enforceOptInRateLimit(openAiRateLimiter(), req, res)) return;
|
||||
|
||||
const apiKey = process.env.OPENAI_API_KEY;
|
||||
if (!apiKey) {
|
||||
res.statusCode = 503;
|
||||
res.setHeader('Content-Type', 'application/json');
|
||||
res.end(JSON.stringify({ error: 'OPENAI_API_KEY is not set' }));
|
||||
return;
|
||||
}
|
||||
|
||||
// Voice model tier, requested by the client as ?tier=standard|mini.
|
||||
// resolveVoiceModel is total: an unknown, empty, or hostile value
|
||||
// resolves to `standard` instead of reaching OpenAI as a model id, so a
|
||||
// bad querystring degrades to a normal session rather than a dead mic.
|
||||
// The env overrides stay authoritative per tier (see .env.example) —
|
||||
// a wrong upstream model id is then a config fix, not a code change.
|
||||
const requestedTier = (() => {
|
||||
try {
|
||||
return new URL(req.url || '', 'http://localhost').searchParams.get('tier');
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
})();
|
||||
const tier = resolveVoiceModel(requestedTier).tier;
|
||||
const model =
|
||||
tier === 'mini'
|
||||
? process.env.OPENAI_REALTIME_MODEL_MINI || OPENAI_REALTIME_MODEL_MINI_DEFAULT
|
||||
: process.env.OPENAI_REALTIME_MODEL || OPENAI_REALTIME_MODEL_DEFAULT;
|
||||
const voice = process.env.OPENAI_REALTIME_VOICE || OPENAI_REALTIME_VOICE_DEFAULT;
|
||||
const effort = process.env.OPENAI_REALTIME_REASONING_EFFORT || OPENAI_REALTIME_REASONING_DEFAULT;
|
||||
const contextTokenLimit = Math.round(Math.max(
|
||||
1000,
|
||||
Math.min(12000, Number(process.env.OPENAI_REALTIME_CONTEXT_TOKENS) || OPENAI_REALTIME_CONTEXT_TOKENS_DEFAULT)
|
||||
));
|
||||
const contextRetentionRatio = Math.max(
|
||||
0.1,
|
||||
Math.min(1, Number(process.env.OPENAI_REALTIME_CONTEXT_RETENTION) || OPENAI_REALTIME_CONTEXT_RETENTION_DEFAULT)
|
||||
);
|
||||
const sessionConfig = {
|
||||
session: {
|
||||
type: 'realtime',
|
||||
model,
|
||||
reasoning: { effort },
|
||||
truncation: {
|
||||
type: 'retention_ratio',
|
||||
retention_ratio: contextRetentionRatio,
|
||||
token_limits: {
|
||||
post_instructions: contextTokenLimit,
|
||||
},
|
||||
},
|
||||
audio: {
|
||||
input: {
|
||||
noise_reduction: { type: 'near_field' },
|
||||
turn_detection: {
|
||||
type: 'semantic_vad',
|
||||
eagerness: 'low',
|
||||
create_response: true,
|
||||
interrupt_response: false,
|
||||
},
|
||||
},
|
||||
output: { voice },
|
||||
},
|
||||
instructions: realtimeInstructions(annotationGuidance),
|
||||
tools: GEV_REALTIME_TOOLS,
|
||||
tool_choice: 'auto',
|
||||
},
|
||||
};
|
||||
const apiKey = process.env.OPENAI_API_KEY;
|
||||
if (!apiKey) {
|
||||
res.statusCode = 503;
|
||||
res.setHeader('Content-Type', 'application/json');
|
||||
res.end(JSON.stringify({ error: 'OPENAI_API_KEY is not set' }));
|
||||
return;
|
||||
}
|
||||
|
||||
// Voice model tier, requested by the client as ?tier=standard|mini.
|
||||
// resolveVoiceModel is total: an unknown, empty, or hostile value
|
||||
// resolves to `standard` instead of reaching OpenAI as a model id, so a
|
||||
// bad querystring degrades to a normal session rather than a dead mic.
|
||||
// The env overrides stay authoritative per tier (see .env.example) —
|
||||
// a wrong upstream model id is then a config fix, not a code change.
|
||||
const requestedTier = (() => {
|
||||
try {
|
||||
const response = await fetch('https://api.openai.com/v1/realtime/client_secrets', {
|
||||
return new URL(req.url || '', 'http://localhost').searchParams.get(
|
||||
'tier',
|
||||
);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
})();
|
||||
const tier = resolveVoiceModel(requestedTier).tier;
|
||||
const model =
|
||||
tier === 'mini'
|
||||
? process.env.OPENAI_REALTIME_MODEL_MINI ||
|
||||
OPENAI_REALTIME_MODEL_MINI_DEFAULT
|
||||
: process.env.OPENAI_REALTIME_MODEL || OPENAI_REALTIME_MODEL_DEFAULT;
|
||||
const voice =
|
||||
process.env.OPENAI_REALTIME_VOICE || OPENAI_REALTIME_VOICE_DEFAULT;
|
||||
const effort =
|
||||
process.env.OPENAI_REALTIME_REASONING_EFFORT ||
|
||||
OPENAI_REALTIME_REASONING_DEFAULT;
|
||||
const contextTokenLimit = Math.round(
|
||||
Math.max(
|
||||
1000,
|
||||
Math.min(
|
||||
12000,
|
||||
Number(process.env.OPENAI_REALTIME_CONTEXT_TOKENS) ||
|
||||
OPENAI_REALTIME_CONTEXT_TOKENS_DEFAULT,
|
||||
),
|
||||
),
|
||||
);
|
||||
const contextRetentionRatio = Math.max(
|
||||
0.1,
|
||||
Math.min(
|
||||
1,
|
||||
Number(process.env.OPENAI_REALTIME_CONTEXT_RETENTION) ||
|
||||
OPENAI_REALTIME_CONTEXT_RETENTION_DEFAULT,
|
||||
),
|
||||
);
|
||||
const sessionConfig = {
|
||||
session: {
|
||||
type: 'realtime',
|
||||
model,
|
||||
reasoning: { effort },
|
||||
truncation: {
|
||||
type: 'retention_ratio',
|
||||
retention_ratio: contextRetentionRatio,
|
||||
token_limits: {
|
||||
post_instructions: contextTokenLimit,
|
||||
},
|
||||
},
|
||||
audio: {
|
||||
input: {
|
||||
noise_reduction: { type: 'near_field' },
|
||||
turn_detection: {
|
||||
type: 'semantic_vad',
|
||||
eagerness: 'low',
|
||||
create_response: true,
|
||||
interrupt_response: false,
|
||||
},
|
||||
},
|
||||
output: { voice },
|
||||
},
|
||||
instructions: realtimeInstructions(annotationGuidance),
|
||||
tools: GEV_REALTIME_TOOLS,
|
||||
tool_choice: 'auto',
|
||||
},
|
||||
};
|
||||
|
||||
try {
|
||||
const response = await fetch(
|
||||
'https://api.openai.com/v1/realtime/client_secrets',
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${apiKey}`,
|
||||
@@ -91,26 +119,34 @@ return async (req, res) => {
|
||||
'OpenAI-Safety-Identifier': 'gev-local-dev',
|
||||
},
|
||||
body: JSON.stringify(sessionConfig),
|
||||
});
|
||||
const body = await response.text();
|
||||
res.statusCode = response.status;
|
||||
res.setHeader('Content-Type', response.headers.get('content-type') || 'application/json');
|
||||
// Which tier/model this secret was actually minted for. The upstream
|
||||
// body is passed through untouched (the client parses it verbatim), so
|
||||
// these headers are the authoritative echo — including the case where a
|
||||
// bogus ?tier= was silently downgraded to standard.
|
||||
res.setHeader('X-GEV-Voice-Tier', tier);
|
||||
res.setHeader('X-GEV-Voice-Model', model);
|
||||
if (requestedTier && !isKnownVoiceTier(requestedTier)) {
|
||||
res.setHeader('X-GEV-Voice-Tier-Fallback', '1');
|
||||
}
|
||||
res.end(body);
|
||||
} catch (error) {
|
||||
res.statusCode = 502;
|
||||
res.setHeader('Content-Type', 'application/json');
|
||||
res.end(JSON.stringify({ error: error?.message || 'Failed to create Realtime token' }));
|
||||
},
|
||||
);
|
||||
const body = await response.text();
|
||||
res.statusCode = response.status;
|
||||
res.setHeader(
|
||||
'Content-Type',
|
||||
response.headers.get('content-type') || 'application/json',
|
||||
);
|
||||
// Which tier/model this secret was actually minted for. The upstream
|
||||
// body is passed through untouched (the client parses it verbatim), so
|
||||
// these headers are the authoritative echo — including the case where a
|
||||
// bogus ?tier= was silently downgraded to standard.
|
||||
res.setHeader('X-GEV-Voice-Tier', tier);
|
||||
res.setHeader('X-GEV-Voice-Model', model);
|
||||
if (requestedTier && !isKnownVoiceTier(requestedTier)) {
|
||||
res.setHeader('X-GEV-Voice-Tier-Fallback', '1');
|
||||
}
|
||||
};
|
||||
res.end(body);
|
||||
} catch (error) {
|
||||
res.statusCode = 502;
|
||||
res.setHeader('Content-Type', 'application/json');
|
||||
res.end(
|
||||
JSON.stringify({
|
||||
error: error?.message || 'Failed to create Realtime token',
|
||||
}),
|
||||
);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export { createRealtimeTokenHandler };
|
||||
+455
-105
@@ -1,38 +1,52 @@
|
||||
|
||||
const GEV_REALTIME_TOOLS = [
|
||||
{
|
||||
type: 'function',
|
||||
name: 'fly_to_location',
|
||||
description: "Fly the God's Eye View camera to a known city, geocoded country/region/city/landmark, or explicit WGS84 coordinate. Countries/cities frame the whole place; landmarks/buildings use close framing.",
|
||||
description:
|
||||
"Fly the God's Eye View camera to a known city, geocoded country/region/city/landmark, or explicit WGS84 coordinate. Countries/cities frame the whole place; landmarks/buildings use close framing.",
|
||||
parameters: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
locationId: {
|
||||
type: 'string',
|
||||
enum: ['austin', 'sf', 'nyc', 'tokyo', 'london', 'paris', 'dubai', 'dc'],
|
||||
description: 'Known city preset ID. Use when the requested place matches one of these cities.',
|
||||
enum: [
|
||||
'austin',
|
||||
'sf',
|
||||
'nyc',
|
||||
'tokyo',
|
||||
'london',
|
||||
'paris',
|
||||
'dubai',
|
||||
'dc',
|
||||
],
|
||||
description:
|
||||
'Known city preset ID. Use when the requested place matches one of these cities.',
|
||||
},
|
||||
query: {
|
||||
type: 'string',
|
||||
description: 'Plain place search query, e.g. "London", "Eiffel Tower", or "Dubai Marina".',
|
||||
description:
|
||||
'Plain place search query, e.g. "London", "Eiffel Tower", or "Dubai Marina".',
|
||||
},
|
||||
latitude: { type: 'number', minimum: -90, maximum: 90 },
|
||||
longitude: { type: 'number', minimum: -180, maximum: 180 },
|
||||
viewMode: {
|
||||
type: 'string',
|
||||
enum: ['close', 'overview'],
|
||||
description: 'Optional framing intent. Usually omit this; GEV infers whole-place framing for countries/cities and close framing for landmarks.',
|
||||
description:
|
||||
'Optional framing intent. Usually omit this; GEV infers whole-place framing for countries/cities and close framing for landmarks.',
|
||||
},
|
||||
rangeM: {
|
||||
type: 'number',
|
||||
minimum: 100,
|
||||
maximum: 20000000,
|
||||
description: 'Optional camera range from the target in meters. Omit it for automatic whole-country/whole-city or close-landmark framing; provide it only when the user explicitly requests a numeric height or distance.',
|
||||
description:
|
||||
'Optional camera range from the target in meters. Omit it for automatic whole-country/whole-city or close-landmark framing; provide it only when the user explicitly requests a numeric height or distance.',
|
||||
},
|
||||
waitForArrival: {
|
||||
type: 'boolean',
|
||||
description: 'Set true when a later tool depends on the destination viewport. The result then waits for the camera flight and returns arrived=true; cancellation returns ok=false.',
|
||||
description:
|
||||
'Set true when a later tool depends on the destination viewport. The result then waits for the camera flight and returns arrived=true; cancellation returns ok=false.',
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -40,7 +54,8 @@ const GEV_REALTIME_TOOLS = [
|
||||
{
|
||||
type: 'function',
|
||||
name: 'select_nearest_aircraft',
|
||||
description: 'Atomically fly to a place, wait for arrival, enable and load Flights or Military Flights in that viewport, exclude on-ground records, and select/follow the nearest airborne aircraft. Healthy fallback feeds remain usable and are reported in the result. This does not open Contacts or Cockpit.',
|
||||
description:
|
||||
'Atomically fly to a place, wait for arrival, enable and load Flights or Military Flights in that viewport, exclude on-ground records, and select/follow the nearest airborne aircraft. Healthy fallback feeds remain usable and are reported in the result. This does not open Contacts or Cockpit.',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
@@ -48,12 +63,23 @@ const GEV_REALTIME_TOOLS = [
|
||||
layerId: {
|
||||
type: 'string',
|
||||
enum: ['flights', 'military'],
|
||||
description: 'Aircraft layer to enable and search. Use flights unless the user explicitly asks for military aircraft.',
|
||||
description:
|
||||
'Aircraft layer to enable and search. Use flights unless the user explicitly asks for military aircraft.',
|
||||
},
|
||||
locationId: {
|
||||
type: 'string',
|
||||
enum: ['austin', 'sf', 'nyc', 'tokyo', 'london', 'paris', 'dubai', 'dc'],
|
||||
description: 'Known city preset ID when the place matches one of these cities.',
|
||||
enum: [
|
||||
'austin',
|
||||
'sf',
|
||||
'nyc',
|
||||
'tokyo',
|
||||
'london',
|
||||
'paris',
|
||||
'dubai',
|
||||
'dc',
|
||||
],
|
||||
description:
|
||||
'Known city preset ID when the place matches one of these cities.',
|
||||
},
|
||||
locationQuery: {
|
||||
type: 'string',
|
||||
@@ -69,7 +95,8 @@ const GEV_REALTIME_TOOLS = [
|
||||
{
|
||||
type: 'function',
|
||||
name: 'adjust_camera_zoom',
|
||||
description: 'Move the current Cesium camera closer to or farther from what it is presently looking at. Use for relative zoom requests without changing location.',
|
||||
description:
|
||||
'Move the current Cesium camera closer to or farther from what it is presently looking at. Use for relative zoom requests without changing location.',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
@@ -81,7 +108,8 @@ const GEV_REALTIME_TOOLS = [
|
||||
amount: {
|
||||
type: 'string',
|
||||
enum: ['little', 'medium', 'lot'],
|
||||
description: 'Use little for phrases like "a bit" or "a little", medium for ordinary zoom requests, and lot for "way out/in".',
|
||||
description:
|
||||
'Use little for phrases like "a bit" or "a little", medium for ordinary zoom requests, and lot for "way out/in".',
|
||||
},
|
||||
},
|
||||
required: ['direction', 'amount'],
|
||||
@@ -90,7 +118,8 @@ const GEV_REALTIME_TOOLS = [
|
||||
{
|
||||
type: 'function',
|
||||
name: 'zoom_to_globe',
|
||||
description: 'Pull the camera out to an ABSOLUTE full-Earth globe view (~18,000 km altitude, the whole planet in frame), keeping the current region centered. Use for "globe view", "whole earth", "see the planet", "zoom all the way out". Never use adjust_camera_zoom for these — its relative steps cannot reach the globe.',
|
||||
description:
|
||||
'Pull the camera out to an ABSOLUTE full-Earth globe view (~18,000 km altitude, the whole planet in frame), keeping the current region centered. Use for "globe view", "whole earth", "see the planet", "zoom all the way out". Never use adjust_camera_zoom for these — its relative steps cannot reach the globe.',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
@@ -134,7 +163,8 @@ const GEV_REALTIME_TOOLS = [
|
||||
{
|
||||
type: 'function',
|
||||
name: 'show_data_layers_menu',
|
||||
description: 'Open the data layers dropdown/menu and optionally scroll to a specific layer row without toggling it.',
|
||||
description:
|
||||
'Open the data layers dropdown/menu and optionally scroll to a specific layer row without toggling it.',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
@@ -171,7 +201,16 @@ const GEV_REALTIME_TOOLS = [
|
||||
properties: {
|
||||
panelId: {
|
||||
type: 'string',
|
||||
enum: ['data-panel', 'location-bar', 'control-panel', 'cctv-panel', 'radio-panel', 'scene-panel', 'pp-toggles', 'global-context-panel'],
|
||||
enum: [
|
||||
'data-panel',
|
||||
'location-bar',
|
||||
'control-panel',
|
||||
'cctv-panel',
|
||||
'radio-panel',
|
||||
'scene-panel',
|
||||
'pp-toggles',
|
||||
'global-context-panel',
|
||||
],
|
||||
},
|
||||
open: { type: 'boolean' },
|
||||
},
|
||||
@@ -181,7 +220,8 @@ const GEV_REALTIME_TOOLS = [
|
||||
{
|
||||
type: 'function',
|
||||
name: 'set_context_mode',
|
||||
description: 'Enter or exit the Global Context sub-mode used by Contacts and Space Missions. Use Contacts only when the user explicitly requests Contacts, and Space Missions only when explicitly requested. A request to open the parent Context panel alone uses set_panel_open and must not activate either sub-mode. Selecting an aircraft does not imply Context.',
|
||||
description:
|
||||
'Enter or exit the Global Context sub-mode used by Contacts and Space Missions. Use Contacts only when the user explicitly requests Contacts, and Space Missions only when explicitly requested. A request to open the parent Context panel alone uses set_panel_open and must not activate either sub-mode. Selecting an aircraft does not imply Context.',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
@@ -198,7 +238,8 @@ const GEV_REALTIME_TOOLS = [
|
||||
{
|
||||
type: 'function',
|
||||
name: 'control_cockpit',
|
||||
description: 'Read or control Cockpit when the user explicitly requests Cockpit: establish Contacts and enter from a selected or tracked aircraft; exit; or navigate nearby Contacts with optional filters. Selecting or viewing an aircraft alone must not enter Cockpit.',
|
||||
description:
|
||||
'Read or control Cockpit when the user explicitly requests Cockpit: establish Contacts and enter from a selected or tracked aircraft; exit; or navigate nearby Contacts with optional filters. Selecting or viewing an aircraft alone must not enter Cockpit.',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
@@ -206,16 +247,24 @@ const GEV_REALTIME_TOOLS = [
|
||||
action: {
|
||||
type: 'string',
|
||||
enum: ['enter', 'exit', 'previous', 'next', 'prev', 'status'],
|
||||
description: 'previous/next (or prev) navigates through nearby contacts in Cockpit context.',
|
||||
description:
|
||||
'previous/next (or prev) navigates through nearby contacts in Cockpit context.',
|
||||
},
|
||||
targetLayer: {
|
||||
type: 'string',
|
||||
enum: ['flights', 'military', 'ais-live-vessels', 'military-installations'],
|
||||
description: 'Optional contact layer filter for next/previous (for example military for a military-only cycle).',
|
||||
enum: [
|
||||
'flights',
|
||||
'military',
|
||||
'ais-live-vessels',
|
||||
'military-installations',
|
||||
],
|
||||
description:
|
||||
'Optional contact layer filter for next/previous (for example military for a military-only cycle).',
|
||||
},
|
||||
aircraftClass: {
|
||||
type: 'string',
|
||||
description: 'Optional aircraft class filter (for example helicopter) when using next/previous navigation.',
|
||||
description:
|
||||
'Optional aircraft class filter (for example helicopter) when using next/previous navigation.',
|
||||
},
|
||||
},
|
||||
required: ['action'],
|
||||
@@ -231,7 +280,15 @@ const GEV_REALTIME_TOOLS = [
|
||||
properties: {
|
||||
style: {
|
||||
type: 'string',
|
||||
enum: ['normal', 'retro', 'surveillance', 'thermal', 'anime', 'noir', 'snow'],
|
||||
enum: [
|
||||
'normal',
|
||||
'retro',
|
||||
'surveillance',
|
||||
'thermal',
|
||||
'anime',
|
||||
'noir',
|
||||
'snow',
|
||||
],
|
||||
},
|
||||
},
|
||||
required: ['style'],
|
||||
@@ -240,7 +297,8 @@ const GEV_REALTIME_TOOLS = [
|
||||
{
|
||||
type: 'function',
|
||||
name: 'get_entity_context',
|
||||
description: 'Get current GEV scene context, including basemap/3D-tile target context, selected entity metadata if active, and entities currently visible in the camera view.',
|
||||
description:
|
||||
'Get current GEV scene context, including basemap/3D-tile target context, selected entity metadata if active, and entities currently visible in the camera view.',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
@@ -248,7 +306,8 @@ const GEV_REALTIME_TOOLS = [
|
||||
scope: {
|
||||
type: 'string',
|
||||
enum: ['auto', 'selected', 'in_view'],
|
||||
description: 'Use auto by default. selected returns the clicked/selected entity; in_view returns visible entities near the screen center.',
|
||||
description:
|
||||
'Use auto by default. selected returns the clicked/selected entity; in_view returns visible entities near the screen center.',
|
||||
},
|
||||
layerId: {
|
||||
type: 'string',
|
||||
@@ -271,7 +330,8 @@ const GEV_REALTIME_TOOLS = [
|
||||
{
|
||||
type: 'function',
|
||||
name: 'get_current_view_state',
|
||||
description: 'Read the current camera, style, Context, Cockpit, HUD, detection, map stack, post-processing, scene-playback, tracked-entity, and layer state before choosing another action.',
|
||||
description:
|
||||
'Read the current camera, style, Context, Cockpit, HUD, detection, map stack, post-processing, scene-playback, tracked-entity, and layer state before choosing another action.',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
@@ -281,12 +341,17 @@ const GEV_REALTIME_TOOLS = [
|
||||
{
|
||||
type: 'function',
|
||||
name: 'set_hud',
|
||||
description: 'Control the intelligence HUD overlay: visibility and/or layout variant.',
|
||||
description:
|
||||
'Control the intelligence HUD overlay: visibility and/or layout variant.',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
visible: { type: 'string', enum: ['on', 'off', 'auto'], description: 'auto restores style-driven show/hide.' },
|
||||
visible: {
|
||||
type: 'string',
|
||||
enum: ['on', 'off', 'auto'],
|
||||
description: 'auto restores style-driven show/hide.',
|
||||
},
|
||||
layout: { type: 'string', enum: ['tactical', 'operator', 'minimal'] },
|
||||
},
|
||||
},
|
||||
@@ -294,30 +359,52 @@ const GEV_REALTIME_TOOLS = [
|
||||
{
|
||||
type: 'function',
|
||||
name: 'set_detection',
|
||||
description: 'Control the detection overlay: on/off, density-derived Sparse/Balanced/Dense profile, and Elastic/Weighted layer allocation.',
|
||||
description:
|
||||
'Control the detection overlay: on/off, density-derived Sparse/Balanced/Dense profile, and Elastic/Weighted layer allocation.',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
enabled: { type: 'boolean', description: 'false turns detection OFF; true restores the current density-derived profile.' },
|
||||
enabled: {
|
||||
type: 'boolean',
|
||||
description:
|
||||
'false turns detection OFF; true restores the current density-derived profile.',
|
||||
},
|
||||
mode: { type: 'string', enum: ['sparse', 'balanced', 'dense'] },
|
||||
densityPct: { type: 'number', description: 'Density snaps to 0, 25, 50, 75, or 100 and derives the active profile.' },
|
||||
allocationStrategy: { type: 'string', enum: ['elastic', 'weighted'], description: 'Elastic splits evenly then lends unused slots; Weighted follows demand and semantic weight.' },
|
||||
densityPct: {
|
||||
type: 'number',
|
||||
description:
|
||||
'Density snaps to 0, 25, 50, 75, or 100 and derives the active profile.',
|
||||
},
|
||||
allocationStrategy: {
|
||||
type: 'string',
|
||||
enum: ['elastic', 'weighted'],
|
||||
description:
|
||||
'Elastic splits evenly then lends unused slots; Weighted follows demand and semantic weight.',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'function',
|
||||
name: 'set_map_stack',
|
||||
description: 'Switch the basemap/imagery stack (NOT the satellites data layer and NOT a visual style filter).',
|
||||
description:
|
||||
'Switch the basemap/imagery stack (NOT the satellites data layer and NOT a visual style filter).',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
stack: {
|
||||
type: 'string',
|
||||
enum: ['photoreal', 'bing-aerial', 'bing-labels', 'esri-imagery', 'osm'],
|
||||
description: 'photoreal = Google 3D. Use bing-aerial only when the user explicitly says "Bing aerial" — "satellite(s)" never means a basemap; only the explicit phrase "Esri" / "Esri imagery" means esri-imagery.',
|
||||
enum: [
|
||||
'photoreal',
|
||||
'bing-aerial',
|
||||
'bing-labels',
|
||||
'esri-imagery',
|
||||
'osm',
|
||||
],
|
||||
description:
|
||||
'photoreal = Google 3D. Use bing-aerial only when the user explicitly says "Bing aerial" — "satellite(s)" never means a basemap; only the explicit phrase "Esri" / "Esri imagery" means esri-imagery.',
|
||||
},
|
||||
},
|
||||
required: ['stack'],
|
||||
@@ -326,7 +413,8 @@ const GEV_REALTIME_TOOLS = [
|
||||
{
|
||||
type: 'function',
|
||||
name: 'set_post_processing',
|
||||
description: 'Control bloom and sharpen post-processing toggles and intensities.',
|
||||
description:
|
||||
'Control bloom and sharpen post-processing toggles and intensities.',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
@@ -336,7 +424,10 @@ const GEV_REALTIME_TOOLS = [
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
enabled: { type: 'boolean' },
|
||||
intensityPct: { type: 'number', description: '0-200 (UI percent).' },
|
||||
intensityPct: {
|
||||
type: 'number',
|
||||
description: '0-200 (UI percent).',
|
||||
},
|
||||
},
|
||||
},
|
||||
sharpen: {
|
||||
@@ -344,7 +435,10 @@ const GEV_REALTIME_TOOLS = [
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
enabled: { type: 'boolean' },
|
||||
intensityPct: { type: 'number', description: '0-100 (UI percent).' },
|
||||
intensityPct: {
|
||||
type: 'number',
|
||||
description: '0-100 (UI percent).',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -353,13 +447,20 @@ const GEV_REALTIME_TOOLS = [
|
||||
{
|
||||
type: 'function',
|
||||
name: 'control_scene',
|
||||
description: 'Cinematic scene playback: list scenes, play one scene by name, stop, advance, or read status. Play starts a single named scene and returns immediately.',
|
||||
description:
|
||||
'Cinematic scene playback: list scenes, play one scene by name, stop, advance, or read status. Play starts a single named scene and returns immediately.',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
action: { type: 'string', enum: ['list', 'play', 'stop', 'next', 'status'] },
|
||||
sceneId: { type: 'string', description: 'Scene id or (partial) title for play.' },
|
||||
action: {
|
||||
type: 'string',
|
||||
enum: ['list', 'play', 'stop', 'next', 'status'],
|
||||
},
|
||||
sceneId: {
|
||||
type: 'string',
|
||||
description: 'Scene id or (partial) title for play.',
|
||||
},
|
||||
},
|
||||
required: ['action'],
|
||||
},
|
||||
@@ -367,14 +468,38 @@ const GEV_REALTIME_TOOLS = [
|
||||
{
|
||||
type: 'function',
|
||||
name: 'control_cctv',
|
||||
description: 'CCTV camera operations: enable/disable the layer, select a camera by name, next/prev/nearest/focus, toggle coverage wedges / projection overlay / auto-hop, "viewshed" for color-coded per-camera coverage volumes, and "adjust" for the on-camera calibration gizmo.',
|
||||
description:
|
||||
'CCTV camera operations: enable/disable the layer, select a camera by name, next/prev/nearest/focus, toggle coverage wedges / projection overlay / auto-hop, "viewshed" for color-coded per-camera coverage volumes, and "adjust" for the on-camera calibration gizmo.',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
action: { type: 'string', enum: ['enable', 'disable', 'select', 'next', 'prev', 'nearest', 'focus', 'coverage', 'viewshed', 'adjust', 'projection', 'autohop'] },
|
||||
cameraQuery: { type: 'string', description: 'Camera name or id for select.' },
|
||||
enabled: { type: 'boolean', description: 'Explicit on/off for coverage/viewshed/adjust/projection/autohop; omit to toggle.' },
|
||||
action: {
|
||||
type: 'string',
|
||||
enum: [
|
||||
'enable',
|
||||
'disable',
|
||||
'select',
|
||||
'next',
|
||||
'prev',
|
||||
'nearest',
|
||||
'focus',
|
||||
'coverage',
|
||||
'viewshed',
|
||||
'adjust',
|
||||
'projection',
|
||||
'autohop',
|
||||
],
|
||||
},
|
||||
cameraQuery: {
|
||||
type: 'string',
|
||||
description: 'Camera name or id for select.',
|
||||
},
|
||||
enabled: {
|
||||
type: 'boolean',
|
||||
description:
|
||||
'Explicit on/off for coverage/viewshed/adjust/projection/autohop; omit to toggle.',
|
||||
},
|
||||
},
|
||||
required: ['action'],
|
||||
},
|
||||
@@ -382,32 +507,85 @@ const GEV_REALTIME_TOOLS = [
|
||||
{
|
||||
type: 'function',
|
||||
name: 'control_radio',
|
||||
description: 'Control Internet Radio playback without moving the map. Use select whenever the request includes a station category, name, country, coordinates, or nearby place—even when the user says play. Use play only for an unqualified "turn on/start the radio" request so the current or nearest station begins. Enable only reveals the Radio layer/markers without audio. Also supports disable, resume, pause, stop, next/previous, volume, and status.',
|
||||
description:
|
||||
'Control Internet Radio playback without moving the map. Use select whenever the request includes a station category, name, country, coordinates, or nearby place—even when the user says play. Use play only for an unqualified "turn on/start the radio" request so the current or nearest station begins. Enable only reveals the Radio layer/markers without audio. Also supports disable, resume, pause, stop, next/previous, volume, and status.',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
action: {
|
||||
type: 'string',
|
||||
enum: ['enable', 'disable', 'play', 'resume', 'pause', 'stop', 'next', 'previous', 'volume', 'select', 'status'],
|
||||
description: 'Use select for any request qualified by category, station, country, coordinates, or place. Use play only for an unqualified turn on/start/listen request. Use enable only when the user explicitly asks to show or enable the Radio layer or its markers without requesting audio.',
|
||||
enum: [
|
||||
'enable',
|
||||
'disable',
|
||||
'play',
|
||||
'resume',
|
||||
'pause',
|
||||
'stop',
|
||||
'next',
|
||||
'previous',
|
||||
'volume',
|
||||
'select',
|
||||
'status',
|
||||
],
|
||||
description:
|
||||
'Use select for any request qualified by category, station, country, coordinates, or place. Use play only for an unqualified turn on/start/listen request. Use enable only when the user explicitly asks to show or enable the Radio layer or its markers without requesting audio.',
|
||||
},
|
||||
volumePct: {
|
||||
type: 'number',
|
||||
minimum: 0,
|
||||
maximum: 100,
|
||||
description:
|
||||
'Required for volume; sets the persistent Radio playback volume.',
|
||||
},
|
||||
volumePct: { type: 'number', minimum: 0, maximum: 100, description: 'Required for volume; sets the persistent Radio playback volume.' },
|
||||
category: {
|
||||
type: 'string',
|
||||
enum: ['all', 'news', 'talk', 'weather', 'public-safety', 'aviation-marine', 'traffic-transit', 'music'],
|
||||
description: 'Station category for select/next/previous. When the user requests playback with a category, action must be select, not play.',
|
||||
enum: [
|
||||
'all',
|
||||
'news',
|
||||
'talk',
|
||||
'weather',
|
||||
'public-safety',
|
||||
'aviation-marine',
|
||||
'traffic-transit',
|
||||
'music',
|
||||
],
|
||||
description:
|
||||
'Station category for select/next/previous. When the user requests playback with a category, action must be select, not play.',
|
||||
},
|
||||
locationId: {
|
||||
type: 'string',
|
||||
enum: ['austin', 'sf', 'nyc', 'tokyo', 'london', 'paris', 'dubai', 'dc'],
|
||||
enum: [
|
||||
'austin',
|
||||
'sf',
|
||||
'nyc',
|
||||
'tokyo',
|
||||
'london',
|
||||
'paris',
|
||||
'dubai',
|
||||
'dc',
|
||||
],
|
||||
description: 'Known nearby-city anchor for select.',
|
||||
},
|
||||
locationQuery: { type: 'string', maxLength: 120, description: 'Place to search near, such as "Austin, Texas" or "Seattle". Selection does not fly the camera.' },
|
||||
locationQuery: {
|
||||
type: 'string',
|
||||
maxLength: 120,
|
||||
description:
|
||||
'Place to search near, such as "Austin, Texas" or "Seattle". Selection does not fly the camera.',
|
||||
},
|
||||
latitude: { type: 'number', minimum: -90, maximum: 90 },
|
||||
longitude: { type: 'number', minimum: -180, maximum: 180 },
|
||||
country: { type: 'string', maxLength: 80, description: 'Country code or name filter, for example US or United States.' },
|
||||
stationQuery: { type: 'string', maxLength: 120, description: 'Optional station name/tag substring.' },
|
||||
country: {
|
||||
type: 'string',
|
||||
maxLength: 80,
|
||||
description:
|
||||
'Country code or name filter, for example US or United States.',
|
||||
},
|
||||
stationQuery: {
|
||||
type: 'string',
|
||||
maxLength: 120,
|
||||
description: 'Optional station name/tag substring.',
|
||||
},
|
||||
},
|
||||
required: ['action'],
|
||||
},
|
||||
@@ -415,13 +593,22 @@ const GEV_REALTIME_TOOLS = [
|
||||
{
|
||||
type: 'function',
|
||||
name: 'track_entity',
|
||||
description: 'Find and follow a specific aircraft (callsign/ICAO hex), ship (name/MMSI), or satellite (name/NORAD id) on enabled layers. Camera follows the entity.',
|
||||
description:
|
||||
'Find and follow a specific aircraft (callsign/ICAO hex), ship (name/MMSI), or satellite (name/NORAD id) on enabled layers. Camera follows the entity.',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
query: { type: 'string', description: 'Callsign, ship name, satellite name, ICAO hex, MMSI, or NORAD id.' },
|
||||
layerId: { type: 'string', description: 'Optional layer hint: flights | military | ais-live-vessels | satellites.' },
|
||||
query: {
|
||||
type: 'string',
|
||||
description:
|
||||
'Callsign, ship name, satellite name, ICAO hex, MMSI, or NORAD id.',
|
||||
},
|
||||
layerId: {
|
||||
type: 'string',
|
||||
description:
|
||||
'Optional layer hint: flights | military | ais-live-vessels | satellites.',
|
||||
},
|
||||
},
|
||||
required: ['query'],
|
||||
},
|
||||
@@ -429,7 +616,8 @@ const GEV_REALTIME_TOOLS = [
|
||||
{
|
||||
type: 'function',
|
||||
name: 'stop_tracking',
|
||||
description: 'Stop following the tracked aircraft/satellite and clear any selected vessel.',
|
||||
description:
|
||||
'Stop following the tracked aircraft/satellite and clear any selected vessel.',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
@@ -439,13 +627,21 @@ const GEV_REALTIME_TOOLS = [
|
||||
{
|
||||
type: 'function',
|
||||
name: 'frame_overhead',
|
||||
description: 'Cinematically frame entities near the current view: pulls the camera back and angles it so nearby aircraft, ships, or satellites are visible together.',
|
||||
description:
|
||||
'Cinematically frame entities near the current view: pulls the camera back and angles it so nearby aircraft, ships, or satellites are visible together.',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
target: { type: 'string', enum: ['flights', 'military', 'satellites', 'vessels'] },
|
||||
radiusKm: { type: 'number', description: 'Search radius around the view target. Defaults: 150 aircraft, 120 ships, 3000 satellites.' },
|
||||
target: {
|
||||
type: 'string',
|
||||
enum: ['flights', 'military', 'satellites', 'vessels'],
|
||||
},
|
||||
radiusKm: {
|
||||
type: 'number',
|
||||
description:
|
||||
'Search radius around the view target. Defaults: 150 aircraft, 120 ships, 3000 satellites.',
|
||||
},
|
||||
},
|
||||
required: ['target'],
|
||||
},
|
||||
@@ -453,14 +649,16 @@ const GEV_REALTIME_TOOLS = [
|
||||
{
|
||||
type: 'function',
|
||||
name: 'annotate_map',
|
||||
description: "Draw annotations on the 3D map to visually point out what you are talking about — like sketching on a whiteboard over the world. Use this whenever you mention a specific place, building, campus, boundary, district, or a relationship between two places, so the user can SEE what you mean. Give place NAMES (preferred) or explicit lat/lng; the app resolves them to real-world positions and real building/area outlines — never guess pixel positions. Call this as you begin describing something, and you may mark several places in one call.",
|
||||
description:
|
||||
'Draw annotations on the 3D map to visually point out what you are talking about — like sketching on a whiteboard over the world. Use this whenever you mention a specific place, building, campus, boundary, district, or a relationship between two places, so the user can SEE what you mean. Give place NAMES (preferred) or explicit lat/lng; the app resolves them to real-world positions and real building/area outlines — never guess pixel positions. Call this as you begin describing something, and you may mark several places in one call.',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
annotations: {
|
||||
type: 'array',
|
||||
description: 'One or more things to mark. Mark multiple related places together when describing them as a group.',
|
||||
description:
|
||||
'One or more things to mark. Mark multiple related places together when describing them as a group.',
|
||||
minItems: 1,
|
||||
maxItems: 24,
|
||||
items: {
|
||||
@@ -470,19 +668,30 @@ const GEV_REALTIME_TOOLS = [
|
||||
type: {
|
||||
type: 'string',
|
||||
enum: ['pin', 'highlight', 'area', 'arrow', 'route', 'label'],
|
||||
description: 'pin = planted marker at a spot; highlight = pulsing ring drawing the eye to a point; area = trace the outline of a building/campus/compound/district; arrow = a connector from one place to another (use target as the origin and toTarget as the destination); route = a path through several waypoints (use the points array); label = a floating text callout.',
|
||||
description:
|
||||
'pin = planted marker at a spot; highlight = pulsing ring drawing the eye to a point; area = trace the outline of a building/campus/compound/district; arrow = a connector from one place to another (use target as the origin and toTarget as the destination); route = a path through several waypoints (use the points array); label = a floating text callout.',
|
||||
},
|
||||
target: {
|
||||
type: 'string',
|
||||
maxLength: 200,
|
||||
description:
|
||||
'Place name to resolve, e.g. "Palace of Fine Arts, San Francisco", "the Pentagon", "Presidio of San Francisco". Preferred over coordinates. For a specific monument/statue/feature that sits within a larger landmark, use its OWN name + city ("Tejano Monument, Austin", "Texas African American History Memorial, Austin") — do NOT phrase it as "X at the Texas State Capitol", which makes the geocoder collapse several of them onto the same centroid so they stack on one spot.',
|
||||
},
|
||||
target: { type: 'string', maxLength: 200, description: 'Place name to resolve, e.g. "Palace of Fine Arts, San Francisco", "the Pentagon", "Presidio of San Francisco". Preferred over coordinates. For a specific monument/statue/feature that sits within a larger landmark, use its OWN name + city ("Tejano Monument, Austin", "Texas African American History Memorial, Austin") — do NOT phrase it as "X at the Texas State Capitol", which makes the geocoder collapse several of them onto the same centroid so they stack on one spot.' },
|
||||
points: {
|
||||
type: 'array',
|
||||
description: 'For type=route: 2+ ordered waypoints the path passes through, each a place name (or coordinates / screen point).',
|
||||
description:
|
||||
'For type=route: 2+ ordered waypoints the path passes through, each a place name (or coordinates / screen point).',
|
||||
minItems: 2,
|
||||
maxItems: 12,
|
||||
items: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
target: { type: 'string', maxLength: 200, description: 'Waypoint place name.' },
|
||||
target: {
|
||||
type: 'string',
|
||||
maxLength: 200,
|
||||
description: 'Waypoint place name.',
|
||||
},
|
||||
latitude: { type: 'number', minimum: -90, maximum: 90 },
|
||||
longitude: { type: 'number', minimum: -180, maximum: 180 },
|
||||
screenX: { type: 'number', minimum: 0, maximum: 1 },
|
||||
@@ -493,32 +702,101 @@ const GEV_REALTIME_TOOLS = [
|
||||
mode: {
|
||||
type: 'string',
|
||||
enum: ['walking', 'driving', 'cycling'],
|
||||
description: 'For type=route: travel mode for a real street-following route (the app returns distance + time). Pick from the verb the user used ("walk" → walking, "drive" → driving). Defaults to walking.',
|
||||
description:
|
||||
'For type=route: travel mode for a real street-following route (the app returns distance + time). Pick from the verb the user used ("walk" → walking, "drive" → driving). Defaults to walking.',
|
||||
},
|
||||
latitude: {
|
||||
type: 'number',
|
||||
minimum: -90,
|
||||
maximum: 90,
|
||||
description:
|
||||
'Explicit latitude (use only if no good place name exists).',
|
||||
},
|
||||
latitude: { type: 'number', minimum: -90, maximum: 90, description: 'Explicit latitude (use only if no good place name exists).' },
|
||||
longitude: { type: 'number', minimum: -180, maximum: 180 },
|
||||
toTarget: { type: 'string', maxLength: 200, description: 'For type=arrow: the destination place name.' },
|
||||
toTarget: {
|
||||
type: 'string',
|
||||
maxLength: 200,
|
||||
description: 'For type=arrow: the destination place name.',
|
||||
},
|
||||
toLatitude: { type: 'number', minimum: -90, maximum: 90 },
|
||||
toLongitude: { type: 'number', minimum: -180, maximum: 180 },
|
||||
label: { type: 'string', maxLength: 120, description: 'Short caption shown on the map (a few words). Optional.' },
|
||||
label: {
|
||||
type: 'string',
|
||||
maxLength: 120,
|
||||
description:
|
||||
'Short caption shown on the map (a few words). Optional.',
|
||||
},
|
||||
color: {
|
||||
type: 'string',
|
||||
enum: ['primary', 'amber', 'cyan', 'green', 'red'],
|
||||
description: 'Accent color. primary = neutral, amber = point of interest, cyan = infrastructure, green = confirmed/safe, red = alert.',
|
||||
description:
|
||||
'Accent color. primary = neutral, amber = point of interest, cyan = infrastructure, green = confirmed/safe, red = alert.',
|
||||
},
|
||||
footprint: {
|
||||
type: 'boolean',
|
||||
description:
|
||||
'For type=area/highlight: trace the real building or campus outline from map data. Defaults true for area.',
|
||||
},
|
||||
intent: {
|
||||
type: 'string',
|
||||
enum: ['the_thing', 'around_the_thing'],
|
||||
description:
|
||||
'For type=area: "the_thing" (default) outlines the place itself (its footprint/boundary); "around_the_thing" highlights a surrounding zone (a buffered radius around it). Infer from phrasing: "the Capitol"/"show me X" → the_thing; "around/near/by X" or "the area around X" → around_the_thing.',
|
||||
},
|
||||
entityKind: {
|
||||
type: 'string',
|
||||
enum: [
|
||||
'building',
|
||||
'compound',
|
||||
'district',
|
||||
'street',
|
||||
'point_feature',
|
||||
],
|
||||
description:
|
||||
'What KIND of thing the target IS — a fact, not a style choice: building = one structure; compound = campus/grounds/mall/park; district = neighborhood or area of a city; street = a named road/corridor; point_feature = monument/statue/memorial/plaque/fountain or other small point landmark. Set it whenever you know it — it routes the resolver to the right footprint source (point_feature anchors monuments as precise points instead of adopting a nearby building outline).',
|
||||
},
|
||||
screenX: {
|
||||
type: 'number',
|
||||
minimum: 0,
|
||||
maximum: 1,
|
||||
description:
|
||||
'Fallback only: when you cannot name/geocode the place but can SEE it in the latest viewport screenshot, the normalized horizontal position (0=left, 1=right) of the spot. The app converts it back to a real world point under that pixel.',
|
||||
},
|
||||
screenY: {
|
||||
type: 'number',
|
||||
minimum: 0,
|
||||
maximum: 1,
|
||||
description:
|
||||
'Fallback only: normalized vertical position (0=top, 1=bottom) of the spot in the latest viewport screenshot.',
|
||||
},
|
||||
toScreenX: {
|
||||
type: 'number',
|
||||
minimum: 0,
|
||||
maximum: 1,
|
||||
description:
|
||||
'For type=arrow: normalized x of the arrow destination from the screenshot (pixel fallback).',
|
||||
},
|
||||
toScreenY: {
|
||||
type: 'number',
|
||||
minimum: 0,
|
||||
maximum: 1,
|
||||
description:
|
||||
'For type=arrow: normalized y of the arrow destination from the screenshot (pixel fallback).',
|
||||
},
|
||||
footprint: { type: 'boolean', description: 'For type=area/highlight: trace the real building or campus outline from map data. Defaults true for area.' },
|
||||
intent: { type: 'string', enum: ['the_thing', 'around_the_thing'], description: 'For type=area: "the_thing" (default) outlines the place itself (its footprint/boundary); "around_the_thing" highlights a surrounding zone (a buffered radius around it). Infer from phrasing: "the Capitol"/"show me X" → the_thing; "around/near/by X" or "the area around X" → around_the_thing.' },
|
||||
entityKind: { type: 'string', enum: ['building', 'compound', 'district', 'street', 'point_feature'], description: 'What KIND of thing the target IS — a fact, not a style choice: building = one structure; compound = campus/grounds/mall/park; district = neighborhood or area of a city; street = a named road/corridor; point_feature = monument/statue/memorial/plaque/fountain or other small point landmark. Set it whenever you know it — it routes the resolver to the right footprint source (point_feature anchors monuments as precise points instead of adopting a nearby building outline).' },
|
||||
screenX: { type: 'number', minimum: 0, maximum: 1, description: 'Fallback only: when you cannot name/geocode the place but can SEE it in the latest viewport screenshot, the normalized horizontal position (0=left, 1=right) of the spot. The app converts it back to a real world point under that pixel.' },
|
||||
screenY: { type: 'number', minimum: 0, maximum: 1, description: 'Fallback only: normalized vertical position (0=top, 1=bottom) of the spot in the latest viewport screenshot.' },
|
||||
toScreenX: { type: 'number', minimum: 0, maximum: 1, description: 'For type=arrow: normalized x of the arrow destination from the screenshot (pixel fallback).' },
|
||||
toScreenY: { type: 'number', minimum: 0, maximum: 1, description: 'For type=arrow: normalized y of the arrow destination from the screenshot (pixel fallback).' },
|
||||
},
|
||||
required: ['type'],
|
||||
},
|
||||
},
|
||||
flyTo: { type: 'boolean', description: 'Also move the camera to frame the first annotation. Default false — leave false if the user is already looking at the spot.' },
|
||||
persist: { type: 'boolean', description: 'Keep annotations until cleared (true, default) or let them auto-fade after ~20s (false).' },
|
||||
flyTo: {
|
||||
type: 'boolean',
|
||||
description:
|
||||
'Also move the camera to frame the first annotation. Default false — leave false if the user is already looking at the spot.',
|
||||
},
|
||||
persist: {
|
||||
type: 'boolean',
|
||||
description:
|
||||
'Keep annotations until cleared (true, default) or let them auto-fade after ~20s (false).',
|
||||
},
|
||||
},
|
||||
required: ['annotations'],
|
||||
},
|
||||
@@ -526,7 +804,8 @@ const GEV_REALTIME_TOOLS = [
|
||||
{
|
||||
type: 'function',
|
||||
name: 'clear_annotations',
|
||||
description: 'Erase ALL map annotations previously drawn with annotate_map. Call this ONLY when the user EXPLICITLY asks to clear or reset the map. Annotations accumulate and persist across navigation and topic changes by design — never clear on your own initiative.',
|
||||
description:
|
||||
'Erase ALL map annotations previously drawn with annotate_map. Call this ONLY when the user EXPLICITLY asks to clear or reset the map. Annotations accumulate and persist across navigation and topic changes by design — never clear on your own initiative.',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
@@ -536,15 +815,29 @@ const GEV_REALTIME_TOOLS = [
|
||||
{
|
||||
type: 'function',
|
||||
name: 'move_camera',
|
||||
description: 'Direct the camera like a drone operator: orbit the current view target, pan, tilt, or rotate — one bounded nudge (mode=once) or continuous motion until stopped (mode=continuous). Continuous motion also stops on any manual camera input or when a navigation tool runs. Say the RESULTING state when confirming ("Orbiting slowly").',
|
||||
description:
|
||||
'Direct the camera like a drone operator: orbit the current view target, pan, tilt, or rotate — one bounded nudge (mode=once) or continuous motion until stopped (mode=continuous). Continuous motion also stops on any manual camera input or when a navigation tool runs. Say the RESULTING state when confirming ("Orbiting slowly").',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
motion: { type: 'string', enum: ['orbit', 'pan', 'tilt', 'rotate', 'stop'] },
|
||||
direction: { type: 'string', enum: ['left', 'right', 'up', 'down'], description: 'Required except for orbit (defaults right/clockwise) and stop.' },
|
||||
motion: {
|
||||
type: 'string',
|
||||
enum: ['orbit', 'pan', 'tilt', 'rotate', 'stop'],
|
||||
},
|
||||
direction: {
|
||||
type: 'string',
|
||||
enum: ['left', 'right', 'up', 'down'],
|
||||
description:
|
||||
'Required except for orbit (defaults right/clockwise) and stop.',
|
||||
},
|
||||
speed: { type: 'string', enum: ['slow', 'normal', 'fast'] },
|
||||
mode: { type: 'string', enum: ['once', 'continuous'], description: 'once = bounded eased nudge (default); continuous = until stop/manual input.' },
|
||||
mode: {
|
||||
type: 'string',
|
||||
enum: ['once', 'continuous'],
|
||||
description:
|
||||
'once = bounded eased nudge (default); continuous = until stop/manual input.',
|
||||
},
|
||||
},
|
||||
required: ['motion'],
|
||||
},
|
||||
@@ -552,12 +845,16 @@ const GEV_REALTIME_TOOLS = [
|
||||
{
|
||||
type: 'function',
|
||||
name: 'fly_route',
|
||||
description: 'Cinematic dolly along an EXISTING route annotation (drawn earlier with annotate_map type=route) — flies the street-following path from start to end. Omit label for the newest route. If no route is drawn, this fails with guidance: draw the route first.',
|
||||
description:
|
||||
'Cinematic dolly along an EXISTING route annotation (drawn earlier with annotate_map type=route) — flies the street-following path from start to end. Omit label for the newest route. If no route is drawn, this fails with guidance: draw the route first.',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
label: { type: 'string', description: 'Match an existing route mark by (partial) label.' },
|
||||
label: {
|
||||
type: 'string',
|
||||
description: 'Match an existing route mark by (partial) label.',
|
||||
},
|
||||
speed: { type: 'string', enum: ['slow', 'normal', 'fast'] },
|
||||
},
|
||||
},
|
||||
@@ -565,59 +862,112 @@ const GEV_REALTIME_TOOLS = [
|
||||
{
|
||||
type: 'function',
|
||||
name: 'analyst_query',
|
||||
description: 'Answer questions ABOUT the data currently loaded on the map — counts, lists, superlatives, and attribute filters over live layers (flights, military, ships, fires, earthquakes). Examples: "how many flights over Texas", "biggest fire near LA", "which ships are headed to Oakland", "anything above 40,000 feet", "fastest thing in view". Queries ONLY client-side data from ENABLED layers — if the needed layer is off, say so and offer to enable it. For a follow-up about the previous answer\'s set ("which of those is closest?"), set followUp=true and send only the new filters/sort.',
|
||||
description:
|
||||
'Answer questions ABOUT the data currently loaded on the map — counts, lists, superlatives, and attribute filters over live layers (flights, military, ships, fires, earthquakes). Examples: "how many flights over Texas", "biggest fire near LA", "which ships are headed to Oakland", "anything above 40,000 feet", "fastest thing in view". Queries ONLY client-side data from ENABLED layers — if the needed layer is off, say so and offer to enable it. For a follow-up about the previous answer\'s set ("which of those is closest?"), set followUp=true and send only the new filters/sort.',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
layers: {
|
||||
type: 'array',
|
||||
items: { type: 'string', enum: ['flights', 'military', 'ais-live-vessels', 'local-firms', 'earthquakes'] },
|
||||
description: 'Layers to query. fires/wildfires → local-firms; ships/vessels → ais-live-vessels.',
|
||||
items: {
|
||||
type: 'string',
|
||||
enum: [
|
||||
'flights',
|
||||
'military',
|
||||
'ais-live-vessels',
|
||||
'local-firms',
|
||||
'earthquakes',
|
||||
],
|
||||
},
|
||||
description:
|
||||
'Layers to query. fires/wildfires → local-firms; ships/vessels → ais-live-vessels.',
|
||||
},
|
||||
scope: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
description: 'Spatial scope. Default: view (near the camera). Use kind=region for "over Texas"-style asks; kind=anywhere for global questions.',
|
||||
description:
|
||||
'Spatial scope. Default: view (near the camera). Use kind=region for "over Texas"-style asks; kind=anywhere for global questions.',
|
||||
properties: {
|
||||
kind: { type: 'string', enum: ['view', 'region', 'radius', 'anywhere'] },
|
||||
name: { type: 'string', description: 'For kind=region: a state/country ("Texas", "France") or a named natural region ("the Alps", "Gulf of Mexico").' },
|
||||
kind: {
|
||||
type: 'string',
|
||||
enum: ['view', 'region', 'radius', 'anywhere'],
|
||||
},
|
||||
name: {
|
||||
type: 'string',
|
||||
description:
|
||||
'For kind=region: a state/country ("Texas", "France") or a named natural region ("the Alps", "Gulf of Mexico").',
|
||||
},
|
||||
km: { type: 'number', description: 'For kind=radius.' },
|
||||
center: { type: 'object', additionalProperties: false, properties: { lat: { type: 'number' }, lon: { type: 'number' } } },
|
||||
center: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
properties: { lat: { type: 'number' }, lon: { type: 'number' } },
|
||||
},
|
||||
},
|
||||
},
|
||||
filters: {
|
||||
type: 'array',
|
||||
description: 'Attribute predicates, ANDed. ALTITUDE IS METERS (40,000 ft = 12192). Fields: altitudeM, speedMps, military, onGround, aircraftClass, callsign, operator, routeOrigin, routeDestination, originCountry (flights); speedKts, shipType, destination (ships); frp, confidence (fires); magnitude, depthKm, place (earthquakes).',
|
||||
description:
|
||||
'Attribute predicates, ANDed. ALTITUDE IS METERS (40,000 ft = 12192). Fields: altitudeM, speedMps, military, onGround, aircraftClass, callsign, operator, routeOrigin, routeDestination, originCountry (flights); speedKts, shipType, destination (ships); frp, confidence (fires); magnitude, depthKm, place (earthquakes).',
|
||||
items: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
field: { type: 'string' },
|
||||
op: { type: 'string', enum: ['gt', 'gte', 'lt', 'lte', 'eq', 'neq', 'contains'] },
|
||||
op: {
|
||||
type: 'string',
|
||||
enum: ['gt', 'gte', 'lt', 'lte', 'eq', 'neq', 'contains'],
|
||||
},
|
||||
value: {},
|
||||
},
|
||||
required: ['field', 'op', 'value'],
|
||||
},
|
||||
},
|
||||
sortBy: { type: 'string', description: 'Field to rank by, or "distance" for nearest-first.' },
|
||||
sortBy: {
|
||||
type: 'string',
|
||||
description: 'Field to rank by, or "distance" for nearest-first.',
|
||||
},
|
||||
sortDir: { type: 'string', enum: ['asc', 'desc'] },
|
||||
limit: { type: 'number' },
|
||||
followUp: { type: 'boolean', description: 'true = re-query the PREVIOUS result set instead of fresh data.' },
|
||||
followUp: {
|
||||
type: 'boolean',
|
||||
description:
|
||||
'true = re-query the PREVIOUS result set instead of fresh data.',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'function',
|
||||
name: 'next_iss_pass',
|
||||
description: "When the user asks when the ISS / the space station will next fly over: returns the next visible ISS pass for the current camera location (or an explicit lat/lon) — rise time (ISO + minutes from now), rise compass direction, peak elevation, and duration. Requires the satellites layer to have loaded its catalog at least once this session; if it hasn't, tell the user to enable the satellites layer and try again.",
|
||||
description:
|
||||
"When the user asks when the ISS / the space station will next fly over: returns the next visible ISS pass for the current camera location (or an explicit lat/lon) — rise time (ISO + minutes from now), rise compass direction, peak elevation, and duration. Requires the satellites layer to have loaded its catalog at least once this session; if it hasn't, tell the user to enable the satellites layer and try again.",
|
||||
parameters: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
latitude: { type: 'number', minimum: -90, maximum: 90, description: 'Optional observer latitude. Omit to use the current camera position.' },
|
||||
longitude: { type: 'number', minimum: -180, maximum: 180, description: 'Optional observer longitude. Omit to use the current camera position.' },
|
||||
minElevationDeg: { type: 'number', minimum: 5, maximum: 60, description: 'Minimum peak elevation (deg) to count as a pass. Default 10.' },
|
||||
latitude: {
|
||||
type: 'number',
|
||||
minimum: -90,
|
||||
maximum: 90,
|
||||
description:
|
||||
'Optional observer latitude. Omit to use the current camera position.',
|
||||
},
|
||||
longitude: {
|
||||
type: 'number',
|
||||
minimum: -180,
|
||||
maximum: 180,
|
||||
description:
|
||||
'Optional observer longitude. Omit to use the current camera position.',
|
||||
},
|
||||
minElevationDeg: {
|
||||
type: 'number',
|
||||
minimum: 5,
|
||||
maximum: 60,
|
||||
description:
|
||||
'Minimum peak elevation (deg) to count as a pass. Default 10.',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
+145
-116
@@ -1,9 +1,23 @@
|
||||
import { makeRateLimiter, clientKey } from './common/rate-limit.js';
|
||||
import { readRequestBodyCapped } from './common/request.js';
|
||||
import { OVERPASS_MAX_BODY_BYTES, OVERPASS_MAX_CONCURRENT } from './overpass/constants.js';
|
||||
import {
|
||||
OVERPASS_MAX_BODY_BYTES,
|
||||
OVERPASS_MAX_CONCURRENT,
|
||||
} from './overpass/constants.js';
|
||||
import { sanitizeOverpassBody } from './overpass/query.js';
|
||||
import { resolveOverpassPreflight, _overpassCache, readOverpassDisk, overpassDiskTtlMs, readStaleOverpass, trimOverpassCache, writeOverpassDisk } from './overpass/cache.js';
|
||||
import { overpassPayloadIsData, fetchOverpassPayload } from './overpass/transport.js';
|
||||
import {
|
||||
resolveOverpassPreflight,
|
||||
_overpassCache,
|
||||
readOverpassDisk,
|
||||
overpassDiskTtlMs,
|
||||
readStaleOverpass,
|
||||
trimOverpassCache,
|
||||
writeOverpassDisk,
|
||||
} from './overpass/cache.js';
|
||||
import {
|
||||
overpassPayloadIsData,
|
||||
fetchOverpassPayload,
|
||||
} from './overpass/transport.js';
|
||||
import { installRouteMiddleware } from './places/routes.js';
|
||||
|
||||
/** @type {Map<string,Promise>} In-flight Overpass requests keyed by normalized query body. */
|
||||
@@ -11,7 +25,11 @@ const _overpassInFlight = new Map();
|
||||
|
||||
let _overpassConcurrent = 0;
|
||||
|
||||
const _overpassRateLimiter = makeRateLimiter({ windowMs: 60_000, max: 90, globalMax: 300 });
|
||||
const _overpassRateLimiter = makeRateLimiter({
|
||||
windowMs: 60_000,
|
||||
max: 90,
|
||||
globalMax: 300,
|
||||
});
|
||||
|
||||
/**
|
||||
* Write a completed Overpass payload to the HTTP response.
|
||||
@@ -43,136 +61,147 @@ function sendOverpassResponse(res, payload, cacheStatus = 'MISS') {
|
||||
*/
|
||||
function overpassProxy() {
|
||||
const installMiddleware = (server) => {
|
||||
server.middlewares.use('/api/overpass', async (req, res) => {
|
||||
// Hoisted out of the try so the catch's serve-stale lookup can see it
|
||||
// (a body-read failure would otherwise hit an out-of-scope reference).
|
||||
let cacheKey = null;
|
||||
server.middlewares.use('/api/overpass', async (req, res) => {
|
||||
// Hoisted out of the try so the catch's serve-stale lookup can see it
|
||||
// (a body-read failure would otherwise hit an out-of-scope reference).
|
||||
let cacheKey = null;
|
||||
try {
|
||||
if (req.method !== 'POST') {
|
||||
res.writeHead(405, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ error: 'Method Not Allowed' }));
|
||||
return;
|
||||
}
|
||||
|
||||
// Collect POST body with a hard byte cap (Overpass QL queries are small)
|
||||
let body;
|
||||
try {
|
||||
if (req.method !== 'POST') {
|
||||
res.writeHead(405, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ error: 'Method Not Allowed' }));
|
||||
body = (
|
||||
await readRequestBodyCapped(req, OVERPASS_MAX_BODY_BYTES)
|
||||
).toString();
|
||||
} catch (err) {
|
||||
if (err?.code === 'BODY_TOO_LARGE') {
|
||||
res.writeHead(413, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ error: 'Overpass query too large' }));
|
||||
return;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
if (!body) {
|
||||
res.writeHead(400, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ error: 'Missing Overpass query body' }));
|
||||
return;
|
||||
}
|
||||
|
||||
// Collect POST body with a hard byte cap (Overpass QL queries are small)
|
||||
let body;
|
||||
try {
|
||||
body = (await readRequestBodyCapped(req, OVERPASS_MAX_BODY_BYTES)).toString();
|
||||
} catch (err) {
|
||||
if (err?.code === 'BODY_TOO_LARGE') {
|
||||
res.writeHead(413, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ error: 'Overpass query too large' }));
|
||||
return;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
if (!body) {
|
||||
res.writeHead(400, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ error: 'Missing Overpass query body' }));
|
||||
return;
|
||||
}
|
||||
// Validate + clamp the QL: reject unbounded/global queries and cap the
|
||||
// server-side timeout so a tiny body can't request planet-scale work.
|
||||
const sanitized = sanitizeOverpassBody(body);
|
||||
if (!sanitized.ok) {
|
||||
res.writeHead(400, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ error: sanitized.error }));
|
||||
return;
|
||||
}
|
||||
const safeBody = sanitized.body;
|
||||
|
||||
// Validate + clamp the QL: reject unbounded/global queries and cap the
|
||||
// server-side timeout so a tiny body can't request planet-scale work.
|
||||
const sanitized = sanitizeOverpassBody(body);
|
||||
if (!sanitized.ok) {
|
||||
res.writeHead(400, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ error: sanitized.error }));
|
||||
return;
|
||||
}
|
||||
const safeBody = sanitized.body;
|
||||
|
||||
// Normalize whitespace so semantically identical Overpass QL queries share cache entries
|
||||
cacheKey = safeBody.replace(/\s+/g, ' ').trim();
|
||||
const preflight = await resolveOverpassPreflight({
|
||||
cacheKey,
|
||||
memoryCache: _overpassCache,
|
||||
inFlight: _overpassInFlight,
|
||||
// Fresh-enough disk entries survive restarts and skip the public
|
||||
// mirrors; boundary-class queries keep their month-long TTL.
|
||||
readDisk: () => readOverpassDisk(cacheKey, overpassDiskTtlMs(cacheKey)),
|
||||
allowUpstream: () => _overpassRateLimiter(clientKey(req)),
|
||||
// Normalize whitespace so semantically identical Overpass QL queries share cache entries
|
||||
cacheKey = safeBody.replace(/\s+/g, ' ').trim();
|
||||
const preflight = await resolveOverpassPreflight({
|
||||
cacheKey,
|
||||
memoryCache: _overpassCache,
|
||||
inFlight: _overpassInFlight,
|
||||
// Fresh-enough disk entries survive restarts and skip the public
|
||||
// mirrors; boundary-class queries keep their month-long TTL.
|
||||
readDisk: () =>
|
||||
readOverpassDisk(cacheKey, overpassDiskTtlMs(cacheKey)),
|
||||
allowUpstream: () => _overpassRateLimiter(clientKey(req)),
|
||||
});
|
||||
if (preflight.source === 'RATE_LIMITED') {
|
||||
res.writeHead(429, {
|
||||
'Content-Type': 'application/json',
|
||||
'Retry-After': '5',
|
||||
});
|
||||
if (preflight.source === 'RATE_LIMITED') {
|
||||
res.writeHead(429, { 'Content-Type': 'application/json', 'Retry-After': '5' });
|
||||
res.end(JSON.stringify({ error: 'Rate limit exceeded' }));
|
||||
return;
|
||||
}
|
||||
if (preflight.source !== 'UPSTREAM') {
|
||||
// A coalesced caller sees the same failure as the original request
|
||||
// and must get the same last-good fallback, not the raw refusal.
|
||||
if (!overpassPayloadIsData(preflight.payload)) {
|
||||
const stale = await readStaleOverpass(cacheKey);
|
||||
if (stale) {
|
||||
sendOverpassResponse(res, stale, 'STALE');
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (preflight.source === 'DISK') {
|
||||
_overpassCache.set(cacheKey, preflight.payload);
|
||||
trimOverpassCache();
|
||||
}
|
||||
sendOverpassResponse(res, preflight.payload, preflight.source);
|
||||
return;
|
||||
}
|
||||
|
||||
// From here onward the request is genuinely upstream-bound and has
|
||||
// consumed one local limiter slot. Cache and dedupe hits above do not.
|
||||
if (_overpassConcurrent >= OVERPASS_MAX_CONCURRENT) {
|
||||
res.writeHead(503, { 'Content-Type': 'application/json', 'Retry-After': '2' });
|
||||
res.end(JSON.stringify({ error: 'Overpass proxy busy — try again shortly' }));
|
||||
return;
|
||||
}
|
||||
_overpassConcurrent += 1;
|
||||
const requestPromise = fetchOverpassPayload(safeBody)
|
||||
.then((payload) => {
|
||||
// Only a 2xx is data. `< 500` cached every 4xx, so one mirror's
|
||||
// refusal was written to memory AND disk — and boundary-class
|
||||
// queries hold a month-long TTL, so a single 406 outlived the
|
||||
// outage that caused it.
|
||||
if (overpassPayloadIsData(payload)) {
|
||||
const entry = { ...payload, cachedAt: Date.now() };
|
||||
_overpassCache.set(cacheKey, entry);
|
||||
trimOverpassCache();
|
||||
writeOverpassDisk(cacheKey, entry);
|
||||
}
|
||||
return payload;
|
||||
})
|
||||
.finally(() => {
|
||||
_overpassConcurrent -= 1;
|
||||
_overpassInFlight.delete(cacheKey);
|
||||
});
|
||||
|
||||
_overpassInFlight.set(cacheKey, requestPromise);
|
||||
const payload = await requestPromise;
|
||||
// Degraded upstream (rate-limited on every mirror / 5xx / runtime
|
||||
// error): last-good roads beat an empty layer — serve stale from
|
||||
// memory or disk at ANY age before surfacing the failure.
|
||||
if (!overpassPayloadIsData(payload)) {
|
||||
res.end(JSON.stringify({ error: 'Rate limit exceeded' }));
|
||||
return;
|
||||
}
|
||||
if (preflight.source !== 'UPSTREAM') {
|
||||
// A coalesced caller sees the same failure as the original request
|
||||
// and must get the same last-good fallback, not the raw refusal.
|
||||
if (!overpassPayloadIsData(preflight.payload)) {
|
||||
const stale = await readStaleOverpass(cacheKey);
|
||||
if (stale) {
|
||||
sendOverpassResponse(res, stale, 'STALE');
|
||||
return;
|
||||
}
|
||||
}
|
||||
sendOverpassResponse(res, payload, 'MISS');
|
||||
} catch (e) {
|
||||
// Every mirror threw (network-level). Same serve-stale rule.
|
||||
const stale = cacheKey
|
||||
? await readStaleOverpass(cacheKey)
|
||||
: null;
|
||||
if (preflight.source === 'DISK') {
|
||||
_overpassCache.set(cacheKey, preflight.payload);
|
||||
trimOverpassCache();
|
||||
}
|
||||
sendOverpassResponse(res, preflight.payload, preflight.source);
|
||||
return;
|
||||
}
|
||||
|
||||
// From here onward the request is genuinely upstream-bound and has
|
||||
// consumed one local limiter slot. Cache and dedupe hits above do not.
|
||||
if (_overpassConcurrent >= OVERPASS_MAX_CONCURRENT) {
|
||||
res.writeHead(503, {
|
||||
'Content-Type': 'application/json',
|
||||
'Retry-After': '2',
|
||||
});
|
||||
res.end(
|
||||
JSON.stringify({
|
||||
error: 'Overpass proxy busy — try again shortly',
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
_overpassConcurrent += 1;
|
||||
const requestPromise = fetchOverpassPayload(safeBody)
|
||||
.then((payload) => {
|
||||
// Only a 2xx is data. `< 500` cached every 4xx, so one mirror's
|
||||
// refusal was written to memory AND disk — and boundary-class
|
||||
// queries hold a month-long TTL, so a single 406 outlived the
|
||||
// outage that caused it.
|
||||
if (overpassPayloadIsData(payload)) {
|
||||
const entry = { ...payload, cachedAt: Date.now() };
|
||||
_overpassCache.set(cacheKey, entry);
|
||||
trimOverpassCache();
|
||||
writeOverpassDisk(cacheKey, entry);
|
||||
}
|
||||
return payload;
|
||||
})
|
||||
.finally(() => {
|
||||
_overpassConcurrent -= 1;
|
||||
_overpassInFlight.delete(cacheKey);
|
||||
});
|
||||
|
||||
_overpassInFlight.set(cacheKey, requestPromise);
|
||||
const payload = await requestPromise;
|
||||
// Degraded upstream (rate-limited on every mirror / 5xx / runtime
|
||||
// error): last-good roads beat an empty layer — serve stale from
|
||||
// memory or disk at ANY age before surfacing the failure.
|
||||
if (!overpassPayloadIsData(payload)) {
|
||||
const stale = await readStaleOverpass(cacheKey);
|
||||
if (stale) {
|
||||
sendOverpassResponse(res, stale, 'STALE');
|
||||
return;
|
||||
}
|
||||
console.error('[Overpass Proxy]', e.message);
|
||||
res.writeHead(502, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ error: 'Overpass proxy error' }));
|
||||
}
|
||||
});
|
||||
sendOverpassResponse(res, payload, 'MISS');
|
||||
} catch (e) {
|
||||
// Every mirror threw (network-level). Same serve-stale rule.
|
||||
const stale = cacheKey ? await readStaleOverpass(cacheKey) : null;
|
||||
if (stale) {
|
||||
sendOverpassResponse(res, stale, 'STALE');
|
||||
return;
|
||||
}
|
||||
console.error('[Overpass Proxy]', e.message);
|
||||
res.writeHead(502, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ error: 'Overpass proxy error' }));
|
||||
}
|
||||
});
|
||||
|
||||
installRouteMiddleware(server.middlewares);
|
||||
};
|
||||
installRouteMiddleware(server.middlewares);
|
||||
};
|
||||
return {
|
||||
name: 'overpass-proxy',
|
||||
configureServer: installMiddleware,
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
import { isOverpassBoundaryQuery } from './query.js';
|
||||
import { OVERPASS_BOUNDARY_DISK_TTL_MS, OVERPASS_DISK_TTL_MS, OVERPASS_DISK_DIR, OVERPASS_CACHE_MS, OVERPASS_CACHE_MAX_ENTRIES } from './constants.js';
|
||||
import {
|
||||
OVERPASS_BOUNDARY_DISK_TTL_MS,
|
||||
OVERPASS_DISK_TTL_MS,
|
||||
OVERPASS_DISK_DIR,
|
||||
OVERPASS_CACHE_MS,
|
||||
OVERPASS_CACHE_MAX_ENTRIES,
|
||||
} from './constants.js';
|
||||
import path from 'node:path';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { promises as fsp } from 'node:fs';
|
||||
@@ -10,12 +16,17 @@ const _overpassCache = new Map();
|
||||
|
||||
/** Disk TTL for a query: boundary geometry keeps for a month, the rest 7 days. */
|
||||
function overpassDiskTtlMs(cacheKey) {
|
||||
return isOverpassBoundaryQuery(cacheKey) ? OVERPASS_BOUNDARY_DISK_TTL_MS : OVERPASS_DISK_TTL_MS;
|
||||
return isOverpassBoundaryQuery(cacheKey)
|
||||
? OVERPASS_BOUNDARY_DISK_TTL_MS
|
||||
: OVERPASS_DISK_TTL_MS;
|
||||
}
|
||||
|
||||
/** Normalized Overpass query -> stable disk-cache file path. */
|
||||
function overpassDiskPath(cacheKey) {
|
||||
return path.join(OVERPASS_DISK_DIR, `${createHash('sha1').update(cacheKey).digest('hex')}.json`);
|
||||
return path.join(
|
||||
OVERPASS_DISK_DIR,
|
||||
`${createHash('sha1').update(cacheKey).digest('hex')}.json`,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -27,7 +38,12 @@ async function readOverpassDisk(cacheKey, maxAgeMs) {
|
||||
try {
|
||||
const raw = await fsp.readFile(overpassDiskPath(cacheKey), 'utf8');
|
||||
const payload = JSON.parse(raw);
|
||||
if (!payload || typeof payload.body !== 'string' || !Number.isFinite(payload.cachedAt)) return null;
|
||||
if (
|
||||
!payload ||
|
||||
typeof payload.body !== 'string' ||
|
||||
!Number.isFinite(payload.cachedAt)
|
||||
)
|
||||
return null;
|
||||
// Older versions persisted 4xx refusals with normal data TTLs. Ignore
|
||||
// them on both fresh and stale reads so an upgrade can recover immediately.
|
||||
if (!overpassPayloadIsData(payload)) return null;
|
||||
@@ -40,9 +56,17 @@ async function readOverpassDisk(cacheKey, maxAgeMs) {
|
||||
|
||||
/** Fire-and-forget disk write for a successful Overpass payload. */
|
||||
function writeOverpassDisk(cacheKey, payload) {
|
||||
fsp.mkdir(OVERPASS_DISK_DIR, { recursive: true })
|
||||
.then(() => fsp.writeFile(overpassDiskPath(cacheKey), JSON.stringify(payload)))
|
||||
.catch((err) => console.warn('[Overpass Proxy] disk cache write failed:', err?.message || err));
|
||||
fsp
|
||||
.mkdir(OVERPASS_DISK_DIR, { recursive: true })
|
||||
.then(() =>
|
||||
fsp.writeFile(overpassDiskPath(cacheKey), JSON.stringify(payload)),
|
||||
)
|
||||
.catch((err) =>
|
||||
console.warn(
|
||||
'[Overpass Proxy] disk cache write failed:',
|
||||
err?.message || err,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -71,7 +95,8 @@ async function resolveOverpassPreflight({
|
||||
cacheMs = OVERPASS_CACHE_MS,
|
||||
}) {
|
||||
const cached = memoryCache.get(cacheKey);
|
||||
if (overpassPayloadIsData(cached) && now - cached.cachedAt <= cacheMs) return { source: 'HIT', payload: cached };
|
||||
if (overpassPayloadIsData(cached) && now - cached.cachedAt <= cacheMs)
|
||||
return { source: 'HIT', payload: cached };
|
||||
|
||||
const pending = inFlight.get(cacheKey);
|
||||
if (pending) return { source: 'INFLIGHT', payload: await pending };
|
||||
@@ -87,7 +112,9 @@ async function resolveOverpassPreflight({
|
||||
/** Return only last-good Overpass data, regardless of its age. */
|
||||
async function readStaleOverpass(cacheKey) {
|
||||
const cached = _overpassCache.get(cacheKey);
|
||||
return overpassPayloadIsData(cached) ? cached : readOverpassDisk(cacheKey, Infinity);
|
||||
return overpassPayloadIsData(cached)
|
||||
? cached
|
||||
: readOverpassDisk(cacheKey, Infinity);
|
||||
}
|
||||
|
||||
/** Evict oldest Overpass cache entries until size is within the cap. */
|
||||
@@ -99,4 +126,12 @@ function trimOverpassCache() {
|
||||
}
|
||||
}
|
||||
|
||||
export { readOverpassDisk, resolveOverpassPreflight, _overpassCache, overpassDiskTtlMs, readStaleOverpass, trimOverpassCache, writeOverpassDisk };
|
||||
export {
|
||||
readOverpassDisk,
|
||||
resolveOverpassPreflight,
|
||||
_overpassCache,
|
||||
overpassDiskTtlMs,
|
||||
readStaleOverpass,
|
||||
trimOverpassCache,
|
||||
writeOverpassDisk,
|
||||
};
|
||||
@@ -97,12 +97,38 @@ const OVERPASS_MAX_BBOX_DEG = 12;
|
||||
const OVERPASS_ELEMENT_TYPES = 'node|way|relation|nwr|nw|nr|wr|rel';
|
||||
|
||||
/** Element-selector (incl. `area`) whose statements must be individually bounded. */
|
||||
const OVERPASS_SELECTOR_RE = new RegExp(`\\b(?:${OVERPASS_ELEMENT_TYPES}|area)\\b`);
|
||||
const OVERPASS_SELECTOR_RE = new RegExp(
|
||||
`\\b(?:${OVERPASS_ELEMENT_TYPES}|area)\\b`,
|
||||
);
|
||||
|
||||
/** An element selector bounded BY an area — the country-scan abuse shape. */
|
||||
const OVERPASS_AREA_ELEMENT_RE = new RegExp(`\\b(?:${OVERPASS_ELEMENT_TYPES})\\s*\\(\\s*area\\b`, 'i');
|
||||
const OVERPASS_AREA_ELEMENT_RE = new RegExp(
|
||||
`\\b(?:${OVERPASS_ELEMENT_TYPES})\\s*\\(\\s*area\\b`,
|
||||
'i',
|
||||
);
|
||||
|
||||
/** A single bbox 4-tuple `(s,w,n,e)` (non-global so it does not advance lastIndex). */
|
||||
const OVERPASS_BBOX_RE = /\(\s*-?\d+(?:\.\d+)?\s*,\s*-?\d+(?:\.\d+)?\s*,\s*-?\d+(?:\.\d+)?\s*,\s*-?\d+(?:\.\d+)?\s*\)/;
|
||||
const OVERPASS_BBOX_RE =
|
||||
/\(\s*-?\d+(?:\.\d+)?\s*,\s*-?\d+(?:\.\d+)?\s*,\s*-?\d+(?:\.\d+)?\s*,\s*-?\d+(?:\.\d+)?\s*\)/;
|
||||
|
||||
export { OVERPASS_BOUNDARY_DISK_TTL_MS, OVERPASS_DISK_TTL_MS, OVERPASS_DISK_DIR, OVERPASS_CACHE_MS, OVERPASS_CACHE_MAX_ENTRIES, OVERPASS_MAX_BODY_BYTES, OVERPASS_MAX_CONCURRENT, OVERPASS_MAX_AROUND_M, OVERPASS_MAX_BBOX_DEG, OVERPASS_AREA_ELEMENT_RE, OVERPASS_SELECTOR_RE, OVERPASS_BBOX_RE, OVERPASS_MAX_QL_TIMEOUT, OVERPASS_SIMPLIFY_MIN_BYTES, OVERPASS_SIMPLIFY_MIN_POINTS, OVERPASS_SIMPLIFY_TOLERANCE_DEG, OVERPASS_MAX_RESPONSE_BYTES, OVERPASS_UPSTREAMS, OVERPASS_TIMEOUT_MS };
|
||||
export {
|
||||
OVERPASS_BOUNDARY_DISK_TTL_MS,
|
||||
OVERPASS_DISK_TTL_MS,
|
||||
OVERPASS_DISK_DIR,
|
||||
OVERPASS_CACHE_MS,
|
||||
OVERPASS_CACHE_MAX_ENTRIES,
|
||||
OVERPASS_MAX_BODY_BYTES,
|
||||
OVERPASS_MAX_CONCURRENT,
|
||||
OVERPASS_MAX_AROUND_M,
|
||||
OVERPASS_MAX_BBOX_DEG,
|
||||
OVERPASS_AREA_ELEMENT_RE,
|
||||
OVERPASS_SELECTOR_RE,
|
||||
OVERPASS_BBOX_RE,
|
||||
OVERPASS_MAX_QL_TIMEOUT,
|
||||
OVERPASS_SIMPLIFY_MIN_BYTES,
|
||||
OVERPASS_SIMPLIFY_MIN_POINTS,
|
||||
OVERPASS_SIMPLIFY_TOLERANCE_DEG,
|
||||
OVERPASS_MAX_RESPONSE_BYTES,
|
||||
OVERPASS_UPSTREAMS,
|
||||
OVERPASS_TIMEOUT_MS,
|
||||
};
|
||||
@@ -1,4 +1,8 @@
|
||||
import { OVERPASS_SIMPLIFY_MIN_BYTES, OVERPASS_SIMPLIFY_MIN_POINTS, OVERPASS_SIMPLIFY_TOLERANCE_DEG } from './constants.js';
|
||||
import {
|
||||
OVERPASS_SIMPLIFY_MIN_BYTES,
|
||||
OVERPASS_SIMPLIFY_MIN_POINTS,
|
||||
OVERPASS_SIMPLIFY_TOLERANCE_DEG,
|
||||
} from './constants.js';
|
||||
|
||||
/** Iterative Douglas-Peucker on [{lat,lon},...] (planar-degree approx — fine at
|
||||
* the ~44 m tolerance used here). Endpoints always kept. */
|
||||
@@ -51,7 +55,10 @@ function simplifyElementGeometry(el, minPoints, toleranceDeg) {
|
||||
}
|
||||
if (Array.isArray(el?.members)) {
|
||||
for (const member of el.members) {
|
||||
if (Array.isArray(member?.geometry) && member.geometry.length >= minPoints) {
|
||||
if (
|
||||
Array.isArray(member?.geometry) &&
|
||||
member.geometry.length >= minPoints
|
||||
) {
|
||||
member.geometry = douglasPeucker(member.geometry, toleranceDeg);
|
||||
}
|
||||
}
|
||||
@@ -73,7 +80,8 @@ function simplifyOverpassPayloadBody(bodyText, opts = {}) {
|
||||
const minBytes = opts.minBytes ?? OVERPASS_SIMPLIFY_MIN_BYTES;
|
||||
const minPoints = opts.minPoints ?? OVERPASS_SIMPLIFY_MIN_POINTS;
|
||||
const toleranceDeg = opts.toleranceDeg ?? OVERPASS_SIMPLIFY_TOLERANCE_DEG;
|
||||
if (typeof bodyText !== 'string' || bodyText.length < minBytes) return bodyText;
|
||||
if (typeof bodyText !== 'string' || bodyText.length < minBytes)
|
||||
return bodyText;
|
||||
let data;
|
||||
try {
|
||||
data = JSON.parse(bodyText);
|
||||
@@ -81,7 +89,8 @@ function simplifyOverpassPayloadBody(bodyText, opts = {}) {
|
||||
return bodyText;
|
||||
}
|
||||
if (!Array.isArray(data?.elements)) return bodyText;
|
||||
for (const el of data.elements) simplifyElementGeometry(el, minPoints, toleranceDeg);
|
||||
for (const el of data.elements)
|
||||
simplifyElementGeometry(el, minPoints, toleranceDeg);
|
||||
try {
|
||||
return JSON.stringify(data);
|
||||
} catch {
|
||||
|
||||
@@ -1,4 +1,11 @@
|
||||
import { OVERPASS_MAX_AROUND_M, OVERPASS_MAX_BBOX_DEG, OVERPASS_AREA_ELEMENT_RE, OVERPASS_SELECTOR_RE, OVERPASS_BBOX_RE, OVERPASS_MAX_QL_TIMEOUT } from './constants.js';
|
||||
import {
|
||||
OVERPASS_MAX_AROUND_M,
|
||||
OVERPASS_MAX_BBOX_DEG,
|
||||
OVERPASS_AREA_ELEMENT_RE,
|
||||
OVERPASS_SELECTOR_RE,
|
||||
OVERPASS_BBOX_RE,
|
||||
OVERPASS_MAX_QL_TIMEOUT,
|
||||
} from './constants.js';
|
||||
|
||||
/**
|
||||
* Whether a normalized Overpass query is BOUNDARY-class (admin `is_in` lookups
|
||||
@@ -41,8 +48,14 @@ function stripOverpassNoise(src) {
|
||||
const quote = c;
|
||||
i += 1;
|
||||
while (i < n) {
|
||||
if (src[i] === '\\') { i += 2; continue; } // escaped char
|
||||
if (src[i] === quote) { i += 1; break; } // closing quote
|
||||
if (src[i] === '\\') {
|
||||
i += 2;
|
||||
continue;
|
||||
} // escaped char
|
||||
if (src[i] === quote) {
|
||||
i += 1;
|
||||
break;
|
||||
} // closing quote
|
||||
i += 1;
|
||||
}
|
||||
out += quote + quote; // collapse the literal to empty quotes
|
||||
@@ -69,11 +82,17 @@ function stripOverpassNoise(src) {
|
||||
|
||||
function sanitizeOverpassBody(rawBody) {
|
||||
let params;
|
||||
try { params = new URLSearchParams(rawBody); } catch { return { ok: false, error: 'Malformed query body' }; }
|
||||
try {
|
||||
params = new URLSearchParams(rawBody);
|
||||
} catch {
|
||||
return { ok: false, error: 'Malformed query body' };
|
||||
}
|
||||
const all = params.getAll('data');
|
||||
if (all.length !== 1) return { ok: false, error: 'Exactly one data query is required' };
|
||||
if (all.length !== 1)
|
||||
return { ok: false, error: 'Exactly one data query is required' };
|
||||
const data = all[0];
|
||||
if (!data || !data.trim()) return { ok: false, error: 'Missing Overpass data query' };
|
||||
if (!data || !data.trim())
|
||||
return { ok: false, error: 'Missing Overpass data query' };
|
||||
|
||||
// Blank quoted literals + strip comments in one lexer pass so a fake bound or a
|
||||
// `//` inside a string can't hide an unbounded selector (or satisfy a bound).
|
||||
@@ -89,9 +108,17 @@ function sanitizeOverpassBody(rawBody) {
|
||||
}
|
||||
}
|
||||
// Reject world-sized / oversized bboxes.
|
||||
for (const m of stripped.matchAll(/\(\s*(-?\d+(?:\.\d+)?)\s*,\s*(-?\d+(?:\.\d+)?)\s*,\s*(-?\d+(?:\.\d+)?)\s*,\s*(-?\d+(?:\.\d+)?)\s*\)/g)) {
|
||||
const s = Number(m[1]); const w = Number(m[2]); const n = Number(m[3]); const e = Number(m[4]);
|
||||
if (Math.abs(n - s) > OVERPASS_MAX_BBOX_DEG || Math.abs(e - w) > OVERPASS_MAX_BBOX_DEG) {
|
||||
for (const m of stripped.matchAll(
|
||||
/\(\s*(-?\d+(?:\.\d+)?)\s*,\s*(-?\d+(?:\.\d+)?)\s*,\s*(-?\d+(?:\.\d+)?)\s*,\s*(-?\d+(?:\.\d+)?)\s*\)/g,
|
||||
)) {
|
||||
const s = Number(m[1]);
|
||||
const w = Number(m[2]);
|
||||
const n = Number(m[3]);
|
||||
const e = Number(m[4]);
|
||||
if (
|
||||
Math.abs(n - s) > OVERPASS_MAX_BBOX_DEG ||
|
||||
Math.abs(e - w) > OVERPASS_MAX_BBOX_DEG
|
||||
) {
|
||||
return { ok: false, error: 'Overpass bbox too large' };
|
||||
}
|
||||
}
|
||||
@@ -122,7 +149,10 @@ function sanitizeOverpassBody(rawBody) {
|
||||
// misread as a spatial bound. Bounds live in (...) / function calls / set
|
||||
// refs, never inside [...], so the probe loses nothing real.
|
||||
const outSets = [];
|
||||
const body = stmt.replace(/->\s*\.(\w+)/g, (_, name) => { outSets.push(name); return ' '; });
|
||||
const body = stmt.replace(/->\s*\.(\w+)/g, (_, name) => {
|
||||
outSets.push(name);
|
||||
return ' ';
|
||||
});
|
||||
const probe = body.replace(/\[[^\]]*\]/g, ' ');
|
||||
|
||||
// Reject element-in-area scans on the TAG-STRIPPED probe, so a tag filter
|
||||
@@ -131,15 +161,21 @@ function sanitizeOverpassBody(rawBody) {
|
||||
// SELECTS admin areas (area.set) and pivots (rel(pivot.x)), never node/way/
|
||||
// relation(area...). The probe collapses tags so `way (area.a)` is caught.
|
||||
if (OVERPASS_AREA_ELEMENT_RE.test(probe)) {
|
||||
return { ok: false, error: 'Overpass area-bounded element selector not allowed' };
|
||||
return {
|
||||
ok: false,
|
||||
error: 'Overpass area-bounded element selector not allowed',
|
||||
};
|
||||
}
|
||||
|
||||
const hasSelector = OVERPASS_SELECTOR_RE.test(probe);
|
||||
const inputSets = [...probe.matchAll(/(?<!\d)\.([a-z_]\w*)/gi)].map((m) => m[1]);
|
||||
const directBound = /around:\s*\d/.test(probe)
|
||||
|| OVERPASS_BBOX_RE.test(probe)
|
||||
|| /is_in\s*\(/.test(probe) // is_in(lat,lon) — the function form only
|
||||
|| /\barea\s*\(/.test(probe); // area(id) — bounded as a set definition
|
||||
const inputSets = [...probe.matchAll(/(?<!\d)\.([a-z_]\w*)/gi)].map(
|
||||
(m) => m[1],
|
||||
);
|
||||
const directBound =
|
||||
/around:\s*\d/.test(probe) ||
|
||||
OVERPASS_BBOX_RE.test(probe) ||
|
||||
/is_in\s*\(/.test(probe) || // is_in(lat,lon) — the function form only
|
||||
/\barea\s*\(/.test(probe); // area(id) — bounded as a set definition
|
||||
|
||||
const setBound = inputSets.some((s) => boundedSets.has(s));
|
||||
const bounded = directBound || setBound;
|
||||
@@ -153,7 +189,8 @@ function sanitizeOverpassBody(rawBody) {
|
||||
|
||||
const clamped = data.replace(
|
||||
/\[timeout:\s*(\d+)\s*\]/gi,
|
||||
(_, n) => `[timeout:${Math.min(Number(n) || OVERPASS_MAX_QL_TIMEOUT, OVERPASS_MAX_QL_TIMEOUT)}]`,
|
||||
(_, n) =>
|
||||
`[timeout:${Math.min(Number(n) || OVERPASS_MAX_QL_TIMEOUT, OVERPASS_MAX_QL_TIMEOUT)}]`,
|
||||
);
|
||||
return { ok: true, body: `data=${encodeURIComponent(clamped)}` };
|
||||
}
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
import { OVERPASS_MAX_RESPONSE_BYTES, OVERPASS_UPSTREAMS, OVERPASS_TIMEOUT_MS } from './constants.js';
|
||||
import {
|
||||
OVERPASS_MAX_RESPONSE_BYTES,
|
||||
OVERPASS_UPSTREAMS,
|
||||
OVERPASS_TIMEOUT_MS,
|
||||
} from './constants.js';
|
||||
import { readResponseTextCapped } from '../common/http.js';
|
||||
import { simplifyOverpassPayloadBody } from './geometry.js';
|
||||
|
||||
@@ -13,10 +17,12 @@ import { simplifyOverpassPayloadBody } from './geometry.js';
|
||||
*/
|
||||
function overpassLooksRateLimited(bodyText) {
|
||||
const text = String(bodyText || '').toLowerCase();
|
||||
return text.includes('rate_limited')
|
||||
|| text.includes('quota of your ip address')
|
||||
|| text.includes('dispatcher_client::request_read_and_idx::rate_limited')
|
||||
|| text.includes('too many requests');
|
||||
return (
|
||||
text.includes('rate_limited') ||
|
||||
text.includes('quota of your ip address') ||
|
||||
text.includes('dispatcher_client::request_read_and_idx::rate_limited') ||
|
||||
text.includes('too many requests')
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -26,9 +32,11 @@ function overpassLooksRateLimited(bodyText) {
|
||||
*/
|
||||
function overpassLooksRuntimeError(bodyText) {
|
||||
const text = String(bodyText || '').toLowerCase();
|
||||
return text.includes('runtime error')
|
||||
|| text.includes('timed out')
|
||||
|| text.includes('out of memory');
|
||||
return (
|
||||
text.includes('runtime error') ||
|
||||
text.includes('timed out') ||
|
||||
text.includes('out of memory')
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -42,10 +50,13 @@ function overpassLooksRuntimeError(bodyText) {
|
||||
*/
|
||||
function overpassPayloadIsData(payload) {
|
||||
const status = Number(payload?.status);
|
||||
return Number.isFinite(status)
|
||||
&& status >= 200 && status < 300
|
||||
&& !payload.rateLimited
|
||||
&& !payload.runtimeError;
|
||||
return (
|
||||
Number.isFinite(status) &&
|
||||
status >= 200 &&
|
||||
status < 300 &&
|
||||
!payload.rateLimited &&
|
||||
!payload.runtimeError
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -57,12 +68,16 @@ function overpassPayloadIsData(payload) {
|
||||
* @param {object} [options] Server-only endpoint and I/O overrides for tests.
|
||||
* @returns {Promise<{status:number,body:string,contentType:string,endpoint:string,rateLimited:boolean}>}
|
||||
*/
|
||||
async function fetchOverpassPayload(body, maxResponseBytes = OVERPASS_MAX_RESPONSE_BYTES, {
|
||||
endpoints = OVERPASS_UPSTREAMS,
|
||||
fetchImpl = fetch,
|
||||
readBody = readResponseTextCapped,
|
||||
simplify = simplifyOverpassPayloadBody,
|
||||
} = {}) {
|
||||
async function fetchOverpassPayload(
|
||||
body,
|
||||
maxResponseBytes = OVERPASS_MAX_RESPONSE_BYTES,
|
||||
{
|
||||
endpoints = OVERPASS_UPSTREAMS,
|
||||
fetchImpl = fetch,
|
||||
readBody = readResponseTextCapped,
|
||||
simplify = simplifyOverpassPayloadBody,
|
||||
} = {},
|
||||
) {
|
||||
let lastError = null;
|
||||
let lastRateLimitPayload = null;
|
||||
let lastRefusalPayload = null;
|
||||
@@ -83,9 +98,11 @@ async function fetchOverpassPayload(body, maxResponseBytes = OVERPASS_MAX_RESPON
|
||||
});
|
||||
|
||||
const responseBody = await readBody(upstream, maxResponseBytes);
|
||||
const contentType = upstream.headers.get('content-type') || 'application/json';
|
||||
const contentType =
|
||||
upstream.headers.get('content-type') || 'application/json';
|
||||
const status = upstream.status;
|
||||
const rateLimited = status === 429 || overpassLooksRateLimited(responseBody);
|
||||
const rateLimited =
|
||||
status === 429 || overpassLooksRateLimited(responseBody);
|
||||
const runtimeError = overpassLooksRuntimeError(responseBody);
|
||||
const payload = {
|
||||
status,
|
||||
@@ -116,7 +133,9 @@ async function fetchOverpassPayload(body, maxResponseBytes = OVERPASS_MAX_RESPON
|
||||
// every mirror has had the chance to answer it.
|
||||
if (status < 200 || status >= 300) {
|
||||
if (!lastRefusalPayload) lastRefusalPayload = payload;
|
||||
lastError = new Error(`Overpass upstream returned ${status} (${endpoint})`);
|
||||
lastError = new Error(
|
||||
`Overpass upstream returned ${status} (${endpoint})`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
export { regionalBriefProxy, regionalBriefHasAnySource } from './regional/briefing.js';
|
||||
export {
|
||||
regionalBriefProxy,
|
||||
regionalBriefHasAnySource,
|
||||
} from './regional/briefing.js';
|
||||
export { weatherEffectsProxy } from './regional/weather-effects.js';
|
||||
export { validRegionalPoint } from './regional/query.js';
|
||||
@@ -18,7 +18,11 @@ const _regionalBriefCache = new Map();
|
||||
|
||||
const _regionalBriefInFlight = new Map();
|
||||
|
||||
const _regionalBriefRateLimiter = makeRateLimiter({ windowMs: 60_000, max: 30, globalMax: 90 });
|
||||
const _regionalBriefRateLimiter = makeRateLimiter({
|
||||
windowMs: 60_000,
|
||||
max: 30,
|
||||
globalMax: 90,
|
||||
});
|
||||
|
||||
function trimRegionalBriefCache() {
|
||||
while (_regionalBriefCache.size > REGIONAL_BRIEF_MAX_CACHE) {
|
||||
@@ -40,13 +44,15 @@ function regionalBriefProxy() {
|
||||
fetchRegionalWeather(point),
|
||||
]);
|
||||
const place = placeResult.status === 'fulfilled' ? placeResult.value : null;
|
||||
const weather = weatherResult.status === 'fulfilled' ? weatherResult.value : null;
|
||||
const weather =
|
||||
weatherResult.status === 'fulfilled' ? weatherResult.value : null;
|
||||
const news = await fetchRegionalNews(place);
|
||||
if (!regionalBriefHasAnySource({ place, weather, news })) {
|
||||
throw new Error('All regional briefing sources unavailable');
|
||||
}
|
||||
const payload = {
|
||||
status: place && weather && news.status !== 'unavailable' ? 'ready' : 'partial',
|
||||
status:
|
||||
place && weather && news.status !== 'unavailable' ? 'ready' : 'partial',
|
||||
retrievedAt: new Date().toISOString(),
|
||||
coordinates: point,
|
||||
place,
|
||||
@@ -71,7 +77,10 @@ function regionalBriefProxy() {
|
||||
return;
|
||||
}
|
||||
if (!_regionalBriefRateLimiter(clientKey(req))) {
|
||||
res.writeHead(429, { 'Content-Type': 'application/json', 'Retry-After': '10' });
|
||||
res.writeHead(429, {
|
||||
'Content-Type': 'application/json',
|
||||
'Retry-After': '10',
|
||||
});
|
||||
res.end(JSON.stringify({ error: 'Rate limit exceeded' }));
|
||||
return;
|
||||
}
|
||||
@@ -79,18 +88,28 @@ function regionalBriefProxy() {
|
||||
const point = validRegionalPoint(url.searchParams);
|
||||
if (!point) {
|
||||
res.writeHead(400, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ error: 'Valid latitude and longitude are required' }));
|
||||
res.end(
|
||||
JSON.stringify({
|
||||
error: 'Valid latitude and longitude are required',
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
const key = `${(Math.round(point.latitude * 10) / 10).toFixed(1)},${(Math.round(point.longitude * 10) / 10).toFixed(1)}`;
|
||||
const now = Date.now();
|
||||
const cached = _regionalBriefCache.get(key);
|
||||
if (cached && now - cached.cachedAt <= REGIONAL_BRIEF_CACHE_MS) {
|
||||
res.writeHead(200, { 'Content-Type': 'application/json', 'Cache-Control': 'public, max-age=60', 'X-Regional-Brief': 'HIT' });
|
||||
res.writeHead(200, {
|
||||
'Content-Type': 'application/json',
|
||||
'Cache-Control': 'public, max-age=60',
|
||||
'X-Regional-Brief': 'HIT',
|
||||
});
|
||||
res.end(JSON.stringify({ ...cached.payload, status: 'cached' }));
|
||||
return;
|
||||
}
|
||||
const request = coalesceProxyRequest(_regionalBriefInFlight, key, () => refresh(point, key));
|
||||
const request = coalesceProxyRequest(_regionalBriefInFlight, key, () =>
|
||||
refresh(point, key),
|
||||
);
|
||||
try {
|
||||
const payload = await request.promise;
|
||||
res.writeHead(200, {
|
||||
@@ -101,12 +120,23 @@ function regionalBriefProxy() {
|
||||
res.end(JSON.stringify(payload));
|
||||
} catch {
|
||||
if (cached && now - cached.cachedAt <= REGIONAL_BRIEF_STALE_MS) {
|
||||
res.writeHead(200, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store', 'X-Regional-Brief': 'STALE' });
|
||||
res.writeHead(200, {
|
||||
'Content-Type': 'application/json',
|
||||
'Cache-Control': 'no-store',
|
||||
'X-Regional-Brief': 'STALE',
|
||||
});
|
||||
res.end(JSON.stringify({ ...cached.payload, status: 'stale' }));
|
||||
return;
|
||||
}
|
||||
res.writeHead(503, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' });
|
||||
res.end(JSON.stringify({ error: 'Regional briefing is temporarily unavailable' }));
|
||||
res.writeHead(503, {
|
||||
'Content-Type': 'application/json',
|
||||
'Cache-Control': 'no-store',
|
||||
});
|
||||
res.end(
|
||||
JSON.stringify({
|
||||
error: 'Regional briefing is temporarily unavailable',
|
||||
}),
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,12 +1,18 @@
|
||||
import { readResponseJsonCapped, readResponseTextCapped } from '../common/http.js';
|
||||
import {
|
||||
readResponseJsonCapped,
|
||||
readResponseTextCapped,
|
||||
} from '../common/http.js';
|
||||
|
||||
const REGIONAL_MAX_RESPONSE_BYTES = 2 * 1024 * 1024;
|
||||
|
||||
async function fetchRegionalJson(url, {
|
||||
headers = {},
|
||||
timeoutMs = 9000,
|
||||
maxBytes = REGIONAL_MAX_RESPONSE_BYTES,
|
||||
} = {}) {
|
||||
async function fetchRegionalJson(
|
||||
url,
|
||||
{
|
||||
headers = {},
|
||||
timeoutMs = 9000,
|
||||
maxBytes = REGIONAL_MAX_RESPONSE_BYTES,
|
||||
} = {},
|
||||
) {
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), timeoutMs);
|
||||
try {
|
||||
@@ -18,11 +24,14 @@ async function fetchRegionalJson(url, {
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchRegionalText(url, {
|
||||
headers = {},
|
||||
timeoutMs = 9000,
|
||||
maxBytes = REGIONAL_MAX_RESPONSE_BYTES,
|
||||
} = {}) {
|
||||
async function fetchRegionalText(
|
||||
url,
|
||||
{
|
||||
headers = {},
|
||||
timeoutMs = 9000,
|
||||
maxBytes = REGIONAL_MAX_RESPONSE_BYTES,
|
||||
} = {},
|
||||
) {
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), timeoutMs);
|
||||
try {
|
||||
|
||||
@@ -4,24 +4,39 @@ import { normalizeRegionalArticles } from '../../../src/data/regionalBrief.js';
|
||||
function decodeRssText(value) {
|
||||
return String(value || '')
|
||||
.replace(/<!\[CDATA\[([\s\S]*?)\]\]>/g, '$1')
|
||||
.replace(/&/g, '&').replace(/"/g, '"').replace(/'/g, "'")
|
||||
.replace(/</g, '<').replace(/>/g, '>')
|
||||
.replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim();
|
||||
.replace(/&/g, '&')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, "'")
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/<[^>]+>/g, ' ')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim();
|
||||
}
|
||||
|
||||
function rssTag(block, tag) {
|
||||
return decodeRssText(new RegExp(`<${tag}(?:\\s[^>]*)?>([\\s\\S]*?)<\\/${tag}>`, 'i').exec(block)?.[1] || '');
|
||||
return decodeRssText(
|
||||
new RegExp(`<${tag}(?:\\s[^>]*)?>([\\s\\S]*?)<\\/${tag}>`, 'i').exec(
|
||||
block,
|
||||
)?.[1] || '',
|
||||
);
|
||||
}
|
||||
|
||||
function normalizeRssArticles(xml, limit = 5) {
|
||||
const seen = new Set();
|
||||
const articles = [];
|
||||
for (const match of String(xml || '').matchAll(/<item>([\s\S]*?)<\/item>/gi)) {
|
||||
for (const match of String(xml || '').matchAll(
|
||||
/<item>([\s\S]*?)<\/item>/gi,
|
||||
)) {
|
||||
const item = match[1];
|
||||
const title = rssTag(item, 'title').slice(0, 180);
|
||||
const url = rssTag(item, 'link');
|
||||
let parsedUrl;
|
||||
try { parsedUrl = new URL(url); } catch { continue; }
|
||||
try {
|
||||
parsedUrl = new URL(url);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
if (!title || !['http:', 'https:'].includes(parsedUrl.protocol)) continue;
|
||||
const source = rssTag(item, 'source');
|
||||
const signature = `${title.toLowerCase()}|${source.toLowerCase() || parsedUrl.hostname}`;
|
||||
@@ -32,7 +47,9 @@ function normalizeRssArticles(xml, limit = 5) {
|
||||
title,
|
||||
url: parsedUrl.href,
|
||||
domain: source || parsedUrl.hostname.replace(/^www\./, ''),
|
||||
publishedAt: Number.isNaN(Date.parse(rawDate)) ? null : new Date(rawDate).toISOString(),
|
||||
publishedAt: Number.isNaN(Date.parse(rawDate))
|
||||
? null
|
||||
: new Date(rawDate).toISOString(),
|
||||
sourceCountry: null,
|
||||
});
|
||||
if (articles.length >= limit) break;
|
||||
@@ -42,7 +59,8 @@ function normalizeRssArticles(xml, limit = 5) {
|
||||
|
||||
async function fetchRegionalNews(place) {
|
||||
const query = place?.locality || place?.region || place?.country;
|
||||
if (!query) return { status: 'unavailable', query: null, articles: [], source: null };
|
||||
if (!query)
|
||||
return { status: 'unavailable', query: null, articles: [], source: null };
|
||||
const rssParams = new URLSearchParams({
|
||||
q: String(query).replace(/["\\]/g, ' ').trim(),
|
||||
hl: 'en-US',
|
||||
@@ -50,13 +68,19 @@ async function fetchRegionalNews(place) {
|
||||
ceid: 'US:en',
|
||||
});
|
||||
try {
|
||||
const xml = await fetchRegionalText(`https://news.google.com/rss/search?${rssParams}`, {
|
||||
headers: { 'User-Agent': 'GodsEyeView/0.1' },
|
||||
timeoutMs: 12_000,
|
||||
});
|
||||
const xml = await fetchRegionalText(
|
||||
`https://news.google.com/rss/search?${rssParams}`,
|
||||
{
|
||||
headers: { 'User-Agent': 'GodsEyeView/0.1' },
|
||||
timeoutMs: 12_000,
|
||||
},
|
||||
);
|
||||
const articles = normalizeRssArticles(xml, 5);
|
||||
if (articles.length) return { status: 'ready', query, articles, source: 'Google News RSS' };
|
||||
} catch { /* fall through to the existing free index */ }
|
||||
if (articles.length)
|
||||
return { status: 'ready', query, articles, source: 'Google News RSS' };
|
||||
} catch {
|
||||
/* fall through to the existing free index */
|
||||
}
|
||||
const params = new URLSearchParams({
|
||||
query: `"${String(query).replace(/["\\]/g, ' ').trim()}"`,
|
||||
mode: 'artlist',
|
||||
@@ -66,12 +90,20 @@ async function fetchRegionalNews(place) {
|
||||
timespan: '48h',
|
||||
});
|
||||
try {
|
||||
const payload = await fetchRegionalJson(`https://api.gdeltproject.org/api/v2/doc/doc?${params}`, {
|
||||
headers: { 'User-Agent': 'GodsEyeView/0.1' },
|
||||
timeoutMs: 12_000,
|
||||
});
|
||||
const payload = await fetchRegionalJson(
|
||||
`https://api.gdeltproject.org/api/v2/doc/doc?${params}`,
|
||||
{
|
||||
headers: { 'User-Agent': 'GodsEyeView/0.1' },
|
||||
timeoutMs: 12_000,
|
||||
},
|
||||
);
|
||||
const articles = normalizeRegionalArticles(payload, 5);
|
||||
return { status: articles.length ? 'ready' : 'empty', query, articles, source: 'GDELT fallback' };
|
||||
return {
|
||||
status: articles.length ? 'ready' : 'empty',
|
||||
query,
|
||||
articles,
|
||||
source: 'GDELT fallback',
|
||||
};
|
||||
} catch {
|
||||
return { status: 'unavailable', query, articles: [], source: null };
|
||||
}
|
||||
|
||||
@@ -18,12 +18,16 @@ function fetchRegionalPlace(point) {
|
||||
addressdetails: '1',
|
||||
'accept-language': 'en',
|
||||
});
|
||||
const payload = await fetchRegionalJson(`https://nominatim.openstreetmap.org/reverse?${params}`, {
|
||||
headers: {
|
||||
'User-Agent': 'GodsEyeView/0.1 (+https://github.com/bilawalsidhu/gods-eye-view)',
|
||||
Referer: 'https://github.com/bilawalsidhu/gods-eye-view',
|
||||
const payload = await fetchRegionalJson(
|
||||
`https://nominatim.openstreetmap.org/reverse?${params}`,
|
||||
{
|
||||
headers: {
|
||||
'User-Agent':
|
||||
'GodsEyeView/0.1 (+https://github.com/bilawalsidhu/gods-eye-view)',
|
||||
Referer: 'https://github.com/bilawalsidhu/gods-eye-view',
|
||||
},
|
||||
},
|
||||
});
|
||||
);
|
||||
return normalizeRegionalPlace(payload);
|
||||
});
|
||||
_nominatimQueue = task.catch(() => null);
|
||||
|
||||
@@ -4,7 +4,8 @@ function validRegionalPoint(params) {
|
||||
const latitude = requiredFiniteQueryNumber(params, 'latitude');
|
||||
const longitude = requiredFiniteQueryNumber(params, 'longitude');
|
||||
if (!Number.isFinite(latitude) || !Number.isFinite(longitude)) return null;
|
||||
if (latitude < -90 || latitude > 90 || longitude < -180 || longitude > 180) return null;
|
||||
if (latitude < -90 || latitude > 90 || longitude < -180 || longitude > 180)
|
||||
return null;
|
||||
return { latitude, longitude };
|
||||
}
|
||||
|
||||
|
||||
@@ -13,7 +13,11 @@ const _weatherEffectsCache = new Map();
|
||||
|
||||
const _weatherEffectsInFlight = new Map();
|
||||
|
||||
const _weatherEffectsRateLimiter = makeRateLimiter({ windowMs: 60_000, max: 45, globalMax: 120 });
|
||||
const _weatherEffectsRateLimiter = makeRateLimiter({
|
||||
windowMs: 60_000,
|
||||
max: 45,
|
||||
globalMax: 120,
|
||||
});
|
||||
|
||||
function trimWeatherEffectsCache() {
|
||||
while (_weatherEffectsCache.size > WEATHER_EFFECTS_MAX_CACHE) {
|
||||
@@ -46,7 +50,10 @@ function weatherEffectsProxy() {
|
||||
return;
|
||||
}
|
||||
if (!_weatherEffectsRateLimiter(clientKey(req))) {
|
||||
res.writeHead(429, { 'Content-Type': 'application/json', 'Retry-After': '10' });
|
||||
res.writeHead(429, {
|
||||
'Content-Type': 'application/json',
|
||||
'Retry-After': '10',
|
||||
});
|
||||
res.end(JSON.stringify({ error: 'Rate limit exceeded' }));
|
||||
return;
|
||||
}
|
||||
@@ -54,7 +61,11 @@ function weatherEffectsProxy() {
|
||||
const point = validRegionalPoint(url.searchParams);
|
||||
if (!point) {
|
||||
res.writeHead(400, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ error: 'Valid latitude and longitude are required' }));
|
||||
res.end(
|
||||
JSON.stringify({
|
||||
error: 'Valid latitude and longitude are required',
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
const key = `${(Math.round(point.latitude * 10) / 10).toFixed(1)},${(Math.round(point.longitude * 10) / 10).toFixed(1)}`;
|
||||
@@ -69,7 +80,9 @@ function weatherEffectsProxy() {
|
||||
res.end(JSON.stringify({ ...cached.payload, status: 'cached' }));
|
||||
return;
|
||||
}
|
||||
const request = coalesceProxyRequest(_weatherEffectsInFlight, key, () => refresh(point, key));
|
||||
const request = coalesceProxyRequest(_weatherEffectsInFlight, key, () =>
|
||||
refresh(point, key),
|
||||
);
|
||||
try {
|
||||
const payload = await request.promise;
|
||||
res.writeHead(200, {
|
||||
@@ -88,8 +101,15 @@ function weatherEffectsProxy() {
|
||||
res.end(JSON.stringify({ ...cached.payload, status: 'stale' }));
|
||||
return;
|
||||
}
|
||||
res.writeHead(503, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' });
|
||||
res.end(JSON.stringify({ error: 'Weather effects are temporarily unavailable' }));
|
||||
res.writeHead(503, {
|
||||
'Content-Type': 'application/json',
|
||||
'Cache-Control': 'no-store',
|
||||
});
|
||||
res.end(
|
||||
JSON.stringify({
|
||||
error: 'Weather effects are temporarily unavailable',
|
||||
}),
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -7,13 +7,17 @@ async function fetchRegionalWeather(point) {
|
||||
const params = new URLSearchParams({
|
||||
latitude: point.latitude.toFixed(5),
|
||||
longitude: point.longitude.toFixed(5),
|
||||
current: 'temperature_2m,apparent_temperature,precipitation,weather_code,cloud_cover,wind_speed_10m,wind_direction_10m,visibility',
|
||||
current:
|
||||
'temperature_2m,apparent_temperature,precipitation,weather_code,cloud_cover,wind_speed_10m,wind_direction_10m,visibility',
|
||||
timezone: 'UTC',
|
||||
});
|
||||
try {
|
||||
const payload = await fetchRegionalJson(`https://api.open-meteo.com/v1/forecast?${params}`, {
|
||||
maxBytes: WEATHER_EFFECTS_MAX_RESPONSE_BYTES,
|
||||
});
|
||||
const payload = await fetchRegionalJson(
|
||||
`https://api.open-meteo.com/v1/forecast?${params}`,
|
||||
{
|
||||
maxBytes: WEATHER_EFFECTS_MAX_RESPONSE_BYTES,
|
||||
},
|
||||
);
|
||||
return normalizeRegionalWeather(payload);
|
||||
} catch {
|
||||
return null;
|
||||
|
||||
@@ -1,4 +1,11 @@
|
||||
import { knownKeySetupEnvVars, admitKeySetupRequest, isKeySetupExternallyManaged, keySetupStatus, validateKeySetupUpdates, upsertDotenvValues } from '../../src/keySetupCore.mjs';
|
||||
import {
|
||||
knownKeySetupEnvVars,
|
||||
admitKeySetupRequest,
|
||||
isKeySetupExternallyManaged,
|
||||
keySetupStatus,
|
||||
validateKeySetupUpdates,
|
||||
upsertDotenvValues,
|
||||
} from '../../src/keySetupCore.mjs';
|
||||
import { defaultSourceRoot } from '../providers/common/source-root.js';
|
||||
import path from 'node:path';
|
||||
import { readEnvironmentSource as readPinokioEnvironmentSource } from '../../scripts/pinokio-environment.mjs';
|
||||
@@ -23,9 +30,15 @@ const LAUNCHER_AT_BOOT = process.env.GEV_LAUNCHER;
|
||||
* Recomputing the snapshot there would classify the panel's own keys as
|
||||
* external (read-only) until the whole process is relaunched.
|
||||
*/
|
||||
const PROVIDER_ENV_AT_BOOT = globalThis.__GEV_PROVIDER_ENV_AT_BOOT ??= Object.freeze(Object.fromEntries(
|
||||
[...knownKeySetupEnvVars()].map((name) => [name, String(process.env[name] ?? '').trim()]),
|
||||
));
|
||||
const PROVIDER_ENV_AT_BOOT = (globalThis.__GEV_PROVIDER_ENV_AT_BOOT ??=
|
||||
Object.freeze(
|
||||
Object.fromEntries(
|
||||
[...knownKeySetupEnvVars()].map((name) => [
|
||||
name,
|
||||
String(process.env[name] ?? '').trim(),
|
||||
]),
|
||||
),
|
||||
));
|
||||
|
||||
/**
|
||||
* `dev-fresh.sh` resolves dotenv and Keychain values before it starts Vite, so
|
||||
@@ -79,8 +92,13 @@ function keySetupEndpoint({ sourceRoot = defaultSourceRoot } = {}) {
|
||||
// `GEV_LAUNCHER=pinokio` line in someone's .env would silently redirect a
|
||||
// plain `npm run dev` to write the Pinokio store it never loaded.
|
||||
const pinokioManaged = () => LAUNCHER_AT_BOOT === 'pinokio';
|
||||
const storeName = () => (pinokioManaged() ? 'pinokio-environment' : 'env-file');
|
||||
const storePath = () => path.join(sourceRoot, ...(pinokioManaged() ? ['pinokio', 'ENVIRONMENT'] : ['.env']));
|
||||
const storeName = () =>
|
||||
pinokioManaged() ? 'pinokio-environment' : 'env-file';
|
||||
const storePath = () =>
|
||||
path.join(
|
||||
sourceRoot,
|
||||
...(pinokioManaged() ? ['pinokio', 'ENVIRONMENT'] : ['.env']),
|
||||
);
|
||||
// Read the store, distinguishing "no store yet" from "cannot read this
|
||||
// store". Only ENOENT means empty. Every other failure — a permission error,
|
||||
// an I/O fault, an undecodable file — must ABORT the save: upserting into a
|
||||
@@ -97,7 +115,9 @@ function keySetupEndpoint({ sourceRoot = defaultSourceRoot } = {}) {
|
||||
return fs.readFileSync(storePath(), 'utf8');
|
||||
} catch (error) {
|
||||
if (error?.code === 'ENOENT') return ''; // The first saved key births the file.
|
||||
const unreadable = new Error('the existing configuration could not be read, so nothing was changed');
|
||||
const unreadable = new Error(
|
||||
'the existing configuration could not be read, so nothing was changed',
|
||||
);
|
||||
unreadable.code = 'GEV_STORE_UNREADABLE';
|
||||
throw unreadable;
|
||||
}
|
||||
@@ -114,16 +134,17 @@ function keySetupEndpoint({ sourceRoot = defaultSourceRoot } = {}) {
|
||||
};
|
||||
// The gate itself is pure and unit-tested (admitKeySetupRequest in
|
||||
// src/keySetupCore.mjs) — this just feeds it the request.
|
||||
const admit = (req) => admitKeySetupRequest({
|
||||
method: req.method,
|
||||
remoteAddress: req.socket?.remoteAddress,
|
||||
hostHeader: req.headers?.host,
|
||||
protocol: req.socket?.encrypted ? 'https:' : 'http:',
|
||||
origin: req.headers?.origin,
|
||||
contentType: req.headers?.['content-type'],
|
||||
proxyHeaders: req.headers || {},
|
||||
env: process.env,
|
||||
});
|
||||
const admit = (req) =>
|
||||
admitKeySetupRequest({
|
||||
method: req.method,
|
||||
remoteAddress: req.socket?.remoteAddress,
|
||||
hostHeader: req.headers?.host,
|
||||
protocol: req.socket?.encrypted ? 'https:' : 'http:',
|
||||
origin: req.headers?.origin,
|
||||
contentType: req.headers?.['content-type'],
|
||||
proxyHeaders: req.headers || {},
|
||||
env: process.env,
|
||||
});
|
||||
// Is this env var supplied by a workflow OTHER than this panel's store? Boot
|
||||
// provenance closes the equal-value ambiguity: an exported X remains
|
||||
// external even when the editable store independently contains X.
|
||||
@@ -147,7 +168,9 @@ function keySetupEndpoint({ sourceRoot = defaultSourceRoot } = {}) {
|
||||
// offered); 'external' = supplied by env/Keychain/another workflow
|
||||
// (read-only — the panel must never rewrite or delete it).
|
||||
key.managed = key.set
|
||||
? (key.envVars.some((name) => isExternallyManaged(name, inStore)) ? 'external' : 'file')
|
||||
? key.envVars.some((name) => isExternallyManaged(name, inStore))
|
||||
? 'external'
|
||||
: 'file'
|
||||
: null;
|
||||
}
|
||||
return { ...status, store: storeName() };
|
||||
@@ -161,7 +184,9 @@ function keySetupEndpoint({ sourceRoot = defaultSourceRoot } = {}) {
|
||||
// Never write THROUGH a symlink into a credential path.
|
||||
try {
|
||||
if (fs.lstatSync(filepath).isSymbolicLink()) {
|
||||
throw new Error('refusing to write a credential store that is a symlink');
|
||||
throw new Error(
|
||||
'refusing to write a credential store that is a symlink',
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
if (error.code !== 'ENOENT') throw error; // absent is fine — first save.
|
||||
@@ -183,7 +208,9 @@ function keySetupEndpoint({ sourceRoot = defaultSourceRoot } = {}) {
|
||||
// hardening failure aborts with the previous store fully intact and the
|
||||
// secret never on disk unprotected — no rollback path to get wrong.
|
||||
if (!hardenCredentialFile(tmp)) {
|
||||
const error = new Error('could not restrict the credential file to your account; nothing was saved');
|
||||
const error = new Error(
|
||||
'could not restrict the credential file to your account; nothing was saved',
|
||||
);
|
||||
error.code = 'GEV_HARDEN_FAILED';
|
||||
throw error;
|
||||
}
|
||||
@@ -215,18 +242,23 @@ function keySetupEndpoint({ sourceRoot = defaultSourceRoot } = {}) {
|
||||
// only install via configureServer (never configurePreviewServer), so they
|
||||
// are absent from preview today — but pinning apply here makes that a
|
||||
// guarantee rather than an accident of which hook a future edit uses.
|
||||
apply: (_config, { command, isPreview }) => command === 'serve' && !isPreview,
|
||||
apply: (_config, { command, isPreview }) =>
|
||||
command === 'serve' && !isPreview,
|
||||
configureServer(server) {
|
||||
server.middlewares.use('/api/setup/status', (req, res) => {
|
||||
if (req.method !== 'GET') return respond(res, 405, { error: 'Method not allowed' });
|
||||
if (req.method !== 'GET')
|
||||
return respond(res, 405, { error: 'Method not allowed' });
|
||||
const admission = admit(req);
|
||||
if (!admission.ok) return respond(res, admission.status, { error: admission.error });
|
||||
if (!admission.ok)
|
||||
return respond(res, admission.status, { error: admission.error });
|
||||
respond(res, 200, providerStatus());
|
||||
});
|
||||
server.middlewares.use('/api/setup/keys', (req, res) => {
|
||||
if (req.method !== 'POST') return respond(res, 405, { error: 'Method not allowed' });
|
||||
if (req.method !== 'POST')
|
||||
return respond(res, 405, { error: 'Method not allowed' });
|
||||
const admission = admit(req);
|
||||
if (!admission.ok) return respond(res, admission.status, { error: admission.error });
|
||||
if (!admission.ok)
|
||||
return respond(res, admission.status, { error: admission.error });
|
||||
let body = '';
|
||||
let overflowed = false;
|
||||
req.on('data', (chunk) => {
|
||||
@@ -237,7 +269,8 @@ function keySetupEndpoint({ sourceRoot = defaultSourceRoot } = {}) {
|
||||
}
|
||||
});
|
||||
req.on('end', () => {
|
||||
if (overflowed) return respond(res, 413, { error: 'Request too large' });
|
||||
if (overflowed)
|
||||
return respond(res, 413, { error: 'Request too large' });
|
||||
let parsed;
|
||||
try {
|
||||
parsed = JSON.parse(body || '{}');
|
||||
@@ -266,10 +299,17 @@ function keySetupEndpoint({ sourceRoot = defaultSourceRoot } = {}) {
|
||||
// "saved world-readable" must never be reported as a generic write
|
||||
// error. Everything else returns a fixed message (a raw filesystem
|
||||
// error can carry an absolute path; that stays in the server log).
|
||||
if (error?.code === 'GEV_HARDEN_FAILED' || error?.code === 'GEV_STORE_UNREADABLE') {
|
||||
return respond(res, 500, { error: `The key was not saved: ${error.message}` });
|
||||
if (
|
||||
error?.code === 'GEV_HARDEN_FAILED' ||
|
||||
error?.code === 'GEV_STORE_UNREADABLE'
|
||||
) {
|
||||
return respond(res, 500, {
|
||||
error: `The key was not saved: ${error.message}`,
|
||||
});
|
||||
}
|
||||
return respond(res, 500, { error: `Could not write the ${storeName()} store` });
|
||||
return respond(res, 500, {
|
||||
error: `Could not write the ${storeName()} store`,
|
||||
});
|
||||
}
|
||||
// Live for the server-side proxies immediately; the restart below is
|
||||
// what re-injects the client-exposed defines (Google, Cesium ion).
|
||||
@@ -289,7 +329,10 @@ function keySetupEndpoint({ sourceRoot = defaultSourceRoot } = {}) {
|
||||
// .env watcher may fire too; a second queued restart is harmless.
|
||||
setTimeout(() => {
|
||||
server.restart().catch((error) => {
|
||||
console.warn('[KeySetup] Dev-server restart failed:', error?.message || error);
|
||||
console.warn(
|
||||
'[KeySetup] Dev-server restart failed:',
|
||||
error?.message || error,
|
||||
);
|
||||
});
|
||||
}, 250);
|
||||
});
|
||||
|
||||
@@ -1,12 +1,21 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { Readable } from 'node:stream';
|
||||
import { mkdtempSync, readFileSync, existsSync, rmSync, statSync } from 'node:fs';
|
||||
import {
|
||||
mkdtempSync,
|
||||
readFileSync,
|
||||
existsSync,
|
||||
rmSync,
|
||||
statSync,
|
||||
} from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { overpassProxy } from 'gods-eye-view/server/providers/overpass';
|
||||
import { militaryInstallationsProxy } from 'gods-eye-view/server/providers/military-installations';
|
||||
import { regionalBriefProxy, weatherEffectsProxy } from 'gods-eye-view/server/providers/regional';
|
||||
import {
|
||||
regionalBriefProxy,
|
||||
weatherEffectsProxy,
|
||||
} from 'gods-eye-view/server/providers/regional';
|
||||
import { openAiRealtimeProxy } from 'gods-eye-view/server/providers/openai';
|
||||
import { keySetupEndpoint } from 'gods-eye-view/server/standalone/key-setup';
|
||||
import { realtimeInstructions } from '../../server/providers/openai/instructions.js';
|
||||
@@ -20,24 +29,57 @@ function install(plugin, preview = false) {
|
||||
});
|
||||
return routes;
|
||||
}
|
||||
function request(handler, { method = 'GET', url = '/', body = '', origin = 'http://localhost:4173' } = {}) {
|
||||
function request(
|
||||
handler,
|
||||
{
|
||||
method = 'GET',
|
||||
url = '/',
|
||||
body = '',
|
||||
origin = 'http://localhost:4173',
|
||||
} = {},
|
||||
) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const req = Readable.from(body ? [Buffer.from(body)] : []);
|
||||
Object.assign(req, { method, url, headers: { host: 'localhost:4173', origin, 'content-type': 'application/json' }, socket: { remoteAddress: '127.0.0.1' } });
|
||||
Object.assign(req, {
|
||||
method,
|
||||
url,
|
||||
headers: {
|
||||
host: 'localhost:4173',
|
||||
origin,
|
||||
'content-type': 'application/json',
|
||||
},
|
||||
socket: { remoteAddress: '127.0.0.1' },
|
||||
});
|
||||
const headers = {};
|
||||
const res = {
|
||||
statusCode: 200,
|
||||
setHeader(name, value) { headers[name.toLowerCase()] = value; },
|
||||
writeHead(status, values) { this.statusCode = status; for (const [k, v] of Object.entries(values)) this.setHeader(k, v); },
|
||||
end(body = '') { resolve({ status: this.statusCode, headers, body: String(body), json: () => JSON.parse(String(body)) }); },
|
||||
setHeader(name, value) {
|
||||
headers[name.toLowerCase()] = value;
|
||||
},
|
||||
writeHead(status, values) {
|
||||
this.statusCode = status;
|
||||
for (const [k, v] of Object.entries(values)) this.setHeader(k, v);
|
||||
},
|
||||
end(body = '') {
|
||||
resolve({
|
||||
status: this.statusCode,
|
||||
headers,
|
||||
body: String(body),
|
||||
json: () => JSON.parse(String(body)),
|
||||
});
|
||||
},
|
||||
};
|
||||
Promise.resolve(handler(req, res)).catch(reject);
|
||||
});
|
||||
}
|
||||
function env(t, name, value) {
|
||||
const old = process.env[name];
|
||||
if (value === undefined) delete process.env[name]; else process.env[name] = value;
|
||||
t.after(() => { if (old === undefined) delete process.env[name]; else process.env[name] = old; });
|
||||
if (value === undefined) delete process.env[name];
|
||||
else process.env[name] = value;
|
||||
t.after(() => {
|
||||
if (old === undefined) delete process.env[name];
|
||||
else process.env[name] = old;
|
||||
});
|
||||
}
|
||||
function root(t) {
|
||||
const dir = mkdtempSync(path.join(tmpdir(), 'gev-services-'));
|
||||
@@ -46,12 +88,29 @@ function root(t) {
|
||||
}
|
||||
|
||||
test('standalone service guards run in development and preview without upstream acquisition', async (t) => {
|
||||
t.mock.method(globalThis, 'fetch', () => { throw Error('invalid requests must not fetch'); });
|
||||
t.mock.method(globalThis, 'fetch', () => {
|
||||
throw Error('invalid requests must not fetch');
|
||||
});
|
||||
for (const preview of [false, true]) {
|
||||
for (const [factory, route] of [[overpassProxy, '/api/overpass'], [militaryInstallationsProxy, '/api/military-installations'], [regionalBriefProxy, '/api/regional-brief'], [weatherEffectsProxy, '/api/weather-effects']]) {
|
||||
for (const [factory, route] of [
|
||||
[overpassProxy, '/api/overpass'],
|
||||
[militaryInstallationsProxy, '/api/military-installations'],
|
||||
[regionalBriefProxy, '/api/regional-brief'],
|
||||
[weatherEffectsProxy, '/api/weather-effects'],
|
||||
]) {
|
||||
const routes = install(factory(), preview);
|
||||
assert.equal((await request(routes.get(route), { method: 'DELETE' })).status, 405);
|
||||
assert.equal((await request(routes.get(route), { method: route === '/api/overpass' ? 'POST' : 'GET' })).status, 400);
|
||||
assert.equal(
|
||||
(await request(routes.get(route), { method: 'DELETE' })).status,
|
||||
405,
|
||||
);
|
||||
assert.equal(
|
||||
(
|
||||
await request(routes.get(route), {
|
||||
method: route === '/api/overpass' ? 'POST' : 'GET',
|
||||
})
|
||||
).status,
|
||||
400,
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -64,17 +123,36 @@ test('weather-only requests share upstream work and retain fresh and stale respo
|
||||
calls++;
|
||||
assert.equal(new URL(url).hostname, 'api.open-meteo.com');
|
||||
if (calls > 1) throw Error('offline');
|
||||
await new Promise(resolve => setTimeout(resolve, 10));
|
||||
return Response.json({ current: { time: '2026-09-12T12:00', temperature_2m: 20, weather_code: 0, wind_speed_10m: 10 } });
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
return Response.json({
|
||||
current: {
|
||||
time: '2026-09-12T12:00',
|
||||
temperature_2m: 20,
|
||||
weather_code: 0,
|
||||
wind_speed_10m: 10,
|
||||
},
|
||||
});
|
||||
});
|
||||
const handler = install(weatherEffectsProxy()).get('/api/weather-effects');
|
||||
const query = { url: '/?latitude=34.61&longitude=-112.43' };
|
||||
const pair = await Promise.all([request(handler, query), request(handler, query)]);
|
||||
assert.deepEqual(pair.map(r => r.headers['x-weather-effects']).sort(), ['INFLIGHT', 'MISS']);
|
||||
const pair = await Promise.all([
|
||||
request(handler, query),
|
||||
request(handler, query),
|
||||
]);
|
||||
assert.deepEqual(pair.map((r) => r.headers['x-weather-effects']).sort(), [
|
||||
'INFLIGHT',
|
||||
'MISS',
|
||||
]);
|
||||
assert.equal(calls, 1);
|
||||
assert.equal((await request(handler, query)).headers['x-weather-effects'], 'HIT');
|
||||
assert.equal(
|
||||
(await request(handler, query)).headers['x-weather-effects'],
|
||||
'HIT',
|
||||
);
|
||||
now += 6 * 60_000;
|
||||
assert.equal((await request(handler, query)).headers['x-weather-effects'], 'STALE');
|
||||
assert.equal(
|
||||
(await request(handler, query)).headers['x-weather-effects'],
|
||||
'STALE',
|
||||
);
|
||||
});
|
||||
|
||||
test('Realtime handler preserves tools and default instructions, isolates supplied annotation guidance, and keeps the upstream key server-side', async (t) => {
|
||||
@@ -83,17 +161,33 @@ test('Realtime handler preserves tools and default instructions, isolates suppli
|
||||
const sent = [];
|
||||
t.mock.method(globalThis, 'fetch', async (url, options) => {
|
||||
assert.equal(url, 'https://api.openai.com/v1/realtime/client_secrets');
|
||||
assert.equal(options.headers.Authorization, 'Bearer fixture-upstream-secret');
|
||||
assert.equal(
|
||||
options.headers.Authorization,
|
||||
'Bearer fixture-upstream-secret',
|
||||
);
|
||||
sent.push(JSON.parse(options.body));
|
||||
return Response.json({ value: 'fixture-ephemeral' });
|
||||
});
|
||||
for (const [options, guidance] of [[{}, undefined], [{ annotationGuidance: 'Fixture annotation instruction.' }, 'Fixture annotation instruction.'], [{}, undefined]]) {
|
||||
const response = await request(install(openAiRealtimeProxy(options)).get('/api/realtime/token'), { url: '/?tier=unknown' });
|
||||
for (const [options, guidance] of [
|
||||
[{}, undefined],
|
||||
[
|
||||
{ annotationGuidance: 'Fixture annotation instruction.' },
|
||||
'Fixture annotation instruction.',
|
||||
],
|
||||
[{}, undefined],
|
||||
]) {
|
||||
const response = await request(
|
||||
install(openAiRealtimeProxy(options)).get('/api/realtime/token'),
|
||||
{ url: '/?tier=unknown' },
|
||||
);
|
||||
assert.equal(response.status, 200);
|
||||
assert.equal(response.headers['x-gev-voice-tier'], 'standard');
|
||||
assert.equal(response.headers['x-gev-voice-tier-fallback'], '1');
|
||||
assert.equal(response.body.includes('fixture-upstream-secret'), false);
|
||||
assert.equal(sent.at(-1).session.instructions, realtimeInstructions(guidance));
|
||||
assert.equal(
|
||||
sent.at(-1).session.instructions,
|
||||
realtimeInstructions(guidance),
|
||||
);
|
||||
assert.deepEqual(sent.at(-1).session.tools, GEV_REALTIME_TOOLS);
|
||||
}
|
||||
assert.notEqual(sent[0].session.instructions, sent[1].session.instructions);
|
||||
@@ -101,30 +195,63 @@ test('Realtime handler preserves tools and default instructions, isolates suppli
|
||||
});
|
||||
|
||||
test('debug logging resolves each supplied application directory independently', async (t) => {
|
||||
const first = root(t), second = root(t);
|
||||
for (const [sourceRoot, marker] of [[first, 'first'], [second, 'second']]) {
|
||||
const handler = install(openAiRealtimeProxy({ sourceRoot })).get('/api/realtime/debug-log');
|
||||
assert.equal((await request(handler, { method: 'POST', body: JSON.stringify({ marker }) })).status, 204);
|
||||
const file = path.join(sourceRoot, '.gev-logs/realtime-conversations.jsonl');
|
||||
const first = root(t),
|
||||
second = root(t);
|
||||
for (const [sourceRoot, marker] of [
|
||||
[first, 'first'],
|
||||
[second, 'second'],
|
||||
]) {
|
||||
const handler = install(openAiRealtimeProxy({ sourceRoot })).get(
|
||||
'/api/realtime/debug-log',
|
||||
);
|
||||
assert.equal(
|
||||
(
|
||||
await request(handler, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ marker }),
|
||||
})
|
||||
).status,
|
||||
204,
|
||||
);
|
||||
const file = path.join(
|
||||
sourceRoot,
|
||||
'.gev-logs/realtime-conversations.jsonl',
|
||||
);
|
||||
assert.equal(JSON.parse(readFileSync(file, 'utf8')).marker, marker);
|
||||
}
|
||||
});
|
||||
|
||||
test('key setup writes only the supplied application root, retains request guards and stays absent from preview', async (t) => {
|
||||
const first = root(t), untouched = root(t);
|
||||
const first = root(t),
|
||||
untouched = root(t);
|
||||
env(t, 'OPENAI_API_KEY', undefined);
|
||||
const plugin = keySetupEndpoint({ sourceRoot: first });
|
||||
assert.equal(plugin.apply({}, { command: 'serve', isPreview: true }), false);
|
||||
assert.equal(plugin.configurePreviewServer, undefined);
|
||||
const routes = install(plugin);
|
||||
const handler = routes.get('/api/setup/keys');
|
||||
const body = JSON.stringify({ OPENAI_API_KEY: 'sk-fixture-only-not-a-real-key' });
|
||||
assert.equal((await request(handler, { method: 'POST', body, origin: 'https://example.com' })).status, 403);
|
||||
const body = JSON.stringify({
|
||||
OPENAI_API_KEY: 'sk-fixture-only-not-a-real-key',
|
||||
});
|
||||
assert.equal(
|
||||
(
|
||||
await request(handler, {
|
||||
method: 'POST',
|
||||
body,
|
||||
origin: 'https://example.com',
|
||||
})
|
||||
).status,
|
||||
403,
|
||||
);
|
||||
assert.equal(existsSync(path.join(first, '.env')), false);
|
||||
const saved = await request(handler, { method: 'POST', body });
|
||||
assert.equal(saved.status, 200);
|
||||
assert.match(readFileSync(path.join(first, '.env'), 'utf8'), /OPENAI_API_KEY=sk-fixture-only-not-a-real-key/);
|
||||
if (process.platform !== 'win32') assert.equal(statSync(path.join(first, '.env')).mode & 0o777, 0o600);
|
||||
assert.match(
|
||||
readFileSync(path.join(first, '.env'), 'utf8'),
|
||||
/OPENAI_API_KEY=sk-fixture-only-not-a-real-key/,
|
||||
);
|
||||
if (process.platform !== 'win32')
|
||||
assert.equal(statSync(path.join(first, '.env')).mode & 0o777, 0o600);
|
||||
assert.equal(saved.body.includes('sk-fixture-only-not-a-real-key'), false);
|
||||
assert.equal(existsSync(path.join(untouched, '.env')), false);
|
||||
});
|
||||
Reference in new issue
Block a user