From be7187cea7ed771e7461eb2bc41a3310ecb9b4ff Mon Sep 17 00:00:00 2001 From: daikaginza Date: Mon, 21 Sep 2026 10:02:19 +0700 Subject: [PATCH 01/60] fix(server): bound the terrain-heights cache and its request rate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every distinct 5-decimal point was a permanent in-memory entry, a permanent line in an uncapped `.gev-cache/terrain-heights.json`, and a live upstream call. Nothing limited the rate, and coordinates were checked for being finite rather than for being on Earth, so an out-of-range pair was still a valid cache key. Four bounds, each following a pattern this repo already uses: - WGS-84 range is enforced in `parseTerrainPoints`, at the edge, so no later stage has to. Poles and the antimeridian still parse. - The point cache holds at most 50,000 entries and evicts oldest-first, the shape `trackBackfillProxies` uses. Sized from measurement: this checkout's cache file held 6,189 entries in 1.0 MB of real use, so the ceiling is eight times that working set. Terrain does not move, so an evicted point costs one refetch and never a wrong answer. - A cache file above 24 MB is not read back, mirroring `rocketLaunchesProxy`. An unmeasurable file is not treated as an oversized one: without stat, readFile decides, exactly as before. - The route carries an always-on limiter of 90/min per client, 270 global — the `/api/overpass` band, and an order of magnitude above measured demand (a full test:track run drove 10 requests, peak 8/min). The ceilings are constructor options so they can be exercised as behaviour rather than asserted as constants; production passes none. They are destructured in the body because `proxyErrorResponses.test.mjs` slices these proxies to the first column-zero closing brace. --- CHANGELOG.md | 11 ++ docs/CURRENT-STATE.md | 11 ++ scripts/format-scope.json | 3 +- scripts/package-boundaries.json | 4 +- server/providers/terrain.js | 93 +++++++++++- src/data/terrainHeightsProxy.js | 8 ++ src/proxyErrorResponses.test.mjs | 4 + src/terrainHeightsBounds.test.mjs | 232 ++++++++++++++++++++++++++++++ 8 files changed, 360 insertions(+), 6 deletions(-) create mode 100644 src/terrainHeightsBounds.test.mjs diff --git a/CHANGELOG.md b/CHANGELOG.md index 29dbedfaa..f59c0c97a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,16 @@ # Changelog +- Bound what a caller can grow through `/api/terrain/heights`. Every distinct + 5-decimal point was a permanent in-memory entry, a permanent line in an + uncapped `.gev-cache/terrain-heights.json`, and a live upstream call, with + nothing limiting the rate and only a finite-number check on the coordinates. + Coordinates outside WGS-84 range are now rejected with `400`, the point cache + holds at most 50,000 entries and evicts oldest-first, a cache file above + 24 MB is not read back, and the route is limited to 90 requests/minute per + client (270 global). Evicting a point costs one refetch, never a wrong + answer, since terrain does not move. Ordinary use is unaffected: a full + `test:track` run drives 10 requests, peaking at 8/min. + - CCTV cameras whose bearing is a guess now say so. Packs mark bearings derived from a hash of the camera id as `headingConfidence: 'low'`, but nothing read the flag, so roughly 70% of a default catalog rendered like surveyed facings. The HUD diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index fdb42fc0c..adf2e7995 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -2064,6 +2064,17 @@ down | auth-failed` (plus the unchanged `missing-key`/`unsupported`) with > tunings. Ambient overlap includes each sprite's own rendered extent. The > gated AIS, CCTV, and satellite passes retain an active-emphasis count so a > settled dim contact always completes its release after tracking ends. +> - **Terrain-height bounds:** because every distinct 5-decimal point is its own +> cache key, its own line in `.gev-cache/terrain-heights.json`, and its own +> upstream call, four limits bound what a caller can grow. Coordinates outside +> WGS-84 range are rejected with `400` at parse (finite is not the same as on +> Earth); the point cache holds at most 50,000 entries, evicting oldest-first +> — terrain does not move, so an evicted point costs one refetch and never a +> wrong answer; a cache file above 24 MB is not read back into memory (the +> ceiling `rocketLaunchesProxy` already applies); and the route carries an +> always-on limiter of 90 requests/minute/client, 270 global. That cap is an +> order of magnitude above measured demand — a full `npm run test:track` run +> drove 10 requests, peaking at 8/min. > - **Terrain-height resilience:** `/api/terrain/heights` caches canonical > 5-decimal points individually, reconstructs reordered/overlapping batches > in exact request order, and refreshes only missing or stale points. Upstream diff --git a/scripts/format-scope.json b/scripts/format-scope.json index eee546dd5..ca43cbbb3 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -839,5 +839,6 @@ "src/ui/imageryBoxTool.test.mjs", "src/ui/recentImagery.test.mjs", "src/layers/recentImagery/testDoubles.mjs", - "scripts/qa-recent-imagery.mjs" + "scripts/qa-recent-imagery.mjs", + "src/terrainHeightsBounds.test.mjs" ] diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index 189192f10..d12fc42ea 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -101,7 +101,9 @@ "exports": ["./server/providers/terrain"], "modules": [ "server/providers/terrain.js", - "src/data/terrainHeightsProxy.js" + "src/data/terrainHeightsProxy.js", + "server/providers/common/rate-limit.js", + "src/sources/rateLimit.js" ], "external": [] }, diff --git a/server/providers/terrain.js b/server/providers/terrain.js index e58a63ea5..ca9442db1 100644 --- a/server/providers/terrain.js +++ b/server/providers/terrain.js @@ -8,6 +8,7 @@ import { terrainPointKey, validTerrainResult, } from '../../src/data/terrainHeightsProxy.js'; +import { makeRateLimiter, clientKey } from './common/rate-limit.js'; /** * Re:Earth terrain point-height proxy: batched lon/lat → ellipsoidal height @@ -20,11 +21,27 @@ import { * Only missing/stale points go upstream; the response is rebuilt in exact * request order. Larger requests are chunked sequentially, and one failing * chunk does not discard the chunks that resolved. + * + * Bounds, because every distinct 5dp point is its own key: coordinates must be + * in WGS-84 range (rejected at parse), the point cache has an entry ceiling + * with oldest-first eviction, an over-large cache file is not read back, and + * the route carries an always-on per-client limiter. */ -export function terrainHeightsProxy() { +export function terrainHeightsProxy(options = {}) { + // Destructured in the body, not the signature: `proxyErrorResponses.test.mjs` + // extracts these proxies by slicing to the first closing brace in column + // zero, so a multi-line parameter list would cut the function in half. + // Overridable so the ceilings can be exercised as behaviour rather than + // asserted as constants; production constructs this with no arguments. + const { + cacheMaxEntries = 50_000, + cacheMaxDiskBytes = 24 * 1024 * 1024, + cachePath, + } = options; const TTL_MS = 30 * 24 * 3600_000; - const CACHE_DIR = path.join(process.cwd(), '.gev-cache'); - const CACHE_PATH = path.join(CACHE_DIR, 'terrain-heights.json'); + const CACHE_PATH = + cachePath || path.join(process.cwd(), '.gev-cache', 'terrain-heights.json'); + const CACHE_DIR = path.dirname(CACHE_PATH); // Sized against measured upstream latency, not the documented page cap. // Re:Earth serves 87-186 ms/point depending on load (observed 2026-09-12, // a 2x swing within one hour). 256 points therefore costs 22-48 s and blows @@ -34,9 +51,50 @@ export function terrainHeightsProxy() { // that range. const UPSTREAM_CHUNK = 64; const MAX_POINTS = 2000; + /** + * Entry ceiling for the point cache, and with it the file written from it. + * + * Every distinct 5dp point is a separate key, so without a ceiling the map + * — and the JSON dumped from it verbatim — grow for as long as a caller + * supplies new coordinates. Measured against real use rather than guessed: + * this checkout's `.gev-cache/terrain-heights.json` held 6,189 entries in + * 1.0 MB after ordinary sessions, i.e. ~168 bytes/entry. 50,000 entries is + * eight times that working set and ~8 MB on disk, comfortably inside the + * 24 MB ceiling `rocketLaunchesProxy` already applies to its own cache file. + */ + const CACHE_MAX_ENTRIES = cacheMaxEntries; + /** Refuse to load a cache file larger than this (mirrors rocketLaunchesProxy). */ + const CACHE_MAX_DISK_BYTES = cacheMaxDiskBytes; + /** + * Requests/min/client. Terrain is keyless and free, so this guards the point + * cache and the upstream community service rather than a bill. Sized from + * measurement: across a full `npm run test:track` run — the heaviest + * exercise this app has — the route served 10 requests, peaking at 8/min. + * 90/min is an order of magnitude above that, and the band `/api/overpass` + * and `/api/military-installations` already use. + */ + const RATE_PER_MIN = 90; + const allow = makeRateLimiter({ + windowMs: 60_000, + max: RATE_PER_MIN, + globalMax: RATE_PER_MIN * 3, + }); /** @type {Map} keyed by canonical 5dp lon/lat. */ const mem = new Map(); + + /** + * Drop the oldest entries until the map is back inside its ceiling. + * Oldest-first by fetch time, the same shape `trackBackfillProxies` uses. + * Terrain does not move, so evicting an old point costs one refetch, never + * a wrong answer. + */ + function evictOverflow() { + if (mem.size <= CACHE_MAX_ENTRIES) return; + const byAge = [...mem.entries()].sort((a, b) => a[1].at - b[1].at); + for (let i = 0; i < byAge.length && mem.size > CACHE_MAX_ENTRIES; i += 1) + mem.delete(byAge[i][0]); + } /** @type {Map>>} single-flight per missing-point subset. */ const inflight = new Map(); let diskLoaded = false; @@ -47,6 +105,21 @@ export function terrainHeightsProxy() { if (diskLoaded) return; diskLoaded = true; try { + // A cache file past the ceiling is not read back into memory: loading it + // would reinstate exactly the unbounded map the ceiling exists to stop. + // The next flush rewrites a bounded one. + // + // "Cannot measure it" is not "too large": if stat is unavailable, fall + // through and let readFile decide, so an absent file behaves exactly as + // it did before this guard existed. + let sizeBytes = null; + try { + sizeBytes = (await fsp.stat(CACHE_PATH)).size; + } catch { + sizeBytes = null; + } + if (sizeBytes !== null && sizeBytes > CACHE_MAX_DISK_BYTES) + throw new Error('cache file too large'); const parsed = JSON.parse(await fsp.readFile(CACHE_PATH, 'utf8')); const pointEntries = parsed?.version === 2 && @@ -87,6 +160,7 @@ export function terrainHeightsProxy() { } diskDirty = mem.size > 0; } + evictOverflow(); } catch { /* no disk cache yet */ } @@ -163,6 +237,14 @@ export function terrainHeightsProxy() { res.end(JSON.stringify(bodyObj)); }; try { + if (!allow(clientKey(req))) { + res.writeHead(429, { + 'Content-Type': 'application/json', + 'Retry-After': '10', + }); + res.end(JSON.stringify({ error: 'Rate limit exceeded' })); + return; + } await loadDiskOnce(); const parsedUrl = new URL(req.url || '', 'http://internal'); const rawPoints = parsedUrl.searchParams.get('points'); @@ -187,7 +269,10 @@ export function terrainHeightsProxy() { fetchMissing: fetchMissingSingleFlight, ttlMs: TTL_MS, }); - if (outcome.cacheChanged) diskDirty = true; + if (outcome.cacheChanged) { + evictOverflow(); + diskDirty = true; + } if (outcome.upstreamError) { console.warn( '[terrain-heights-proxy] refresh incomplete' + diff --git a/src/data/terrainHeightsProxy.js b/src/data/terrainHeightsProxy.js index bfb058693..11b8e0037 100644 --- a/src/data/terrainHeightsProxy.js +++ b/src/data/terrainHeightsProxy.js @@ -18,6 +18,13 @@ function defaultSleep(ms) { /** * Parse the proxy's `points=lon,lat;...` query parameter. + * + * Finite is not the same as on Earth. A coordinate outside WGS-84 range can + * never resolve to a height, but it is still a distinct 5dp cache key, so + * accepting it meant an out-of-range point became a permanent cache entry and + * a live upstream call. Range is checked here, at the edge, so no later stage + * has to. + * * @param {string|null|undefined} raw * @returns {Array<[number, number]>|null} */ @@ -36,6 +43,7 @@ export function parseTerrainPoints(raw) { const lon = Number(parts[0]); const lat = Number(parts[1]); if (!Number.isFinite(lon) || !Number.isFinite(lat)) return null; + if (lat < -90 || lat > 90 || lon < -180 || lon > 180) return null; points.push([lon, lat]); } return points; diff --git a/src/proxyErrorResponses.test.mjs b/src/proxyErrorResponses.test.mjs index 2b45a3b9e..9cc1ff9c7 100644 --- a/src/proxyErrorResponses.test.mjs +++ b/src/proxyErrorResponses.test.mjs @@ -3,6 +3,7 @@ import assert from 'node:assert/strict'; import { readFileSync } from 'node:fs'; import path from 'node:path'; import { readResponseTextCapped, coalesceProxyRequest } from './sources/httpBody.js'; +import { makeRateLimiter, clientKey } from '../server/providers/common/rate-limit.js'; const source = ['local.js', 'common/http.js', 'aircraft/enrichment.js', 'terrain.js', 'space/celestrak.js', 'space/launch-library.js', '../../src/data/spaceProviderRequests.js'] .map(file => readFileSync(new URL(`../server/providers/${file}`, import.meta.url), 'utf8')) @@ -22,6 +23,9 @@ function fixture(name, overrides = {}, preview = false) { const logs = []; const deps = { readResponseTextCapped, coalesceProxyRequest, + // The rate-limited proxies reach for these; give them the real ones so a + // fixture request is throttled exactly as production would be. + makeRateLimiter, clientKey, path, process: { cwd: () => '/fixture', env: {} }, fsp: { readFile: async () => { throw new Error('cache absent'); }, diff --git a/src/terrainHeightsBounds.test.mjs b/src/terrainHeightsBounds.test.mjs new file mode 100644 index 000000000..1a0c80290 --- /dev/null +++ b/src/terrainHeightsBounds.test.mjs @@ -0,0 +1,232 @@ +// TERRAIN HEIGHTS — the bounds on a cache keyed by caller-chosen coordinates. +// +// Every distinct 5dp lon/lat is its own cache key, its own permanent line in +// `.gev-cache/terrain-heights.json`, and its own upstream call. Four things +// bound that: WGS-84 range at parse, an entry ceiling on the map, a size +// ceiling on the file that is read back, and a per-client request limiter. +// +// These cases drive the real exported route handler. `fetch` is stubbed per +// test and restored after, so a case that reaches upstream fails loudly. +// +// Run with: npm test +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { parseTerrainPoints } from './data/terrainHeightsProxy.js'; +import { terrainHeightsProxy } from '../server/providers/terrain.js'; + +function install(plugin) { + const routes = new Map(); + plugin.configureServer({ + middlewares: { + use(path, handler) { + routes.set(path, handler); + }, + }, + }); + return routes; +} + +function request(handler, { url = '/', remoteAddress = '127.0.0.1' } = {}) { + return new Promise((resolve, reject) => { + const headers = new Map(); + const req = { method: 'GET', url, headers: {}, socket: { remoteAddress } }; + const res = { + statusCode: 200, + headersSent: false, + setHeader(name, value) { + headers.set(String(name).toLowerCase(), String(value)); + }, + writeHead(status, hdrs = {}) { + this.statusCode = status; + this.headersSent = true; + for (const [name, value] of Object.entries(hdrs)) + headers.set(String(name).toLowerCase(), String(value)); + }, + end(body = '') { + let parsed = null; + try { + parsed = body ? JSON.parse(String(body)) : null; + } catch { + parsed = String(body); + } + resolve({ + statusCode: this.statusCode, + headers: Object.fromEntries(headers), + body: parsed, + }); + }, + }; + Promise.resolve(handler(req, res)).catch(reject); + }); +} + +function stubFetch(t, impl) { + const original = globalThis.fetch; + globalThis.fetch = impl; + t.after(() => { + globalThis.fetch = original; + }); +} + +/** + * Answer like Re:Earth does: one `{ellipsoid}` per requested point, in order. + * Records which point strings were actually asked for, so a test can assert on + * cache misses rather than on internal state. + */ +function stubTerrainUpstream(t) { + const asked = []; + stubFetch(t, async (url) => { + const points = decodeURIComponent( + new URL(String(url)).searchParams.get('points') || '', + ) + .split(';') + .filter(Boolean); + asked.push(...points); + return Response.json({ results: points.map(() => ({ ellipsoid: 150 })) }); + }); + return asked; +} + +test('a coordinate off the globe is not a cache key', () => { + // Finite but not on Earth — the old guard accepted every one of these. + for (const raw of [ + '0,91', + '0,-91', + '181,0', + '-181,0', + '1e9,1e9', + '0,90.00001', + ]) + assert.equal(parseTerrainPoints(raw), null, raw); + + // The poles and the antimeridian are real places and must still parse. + for (const raw of ['0,90', '0,-90', '180,0', '-180,0', '-97.74,30.26']) + assert.ok(parseTerrainPoints(raw), raw); +}); + +test('an out-of-range point is refused before it reaches upstream or the cache', async (t) => { + let upstreamCalls = 0; + stubFetch(t, async () => { + upstreamCalls += 1; + return new Response('[]', { status: 200 }); + }); + const route = install(terrainHeightsProxy()).get('/api/terrain/heights'); + + const refused = await request(route, { url: '/?points=0,91' }); + assert.equal(refused.statusCode, 400); + assert.equal( + upstreamCalls, + 0, + 'an impossible coordinate must not cost an upstream call', + ); +}); + +test('one out-of-range point rejects the batch it is hidden in', async (t) => { + stubFetch(t, async () => new Response('[]', { status: 200 })); + const route = install(terrainHeightsProxy()).get('/api/terrain/heights'); + const mixed = await request(route, { + url: '/?points=-97.74,30.26;0,999;-97.75,30.27', + }); + assert.equal(mixed.statusCode, 400); +}); + +test('the point cache stops growing, and an evicted point is simply refetched', async (t) => { + const asked = stubTerrainUpstream(t); + // A ceiling of 3 exercises the same code path 50,000 does, in four requests. + const route = install(terrainHeightsProxy({ cacheMaxEntries: 3 })).get( + '/api/terrain/heights', + ); + + const point = (i) => `-97.7${i}000,30.26000`; + for (let i = 1; i <= 4; i += 1) + await request(route, { url: `/?points=${point(i)}` }); + assert.equal(asked.length, 4, 'four distinct points, four upstream lookups'); + + // The newest point is still cached... + await request(route, { url: `/?points=${point(4)}` }); + assert.equal(asked.length, 4, 'a cached point costs nothing'); + + // ...and the oldest was evicted, so it costs one refetch — never a wrong + // answer, because terrain does not move. + await request(route, { url: `/?points=${point(1)}` }); + assert.equal(asked.length, 5, 'the evicted point was asked for again'); + assert.equal(asked[4], point(1)); +}); + +test('an over-large cache file is not read back into memory', async (t) => { + const asked = stubTerrainUpstream(t); + const point = '-97.74000,30.26000'; + const dir = await mkdtemp(path.join(tmpdir(), 'gev-terrain-')); + const cachePath = path.join(dir, 'terrain-heights.json'); + t.after(() => rm(dir, { recursive: true, force: true })); + + // A cache file holding the answer, but larger than the ceiling allows. + const padded = { + version: 2, + points: { [point]: { at: Date.now(), result: { ellipsoid: 150 } } }, + _pad: 'x'.repeat(4096), + }; + await writeFile(cachePath, JSON.stringify(padded), 'utf8'); + + const route = install( + terrainHeightsProxy({ cachePath, cacheMaxDiskBytes: 1024 }), + ).get('/api/terrain/heights'); + await request(route, { url: `/?points=${point}` }); + assert.deepEqual( + asked, + [point], + 'the oversized file was skipped, so the point was a miss', + ); + + // The same file under a ceiling that admits it IS used, which is what proves + // the previous assertion came from the size guard and not a broken path. + const admitted = install( + terrainHeightsProxy({ cachePath, cacheMaxDiskBytes: 1024 * 1024 }), + ).get('/api/terrain/heights'); + await request(admitted, { url: `/?points=${point}` }); + assert.deepEqual(asked, [point], 'a cache within the ceiling is still read'); +}); + +test('the route refuses a flood of requests from one client', async (t) => { + stubTerrainUpstream(t); + const route = install(terrainHeightsProxy()).get('/api/terrain/heights'); + + let refusedAt = 0; + for (let i = 1; i <= 95; i += 1) { + // A different point each time: distinct keys are the growth path. + const lon = (-97.7 - i / 1000).toFixed(5); + const answer = await request(route, { url: `/?points=${lon},30.26` }); + if (answer.statusCode === 429) { + refusedAt = i; + break; + } + } + assert.equal(refusedAt, 91, 'the 91st request in a minute is refused'); +}); + +test('a second client keeps its own quota', async (t) => { + stubFetch(t, async () => + Response.json([{ lng: -97.74, lat: 30.26, height: 150 }]), + ); + const route = install(terrainHeightsProxy()).get('/api/terrain/heights'); + + for (let i = 0; i < 90; i += 1) + await request(route, { + url: `/?points=${(-98 - i / 1000).toFixed(5)},30.26`, + remoteAddress: '198.51.100.7', + }); + const exhausted = await request(route, { + url: '/?points=-99.5,30.26', + remoteAddress: '198.51.100.7', + }); + assert.equal(exhausted.statusCode, 429); + + const neighbour = await request(route, { + url: '/?points=-99.6,30.26', + remoteAddress: '198.51.100.8', + }); + assert.notEqual(neighbour.statusCode, 429); +}); From a4ce7381d88f7da849d80010fe1d8979457b754c Mon Sep 17 00:00:00 2001 From: Kevin Rajan <7121943+kvnloo@users.noreply.github.com> Date: Mon, 28 Sep 2026 18:32:50 -0500 Subject: [PATCH 02/60] fix(ui): enforce Context ownership before disclosure no-op --- src/ui/panelChrome.js | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/src/ui/panelChrome.js b/src/ui/panelChrome.js index 30266312a..6464f1588 100644 --- a/src/ui/panelChrome.js +++ b/src/ui/panelChrome.js @@ -474,6 +474,17 @@ export class PanelChrome { syncShare, }); } + // Parent ownership is an invariant even on a no-op disclosure request. + // A stale compact Radio disclosure is absolutely positioned outside the + // measured Context rail; clear it before returning from an already-open + // Context request so it cannot paint over the active Context body. + if ( + !nextCollapsed && + panelId === 'global-context-panel' && + this._contextRadioDock?.classList.contains('disclosure-open') + ) { + this._setRadioDisclosure?.(false); + } if ( panelEl.classList.contains('collapsed') === nextCollapsed && !wasAutoCollapsed @@ -502,13 +513,6 @@ export class PanelChrome { this.cockpitView.setContextCollapsed(true); } } - if ( - !nextCollapsed && - panelId === 'global-context-panel' && - this._contextRadioDock?.classList.contains('disclosure-open') - ) { - this._setRadioDisclosure?.(false); - } if ( !nextCollapsed && panelId === 'radio-panel' && From b79ab84a1a5efaffea6eb2540142b22c34a67ca2 Mon Sep 17 00:00:00 2001 From: Kevin Rajan <7121943+kvnloo@users.noreply.github.com> Date: Mon, 28 Sep 2026 18:33:09 -0500 Subject: [PATCH 03/60] test(ui): pin Context and compact Radio ownership --- src/ui/panelChrome.radioOwnership.test.mjs | 67 ++++++++++++++++++++++ 1 file changed, 67 insertions(+) create mode 100644 src/ui/panelChrome.radioOwnership.test.mjs diff --git a/src/ui/panelChrome.radioOwnership.test.mjs b/src/ui/panelChrome.radioOwnership.test.mjs new file mode 100644 index 000000000..c52a4888e --- /dev/null +++ b/src/ui/panelChrome.radioOwnership.test.mjs @@ -0,0 +1,67 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { PanelChrome } from './panelChrome.js'; + +function classList(initial = []) { + const values = new Set(initial); + return { + contains: (value) => values.has(value), + add: (...items) => items.forEach((item) => values.add(item)), + remove: (...items) => items.forEach((item) => values.delete(item)), + toggle(value, force) { + if (force === true) values.add(value); + else if (force === false) values.delete(value); + else if (values.has(value)) values.delete(value); + else values.add(value); + return values.has(value); + }, + }; +} + +test('opening an already-open Context still closes a stale compact Radio disclosure', (t) => { + const priorDocument = globalThis.document; + const contextPanel = { + id: 'global-context-panel', + classList: classList(), + }; + globalThis.document = { + documentElement: { dataset: { uiTheme: 'tactical' } }, + getElementById(id) { + return id === 'global-context-panel' ? contextPanel : null; + }, + }; + t.after(() => { + globalThis.document = priorDocument; + }); + + const radioDock = { classList: classList(['disclosure-open']) }; + const calls = []; + const owner = { + _contextRadioDock: radioDock, + _setRadioDisclosure(open) { + calls.push(open); + radioDock.classList.toggle('disclosure-open', open); + }, + _leftPanelStack: null, + _rightPanelStack: null, + _panelLayout: { + _leftStackPreferredPanelId: null, + _rightStackPreferredPanelId: null, + }, + _syncPanelCollapseButton() {}, + _scheduleLeftPanelLayout() {}, + _scheduleRightPanelLayout() {}, + shareLinkManager: null, + cockpitView: null, + }; + + PanelChrome.prototype.setPanelCollapsed.call( + owner, + 'global-context-panel', + false, + ); + + assert.deepEqual(calls, [false]); + assert.equal(radioDock.classList.contains('disclosure-open'), false); +}); From b22e6d519a0387b846a71a3069dc8c66797bf52d Mon Sep 17 00:00:00 2001 From: Kevin Rajan <7121943+kvnloo@users.noreply.github.com> Date: Mon, 28 Sep 2026 18:33:11 -0500 Subject: [PATCH 04/60] chore(format): adopt panel ownership test --- scripts/format-scope.json | 1 + 1 file changed, 1 insertion(+) diff --git a/scripts/format-scope.json b/scripts/format-scope.json index ef44df03f..507ee3126 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -651,6 +651,7 @@ "src/ui/visualSettings.js", "src/ui/panelChrome.js", "src/testSupport/readShellSource.mjs", + "src/ui/panelChrome.radioOwnership.test.mjs", "src/ui/stateOwners.test.mjs", "src/layers/flights/records.js", "src/layers/flights/recordPolicy.js", From 4fc813e1752b8e38f6051c6ddcaf0e3e3a97947a Mon Sep 17 00:00:00 2001 From: Kevin Rajan <7121943+kvnloo@users.noreply.github.com> Date: Tue, 29 Sep 2026 03:02:02 -0500 Subject: [PATCH 05/60] feat(agent): extract model-free World Packet boundary --- src/agent/worldPacket.js | 184 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 184 insertions(+) create mode 100644 src/agent/worldPacket.js diff --git a/src/agent/worldPacket.js b/src/agent/worldPacket.js new file mode 100644 index 000000000..c492f3d6b --- /dev/null +++ b/src/agent/worldPacket.js @@ -0,0 +1,184 @@ +import * as Cesium from 'cesium'; +import { + feedProvenanceEnvelope, + layerSnapshots, + viewStateLayerRow, +} from '../data/layerSnapshot.js'; +import { contextModeWord } from '../contextModePolicy.js'; + +const TRACKABLE_FAMILIES = Object.freeze([ + { layerId: 'flights', kind: 'aircraft' }, + { layerId: 'military', kind: 'aircraft' }, + { layerId: 'ais-live-vessels', kind: 'vessel' }, + { layerId: 'satellites', kind: 'satellite' }, +]); + +const CONTEXT_MODE_RESULT_FIELDS = Object.freeze([ + { field: 'mode', emptyAs: 'off' }, + { field: 'entering', emptyAs: null }, + { field: 'priorMode', emptyAs: null }, +]); + +const NESTED_CONTEXT_RESULT_FIELDS = Object.freeze([ + 'context', + 'contextRollback', +]); + +const NON_SEMANTIC_PACKET_KEYS = new Set([ + 'note', + 'ageLabel', + 'generatedAt', +]); + +function semanticPacketValue(value) { + if (Array.isArray(value)) return value.map(semanticPacketValue); + if (!value || typeof value !== 'object') return value; + const out = {}; + for (const key of Object.keys(value).sort()) { + if (NON_SEMANTIC_PACKET_KEYS.has(key)) continue; + out[key] = semanticPacketValue(value[key]); + } + return out; +} + +/** + * Stable semantic fingerprint for change detection, not authentication. + * + * Presentation-only narration/age labels are excluded. Feed state, source, + * lastUpdate, camera, context, tracked entities, and other semantic state stay + * inside the digest. + */ +export function semanticWorldPacketFingerprint(packet) { + const canonical = JSON.stringify(semanticPacketValue(packet)); + let hash = 0xcbf29ce484222325n; + const prime = 0x100000001b3n; + for (let i = 0; i < canonical.length; i += 1) { + hash ^= BigInt(canonical.charCodeAt(i)); + hash = BigInt.asUintN(64, hash * prime); + } + return `fnv1a64:${hash.toString(16).padStart(16, '0')}`; +} + +/** + * Translate context-mode payloads into the public tool vocabulary while + * retaining the internal id beside each translated field. + */ +export function withContextModeVocabulary(state) { + if (!state || typeof state !== 'object') return state; + let out = state; + const mutable = () => { + if (out === state) out = { ...state }; + return out; + }; + for (const { field, emptyAs } of CONTEXT_MODE_RESULT_FIELDS) { + if (!(field in state)) continue; + const internal = state[field] ?? null; + const target = mutable(); + target[field] = contextModeWord(internal, { emptyAs }); + target[`${field}Internal`] = internal; + } + for (const field of NESTED_CONTEXT_RESULT_FIELDS) { + const nested = state[field]; + if (!nested || typeof nested !== 'object') continue; + const translated = withContextModeVocabulary(nested); + if (translated !== nested) mutable()[field] = translated; + } + return out; +} + +/** Read tracked entities from their canonical layer owners. */ +export function collectTrackedEntities(dataManager) { + const tracked = []; + for (const family of TRACKABLE_FAMILIES) { + const module = dataManager?.layers?.get(family.layerId)?.module; + if (!module) continue; + try { + const info = + family.kind === 'vessel' + ? module.getSelectedInfo?.() + : module.getTrackedInfo?.(); + if (info) + tracked.push({ + kind: family.kind, + layerId: family.layerId, + ...info, + }); + } catch { + // A not-yet-ready layer contributes no tracked observation. + } + } + return tracked; +} + +/** Read the active Contacts window from its owning awareness layer. */ +export function activeContactsWindow(dataManager) { + try { + const layer = dataManager?.layers?.get('military-awareness')?.module; + return ( + layer?.contactsWindowFromSnapshot?.(layer.getContextSnapshot?.()) ?? null + ); + } catch { + return null; + } +} + +/** + * Build the model-free fast World Packet from existing application owners. + * + * This performs no network requests and intentionally mirrors the historical + * get_current_view_state payload so voice can delegate without behavior drift. + */ +export function getWorldPacket({ + viewer, + styleManager, + dataManager, + sceneDirector = null, + now = Date.now(), +} = {}) { + if (!viewer?.camera?.positionWC) + throw new TypeError('World Packet requires a viewer camera'); + if (!dataManager || typeof dataManager.getAll !== 'function') + throw new TypeError('World Packet requires a data manager'); + + const cartographic = Cesium.Cartographic.fromCartesian( + viewer.camera.positionWC, + ); + const rows = dataManager.getAll(); + const contactsWindow = activeContactsWindow(dataManager); + const contextState = + typeof styleManager?.getContextModeState === 'function' + ? withContextModeVocabulary(styleManager.getContextModeState()) + : null; + + return { + ok: true, + action: 'get_current_view_state', + camera: { + latitude: Cesium.Math.toDegrees(cartographic.latitude), + longitude: Cesium.Math.toDegrees(cartographic.longitude), + heightM: cartographic.height, + }, + style: styleManager?.activeStyle || 'normal', + context: contextState + ? { + ...contextState, + ...(contactsWindow ? { contactsWindow } : {}), + } + : null, + cockpit: + typeof styleManager?.getCockpitState === 'function' + ? styleManager.getCockpitState() + : null, + controls: + typeof styleManager?.getControlState === 'function' + ? styleManager.getControlState() + : null, + scenePlayback: sceneDirector?.getPlaybackStatus?.() || null, + tracked: collectTrackedEntities(dataManager), + layers: rows.map((layer) => viewStateLayerRow(layer, { now })), + feedProvenance: feedProvenanceEnvelope( + layerSnapshots(rows, { now }).filter((layer) => layer.enabled), + { now }, + ), + }; +} From 5c044a3d8da61eb707c7b61e16e42d6c72fc2ad3 Mon Sep 17 00:00:00 2001 From: Kevin Rajan <7121943+kvnloo@users.noreply.github.com> Date: Tue, 29 Sep 2026 03:02:05 -0500 Subject: [PATCH 06/60] test(agent): prove World Packet parity and fingerprint semantics --- src/agent/worldPacket.test.mjs | 110 +++++++++++++++++++++++++++++++++ 1 file changed, 110 insertions(+) create mode 100644 src/agent/worldPacket.test.mjs diff --git a/src/agent/worldPacket.test.mjs b/src/agent/worldPacket.test.mjs new file mode 100644 index 000000000..9d9e0bd7d --- /dev/null +++ b/src/agent/worldPacket.test.mjs @@ -0,0 +1,110 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import * as Cesium from 'cesium'; + +import { + getWorldPacket, + semanticWorldPacketFingerprint, +} from './worldPacket.js'; +import { createGevActionRunner } from '../voice/gevActions.js'; + +function harness() { + const position = Cesium.Cartesian3.fromDegrees(-97.74, 30.26, 500); + const viewer = { + trackedEntity: undefined, + clock: { onTick: { addEventListener: () => () => {} } }, + scene: { + canvas: { + clientWidth: 1200, + clientHeight: 800, + addEventListener() {}, + removeEventListener() {}, + }, + globe: { getHeight: () => 0 }, + tweens: [], + }, + camera: { + positionWC: position, + positionCartographic: Cesium.Cartographic.fromCartesian(position), + heading: 0, + pitch: Cesium.Math.toRadians(-45), + moveEnd: { addEventListener: () => () => {} }, + cancelFlight() {}, + lookAtTransform() {}, + }, + }; + const rows = [ + { + id: 'traffic', + name: 'Traffic', + enabled: true, + stats: { + count: 3, + source: 'fixture', + lastUpdate: null, + }, + }, + ]; + const dataManager = { + layers: new Map([['traffic', { module: null }]]), + getAll: () => rows, + }; + const styleManager = { + activeStyle: 'normal', + getContextModeState: () => ({ mode: 'flights' }), + getCockpitState: () => null, + getControlState: () => ({ hud: { visible: 'auto' } }), + }; + return { viewer, dataManager, styleManager }; +} + +test('World Packet preserves get_current_view_state parity', async () => { + const { viewer, dataManager, styleManager } = harness(); + const packet = getWorldPacket({ viewer, dataManager, styleManager }); + const runner = createGevActionRunner({ viewer, dataManager, styleManager }); + const voice = await runner('get_current_view_state'); + + assert.deepEqual(voice, packet); + assert.equal(packet.context.mode, 'contacts'); + assert.equal(packet.context.modeInternal, 'flights'); + assert.equal(packet.layers[0].feedState, 'nominal'); + assert.equal(packet.feedProvenance.overall, 'nominal'); +}); + +test('World Packet keeps stale/unavailable feed semantics distinct from empty', () => { + const { viewer, dataManager, styleManager } = harness(); + dataManager.getAll = () => [ + { + id: 'traffic', + name: 'Traffic', + enabled: true, + stats: { + count: 0, + source: 'fixture', + error: 'fixture unavailable', + lastUpdate: null, + }, + }, + ]; + + const packet = getWorldPacket({ viewer, dataManager, styleManager }); + assert.equal(packet.layers[0].count, 0); + assert.notEqual(packet.layers[0].feedState, 'nominal'); + assert.notEqual(packet.feedProvenance.overall, 'nominal'); + assert.match(packet.feedProvenance.note, /fixture unavailable/i); +}); + + +test('semantic fingerprint ignores narration prose but changes with observed state', () => { + const { viewer, dataManager, styleManager } = harness(); + const packet = getWorldPacket({ viewer, dataManager, styleManager }); + const original = semanticWorldPacketFingerprint(packet); + + const proseOnly = structuredClone(packet); + proseOnly.feedProvenance.note = 'different generated narration'; + assert.equal(semanticWorldPacketFingerprint(proseOnly), original); + + const changed = structuredClone(packet); + changed.layers[0].count += 1; + assert.notEqual(semanticWorldPacketFingerprint(changed), original); +}); From 3c163d09a15f2a152cb6ce6de09ff0e82db1a63e Mon Sep 17 00:00:00 2001 From: Kevin Rajan <7121943+kvnloo@users.noreply.github.com> Date: Tue, 29 Sep 2026 03:02:08 -0500 Subject: [PATCH 07/60] refactor(voice): delegate world state to World Packet builder --- src/voice/gevActions.js | 73 ++++------------------------------------- 1 file changed, 7 insertions(+), 66 deletions(-) diff --git a/src/voice/gevActions.js b/src/voice/gevActions.js index cfdb1fb0b..cccae249e 100644 --- a/src/voice/gevActions.js +++ b/src/voice/gevActions.js @@ -39,6 +39,7 @@ import { unavailablePlaceSearch } from '../search/placeSearch.js'; import * as defaultAnnotationResolver from '../annotations/annotationResolver.js'; import { normalizeRadioCountryInput } from '../data/radioCountry.js'; import { TR3B_CLASS } from '../data/tr3bRegistry.js'; +import { getWorldPacket } from '../agent/worldPacket.js'; const ALLOWED_STYLES = new Set([ 'normal', @@ -2424,25 +2425,6 @@ function runManagedVoiceNavigation( } /** Gathers tracked/selected entities across layer families for read-back. */ -function collectTrackedEntities(dataManager) { - const tracked = []; - for (const family of TRACKABLE_FAMILIES) { - const module = dataManager.layers.get(family.layerId)?.module; - if (!module) continue; - try { - const info = - family.kind === 'vessel' - ? module.getSelectedInfo?.() - : module.getTrackedInfo?.(); - if (info) - tracked.push({ kind: family.kind, layerId: family.layerId, ...info }); - } catch { - // layer not ready - } - } - return tracked; -} - export async function getBasemapLabelContext( viewer, service = defaultGeospatial, @@ -3073,53 +3055,12 @@ function getCurrentViewState( dataManager, sceneDirector = null, ) { - const cartographic = Cesium.Cartographic.fromCartesian( - viewer.camera.positionWC, - ); - return { - ok: true, - action: 'get_current_view_state', - camera: { - latitude: Cesium.Math.toDegrees(cartographic.latitude), - longitude: Cesium.Math.toDegrees(cartographic.longitude), - heightM: cartographic.height, - }, - style: styleManager.activeStyle || 'normal', - context: - typeof styleManager.getContextModeState === 'function' - ? { - ...withContextModeVocabulary(styleManager.getContextModeState()), - // The numbers on the operator's Contacts panel, so a window/count - // question can be answered from what they are looking at. - ...(activeContactsWindow(dataManager) - ? { contactsWindow: activeContactsWindow(dataManager) } - : {}), - } - : null, - cockpit: - typeof styleManager.getCockpitState === 'function' - ? styleManager.getCockpitState() - : null, - controls: - typeof styleManager.getControlState === 'function' - ? styleManager.getControlState() - : null, - scenePlayback: sceneDirector?.getPlaybackStatus?.() || null, - tracked: collectTrackedEntities(dataManager), - layers: dataManager.getAll().map((layer) => ({ - id: layer.id, - name: layer.name, - enabled: layer.enabled, - count: layer.stats?.count || 0, - error: layer.stats?.error || null, - feedState: layerSnapshot(layer).feedState, - source: layerSnapshot(layer).source, - lastUpdate: layerSnapshot(layer).lastUpdate, - })), - feedProvenance: feedProvenanceEnvelope( - layerSnapshots(dataManager.getAll()).filter((layer) => layer.enabled), - ), - }; + return getWorldPacket({ + viewer, + styleManager, + dataManager, + sceneDirector, + }); } async function getEntityContext( From 6439575d158238ce92470106d221f5d450c6f29c Mon Sep 17 00:00:00 2001 From: Kevin Rajan <7121943+kvnloo@users.noreply.github.com> Date: Tue, 29 Sep 2026 03:02:11 -0500 Subject: [PATCH 08/60] feat(agent): export World Packet boundary --- package.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/package.json b/package.json index e48881c6d..0a299b51d 100644 --- a/package.json +++ b/package.json @@ -250,6 +250,7 @@ "./layers/transit/standalone": "./src/data/transit.js", "./layers/transit/source": "./src/layers/transit/source.js", "./sources/transit-service": "./src/sources/transitService.js", - "./sources/map-features": "./src/sources/overpassFeatures.js" + "./sources/map-features": "./src/sources/overpassFeatures.js", + "./agent/world-packet": "./src/agent/worldPacket.js" } } From c55d4d7c25877abb1a6eec331d82ad0a124bf61e Mon Sep 17 00:00:00 2001 From: Kevin Rajan <7121943+kvnloo@users.noreply.github.com> Date: Tue, 29 Sep 2026 03:02:14 -0500 Subject: [PATCH 09/60] build: register World Packet package boundary --- scripts/package-boundaries.json | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index 276b9e0db..42e98d736 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -3072,5 +3072,15 @@ "src/layers/perimeters/source.js" ], "external": [] + }, + "agent-world-packet": { + "exports": ["./agent/world-packet"], + "modules": [ + "src/agent/worldPacket.js", + "src/data/layerSnapshot.js", + "src/data/feedState.js", + "src/contextModePolicy.js" + ], + "external": ["cesium"] } } From 05b13e9a78b091d1426e5a27889ad274d4cb350b Mon Sep 17 00:00:00 2001 From: Kevin Rajan <7121943+kvnloo@users.noreply.github.com> Date: Wed, 30 Sep 2026 20:51:48 -0500 Subject: [PATCH 10/60] chore: add one-shot branch formatter --- .github/workflows/tmp-format-fix.yml | 37 ++++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) create mode 100644 .github/workflows/tmp-format-fix.yml diff --git a/.github/workflows/tmp-format-fix.yml b/.github/workflows/tmp-format-fix.yml new file mode 100644 index 000000000..73af025ad --- /dev/null +++ b/.github/workflows/tmp-format-fix.yml @@ -0,0 +1,37 @@ +name: Temporary branch formatter + +on: + push: + branches: + - feat/world-packet-boundary-clean-20260929 + +permissions: + contents: write + +jobs: + format: + if: github.actor != 'github-actions[bot]' + runs-on: ubuntu-latest + env: + PUPPETEER_SKIP_DOWNLOAD: 1 + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 + with: + ref: feat/world-packet-boundary-clean-20260929 + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 + with: + node-version: 24.14.0 + cache: npm + - run: npm ci + - run: npm run format + - name: Commit formatting + shell: bash + run: | + if git diff --quiet; then + exit 0 + fi + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git add -u + git commit -m "style: apply adopted formatting" + git push origin HEAD:feat/world-packet-boundary-clean-20260929 From 58b7d2d95e7ba3e323a5bd506946f4687df670a7 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 1 Oct 2026 01:52:28 +0000 Subject: [PATCH 11/60] style: apply adopted formatting --- src/agent/worldPacket.js | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/src/agent/worldPacket.js b/src/agent/worldPacket.js index c492f3d6b..e8c522caa 100644 --- a/src/agent/worldPacket.js +++ b/src/agent/worldPacket.js @@ -24,11 +24,7 @@ const NESTED_CONTEXT_RESULT_FIELDS = Object.freeze([ 'contextRollback', ]); -const NON_SEMANTIC_PACKET_KEYS = new Set([ - 'note', - 'ageLabel', - 'generatedAt', -]); +const NON_SEMANTIC_PACKET_KEYS = new Set(['note', 'ageLabel', 'generatedAt']); function semanticPacketValue(value) { if (Array.isArray(value)) return value.map(semanticPacketValue); From 6a6703e0c6db8bca4ac9ab7aa27c6df931106f5c Mon Sep 17 00:00:00 2001 From: Kevin Rajan <7121943+kvnloo@users.noreply.github.com> Date: Wed, 30 Sep 2026 20:52:54 -0500 Subject: [PATCH 12/60] chore: remove one-shot branch formatter --- .github/workflows/tmp-format-fix.yml | 37 ---------------------------- 1 file changed, 37 deletions(-) delete mode 100644 .github/workflows/tmp-format-fix.yml diff --git a/.github/workflows/tmp-format-fix.yml b/.github/workflows/tmp-format-fix.yml deleted file mode 100644 index 73af025ad..000000000 --- a/.github/workflows/tmp-format-fix.yml +++ /dev/null @@ -1,37 +0,0 @@ -name: Temporary branch formatter - -on: - push: - branches: - - feat/world-packet-boundary-clean-20260929 - -permissions: - contents: write - -jobs: - format: - if: github.actor != 'github-actions[bot]' - runs-on: ubuntu-latest - env: - PUPPETEER_SKIP_DOWNLOAD: 1 - steps: - - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 - with: - ref: feat/world-packet-boundary-clean-20260929 - - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 - with: - node-version: 24.14.0 - cache: npm - - run: npm ci - - run: npm run format - - name: Commit formatting - shell: bash - run: | - if git diff --quiet; then - exit 0 - fi - git config user.name "github-actions[bot]" - git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - git add -u - git commit -m "style: apply adopted formatting" - git push origin HEAD:feat/world-packet-boundary-clean-20260929 From 3c53d63defb180199a039787412e6aa811d43b2d Mon Sep 17 00:00:00 2001 From: Kevin Rajan <7121943+kvnloo@users.noreply.github.com> Date: Wed, 30 Sep 2026 20:55:09 -0500 Subject: [PATCH 13/60] fix: declare World Packet in application boundary --- scripts/package-boundaries.json | 1 + 1 file changed, 1 insertion(+) diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index 42e98d736..b362d28f4 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -1851,6 +1851,7 @@ "src/voice/commands.js", "src/voice/control.js", "src/voice/gevActions.js", + "src/agent/worldPacket.js", "src/voice/gevRealtime.js", "src/voice/layerSummary.js", "src/voice/realtimeBackend.js", From 8acb526d1fc28dbec508b5303f1865bc44c099f2 Mon Sep 17 00:00:00 2001 From: Kevin Rajan <7121943+kvnloo@users.noreply.github.com> Date: Wed, 30 Sep 2026 20:57:17 -0500 Subject: [PATCH 14/60] fix: include World Packet feed-state dependencies --- scripts/package-boundaries.json | 746 ++++++++++++++++++++++++-------- 1 file changed, 567 insertions(+), 179 deletions(-) diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index b362d28f4..260179257 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -12,27 +12,50 @@ "src/data/inputOwnership.js", "src/data/infrastructureOverlayEntry.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", "src/app/atmosphereCompat.js"], - "external": ["cesium"] + "exports": [ + "./application/viewer" + ], + "modules": [ + "src/app/viewer.js", + "src/app/atmosphereCompat.js" + ], + "external": [ + "cesium" + ] }, "vite-build": { "runtime": "node", - "exports": ["./build/vite", "./build/html"], - "modules": ["build/vite.js", "build/application-html.js"], - "external": ["vite-plugin-cesium"] + "exports": [ + "./build/vite", + "./build/html" + ], + "modules": [ + "build/vite.js", + "build/application-html.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", @@ -48,16 +71,24 @@ "src/data/aisWatchdog.js", "src/sources/httpBody.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", @@ -75,13 +106,19 @@ "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", @@ -93,13 +130,19 @@ "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" @@ -107,35 +150,59 @@ "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-csv": { - "exports": ["./sources/firms-csv"], - "modules": ["src/data/firmsCsv.js"], + "exports": [ + "./sources/firms-csv" + ], + "modules": [ + "src/data/firmsCsv.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"], + "exports": [ + "./server/providers/gbfs" + ], "modules": [ "server/providers/gbfs.js", "server/providers/common/http.js", @@ -145,13 +212,19 @@ "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/cap.js", @@ -176,7 +249,9 @@ }, "radio-provider": { "runtime": "node", - "exports": ["./server/providers/radio"], + "exports": [ + "./server/providers/radio" + ], "modules": [ "server/providers/radio.js", "server/providers/radio/catalog.js", @@ -192,7 +267,9 @@ }, "overpass-provider": { "runtime": "node", - "exports": ["./server/providers/overpass"], + "exports": [ + "./server/providers/overpass" + ], "modules": [ "server/providers/common/geo.js", "server/providers/common/http.js", @@ -215,7 +292,9 @@ }, "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", @@ -234,7 +313,9 @@ }, "regional-provider": { "runtime": "node", - "exports": ["./server/providers/regional"], + "exports": [ + "./server/providers/regional" + ], "modules": [ "server/providers/common/http.js", "server/providers/common/query.js", @@ -261,7 +342,9 @@ }, "openai-provider": { "runtime": "node", - "exports": ["./server/providers/openai"], + "exports": [ + "./server/providers/openai" + ], "modules": [ "src/hudLayoutPolicy.js", "server/providers/common/rate-limit.js", @@ -289,7 +372,9 @@ }, "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", @@ -300,7 +385,9 @@ "external": [] }, "place-search": { - "exports": ["./search"], + "exports": [ + "./search" + ], "modules": [ "src/search/index.js", "src/search/placeSearch.js", @@ -321,17 +408,27 @@ "external": [] }, "panel-disclosure": { - "exports": ["./ui/panels"], - "modules": ["src/ui/panelDisclosure.js"], + "exports": [ + "./ui/panels" + ], + "modules": [ + "src/ui/panelDisclosure.js" + ], "external": [] }, "surface-keyboard": { - "exports": ["./ui/surfaces"], - "modules": ["src/ui/surfaceKeyboard.js"], + "exports": [ + "./ui/surfaces" + ], + "modules": [ + "src/ui/surfaceKeyboard.js" + ], "external": [] }, "panel-rail-layout": { - "exports": ["./ui/layout"], + "exports": [ + "./ui/layout" + ], "modules": [ "src/ui/panelRails.js", "src/ui/displayPanelScroll.js", @@ -344,7 +441,9 @@ "external": [] }, "visual-input": { - "exports": ["./ui/input"], + "exports": [ + "./ui/input" + ], "modules": [ "src/ui/visualInput.js", "src/ui/applicationShortcuts.js", @@ -353,12 +452,19 @@ "external": [] }, "display-controls": { - "exports": ["./ui/display"], - "modules": ["src/ui/displayControls.js"], + "exports": [ + "./ui/display" + ], + "modules": [ + "src/ui/displayControls.js" + ], "external": [] }, "visual-effects": { - "exports": ["./ui/effects", "./ui/effects/bloom"], + "exports": [ + "./ui/effects", + "./ui/effects/bloom" + ], "modules": [ "src/ui/effects.js", "src/ui/visualEffects.js", @@ -371,10 +477,14 @@ "src/styles/noir.js", "src/styles/snow.js" ], - "external": ["cesium"] + "external": [ + "cesium" + ] }, "map-source-controls": { - "exports": ["./ui/maps"], + "exports": [ + "./ui/maps" + ], "modules": [ "src/ui/mapSource.js", "src/ui/mapSourceControls.js", @@ -384,7 +494,10 @@ "external": [] }, "layer-panel": { - "exports": ["./ui/layers", "./ui/layers/feedback"], + "exports": [ + "./ui/layers", + "./ui/layers/feedback" + ], "modules": [ "src/ui/layers.js", "src/ui/layerPanel.js", @@ -404,7 +517,9 @@ "external": [] }, "location-controls": { - "exports": ["./ui/location"], + "exports": [ + "./ui/location" + ], "modules": [ "src/ui/location.js", "src/ui/locationControls.js", @@ -415,7 +530,9 @@ "external": [] }, "radio-controls": { - "exports": ["./ui/radio"], + "exports": [ + "./ui/radio" + ], "modules": [ "src/ui/radio.js", "src/ui/radioControls.js", @@ -426,7 +543,9 @@ "external": [] }, "cctv-controls": { - "exports": ["./ui/cctv"], + "exports": [ + "./ui/cctv" + ], "modules": [ "src/ui/cctv.js", "src/ui/cctvBindings.js", @@ -439,7 +558,10 @@ "external": [] }, "context-controls": { - "exports": ["./ui/context", "./ui/context/policy"], + "exports": [ + "./ui/context", + "./ui/context/policy" + ], "modules": [ "src/ui/context.js", "src/ui/contextActions.js", @@ -482,10 +604,14 @@ "src/cockpitVisionPolicy.js", "src/ui/panelRailGeometry.js" ], - "external": ["cesium"] + "external": [ + "cesium" + ] }, "scene-controls": { - "exports": ["./ui/scenes"], + "exports": [ + "./ui/scenes" + ], "modules": [ "src/ui/scenes.js", "src/ui/sceneControls.js", @@ -494,7 +620,9 @@ "external": [] }, "ui-shell": { - "exports": ["./ui/shell"], + "exports": [ + "./ui/shell" + ], "modules": [ "src/hudLayouts.js", "src/hudLayoutPolicy.js", @@ -624,10 +752,14 @@ "src/ui/localSdrPresentation.js", "src/data/cameraArrival.js" ], - "external": ["cesium"] + "external": [ + "cesium" + ] }, "ui-styles": { - "exports": ["./ui/styles"], + "exports": [ + "./ui/styles" + ], "modules": [ "src/ui/styles.js", "style.css", @@ -657,7 +789,9 @@ "external": [] }, "director": { - "exports": ["./director"], + "exports": [ + "./director" + ], "modules": [ "src/director/playback.js", "src/director/clock.js", @@ -680,7 +814,9 @@ "external": [] }, "scenes": { - "exports": ["./scenes"], + "exports": [ + "./scenes" + ], "modules": [ "src/scenes/nepalEvidencePack.js", "src/data/bhoteKoshiIncidentPlaces.js", @@ -729,10 +865,14 @@ "src/ui/sceneSharing.js", "src/data/cameraArrival.js" ], - "external": ["cesium"] + "external": [ + "cesium" + ] }, "live-observations": { - "exports": ["./sources/live"], + "exports": [ + "./sources/live" + ], "modules": [ "src/sources/live/aircraft.js", "src/sources/live/contract.js", @@ -743,7 +883,9 @@ "external": [] }, "civil-flights": { - "exports": ["./layers/flights"], + "exports": [ + "./layers/flights" + ], "modules": [ "src/cyberSonar.js", "src/data/aircraftClass.js", @@ -782,10 +924,14 @@ "src/data/inputOwnership.js", "src/data/modelEligibility.js" ], - "external": ["cesium"] + "external": [ + "cesium" + ] }, "military-flights": { - "exports": ["./layers/military"], + "exports": [ + "./layers/military" + ], "modules": [ "src/cyberSonar.js", "src/data/aircraftClass.js", @@ -820,10 +966,14 @@ "src/data/inputOwnership.js", "src/data/modelEligibility.js" ], - "external": ["cesium"] + "external": [ + "cesium" + ] }, "aircraft-mechanics": { - "exports": ["./aircraft"], + "exports": [ + "./aircraft" + ], "modules": [ "src/data/aircraftClass.js", "src/data/aircraftIcons.js", @@ -845,10 +995,14 @@ "src/layers/aircraft/index.js", "src/data/inputOwnership.js" ], - "external": ["cesium"] + "external": [ + "cesium" + ] }, "vessel-layer": { - "exports": ["./layers/vessels"], + "exports": [ + "./layers/vessels" + ], "modules": [ "src/layers/vessels/cards.js", "src/layers/vessels/evidence.js", @@ -869,20 +1023,28 @@ "src/layers/vessels/snapshotRenderer.js", "src/data/inputOwnership.js" ], - "external": ["cesium"] + "external": [ + "cesium" + ] }, "earthquakes": { - "exports": ["./layers/earthquakes"], + "exports": [ + "./layers/earthquakes" + ], "modules": [ "src/layers/earthquakes/index.js", "src/layers/earthquakes/model.js", "src/layers/earthquakes/source.js", "src/layers/earthquakes/records.js" ], - "external": ["cesium"] + "external": [ + "cesium" + ] }, "recent-imagery": { - "exports": ["./layers/recent-imagery"], + "exports": [ + "./layers/recent-imagery" + ], "modules": [ "src/layers/recentImagery/index.js", "src/layers/recentImagery/model.js", @@ -891,10 +1053,14 @@ "src/layers/recentImagery/thumbnails.js", "src/maps/imageryHost.js" ], - "external": ["cesium"] + "external": [ + "cesium" + ] }, "fire-layer": { - "exports": ["./layers/firms"], + "exports": [ + "./layers/firms" + ], "modules": [ "src/data/firmsAdapt.js", "src/data/firmsLabels.js", @@ -914,10 +1080,14 @@ "src/layers/firms/viewport.js", "src/data/inputOwnership.js" ], - "external": ["cesium"] + "external": [ + "cesium" + ] }, "satellites-layer": { - "exports": ["./layers/satellites"], + "exports": [ + "./layers/satellites" + ], "modules": [ "src/data/issPass.js", "src/data/satelliteClass.js", @@ -939,10 +1109,15 @@ "src/layers/satellites/tracking.js", "src/data/inputOwnership.js" ], - "external": ["cesium", "satellite.js"] + "external": [ + "cesium", + "satellite.js" + ] }, "launches-layer": { - "exports": ["./layers/launches"], + "exports": [ + "./layers/launches" + ], "modules": [ "src/cyberSonar.js", "src/layers/launches/camera.js", @@ -966,10 +1141,14 @@ "src/layers/launches/testing.js", "src/data/inputOwnership.js" ], - "external": ["cesium"] + "external": [ + "cesium" + ] }, "installations-layer": { - "exports": ["./layers/installations"], + "exports": [ + "./layers/installations" + ], "modules": [ "src/data/cameraSensitivity.js", "src/data/installationFeedback.js", @@ -1001,10 +1180,16 @@ "src/layers/installations/namedMarkers.js", "src/data/infrastructureOverlayEntry.js" ], - "external": ["cesium", "@mapbox/vector-tile", "pbf"] + "external": [ + "cesium", + "@mapbox/vector-tile", + "pbf" + ] }, "awareness-layer": { - "exports": ["./layers/awareness"], + "exports": [ + "./layers/awareness" + ], "modules": [ "src/cyberSonar.js", "src/cockpitMath.js", @@ -1028,10 +1213,14 @@ "src/layers/awareness/testing.js", "src/ui/panelRailGeometry.js" ], - "external": ["cesium"] + "external": [ + "cesium" + ] }, "traffic-layer": { - "exports": ["./layers/traffic"], + "exports": [ + "./layers/traffic" + ], "modules": [ "src/data/cameraSensitivity.js", "src/data/flowMatch.js", @@ -1069,10 +1258,16 @@ "src/sources/phaseTiming.js", "src/layers/traffic/roadModes.js" ], - "external": ["cesium", "pbf", "@mapbox/vector-tile"] + "external": [ + "cesium", + "pbf", + "@mapbox/vector-tile" + ] }, "bikeshare-layer": { - "exports": ["./layers/bikeshare"], + "exports": [ + "./layers/bikeshare" + ], "modules": [ "src/layers/bikeshare/controls.js", "src/layers/bikeshare/index.js", @@ -1091,10 +1286,14 @@ "src/data/inputOwnership.js", "src/data/cameraSensitivity.js" ], - "external": ["cesium"] + "external": [ + "cesium" + ] }, "cctv-layer": { - "exports": ["./layers/cctv"], + "exports": [ + "./layers/cctv" + ], "modules": [ "src/data/cctvCards.js", "src/data/cctvGizmo.js", @@ -1132,10 +1331,15 @@ "src/data/inputOwnership.js", "src/layers/cctv/sourcePolicy.js" ], - "external": ["hls.js", "cesium"] + "external": [ + "hls.js", + "cesium" + ] }, "alpr-cameras": { - "exports": ["./layers/alpr"], + "exports": [ + "./layers/alpr" + ], "modules": [ "src/layers/alpr/index.js", "src/layers/alpr/model.js", @@ -1154,10 +1358,16 @@ "src/sources/phaseTiming.js", "src/layers/alpr/floors.js" ], - "external": ["cesium", "@mapbox/vector-tile", "pbf"] + "external": [ + "cesium", + "@mapbox/vector-tile", + "pbf" + ] }, "radio-layer": { - "exports": ["./layers/radio"], + "exports": [ + "./layers/radio" + ], "modules": [ "src/data/iconOrientation.js", "src/data/layerState.js", @@ -1188,10 +1398,14 @@ "src/ui/radioTunerModel.js", "src/data/inputOwnership.js" ], - "external": ["cesium"] + "external": [ + "cesium" + ] }, "submarine-cables": { - "exports": ["./layers/submarine-cables"], + "exports": [ + "./layers/submarine-cables" + ], "modules": [ "src/layers/submarineCables/geometry.js", "src/layers/submarineCables/index.js", @@ -1205,10 +1419,14 @@ "src/layers/submarineCables/surface.js", "src/data/inputOwnership.js" ], - "external": ["cesium"] + "external": [ + "cesium" + ] }, "bundled-cables": { - "exports": ["./sources/bundled-cables"], + "exports": [ + "./sources/bundled-cables" + ], "modules": [ "src/layers/submarineCables/bundledSource.js", "src/data/local_data/telegeography_submarine_cables/cable-geo.json", @@ -1217,7 +1435,10 @@ "external": [] }, "geography": { - "exports": ["./geography/natural-earth", "./geography/neighborhoods"], + "exports": [ + "./geography/natural-earth", + "./geography/neighborhoods" + ], "modules": [ "src/data/naturalEarthRegions.js", "src/data/neighborhoodPolygons.js", @@ -1253,7 +1474,9 @@ "src/keySetupCore.mjs", "src/maps/imageryHost.js" ], - "external": ["cesium"] + "external": [ + "cesium" + ] }, "voice-connection": { "exports": [ @@ -2042,12 +2265,18 @@ ] }, "application-catalog": { - "exports": ["./application/catalog"], - "modules": ["src/app/catalog.js"], + "exports": [ + "./application/catalog" + ], + "modules": [ + "src/app/catalog.js" + ], "external": [] }, "application-layer-construction": { - "exports": ["./application/layers"], + "exports": [ + "./application/layers" + ], "modules": [ "src/cyberSonar.js", "src/data/bhoteKoshiEvent.js", @@ -2526,7 +2755,9 @@ ] }, "application-operations": { - "exports": ["./application/operations"], + "exports": [ + "./application/operations" + ], "modules": [ "src/annotations/annotationResolver.js", "src/annotations/resolver.js", @@ -2587,22 +2818,33 @@ ] }, "layer-lifecycle": { - "exports": ["./data/lifecycle"], - "modules": ["src/data/lifecycle.js", "src/data/cameraArrival.js"], + "exports": [ + "./data/lifecycle" + ], + "modules": [ + "src/data/lifecycle.js", + "src/data/cameraArrival.js" + ], "external": [] }, "ui-navigation": { - "exports": ["./ui/navigation"], + "exports": [ + "./ui/navigation" + ], "modules": [ "src/navigationPolicy.js", "src/ui/navigationController.js", "src/app/stateChannel.js", "src/data/cameraArrival.js" ], - "external": ["cesium"] + "external": [ + "cesium" + ] }, "ui-share-restoration": { - "exports": ["./ui/share-restoration"], + "exports": [ + "./ui/share-restoration" + ], "modules": [ "src/data/installationFeedback.js", "src/data/layerState.js", @@ -2615,7 +2857,9 @@ "external": [] }, "ui-visual-settings": { - "exports": ["./ui/visual-settings"], + "exports": [ + "./ui/visual-settings" + ], "modules": [ "src/cyberSonar.js", "src/hudLayouts.js", @@ -2640,10 +2884,14 @@ "src/ui/visualPresets.js", "src/ui/visualSettings.js" ], - "external": ["cesium"] + "external": [ + "cesium" + ] }, "ui-panel-chrome": { - "exports": ["./ui/panel-chrome"], + "exports": [ + "./ui/panel-chrome" + ], "modules": [ "src/ui/displayPanelScroll.js", "src/ui/radioPanelPlacement.js", @@ -2662,7 +2910,9 @@ "external": [] }, "civil-flight-records": { - "exports": ["./layers/flights/records"], + "exports": [ + "./layers/flights/records" + ], "modules": [ "src/layers/flights/records.js", "src/layers/flights/recordPolicy.js", @@ -2673,7 +2923,9 @@ "external": [] }, "civil-flight-ingestion": { - "exports": ["./layers/flights/ingestion"], + "exports": [ + "./layers/flights/ingestion" + ], "modules": [ "src/layers/flights/ingestion.js", "src/layers/flights/recordPolicy.js" @@ -2681,7 +2933,9 @@ "external": [] }, "military-flight-records": { - "exports": ["./layers/military/records"], + "exports": [ + "./layers/military/records" + ], "modules": [ "src/layers/military/records.js", "src/layers/military/recordPolicy.js", @@ -2692,7 +2946,9 @@ "external": [] }, "military-flight-ingestion": { - "exports": ["./layers/military/ingestion"], + "exports": [ + "./layers/military/ingestion" + ], "modules": [ "src/layers/military/ingestion.js", "src/layers/military/recordPolicy.js" @@ -2700,7 +2956,9 @@ "external": [] }, "vessel-records": { - "exports": ["./layers/vessels/records"], + "exports": [ + "./layers/vessels/records" + ], "modules": [ "src/layers/vessels/records.js", "src/layers/vessels/recordPolicy.js" @@ -2708,7 +2966,9 @@ "external": [] }, "vessel-ingestion": { - "exports": ["./layers/vessels/ingestion"], + "exports": [ + "./layers/vessels/ingestion" + ], "modules": [ "src/layers/vessels/ingestion.js", "src/layers/vessels/recordPolicy.js" @@ -2716,37 +2976,68 @@ "external": [] }, "directions-layer": { - "exports": ["./layers/directions"], - "modules": ["src/layers/directions/index.js", "src/data/routeSteps.js"], - "external": ["cesium"] + "exports": [ + "./layers/directions" + ], + "modules": [ + "src/layers/directions/index.js", + "src/data/routeSteps.js" + ], + "external": [ + "cesium" + ] }, "radio-browser-source": { - "exports": ["./sources/radio-browser"], - "modules": ["src/sources/radioBrowser.js", "src/data/radioCountry.js"], + "exports": [ + "./sources/radio-browser" + ], + "modules": [ + "src/sources/radioBrowser.js", + "src/data/radioCountry.js" + ], "external": [] }, "cctv-types-source": { - "exports": ["./sources/cctv-types"], - "modules": ["src/sources/cctvTypes.js"], + "exports": [ + "./sources/cctv-types" + ], + "modules": [ + "src/sources/cctvTypes.js" + ], "external": [] }, "regional-source": { - "exports": ["./sources/regional"], - "modules": ["src/data/regionalModel.js"], + "exports": [ + "./sources/regional" + ], + "modules": [ + "src/data/regionalModel.js" + ], "external": [] }, "voice-action-schemas": { - "exports": ["./voice/action-schemas"], - "modules": ["src/voice/actionSchemas.js", "src/hudLayoutPolicy.js"], + "exports": [ + "./voice/action-schemas" + ], + "modules": [ + "src/voice/actionSchemas.js", + "src/hudLayoutPolicy.js" + ], "external": [] }, "voice-session": { - "exports": ["./voice/session"], - "modules": ["src/voice/session.js"], + "exports": [ + "./voice/session" + ], + "modules": [ + "src/voice/session.js" + ], "external": [] }, "voice-session-controls": { - "exports": ["./voice/session-controls"], + "exports": [ + "./voice/session-controls" + ], "modules": [ "src/voice/sessionCommands.js", "src/voice/session.js", @@ -2755,7 +3046,9 @@ "external": [] }, "alpr-source": { - "exports": ["./layers/alpr/source"], + "exports": [ + "./layers/alpr/source" + ], "modules": [ "src/layers/alpr/policy.js", "src/layers/alpr/records.js", @@ -2766,20 +3059,34 @@ "src/sources/vectorTiles.js", "src/sources/phaseTiming.js" ], - "external": ["@mapbox/vector-tile", "pbf"] + "external": [ + "@mapbox/vector-tile", + "pbf" + ] }, "bikeshare-source": { - "exports": ["./layers/bikeshare/source"], - "modules": ["src/layers/bikeshare/source.js"], + "exports": [ + "./layers/bikeshare/source" + ], + "modules": [ + "src/layers/bikeshare/source.js" + ], "external": [] }, "cctv-source": { - "exports": ["./layers/cctv/source"], - "modules": ["src/layers/cctv/source.js", "src/layers/cctv/sourcePolicy.js"], + "exports": [ + "./layers/cctv/source" + ], + "modules": [ + "src/layers/cctv/source.js", + "src/layers/cctv/sourcePolicy.js" + ], "external": [] }, "earthquakes-source": { - "exports": ["./layers/earthquakes/source"], + "exports": [ + "./layers/earthquakes/source" + ], "modules": [ "src/layers/earthquakes/records.js", "src/layers/earthquakes/source.js" @@ -2787,12 +3094,18 @@ "external": [] }, "firms-source": { - "exports": ["./layers/firms/source"], - "modules": ["src/layers/firms/source.js"], + "exports": [ + "./layers/firms/source" + ], + "modules": [ + "src/layers/firms/source.js" + ], "external": [] }, "installations-source": { - "exports": ["./layers/installations/source"], + "exports": [ + "./layers/installations/source" + ], "modules": [ "src/layers/installations/source.js", "src/data/militaryInstallationData.js", @@ -2809,25 +3122,43 @@ "src/data/bundledJson.js", "src/data/retryableLoad.js" ], - "external": ["@mapbox/vector-tile", "pbf"] + "external": [ + "@mapbox/vector-tile", + "pbf" + ] }, "launches-source": { - "exports": ["./layers/launches/source"], - "modules": ["src/layers/launches/source.js"], + "exports": [ + "./layers/launches/source" + ], + "modules": [ + "src/layers/launches/source.js" + ], "external": [] }, "radio-source": { - "exports": ["./layers/radio/source"], - "modules": ["src/layers/radio/policy.js", "src/layers/radio/source.js"], + "exports": [ + "./layers/radio/source" + ], + "modules": [ + "src/layers/radio/policy.js", + "src/layers/radio/source.js" + ], "external": [] }, "satellites-source": { - "exports": ["./layers/satellites/source"], - "modules": ["src/layers/satellites/source.js"], + "exports": [ + "./layers/satellites/source" + ], + "modules": [ + "src/layers/satellites/source.js" + ], "external": [] }, "traffic-source-factory": { - "exports": ["./layers/traffic/source"], + "exports": [ + "./layers/traffic/source" + ], "modules": [ "src/data/tomtomTiles.js", "src/layers/traffic/flowDecode.js", @@ -2843,10 +3174,16 @@ "src/layers/traffic/roadModes.js", "src/data/flowMatch.js" ], - "external": ["@mapbox/vector-tile", "pbf"] + "external": [ + "@mapbox/vector-tile", + "pbf" + ] }, "reference-sources": { - "exports": ["./sources/reference", "./layers/submarine-cables/source"], + "exports": [ + "./sources/reference", + "./layers/submarine-cables/source" + ], "modules": [ "src/sources/reference.js", "src/layers/earthquakes/source.js", @@ -2859,7 +3196,9 @@ "external": [] }, "nominatim-provider": { - "exports": ["./search/nominatim"], + "exports": [ + "./search/nominatim" + ], "modules": [ "src/search/nominatim.js", "src/nominatimGeocode.js", @@ -2872,17 +3211,27 @@ "external": [] }, "source-http-body": { - "exports": ["./sources/http-body"], - "modules": ["src/sources/httpBody.js"], + "exports": [ + "./sources/http-body" + ], + "modules": [ + "src/sources/httpBody.js" + ], "external": [] }, "overpass-lexer": { - "exports": ["./sources/overpass"], - "modules": ["src/sources/overpass.js"], + "exports": [ + "./sources/overpass" + ], + "modules": [ + "src/sources/overpass.js" + ], "external": [] }, "nominatim-source": { - "exports": ["./sources/nominatim"], + "exports": [ + "./sources/nominatim" + ], "modules": [ "src/sources/nominatim.js", "src/nominatimGeocode.js", @@ -2894,7 +3243,9 @@ }, "transit-provider": { "runtime": "node", - "exports": ["./server/providers/transit"], + "exports": [ + "./server/providers/transit" + ], "modules": [ "server/providers/common/http.js", "server/providers/common/rate-limit.js", @@ -2909,20 +3260,28 @@ "src/sources/transitHistoryStore.js", "src/sources/rateLimit.js" ], - "external": ["pbf"] + "external": [ + "pbf" + ] }, "transit-source": { - "exports": ["./sources/transit"], + "exports": [ + "./sources/transit" + ], "modules": [ "src/data/gtfsRealtime.js", "src/data/transitFeeds.js", "src/data/transitProxy.js", "src/sources/transitHistory.js" ], - "external": ["pbf"] + "external": [ + "pbf" + ] }, "transit-standalone": { - "exports": ["./layers/transit/standalone"], + "exports": [ + "./layers/transit/standalone" + ], "modules": [ "src/cyberSonar.js", "src/app/sourceSlot.js", @@ -2981,10 +3340,16 @@ "src/sources/featureSource.js", "src/sources/capability.js" ], - "external": ["cesium", "egm96-universal", "pbf"] + "external": [ + "cesium", + "egm96-universal", + "pbf" + ] }, "transit-layer": { - "exports": ["./layers/transit"], + "exports": [ + "./layers/transit" + ], "modules": [ "src/data/iconOrientation.js", "src/data/inputOwnership.js", @@ -3018,10 +3383,15 @@ "src/layers/transit/source.js", "src/sources/transitHistory.js" ], - "external": ["cesium", "pbf"] + "external": [ + "cesium", + "pbf" + ] }, "transit-source-factory": { - "exports": ["./layers/transit/source"], + "exports": [ + "./layers/transit/source" + ], "modules": [ "src/layers/transit/source.js", "src/sources/transitHistory.js" @@ -3029,7 +3399,9 @@ "external": [] }, "transit-service": { - "exports": ["./sources/transit-service"], + "exports": [ + "./sources/transit-service" + ], "modules": [ "src/data/transitFeeds.js", "src/data/transitProxy.js", @@ -3040,10 +3412,14 @@ "src/sources/transitHistoryStore.js", "src/sources/rateLimit.js" ], - "external": ["pbf"] + "external": [ + "pbf" + ] }, "map-feature-source": { - "exports": ["./sources/map-features"], + "exports": [ + "./sources/map-features" + ], "modules": [ "src/sources/featureGeometry.js", "src/sources/overpassFeaturesRecords.js", @@ -3054,7 +3430,9 @@ "external": [] }, "perimeters": { - "exports": ["./layers/perimeters"], + "exports": [ + "./layers/perimeters" + ], "modules": [ "src/layers/perimeters/index.js", "src/sources/httpBody.js", @@ -3063,10 +3441,14 @@ "src/layers/perimeters/cards.js", "src/layers/perimeters/inciweb.js" ], - "external": ["cesium"] + "external": [ + "cesium" + ] }, "perimeters-source": { - "exports": ["./layers/perimeters/source"], + "exports": [ + "./layers/perimeters/source" + ], "modules": [ "src/layers/perimeters/records.js", "src/sources/httpBody.js", @@ -3075,13 +3457,19 @@ "external": [] }, "agent-world-packet": { - "exports": ["./agent/world-packet"], + "exports": [ + "./agent/world-packet" + ], "modules": [ "src/agent/worldPacket.js", "src/data/layerSnapshot.js", "src/data/feedState.js", - "src/contextModePolicy.js" + "src/contextModePolicy.js", + "src/loadingFeedback.js", + "src/data/installationFeedback.js" ], - "external": ["cesium"] + "external": [ + "cesium" + ] } } From cbd3b13afb022cf1904dd08e809d58167f6e7340 Mon Sep 17 00:00:00 2001 From: Kevin Rajan <7121943+kvnloo@users.noreply.github.com> Date: Wed, 30 Sep 2026 20:58:20 -0500 Subject: [PATCH 15/60] chore: add one-shot branch formatter --- .github/workflows/tmp-format-fix.yml | 37 ++++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) create mode 100644 .github/workflows/tmp-format-fix.yml diff --git a/.github/workflows/tmp-format-fix.yml b/.github/workflows/tmp-format-fix.yml new file mode 100644 index 000000000..73af025ad --- /dev/null +++ b/.github/workflows/tmp-format-fix.yml @@ -0,0 +1,37 @@ +name: Temporary branch formatter + +on: + push: + branches: + - feat/world-packet-boundary-clean-20260929 + +permissions: + contents: write + +jobs: + format: + if: github.actor != 'github-actions[bot]' + runs-on: ubuntu-latest + env: + PUPPETEER_SKIP_DOWNLOAD: 1 + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 + with: + ref: feat/world-packet-boundary-clean-20260929 + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 + with: + node-version: 24.14.0 + cache: npm + - run: npm ci + - run: npm run format + - name: Commit formatting + shell: bash + run: | + if git diff --quiet; then + exit 0 + fi + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git add -u + git commit -m "style: apply adopted formatting" + git push origin HEAD:feat/world-packet-boundary-clean-20260929 From 914df05346d50adf13abd3337e0ab1d65daf19b0 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 1 Oct 2026 01:59:00 +0000 Subject: [PATCH 16/60] style: apply adopted formatting --- scripts/package-boundaries.json | 742 ++++++++------------------------ 1 file changed, 178 insertions(+), 564 deletions(-) diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index 260179257..fa4f872a4 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -12,50 +12,27 @@ "src/data/inputOwnership.js", "src/data/infrastructureOverlayEntry.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", - "src/app/atmosphereCompat.js" - ], - "external": [ - "cesium" - ] + "exports": ["./application/viewer"], + "modules": ["src/app/viewer.js", "src/app/atmosphereCompat.js"], + "external": ["cesium"] }, "vite-build": { "runtime": "node", - "exports": [ - "./build/vite", - "./build/html" - ], - "modules": [ - "build/vite.js", - "build/application-html.js" - ], - "external": [ - "vite-plugin-cesium" - ] + "exports": ["./build/vite", "./build/html"], + "modules": ["build/vite.js", "build/application-html.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", @@ -71,24 +48,16 @@ "src/data/aisWatchdog.js", "src/sources/httpBody.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", @@ -106,19 +75,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", @@ -130,19 +93,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" @@ -150,59 +107,35 @@ "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-csv": { - "exports": [ - "./sources/firms-csv" - ], - "modules": [ - "src/data/firmsCsv.js" - ], + "exports": ["./sources/firms-csv"], + "modules": ["src/data/firmsCsv.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" - ], + "exports": ["./server/providers/gbfs"], "modules": [ "server/providers/gbfs.js", "server/providers/common/http.js", @@ -212,19 +145,13 @@ "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/cap.js", @@ -249,9 +176,7 @@ }, "radio-provider": { "runtime": "node", - "exports": [ - "./server/providers/radio" - ], + "exports": ["./server/providers/radio"], "modules": [ "server/providers/radio.js", "server/providers/radio/catalog.js", @@ -267,9 +192,7 @@ }, "overpass-provider": { "runtime": "node", - "exports": [ - "./server/providers/overpass" - ], + "exports": ["./server/providers/overpass"], "modules": [ "server/providers/common/geo.js", "server/providers/common/http.js", @@ -292,9 +215,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", @@ -313,9 +234,7 @@ }, "regional-provider": { "runtime": "node", - "exports": [ - "./server/providers/regional" - ], + "exports": ["./server/providers/regional"], "modules": [ "server/providers/common/http.js", "server/providers/common/query.js", @@ -342,9 +261,7 @@ }, "openai-provider": { "runtime": "node", - "exports": [ - "./server/providers/openai" - ], + "exports": ["./server/providers/openai"], "modules": [ "src/hudLayoutPolicy.js", "server/providers/common/rate-limit.js", @@ -372,9 +289,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", @@ -385,9 +300,7 @@ "external": [] }, "place-search": { - "exports": [ - "./search" - ], + "exports": ["./search"], "modules": [ "src/search/index.js", "src/search/placeSearch.js", @@ -408,27 +321,17 @@ "external": [] }, "panel-disclosure": { - "exports": [ - "./ui/panels" - ], - "modules": [ - "src/ui/panelDisclosure.js" - ], + "exports": ["./ui/panels"], + "modules": ["src/ui/panelDisclosure.js"], "external": [] }, "surface-keyboard": { - "exports": [ - "./ui/surfaces" - ], - "modules": [ - "src/ui/surfaceKeyboard.js" - ], + "exports": ["./ui/surfaces"], + "modules": ["src/ui/surfaceKeyboard.js"], "external": [] }, "panel-rail-layout": { - "exports": [ - "./ui/layout" - ], + "exports": ["./ui/layout"], "modules": [ "src/ui/panelRails.js", "src/ui/displayPanelScroll.js", @@ -441,9 +344,7 @@ "external": [] }, "visual-input": { - "exports": [ - "./ui/input" - ], + "exports": ["./ui/input"], "modules": [ "src/ui/visualInput.js", "src/ui/applicationShortcuts.js", @@ -452,19 +353,12 @@ "external": [] }, "display-controls": { - "exports": [ - "./ui/display" - ], - "modules": [ - "src/ui/displayControls.js" - ], + "exports": ["./ui/display"], + "modules": ["src/ui/displayControls.js"], "external": [] }, "visual-effects": { - "exports": [ - "./ui/effects", - "./ui/effects/bloom" - ], + "exports": ["./ui/effects", "./ui/effects/bloom"], "modules": [ "src/ui/effects.js", "src/ui/visualEffects.js", @@ -477,14 +371,10 @@ "src/styles/noir.js", "src/styles/snow.js" ], - "external": [ - "cesium" - ] + "external": ["cesium"] }, "map-source-controls": { - "exports": [ - "./ui/maps" - ], + "exports": ["./ui/maps"], "modules": [ "src/ui/mapSource.js", "src/ui/mapSourceControls.js", @@ -494,10 +384,7 @@ "external": [] }, "layer-panel": { - "exports": [ - "./ui/layers", - "./ui/layers/feedback" - ], + "exports": ["./ui/layers", "./ui/layers/feedback"], "modules": [ "src/ui/layers.js", "src/ui/layerPanel.js", @@ -517,9 +404,7 @@ "external": [] }, "location-controls": { - "exports": [ - "./ui/location" - ], + "exports": ["./ui/location"], "modules": [ "src/ui/location.js", "src/ui/locationControls.js", @@ -530,9 +415,7 @@ "external": [] }, "radio-controls": { - "exports": [ - "./ui/radio" - ], + "exports": ["./ui/radio"], "modules": [ "src/ui/radio.js", "src/ui/radioControls.js", @@ -543,9 +426,7 @@ "external": [] }, "cctv-controls": { - "exports": [ - "./ui/cctv" - ], + "exports": ["./ui/cctv"], "modules": [ "src/ui/cctv.js", "src/ui/cctvBindings.js", @@ -558,10 +439,7 @@ "external": [] }, "context-controls": { - "exports": [ - "./ui/context", - "./ui/context/policy" - ], + "exports": ["./ui/context", "./ui/context/policy"], "modules": [ "src/ui/context.js", "src/ui/contextActions.js", @@ -604,14 +482,10 @@ "src/cockpitVisionPolicy.js", "src/ui/panelRailGeometry.js" ], - "external": [ - "cesium" - ] + "external": ["cesium"] }, "scene-controls": { - "exports": [ - "./ui/scenes" - ], + "exports": ["./ui/scenes"], "modules": [ "src/ui/scenes.js", "src/ui/sceneControls.js", @@ -620,9 +494,7 @@ "external": [] }, "ui-shell": { - "exports": [ - "./ui/shell" - ], + "exports": ["./ui/shell"], "modules": [ "src/hudLayouts.js", "src/hudLayoutPolicy.js", @@ -752,14 +624,10 @@ "src/ui/localSdrPresentation.js", "src/data/cameraArrival.js" ], - "external": [ - "cesium" - ] + "external": ["cesium"] }, "ui-styles": { - "exports": [ - "./ui/styles" - ], + "exports": ["./ui/styles"], "modules": [ "src/ui/styles.js", "style.css", @@ -789,9 +657,7 @@ "external": [] }, "director": { - "exports": [ - "./director" - ], + "exports": ["./director"], "modules": [ "src/director/playback.js", "src/director/clock.js", @@ -814,9 +680,7 @@ "external": [] }, "scenes": { - "exports": [ - "./scenes" - ], + "exports": ["./scenes"], "modules": [ "src/scenes/nepalEvidencePack.js", "src/data/bhoteKoshiIncidentPlaces.js", @@ -865,14 +729,10 @@ "src/ui/sceneSharing.js", "src/data/cameraArrival.js" ], - "external": [ - "cesium" - ] + "external": ["cesium"] }, "live-observations": { - "exports": [ - "./sources/live" - ], + "exports": ["./sources/live"], "modules": [ "src/sources/live/aircraft.js", "src/sources/live/contract.js", @@ -883,9 +743,7 @@ "external": [] }, "civil-flights": { - "exports": [ - "./layers/flights" - ], + "exports": ["./layers/flights"], "modules": [ "src/cyberSonar.js", "src/data/aircraftClass.js", @@ -924,14 +782,10 @@ "src/data/inputOwnership.js", "src/data/modelEligibility.js" ], - "external": [ - "cesium" - ] + "external": ["cesium"] }, "military-flights": { - "exports": [ - "./layers/military" - ], + "exports": ["./layers/military"], "modules": [ "src/cyberSonar.js", "src/data/aircraftClass.js", @@ -966,14 +820,10 @@ "src/data/inputOwnership.js", "src/data/modelEligibility.js" ], - "external": [ - "cesium" - ] + "external": ["cesium"] }, "aircraft-mechanics": { - "exports": [ - "./aircraft" - ], + "exports": ["./aircraft"], "modules": [ "src/data/aircraftClass.js", "src/data/aircraftIcons.js", @@ -995,14 +845,10 @@ "src/layers/aircraft/index.js", "src/data/inputOwnership.js" ], - "external": [ - "cesium" - ] + "external": ["cesium"] }, "vessel-layer": { - "exports": [ - "./layers/vessels" - ], + "exports": ["./layers/vessels"], "modules": [ "src/layers/vessels/cards.js", "src/layers/vessels/evidence.js", @@ -1023,28 +869,20 @@ "src/layers/vessels/snapshotRenderer.js", "src/data/inputOwnership.js" ], - "external": [ - "cesium" - ] + "external": ["cesium"] }, "earthquakes": { - "exports": [ - "./layers/earthquakes" - ], + "exports": ["./layers/earthquakes"], "modules": [ "src/layers/earthquakes/index.js", "src/layers/earthquakes/model.js", "src/layers/earthquakes/source.js", "src/layers/earthquakes/records.js" ], - "external": [ - "cesium" - ] + "external": ["cesium"] }, "recent-imagery": { - "exports": [ - "./layers/recent-imagery" - ], + "exports": ["./layers/recent-imagery"], "modules": [ "src/layers/recentImagery/index.js", "src/layers/recentImagery/model.js", @@ -1053,14 +891,10 @@ "src/layers/recentImagery/thumbnails.js", "src/maps/imageryHost.js" ], - "external": [ - "cesium" - ] + "external": ["cesium"] }, "fire-layer": { - "exports": [ - "./layers/firms" - ], + "exports": ["./layers/firms"], "modules": [ "src/data/firmsAdapt.js", "src/data/firmsLabels.js", @@ -1080,14 +914,10 @@ "src/layers/firms/viewport.js", "src/data/inputOwnership.js" ], - "external": [ - "cesium" - ] + "external": ["cesium"] }, "satellites-layer": { - "exports": [ - "./layers/satellites" - ], + "exports": ["./layers/satellites"], "modules": [ "src/data/issPass.js", "src/data/satelliteClass.js", @@ -1109,15 +939,10 @@ "src/layers/satellites/tracking.js", "src/data/inputOwnership.js" ], - "external": [ - "cesium", - "satellite.js" - ] + "external": ["cesium", "satellite.js"] }, "launches-layer": { - "exports": [ - "./layers/launches" - ], + "exports": ["./layers/launches"], "modules": [ "src/cyberSonar.js", "src/layers/launches/camera.js", @@ -1141,14 +966,10 @@ "src/layers/launches/testing.js", "src/data/inputOwnership.js" ], - "external": [ - "cesium" - ] + "external": ["cesium"] }, "installations-layer": { - "exports": [ - "./layers/installations" - ], + "exports": ["./layers/installations"], "modules": [ "src/data/cameraSensitivity.js", "src/data/installationFeedback.js", @@ -1180,16 +1001,10 @@ "src/layers/installations/namedMarkers.js", "src/data/infrastructureOverlayEntry.js" ], - "external": [ - "cesium", - "@mapbox/vector-tile", - "pbf" - ] + "external": ["cesium", "@mapbox/vector-tile", "pbf"] }, "awareness-layer": { - "exports": [ - "./layers/awareness" - ], + "exports": ["./layers/awareness"], "modules": [ "src/cyberSonar.js", "src/cockpitMath.js", @@ -1213,14 +1028,10 @@ "src/layers/awareness/testing.js", "src/ui/panelRailGeometry.js" ], - "external": [ - "cesium" - ] + "external": ["cesium"] }, "traffic-layer": { - "exports": [ - "./layers/traffic" - ], + "exports": ["./layers/traffic"], "modules": [ "src/data/cameraSensitivity.js", "src/data/flowMatch.js", @@ -1258,16 +1069,10 @@ "src/sources/phaseTiming.js", "src/layers/traffic/roadModes.js" ], - "external": [ - "cesium", - "pbf", - "@mapbox/vector-tile" - ] + "external": ["cesium", "pbf", "@mapbox/vector-tile"] }, "bikeshare-layer": { - "exports": [ - "./layers/bikeshare" - ], + "exports": ["./layers/bikeshare"], "modules": [ "src/layers/bikeshare/controls.js", "src/layers/bikeshare/index.js", @@ -1286,14 +1091,10 @@ "src/data/inputOwnership.js", "src/data/cameraSensitivity.js" ], - "external": [ - "cesium" - ] + "external": ["cesium"] }, "cctv-layer": { - "exports": [ - "./layers/cctv" - ], + "exports": ["./layers/cctv"], "modules": [ "src/data/cctvCards.js", "src/data/cctvGizmo.js", @@ -1331,15 +1132,10 @@ "src/data/inputOwnership.js", "src/layers/cctv/sourcePolicy.js" ], - "external": [ - "hls.js", - "cesium" - ] + "external": ["hls.js", "cesium"] }, "alpr-cameras": { - "exports": [ - "./layers/alpr" - ], + "exports": ["./layers/alpr"], "modules": [ "src/layers/alpr/index.js", "src/layers/alpr/model.js", @@ -1358,16 +1154,10 @@ "src/sources/phaseTiming.js", "src/layers/alpr/floors.js" ], - "external": [ - "cesium", - "@mapbox/vector-tile", - "pbf" - ] + "external": ["cesium", "@mapbox/vector-tile", "pbf"] }, "radio-layer": { - "exports": [ - "./layers/radio" - ], + "exports": ["./layers/radio"], "modules": [ "src/data/iconOrientation.js", "src/data/layerState.js", @@ -1398,14 +1188,10 @@ "src/ui/radioTunerModel.js", "src/data/inputOwnership.js" ], - "external": [ - "cesium" - ] + "external": ["cesium"] }, "submarine-cables": { - "exports": [ - "./layers/submarine-cables" - ], + "exports": ["./layers/submarine-cables"], "modules": [ "src/layers/submarineCables/geometry.js", "src/layers/submarineCables/index.js", @@ -1419,14 +1205,10 @@ "src/layers/submarineCables/surface.js", "src/data/inputOwnership.js" ], - "external": [ - "cesium" - ] + "external": ["cesium"] }, "bundled-cables": { - "exports": [ - "./sources/bundled-cables" - ], + "exports": ["./sources/bundled-cables"], "modules": [ "src/layers/submarineCables/bundledSource.js", "src/data/local_data/telegeography_submarine_cables/cable-geo.json", @@ -1435,10 +1217,7 @@ "external": [] }, "geography": { - "exports": [ - "./geography/natural-earth", - "./geography/neighborhoods" - ], + "exports": ["./geography/natural-earth", "./geography/neighborhoods"], "modules": [ "src/data/naturalEarthRegions.js", "src/data/neighborhoodPolygons.js", @@ -1474,9 +1253,7 @@ "src/keySetupCore.mjs", "src/maps/imageryHost.js" ], - "external": [ - "cesium" - ] + "external": ["cesium"] }, "voice-connection": { "exports": [ @@ -2265,18 +2042,12 @@ ] }, "application-catalog": { - "exports": [ - "./application/catalog" - ], - "modules": [ - "src/app/catalog.js" - ], + "exports": ["./application/catalog"], + "modules": ["src/app/catalog.js"], "external": [] }, "application-layer-construction": { - "exports": [ - "./application/layers" - ], + "exports": ["./application/layers"], "modules": [ "src/cyberSonar.js", "src/data/bhoteKoshiEvent.js", @@ -2755,9 +2526,7 @@ ] }, "application-operations": { - "exports": [ - "./application/operations" - ], + "exports": ["./application/operations"], "modules": [ "src/annotations/annotationResolver.js", "src/annotations/resolver.js", @@ -2818,33 +2587,22 @@ ] }, "layer-lifecycle": { - "exports": [ - "./data/lifecycle" - ], - "modules": [ - "src/data/lifecycle.js", - "src/data/cameraArrival.js" - ], + "exports": ["./data/lifecycle"], + "modules": ["src/data/lifecycle.js", "src/data/cameraArrival.js"], "external": [] }, "ui-navigation": { - "exports": [ - "./ui/navigation" - ], + "exports": ["./ui/navigation"], "modules": [ "src/navigationPolicy.js", "src/ui/navigationController.js", "src/app/stateChannel.js", "src/data/cameraArrival.js" ], - "external": [ - "cesium" - ] + "external": ["cesium"] }, "ui-share-restoration": { - "exports": [ - "./ui/share-restoration" - ], + "exports": ["./ui/share-restoration"], "modules": [ "src/data/installationFeedback.js", "src/data/layerState.js", @@ -2857,9 +2615,7 @@ "external": [] }, "ui-visual-settings": { - "exports": [ - "./ui/visual-settings" - ], + "exports": ["./ui/visual-settings"], "modules": [ "src/cyberSonar.js", "src/hudLayouts.js", @@ -2884,14 +2640,10 @@ "src/ui/visualPresets.js", "src/ui/visualSettings.js" ], - "external": [ - "cesium" - ] + "external": ["cesium"] }, "ui-panel-chrome": { - "exports": [ - "./ui/panel-chrome" - ], + "exports": ["./ui/panel-chrome"], "modules": [ "src/ui/displayPanelScroll.js", "src/ui/radioPanelPlacement.js", @@ -2910,9 +2662,7 @@ "external": [] }, "civil-flight-records": { - "exports": [ - "./layers/flights/records" - ], + "exports": ["./layers/flights/records"], "modules": [ "src/layers/flights/records.js", "src/layers/flights/recordPolicy.js", @@ -2923,9 +2673,7 @@ "external": [] }, "civil-flight-ingestion": { - "exports": [ - "./layers/flights/ingestion" - ], + "exports": ["./layers/flights/ingestion"], "modules": [ "src/layers/flights/ingestion.js", "src/layers/flights/recordPolicy.js" @@ -2933,9 +2681,7 @@ "external": [] }, "military-flight-records": { - "exports": [ - "./layers/military/records" - ], + "exports": ["./layers/military/records"], "modules": [ "src/layers/military/records.js", "src/layers/military/recordPolicy.js", @@ -2946,9 +2692,7 @@ "external": [] }, "military-flight-ingestion": { - "exports": [ - "./layers/military/ingestion" - ], + "exports": ["./layers/military/ingestion"], "modules": [ "src/layers/military/ingestion.js", "src/layers/military/recordPolicy.js" @@ -2956,9 +2700,7 @@ "external": [] }, "vessel-records": { - "exports": [ - "./layers/vessels/records" - ], + "exports": ["./layers/vessels/records"], "modules": [ "src/layers/vessels/records.js", "src/layers/vessels/recordPolicy.js" @@ -2966,9 +2708,7 @@ "external": [] }, "vessel-ingestion": { - "exports": [ - "./layers/vessels/ingestion" - ], + "exports": ["./layers/vessels/ingestion"], "modules": [ "src/layers/vessels/ingestion.js", "src/layers/vessels/recordPolicy.js" @@ -2976,68 +2716,37 @@ "external": [] }, "directions-layer": { - "exports": [ - "./layers/directions" - ], - "modules": [ - "src/layers/directions/index.js", - "src/data/routeSteps.js" - ], - "external": [ - "cesium" - ] + "exports": ["./layers/directions"], + "modules": ["src/layers/directions/index.js", "src/data/routeSteps.js"], + "external": ["cesium"] }, "radio-browser-source": { - "exports": [ - "./sources/radio-browser" - ], - "modules": [ - "src/sources/radioBrowser.js", - "src/data/radioCountry.js" - ], + "exports": ["./sources/radio-browser"], + "modules": ["src/sources/radioBrowser.js", "src/data/radioCountry.js"], "external": [] }, "cctv-types-source": { - "exports": [ - "./sources/cctv-types" - ], - "modules": [ - "src/sources/cctvTypes.js" - ], + "exports": ["./sources/cctv-types"], + "modules": ["src/sources/cctvTypes.js"], "external": [] }, "regional-source": { - "exports": [ - "./sources/regional" - ], - "modules": [ - "src/data/regionalModel.js" - ], + "exports": ["./sources/regional"], + "modules": ["src/data/regionalModel.js"], "external": [] }, "voice-action-schemas": { - "exports": [ - "./voice/action-schemas" - ], - "modules": [ - "src/voice/actionSchemas.js", - "src/hudLayoutPolicy.js" - ], + "exports": ["./voice/action-schemas"], + "modules": ["src/voice/actionSchemas.js", "src/hudLayoutPolicy.js"], "external": [] }, "voice-session": { - "exports": [ - "./voice/session" - ], - "modules": [ - "src/voice/session.js" - ], + "exports": ["./voice/session"], + "modules": ["src/voice/session.js"], "external": [] }, "voice-session-controls": { - "exports": [ - "./voice/session-controls" - ], + "exports": ["./voice/session-controls"], "modules": [ "src/voice/sessionCommands.js", "src/voice/session.js", @@ -3046,9 +2755,7 @@ "external": [] }, "alpr-source": { - "exports": [ - "./layers/alpr/source" - ], + "exports": ["./layers/alpr/source"], "modules": [ "src/layers/alpr/policy.js", "src/layers/alpr/records.js", @@ -3059,34 +2766,20 @@ "src/sources/vectorTiles.js", "src/sources/phaseTiming.js" ], - "external": [ - "@mapbox/vector-tile", - "pbf" - ] + "external": ["@mapbox/vector-tile", "pbf"] }, "bikeshare-source": { - "exports": [ - "./layers/bikeshare/source" - ], - "modules": [ - "src/layers/bikeshare/source.js" - ], + "exports": ["./layers/bikeshare/source"], + "modules": ["src/layers/bikeshare/source.js"], "external": [] }, "cctv-source": { - "exports": [ - "./layers/cctv/source" - ], - "modules": [ - "src/layers/cctv/source.js", - "src/layers/cctv/sourcePolicy.js" - ], + "exports": ["./layers/cctv/source"], + "modules": ["src/layers/cctv/source.js", "src/layers/cctv/sourcePolicy.js"], "external": [] }, "earthquakes-source": { - "exports": [ - "./layers/earthquakes/source" - ], + "exports": ["./layers/earthquakes/source"], "modules": [ "src/layers/earthquakes/records.js", "src/layers/earthquakes/source.js" @@ -3094,18 +2787,12 @@ "external": [] }, "firms-source": { - "exports": [ - "./layers/firms/source" - ], - "modules": [ - "src/layers/firms/source.js" - ], + "exports": ["./layers/firms/source"], + "modules": ["src/layers/firms/source.js"], "external": [] }, "installations-source": { - "exports": [ - "./layers/installations/source" - ], + "exports": ["./layers/installations/source"], "modules": [ "src/layers/installations/source.js", "src/data/militaryInstallationData.js", @@ -3122,43 +2809,25 @@ "src/data/bundledJson.js", "src/data/retryableLoad.js" ], - "external": [ - "@mapbox/vector-tile", - "pbf" - ] + "external": ["@mapbox/vector-tile", "pbf"] }, "launches-source": { - "exports": [ - "./layers/launches/source" - ], - "modules": [ - "src/layers/launches/source.js" - ], + "exports": ["./layers/launches/source"], + "modules": ["src/layers/launches/source.js"], "external": [] }, "radio-source": { - "exports": [ - "./layers/radio/source" - ], - "modules": [ - "src/layers/radio/policy.js", - "src/layers/radio/source.js" - ], + "exports": ["./layers/radio/source"], + "modules": ["src/layers/radio/policy.js", "src/layers/radio/source.js"], "external": [] }, "satellites-source": { - "exports": [ - "./layers/satellites/source" - ], - "modules": [ - "src/layers/satellites/source.js" - ], + "exports": ["./layers/satellites/source"], + "modules": ["src/layers/satellites/source.js"], "external": [] }, "traffic-source-factory": { - "exports": [ - "./layers/traffic/source" - ], + "exports": ["./layers/traffic/source"], "modules": [ "src/data/tomtomTiles.js", "src/layers/traffic/flowDecode.js", @@ -3174,16 +2843,10 @@ "src/layers/traffic/roadModes.js", "src/data/flowMatch.js" ], - "external": [ - "@mapbox/vector-tile", - "pbf" - ] + "external": ["@mapbox/vector-tile", "pbf"] }, "reference-sources": { - "exports": [ - "./sources/reference", - "./layers/submarine-cables/source" - ], + "exports": ["./sources/reference", "./layers/submarine-cables/source"], "modules": [ "src/sources/reference.js", "src/layers/earthquakes/source.js", @@ -3196,9 +2859,7 @@ "external": [] }, "nominatim-provider": { - "exports": [ - "./search/nominatim" - ], + "exports": ["./search/nominatim"], "modules": [ "src/search/nominatim.js", "src/nominatimGeocode.js", @@ -3211,27 +2872,17 @@ "external": [] }, "source-http-body": { - "exports": [ - "./sources/http-body" - ], - "modules": [ - "src/sources/httpBody.js" - ], + "exports": ["./sources/http-body"], + "modules": ["src/sources/httpBody.js"], "external": [] }, "overpass-lexer": { - "exports": [ - "./sources/overpass" - ], - "modules": [ - "src/sources/overpass.js" - ], + "exports": ["./sources/overpass"], + "modules": ["src/sources/overpass.js"], "external": [] }, "nominatim-source": { - "exports": [ - "./sources/nominatim" - ], + "exports": ["./sources/nominatim"], "modules": [ "src/sources/nominatim.js", "src/nominatimGeocode.js", @@ -3243,9 +2894,7 @@ }, "transit-provider": { "runtime": "node", - "exports": [ - "./server/providers/transit" - ], + "exports": ["./server/providers/transit"], "modules": [ "server/providers/common/http.js", "server/providers/common/rate-limit.js", @@ -3260,28 +2909,20 @@ "src/sources/transitHistoryStore.js", "src/sources/rateLimit.js" ], - "external": [ - "pbf" - ] + "external": ["pbf"] }, "transit-source": { - "exports": [ - "./sources/transit" - ], + "exports": ["./sources/transit"], "modules": [ "src/data/gtfsRealtime.js", "src/data/transitFeeds.js", "src/data/transitProxy.js", "src/sources/transitHistory.js" ], - "external": [ - "pbf" - ] + "external": ["pbf"] }, "transit-standalone": { - "exports": [ - "./layers/transit/standalone" - ], + "exports": ["./layers/transit/standalone"], "modules": [ "src/cyberSonar.js", "src/app/sourceSlot.js", @@ -3340,16 +2981,10 @@ "src/sources/featureSource.js", "src/sources/capability.js" ], - "external": [ - "cesium", - "egm96-universal", - "pbf" - ] + "external": ["cesium", "egm96-universal", "pbf"] }, "transit-layer": { - "exports": [ - "./layers/transit" - ], + "exports": ["./layers/transit"], "modules": [ "src/data/iconOrientation.js", "src/data/inputOwnership.js", @@ -3383,15 +3018,10 @@ "src/layers/transit/source.js", "src/sources/transitHistory.js" ], - "external": [ - "cesium", - "pbf" - ] + "external": ["cesium", "pbf"] }, "transit-source-factory": { - "exports": [ - "./layers/transit/source" - ], + "exports": ["./layers/transit/source"], "modules": [ "src/layers/transit/source.js", "src/sources/transitHistory.js" @@ -3399,9 +3029,7 @@ "external": [] }, "transit-service": { - "exports": [ - "./sources/transit-service" - ], + "exports": ["./sources/transit-service"], "modules": [ "src/data/transitFeeds.js", "src/data/transitProxy.js", @@ -3412,14 +3040,10 @@ "src/sources/transitHistoryStore.js", "src/sources/rateLimit.js" ], - "external": [ - "pbf" - ] + "external": ["pbf"] }, "map-feature-source": { - "exports": [ - "./sources/map-features" - ], + "exports": ["./sources/map-features"], "modules": [ "src/sources/featureGeometry.js", "src/sources/overpassFeaturesRecords.js", @@ -3430,9 +3054,7 @@ "external": [] }, "perimeters": { - "exports": [ - "./layers/perimeters" - ], + "exports": ["./layers/perimeters"], "modules": [ "src/layers/perimeters/index.js", "src/sources/httpBody.js", @@ -3441,14 +3063,10 @@ "src/layers/perimeters/cards.js", "src/layers/perimeters/inciweb.js" ], - "external": [ - "cesium" - ] + "external": ["cesium"] }, "perimeters-source": { - "exports": [ - "./layers/perimeters/source" - ], + "exports": ["./layers/perimeters/source"], "modules": [ "src/layers/perimeters/records.js", "src/sources/httpBody.js", @@ -3457,9 +3075,7 @@ "external": [] }, "agent-world-packet": { - "exports": [ - "./agent/world-packet" - ], + "exports": ["./agent/world-packet"], "modules": [ "src/agent/worldPacket.js", "src/data/layerSnapshot.js", @@ -3468,8 +3084,6 @@ "src/loadingFeedback.js", "src/data/installationFeedback.js" ], - "external": [ - "cesium" - ] + "external": ["cesium"] } } From abea3f2feb99c14a64e5c1884292411b044a0bb2 Mon Sep 17 00:00:00 2001 From: Kevin Rajan <7121943+kvnloo@users.noreply.github.com> Date: Wed, 30 Sep 2026 20:59:48 -0500 Subject: [PATCH 17/60] chore: remove one-shot branch formatter --- .github/workflows/tmp-format-fix.yml | 37 ---------------------------- 1 file changed, 37 deletions(-) delete mode 100644 .github/workflows/tmp-format-fix.yml diff --git a/.github/workflows/tmp-format-fix.yml b/.github/workflows/tmp-format-fix.yml deleted file mode 100644 index 73af025ad..000000000 --- a/.github/workflows/tmp-format-fix.yml +++ /dev/null @@ -1,37 +0,0 @@ -name: Temporary branch formatter - -on: - push: - branches: - - feat/world-packet-boundary-clean-20260929 - -permissions: - contents: write - -jobs: - format: - if: github.actor != 'github-actions[bot]' - runs-on: ubuntu-latest - env: - PUPPETEER_SKIP_DOWNLOAD: 1 - steps: - - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 - with: - ref: feat/world-packet-boundary-clean-20260929 - - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 - with: - node-version: 24.14.0 - cache: npm - - run: npm ci - - run: npm run format - - name: Commit formatting - shell: bash - run: | - if git diff --quiet; then - exit 0 - fi - git config user.name "github-actions[bot]" - git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - git add -u - git commit -m "style: apply adopted formatting" - git push origin HEAD:feat/world-packet-boundary-clean-20260929 From a194da21dd6f1fdb4d41170e560e759f5244cf16 Mon Sep 17 00:00:00 2001 From: Kevin Rajan <7121943+kvnloo@users.noreply.github.com> Date: Thu, 1 Oct 2026 00:52:08 -0500 Subject: [PATCH 18/60] chore: add one-shot downstream format fixer --- .github/workflows/downstream-format-fix.yml | 37 +++++++++++++++++++++ 1 file changed, 37 insertions(+) create mode 100644 .github/workflows/downstream-format-fix.yml diff --git a/.github/workflows/downstream-format-fix.yml b/.github/workflows/downstream-format-fix.yml new file mode 100644 index 000000000..40b244d2a --- /dev/null +++ b/.github/workflows/downstream-format-fix.yml @@ -0,0 +1,37 @@ +name: Downstream format fix + +on: + push: + branches: + - feat/world-packet-boundary-clean-20260929 + +permissions: + contents: write + +jobs: + format: + if: ${{ github.actor != 'github-actions[bot]' }} + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + with: + ref: feat/world-packet-boundary-clean-20260929 + - uses: actions/setup-node@v5 + with: + node-version: 24.14.0 + cache: npm + - run: npm ci + env: + PUPPETEER_SKIP_DOWNLOAD: 1 + - run: npm run format + - name: Commit formatting if needed + shell: bash + run: | + if git diff --quiet; then + exit 0 + fi + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git add -A + git commit -m "chore: apply adopted formatting" + git push From 2b7f832f456c933811c8dc0faa75caf111816718 Mon Sep 17 00:00:00 2001 From: Kevin Rajan <7121943+kvnloo@users.noreply.github.com> Date: Thu, 1 Oct 2026 00:52:50 -0500 Subject: [PATCH 19/60] chore: remove one-shot downstream format fixer --- .github/workflows/downstream-format-fix.yml | 37 --------------------- 1 file changed, 37 deletions(-) delete mode 100644 .github/workflows/downstream-format-fix.yml diff --git a/.github/workflows/downstream-format-fix.yml b/.github/workflows/downstream-format-fix.yml deleted file mode 100644 index 40b244d2a..000000000 --- a/.github/workflows/downstream-format-fix.yml +++ /dev/null @@ -1,37 +0,0 @@ -name: Downstream format fix - -on: - push: - branches: - - feat/world-packet-boundary-clean-20260929 - -permissions: - contents: write - -jobs: - format: - if: ${{ github.actor != 'github-actions[bot]' }} - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v5 - with: - ref: feat/world-packet-boundary-clean-20260929 - - uses: actions/setup-node@v5 - with: - node-version: 24.14.0 - cache: npm - - run: npm ci - env: - PUPPETEER_SKIP_DOWNLOAD: 1 - - run: npm run format - - name: Commit formatting if needed - shell: bash - run: | - if git diff --quiet; then - exit 0 - fi - git config user.name "github-actions[bot]" - git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - git add -A - git commit -m "chore: apply adopted formatting" - git push From ff799d1520087c01c6b9f3bd88bf2e3a4d6064cc Mon Sep 17 00:00:00 2001 From: Sunil Date: Thu, 8 Oct 2026 21:13:16 +0530 Subject: [PATCH 20/60] fix(server): refuse cross-site requests on the terrain-heights route Follow-up to #686 requested on #923. The route's per-client limiter keys on the socket address, which for a cross-site is the visitor's own browser, so any web page could fill the bounded cache with junk keys and spend the visitor's 90/min quota, leaving the app's own terrain lookups on 429. Gate the route with admitSameSite before the limiter, like the Places routes. Co-authored-by: daikaginza --- scripts/package-boundaries.json | 4 ++- server/providers/terrain.js | 9 +++++- src/proxyErrorResponses.test.mjs | 3 ++ src/terrainHeightsBounds.test.mjs | 46 +++++++++++++++++++++++++++++-- 4 files changed, 58 insertions(+), 4 deletions(-) diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index e1af1ed50..610986341 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -113,7 +113,9 @@ "server/providers/terrain.js", "src/data/terrainHeightsProxy.js", "server/providers/common/rate-limit.js", - "src/sources/rateLimit.js" + "src/sources/rateLimit.js", + "server/providers/common/same-site.js", + "src/localRequestGate.mjs" ], "external": [] }, diff --git a/server/providers/terrain.js b/server/providers/terrain.js index ca9442db1..b2615094e 100644 --- a/server/providers/terrain.js +++ b/server/providers/terrain.js @@ -9,6 +9,7 @@ import { validTerrainResult, } from '../../src/data/terrainHeightsProxy.js'; import { makeRateLimiter, clientKey } from './common/rate-limit.js'; +import { admitSameSite } from './common/same-site.js'; /** * Re:Earth terrain point-height proxy: batched lon/lat → ellipsoidal height @@ -25,7 +26,9 @@ import { makeRateLimiter, clientKey } from './common/rate-limit.js'; * Bounds, because every distinct 5dp point is its own key: coordinates must be * in WGS-84 range (rejected at parse), the point cache has an entry ceiling * with oldest-first eviction, an over-large cache file is not read back, and - * the route carries an always-on per-client limiter. + * the route carries an always-on per-client limiter. The route also refuses + * cross-site browser requests, so another web page cannot use the visitor's + * browser to fill the cache or spend the visitor's own rate-limit quota. */ export function terrainHeightsProxy(options = {}) { // Destructured in the body, not the signature: `proxyErrorResponses.test.mjs` @@ -231,6 +234,10 @@ export function terrainHeightsProxy(options = {}) { const installMiddleware = (server) => { server.middlewares.use('/api/terrain/heights', async (req, res) => { + // Refuse cross-site browser requests (an or fetch from another + // site) before the limiter, cache or upstream see them. Same-origin app + // requests and non-browser loopback callers (MCP panel_request) pass. + if (admitSameSite(req, res)) return; const send = (status, bodyObj) => { if (res.headersSent) return; res.writeHead(status, { 'Content-Type': 'application/json' }); diff --git a/src/proxyErrorResponses.test.mjs b/src/proxyErrorResponses.test.mjs index 9cc1ff9c7..35845ee88 100644 --- a/src/proxyErrorResponses.test.mjs +++ b/src/proxyErrorResponses.test.mjs @@ -26,6 +26,9 @@ function fixture(name, overrides = {}, preview = false) { // The rate-limited proxies reach for these; give them the real ones so a // fixture request is throttled exactly as production would be. makeRateLimiter, clientKey, + // terrain.js gates cross-site requests first; fixture requests carry no + // browser headers, so admit them like a loopback tool. + admitSameSite: () => false, path, process: { cwd: () => '/fixture', env: {} }, fsp: { readFile: async () => { throw new Error('cache absent'); }, diff --git a/src/terrainHeightsBounds.test.mjs b/src/terrainHeightsBounds.test.mjs index 1a0c80290..83edc262d 100644 --- a/src/terrainHeightsBounds.test.mjs +++ b/src/terrainHeightsBounds.test.mjs @@ -29,10 +29,18 @@ function install(plugin) { return routes; } -function request(handler, { url = '/', remoteAddress = '127.0.0.1' } = {}) { +function request( + handler, + { url = '/', remoteAddress = '127.0.0.1', reqHeaders = {} } = {}, +) { return new Promise((resolve, reject) => { const headers = new Map(); - const req = { method: 'GET', url, headers: {}, socket: { remoteAddress } }; + const req = { + method: 'GET', + url, + headers: reqHeaders, + socket: { remoteAddress }, + }; const res = { statusCode: 200, headersSent: false, @@ -207,6 +215,40 @@ test('the route refuses a flood of requests from one client', async (t) => { assert.equal(refusedAt, 91, 'the 91st request in a minute is refused'); }); +test('a cross-site page cannot fill the cache or spend the visitor quota', async (t) => { + const asked = stubTerrainUpstream(t); + const route = install(terrainHeightsProxy()).get('/api/terrain/heights'); + const host = 'localhost:5173'; + const refused = [ + { host, 'sec-fetch-site': 'cross-site' }, // from any web page + { host, 'sec-fetch-site': 'same-site' }, + { host, origin: 'https://evil.example' }, // fetch from another page + { host, origin: 'null' }, // sandboxed frame + { host, 'x-forwarded-for': '203.0.113.9' }, // through a proxy + ]; + // More refused requests than the 90/min quota, each a distinct new key. + for (let i = 0; i < 100; i += 1) { + const reqHeaders = refused[i % refused.length]; + const answer = await request(route, { + url: `/?points=${(-97 - i / 1000).toFixed(5)},30.26`, + reqHeaders, + }); + assert.equal(answer.statusCode, 403, JSON.stringify(reqHeaders)); + } + assert.deepEqual(asked, [], 'nothing reached upstream or the cache'); + + // The visitor's own app still has its full quota afterwards, and a + // loopback tool that sends no browser headers is admitted too. + const app = await request(route, { + url: '/?points=-97.74,30.26', + reqHeaders: { host, 'sec-fetch-site': 'same-origin' }, + }); + assert.equal(app.statusCode, 200); + const tool = await request(route, { url: '/?points=-97.75,30.26' }); + assert.equal(tool.statusCode, 200); + assert.equal(asked.length, 2); +}); + test('a second client keeps its own quota', async (t) => { stubFetch(t, async () => Response.json([{ lng: -97.74, lat: 30.26, height: 150 }]), From e38672b45d7660381afbb36e37af68ddad595157 Mon Sep 17 00:00:00 2001 From: Sunil Date: Fri, 9 Oct 2026 14:38:41 +0530 Subject: [PATCH 21/60] docs: document terrain same-site gate --- CHANGELOG.md | 4 ++++ docs/CURRENT-STATE.md | 5 ++++- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 40a155d92..c5b572bcd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -79,6 +79,10 @@ answer, since terrain does not move. Ordinary use is unaffected: a full `test:track` run drives 10 requests, peaking at 8/min. +- `/api/terrain/heights` refuses cross-site browser requests before rate + limiting, cache access or upstream work. Same-origin app requests and + non-browser local callers remain supported. + - Add an experimental ChatGPT/Codex OAuth option for cloud voice in Provider Settings. API-key voice remains the default. Local sign-in can start from the auth button and reports completion, failure, timeout, or expired diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index c7afdd24c..7c260baff 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -1103,7 +1103,10 @@ plugin order, server-key selection, validation, disk caches, budgets, retries and stale/error responses remain unchanged. Each has a Node-only package entry under `gods-eye-view/server/providers/`. Portable terrain mechanics, traffic tile math and GBFS source rules are available under `gods-eye-view/sources/`. -The browser layers and their rendering remain in their existing modules. +The `/api/terrain/heights` route refuses cross-site browser requests before +rate limiting, cache access or upstream work. Same-origin app requests and +non-browser local callers remain supported in development and preview. The +browser layers and their rendering remain in their existing modules. ## Terrain height cache bound From 2110d06e36eb7b7ed0a6cf595257c414ecde2587 Mon Sep 17 00:00:00 2001 From: quanticsoul4772 <71096528+quanticsoul4772@users.noreply.github.com> Date: Sat, 10 Oct 2026 20:32:45 -0700 Subject: [PATCH 22/60] feat(tools): count and summarize every match in the area tools aircraft_in_area and vessels_in_area return at most 200 rows, so a client asked which operators dominate an area, or the average altitude there, could only estimate from a truncated sample. Both tools now take an optional group_by (aircraft: operator, origin_country, type_code; ships: type, destination) and stats (airborne altitude and speed for aircraft, speed for ships). Each is computed over every match before rows are capped. Breakdowns list the ten largest groups plus other and unknown counts, and the summary names the three largest. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 5 ++ docs/CURRENT-STATE.md | 6 ++- docs/TOOLS.md | 4 +- scripts/format-scope.json | 1 + src/tools/queries/aviation.js | 37 +++++++++++++- src/tools/queries/aviation.test.mjs | 57 +++++++++++++++++++++ src/tools/queries/maritime.js | 22 +++++++- src/tools/queries/maritime.test.mjs | 25 +++++++++ src/tools/results.js | 69 +++++++++++++++++++++++++ src/tools/results.test.mjs | 78 +++++++++++++++++++++++++++++ 10 files changed, 299 insertions(+), 5 deletions(-) create mode 100644 src/tools/results.test.mjs diff --git a/CHANGELOG.md b/CHANGELOG.md index 68ab1c52d..0a5f41549 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,10 @@ # Changelog +- `aircraft_in_area` and `vessels_in_area` take `group_by` and `stats`, so a + client can ask which operators or ship types dominate an area, or the average + altitude or speed, over every match instead of estimating from the returned + rows. + - Treat explicitly current, complete empty vessel coverage as a successful update, clearing obsolete contacts while retaining stale or incomplete snapshots. diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 274294b5d..520bb9207 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -93,7 +93,11 @@ perimeters, terrain height, military installations and map features, plus a combined situation brief, military awareness around a point, the app's heads-up display caption, and a link that opens the app over an area. Tools reuse the layers' portable source factories, take a shared `area` argument (place name, bounding box, or point and radius) and cap -lists at 25 rows by default. Voice offers the same queries next to its app +lists at 25 rows by default. `aircraft_in_area` and `vessels_in_area` can also +count every match by a field (`group_by`: operator, country or type for +aircraft; type or destination for ships; the ten largest groups, then other and +unknown counts) and summarize airborne altitude and speed (`stats`: count, min, +median, mean, max). Both cover all matches, not just the rows returned. Voice offers the same queries next to its app actions: the session lists them, and the browser runs them through the same catalog, loaded on first use. See [tools and the MCP server](TOOLS.md). diff --git a/docs/TOOLS.md b/docs/TOOLS.md index 401058d91..f3b86f7a3 100644 --- a/docs/TOOLS.md +++ b/docs/TOOLS.md @@ -263,11 +263,11 @@ and the development server's internal routes. See SECURITY.md. | `get_earthquakes` | `earthquakes` | USGS M2.5+ events in the last 24 hours, strongest first | | `get_active_fires` | `fires` | NASA FIRMS detections in an area, highest radiative power first | | `get_recent_launches` | `launches` | Launch Library 2 launches in the last 30 days, newest first | -| `aircraft_in_area` | `aircraft` | Aircraft in an area, nearest first; `military: true` reads the `military` feed | +| `aircraft_in_area` | `aircraft` | Aircraft in an area, nearest first; `military: true` reads the `military` feed; `group_by` and `stats` count and summarize every match | | `find_aircraft` | `aircraft` | Aircraft anywhere by callsign, ICAO address or registration | | `get_aircraft_track` | `aircraft` | Recent positions of one aircraft, thinned to 200 points | | `get_aircraft_info` | `aircraft` | Aircraft type and registration, and flight route, from adsbdb | -| `vessels_in_area` | `vessels` | Ships reported by AIS in an area, nearest first, optionally by type | +| `vessels_in_area` | `vessels` | Ships reported by AIS in an area, nearest first, optionally by type; `group_by` and `stats` count and summarize every match | | `find_vessel` | `vessels` | Ships anywhere by MMSI, IMO number or name | | `get_vessel_track` | `vessels` | Recent positions of one ship, thinned to 200 points | | `next_satellite_pass` | `satellites` | Next pass over a place or point (default the ISS), with naked-eye visibility | diff --git a/scripts/format-scope.json b/scripts/format-scope.json index 6b5c9b544..f8c056177 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -941,6 +941,7 @@ "scripts/qa-context-arrivals.mjs", "src/tools/catalog.test.mjs", "src/tools/area.test.mjs", + "src/tools/results.test.mjs", "src/tools/queries/queries.test.mjs", "src/tools/mcp/mcp.test.mjs", "src/tools/localServer.test.mjs", diff --git a/src/tools/queries/aviation.js b/src/tools/queries/aviation.js index 0b8b54b79..e3aca82c4 100644 --- a/src/tools/queries/aviation.js +++ b/src/tools/queries/aviation.js @@ -11,9 +11,12 @@ import { } from '../area.js'; import { LIMIT_SCHEMA, + breakdown, + breakdownPhrase, capRows, countNoun, isoTime, + numericSummary, thinEvenly, } from '../results.js'; @@ -32,6 +35,11 @@ const MILITARY_SCHEMA = Object.freeze({ description: 'Use the military aircraft feed instead of the general feed.', }); const MAX_TRACK_POINTS = 200; +const GROUP_LABELS = Object.freeze({ + operator: 'operator', + origin_country: 'country', + type_code: 'type', +}); const round = (value, digits) => Number.isFinite(value) ? Number(value.toFixed(digits)) : null; @@ -82,7 +90,10 @@ export const aircraftInArea = defineTool({ title: 'Aircraft in an area', description: 'Aircraft currently reported inside an area, nearest the center first. ' + - 'Optionally military aircraft only, airborne only, or within an altitude band.', + 'Optionally military aircraft only, airborne only, or within an altitude band. ' + + 'group_by counts every matched aircraft by operator, country or type, and ' + + 'stats summarizes airborne altitude and speed; both cover all matches, not ' + + 'just the rows returned.', inputSchema: { type: 'object', properties: { @@ -91,6 +102,16 @@ export const aircraftInArea = defineTool({ airborne_only: { type: 'boolean' }, min_altitude_m: { type: 'number', minimum: -500, maximum: 30000 }, max_altitude_m: { type: 'number', minimum: -500, maximum: 30000 }, + group_by: { + type: 'string', + enum: Object.keys(GROUP_LABELS), + description: 'Count matched aircraft by this field, largest first.', + }, + stats: { + type: 'boolean', + description: + 'Summarize airborne altitude and speed over matched aircraft.', + }, limit: LIMIT_SCHEMA, }, required: ['area'], @@ -126,9 +147,11 @@ export const aircraftInArea = defineTool({ ...(regional ? [`regional feed: ${snapshot.coverage}`] : []), ...(snapshot.freshness === 'stale' ? ['data may be stale'] : []), ]; + const groups = args.group_by ? breakdown(rows, args.group_by) : null; return { summary: `${rows.length} ${kind} in ${area.label}` + + (groups ? breakdownPhrase(groups, GROUP_LABELS[args.group_by]) : '') + (notes.length ? ` (${notes.join('; ')}).` : '.'), data: { view: suggestView(services, { @@ -136,6 +159,18 @@ export const aircraftInArea = defineTool({ layers: [args.military ? 'military' : 'flights'], }), ...capRows(rows, args.limit), + ...(groups ? { breakdown: groups } : {}), + ...(args.stats + ? { + stats: { + altitude_m: numericSummary( + rows.filter((row) => !row.on_ground), + 'altitude_m', + ), + speed_mps: numericSummary(rows, 'speed_mps'), + }, + } + : {}), ...snapshotInfo(snapshot), }, }; diff --git a/src/tools/queries/aviation.test.mjs b/src/tools/queries/aviation.test.mjs index 20ec833aa..618085b00 100644 --- a/src/tools/queries/aviation.test.mjs +++ b/src/tools/queries/aviation.test.mjs @@ -388,3 +388,60 @@ test('the real OpenSky source marks an hour-old snapshot stale for searches', as assert.deepEqual(result.data.stale_feeds, ['civil']); assert.match(result.summary, /may be stale\.$/); }); + +test('aircraft in an area can be counted by operator over every match', async () => { + const fleet = [ + record('a1', 37.6, -122.3, { operator: 'United' }), + record('a2', 37.61, -122.3, { operator: 'Delta' }), + record('a3', 37.62, -122.3, { operator: 'United', baroAltitudeM: 3000 }), + record('a4', 37.63, -122.3, { onGround: true, baroAltitudeM: 0 }), + record('a5', 51.5, -0.1, { operator: 'British Airways' }), + ]; + const catalog = composeCatalog({ + tools: coreTools, + services: { aircraft: feed(fleet) }, + }); + const result = await catalog.call('aircraft_in_area', { + area: bay, + group_by: 'operator', + stats: true, + limit: 1, + }); + assert.equal( + result.summary, + '4 aircraft in 60 km around 37.600, -122.300; most by operator: United (2), Delta (1).', + ); + assert.equal(result.data.returned, 1); + assert.deepEqual(result.data.breakdown, { + field: 'operator', + groups: [ + { value: 'United', count: 2 }, + { value: 'Delta', count: 1 }, + ], + other_count: 0, + unknown_count: 1, + }); + // Altitude covers airborne aircraft only; speed covers every match. + assert.deepEqual(result.data.stats.altitude_m, { + count: 3, + min: 3000, + median: 10000, + mean: 7666.7, + max: 10000, + }); + assert.equal(result.data.stats.speed_mps.count, 4); +}); + +test('aircraft answers carry no breakdown or stats unless asked', async () => { + const catalog = composeCatalog({ + tools: coreTools, + services: { aircraft: feed(civil) }, + }); + const result = await catalog.call('aircraft_in_area', { area: bay }); + assert.equal('breakdown' in result.data, false); + assert.equal('stats' in result.data, false); + await assert.rejects( + catalog.call('aircraft_in_area', { area: bay, group_by: 'callsign' }), + /must be one of/, + ); +}); diff --git a/src/tools/queries/maritime.js b/src/tools/queries/maritime.js index 5b64f2826..a918c1f52 100644 --- a/src/tools/queries/maritime.js +++ b/src/tools/queries/maritime.js @@ -12,9 +12,12 @@ import { } from '../area.js'; import { LIMIT_SCHEMA, + breakdown, + breakdownPhrase, capRows, countNoun, isoTime, + numericSummary, thinEvenly, } from '../results.js'; @@ -88,7 +91,9 @@ export const vesselsInArea = defineTool({ title: 'Vessels in an area', description: 'Ships currently reported by AIS inside an area, nearest the center ' + - 'first, optionally filtered by vessel type.', + 'first, optionally filtered by vessel type. group_by counts every matched ' + + 'ship by type or destination, and stats summarizes their speed; both ' + + 'cover all matches, not just the rows returned.', inputSchema: { type: 'object', properties: { @@ -99,6 +104,15 @@ export const vesselsInArea = defineTool({ maxLength: 40, description: 'Vessel type to match, such as "tanker" or "cargo".', }, + group_by: { + type: 'string', + enum: ['type', 'destination'], + description: 'Count matched vessels by this field, largest first.', + }, + stats: { + type: 'boolean', + description: 'Summarize speed over matched vessels.', + }, limit: LIMIT_SCHEMA, }, required: ['area'], @@ -119,13 +133,19 @@ export const vesselsInArea = defineTool({ .map((record) => vesselRow(record, center)) .sort((a, b) => a.distance_km - b.distance_km); const kind = args.type ? `${args.type} vessel` : 'vessel'; + const groups = args.group_by ? breakdown(rows, args.group_by) : null; return { summary: `${countNoun(rows.length, kind)} in ${area.label}` + + (groups ? breakdownPhrase(groups, args.group_by) : '') + (snapshot.freshness === 'stale' ? ' (data may be stale).' : '.'), data: { view: suggestView(services, { area, layers: ['ais-live-vessels'] }), ...capRows(rows, args.limit), + ...(groups ? { breakdown: groups } : {}), + ...(args.stats + ? { stats: { speed_mps: numericSummary(rows, 'speed_mps') } } + : {}), ...snapshotInfo(snapshot), }, }; diff --git a/src/tools/queries/maritime.test.mjs b/src/tools/queries/maritime.test.mjs index 785e38223..d1c43e60a 100644 --- a/src/tools/queries/maritime.test.mjs +++ b/src/tools/queries/maritime.test.mjs @@ -224,3 +224,28 @@ test('an area search names its area to the server; a vessel lookup asks for ever assert.equal(lookup.has('lat'), false); assert.equal(lookup.has('radius_km'), false); }); + +test('vessels in an area can be counted by destination with a speed summary', async () => { + const catalog = composeCatalog({ + tools: coreTools, + services: { vessels: vessels() }, + }); + const result = await catalog.call('vessels_in_area', { + area: bay, + group_by: 'destination', + stats: true, + }); + assert.equal( + result.summary, + '2 vessels in 20 km around 37.780, -122.400; most by destination: OAKLAND (1).', + ); + assert.deepEqual(result.data.breakdown, { + field: 'destination', + groups: [{ value: 'OAKLAND', count: 1 }], + other_count: 0, + unknown_count: 1, + }); + assert.deepEqual(result.data.stats, { + speed_mps: { count: 2, min: 6.2, median: 6.2, mean: 6.2, max: 6.2 }, + }); +}); diff --git a/src/tools/results.js b/src/tools/results.js index 51ed82bbb..b4a24c72a 100644 --- a/src/tools/results.js +++ b/src/tools/results.js @@ -22,6 +22,75 @@ export function capRows(rows, limit = DEFAULT_LIMIT) { }; } +/** Most groups a breakdown lists before counting the rest as other. */ +export const MAX_GROUPS = 10; + +/** + * Count rows by one text field over every row, not just those returned. + * Values group case-insensitively and keep their first spelling; rows + * without a value are counted as unknown. + */ +export function breakdown(rows, field) { + const groups = new Map(); + let unknown = 0; + for (const row of rows) { + const value = typeof row[field] === 'string' ? row[field].trim() : ''; + if (!value) { + unknown += 1; + continue; + } + const key = value.toLowerCase(); + const group = groups.get(key); + if (group) group.count += 1; + else groups.set(key, { value, count: 1 }); + } + const sorted = [...groups.values()].sort( + (a, b) => b.count - a.count || a.value.localeCompare(b.value), + ); + const listed = sorted.slice(0, MAX_GROUPS); + return { + field, + groups: listed, + other_count: rows.length - unknown - sum(listed.map((g) => g.count)), + unknown_count: unknown, + }; +} + +/** Minimum, median, mean and maximum of one numeric field, or null. */ +export function numericSummary(rows, field) { + const values = rows + .map((row) => row[field]) + .filter(Number.isFinite) + .sort((a, b) => a - b); + if (!values.length) return null; + const middle = values.length >> 1; + const median = + values.length % 2 + ? values[middle] + : (values[middle - 1] + values[middle]) / 2; + const round1 = (value) => Number(value.toFixed(1)); + return { + count: values.length, + min: values[0], + median: round1(median), + mean: round1(sum(values) / values.length), + max: values.at(-1), + }; +} + +/** "; most by operator: Delta (12), United (9), American (7)" or "". */ +export function breakdownPhrase(result, label) { + if (!result.groups.length) return ''; + const top = result.groups + .slice(0, 3) + .map((group) => `${group.value} (${group.count})`); + return `; most by ${label}: ${top.join(', ')}`; +} + +function sum(values) { + return values.reduce((total, value) => total + value, 0); +} + /** Format an epoch millisecond time as ISO 8601, or null. */ export function isoTime(ms) { return Number.isFinite(ms) ? new Date(ms).toISOString() : null; diff --git a/src/tools/results.test.mjs b/src/tools/results.test.mjs new file mode 100644 index 000000000..ba67b9f64 --- /dev/null +++ b/src/tools/results.test.mjs @@ -0,0 +1,78 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { + MAX_GROUPS, + breakdown, + breakdownPhrase, + numericSummary, +} from './results.js'; + +test('a breakdown counts every row, case-insensitively, largest first', () => { + const rows = [ + { operator: 'Delta' }, + { operator: 'United' }, + { operator: ' delta ' }, + { operator: null }, + { operator: '' }, + { operator: 'American' }, + { operator: 'United' }, + { operator: 'DELTA' }, + ]; + assert.deepEqual(breakdown(rows, 'operator'), { + field: 'operator', + groups: [ + { value: 'Delta', count: 3 }, + { value: 'United', count: 2 }, + { value: 'American', count: 1 }, + ], + other_count: 0, + unknown_count: 2, + }); +}); + +test('groups past the limit are counted as other', () => { + const rows = Array.from({ length: MAX_GROUPS + 3 }, (_, index) => ({ + type: `T${String(index).padStart(2, '0')}`, + })); + rows.push({ type: 'T00' }); + const result = breakdown(rows, 'type'); + assert.equal(result.groups.length, MAX_GROUPS); + assert.deepEqual(result.groups[0], { value: 'T00', count: 2 }); + assert.equal(result.other_count, 3); + assert.equal( + result.groups.reduce((total, group) => total + group.count, 0) + + result.other_count + + result.unknown_count, + rows.length, + ); +}); + +test('a numeric summary skips missing values', () => { + const rows = [{ v: 4 }, { v: null }, { v: 1 }, { v: 10 }, { v: 3 }, {}]; + assert.deepEqual(numericSummary(rows, 'v'), { + count: 4, + min: 1, + median: 3.5, + mean: 4.5, + max: 10, + }); + assert.equal(numericSummary([{ v: 2 }, { v: 7 }, { v: 5 }], 'v').median, 5); + assert.equal(numericSummary([{ v: null }], 'v'), null); + assert.equal(numericSummary([], 'v'), null); +}); + +test('the breakdown phrase names the three largest groups', () => { + const result = { + groups: [ + { value: 'A', count: 4 }, + { value: 'B', count: 3 }, + { value: 'C', count: 2 }, + { value: 'D', count: 1 }, + ], + }; + assert.equal( + breakdownPhrase(result, 'type'), + '; most by type: A (4), B (3), C (2)', + ); + assert.equal(breakdownPhrase({ groups: [] }, 'type'), ''); +}); From 603f428397f61ddd06893b3178081398f5f9c50c Mon Sep 17 00:00:00 2001 From: quanticsoul4772 <71096528+quanticsoul4772@users.noreply.github.com> Date: Sat, 10 Oct 2026 20:35:24 -0700 Subject: [PATCH 23/60] fix(tools): say when a breakdown's field is missing from the feed OpenSky, the default civil feed, reports no operator or type code, so a breakdown by either put every aircraft under unknown while the summary said nothing. The summary now says "no operator reported" in that case, and otherwise appends the unknown count after the largest groups. Co-Authored-By: Claude Opus 5.5 --- docs/CURRENT-STATE.md | 5 +++-- src/tools/queries/aviation.js | 4 +++- src/tools/queries/aviation.test.mjs | 2 +- src/tools/queries/maritime.test.mjs | 2 +- src/tools/results.js | 13 ++++++++++--- src/tools/results.test.mjs | 12 +++++++++++- 6 files changed, 29 insertions(+), 9 deletions(-) diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 520bb9207..f9e9c02a7 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -96,8 +96,9 @@ shared `area` argument (place name, bounding box, or point and radius) and cap lists at 25 rows by default. `aircraft_in_area` and `vessels_in_area` can also count every match by a field (`group_by`: operator, country or type for aircraft; type or destination for ships; the ten largest groups, then other and -unknown counts) and summarize airborne altitude and speed (`stats`: count, min, -median, mean, max). Both cover all matches, not just the rows returned. Voice offers the same queries next to its app +unknown counts; the summary says when a feed reports none of the field) and +summarize airborne altitude and speed (`stats`: count, min, median, mean, max). +Both cover all matches, not just the rows returned. Voice offers the same queries next to its app actions: the session lists them, and the browser runs them through the same catalog, loaded on first use. See [tools and the MCP server](TOOLS.md). diff --git a/src/tools/queries/aviation.js b/src/tools/queries/aviation.js index e3aca82c4..23652f211 100644 --- a/src/tools/queries/aviation.js +++ b/src/tools/queries/aviation.js @@ -105,7 +105,9 @@ export const aircraftInArea = defineTool({ group_by: { type: 'string', enum: Object.keys(GROUP_LABELS), - description: 'Count matched aircraft by this field, largest first.', + description: + 'Count matched aircraft by this field, largest first. Feeds that do not ' + + 'report the field count those aircraft as unknown.', }, stats: { type: 'boolean', diff --git a/src/tools/queries/aviation.test.mjs b/src/tools/queries/aviation.test.mjs index 618085b00..b6b6e3497 100644 --- a/src/tools/queries/aviation.test.mjs +++ b/src/tools/queries/aviation.test.mjs @@ -409,7 +409,7 @@ test('aircraft in an area can be counted by operator over every match', async () }); assert.equal( result.summary, - '4 aircraft in 60 km around 37.600, -122.300; most by operator: United (2), Delta (1).', + '4 aircraft in 60 km around 37.600, -122.300; most by operator: United (2), Delta (1); 1 unknown.', ); assert.equal(result.data.returned, 1); assert.deepEqual(result.data.breakdown, { diff --git a/src/tools/queries/maritime.test.mjs b/src/tools/queries/maritime.test.mjs index d1c43e60a..662e5bdf2 100644 --- a/src/tools/queries/maritime.test.mjs +++ b/src/tools/queries/maritime.test.mjs @@ -237,7 +237,7 @@ test('vessels in an area can be counted by destination with a speed summary', as }); assert.equal( result.summary, - '2 vessels in 20 km around 37.780, -122.400; most by destination: OAKLAND (1).', + '2 vessels in 20 km around 37.780, -122.400; most by destination: OAKLAND (1); 1 unknown.', ); assert.deepEqual(result.data.breakdown, { field: 'destination', diff --git a/src/tools/results.js b/src/tools/results.js index b4a24c72a..528f59ea5 100644 --- a/src/tools/results.js +++ b/src/tools/results.js @@ -78,13 +78,20 @@ export function numericSummary(rows, field) { }; } -/** "; most by operator: Delta (12), United (9), American (7)" or "". */ +/** + * "; most by operator: Delta (12), United (9), American (7); 4 unknown", or + * "; no operator reported" when no row carries the field. + */ export function breakdownPhrase(result, label) { - if (!result.groups.length) return ''; + if (!result.groups.length) + return result.unknown_count ? `; no ${label} reported` : ''; const top = result.groups .slice(0, 3) .map((group) => `${group.value} (${group.count})`); - return `; most by ${label}: ${top.join(', ')}`; + const unknown = result.unknown_count + ? `; ${result.unknown_count} unknown` + : ''; + return `; most by ${label}: ${top.join(', ')}${unknown}`; } function sum(values) { diff --git a/src/tools/results.test.mjs b/src/tools/results.test.mjs index ba67b9f64..2e480afcd 100644 --- a/src/tools/results.test.mjs +++ b/src/tools/results.test.mjs @@ -63,6 +63,7 @@ test('a numeric summary skips missing values', () => { test('the breakdown phrase names the three largest groups', () => { const result = { + unknown_count: 0, groups: [ { value: 'A', count: 4 }, { value: 'B', count: 3 }, @@ -74,5 +75,14 @@ test('the breakdown phrase names the three largest groups', () => { breakdownPhrase(result, 'type'), '; most by type: A (4), B (3), C (2)', ); - assert.equal(breakdownPhrase({ groups: [] }, 'type'), ''); + assert.equal( + breakdownPhrase({ ...result, unknown_count: 5 }, 'type'), + '; most by type: A (4), B (3), C (2); 5 unknown', + ); + // A feed that never reports the field says so instead of staying silent. + assert.equal( + breakdownPhrase({ groups: [], unknown_count: 236 }, 'operator'), + '; no operator reported', + ); + assert.equal(breakdownPhrase({ groups: [], unknown_count: 0 }, 'type'), ''); }); From 1371a867137aebcdcd488213a0b2ce7b3e42bfa9 Mon Sep 17 00:00:00 2001 From: quanticsoul4772 <71096528+quanticsoul4772@users.noreply.github.com> Date: Sat, 10 Oct 2026 21:11:47 -0700 Subject: [PATCH 24/60] docs: say the area breakdowns reach MCP clients only Voice leaves aircraft_in_area and vessels_in_area out, so the changelog no longer suggests spoken questions gain breakdowns. It also stops leading with operators, which the default civil feed never reports. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 9 +++++---- docs/CURRENT-STATE.md | 3 ++- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0a5f41549..b918e89cc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,9 +1,10 @@ # Changelog -- `aircraft_in_area` and `vessels_in_area` take `group_by` and `stats`, so a - client can ask which operators or ship types dominate an area, or the average - altitude or speed, over every match instead of estimating from the returned - rows. +- `aircraft_in_area` and `vessels_in_area` take `group_by` and `stats`, so an + MCP client can ask which countries, aircraft types or ship types dominate an + area, or the average altitude or speed, over every match instead of + estimating from the returned rows. Operator and type are counted only where + the feed reports them. - Treat explicitly current, complete empty vessel coverage as a successful update, clearing obsolete contacts while retaining stale or incomplete snapshots. diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index f9e9c02a7..bdc9573b4 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -98,7 +98,8 @@ count every match by a field (`group_by`: operator, country or type for aircraft; type or destination for ships; the ten largest groups, then other and unknown counts; the summary says when a feed reports none of the field) and summarize airborne altitude and speed (`stats`: count, min, median, mean, max). -Both cover all matches, not just the rows returned. Voice offers the same queries next to its app +Both cover all matches, not just the rows returned; voice leaves these two +tools out, so they reach MCP clients only. Voice offers the same queries next to its app actions: the session lists them, and the browser runs them through the same catalog, loaded on first use. See [tools and the MCP server](TOOLS.md). From d9e2c8a7e744c3fff55f8e28b097a4c922fd4fac Mon Sep 17 00:00:00 2001 From: Kevin Rajan <7121943+kvnloo@users.noreply.github.com> Date: Mon, 12 Oct 2026 10:38:19 -0500 Subject: [PATCH 25/60] fix(ui): enforce compact Radio ownership on right-rail restores Close the overlay when adaptive layout restores an auto-collapsed Context directly. Regression covers the reachable rail reflow path; changelog credits the original report by raysung-2026 (#804). --- CHANGELOG.md | 5 ++ src/ui/panelChrome.js | 16 ++++- src/ui/panelChrome.radioOwnership.test.mjs | 69 ++++++++++++++++++++++ 3 files changed, 89 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8e0c5f754..802407a41 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,10 @@ # Changelog +- Prevent a compact Radio disclosure from covering Space Missions when the + adaptive right rail restores Context without calling the panel owner. + Repeated Context-open calls and rail-driven expansion both close the mini, + with a regression for auto-collapse restoration (raysung-2026, #804). + - CCTV cameras whose bearing is a guess now say so. Packs mark bearings derived from a hash of the camera id as `headingConfidence: 'low'`, but nothing read the flag, so roughly 70% of a default catalog rendered like surveyed facings. The HUD diff --git a/src/ui/panelChrome.js b/src/ui/panelChrome.js index 6464f1588..0717a1313 100644 --- a/src/ui/panelChrome.js +++ b/src/ui/panelChrome.js @@ -68,7 +68,8 @@ export class PanelChrome { variant: this.hud.getVariant(), }), scheduleCockpitLayout: () => this.cockpitView?.scheduleContextLayout(), - syncPanelCollapseButton: (panel) => this._syncPanelCollapseButton(panel), + syncPanelCollapseButton: (panel) => + this._syncRailPanelCollapseButton(panel), readDisplayScrollTop: () => this._displayPortalScrollRestoreOwner === 'standard' ? this._standardDisplayScrollTop @@ -282,6 +283,19 @@ export class PanelChrome { return this._panelLayout._scheduleLeftPanelLayout(options0); } + _syncRailPanelCollapseButton(panelEl) { + // Adaptive rail layout may directly restore an auto-collapsed Context + // without invoking setPanelCollapsed(). Enforce Radio ownership there too. + if ( + panelEl?.id === 'global-context-panel' && + !panelEl.classList.contains('collapsed') && + this._contextRadioDock?.classList.contains('disclosure-open') + ) { + this._setRadioDisclosure?.(false); + } + this._syncPanelCollapseButton(panelEl); + } + _syncPanelCollapseButton(panelEl) { const isRightRail = [ 'pp-toggles', diff --git a/src/ui/panelChrome.radioOwnership.test.mjs b/src/ui/panelChrome.radioOwnership.test.mjs index c52a4888e..1ecc1c7b9 100644 --- a/src/ui/panelChrome.radioOwnership.test.mjs +++ b/src/ui/panelChrome.radioOwnership.test.mjs @@ -2,6 +2,7 @@ import assert from 'node:assert/strict'; import test from 'node:test'; import { PanelChrome } from './panelChrome.js'; +import { PanelLayoutController } from './panelLayoutController.js'; function classList(initial = []) { const values = new Set(initial); @@ -65,3 +66,71 @@ test('opening an already-open Context still closes a stale compact Radio disclos assert.deepEqual(calls, [false]); assert.equal(radioDock.classList.contains('disclosure-open'), false); }); + +test('adaptive rail restore hides compact Radio before revealing Context', (t) => { + const priorRaf = globalThis.requestAnimationFrame; + let nextFrame; + globalThis.requestAnimationFrame = (callback) => { + nextFrame = callback; + return 1; + }; + t.after(() => { + globalThis.requestAnimationFrame = priorRaf; + }); + + // Valid starting state: the tactical rail automatically collapsed Context, + // and the user opened the compact Radio controls from its header. + const contextPanel = { + id: 'global-context-panel', + classList: classList(['collapsed', 'layout-auto-collapsed']), + }; + const radioDock = { classList: classList(['disclosure-open']) }; + const radioMini = { hidden: false }; + const calls = []; + const chrome = { + _contextRadioDock: radioDock, + _setRadioDisclosure(open) { + calls.push(`disclosure:${open}`); + radioDock.classList.toggle('disclosure-open', open); + radioMini.hidden = !open; + }, + _syncPanelCollapseButton(panel) { + calls.push(`button:${panel.id}`); + }, + }; + const layout = { + destroyed: false, + _rightStackLayoutFrame: null, + _rightStackReconsiderAutoCollapse: false, + _rightPanelStack: { + querySelectorAll: (selector) => + selector === '.layout-auto-collapsed' ? [contextPanel] : [], + }, + _syncPanelCollapseButton(panel) { + PanelChrome.prototype._syncRailPanelCollapseButton.call(chrome, panel); + }, + _syncRightPanelAdaptiveLayout() { + calls.push('layout'); + }, + }; + + PanelLayoutController.prototype._scheduleRightPanelLayout.call(layout, { + reconsiderAutoCollapse: true, + }); + assert.equal(contextPanel.classList.contains('collapsed'), true); + assert.equal(radioMini.hidden, false); + nextFrame(); + + assert.equal(contextPanel.classList.contains('collapsed'), false); + assert.equal( + contextPanel.classList.contains('layout-auto-collapsed'), + false, + ); + assert.equal(radioDock.classList.contains('disclosure-open'), false); + assert.equal(radioMini.hidden, true); + assert.deepEqual(calls, [ + 'disclosure:false', + 'button:global-context-panel', + 'layout', + ]); +}); From 4fde920665b3cbd5034d499fb67c7b4691c511f2 Mon Sep 17 00:00:00 2001 From: manjunath Date: Fri, 16 Oct 2026 17:09:03 +0530 Subject: [PATCH 26/60] feat(voice): harden Gemini Live provider integration --- .env.example | 12 + CHANGELOG.md | 63 + README.md | 11 + SECURITY.md | 3 + docs/CURRENT-STATE.md | 80 + docs/VOICE-OWNERSHIP.md | 151 +- pinokio/_ENVIRONMENT | 6 + pinokio/install.js | 3 + pinokio/start.js | 3 + pinokio/update.js | 3 + scripts/dev-fresh.sh | 14 +- scripts/format-scope.json | 21 +- scripts/package-boundaries.json | 23 +- scripts/pinokio-environment.mjs | 4 + scripts/qa-gemini-voice.mjs | 246 + scripts/qa-panel-resize.mjs | 28 +- scripts/qa-voice-routing.mjs | 2 +- scripts/setup-doctor.mjs | 5 +- scripts/voice-bench/phrases.mjs | 8 +- server/providers/gemini.js | 22 + server/providers/gemini/config.js | 54 + server/providers/gemini/realtime.js | 180 + server/providers/gemini/request.js | 117 + server/providers/local.js | 2 + server/providers/openai/instructions.js | 7 +- server/providers/openai/realtime.js | 5 +- server/providers/openai/toolDescriptions.js | 4 +- server/standalone/vite.config.js | 4 +- src/annotations/annotationEngine.js | 188 +- src/annotations/annotationEngine.test.mjs | 224 + src/app/tools.js | 8 + src/data/radio.test.mjs | 6 + src/devFreshDotenv.test.mjs | 55 +- src/firstRunExperience.test.mjs | 632 ++- src/geminiServer.test.mjs | 793 +++ src/keySetupCore.mjs | 10 +- src/keySetupCore.test.mjs | 31 + src/layers/radio/playback.js | 6 + src/layers/radio/selection.js | 2 + src/layers/radio/tuning.js | 1 + src/pinokioEnvironment.test.mjs | 33 + src/pinokioLauncherContract.test.mjs | 4 +- src/radioMarkup.test.mjs | 552 +- src/setupDoctor.test.mjs | 74 +- src/standalone/tools.js | 6 +- src/tooling/localServices.test.mjs | 30 + src/tooling/voiceBench.test.mjs | 586 ++- src/ui/styles/status.css | 49 +- src/ui/styles/voice-cost.css | 85 + src/voice/actionSchemas.test.mjs | 33 +- src/voice/annotationCancellation.test.mjs | 307 ++ src/voice/commands.js | 27 +- src/voice/commands.test.mjs | 199 + src/voice/control.js | 11 + src/voice/geminiAudio.js | 375 ++ src/voice/geminiAudio.test.mjs | 1483 ++++++ src/voice/geminiAutomaticVad.test.mjs | 783 +++ src/voice/geminiCaptureWorklet.js | 125 + src/voice/geminiNativeHarness.mjs | 254 + src/voice/geminiNativeOwnership.test.mjs | 524 ++ src/voice/geminiNativePolicy.test.mjs | 783 +++ src/voice/geminiNativeRadio.test.mjs | 1049 ++++ src/voice/geminiNativeRecovery.test.mjs | 601 +++ src/voice/geminiSession.js | 2214 ++++++++ src/voice/geminiSession.test.mjs | 5222 +++++++++++++++++++ src/voice/gevActions.js | 175 +- src/voice/gevActions.test.mjs | 390 +- src/voice/gevRealtime.js | 30 +- src/voice/gevRealtime.test.mjs | 3442 ++++++++---- src/voice/integrationOwnership.test.mjs | 358 ++ src/voice/pointAndAsk.test.mjs | 10 +- src/voice/pointerContext.js | 11 +- src/voice/pointerContext.test.mjs | 6 + src/voice/providerCapabilities.js | 33 + src/voice/providerCapabilities.test.mjs | 77 + src/voice/providerCommands.js | 154 + src/voice/providerCommands.test.mjs | 817 +++ src/voice/providerIntent.js | 320 ++ src/voice/providerIntent.test.mjs | 445 ++ src/voice/providerIntentFragments.js | 79 + src/voice/providerIntentFragments.test.mjs | 74 + src/voice/providerLifetime.test.mjs | 95 + src/voice/radioActivation.test.mjs | 266 + src/voice/radioVoiceResumeContract.test.mjs | 778 +++ src/voice/realtimeConnection.js | 1 + src/voice/realtimeController.js | 20 + src/voice/realtimeNarration.test.mjs | 1167 ++++- src/voice/realtimeOwners.test.mjs | 595 ++- src/voice/realtimeProtocol.js | 22 +- src/voice/realtimeRadio.js | 133 +- src/voice/realtimeSession.js | 8 +- src/voice/realtimeTurns.js | 357 +- src/voice/session.js | 47 +- src/voice/session.test.mjs | 121 + src/voice/sessionCommands.js | 67 +- src/voice/toolExecutionPolicy.js | 137 + src/voice/toolExecutionPolicy.test.mjs | 541 ++ src/voice/userTurnIntent.js | 286 + src/voice/userTurnIntent.test.mjs | 461 ++ src/voice/voiceCard.js | 4 + src/voice/voiceCard.test.mjs | 89 + src/voice/voiceCardPresentation.js | 47 +- 102 files changed, 28528 insertions(+), 1581 deletions(-) create mode 100644 scripts/qa-gemini-voice.mjs create mode 100644 server/providers/gemini.js create mode 100644 server/providers/gemini/config.js create mode 100644 server/providers/gemini/realtime.js create mode 100644 server/providers/gemini/request.js create mode 100644 src/geminiServer.test.mjs create mode 100644 src/voice/annotationCancellation.test.mjs create mode 100644 src/voice/commands.test.mjs create mode 100644 src/voice/geminiAudio.js create mode 100644 src/voice/geminiAudio.test.mjs create mode 100644 src/voice/geminiAutomaticVad.test.mjs create mode 100644 src/voice/geminiCaptureWorklet.js create mode 100644 src/voice/geminiNativeHarness.mjs create mode 100644 src/voice/geminiNativeOwnership.test.mjs create mode 100644 src/voice/geminiNativePolicy.test.mjs create mode 100644 src/voice/geminiNativeRadio.test.mjs create mode 100644 src/voice/geminiNativeRecovery.test.mjs create mode 100644 src/voice/geminiSession.js create mode 100644 src/voice/geminiSession.test.mjs create mode 100644 src/voice/integrationOwnership.test.mjs create mode 100644 src/voice/providerCapabilities.js create mode 100644 src/voice/providerCapabilities.test.mjs create mode 100644 src/voice/providerCommands.js create mode 100644 src/voice/providerCommands.test.mjs create mode 100644 src/voice/providerIntent.js create mode 100644 src/voice/providerIntent.test.mjs create mode 100644 src/voice/providerIntentFragments.js create mode 100644 src/voice/providerIntentFragments.test.mjs create mode 100644 src/voice/providerLifetime.test.mjs create mode 100644 src/voice/radioActivation.test.mjs create mode 100644 src/voice/radioVoiceResumeContract.test.mjs create mode 100644 src/voice/toolExecutionPolicy.js create mode 100644 src/voice/toolExecutionPolicy.test.mjs create mode 100644 src/voice/userTurnIntent.js create mode 100644 src/voice/userTurnIntent.test.mjs diff --git a/.env.example b/.env.example index a2f8afa24..d54ccf255 100644 --- a/.env.example +++ b/.env.example @@ -56,6 +56,18 @@ GOOGLE_MAPS_SERVER_API_KEY= # CLIENT-EXPOSED — use a public assets:read token with URL restrictions. CESIUM_ION_TOKEN= +# Optional: Gemini Developer API Live voice. Use a separate Gemini API key +# created in Google AI Studio: +# https://aistudio.google.com/apikey — separate from the Google Maps keys above. +# Setup and security: https://ai.google.dev/gemini-api/docs/api-key +# Free-tier access depends on the model, project eligibility and available quota. +# SERVER-SIDE ONLY: the browser receives a short-lived session token, not this key. +GEMINI_API_KEY= +GEMINI_LIVE_MODEL=gemini-3.8-live +# Per-IP Gemini session-token throttle; default 30 per minute, 0 disables it. +# This limits token requests, not provider billing or audio-session duration. +# GEV_RATELIMIT_GEMINI_PER_MIN=30 + # Optional: OpenAI Realtime voice control. API key is the default cloud auth. # Provider Settings can instead opt voice into the signed-in local # ChatGPT/Codex OAuth session; the browser still receives only a short-lived diff --git a/CHANGELOG.md b/CHANGELOG.md index dd76d7a25..f97a6bd00 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,11 @@ MobilityData's systems catalogue; AMBici's is CC0. `gbfs.nextbike.net` joins the proxy allowlist. These are the layer's first stations in Spain. +- Suppress empty and near-silent Gemini push-to-talk submissions without + losing buffered speech onset or release tails. Space holds silence local + playback; only admitted speech retires the current provider/tool turn. Keep narrow-screen toast notices clear of + the voice provider selector and bottom dock. + - Add nine Ontario transit feeds: TTC, MiWay, Hamilton HSR, Durham Region, YRT/Viva, Guelph, Burlington, Barrie and Kingston, each carrying its own licence quote and attribution. A feed can now name its own modes, so Toronto's @@ -130,6 +135,56 @@ get an empty body; anything else that cannot become a response fails that mirror so the next one is tried. +- Cancel pending voice-owned annotation lookups and deferred outlines on Stop, + interruption or replacement without clearing committed or unrelated marks. + Let stronger Gemini Radio controls start without waiting for older station + lookups; successful controls cancel covered pending work while preserving + committed selection and unrelated sibling calls. “Turn Radio on” enables the + layer without audio; playback requires an explicit play, listen or resume request. + Resume voice after Radio pause/off only when playback interrupted active voice; + manual playback follows the same rule. Provider changes and explicit voice + Stop cancel automatic resume, and manual voice startup pauses Radio. + Show a toast when Radio playback stops active voice. A tuner drag that ends + paused without a new station also resumes the voice Radio interrupted. An + older queued Gemini Radio request cannot start after a later successful + Stop, and deferred annotation outlines outlive their tool call for both + providers until the next input or session boundary. + +- Keep Gemini sentence-level voice activity separate from observed reply + generation, so a pause before the first answer does not retire that answer. + Preserve newer submitted input across an older reply's completion and finish + silent native replies without inventing audio or captions. Expiry activity + bookkeeping does not identify server generations or authorize tools. + +- Reject malformed Gemini tool-call identities before they can affect turn + policy. Retain accepted and cancelled identities for the socket lifetime; + require an explicit restart before a new batch exceeds the 256-identity budget. + +- Refuse voice tool dispatch when authoritative typed input or OpenAI speech + exceeds the normalized 500-character policy limit. Ask for a shorter command + without inferring a provider change; cancel cooperative preparation and withhold + deferred Radio playback while preserving changes that already completed. + +- Preserve OpenAI speech-command identity with disabled or failed optional + transcription. Gemini native tool calls use current socket, session, local-turn + and call ownership; unordered input captions are advisory. First-party typed + input retains its authority through deferred delivery and same-turn + continuation until new input or Stop supersedes it. An accepted fresh + `set_voice_provider` call refuses tools that have not started in that local + turn; provider selection remains manual. Narrow provider interception, make + chooser guidance host-aware, hide single-option choosers, preserve useful + bounded failure reasons, and use injected Gemini boundary timers. + Later authoritative input can cancel cooperative generic/CCTV preparation; + committed changes and non-cooperative map-stack work are not undone. + +- End OpenAI sessions with an explicit restart message when unmatched response + reservations overflow. Preserve FIFO ownership and reject stale callbacks; + recovery never reconnects automatically or guesses a response's user item. + +- Recognize direct voice-provider requests whose finalized input segments carry + sentence punctuation between command and target, such as "Switch to. Gemini." + Provider selection remains manual. + - Snow flakes sway within a fixed range instead of drifting sideways faster the longer the app runs; the Wind setting still scales the sway. @@ -372,6 +427,14 @@ Mapillary only and hide while GOOGLE STREET VIEW is lit; its globe marker is a Google-blue view cone. +- Add Gemini Live as an alternative voice provider using a separate server-side + API key and short-lived browser credentials. It shares the existing microphone, + push-to-talk, result card, map tools and Radio handoff with OpenAI. The manual + provider dropdown reconnects active voice in its prior input mode; spoken or + typed provider-switch requests give fixed dropdown guidance. Gemini owns + device-rate capture, 16 kHz resampling, 24 kHz playback, bounded queues, + interruption cleanup and explicit restart after its connection expires. + - Add an experimental ChatGPT/Codex OAuth option for cloud voice in Provider Settings. API-key voice remains the default. Local sign-in can start from the auth button and reports completion, failure, timeout, or expired diff --git a/README.md b/README.md index c5aeb4710..847abf535 100644 --- a/README.md +++ b/README.md @@ -662,3 +662,14 @@ One heads-up from the inside: build in this space for a week and you learn that **🌐 God's Eye View. No place left behind.** + + +### Gemini voice provider + +For Gemini, create a separate API key in [Google AI Studio](https://aistudio.google.com/apikey) and add it as `GEMINI_API_KEY` in **Provider Settings**. The Google Maps key does not configure Gemini voice. See Google's [API key setup and security guidance](https://ai.google.dev/gemini-api/docs/api-key). The rest of the app works without either voice key. + +Gemini uses the Developer API's Live API with `gemini-3.8-live` by default (`GEMINI_LIVE_MODEL` overrides it on the server). AI Studio is where you create its key. Free-tier availability depends on the model and project quotas; see the [Gemini pricing](https://ai.google.dev/gemini-api/docs/pricing) and [rate limits](https://ai.google.dev/gemini-api/docs/rate-limits). A connection or quota failure appears in the voice error tray. The [ephemeral token](https://ai.google.dev/gemini-api/docs/live-api/ephemeral-tokens) must start its one new session within 60 seconds and is configured to expire after 30 minutes, but that token lifetime does not promise an uninterrupted 30-minute conversation: Google limits a Live connection to [around 10 minutes](https://ai.google.dev/gemini-api/docs/live-api/session-management). This version intentionally does not resume the session. When Gemini sends `goAway`, GEV stops admitting new turns and drains the current turn until its reply finishes or the supplied deadline expires, then releases local resources. Start voice again to request a fresh token and a new conversation. The same explicit restart is required before a session exceeds 256 distinct tool-call identities; old identities remain blocked for that connection. + +For a clear standalone spoken or typed request to switch voice providers, the active model says in its normal voice, “I can’t switch voice providers directly. Hover over the Voice panel and use the provider dropdown.” No tool can change the selected provider. Authoritative typed requests and recognized OpenAI speech block app actions; Gemini native speech uses current socket, session, local-turn and call ownership, with captions treated as advisory. An accepted fresh `set_voice_provider` call refuses not-yet-started tools in that Gemini turn without undoing completed work. These refusals do not write the saved provider preference, reconnect voice or remount a session. Ambiguous, quoted, negated, conditional and compound text remains ordinary conversation. The manual dropdown is the only provider-switching path. Gemini captures at the device rate, resamples input to 16 kHz and plays output through a separate 24 kHz context. Microphone permission is obtained before minting its short-admission token. Congestion may drop obsolete microphone or image frames; reliable control and tool results have bounded queue and recovery limits. + +Gemini Live is available alongside OpenAI in the standalone app. Choose a supported provider, then use the existing microphone button or hold Space. The API-key provider setup remains server-owned. Gemini has no OpenAI cost estimate or spend cap; OpenAI retains its configured cost controls. diff --git a/SECURITY.md b/SECURITY.md index cc3f27cfe..65038b883 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -18,6 +18,7 @@ The golden rule: **secret-bearing API keys stay on the server side.** The dev/pr | Key | Where it lives | How the browser uses it | |-----|----------------|--------------------------| | `OPENAI_API_KEY` | Server only | Browser fetches a short-lived **ephemeral** Realtime session token from `/api/realtime/token`; the real key never ships | +| `GEMINI_API_KEY` | Server only | Browser fetches a short-lived, single-use Gemini Live credential from `/api/gemini/token`; the real key never ships | | ChatGPT sign-in through Codex (optional, experimental) | Codex's own `auth.json` (`CODEX_HOME`, default `~/.codex`); GEV reads it server-side and never writes or refreshes it | Only after **USE CHATGPT OAUTH** is selected in Provider Settings: the server mints the same ephemeral Realtime token with the ChatGPT access token, for loopback clients only | | `AISSTREAM_API_KEY` | Server only | Server holds the AISStream websocket; browser polls the same-origin `/api/vessels` cache | | OpenSky OAuth (`OPENSKY_CLIENT_ID/SECRET`) | Server only | Server mints + refreshes the token behind `/api/flights` | @@ -86,7 +87,9 @@ The dev server is a **key broker**: every server-side key above is spendable by - **Local-only by default.** `./scripts/dev-fresh.sh` (and the Vite config itself) bind to `localhost`, so only your machine can reach the server — and only local names are accepted (`allowedHosts` stays restricted, which also blunts DNS-rebinding tricks). Binding to all interfaces keeps that restriction: IP addresses and `localhost` work, and LAN hostnames must be listed in `GEV_ALLOWED_HOSTS` (for example `GEV_ALLOWED_HOSTS=globe.lan`); suffix and wildcard entries are ignored. - **LAN exposure is an explicit opt-in**: `HOST=0.0.0.0 ./scripts/dev-fresh.sh`. The launcher prints a prominent warning plus your LAN URL. Understand what opting in means: **every device on that network can drive the proxies and spend your OpenAI / Google / OpenSky / AISStream / TomTom / FIRMS quota** for as long as the server runs. Do this only on networks you trust. + The same exposure applies to a configured Gemini key and its project quota. - **App-level throttles (on by default):** `GEV_RATELIMIT_OPENAI_PER_MIN` (default `30`) and `GEV_RATELIMIT_GOOGLE_PER_MIN` (default `120`) — the values the Pinokio build already ships — cap the cost-bearing endpoints per client IP per minute (over-limit requests receive a sanitized `429`). Both defaults sit well above what the app itself generates; set either to exactly `0` to run unthrottled. They are **per-IP, process-local, in-memory guards** — they reset on restart, a caller with several addresses gets a bucket per address, and they are **not billing caps**. +- **Gemini session-start throttle:** `GEV_RATELIMIT_GEMINI_PER_MIN` defaults to `30`; set it to exactly `0` to disable it. It applies the same per-IP, process-local limits to `/api/gemini/token`, caps new session starts rather than audio usage, and is not a billing cap. - **Docker trusts one address, and only because the port is on loopback.** Docker delivers the host's own browser to the container from the container network's gateway, never from `127.0.0.1`, so a loopback-only Provider diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 4c06dd3cb..faafc19f0 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -1,5 +1,75 @@ # God's Eye View Current State +Authoritative typed input and OpenAI speech longer than 500 characters after +policy normalization are unverified: tool dispatch asks for a shorter command. +Later authoritative input can cancel cooperative preparation and the same input's +deferred Radio playback; completed layer changes or station selection remain +reported truthfully. This limit refusal does not infer a provider request or +change providers. + +Voice retains authentic user-turn identity when OpenAI input transcription is +explicitly disabled or fails. Gemini native tool calls use current socket, +session, local-turn and call ownership; unordered input captions are advisory +and cannot grant or revoke tool authority. First-party typed input remains +authoritative through deferred delivery and same-turn continuation until new +input or Stop supersedes it. An accepted fresh `set_voice_provider` call refuses +tools that have not started in that local turn without undoing completed work. +Provider selection remains manual. Built-in embed instructions default to +host-neutral guidance; standalone opts into its chooser. Hosts with fewer than +two distinct usable provider options hide the chooser and retain neutral guidance. +Failed tool output retains bounded, redacted reason data with static follow-up +instructions. Gemini boundary waits use its injected clock. +Native Gemini call IDs must be nonempty protocol strings of at most 1,024 +characters, without control characters. Their exact identities stay retired for +the socket lifetime. At the 256-identity budget, another new identity ends the +session with a visible restart instruction before any batch action is queued; +old identities are never evicted to admit replays. + +Gemini sentence-level voice activity does not by itself prove a reply is being +generated. Local activity bookkeeping keeps an expiring session open for a +possible reply; it does not identify server generations or authorize tools. +An older retired completion preserves newer submitted input. A native terminal +completion also settles a silent user turn, while tools and queued audio still +delay presentation completion. Noise-only activity can wait until the native +expiry deadline with the microphone muted; no shorter recovery timer is added. +See [voice ownership](VOICE-OWNERSHIP.md) for deferred-input and protocol limits. + +Gemini push-to-talk suppresses empty and near-silent captures before submitting +context, activity markers or audio. Sustained input energy admits bounded +pre-roll and the rest of that hold; release still flushes its tail. Physical +Space holds silence local playback and revoke pending Radio handoff. Only +admitted speech retires the current provider/tool turn; a silent hold does not +create a provider reply or a native-boundary wait. This is an energy floor, not speech recognition or a +guarantee against loud background noise. Open-mic behavior is unchanged. +On narrow or short screens, transient toast notices are positioned above the +bottom voice controls rather than covering the provider selector. + +Gemini retires old replies before forwarding deferred input. Output refreshes +the five-second retirement inactivity bound; deferred-input deadlines remain +absolute. New input, Stop and provider replacement revoke older work. Existing +map-stack work lacks caller cancellation. Pending annotation lookups and deferred +outlines now share the voice call's cancellation signal; committed marks and +unrelated annotation work survive. Stronger Gemini Radio controls start without +waiting for an older pending selection; successful controls cancel covered work +without treating siblings as a new turn or erasing committed selection. “Turn +Radio on” enables its layer without starting audio; playback requires an explicit +play, listen or resume request. Radio playback suspends active voice; pausing or +disabling Radio resumes that same session preference only when Radio interrupted +it. Manual playback follows the same rule, without a playback-duration limit. +Provider replacement and explicit voice Stop cancel automatic resume; manual +voice startup pauses Radio. A toast explains when Radio playback stops voice. +A previously inactive voice session stays off when Radio stops. Local regression checks +do not establish provider startup or browser/audio acceptance. + +OpenAI ends an owned session after more than 16 unmatched response reservations +with a visible restart-required error. It preserves ordered correlation and +requires explicit restart rather than dropping old provenance or reconnecting. +OpenAI provider text arriving after a synchronous action committed cannot +retroactively prevent that change; later tools remain blocked. +Provider classification recognizes sentence punctuation at finalized segment +boundaries while retaining quoted, negated, compound and non-voice-provider +exclusions. + Vessel sources can report healthy empty coverage using a current, complete snapshot with zero raw rows, a healthy transport and a positive last-message time. This clears obsolete contacts and settles startup without manufacturing @@ -5661,3 +5731,13 @@ datacenters, dams and the Nepal locator retain that shared credit while displaye Military fragments stitch by source identity. Different mapped parcels merge only when their footprints touch or contain one another, never across a seam tolerance gap. + +### Gemini Live provider integration + +Standalone voice supports OpenAI and Gemini; embedded hosts default to OpenAI and opt into Gemini through `voice.supportedProviders`. The provider dropdown uses the existing microphone control and result card. Manual selection reconnects an active session in its prior input mode; push-to-talk stays muted until a fresh Space hold. Voice that was off stays off. + +Gemini uses a separate server-side `GEMINI_API_KEY` and one-session ephemeral credentials from `/api/gemini/token`. Device-rate microphone capture is resampled to 16 kHz; playback uses a separate 24 kHz context. Microphone permission precedes token minting. Bounded control queues, retired replies and pending tool actions are invalidated on interruption, Stop, replacement and teardown; input capture identities follow the physical microphone gate and end on Stop, replacement and teardown rather than on every interruption. `goAway` stops new turns and drains the current reply until completion or its supplied deadline, then requires an explicit restart. + +Both providers use the shared action schemas, tool-result policy, pointer context and Radio ownership. Successful Radio playback, voice-requested or manual, suspends active voice and shows a toast; pausing, stopping or disabling that Radio resumes the prior voice mode once, with no time limit. Radio-only station, tuner and volume changes keep the lease; a tuner drag that ends paused without a new station also resumes voice. Explicit voice Stop, starting voice, provider replacement and teardown revoke it. + +Clear standalone provider-switch requests receive the fixed spoken dropdown guidance; neither provider can change the selected provider through a tool. Ambiguous, quoted, negated, conditional and compound text remains ordinary conversation. The separate API-key setup entry remains available; the core provider does not add a Voice Settings panel or voice actions for opening key setup. diff --git a/docs/VOICE-OWNERSHIP.md b/docs/VOICE-OWNERSHIP.md index bb28eb3e9..983f8d618 100644 --- a/docs/VOICE-OWNERSHIP.md +++ b/docs/VOICE-OWNERSHIP.md @@ -5,22 +5,22 @@ overall lifetime. It coordinates startup and shutdown. `realtimeFacade.js` preserves existing methods and properties by delegation, without copying state. The session adapter, action runner and backend interfaces remain unchanged. -| Owner | State and responsibility | -| --- | --- | -| `realtimeConnection.js` | Start generation, request cancellation, peer/data channel, acquired microphone stream and playback element | -| `realtimeBackend.js` | Token and SDP requests, their separate transports, the abort/timeout scope around both, and client-secret expiry | -| `realtimeTurns.js` | Active and superseded responses, pending confirmations, deduplicated calls and action cancellation | -| `realtimeRadio.js` | Speaker ducking, prepared playback, stronger-action reservations and playback observers | -| `realtimeInput.js` | Physical Space gesture, microphone mute, input/output meters and their frame/listener lifetime | -| `realtimeCost.js` | Next-session preferences and the current session's model-bound meter | -| `realtimeViewport.js` | One retained image, bounded deletion identities and capture generation | -| `realtimeDiagnostics.js` | Bounded error history and sanitized optional diagnostics | -| `turnMetrics.js` | Per-turn latency and speech spans written to the debug log | -| `narration.js` | Progress lines and the earcon while tools run; spoken through the turns owner | -| `pointerContext.js` | Pointer position, dwell pick and the current turn's pointer snapshot (session-scoped) | -| `referents.js` | Numbered items from the latest result for `referent:n` (session-scoped) | -| `resultDisplay.js` | What the voice card shows for a result and which items it numbers (read by the card and `referents.js`) | -| `gevActions.js` runner | Analyst follow-up memory (session: `resetConversation()` on stop, `dispose()` on removal) | +| Owner | State and responsibility | +| ------------------------ | ---------------------------------------------------------------------------------------------------------------- | +| `realtimeConnection.js` | Start generation, request cancellation, peer/data channel, acquired microphone stream and playback element | +| `realtimeBackend.js` | Token and SDP requests, their separate transports, the abort/timeout scope around both, and client-secret expiry | +| `realtimeTurns.js` | Active and superseded responses, pending confirmations, deduplicated calls and action cancellation | +| `realtimeRadio.js` | Speaker ducking, prepared playback, stronger-action reservations and playback observers | +| `realtimeInput.js` | Physical Space gesture, microphone mute, input/output meters and their frame/listener lifetime | +| `realtimeCost.js` | Next-session preferences and the current session's model-bound meter | +| `realtimeViewport.js` | One retained image, bounded deletion identities and capture generation | +| `realtimeDiagnostics.js` | Bounded error history and sanitized optional diagnostics | +| `turnMetrics.js` | Per-turn latency and speech spans written to the debug log | +| `narration.js` | Progress lines and the earcon while tools run; spoken through the turns owner | +| `pointerContext.js` | Pointer position, dwell pick and the current turn's pointer snapshot (session-scoped) | +| `referents.js` | Numbered items from the latest result for `referent:n` (session-scoped) | +| `resultDisplay.js` | What the voice card shows for a result and which items it numbers (read by the card and `referents.js`) | +| `gevActions.js` runner | Analyst follow-up memory (session: `resetConversation()` on stop, `dispose()` on removal) | The backend is replaceable per deployment: `realtimeController.js` accepts it as a `backend` option and falls back to `createRealtimeBackend()`. Its two method @@ -45,11 +45,122 @@ Action and post-capture continuations retain their conversation identity before publishing output, queueing a response or changing status. Behavioral invariants remain in force: a 500 ms Space hold claims push-to-talk; -short control taps and text entry remain native; a claimed hold while the assistant speaks interrupts it (never during a Radio handoff); a click-started session stays -open-mic. Every accepted or refused tool call receives its terminal output once. +short control taps and text entry remain native. A claimed hold silences local playback when the Radio speaker guard permits it; +admitted speech retires the provider turn and supersedes its tool work. A denied local claim +preserves queued confirmation audio; a later native Gemini interruption retires +the old reply and cancels its handoff. A click-started session stays open-mic. +Gemini PTT waits for two consecutive 10 ms PCM windows above a low energy floor +before submitting pointer context, activity markers or audio. Up to 400 ms of +bounded pre-roll preserves speech onset; after admission, tails and pauses pass +through until release. An empty or near-silent hold submits no new provider turn +and clears its local pending-input state. It still silences existing local +playback as an explicit Space hold and revokes a pending Radio handoff. A silent +hold alone preserves the current provider/tool turn until its natural completion. This energy check is not a speech classifier: +very quiet input below the floor is suppressed and sustained loud noise can pass. +Open-mic capture and native automatic activity detection are unchanged. +Current, uncancelled tool calls receive one terminal output within their owning +provider conversation. Gemini suppresses calls retired by a session or local-turn +boundary, or cancelled by the server. OpenAI answers superseded calls with a +terminal refusal while the same conversation remains owned; callbacks from a +replaced conversation cannot publish into the new session. New typed input supersedes old intent. Radio waits for the spoken confirmation and -verified muted playback. A live cost meter stays bound to its negotiated model, -even when the next-session preference changes. +verified muted playback. Eligible stronger Radio controls begin without waiting +for an older station lookup; a successful control cancels covered pending work. +Already committed selection and unrelated sibling calls retain their authority. +“Turn Radio on” enables the layer without selecting or playing a station; +playback requires an explicit play, listen or resume request. Radio playback +suspends an active voice session and remembers it until Radio is paused or +disabled. This also applies to manual playback; merely enabling the layer does +not suspend voice. Pausing or disabling Radio leaves a previously inactive +voice session off. Provider replacement or explicit voice Stop cancels the +saved resume; manually starting voice pauses Radio. A live cost meter stays +bound to its negotiated model, +even when the next-session preference changes. Clear standalone first-party +provider requests remain ordinary provider turns, so the active OpenAI or Gemini +model says in its normal voice, “I can’t switch voice providers directly. Hover +over the Voice panel and use the provider dropdown.” Hosts without at least two +distinct usable chooser options hide that control and +instead explain that provider selection is managed by the app. The built-in +endpoint defaults to this neutral guidance; the standalone host opts into chooser +guidance. No tool can change the selected provider. Authoritative typed requests and +recognized OpenAI speech block app actions; Gemini native-call refusal is +described below. These refusals never change or save provider state, reconnect +voice or remount a session. Ambiguous, quoted, negated, conditional and compound text +remains ordinary conversation. Manual selection is the only provider switching +path. + +Gemini native tool calls are bound to the current socket, session epoch, local +turn and unique call ID. Native input captions are unordered advisory segments: +they are shown without authoritative request attribution and cannot grant or +revoke a tool's lifetime. First-party typed input retains its authenticated +request token through transport pressure, deferred delivery and same-turn +continuation, until real new input or Stop revokes it. An accepted fresh +`set_voice_provider` call refuses every not-yet-started tool for the rest of its +local turn; malformed or replayed calls cannot quarantine a new turn. No tool +changes or saves the selected provider, reconnects voice or remounts a session. + +Gemini's sentence-level `ACTIVITY_START`/`ACTIVITY_END` signals track detected +speech, not proof that a reply generation exists. Automatic activity alone does +not retire an unanswered generation; observed server output or an explicit +submitted push-to-talk/typed request retains the reply fence. Local activity +owners preserve expiry liveness across an older retired completion, without +identifying server generations or granting tools authority. The old boundary +cannot clear a newer submitted request's pending reply. If expiry discards a +newest deferred request, the ordered retired boundary closes the session rather +than allowing its superseded forwarded predecessor to run under that request. +Shorter repeated expiry notices retain that supersession. A native terminal +completion settles even a silent user turn; pending tools, queued controls and +audio still delay presentation completion, and no speech or captions are invented. + +Noise-only automatic activity may receive no generation. During expiry it can +wait with the microphone muted until the supplied deadline, capped at ten +minutes; no shorter no-evidence timer assigns reply identity. Native Live events +do not provide a VAD-sentence generation ID or guarantee their ordering relative +to tools. Local activity ownership cannot prove that arbitrary undelimited late +output belongs to an old generation; tools retain the socket/session/local-turn +and call-ID policy above. + +There is no first-party typed-command UI. Embedders and programmatic `sendTextCommand` +callers can issue rapid typed requests while open-mic input is active. While a +typed request is deferred behind an older reply boundary, concurrent microphone +PCM may be discarded for up to that boundary's deadline. It is never replayed +under the typed owner's identity. `deferredAudioDrops.nonPttOwner` and +`deferredAudioDrops.closedActivity` report reason-specific lifetime-cumulative +counts alongside `droppedAudioChunks`; they do not change admission or recovery. + +`sendTextCommand` return values are provider-dependent. Gemini returns `true` +when it admits the request locally (sent or queued) and `false` for empty input, +an expiring session or failed local admission. Its empty-input and expiry checks +precede connection validation; ordinary nonempty input requires an open socket. +Oversized text and some context or queue-limit violations throw. A queued request +can still be superseded or fail its bounded wait, which status events report. +OpenAI returns `undefined` for successful submission or connected empty input, +`false` when sending the user conversation item fails, and throws when it is not +connected. Local admission does not guarantee provider delivery or execution. + +For authoritative typed input and OpenAI speech, provider classification +normalizes sentence punctuation at word boundaries, so "Switch to. Gemini." +still identifies the direct request. Later authoritative segments can cancel +cooperative pending resources, including generic and CCTV layer initialization. +Pending annotations and their deferred outlines use the calling voice operation's +cancellation signal. Stop, interruption and replacement cancel only that work; +already committed marks and unrelated annotation owners remain. Completed actions +and map-stack changes without caller cancellation cannot be undone. Gemini +captions do not supply cancellation authority. +Gemini retires old replies before forwarding deferred input; continued old output +refreshes its five-second inactivity bound while deferred-input deadlines stay +absolute. Stop and replacement invalidate both waits and their continuations. +OpenAI input with explicitly disabled or failed optional transcription retains +its authentic speech identity for ordinary tools. Missing, empty or untracked +input cannot supply tool authority, and provider switching remains manual. + +OpenAI response reservations remain ordered: a delayed response must consume its +own slot. More than 16 unmatched reservations ends the owned session and shows +"Voice lost turn tracking. Start voice again." A deliberate restart restores a +fresh transport and turn tracker; it never automatically reconnects or assigns +old responses to new input. A missing response before overflow remains ambiguous +with a delayed one, so this does not establish native leak frequency or repair +every possible FIFO mismatch. Run the unit suite and both live recorded-audio modes against a configured local server: diff --git a/pinokio/_ENVIRONMENT b/pinokio/_ENVIRONMENT index 243f60615..88ddee92a 100644 --- a/pinokio/_ENVIRONMENT +++ b/pinokio/_ENVIRONMENT @@ -14,6 +14,11 @@ # GOOGLE_MAPS_SERVER_API_KEY= # CESIUM_ION_TOKEN= # OPENAI_API_KEY= +# Gemini voice uses a separate Gemini API key created in Google AI Studio, +# separate from Google Maps: https://aistudio.google.com/apikey +# Setup and security: https://ai.google.dev/gemini-api/docs/api-key +# GEMINI_API_KEY= +# GEMINI_LIVE_MODEL=gemini-3.8-live # AISSTREAM_API_KEY= # FIRMS_MAP_KEY= # TOMTOM_API_KEY= @@ -29,4 +34,5 @@ PINOKIO_SHARE_VAR=__gev_sharing_disabled__ # App-level guards, not billing caps. Provider-side budgets remain authoritative. GEV_RATELIMIT_OPENAI_PER_MIN=30 +GEV_RATELIMIT_GEMINI_PER_MIN=30 GEV_RATELIMIT_GOOGLE_PER_MIN=120 diff --git a/pinokio/install.js b/pinokio/install.js index 3e9b72d7c..a796bfa97 100644 --- a/pinokio/install.js +++ b/pinokio/install.js @@ -20,6 +20,8 @@ module.exports = { GOOGLE_MAPS_API_KEY: '{{env.GOOGLE_MAPS_API_KEY || ""}}', CESIUM_ION_TOKEN: '{{env.CESIUM_ION_TOKEN || ""}}', OPENAI_API_KEY: '{{env.OPENAI_API_KEY || ""}}', + GEMINI_API_KEY: '{{env.GEMINI_API_KEY || ""}}', + GEMINI_LIVE_MODEL: '{{env.GEMINI_LIVE_MODEL || ""}}', AISSTREAM_API_KEY: '{{env.AISSTREAM_API_KEY || ""}}', FIRMS_MAP_KEY: '{{env.FIRMS_MAP_KEY || ""}}', TOMTOM_API_KEY: '{{env.TOMTOM_API_KEY || ""}}', @@ -27,6 +29,7 @@ module.exports = { OPENSKY_CLIENT_SECRET: '{{env.OPENSKY_CLIENT_SECRET || ""}}', LL2_API_TOKEN: '{{env.LL2_API_TOKEN || ""}}', GEV_RATELIMIT_OPENAI_PER_MIN: '{{env.GEV_RATELIMIT_OPENAI_PER_MIN || ""}}', + GEV_RATELIMIT_GEMINI_PER_MIN: '{{env.GEV_RATELIMIT_GEMINI_PER_MIN || ""}}', GEV_RATELIMIT_GOOGLE_PER_MIN: '{{env.GEV_RATELIMIT_GOOGLE_PER_MIN || ""}}', }, message: 'node scripts/pinokio-install.mjs', diff --git a/pinokio/start.js b/pinokio/start.js index a3ae847d3..e90a9d140 100644 --- a/pinokio/start.js +++ b/pinokio/start.js @@ -12,6 +12,8 @@ module.exports = { GOOGLE_MAPS_API_KEY: '{{env.GOOGLE_MAPS_API_KEY || ""}}', CESIUM_ION_TOKEN: '{{env.CESIUM_ION_TOKEN || ""}}', OPENAI_API_KEY: '{{env.OPENAI_API_KEY || ""}}', + GEMINI_API_KEY: '{{env.GEMINI_API_KEY || ""}}', + GEMINI_LIVE_MODEL: '{{env.GEMINI_LIVE_MODEL || ""}}', AISSTREAM_API_KEY: '{{env.AISSTREAM_API_KEY || ""}}', FIRMS_MAP_KEY: '{{env.FIRMS_MAP_KEY || ""}}', TOMTOM_API_KEY: '{{env.TOMTOM_API_KEY || ""}}', @@ -22,6 +24,7 @@ module.exports = { PINOKIO_SHARE_LOCAL: '{{env.PINOKIO_SHARE_LOCAL || "false"}}', PINOKIO_SHARE_VAR: '{{env.PINOKIO_SHARE_VAR || "__gev_sharing_disabled__"}}', GEV_RATELIMIT_OPENAI_PER_MIN: '{{env.GEV_RATELIMIT_OPENAI_PER_MIN || ""}}', + GEV_RATELIMIT_GEMINI_PER_MIN: '{{env.GEV_RATELIMIT_GEMINI_PER_MIN || ""}}', GEV_RATELIMIT_GOOGLE_PER_MIN: '{{env.GEV_RATELIMIT_GOOGLE_PER_MIN || ""}}', }, message: 'node scripts/pinokio-start.mjs', diff --git a/pinokio/update.js b/pinokio/update.js index 7d9a9a457..141da19ff 100644 --- a/pinokio/update.js +++ b/pinokio/update.js @@ -11,6 +11,8 @@ module.exports = { GOOGLE_MAPS_API_KEY: '{{env.GOOGLE_MAPS_API_KEY || ""}}', CESIUM_ION_TOKEN: '{{env.CESIUM_ION_TOKEN || ""}}', OPENAI_API_KEY: '{{env.OPENAI_API_KEY || ""}}', + GEMINI_API_KEY: '{{env.GEMINI_API_KEY || ""}}', + GEMINI_LIVE_MODEL: '{{env.GEMINI_LIVE_MODEL || ""}}', AISSTREAM_API_KEY: '{{env.AISSTREAM_API_KEY || ""}}', FIRMS_MAP_KEY: '{{env.FIRMS_MAP_KEY || ""}}', TOMTOM_API_KEY: '{{env.TOMTOM_API_KEY || ""}}', @@ -18,6 +20,7 @@ module.exports = { OPENSKY_CLIENT_SECRET: '{{env.OPENSKY_CLIENT_SECRET || ""}}', LL2_API_TOKEN: '{{env.LL2_API_TOKEN || ""}}', GEV_RATELIMIT_OPENAI_PER_MIN: '{{env.GEV_RATELIMIT_OPENAI_PER_MIN || ""}}', + GEV_RATELIMIT_GEMINI_PER_MIN: '{{env.GEV_RATELIMIT_GEMINI_PER_MIN || ""}}', GEV_RATELIMIT_GOOGLE_PER_MIN: '{{env.GEV_RATELIMIT_GOOGLE_PER_MIN || ""}}', }, message: 'node scripts/pinokio-update.mjs', diff --git a/scripts/dev-fresh.sh b/scripts/dev-fresh.sh index 69d3ac319..8afc43605 100755 --- a/scripts/dev-fresh.sh +++ b/scripts/dev-fresh.sh @@ -56,6 +56,7 @@ KEY_SETUP_EXTERNAL_KEYS=() [[ -n "${GOOGLE_MAPS_SERVER_API_KEY:-}" ]] && KEY_SETUP_EXTERNAL_KEYS+=(GOOGLE_MAPS_SERVER_API_KEY) [[ -n "${CESIUM_ION_TOKEN:-}" ]] && KEY_SETUP_EXTERNAL_KEYS+=(CESIUM_ION_TOKEN) [[ -n "${OPENAI_API_KEY:-}" ]] && KEY_SETUP_EXTERNAL_KEYS+=(OPENAI_API_KEY) +[[ -n "${GEMINI_API_KEY:-}" ]] && KEY_SETUP_EXTERNAL_KEYS+=(GEMINI_API_KEY) [[ -n "${AISSTREAM_API_KEY:-}" ]] && KEY_SETUP_EXTERNAL_KEYS+=(AISSTREAM_API_KEY) [[ -n "${FIRMS_MAP_KEY:-}" ]] && KEY_SETUP_EXTERNAL_KEYS+=(FIRMS_MAP_KEY) [[ -n "${TOMTOM_API_KEY:-}" ]] && KEY_SETUP_EXTERNAL_KEYS+=(TOMTOM_API_KEY) @@ -233,6 +234,9 @@ resolve_opensky_credentials # Add to Keychain with e.g.: # security add-generic-password -U -s "openai-api" -a "api-key" -w OPENAI_API_KEY="${OPENAI_API_KEY:-$(read_dotenv_value "OPENAI_API_KEY")}" +# Gemini uses its own voice credential, never the Google Maps key or Keychain. +GEMINI_API_KEY="${GEMINI_API_KEY:-$(read_dotenv_value "GEMINI_API_KEY")}" +GEMINI_LIVE_MODEL="${GEMINI_LIVE_MODEL:-$(read_dotenv_value "GEMINI_LIVE_MODEL")}" AISSTREAM_API_KEY="${AISSTREAM_API_KEY:-$(read_dotenv_value "AISSTREAM_API_KEY")}" CESIUM_ION_TOKEN="${CESIUM_ION_TOKEN:-$(read_dotenv_value "CESIUM_ION_TOKEN")}" LL2_API_TOKEN="${LL2_API_TOKEN:-$(read_dotenv_value "LL2_API_TOKEN")}" @@ -347,7 +351,13 @@ case "${OPENSKY_AUTH_MODE}" in echo "OpenSky auth: disabled (anonymous mode)" ;; esac -[[ -n "${OPENAI_API_KEY}" ]] && echo "OpenAI key (voice + HUD summary): configured" || echo "OpenAI key (voice + HUD summary): not set — GEV MIC off unless a Codex ChatGPT sign-in is selected in Provider Settings (experimental)" +[[ -n "${OPENAI_API_KEY}" ]] && echo "OpenAI key (voice + HUD summary): configured" || echo "OpenAI key (voice + HUD summary): not set" +[[ -n "${GEMINI_API_KEY}" ]] && echo "Gemini key (voice): configured" || echo "Gemini key (voice): not set" +if [[ -n "${OPENAI_API_KEY}" || -n "${GEMINI_API_KEY}" ]]; then + echo "Voice control: available" +else + echo "Voice control: add an OpenAI or Gemini key, or select a configured OpenAI provider in Provider Settings" +fi [[ -n "${AISSTREAM_API_KEY}" ]] && echo "AISStream key (live vessels): configured" || echo "AISStream key (live vessels): not set — ships layer empty" if [[ -n "${GOOGLE_MAPS_API_KEY}" ]]; then echo "Startup map: Google Photorealistic 3D Tiles (direct)" @@ -415,6 +425,8 @@ put_env_if_set OPENSKY_CLIENT_SECRET "${OPENSKY_CLIENT_SECRET}" put_env_if_set OPENSKY_USERNAME "${OPENSKY_USERNAME}" put_env_if_set OPENSKY_PASSWORD "${OPENSKY_PASSWORD}" put_env_if_set OPENAI_API_KEY "${OPENAI_API_KEY}" +put_env_if_set GEMINI_API_KEY "${GEMINI_API_KEY}" +put_env_if_set GEMINI_LIVE_MODEL "${GEMINI_LIVE_MODEL}" put_env_if_set AISSTREAM_API_KEY "${AISSTREAM_API_KEY}" put_env_if_set CESIUM_ION_TOKEN "${CESIUM_ION_TOKEN}" put_env_if_set TOMTOM_API_KEY "${TOMTOM_API_KEY}" diff --git a/scripts/format-scope.json b/scripts/format-scope.json index c62c6886f..6f8b3536d 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -1054,5 +1054,24 @@ "src/tooling/webAppIcons.test.mjs", "src/ui/flyToDataSource.js", "src/ui/flyToDataSource.test.mjs", - "src/data/gbfsProxyRateLimit.test.mjs" + "src/data/gbfsProxyRateLimit.test.mjs", + "src/voice/commands.test.mjs", + "src/voice/providerLifetime.test.mjs", + "src/voice/providerCapabilities.js", + "src/voice/providerCapabilities.test.mjs", + "src/voice/providerIntent.js", + "src/voice/providerIntent.test.mjs", + "src/voice/providerIntentFragments.js", + "src/voice/providerIntentFragments.test.mjs", + "src/voice/toolExecutionPolicy.js", + "src/voice/toolExecutionPolicy.test.mjs", + "src/voice/userTurnIntent.js", + "src/voice/userTurnIntent.test.mjs", + "scripts/qa-gemini-voice.mjs", + "src/geminiServer.test.mjs", + "src/voice/geminiAudio.test.mjs", + "src/voice/geminiAutomaticVad.test.mjs", + "src/voice/geminiSession.test.mjs", + "src/voice/integrationOwnership.test.mjs", + "src/voice/providerCommands.test.mjs" ] diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index f6131dcd4..78f855a48 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -313,6 +313,7 @@ "server/providers/openai/debug-log.js", "server/providers/openai/hud-summary.js", "server/providers/openai/instructions.js", + "src/voice/providerCapabilities.js", "server/providers/openai/rate-limit.js", "server/providers/openai/realtime.js", "server/providers/openai/tools.js", @@ -1408,6 +1409,10 @@ "src/voice/layerSummary.js", "src/voice/voiceCost.js", "src/voice/commands.js", + "src/voice/providerCommands.js", + "src/voice/geminiSession.js", + "src/voice/geminiAudio.js", + "src/voice/geminiCaptureWorklet.js", "src/voice/session.js", "src/voice/sessionCommands.js", "src/voice/realtimeSession.js", @@ -1434,7 +1439,12 @@ "src/voice/narration.js", "src/voice/earcon.js", "src/voice/pointerContext.js", - "src/voice/pointerReticle.js" + "src/voice/pointerReticle.js", + "src/voice/providerCapabilities.js", + "src/voice/providerIntent.js", + "src/voice/providerIntentFragments.js", + "src/voice/userTurnIntent.js", + "src/voice/toolExecutionPolicy.js" ], "external": [] }, @@ -2011,6 +2021,10 @@ "src/ui/visualPresets.js", "src/ui/visualSettings.js", "src/voice/commands.js", + "src/voice/providerCommands.js", + "src/voice/geminiSession.js", + "src/voice/geminiAudio.js", + "src/voice/geminiCaptureWorklet.js", "src/voice/control.js", "src/voice/gevActions.js", "src/voice/speech.js", @@ -2261,7 +2275,6 @@ "src/data/cameraArrival.js", "src/layers/alpr/floors.js", "src/data/militaryNames.js", - "src/sources/requestDeadline.js", "src/data/local_data/osm_military_names/names.json", "src/layers/installations/namedMarkers.js", "src/data/infrastructureOverlayEntry.js", @@ -2273,6 +2286,11 @@ "src/data/cameraFraming.js", "src/data/geoImportPins.js", "src/data/geoImportImages.js", + "src/voice/providerCapabilities.js", + "src/voice/providerIntent.js", + "src/voice/providerIntentFragments.js", + "src/voice/userTurnIntent.js", + "src/voice/toolExecutionPolicy.js", "src/app/layers/airQuality.js", "src/layers/airQuality/index.js", "src/layers/airQuality/model.js", @@ -2822,7 +2840,6 @@ "src/data/cameraArrival.js", "src/layers/alpr/floors.js", "src/data/militaryNames.js", - "src/sources/requestDeadline.js", "src/data/local_data/osm_military_names/names.json", "src/layers/installations/namedMarkers.js", "src/data/infrastructureOverlayEntry.js", diff --git a/scripts/pinokio-environment.mjs b/scripts/pinokio-environment.mjs index b32b7c18b..6df231368 100644 --- a/scripts/pinokio-environment.mjs +++ b/scripts/pinokio-environment.mjs @@ -11,6 +11,8 @@ export const PINOKIO_CONFIG_FIELDS = Object.freeze([ 'GOOGLE_MAPS_SERVER_API_KEY', 'CESIUM_ION_TOKEN', 'OPENAI_API_KEY', + 'GEMINI_API_KEY', + 'GEMINI_LIVE_MODEL', 'AISSTREAM_API_KEY', 'FIRMS_MAP_KEY', 'TOMTOM_API_KEY', @@ -18,6 +20,7 @@ export const PINOKIO_CONFIG_FIELDS = Object.freeze([ 'OPENSKY_CLIENT_SECRET', 'LL2_API_TOKEN', 'GEV_RATELIMIT_OPENAI_PER_MIN', + 'GEV_RATELIMIT_GEMINI_PER_MIN', 'GEV_RATELIMIT_GOOGLE_PER_MIN', 'PINOKIO_SHARE_CLOUDFLARE', 'PINOKIO_SHARE_LOCAL', @@ -26,6 +29,7 @@ export const PINOKIO_CONFIG_FIELDS = Object.freeze([ const PINOKIO_DEFAULTS = Object.freeze({ GEV_RATELIMIT_OPENAI_PER_MIN: '30', + GEV_RATELIMIT_GEMINI_PER_MIN: '30', GEV_RATELIMIT_GOOGLE_PER_MIN: '120', PINOKIO_SHARE_CLOUDFLARE: 'false', PINOKIO_SHARE_LOCAL: 'false', diff --git a/scripts/qa-gemini-voice.mjs b/scripts/qa-gemini-voice.mjs new file mode 100644 index 000000000..b0ea02af1 --- /dev/null +++ b/scripts/qa-gemini-voice.mjs @@ -0,0 +1,246 @@ +#!/usr/bin/env node +/** Real Gemini acceptance: feed a WAV through Chromium's microphone and await spoken output. + * Example: node scripts/qa-gemini-voice.mjs --wav path/to/question.wav --expect-response 'four|4' + * Requires a configured Gemini key in the running app; never reads the permanent key. + */ +import fs from 'node:fs/promises'; +import path from 'node:path'; +import { createHash } from 'node:crypto'; +import puppeteer from 'puppeteer'; + +const args = process.argv.slice(2); +const option = (name, fallback) => { + const index = args.indexOf(name); + return index < 0 ? fallback : args[index + 1]; +}; +const appUrl = new URL(option('--url', 'http://127.0.0.1:43981/')); +appUrl.searchParams.set('welcome', '0'); +const wav = option('--wav'); +if (!wav) + throw new Error( + 'Pass --wav with a PCM WAV containing the spoken test request.', + ); +const wavPath = path.resolve(wav); +const fixture = await fs.readFile(wavPath); +if ( + fixture.toString('ascii', 0, 4) !== 'RIFF' || + fixture.toString('ascii', 8, 12) !== 'WAVE' +) + throw new Error('The microphone fixture must be a WAV file.'); +const expectedResponse = option('--expect-response', 'four|4'); +const expectedInput = option('--expect-input', 'two plus two|2 plus 2|2 \\+ 2'); +const requiredAction = option('--expect-action', ''); +const minimumHeight = Number(option('--min-height', '0')); +const outDir = path.resolve(option('--out', 'qa-shots/gemini-voice')); +await fs.mkdir(outDir, { recursive: true }); +const browser = await puppeteer.launch({ + headless: !args.includes('--headed'), + args: [ + '--use-fake-device-for-media-stream', + `--use-file-for-fake-audio-capture=${wavPath}%noloop`, + '--disable-background-timer-throttling', + '--disable-renderer-backgrounding', + '--window-size=1440,900', + ], +}); +let result; +try { + const context = browser.defaultBrowserContext(); + await context.overridePermissions(appUrl.origin, ['microphone']); + const page = await browser.newPage(); + await page.setViewport({ width: 1440, height: 900, deviceScaleFactor: 1 }); + // Only the test input device is substituted. GEV still captures this browser + // MediaStream through its real AudioWorklet and calls the real Gemini API. + if (args.includes('--virtual-mic')) { + await page.evaluateOnNewDocument((base64) => { + const original = navigator.mediaDevices.getUserMedia.bind( + navigator.mediaDevices, + ); + navigator.mediaDevices.getUserMedia = async (constraints) => { + if (!constraints.audio || constraints.video) + return original(constraints); + const context = new AudioContext(); + await context.resume(); + const bytes = Uint8Array.from(atob(base64), (char) => + char.charCodeAt(0), + ); + const buffer = await context.decodeAudioData(bytes.buffer); + const source = context.createBufferSource(); + source.buffer = buffer; + const destination = context.createMediaStreamDestination(); + source.connect(destination); + const stream = destination.stream; + const track = stream.getAudioTracks()[0]; + const stop = track.stop.bind(track); + track.stop = () => { + try { + source.stop(); + } catch { + /* Already ended. */ + } + source.disconnect(); + destination.disconnect(); + void context.close().catch(() => {}); + stop(); + }; + source.start(context.currentTime + 0.25); + return stream; + }; + }, fixture.toString('base64')); + } + const tokenStatuses = []; + page.on('response', (response) => { + if (new URL(response.url()).pathname === '/api/gemini/token') + tokenStatuses.push(response.status()); + }); + await page.goto(appUrl.href, { waitUntil: 'domcontentloaded' }); + await page.waitForFunction( + () => { + const loading = document.getElementById('loading-screen'); + return ( + window.__godsEyeView?.voiceCommands && + document.getElementById('gev-voice-provider') && + (!loading || + getComputedStyle(loading).display === 'none' || + Number(getComputedStyle(loading).opacity) === 0) + ); + }, + { timeout: 90000 }, + ); + await page.select('#gev-voice-provider', 'gemini'); + await page.evaluate(() => { + window.__geminiAcceptance = { + transcripts: [], + inputSegments: [], + actions: [], + }; + window.__godsEyeView.voiceCommands.session.subscribe((event) => { + if ( + event.type === 'transcript' && + event.role === 'user' && + event.attribution === 'unverified' && + event.delta === true && + event.final === false && + typeof event.text === 'string' + ) + window.__geminiAcceptance.inputSegments.push(event.text); + if (event.type === 'transcript' && event.final) + window.__geminiAcceptance.transcripts.push({ + role: event.role, + text: event.text, + }); + if (event.type === 'action-result') + window.__geminiAcceptance.actions.push({ + name: event.name, + ok: event.result?.ok === true, + }); + }); + }); + const began = Date.now(); + await page.click('#gev-voice-button'); + let timedOut = false; + try { + await page.waitForFunction( + () => { + const voice = window.__godsEyeView.voiceCommands; + const d = voice.getDiagnostics(); + return ( + d.status === 'error' || + (d.completedTurns > 0 && + d.audio?.outputSamples > 0 && + (d.audio.discardedOutputSamples > 0 || + (d.audio.completedOutputSamples === d.audio.outputSamples && + d.audio.queuedSeconds < 0.05))) + ); + }, + { timeout: 75000 }, + ); + } catch { + timedOut = true; + } + const proof = await page.evaluate(() => ({ + ...window.__geminiAcceptance, + diagnostics: window.__godsEyeView.voiceCommands.getDiagnostics(), + provider: document.querySelector('#gev-voice-provider').value, + detail: document.querySelector('#gev-voice-detail').textContent, + cameraHeightM: + window.__godsEyeView.viewer.camera.positionCartographic.height, + })); + // Fixture-level caption evidence only: these unordered native segments do + // not identify a command, authorize a tool, or form a final user transcript. + const userText = proof.inputSegments.join(''); + const assistantText = proof.transcripts + .filter((item) => item.role === 'assistant') + .map((item) => item.text) + .join(' '); + // A completed Radio handoff releases Gemini's speaker and microphone. + // Closed audio is valid only when the successful handoff is already idle. + const completedRadioHandoff = + proof.actions.some( + (action) => action.name === 'control_radio' && action.ok, + ) && + proof.diagnostics.status === 'idle' && + !proof.diagnostics.connected && + !proof.diagnostics.microphoneActive && + proof.diagnostics.audio?.resourcesReleased === true; + const checks = { + geminiSelected: proof.provider === 'gemini', + tokenGranted: tokenStatuses.includes(200), + heardInput: new RegExp(expectedInput, 'i').test(userText), + expectedReply: new RegExp(expectedResponse, 'i').test(assistantText), + realMicrophoneFrames: + proof.diagnostics.audio?.inputChunks > 0 && + proof.diagnostics.audio?.nonSilentInputChunks > 0, + playedReply: + proof.diagnostics.audio?.outputSamples > 0 && + proof.diagnostics.audio.completedOutputSamples === + proof.diagnostics.audio.outputSamples && + proof.diagnostics.audio.discardedOutputSamples === 0 && + proof.diagnostics.audio.queuedSeconds < 0.05 && + (proof.diagnostics.audio.contextState === 'running' || + (completedRadioHandoff && + proof.diagnostics.audio.contextState === 'closed')), + completed: + !timedOut && + proof.diagnostics.completedTurns > 0 && + proof.diagnostics.status !== 'error', + action: + !requiredAction || + proof.actions.some( + (action) => action.name === requiredAction && action.ok, + ), + camera: !minimumHeight || proof.cameraHeightM >= minimumHeight, + }; + await page.screenshot({ path: path.join(outDir, 'response.png') }); + // Stop is idempotent; toggling the button could restart an idle session + // after Radio has taken the speaker between proof collection and cleanup. + await page.evaluate(() => window.__godsEyeView.voiceCommands.stop()); + const stopped = await page.evaluate(() => + window.__godsEyeView.voiceCommands.getDiagnostics(), + ); + checks.stopped = + stopped.status === 'idle' && + !stopped.connected && + !stopped.microphoneActive; + result = { + ok: Object.values(checks).every(Boolean), + microphoneSource: args.includes('--virtual-mic') + ? 'Web Audio virtual microphone from macOS WAV' + : 'Chromium WAV capture device', + checks, + elapsedMs: Date.now() - began, + fixtureSha256: createHash('sha256').update(fixture).digest('hex'), + tokenStatuses, + ...proof, + stopped, + }; + await fs.writeFile( + path.join(outDir, 'result.json'), + JSON.stringify(result, null, 2) + '\n', + ); + console.log(JSON.stringify(result, null, 2)); + console.log(`VERDICT: ${result.ok ? 'PASS' : 'FAIL'}`); + process.exitCode = result.ok ? 0 : 1; +} finally { + await browser.close(); +} diff --git a/scripts/qa-panel-resize.mjs b/scripts/qa-panel-resize.mjs index d4c5b9287..194e877a3 100644 --- a/scripts/qa-panel-resize.mjs +++ b/scripts/qa-panel-resize.mjs @@ -298,11 +298,11 @@ async function main() { ); console.log('PASS: header drag lifts CCTV out of the rail'); - // Park near the top: the voice dock would cover the south corners once - // the loop below has grown the window. + // Park high enough that the voice provider row cannot cover the southwest + // handle after the loop below has grown the window. { const header = await headerPoint(); - await drag(header, 0, 120 - header.y); + await drag(header, 0, 40 - header.y); } const limits = { @@ -346,6 +346,28 @@ async function main() { assert.deepEqual(driftedEdges(before, after, 'nw', 2), []); console.log('PASS: minimum size holds from both corners'); + // Minimum-size clamps do not move the window away from the top edge. + // Drag it down through the real header before proving north-edge growth. + { + const header = await headerPoint(); + await drag(header, 0, 140 - header.y); + } + for (const dir of ['n', 'ne']) { + before = await readPanel(); + const { dx, dy } = growthDelta(dir, 40); + const expected = resizeBox(before, dir, dx, dy, limits); + assert.ok( + expected.top <= before.top - 38, + `${dir} has room to grow north`, + ); + await drag(handlePoint(before, dir), dx, dy); + after = await readPanel(); + for (const key of ['left', 'top', 'width', 'height']) + near(after[key], expected[key], 2, `${dir} north ${key}`); + assert.deepEqual(driftedEdges(before, after, dir, 2), [], `${dir} pins`); + } + console.log('PASS: north edges grow from the minimum size'); + const saved = await readPanel(); const record = JSON.parse(saved.stored); assert.equal(record.floating, true); diff --git a/scripts/qa-voice-routing.mjs b/scripts/qa-voice-routing.mjs index f1b2b0ced..f91fc5230 100644 --- a/scripts/qa-voice-routing.mjs +++ b/scripts/qa-voice-routing.mjs @@ -2182,7 +2182,7 @@ async function runBehaviorLayer() { ?.get('military-awareness') ?.module?.getContextSnapshot?.(); const cohort = snap?.cohorts?.find((c) => c.id === 'flights'); - return cohort ? cohort.count : null; + return Number.isFinite(cohort?.count) ? cohort.count : 'unknown'; }); const windowBlock = r?.contactsWindow || null; report( diff --git a/scripts/setup-doctor.mjs b/scripts/setup-doctor.mjs index 49c6722c2..bf284c2c3 100644 --- a/scripts/setup-doctor.mjs +++ b/scripts/setup-doctor.mjs @@ -16,6 +16,7 @@ export const CREDENTIALS = Object.freeze([ { name: 'GOOGLE_MAPS_SERVER_API_KEY', label: 'Google Places / Street View server', keychain: [] }, { name: 'CESIUM_ION_TOKEN', label: 'Cesium ion', keychain: [['cesium-ion', 'token']] }, { name: 'OPENAI_API_KEY', label: 'OpenAI voice', keychain: [['openai-api', 'api-key']] }, + { name: 'GEMINI_API_KEY', label: 'Gemini voice', keychain: [] }, { name: 'AISSTREAM_API_KEY', label: 'AISStream vessels', keychain: [['aisstream-api', 'api-key']] }, { name: 'FIRMS_MAP_KEY', label: 'NASA FIRMS fires', keychain: [['firms-map', 'map-key']] }, { name: 'TOMTOM_API_KEY', label: 'TomTom traffic', keychain: [['tomtom-api', 'api-key']] }, @@ -176,9 +177,9 @@ export function buildCapabilitySummary( ? 'Google Photorealistic 3D Tiles through Cesium ion; Bing and world-terrain stacks available' : 'Esri World Imagery (keyless satellite basemap) with keyless terrain', flights, - voice: configured('OPENAI_API_KEY') + voice: configured('OPENAI_API_KEY') || configured('GEMINI_API_KEY') ? 'available' - : 'off until an OpenAI key is added, or a Codex ChatGPT sign-in is selected in Provider Settings (experimental)', + : 'off until an OpenAI or Gemini key is added, or a configured OpenAI provider is selected in Provider Settings', vessels: configured('AISSTREAM_API_KEY') ? 'live AISStream feed' : 'off until an AISStream key is added', fires: configured('FIRMS_MAP_KEY') ? 'live NASA FIRMS feed' : 'off until a FIRMS key is added', traffic: configured('TOMTOM_API_KEY') ? 'live TomTom flow' : 'built-in traffic simulation', diff --git a/scripts/voice-bench/phrases.mjs b/scripts/voice-bench/phrases.mjs index 2f3f73a4c..c39a73357 100644 --- a/scripts/voice-bench/phrases.mjs +++ b/scripts/voice-bench/phrases.mjs @@ -82,7 +82,7 @@ export const CORE_PHRASES = Object.freeze([ { phrase: 'Show me the nearest traffic camera', expect: 'control_cctv', typedArgs: [where('control_cctv', (a) => a.action === 'nearest')] }, { phrase: 'Turn on the camera viewsheds', expect: 'control_cctv', typedArgs: [where('control_cctv', (a) => a.action === 'viewshed' && a.enabled !== false)] }, { phrase: 'Play a news radio station near Austin', expect: 'control_radio', args: { action: 'select', category: 'news', locationId: 'austin' }, typedArgs: [where('control_radio', (a) => a.action === 'select' && a.category === 'news' && a.locationId === 'austin')] }, - { phrase: 'Turn on the radio', expect: 'control_radio', args: { action: 'play' }, typedArgs: [where('control_radio', (a) => a.action === 'play')] }, + { phrase: 'Turn on the radio', expect: 'control_radio', args: { action: 'enable' }, typedArgs: [where('control_radio', (a) => a.action === 'enable')] }, { phrase: 'Set the radio volume to thirty percent', expect: 'control_radio', args: { action: 'volume', volumePct: 30 } }, { phrase: 'Pause the radio', expect: 'control_radio', args: { action: 'pause' }, typedArgs: [where('control_radio', (a) => a.action === 'pause')] }, { phrase: 'Stop the radio', expect: 'control_radio', args: { action: 'stop' }, typedArgs: [where('control_radio', (a) => a.action === 'stop')] }, @@ -110,7 +110,7 @@ export const CORE_PHRASES = Object.freeze([ { phrase: 'Go to full planet view and then turn on the radio', expect: ['zoom_to_globe', 'control_radio'], - argsByTool: { control_radio: { action: 'play' } }, + argsByTool: { control_radio: { action: 'enable' } }, }, // — camera verbs (tools #23/#24) — @@ -507,8 +507,8 @@ export const DIALOGUES = Object.freeze([ { id: 'radio-then-volume', turns: ['Turn on the radio', 'Turn it down to twenty percent'], - results: { control_radio: [{ ok: true, prepared: true, action: 'play', station: 'KUT 90.5 Austin' }] }, - first: { expect: 'control_radio', typedArgs: [where('control_radio', (a) => a.action === 'play')] }, + results: { control_radio: [{ ok: true, enabled: true, action: 'enable', audioState: 'stopped' }] }, + first: { expect: 'control_radio', typedArgs: [where('control_radio', (a) => a.action === 'enable')] }, second: { expect: 'control_radio', typedArgs: [where('control_radio', (a) => a.action === 'volume' && a.volumePct === 20)] }, }, { diff --git a/server/providers/gemini.js b/server/providers/gemini.js new file mode 100644 index 000000000..b3fe29e80 --- /dev/null +++ b/server/providers/gemini.js @@ -0,0 +1,22 @@ +import { createGeminiTokenHandler } from './gemini/realtime.js'; + +/** Mint constrained Live API credentials; the permanent key stays on Node. */ +function geminiLiveProxy({ annotationGuidance, realtime = {} } = {}) { + function install(middlewares) { + middlewares.use( + '/api/gemini/token', + createGeminiTokenHandler({ ...realtime, annotationGuidance }), + ); + } + return { + name: 'gemini-live-proxy', + configureServer(server) { + install(server.middlewares); + }, + configurePreviewServer(server) { + install(server.middlewares); + }, + }; +} + +export { geminiLiveProxy }; diff --git a/server/providers/gemini/config.js b/server/providers/gemini/config.js new file mode 100644 index 000000000..b15455cab --- /dev/null +++ b/server/providers/gemini/config.js @@ -0,0 +1,54 @@ +import { realtimeInstructions } from '../openai/instructions.js'; +import { GEV_REALTIME_TOOLS } from '../openai/tools.js'; + +export const GEMINI_LIVE_MODEL_DEFAULT = 'gemini-3.8-live'; +export const GEMINI_TOKEN_ENDPOINT = + 'https://generativelanguage.googleapis.com/v1beta/auth_tokens'; +export const GEMINI_LIVE_WEBSOCKET_URL = + 'wss://generativelanguage.googleapis.com/ws/google.ai.generativelanguage.v1beta.GenerativeService.BidiGenerateContentConstrained'; + +export function normalizeGeminiModel(value) { + const model = String(value ?? GEMINI_LIVE_MODEL_DEFAULT) + .trim() + .replace(/^models\//, ''); + return /^gemini-[a-z0-9][a-z0-9.-]{0,119}$/.test(model) ? model : null; +} + +/** REST BidiGenerateContentSetup fields, excluding its model resource name. */ +export function createGeminiLiveConfig( + annotationGuidance, + inputMode = 'open-mic', + tools = GEV_REALTIME_TOOLS, + capabilities, +) { + if (!['open-mic', 'push-to-talk'].includes(inputMode)) + throw new Error('Invalid Gemini input mode'); + const config = { + generationConfig: { responseModalities: ['AUDIO'] }, + systemInstruction: { + parts: [{ text: realtimeInstructions(annotationGuidance, capabilities) }], + }, + tools: [ + { + functionDeclarations: tools.map( + ({ name, description, parameters }) => ({ + name, + description, + // GEV's canonical schemas are JSON Schema, not Google's Schema. + parametersJsonSchema: structuredClone(parameters), + // App actions must finish before the model confirms their result. + behavior: 'BLOCKING', + }), + ), + }, + ], + inputAudioTranscription: {}, + outputAudioTranscription: {}, + contextWindowCompression: { slidingWindow: {} }, + }; + if (inputMode === 'push-to-talk') + config.realtimeInputConfig = { + automaticActivityDetection: { disabled: true }, + }; + return config; +} diff --git a/server/providers/gemini/realtime.js b/server/providers/gemini/realtime.js new file mode 100644 index 000000000..db3a95b72 --- /dev/null +++ b/server/providers/gemini/realtime.js @@ -0,0 +1,180 @@ +import { clientKey, makeCostRateLimiter } from '../common/rate-limit.js'; +import { readResponseJsonCapped } from '../../../src/sources/httpBody.js'; +import { + GEMINI_TOKEN_ENDPOINT, + GEMINI_LIVE_WEBSOCKET_URL, + GEMINI_LIVE_MODEL_DEFAULT, + createGeminiLiveConfig, + normalizeGeminiModel, +} from './config.js'; +import { + isGeminiRequestOriginAllowed, + readGeminiTokenRequest, +} from './request.js'; + +const UPSTREAM_RESPONSE_BYTES = 32 * 1024; +const MINT_FAILURE = 'Gemini voice could not connect. Try again.'; + +export function createGeminiTokenHandler({ + annotationGuidance, + providerDropdown = false, + tools, + fetchImpl = (...args) => fetch(...args), + resolveApiKey = () => process.env.GEMINI_API_KEY, + resolveModel = () => + process.env.GEMINI_LIVE_MODEL || GEMINI_LIVE_MODEL_DEFAULT, + resolveHost = () => process.env.HOST, + resolveAllowedHosts = () => process.env.GEV_ALLOWED_HOSTS, + resolveRateLimit = () => process.env.GEV_RATELIMIT_GEMINI_PER_MIN ?? '30', + now = Date.now, + timeoutMs = 15_000, +} = {}) { + let limiter; + return async (req, res) => { + res.setHeader('Cache-Control', 'no-store'); + res.setHeader('Content-Type', 'application/json; charset=utf-8'); + res.setHeader('X-Content-Type-Options', 'nosniff'); + const reply = (status, errorOrPayload) => { + if (res.destroyed || res.writableEnded) return; + res.statusCode = status; + if (status >= 400) res.setHeader('Connection', 'close'); + res.end( + JSON.stringify( + typeof errorOrPayload === 'string' + ? { error: errorOrPayload } + : errorOrPayload, + ), + ); + }; + if (req.method !== 'POST') { + res.setHeader('Allow', 'POST'); + return reply(405, 'Method not allowed'); + } + if ( + !isGeminiRequestOriginAllowed(req, resolveHost(), resolveAllowedHosts()) + ) + return reply(403, 'Gemini voice requires a same-origin app request'); + const apiKey = String(resolveApiKey() || '').trim(); + if (!apiKey) return reply(503, 'Add a Gemini API key in Provider Settings'); + const model = normalizeGeminiModel(resolveModel()); + if (!model) + return reply(503, 'The Gemini Live model configuration is invalid'); + if (limiter === undefined) + limiter = makeCostRateLimiter(resolveRateLimit(), 30); + if (limiter && !limiter(clientKey(req))) { + res.setHeader('Retry-After', '60'); + return reply( + 429, + 'Gemini voice is starting too often. Try again shortly.', + ); + } + + const lifetime = new AbortController(); + let disconnected = false; + const disconnect = () => { + disconnected = true; + lifetime.abort(new DOMException('Request closed', 'AbortError')); + }; + const responseClosed = () => { + if (!res.writableEnded) disconnect(); + }; + req.once('aborted', disconnect); + res.once('close', responseClosed); + const deadline = setTimeout(() => { + lifetime.abort( + new DOMException('Token request timed out', 'TimeoutError'), + ); + }, timeoutMs); + deadline.unref?.(); + try { + const inputMode = await readGeminiTokenRequest(req, lifetime.signal); + lifetime.signal.throwIfAborted(); + const config = createGeminiLiveConfig( + annotationGuidance, + inputMode, + tools, + { providerDropdown }, + ); + const issuedAt = now(); + // REST uses bidiGenerateContentSetup. liveConnectConstraints is the SDK + // input shape, converted before transport. With no fieldMask the complete + // setup is locked to this server-owned configuration. + const request = { + uses: 1, + expireTime: new Date(issuedAt + 30 * 60_000).toISOString(), + newSessionExpireTime: new Date(issuedAt + 60_000).toISOString(), + bidiGenerateContentSetup: { model: `models/${model}`, ...config }, + }; + const response = await fetchImpl(GEMINI_TOKEN_ENDPOINT, { + method: 'POST', + redirect: 'error', + signal: lifetime.signal, + headers: { + 'x-goog-api-key': apiKey, + 'Content-Type': 'application/json', + }, + body: JSON.stringify(request), + }); + lifetime.signal.throwIfAborted(); + if ( + response.redirected || + (response.url && response.url !== GEMINI_TOKEN_ENDPOINT) + ) + throw new Error('Unexpected token response destination'); + if (!response.ok) { + // Never forward upstream bodies, headers or exception messages. + void response.body?.cancel().catch(() => {}); + if (response.status === 429) { + res.setHeader('Retry-After', '60'); + return reply(429, 'Gemini quota is unavailable. Try again later.'); + } + if (response.status === 401 || response.status === 403) + return reply( + 503, + 'Gemini access was refused. Check the API key and model access.', + ); + if (response.status === 400 || response.status === 404) + return reply( + 502, + 'Gemini Live is unavailable for the configured model.', + ); + return reply(502, MINT_FAILURE); + } + const data = await readResponseJsonCapped( + response, + UPSTREAM_RESPONSE_BYTES, + lifetime.signal, + ); + lifetime.signal.throwIfAborted(); + if ( + !data || + Array.isArray(data) || + typeof data !== 'object' || + typeof data.name !== 'string' || + data.name.length > 8192 || + !/^auth_tokens\/[A-Za-z0-9._~+/=-]+$/.test(data.name) || + data.name.includes(apiKey) + ) + throw new Error('Invalid ephemeral token'); + reply(200, { + token: data.name, + model, + config, + websocketUrl: GEMINI_LIVE_WEBSOCKET_URL, + }); + } catch (error) { + if (disconnected || req.aborted) return; + if (error?.code === 'BODY_TOO_LARGE') + return reply(413, 'Token request is too large'); + if (error?.code === 'BAD_CONTENT_TYPE') + return reply(415, 'Use an empty JSON token request'); + if (error?.code === 'BAD_BODY') + return reply(400, 'Use an empty JSON token request'); + reply(lifetime.signal.aborted ? 504 : 502, MINT_FAILURE); + } finally { + clearTimeout(deadline); + req.off('aborted', disconnect); + res.off('close', responseClosed); + } + }; +} diff --git a/server/providers/gemini/request.js b/server/providers/gemini/request.js new file mode 100644 index 000000000..6ce807e24 --- /dev/null +++ b/server/providers/gemini/request.js @@ -0,0 +1,117 @@ +import { admitSameSiteRequest } from '../../../src/localRequestGate.mjs'; +import { + isAllowedHost, + resolveAllowedHosts, +} from '../../../build/allowedHosts.js'; + +/** Use the app's Host and request policies, with a required browser Origin. */ +export function isGeminiRequestOriginAllowed( + req, + configuredHost = '', + configuredAllowedHosts, +) { + const host = req.headers?.host; + const origin = req.headers?.origin; + if ( + typeof host !== 'string' || + typeof origin !== 'string' || + !host || + /[\s/?#@\\]/.test(host) + ) + return false; + const protocol = req.socket?.encrypted ? 'https:' : 'http:'; + if ( + !admitSameSiteRequest({ + hostHeader: host, + protocol, + origin, + secFetchSite: req.headers?.['sec-fetch-site'], + proxyHeaders: req.headers || {}, + }).ok + ) + return false; + const boundHost = String(configuredHost || '') + .trim() + .toLowerCase(); + if ( + !isAllowedHost(host, resolveAllowedHosts(configuredAllowedHosts), [ + boundHost, + ]) + ) + return false; + try { + const authority = new URL(`${protocol}//${host}`); + const source = new URL(origin); + return ( + source.origin === authority.origin && + source.username === '' && + source.password === '' && + source.pathname === '/' && + source.search === '' && + source.hash === '' + ); + } catch { + return false; + } +} + +/** Admit only the existing input gesture; callers cannot supply model/setup. */ +export async function readGeminiTokenRequest(req, signal, maxBytes = 1024) { + const declared = Number(req.headers?.['content-length']); + const fail = (code) => + Object.assign(new Error('Invalid token request'), { code }); + if (Number.isFinite(declared) && declared > maxBytes) + throw fail('BODY_TOO_LARGE'); + const body = await new Promise((resolve, reject) => { + let total = 0; + const chunks = []; + const cleanup = () => { + req.off('data', data); + req.off('end', end); + req.off('error', error); + signal.removeEventListener('abort', abort); + }; + const error = (reason) => { + cleanup(); + req.pause(); + reject(reason); + }; + const abort = () => error(signal.reason); + const data = (chunk) => { + total += chunk.length; + if (total > maxBytes) return error(fail('BODY_TOO_LARGE')); + chunks.push(chunk); + }; + const end = () => { + cleanup(); + resolve(Buffer.concat(chunks).toString('utf8')); + }; + req.on('data', data); + req.once('end', end); + req.once('error', error); + signal.addEventListener('abort', abort, { once: true }); + if (signal.aborted) abort(); + }); + if (!body.trim()) return 'open-mic'; + if ( + !/^application\/json(?:\s*;|$)/i.test(req.headers?.['content-type'] || '') + ) + throw fail('BAD_CONTENT_TYPE'); + let parsed; + try { + parsed = JSON.parse(body); + } catch { + throw fail('BAD_BODY'); + } + if (!parsed || Array.isArray(parsed) || typeof parsed !== 'object') + throw fail('BAD_BODY'); + const keys = Object.keys(parsed); + if (!keys.length) return 'open-mic'; + if ( + keys.length === 1 && + keys[0] === 'inputMode' && + parsed.inputMode === 'push-to-talk' + ) + return 'push-to-talk'; + throw fail('BAD_BODY'); +} diff --git a/server/providers/local.js b/server/providers/local.js index 854b77f33..816df3e40 100644 --- a/server/providers/local.js +++ b/server/providers/local.js @@ -19,6 +19,7 @@ import { adsbLolProxy } from './aircraft/adsb-lol.js'; import { aisLiveProxy } from './vessels/ais-live.js'; import { trackBackfillProxies } from './aircraft/tracks.js'; import { openAiRealtimeProxy } from './openai.js'; +import { geminiLiveProxy } from './gemini.js'; import { googlePlacesContextProxy } from './places.js'; import { mapillaryProxy } from './mapillary.js'; import { keySetupEndpoint } from '../standalone/key-setup.js'; @@ -55,6 +56,7 @@ function localProviderPlugins({ realtime } = {}) { aisLiveProxy(), trackBackfillProxies(), openAiRealtimeProxy({ realtime }), + geminiLiveProxy({ realtime }), googlePlacesContextProxy(), windProxy(), weatherProxy(), diff --git a/server/providers/openai/instructions.js b/server/providers/openai/instructions.js index 7d0e35636..a6ff6e5ac 100644 --- a/server/providers/openai/instructions.js +++ b/server/providers/openai/instructions.js @@ -1,10 +1,15 @@ +import { voiceProviderGuidance } from '../../../src/voice/providerCapabilities.js'; + const DEFAULT_ANNOTATION_GUIDANCE = 'Put related places in one annotate_map call. Set flyTo true only when the user is not already looking at the place (off-screen marks auto-frame, so when unsure leave it false). Do not say you are drawing or annotating — talk about the places while the marks appear. Marks ACCUMULATE AND PERSIST across flights and topics so the user can build up the map: 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, + capabilities, ) { return [ + 'When the user explicitly requests Radio playback, the shared client handoff verifies playback before giving Radio the speaker. Enabling the Radio layer alone never requests that handoff. Successful Radio playback, requested or manual, suspends active voice. Voice resumes once when the user pauses or turns off that Radio, with no time limit; Radio-only station or volume changes keep that right. Explicit voice Stop, starting voice, provider replacement and teardown cancel it. Confirm authoritative returned playback state; a prepared result is not already playing.', + `For a clear standalone voice-provider request naming OpenAI or Gemini, or asking to choose a voice provider, say exactly once in your normal voice: “${voiceProviderGuidance(capabilities)}” Do not call a tool, change provider state, or claim the provider changed. Voice volume/speed and map/search providers are not voice-provider requests. Ambiguous, quoted, negated, conditional and compound text remains ordinary conversation.`, "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.', @@ -51,7 +56,7 @@ function realtimeInstructions( 'HUD requests ("hud on/off", "switch to operator/minimal/tactical/cyber 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.', 'Cyber sonar requests use set_cyber_sonar: enabled, rings, rangePct, intensityPct (Power), opacityPct, sectorDeg. For settings questions read get_current_view_state.controls.hud.sonar. Sonar is not detection. Do not silently switch themes, reveal the HUD or enable sonar when changing a slider. If the user explicitly requests Cyber plus sonar settings, call set_hud first, wait for success, then set_cyber_sonar. Confirm returned values; mapSweepActive and contactSweepActive distinguish saved settings from active map effects, including in Cockpit or reduced motion. Do not claim visible contacts solely because enabled is true.', '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.', + 'Radio requests use control_radio. "Turn on/start the radio" means action=enable: reveal the Radio layer/markers without starting audio or selecting a station. Showing or enabling Radio never implies playback, even when a station category or place is mentioned. Playback requires an explicit play, listen, or resume request. Use play for unqualified explicit playback, resume for explicit resume, and select for explicit playback qualified by category, station, country, coordinates, or place. After a prepared playback result, briefly confirm any other completed actions and say playback is being prepared—never claim it is already playing. The client keeps Radio muted until playback is verified, then closes voice before restoring Radio volume. Examples: "turn the radio on" → enable; "show radio stations" → enable; "play the radio" → play; "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 " (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.', '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.', diff --git a/server/providers/openai/realtime.js b/server/providers/openai/realtime.js index 6823c918d..028bc2e34 100644 --- a/server/providers/openai/realtime.js +++ b/server/providers/openai/realtime.js @@ -129,6 +129,7 @@ function createRealtimeOAuthLoginHandler({ function createRealtimeTokenHandler({ annotationGuidance, + providerDropdown = false, endpoint = 'https://api.openai.com/v1/realtime/client_secrets', fetchImpl = (...args) => fetch(...args), resolveApiKey = () => process.env.OPENAI_API_KEY, @@ -298,7 +299,9 @@ function createRealtimeTokenHandler({ }, output: { voice }, }, - instructions: realtimeInstructions(annotationGuidance), + instructions: realtimeInstructions(annotationGuidance, { + providerDropdown, + }), tools, tool_choice: 'auto', }, diff --git a/server/providers/openai/toolDescriptions.js b/server/providers/openai/toolDescriptions.js index 18de877db..677ba8a06 100644 --- a/server/providers/openai/toolDescriptions.js +++ b/server/providers/openai/toolDescriptions.js @@ -314,13 +314,13 @@ export const ACTION_DESCRIPTIONS = { }, 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.', + 'Control Internet Radio without moving the map. "Turn on/start the radio" means enable: reveal the Radio layer/markers without starting audio or selecting a station. Playback requires an explicit play, listen, or resume request. Use select for an explicit playback request qualified by category, station name, country, coordinates, or nearby place—even when the user says play. Use play for unqualified explicit playback. Also supports disable, resume, pause, stop, next/previous, volume, and status.', $position: 1, parameters: { properties: { action: { 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.', + 'Use enable for turn on/start/show/enable Radio or its markers without an explicit playback request. Enable never starts audio or selects a station. Use play for an explicit unqualified play/listen request, resume for explicit resume, and select for explicit playback qualified by category, station, country, coordinates, or place.', $position: 2, }, volumePct: { diff --git a/server/standalone/vite.config.js b/server/standalone/vite.config.js index a14292597..3a1b4cf2a 100644 --- a/server/standalone/vite.config.js +++ b/server/standalone/vite.config.js @@ -17,7 +17,9 @@ export default defineConfig(({ command, mode }) => { } return createBrowserViteConfig({ plugins: [ - ...localProviderPlugins({ realtime: { tools: standaloneVoiceTools() } }), + ...localProviderPlugins({ + realtime: { tools: standaloneVoiceTools(), providerDropdown: true }, + }), localMcpPlugin(), apiNotFoundPlugin(), ], diff --git a/src/annotations/annotationEngine.js b/src/annotations/annotationEngine.js index 27b049bb8..a763da4b9 100644 --- a/src/annotations/annotationEngine.js +++ b/src/annotations/annotationEngine.js @@ -72,16 +72,20 @@ export async function resolveOutlineWithRetry( delaysMs = OUTLINE_RETRY_DELAYS_MS, isStale = () => false, waitFn = wait, + signal, } = {}, ) { let retriedRateLimit = false; for (let attempt = 0; ; attempt += 1) { + if (signal?.aborted || isStale()) return undefined; let fp; try { - fp = await resolveOutline(); - } catch { + fp = await abortable(resolveOutline(), signal); + } catch (error) { + if (signal?.aborted || error?.name === 'AbortError') return undefined; fp = null; // hard failure — definitive, keep the honest point } + if (signal?.aborted || isStale()) return undefined; if (isUnavailableCapability(fp)) return fp; if (isRateLimitedOutcome(fp)) { // A throttle gets one deliberately spaced retry. If that retry is throttled @@ -90,14 +94,27 @@ export async function resolveOutlineWithRetry( const retryAfterMs = Number.isFinite(fp.retryAfterMs) ? fp.retryAfterMs : 0; - await waitFn(Math.max(retryAfterMs, delaysMs[attempt])); + try { + await abortable( + waitFn(Math.max(retryAfterMs, delaysMs[attempt]), signal), + signal, + ); + } catch (error) { + if (signal?.aborted) return undefined; + throw error; + } if (isStale()) return undefined; retriedRateLimit = true; continue; } if (fp !== undefined) return fp; if (attempt >= delaysMs.length) return undefined; // transient budget exhausted - await waitFn(delaysMs[attempt]); + try { + await abortable(waitFn(delaysMs[attempt], signal), signal); + } catch (error) { + if (signal?.aborted) return undefined; + throw error; + } if (isStale()) return undefined; // board cleared/superseded mid-backoff } } @@ -165,14 +182,32 @@ export function createAnnotationEngine({ activeControllers.clear(); } - function dropQueuedOutlineUpgrades() { - while (outlineQueue.length) { - const task = outlineQueue.shift(); - if (task.anno._outlineTask === task) task.anno._outlineTask = null; + function dropQueuedOutlineUpgrades(controller = null) { + for (let i = outlineQueue.length - 1; i >= 0; i -= 1) { + const task = outlineQueue[i]; + if (controller && task.controller !== controller) continue; + outlineQueue.splice(i, 1); + finishOutlineTask(task); releaseController(task.controller); } } + function finishOutlineTask(task) { + if (task.anno._outlineTask !== task) return; + task.anno._outlineTask = null; + task.anno.pendingOutline = false; + } + + function controllerIsStale(controller) { + if ( + !controller.signal.aborted && + typeof controller._isCurrent === 'function' && + !controller._isCurrent() + ) + controller.abort(); + return controller.signal.aborted; + } + // A controller stays abortable (in activeControllers) while its annotate() call OR any // progressive outline-upgrade task it spawned is still running — clear() must be able to // abort in-flight Overpass fetches even after the tool result has already returned. @@ -182,7 +217,10 @@ export function createAnnotationEngine({ } function releaseController(c) { c._retain = (c._retain || 0) - 1; - if (c._retain <= 0) activeControllers.delete(c); + if (c._retain <= 0) { + activeControllers.delete(c); + c._cleanup?.(); + } } // World annotations carry persistent per-frame scene animation (pulsing @@ -258,6 +296,8 @@ export function createAnnotationEngine({ * @param {boolean} [opts.clearPrevious] * @param {boolean} [opts.persist] Keep until cleared (default true). * @param {boolean} [opts.flyTo] Frame the first resolved annotation. + * @param {AbortSignal} [opts.signal] Cancel only this call's pending work. + * @param {Function} [opts.isCurrent] Guard commits and deferred upgrades. * @returns {Promise<{ok, drawn, failed, ids, results}>} */ async function annotate(requests, opts = {}) { @@ -270,15 +310,41 @@ export function createAnnotationEngine({ results: [], error: 'destroyed', }; + const cancelledResult = (results = [], ids = []) => ({ + ok: false, + drawn: results.filter((r) => r.ok).length, + failed: results.filter((r) => !r.ok).length, + ids, + results, + aborted: true, + cancelled: true, + }); + // Check authority BEFORE clearPrevious: an already-cancelled owner cannot + // erase a manual mark or invalidate a different owner's pending operation. + if ( + opts.signal?.aborted || + (typeof opts.isCurrent === 'function' && !opts.isCurrent()) + ) + return cancelledResult(); const list = Array.isArray(requests) ? requests : [requests]; if (opts.clearPrevious) clear(); // bumps generation + aborts older pending work const persist = opts.persist !== false; - // Per-call cancellation: bumped/aborted by any later clear() or destroy(). + // The call owns this controller through its final deferred outline task. const controller = new AbortController(); + controller._isCurrent = opts.isCurrent; + const cancel = () => { + controller.abort(opts.signal.reason); + dropQueuedOutlineUpgrades(controller); + }; + controller._cleanup = () => + opts.signal?.removeEventListener('abort', cancel); + opts.signal?.addEventListener('abort', cancel, { once: true }); retainController(controller); + if (opts.signal?.aborted) cancel(); const myGen = generation; - const superseded = () => myGen !== generation || controller.signal.aborted; + const superseded = () => + myGen !== generation || controllerIsStale(controller); const results = []; const ids = []; @@ -292,7 +358,13 @@ export function createAnnotationEngine({ // order are all preserved exactly as the old serial loop had them. const settled = await Promise.allSettled( list.map((spec) => - resolveSpec(spec, controller.signal, opts.flyTo === true), + abortable( + resolveSpec(spec, controller.signal, opts.flyTo === true, superseded), + controller.signal, + ).catch((error) => { + if (error?.name === 'AbortError') controller.abort(error); + throw error; + }), ), ); @@ -302,7 +374,13 @@ export function createAnnotationEngine({ const outcome = settled[i]; if (superseded()) break; try { - if (outcome.status === 'rejected') throw outcome.reason; // resolution threw → same path as before + if (outcome.status === 'rejected') { + if (outcome.reason?.name === 'AbortError') { + controller.abort(outcome.reason); + break; + } + throw outcome.reason; + } const resolved = outcome.value; if (!resolved) { results.push(failResult(spec, 'could not resolve location')); @@ -449,23 +527,14 @@ export function createAnnotationEngine({ releaseController(controller); } - // A clear/new-topic landed mid-flight: drop everything this call produced and - // do not touch the renderer or camera — the board now belongs to a newer call. - if (superseded()) { - return { - ok: false, - drawn: 0, - failed: results.length, - ids: [], - results, - aborted: true, - }; - } + // Pending work is cancelled without removing any mark already committed. + if (superseded()) return cancelledResult(results, ids); if (ids.length) { renderer.sync(annotations); ensureTicking(); } + if (superseded()) return cancelledResult(results, ids); if (opts.flyTo && firstAnchor) frameAnnotation(firstAnchor); else if (ids.length) ensureMarksVisible(ids); @@ -480,7 +549,17 @@ export function createAnnotationEngine({ }; } - async function resolveSpec(spec, signal, allowDistant = false) { + async function resolveSpec( + spec, + signal, + allowDistant = false, + isStale = () => false, + ) { + const checkCurrent = () => { + isStale(); // invalid currentness aborts this call's controller + signal.throwIfAborted(); + }; + checkCurrent(); const type = normalizeType(spec?.type); // MANUAL geometry (drawTool.js): the person clicked the vertices, so there is // nothing to resolve. Same record shapes as a resolved spec, source 'manual'. @@ -505,6 +584,7 @@ export function createAnnotationEngine({ allowDistant, signal, }); + checkCurrent(); if (r) resolvedPts.push(r); else failed.push(name || 'a waypoint'); } @@ -526,6 +606,7 @@ export function createAnnotationEngine({ signal, placeSearch, ); + checkCurrent(); if (routed) { return { path: routed.geometry.map(([lon, lat]) => ({ lon, lat, height: 0 })), @@ -563,6 +644,7 @@ export function createAnnotationEngine({ allowDistant, signal, }); + checkCurrent(); const to = await resolveTarget({ placeSearch, viewer, @@ -575,6 +657,7 @@ export function createAnnotationEngine({ allowDistant, signal, }); + checkCurrent(); if (!from || !to) { // Name the endpoint(s) that actually failed so the partial voice path can't // blame the wrong place (a valid origin → unresolved destination must report @@ -647,10 +730,10 @@ export function createAnnotationEngine({ const task = outlineQueue.shift(); const stale = task.myGen !== generation || - task.controller.signal.aborted || + controllerIsStale(task.controller) || !annotations.has(task.anno.id); if (stale) { - if (task.anno._outlineTask === task) task.anno._outlineTask = null; + finishOutlineTask(task); releaseController(task.controller); continue; } @@ -670,10 +753,11 @@ export function createAnnotationEngine({ delaysMs: outlineRetryDelaysMs, isStale: () => myGen !== generation || - controller.signal.aborted || + controllerIsStale(controller) || !annotations.has(anno.id), + signal: controller.signal, }); - if (myGen !== generation || controller.signal.aborted) return; // board superseded + if (myGen !== generation || controllerIsStale(controller)) return; // board superseded if (!annotations.has(anno.id)) return; // mark replaced/removed while resolving anno.pendingOutline = false; anno.outlineUnavailable = isUnavailableCapability(fp); @@ -742,7 +826,7 @@ export function createAnnotationEngine({ ...(fp && anno.synthesized ? { approximate: true } : {}), }); } finally { - if (anno._outlineTask === task) anno._outlineTask = null; + finishOutlineTask(task); releaseController(controller); activeOutlineUpgrades -= 1; drainOutlineQueue(); @@ -1291,8 +1375,46 @@ export function createAnnotationEngine({ return engine; } -function wait(ms) { - return new Promise((resolve) => setTimeout(resolve, ms)); +// Race non-cooperative adapters too: cancellation must settle owned queues even +// when an upstream promise never acknowledges AbortSignal. Late rejection is +// consumed, and listeners are removed on either outcome. +function abortable(promise, signal) { + if (!signal) return Promise.resolve(promise); + return new Promise((resolve, reject) => { + const cleanup = () => signal.removeEventListener('abort', cancel); + const cancel = () => { + cleanup(); + reject(signal.reason); + }; + if (signal.aborted) cancel(); + else signal.addEventListener('abort', cancel, { once: true }); + Promise.resolve(promise).then( + (value) => { + cleanup(); + resolve(value); + }, + (error) => { + cleanup(); + reject(error); + }, + ); + }); +} + +function wait(ms, signal) { + return new Promise((resolve) => { + const cancel = () => { + clearTimeout(timer); + signal?.removeEventListener('abort', cancel); + resolve(); + }; + const timer = setTimeout(() => { + signal?.removeEventListener('abort', cancel); + resolve(); + }, ms); + signal?.addEventListener('abort', cancel, { once: true }); + if (signal?.aborted) cancel(); + }); } /** Exact per-vertex ring equality ([[lon,lat],…], ~1m epsilon) — the geometry-identity test. */ diff --git a/src/annotations/annotationEngine.test.mjs b/src/annotations/annotationEngine.test.mjs index 5f39a077a..a82915d2a 100644 --- a/src/annotations/annotationEngine.test.mjs +++ b/src/annotations/annotationEngine.test.mjs @@ -730,3 +730,227 @@ test('only an explicit navigation request permits resolving distant annotation t assert.deepEqual(received.map(options => options.allowDistant), [false, true]); engine.destroy(); }); + +// Call ownership is narrower than the board's explicit clear/destroy generation. +function deferredAnnotation() { + let resolve; + const promise = new Promise((done) => { resolve = done; }); + return { promise, resolve }; +} + +const cancellationAnchor = { lon: 10, lat: 20, height: 0, source: 'test' }; + +test('cancelled admission cannot clear existing marks or abort another owner', async (t) => { + installAnimationFrameStubs(t); + const { renderer, calls } = fakeRenderer(); + const pending = deferredAnnotation(); + let siblingSignal; + const engine = createAnnotationEngine({ viewer: {}, renderer, resolveTarget: ({ signal }) => { + siblingSignal = signal; + return pending.promise; + } }); + t.after(() => engine.clear()); + await engine.annotate([{ type: 'pin', manual: true, longitude: 1, latitude: 2, label: 'Manual' }]); + const sibling = engine.annotate([{ target: 'Sibling' }]); + const cancelled = new AbortController(); + cancelled.abort(); + for (const opts of [{ signal: cancelled.signal }, { isCurrent: () => false }, { isCurrent: () => undefined }]) { + const result = await engine.annotate([{ target: 'Stale' }], { ...opts, clearPrevious: true }); + assert.equal(result.cancelled, true); + assert.equal(result.ok, false); + assert.equal(result.failed, 0, 'cancellation is not a missing location'); + assert.equal(siblingSignal.aborted, false); + assert.equal(engine.count(), 1); + assert.equal(calls.remove, 0); + } + pending.resolve(cancellationAnchor); + assert.equal((await sibling).ok, true); + assert.equal(engine.count(), 2); +}); + +test('owner abort settles a non-cooperative lookup while another owner may still commit', async (t) => { + installAnimationFrameStubs(t); + const { renderer, calls } = fakeRenderer(); + const late = deferredAnnotation(); + const sibling = deferredAnnotation(); + const signals = new Map(); + const engine = createAnnotationEngine({ viewer: {}, renderer, resolveTarget: ({ target, signal }) => { + signals.set(target, signal); + return (target === 'Late' ? late : sibling).promise; + } }); + t.after(() => engine.clear()); + const owner = new AbortController(); + const a = engine.annotate([{ target: 'Late' }], { signal: owner.signal, flyTo: true }); + const b = engine.annotate([{ target: 'Sibling' }]); + owner.abort(); + const cancelled = await a; + assert.equal(cancelled.cancelled, true); + assert.equal(cancelled.drawn, 0); + assert.equal(signals.get('Late').aborted, true); + assert.equal(signals.get('Sibling').aborted, false); + sibling.resolve(cancellationAnchor); + assert.equal((await b).ok, true); + late.resolve({ ...cancellationAnchor, lon: 40 }); + await flushMicrotasks(); + assert.equal(engine.count(), 1); + assert.equal(calls.add, 1); +}); + +test('currentness is rechecked before mutation and AbortError is cancellation, not resolution failure', async (t) => { + installAnimationFrameStubs(t); + const { renderer, calls } = fakeRenderer(); + const late = deferredAnnotation(); + let current = true; + let signal; + const engine = createAnnotationEngine({ viewer: {}, renderer, resolveTarget: (options) => { + signal = options.signal; + return late.promise; + } }); + const pending = engine.annotate([{ target: 'Late' }], { isCurrent: () => current }); + current = false; + late.resolve(cancellationAnchor); + assert.equal((await pending).cancelled, true); + assert.equal(signal.aborted, true); + assert.equal(calls.add, 0); + const errorEngine = createAnnotationEngine({ viewer: {}, renderer, resolveTarget: async () => { + throw new DOMException('Cancelled lookup', 'AbortError'); + } }); + const result = await errorEngine.annotate([{ target: 'Aborted lookup' }]); + assert.equal(result.cancelled, true); + assert.equal(result.failed, 0); + assert.deepEqual(result.results, []); +}); + +test('outline cancellation frees the queue, preserves committed marks and permits a later duplicate retry', async (t) => { + installAnimationFrameStubs(t); + const { renderer, calls } = fakeRenderer(); + const late = deferredAnnotation(); + const started = []; + const signals = new Map(); + let retries = 0; + const engine = createAnnotationEngine({ viewer: {}, renderer, resolveTarget: async ({ target, signal }) => { + signals.set(target, signal); + const i = Number(target.slice(-1)); + return { + ...cancellationAnchor, lon: 10 + i, + resolveOutline: () => { + started.push(target); + if (target === 'Other9' || retries) return Promise.resolve(null); + return late.promise; + }, + }; + } }); + t.after(() => engine.clear()); + const owner = new AbortController(); + await engine.annotate([0, 1, 2, 3].map((i) => ({ type: 'area', target: `Owned${i}` })), { signal: owner.signal }); + const other = await engine.annotate([{ type: 'area', target: 'Other9' }]); + assert.equal(other.ok, true); + assert.deepEqual(started, ['Owned0', 'Owned1']); + owner.abort(); + await flushMicrotasks(); + assert.deepEqual(started, ['Owned0', 'Owned1', 'Other9'], 'cancelled queued outlines never start; sibling is unblocked'); + assert.equal(signals.get('Owned0').aborted, true); + assert.equal(signals.get('Other9').aborted, false); + assert.equal(engine.count(), 5); + assert.equal(engine.list().some((mark) => mark.pendingOutline), false); + const updates = calls.update; + late.resolve(FP); + await flushMicrotasks(); + assert.equal(calls.update, updates, 'non-cooperative late outline cannot update or deduplicate marks'); + retries++; + const retry = await engine.annotate([{ type: 'area', target: 'Owned0' }]); + await flushMicrotasks(); + assert.equal(retry.results[0].duplicate, true); + assert.equal(engine.count(), 5); + assert.equal(started.at(-1), 'Owned0'); +}); + +test('caller abort listeners detach after resolution, cancellation and final deferred outline', async (t) => { + const { getEventListeners } = await import('node:events'); + installAnimationFrameStubs(t); + const { renderer } = fakeRenderer(); + const late = deferredAnnotation(); + const engine = createAnnotationEngine({ viewer: {}, renderer, resolveTarget: async ({ target }) => ({ + ...cancellationAnchor, + ...(target === 'Outline' ? { resolveOutline: () => late.promise } : {}), + }) }); + t.after(() => engine.clear()); + for (const target of ['Plain', 'Outline']) { + const owner = new AbortController(); + await engine.annotate([{ type: 'area', target }], { signal: owner.signal }); + assert.equal(getEventListeners(owner.signal, 'abort').length, target === 'Plain' ? 0 : 1); + if (target === 'Outline') { + late.resolve(null); + await flushMicrotasks(); + assert.equal(getEventListeners(owner.signal, 'abort').length, 0); + } + } + const unresolved = deferredAnnotation(); + const cancelledEngine = createAnnotationEngine({ viewer: {}, renderer, resolveTarget: () => unresolved.promise }); + const owner = new AbortController(); + const work = cancelledEngine.annotate([{ target: 'Never settles' }], { signal: owner.signal }); + owner.abort(); + await work; + assert.equal(getEventListeners(owner.signal, 'abort').length, 0); +}); + +test('cancelled arrow and route do not begin the next lookup after a non-cooperative late anchor', async (t) => { + installAnimationFrameStubs(t); + for (const spec of [ + { type: 'arrow', target: 'From', toTarget: 'To' }, + { type: 'route', points: [{ target: 'From' }, { target: 'To' }] }, + ]) { + const { renderer, calls } = fakeRenderer(); + const late = deferredAnnotation(); + const targets = []; + const engine = createAnnotationEngine({ viewer: {}, renderer, resolveTarget: ({ target }) => { + targets.push(target); + return late.promise; + } }); + const owner = new AbortController(); + const pending = engine.annotate([spec], { signal: owner.signal }); + owner.abort(); + assert.equal((await pending).cancelled, true); + late.resolve(cancellationAnchor); + await flushMicrotasks(); + assert.deepEqual(targets, ['From']); + assert.equal(calls.add, 0); + } +}); + +test('aborting deferred outline backoff settles without another resolver attempt', async () => { + const owner = new AbortController(); + const waiting = deferredAnnotation(); + const entered = deferredAnnotation(); + let attempts = 0; + const pending = resolveOutlineWithRetry(async () => { attempts++; return undefined; }, { + delaysMs: [8000], + signal: owner.signal, + waitFn(ms, signal) { + assert.equal(ms, 8000); + assert.equal(signal, owner.signal); + entered.resolve(); + return waiting.promise; + }, + }); + await entered.promise; + owner.abort(); + assert.equal(await pending, undefined); + waiting.resolve(); + await flushMicrotasks(); + assert.equal(attempts, 1); +}); + +test('AbortError in one batch item promptly settles its non-cooperative sibling', async (t) => { + installAnimationFrameStubs(t); + const { renderer, calls } = fakeRenderer(); + const never = deferredAnnotation(); + const engine = createAnnotationEngine({ viewer: {}, renderer, resolveTarget: async ({ target }) => { + if (target === 'Abort') throw new DOMException('Cancelled lookup', 'AbortError'); + return never.promise; + } }); + const result = await engine.annotate([{ target: 'Abort' }, { target: 'Never' }]); + assert.equal(result.cancelled, true); + assert.equal(result.failed, 0); + assert.equal(calls.add, 0); +}); diff --git a/src/app/tools.js b/src/app/tools.js index 7b3ea83c9..6c02f0ff5 100644 --- a/src/app/tools.js +++ b/src/app/tools.js @@ -183,6 +183,14 @@ export function createApplicationTools({ dataManager, sceneDirector, annotations, + onSessionEvent: (event) => { + voice.onSessionEvent?.(event); + // Radio playback stops voice so both never share the speaker. + if (event?.type === 'voice-suspended' && event.reason === 'radio') + styleManager._showToast?.( + 'Voice stopped for Radio. Pause or stop Radio to resume voice.', + ); + }, }); defer(() => { voiceCommands.stop({ removeUi: true }); diff --git a/src/data/radio.test.mjs b/src/data/radio.test.mjs index 58a4101a1..58d04d314 100644 --- a/src/data/radio.test.mjs +++ b/src/data/radio.test.mjs @@ -603,6 +603,12 @@ test('late media errors after replacement or Pause cannot mutate the active stat assert.equal(radioLayer.getUIState().audioState, 'loading'); assert.equal(radioLayer.stopPlayback({ origin: 'user' }), true); assert.deepEqual(controlEvents[1], { + action: 'play-request', + observedAudioState: 'paused', + observedStationId: stationRows[1].id, + cleanupResult: false, + }, 'manual playback announces replacement before the new attempt owns media'); + assert.deepEqual(controlEvents[2], { action: 'stop', observedAudioState: 'stopped', observedStationId: null, diff --git a/src/devFreshDotenv.test.mjs b/src/devFreshDotenv.test.mjs index e41689bc2..f73979849 100644 --- a/src/devFreshDotenv.test.mjs +++ b/src/devFreshDotenv.test.mjs @@ -70,7 +70,7 @@ test('dev-fresh passes names-only boot provenance before resolving file fallback const dotenvResolution = source.indexOf('GOOGLE_MAPS_API_KEY_ENV="${GOOGLE_MAPS_API_KEY:-}"'); assert.ok(capture >= 0 && capture < dotenvResolution, 'parent-shell provenance must be captured first'); for (const name of [ - 'GOOGLE_MAPS_API_KEY', 'CESIUM_ION_TOKEN', 'OPENAI_API_KEY', 'AISSTREAM_API_KEY', + 'GOOGLE_MAPS_API_KEY', 'CESIUM_ION_TOKEN', 'OPENAI_API_KEY', 'GEMINI_API_KEY', 'AISSTREAM_API_KEY', 'FIRMS_MAP_KEY', 'TOMTOM_API_KEY', 'OPENSKY_CLIENT_ID', 'OPENSKY_CLIENT_SECRET', 'LL2_API_TOKEN', ]) { @@ -128,7 +128,56 @@ bashTest('the external-keys provenance block survives set -u keyless and joins n const keyless = await run('bash', ['-c', probe], { env: { PATH: process.env.PATH } }); assert.equal(keyless.stdout, ''); const keyed = await run('bash', ['-c', probe], { - env: { PATH: process.env.PATH, FIRMS_MAP_KEY: 'x', TOMTOM_API_KEY: 'y' }, + env: { PATH: process.env.PATH, GEMINI_API_KEY: 'gemini-fixture', FIRMS_MAP_KEY: 'x', TOMTOM_API_KEY: 'y' }, }); - assert.equal(keyed.stdout, 'FIRMS_MAP_KEY,TOMTOM_API_KEY'); + assert.equal(keyed.stdout, 'GEMINI_API_KEY,FIRMS_MAP_KEY,TOMTOM_API_KEY'); +}); + + +bashTest('dev-fresh keeps voice available when only a Gemini key is configured', async () => { + const script = await fs.readFile(new URL('../scripts/dev-fresh.sh', import.meta.url), 'utf8'); + const start = script.indexOf('[[ -n "${OPENAI_API_KEY}" ]] && echo "OpenAI key'); + const end = script.indexOf('[[ -n "${AISSTREAM_API_KEY}" ]] && echo', start); + assert.ok(start >= 0 && end > start, 'voice startup status block not found'); + const block = script.slice(start, end); + const { execFile } = await import('node:child_process'); + const { promisify } = await import('node:util'); + const run = promisify(execFile); + for (const [openai, gemini, available] of [ + ['', '', false], ['openai-fixture', '', true], ['', 'gemini-fixture', true], + ]) { + const { stdout } = await run('bash', ['-c', `set -u\n${block}`], { + env: { PATH: process.env.PATH, OPENAI_API_KEY: openai, GEMINI_API_KEY: gemini }, + }); + if (available) { + assert.match(stdout, /Voice control: available/); + assert.doesNotMatch(stdout, /GEV MIC disabled/); + } else { + assert.match(stdout, /add an OpenAI or Gemini key/); + } + assert.doesNotMatch(stdout, /openai-fixture|gemini-fixture/); + } +}); + +bashTest('dev-fresh preserves exported Gemini key and model with independent dotenv fallbacks', async () => { + const script = await fs.readFile(new URL('../scripts/dev-fresh.sh', import.meta.url), 'utf8'); + const assignments = script.split('\n').filter((line) => /^GEMINI_(API_KEY|LIVE_MODEL)=/.test(line)); + assert.equal(assignments.length, 2); + assert.match(script, /put_env_if_set GEMINI_API_KEY "\$\{GEMINI_API_KEY\}"/); + assert.match(script, /put_env_if_set GEMINI_LIVE_MODEL "\$\{GEMINI_LIVE_MODEL\}"/); + const { execFile } = await import('node:child_process'); + const { promisify } = await import('node:util'); + const run = promisify(execFile); + const probe = [ + 'set -u', + 'read_dotenv_value() { printf "file-%s" "$1"; }', + ...assignments, + 'printf "%s|%s" "$GEMINI_API_KEY" "$GEMINI_LIVE_MODEL"', + ].join('\n'); + const inherited = await run('bash', ['-c', probe], { + env: { PATH: process.env.PATH, GEMINI_API_KEY: 'shell-key', GEMINI_LIVE_MODEL: 'shell-model' }, + }); + assert.equal(inherited.stdout, 'shell-key|shell-model'); + const fallback = await run('bash', ['-c', probe], { env: { PATH: process.env.PATH } }); + assert.equal(fallback.stdout, 'file-GEMINI_API_KEY|file-GEMINI_LIVE_MODEL'); }); diff --git a/src/firstRunExperience.test.mjs b/src/firstRunExperience.test.mjs index 50dbb15c9..c7ae39b05 100644 --- a/src/firstRunExperience.test.mjs +++ b/src/firstRunExperience.test.mjs @@ -52,13 +52,23 @@ test('dismissal is session-scoped; only the checkbox suppresses durably', () => rememberFirstRunSessionDismissed(session); assert.equal(session.read(), 'dismissed'); // Gone for THIS session... - assert.equal(shouldShowFirstRun({ storage, sessionStorageRef: session, location: { search: '' } }), false); + assert.equal( + shouldShowFirstRun({ + storage, + sessionStorageRef: session, + location: { search: '' }, + }), + false, + ); // ...and back in the next one, because sessionStorage did not survive it. - assert.equal(shouldShowFirstRun({ - storage, - sessionStorageRef: memoryStorage(FIRST_RUN_SESSION_KEY), - location: { search: '' }, - }), true); + assert.equal( + shouldShowFirstRun({ + storage, + sessionStorageRef: memoryStorage(FIRST_RUN_SESSION_KEY), + location: { search: '' }, + }), + true, + ); // Session dismissal must never have written the durable key. assert.equal(storage.read(), null); }); @@ -67,43 +77,65 @@ test('the checkbox writes and clears durable suppression, and a storage reset un const storage = memoryStorage(FIRST_RUN_STORAGE_KEY); setFirstRunSuppressed(true, storage); assert.equal(storage.read(), 'suppressed'); - assert.equal(shouldShowFirstRun({ - storage, - sessionStorageRef: memoryStorage(FIRST_RUN_SESSION_KEY), - location: { search: '' }, - }), false); + assert.equal( + shouldShowFirstRun({ + storage, + sessionStorageRef: memoryStorage(FIRST_RUN_SESSION_KEY), + location: { search: '' }, + }), + false, + ); // Unticking before dismissing takes the suppression back. setFirstRunSuppressed(false, storage); assert.equal(storage.read(), null); - assert.equal(shouldShowFirstRun({ - storage, - sessionStorageRef: memoryStorage(FIRST_RUN_SESSION_KEY), - location: { search: '' }, - }), true); + assert.equal( + shouldShowFirstRun({ + storage, + sessionStorageRef: memoryStorage(FIRST_RUN_SESSION_KEY), + location: { search: '' }, + }), + true, + ); // A cleared/hard-reset profile shows it again — an accepted, documented cost. setFirstRunSuppressed(true, storage); - assert.equal(shouldShowFirstRun({ - storage: memoryStorage(FIRST_RUN_STORAGE_KEY), - sessionStorageRef: memoryStorage(FIRST_RUN_SESSION_KEY), - location: { search: '' }, - }), true); + assert.equal( + shouldShowFirstRun({ + storage: memoryStorage(FIRST_RUN_STORAGE_KEY), + sessionStorageRef: memoryStorage(FIRST_RUN_SESSION_KEY), + location: { search: '' }, + }), + true, + ); }); test('welcome params work in both directions and outrank both suppressions', () => { const suppressed = memoryStorage(FIRST_RUN_STORAGE_KEY, 'suppressed'); const dismissed = memoryStorage(FIRST_RUN_SESSION_KEY, 'dismissed'); // ?welcome=1 replays past the checkbox AND past a session dismissal. - assert.equal(shouldShowFirstRun({ - storage: suppressed, sessionStorageRef: dismissed, location: { search: '?welcome=1' }, - }), true); + assert.equal( + shouldShowFirstRun({ + storage: suppressed, + sessionStorageRef: dismissed, + location: { search: '?welcome=1' }, + }), + true, + ); // ?welcome=0 suppresses a session that would otherwise see it. - assert.equal(shouldShowFirstRun({ ...fresh(), location: { search: '?welcome=0' } }), false); + assert.equal( + shouldShowFirstRun({ ...fresh(), location: { search: '?welcome=0' } }), + false, + ); // A share link outranks everything, including the replay hatch. - assert.equal(shouldShowFirstRun({ - hasShareState: true, ...fresh(), location: { search: '?welcome=1' }, - }), false); + assert.equal( + shouldShowFirstRun({ + hasShareState: true, + ...fresh(), + location: { search: '?welcome=1' }, + }), + false, + ); }); test('a share link never sees the launcher — its author already chose the view', () => { @@ -112,11 +144,20 @@ test('a share link never sees the launcher — its author already chose the view test('privacy-restricted storage fails open and every write stays best-effort', () => { const blocked = { - getItem: () => { throw new Error('blocked'); }, - setItem: () => { throw new Error('blocked'); }, - removeItem: () => { throw new Error('blocked'); }, + getItem: () => { + throw new Error('blocked'); + }, + setItem: () => { + throw new Error('blocked'); + }, + removeItem: () => { + throw new Error('blocked'); + }, }; - assert.equal(shouldShowFirstRun({ storage: blocked, sessionStorageRef: blocked }), true); + assert.equal( + shouldShowFirstRun({ storage: blocked, sessionStorageRef: blocked }), + true, + ); assert.doesNotThrow(() => setFirstRunSuppressed(true, blocked)); assert.doesNotThrow(() => rememberFirstRunSessionDismissed(blocked)); }); @@ -128,10 +169,15 @@ test('a THROWING storage getter still fails open — Safari private mode', () => // before the function body starts, so the SecurityError escapes every // try/catch in the module and the launcher silently never appears. const saved = Object.getOwnPropertyDescriptor(globalThis, 'localStorage'); - const savedSession = Object.getOwnPropertyDescriptor(globalThis, 'sessionStorage'); + const savedSession = Object.getOwnPropertyDescriptor( + globalThis, + 'sessionStorage', + ); const hostile = { configurable: true, - get() { throw new Error('SecurityError: The operation is insecure.'); }, + get() { + throw new Error('SecurityError: The operation is insecure.'); + }, }; Object.defineProperty(globalThis, 'localStorage', hostile); Object.defineProperty(globalThis, 'sessionStorage', hostile); @@ -152,16 +198,22 @@ test('a THROWING storage getter still fails open — Safari private mode', () => } finally { if (saved) Object.defineProperty(globalThis, 'localStorage', saved); else delete globalThis.localStorage; - if (savedSession) Object.defineProperty(globalThis, 'sessionStorage', savedSession); + if (savedSession) + Object.defineProperty(globalThis, 'sessionStorage', savedSession); else delete globalThis.sessionStorage; } }); test('no storage is touched from a default parameter position', () => { - const module = fs.readFileSync(new URL('./firstRunExperience.js', import.meta.url), 'utf8'); + const module = fs.readFileSync( + new URL('./firstRunExperience.js', import.meta.url), + 'utf8', + ); // Comments stripped first: the block explaining this very defect quotes the // bad pattern, and matching prose instead of code would make the pin a liar. - const code = module.replace(/\/\*[\s\S]*?\*\//g, '').replace(/^\s*\/\/.*$/gm, ''); + const code = module + .replace(/\/\*[\s\S]*?\*\//g, '') + .replace(/^\s*\/\/.*$/gm, ''); assert.doesNotMatch( code, /=\s*globalThis\.(local|session)Storage/, @@ -169,14 +221,24 @@ test('no storage is touched from a default parameter position', () => { ); // The only reads of the global areas live inside the guarded resolver. const globalReads = [...code.matchAll(/globalThis\.(local|session)Storage/g)]; - assert.equal(globalReads.length, 2, 'exactly two global storage reads, both in resolveStore'); - const resolver = code.slice(code.indexOf('function resolveStore'), code.indexOf('function readStored')); + assert.equal( + globalReads.length, + 2, + 'exactly two global storage reads, both in resolveStore', + ); + const resolver = code.slice( + code.indexOf('function resolveStore'), + code.indexOf('function readStored'), + ); assert.equal( [...resolver.matchAll(/globalThis\.(local|session)Storage/g)].length, 2, 'both global storage reads must be inside resolveStore, inside its try', ); - assert.match(resolver, /try \{[\s\S]*globalThis\.sessionStorage[\s\S]*\} catch/); + assert.match( + resolver, + /try \{[\s\S]*globalThis\.sessionStorage[\s\S]*\} catch/, + ); assert.match(code, /function readStored\(kind, injected, key\)/); assert.match(code, /function writeStored\(kind, injected, key, value\)/); }); @@ -184,33 +246,52 @@ test('no storage is touched from a default parameter position', () => { // ── ESC arbitration: one surface, one key, never an invisible handler ──────── test('the JS and CSS lists of screen-claiming surfaces stay in step', () => { - const module = fs.readFileSync(new URL('./firstRunExperience.js', import.meta.url), 'utf8'); + const module = fs.readFileSync( + new URL('./firstRunExperience.js', import.meta.url), + 'utf8', + ); const css = readStylesheet(new URL('../style.css', import.meta.url)); - assert.deepEqual( - [...EXCLUSIVE_SURFACE_CLASSES].sort(), - ['cockpit-mode', 'recording-mode', 'scene-playback-mode', 'ui-clean-view'], - ); + assert.deepEqual([...EXCLUSIVE_SURFACE_CLASSES].sort(), [ + 'cockpit-mode', + 'recording-mode', + 'scene-playback-mode', + 'ui-clean-view', + ]); // A surface that hides the card in CSS but is missing from the JS list would // leave an invisible launcher holding the ESC handler — blocker 2 exactly. const hideRule = css.slice( css.indexOf('body.ui-clean-view #first-run-launcher'), - css.indexOf('display: none', css.indexOf('body.ui-clean-view #first-run-launcher')), + css.indexOf( + 'display: none', + css.indexOf('body.ui-clean-view #first-run-launcher'), + ), ); - const inCss = [...hideRule.matchAll(/body\.([a-z-]+) #first-run-launcher/g)].map((m) => m[1]); + const inCss = [ + ...hideRule.matchAll(/body\.([a-z-]+) #first-run-launcher/g), + ].map((m) => m[1]); assert.deepEqual( inCss.sort(), [...EXCLUSIVE_SURFACE_CLASSES].sort(), 'every screen-claiming surface must appear in BOTH lists', ); - assert.match(module, /export const EXCLUSIVE_SURFACE_CLASSES = Object\.freeze\(\[/); + assert.match( + module, + /export const EXCLUSIVE_SURFACE_CLASSES = Object\.freeze\(\[/, + ); }); test('exclusiveSurfaceActive reads the live body classes', () => { - const make = (classes) => ({ body: { classList: { contains: (name) => classes.includes(name) } } }); + const make = (classes) => ({ + body: { classList: { contains: (name) => classes.includes(name) } }, + }); assert.equal(exclusiveSurfaceActive(make([])), false); for (const name of EXCLUSIVE_SURFACE_CLASSES) { - assert.equal(exclusiveSurfaceActive(make([name])), true, `${name} must count as exclusive`); + assert.equal( + exclusiveSurfaceActive(make([name])), + true, + `${name} must count as exclusive`, + ); } assert.equal(exclusiveSurfaceActive(make(['some-other-class'])), false); assert.equal(exclusiveSurfaceActive(undefined), false); @@ -218,14 +299,25 @@ test('exclusiveSurfaceActive reads the live body classes', () => { }); test('the key handler refuses to act for a card that is not really on screen', () => { - const module = fs.readFileSync(new URL('./firstRunExperience.js', import.meta.url), 'utf8'); + const module = fs.readFileSync( + new URL('./firstRunExperience.js', import.meta.url), + 'utf8', + ); assert.match(module, /isActive: \(\) => !closing && isTopmost\(\)/); // Real visibility, not just the class: the class survives while CSS hides the // card, which is precisely how a Scene left an invisible ESC handler armed. assert.match(module, /const isTopmost = \(\) =>\s*root\.isConnected/); - assert.match(module, /&&\s*root\.getClientRects\(\)\.length > 0 &&\s*!coveredByOverlay\(\);/); - const keyboard = fs.readFileSync(new URL('./ui/surfaceKeyboard.js', import.meta.url), 'utf8'); - const handler = keyboard.slice(keyboard.indexOf('const onKeyDown = (event) => {')); + assert.match( + module, + /&&\s*root\.getClientRects\(\)\.length > 0 &&\s*!coveredByOverlay\(\);/, + ); + const keyboard = fs.readFileSync( + new URL('./ui/surfaceKeyboard.js', import.meta.url), + 'utf8', + ); + const handler = keyboard.slice( + keyboard.indexOf('const onKeyDown = (event) => {'), + ); assert.match( handler.slice(0, handler.indexOf("if (event.key === 'Escape')")), /!isActive\(\)/, @@ -234,7 +326,10 @@ test('the key handler refuses to act for a card that is not really on screen', ( }); test('an overlay with NO class to watch still disarms the launcher', () => { - const module = fs.readFileSync(new URL('./firstRunExperience.js', import.meta.url), 'utf8'); + const module = fs.readFileSync( + new URL('./firstRunExperience.js', import.meta.url), + 'utf8', + ); const css = readStylesheet(new URL('../style.css', import.meta.url)); // The repro, pinned as the stacking it actually is: the attribution lightbox @@ -242,7 +337,10 @@ test('an overlay with NO class to watch still disarms the launcher', () => { // keeps its box, so getClientRects() alone called it visible, ESC dismissed a // buried launcher and wrote the session flag while the lightbox stayed open. const overlay = css.slice(css.indexOf('.cesium-credit-lightbox-overlay {')); - assert.match(overlay.slice(0, overlay.indexOf('}')), /z-index: 200 !important/); + assert.match( + overlay.slice(0, overlay.indexOf('}')), + /z-index: 200 !important/, + ); const launcher = css.slice(css.indexOf('#first-run-launcher {')); assert.match(launcher.slice(0, launcher.indexOf('}')), /z-index: 175/); @@ -259,14 +357,26 @@ test('an overlay with NO class to watch still disarms the launcher', () => { // ...and every inconclusive answer is UNCOVERED, so a guard added to stop the // launcher acting under an overlay can never become why ESC stopped working. - assert.match(covered, /if \(typeof documentRef\.elementFromPoint !== 'function'\) return false;/); - assert.match(covered, /if \(!\(rect\.width > 0 && rect\.height > 0\)\) return false;/); + assert.match( + covered, + /if \(typeof documentRef\.elementFromPoint !== 'function'\) return false;/, + ); + assert.match( + covered, + /if \(!\(rect\.width > 0 && rect\.height > 0\)\) return false;/, + ); assert.match(covered, /\} catch \{\s*\n\s*return false;\s*\n\s*\}/); }); test('one ESC does one thing — the radio disclosure stops the launcher outright', () => { - const ui = fs.readFileSync(new URL('./ui/radioBindings.js', import.meta.url), 'utf8'); - const module = fs.readFileSync(new URL('./firstRunExperience.js', import.meta.url), 'utf8'); + const ui = fs.readFileSync( + new URL('./ui/radioBindings.js', import.meta.url), + 'utf8', + ); + const module = fs.readFileSync( + new URL('./firstRunExperience.js', import.meta.url), + 'utf8', + ); // stopPropagation() does NOT stop later listeners on the SAME document, so the // disclosure's earlier capture handler closed the disclosure and the launcher @@ -276,30 +386,45 @@ test('one ESC does one thing — the radio disclosure stops the launcher outrigh const claim = radioEsc.slice(0, radioEsc.indexOf('setRadioDisclosure(false')); assert.match(claim, /event\.preventDefault\(\);/); assert.match(claim, /event\.stopImmediatePropagation\(\);/); - assert.doesNotMatch(claim, /event\.stopPropagation\(\);/, - 'the plain form leaves the launcher listening and is the defect itself'); + assert.doesNotMatch( + claim, + /event\.stopPropagation\(\);/, + 'the plain form leaves the launcher listening and is the defect itself', + ); // Belt on the launcher side: a key another surface already marked is not ours, // whether or not that surface remembered to silence us. - const keyboard = fs.readFileSync(new URL('./ui/surfaceKeyboard.js', import.meta.url), 'utf8'); - const handler = keyboard.slice(keyboard.indexOf('const onKeyDown = (event) => {')); + const keyboard = fs.readFileSync( + new URL('./ui/surfaceKeyboard.js', import.meta.url), + 'utf8', + ); + const handler = keyboard.slice( + keyboard.indexOf('const onKeyDown = (event) => {'), + ); assert.match( handler.slice(0, handler.indexOf("if (event.key === 'Escape')")), /event\.defaultPrevented\) return;/, - 'a marked key must be somebody else\'s key', + "a marked key must be somebody else's key", ); }); test('a refused write takes the tick back instead of promising "never again"', () => { - const module = fs.readFileSync(new URL('./firstRunExperience.js', import.meta.url), 'utf8'); + const module = fs.readFileSync( + new URL('./firstRunExperience.js', import.meta.url), + 'utf8', + ); // The write stays best-effort; the OUTCOME is now reported, because a box left // ticked after a refused write tells the visitor the launcher is gone for good // while it is already guaranteed to return next session. const blocked = { getItem: () => null, - setItem: () => { throw new Error('blocked'); }, - removeItem: () => { throw new Error('blocked'); }, + setItem: () => { + throw new Error('blocked'); + }, + removeItem: () => { + throw new Error('blocked'); + }, }; assert.equal(setFirstRunSuppressed(true, blocked), false); assert.equal(setFirstRunSuppressed(false, blocked), false); @@ -318,13 +443,26 @@ test('a refused write takes the tick back instead of promising "never again"', ( module.indexOf('const onSuppressChange = (event) => {'), module.indexOf(' const keyboard = createSurfaceKeyboard({'), ); - assert.match(handler, /if \(setFirstRunSuppressed\(wanted, storage\)\) return;/); - assert.match(handler, /box\.checked = !wanted;/, 'a refused write must revert the tick'); + assert.match( + handler, + /if \(setFirstRunSuppressed\(wanted, storage\)\) return;/, + ); + assert.match( + handler, + /box\.checked = !wanted;/, + 'a refused write must revert the tick', + ); }); test('a surface class that never clears is an ACCEPTED no-show, not a timer', () => { - const module = fs.readFileSync(new URL('./firstRunExperience.js', import.meta.url), 'utf8'); - const state = fs.readFileSync(new URL('../docs/CURRENT-STATE.md', import.meta.url), 'utf8'); + const module = fs.readFileSync( + new URL('./firstRunExperience.js', import.meta.url), + 'utf8', + ); + const state = fs.readFileSync( + new URL('../docs/CURRENT-STATE.md', import.meta.url), + 'utf8', + ); // A "reveal anyway after N seconds" would trade a benign no-show for the card // punching through a recording in progress — recordings run long, and none of @@ -336,33 +474,65 @@ test('a surface class that never clears is an ACCEPTED no-show, not a timer', () module.indexOf('ACCEPTED, DELIBERATELY NOT TIMED OUT'), module.indexOf(' const onViewportResize'), ); - assert.ok(accepted.length > 0, 'the acceptance must be written where the next editor will read it'); - assert.match(accepted, /const syncToExclusiveSurfaces = \(\) => \{/, - 'the pin must cover the function the note governs, not just the note'); - assert.doesNotMatch(accepted, /setTimeout|setInterval/, - 'the acceptance is the decision NOT to time this out'); + assert.ok( + accepted.length > 0, + 'the acceptance must be written where the next editor will read it', + ); + assert.match( + accepted, + /const syncToExclusiveSurfaces = \(\) => \{/, + 'the pin must cover the function the note governs, not just the note', + ); + assert.doesNotMatch( + accepted, + /setTimeout|setInterval/, + 'the acceptance is the decision NOT to time this out', + ); assert.match(accepted, /docs\/CURRENT-STATE\.md/); - assert.match(state, /a surface class that never clears means no launcher for that page/i); + assert.match( + state, + /a surface class that never clears means no launcher for that page/i, + ); }); test('the scroll fade only appears when the list really overflows', () => { - const module = fs.readFileSync(new URL('./firstRunExperience.js', import.meta.url), 'utf8'); + const module = fs.readFileSync( + new URL('./firstRunExperience.js', import.meta.url), + 'utf8', + ); const css = readStylesheet(new URL('../style.css', import.meta.url)); // A fade on a card where all five tiles fit promises a sixth mission that does // not exist, which is worse than no affordance at all. - assert.match(module, /const overflows = choiceList\.scrollHeight > choiceList\.clientHeight \+ 1;/); - assert.match(module, /choiceList\.dataset\.scrollable = String\(overflows\);/); + assert.match( + module, + /const overflows = choiceList\.scrollHeight > choiceList\.clientHeight \+ 1;/, + ); + assert.match( + module, + /choiceList\.dataset\.scrollable = String\(overflows\);/, + ); // ...and the CSS must be gated on that flag, never on the media query alone. - assert.match(css, /\.first-run-choices\[data-scrollable='true'\] \{[\s\S]*?mask-image/); - const bareFade = css.match(/^\s*\.first-run-choices \{[\s\S]*?\n\}/m)?.[0] || ''; - assert.doesNotMatch(bareFade, /mask-image/, 'the fade must never apply unconditionally'); + assert.match( + css, + /\.first-run-choices\[data-scrollable='true'\] \{[\s\S]*?mask-image/, + ); + const bareFade = + css.match(/^\s*\.first-run-choices \{[\s\S]*?\n\}/m)?.[0] || ''; + assert.doesNotMatch( + bareFade, + /mask-image/, + 'the fade must never apply unconditionally', + ); // Rotating a phone changes which tiles fit, so it re-measures. assert.match(module, /addEventListener\?\.\('resize', onViewportResize\)/); assert.match(module, /removeEventListener\?\.\('resize', onViewportResize\)/); }); test('the launcher yields on engage and waits when a surface is already up', () => { - const module = fs.readFileSync(new URL('./firstRunExperience.js', import.meta.url), 'utf8'); + const module = fs.readFileSync( + new URL('./firstRunExperience.js', import.meta.url), + 'utf8', + ); // Both directions from one observer: yield if it is up and something takes // the screen; wait to reveal if something already has it. assert.match( @@ -375,19 +545,28 @@ test('the launcher yields on engage and waits when a surface is already up', () // not see a new hold because of onboarding chrome. assert.match(module, /attributes: true,\s*attributeFilter: \['class'\]/); assert.match(module, /surfaceObserver\?\.disconnect\(\)/); - assert.doesNotMatch(module, /setInterval|requestAnimationFrame\(function poll/); + assert.doesNotMatch( + module, + /setInterval|requestAnimationFrame\(function poll/, + ); }); // ── Per-mission behavior ───────────────────────────────────────────────────── -function missionSpy({ contextOk = true, layerResult = () => true, globe = async () => ({ ok: true }) } = {}) { +function missionSpy({ + contextOk = true, + layerResult = () => true, + globe = async () => ({ ok: true }), +} = {}) { const calls = { contextModes: [], layerIds: [], globeFlights: 0 }; return { calls, deps: { setContextMode: async (mode) => { calls.contextModes.push(mode); - return contextOk ? { ok: true, mode } : { ok: false, failedLayerIds: ['rocket-launches'] }; + return contextOk + ? { ok: true, mode } + : { ok: false, failedLayerIds: ['rocket-launches'] }; }, setLayerEnabled: async (layerId) => { calls.layerIds.push(layerId); @@ -408,14 +587,23 @@ test('the menu is the four owner-ordered missions', () => { // went is the one-click globe-scale dump. Restoring the tile needs the // globe-LOD declutter first. assert.deepEqual(Object.keys(FIRST_RUN_MISSIONS), [ - 'contacts', 'space-missions', 'environmental', 'explore', + 'contacts', + 'space-missions', + 'environmental', + 'explore', ]); - assert.equal(FIRST_RUN_MISSIONS.infrastructure, undefined, - 'the infrastructure mission must be gone, not dormant'); + assert.equal( + FIRST_RUN_MISSIONS.infrastructure, + undefined, + 'the infrastructure mission must be gone, not dormant', + ); }); test('Live Contacts and Space Missions go through the one setContextMode facade', async () => { - for (const [choice, mode] of [['contacts', 'contacts'], ['space-missions', 'space-missions']]) { + for (const [choice, mode] of [ + ['contacts', 'contacts'], + ['space-missions', 'space-missions'], + ]) { const spy = missionSpy(); const outcome = await runFirstRunChoice(choice, spy.deps); assert.equal(outcome.ok, true); @@ -445,8 +633,14 @@ test('the tile is the FULLY CONFIGURED experience: quakes and fires together', ( // that row into LOAD FAILED — a defect in the shared state machine, ledgered // post-launch, and the note must stay where the next editor will read it // rather than being re-discovered as a launcher bug. - const module = fs.readFileSync(new URL('./firstRunExperience.js', import.meta.url), 'utf8'); - const table = module.slice(module.indexOf(' environmental: Object.freeze({'), module.indexOf(' explore:')); + const module = fs.readFileSync( + new URL('./firstRunExperience.js', import.meta.url), + 'utf8', + ); + const table = module.slice( + module.indexOf(' environmental: Object.freeze({'), + module.indexOf(' explore:'), + ); assert.match(table, /KEY REQUIRED/); assert.match(table, /src\/loadingFeedback\.js/); assert.match(table, /LEDGERED post-launch/); @@ -461,7 +655,10 @@ test('every visitor gets the same tile — there is no degraded keyless variant' assert.equal(outcome.ok, true); assert.deepEqual(outcome.failedLayerIds, []); assert.deepEqual(spy.calls.layerIds, ['earthquakes', 'local-firms']); - const module = fs.readFileSync(new URL('./firstRunExperience.js', import.meta.url), 'utf8'); + const module = fs.readFileSync( + new URL('./firstRunExperience.js', import.meta.url), + 'utf8', + ); assert.doesNotMatch( module.slice(module.indexOf('export async function runFirstRunChoice')), /FIRMS_MAP_KEY|hasKey|keyless\s*\?/, @@ -476,16 +673,31 @@ test('a refused layer fails the mission by name, and a stalled flight never does assert.deepEqual(outcome.failedLayerIds, ['earthquakes']); // The globe flight is framing. A cancelled or throwing flight is not a failure. - const flightDown = missionSpy({ globe: () => { throw new Error('cancelled'); } }); - assert.equal((await runFirstRunChoice('environmental', flightDown.deps)).ok, true); + const flightDown = missionSpy({ + globe: () => { + throw new Error('cancelled'); + }, + }); + assert.equal( + (await runFirstRunChoice('environmental', flightDown.deps)).ok, + true, + ); }); test('Explore manually touches nothing at all, and an unknown choice is inert', async () => { const spy = missionSpy(); assert.equal((await runFirstRunChoice('explore', spy.deps)).ok, true); - assert.deepEqual(spy.calls, { contextModes: [], layerIds: [], globeFlights: 0 }); + assert.deepEqual(spy.calls, { + contextModes: [], + layerIds: [], + globeFlights: 0, + }); assert.equal((await runFirstRunChoice('nope', spy.deps)).ok, false); - assert.deepEqual(spy.calls, { contextModes: [], layerIds: [], globeFlights: 0 }); + assert.deepEqual(spy.calls, { + contextModes: [], + layerIds: [], + globeFlights: 0, + }); }); test('a failed Context mission reports the layers the facade named', async () => { @@ -500,14 +712,22 @@ test('the fires/quakes tile name is switchable from one constant', () => { assert.equal(environmentalLabel('EARTH_WATCH').title, 'EARTH WATCH'); assert.equal(environmentalLabel('ACTIVE_EVENTS').title, 'ACTIVE EVENTS'); assert.equal(environmentalLabel('nonsense').title, 'ENVIRONMENTAL'); - assert.equal(environmentalLabel().title, environmentalLabel(ENVIRONMENTAL_LABEL_CHOICE).title); + assert.equal( + environmentalLabel().title, + environmentalLabel(ENVIRONMENTAL_LABEL_CHOICE).title, + ); }); // ── Defaults interplay: what a mission is allowed to persist ───────────────── test('no mission writes a preference the visitor did not choose by picking it', () => { - const module = fs.readFileSync(new URL('./firstRunExperience.js', import.meta.url), 'utf8'); - const code = module.slice(module.indexOf('export function shouldShowFirstRun')); + const module = fs.readFileSync( + new URL('./firstRunExperience.js', import.meta.url), + 'utf8', + ); + const code = module.slice( + module.indexOf('export function shouldShowFirstRun'), + ); // Layer enables ARE durable in this app and a mission tile IS that choice, so // they run at the same origin a click on those rows uses. @@ -528,22 +748,45 @@ test('no mission writes a preference the visitor did not choose by picking it', '_setModels3dParams', 'setFeather', ]) { - assert.doesNotMatch(code, new RegExp(forbidden), `a mission must never touch ${forbidden}`); + assert.doesNotMatch( + code, + new RegExp(forbidden), + `a mission must never touch ${forbidden}`, + ); } // The only durable panel write is the Context reveal, and only on the Context // missions — the globe missions open no panel at all. const panelWrites = code.match(/setPanelCollapsed/g) || []; - assert.equal(panelWrites.length, 1, 'exactly one panel reveal, on the Context path'); - const contextPath = code.slice(code.indexOf('setContextMode: async (mode)'), code.indexOf('setLayerEnabled:')); - assert.match(contextPath, /result\?\.ok[\s\S]*?setPanelCollapsed\?\.\('global-context-panel', false, \{\s*explicit: true,?\s*\}\)/); + assert.equal( + panelWrites.length, + 1, + 'exactly one panel reveal, on the Context path', + ); + const contextPath = code.slice( + code.indexOf('setContextMode: async (mode)'), + code.indexOf('setLayerEnabled:'), + ); + assert.match( + contextPath, + /result\?\.ok[\s\S]*?setPanelCollapsed\?\.\('global-context-panel', false, \{\s*explicit: true,?\s*\}\)/, + ); }); test('the decision table is written down where the next editor will read it', () => { - const module = fs.readFileSync(new URL('./firstRunExperience.js', import.meta.url), 'utf8'); - assert.match(module, /MISSION → APP STATE, AND WHAT IT IS ALLOWED TO PERSIST/); + const module = fs.readFileSync( + new URL('./firstRunExperience.js', import.meta.url), + 'utf8', + ); + assert.match( + module, + /MISSION → APP STATE, AND WHAT IT IS ALLOWED TO PERSIST/, + ); for (const row of ['TOUCHED, DURABLE', 'TOUCHED, SESSION', 'NOT TOUCHED']) { - assert.ok(module.includes(row), `decision table is missing its "${row}" rows`); + assert.ok( + module.includes(row), + `decision table is missing its "${row}" rows`, + ); } assert.match(module, /SHOW POLICY/); }); @@ -551,56 +794,109 @@ test('the decision table is written down where the next editor will read it', () // ── Markup, startup ordering, accessibility ───────────────────────────────── test('markup, startup ordering and accessibility remain pinned', () => { - const html = expandApplicationHtml(fs.readFileSync(new URL('../index.html', import.meta.url), 'utf8')); - const startup = fs.readFileSync(new URL('./app/startupChrome.js', import.meta.url), 'utf8'); + const html = expandApplicationHtml( + fs.readFileSync(new URL('../index.html', import.meta.url), 'utf8'), + ); + const startup = fs.readFileSync( + new URL('./app/startupChrome.js', import.meta.url), + 'utf8', + ); const css = readStylesheet(new URL('../style.css', import.meta.url)); - assert.match(html, /id="first-run-launcher" role="dialog"[^>]*aria-labelledby="first-run-title"[^>]*hidden/); + assert.match( + html, + /id="first-run-launcher" role="dialog"[^>]*aria-labelledby="first-run-title"[^>]*hidden/, + ); assert.equal((html.match(/data-first-run-choice=/g) || []).length, 4); - assert.match(html, /data-first-run-status[^>]*role="status"[^>]*aria-live="polite"/); + assert.match( + html, + /data-first-run-status[^>]*role="status"[^>]*aria-live="polite"/, + ); assert.match(html, //); assert.match(html, //); // Subcopy must name BOTH feeds the tile turns on — a tile that promised only // half of what it does is the defect this replaced. Only the VISIBLE // text counts; the comment beside it naturally says the words too. - const envTile = html.slice(html.indexOf('data-first-run-choice="environmental"')); - const visible = envTile.slice(envTile.indexOf(''), envTile.indexOf('')); + const envTile = html.slice( + html.indexOf('data-first-run-choice="environmental"'), + ); + const visible = envTile.slice( + envTile.indexOf(''), + envTile.indexOf(''), + ); assert.match(visible, /earthquakes/i); - assert.match(visible, /fires?/i, 'the tile must promise the fires it enables'); + assert.match( + visible, + /fires?/i, + 'the tile must promise the fires it enables', + ); // The card's one persuasive line is OWNER-AUTHORED and pinned verbatim, // unspaced em dash included. This is copy, not prose to be improved in a // passing edit — changing it needs the owner, not a nicer-sounding rewrite. assert.ok( - html.includes('

It feels like a forbidden cockpit' - + '—then you realize the sources are public and the data is real.

'), + html.includes( + '

It feels like a forbidden cockpit' + + '—then you realize the sources are public and the data is real.

', + ), 'the owner-authored first-run line must ship exactly as written', ); // Menu order is the owner's, read straight off the markup. - const order = [...html.matchAll(/data-first-run-choice="([a-z-]+)"/g)].map((match) => match[1]); - assert.deepEqual(order, ['contacts', 'space-missions', 'environmental', 'explore']); - assert.doesNotMatch(html, /data-first-run-choice="infrastructure"/, - 'the removed tile must leave no markup behind'); + const order = [...html.matchAll(/data-first-run-choice="([a-z-]+)"/g)].map( + (match) => match[1], + ); + assert.deepEqual(order, [ + 'contacts', + 'space-missions', + 'environmental', + 'explore', + ]); + assert.doesNotMatch( + html, + /data-first-run-choice="infrastructure"/, + 'the removed tile must leave no markup behind', + ); assert.match(startup, /styleManager\.initialRestorePromise/); - assert.ok(startup.indexOf("loadingScreen.classList.add('hidden')") < startup.indexOf("loadingScreen.addEventListener('transitionend', revealFirstRun")); - assert.match(startup, /initializeWelcome\?\.\(\{ styleManager, dataManager \}\)/); + assert.ok( + startup.indexOf("loadingScreen.classList.add('hidden')") < + startup.indexOf( + "loadingScreen.addEventListener('transitionend', revealFirstRun", + ), + ); + assert.match( + startup, + /initializeWelcome\?\.\(\{ styleManager, dataManager \}\)/, + ); assert.match(css, /body\.ui-clean-view #first-run-launcher/); assert.match(css, /body\.recording-mode #first-run-launcher/); // Scoped, not a bare search: style.css has other reduced-motion blocks, and // matching one of THOSE would let the launcher's own opt-out be deleted. - const reducedMotion = [...css.matchAll(/@media \(prefers-reduced-motion: reduce\) \{([\s\S]*?)\n\}/g)] - .map((match) => match[1]); + const reducedMotion = [ + ...css.matchAll( + /@media \(prefers-reduced-motion: reduce\) \{([\s\S]*?)\n\}/g, + ), + ].map((match) => match[1]); assert.ok( - reducedMotion.some((block) => block.includes('#first-run-launcher') && block.includes('.first-run-choices')), + reducedMotion.some( + (block) => + block.includes('#first-run-launcher') && + block.includes('.first-run-choices'), + ), 'the launcher must keep its OWN prefers-reduced-motion block, covering the card and the scrolling list', ); // The card must be click-through until revealed and again while it fades out. - const base = css.slice(css.indexOf('#first-run-launcher {'), css.indexOf('#first-run-launcher.visible')); + const base = css.slice( + css.indexOf('#first-run-launcher {'), + css.indexOf('#first-run-launcher.visible'), + ); assert.match(base, /pointer-events: none/); - assert.match(css, /#first-run-launcher\.visible \{[\s\S]*?pointer-events: auto/); + assert.match( + css, + /#first-run-launcher\.visible \{[\s\S]*?pointer-events: auto/, + ); // The card is a flex column so its mission list can scroll on a short // viewport — and an AUTHOR `display` on this id outranks the UA's // `[hidden] { display: none }`, which would strand the card in the @@ -616,13 +912,22 @@ test('markup, startup ordering and accessibility remain pinned', () => { }); test('the launcher keeps focus, restores it, and never disables the focused button', () => { - const module = fs.readFileSync(new URL('./firstRunExperience.js', import.meta.url), 'utf8'); + const module = fs.readFileSync( + new URL('./firstRunExperience.js', import.meta.url), + 'utf8', + ); // aria-disabled, never the `disabled` property: disabling a focused button // drops the keyboard to and strands the visitor outside the launcher. - assert.match(module, /button\.setAttribute\('aria-disabled', String\(next\)\)/); + assert.match( + module, + /button\.setAttribute\('aria-disabled', String\(next\)\)/, + ); assert.doesNotMatch(module, /button\.disabled = /); // Tab is confined to the launcher, and ESC always releases it. - const keyboard = fs.readFileSync(new URL('./ui/surfaceKeyboard.js', import.meta.url), 'utf8'); + const keyboard = fs.readFileSync( + new URL('./ui/surfaceKeyboard.js', import.meta.url), + 'utf8', + ); assert.match(module, /keyboard\.activate\(\)/); assert.match(module, /keyboard\.deactivate\(\{ restoreFocus \}\)/); assert.match(keyboard, /event\.key !== 'Tab'/); @@ -633,7 +938,10 @@ test('the launcher keeps focus, restores it, and never disables the focused butt }); test('the DISPLAY rail starts collapsed on a first run, and a stored choice wins', () => { - const ui = fs.readFileSync(new URL('./ui/panelPositionControls.js', import.meta.url), 'utf8'); + const ui = fs.readFileSync( + new URL('./ui/panelPositionControls.js', import.meta.url), + 'utf8', + ); // The rail opened by default to advertise HUD / DETECT / 3D. Those default ON // now, so it was opening to offer controls for things already happening — // while competing with the mission card for the one first impression there is. @@ -644,7 +952,11 @@ test('the DISPLAY rail starts collapsed on a first run, and a stored choice wins ); // The first-run default may only apply when NOTHING is stored: a visitor who // opened the rail keeps it open, and one who closed it keeps it closed. - const block = ui.slice(ui.indexOf('stored = localStorage.getItem(this._panelCollapseStorageKey(panelId))')); + const block = ui.slice( + ui.indexOf( + 'stored = localStorage.getItem(this._panelCollapseStorageKey(panelId))', + ), + ); const guard = block.slice(0, block.indexOf('panelEl.classList.toggle')); assert.match(guard, /if \(stored === '1'\) collapsed = true;/); assert.match(guard, /if \(stored === '0'\) collapsed = false;/); @@ -661,9 +973,17 @@ test('the voice TOOL SCHEMA matches the pinned contract — the mission mapping // Canonical serialization pins every tool name, description, property and // ordering while allowing source formatting. Derived from the unchanged // release schema before formatting (the previous source-byte pin passed). - const legacyTools = structuredClone(GEV_REALTIME_TOOLS).filter((tool) => tool.name !== 'set_cyber_sonar'); - const hudLayout = legacyTools.find((tool) => tool.name === 'set_hud').parameters.properties.layout; - assert.deepEqual(hudLayout.enum, ['tactical', 'operator', 'minimal', 'cyber']); + const legacyTools = structuredClone(GEV_REALTIME_TOOLS).filter( + (tool) => tool.name !== 'set_cyber_sonar', + ); + const hudLayout = legacyTools.find((tool) => tool.name === 'set_hud') + .parameters.properties.layout; + assert.deepEqual(hudLayout.enum, [ + 'tactical', + 'operator', + 'minimal', + 'cyber', + ]); // Cyber deliberately adds one layout; first-run missions still change no tools. hudLayout.enum = hudLayout.enum.filter((layout) => layout !== 'cyber'); const block = JSON.stringify(legacyTools); @@ -675,13 +995,17 @@ test('the voice TOOL SCHEMA matches the pinned contract — the mission mapping // list descriptions; the missions still ride existing tools. // Street Level and GNSS extend the manifest; broadcast caveats describe tracked and analyst aircraft. // Air quality (ECCC AQHI) extends the manifest. - assert.equal(block.length, 30655, 'serialized tool schema length drifted'); + // control_radio separates "turn on" (enable) from explicit playback. + assert.equal(block.length, 30773, 'serialized tool schema length drifted'); assert.equal( crypto.createHash('sha256').update(block).digest('hex'), - '3b1d5e77c10a92f6543a4f9d1ff1870101a0306b65e5b8a6113111c81eda371e', + 'c91af86fbfd53a40b1e5d5838e959c97ef8f475591959768775693f371971c8d', 'the first-run missions must ride EXISTING tools: no schema edit, no cache bust', ); - const instructions = fs.readFileSync(new URL('../server/providers/openai/instructions.js', import.meta.url), 'utf8'); + const instructions = fs.readFileSync( + new URL('../server/providers/openai/instructions.js', import.meta.url), + 'utf8', + ); // ...and the mapping that makes them reachable by voice is one instruction // string, whose rollback is deleting that string. Anchored to a LIVE array @@ -692,23 +1016,39 @@ test('the voice TOOL SCHEMA matches the pinned contract — the mission mapping /\n\s+'NAMED VIEWS are shorthand/, 'the mission mapping must be an active instruction entry, not commented out', ); - const mapping = instructions.slice(instructions.indexOf('NAMED VIEWS are shorthand')); + const mapping = instructions.slice( + instructions.indexOf('NAMED VIEWS are shorthand'), + ); const paragraph = mapping.slice(0, mapping.indexOf("',\n")); for (const layerId of [ - 'local-datacenters', 'local-dams', 'telegeography-submarine-cables', 'local-firms', 'earthquakes', + 'local-datacenters', + 'local-dams', + 'telegeography-submarine-cables', + 'local-firms', + 'earthquakes', ]) { - assert.ok(paragraph.includes(layerId), `mapping must name the existing ${layerId} enum value`); + assert.ok( + paragraph.includes(layerId), + `mapping must name the existing ${layerId} enum value`, + ); } assert.ok(paragraph.includes('zoom_to_globe')); assert.ok(paragraph.includes('set_layer_visibility')); }); test('every layer a mission drives is already in the shipped set_layer_visibility enum', () => { - const tool = GEV_REALTIME_TOOLS.find(tool => tool.name === 'set_layer_visibility'); + const tool = GEV_REALTIME_TOOLS.find( + (tool) => tool.name === 'set_layer_visibility', + ); const allowedLayers = tool.parameters.properties.layerId.enum; - const missionLayerIds = Object.values(FIRST_RUN_MISSIONS).flatMap((mission) => mission.layerIds || []); + const missionLayerIds = Object.values(FIRST_RUN_MISSIONS).flatMap( + (mission) => mission.layerIds || [], + ); assert.ok(missionLayerIds.length > 0); for (const layerId of missionLayerIds) { - assert.ok(allowedLayers.includes(layerId), `${layerId} must already be an allowed enum value`); + assert.ok( + allowedLayers.includes(layerId), + `${layerId} must already be an allowed enum value`, + ); } }); diff --git a/src/geminiServer.test.mjs b/src/geminiServer.test.mjs new file mode 100644 index 000000000..f7630fe23 --- /dev/null +++ b/src/geminiServer.test.mjs @@ -0,0 +1,793 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import http from 'node:http'; +import { once } from 'node:events'; +import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { createServer, preview } from 'vite'; +import { hostCheckPlugin, resolveAllowedHosts } from '../build/allowedHosts.js'; +import { geminiLiveProxy } from '../server/providers/gemini.js'; +import { createGeminiTokenHandler } from '../server/providers/gemini/realtime.js'; +import { + GEMINI_TOKEN_ENDPOINT, + GEMINI_LIVE_WEBSOCKET_URL, + createGeminiLiveConfig, +} from '../server/providers/gemini/config.js'; +import { GEV_REALTIME_TOOLS } from '../server/providers/openai/tools.js'; +import { standaloneVoiceTools } from '../server/standalone/voiceTools.js'; +import { PROXY_SIGNALS } from './localRequestGate.mjs'; + +const API_KEY = 'private-gemini-test-key-never-return'; +const TOKEN = 'auth_tokens/short-lived-test'; +const ISSUED_AT = Date.parse('2026-09-29T12:00:00Z'); + +async function fixture(t, options = {}) { + const calls = []; + const handler = createGeminiTokenHandler({ + resolveApiKey: () => API_KEY, + resolveModel: () => 'gemini-3.8-live', + resolveHost: () => '', + resolveAllowedHosts: () => '', + resolveRateLimit: () => '0', + now: () => ISSUED_AT, + fetchImpl: async (...args) => { + calls.push(args); + return Response.json({ + name: TOKEN, + privateDebug: API_KEY, + upstreamUrl: GEMINI_TOKEN_ENDPOINT, + }); + }, + ...options, + }); + const server = http.createServer((req, res) => void handler(req, res)); + server.listen(0, '127.0.0.1'); + await once(server, 'listening'); + const origin = `http://127.0.0.1:${server.address().port}`; + t.after(async () => { + server.closeAllConnections(); + await new Promise((resolve) => server.close(resolve)); + }); + const send = (overrides = {}) => { + const { headers, ...rest } = overrides; + return fetch(origin, { + method: 'POST', + body: '{}', + ...rest, + headers: { + Origin: origin, + 'Content-Type': 'application/json', + ...headers, + }, + }); + }; + const sendRaw = (overrides = {}) => + new Promise((resolve, reject) => { + const { headers, body = '{}', ...rest } = overrides; + const req = http.request( + origin, + { + method: 'POST', + ...rest, + headers: { + Origin: origin, + 'Content-Type': 'application/json', + ...headers, + }, + }, + (res) => { + const chunks = []; + res.on('data', (chunk) => chunks.push(chunk)); + res.on('end', () => + resolve( + new Response(Buffer.concat(chunks), { + status: res.statusCode, + headers: res.headers, + }), + ), + ); + res.on('error', reject); + }, + ); + req.on('error', reject); + req.end(body); + }); + return { calls, origin, send, sendRaw }; +} + +test('dev and preview install the same bounded Gemini token endpoint', () => { + for (const hook of ['configureServer', 'configurePreviewServer']) { + const installed = []; + geminiLiveProxy()[hook]({ + middlewares: { use: (...args) => installed.push(args) }, + }); + assert.equal(installed.length, 1); + assert.equal(installed[0][0], '/api/gemini/token'); + assert.equal(typeof installed[0][1], 'function'); + } +}); + +test('mint locks canonical action schemas and server instructions with no permanent credential in browser data', async (t) => { + const f = await fixture(t, { + annotationGuidance: 'Keep existing annotations.', + }); + const response = await f.send(); + assert.equal(response.status, 200); + assert.equal(response.headers.get('cache-control'), 'no-store'); + assert.equal(response.headers.get('x-content-type-options'), 'nosniff'); + const data = await response.json(); + assert.deepEqual(Object.keys(data).sort(), [ + 'config', + 'model', + 'token', + 'websocketUrl', + ]); + assert.equal(data.token, TOKEN); + assert.equal(data.model, 'gemini-3.8-live'); + assert.equal(data.websocketUrl, GEMINI_LIVE_WEBSOCKET_URL); + assert.equal(new URL(data.websocketUrl).search, ''); + assert.equal(JSON.stringify(data).includes(API_KEY), false); + assert.deepEqual(data.config.generationConfig, { + responseModalities: ['AUDIO'], + }); + assert.match( + data.config.systemInstruction.parts[0].text, + /Keep existing annotations\./, + ); + assert.deepEqual(data.config.inputAudioTranscription, {}); + assert.deepEqual(data.config.outputAudioTranscription, {}); + assert.equal( + data.config.realtimeInputConfig?.automaticActivityDetection?.disabled, + undefined, + ); + const functions = data.config.tools[0].functionDeclarations; + assert.deepEqual( + functions.map((tool) => tool.name), + GEV_REALTIME_TOOLS.map((tool) => tool.name), + ); + for (let i = 0; i < functions.length; i++) { + assert.equal(functions[i].behavior, 'BLOCKING'); + assert.deepEqual( + functions[i].parametersJsonSchema, + GEV_REALTIME_TOOLS[i].parameters, + ); + assert.equal(functions[i].parameters, undefined); + } + const [url, init] = f.calls[0]; + assert.equal(url, GEMINI_TOKEN_ENDPOINT); + assert.equal(init.redirect, 'error'); + assert.equal(init.headers['x-goog-api-key'], API_KEY); + const request = JSON.parse(init.body); + assert.equal(request.uses, 1); + assert.equal(Date.parse(request.expireTime) - ISSUED_AT, 30 * 60_000); + assert.equal(Date.parse(request.newSessionExpireTime) - ISSUED_AT, 60_000); + assert.equal(request.fieldMask, undefined); + assert.equal(request.liveConnectConstraints, undefined); + assert.deepEqual(request.bidiGenerateContentSetup, { + model: 'models/gemini-3.8-live', + ...data.config, + }); +}); + +test('standalone mints lock the full voice query catalog in both credential and browser setup', async (t) => { + const tools = standaloneVoiceTools(); + const declarations = tools.map(({ name, description, parameters }) => ({ + name, + description, + parametersJsonSchema: parameters, + behavior: 'BLOCKING', + })); + for (const name of ['search_places', 'get_weather', 'get_wind', 'plan_route']) + assert.ok( + tools.some((tool) => tool.name === name), + name, + ); + assert.ok(tools.length > GEV_REALTIME_TOOLS.length); + for (const inputMode of ['open-mic', 'push-to-talk']) { + const f = await fixture(t, { tools }); + const response = await f.send({ + body: JSON.stringify(inputMode === 'open-mic' ? {} : { inputMode }), + }); + assert.equal(response.status, 200); + const data = await response.json(); + assert.deepEqual(data.config.tools, [ + { functionDeclarations: declarations }, + ]); + const request = JSON.parse(f.calls[0][1].body); + assert.deepEqual(request.bidiGenerateContentSetup, { + model: 'models/gemini-3.8-live', + ...data.config, + }); + assert.equal(request.fieldMask, undefined); + assert.equal(JSON.stringify(data).includes(API_KEY), false); + } +}); + +test('Gemini endpoint instructions advertise only the server-owned provider chooser', async (t) => { + for (const options of [ + {}, + { providerDropdown: false }, + { providerDropdown: true }, + ]) { + const f = await fixture(t, options); + const response = await f.send(); + assert.equal(response.status, 200); + const data = await response.json(); + const instructions = data.config.systemInstruction.parts[0].text; + assert.match(instructions, /I can’t switch voice providers directly/); + if (options.providerDropdown === true) + assert.match( + instructions, + /Hover over the Voice panel and use the provider dropdown/, + ); + else assert.doesNotMatch(instructions, /hover|provider dropdown/i); + const upstream = JSON.parse(f.calls[0][1].body); + assert.equal( + upstream.bidiGenerateContentSetup.systemInstruction.parts[0].text, + instructions, + ); + } +}); + +test('browser token requests cannot enable provider chooser guidance', async (t) => { + for (const body of [ + { providerDropdown: true }, + { inputMode: 'push-to-talk', providerDropdown: true }, + { capabilities: { providerDropdown: true } }, + ]) { + const f = await fixture(t); + assert.equal((await f.send({ body: JSON.stringify(body) })).status, 400); + assert.equal(f.calls.length, 0); + } +}); + +test('missing key and malformed model fail without an upstream request', async (t) => { + const missing = await fixture(t, { resolveApiKey: () => '' }); + assert.equal((await missing.send()).status, 503); + assert.equal(missing.calls.length, 0); + for (const model of [ + '', + 'models/../../secrets', + 'https://attacker.example/model', + 'gemini-live?key=private', + 'x'.repeat(150), + ]) { + const f = await fixture(t, { resolveModel: () => model }); + const response = await f.send(); + assert.equal(response.status, 503); + assert.equal(f.calls.length, 0); + assert.equal((await response.text()).includes(model || API_KEY), false); + } + const valid = await fixture(t, { + resolveModel: () => + ' models/gemini-2.5-flash-native-audio-preview-12-2025 ', + }); + assert.equal( + (await (await valid.send()).json()).model, + 'gemini-2.5-flash-native-audio-preview-12-2025', + ); +}); + +test('only the PTT gesture selects manual activity in the complete locked setup', async (t) => { + const f = await fixture(t); + const ordinary = await (await f.send()).json(); + const response = await f.send({ + body: JSON.stringify({ inputMode: 'push-to-talk' }), + }); + assert.equal(response.status, 200); + const manual = await response.json(); + assert.deepEqual(manual.config.realtimeInputConfig, { + automaticActivityDetection: { disabled: true }, + }); + const { realtimeInputConfig, ...shared } = manual.config; + assert.equal(JSON.stringify(shared), JSON.stringify(ordinary.config)); + assert.equal( + JSON.stringify(createGeminiLiveConfig(undefined, 'open-mic')), + JSON.stringify(ordinary.config), + ); + assert.throws( + () => createGeminiLiveConfig(undefined, 'unknown'), + /input mode/, + ); + const locked = JSON.parse(f.calls[1][1].body); + assert.deepEqual(locked.bidiGenerateContentSetup, { + model: 'models/gemini-3.8-live', + ...manual.config, + }); + assert.equal(locked.fieldMask, undefined); + assert.equal(locked.uses, 1); + assert.equal(Date.parse(locked.newSessionExpireTime) - ISSUED_AT, 60_000); + assert.equal(Date.parse(locked.expireTime) - ISSUED_AT, 30 * 60_000); + assert.equal(JSON.stringify(manual).includes(API_KEY), false); +}); + +test('other methods, missing/cross origins, hostile hosts and cross-site metadata cannot mint', async (t) => { + const f = await fixture(t); + for (const method of ['GET', 'OPTIONS', 'PUT', 'DELETE']) { + const response = await f.send({ method, body: undefined }); + assert.equal(response.status, 405); + assert.equal(response.headers.get('allow'), 'POST'); + } + for (const headers of [ + { Origin: '' }, + { Origin: 'null' }, + { Origin: 'https://attacker.example' }, + { Origin: `${f.origin}/path` }, + { Origin: `${f.origin}?key=secret` }, + { Origin: f.origin.replace('http:', 'https:') }, + { Host: 'attacker.example', Origin: 'http://attacker.example' }, + { Host: '127.0.0.1@attacker.example', Origin: 'http://attacker.example' }, + { 'Sec-Fetch-Site': 'cross-site' }, + ]) { + assert.equal((await f.sendRaw({ headers })).status, 403); + } + assert.equal(f.calls.length, 0); + assert.equal((await f.send()).status, 200); +}); + +test('same-origin local and explicitly bound hosts work without trusting forwarded authority', async (t) => { + const f = await fixture(t, { resolveHost: () => 'demo.example' }); + assert.equal( + ( + await f.sendRaw({ + headers: { + Host: 'demo.example:4173', + Origin: 'http://demo.example:4173', + }, + }) + ).status, + 200, + ); + assert.equal( + ( + await f.sendRaw({ + headers: { Host: 'gev.local:4173', Origin: 'http://gev.local:4173' }, + }) + ).status, + 403, + ); + assert.equal( + ( + await f.send({ + headers: { + Origin: 'https://demo.example', + 'X-Forwarded-Host': 'demo.example', + 'X-Forwarded-Proto': 'https', + }, + }) + ).status, + 403, + ); +}); + +test('forwarded requests cannot mint or consume the real socket peer rate cap', async (t) => { + const f = await fixture(t, { resolveRateLimit: () => '1' }); + assert.equal( + (await f.send({ headers: { 'X-Forwarded-For': '10.1.1.1' } })).status, + 403, + ); + assert.equal( + (await f.send({ headers: { 'X-Forwarded-For': '10.1.1.2' } })).status, + 403, + ); + assert.equal((await f.send()).status, 200); + const response = await f.send(); + assert.equal(response.status, 429); + assert.equal(response.headers.get('retry-after'), '60'); + assert.equal(f.calls.length, 1); +}); + +test('all shared proxy signals and same-site cross-origin metadata fail before minting', async (t) => { + const f = await fixture(t, { resolveRateLimit: () => '1' }); + for (const header of PROXY_SIGNALS) { + assert.equal( + (await f.sendRaw({ headers: { [header]: 'untrusted' } })).status, + 403, + header, + ); + } + assert.equal( + (await f.sendRaw({ headers: { 'Sec-Fetch-Site': 'same-site' } })).status, + 403, + ); + assert.equal(f.calls.length, 0); + assert.equal( + (await f.sendRaw({ headers: { 'Sec-Fetch-Site': 'same-origin' } })).status, + 200, + ); + assert.equal((await f.send()).status, 429); + assert.equal(f.calls.length, 1); +}); + +test('LAN binding, exact configured hosts, IPs, and localhost subdomains follow the app host policy', async (t) => { + const f = await fixture(t, { + resolveHost: () => '0.0.0.0', + resolveAllowedHosts: () => 'gev.local, demo.example', + }); + for (const host of [ + 'gev.local:4173', + 'demo.example:4173', + '192.168.1.20:4173', + '[::1]:4173', + 'demo.localhost:4173', + ]) { + assert.equal( + (await f.sendRaw({ headers: { Host: host, Origin: `http://${host}` } })) + .status, + 200, + host, + ); + } + for (const host of ['arbitrary.local:4173', 'child.demo.example:4173']) { + assert.equal( + (await f.sendRaw({ headers: { Host: host, Origin: `http://${host}` } })) + .status, + 403, + host, + ); + } + assert.equal(f.calls.length, 5); +}); + +test('unset, blank, malformed and negative rate limits keep the 30-per-minute cap', async (t) => { + for (const value of [undefined, '', ' ', '3O', 'Infinity', '-1']) { + await t.test(String(value), async (t) => { + const f = await fixture(t, { resolveRateLimit: () => value }); + for (let index = 0; index < 30; index++) + assert.equal((await f.send()).status, 200, `request ${index + 1}`); + const response = await f.send(); + assert.equal(response.status, 429); + assert.equal(response.headers.get('retry-after'), '60'); + assert.equal(f.calls.length, 30); + }); + } +}); + +test('a positive fraction applies a real cap and only explicit zero disables it', async (t) => { + const fractional = await fixture(t, { resolveRateLimit: () => '0.5' }); + assert.equal((await fractional.send()).status, 200); + assert.equal((await fractional.send()).status, 429); + assert.equal(fractional.calls.length, 1); + const unlimited = await fixture(t, { resolveRateLimit: () => '0' }); + for (let index = 0; index < 31; index++) + assert.equal((await unlimited.send()).status, 200); + assert.equal(unlimited.calls.length, 31); +}); + +test('real Vite dev and preview reject hostile hosts before the Gemini middleware', async (t) => { + for (const mode of ['dev', 'preview']) { + await t.test(mode, async (t) => { + const root = await mkdtemp(path.join(tmpdir(), 'gev-gemini-vite-test-')); + await mkdir(path.join(root, 'dist')); + await writeFile(path.join(root, 'index.html'), '

isolated fixture

'); + await writeFile( + path.join(root, 'dist/index.html'), + '

isolated fixture

', + ); + let credentialReads = 0; + let upstreamCalls = 0; + const allowedHosts = resolveAllowedHosts('gev.local'); + const config = { + configFile: false, + envFile: false, + root, + logLevel: 'silent', + publicDir: false, + optimizeDeps: { noDiscovery: true, include: [] }, + // Deliberately put the provider first: enforce: pre must order the gate. + plugins: [ + geminiLiveProxy({ + realtime: { + resolveApiKey: () => { + credentialReads++; + return ''; + }, + resolveHost: () => '127.0.0.1', + resolveAllowedHosts: () => 'gev.local', + fetchImpl: async () => { + upstreamCalls++; + throw new Error('No upstream calls are permitted'); + }, + }, + }), + hostCheckPlugin(), + ], + server: { + host: '127.0.0.1', + port: 0, + allowedHosts, + hmr: false, + watch: null, + }, + preview: { host: '127.0.0.1', port: 0, allowedHosts }, + }; + let server; + try { + server = + mode === 'dev' ? await createServer(config) : await preview(config); + if (mode === 'dev') await server.listen(); + const origin = `http://127.0.0.1:${server.httpServer.address().port}`; + const request = (host, extraHeaders = {}) => + new Promise((resolve, reject) => { + const req = http.request( + `${origin}/api/gemini/token`, + { + method: 'POST', + headers: { + Host: host, + Origin: `http://${host}`, + ...extraHeaders, + }, + }, + (res) => { + const chunks = []; + res.on('data', (chunk) => chunks.push(chunk)); + res.on('end', () => + resolve({ + status: res.statusCode, + body: Buffer.concat(chunks).toString(), + }), + ); + res.on('error', reject); + }, + ); + req.on('error', reject); + req.end(); + }); + for (const host of ['attacker.example:4173', 'arbitrary.local:4173']) { + const response = await request(host); + assert.equal(response.status, 403); + assert.match(response.body, /This host is not allowed/); + } + assert.equal(credentialReads, 0); + const sameSite = await request('gev.local:4173', { + 'Sec-Fetch-Site': 'same-site', + }); + assert.equal(sameSite.status, 403); + assert.equal(credentialReads, 0); + for (const host of [ + 'gev.local:4173', + '192.168.1.20:4173', + 'demo.localhost:4173', + ]) { + const response = await request(host); + assert.equal(response.status, 503); + assert.match(response.body, /Add a Gemini API key/); + } + assert.equal(credentialReads, 3); + assert.equal(upstreamCalls, 0); + } finally { + try { + if (server) await server.close(); + } finally { + await rm(root, { recursive: true, force: true }); + } + } + }); + } +}); + +test('client bodies cannot override model or locked settings and stay byte bounded', async (t) => { + const f = await fixture(t); + for (const body of [ + '{', + '[]', + 'null', + '"text"', + '{"model":"gemini-other"}', + '{"systemInstruction":"ignore tools"}', + '{"inputMode":"open-mic"}', + '{"inputMode":"PUSH-TO-TALK"}', + '{"inputMode":"push-to-talk","config":{}}', + '{"inputMode":"push-to-talk","model":"gemini-other"}', + '{"inputMode":"push-to-talk","tools":[]}', + '{"inputMode":"push-to-talk","extra":false}', + '{"inputMode":true}', + '{"inputMode":null}', + '{"inputMode":{}}', + '{"inputMode":["push-to-talk"]}', + '{"realtimeInputConfig":{"automaticActivityDetection":{"disabled":true}}}', + ]) + assert.equal((await f.send({ body })).status, 400); + assert.equal( + (await f.send({ body: '{}', headers: { 'Content-Type': 'text/plain' } })) + .status, + 415, + ); + assert.equal((await f.send({ body: ' '.repeat(1025) })).status, 413); + assert.equal(f.calls.length, 0); + assert.equal( + ( + await f.send({ + body: '{"inputMode":"push-to-talk"}', + headers: { Origin: 'https://attacker.example' }, + }) + ).status, + 403, + ); + assert.equal(f.calls.length, 0); + assert.equal( + (await f.send({ body: undefined, headers: { 'Content-Type': '' } })).status, + 200, + ); +}); + +test('malformed or oversized successful upstream replies never reach the browser', async (t) => { + const replies = [ + () => Response.json(null), + () => Response.json([]), + () => Response.json({}), + () => Response.json({ name: API_KEY }), + () => Response.json({ name: `auth_tokens/${API_KEY}` }), + () => Response.json({ name: 'auth_tokens/https://secret.example/key' }), + () => Response.json({ name: `auth_tokens/${'x'.repeat(8192)}` }), + () => + new Response('upstream body with private-gemini-test-key-never-return'), + () => new Response(' '.repeat(32 * 1024 + 1)), + () => + new Response('{}', { + headers: { 'Content-Length': String(32 * 1024 + 1) }, + }), + ]; + for (const makeResponse of replies) { + const f = await fixture(t, { fetchImpl: async () => makeResponse() }); + const response = await f.send(); + assert.equal(response.status, 502); + const body = await response.text(); + assert.equal(body.includes(API_KEY), false); + assert.equal(body.includes('upstream'), false); + assert.equal(body.includes('secret.example'), false); + } +}); + +test('upstream refusals and network exceptions are normalized and never log secrets', async (t) => { + const leaked = `${API_KEY} https://generativelanguage.googleapis.com/v1beta/auth_tokens?key=${API_KEY}`; + const logs = []; + const originalWarn = console.warn; + console.warn = (...args) => logs.push(args.join(' ')); + t.after(() => { + console.warn = originalWarn; + }); + for (const [status, expected] of [ + [400, 502], + [401, 503], + [403, 503], + [404, 502], + [429, 429], + [500, 502], + [302, 502], + ]) { + const f = await fixture(t, { + fetchImpl: async () => new Response(leaked, { status }), + }); + const response = await f.send(); + assert.equal(response.status, expected); + assert.equal((await response.text()).includes(API_KEY), false); + } + const failed = await fixture(t, { + fetchImpl: async () => { + throw new Error(leaked); + }, + }); + assert.equal((await failed.send()).status, 502); + const redirected = await fixture(t, { + fetchImpl: async () => { + const response = Response.json({ name: TOKEN }); + Object.defineProperty(response, 'url', { + value: 'https://attacker.example/token', + }); + return response; + }, + }); + assert.equal((await redirected.send()).status, 502); + assert.equal(logs.join(' ').includes(API_KEY), false); +}); + +test('a stalled mint is aborted at the deadline without forwarding error details', async (t) => { + let cancelled = false; + const f = await fixture(t, { + timeoutMs: 30, + fetchImpl: (_url, { signal }) => + new Promise((_resolve, reject) => { + signal.addEventListener( + 'abort', + () => { + cancelled = true; + reject(signal.reason); + }, + { once: true }, + ); + }), + }); + const response = await f.send(); + assert.equal(response.status, 504); + assert.equal(cancelled, true); + assert.deepEqual(await response.json(), { + error: 'Gemini voice could not connect. Try again.', + }); +}); + +test('request disconnect cancels its pending mint and cannot publish a late token', async (t) => { + let started; + let cancelled; + let release; + const ready = new Promise((resolve) => { + started = resolve; + }); + const aborted = new Promise((resolve) => { + cancelled = resolve; + }); + const f = await fixture(t, { + fetchImpl: (_url, { signal }) => { + signal.addEventListener('abort', cancelled, { once: true }); + started(); + return new Promise((resolve) => { + release = resolve; + }); + }, + }); + const req = http.request(f.origin, { + method: 'POST', + headers: { Origin: f.origin }, + }); + req.on('error', () => {}); + req.end(); + await ready; + req.destroy(); + await aborted; + release(Response.json({ name: TOKEN })); + await new Promise((resolve) => setImmediate(resolve)); +}); + +test('timeout also bounds a stalled upstream response body', async (t) => { + let cancelled = false; + const f = await fixture(t, { + timeoutMs: 30, + fetchImpl: async () => + new Response( + new ReadableStream({ + cancel() { + cancelled = true; + }, + }), + ), + }); + assert.equal((await f.send()).status, 504); + assert.equal(cancelled, true); +}); + +test('chunked client uploads are capped without starting a mint', async (t) => { + const f = await fixture(t); + const response = await f.sendRaw({ + body: ' '.repeat(1025), + headers: { 'Transfer-Encoding': 'chunked' }, + }); + assert.equal(response.status, 413); + assert.equal(f.calls.length, 0); +}); + +test('a partial token request cannot hold the handler beyond its deadline', async (t) => { + const f = await fixture(t, { timeoutMs: 30 }); + const response = await new Promise((resolve, reject) => { + const req = http.request( + f.origin, + { + method: 'POST', + headers: { + Origin: f.origin, + 'Content-Type': 'application/json', + 'Transfer-Encoding': 'chunked', + }, + }, + resolve, + ); + req.on('error', reject); + t.after(() => req.destroy()); + req.write('{'); + }); + assert.equal(response.statusCode, 504); + response.resume(); + await once(response, 'end'); + assert.equal(f.calls.length, 0); +}); diff --git a/src/keySetupCore.mjs b/src/keySetupCore.mjs index d455f1ee4..1446cc621 100644 --- a/src/keySetupCore.mjs +++ b/src/keySetupCore.mjs @@ -17,7 +17,7 @@ import { hasProxySignals } from './localRequestGate.mjs'; /** Longest accepted key/token value. Real provider keys are all far shorter. */ export const KEY_SETUP_VALUE_LIMIT = 512; -/** Most env vars accepted in one save. The registry defines ten. */ +/** Most env vars accepted in one save; enough for the complete registry. */ export const KEY_SETUP_UPDATE_LIMIT = 16; /** Header line written above keys the panel appends to a .env file. */ @@ -60,6 +60,14 @@ export const KEY_SETUP_KEYS = Object.freeze([ envVars: Object.freeze(['OPENAI_API_KEY']), tier: 'metered', }), + Object.freeze({ + id: 'gemini', + title: 'GEMINI VOICE', + unlocks: 'Voice control — free tier depends on model and project quotas', + getUrl: 'https://aistudio.google.com/apikey', + envVars: Object.freeze(['GEMINI_API_KEY']), + tier: 'free', + }), Object.freeze({ id: 'aisstream', title: 'AISSTREAM', diff --git a/src/keySetupCore.test.mjs b/src/keySetupCore.test.mjs index c734fe766..23413b888 100644 --- a/src/keySetupCore.test.mjs +++ b/src/keySetupCore.test.mjs @@ -393,3 +393,34 @@ test('key guidance can offer another source that works with a key already set', ); assert.equal(keySetupWithAlternatives('Needs X', []), 'Needs X'); }); + +test('Gemini voice has an independent server-only key and never reuses Maps credentials', () => { + const mapsOnly = keySetupStatus({ GOOGLE_MAPS_API_KEY: 'maps-fixture' }); + const gemini = mapsOnly.keys.find((key) => key.id === 'gemini'); + assert.equal(gemini.title, 'GEMINI VOICE'); + assert.equal(gemini.getUrl, 'https://aistudio.google.com/apikey'); + assert.deepEqual(gemini.envVars, ['GEMINI_API_KEY']); + assert.equal(gemini.clientExposed, false); + assert.equal(gemini.set, false); + assert.match(gemini.unlocks, /quota/); + assert.equal(keySetupRequirement('gemini'), 'Needs GEMINI_API_KEY — add it in Provider Settings'); + + const geminiOnly = keySetupStatus({ GEMINI_API_KEY: 'gemini-private-fixture' }); + assert.equal(geminiOnly.keys.find((key) => key.id === 'gemini').set, true); + assert.equal(geminiOnly.keys.find((key) => key.id === 'google-maps').set, false); + assert.equal(geminiOnly.keys.find((key) => key.id === 'openai').set, false); + assert.equal(geminiOnly.setCount, 1); + assert.doesNotMatch(JSON.stringify(geminiOnly), /gemini-private-fixture/); + assert.equal(keySetupStatus({ GEMINI_API_KEY: ' ' }).keys.find((key) => key.id === 'gemini').set, false); +}); + +test('saving and removing a Gemini key preserves Google Maps and OpenAI credentials', () => { + const original = 'GOOGLE_MAPS_API_KEY=maps-fixture\nOPENAI_API_KEY=openai-fixture\n# GEMINI_API_KEY=\n'; + const update = validateKeySetupUpdates({ GEMINI_API_KEY: 'gemini-fixture' }); + assert.equal(update.ok, true); + const saved = upsertDotenvValues(original, update.updates); + assert.equal(saved, original.replace('# GEMINI_API_KEY=', 'GEMINI_API_KEY=gemini-fixture')); + const removal = validateKeySetupUpdates({ GEMINI_API_KEY: null }); + assert.equal(removal.ok, true); + assert.equal(upsertDotenvValues(saved, removal.updates), original); +}); diff --git a/src/layers/radio/playback.js b/src/layers/radio/playback.js index cbb4b8be5..f580e5175 100644 --- a/src/layers/radio/playback.js +++ b/src/layers/radio/playback.js @@ -148,6 +148,12 @@ export function createPlayback({ state: layerState, services, parts, source }) { const generation = ++layerState._playGeneration; const ownedAttemptId = attemptId || `radio-play-${++layerState._playAttemptSequence}`; + if (origin === 'user') + parts.presentation.emitPlaybackControl( + 'play-request', + origin, + ownedAttemptId, + ); layerState._activePlaybackAttempt = { id: ownedAttemptId, origin, diff --git a/src/layers/radio/selection.js b/src/layers/radio/selection.js index bffc7de04..de2de3de5 100644 --- a/src/layers/radio/selection.js +++ b/src/layers/radio/selection.js @@ -104,6 +104,8 @@ export function createSelection({ ) parts.tuning.endRadioTuning(); layerState._cancelledTuningPresentationStation = null; + if (origin === 'user') + parts.presentation.emitPlaybackControl('select', origin); layerState._selectedId = station.id; const generation = ++layerState._selectionGeneration; const sessionGeneration = layerState._sessionGeneration; diff --git a/src/layers/radio/tuning.js b/src/layers/radio/tuning.js index ab5c56bb5..5d2b092bc 100644 --- a/src/layers/radio/tuning.js +++ b/src/layers/radio/tuning.js @@ -67,6 +67,7 @@ export function createTuning({ state: layerState, services, parts, source }) { layerState._tuningStartStationId = layerState._selectedId; layerState._tuningPreviewId = layerState._selectedId; parts.tuningNoise.installTuningNoise(); + parts.presentation.emitPlaybackControl('tune', 'user'); parts.playback.pauseRadioPlayback({ origin: 'user' }); parts.tuningNoise.syncTuningNoiseGain(); parts.presentation.emitState(); diff --git a/src/pinokioEnvironment.test.mjs b/src/pinokioEnvironment.test.mjs index 817774c8d..392d1c380 100644 --- a/src/pinokioEnvironment.test.mjs +++ b/src/pinokioEnvironment.test.mjs @@ -22,6 +22,7 @@ const PROVIDER_FIELDS = [ 'GOOGLE_MAPS_SERVER_API_KEY', 'CESIUM_ION_TOKEN', 'OPENAI_API_KEY', + 'GEMINI_API_KEY', 'AISSTREAM_API_KEY', 'FIRMS_MAP_KEY', 'TOMTOM_API_KEY', @@ -42,6 +43,7 @@ test('the fresh template keeps provider credentials out of native Configure', () assert.equal(configured.PINOKIO_SHARE_LOCAL, 'false'); assert.equal(configured.PINOKIO_SHARE_VAR, '__gev_sharing_disabled__'); assert.equal(configured.GEV_RATELIMIT_OPENAI_PER_MIN, '30'); + assert.equal(configured.GEV_RATELIMIT_GEMINI_PER_MIN, '30'); assert.equal(configured.GEV_RATELIMIT_GOOGLE_PER_MIN, '120'); assert.match(source, /Do not enter credentials in Pinokio 8\.0\.40's native Configure panel/); assert.match(source, /trusted local text editor/); @@ -226,3 +228,34 @@ test('server Google key follows app values, blanks and absence instead of inheri rmSync(root, { recursive: true, force: true }); } }); + + +test('Gemini app credentials and model override inherited values without borrowing Maps', () => { + const root = mkdtempSync(path.join(tmpdir(), 'gev-pinokio-gemini-')); + try { + const filepath = path.join(root, 'ENVIRONMENT'); + for (const [source, expectedKey, expectedModel] of [ + ['GEMINI_API_KEY=app-gemini\nGEMINI_LIVE_MODEL=gemini-model-fixture\n', 'app-gemini', 'gemini-model-fixture'], + ['GEMINI_API_KEY=\nGEMINI_LIVE_MODEL=\n', '', ''], + ['GOOGLE_MAPS_API_KEY=maps-only\n', '', ''], + ]) { + writeFileSync(filepath, source); + const environment = { + GEMINI_API_KEY: 'global-gemini', + GEMINI_LIVE_MODEL: 'global-model', + GEV_RATELIMIT_GEMINI_PER_MIN: '999', + }; + applyPinokioEnvironment({ environment, filepath }); + assert.equal(environment.GEMINI_API_KEY, expectedKey); + assert.equal(environment.GEMINI_LIVE_MODEL, expectedModel); + assert.equal(environment.GEV_RATELIMIT_GEMINI_PER_MIN, '30'); + assert.equal(environment.OPENAI_API_KEY, ''); + } + writeFileSync(filepath, 'GEV_RATELIMIT_GEMINI_PER_MIN=12\n'); + const environment = {}; + applyPinokioEnvironment({ environment, filepath }); + assert.equal(environment.GEV_RATELIMIT_GEMINI_PER_MIN, '12'); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); diff --git a/src/pinokioLauncherContract.test.mjs b/src/pinokioLauncherContract.test.mjs index 24c9a9908..ddba5ea1c 100644 --- a/src/pinokioLauncherContract.test.mjs +++ b/src/pinokioLauncherContract.test.mjs @@ -15,6 +15,7 @@ const PROVIDER_FIELDS = [ 'GOOGLE_MAPS_SERVER_API_KEY', 'CESIUM_ION_TOKEN', 'OPENAI_API_KEY', + 'GEMINI_API_KEY', 'AISSTREAM_API_KEY', 'FIRMS_MAP_KEY', 'TOMTOM_API_KEY', @@ -24,9 +25,10 @@ const PROVIDER_FIELDS = [ ]; const RATE_LIMIT_FIELDS = [ 'GEV_RATELIMIT_OPENAI_PER_MIN', + 'GEV_RATELIMIT_GEMINI_PER_MIN', 'GEV_RATELIMIT_GOOGLE_PER_MIN', ]; -const APP_VALUE_FIELDS = [...PROVIDER_FIELDS, ...RATE_LIMIT_FIELDS]; +const APP_VALUE_FIELDS = [...PROVIDER_FIELDS, ...RATE_LIMIT_FIELDS, 'GEMINI_LIVE_MODEL']; const SHARING_FIELDS = [ 'PINOKIO_SHARE_CLOUDFLARE', 'PINOKIO_SHARE_LOCAL', diff --git a/src/radioMarkup.test.mjs b/src/radioMarkup.test.mjs index 23ad778d5..f574151f6 100644 --- a/src/radioMarkup.test.mjs +++ b/src/radioMarkup.test.mjs @@ -5,25 +5,50 @@ import { expandApplicationHtml } from '../build/application-html.js'; import { readLayerSource } from './testSupport/readLayerSource.mjs'; import { readStylesheet } from './testSupport/readStylesheet.mjs'; import { readFileSync as readRadioSource } from 'node:fs'; -const radioBindings = readRadioSource(new URL('./ui/radioBindings.js', import.meta.url), 'utf8'); -const radioPresentation = readRadioSource(new URL('./ui/radioPresentation.js', import.meta.url), 'utf8'); -const radioControlsSource = readRadioSource(new URL('./ui/radioControls.js', import.meta.url), 'utf8'); +const radioBindings = readRadioSource( + new URL('./ui/radioBindings.js', import.meta.url), + 'utf8', +); +const radioPresentation = readRadioSource( + new URL('./ui/radioPresentation.js', import.meta.url), + 'utf8', +); +const radioControlsSource = readRadioSource( + new URL('./ui/radioControls.js', import.meta.url), + 'utf8', +); import assert from 'node:assert/strict'; import { createHash } from 'node:crypto'; import { readFileSync } from 'node:fs'; import test from 'node:test'; -const html = expandApplicationHtml(readFileSync(new URL('../index.html', import.meta.url), 'utf8')); +const html = expandApplicationHtml( + readFileSync(new URL('../index.html', import.meta.url), 'utf8'), +); const ui = readShellSource(); -const radio = ['playback', 'interaction'].map(name => - readFileSync(new URL(`./layers/radio/${name}.js`, import.meta.url), 'utf8') -).join('\n').replace(/layerState\.|parts\.\w+\./g, ''); -const rocketLaunches = readLayerSource(new URL('./data/rocketLaunches.js', import.meta.url), 'utf8'); +const radio = ['playback', 'interaction'] + .map((name) => + readFileSync(new URL(`./layers/radio/${name}.js`, import.meta.url), 'utf8'), + ) + .join('\n') + .replace(/layerState\.|parts\.\w+\./g, ''); +const rocketLaunches = readLayerSource( + new URL('./data/rocketLaunches.js', import.meta.url), + 'utf8', +); const realtime = readRealtimeSource(); -const voice = ['./voice/actionSchemas.js', '../server/providers/openai/toolDescriptions.js', '../server/providers/openai/instructions.js'].map(file => readFileSync(new URL(file, import.meta.url), 'utf8')).join('\n'); +const voice = [ + './voice/actionSchemas.js', + '../server/providers/openai/toolDescriptions.js', + '../server/providers/openai/instructions.js', +] + .map((file) => readFileSync(new URL(file, import.meta.url), 'utf8')) + .join('\n'); const css = readStylesheet(new URL('../style.css', import.meta.url)); -function realtimeTools() { return GEV_REALTIME_TOOLS; } +function realtimeTools() { + return GEV_REALTIME_TOOLS; +} test('Realtime schema exposes the authoritative 30-tool inventory', () => { const tools = realtimeTools(); @@ -58,14 +83,26 @@ test('the counting contract is stated in the Realtime instructions', () => { // One instruction per source line; the string carries escaped quotes, so take // the line rather than trying to match a quoted literal. const text = voice.slice(start, voice.indexOf('\n', start)); - assert.match(text, /Contacts is ACTIVE/, 'rule 1: active means the Contacts window'); + assert.match( + text, + /Contacts is ACTIVE/, + 'rule 1: active means the Contacts window', + ); assert.match(text, /contactsWindow/, 'rule 1 names its mechanism'); assert.match(text, /call set_context_mode\{mode:"contacts"\} first/); assert.match(text, /contactsWindow\.aircraft/); - assert.match(text, /Contacts OFF, "nearby" means in view/, 'rule 2: off means in view'); + assert.match( + text, + /Contacts OFF, "nearby" means in view/, + 'rule 2: off means in view', + ); assert.match(text, /EVERY count names its scope in words/, 'rule 3'); assert.match(text, /scopeLabel/, 'rule 3 names its mechanism'); - assert.match(text, /never a bare number/, 'rule 3 is stated as a prohibition too'); + assert.match( + text, + /never a bare number/, + 'rule 3 is stated as a prohibition too', + ); assert.match(text, /VERBATIM/, 'rule 4: no estimating'); // Rule 5, the loaded-data caveat, is now one word inside the spoken scope // ("37 loaded flights over Texas"); the longer note stays on screen. @@ -73,24 +110,44 @@ test('the counting contract is stated in the Realtime instructions', () => { voice.indexOf("'For ANALYTICAL questions"), voice.indexOf('\n', voice.indexOf("'For ANALYTICAL questions")), ); - assert.match(analyst, /"37 loaded flights over Texas"/, 'rule 5: loaded scope stays audible'); + assert.match( + analyst, + /"37 loaded flights over Texas"/, + 'rule 5: loaded scope stays audible', + ); }); test('Context panel opening stays distinct from Contacts activation', () => { - const start = voice.indexOf("'For requests to open, show, reveal, or focus a menu/panel"); + const start = voice.indexOf( + "'For requests to open, show, reveal, or focus a menu/panel", + ); assert.ok(start >= 0, 'panel-routing instruction is missing'); const text = voice.slice(start, voice.indexOf('\n', start)); assert.match(text, /"Open Context" means only set_panel_open/); assert.match(text, /does not activate a Context sub-mode/); - assert.match(text, /"Open Contacts" means set_context_mode\{mode:"contacts"\}/); - assert.match(text, /expands the parent Context panel before activating Contacts/); + assert.match( + text, + /"Open Contacts" means set_context_mode\{mode:"contacts"\}/, + ); + assert.match( + text, + /expands the parent Context panel before activating Contacts/, + ); }); test('nearest-aircraft selection stays out of Contacts and Cockpit', () => { - const start = voice.indexOf("'For a request to enable an aircraft layer and SELECT or FIND"); - assert.ok(start >= 0, 'nearest-aircraft selection routing instruction is missing'); + const start = voice.indexOf( + "'For a request to enable an aircraft layer and SELECT or FIND", + ); + assert.ok( + start >= 0, + 'nearest-aircraft selection routing instruction is missing', + ); const text = voice.slice(start, voice.indexOf('\n', start)); - assert.match(text, /Turn on flights and select the closest aircraft to Austin/); + assert.match( + text, + /Turn on flights and select the closest aircraft to Austin/, + ); assert.match(text, /call select_nearest_aircraft once/); assert.match(text, /atomically turns on the requested aircraft layer first/); assert.match(text, /waits for location arrival/); @@ -98,22 +155,40 @@ test('nearest-aircraft selection stays out of Contacts and Cockpit', () => { assert.match(text, /filters out landed\/on-ground records/); assert.match(text, /nearest airborne result/); assert.match(text, /healthy fallback feed is valid data/i); - assert.match(text, /Do not also call fly_to_location, set_layer_visibility, analyst_query, track_entity/); + assert.match( + text, + /Do not also call fly_to_location, set_layer_visibility, analyst_query, track_entity/, + ); assert.match(text, /SELECT\/FIND never implies Contacts or Cockpit/); assert.match(text, /set_context_mode, or control_cockpit/); const byName = new Map(realtimeTools().map((tool) => [tool.name, tool])); - assert.match(byName.get('set_context_mode').description, /explicitly requests/); - assert.match(byName.get('set_context_mode').description, /selecting an aircraft does not imply Context/i); - assert.match(byName.get('control_cockpit').description, /explicitly requests Cockpit/); - assert.match(byName.get('control_cockpit').description, /must not enter Cockpit/); + assert.match( + byName.get('set_context_mode').description, + /explicitly requests/, + ); + assert.match( + byName.get('set_context_mode').description, + /selecting an aircraft does not imply Context/i, + ); + assert.match( + byName.get('control_cockpit').description, + /explicitly requests Cockpit/, + ); + assert.match( + byName.get('control_cockpit').description, + /must not enter Cockpit/, + ); assert.equal( byName.get('fly_to_location').parameters.properties.waitForArrival.type, 'boolean', ); const nearest = byName.get('select_nearest_aircraft'); assert.deepEqual(nearest.parameters.required, ['layerId']); - assert.deepEqual(nearest.parameters.properties.layerId.enum, ['flights', 'military']); + assert.deepEqual(nearest.parameters.properties.layerId.enum, [ + 'flights', + 'military', + ]); assert.match(nearest.description, /Atomically/); assert.match(nearest.description, /exclude on-ground records/); assert.match(nearest.description, /fallback feeds remain usable/); @@ -125,17 +200,24 @@ test('the two Context/Cockpit tools pin their enums and required arguments', () const contextMode = byName.get('set_context_mode'); assert.deepEqual(contextMode.parameters.required, ['mode']); - assert.deepEqual( - contextMode.parameters.properties.mode.enum, - ['off', 'contacts', 'flights', 'space-missions', 'missions'], - ); + assert.deepEqual(contextMode.parameters.properties.mode.enum, [ + 'off', + 'contacts', + 'flights', + 'space-missions', + 'missions', + ]); const cockpit = byName.get('control_cockpit'); assert.deepEqual(cockpit.parameters.required, ['action']); - assert.deepEqual( - cockpit.parameters.properties.action.enum, - ['enter', 'exit', 'previous', 'next', 'prev', 'status'], - ); + assert.deepEqual(cockpit.parameters.properties.action.enum, [ + 'enter', + 'exit', + 'previous', + 'next', + 'prev', + 'status', + ]); assert.deepEqual( cockpit.parameters.properties.targetLayer.enum, ['flights', 'military', 'ais-live-vessels', 'military-installations'], @@ -151,10 +233,18 @@ test('the edited existing tools changed exactly as intended', () => { // Edit 1: the Context panel became voice-addressable alongside set_context_mode. const panel = byName.get('set_panel_open'); - assert.deepEqual( - panel.parameters.properties.panelId.enum, - ['data-panel', 'location-bar', 'control-panel', 'cctv-panel', 'radio-panel', 'scene-panel', 'pp-toggles', 'global-context-panel', 'weather-panel', 'recent-imagery-panel'], - ); + assert.deepEqual(panel.parameters.properties.panelId.enum, [ + 'data-panel', + 'location-bar', + 'control-panel', + 'cctv-panel', + 'radio-panel', + 'scene-panel', + 'pp-toggles', + 'global-context-panel', + 'weather-panel', + 'recent-imagery-panel', + ]); assert.deepEqual(panel.parameters.required, ['panelId', 'open']); // Edit 2: description only — the view state now reports Context and Cockpit. @@ -165,7 +255,10 @@ test('the edited existing tools changed exactly as intended', () => { // Edit 3: dependent multi-tool navigation can wait for the destination view. const location = byName.get('fly_to_location'); assert.equal(location.parameters.properties.waitForArrival.type, 'boolean'); - assert.match(location.parameters.properties.waitForArrival.description, /arrived=true/); + assert.match( + location.parameters.properties.waitForArrival.description, + /arrived=true/, + ); }); test('no unchanged Realtime tool definition drifts silently', () => { @@ -199,6 +292,8 @@ test('no unchanged Realtime tool definition drifts silently', () => { 'track_entity', // Point-and-ask: 'pointer' sentinels and referent arguments. 'annotate_map', + // "Turn on" enables the layer; playback needs an explicit request. + 'control_radio', ]); const unchanged = realtimeTools() .filter((tool) => !TOUCHED.has(tool.name)) @@ -206,56 +301,121 @@ test('no unchanged Realtime tool definition drifts silently', () => { .map((tool) => structuredClone(tool)) .filter((tool) => tool.name !== 'set_cyber_sonar'); // Cyber adds one HUD choice and the sonar tool; retain the existing pin for every legacy field. - const hudLayout = unchanged.find((tool) => tool.name === 'set_hud').parameters.properties.layout; - assert.deepEqual(hudLayout.enum, ['tactical', 'operator', 'minimal', 'cyber']); + const hudLayout = unchanged.find((tool) => tool.name === 'set_hud').parameters + .properties.layout; + assert.deepEqual(hudLayout.enum, [ + 'tactical', + 'operator', + 'minimal', + 'cyber', + ]); hudLayout.enum = hudLayout.enum.filter((layout) => layout !== 'cyber'); - assert.equal(unchanged.length, 14); + assert.equal(unchanged.length, 13); const digest = createHash('sha256') .update(JSON.stringify(unchanged)) .digest('hex') .slice(0, 16); // Analyst additions and ISS wording correction are explicitly excluded above; all other tool definitions retain their pin. - assert.equal(digest, '25d1b902ecaa22fb', 'an unchanged Realtime tool definition drifted'); + // control_radio is excluded above; the remaining 13 match current main. + assert.equal( + digest, + '62bc6b1534c353dc', + 'an unchanged Realtime tool definition drifted', + ); }); test('Radio volume and mission speed share the Sharpen slider visual language', () => { - for (const id of ['cockpit-radio-volume', 'context-radio-mini-volume', 'radio-volume', 'sdr-volume']) { + for (const id of [ + 'cockpit-radio-volume', + 'context-radio-mini-volume', + 'radio-volume', + 'sdr-volume', + ]) { assert.match( html, - new RegExp(`id="${id}"[^>]*class="gev-quantitative-slider"[^>]*type="range"`), + new RegExp( + `id="${id}"[^>]*class="gev-quantitative-slider"[^>]*type="range"`, + ), ); } assert.match( rocketLaunches, /id="space-mission-replay-speed" class="gev-quantitative-slider" type="range" min="0\.25" max="4" step="0\.25" value="1"/, ); - assert.match(rocketLaunches, /class="gev-slider-value"[^>]*data-mission-replay-speed-output/); - assert.match(css, /\.gev-quantitative-slider\s*\{[\s\S]*?min-width: 0;[\s\S]*?height: 18px;/); - assert.match(css, /\.gev-quantitative-slider::-webkit-slider-runnable-track\s*\{[\s\S]*?height: 3px;[\s\S]*?background: rgba\(255, 255, 255, 0\.08\);/); - assert.match(css, /\.gev-quantitative-slider::-webkit-slider-thumb\s*\{[\s\S]*?width: 10px;[\s\S]*?height: 10px;[\s\S]*?border-radius: 50%;[\s\S]*?background: var\(--accent\);/); - assert.match(css, /\.gev-quantitative-slider:focus-visible\s*\{[\s\S]*?outline: 1px solid/); - assert.match(css, /\.gev-quantitative-slider:disabled\s*\{[\s\S]*?opacity: 0\.42;[\s\S]*?cursor: not-allowed;/); - assert.match(css, /\.gev-slider-value\s*\{[\s\S]*?color: var\(--accent\);[\s\S]*?font-size: 9px;/); - assert.doesNotMatch(css, /#space-mission-panel \[data-mission-replay-speed\]::-webkit-slider-thumb/); + assert.match( + rocketLaunches, + /class="gev-slider-value"[^>]*data-mission-replay-speed-output/, + ); + assert.match( + css, + /\.gev-quantitative-slider\s*\{[\s\S]*?min-width: 0;[\s\S]*?height: 18px;/, + ); + assert.match( + css, + /\.gev-quantitative-slider::-webkit-slider-runnable-track\s*\{[\s\S]*?height: 3px;[\s\S]*?background: rgba\(255, 255, 255, 0\.08\);/, + ); + assert.match( + css, + /\.gev-quantitative-slider::-webkit-slider-thumb\s*\{[\s\S]*?width: 10px;[\s\S]*?height: 10px;[\s\S]*?border-radius: 50%;[\s\S]*?background: var\(--accent\);/, + ); + assert.match( + css, + /\.gev-quantitative-slider:focus-visible\s*\{[\s\S]*?outline: 1px solid/, + ); + assert.match( + css, + /\.gev-quantitative-slider:disabled\s*\{[\s\S]*?opacity: 0\.42;[\s\S]*?cursor: not-allowed;/, + ); + assert.match( + css, + /\.gev-slider-value\s*\{[\s\S]*?color: var\(--accent\);[\s\S]*?font-size: 9px;/, + ); + assert.doesNotMatch( + css, + /#space-mission-panel \[data-mission-replay-speed\]::-webkit-slider-thumb/, + ); }); test('Radio is nested inside Context with separate disclosure and power controls', () => { const contextStart = html.indexOf('id="global-context-panel"'); const radioStart = html.indexOf('id="radio-panel"'); const contextEnd = html.indexOf('\n ', contextStart); - assert.ok(contextStart >= 0 && radioStart > contextStart && radioStart < contextEnd); + assert.ok( + contextStart >= 0 && radioStart > contextStart && radioStart < contextEnd, + ); assert.match(html, /id="radio-panel"[^>]*data-panel-id="radio-panel"/); assert.match(html, /aria-label="Radio playback"/); - assert.match(html, /id="context-radio-toggle-btn"[^>]*aria-expanded="false"[^>]*aria-controls="context-radio-mini"/); + assert.match( + html, + /id="context-radio-toggle-btn"[^>]*aria-expanded="false"[^>]*aria-controls="context-radio-mini"/, + ); assert.doesNotMatch(html, /id="context-radio-toggle-btn"[^>]*aria-pressed=/); - assert.match(html, /id="context-radio-mini"[^>]*aria-label="Compact Radio controls"[^>]*hidden/); - assert.match(html, /id="context-radio-mini-enable-btn"[^>]*aria-pressed="false"/); - assert.match(html, /id="context-radio-details-btn"[^>]*aria-expanded="false"[^>]*aria-controls="radio-panel"/); - assert.match(html, /id="context-radio-details-btn"[\s\S]*?