// Recap-side cache of the relay's GET /relay/capabilities response. // The relay tells us how large an audio file it can comfortably accept // FOR THIS SPECIFIC INSTALL given the relay's current routing config // AND this install's tier + Gemini-cap state. The same operator config // will route a fresh install through Gemini (chunk at 60min/30MB) and // a cap-exhausted install through hardware (no chunking) — so the // capabilities answer is per-install, not per-operator. // // Recap calls this in three places: // - on boot (warm the cache with safe defaults) // - hourly background refresh (catches operator config edits) // - inline before every relay-backed transcribe (so the chunking // decision matches the routing decision the relay will actually // make for this install RIGHT NOW) // // When the relay is unreachable, we fall back to Gemini-safe defaults // so chunking happens defensively for long audio. import { getRelayBaseURL } from "./relay-default.js"; import { getInstallId } from "./install-id.js"; import { getRawLicenseKey } from "./license.js"; const REFRESH_INTERVAL_MS = 60 * 60 * 1000; // hourly const FETCH_TIMEOUT_MS = 5000; // Safe defaults: chunk like we have been. Used until /relay/capabilities // successfully populates the cache, OR if the relay is unreachable. const DEFAULTS = Object.freeze({ max_audio_mb: 30, max_audio_minutes: 60, preferred_chunk_seconds: 2700, // Audio-first ("walking mode") TTS availability. The relay advertises // whether ANY TTS backend (Kokoro on operator hardware, or ElevenLabs) // can serve a /relay/tts call. The frontend uses has_tts to decide // whether to show the "Listen" affordance; the prepare route checks it // before attempting synthesis. Conservative default: off until a fetch // confirms it. has_tts: false, tts_backend: null, // "kokoro" | "elevenlabs" | null tts_default_voice: null, reason: "default (relay unreachable or not yet fetched)", fetched_at: 0, }); let cached = { ...DEFAULTS }; let refreshTimer = null; export async function refreshRelayCapabilities() { const base = getRelayBaseURL(); if (!base) return cached; const url = `${base.replace(/\/$/, "")}/relay/capabilities`; // Send install-id + license so the relay can run the per-install // routing decision. Both are optional from the relay's perspective — // missing install-id falls back to operator-wide capabilities, which // is still safer than nothing. const headers = {}; try { const installId = getInstallId(); if (installId) headers["X-Recap-Install-Id"] = installId; } catch {} try { const licenseKey = getRawLicenseKey(); if (licenseKey) headers["Authorization"] = `Bearer ${licenseKey}`; } catch {} try { const r = await fetch(url, { headers, signal: AbortSignal.timeout(FETCH_TIMEOUT_MS), }); if (!r.ok) { console.warn(`[relay-capabilities] ${url} returned HTTP ${r.status}`); return cached; } const data = await r.json(); cached = { max_audio_mb: typeof data.max_audio_mb === "number" ? data.max_audio_mb : DEFAULTS.max_audio_mb, max_audio_minutes: typeof data.max_audio_minutes === "number" ? data.max_audio_minutes : DEFAULTS.max_audio_minutes, preferred_chunk_seconds: data.preferred_chunk_seconds === null ? null : typeof data.preferred_chunk_seconds === "number" ? data.preferred_chunk_seconds : DEFAULTS.preferred_chunk_seconds, has_tts: !!data.has_tts, tts_backend: typeof data.tts_backend === "string" ? data.tts_backend : null, tts_default_voice: typeof data.tts_default_voice === "string" ? data.tts_default_voice : null, reason: typeof data.reason === "string" ? data.reason : null, fetched_at: Date.now(), }; console.log( `[relay-capabilities] refreshed: ${cached.max_audio_mb}MB / ${cached.max_audio_minutes}min / chunk=${cached.preferred_chunk_seconds}s (${cached.reason})` ); } catch (err) { console.warn(`[relay-capabilities] fetch failed: ${err?.message || err}`); } return cached; } export function startRelayCapabilitiesRefresh() { // Fire-and-forget first refresh on boot; schedule hourly thereafter. refreshRelayCapabilities().catch(() => {}); if (refreshTimer) clearInterval(refreshTimer); refreshTimer = setInterval(() => { refreshRelayCapabilities().catch(() => {}); }, REFRESH_INTERVAL_MS); } export function getRelayCapabilities() { return cached; }