diff --git a/.env.template b/.env.template index 7e616cc..cdd813f 100644 --- a/.env.template +++ b/.env.template @@ -9,6 +9,10 @@ VITE_STRAVA_CLIENT_ID= GMP_SERVER_API_KEY= VITE_GMP_API_KEY= VITE_ISOCHRONES_GMP_API_KEY= +# Optional dedicated server key for Real World Reasoning's Maps Grounding Lite. +# Falls back to GMP_SERVER_API_KEY when empty. +GMP_MCP_KEY= +RWR_GROUNDING_LITE_ENABLED=false # Contact form + email list (optional locally) RESEND_API_KEY= @@ -31,7 +35,7 @@ GITHUB_REVIEW_TOKEN= # Analytics (optional public configuration for the portfolio and hosted demos) ANALYTICS_MEASUREMENT_ID= -# Gemini (optional; server-only contact classification) +# Gemini (server-only Hairstyle AI shared tier and optional contact classification) # Never prefix this with VITE_. Configure the production value through # Secret Manager as GEMINI_API_KEY=gemini-api-key:latest on Cloud Run. GEMINI_API_KEY= diff --git a/.github/dependabot.yml b/.github/dependabot.yml index fab4c6d..849079c 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -25,6 +25,11 @@ updates: schedule: interval: "weekly" + - package-ecosystem: "npm" + directory: "/demos/real-world-reasoning-agent" + schedule: + interval: "weekly" + - package-ecosystem: "github-actions" directory: "/" schedule: diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index ed515e5..110754e 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -121,7 +121,7 @@ jobs: echo "- RESEND_API_KEY (optional)" echo "- RESEND_SEGMENT_ID (optional, email-list subscriptions)" echo "- RESEND_TOPIC_ID (optional, email-list preferences)" - echo "- GEMINI_API_KEY (optional)" + echo "- GEMINI_API_KEY (required for the Hairstyle AI shared tier)" echo "- CONTACT_TO_EMAIL (optional)" echo "- GOOGLE_OAUTH_CLIENT_SECRET (required for /writer/)" echo "- GOOGLE_OAUTH_SESSION_SECRET (required for /writer/)" @@ -209,6 +209,7 @@ jobs: # --project "$GCP_PROJECT_ID" --region "$GCP_REGION" \ # --update-secrets=STRAVA_CLIENT_SECRET=strava-client-secret:latest \ # --update-secrets=GMP_SERVER_API_KEY=gmp-server-api-key:latest \ + # --update-secrets=GMP_MCP_KEY=gmp-mcp-key:latest \ # --update-secrets=RESEND_API_KEY=resend-api-key:latest \ # --update-secrets=RESEND_SEGMENT_ID=resend-segment-id:latest \ # --update-secrets=RESEND_TOPIC_ID=resend-topic-id:latest \ @@ -219,7 +220,7 @@ jobs: # --update-secrets=GITHUB_CONTENT_TOKEN=github-content-token:latest \ # --update-secrets=GITHUB_REVIEW_TOKEN=github-review-token:latest \ # --update-secrets=BUFFER_API_KEY=buffer-api-key:latest \ - # --set-env-vars=STRAVA_CLIENT_ID=,CONTACT_FROM_EMAIL=,GOOGLE_OAUTH_CLIENT_ID=,WRITER_PUBLIC_ORIGIN=https://ryanbaumann-dashboardfolio.admin.com,BUFFER_ORGANIZATION_ID=,BUFFER_LINKEDIN_CHANNEL_ID=,BUFFER_X_CHANNEL_ID= + # --update-env-vars=STRAVA_CLIENT_ID=,CONTACT_FROM_EMAIL=,GOOGLE_OAUTH_CLIENT_ID=,WRITER_PUBLIC_ORIGIN=https://ryanbaumann-dashboardfolio.admin.com,BUFFER_ORGANIZATION_ID=,BUFFER_LINKEDIN_CHANNEL_ID=,BUFFER_X_CHANNEL_ID=,RWR_GROUNDING_LITE_ENABLED=true # # STRAVA_CLIENT_ID isn't secret on its own (it's public in the # OAuth authorize URL), so plain --set-env-vars is fine for it. @@ -246,6 +247,7 @@ jobs: 'STRAVA_CLIENT_ID', 'STRAVA_CLIENT_SECRET', 'GMP_SERVER_API_KEY', + 'GEMINI_API_KEY', 'GITHUB_CONTENT_TOKEN', ]); for (const app of apps) { diff --git a/AGENTS.md b/AGENTS.md index 5cbce6f..46a9908 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -19,6 +19,8 @@ picture. - `demos/strava-explorer/`: Vite app for exploring Strava activities on Google Maps Platform 3D Maps. - `demos/aqi-map/`: Vite app rendering Air Quality API heatmap tiles and point conditions on a 2D Google map. - `demos/isochrones/`: Vite + Node app for reachability analysis using Google Maps Platform Isochrones. +- `demos/hairstyle-ai-studio/`: React + Vite app for Gemini-powered hairstyle generation, with a gateway-hosted daily allowance and optional tab-memory personal key. +- `demos/real-world-reasoning-agent/`: React + Vite Google Maps Platform and Gemini agent demo with first-party gateway proxies for live Maps and AI missions. - `gateway/`: zero-npm-dependency Node server that serves the portfolio at the root path, every workspace demo app's static build (both routed via the root `apps.json` manifest, most-specific path first), and same-origin `/api/*` diff --git a/CHANGELOG.md b/CHANGELOG.md index 83ef9ea..b7d70bc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,10 @@ All notable changes to this project will be documented in this file. ## [Unreleased] ### Security +- Documented and enforced the Lab gateway's key boundaries, CSP assumptions, + conservative per-IP and process-wide daily Maps/Gemini ceilings, bounded + limiter memory, privacy-limited analytics, and production Grounding Lite + configuration. - Added a same-origin, BYO-key Gemini gateway for Hairstyle AI Studio. Visitor keys stay in React memory, pass transiently in a request header, and are never stored or included in analytics. The gateway validates image data and @@ -16,6 +20,7 @@ All notable changes to this project will be documented in this file. - Pinned the Cloud Run service to `--max-instances 1`. The gateway's in-memory per-IP rate limiters (private-demo auth brute-force, and the spend limits in front of Isochrones, Gemini, and Resend) are only correct on a single instance, but the deploy passed no instance cap and Cloud Run's default is 100, so under load every limit silently became per-instance. Also pinned `--concurrency`, `--memory`, and `--cpu` at their current defaults so a platform default change cannot raise cost or dilute the limits again. ### Added +- Released Real World Reasoning Agent as a first-party open-source Fieldwork Lab from the explicitly authorized private snapshot at `68e8c34547066a984ccb97f5b587caeb97561ec1`. The reviewed source, tests, eval fixtures, guarded Maps/Gemini proxy logic, and provenance now live under `demos/real-world-reasoning-agent/`; the old repository's visibility and settings were not changed. - Imported Hairstyle AI Studio from its public upstream repository at `9ea2c0f31e5e1d252220ede6731b655bf2fb8fba`, hosted it at `/hairstyle-ai-studio/`, and placed it third in the homepage Labs order. @@ -28,6 +33,8 @@ All notable changes to this project will be documented in this file. mobile selection flow. The existing merge-time Buffer workflow now accepts validated one-off release drafts and stages them for approval without publishing. +- Added a follow-up Buffer draft for the five-free-generations tier and the + corrected personal-key fallback. It remains editable and unpublished. - Added frozen development/selection eval suites for responsive design, portfolio content/design/review, Google Maps Platform, and the skill improvement workflow. The deterministic gate now validates eval ownership, @@ -55,6 +62,7 @@ All notable changes to this project will be documented in this file. - Added gateway test coverage verifying that all external image, font, and avatar hosts loaded by demo apps are permitted by their respective CSP policies. ### Changed +- Reworked Hairstyle AI Studio's Gemini access to match the proven hosted-plus-BYOK pattern: each client IP receives five successful image generations per UTC day, recommendation analysis stays outside that spend cap, and a validated memory-only personal key bypasses the shared allowance while retaining a separate abuse guard. The UI now opens directly into the studio, shows remaining free generations, and distinguishes shared exhaustion from personal-key provider quota. - Updated Hairstyle AI Studio to current compatible dependencies with a clean audit, routed optional recommendations to `gemini-3.5-flash-lite`, retained `gemini-3.1-flash-lite-image` for image-capable generation, and replaced diff --git a/LEARNINGS.md b/LEARNINGS.md index 7f08bfc..7ed9b45 100644 --- a/LEARNINGS.md +++ b/LEARNINGS.md @@ -2,6 +2,56 @@ This log captures durable lessons discovered while building and maintaining the portfolio and demo lab, keeping the root instructions lean. +## 2026-07-27 - Per-user free tiers still need a global spend ceiling + +Context: IP allowances limited ordinary use but an IP-rotating caller could +still spend the hosted provider key, and imported Maps proxy limits were sized +for load testing rather than a public portfolio. +Learning: Separate per-user fairness, process-wide daily spend ceilings, and +provider-side quotas. Pin exact upstream routes and methods; same-origin checks +alone are not caller authentication. +Evidence: Hairstyle now has per-IP and global hosted-generation caps; Real +World Reasoning has separate request classes, daily Maps/Gemini ceilings, +bounded limiter memory, exact route/method allowlists, and focused regressions. +Use next time: Define the maximum affordable provider usage before exposing a +public proxy, then make cloud quotas the hard backstop for restart and botnet +scenarios. + +## 2026-07-27 - A private-to-open-source migration needs explicit release provenance + +Context: Real World Reasoning Agent was still private when its owner requested +that Fieldwork become the first-party open-source home. The public-import +command correctly refused to accept a false public-source confirmation. +Learning: Explicit owner authorization can release a reviewed snapshot without +changing the predecessor repository's visibility, but the boundary must remain +auditable: pin the exact commit, exclude Git history, environment and deployment +files, scan for credentials, record the source visibility and release authority, +and make the new repository the canonical public link. +Evidence: `demos/real-world-reasoning-agent/PROVENANCE.md` records private source +commit `68e8c34547066a984ccb97f5b587caeb97561ec1`; the pre-import scan found no +credentials; and the public tree excludes history, environment files, and +private deployment configuration. +Use next time: Never pass `--confirm-source-public` for a private source. Require +explicit release authority, a clean snapshot scan, and provenance that explains +the exception before public integration. + +## 2026-07-27 - Hosted allowance and personal-key abuse limits are different controls + +Context: Hairstyle AI Studio put every image request behind one five-per-hour +IP limiter. Entering a personal Gemini key therefore did not help: the gateway +rejected the request before it inspected which credential would pay for it. +Learning: Select and validate the credential before applying spend controls. +Hosted calls consume the shared daily allowance; valid personal-key calls +bypass that spend cap but retain a separate, generous site-abuse limiter. +Malformed personal keys fail closed instead of silently falling back. Provider +quota errors need a different code from local allowance exhaustion so the UI +can offer the right recovery. +Evidence: Gateway regressions cover five successful hosted image generations, +UTC reset metadata, failed-request refunds, personal-key bypass, malformed-key +denial, non-generating key validation, and distinct upstream quota responses. +Use next time: Model shared budget, provider quota, and gateway abuse as +separate policies, then test routing order across every credential source. + ## 2026-07-27 - Buffer staging inputs need an explicit merge trigger Context: The existing Buffer workflow could stage social copy only when a new diff --git a/README.md b/README.md index 94505db..a0b633b 100644 --- a/README.md +++ b/README.md @@ -36,7 +36,7 @@ The narrative is intentionally grounded: solution architecture, developer experi * **[Air Quality Map](demos/aqi-map/README.md)**: inspect live Air Quality API heatmap tiles and point conditions on a 2D Google map. * **[Isochrones](demos/isochrones/README.md)**: analyze delivery, commute, and response reachability with live-regenerating isochrone bands. * **Infographic Agent**: an external portable-skill experiment linked from Ryan’s Lab. -* **Atlas, Real World Reasoning Agent**: an external map-agent experiment linked from Ryan’s Lab. +* **Atlas, Real World Reasoning Agent**: a first-party open-source map agent hosted with Fieldwork. ## Local development diff --git a/apps.json b/apps.json index e162f43..d4da3da 100644 --- a/apps.json +++ b/apps.json @@ -103,7 +103,7 @@ "providers": [ "gemini" ], - "source_url": "https://github.com/ryanbaumann/hairstyle-ai-studio", + "source_url": "https://github.com/ryanbaumann/fieldwork/tree/main/demos/hairstyle-ai-studio", "source_ref": "9ea2c0f31e5e1d252220ede6731b655bf2fb8fba", "tags": [ "gemini-api", @@ -116,20 +116,26 @@ "name": "real-world-reasoning-agent", "title": "Atlas - Real World Reasoning Agent", "description": "An interactive map agent that handles real-world geo reasoning problems using Maps APIs.", - "path": "https://atlas-demo-561209038703.us-central1.run.app/", + "path": "/real-world-reasoning-agent/", + "dev_build_dir": "demos/real-world-reasoning-agent/dist", "source": { - "type": "external" + "type": "workspace", + "package": "demos/real-world-reasoning-agent", + "output": "dist" }, "api": { - "type": "none" + "type": "gateway", + "prefix": "/api/real-world-reasoning-agent/" }, + "csp": "maps", "tags": [ "gemini-api", "agents", "google-maps-platform" ], - "preview": "/previews/real-world-reasoning-agent.webp", - "hideOnHome": true + "source_url": "https://github.com/ryanbaumann/fieldwork/tree/main/demos/real-world-reasoning-agent", + "source_ref": "68e8c34547066a984ccb97f5b587caeb97561ec1", + "preview": "/previews/real-world-reasoning-agent.webp" }, { "name": "aqi-map", diff --git a/demos/hairstyle-ai-studio/.agents/skills/hairstyle-ai-studio/SKILL.md b/demos/hairstyle-ai-studio/.agents/skills/hairstyle-ai-studio/SKILL.md index 203c3d2..ae2405d 100644 --- a/demos/hairstyle-ai-studio/.agents/skills/hairstyle-ai-studio/SKILL.md +++ b/demos/hairstyle-ai-studio/.agents/skills/hairstyle-ai-studio/SKILL.md @@ -21,11 +21,17 @@ Use this skill when changing this repository's Gemini model integration, UX flow ## Secret checklist - Never put Gemini keys in `VITE_*` variables, source, local storage, IndexedDB, logs, analytics, URLs, or committed environment files. -- Visitors bring their own key at runtime. Keep it only in React memory and - pass it transiently in `X-Gemini-API-Key` to the same-origin - `/api/hairstyle-ai-studio/*` gateway routes. -- The gateway forwards the caller's key only to Gemini. It must not store, - return, log, or replace it with a shared deployed key. +- Use the server-side `GEMINI_API_KEY` for the shared allowance of five + successful image generations per client IP per UTC day. Do not charge the + allowance for recommendation analysis or failed generation requests. +- Let visitors override the shared key at runtime. Validate a personal key + through the non-generating proxy endpoint before activation, keep it only in + React memory, and pass it transiently in `X-Gemini-API-Key`. +- A valid personal key bypasses the shared spend cap but not the separate + gateway abuse limiter. A supplied malformed or rejected key must fail closed; + never silently replace it with the shared deployed key. +- The gateway forwards the selected key only to Gemini. It must not store, + return, log, or send it to analytics. - Update the app README, privacy copy, gateway tests, and rate-limit tests when environment or request behavior changes. diff --git a/demos/hairstyle-ai-studio/AGENTS.md b/demos/hairstyle-ai-studio/AGENTS.md index e1f734d..42d02bc 100644 --- a/demos/hairstyle-ai-studio/AGENTS.md +++ b/demos/hairstyle-ai-studio/AGENTS.md @@ -23,8 +23,10 @@ Model IDs are server-owned in the repository root's `gateway/lib/hairstyleAi.js` ## Secret handling - Never add `VITE_GEMINI_API_KEY`; Vite would publish it in the browser bundle. -- Users provide their own key at runtime. Keep it only in React memory and pass it transiently in `X-Gemini-API-Key` to the same-origin `/api/hairstyle-ai-studio/*` gateway routes. -- The gateway must never log, store, return, or send the key to analytics. +- The server-side `GEMINI_API_KEY` owns the five-successful-image-generations-per-IP daily allowance. Recommendation analysis does not consume it. +- A user may override the shared key at runtime. Validate the personal key through the non-generating proxy endpoint before activation, keep it only in React memory, and pass it transiently in `X-Gemini-API-Key`. +- A valid personal key bypasses the shared daily spend cap but remains subject to the gateway's separate abuse limiter. Never silently fall back to the shared key when a supplied personal key is malformed or rejected. +- The gateway must never log, store, return, or send either key to analytics. - Never commit real API keys or screenshots containing keys. ## UX rules diff --git a/demos/hairstyle-ai-studio/README.md b/demos/hairstyle-ai-studio/README.md index 6775fe7..6d10098 100644 --- a/demos/hairstyle-ai-studio/README.md +++ b/demos/hairstyle-ai-studio/README.md @@ -11,8 +11,10 @@ commit `9ea2c0f31e5e1d252220ede6731b655bf2fb8fba`. - The React, Vite, Tailwind, local-history, result, refinement, and salon-brief flows remain app-local. - The standalone Express server and Docker image were removed. Fieldwork's zero-dependency gateway owns `/api/hairstyle-ai-studio/*`. -- Visitors bring their own Gemini API key. The browser keeps it only in React memory and sends it in a transient same-origin request header. The gateway does not store, log, return, or analyze it. -- The gateway validates image data, caps request size and prompt length, requires same-origin browser mutations, and rate-limits text and image requests by client IP. +- The shared server key provides five successful image generations per client IP per UTC day. Recommendation analysis does not consume that allowance. +- Visitors can connect a personal Gemini API key after the shared allowance is exhausted or at any time. The proxy validates it without generating content; the browser keeps it only in React memory and sends it in a transient same-origin request header. +- Personal-key calls bypass the shared spend cap but retain a generous per-IP abuse guard. The gateway never stores, logs, returns, or analyzes a visitor key. +- The gateway validates image data, caps request size and prompt length, requires same-origin browser mutations, and separates shared allowance, visitor-key quota, and site-abuse errors. - Google Analytics uses Fieldwork's privacy-limited shared loader. Events contain only enumerated funnel states, never keys, photos, prompts, style URLs, filenames, IDs, or raw errors. ## Models @@ -53,7 +55,7 @@ Gateway behavior and rate limits are tested from `gateway/test/hairstyleAi.test. ## Privacy -Photos are sent to Google Gemini only after the visitor explicitly requests a recommendation, generation, or refinement. Fieldwork does not store those photos on the server. Generated history is stored in the browser's IndexedDB and can be deleted from the app. +Photos are sent to Google Gemini only after the visitor explicitly requests a recommendation, generation, or refinement. Fieldwork does not store those photos on the server. The shared allowance keeps an in-memory daily generation count keyed by client IP; a service restart can reset it early. Generated history is stored in the browser's IndexedDB and can be deleted from the app. ## License diff --git a/demos/hairstyle-ai-studio/app/App.tsx b/demos/hairstyle-ai-studio/app/App.tsx index 3c0ce12..72f52f3 100644 --- a/demos/hairstyle-ai-studio/app/App.tsx +++ b/demos/hairstyle-ai-studio/app/App.tsx @@ -1,12 +1,13 @@ -import React, { useEffect, useRef, useState } from 'react'; +import React, { useEffect, useRef } from 'react'; import { ThemeToggle } from './components/ThemeToggle'; import { StepUpload } from './components/StepUpload'; import { StepStyle } from './components/StepStyle'; import { StepResult } from './components/StepResult'; import { SalonBriefModal } from './components/SalonBriefModal'; import { LoadingView } from './components/LoadingView'; +import { GeminiKeyDialog } from './components/GeminiKeyDialog'; import { useAppFlow } from './hooks/useAppFlow'; -import { Sparkles, Scissors, ShieldCheck, ChevronRight, KeyRound, Code2, ArrowUpRight } from 'lucide-react'; +import { Scissors, ShieldCheck, ChevronRight, KeyRound, Code2, ArrowUpRight } from 'lucide-react'; const FIELDWORK_URL = 'https://ryanbaumann.dev/'; const SOURCE_URL = 'https://github.com/ryanbaumann/fieldwork/tree/main/demos/hairstyle-ai-studio'; @@ -18,7 +19,11 @@ export const App = () => { setState, apiKey, hasKey, + freeTier, + isKeyDialogRequested, + setIsKeyDialogRequested, handleSelectKey, + forgetApiKey, updateImages, clearImage, handleGenerate, @@ -30,8 +35,6 @@ export const App = () => { navigateTo, refinementPrompt } = useAppFlow(scrollContainerRef); - const [keyInput, setKeyInput] = useState(''); - const [keyError, setKeyError] = useState(''); useEffect(() => { const savedTheme = localStorage.getItem('theme'); @@ -45,74 +48,6 @@ export const App = () => { const canGoToStyle = !!state.images.front || state.history.length > 0; const canGoToResult = !!state.generatedResult; - if (!hasKey) { - return ( -
-
{ - event.preventDefault(); - if (!handleSelectKey(keyInput)) { - setKeyError('Enter a valid Gemini API key.'); - return; - } - setKeyInput(''); - setKeyError(''); - }} - > -
- -
-
-

Bring your own Gemini key

-

Try a new look privately

-

- Your key stays in this tab's memory, passes transiently through Fieldwork's rate-limited proxy, and is never stored or sent to analytics. -

-
- - { - setKeyInput(event.target.value); - setKeyError(''); - }} - autoComplete="off" - spellCheck={false} - aria-describedby="key-help key-error" - className="w-full min-h-12 rounded-xl border border-slate-300 dark:border-slate-700 bg-white dark:bg-slate-950 px-4 font-mono text-sm text-slate-900 dark:text-white outline-none focus-visible:ring-2 focus-visible:ring-primary-500" - placeholder="Paste your key" - /> -

- Need one? Create a key in Google AI Studio. -

- - -

- Photos are sent to Google Gemini only when you request analysis or generation. See Fieldwork privacy. -

- -
-
- ); - } - return (
@@ -157,14 +92,12 @@ export const App = () => {
@@ -181,6 +114,27 @@ export const App = () => {
+ {!hasKey && freeTier && ( +
+

+ {freeTier.enabled ? ( + <> + {freeTier.remaining} of {freeTier.limit} free generations left today.{' '} + Resets at midnight UTC. + + ) : ( + The shared tier is unavailable in this environment. + )} +

+ +
+ )} {state.errorMessage && (
@@ -273,6 +227,13 @@ export const App = () => { referenceImage={state.styleReferenceImage} referenceUrl={state.styleReferenceUrl} /> + setIsKeyDialogRequested(false)} + onConnect={handleSelectKey} + onDisconnect={forgetApiKey} + />
); }; diff --git a/demos/hairstyle-ai-studio/app/components/GeminiKeyDialog.tsx b/demos/hairstyle-ai-studio/app/components/GeminiKeyDialog.tsx new file mode 100644 index 0000000..dd1cffc --- /dev/null +++ b/demos/hairstyle-ai-studio/app/components/GeminiKeyDialog.tsx @@ -0,0 +1,157 @@ +import React, { useEffect, useRef, useState } from 'react'; +import { CheckCircle2, Eye, EyeOff, KeyRound, X } from 'lucide-react'; + +interface GeminiKeyDialogProps { + isOpen: boolean; + hasPersonalKey: boolean; + onClose: () => void; + onConnect: (apiKey: string) => Promise; + onDisconnect: () => void; +} + +export const GeminiKeyDialog = ({ + isOpen, + hasPersonalKey, + onClose, + onConnect, + onDisconnect, +}: GeminiKeyDialogProps) => { + const inputRef = useRef(null); + const [keyInput, setKeyInput] = useState(''); + const [isVisible, setIsVisible] = useState(false); + const [isValidating, setIsValidating] = useState(false); + const [error, setError] = useState(''); + + useEffect(() => { + if (!isOpen) return; + setError(''); + window.requestAnimationFrame(() => inputRef.current?.focus()); + const onKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Escape') onClose(); + }; + window.addEventListener('keydown', onKeyDown); + return () => window.removeEventListener('keydown', onKeyDown); + }, [isOpen, onClose]); + + if (!isOpen) return null; + + return ( +
+
+
+
+ + +
+

+ Keep creating +

+

+ {hasPersonalKey ? 'Personal Gemini key connected' : 'Use your Gemini API key'} +

+
+
+ +
+ + {hasPersonalKey ? ( +
+

+

+ +
+ ) : ( +
{ + event.preventDefault(); + setError(''); + setIsValidating(true); + try { + const connected = await onConnect(keyInput); + if (!connected) { + setError('That key format is not valid.'); + return; + } + setKeyInput(''); + } catch (validationError) { + setError(validationError instanceof Error ? validationError.message : 'Gemini could not validate that key.'); + } finally { + setIsValidating(false); + } + }} + > + +
+ { + setKeyInput(event.target.value); + setError(''); + }} + autoComplete="off" + spellCheck={false} + aria-describedby="gemini-key-help gemini-key-error" + className="min-h-12 w-full rounded-xl border border-slate-300 bg-white px-4 pr-12 font-mono text-sm text-slate-900 outline-none focus-visible:ring-2 focus-visible:ring-primary-500 dark:border-slate-700 dark:bg-slate-950 dark:text-white" + placeholder="Paste your key" + /> + +
+

+ The proxy validates your key without generating content. It stays in this tab’s memory and is never stored or sent to analytics.{' '} + + Create a key + +

+ + +
+ )} +
+
+ ); +}; diff --git a/demos/hairstyle-ai-studio/app/hooks/useAppFlow.ts b/demos/hairstyle-ai-studio/app/hooks/useAppFlow.ts index 2dbf6e3..13e3988 100644 --- a/demos/hairstyle-ai-studio/app/hooks/useAppFlow.ts +++ b/demos/hairstyle-ai-studio/app/hooks/useAppFlow.ts @@ -1,15 +1,36 @@ import React, { useState, useEffect, useCallback, useRef } from 'react'; import { AppState, GeneratedImage, ViewType } from '../types'; -import { generateHairstyleImage, refineHairstyleImage, RateLimitError } from '../services/geminiService'; +import { + generateHairstyleImage, + refineHairstyleImage, + getFreeTierStatus, + validateGeminiKey, + FreeTierStatus, + GeminiApiError, + RateLimitError, +} from '../services/geminiService'; import { saveImage, getImage, clearAllImages, deleteImage } from '../services/imageStorage'; import { trackEvent } from '../services/analytics'; -const RATE_LIMIT_MESSAGE = "You've reached the current generation limit. Check your Gemini quota or try again later."; const titleFromPrompt = (prompt: string) => prompt.split(/\s+/).filter(Boolean).slice(0, 4).join(' ').replace(/[.,;:!?-]+$/, '') || 'New Hairstyle'; const describeError = (error: unknown, fallback: string): string => { - if (error instanceof RateLimitError) return RATE_LIMIT_MESSAGE; + if (error instanceof GeminiApiError) { + if (error.code === 'FREE_TIER_EXHAUSTED') { + return "You've used today's 5 free generations. Add your Gemini API key to keep creating."; + } + if (error.code === 'FREE_TIER_UNAVAILABLE') { + return 'The shared allowance is temporarily unavailable. Add your Gemini API key to continue.'; + } + if (error.code === 'GEMINI_QUOTA_EXHAUSTED') { + return 'Your Gemini API key has reached its provider quota. Check its quota or connect another key.'; + } + if (error.code === 'SITE_ABUSE_LIMIT') { + return 'Too many requests in a short period. Wait a moment, then try again.'; + } + return error.message; + } if (error instanceof DOMException && error.name === 'AbortError') { return 'Generation cancelled or timed out. You can try again when ready.'; } @@ -18,6 +39,8 @@ const describeError = (error: unknown, fallback: string): string => { export const useAppFlow = (scrollContainerRef?: React.RefObject) => { const [apiKey, setApiKey] = useState(''); + const [freeTier, setFreeTier] = useState(null); + const [isKeyDialogRequested, setIsKeyDialogRequested] = useState(false); const [state, setState] = useState({ step: 'upload', images: { front: null, side: null, back: null }, @@ -40,14 +63,36 @@ export const useAppFlow = (scrollContainerRef?: React.RefObject(null); - const handleSelectKey = (value: string) => { + const handleSelectKey = async (value: string) => { const nextKey = value.trim(); - if (!/^[A-Za-z0-9_-]{20,200}$/.test(nextKey)) return false; + if (!/^[\x21-\x7E]{20,200}$/.test(nextKey)) return false; + const valid = await validateGeminiKey(nextKey); + if (!valid) return false; setApiKey(nextKey); - trackEvent('key_setup', { result: 'accepted' }); + setIsKeyDialogRequested(false); + setState(prev => ({ ...prev, errorMessage: null })); + trackEvent('key_setup', { result: 'validated' }); return true; }; + const forgetApiKey = useCallback(() => { + setApiKey(''); + setIsKeyDialogRequested(false); + trackEvent('key_setup', { result: 'disconnected' }); + }, []); + + const refreshFreeTier = useCallback(async () => { + try { + setFreeTier(await getFreeTierStatus()); + } catch { + setFreeTier(null); + } + }, []); + + useEffect(() => { + void refreshFreeTier(); + }, [refreshFreeTier]); + // Persistence: Load History useEffect(() => { const loadHistory = async () => { @@ -147,6 +192,7 @@ export const useAppFlow = (scrollContainerRef?: React.RefObject ({ @@ -163,6 +209,13 @@ export const useAppFlow = (scrollContainerRef?: React.RefObject ({ @@ -224,6 +278,13 @@ export const useAppFlow = (scrollContainerRef?: React.RefObject { expect(init.headers['X-Gemini-API-Key']).toBe(API_KEY); expect(JSON.parse(init.body).availableStyles).toEqual(styles); }); + + it('omits the personal-key header while using the hosted free tier', async () => { + mockFetch.mockResolvedValue(jsonResponse({ recommendedStyleId: null })); + await analyzeUserImage('', 'data:image/png;base64,abc', []); + + expect(mockFetch.mock.calls[0][1].headers['X-Gemini-API-Key']).toBeUndefined(); + }); +}); + +describe('credential and quota flow', () => { + it('validates a personal key through the proxy before activation', async () => { + mockFetch.mockResolvedValue(jsonResponse({ valid: true })); + await expect(validateGeminiKey(API_KEY)).resolves.toBe(true); + expect(mockFetch.mock.calls[0][0]).toBe('/api/hairstyle-ai-studio/validate-key'); + expect(mockFetch.mock.calls[0][1].headers['X-Gemini-API-Key']).toBe(API_KEY); + }); + + it('reads the shared allowance without consuming it', async () => { + const quota = { enabled: true, limit: 5, remaining: 4, resetAt: '2026-07-28T00:00:00.000Z' }; + mockFetch.mockResolvedValue(jsonResponse(quota)); + await expect(getFreeTierStatus()).resolves.toEqual(quota); + expect(mockFetch.mock.calls[0][1].method).toBe('GET'); + }); }); describe('generateHairstyleImage', () => { @@ -53,10 +79,23 @@ describe('generateHairstyleImage', () => { }); it('raises RateLimitError on HTTP 429', async () => { - mockFetch.mockResolvedValue(jsonResponse({ error: 'Slow down' }, false, 429)); + mockFetch.mockResolvedValue(jsonResponse({ error: 'Use your key', code: 'FREE_TIER_EXHAUSTED' }, false, 429)); + const promise = generateHairstyleImage( + '', { front: 'x', side: null, back: null }, 'bob' + ); await expect( - generateHairstyleImage(API_KEY, { front: 'x', side: null, back: null }, 'bob') + promise ).rejects.toBeInstanceOf(RateLimitError); + await expect(promise).rejects.toMatchObject({ code: 'FREE_TIER_EXHAUSTED' }); + }); + + it('preserves provider quota error codes for a personal key', async () => { + mockFetch.mockResolvedValue(jsonResponse({ error: 'Key quota used', code: 'GEMINI_QUOTA_EXHAUSTED' }, false, 429)); + await expect( + generateHairstyleImage(API_KEY, { front: 'x', side: null, back: null }, 'bob') + ).rejects.toEqual(expect.objectContaining>({ + code: 'GEMINI_QUOTA_EXHAUSTED', + })); }); it('rejects a successful response without an image', async () => { diff --git a/demos/hairstyle-ai-studio/app/services/geminiService.ts b/demos/hairstyle-ai-studio/app/services/geminiService.ts index 03fc240..58a6a95 100644 --- a/demos/hairstyle-ai-studio/app/services/geminiService.ts +++ b/demos/hairstyle-ai-studio/app/services/geminiService.ts @@ -3,17 +3,25 @@ import { OutputLayout, HairstyleOption, GenerationMode } from '../types'; const API_BASE = '/api/hairstyle-ai-studio'; const GENERATION_TIMEOUT_MS = 120_000; +export interface FreeTierStatus { + enabled: boolean; + limit: number; + remaining: number; + resetAt: string; +} + const headersFor = (apiKey: string) => ({ 'Content-Type': 'application/json', - 'X-Gemini-API-Key': apiKey, + ...(apiKey ? { 'X-Gemini-API-Key': apiKey } : {}), }); async function readJson(response: Response) { const data = await response.json().catch(() => ({})); if (!response.ok) { const message = typeof data?.error === 'string' ? data.error : 'Gemini request failed.'; - if (response.status === 429) throw new RateLimitError(message); - throw new Error(message); + const code = typeof data?.code === 'string' ? data.code : 'GEMINI_REQUEST_FAILED'; + if (response.status === 429) throw new RateLimitError(message, code); + throw new GeminiApiError(message, code); } return data; } @@ -24,13 +32,35 @@ const withTimeout = (signal?: AbortSignal): AbortSignal => { return typeof AbortSignal.any === 'function' ? AbortSignal.any([signal, timeout]) : signal; }; -export class RateLimitError extends Error { - constructor(message: string) { +export class GeminiApiError extends Error { + constructor(message: string, public readonly code: string) { super(message); + this.name = 'GeminiApiError'; + } +} + +export class RateLimitError extends GeminiApiError { + constructor(message: string, code = 'RATE_LIMITED') { + super(message, code); this.name = 'RateLimitError'; } } +export const getFreeTierStatus = async (): Promise => { + const response = await fetch(`${API_BASE}/quota`, { method: 'GET' }); + return readJson(response); +}; + +export const validateGeminiKey = async (apiKey: string): Promise => { + const response = await fetch(`${API_BASE}/validate-key`, { + method: 'POST', + headers: headersFor(apiKey), + body: '{}', + }); + const data = await readJson(response); + return data.valid === true; +}; + export const analyzeUserImage = async ( apiKey: string, base64Image: string, diff --git a/demos/real-world-reasoning-agent/.gitignore b/demos/real-world-reasoning-agent/.gitignore new file mode 100644 index 0000000..0800891 --- /dev/null +++ b/demos/real-world-reasoning-agent/.gitignore @@ -0,0 +1,42 @@ +node_modules/ +dist/ +*.local +.DS_Store + +# Agent worktrees / local harness state +.claude/ + +# TS build info +*.tsbuildinfo + +# Journey screenshots need the live basemap; only the landing hero is committed. +docs/screens/concierge.png +docs/screens/insight.png +docs/screens/fleet.png +docs/screens/cinema.png +docs/screens/05-adstudio.png +docs/screens/06-scout.png + +# Playwright MCP / browser-driver scratch output — never commit local run logs. +.playwright-mcp/ +# Mission smoke writes here; keep local-only. +/tmp/atlas-mission-smoke/ + +# Local credentials +.env +.env.* +!.env.example + +# Verification artifacts (compact PASS/FAIL JSON) +.verify/ + +# Eval artifacts (trace-eval baseline + last run; local only) +.eval/ + +# Telemetry report artifacts (sanitized report + replay fixtures; local only) +.telemetry/ + +# Triage artifacts (clustered report + issue proposals; local/CI only) +.triage/ +# Cloud Run log export the triage job reads (never commit raw logs) +logs.json diff --git a/demos/real-world-reasoning-agent/ATLAS_2_TODO.md b/demos/real-world-reasoning-agent/ATLAS_2_TODO.md new file mode 100644 index 0000000..92c2859 --- /dev/null +++ b/demos/real-world-reasoning-agent/ATLAS_2_TODO.md @@ -0,0 +1,137 @@ +# Atlas 2.0 completion plan + +Imported planning note from the source snapshot. Current development and review +now happen in the Fieldwork repository. + +Target: `main` via `fix/a2ui-adcreative-image-reactivity` + +Baseline SHA: `4d8159f7e62e99765736ee67954225748e9bc501` + +## Outcome and scope + +Land one truthful flagship mission: + +**Brief → grounded Scout → evidence-backed comparison → approval → real Ad +Studio campaign → shareable result → mission-specific 3D/audio payoff** + +Existing Flash reasoning, tap-to-talk, TTS, image generation, and optional Omni +are composed. A new Live/WebSocket architecture, generic Places UI replacement, +Fleet/Insight refactors, and production deployment are out of scope. + +## Invariants + +- Copilot tools execute sequentially in emission order. +- Every A2UI text prop resolves bindings through `resolveDisplayText` (or the + equivalent interpolate/drop pair). +- Live place facts and geometry come from current Google Maps Platform calls. + Demo fixtures are deterministic and visibly labeled; modeled reach is never + called an isochrone or real route. +- Share payloads contain only application-owned inputs and decisions, never + cached Google Maps content. +- Reveal is unavailable until a successful campaign artifact exists. +- Pinned guidance in `.agents/skill-sources.json` is applied to Maps, Gemini, + eval, and A2UI changes. + +## Execution gates + +### Wave 0 — source of truth + +- [x] Audit local branch and PR metadata (clean/mergeable/CI green at baseline). +- [x] Replace the stale slice handoff with this decision-complete plan. +- [x] Pin Agentic UI Toolkit, Gemini skills, Google eval skill, and A2UI sources. +- [ ] Commit/push plan and confirm remote SHA. +- [ ] Update PR body with exact evidence; distinguish Demo, credentialed Live, + reranker consistency, agent eval, and GPU/manual verification. + +### Wave 1 — truthful, state-safe behavior + +- [x] Event-driven lifecycle covers launch/observe/compare/approve/invalidate/ + create/complete/undo/partial/resume/failure. +- [x] Scout candidates/evidence merge by ID without losing geometry/selection. +- [x] Area/priority changes invalidate approval and downstream artifacts. +- [ ] Spatial constraints have center + normalized outer/exclusion rings, edit + rehydration, prompt serialization, and candidate filtering. +- [x] Live approval uses Routes API geometry; Demo route/reach stays labeled. +- [ ] Current evidence and What-if are separated; missing hours/weather/traffic + disable controls; `openOnly` requires valid hours; scoring uses mission weights. +- [x] ChoicePicker binding regression is fixed and tested. +- [x] Required usage attribution is present on supported 2D/3D surfaces. + +### Wave 2 — flagship workspace + +- [ ] Active missions suppress generic scenario intro drawers. +- [ ] Mobile uses coordinated safe-area sheets, 44px targets, visible focus, and + no ribbon/editor/lens/panel overlap. +- [x] Preference Passport is behind “Tune my passport”; launch remains near the + initial mobile viewport. +- [x] Claim Lens co-locates evidence, confidence, limitations, and + Places UI Kit detail with loading/unavailable/retry states. +- [ ] Dark/light and reduced-motion modes are truthful and accessible. + +### Wave 3 — action, payoff, sharing, developer story + +- [x] Demo populates real Scout and Ad Studio stores with labeled fixtures. +- [x] Live typed handoff creates campaign readiness only after a visible creative. +- [ ] Mission finale shows winner/evidence/route/creative/3D orbit, a 2D/static + fallback, and Back to decision state restoration. +- [ ] Final narration is opt-in; image generation is visible; Omni is optional + unless configured/authorized, with honest unavailable/fallback state. +- [ ] Versioned allowlisted mission share is encoded without Maps content; + exact-once replay restoration remains open. +- [ ] Stage-aware developer X-ray lists real tools/products/models/policies, + provenance, source entrypoints, and share/fork CTAs. + +## Verification gates + +Run on Node 22+: + +```bash +git diff --check +npm test +npm run build +npm run eval +npm run mission-smoke +``` + +Browser gates must auto-detect installed Chromium and split deterministic Demo, +credentialed Live, injected failure/fallback, all-recipes, and GPU/manual runs. +Demo coverage targets 320×568, 390×844, 768×1024, 1440×900, and 1920×1080 in +light/dark and reduced-motion. Unexpected console/page/network errors fail. + +Credentialed Live, paid agent evaluation, Omni, and GPU runs require explicit +cost/environment authority. Never print `.env` values. The reranker fixture is a +consistency contract, not agent-quality evidence. Agent-quality merge thresholds: +zero critical grounding/attribution/privacy/unsupported-claim failures, ≥90% +mission success, ≥95% tool-order/handoff integrity, and ≤5-point broad-quality +regression, with at most two repair cycles and an evaluator distinct from the +optimizer. + +## Landing gate + +Resolve every P0/P1 and explicitly defer accepted P2s. Re-run GMP compliance, +secret scan, full tests/build, latest-SHA CI, independent review, and GPU/manual +showcase where authorized. Document 3D/Omni preview status, billing/key +restrictions, EEA/prohibited-territory review, and no Maps-content persistence or +training. Squash-merge PR #36 only when these gates are evidenced; no deployment +is implied. + +## Current verification log + +- 2026-07-11 baseline `npm run build`: PASS (Vite build, 3,238 modules). +- 2026-07-11 baseline `npm test`: interrupted by the in-progress Wave 1 writer; + 200 tests passed and `src/ai/engine.test.ts` exposed the expected caller update + from `setStatus` to the new transition API. Re-run after integration. +- 2026-07-11 integrated `npm test`: PASS, 35 files / 215 tests. +- 2026-07-11 integrated `npm run build`: PASS, 3,238 modules. +- 2026-07-11 `npm run eval`: PASS, 3 reranker consistency tests (not agent quality). +- 2026-07-11 deterministic `npm run mission-smoke`: PASS on Chrome at 1440×900 + and 390×844, including real Demo creative and headless raster fallback. +- 2026-07-11 credentialed Live, paid agent eval, axe/theme/viewport matrix, and + real-GPU/Omni showcase: NOT RUN; approval/environment gates remain. + +## Rollback and stop rules + +Keep each wave as a focused commit. Do not merge partial lifecycle contracts, +fabricated Live route/condition claims, decorative campaign artifacts, or share +payloads containing Maps content. Stop as `PARTIAL_BLOCKED` when a paid/credentialed +or legal gate lacks approval; do not relabel it as passed. diff --git a/demos/real-world-reasoning-agent/CHANGELOG.md b/demos/real-world-reasoning-agent/CHANGELOG.md new file mode 100644 index 0000000..c92cca7 --- /dev/null +++ b/demos/real-world-reasoning-agent/CHANGELOG.md @@ -0,0 +1,173 @@ +# Changelog + +All notable changes to the **Atlas — AI-Native Map** demo project. + +## [Unreleased] + +- **Model upgrade to 3.6 series**: Upgraded default model routing to `gemini-3.5-flash-lite` for Flash Lite tasks and added `gemini-3.6-flash` for primary Flash tasks across default routing, server proxy allowlists, admin tuner options, and documentation. + +- **Explorer flow & UX improvements**: + - Expanded the sidebar drawer by default for the explorer scenario (`state.setDrawer(true)`) instead of collapsing it, ensuring a smoother transition from the landing page. + - Wired up dynamic followup suggestions (`suggestFollowups` utility) to run at the end of explorer journeys, providing contextual next-step chips to the user. + - Replaced simple text responses with native Maps UI `PlaceCard` components (utilizing `gmp-place-details-compact` for enriched details including photos and ratings). +- **Smart scrolling in Copilot Dock**: Implemented user-interaction-aware scroll behavior. Auto-scrolling to the bottom of the transcript is suppressed if the user has manually scrolled up to read history, while auto-scrolling is preserved for streaming tokens and new user messages. +- **Model routing default changes**: Switched default model to `gemini-3.1-flash-lite` on `MEDIUM` thinking by default for standard orchestration/chat tasks, and `MINIMAL` thinking for latency-sensitive tasks like followups and speech-to-text. +- **Bug Fixes**: + - Fixed a `SecurityError` with history `replaceState` by collapsing multiple leading slashes in `window.location.pathname` (e.g. `//`) to prevent protocol-relative URL interpretation. +- Replaced the first-run mission kickoff with a Live-only, one-prompt Places + Routes explorer that adds Weather only when requested, presents one compact evidence surface, labels jacket advice as inference, and supports a typed travel-mode counterfactual. Launch now fails closed when required Live services are unavailable. + +### Added +- **Telemetry-triage CI/CD loop (production → GitHub issues)**: closes the Agent Quality Flywheel's *Ship & Monitor → Learn & Refine* arc. The deployed Cloud Run service now emits privacy-safe **structural** failure signal from two sources — server-side `/ai`+`/gmp` proxy failures (`evt:proxy`, endpoint label + failure class only) and an **opt-in** client send of sanitized diagnostics (`evt:diag_batch`, via a consent-gated "Send diagnostics" action in the admin panel POSTing to the existing `/metadata` sink). A scheduled GitHub Actions job (`.github/workflows/telemetry-triage.yml`) reads those logs with `gcloud logging read`, clusters/classifies them via the shared `buildTelemetryReport` (new `src/diagnostics/triage.ts` reducer, `npm run triage:report`), and — **only on a manual `workflow_dispatch` approval** (mirrors the `EVAL_REMOTE_APPROVED` gate) — opens/updates fingerprint-deduped issues (`scripts/triage-open-issues.mjs`). Scheduled runs are always a dry run. Nothing content-bearing (prompts, URLs, coords, ids) ever enters a log or issue; the reducer and opener both fail loudly on any forbidden content. See [docs/TELEMETRY_TRIAGE.md](docs/TELEMETRY_TRIAGE.md). + - **Telemetry observability — heartbeat + empty-window guard**: the proxy previously logged only failures, so a healthy-quiet service and a dead/undeployed one were indistinguishable (both emit zero lines). The `/ai`+`/gmp` proxy now emits a rate-limited, content-free **`ok` heartbeat** — at most one per endpoint per `PROXY_HEARTBEAT_MS` (default 10m, `0` disables) — so the triage window carries a positive "traffic flowed and telemetry is wired" signal. Heartbeats are `status:ok` and never become issues (`buildTriage` proposes only error clusters). The triage CLI now prints a loud **0-records warning** (a stale deploy or unreachable service no longer reads as calm), the triage workflow reads logs with an explicit `--limit` (gcloud defaults to 1000 and truncates silently), and a **coverage step** posts entries-fetched + deployed image + truncation/empty warnings to the job summary. See `shouldHeartbeat` in `server/lib.mjs`. +- **Atlas 2.0 flagship mission — slice 2 (map-native interactions, evidence, eval)**: builds on the flagship cold-open with the interactions the mission always implied. + - **Claim Lens**: click a candidate on the map to focus it and open a detail lens (score, confidence, factor bars, Places UI Kit place details for live candidates, one-click approve). See `src/mission/ClaimLens.tsx`. + - **Area / exclusion editor** via **Terra Draw** (+ Google Maps adapter) — the maintained successor to the deprecated Drawing Library. Draw a search polygon; holes/extra polygons become exclusions → `mission.area`, rendered on the map. See `src/mission/AreaEditor.tsx`. + - **Condition Preview scrubber**: preview how time / weather / open-hours / traffic / sun would reweight the comparison, with a deterministic before/after disruption readout and ranking-flip detection — no model call. Pure logic in `src/mission/scrub.ts`. + - **Evidence strip**: provenance-badged evidence with confidence, imagery timestamp (only when the provider returns one), Street View heading, attribution, and limitations. `src/mission/EvidenceStrip.tsx`. + - **Reach + route geometry**: a deterministic walking-reach hull and suggested route for the focused/approved site, rendered in 2D and in the 3D reveal (via maps3d vector elements, with graceful fallback to marker + orbit). `src/mission/geometry.ts`. + - **Mission eval baseline**: a 50-case dataset (`eval/missions.json`) and CI-gated harness (`src/mission/evalScore.ts`) that baselines the local reranker's top-1 accuracy (1.00) ahead of any prompt/routing optimization. `npm run eval`. + - **Mission browser smoke** (`npm run mission-smoke`): drives landing → recipes → demo launch → approve → create campaign → 3D reveal, plus a mobile a11y pass. +- **Hidden admin panel for live model A/B testing**: a hidden tuner (toggle with **Cmd/Ctrl+Shift+A**, or open on load with `?admin=1`) lets you switch the copilot **chat model** and **thinking level** at runtime — no code edits or env changes — to compare feels side by side. Model picker lists the server-allowlisted chat models plus a free-text field for any allowlisted id; thinking is `default | minimal | low | medium | high` (`default` = medium). Changing a value rebuilds the per-journey engines so the next message uses the new config (the transcript is kept as a log; the SDK chat context resets). New `VITE_GEMINI_CHAT_THINKING` env also overrides the chat thinking level globally. See `src/shell/AdminPanel.tsx` and `getChatThinkingConfig` in `src/lib/config.ts`. +- **Follow-up suggestion chips**: after every copilot answer, Atlas now proposes **2–3 clickable "next step" chips** below the response — tap one to send it straight back to the agent. Suggestions are generated by a separate, cheap utility call (`MODELS.utility`, default `gemini-3.1-flash-lite`, at **MINIMAL thinking**) grounded in the recent transcript and the active journey, so they're always context-aware. The call runs off the main function-calling chat and never blocks or slows the streamed answer; it's best-effort (any failure just hides the chips), clears when you send a new prompt, and skips itself if a newer turn supersedes it. See `src/ai/followups.ts`. +- **Hands-free voice dictation**: when using the composer mic, Atlas now **auto-stops recording when you stop speaking** (end-of-utterance silence detection via a Web Audio analyser on the mic stream) and **auto-submits the transcript after a 1s grace period** — so you can dictate a request without touching the screen. Typing during the grace period (or a manual tap-to-stop) cancels the auto-send and preserves the review-before-send behavior. Degrades gracefully to tap-to-stop where `AudioContext` is unavailable. +- **Voice input for the copilot**: a microphone button in the composer lets you **speak your request** to any journey's copilot. Tap to record (the mic turns red and pulses), tap again to stop; the audio is transcribed by a low-latency Gemini model (`MODELS.stt`, default `gemini-3.1-flash-lite`, overridable via `VITE_GEMINI_STT_MODEL`) and dropped into the input box so you can review and edit before sending. Transcription runs with **MINIMAL thinking** for the fastest turnaround — the model is transcribing, not reasoning — and text-to-speech narration now does the same. Mic access is enabled via the server `permissions-policy` (`microphone=(self)`); recording only starts on an explicit tap, audio is sent once through the same-origin `/ai` proxy, and the mic track is released the moment recording ends. The button hides itself where the browser can't record. Real-time bidirectional voice via the Gemini Live API is noted as a follow-up in [docs/FUTURE_WORK.md](docs/FUTURE_WORK.md). +- **Graceful "map couldn't load" state + honest status**: when the Google Maps JavaScript API fails to initialize (missing `VITE_GMP_BROWSER_KEY`, an HTTP-referrer restriction that doesn't allow the origin, or a required Maps API not enabled), Atlas now shows a calm centered notice explaining the likely causes and linking to the README setup — instead of a silent black void. The StatusBar health dot is now wired to real signal (via `window.gm_authFailure` / `APIProvider onError`): it reads **live / degraded / offline** with an explanatory tooltip, rather than always showing green "live". +- **Stop / pause / resume for copilot queries**: the composer's send button becomes a **Stop** button while a query runs, so any journey's in-flight agent turn can be cancelled. Switching journeys automatically pauses the one you leave and records its prompt; a **Resume** chip re-runs the interrupted prompt when you return. Copilot run state is now tracked per journey. Text-to-speech is scoped to the active journey and a run's abort signal, so **narration never bleeds onto the screen you switched to** (previously a still-running Insight brief could start speaking after you left the page). +- **Generative UI (A2UI v0.9)**: the copilot now renders interactive UI surfaces in the chat dock instead of only text — place carousels, choice chips, stat grids, image cards, and buttons that fly the camera or drive follow-up prompts. + - Protocol-first, native React renderer: Gemini emits real A2UI v0.9 messages via a `render_surface` tool; Atlas renders them with a native catalog styled in the glass design system. Wire compatibility is validated against `@a2ui/web_core` zod schemas in CI. + - Catalog: Text, Image, Button, Card, Row, Column, List, Divider, ChoicePicker, StatGrid, PlaceCard, MapPreview, AdCreative. See [docs/GENUI.md](docs/GENUI.md). + - Session image registry keeps generated/base64 imagery out of the transcript via short `img:` references. +- **Ad Studio journey**: turn any real business into a grounded ad campaign — resolve the place, gather grounded facts (Maps grounding + live weather/air quality + a real Street View/Places photo), generate image ad creatives **conditioned on that real photo**, draw a travel-time geo-targeting ring, and export a campaign surface. Every creative carries an "AI-generated" badge; copy is constrained to tool-returned facts. +- **Scout journey**: real-world visual reasoning — the agent walks multi-heading Street View frames, runs Gemini vision over them, and fuses the visual read with Places density, environment, and route-matrix accessibility into ranked, evidence-backed scorecards that show the exact frames analyzed. +- **Model-override env seams**: optional `VITE_GEMINI_CHAT_MODEL` / `VITE_GEMINI_IMAGE_MODEL` and server-side `GENAI_EXTRA_MODELS` allowlist to swap in a heavier Gemini image ("omni") model without code changes. +- **Scout site selection**: inspection now grounds each candidate in real imagery from two vantage points — multi-heading Street View **plus an overhead satellite frame** (read together by Gemini vision) — and a new `compare_sites` tool returns a side-by-side decision matrix of the ranked sites with a recommended winner, rendered as an A2UI comparison surface. New `MODELS.vision` seam (`VITE_GEMINI_VISION_MODEL`) for the imagery-grounding model. +- **Video generation with Gemini omni (preview, flag-gated)**: image→video powered by `MODELS.omni` (`gemini-omni-flash-preview`) through the `@google/genai` **Interactions API** (`src/ai/video.ts`), now wired into two surfaces behind `VITE_VIDEO_GEN_ENABLED`: + - **Cinema — "Generate a video of this tour"**: seeds omni with the current stop's Street View still and plays the short flythrough inline in the Cinema panel (with loading/error states). + - **Scout — "Walkthrough video of the winning site"**: seeds omni with the top-ranked candidate's Street View evidence frame and renders the clip as a deterministic A2UI surface (new `Video` catalog component). + - **Server**: the `/ai` proxy now forwards the Interactions endpoint (`/{version}/interactions`), enforcing the same model allowlist by inspecting the request body's `model` (the path carries no `models/`), with a longer, separately-tunable `VIDEO_UPSTREAM_TIMEOUT_MS` (default 300s) for the slow omni call. + - Off by default and fully inert (no buttons, no tools, no proxy change in effect) unless enabled; enabling also requires adding `gemini-omni-flash-preview` to `GENAI_EXTRA_MODELS`. See [docs/FUTURE_WORK.md](docs/FUTURE_WORK.md). + +### Changed +- **One-prompt, consent-safe cold open**: the first screen now presents one goal prompt, three contract-accurate examples, one explicit/revocable location action, and one launch CTA. A content-free `/capabilities` preflight enables launch only when browser Maps plus explicitly enabled server Grounding Lite capabilities are configured and healthy; every unknown or degraded state fails closed with a recovery message. The first result hides unrelated journey, status, drawer, telemetry, and sharing chrome while preserving a Home escape, the map, one evidence surface, and the composer. +- **Trustworthy first-run baseline**: the cold open now defaults to deterministic Demo data, removes the unsupported travel-time, step-free, and pollen guarantees, and keeps application-owned mission orchestration out of the human transcript. Scout normalizes preference weights before fusion so 0–100 sliders cannot saturate every candidate at 100, and the pinned guidance manifest now has a fail-loud integrity check wired into verification. +- **One-click Demo campaign**: approving a Demo candidate now continues directly into its deterministic campaign. A2UI style chips synthesize a safe prompt when a model omits action context, and the desktop mission ribbon has additional clearance below the app header. +- **Default reasoning model**: primary copilot and Scout vision calls now default to `gemini-3.1-flash-lite` at medium thinking; latency-sensitive utility and transcription paths remain on minimal thinking. +- **Complete campaign handoff**: Ad Studio now waits for generated imagery before returning creative refs for A2UI composition. Scout→Ad Studio preserves the approved mission in a visible continuation notice, resets stale journey chat/context, offers campaign-specific next actions, expands the agent, and removes stale Scout overlays from the campaign workspace. +- **Map-first result inspection**: choosing “Show on map” now minimizes the response into a persistent “Ask Atlas” launcher, preserving conversational affordance without covering the selected place. Mobile status and mission headers use compact, non-overlapping layouts. +- **Internal prompt envelopes stay internal**: completed copilot messages now remove echoed mission-context and user-request wrappers before entering the visible transcript, with regression coverage for both legacy delimiter and current tagged formats. +- **Focused mobile work state**: streamed provisional prose is replaced by a visually distinct, accessible Atlas work card with live tool progress. While it is active on mobile, competing mission sheets and area controls temporarily yield their shared overlay lane; they restore automatically when the final result arrives. +- **Server rate limit fallbacks raised 10x**: Increased fallback limits in the server proxy to 2400 AI requests per 10-minute window, 12000 GMP requests, and a daily cap of 50000 to prevent 503/429 errors under heavy parallel agent workloads. +- **Narration is now audio-first and non-blocking**: the big streamed caption card that painted narration text over the map (Cinema tours and free-explore) is gone. A small global audio pill (pause/resume/stop) appears whenever narration is playing — including Cinema free-explore and Insight read-aloud briefs, which previously had **no way to pause audio at all**. Tour transport controls are unchanged. +- **Rate limits raised across the board for the next deploy**: the `/ai` proxy default is now 240 requests per 10-minute window (was 30 — a single Scout run makes 25+ Gemini calls and immediately tripped it, causing visible retry churn), `/gmp` 1200 (was 300), daily AI cap 5000 (was 1000). `.env.example` documents the sizing. When the AI limit is still hit, the copilot now shows one friendly "give it a minute" notice, marks the status dot **degraded** (auto-recovers on the next successful call), and backs off far longer between retries instead of hammering the window. +- **Mobile/touch UX pass** (final demo-readiness review): the landing screen now scrolls on short viewports so every journey card and the footer are reachable (Ad Studio and Scout were previously clipped off-screen on phones); the drawer collapse button is clamped on-screen at narrow widths (it used to slide off the left edge, leaving the panel impossible to close); compact controls (copilot suggestion/resume chips, Fleet speed buttons, Insight AQI/pollen toggles) get ≥44px hit targets on coarse-pointer devices without changing the desktop layout; and truncated suggestion/resume chips expose their full text via `title` tooltips. +- The copilot composer now reflects run state: while a query is streaming, the input dims and its placeholder reads "Atlas is working — press Stop to interrupt…" so typed-and-ignored input isn't confusing. +- Copy corrected for the current six journeys: the landing hero and page/social meta descriptions now include **Ad Studio** and **Scout** (previously enumerated only the original four), and `package.json` says "six live-map journeys." +- The smoke script (`scripts/smoke.mjs`) now treats a missing/referrer-restricted browser key as a graceful skip ("canvas assertions skipped") and exits clean, instead of reporting the expected Maps-load error as a severe failure — keyless CI/sandbox runs are green-with-a-warning while real errors still fail. +- Copy refreshed end-to-end (landing, per-scenario taglines/prompts, README, page title/meta) to frame Atlas as a **real-world reasoning agent** that sees real imagery, reasons over live Google Maps data, and acts on the map. +- Copilot long-running work now has a visible progress panel, readable tool labels, elapsed time, and Ad Studio creative-generation placeholders instead of sparse telemetry while image jobs run. +- Copilot streaming now retries transient Gemini stream failures before any partial output is received, keeps empty thinking states out of the transcript, and lets completed place-search/tool chips expand into compact result previews. +- Ad Studio now fetches the real conditioning image once per creative batch instead of once per generated style, and Atlas prompts now explicitly batch independent tool calls so the engine can run them in one turn. +- Ad Studio now guards against missing visual-style pickers by rendering fallback style chips, and the shared markdown renderer supports simple tables. +- Fleet demand visualization now uses a lightweight deck.gl scatter layer to avoid the luma.gl heatmap aggregation shader warning. +- Low-complexity Gemini utility calls now use `gemini-3.1-flash-lite` by default via `VITE_GEMINI_UTILITY_MODEL`, while the main tool-using copilot remains on the configurable chat model. +- Shared Gemini chat calls now use the low-thinking configuration consistently across copilot, grounding, vision, Insight briefs, and Cinema narration for better interactive responsiveness. +- Future-work hardening pass: Ad Studio now proxies Places photo conditioning through `/gmp/placephoto`, surfaces text-only creative fallbacks, shows remaining creative budget, and Vite splits deck.gl, Gemini, Maps, and shared vendor chunks. +- Ad Studio generation is smoother: the conditioning image is prefetched during fact-gathering (jobs no longer serially block on it), each creative tile shows a staged label (prompting → rendering → finalizing, with a text-only fallback indicator) and a live elapsed timer, error tiles offer a one-tap **Retry**, and the copilot turn no longer blocks on the slowest image job. +- Vitest now uses `test.projects` for separate node/jsdom test projects. +- Every existing journey (Concierge, Insight, Fleet, Cinema) automatically gains the `render_surface` tool and a GenUI style guide appended to its system prompt. +- CI now runs `npm run test:unit` (vitest) between typecheck and build; the smoke script covers all six journeys. + +### Fixed +- **CI build no longer fails on the Landing test.** `src/shell/Landing.test.tsx` referenced a top-level `vi.fn()` from inside a hoisted `vi.mock` factory, which hit the temporal dead zone (`Cannot access 'startFlagshipMission' before initialization`). It now uses `vi.hoisted`, restoring the suite (all 201 tests green). +- **Mission demo evidence references no longer break after reranking.** Demo candidate `evidenceIds` were keyed by pre-rank list position while the evidence array was rebuilt by post-rank position, so a candidate could point at another's evidence once the reranker reordered the list. Evidence IDs are now keyed by candidate id (`src/mission/controller.ts`). +- **`revealMissionIn3D` now guards on approval**, matching `handoffMissionToAdStudio`, so the 3D reveal can't surface an unapproved candidate if invoked programmatically. +- **A2UI surfaces no longer fail to render on the model's common near-misses.** `render_surface` validated strictly and turned any slightly-off A2UI into a "Rendering the response" error chip with no card — which `gemini-3.5-flash` at LOW thinking hit often. The tool now runs a deterministic normalization pass (`normalizeA2uiInput` in `src/genui/protocol.ts`) before validation that repairs the frequent mistakes: a missing `createSurface` (prepended for any referenced-but-uncreated surface), a missing `root` (a synthetic root `Column` wrapping the top-level components is injected for new surfaces), inline-nested child component objects (hoisted into A2UI's required flat list), a missing/foreign `catalogId` or wrong `version`, and stringified/`{messages:[…]}`/single-object payloads. Anything unrepairable still passes through so the validator returns a precise error for the model to self-correct. +- **Copilot tool calls no longer race when the model batches a prerequisite with a dependent call.** The engine executed a turn's tool calls in parallel (`Promise.all`), but every journey's prompt tells the model to *batch* independent work in one turn. When the model batched a prerequisite and a dependent call together — e.g. Ad Studio's `set_campaign_business` + `gather_campaign_facts` — the dependent handler read scenario-store state the prerequisite hadn't written yet and failed with a spurious "Call set_campaign_business first." (and the same class of "call X first" error in Scout's inspect→score→compare flow). The engine now runs a turn's tool calls **sequentially, in the order the model emitted them** (`src/ai/engine.ts`), so batched, dependency-ordered turns just work; it's also strictly safer for tools that mutate shared scenario state. Applies to every journey. +- **Follow-up suggestion chips no longer propose actions the agent can't perform.** The suggester (`src/ai/followups.ts`) only saw the journey title/tagline/transcript, so it invented capabilities the agent lacks — e.g. Fleet chips like "show a traffic heatmap". It's now grounded in each journey's **actual tool capabilities** (derived from the scenario's tool declarations, minus UI plumbing) with a hard rule to never invent unsupported features. Applies to every journey. +- **Text-to-speech narration no longer 400s.** The TTS call hardcoded a `thinkingConfig`, which the `gemini-3.1-flash-tts-preview` model rejects with a `400 Bad Request` (synthesis has no reasoning phase). Removed it (`src/ai/tts.ts`); narration now plays instead of silently failing. +- **Copilot no longer wedges on "Thinking…" at the end of a turn.** A model stream that went silent-but-open (upstream/proxy sent no more bytes and never closed — most visible on the final synthesis hop after tool calls) left the run flagged as in-flight forever: the progress panel stayed on "Thinking / Atlas is working through the next step", all tool chips already ✓, and no follow-up chips ever appeared. Two compounding bugs are fixed: (1) **client** — each model stream now has a **35s idle watchdog** (`withIdleTimeout` in `src/ai/engine.ts`) that aborts and retries a stalled attempt via a fresh per-attempt `AbortController`, then surfaces a "please try again" notice + **Resume** chip and clears the loading state if all retries stall; a model message left mid-stream is also un-stuck so its blinking caret can't persist; (2) **server** — when the proxy's upstream timeout fired *mid-stream* (after response headers were sent), the error handler called `writeHead` a second time and threw, leaving the socket open and the client hanging — it now `res.destroy()`s the already-started response so the client sees a failed read and recovers. +- **A2UI Text/StatGrid now interpolate `{path}` tokens**, so generated surfaces no longer render raw placeholders like `#{rank}` / `{label} — Score: {total}` (seen in Scout's ranked scorecards). Literal strings in `Text` and `StatGrid` resolve `{path}` tokens against the surface data model, scoped to the current List item; the GenUI prompt guide documents the tokens and adds a ranked-list worked example. Unresolvable tokens and plain prose braces are left untouched. +- **A2UI `AdCreative` cards no longer leak raw `{headline}` / `{body}` / `{cta}` (and adjacent `{style}` labels) onto the surface.** `AdCreative` was the only text-bearing catalog component that never interpolated `{path}` tokens — so an ad card built as a List template (the pattern the prompt guide teaches) rendered mustache placeholders verbatim in Ad Studio. Two fixes: (1) a shared `resolveDisplayText` resolver (`src/genui/protocol.ts`) now interpolates literal `{path}` tokens for `AdCreative`, matching `Text`/`StatGrid`; (2) all three components now **drop** any binding-shaped token the data model never satisfied (`dropUnresolvedTokens`) so an unresolved token renders as nothing instead of raw `{token}` braces (legitimate prose braces like `{ spaced }` are preserved). The prompt guide now documents that tokens work in `AdCreative` strings too. Covered by protocol unit tests plus an `AdCreative` render test. +- **The context drawer no longer overlaps the copilot input bar on phones** — the composer stays fully visible and tappable at narrow widths. +- **A2UI surfaces no longer overlap each other.** In the copilot dock, a tall agent-response surface was a shrinkable flex item in the height-capped, scrolling message column, so it compressed below its content — and because surfaces use `overflow: visible`, the excess spilled onto the next surface, stacking agent responses on top of one another (most visible in Scout's scored/compare surfaces). Message children are now pinned to their content height (`flex-shrink: 0`) so the column scrolls instead. The same flex-compression bug in every right-hand drawer panel's scroll region (Scout, Ad Studio, Concierge, Insight, Fleet, Cinema, and the Concierge postcard modal) is fixed with a shared `.panel-scroll > *` rule. +- A2UI button/chip labels render as compact inline labels again (primary buttons use the accent fill), instead of a full chat-bubble markdown block that inflated the control; surface body text now uses the full card width instead of the chat-bubble `max-width`. +- Copilot markdown now renders heading syntax as styled headings, and generated choice/Places/creative surfaces have responsive sizing to avoid cramped or overflowing agent responses. +- ChoicePicker action contexts now substitute `{selection}` placeholders before dispatch, with focused unit coverage. +- Scout now surfaces remaining inspection budget from the shared cap constant instead of hardcoding the displayed limit. +- Smoke script (`scripts/smoke.mjs`) now navigates each journey via its left-rail button **before** capturing, so each screenshot shows its own journey instead of lagging one behind. +- **UI/UX pass across all journeys** (layering, scroll, orientation): + - Cinema captions and transport controls now stack above the copilot dock (were rendering behind it), and spoken narration is gated until the camera actually arrives at each stop, so audio matches what is on screen instead of starting mid-fly-in. + - Ad Studio's creative preview lightbox now opens above the shell (`--z-modal`) instead of behind it, and tall generative-UI responses scroll vertically within the dock — including vertical passthrough over horizontal creative carousels. + - Tool-call detail popovers (search results, weather, air quality) now position with viewport-aware fixed placement above all dock UI instead of overlapping neighboring components or clipping off-screen. + - Insight dossier results scroll within a height-bounded drawer, and the Concierge postcard preview is constrained to the viewport with internal scroll instead of being vertically cut off. + - Fleet trucks rotate to face their direction of travel (shortest-arc heading, upright glyph) instead of pointing a fixed direction. +- Added `scripts/uiux-audit.mjs`: a multi-viewport (xs→xl) Playwright audit that captures every journey and flags horizontal overflow, off-viewport controls, sub-40px tap targets, and unlabeled controls. +- **Second UI/UX pass** (popovers, GenUI rendering, cinema audio, layout): + - Tool-call detail popovers are now proper popovers with a click-away backdrop and Escape/scenario-change dismissal (using the `--z-modal` token) instead of a fixed box overlaying the whole app; many more tool calls are clickable, showing input args and rich result rows. + - Tall A2UI surfaces scroll within the dock instead of clipping; heading/caption text no longer leaks raw `###`/`**` markdown; generated ad-preview images show the whole photo (contain) with clamped overlay text so nothing overlaps the image, badge, or actions. + - Cinema narration audio stops on every stop transition and auto-advance waits for playback to finish (no more hearing the previous stop after the camera has moved); captions reveal on arrival, also render in free-explore, and sit below the dock/drawer within the map region. + - Concierge postcard modal is fully reachable on short viewports (scrollable scrim, padding-aware max-height, whole image visible), and the itinerary panel's scroll chain is hardened (`min-height:0`). + +### Verification notes +- Keyed end-to-end run against a local server with live keys: the server-side `/gmp` (static map, Street View) and `/ai` (Gemini) proxies respond live, and the production build ships only the public referrer-restricted browser key (the Gemini key stays server-side). The client-side Maps JavaScript API cannot load from `localhost` because the browser key is HTTP-referrer restricted, so in-sandbox journey screenshots render without the basemap; deploy behind an allowed referrer to capture full-map screenshots. + +## [1.0.0] - 2026-07-07 + +Initial public release. + +### Added +- **GitHub Repository Attribution**: + - Added "Try it yourself" link in the Landing page footer. + - Added a persistent "GitHub" link next to the status indicator in the StatusBar. +- **Cloud Run Deployment Security**: + - Added `cloudbuild.yaml` to pass `VITE_GMP_BROWSER_KEY` and `VITE_GMP_MAP_ID` as Docker build arguments during Cloud Build. + - Modified `scripts/deploy.sh` to use the Cloud Build configuration instead of command-line tags. + - Enforced a `--max-instances 3` limit on the Cloud Run service to protect against runaway billing and abuse. +- **Interactive Location Comparison UI (Insight Scenario)**: + - Implemented dual-location comparison support (`subject` for Location A, `compare` for Location B). + - Displays comparison dossiers side-by-side or stacked in a responsive view. + - Draws a customized dotted connector route line between compared locations. + - Added support for clearing individual slots, promoting Location B to A dynamically. + - Incorporated a click-to-analyze toggle mechanism to seamlessly switch active targets. +- **Solar Potential Dashboard**: + - Displays total sunshine hours, maximum solar panel capacity, and roof surface area. + - Computes and renders CO2 offset equivalents in kilograms per year and equivalent trees planted. + - Visualizes offsets against average household electricity consumption (10,500 kWh) using a progress bar. + - Renders building roof bounding boxes dynamically on the map to visualize the solar target footprint. +- **Pollen Layer Overlay**: + - Added toggle support for tree pollen density map overlays (`TREE_UPI` heatmap tiles). +- **Deep-linking State Sync**: + - Implemented automatic synchronization of map state variables (active scenario, 2D/3D mode, drawer toggle, landing page status, selected marker, camera viewport settings) directly to URL query parameters. + - Parses initial values from the URL on page load, allowing full map view restoring and sharing. +- **Fast-response Inference Engine Tuning**: + - Equipped Gemini 3.5 Flash chat and Maps grounding APIs with a low-thinking-level configuration (`ThinkingLevel.LOW`) to ensure rapid response streaming. +- **Stable Marker Management & Tour Interactions**: + - Configured stable marker IDs (`concierge-stop-${i}`) for Concierge scenario itinerary stops. + - Automatically highlights and selects active tour stops in the place detail popover during playback. + +### Changed & Optimized +- **Offset-Aware Map Viewport Camera**: + - Refactored 2D camera viewport positioning logic to calculate screen offsets from left rails, right drawer panels, top statuses, and bottom docks to center and fit bounds without overlapping interactive UI elements. +- **Parallelized Route Fetching**: + - Replaced sequential itinerary stop route fetches with concurrent promises (`Promise.all`) to accelerate Concierge tour generation. +- **Cleaned Markdown Streaming Layout**: + - Rewrote the markdown inline parsing mechanism to support block-less lists and formatting during real-time streaming chunks in the Copilot dock. +- **Refined AI System Prompts**: + - Adjusted prompt constraints across all scenarios (Cinema, Concierge, Fleet, Insight) to favor direct, concise, factual, and quantitative statements while strictly avoiding verbose, repetitive, or flowery descriptions. + - Configured prompt warnings to prevent AI dispatchers from entering polling loops. +- **Updated Gemini Models**: + - Replaced the image generator model with `gemini-3.1-flash-lite-image`. +- **Upgraded Google Maps SDK Channel**: + - Promoted the Google Maps API loader channel from `alpha` to `weekly` for improved stability. + +--- + +## Earlier development + +### Added +- **Custom High-Contrast Map Markers (`AtlasMarker`)**: + - Created a custom styled circular marker layout with hover scale transformations, distinct selection highlighting, and glyph text support. +- **Places Detail Popover Card (`MarkerPlaceCard`)**: + - Designed a custom popover overlay showing address, rating, visual assets, and a close button, properly placed relative to the selected map coordinate. +- **Layout & Style Refinements**: + - Optimized viewport dimensions and grid layout bounds to prevent marker collisions and clutter. diff --git a/demos/real-world-reasoning-agent/CONTRIBUTING.md b/demos/real-world-reasoning-agent/CONTRIBUTING.md new file mode 100644 index 0000000..ee4e314 --- /dev/null +++ b/demos/real-world-reasoning-agent/CONTRIBUTING.md @@ -0,0 +1,10 @@ +# Contributing + +Thanks for your interest in Atlas! This is a demo project, so contributions are kept lightweight: + +1. Fork the repo and create a feature branch. +2. Follow the setup in [README.md](README.md) (Node 22+, `.env` from `.env.example`). +3. Before opening a PR, make sure `npm run typecheck` and `npm run build` pass. +4. Keep changes focused; open an issue first for anything large. + +Never commit API keys or `.env` files — CI runs secret scanning on every push. For security issues, see [SECURITY.md](SECURITY.md) instead of opening a public issue. diff --git a/demos/real-world-reasoning-agent/LEARNINGS.md b/demos/real-world-reasoning-agent/LEARNINGS.md new file mode 100644 index 0000000..c970ef5 --- /dev/null +++ b/demos/real-world-reasoning-agent/LEARNINGS.md @@ -0,0 +1,116 @@ +# learnings + +## 2026-07-13 — Agent answers and evidence surfaces need separate ownership + +Context: The first-run explorer rendered its deterministic fixture, ranking, weather, limitations, and repeated provenance as one long A2UI surface. Users saw cards but no recognizable Atlas answer and mistook Sample fixtures for a live agent result. +Learning: Keep the concise agent conclusion as a model transcript message and the deterministic A2UI graph as supporting evidence. Generate prose only from a bounded verified view, reject links or shortlist-foreign place names, and retain a deterministic fallback for keyless Sample runs. Sample provenance should be a single non-clickable disclosure; Google Maps attribution remains adjacent only for Live claims. +Use next time: Do not make a result surface carry both the conversational answer and every evidence detail. Preserve a clear answer/evidence hierarchy and ensure demo fixtures cannot leak repository or source links into user-facing results. + +## 2026-07-09 — Gemini thinkingConfig Model Compatibility + +Context: Implementing task-specific thinking configurations (medium, low, minimal) across the application. +Learning: The Gemini API throws a 400 Bad Request error if a `thinkingConfig` parameter is passed to models that do not support it (e.g. Gemini 2.0 Flash, Gemini 1.5 Pro, and specialized image, audio, or video models). +Evidence: Gemini API documentation, `@google/genai` typings (`ThinkingConfig`), and actual API schema behavior. Specifically: + - Gemini 3/3.5 models (e.g., `gemini-3.5-flash-lite`) support `thinkingLevel`. + - Gemini 2.5 models support `thinkingBudget`. + - Older models (e.g., `gemini-1.5-flash`), standard 2.0 (e.g., `gemini-2.0-flash`), and non-text modalities (image, tts, omni video) do not support `thinkingConfig` fields. +Use next time: Use the `getThinkingConfig(model, level)` helper function to dynamically assign the configuration object or omit it for models that do not support reasoning config. + +## 2026-07-10 — Copilot tool calls must run sequentially, not in parallel + +Context: Debugging "a fair number of failed tool calls" — spurious `{ok:false, error:'Call set_campaign_business first.'}` in Ad Studio (same class of "call X first" guard exists in Scout). +Learning: The `CopilotEngine` tool loop (`src/ai/engine.ts`) originally executed all tool calls in a turn with `Promise.all`, but every journey's system prompt tells the model to *batch* independent tool calls in one turn. When the model batches a **prerequisite** call with a **dependent** call (e.g. `set_campaign_business` + `gather_campaign_facts`), parallel execution races: the dependent handler reads scenario-store state (`adstudio().business`) before the prerequisite's `await` (a `placeDetails` fetch) has written it, so the prerequisite guard fires falsely. This is NOT a model-tier problem — the copilot is already on `gemini-3.6-flash` at LOW thinking; a stronger model would still hit the race. +Evidence: `adstudio/controller.ts` guards check `adstudio().business` set by `setCampaignBusiness`, which writes state only after `await placeDetails(...)`. Reproduces whenever the model emits both calls in one function-call response. +Use next time: Keep the engine's per-turn tool execution **sequential, in the model's emission order** — the model emits prerequisites before dependents, so ordering holds and batched turns succeed. Do NOT reintroduce `Promise.all` there. Prefer scenario-store race safety over the marginal latency of parallelizing (turns are usually a single call anyway). + +## 2026-07-10 — Follow-up suggestions must be grounded in the journey's real tools + +Context: Fleet follow-up chips suggested actions the agent can't do (e.g. "show a traffic heatmap"). +Learning: `suggestFollowups` (`src/ai/followups.ts`) fed the utility model only the journey title/tagline/transcript — no capability list — so it hallucinated features. The scenario's real capabilities are already available via `SCENARIOS[scenario].tools` (each `ToolDefinition.declaration` has a name + description). +Use next time: When adding/altering suggestion or planning prompts, ground them in `describeCapabilities(scenario)` (tool declarations minus UI plumbing like `render_surface`/`show_notice`) and forbid inventing unsupported features. A new tool automatically becomes a suggestable action; a removed tool stops being suggested. + +## 2026-07-10 — A2UI render_surface failures are model near-misses, not model tier; normalize before validating + +Context: "A ton of 'Rendering the response' failed chips" for A2UI cards. The copilot is already on `gemini-3.6-flash`; a bigger model wasn't the fix. +Learning: `render_surface` validated strictly (`validateMessages`) and returned `{ok:false}` on any deviation, which renders as an error chip with no card. Flash-family models routinely emit *near-valid* A2UI: omit `createSurface`, forget the `root` component, nest child component objects instead of a flat list, use a foreign `catalogId`/`version`, or return stringified JSON. These are deterministically repairable. +Use next time: `normalizeA2uiInput(raw, existingSurfaces)` (`src/genui/protocol.ts`) runs before `validateMessages` in the tool and repairs those near-misses (prepend `createSurface`, inject a synthetic `root` Column over top-level nodes, hoist nested children into the flat list, default catalogId/version, coerce string/`{messages}`/single-object). Keep `validateMessages` STRICT (its tests assert exact errors) and do the leniency in the normalize pass. For extra reliability on JSON-only helper calls (e.g. follow-ups) prefer a Gemini structured-output `responseJsonSchema` over trusting `responseMimeType:'application/json'` alone — it keeps the cheap flash-lite model parser-clean. + +## 2026-07-10 — "Filming the tour" uses the omni VIDEO model, not flash-lite + +Context: A user reported "Filming the tour" (and "Filming the walkthrough") failing and assumed flash-lite was to blame. +Learning: `generate_tour_video`/`walkthrough_video` → `generateVideo` (`src/ai/video.ts`) → `MODELS.omni` (`gemini-omni-flash-preview`), a VIDEO-generation model driven via the Interactions API. It is NOT flash-lite and CANNOT be replaced with `gemini-3.6-flash` (a text model). `MODELS.omni` is also **not** in the server base allowlist (`server/index.mjs`) — it must be added via `GENAI_EXTRA_MODELS`, and the feature is gated by `VITE_VIDEO_GEN_ENABLED` (on by default). +Use next time: If tour/walkthrough video "fails", the cause is the omni model being unavailable/unallowlisted, not the text model tier. To make it work, provide a real image→video model available to the project + allowlist it (`GENAI_EXTRA_MODELS`) and set `VITE_GEMINI_OMNI_MODEL`; to hide the CTAs, set `VITE_VIDEO_GEN_ENABLED=false`. Do not "fix" it by swapping to a text model. + +## 2026-07-10 — A2UI text components must interpolate AND drop unresolved `{path}` tokens + +Context: Ad Studio cards still rendered raw `{style}`, `{headline}`, `{body}`, `{cta}` placeholders even after the structured-output work — two AdCreative cards each showing literal mustache braces. +Learning: The A2UI catalog had an interpolation gap. `Text` and `StatGrid` resolved literal `{path}` tokens against the surface data model, but `AdCreative` (the only other text-bearing component) called `resolveDynamic` directly and rendered the resolved string verbatim — so a card built as a List template with `headline:"{headline}"` (the exact pattern `promptGuide.ts` teaches) leaked the raw token. Separately, ANY unresolved token (wrong key / missing data / template shell with no data model) leaked as raw `{token}` from every component, because `interpolate` deliberately leaves unresolvable tokens untouched (its tests assert that, and prose braces must survive). +Evidence: `src/genui/components/AdCreative.tsx` used `displayText(resolveDynamic(...))` with no `interpolate`; `Text.tsx`/`StatGrid.tsx` did interpolate. New `AdCreative.test.tsx` reproduces the leak (raw `{headline}` in the rendered markup) and proves the fix. +Use next time: Route every text-bearing component's string props through `resolveDisplayText(v, dataModel, scope)` (`src/genui/protocol.ts`) — it resolves the dynamic, interpolates `{path}` tokens for literals, then drops any binding-shaped token the data model never satisfied (`dropUnresolvedTokens`) so broken bindings render as empty, not raw braces. Keep `interpolate` strict; do the token-dropping in the component/`resolveDisplayText` layer only (never on bound data values — real data may legitimately contain braces). When adding a new catalog component with a text prop, use `resolveDisplayText`, and update the `promptGuide.ts` "tokens work in …" line to list it. + +## 2026-07-10 — Headless CI can't render the WebGL vector / photorealistic 3D basemap + +Context: The mission browser smoke (`scripts/mission-smoke.mjs`) and the Playwright MCP browser both log `Attempted to load a Vector Map, but failed. Falling back to Raster` and, in Cinema, `Attempted to load a 3D Map, but failed`, with the app's "Oops! Something went wrong" map notice on the 3D reveal. +Learning: This is an **environment** limitation (no GPU; `--use-gl=angle --use-angle=swiftshader` can't do the vector/3D basemap), not an app bug. Consequences for tests: (1) a raster fallback has **no sized ``**, so asserting `canvas.width>0` is wrong — accept `.gm-style` / tile `` too; (2) the 3D reveal cannot be screenshot-verified headless — verify the 2D reach/route instead and rely on the defensive feature-detect in `MissionReach3D` (it `customElements.get('gmp-polygon-3d')` + try/catch, so a missing maps3d vector element degrades to marker + orbit rather than throwing). +Use next time: For mission-flow smoke, run in demo mode (no creds needed for the flow), assert the map area via canvas OR `.gm-style` OR tiles, and gate horizontal-overflow checks on **visible** content (`left < viewport && right > viewport`) — the app pins `body{overflow:hidden}` and parks closed slide-overs fully off-canvas by design, so `documentElement.scrollWidth` is a false positive. Showcase screenshots that need the real basemap require a GPU-backed browser. + +## 2026-07-10 — Terra Draw is the drawing-editor path (not the deprecated Drawing Library) + +Context: The area/exclusion editor slice needed interactive polygon drawing on the Google map. +Learning: Use `terra-draw` + `terra-draw-google-maps-adapter`. Init inside a component under `` with `useMap()`: `new TerraDraw({ adapter: new TerraDrawGoogleMapsAdapter({ lib: google.maps, map }), modes: [new TerraDrawPolygonMode()] })`, then `draw.start()`, `draw.setMode('polygon')`, listen with `draw.on('finish', …)`, read `draw.getSnapshot()` (GeoJSON — coords are **[lng,lat]**, rings closed). The map instance is only reachable via `useMap()`/`useMap3D()` inside the React tree; controllers drive the map through the zustand store, never a map singleton. +Use next time: TS types ship with the package, so rely on `tsc` to validate the adapter/API rather than guessing. `npm audit` flags a pre-existing critical only in `vitest` (dev-only UI server), not in terra-draw. +# A2UI updates must be atomic and graph-valid + +Agent-emitted surfaces are flat component graphs, so schema-valid messages can +still create missing children, cycles, unreachable controls, or an empty root +after a partial update. Validate the merged graph in O(nodes + edges), stage the +whole batch before committing, preserve the last valid surface on error, report +the error through `render_surface`, and prune unreachable stale subtrees after a +valid root replacement. Protocol-only validation cannot prove an incremental +patch is safe because it does not have the prior surface graph. + +## 2026-07-13 — Normalize scoring inputs and separate human turns from orchestration + +Context: The flagship mission stored priorities on a 0–100 UI scale while Scout fusion expected weights summing to 1, and app-owned kickoff/handoff prompts used the same transcript path as human messages. + +Learning: Normalize rubric weights at the scoring boundary even when upstream callers normally normalize them; otherwise one raw UI-scale caller clamps every weighted total to 100. Application-owned agent instructions also need an explicit internal-send path: they may remain a model request, but must not be rendered or replayed as if the human typed them. + +Use next time: Route scoring through `normalizeRubricWeights`, derive active-mission Scout weights with `prioritiesToRubric`, and use `sendInternalToCopilot` for controller-owned turns. Keep ordinary `sendToCopilot` for actual user actions. Validate every pinned source path at its exact ref with `npm run verify:sources`; never substitute a default branch when a declared path is missing. + +## 2026-07-13 — Real-world facts cross a provider-neutral outcome boundary + +Context: Preparing Places + Routes + Weather for a portable agent runtime without changing the current journeys. + +Learning: SDK responses and REST payloads are translated at `src/world/` into JSON-safe facts and discriminated outcomes. Every outcome carries provider attribution, freshness, limitations, retention, and model-use policy—even empty, failed, or cancelled calls. Missing coordinates remain `null` instead of being fabricated at `(0,0)`, and unavailable route metrics remain `null` in normalized results instead of becoming zero or `NaN`. Direct Google Maps API content is default-deny for model context; a model-facing grounding adapter must establish its own product-compliant policy and keep sources adjacent. + +Use next time: Add new real-world products through a port in `src/world/ports.ts`, return `ProviderResult`, and keep SDK objects out of contracts and fixtures. Preserve old caller semantics in a compatibility adapter until the caller migrates. Include `gmp_git_agentskills_v1` in supported Maps SDK requests and the Weather solution-ID header. + +## 2026-07-13 — Capability preflight and location consent must fail closed + +Context: The cold open manually exposed Live/Demo and requested browser geolocation during app startup, before a user action. + +Learning: `apiHealth: 'ok'` is not proof that a live mission can run: it does not establish browser Maps configuration or the server-side Maps and Gemini capabilities. Likewise, a visually covered shell is still keyboard- and screen-reader-reachable when it remains mounted behind an overlay. Exact location should not enter global state or trigger reverse geocoding merely because the user opened the browser consent prompt; otherwise “Remove” cannot truly stop app use of those coordinates. + +Use next time: default to Sample, combine the same-origin content-free `/capabilities` response with browser-key, online, and API-health signals, and fall back on every unknown/error. Mount the operational shell only after landing dismissal. Keep coordinates local after **Use my location**, discard them on **Remove**, and create the session-only custom area only when the user launches while location remains selected. + +## 2026-07-13 — Capability executors return effects; adapters own host mutation + +Context: Extracting the first portable capability slice without rewriting the copilot engine or weakening Maps-content policy. + +Learning: Serializable capability manifests, pure executors, typed host effects, and host adapters are separate layers. The executor returns data/effects and never imports Atlas state; the adapter alone projects effects into Zustand and exposes the legacy `ToolDefinition`. New turns must abort the prior shared signal before an awaited executor can project stale effects. Provider-backed tools cannot migrate merely for architectural symmetry when their evidence policy denies direct model context. + +Use next time: Add capabilities to the versioned registry, generate declarations from the manifest, resolve the smallest prerequisite profile, replay effects in a fixture host, and keep engine calls sequential in model-emission order. Put new Maps-backed model tools behind a permitted grounding adapter rather than unwrapping direct provider facts. + +## 2026-07-13 — Grounded explorer decisions separate evidence, ranking, and display geometry + +Context: Building a one-prompt Places + Routes + Weather decision without returning ordinary Maps API content to the generic copilot model. + +Learning: Maps Grounding Lite can own the structured, attributed decision evidence while deterministic code enforces hard route limits and produces the recommendation. A direct Routes provider may separately draw the selected polyline, but its `modelContext: denied` result stays inside the display-effect boundary. One fixed A2UI surface should be updated through every stage; local typed counterfactual actions must reuse the shortlist and atomically replace marker ranks and route state. + +Use next time: keep Grounding Lite attribution title/URL and each place link adjacent to its supported claim, show the full WALK beta/sidewalk warning on every view containing walking data, reject unattributed grounded records, keep live results session-scoped, and expose only exact bounded argument schemas for the three read-only MCP tools through the same-origin server proxy. A server key alone is not readiness proof; require an explicit deployment opt-in after API enablement and key restrictions are verified. + +## 2026-07-21 — Always pass --project geojson-bq-blog; never trust gcloud default + +Context: A deploy attempt used the local gcloud default project (`gmp-demos-ryanbaumann`) instead of the correct project (`geojson-bq-blog`). The local default is set to a different project that `rsbaumann@gmail.com` does not even have permissions on, so the build failed with PERMISSION_DENIED — but if the account *had* access, it would have deployed to the wrong environment. +Learning: The local `gcloud config get-value project` is unreliable and returns the wrong project for this repo. Every `gcloud` command — builds, deploys, secrets, services, artifact repos — MUST include `--project geojson-bq-blog` explicitly. The forbidden project `gmp-demos-ryanbaumann` must never be targeted. +Use next time: Hardcoded in `AGENTS.md`. Always pass `--project geojson-bq-blog` on every gcloud invocation. Never omit `--project` and never use `gmp-demos-ryanbaumann`. diff --git a/demos/real-world-reasoning-agent/LICENSE b/demos/real-world-reasoning-agent/LICENSE new file mode 100644 index 0000000..48aa759 --- /dev/null +++ b/demos/real-world-reasoning-agent/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Ryan Baumann + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/demos/real-world-reasoning-agent/PROVENANCE.md b/demos/real-world-reasoning-agent/PROVENANCE.md new file mode 100644 index 0000000..2595012 --- /dev/null +++ b/demos/real-world-reasoning-agent/PROVENANCE.md @@ -0,0 +1,18 @@ +# Source provenance + +- `source_repository`: `https://github.com/ryanbaumann/real-world-reasoning-demo` +- `source_ref`: `68e8c34547066a984ccb97f5b587caeb97561ec1` +- `source_default_branch`: `main` +- `source_visibility_at_import`: `private` +- `release_authority`: `user-requested public Fieldwork migration` +- `imported_as`: `demos/real-world-reasoning-agent` + +The imported tree was created from the exact Git commit above. Git history, +environment files, credentials, dependency and build output, private +deployment configuration, standalone CI workflows, and repository-local agent +instructions were excluded before the snapshot entered Fieldwork. + +The app's source code, tests, fixtures, evaluation data, and bounded server +modules were retained. Fieldwork is the canonical public source and deployment +owner after this migration; the source repository's visibility and settings +were not changed. diff --git a/demos/real-world-reasoning-agent/README.md b/demos/real-world-reasoning-agent/README.md new file mode 100644 index 0000000..f3cc058 --- /dev/null +++ b/demos/real-world-reasoning-agent/README.md @@ -0,0 +1,126 @@ +# Atlas — a real-world reasoning agent + +[![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE) +[![Node 20.19+](https://img.shields.io/badge/node-%3E%3D20.19-brightgreen.svg)](package.json) + +> **A personal project by [Ryan Baumann](https://github.com/ryanbaumann).** Atlas is not an official Google product and is not affiliated with, sponsored, or endorsed by Google. It is an independent demo built on the public [Google Maps Platform](https://developers.google.com/maps) and [Gemini API](https://ai.google.dev/gemini-api/docs) — bring your own API keys, and you are responsible for your own usage and billing. + +The canonical source now lives in +[ryanbaumann/fieldwork](https://github.com/ryanbaumann/fieldwork/tree/main/demos/real-world-reasoning-agent). +Fieldwork serves the production build at `/real-world-reasoning-agent/`. See +[PROVENANCE.md](PROVENANCE.md) for the pinned source snapshot and migration +boundary. + +Atlas gives an AI eyes on the real world. It's a Vite + React demo of one agent that **perceives** (Street View + satellite imagery), **reasons** over grounded signals (routes, air, weather, visible street activity, solar), and **acts** — rendering its results as **interactive generative UI**. Atlas 2.0 leads with one mission that observes, compares, asks for approval, creates a campaign, and reveals the result in 3D. The six original journeys remain available as recipes. One reusable agent loop coordinates purpose-built tools, policies, typed state, and generated surfaces. + + + +![Atlas landing screen](docs/screens/00-landing.png) + +## Journeys + +| Journey | What it does | +|---|---| +| **Concierge** | One sentence in — a walkable day out. Real places, real hours, reasoned into order and drawn on the map. | +| **Insight** | Click any block. The agent pulls air, weather, solar, and live commutes — and hands down a verdict with receipts. | +| **Fleet** | A dispatcher that watches live traffic and weighs every tradeoff before it commits a single van. | +| **Cinema** | The agent flies a photoreal 3D city and narrates it — speaking only facts it can prove. | +| **Ad Studio** | Point at a storefront. Get back a campaign: grounded copy, conditioned creatives, walk-time targeting. | +| **Scout** | It walks the block on Street View, reads frontage and visible street activity, then defends its site pick with evidence. | + +## Why this matters for developers + +- **Visible reasoning traces.** Every answer is built from live tool calls surfaced as status chips and a progress panel — you watch the agent perceive and act, not just read a final blob of text. +- **3D as the reasoning payoff.** Cinema flies a photorealistic 3D city and narrates only what it can ground, turning the map itself into the agent's evidence. +- **Replay links.** Any completed run yields a one-click link that re-opens Atlas in the same journey + city and re-runs the prompt live — so sharing a result means sharing the *agent reasoning*, not a screenshot. Click **How it's built** in any journey to see the exact prompt and tools behind it. + +## Generative UI (A2UI) + +Every journey's copilot can render interactive surfaces in the chat dock (place carousels, choice chips, stat grids, image cards, camera buttons) instead of only text. Atlas speaks the [Maps Agentic UI Toolkit](https://github.com/googlemaps/a2ui) **A2UI v0.9** protocol via a `render_surface` tool and renders it with a native React catalog; wire compatibility is validated against the official `@a2ui/web_core` schemas in CI. See **[docs/GENUI.md](docs/GENUI.md)**. + +## Voice input + +Tap the microphone in the copilot composer to **speak your request** instead of typing. Audio is transcribed by the low-latency task-agent model (`gemini-3.5-flash-lite` by default, override with `VITE_GEMINI_STT_MODEL`) running with **MINIMAL thinking** for the fastest turnaround, and the transcript drops into the input box for you to review before sending. Recording starts only on an explicit tap, audio is sent once through the same-origin `/ai` proxy, and the mic is released as soon as you stop. Real-time bidirectional voice via the Gemini [Live API](https://ai.google.dev/gemini-api/docs/live-api) is tracked in [docs/FUTURE_WORK.md](docs/FUTURE_WORK.md). + +## Architecture + +```text +Browser + ├─ Maps JavaScript SDK with VITE_GMP_API_KEY (restricted browser key) + ├─ A2UI surfaces ← render_surface tool ← Gemini copilot + ├─ /api/real-world-reasoning-agent/gmp/* → Fieldwork gateway → Google Maps Platform REST/static APIs + └─ /api/real-world-reasoning-agent/ai/* → Fieldwork gateway → Gemini API (model-allowlisted) +``` + +Only the restricted browser Maps key is bundled into the app. Server-side Google Maps Platform REST calls and all Gemini calls are proxied by `server/index.mjs`, which allowlists every target model. Gemini uses the hosted `GEMINI_KEY` by default. A user may instead connect a personal Gemini key from the cold open or the **AI key** control; it stays in tab memory, travels in a private same-origin header, overrides hosted access for that tab, and is never stored in URLs, browser storage, chat, or diagnostics. + +### Model configuration + +Atlas uses `gemini-3.6-flash` at medium thinking as the orchestration model. Bounded task agents use `gemini-3.5-flash-lite`: minimal thinking for formatting, suggestions, and voice; medium thinking for multimodal evidence analysis. Image, TTS, and optional video keep their purpose-built models. Model IDs remain overridable through the environment variables documented below. + +### Bring your own Gemini key + +Open **Connect Gemini** on the first screen (or **AI key** in the app header), paste a key from [Google AI Studio](https://aistudio.google.com/apikey), and choose **Test and connect**. Atlas verifies access to both the orchestrator and task-agent models without generating content. The key lasts only for the current tab and can be disconnected immediately; reloading clears it. Requests still pass through Atlas's guarded same-origin proxy, so use a deployment you trust. Your Gemini usage and billing apply to your key. + +## Local development + +Prerequisites: Node.js 20.19+ and a Google Cloud project with the Maps JavaScript, Places (New), Routes, Map Tiles, Geocoding, Air Quality, Weather, Pollen, Solar, Static Maps/Street View, and Maps Grounding Lite APIs enabled ([create API keys](https://console.cloud.google.com/google/maps-apis/credentials)), plus a [Gemini API key](https://aistudio.google.com/apikey). + +1. Create an ignored `.env` file and set: + - `VITE_GMP_API_KEY` — restricted browser key for Maps JavaScript, Places (New), Routes, and Map Tiles. + - `VITE_GMP_MAP_ID` — optional public map ID; Atlas uses Google's `DEMO_MAP_ID` when unset. + - `GMP_SERVER_KEY` — server-side Google Maps Platform REST/static key. + - `GROUNDING_LITE_ENABLED=true` — explicit live-explorer opt-in after Grounding Lite enablement and key restrictions are verified. + - `GEMINI_KEY` — server-side Gemini key. + +2. Install and run: + + ```bash + npm ci + npm run dev + ``` + +Vite keeps development equivalents for `/api/real-world-reasoning-agent/capabilities`, `/api/real-world-reasoning-agent/gmp/*`, and `/api/real-world-reasoning-agent/ai/*`, so local development does not require a separate server process. + +## Fieldwork production integration + +Build the workspace package from this directory: + +```bash +npm run build +``` + +Fieldwork's root build stages `dist/` at `/real-world-reasoning-agent/`. The +snapshot's bounded server modules remain under `server/` as migration input for +the shared gateway. They are not a second production server inside the +Fieldwork container. `npm run preview` builds the package and starts the shared +Fieldwork gateway from the repository root. + +## Tests + +`npm run test:unit` runs the vitest suite (A2UI protocol/store, schema parity against `@a2ui/web_core`, the Ad Studio prompt builder + targeting-ring math, and Scout score fusion / vision JSON parsing). `npm test` runs typecheck, unit tests, and the source-integrity unit tests. Fieldwork CI discovers and builds this package through `apps.json`. + +## Smoke test + +`npm run smoke` drives each journey in a headless browser and asserts the map canvas paints. It needs a running preview server and a Chromium binary: + +```bash +npm run preview & # serves the built app on :8080 +ALLOW_LIVE_MAPS_BROWSER=1 CHROMIUM_PATH=/path/to/chromium npm run smoke +``` + +Override the target with `SMOKE_URL` (defaults to `http://localhost:8080`). Screenshots are written to `/tmp/atlas-journey-smoke` unless `SMOKE_OUT` is set. The explicit `ALLOW_LIVE_MAPS_BROWSER=1` acknowledgement is required because the smoke mounts the configured Maps JavaScript renderer and may incur Google Maps Platform costs. + +## Security notes + +Keep `GMP_SERVER_KEY` and hosted `GEMINI_KEY` server-side only (`.env` locally, the Fieldwork gateway in production) — never commit them. A user-entered Gemini key is deliberately tab-scoped and never persisted, but any same-origin JavaScript can access in-memory credentials; use BYOK only on a deployment you trust. Restrict `VITE_GMP_API_KEY` by HTTP referrer and to the specific Maps APIs it needs, since it ships in the client bundle. See [SECURITY.md](SECURITY.md) for reporting and key-handling expectations. + +## Contributing & license + +Contributions are welcome — see [CONTRIBUTING.md](CONTRIBUTING.md) for the lightweight workflow. Released under the [MIT License](LICENSE). + +Atlas is maintained by [Ryan Baumann](https://github.com/ryanbaumann) as a personal project. If it helped you build something with maps + agents, a ⭐ helps others find it. diff --git a/demos/real-world-reasoning-agent/SECURITY.md b/demos/real-world-reasoning-agent/SECURITY.md new file mode 100644 index 0000000..dd1a7a1 --- /dev/null +++ b/demos/real-world-reasoning-agent/SECURITY.md @@ -0,0 +1,12 @@ +# Security + +Please do not open public issues for vulnerabilities or exposed credentials. Use [Fieldwork's private vulnerability reporting](https://github.com/ryanbaumann/fieldwork/security/advisories/new) to reach the maintainer. + +Atlas expects only the restricted browser Maps key to reach client code. +Fieldwork keeps `GMP_SERVER_API_KEY`, optional `GMP_MCP_KEY`, and +`GEMINI_API_KEY` in Cloud Run through Secret Manager. + +The optional in-app Gemini BYOK flow is explicitly tab-scoped: the raw key is held only in module memory, never local/session storage, URLs, app state, transcripts, telemetry, or logs. It is sent to the same-origin `/ai` proxy in `X-Atlas-Gemini-Key`; the proxy removes that private header and replaces it with the standard Gemini auth header only after pinning and allowlisting the upstream request. Because same-origin JavaScript can access any in-memory credential, users should connect personal keys only on deployments they trust and disconnect or reload when finished. + +The complete production assumptions, key restrictions, CSP, quotas, and tests +are in [`docs/LABS_SECURITY.md`](../../docs/LABS_SECURITY.md). diff --git a/demos/real-world-reasoning-agent/docs/FUTURE_WORK.md b/demos/real-world-reasoning-agent/docs/FUTURE_WORK.md new file mode 100644 index 0000000..8ea9c0d --- /dev/null +++ b/demos/real-world-reasoning-agent/docs/FUTURE_WORK.md @@ -0,0 +1,85 @@ +# Atlas GenUI — Future Work + +Follow-ups identified in the end-to-end self-review of the A2UI generative-UI + +Ad Studio + Scout release (PR #3). None of these block that PR; they are the +next round of hardening and polish. Roughly ordered by value. + +## 1. Keyed end-to-end verification (highest value) + +The build sandbox has no live Google Maps Platform / Gemini keys, so only +key-free paths were exercised (typecheck, 72 unit tests, production build). Before +a production deploy, run with real keys: + +- Ad Studio: full campaign — resolve a real business → `gather_campaign_facts` + → `generate_ad_creatives` (confirm photo-conditioned image gen and the + text-only fallback) → `set_geo_targeting` ring → export surface. +- Scout: `scout_area` → inspect 2+ candidates (confirm multi-heading Street View + frames actually load and vision returns parseable JSON) → `score_candidates` + → `show_evidence` surface. +- `npm run preview` + `ALLOW_LIVE_MAPS_BROWSER=1 CHROMIUM_PATH=… SMOKE_OUT=docs/screens npm run smoke` + for all six journeys when intentionally regenerating the committed screenshots. + +## 2. A2UI renderer parity & streaming (longer-term) + +- Parity tests currently assert **envelope-level** conformance against + `@a2ui/web_core`; component-prop parity is best-effort. If/when the toolkit + exposes per-component schemas, tighten the tests. +- Surfaces render only after the `render_surface` tool completes. Incremental / + streaming surface updates would make long campaign/scout outputs feel live. +- Evaluate swapping in the official `@googlemaps/a2ui` renderer behind a flag + once its React 19 story is settled. + +## 3. Real-time voice (Live API) + +Voice input ships as **tap-to-talk**: record → transcribe (`MODELS.stt`, MINIMAL +thinking) → fill the composer. + +**Streaming TTS shipped** — `src/ai/tts.ts` uses `generateContentStream` and +schedules PCM segments on a gapless cursor as they arrive. Measured against +`gemini-3.1-flash-tts-preview` for ~10s of narration: time-to-first-audio fell +from **9.8s (whole-clip) to 1.2s (first streamed frame)**. Remaining follow-up: + +- **Live API bidirectional voice.** Real-time speech-in/speech-out (and + [live translation](https://ai.google.dev/gemini-api/docs/live-api/live-translate), + `gemini-3.5-live-translate-preview`) uses a **WebSocket** transport + (`wss://…BidiGenerateContent`) with 16 kHz PCM input / 24 kHz PCM output and + **ephemeral tokens** for browser clients — not the REST `generateContent` the + `/ai` proxy speaks today. Shipping it needs a WebSocket proxy + ephemeral-token + minting route in `server/index.mjs`; it does not fit the current REST seam. + +## 4. Demo affordances + +- Consider persisting the last campaign/scout run to a shareable deep link. + +## Completed + +- **Video generation with Gemini omni** (flag-gated, `VITE_VIDEO_GEN_ENABLED`): + image→video via the `@google/genai` Interactions API (`src/ai/video.ts`, now the + typed `ai.interactions.create` call) is wired into two surfaces — Cinema + "Generate a video of this tour" (seeds omni with the current stop's Street View + still, plays inline in the panel) and Scout "Walkthrough video of the winning + site" (seeds with the top candidate's Street View frame, renders a deterministic + A2UI surface via the new `Video` catalog component). The `/ai` proxy forwards the + Interactions endpoint with the model allowlist enforced from the request body and + a longer `VIDEO_UPSTREAM_TIMEOUT_MS`. Still off by default and requires + `gemini-omni-flash-preview` in `GENAI_EXTRA_MODELS`. **Not yet keyed-verified** + end-to-end (no live omni access in the sandbox) — belongs in the §1 keyed pass. +- Scout is now an explicit **site-selection** journey: inspection grounds each + candidate in Street View **plus an overhead satellite frame**, and a + `compare_sites` decision matrix ranks sites and recommends a winner. +- Second UI/UX pass: tool-call popovers dismiss cleanly (click-away/Escape), + tall A2UI surfaces scroll, heading/caption markdown no longer leaks raw + tokens, ad-preview images show in full with clamped overlays, cinema audio no + longer bleeds across stops, and the postcard/itinerary scroll chains are fixed. +- Ad Studio generation UX: prefetched conditioning image, per-tile staged + progress + elapsed timer + fallback indicator, error-tile retry, and a + non-blocking copilot turn. +- End-to-end copy refresh framing Atlas as a real-world reasoning agent. + +- ChoicePicker `{selection}` interpolation now substitutes selected chip values + before `send_prompt`, with focused unit coverage. +- Vite now splits deck.gl, Gemini, Maps, and shared vendor chunks. +- Ad Studio routes Places photo conditioning through `/gmp/placephoto` and marks + generated creatives that fell back to text-only generation. +- Vitest now uses `test.projects` for separate `node` and `jsdom` projects. +- Ad Studio and Scout boards now show remaining session budget. diff --git a/demos/real-world-reasoning-agent/docs/GENUI.md b/demos/real-world-reasoning-agent/docs/GENUI.md new file mode 100644 index 0000000..c79c1d0 --- /dev/null +++ b/demos/real-world-reasoning-agent/docs/GENUI.md @@ -0,0 +1,134 @@ +# Generative UI (A2UI) in Atlas + +Atlas's copilot can render **interactive UI surfaces** in the chat dock, not just +text. It speaks the [Maps Agentic UI Toolkit](https://github.com/googlemaps/a2ui) +**A2UI v0.9** wire protocol: Gemini emits protocol messages through a +`render_surface` tool, and Atlas renders them with a **native React catalog** +styled in its own glass design system. + +Why native rather than the alpha Lit renderer? React 19 interop, theming, and +avoiding a second live map in the chat. To keep the wire format honest, the +official `@a2ui/web_core` zod schemas are a devDependency and +`src/genui/parity.test.ts` validates our envelopes against them in CI — so the +official toolkit renderer stays swappable later. + +- Catalog id: `atlas://maps-agentic-ui-catalog` (`ATLAS_CATALOG_ID`) +- Core code: `src/genui/` — `protocol.ts` (types + validator), `store.ts` + (surface state), `tool.ts` (`render_surface`), `catalog.tsx` + `components/` + (renderers), `SurfaceView.tsx`, `actions.ts`, `images.ts`. + +## Protocol + +A surface is built by an **array of messages**, each with `version: "v0.9"` and +exactly one of four operations: + +| Message | Shape | Effect | +|---|---|---| +| `createSurface` | `{ surfaceId, catalogId, theme?, sendDataModel? }` | Declares a new surface. | +| `updateComponents` | `{ surfaceId, components: ComponentNode[] }` | Upserts nodes by `id` (last-writer-wins). | +| `updateDataModel` | `{ surfaceId, path?, value? }` | Sets `value` at a JSON-pointer `path` (omit `path` to replace the whole model). | +| `deleteSurface` | `{ surfaceId }` | Removes the surface. | + +**Components are a flat list; children are referenced by `id`.** The root of a +surface is the component with `id: "root"`. To *update* a surface, re-send with +the **same `surfaceId`** rather than creating a new one. + +### Dynamic values (data binding) + +Any prop can be a literal, or a **binding** `{ "path": "/a/b" }` that reads from +the surface's data model. Arrays live in the data model and are rendered with a +`List` template. Inside a template, relative paths (`{ "path": "name" }`, no +leading `/`) resolve within the current item's scope. (The A2UI function-call +form `{call,args}` is intentionally unsupported in Atlas v1.) + +Resolution lives in `resolveDynamic()` / `getAtPath()` (`src/genui/protocol.ts`). + +## Catalog + +Component names are whitelisted by `CATALOG_COMPONENT_NAMES`; an unknown +component renders a subtle "unsupported" chip rather than crashing. + +The catalog below is the **Atlas A2UI v0.9 subset** — a documented subset of the +Maps Agentic UI Toolkit basic catalog (not the full basic-catalog semantics). +The lower group is the journey-proven additions (Scout / Ad Studio / Fleet / +Concierge / Insight / Cinema), each with a golden fixture in `__fixtures__/` and +a render test in `catalogSubset.test.tsx`. + +| Component | Key props | +|---|---| +| `Column` / `Row` | `children: string[]`, `align?`, `gap?` | +| `Card` | `child: string` | +| `Text` | `text: Dynamic`, `variant?: h1..h5 \| body \| caption` | +| `Image` | `url: Dynamic`, `alt?`, `fit?: cover \| contain`, `aspect?` — only `https:`, `data:image/`, and same-origin `/gmp/…` allowed | +| `Button` | `child: string` (a component id), `variant?`, `action: { event: { name, context? } }` | +| `List` | `children: string[]` **or** template `{ componentId, path }`, `direction?: vertical \| horizontal` | +| `Divider` | — | +| `ChoicePicker` | `options: [{label,value}]`, `selection?: {path}`, `multi?`, `action?` | +| `StatGrid` | `items: Dynamic<[{ label, value, hint? }]>` | +| `PlaceCard` | `placeId: Dynamic` — fetches live Places details; shows Google data unaltered | +| `MapPreview` | `lat`, `lng`, `zoom?`, `markers?: [{lat,lng,color?,label?}]`, `label?` — a static-map thumbnail, click flies the camera | +| `AdCreative` | `imageRef: Dynamic`, `headline`, `body?`, `cta?`, `badge?` — always renders an "AI-generated" badge | +| `ProgressStatus` | `label`, `state?: running \| done \| error \| pending`, `detail?`, `step?`, `total?` — streaming step indicator (`role="status"`) | +| `RecoverableError` | `message`, `retryLabel?`, `action?` — error banner (`role="alert"`) with a Retry button; falls back to a `send_prompt` retry | +| `EvidenceSource` | `label`, `provenance?: observed \| computed \| inferred \| generated`, `url?`, `confidence?: 0..1` — grounded-source chip | +| `RouteItinerary` | `steps: Dynamic<[{ instruction, distance?, duration? }]>` — ordered turn-by-turn list | +| `EtaSummary` | `duration`, `distance?`, `mode?: walk \| drive \| transit \| bike` — compact ETA row | +| `ComparisonTable` | `columns: Dynamic`, `rows: Dynamic<[{ label, values: string[], highlight? }]>`, `caption?` — decision matrix `` | +| `ConfirmationResult` | `title`, `status?: success \| error`, `detail?`, `action?` — end-of-journey confirmation (`role="status"`) | + +## Actions + +A `Button`/`ChoicePicker` action fires `{ name, surfaceId, sourceComponentId, +timestamp, context }` into `dispatchSurfaceAction` (`src/genui/actions.ts`). +Built-in names are handled locally; **any other name is forwarded to the copilot +as a structured user turn**, so the model can define its own action names. + +| Action | Context | Effect | +|---|---|---| +| `fly_to` | `{ lat, lng, zoom? }` | Flies the map camera. | +| `select_place` | `{ placeId }` | Selects + flies to a place. | +| `send_prompt` | `{ prompt }` | Sends a follow-up user turn to the copilot. | +| `open_url` | `{ url }` | Opens an `https:` URL. | +| `download_image` | `{ dataUrl \| src, filename? }` | Downloads an image. | +| *(anything else)* | *(any)* | Forwarded to the copilot as `[ui-action] …`. | + +## Image registry + +Generated creatives and inspected Street View frames are large base64 data URLs. +`src/genui/images.ts` stores them and hands back a short `img:` reference; +data models and transcripts carry only the reference. `Image`/`AdCreative` +resolve refs back to data URLs via `getImage()` (non-refs pass through). The +registry is demo-session scoped (cleared when the tab closes). + +## Adding a component + +1. Add the name to `CATALOG_COMPONENT_NAMES` in `src/genui/protocol.ts`. +2. Add a renderer `FC<{ node, surface, scope? }>` in `src/genui/components/` and + register it in `src/genui/catalog.tsx`. Resolve every prop through + `resolveDynamic(node.prop, surface.dataModel, scope)`. +3. Document the props in the catalog table above and, if it should be part of the + parity guarantee, add a fixture under `src/genui/__fixtures__/`. + +## Worked example + +The canonical place-carousel fixture (validated in `parity.test.ts`): + +```json +[ + { "version": "v0.9", "createSurface": { "surfaceId": "espresso-1", "catalogId": "atlas://maps-agentic-ui-catalog" } }, + { "version": "v0.9", "updateComponents": { "surfaceId": "espresso-1", "components": [ + { "id": "root", "component": "Column", "children": ["title", "carousel", "chips"] }, + { "id": "title", "component": "Text", "variant": "h3", "text": "Top espresso near the Ferry Building" }, + { "id": "carousel", "component": "List", "direction": "horizontal", "children": { "componentId": "placeTpl", "path": "/places" } }, + { "id": "placeTpl", "component": "PlaceCard", "placeId": { "path": "placeId" } }, + { "id": "chips", "component": "ChoicePicker", + "options": [{ "label": "Open now", "value": "open" }, { "label": "Walkable", "value": "walk" }], + "action": { "event": { "name": "send_prompt", "context": { "prompt": "filter the espresso list: {selection}" } } } } + ] } }, + { "version": "v0.9", "updateDataModel": { "surfaceId": "espresso-1", "path": "/places", + "value": [{ "placeId": "PLACE_ID_1" }, { "placeId": "PLACE_ID_2" }] } } +] +``` + +See `src/genui/__fixtures__/` for the Ad Studio campaign and Scout evidence +surfaces as well. diff --git a/demos/real-world-reasoning-agent/docs/grounding/README.md b/demos/real-world-reasoning-agent/docs/grounding/README.md new file mode 100644 index 0000000..e275d9e --- /dev/null +++ b/demos/real-world-reasoning-agent/docs/grounding/README.md @@ -0,0 +1,6 @@ +# Grounding references + +The previous verbatim Google documentation snapshots were removed from this repository. Use canonical Google documentation instead: + +- Google Maps Platform docs: https://developers.google.com/maps +- Gemini API docs: https://ai.google.dev/gemini-api/docs diff --git a/demos/real-world-reasoning-agent/docs/implementation/capability-runtime.md b/demos/real-world-reasoning-agent/docs/implementation/capability-runtime.md new file mode 100644 index 0000000..7fba48d --- /dev/null +++ b/demos/real-world-reasoning-agent/docs/implementation/capability-runtime.md @@ -0,0 +1,21 @@ +# Capability runtime boundary + +`src/capabilities/` separates serializable capability metadata and typed host effects from Gemini declarations and Atlas state mutation. + +The first migrated profile contains three local presentation capabilities: `fly_to`, `clear_map`, and `show_notice`. Their executors return data plus ordered effects and do not import Zustand, React, Gemini, browser APIs, or Maps SDK objects. `atlasAdapter.ts` is the compatibility edge that turns generated declarations back into the existing `ToolDefinition` shape and projects effects into the current store. + +Provider-backed tools remain legacy in PR4. PR3 marks direct Maps API facts `modelContext: denied`; Places, Routes, and Weather cannot move into a new model-facing capability until PR5 supplies a product-compliant grounding path with adjacent evidence. + +## Invariants + +- Model-emitted calls still execute sequentially in emission order. +- A new turn aborts the superseded turn before delayed effects can project. +- Capability manifests are JSON-safe, versioned, and include prerequisites, host/provider features, consent, approval, side-effect, cost, latency, idempotency, presenter, retry, summarizer, and eval metadata. +- Minimal profiles resolve prerequisites deterministically and reject missing IDs, duplicate registrations, duplicate model names, and cycles. +- `add_markers` remains legacy until coordinate provenance can be enforced rather than merely described. +- Local presentation effects are not automatically retried. +- Fixture replay rebuilds from a clean checkpoint. Append effects are intentionally per-invocation and are not deduplicated onto an already-projected snapshot. + +## Evaluation + +The deterministic trace dataset remains the independent grader. Before and after this slice, all three golden traces pass all five stable dimensions (mission completion, tool order, grounding, surface ownership, and final/UI consistency); each of the six negative fixtures continues to fail only its intended dimension. No paid remote evaluator was invoked. diff --git a/demos/real-world-reasoning-agent/docs/implementation/default-first-run-experience-review.md b/demos/real-world-reasoning-agent/docs/implementation/default-first-run-experience-review.md new file mode 100644 index 0000000..afa3a4d --- /dev/null +++ b/demos/real-world-reasoning-agent/docs/implementation/default-first-run-experience-review.md @@ -0,0 +1,242 @@ +# Default first-run experience review plan + +## Goal + +Make the default path from the landing page to the first terminal Atlas result +feel obvious, fast, trustworthy, and visually calm on desktop and mobile. Every +suggested landing prompt must produce an experience that matches its promise, +uses real grounded evidence in Live mode, fails honestly, and leaves one clear +next action. + +This plan covers the cold open, mission launch, streaming/progress state, map +updates, the first evidence surface, the concise agent answer, and the first +follow-up. It does not redesign all six recipe journeys. + +## Current baseline on `main` + +Baseline inspected at `dc6dd96`. + +- Focused landing/explorer/follow-up suite: 33 tests passed. +- `npm run typecheck`: passed. +- Landing DOM audit passed at 320x568, 390x844, 768x1024, 1440x900, and + 1920x1080: no overflow, off-screen controls, sub-44px targets, unlabeled + controls, or browser errors. +- The landing is structurally simple: one prompt, one primary action, three + examples, and optional geolocation. + +The checks above do not prove the Live journey. The current deterministic eval +uses fictional fixtures and exercises only the quiet-work-cafe prompt family. + +## Blocking findings to resolve before qualitative polish + +1. **Live readiness is asserted, not proven.** `Landing.tsx` always displays + "Live services are configured" and always starts Live, even though the + existing capability preflight can detect missing Maps, Grounding Lite, + offline, or degraded states. +2. **One suggestion violates the runtime contract.** "Find a nearby errand stop + with the shortest drive" still launches a fixed 15-minute `WALK` run. +3. **The headline over-promises the outcome.** The first explorer recommends a + place and checks weather; it does not "launch" anything or ask for approval. +4. **Quietness is not measured.** The runtime explicitly discloses that search + relevance is not a quietness score, but the default prompt leads with + "quiet-work cafe." +5. **Live candidate identity is too generic.** The Grounding Lite adapter labels + results "Grounded candidate 1/2/3," so the result cannot yet feel like a + confident real-place recommendation. +6. **Browser gates are stale.** `scripts/smoke.mjs`, + `scripts/mission-smoke.mjs`, and `scripts/uiux-audit.mjs` still target the + removed Sample CTA and old flagship mission controller. +7. **The latest engine behavior conflicts with the repo invariant.** Same-name + tool calls now use `Promise.all`, while `AGENTS.md` requires all copilot tool + calls to run sequentially in emission order. Restore the invariant before + using copilot traces as quality evidence. +8. **Small-screen text is legally visible but not excellent.** At 320px the + example chips and privacy copy render at 9px; the audit catches geometry, + not readability or hierarchy. +9. **Stored screenshots are stale.** `docs/screens/00-landing.png` shows the old + journey-card landing and cannot serve as current review evidence. + +## Product contract to lock first + +Use one honest first-run promise: + +> Describe the place you need. Atlas finds up to three real candidates, verifies +> the requested walk or drive limit, checks current weather when relevant, and +> recommends one with evidence. + +Unless the first journey is expanded to perform a real downstream action, +replace "launch" language with "find," "compare," or "choose." Do not add new +capabilities merely to preserve marketing copy. + +Each landing example must map to explicit typed inputs: place intent, travel +mode, travel-time limit, and whether weather is requested. Unsupported +qualities such as quietness must either gain a grounded signal or be removed +from the promise. + +## Review and implementation sequence + +### 1. Restore a trustworthy test harness + +- Update smoke and UI-audit selectors to the current landing and explorer. +- Keep a deterministic, network-intercepted browser path for CI; add a separate + opt-in Live path that never silently falls back to fixtures. +- Restore strictly sequential copilot tool execution and add a regression test + for consecutive same-name calls that mutate shared scenario state. +- Make the landing capability state truthful: checking, Live-ready, or an + actionable unavailable state. Never label fixtures or missing credentials as + Live. + +Gate: deterministic browser tests can launch, reach one terminal surface, and +capture current evidence without paid calls or credentials. + +### 2. Verify every suggested prompt against the capability contract + +Add cases for the default prompt and all three examples. For each case assert: + +- requested travel mode and limit reach the route provider; +- a real place name/identity from Google Maps data appears in the result; +- only route-verified candidates can win; +- weather/jacket advice appears only when current weather was verified; +- unsupported qualities are disclosed and never used as ranking facts; +- evidence, final answer, markers, and winner route agree; +- source attribution is adjacent and Google Maps attribution remains visible; +- empty, partial, denied-location, offline, and provider-error states give one + useful recovery action instead of a dead end. + +The smallest likely correction is to make the examples describe what the +runtime already proves. If product intent requires walk/drive parsing or a new +quality signal, implement that as a separate behavior change with its own eval. + +### 3. Simplify the operational shell for the first result + +Review the post-launch screen as a progressive disclosure sequence: + +1. map plus one compact "Atlas is working" state; +2. markers as soon as grounded candidates arrive; +3. one concise recommendation plus a compact evidence surface; +4. one primary counterfactual or follow-up. + +During the first run, hide or subordinate recipe navigation, developer chrome, +duplicate tool chips, sharing, and the context drawer unless they directly help +the current decision. Do not show the same conclusion in competing panels. + +Desktop gates: + +- the map remains the dominant visual surface; +- only one primary action is visually dominant at each stage; +- dock, drawer, header, and map controls never compete or overlap; +- terminal evidence is scannable without covering most of the map. + +Mobile gates at 320x568 and 390x844: + +- no horizontal overflow or clipped/floating controls; +- all touch targets are at least 44x44; +- body text is readable without 9px prompt chips or legal copy; +- the result behaves like one bounded sheet with an obvious collapse/expand + control and preserves useful map context; +- keyboard, focus order, reduced motion, safe areas, and Maps attribution pass. + +### 4. Run the Agent Quality Flywheel + +Expand `eval/explorer/cases.json` from the current fixture-heavy five cases to a +versioned dataset covering the four landing prompts plus constraint mismatch, +no results, missing routes, missing weather, denied location, offline, and +provider failure. + +Keep the evaluator independent from the prompt/code change. Add stable custom +measures for: + +- promise fidelity; +- requested travel-mode fidelity; +- grounded place specificity; +- source integrity and unsupported-claim rate; +- UI/final/map consistency; +- terminal usefulness and recovery; +- time to first map evidence and time to useful terminal result. + +Require 100% on deterministic contract/safety gates and no regression in the +existing dimensions. Use before/after deltas, not an absolute vibe score. +Remote inference, AutoRaters, and paid Live evaluation require explicit human +approval before execution. + +### 5. Perform the Live acceptance pass + +After deterministic gates are green, run the default and all three examples +against configured Live services in the default city. Run one consented +device-location case separately. Capture sanitized traces and screenshots at +1440x900 and 390x844 without storing raw prompts, precise user location, Maps +content, credentials, or provider payloads in telemetry. + +Live pass criteria: + +- all four prompts reach an honest terminal result or a clear recoverable + provider state; +- the first grounded map evidence appears within 3 seconds after the place + search returns, with a provisional end-to-end target of 25 seconds; +- place identity, route mode/time, weather, recommendation, sources, and map + agree; +- no raw A2UI tokens, internal tool names, stale spinners, duplicate surfaces, + or severe console errors appear; +- a human reviewer can state the recommendation, why it won, and the next + action after a five-second scan. + +### 6. Ship in small reviewable changes + +1. Harness, capability truthfulness, sequential-tool invariant, and current + screenshots. +2. Prompt/capability alignment and expanded deterministic eval dataset. +3. First-result shell simplification and responsive/accessibility polish. +4. Opt-in Live evidence pass and final before/after report. + +Each change includes its focused test, browser evidence, eval delta, and a +compliance review. Stop if a change improves one showcased prompt but regresses +another case or weakens grounding. + +## Google Maps / privacy / cost guardrails + +- All names, locations, routes, conditions, and other place facts must come + from active Google Maps Platform or Grounding responses; never invent them. +- Preserve Google attribution and do not obscure map legal controls. +- Keep precise location opt-in, revocable, and scoped to the launched mission. +- Before Live release, confirm the billing region and intended audience, apply + EEA-specific terms where relevant, and exclude prohibited territories. +- Do not use Google Maps content for model training, telemetry, or persistent + diagnostic fixtures. +- Live calls may incur Google Maps Platform and Gemini costs. Use deterministic + fixtures for CI and require approval for paid remote evals or repeated Live + runs. A Maps Demo Key is available for supported zero-cost prototyping, but + is not for production. +- Treat Agentic UI Toolkit behavior as pre-GA unless current pinned guidance + proves otherwise. Re-fetch the pinned sources in `.agents/skill-sources.json` + before changing Maps, Gemini, eval, or A2UI behavior. + +## Definition of done + +The first experience is done only when the landing promise, all suggested +prompts, runtime behavior, agent answer, evidence surface, map, and recovery +states agree; deterministic gates are green; the approved Live pass is green; +desktop/mobile screenshots show one calm visual hierarchy; and an independent +reviewer signs off on clarity, grounding, and simplicity. + +## Execution status (2026-07-14) + +Completed deterministic implementation waves: + +- all copilot tool calls execute sequentially in model emission order; +- Live launch fails closed until Maps and Grounding Lite readiness is verified; +- suggested walk, drive, and optional-weather prompts map to deterministic + runtime intent, and unrequested weather calls are omitted; +- the explorer evaluator covers seven deterministic cases with stable prompt + fidelity and grounded-specificity dimensions (7/7 perfect candidate run); +- the first result uses focused shell chrome, retains a Home escape, fixes the + closed-drawer overflow class, and raises compact mobile text/touch targets; +- browser harnesses intercept Grounding Lite, route drawing, and Gemini, write + artifacts only to `/tmp`, and require an explicit cost acknowledgement + because the configured Maps JavaScript renderer remains live. + +The configured Grounding Lite/Gemini Live acceptance pass remains intentionally +unexecuted. It requires separate approval because it invokes external services +and can incur Google Maps Platform and Gemini costs. Browser screenshots used +the configured live Maps JavaScript renderer with deterministic app-provider +fixtures; they are not evidence that production Grounding Lite/Gemini +credentials, provider response shapes, or provider latency were verified. diff --git a/demos/real-world-reasoning-agent/docs/implementation/demo-reliability-hardening.md b/demos/real-world-reasoning-agent/docs/implementation/demo-reliability-hardening.md new file mode 100644 index 0000000..83c5036 --- /dev/null +++ b/demos/real-world-reasoning-agent/docs/implementation/demo-reliability-hardening.md @@ -0,0 +1,108 @@ +# Demo reliability hardening + +## Goal + +Make the Scout-to-Ad-Studio hero journey unambiguous, testable, and resilient; +harden the Atlas A2UI v0.9 subset before adding only journey-proven components; +and establish privacy-safe quality and diagnostic loops. + +## Delivery checklist + +### 1. Hero journey reliability + +- [x] Make one viewport-appropriate mission action authoritative; secondary + representations are status-only. +- [x] Ensure rail navigation closes or safely layers the Scout context drawer. +- [x] Show a brief, explicit approval-to-Ad-Studio transition. +- [x] Fix mobile overflow, stacking, and scenario-state transitions. +- [x] Update the mission smoke to the automatic campaign flow with uniquely + scoped locators. +- [x] Verify landing -> demo launch -> approve -> creative -> reveal at desktop + and 390 x 844, with no stale content or horizontal overflow. + +### 2. A2UI hardening + +- [x] Reject malformed catalog-node props with actionable validation errors. +- [x] Give PlaceCard, Image, Video, MapPreview, and AdCreative visible loading, + empty, error, and retry states. +- [x] Resolve and drop unresolved display tokens for every text and alt/label + prop. +- [x] Keep AdCreative's AI-generated disclosure non-overridable. +- [x] Add the Google Maps internal usage attribution ID to PlaceCard while + retaining Google attribution. +- [ ] Make exactly one layer responsible for Scout comparison surfaces. + (Deferred: needs an architectural decision on Scout vs. catalog ownership.) +- [x] Raise catalog button and chip targets to 44 x 44 on coarse pointers. +- [x] Surface catalog action failures through a visible toast or status event. + +### 3. Journey-proven catalog subset + +- [x] Add fixtures first, then the smallest reusable components required by + journey output: ProgressStatus, RecoverableError, EvidenceSource, + RouteItinerary/EtaSummary, ComparisonTable, and ConfirmationResult. +- [x] Add a golden hero fixture and browser render test for every journey, plus + loading, empty, error, and update fixtures. +- [x] Describe the implemented contract as the "Atlas A2UI v0.9 subset" until + full basic-catalog semantics exist. + +### 4. Deterministic quality gates + +- [x] Cover invalid keys, model stream failure/recovery states, tool ordering, + missing media, invalid A2UI, action failures, session/offline behavior, and + keyboard/coarse-pointer navigation. (Tool ordering and invalid A2UI were + already strong; this pass adds the zero-coverage gaps — server abuse gates + [429/daily-cap/cross-origin/missing-key], the stream retry/recovery loop, and + the >=44px coarse-pointer hit targets. Invalid keys, missing media, and action + failures retain their existing partial coverage.) +- [x] Create a trace dataset and independent evaluation of mission completion, + tool order, grounding, surface ownership, and UI/final-response consistency. + (`eval/traces.json` + `src/ai/traceEval.ts`, grader kept separate from the + optimizer; `fail.*` fixtures isolate each dimension.) +- [x] Require human approval before a paid remote evaluation and compare + before/after deltas for prompt or tool changes. (`scripts/eval-remote.ts` + gates the paid AutoRater behind `EVAL_REMOTE_APPROVED=1`; always runs the free + local eval and reports per-dimension deltas vs `.eval/baseline.json`.) + +### 5. Minimal privacy-safe telemetry and replay + +- [x] Remove raw prompts from query strings and prevent full-referrer forwarding. + (Proxy forwards ORIGIN-ONLY referer/origin upstream via `stripToOrigin`; the + app scrubs `prompt`/`mission` from the address bar on mount via + `scrubReplayParams`.) +- [x] Add bounded in-memory/session diagnostics with Export and Clear controls. + (Per-scenario telemetry stays bounded; Admin panel exports SANITIZED JSON and + `clearAllTelemetry` clears it.) +- [x] Emit only structured, sanitized server metadata and add a strict, + consent-gated browser metadata endpoint. (`POST /metadata` requires + `X-Atlas-Consent: 1`, strictly validates an allowlisted sanitized payload, logs + a content-free counter, and forwards nothing to third parties.) +- [x] Build a local telemetry report that validates, fingerprints, deduplicates, + clusters, and creates deterministic replay fixtures. (`src/diagnostics/ + telemetry.ts` + `scripts/telemetry-report.ts`; fails loudly on forbidden + content.) +- [x] Keep Maps content and personal data out of telemetry; do not use Maps + content for training. Gate raw diagnostic bundles on regional/EEA review. + (`sanitizeDiagnostic` drops all content; only structural metadata is exported; + raw/unsanitized bundles are not produced here — gated on regional review.) + +## Commit sequence + +1. This plan and draft PR. +2. Hero action ownership, drawer/rail behavior, transition, and mission smoke. +3. A2UI validation, resilient component states, display text, attribution, and + action feedback. +4. Journey fixtures and the minimal proven catalog additions. +5. Deterministic browser/evaluation matrix. +6. Privacy-safe diagnostics and replay tooling. + +Each implementation commit must include its focused verifier. Deferred work is +left unchecked so this PR remains a useful resume point if execution pauses. + +## Constraints + +- Preserve the sequential tool-call execution invariant in `src/ai/engine.ts`. +- Do not let suggestion prompts advertise tools unavailable to the scenario. +- Do not record raw prompts, transcripts, tool data, Maps content, identifiers, + locations, URLs, headers, cookies, or credentials. +- Treat Agentic UI Toolkit behavior as experimental/pre-GA and retain existing + browser/server key restrictions and quota controls. diff --git a/demos/real-world-reasoning-agent/docs/implementation/evidence/pr5-browser/01-explorer-ready.png b/demos/real-world-reasoning-agent/docs/implementation/evidence/pr5-browser/01-explorer-ready.png new file mode 100644 index 0000000..0c02825 Binary files /dev/null and b/demos/real-world-reasoning-agent/docs/implementation/evidence/pr5-browser/01-explorer-ready.png differ diff --git a/demos/real-world-reasoning-agent/docs/implementation/evidence/pr5-browser/02-explorer-drive-counterfactual.png b/demos/real-world-reasoning-agent/docs/implementation/evidence/pr5-browser/02-explorer-drive-counterfactual.png new file mode 100644 index 0000000..f1d6760 Binary files /dev/null and b/demos/real-world-reasoning-agent/docs/implementation/evidence/pr5-browser/02-explorer-drive-counterfactual.png differ diff --git a/demos/real-world-reasoning-agent/docs/implementation/evidence/pr5-browser/03-explorer-mobile.png b/demos/real-world-reasoning-agent/docs/implementation/evidence/pr5-browser/03-explorer-mobile.png new file mode 100644 index 0000000..1c4c03e Binary files /dev/null and b/demos/real-world-reasoning-agent/docs/implementation/evidence/pr5-browser/03-explorer-mobile.png differ diff --git a/demos/real-world-reasoning-agent/docs/implementation/one-agentic-interface-todo.md b/demos/real-world-reasoning-agent/docs/implementation/one-agentic-interface-todo.md new file mode 100644 index 0000000..9c6ac32 --- /dev/null +++ b/demos/real-world-reasoning-agent/docs/implementation/one-agentic-interface-todo.md @@ -0,0 +1,88 @@ +# One agentic interface — status + +Increments A–E are **implemented and landed**. The remaining work is verification: +the live browser harness does not currently pass against the new shell. + +Full plan: `~/.claude/plans/reactive-kindling-melody.md`. Background: +`docs/implementation/real-world-agent-foundation.md`. + +## Landed + +| Increment | Commit | What | +| --- | --- | --- | +| A — one session | `5d2dd28` | Five `Record` maps, six engines, and App.tsx's reconciliation effect collapse into one session. `activeScenario` selects prompt + tools only. Explorer stopped impersonating Scout and owns an `explorer` genUI scope. | +| B + C — canvas + camera | `b7fdbc5` | `AgentCanvas` replaces the six-strip dock and the competing `ContextDrawer` (deleted). Shell is a CSS grid, so the map owns a declared cell. `resolveCamera` is a pure reducer shared by 2D/3D; the DOM-measurement padding math is gone. `cameraOwner` suspends auto-camera after a user drag. | +| B/C fixes | `27e2336` | Defects found in a browser pass at five viewports: horizontal overflow from bare `1fr` tracks, header floating over the map, rail covering the mobile sheet, sheet anchored to a hardcoded composer height, BYOK control hidden on mobile. | +| D — actions | `070bc3e` | Typed action registry. Unregistered surface actions are refused instead of being forwarded to the agent as `[ui-action] …` prompt text. | +| D — capabilities | `d55134d` | `COMMON_TOOLS` resolves from the capability registry; `search_places` and friends return `{data, effects}` instead of mutating the store. `focus_place` reports `focusRequested` rather than claiming a focus it cannot verify. | +| D — genUI | `49b894d` | `NextActions` catalog component; the explorer presenter authors its own counterfactual instead of the shell hardcoding it. | +| E — recipes | `923803a` | `ExperienceManifest` records with no React/Zustand/SDK; `RecipePicker` in the composer replaces the deleted `ScenarioRail`. Canvas starters come from the manifest. | + +Green at the merge commit: `tsc --noEmit` clean, **558/558 unit tests across 69 +files**, `vite build` ok. + +## Deliberately not done + +**`render_surface` is still a model-facing tool with its full prompt guide.** The +plan called for demoting it to a developer escape hatch and dropping the A2UI +tutorial from every recipe prompt. It is load-bearing for three recipes today — +Scout renders comparison/evidence surfaces, Ad Studio renders the style picker +and creative carousel, Concierge renders place cards — all model-authored. That +demotion needs those three ported to deterministic presenters first (PR 9 +territory), so removing the guide now would simply break them. + +## Open: the live browser pass fails + +**The app itself is verified working.** Driving the flow manually against the +built app and the harness's own fixtures produces the correct terminal state: + +``` +Rank 1 · Grounded candidate 1 · 9 min · inside limit +``` + +with the evidence surface at 371x622 visible, place/route/weather attribution, +the jacket inference, limitations, and the new NextActions row ("Compare +driving", "Open in Google Maps"). The genUI surface, the single composer, the +single canvas, zero rail and zero drawer were all confirmed in a real browser at +320x568, 390x844, 768x1024, 1440x900 and 1920x1080 with zero horizontal +overflow. + +**`npm run mission-smoke` does not pass.** It fails waiting for that same text, +which the app demonstrably renders. Two harness bugs were found and are NOT yet +fixed in the repo (the exploratory fixes were reverted rather than landed +half-verified): + +1. **The landing assertion is inverted.** Line ~110 asserts + `/live/i.test(document.querySelector('.mission-capability').innerText())`. + The ready state renders `● Ready`; the only copy containing "live" is the + *failure* string `Live unavailable`. So that assertion could only ever pass + when Live was broken. The Live promise now lives on the CTA + ("Find with live evidence") — assert readiness there and on the chip's + `Ready`, not the literal word "live". +2. **The terminal-text wait fails for an unknown reason.** Raising it from 15s to + 45s did not help, so it is not latency. The same regex against + `document.body.innerText` matches in a hand-driven run of the identical flow, + so the defect is in how the harness observes the page, not in what the page + renders. Suspects not yet ruled out: the harness's Chromium flags + (`--use-gl=angle --use-angle=swiftshader`) versus a default launch, and a + `Cannot read properties of undefined (reading 'getRootNode')` page error seen + during 3D/deck.gl init. + +Start there, and start from the fact that the render is correct. + +Both harnesses are gated behind `ALLOW_LIVE_MAPS_BROWSER=1` because the Maps +JavaScript renderer is live: + +```bash +PORT=8099 npm start & +ALLOW_LIVE_MAPS_BROWSER=1 CHROMIUM_PATH=/usr/bin/google-chrome \ + SMOKE_URL=http://localhost:8099 npm run mission-smoke +ALLOW_LIVE_MAPS_BROWSER=1 CHROMIUM_PATH=/usr/bin/google-chrome \ + SMOKE_URL=http://localhost:8099 node scripts/uiux-audit.mjs +``` + +Note the browser key is HTTP-referrer restricted and rejects `localhost` +(`RefererNotAllowedMapError`), so local runs exercise the app without a working +map tile surface — and without billable Maps usage. + +`scripts/uiux-audit.mjs` has not been run against the new shell at all. diff --git a/demos/real-world-reasoning-agent/docs/implementation/universal-explorer.md b/demos/real-world-reasoning-agent/docs/implementation/universal-explorer.md new file mode 100644 index 0000000..e3f2fe4 --- /dev/null +++ b/demos/real-world-reasoning-agent/docs/implementation/universal-explorer.md @@ -0,0 +1,24 @@ +# Universal explorer vertical slice + +The first-run shell now launches one bounded decision: find a relevant place near the selected area, verify the hard travel-time limit, and check current weather. The Sample path uses fictional deterministic records; Live uses the three read-only Maps Grounding Lite MCP tools through a same-origin, body-capped, rate-limited server proxy with exact tool argument allowlists. Live additionally requires the explicit `GROUNDING_LITE_ENABLED=true` readiness opt-in. + +## Runtime boundary + +- `search_places` produces the session-scoped shortlist and the first marker/fit effects. +- `compute_routes` runs sequentially for at most three candidates. The deterministic ranker excludes unverified or over-limit routes and chooses the shortest verified trip; every view containing walking data says that WALK routes are beta and may sometimes lack clear sidewalks or pedestrian paths. +- `lookup_weather` runs only after a route-backed winner exists. Weather failure produces a partial decision and no jacket claim. Verified weather is followed by a visibly labeled deterministic Atlas jacket inference. +- The direct Routes provider is display-only: it draws the winner polyline but never enters Gemini context or the grounded narrative. +- A fixed `universal-explorer` A2UI surface owns progress, compact evidence, sources, limitations, and the typed Walk/Drive counterfactual. The model does not author this component graph. A separate concise Atlas answer is derived from the verified runtime state; Live may refine it with Gemini, while invalid, linked, ungrounded, or keyless output keeps the deterministic answer. +- Sample mode is a visibly fictional preview. It renders no clickable fixture provenance and never exposes repository links in the answer or evidence surface; the repository link remains only in the demo header. + +Grounding Lite records fail closed unless coordinates, required attribution, and the Google Maps place link are present. Attribution titles remain unmodified and untruncated, both the title and `Google Maps` are marked `translate="no"`, and both the required source URL and place link are available immediately beside the claim. + +The representative departure-time/rain counterfactual remains outside this slice. Grounding Lite requires location-local date/hour inputs for hourly Weather, while this minimal profile has no timezone capability; its route tool also does not expose the departure-time traffic semantics needed for an honest “leave 20 minutes later” comparison. This PR therefore ships a typed Walk/Drive shortlist rerun and does not fabricate a time-based answer. + +## Eval delta + +`eval/explorer/baseline.json` locks the PR4 no-runtime baseline and `eval/explorer/cases.json` is an executable five-case dataset (flagship, ambiguity, empty, partial Weather, and Drive counterfactual). `npm run eval:explorer` computes the before/after report: the PR5 candidate passes all five cases, with positive deltas across task completion, rendered source integrity, constraint fidelity, candidate differentiation, dependency order, surface ownership, UI/map consistency, explanation proxy, first-map-effect SLO, recovery, counterfactual coherence, and minimal capability profile. The deterministic trace p75 is 0 ms under its injected clock. The production-browser Sample smoke independently gates first attributed evidence at 25 seconds, updates the same surface for Drive, and captures desktop/mobile evidence in `docs/implementation/evidence/pr5-browser/`. + +The explanation score is an automated structural proxy, not proof of human comprehension. No five-person comprehension study was run. + +No paid remote evaluator or live Maps trace was used. Synthetic fixtures contain no captured Google Maps names, addresses, URLs, imagery, or user prompts. diff --git a/demos/real-world-reasoning-agent/docs/implementation/world-contract-boundary.md b/demos/real-world-reasoning-agent/docs/implementation/world-contract-boundary.md new file mode 100644 index 0000000..aef9ed8 --- /dev/null +++ b/demos/real-world-reasoning-agent/docs/implementation/world-contract-boundary.md @@ -0,0 +1,16 @@ +# World contract boundary + +`src/world/` is the provider-neutral boundary for Places, Routes, and environment facts. New consumers use the discriminated `ProviderResult` and must preserve its evidence alongside the value. The evidence records attribution, freshness, limitations, credential path, regional/product-term references, retention policy, and model-use policy for every outcome, including empty and failed calls. + +Direct Google Maps API content is marked `modelContext: denied`. A future model-facing adapter may use only a product path whose terms explicitly permit that use (for example, a supported grounding product), and must keep its sources adjacent to the designed output. `modelImprovement: denied` is invariant. + +## Temporary compatibility exception + +The existing `searchText`, `searchNearby`, `placeDetails`, `computeRoute`, `computeMatrix`, and `environmentSnapshot` exports intentionally unwrap normalized outcomes so the six current journeys do not change in PR3. Some existing AI tools already return those legacy values to Gemini. PR3 does not expand that behavior, but it also cannot enforce the new deny policy at those old call sites without violating its no-behavior-change scope. + +PR4 must migrate model-facing callers to normalized results before removing these adapters. Until then: + +- new model-facing code must not call a legacy value adapter; +- new provider work must implement a port in `src/world/ports.ts`; +- fixtures must contain synthetic data only; +- compatibility wrappers must preserve their previous success, empty, and failure behavior. diff --git a/demos/real-world-reasoning-agent/docs/screens/00-landing.png b/demos/real-world-reasoning-agent/docs/screens/00-landing.png new file mode 100644 index 0000000..098541d Binary files /dev/null and b/demos/real-world-reasoning-agent/docs/screens/00-landing.png differ diff --git a/demos/real-world-reasoning-agent/eval/README.md b/demos/real-world-reasoning-agent/eval/README.md new file mode 100644 index 0000000..db303b7 --- /dev/null +++ b/demos/real-world-reasoning-agent/eval/README.md @@ -0,0 +1,125 @@ +# Evaluation datasets + +This directory holds two datasets: + +1. **`missions.json`** — reranking ground truth for the deterministic reranker (`src/mission/rank.ts`), scored by `src/mission/evalScore.ts`. Documented below. +2. **`traces.json`** — agent-trace dataset for the independent, five-dimension journey evaluator (`src/ai/traceEval.ts`). See [Agent trace evaluation](#agent-trace-evaluation). + +## Agent trace evaluation + +`traces.json` scores whole journey turns on five deterministic, countable dimensions — **mission completion, tool order, grounding, surface ownership, and UI/final-response consistency** — so a prompt or tool change is judged by its per-dimension delta, not by eyeballing a few runs (see the Agent Quality Flywheel in `~/.claude/CLAUDE.md`). + +- The grader (`src/ai/traceEval.ts`) is kept **separate from the optimizer** (the prompts/tool code it scores) — a self-grader learns to game the metric. +- The dataset mixes `golden.*` traces (pass all five) with `fail.*` traces that each violate exactly one dimension, so the evaluator's per-dimension sensitivity is itself tested (`src/ai/traceEval.test.ts`). +- Traces are generic and synthetic. Do not add Google Maps Content captured from live runs to model optimization, testing, or validation datasets. Live canaries are limited to integration health and attribution checks. + +Commands: + +``` +npm run eval:trace # assert the evaluator + dataset (deterministic, offline) +npm run eval:report # print per-dimension counts + delta vs .eval/baseline.json +node_modules/.bin/vite-node scripts/eval-remote.ts --save-baseline # set/refresh the baseline +``` + +`scripts/eval-remote.ts` gates the **paid remote AutoRater** behind an explicit human approval env var — `EVAL_REMOTE_APPROVED=1` — so a routine eval can never silently spend. Without approval it runs only the free local eval and reports deltas; it never fabricates remote scores. Baseline/last-run artifacts land in `.eval/` (git-ignored). + +--- + +# Mission Candidate Reranking Evaluation + +This section covers the baseline evaluation dataset and scoring harness for the **deterministic local reranking logic** (`src/mission/rank.ts`). + +## What it measures + +**Top-1 accuracy**: given a set of mission candidates with pre-scored factors (visibility, condition, activity, access, environment) and a set of user priorities (weights for each factor), does the reranker correctly predict which candidate should rank #1? + +The reranker is deterministic — it computes a weighted score for each candidate using: + +``` +score = round((Σ factor[i] × priority[i]) / Σ priority[i] × 10) / 10 +``` + +and ranks candidates by descending score (ties broken by input order). + +## Dataset: `missions.json` + +- **50 cases** covering diverse scenarios across six cities (SF, NYC, London, Tokyo, Paris, Sydney) +- Each case includes: + - A natural-language `goal` + - A `cityId` + - User `priorities` (weights for the five factors) + - 3–5 `candidates` with pre-scored `factors` + - An `expectedWinnerId` — the candidate that should rank #1 given the priorities +- Cases are **internally consistent**: the `expectedWinnerId` is computed using the same weighted-score formula the reranker uses, so the baseline top-1 accuracy should be **100%** +- Coverage: + - Varied dominant factors (visibility-first, condition-first, access-first, environment-first, activity-first) + - Near-ties and clear winners + - Equal-priority cases (average score wins) + - Extreme single-factor dominance + - 3, 4, and 5-candidate sets + +## Scoring harness: `src/mission/evalScore.ts` + +- `predictWinner(case)` — runs the real production reranker and returns the ID of the #1-ranked candidate +- `scoreCase(case)` — compares predicted winner to expected winner +- `evaluate(cases)` — computes top-1 accuracy and lists failures + +## Running the evaluation + +```bash +npx vitest run src/mission/evalScore.test.ts +``` + +The test asserts: + +1. Dataset has 30–50 cases +2. Every case has 3–5 candidates, no duplicate IDs, and a valid `expectedWinnerId` +3. **Top-1 accuracy is 100%** (the dataset is self-consistent ground truth) + +If the baseline test fails, the dataset's `expectedWinnerId` values are inconsistent with the reranker's logic and should be fixed. + +## Baseline snapshot + +**Current baseline (pre-optimization)**: + +- Total cases: **50** +- Correct: **50** +- Top-1 accuracy: **1.0 (100%)** + +This is the reference point for any future changes to the reranking logic. + +## How to read the baseline + +- **100% accuracy** means the dataset is a valid, self-consistent ground truth for the current deterministic reranker +- This is **not** an evaluation of the *quality* of the reranker's decisions (e.g., "does it match human judgment?") — it's a test that the implementation matches its own spec +- Future work could: + - Add synthetic or independently licensed human-judgment cases and compare predicted rankings to those decisions + - Run an **LLM-as-a-judge eval** to score ranking quality (e.g., "does this ranking make sense given the goal?") + - Measure **ranking stability** when priorities are slightly perturbed + +## Extending the decision-quality dataset + +To move beyond the deterministic baseline without using Google Maps Content as evaluation data: + +1. Create synthetic or independently licensed cases that include: + - User goal + - City + - User priorities (slider state) + - Candidates with scored factors + - **User decision** (which candidate was approved) +2. Import cases into a clearly sourced decision-quality dataset +3. Compute **top-1 agreement**: does the reranker's #1 pick match the independent human decision? +4. Add a separate test suite for decision-quality evaluation +5. Use disagreements to debug the factor scoring logic (not just the reranker math) + +For **model-driven or prompt-driven components** (e.g., if factor scoring becomes LLM-based), apply the **Agent Quality Flywheel** (see global agent config `~/.claude/CLAUDE.md`) to eval-driven iteration. + +## Reference + +This eval baselines the **local reranking** logic only — it does NOT evaluate: + +- The **factor scoring** logic (visibility/condition/activity/access/environment are assumed pre-computed and correct) +- The **candidate generation** (Places search, map area selection) +- The **end-to-end mission flow** (user interaction, decision approval, artifact creation) + +Those are orthogonal surfaces for future eval expansion. diff --git a/demos/real-world-reasoning-agent/eval/explorer/baseline.json b/demos/real-world-reasoning-agent/eval/explorer/baseline.json new file mode 100644 index 0000000..7a7ab73 --- /dev/null +++ b/demos/real-world-reasoning-agent/eval/explorer/baseline.json @@ -0,0 +1,9 @@ +[ + { "caseId": "explorer.flagship.quiet-work", "dimensions": { "taskCompleted": "fail", "sourceIntegrity": "fail", "constraintFidelity": "fail", "promptFidelity": "fail", "groundedSpecificity": "fail", "candidateDifferentiation": "fail", "toolDependencyOrder": "fail", "surfaceOwnership": "fail", "uiFinalConsistency": "fail", "comprehensionProxy": "fail", "firstMapEffectWithinSlo": "fail", "recoveryComplete": "fail", "counterfactualCoherent": "na", "minimalCapabilityProfile": "fail" } }, + { "caseId": "explorer.flagship.shortest-drive", "dimensions": { "taskCompleted": "fail", "sourceIntegrity": "fail", "constraintFidelity": "fail", "promptFidelity": "fail", "groundedSpecificity": "fail", "candidateDifferentiation": "fail", "toolDependencyOrder": "fail", "surfaceOwnership": "fail", "uiFinalConsistency": "fail", "comprehensionProxy": "fail", "firstMapEffectWithinSlo": "fail", "recoveryComplete": "fail", "counterfactualCoherent": "na", "minimalCapabilityProfile": "fail" } }, + { "caseId": "explorer.flagship.lunch-jacket", "dimensions": { "taskCompleted": "fail", "sourceIntegrity": "fail", "constraintFidelity": "fail", "promptFidelity": "fail", "groundedSpecificity": "fail", "candidateDifferentiation": "fail", "toolDependencyOrder": "fail", "surfaceOwnership": "fail", "uiFinalConsistency": "fail", "comprehensionProxy": "fail", "firstMapEffectWithinSlo": "fail", "recoveryComplete": "fail", "counterfactualCoherent": "na", "minimalCapabilityProfile": "fail" } }, + { "caseId": "explorer.ambiguity.missing-intent", "dimensions": { "taskCompleted": "fail", "sourceIntegrity": "fail", "constraintFidelity": "fail", "promptFidelity": "fail", "groundedSpecificity": "na", "candidateDifferentiation": "na", "toolDependencyOrder": "fail", "surfaceOwnership": "fail", "uiFinalConsistency": "fail", "comprehensionProxy": "fail", "firstMapEffectWithinSlo": "na", "recoveryComplete": "fail", "counterfactualCoherent": "na", "minimalCapabilityProfile": "fail" } }, + { "caseId": "explorer.no-results", "dimensions": { "taskCompleted": "fail", "sourceIntegrity": "fail", "constraintFidelity": "fail", "promptFidelity": "fail", "groundedSpecificity": "na", "candidateDifferentiation": "na", "toolDependencyOrder": "fail", "surfaceOwnership": "fail", "uiFinalConsistency": "fail", "comprehensionProxy": "fail", "firstMapEffectWithinSlo": "fail", "recoveryComplete": "fail", "counterfactualCoherent": "na", "minimalCapabilityProfile": "fail" } }, + { "caseId": "explorer.partial.weather-unavailable", "dimensions": { "taskCompleted": "fail", "sourceIntegrity": "fail", "constraintFidelity": "fail", "promptFidelity": "fail", "groundedSpecificity": "fail", "candidateDifferentiation": "fail", "toolDependencyOrder": "fail", "surfaceOwnership": "fail", "uiFinalConsistency": "fail", "comprehensionProxy": "fail", "firstMapEffectWithinSlo": "fail", "recoveryComplete": "fail", "counterfactualCoherent": "na", "minimalCapabilityProfile": "fail" } }, + { "caseId": "explorer.counterfactual.drive", "dimensions": { "taskCompleted": "fail", "sourceIntegrity": "fail", "constraintFidelity": "fail", "promptFidelity": "fail", "groundedSpecificity": "fail", "candidateDifferentiation": "fail", "toolDependencyOrder": "fail", "surfaceOwnership": "fail", "uiFinalConsistency": "fail", "comprehensionProxy": "fail", "firstMapEffectWithinSlo": "fail", "recoveryComplete": "fail", "counterfactualCoherent": "fail", "minimalCapabilityProfile": "fail" } } +] diff --git a/demos/real-world-reasoning-agent/eval/explorer/cases.json b/demos/real-world-reasoning-agent/eval/explorer/cases.json new file mode 100644 index 0000000..718e862 --- /dev/null +++ b/demos/real-world-reasoning-agent/eval/explorer/cases.json @@ -0,0 +1,9 @@ +[ + { "id": "explorer.flagship.quiet-work", "scenario": "default", "query": "quiet work cafe within 15 minutes; tell me if I need a jacket", "expectedTerminal": "ready", "expectedWinnerId": "synthetic-candidate-a", "expectedTravelMode": "WALK", "expectedWeatherRequested": true, "expectedWeatherLookups": 1 }, + { "id": "explorer.flagship.shortest-drive", "scenario": "default", "query": "find a nearby errand stop with the shortest drive", "expectedTerminal": "ready", "expectedWinnerId": "synthetic-candidate-b", "expectedTravelMode": "DRIVE", "expectedWeatherRequested": false, "expectedWeatherLookups": 0 }, + { "id": "explorer.flagship.lunch-jacket", "scenario": "default", "query": "find a nearby lunch spot and tell me if I need a jacket", "expectedTerminal": "ready", "expectedWinnerId": "synthetic-candidate-a", "expectedTravelMode": "WALK", "expectedWeatherRequested": true, "expectedWeatherLookups": 1 }, + { "id": "explorer.ambiguity.missing-intent", "scenario": "default", "query": "ok", "expectedTerminal": "needs-clarification", "expectedTravelMode": "WALK", "expectedWeatherRequested": false, "expectedWeatherLookups": 0 }, + { "id": "explorer.no-results", "scenario": "no-results", "query": "quiet work cafe within 15 minutes", "expectedTerminal": "empty", "expectedTravelMode": "WALK", "expectedWeatherRequested": false, "expectedWeatherLookups": 0 }, + { "id": "explorer.partial.weather-unavailable", "scenario": "weather-unavailable", "query": "quiet work cafe within 15 minutes; check the weather", "expectedTerminal": "partial", "expectedWinnerId": "synthetic-candidate-a", "expectedTravelMode": "WALK", "expectedWeatherRequested": true, "expectedWeatherLookups": 1 }, + { "id": "explorer.counterfactual.drive", "scenario": "counterfactual-drive", "query": "quiet work cafe within 15 minutes", "expectedTerminal": "ready", "expectedWinnerId": "synthetic-candidate-b", "expectedTravelMode": "DRIVE", "expectedWeatherRequested": false, "expectedWeatherLookups": 0 } +] diff --git a/demos/real-world-reasoning-agent/eval/missions.json b/demos/real-world-reasoning-agent/eval/missions.json new file mode 100644 index 0000000..2e59662 --- /dev/null +++ b/demos/real-world-reasoning-agent/eval/missions.json @@ -0,0 +1,2501 @@ +[ + { + "id": "m01", + "goal": "Find a highly visible corner café in SF Financial District", + "cityId": "sf", + "priorities": { + "visibility": 95, + "condition": 50, + "activity": 40, + "access": 60, + "environment": 50 + }, + "candidates": [ + { + "id": "c1", + "label": "Embarcadero corner", + "factors": { + "visibility": 92, + "condition": 75, + "activity": 60, + "access": 80, + "environment": 70 + } + }, + { + "id": "c2", + "label": "Market St midblock", + "factors": { + "visibility": 78, + "condition": 85, + "activity": 70, + "access": 90, + "environment": 75 + } + }, + { + "id": "c3", + "label": "Battery St corner", + "factors": { + "visibility": 88, + "condition": 80, + "activity": 65, + "access": 85, + "environment": 72 + } + } + ], + "expectedWinnerId": "c3" + }, + { + "id": "m02", + "goal": "Find a pristine, well-maintained storefront in NYC SoHo", + "cityId": "nyc", + "priorities": { + "visibility": 50, + "condition": 90, + "activity": 45, + "access": 65, + "environment": 55 + }, + "candidates": [ + { + "id": "c1", + "label": "Broadway flagship", + "factors": { + "visibility": 85, + "condition": 92, + "activity": 80, + "access": 88, + "environment": 70 + } + }, + { + "id": "c2", + "label": "Greene St boutique", + "factors": { + "visibility": 75, + "condition": 78, + "activity": 60, + "access": 82, + "environment": 75 + } + }, + { + "id": "c3", + "label": "Spring St corner", + "factors": { + "visibility": 80, + "condition": 85, + "activity": 70, + "access": 85, + "environment": 72 + } + } + ], + "expectedWinnerId": "c1" + }, + { + "id": "m03", + "goal": "Find a quiet work café with excellent access in London Shoreditch", + "cityId": "london", + "priorities": { + "visibility": 60, + "condition": 55, + "activity": 30, + "access": 85, + "environment": 75 + }, + "candidates": [ + { + "id": "c1", + "label": "Old St station exit", + "factors": { + "visibility": 80, + "condition": 70, + "activity": 85, + "access": 95, + "environment": 60 + } + }, + { + "id": "c2", + "label": "Hoxton Square quiet corner", + "factors": { + "visibility": 65, + "condition": 75, + "activity": 40, + "access": 75, + "environment": 88 + } + }, + { + "id": "c3", + "label": "Curtain Rd corner", + "factors": { + "visibility": 78, + "condition": 72, + "activity": 55, + "access": 88, + "environment": 78 + } + } + ], + "expectedWinnerId": "c1" + }, + { + "id": "m04", + "goal": "Find a calm, low-activity spot with great air quality in Tokyo", + "cityId": "tokyo", + "priorities": { + "visibility": 50, + "condition": 60, + "activity": 25, + "access": 70, + "environment": 90 + }, + "candidates": [ + { + "id": "c1", + "label": "Yoyogi Park edge", + "factors": { + "visibility": 70, + "condition": 80, + "activity": 35, + "access": 75, + "environment": 94 + } + }, + { + "id": "c2", + "label": "Shibuya crossing", + "factors": { + "visibility": 95, + "condition": 75, + "activity": 98, + "access": 90, + "environment": 45 + } + }, + { + "id": "c3", + "label": "Harajuku side street", + "factors": { + "visibility": 65, + "condition": 78, + "activity": 50, + "access": 80, + "environment": 85 + } + } + ], + "expectedWinnerId": "c1" + }, + { + "id": "m05", + "goal": "Find a bustling, high-activity retail spot in Paris Le Marais", + "cityId": "paris", + "priorities": { + "visibility": 70, + "condition": 60, + "activity": 95, + "access": 75, + "environment": 50 + }, + "candidates": [ + { + "id": "c1", + "label": "Rue des Francs-Bourgeois corner", + "factors": { + "visibility": 82, + "condition": 75, + "activity": 92, + "access": 85, + "environment": 68 + } + }, + { + "id": "c2", + "label": "Rue de Rivoli arcade", + "factors": { + "visibility": 88, + "condition": 80, + "activity": 78, + "access": 90, + "environment": 65 + } + }, + { + "id": "c3", + "label": "Place des Vosges edge", + "factors": { + "visibility": 75, + "condition": 85, + "activity": 60, + "access": 70, + "environment": 82 + } + } + ], + "expectedWinnerId": "c1" + }, + { + "id": "m06", + "goal": "Find a balanced, all-around strong location in Sydney CBD", + "cityId": "sydney", + "priorities": { + "visibility": 70, + "condition": 70, + "activity": 70, + "access": 70, + "environment": 70 + }, + "candidates": [ + { + "id": "c1", + "label": "George St corner", + "factors": { + "visibility": 85, + "condition": 82, + "activity": 80, + "access": 88, + "environment": 78 + } + }, + { + "id": "c2", + "label": "Pitt St mall", + "factors": { + "visibility": 80, + "condition": 78, + "activity": 85, + "access": 85, + "environment": 75 + } + }, + { + "id": "c3", + "label": "York St midblock", + "factors": { + "visibility": 70, + "condition": 75, + "activity": 70, + "access": 80, + "environment": 80 + } + } + ], + "expectedWinnerId": "c1" + }, + { + "id": "m07", + "goal": "Near-tie case: visibility vs access in SF Mission", + "cityId": "sf", + "priorities": { + "visibility": 80, + "condition": 50, + "activity": 50, + "access": 85, + "environment": 60 + }, + "candidates": [ + { + "id": "c1", + "label": "Valencia St corner", + "factors": { + "visibility": 90, + "condition": 70, + "activity": 75, + "access": 82, + "environment": 68 + } + }, + { + "id": "c2", + "label": "16th St BART exit", + "factors": { + "visibility": 78, + "condition": 68, + "activity": 80, + "access": 92, + "environment": 65 + } + }, + { + "id": "c3", + "label": "24th St side", + "factors": { + "visibility": 72, + "condition": 75, + "activity": 60, + "access": 78, + "environment": 75 + } + } + ], + "expectedWinnerId": "c1" + }, + { + "id": "m08", + "goal": "Condition-dominant pick in NYC Upper East Side", + "cityId": "nyc", + "priorities": { + "visibility": 55, + "condition": 95, + "activity": 40, + "access": 70, + "environment": 60 + }, + "candidates": [ + { + "id": "c1", + "label": "Madison Ave pristine", + "factors": { + "visibility": 80, + "condition": 96, + "activity": 55, + "access": 85, + "environment": 75 + } + }, + { + "id": "c2", + "label": "Lexington Ave busy", + "factors": { + "visibility": 88, + "condition": 72, + "activity": 82, + "access": 90, + "environment": 68 + } + }, + { + "id": "c3", + "label": "Park Ave corner", + "factors": { + "visibility": 85, + "condition": 88, + "activity": 60, + "access": 82, + "environment": 78 + } + } + ], + "expectedWinnerId": "c1" + }, + { + "id": "m09", + "goal": "Environment-dominant green space café in London", + "cityId": "london", + "priorities": { + "visibility": 50, + "condition": 60, + "activity": 35, + "access": 65, + "environment": 95 + }, + "candidates": [ + { + "id": "c1", + "label": "Hyde Park edge", + "factors": { + "visibility": 65, + "condition": 78, + "activity": 45, + "access": 70, + "environment": 92 + } + }, + { + "id": "c2", + "label": "Regent St flagship", + "factors": { + "visibility": 92, + "condition": 85, + "activity": 88, + "access": 90, + "environment": 55 + } + }, + { + "id": "c3", + "label": "Notting Hill quiet lane", + "factors": { + "visibility": 60, + "condition": 72, + "activity": 40, + "access": 68, + "environment": 88 + } + } + ], + "expectedWinnerId": "c2" + }, + { + "id": "m10", + "goal": "Activity-first high-traffic retail in Tokyo Shibuya", + "cityId": "tokyo", + "priorities": { + "visibility": 65, + "condition": 55, + "activity": 95, + "access": 75, + "environment": 45 + }, + "candidates": [ + { + "id": "c1", + "label": "Shibuya Crossing corner", + "factors": { + "visibility": 95, + "condition": 70, + "activity": 98, + "access": 90, + "environment": 50 + } + }, + { + "id": "c2", + "label": "Center Gai entrance", + "factors": { + "visibility": 80, + "condition": 65, + "activity": 85, + "access": 85, + "environment": 55 + } + }, + { + "id": "c3", + "label": "Dogenzaka side", + "factors": { + "visibility": 72, + "condition": 75, + "activity": 75, + "access": 78, + "environment": 68 + } + } + ], + "expectedWinnerId": "c1" + }, + { + "id": "m11", + "goal": "Access-priority transit hub café in Paris", + "cityId": "paris", + "priorities": { + "visibility": 60, + "condition": 55, + "activity": 50, + "access": 92, + "environment": 58 + }, + "candidates": [ + { + "id": "c1", + "label": "Châtelet Metro exit", + "factors": { + "visibility": 75, + "condition": 68, + "activity": 85, + "access": 95, + "environment": 60 + } + }, + { + "id": "c2", + "label": "Saint-Michel plaza", + "factors": { + "visibility": 82, + "condition": 78, + "activity": 75, + "access": 80, + "environment": 75 + } + }, + { + "id": "c3", + "label": "Pont Neuf view", + "factors": { + "visibility": 70, + "condition": 75, + "activity": 60, + "access": 72, + "environment": 82 + } + } + ], + "expectedWinnerId": "c1" + }, + { + "id": "m12", + "goal": "Mixed priorities: moderate weights across all factors in Sydney", + "cityId": "sydney", + "priorities": { + "visibility": 65, + "condition": 68, + "activity": 62, + "access": 70, + "environment": 66 + }, + "candidates": [ + { + "id": "c1", + "label": "Circular Quay corner", + "factors": { + "visibility": 85, + "condition": 80, + "activity": 78, + "access": 88, + "environment": 82 + } + }, + { + "id": "c2", + "label": "The Rocks heritage", + "factors": { + "visibility": 78, + "condition": 92, + "activity": 65, + "access": 75, + "environment": 78 + } + }, + { + "id": "c3", + "label": "Darling Harbour walk", + "factors": { + "visibility": 72, + "condition": 75, + "activity": 70, + "access": 82, + "environment": 88 + } + } + ], + "expectedWinnerId": "c1" + }, + { + "id": "m13", + "goal": "Low visibility OK, environment and access matter in SF Presidio", + "cityId": "sf", + "priorities": { + "visibility": 40, + "condition": 60, + "activity": 35, + "access": 80, + "environment": 90 + }, + "candidates": [ + { + "id": "c1", + "label": "Presidio Tunnel Tops", + "factors": { + "visibility": 55, + "condition": 85, + "activity": 40, + "access": 75, + "environment": 95 + } + }, + { + "id": "c2", + "label": "Lombard St corner", + "factors": { + "visibility": 88, + "condition": 70, + "activity": 82, + "access": 78, + "environment": 60 + } + }, + { + "id": "c3", + "label": "Crissy Field edge", + "factors": { + "visibility": 60, + "condition": 78, + "activity": 35, + "access": 85, + "environment": 92 + } + } + ], + "expectedWinnerId": "c3" + }, + { + "id": "m14", + "goal": "Visibility and condition dominate in NYC Midtown", + "cityId": "nyc", + "priorities": { + "visibility": 88, + "condition": 85, + "activity": 50, + "access": 65, + "environment": 55 + }, + "candidates": [ + { + "id": "c1", + "label": "Fifth Ave flagship", + "factors": { + "visibility": 95, + "condition": 90, + "activity": 80, + "access": 85, + "environment": 65 + } + }, + { + "id": "c2", + "label": "Bryant Park edge", + "factors": { + "visibility": 78, + "condition": 82, + "activity": 60, + "access": 75, + "environment": 78 + } + }, + { + "id": "c3", + "label": "Sixth Ave midblock", + "factors": { + "visibility": 82, + "condition": 75, + "activity": 70, + "access": 80, + "environment": 70 + } + } + ], + "expectedWinnerId": "c1" + }, + { + "id": "m15", + "goal": "Three-way near-tie in London West End", + "cityId": "london", + "priorities": { + "visibility": 75, + "condition": 72, + "activity": 68, + "access": 78, + "environment": 70 + }, + "candidates": [ + { + "id": "c1", + "label": "Covent Garden corner", + "factors": { + "visibility": 88, + "condition": 80, + "activity": 85, + "access": 85, + "environment": 75 + } + }, + { + "id": "c2", + "label": "Leicester Square edge", + "factors": { + "visibility": 85, + "condition": 78, + "activity": 88, + "access": 82, + "environment": 72 + } + }, + { + "id": "c3", + "label": "Piccadilly arcade", + "factors": { + "visibility": 82, + "condition": 85, + "activity": 78, + "access": 88, + "environment": 78 + } + } + ], + "expectedWinnerId": "c1" + }, + { + "id": "m16", + "goal": "Low activity, high environment focus in Tokyo Meguro", + "cityId": "tokyo", + "priorities": { + "visibility": 55, + "condition": 65, + "activity": 30, + "access": 70, + "environment": 88 + }, + "candidates": [ + { + "id": "c1", + "label": "Meguro River walk", + "factors": { + "visibility": 68, + "condition": 80, + "activity": 35, + "access": 75, + "environment": 92 + } + }, + { + "id": "c2", + "label": "Nakameguro station", + "factors": { + "visibility": 82, + "condition": 72, + "activity": 78, + "access": 88, + "environment": 65 + } + }, + { + "id": "c3", + "label": "Daikanyama backstreet", + "factors": { + "visibility": 60, + "condition": 75, + "activity": 40, + "access": 68, + "environment": 85 + } + } + ], + "expectedWinnerId": "c2" + }, + { + "id": "m17", + "goal": "Balanced priorities, slightly favoring visibility in Paris", + "cityId": "paris", + "priorities": { + "visibility": 78, + "condition": 68, + "activity": 65, + "access": 72, + "environment": 70 + }, + "candidates": [ + { + "id": "c1", + "label": "Champs-Élysées corner", + "factors": { + "visibility": 92, + "condition": 85, + "activity": 88, + "access": 85, + "environment": 72 + } + }, + { + "id": "c2", + "label": "Arc de Triomphe plaza", + "factors": { + "visibility": 88, + "condition": 80, + "activity": 82, + "access": 78, + "environment": 68 + } + }, + { + "id": "c3", + "label": "Avenue Montaigne side", + "factors": { + "visibility": 78, + "condition": 88, + "activity": 70, + "access": 82, + "environment": 80 + } + } + ], + "expectedWinnerId": "c1" + }, + { + "id": "m18", + "goal": "Access and environment over visibility in Sydney Bondi", + "cityId": "sydney", + "priorities": { + "visibility": 50, + "condition": 60, + "activity": 45, + "access": 85, + "environment": 90 + }, + "candidates": [ + { + "id": "c1", + "label": "Campbell Parade beachfront", + "factors": { + "visibility": 85, + "condition": 75, + "activity": 88, + "access": 70, + "environment": 82 + } + }, + { + "id": "c2", + "label": "Hall St quiet corner", + "factors": { + "visibility": 60, + "condition": 78, + "activity": 50, + "access": 88, + "environment": 92 + } + }, + { + "id": "c3", + "label": "Roscoe St access", + "factors": { + "visibility": 65, + "condition": 72, + "activity": 55, + "access": 90, + "environment": 88 + } + } + ], + "expectedWinnerId": "c1" + }, + { + "id": "m19", + "goal": "High condition, moderate everything else in SF Pacific Heights", + "cityId": "sf", + "priorities": { + "visibility": 65, + "condition": 90, + "activity": 50, + "access": 70, + "environment": 68 + }, + "candidates": [ + { + "id": "c1", + "label": "Fillmore St boutique", + "factors": { + "visibility": 78, + "condition": 95, + "activity": 65, + "access": 80, + "environment": 75 + } + }, + { + "id": "c2", + "label": "Union St corner", + "factors": { + "visibility": 85, + "condition": 82, + "activity": 75, + "access": 85, + "environment": 78 + } + }, + { + "id": "c3", + "label": "Sacramento St classic", + "factors": { + "visibility": 70, + "condition": 88, + "activity": 55, + "access": 75, + "environment": 82 + } + } + ], + "expectedWinnerId": "c2" + }, + { + "id": "m20", + "goal": "Activity and access priority in NYC Lower East Side", + "cityId": "nyc", + "priorities": { + "visibility": 60, + "condition": 55, + "activity": 88, + "access": 85, + "environment": 58 + }, + "candidates": [ + { + "id": "c1", + "label": "Delancey St subway exit", + "factors": { + "visibility": 78, + "condition": 68, + "activity": 92, + "access": 95, + "environment": 65 + } + }, + { + "id": "c2", + "label": "Essex Market edge", + "factors": { + "visibility": 82, + "condition": 75, + "activity": 85, + "access": 82, + "environment": 70 + } + }, + { + "id": "c3", + "label": "Orchard St midblock", + "factors": { + "visibility": 70, + "condition": 78, + "activity": 80, + "access": 78, + "environment": 72 + } + } + ], + "expectedWinnerId": "c1" + }, + { + "id": "m21", + "goal": "Visibility vs environment near-tie in London Camden", + "cityId": "london", + "priorities": { + "visibility": 82, + "condition": 60, + "activity": 55, + "access": 68, + "environment": 80 + }, + "candidates": [ + { + "id": "c1", + "label": "Camden Lock corner", + "factors": { + "visibility": 88, + "condition": 72, + "activity": 85, + "access": 75, + "environment": 78 + } + }, + { + "id": "c2", + "label": "Regent's Canal walk", + "factors": { + "visibility": 70, + "condition": 75, + "activity": 50, + "access": 70, + "environment": 92 + } + }, + { + "id": "c3", + "label": "Chalk Farm Rd", + "factors": { + "visibility": 82, + "condition": 68, + "activity": 70, + "access": 72, + "environment": 82 + } + } + ], + "expectedWinnerId": "c1" + }, + { + "id": "m22", + "goal": "Condition and environment dominant in Tokyo Kichijoji", + "cityId": "tokyo", + "priorities": { + "visibility": 50, + "condition": 85, + "activity": 40, + "access": 65, + "environment": 92 + }, + "candidates": [ + { + "id": "c1", + "label": "Inokashira Park gate", + "factors": { + "visibility": 65, + "condition": 88, + "activity": 45, + "access": 72, + "environment": 95 + } + }, + { + "id": "c2", + "label": "Sun Road arcade", + "factors": { + "visibility": 82, + "condition": 75, + "activity": 80, + "access": 85, + "environment": 68 + } + }, + { + "id": "c3", + "label": "Kichijoji station plaza", + "factors": { + "visibility": 78, + "condition": 80, + "activity": 70, + "access": 90, + "environment": 75 + } + } + ], + "expectedWinnerId": "c3" + }, + { + "id": "m23", + "goal": "Visibility and activity high, others low in Paris Bastille", + "cityId": "paris", + "priorities": { + "visibility": 90, + "condition": 52, + "activity": 88, + "access": 60, + "environment": 55 + }, + "candidates": [ + { + "id": "c1", + "label": "Place de la Bastille corner", + "factors": { + "visibility": 95, + "condition": 70, + "activity": 92, + "access": 78, + "environment": 68 + } + }, + { + "id": "c2", + "label": "Rue de la Roquette mid", + "factors": { + "visibility": 78, + "condition": 75, + "activity": 80, + "access": 82, + "environment": 72 + } + }, + { + "id": "c3", + "label": "Faubourg St-Antoine", + "factors": { + "visibility": 82, + "condition": 68, + "activity": 85, + "access": 75, + "environment": 70 + } + } + ], + "expectedWinnerId": "c1" + }, + { + "id": "m24", + "goal": "Access first, environment second in Sydney Newtown", + "cityId": "sydney", + "priorities": { + "visibility": 55, + "condition": 58, + "activity": 50, + "access": 92, + "environment": 80 + }, + "candidates": [ + { + "id": "c1", + "label": "King St station exit", + "factors": { + "visibility": 75, + "condition": 70, + "activity": 75, + "access": 95, + "environment": 78 + } + }, + { + "id": "c2", + "label": "Enmore Rd corner", + "factors": { + "visibility": 70, + "condition": 68, + "activity": 68, + "access": 82, + "environment": 88 + } + }, + { + "id": "c3", + "label": "Wilson St quiet", + "factors": { + "visibility": 60, + "condition": 75, + "activity": 55, + "access": 78, + "environment": 90 + } + } + ], + "expectedWinnerId": "c1" + }, + { + "id": "m25", + "goal": "Four candidates, visibility priority in SF SOMA", + "cityId": "sf", + "priorities": { + "visibility": 88, + "condition": 62, + "activity": 58, + "access": 72, + "environment": 65 + }, + "candidates": [ + { + "id": "c1", + "label": "Market & 2nd corner", + "factors": { + "visibility": 92, + "condition": 75, + "activity": 78, + "access": 85, + "environment": 72 + } + }, + { + "id": "c2", + "label": "Folsom St industrial", + "factors": { + "visibility": 68, + "condition": 70, + "activity": 60, + "access": 75, + "environment": 78 + } + }, + { + "id": "c3", + "label": "Howard St midblock", + "factors": { + "visibility": 78, + "condition": 72, + "activity": 68, + "access": 78, + "environment": 70 + } + }, + { + "id": "c4", + "label": "Mission St transit", + "factors": { + "visibility": 85, + "condition": 68, + "activity": 82, + "access": 88, + "environment": 68 + } + } + ], + "expectedWinnerId": "c1" + }, + { + "id": "m26", + "goal": "Five candidates, balanced priorities in NYC Greenwich Village", + "cityId": "nyc", + "priorities": { + "visibility": 70, + "condition": 72, + "activity": 68, + "access": 75, + "environment": 73 + }, + "candidates": [ + { + "id": "c1", + "label": "Bleecker St corner", + "factors": { + "visibility": 85, + "condition": 82, + "activity": 78, + "access": 88, + "environment": 80 + } + }, + { + "id": "c2", + "label": "MacDougal St café row", + "factors": { + "visibility": 78, + "condition": 78, + "activity": 82, + "access": 82, + "environment": 78 + } + }, + { + "id": "c3", + "label": "Minetta Lane quiet", + "factors": { + "visibility": 65, + "condition": 85, + "activity": 55, + "access": 70, + "environment": 88 + } + }, + { + "id": "c4", + "label": "Sixth Ave storefront", + "factors": { + "visibility": 88, + "condition": 75, + "activity": 85, + "access": 85, + "environment": 72 + } + }, + { + "id": "c5", + "label": "Washington Sq corner", + "factors": { + "visibility": 82, + "condition": 80, + "activity": 75, + "access": 80, + "environment": 82 + } + } + ], + "expectedWinnerId": "c1" + }, + { + "id": "m27", + "goal": "Environment dominant, low activity in London Hampstead", + "cityId": "london", + "priorities": { + "visibility": 55, + "condition": 68, + "activity": 32, + "access": 65, + "environment": 95 + }, + "candidates": [ + { + "id": "c1", + "label": "Hampstead Heath edge", + "factors": { + "visibility": 68, + "condition": 82, + "activity": 35, + "access": 70, + "environment": 96 + } + }, + { + "id": "c2", + "label": "High St Hampstead", + "factors": { + "visibility": 85, + "condition": 78, + "activity": 75, + "access": 88, + "environment": 72 + } + }, + { + "id": "c3", + "label": "Flask Walk quiet", + "factors": { + "visibility": 60, + "condition": 75, + "activity": 40, + "access": 68, + "environment": 90 + } + }, + { + "id": "c4", + "label": "Pond St view", + "factors": { + "visibility": 65, + "condition": 80, + "activity": 38, + "access": 72, + "environment": 92 + } + } + ], + "expectedWinnerId": "c2" + }, + { + "id": "m28", + "goal": "Access and visibility tie-breaker in Tokyo Roppongi", + "cityId": "tokyo", + "priorities": { + "visibility": 82, + "condition": 60, + "activity": 65, + "access": 85, + "environment": 58 + }, + "candidates": [ + { + "id": "c1", + "label": "Roppongi Crossing corner", + "factors": { + "visibility": 92, + "condition": 72, + "activity": 88, + "access": 90, + "environment": 65 + } + }, + { + "id": "c2", + "label": "Tokyo Midtown entrance", + "factors": { + "visibility": 85, + "condition": 80, + "activity": 75, + "access": 88, + "environment": 75 + } + }, + { + "id": "c3", + "label": "Hinokicho Park edge", + "factors": { + "visibility": 70, + "condition": 75, + "activity": 55, + "access": 75, + "environment": 82 + } + } + ], + "expectedWinnerId": "c1" + }, + { + "id": "m29", + "goal": "Condition priority, low visibility OK in Paris Montmartre", + "cityId": "paris", + "priorities": { + "visibility": 48, + "condition": 92, + "activity": 52, + "access": 68, + "environment": 70 + }, + "candidates": [ + { + "id": "c1", + "label": "Sacré-Cœur steps", + "factors": { + "visibility": 78, + "condition": 88, + "activity": 85, + "access": 60, + "environment": 80 + } + }, + { + "id": "c2", + "label": "Rue Lepic heritage", + "factors": { + "visibility": 65, + "condition": 95, + "activity": 60, + "access": 75, + "environment": 78 + } + }, + { + "id": "c3", + "label": "Place du Tertre edge", + "factors": { + "visibility": 82, + "condition": 80, + "activity": 88, + "access": 72, + "environment": 75 + } + }, + { + "id": "c4", + "label": "Abbesses Metro exit", + "factors": { + "visibility": 72, + "condition": 85, + "activity": 70, + "access": 85, + "environment": 72 + } + } + ], + "expectedWinnerId": "c3" + }, + { + "id": "m30", + "goal": "Three candidates, moderate spread in Sydney Surry Hills", + "cityId": "sydney", + "priorities": { + "visibility": 72, + "condition": 68, + "activity": 65, + "access": 78, + "environment": 70 + }, + "candidates": [ + { + "id": "c1", + "label": "Crown St corner", + "factors": { + "visibility": 85, + "condition": 80, + "activity": 78, + "access": 88, + "environment": 78 + } + }, + { + "id": "c2", + "label": "Bourke St café strip", + "factors": { + "visibility": 78, + "condition": 82, + "activity": 82, + "access": 82, + "environment": 75 + } + }, + { + "id": "c3", + "label": "Riley St quiet", + "factors": { + "visibility": 68, + "condition": 75, + "activity": 65, + "access": 75, + "environment": 85 + } + } + ], + "expectedWinnerId": "c1" + }, + { + "id": "m31", + "goal": "Equal priorities, highest average wins in SF North Beach", + "cityId": "sf", + "priorities": { + "visibility": 70, + "condition": 70, + "activity": 70, + "access": 70, + "environment": 70 + }, + "candidates": [ + { + "id": "c1", + "label": "Columbus Ave corner", + "factors": { + "visibility": 88, + "condition": 85, + "activity": 82, + "access": 90, + "environment": 86 + } + }, + { + "id": "c2", + "label": "Grant Ave midblock", + "factors": { + "visibility": 80, + "condition": 78, + "activity": 85, + "access": 82, + "environment": 80 + } + }, + { + "id": "c3", + "label": "Stockton St edge", + "factors": { + "visibility": 75, + "condition": 82, + "activity": 78, + "access": 85, + "environment": 78 + } + } + ], + "expectedWinnerId": "c1" + }, + { + "id": "m32", + "goal": "Visibility low priority, access and environment high in NYC Brooklyn Heights", + "cityId": "nyc", + "priorities": { + "visibility": 45, + "condition": 62, + "activity": 50, + "access": 88, + "environment": 90 + }, + "candidates": [ + { + "id": "c1", + "label": "Clark St subway exit", + "factors": { + "visibility": 68, + "condition": 75, + "activity": 65, + "access": 92, + "environment": 88 + } + }, + { + "id": "c2", + "label": "Montague St corner", + "factors": { + "visibility": 82, + "condition": 80, + "activity": 78, + "access": 80, + "environment": 78 + } + }, + { + "id": "c3", + "label": "Promenade edge", + "factors": { + "visibility": 60, + "condition": 78, + "activity": 50, + "access": 75, + "environment": 95 + } + } + ], + "expectedWinnerId": "c1" + }, + { + "id": "m33", + "goal": "Activity and visibility high, others moderate in London Soho", + "cityId": "london", + "priorities": { + "visibility": 85, + "condition": 60, + "activity": 88, + "access": 68, + "environment": 58 + }, + "candidates": [ + { + "id": "c1", + "label": "Carnaby St corner", + "factors": { + "visibility": 92, + "condition": 72, + "activity": 95, + "access": 80, + "environment": 68 + } + }, + { + "id": "c2", + "label": "Wardour St midblock", + "factors": { + "visibility": 82, + "condition": 75, + "activity": 88, + "access": 82, + "environment": 70 + } + }, + { + "id": "c3", + "label": "Broadwick St edge", + "factors": { + "visibility": 85, + "condition": 68, + "activity": 82, + "access": 75, + "environment": 65 + } + }, + { + "id": "c4", + "label": "Berwick St market", + "factors": { + "visibility": 78, + "condition": 70, + "activity": 90, + "access": 78, + "environment": 72 + } + } + ], + "expectedWinnerId": "c1" + }, + { + "id": "m34", + "goal": "Condition and environment over visibility in Tokyo Shimokitazawa", + "cityId": "tokyo", + "priorities": { + "visibility": 52, + "condition": 82, + "activity": 48, + "access": 68, + "environment": 85 + }, + "candidates": [ + { + "id": "c1", + "label": "Shimokita station plaza", + "factors": { + "visibility": 78, + "condition": 70, + "activity": 82, + "access": 88, + "environment": 68 + } + }, + { + "id": "c2", + "label": "Minami-guchi quiet alley", + "factors": { + "visibility": 60, + "condition": 88, + "activity": 50, + "access": 72, + "environment": 92 + } + }, + { + "id": "c3", + "label": "Suzunari Theater corner", + "factors": { + "visibility": 68, + "condition": 80, + "activity": 65, + "access": 75, + "environment": 85 + } + } + ], + "expectedWinnerId": "c3" + }, + { + "id": "m35", + "goal": "High visibility and access, others lower in Paris Opéra", + "cityId": "paris", + "priorities": { + "visibility": 90, + "condition": 62, + "activity": 58, + "access": 88, + "environment": 60 + }, + "candidates": [ + { + "id": "c1", + "label": "Boulevard des Capucines corner", + "factors": { + "visibility": 95, + "condition": 80, + "activity": 78, + "access": 92, + "environment": 72 + } + }, + { + "id": "c2", + "label": "Rue de la Paix flagship", + "factors": { + "visibility": 92, + "condition": 88, + "activity": 75, + "access": 85, + "environment": 70 + } + }, + { + "id": "c3", + "label": "Place Vendôme edge", + "factors": { + "visibility": 88, + "condition": 85, + "activity": 65, + "access": 78, + "environment": 75 + } + } + ], + "expectedWinnerId": "c1" + }, + { + "id": "m36", + "goal": "Environment dominant, quiet and green in Sydney Balmain", + "cityId": "sydney", + "priorities": { + "visibility": 55, + "condition": 65, + "activity": 38, + "access": 70, + "environment": 92 + }, + "candidates": [ + { + "id": "c1", + "label": "Darling St main", + "factors": { + "visibility": 82, + "condition": 78, + "activity": 75, + "access": 85, + "environment": 70 + } + }, + { + "id": "c2", + "label": "Elliot St park edge", + "factors": { + "visibility": 65, + "condition": 80, + "activity": 42, + "access": 72, + "environment": 94 + } + }, + { + "id": "c3", + "label": "Mort Bay waterfront", + "factors": { + "visibility": 70, + "condition": 75, + "activity": 48, + "access": 78, + "environment": 90 + } + } + ], + "expectedWinnerId": "c1" + }, + { + "id": "m37", + "goal": "Access priority with high activity in SF Civic Center", + "cityId": "sf", + "priorities": { + "visibility": 68, + "condition": 58, + "activity": 78, + "access": 92, + "environment": 62 + }, + "candidates": [ + { + "id": "c1", + "label": "UN Plaza BART exit", + "factors": { + "visibility": 75, + "condition": 65, + "activity": 88, + "access": 95, + "environment": 68 + } + }, + { + "id": "c2", + "label": "Market St plaza", + "factors": { + "visibility": 85, + "condition": 72, + "activity": 85, + "access": 88, + "environment": 72 + } + }, + { + "id": "c3", + "label": "Hayes Valley edge", + "factors": { + "visibility": 78, + "condition": 80, + "activity": 70, + "access": 80, + "environment": 78 + } + } + ], + "expectedWinnerId": "c2" + }, + { + "id": "m38", + "goal": "Condition first, visibility second in NYC Chelsea", + "cityId": "nyc", + "priorities": { + "visibility": 78, + "condition": 92, + "activity": 55, + "access": 68, + "environment": 65 + }, + "candidates": [ + { + "id": "c1", + "label": "Eighth Ave corner", + "factors": { + "visibility": 85, + "condition": 95, + "activity": 72, + "access": 80, + "environment": 75 + } + }, + { + "id": "c2", + "label": "High Line entrance", + "factors": { + "visibility": 88, + "condition": 82, + "activity": 80, + "access": 75, + "environment": 82 + } + }, + { + "id": "c3", + "label": "Tenth Ave gallery row", + "factors": { + "visibility": 78, + "condition": 88, + "activity": 60, + "access": 72, + "environment": 78 + } + } + ], + "expectedWinnerId": "c1" + }, + { + "id": "m39", + "goal": "Visibility and environment balanced in London Canary Wharf", + "cityId": "london", + "priorities": { + "visibility": 82, + "condition": 68, + "activity": 62, + "access": 75, + "environment": 80 + }, + "candidates": [ + { + "id": "c1", + "label": "Canada Square corner", + "factors": { + "visibility": 92, + "condition": 85, + "activity": 78, + "access": 88, + "environment": 82 + } + }, + { + "id": "c2", + "label": "Cabot Square plaza", + "factors": { + "visibility": 85, + "condition": 78, + "activity": 70, + "access": 82, + "environment": 85 + } + }, + { + "id": "c3", + "label": "Jubilee Place mall", + "factors": { + "visibility": 78, + "condition": 82, + "activity": 82, + "access": 90, + "environment": 75 + } + } + ], + "expectedWinnerId": "c1" + }, + { + "id": "m40", + "goal": "Access and activity over visibility and condition in Tokyo Akihabara", + "cityId": "tokyo", + "priorities": { + "visibility": 58, + "condition": 55, + "activity": 90, + "access": 88, + "environment": 52 + }, + "candidates": [ + { + "id": "c1", + "label": "Chuo Dori main", + "factors": { + "visibility": 88, + "condition": 70, + "activity": 95, + "access": 92, + "environment": 60 + } + }, + { + "id": "c2", + "label": "Electric Town exit", + "factors": { + "visibility": 82, + "condition": 68, + "activity": 92, + "access": 90, + "environment": 58 + } + }, + { + "id": "c3", + "label": "Kanda Myojin approach", + "factors": { + "visibility": 70, + "condition": 75, + "activity": 65, + "access": 75, + "environment": 78 + } + } + ], + "expectedWinnerId": "c1" + }, + { + "id": "m41", + "goal": "Environment and condition, low activity in Paris Luxembourg", + "cityId": "paris", + "priorities": { + "visibility": 60, + "condition": 85, + "activity": 42, + "access": 68, + "environment": 88 + }, + "candidates": [ + { + "id": "c1", + "label": "Luxembourg Gardens gate", + "factors": { + "visibility": 72, + "condition": 90, + "activity": 48, + "access": 75, + "environment": 92 + } + }, + { + "id": "c2", + "label": "Rue de Vaugirard corner", + "factors": { + "visibility": 78, + "condition": 82, + "activity": 65, + "access": 82, + "environment": 80 + } + }, + { + "id": "c3", + "label": "Boulevard Saint-Michel", + "factors": { + "visibility": 85, + "condition": 78, + "activity": 78, + "access": 88, + "environment": 72 + } + } + ], + "expectedWinnerId": "c3" + }, + { + "id": "m42", + "goal": "All factors near-equal weights in Sydney CBD", + "cityId": "sydney", + "priorities": { + "visibility": 72, + "condition": 74, + "activity": 70, + "access": 76, + "environment": 73 + }, + "candidates": [ + { + "id": "c1", + "label": "Martin Place corner", + "factors": { + "visibility": 88, + "condition": 85, + "activity": 82, + "access": 90, + "environment": 84 + } + }, + { + "id": "c2", + "label": "Pitt St Mall entrance", + "factors": { + "visibility": 85, + "condition": 82, + "activity": 88, + "access": 88, + "environment": 80 + } + }, + { + "id": "c3", + "label": "Castlereagh St midblock", + "factors": { + "visibility": 78, + "condition": 88, + "activity": 75, + "access": 82, + "environment": 82 + } + } + ], + "expectedWinnerId": "c1" + }, + { + "id": "m43", + "goal": "Visibility extreme priority in SF Union Square", + "cityId": "sf", + "priorities": { + "visibility": 98, + "condition": 52, + "activity": 48, + "access": 65, + "environment": 55 + }, + "candidates": [ + { + "id": "c1", + "label": "Powell & Geary corner", + "factors": { + "visibility": 96, + "condition": 75, + "activity": 80, + "access": 85, + "environment": 70 + } + }, + { + "id": "c2", + "label": "Stockton St cable", + "factors": { + "visibility": 88, + "condition": 82, + "activity": 75, + "access": 88, + "environment": 75 + } + }, + { + "id": "c3", + "label": "Maiden Lane boutique", + "factors": { + "visibility": 78, + "condition": 90, + "activity": 65, + "access": 80, + "environment": 82 + } + } + ], + "expectedWinnerId": "c1" + }, + { + "id": "m44", + "goal": "Activity extreme priority in NYC Times Square", + "cityId": "nyc", + "priorities": { + "visibility": 68, + "condition": 55, + "activity": 98, + "access": 72, + "environment": 48 + }, + "candidates": [ + { + "id": "c1", + "label": "Broadway & 42nd corner", + "factors": { + "visibility": 95, + "condition": 70, + "activity": 98, + "access": 90, + "environment": 55 + } + }, + { + "id": "c2", + "label": "Seventh Ave plaza", + "factors": { + "visibility": 92, + "condition": 68, + "activity": 95, + "access": 88, + "environment": 58 + } + }, + { + "id": "c3", + "label": "Duffy Square edge", + "factors": { + "visibility": 88, + "condition": 72, + "activity": 92, + "access": 85, + "environment": 60 + } + } + ], + "expectedWinnerId": "c1" + }, + { + "id": "m45", + "goal": "Condition extreme priority in London Mayfair", + "cityId": "london", + "priorities": { + "visibility": 62, + "condition": 98, + "activity": 50, + "access": 68, + "environment": 65 + }, + "candidates": [ + { + "id": "c1", + "label": "Bond St flagship", + "factors": { + "visibility": 88, + "condition": 98, + "activity": 75, + "access": 85, + "environment": 78 + } + }, + { + "id": "c2", + "label": "Savile Row heritage", + "factors": { + "visibility": 78, + "condition": 95, + "activity": 65, + "access": 78, + "environment": 82 + } + }, + { + "id": "c3", + "label": "Berkeley Square edge", + "factors": { + "visibility": 82, + "condition": 92, + "activity": 70, + "access": 82, + "environment": 85 + } + } + ], + "expectedWinnerId": "c1" + }, + { + "id": "m46", + "goal": "Access extreme priority in Tokyo Shinjuku", + "cityId": "tokyo", + "priorities": { + "visibility": 60, + "condition": 58, + "activity": 62, + "access": 98, + "environment": 55 + }, + "candidates": [ + { + "id": "c1", + "label": "Shinjuku Station East Exit", + "factors": { + "visibility": 85, + "condition": 72, + "activity": 88, + "access": 98, + "environment": 65 + } + }, + { + "id": "c2", + "label": "Kabukicho main gate", + "factors": { + "visibility": 92, + "condition": 68, + "activity": 95, + "access": 90, + "environment": 58 + } + }, + { + "id": "c3", + "label": "Nishi-Shinjuku skyscraper", + "factors": { + "visibility": 78, + "condition": 82, + "activity": 70, + "access": 88, + "environment": 72 + } + } + ], + "expectedWinnerId": "c1" + }, + { + "id": "m47", + "goal": "Environment extreme priority in Paris Bois de Boulogne", + "cityId": "paris", + "priorities": { + "visibility": 52, + "condition": 62, + "activity": 38, + "access": 68, + "environment": 98 + }, + "candidates": [ + { + "id": "c1", + "label": "Lac Inférieur pavilion", + "factors": { + "visibility": 68, + "condition": 82, + "activity": 45, + "access": 72, + "environment": 98 + } + }, + { + "id": "c2", + "label": "Porte Maillot entrance", + "factors": { + "visibility": 85, + "condition": 75, + "activity": 78, + "access": 90, + "environment": 68 + } + }, + { + "id": "c3", + "label": "Jardin d'Acclimatation", + "factors": { + "visibility": 75, + "condition": 78, + "activity": 60, + "access": 80, + "environment": 92 + } + } + ], + "expectedWinnerId": "c3" + }, + { + "id": "m48", + "goal": "Five candidates, access and visibility co-dominant in Sydney", + "cityId": "sydney", + "priorities": { + "visibility": 85, + "condition": 62, + "activity": 58, + "access": 88, + "environment": 65 + }, + "candidates": [ + { + "id": "c1", + "label": "Wynyard Station corner", + "factors": { + "visibility": 92, + "condition": 75, + "activity": 78, + "access": 95, + "environment": 72 + } + }, + { + "id": "c2", + "label": "QVB entrance", + "factors": { + "visibility": 88, + "condition": 82, + "activity": 75, + "access": 90, + "environment": 75 + } + }, + { + "id": "c3", + "label": "Town Hall plaza", + "factors": { + "visibility": 85, + "condition": 78, + "activity": 82, + "access": 88, + "environment": 70 + } + }, + { + "id": "c4", + "label": "Hyde Park edge", + "factors": { + "visibility": 75, + "condition": 80, + "activity": 60, + "access": 78, + "environment": 88 + } + }, + { + "id": "c5", + "label": "Macquarie St heritage", + "factors": { + "visibility": 80, + "condition": 88, + "activity": 65, + "access": 82, + "environment": 80 + } + } + ], + "expectedWinnerId": "c1" + }, + { + "id": "m49", + "goal": "Tie-breaker by index: near-identical weighted scores in SF", + "cityId": "sf", + "priorities": { + "visibility": 75, + "condition": 75, + "activity": 75, + "access": 75, + "environment": 75 + }, + "candidates": [ + { + "id": "c1", + "label": "First candidate", + "factors": { + "visibility": 82, + "condition": 82, + "activity": 82, + "access": 82, + "environment": 82 + } + }, + { + "id": "c2", + "label": "Second candidate (identical)", + "factors": { + "visibility": 82, + "condition": 82, + "activity": 82, + "access": 82, + "environment": 82 + } + }, + { + "id": "c3", + "label": "Third candidate (lower)", + "factors": { + "visibility": 80, + "condition": 80, + "activity": 80, + "access": 80, + "environment": 80 + } + } + ], + "expectedWinnerId": "c1" + }, + { + "id": "m50", + "goal": "Complex five-factor spread in NYC", + "cityId": "nyc", + "priorities": { + "visibility": 65, + "condition": 78, + "activity": 58, + "access": 82, + "environment": 72 + }, + "candidates": [ + { + "id": "c1", + "label": "Grand Central corner", + "factors": { + "visibility": 88, + "condition": 90, + "activity": 85, + "access": 95, + "environment": 82 + } + }, + { + "id": "c2", + "label": "Lexington Ave midblock", + "factors": { + "visibility": 78, + "condition": 85, + "activity": 75, + "access": 88, + "environment": 78 + } + }, + { + "id": "c3", + "label": "Park Ave plaza", + "factors": { + "visibility": 85, + "condition": 88, + "activity": 80, + "access": 90, + "environment": 85 + } + }, + { + "id": "c4", + "label": "Madison Ave boutique", + "factors": { + "visibility": 82, + "condition": 92, + "activity": 70, + "access": 85, + "environment": 80 + } + } + ], + "expectedWinnerId": "c1" + } +] \ No newline at end of file diff --git a/demos/real-world-reasoning-agent/eval/traces.json b/demos/real-world-reasoning-agent/eval/traces.json new file mode 100644 index 0000000..66eb2d8 --- /dev/null +++ b/demos/real-world-reasoning-agent/eval/traces.json @@ -0,0 +1,119 @@ +{ + "$schema": "./traces.schema.md", + "description": "Independent agent-trace eval dataset (reliability plan §4). Generic synthetic traces bootstrap the flywheel without Google Maps Content; the golden.* cases pass all five dimensions and each fail.* case violates exactly ONE dimension so the evaluator's per-dimension sensitivity is testable. Scored by src/ai/traceEval.ts.", + "traces": [ + { + "id": "golden.scout.site-select", + "journey": "scout", + "userGoal": "Find the best synthetic candidate for a demo cafe in the sample district", + "toolCalls": ["scout_area", "score_candidates", "inspect_candidate", "show_evidence"], + "finalText": "Ranked #1: Demo candidate A, because the synthetic evidence fixture gave it the strongest supported visibility score.", + "surfaces": [ + { "type": "ComparisonTable", "logical": "comparison", "owner": "catalog" }, + { "type": "EvidenceSource", "logical": "evidence", "owner": "catalog" } + ], + "finalReferences": ["ComparisonTable", "EvidenceSource"], + "grounding": { "used": true, "invented": [], "required": true }, + "mission": { "expectedComplete": true, "status": "complete", "artifacts": ["scout-winner"] } + }, + { + "id": "golden.adstudio.campaign", + "journey": "adstudio", + "userGoal": "Build a campaign fixture for Demo candidate A", + "toolCalls": ["set_campaign_business", "gather_campaign_facts", "set_geo_targeting", "generate_ad_creatives", "export_campaign"], + "finalText": "Campaign fixture ready — three synthetic creatives use only the supplied demo evidence. Verify claims before publishing.", + "surfaces": [ + { "type": "StatGrid", "logical": "facts", "owner": "catalog" }, + { "type": "AdCreative", "logical": "creative", "owner": "catalog" }, + { "type": "MapPreview", "logical": "targeting", "owner": "catalog" } + ], + "finalReferences": ["AdCreative", "StatGrid", "MapPreview"], + "grounding": { "used": true, "invented": [], "required": true }, + "mission": { "expectedComplete": true, "status": "complete", "artifacts": ["campaign-creative-1"] } + }, + { + "id": "golden.concierge.postcard", + "journey": "concierge", + "userGoal": "Plan a day in SF and make me a postcard", + "toolCalls": ["plan_itinerary", "make_postcard"], + "finalText": "Here's the synthetic itinerary and postcard fixture using only the supplied demo stops.", + "surfaces": [ + { "type": "RouteItinerary", "logical": "itinerary", "owner": "catalog" }, + { "type": "Image", "logical": "postcard", "owner": "scenario" } + ], + "finalReferences": ["RouteItinerary", "Image"], + "grounding": { "used": true, "invented": [], "required": false }, + "mission": { "expectedComplete": false, "status": "none", "artifacts": [] } + }, + { + "id": "fail.toolorder.facts-before-business", + "journey": "adstudio", + "userGoal": "Make a campaign for that cafe", + "toolCalls": ["gather_campaign_facts", "set_campaign_business", "generate_ad_creatives"], + "finalText": "Here are your creatives, because the storefront photo grounded them.", + "surfaces": [{ "type": "AdCreative", "logical": "creative", "owner": "catalog" }], + "finalReferences": ["AdCreative"], + "grounding": { "used": true, "invented": [], "required": true }, + "mission": { "expectedComplete": true, "status": "complete", "artifacts": ["c1"] } + }, + { + "id": "fail.grounding.invented-rating", + "journey": "adstudio", + "userGoal": "Make a campaign for that cafe", + "toolCalls": ["set_campaign_business", "gather_campaign_facts", "generate_ad_creatives", "export_campaign"], + "finalText": "Voted #1 cafe in San Francisco with 2,000 daily visitors — creatives ready.", + "surfaces": [{ "type": "AdCreative", "logical": "creative", "owner": "catalog" }], + "finalReferences": ["AdCreative"], + "grounding": { "used": true, "invented": ["Voted #1 cafe in San Francisco", "2,000 daily visitors"], "required": true }, + "mission": { "expectedComplete": true, "status": "complete", "artifacts": ["c1"] } + }, + { + "id": "fail.ownership.double-owned-comparison", + "journey": "scout", + "userGoal": "Compare the three corners", + "toolCalls": ["scout_area", "score_candidates", "compare_sites", "show_evidence"], + "finalText": "Here is the comparison, because you asked to see them side by side.", + "surfaces": [ + { "type": "ComparisonTable", "logical": "comparison", "owner": "catalog" }, + { "type": "ScoutCompareBoard", "logical": "comparison", "owner": "scenario" }, + { "type": "EvidenceSource", "logical": "evidence", "owner": "catalog" } + ], + "finalReferences": ["ComparisonTable"], + "grounding": { "used": true, "invented": [], "required": true }, + "mission": { "expectedComplete": true, "status": "complete", "artifacts": ["scout-winner"] } + }, + { + "id": "fail.consistency.leaked-token", + "journey": "adstudio", + "userGoal": "Make a campaign", + "toolCalls": ["set_campaign_business", "gather_campaign_facts", "generate_ad_creatives", "export_campaign"], + "finalText": "Grab a coffee at {business.name} today — creatives ready.", + "surfaces": [{ "type": "AdCreative", "logical": "creative", "owner": "catalog" }], + "finalReferences": ["AdCreative"], + "grounding": { "used": true, "invented": [], "required": true }, + "mission": { "expectedComplete": true, "status": "complete", "artifacts": ["c1"] } + }, + { + "id": "fail.consistency.claims-absent-surface", + "journey": "adstudio", + "userGoal": "Make a campaign and show me the map targeting", + "toolCalls": ["set_campaign_business", "gather_campaign_facts", "generate_ad_creatives", "export_campaign"], + "finalText": "Your creatives and the targeting map are ready.", + "surfaces": [{ "type": "AdCreative", "logical": "creative", "owner": "catalog" }], + "finalReferences": ["AdCreative", "MapPreview"], + "grounding": { "used": true, "invented": [], "required": true }, + "mission": { "expectedComplete": true, "status": "complete", "artifacts": ["c1"] } + }, + { + "id": "fail.mission.partial-no-artifact", + "journey": "adstudio", + "userGoal": "Make a campaign for that cafe", + "toolCalls": ["set_campaign_business", "gather_campaign_facts", "generate_ad_creatives"], + "finalText": "I hit the tool-step limit before finishing, because the run stalled — resume to continue.", + "surfaces": [{ "type": "ProgressStatus", "logical": "progress", "owner": "catalog" }], + "finalReferences": ["ProgressStatus"], + "grounding": { "used": true, "invented": [], "required": true }, + "mission": { "expectedComplete": true, "status": "partial", "artifacts": [] } + } + ] +} diff --git a/demos/real-world-reasoning-agent/index.html b/demos/real-world-reasoning-agent/index.html new file mode 100644 index 0000000..66594e1 --- /dev/null +++ b/demos/real-world-reasoning-agent/index.html @@ -0,0 +1,25 @@ + + + + + + + + + + + + Atlas — A Real-World Reasoning Agent on Google Maps + + + ryanbaumann.dev +
+ + + diff --git a/demos/real-world-reasoning-agent/package-lock.json b/demos/real-world-reasoning-agent/package-lock.json new file mode 100644 index 0000000..84cb398 --- /dev/null +++ b/demos/real-world-reasoning-agent/package-lock.json @@ -0,0 +1,5203 @@ +{ + "name": "real-world-reasoning-agent", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "real-world-reasoning-agent", + "version": "1.0.0", + "license": "MIT", + "dependencies": { + "@deck.gl/aggregation-layers": "9.3.6", + "@deck.gl/core": "9.3.6", + "@deck.gl/geo-layers": "9.3.6", + "@deck.gl/google-maps": "9.3.6", + "@deck.gl/layers": "9.3.6", + "@fontsource-variable/inter": "5.2.8", + "@fontsource-variable/space-grotesk": "5.2.10", + "@google/genai": "2.10.0", + "@googlemaps/markerclusterer": "2.6.2", + "@vis.gl/react-google-maps": "1.9.0", + "lucide-react": "1.23.0", + "react": "19.2.7", + "react-dom": "19.2.7", + "terra-draw": "^1.31.2", + "terra-draw-google-maps-adapter": "^1.6.1", + "zustand": "5.0.14" + }, + "devDependencies": { + "@a2ui/web_core": "0.10.4", + "@types/google.maps": "3.65.2", + "@types/react": "19.2.17", + "@types/react-dom": "19.2.3", + "@vitejs/plugin-react": "5.2.0", + "jsdom": "25.0.1", + "playwright-core": "1.56.1", + "typescript": "5.8.3", + "vite": "7.3.6", + "vitest": "3.2.7" + }, + "engines": { + "node": ">=20.19" + } + }, + "node_modules/@a2ui/web_core": { + "version": "0.10.4", + "resolved": "https://registry.npmjs.org/@a2ui/web_core/-/web_core-0.10.4.tgz", + "integrity": "sha512-sahOUSKZIGv9ZtnHjfzWR8o/F1XfSTp4sQAX5/auSenQYBYDRaY0+vrZIkddc/IEzpXJob6cFJT2r5/mLzAvqg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@preact/signals-core": "^1.14.2", + "date-fns": "^4.4.0", + "zod": "^3.25.76", + "zod-to-json-schema": "^3.25.2" + } + }, + "node_modules/@asamuzakjp/css-color": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-3.2.0.tgz", + "integrity": "sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@csstools/css-calc": "^2.1.3", + "@csstools/css-color-parser": "^3.0.9", + "@csstools/css-parser-algorithms": "^3.0.4", + "@csstools/css-tokenizer": "^3.0.3", + "lru-cache": "^10.4.3" + } + }, + "node_modules/@asamuzakjp/css-color/node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/@babel/code-frame": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.29.7", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz", + "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz", + "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helpers": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.7.tgz", + "integrity": "sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz", + "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.29.7", + "@babel/helper-validator-option": "^7.29.7", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", + "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", + "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz", + "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7", + "@babel/traverse": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.29.7.tgz", + "integrity": "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz", + "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz", + "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz", + "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.7" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.29.7.tgz", + "integrity": "sha512-TL0hMc9xzy86VD31nUiwzd5otRAcyEPcsegCxolO0PvcXuH1v0kECe/UIznYFihpkvU5wg/jk4v0TTEFfm53fw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.29.7.tgz", + "integrity": "sha512-06IyK09H3wi4cGbhDBwp5gUGo0IKtnYa8tyTiephirPCK6fbobVGiXMMI5zLQ4aKEYP3wZ3ArU44o+8KMrSG/Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/template": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", + "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.7.tgz", + "integrity": "sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-globals": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz", + "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@csstools/color-helpers": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-5.1.0.tgz", + "integrity": "sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=18" + } + }, + "node_modules/@csstools/css-calc": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-2.1.4.tgz", + "integrity": "sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-3.1.0.tgz", + "integrity": "sha512-nbtKwh3a6xNVIp/VRuXV64yTKnb1IjTAEEh3irzS+HkKjAOYLTGNb9pmVNntZ8iVBHcWDA2Dof0QtPgFI1BaTA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^5.1.0", + "@csstools/css-calc": "^2.1.4" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-3.0.5.tgz", + "integrity": "sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-tokenizer": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-3.0.4.tgz", + "integrity": "sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@deck.gl/aggregation-layers": { + "version": "9.3.6", + "resolved": "https://registry.npmjs.org/@deck.gl/aggregation-layers/-/aggregation-layers-9.3.6.tgz", + "integrity": "sha512-shHIzD1fzPXYNqT5MvCW++3LMUA/odF25eWrjwQ6mRwPjwty9jUOEg5UoRYmNUN0fiE00vnc5kzMQVL7T/jDKA==", + "license": "MIT", + "dependencies": { + "@luma.gl/shadertools": "^9.3.3", + "@math.gl/core": "^4.1.0", + "@math.gl/web-mercator": "^4.1.0", + "d3-hexbin": "^0.2.1" + }, + "peerDependencies": { + "@deck.gl/core": "~9.3.0", + "@deck.gl/layers": "~9.3.0", + "@luma.gl/core": "~9.3.3", + "@luma.gl/engine": "~9.3.3" + } + }, + "node_modules/@deck.gl/core": { + "version": "9.3.6", + "resolved": "https://registry.npmjs.org/@deck.gl/core/-/core-9.3.6.tgz", + "integrity": "sha512-dCwF+ATE382DdbIqbaoHYCTAW7vutkVlQvWlmMebTDW1j7rPnKrT6ZPa0Y996U/8ZL5NNWc2/wWWY9LYhZVm2Q==", + "license": "MIT", + "dependencies": { + "@loaders.gl/core": "^4.4.3", + "@loaders.gl/images": "^4.4.3", + "@luma.gl/core": "^9.3.3", + "@luma.gl/engine": "^9.3.3", + "@luma.gl/shadertools": "^9.3.3", + "@luma.gl/webgl": "^9.3.3", + "@math.gl/core": "^4.1.0", + "@math.gl/sun": "^4.1.0", + "@math.gl/types": "^4.1.0", + "@math.gl/web-mercator": "^4.1.0", + "@probe.gl/env": "^4.1.1", + "@probe.gl/log": "^4.1.1", + "@probe.gl/stats": "^4.1.1", + "@types/offscreencanvas": "^2019.6.4", + "gl-matrix": "^3.0.0", + "mjolnir.js": "^3.0.0" + } + }, + "node_modules/@deck.gl/extensions": { + "version": "9.3.6", + "resolved": "https://registry.npmjs.org/@deck.gl/extensions/-/extensions-9.3.6.tgz", + "integrity": "sha512-zWAyeyFlbryrjvlQYYUn+0g7u7LUtS4J6WDWMjrJOP89WCiYitWPz+aqL+0x6iNTZk5xz0YK5FxSarDRCWxZCQ==", + "license": "MIT", + "peer": true, + "dependencies": { + "@luma.gl/shadertools": "^9.3.3", + "@luma.gl/webgl": "^9.3.3", + "@math.gl/core": "^4.1.0" + }, + "peerDependencies": { + "@deck.gl/core": "~9.3.0", + "@luma.gl/core": "~9.3.3", + "@luma.gl/engine": "~9.3.3" + } + }, + "node_modules/@deck.gl/geo-layers": { + "version": "9.3.6", + "resolved": "https://registry.npmjs.org/@deck.gl/geo-layers/-/geo-layers-9.3.6.tgz", + "integrity": "sha512-qhRrbVQ05nuxM4H8DnA6kuikTFDZWXVvOMDFBeOHF5k4rsjiyIg5ahIJG0AYuYKMzAeYMNyPDR+diFH1JV0yLg==", + "license": "MIT", + "dependencies": { + "@loaders.gl/3d-tiles": "^4.4.3", + "@loaders.gl/gis": "^4.4.3", + "@loaders.gl/loader-utils": "^4.4.3", + "@loaders.gl/mvt": "^4.4.3", + "@loaders.gl/schema": "^4.4.3", + "@loaders.gl/terrain": "^4.4.3", + "@loaders.gl/tiles": "^4.4.3", + "@loaders.gl/wms": "^4.4.3", + "@luma.gl/gltf": "^9.3.3", + "@luma.gl/shadertools": "^9.3.3", + "@math.gl/core": "^4.1.0", + "@math.gl/culling": "^4.1.0", + "@math.gl/web-mercator": "^4.1.0", + "@types/geojson": "^7946.0.8", + "a5-js": "^0.7.2", + "h3-js": "^4.4.0", + "long": "^3.2.0" + }, + "peerDependencies": { + "@deck.gl/core": "~9.3.0", + "@deck.gl/extensions": "~9.3.0", + "@deck.gl/layers": "~9.3.0", + "@deck.gl/mesh-layers": "~9.3.0", + "@loaders.gl/core": "^4.4.3", + "@luma.gl/core": "~9.3.3", + "@luma.gl/engine": "~9.3.3" + } + }, + "node_modules/@deck.gl/google-maps": { + "version": "9.3.6", + "resolved": "https://registry.npmjs.org/@deck.gl/google-maps/-/google-maps-9.3.6.tgz", + "integrity": "sha512-L5i1DVo+RF6IQ75UZ51GGz7WgSLVcHkCvMiXwi+cq9HWSjmpKef8hj6/7WjpsF3oddtBGJMhWalAMAF3sGMh/w==", + "license": "MIT", + "dependencies": { + "@luma.gl/webgl": "^9.3.3", + "@math.gl/core": "^4.1.0", + "@types/google.maps": "^3.48.6" + }, + "peerDependencies": { + "@deck.gl/core": "~9.3.0", + "@luma.gl/core": "~9.3.3", + "@luma.gl/webgl": "~9.3.3" + } + }, + "node_modules/@deck.gl/layers": { + "version": "9.3.6", + "resolved": "https://registry.npmjs.org/@deck.gl/layers/-/layers-9.3.6.tgz", + "integrity": "sha512-Ex3Lh4AIQ4zi8iojyWr/kpjwdY9HFKkR0iLeNt0hgz3fOmgL8++5jwUuzt+VOQnjE+x7TsNlM1B1fLciHnH4Xg==", + "license": "MIT", + "dependencies": { + "@loaders.gl/images": "^4.4.3", + "@loaders.gl/schema": "^4.4.3", + "@luma.gl/shadertools": "^9.3.3", + "@mapbox/tiny-sdf": "^2.0.5", + "@math.gl/core": "^4.1.0", + "@math.gl/polygon": "^4.1.0", + "@math.gl/web-mercator": "^4.1.0", + "@types/geojson": "^7946.0.16", + "earcut": "^2.2.4" + }, + "peerDependencies": { + "@deck.gl/core": "~9.3.0", + "@loaders.gl/core": "^4.4.3", + "@luma.gl/core": "~9.3.3", + "@luma.gl/engine": "~9.3.3" + } + }, + "node_modules/@deck.gl/mesh-layers": { + "version": "9.3.6", + "resolved": "https://registry.npmjs.org/@deck.gl/mesh-layers/-/mesh-layers-9.3.6.tgz", + "integrity": "sha512-tKG+gqwSfVQTocH2XlV5o3otUQ/DARJ5vj/JpRv6FTWhUkHo5Acp9BDkvv3Lb5KYx0MkLZyjSKsvai1oAxVAIQ==", + "license": "MIT", + "peer": true, + "dependencies": { + "@loaders.gl/gltf": "^4.4.3", + "@loaders.gl/schema": "^4.4.3", + "@luma.gl/gltf": "^9.3.3", + "@luma.gl/shadertools": "^9.3.3" + }, + "peerDependencies": { + "@deck.gl/core": "~9.3.0", + "@luma.gl/core": "~9.3.3", + "@luma.gl/engine": "~9.3.3", + "@luma.gl/gltf": "~9.3.3", + "@luma.gl/shadertools": "~9.3.3" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz", + "integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz", + "integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz", + "integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz", + "integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz", + "integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz", + "integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz", + "integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz", + "integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz", + "integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz", + "integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz", + "integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz", + "integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz", + "integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz", + "integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz", + "integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz", + "integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz", + "integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz", + "integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz", + "integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz", + "integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz", + "integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz", + "integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz", + "integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz", + "integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz", + "integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz", + "integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@fontsource-variable/inter": { + "version": "5.2.8", + "resolved": "https://registry.npmjs.org/@fontsource-variable/inter/-/inter-5.2.8.tgz", + "integrity": "sha512-kOfP2D+ykbcX/P3IFnokOhVRNoTozo5/JxhAIVYLpea/UBmCQ/YWPBfWIDuBImXX/15KH+eKh4xpEUyS2sQQGQ==", + "license": "OFL-1.1", + "funding": { + "url": "https://github.com/sponsors/ayuhito" + } + }, + "node_modules/@fontsource-variable/space-grotesk": { + "version": "5.2.10", + "resolved": "https://registry.npmjs.org/@fontsource-variable/space-grotesk/-/space-grotesk-5.2.10.tgz", + "integrity": "sha512-yJQO/o35/hAP3CFnpdFTwQku2yzJOae2HIpBmqkOVoxhhXJaQP3g+b6Jrz7u+eI7A5ZdCIf88uMWpBJdFiGr5w==", + "license": "OFL-1.1", + "funding": { + "url": "https://github.com/sponsors/ayuhito" + } + }, + "node_modules/@google/genai": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/@google/genai/-/genai-2.10.0.tgz", + "integrity": "sha512-e4cFxj3tiuMtsgOT4G9c1hXyGJhg7/Buj7VVeBacRY3fRtkRZZ59Q3nuVp2xbq8BGQXLXCDB253qMhklMOeUDg==", + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "google-auth-library": "^10.3.0", + "p-retry": "^4.6.2", + "protobufjs": "^7.5.4", + "ws": "^8.18.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "@modelcontextprotocol/sdk": "^1.25.2" + }, + "peerDependenciesMeta": { + "@modelcontextprotocol/sdk": { + "optional": true + } + } + }, + "node_modules/@googlemaps/js-api-loader": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@googlemaps/js-api-loader/-/js-api-loader-2.1.1.tgz", + "integrity": "sha512-yUpAwksbHrlZIWD49JmveNSfBG4oAK0AwMknfSaPMnP5N7UT8oFRVCqwjGb1XQovi//7KLbPQKZpbofiLGzpDw==", + "license": "Apache-2.0", + "dependencies": { + "@types/google.maps": "^3.53.1" + } + }, + "node_modules/@googlemaps/markerclusterer": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/@googlemaps/markerclusterer/-/markerclusterer-2.6.2.tgz", + "integrity": "sha512-U6uVhq8iWhiIckA89sgRu8OK35mjd6/3CuoZKWakKEf0QmRRWpatlsPb3kqXkoWSmbcZkopRiI4dnW6DQSd7bQ==", + "license": "Apache-2.0", + "dependencies": { + "@types/supercluster": "^7.1.3", + "fast-equals": "^5.2.2", + "supercluster": "^8.0.1" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@loaders.gl/3d-tiles": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/@loaders.gl/3d-tiles/-/3d-tiles-4.4.3.tgz", + "integrity": "sha512-trKDXRYE7xIyH0g2tvDG0SHo/J5sCUhOec70Ne1a5t64/yY+yifQ4B67n4AnSOnZ+l4sxjUypjxhHUqoAeWieQ==", + "license": "MIT", + "dependencies": { + "@loaders.gl/compression": "4.4.3", + "@loaders.gl/crypto": "4.4.3", + "@loaders.gl/draco": "4.4.3", + "@loaders.gl/gltf": "4.4.3", + "@loaders.gl/images": "4.4.3", + "@loaders.gl/loader-utils": "4.4.3", + "@loaders.gl/math": "4.4.3", + "@loaders.gl/tiles": "4.4.3", + "@loaders.gl/zip": "4.4.3", + "@math.gl/core": "^4.1.0", + "@math.gl/culling": "^4.1.0", + "@math.gl/geospatial": "^4.1.0", + "@probe.gl/log": "^4.1.1", + "long": "^5.2.1" + }, + "peerDependencies": { + "@loaders.gl/core": "~4.4.0" + } + }, + "node_modules/@loaders.gl/3d-tiles/node_modules/long": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz", + "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==", + "license": "Apache-2.0" + }, + "node_modules/@loaders.gl/compression": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/@loaders.gl/compression/-/compression-4.4.3.tgz", + "integrity": "sha512-v3feEE48FblxBPaILwejV48plcLmjvOh2yBQrgvKvLCaQdQ9bVz7PzhrUdLW0VDVlGnoR1NUJtI833627U8Heg==", + "license": "MIT", + "dependencies": { + "@loaders.gl/loader-utils": "4.4.3", + "@loaders.gl/worker-utils": "4.4.3", + "@types/pako": "^1.0.1", + "fflate": "0.7.4", + "pako": "1.0.11", + "snappyjs": "^0.6.1" + }, + "optionalDependencies": { + "@types/brotli": "^1.3.0", + "brotli": "^1.3.2", + "lz4js": "^0.2.0", + "zstd-codec": "^0.1" + }, + "peerDependencies": { + "@loaders.gl/core": "~4.4.0" + } + }, + "node_modules/@loaders.gl/core": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/@loaders.gl/core/-/core-4.4.3.tgz", + "integrity": "sha512-D6kXFdpUtYwoqS5OQLLaLELkHlS3qIdcSgzVvsh/MinRteeVIITJojqEc/lmDx1zVR3j6WND7yPGF4i4aOwiOg==", + "license": "MIT", + "dependencies": { + "@loaders.gl/loader-utils": "4.4.3", + "@loaders.gl/schema": "4.4.3", + "@loaders.gl/schema-utils": "4.4.3", + "@loaders.gl/worker-utils": "4.4.3", + "@probe.gl/log": "^4.1.1" + } + }, + "node_modules/@loaders.gl/crypto": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/@loaders.gl/crypto/-/crypto-4.4.3.tgz", + "integrity": "sha512-zratEvtj/Mdbu0NwwwzdbP1oyY4FNxLcOY2JRcYqe3wzw+0kyeK7THdaVPcduZAnQ06HtpwHls61ONZunjMtXA==", + "license": "MIT", + "dependencies": { + "@loaders.gl/loader-utils": "4.4.3", + "@loaders.gl/worker-utils": "4.4.3", + "@types/crypto-js": "^4.0.2" + }, + "peerDependencies": { + "@loaders.gl/core": "~4.4.0" + } + }, + "node_modules/@loaders.gl/draco": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/@loaders.gl/draco/-/draco-4.4.3.tgz", + "integrity": "sha512-YNI1MUDDIbrJBamgU1emLBC2kQUbESNIOZv9bcS8xwxr9SNMfnAMZWgdUJkYcC5xzV2q6ty2I/b2eGhXQkd9EQ==", + "license": "MIT", + "dependencies": { + "@loaders.gl/loader-utils": "4.4.3", + "@loaders.gl/schema": "4.4.3", + "@loaders.gl/schema-utils": "4.4.3", + "@loaders.gl/worker-utils": "4.4.3", + "draco3d": "1.5.7" + }, + "peerDependencies": { + "@loaders.gl/core": "~4.4.0" + } + }, + "node_modules/@loaders.gl/geoarrow": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/@loaders.gl/geoarrow/-/geoarrow-4.4.3.tgz", + "integrity": "sha512-P0TSbtsw1UI1rZnp1tGHuqPVHcH7Yc14q9jZLIcrjhQOzol55YDI0/hYSXpA73794nR6o8ALxNwiy7/4HlBlcA==", + "license": "MIT", + "dependencies": { + "@math.gl/polygon": "^4.1.0", + "apache-arrow": ">= 17.0.0" + }, + "peerDependencies": { + "@loaders.gl/core": "~4.4.0" + } + }, + "node_modules/@loaders.gl/gis": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/@loaders.gl/gis/-/gis-4.4.3.tgz", + "integrity": "sha512-2dhhzfCT1cXQQLZ1lMtb2Y+pX414qaeLL8Wpx7FJu/ar1HJfKL6Fb3juTR+2WzMybkAOX5zLxYh6+y7LqiPjgA==", + "license": "MIT", + "dependencies": { + "@loaders.gl/geoarrow": "4.4.3", + "@loaders.gl/loader-utils": "4.4.3", + "@loaders.gl/schema": "4.4.3", + "@loaders.gl/schema-utils": "4.4.3", + "@mapbox/vector-tile": "^1.3.1", + "@math.gl/polygon": "^4.1.0", + "pbf": "^3.2.1" + }, + "peerDependencies": { + "@loaders.gl/core": "~4.4.0" + } + }, + "node_modules/@loaders.gl/gltf": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/@loaders.gl/gltf/-/gltf-4.4.3.tgz", + "integrity": "sha512-OYE/0nGLYm3weiCb78+ROwdNVyuLS8IZwN8NvGqctqt0HS4ZD40biu7b9L8xkFbVTZQkQZXDpyZ61n5PSQeU1A==", + "license": "MIT", + "dependencies": { + "@loaders.gl/draco": "4.4.3", + "@loaders.gl/images": "4.4.3", + "@loaders.gl/loader-utils": "4.4.3", + "@loaders.gl/schema": "4.4.3", + "@loaders.gl/textures": "4.4.3", + "@math.gl/core": "^4.1.0" + }, + "peerDependencies": { + "@loaders.gl/core": "~4.4.0" + } + }, + "node_modules/@loaders.gl/images": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/@loaders.gl/images/-/images-4.4.3.tgz", + "integrity": "sha512-a4C6x+4GZUchf+IwpdBOvyGeLGSbGOVob+WSWfGUezEQ4gqu1zS1pn3lCX3ZTL5P+Ch3v6KKwPGdO5YdTd52Ew==", + "license": "MIT", + "dependencies": { + "@loaders.gl/loader-utils": "4.4.3" + }, + "peerDependencies": { + "@loaders.gl/core": "~4.4.0" + } + }, + "node_modules/@loaders.gl/loader-utils": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/@loaders.gl/loader-utils/-/loader-utils-4.4.3.tgz", + "integrity": "sha512-5yt1OqZPYR3d+xFAqtzSKO24Hd7019KqN+iOWp7XBKPQBS+sAEhAnmSTnO1axo45IEi14J3FhMDk+ndqBoSKEQ==", + "license": "MIT", + "dependencies": { + "@loaders.gl/schema": "4.4.3", + "@loaders.gl/worker-utils": "4.4.3", + "@probe.gl/log": "^4.1.1", + "@probe.gl/stats": "^4.1.1" + } + }, + "node_modules/@loaders.gl/math": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/@loaders.gl/math/-/math-4.4.3.tgz", + "integrity": "sha512-EdEsZGqo1AX3sqgXt8bSBmdo+8ncURXjWucyQ8eeIJ2h0VIqM3bLkOGKk/D1ngeqK4hJXcjiturYHBW1B286lw==", + "license": "MIT", + "dependencies": { + "@math.gl/core": "^4.1.0" + }, + "peerDependencies": { + "@loaders.gl/core": "~4.4.0" + } + }, + "node_modules/@loaders.gl/mvt": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/@loaders.gl/mvt/-/mvt-4.4.3.tgz", + "integrity": "sha512-s2R8GRlaWDfZ7oKDFiY0c5EJ8elkf2VbPvdC0eoh69DN71y+AKngDSB+7h4veH/oueLEjmV7tNlF0+Snv58CPw==", + "license": "MIT", + "dependencies": { + "@loaders.gl/gis": "4.4.3", + "@loaders.gl/images": "4.4.3", + "@loaders.gl/loader-utils": "4.4.3", + "@loaders.gl/schema": "4.4.3", + "@math.gl/polygon": "^4.1.0", + "@probe.gl/stats": "^4.1.1", + "pbf": "^3.2.1" + }, + "peerDependencies": { + "@loaders.gl/core": "~4.4.0" + } + }, + "node_modules/@loaders.gl/schema": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/@loaders.gl/schema/-/schema-4.4.3.tgz", + "integrity": "sha512-tDb/rOn44nHWvMFSOaAk1/pQcQcusxirGCM1O5BMJR0PQ71UrCHey56rsbcf7/wGhvyRwPtX+oiMzlJ9TmN8vQ==", + "license": "MIT", + "dependencies": { + "@types/geojson": "^7946.0.7", + "apache-arrow": ">= 17.0.0" + } + }, + "node_modules/@loaders.gl/schema-utils": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/@loaders.gl/schema-utils/-/schema-utils-4.4.3.tgz", + "integrity": "sha512-TggzQWkzf9pIYbj8I9RlTk33kwXslvDkY1nnw/OL/hika6qTls1G127qK5m9skiAjElk5ZkvQLQcwhOboZXZeA==", + "license": "MIT", + "dependencies": { + "@loaders.gl/schema": "4.4.3", + "@types/geojson": "^7946.0.7", + "apache-arrow": ">= 17.0.0" + }, + "peerDependencies": { + "@loaders.gl/core": "~4.4.0" + } + }, + "node_modules/@loaders.gl/terrain": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/@loaders.gl/terrain/-/terrain-4.4.3.tgz", + "integrity": "sha512-wu7O3FVXE6D1kSm5inRu8M8V1Vv5AYlnlNB0EJIOSSXBLhfNbPL5oHjlD2wIlag0gSxOVlhWjCUcWS3/Nb0P8Q==", + "license": "MIT", + "dependencies": { + "@loaders.gl/images": "4.4.3", + "@loaders.gl/loader-utils": "4.4.3", + "@loaders.gl/schema": "4.4.3", + "@mapbox/martini": "^0.2.0" + }, + "peerDependencies": { + "@loaders.gl/core": "~4.4.0" + } + }, + "node_modules/@loaders.gl/textures": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/@loaders.gl/textures/-/textures-4.4.3.tgz", + "integrity": "sha512-QNC+anwJJcHsLsaAzXozJ+IUNxBnqWsZLUMkUZIorVey+XQcp2hcYng5iOmUQnCZdIR9gYe53VJa0j1C0JXRSg==", + "license": "MIT", + "dependencies": { + "@loaders.gl/images": "4.4.3", + "@loaders.gl/loader-utils": "4.4.3", + "@loaders.gl/schema": "4.4.3", + "@loaders.gl/worker-utils": "4.4.3", + "@math.gl/types": "^4.1.0", + "ktx-parse": "^0.7.0", + "texture-compressor": "^1.0.2" + }, + "peerDependencies": { + "@loaders.gl/core": "~4.4.0" + } + }, + "node_modules/@loaders.gl/tiles": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/@loaders.gl/tiles/-/tiles-4.4.3.tgz", + "integrity": "sha512-8ZkPMe+daMV5mHKTEU591mJSp6pswdWliI+PcNhSOkpeuvyjecaYKSzuzMFMmaywfgOLpYl+lpfzKUEmCcE5vQ==", + "license": "MIT", + "dependencies": { + "@loaders.gl/loader-utils": "4.4.3", + "@loaders.gl/math": "4.4.3", + "@math.gl/core": "^4.1.0", + "@math.gl/culling": "^4.1.0", + "@math.gl/geospatial": "^4.1.0", + "@math.gl/web-mercator": "^4.1.0", + "@probe.gl/stats": "^4.1.1" + }, + "peerDependencies": { + "@loaders.gl/core": "~4.4.0" + } + }, + "node_modules/@loaders.gl/wms": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/@loaders.gl/wms/-/wms-4.4.3.tgz", + "integrity": "sha512-fKYCdIp6xQcucbm42bztprlfMJ5SZKx5f2ZwWLcj+kwv53U7hjcbIl04uMAk/i/7Xw/I4QEyA/XnPnxquuIFHQ==", + "license": "MIT", + "dependencies": { + "@loaders.gl/images": "4.4.3", + "@loaders.gl/loader-utils": "4.4.3", + "@loaders.gl/schema": "4.4.3", + "@loaders.gl/xml": "4.4.3", + "@turf/rewind": "^5.1.5", + "deep-strict-equal": "^0.2.0" + }, + "peerDependencies": { + "@loaders.gl/core": "~4.4.0" + } + }, + "node_modules/@loaders.gl/worker-utils": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/@loaders.gl/worker-utils/-/worker-utils-4.4.3.tgz", + "integrity": "sha512-RZt/NwFXm6Kx3Fn/rqiW3Alse9Z0XtBktH/EFAp8W6LvsKuxpRMjULqGfybTu2waRXRYUNYZ6zGs9aOWbtDRLw==", + "license": "MIT", + "peerDependencies": { + "@loaders.gl/core": "~4.4.0" + } + }, + "node_modules/@loaders.gl/xml": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/@loaders.gl/xml/-/xml-4.4.3.tgz", + "integrity": "sha512-/CtpIWaPKBs/AaX7MqUGSSE1OAhq78nDrJ5xPExYdIQAw5gl5OSmLZobUSSxV5FGaqkJRVbtC/sj5sUpci7doA==", + "license": "MIT", + "dependencies": { + "@loaders.gl/loader-utils": "4.4.3", + "@loaders.gl/schema": "4.4.3", + "fast-xml-parser": "^5.3.6" + }, + "peerDependencies": { + "@loaders.gl/core": "~4.4.0" + } + }, + "node_modules/@loaders.gl/zip": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/@loaders.gl/zip/-/zip-4.4.3.tgz", + "integrity": "sha512-HH/JLulJJVyqmbQYp4e/9MPjaTnUFnWfhgza8sMGIBYZ/YuZXOc6Ad2vvmyFQHvNbVr+NLrRZODASEjNZQ5rfQ==", + "license": "MIT", + "dependencies": { + "@loaders.gl/compression": "4.4.3", + "@loaders.gl/crypto": "4.4.3", + "@loaders.gl/loader-utils": "4.4.3", + "jszip": "^3.1.5", + "md5": "^2.3.0" + }, + "peerDependencies": { + "@loaders.gl/core": "~4.4.0" + } + }, + "node_modules/@luma.gl/core": { + "version": "9.3.6", + "resolved": "https://registry.npmjs.org/@luma.gl/core/-/core-9.3.6.tgz", + "integrity": "sha512-eqHnCPh2xHYkdd9rEkiIIkGMixNiJkq1ROrnTob6npvmZNVHXL0ubIw5KueL7rqW0+J1tm+p2+TXZaCmn0sP7Q==", + "license": "MIT", + "dependencies": { + "@math.gl/types": "^4.1.0", + "@probe.gl/env": "^4.1.1", + "@probe.gl/log": "^4.1.1", + "@probe.gl/stats": "^4.1.1", + "@types/offscreencanvas": "^2019.7.3" + } + }, + "node_modules/@luma.gl/engine": { + "version": "9.3.6", + "resolved": "https://registry.npmjs.org/@luma.gl/engine/-/engine-9.3.6.tgz", + "integrity": "sha512-NYTdhn2NaH/MjN8qXCl2ukrT1Ac9iTKu1mgN0wz11XRd1QYnlMNBXswRROD7IZ2PUsBWdmYHc9qE8wKBQqMuIA==", + "license": "MIT", + "dependencies": { + "@math.gl/core": "^4.1.0", + "@math.gl/types": "^4.1.0", + "@probe.gl/log": "^4.1.1", + "@probe.gl/stats": "^4.1.1" + }, + "peerDependencies": { + "@luma.gl/core": "~9.3.0", + "@luma.gl/shadertools": "~9.3.0" + } + }, + "node_modules/@luma.gl/gltf": { + "version": "9.3.6", + "resolved": "https://registry.npmjs.org/@luma.gl/gltf/-/gltf-9.3.6.tgz", + "integrity": "sha512-9R27aYwvu6KBJzd9Kxs4cqIJpgJsI1AyL6Pn6+5CrmGbaCqMtgWi7Sf9Wo+bqv0PRxwzH0j1oAv7qRk0hGbstw==", + "license": "MIT", + "dependencies": { + "@loaders.gl/core": "~4.4.0", + "@loaders.gl/gltf": "~4.4.0", + "@loaders.gl/textures": "~4.4.0", + "@math.gl/core": "^4.1.0" + }, + "peerDependencies": { + "@luma.gl/core": "~9.3.0", + "@luma.gl/engine": "~9.3.0", + "@luma.gl/shadertools": "~9.3.0" + } + }, + "node_modules/@luma.gl/shadertools": { + "version": "9.3.6", + "resolved": "https://registry.npmjs.org/@luma.gl/shadertools/-/shadertools-9.3.6.tgz", + "integrity": "sha512-dDuD8lCOkAE1L7hJGZEyZAi7upR1HG3wEEIQ1gCLaTTbJWC7tNtnVz853lqUA+VXgjgS9NiQFFRcosiIZmW4Vg==", + "license": "MIT", + "dependencies": { + "@math.gl/core": "^4.1.0", + "@math.gl/types": "^4.1.0" + }, + "peerDependencies": { + "@luma.gl/core": "~9.3.0" + } + }, + "node_modules/@luma.gl/webgl": { + "version": "9.3.6", + "resolved": "https://registry.npmjs.org/@luma.gl/webgl/-/webgl-9.3.6.tgz", + "integrity": "sha512-tGm7FGWPmJKxGZMvkRPRi219x3tKmBxR7Uke09nTp2ujNak/O5V9xPIQ9lUBGTxqAb8U2yjKkXG8j+f/4b2+sw==", + "license": "MIT", + "dependencies": { + "@math.gl/types": "^4.1.0", + "@probe.gl/env": "^4.1.1" + }, + "peerDependencies": { + "@luma.gl/core": "~9.3.0" + } + }, + "node_modules/@mapbox/martini": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/@mapbox/martini/-/martini-0.2.0.tgz", + "integrity": "sha512-7hFhtkb0KTLEls+TRw/rWayq5EeHtTaErgm/NskVoXmtgAQu/9D299aeyj6mzAR/6XUnYRp2lU+4IcrYRFjVsQ==", + "license": "ISC" + }, + "node_modules/@mapbox/point-geometry": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/@mapbox/point-geometry/-/point-geometry-0.1.0.tgz", + "integrity": "sha512-6j56HdLTwWGO0fJPlrZtdU/B13q8Uwmo18Ck2GnGgN9PCFyKTZ3UbXeEdRFh18i9XQ92eH2VdtpJHpBD3aripQ==", + "license": "ISC" + }, + "node_modules/@mapbox/tiny-sdf": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@mapbox/tiny-sdf/-/tiny-sdf-2.2.0.tgz", + "integrity": "sha512-LVL4wgI9YAum5V+LNVQO6QgFBPw7/MIIY4XJPNsPDMrjEwcE+JfKk1LuIl8GnF197ejVdC9QdPaxrx5gfgdGXg==", + "license": "BSD-2-Clause" + }, + "node_modules/@mapbox/vector-tile": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@mapbox/vector-tile/-/vector-tile-1.3.1.tgz", + "integrity": "sha512-MCEddb8u44/xfQ3oD+Srl/tNcQoqTw3goGk2oLsrFxOTc3dUp+kAnby3PvAeeBYSMSjSPD1nd1AJA6W49WnoUw==", + "license": "BSD-3-Clause", + "dependencies": { + "@mapbox/point-geometry": "~0.1.0" + } + }, + "node_modules/@math.gl/core": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@math.gl/core/-/core-4.1.0.tgz", + "integrity": "sha512-FrdHBCVG3QdrworwrUSzXIaK+/9OCRLscxI2OUy6sLOHyHgBMyfnEGs99/m3KNvs+95BsnQLWklVfpKfQzfwKA==", + "license": "MIT", + "dependencies": { + "@math.gl/types": "4.1.0" + } + }, + "node_modules/@math.gl/culling": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@math.gl/culling/-/culling-4.1.0.tgz", + "integrity": "sha512-jFmjFEACnP9kVl8qhZxFNhCyd47qPfSVmSvvjR0/dIL6R9oD5zhR1ub2gN16eKDO/UM7JF9OHKU3EBIfeR7gtg==", + "license": "MIT", + "dependencies": { + "@math.gl/core": "4.1.0", + "@math.gl/types": "4.1.0" + } + }, + "node_modules/@math.gl/geospatial": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@math.gl/geospatial/-/geospatial-4.1.0.tgz", + "integrity": "sha512-BzsUhpVvnmleyYF6qdqJIip6FtIzJmnWuPTGhlBuPzh7VBHLonCFSPtQpbkRuoyAlbSyaGXcVt6p6lm9eK2vtg==", + "license": "MIT", + "dependencies": { + "@math.gl/core": "4.1.0", + "@math.gl/types": "4.1.0" + } + }, + "node_modules/@math.gl/polygon": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@math.gl/polygon/-/polygon-4.1.0.tgz", + "integrity": "sha512-YA/9PzaCRHbIP5/0E9uTYrqe+jsYTQoqoDWhf6/b0Ixz8bPZBaGDEafLg3z7ffBomZLacUty9U3TlPjqMtzPjA==", + "license": "MIT", + "dependencies": { + "@math.gl/core": "4.1.0" + } + }, + "node_modules/@math.gl/sun": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@math.gl/sun/-/sun-4.1.0.tgz", + "integrity": "sha512-i3q6OCBLSZ5wgZVhXg+X7gsjY/TUtuFW/2KBiq/U1ypLso3S4sEykoU/MGjxUv1xiiGtr+v8TeMbO1OBIh/HmA==", + "license": "MIT" + }, + "node_modules/@math.gl/types": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@math.gl/types/-/types-4.1.0.tgz", + "integrity": "sha512-clYZdHcmRvMzVK5fjeDkQlHUzXQSNdZ7s4xOqC3nJPgz4C/TZkUecTo9YS4PruZqtDda/ag4erndP0MIn40dGA==", + "license": "MIT" + }, + "node_modules/@math.gl/web-mercator": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@math.gl/web-mercator/-/web-mercator-4.1.0.tgz", + "integrity": "sha512-HZo3vO5GCMkXJThxRJ5/QYUYRr3XumfT8CzNNCwoJfinxy5NtKUd7dusNTXn7yJ40UoB8FMIwkVwNlqaiRZZAw==", + "license": "MIT", + "dependencies": { + "@math.gl/core": "4.1.0" + } + }, + "node_modules/@nodable/entities": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@nodable/entities/-/entities-3.0.0.tgz", + "integrity": "sha512-8L9xFeTYKhm49xfIypoe2W5wV1m/3Z58kT+7kR9A8OyFxcPduI4VmxaUMQyKYrRjUoLLSXv6EKKID5Tvj9cUVw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/nodable" + } + ], + "license": "MIT" + }, + "node_modules/@preact/signals-core": { + "version": "1.14.4", + "resolved": "https://registry.npmjs.org/@preact/signals-core/-/signals-core-1.14.4.tgz", + "integrity": "sha512-HNB6HYeYKhQbJ1aKl+YRjrS4+QWHLKX6qKoUsfS/m0vqzsVaEBiZiaKbG/e+NKk2ch5ALQr/ihWaMHxiCuuWHA==", + "dev": true, + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/preact" + } + }, + "node_modules/@probe.gl/env": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/@probe.gl/env/-/env-4.1.1.tgz", + "integrity": "sha512-+68seNDMVsEegRB47pFA/Ws1Fjy8agcFYXxzorKToyPcD6zd+gZ5uhwoLd7TzsSw6Ydns//2KEszWn+EnNHTbA==", + "license": "MIT" + }, + "node_modules/@probe.gl/log": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/@probe.gl/log/-/log-4.1.1.tgz", + "integrity": "sha512-kcZs9BT44pL7hS1OkRGKYRXI/SN9KejUlPD+BY40DguRLzdC5tLG/28WGMyfKdn/51GT4a0p+0P8xvDn1Ez+Kg==", + "license": "MIT", + "dependencies": { + "@probe.gl/env": "4.1.1" + } + }, + "node_modules/@probe.gl/stats": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/@probe.gl/stats/-/stats-4.1.1.tgz", + "integrity": "sha512-4VpAyMHOqydSvPlEyHwXaE+AkIdR03nX+Qhlxsk2D/IW4OVmDZgIsvJB1cDzyEEtcfKcnaEbfXeiPgejBceT6g==", + "license": "MIT" + }, + "node_modules/@protobufjs/aspromise": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz", + "integrity": "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/base64": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/base64/-/base64-1.1.2.tgz", + "integrity": "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/codegen": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@protobufjs/codegen/-/codegen-2.0.5.tgz", + "integrity": "sha512-zgXFLzW3Ap33e6d0Wlj4MGIm6Ce8O89n/apUaGNB/jx+hw+ruWEp7EwGUshdLKVRCxZW12fp9r40E1mQrf/34g==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/eventemitter": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@protobufjs/eventemitter/-/eventemitter-1.1.1.tgz", + "integrity": "sha512-vW1GmwMZNnL+gMRaovlh9yZX74kc+TTU3FObkkurpMaRtBfLP3ldjS9KQWlwZgraRE0+dheEEoAxdzcJQ8eXZg==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/fetch": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@protobufjs/fetch/-/fetch-1.1.1.tgz", + "integrity": "sha512-GpptLrs57adMSuHi3VNj0mAF8dwh36LMaYF6XyJ6JMWlVsc+t42tm1HSEDmOs3A8fC9yyeisgLhsTVQokOZ0zw==", + "license": "BSD-3-Clause", + "dependencies": { + "@protobufjs/aspromise": "^1.1.1" + } + }, + "node_modules/@protobufjs/float": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@protobufjs/float/-/float-1.0.2.tgz", + "integrity": "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/path": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/path/-/path-1.1.2.tgz", + "integrity": "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/pool": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/pool/-/pool-1.1.0.tgz", + "integrity": "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/utf8": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.1.tgz", + "integrity": "sha512-oOAWABowe8EAbMyWKM0tYDKi8Yaox52D+HWZhAIJqQXbqe0xI/GV7FhLWqlEKreMkfDjshR5FKgi3mnle0h6Eg==", + "license": "BSD-3-Clause" + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-rc.3", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.3.tgz", + "integrity": "sha512-eybk3TjzzzV97Dlj5c+XrBFW57eTNhzod66y9HrBlzJ6NsCrWCp/2kaPS3K9wJmurBC0Tdw4yPjXKZqlznim3Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.2.tgz", + "integrity": "sha512-6o7ZLZK+BeenkZCFNDXqpbjw9bD6nuWonvS/lwQJp7NoVVxm6p3qE7qQ5jGuBjiFsgvqjD8mZAU5oWxTmbOeOg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.62.2.tgz", + "integrity": "sha512-BaH7BllCACHoH1LguOU56UItGfUWjujlO65kS9LAodViaN4bwIKd7oeW/ZHJ/4ljr/7MIiENnNy3HJ0zXv8Zkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.62.2.tgz", + "integrity": "sha512-v39RCCvj4He82I9sFmk+M1VZ0PLM9sfsLVikjfx2hYBNALhrrOR2D3JjQA6AhlaSOgcR+RzrKY7e1+bT6SUO/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.62.2.tgz", + "integrity": "sha512-yl0y2vq3S3lHeuXhEdss6TWfKW8vkujImO12tn4ZkG/4oghr09LvdYm2RElVjokTQiUvDUGXLGsYeLqUMCKpGA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.62.2.tgz", + "integrity": "sha512-tT4pvt4qXD+vEoezupCWi+a1F0vvDiksiHc+PxRlYTOH1I6/X4id9jPxTP+Fg+545euaFT1jJVs4CEdHZAU1vw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.62.2.tgz", + "integrity": "sha512-6nU5F2wCW+qvCBhTn1pdIU3bzsIoF7EUwsCDRxilWGprQR6yd508YnH9+OKFCwpfS8pjZqDUmnCAr7exax0XCg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.62.2.tgz", + "integrity": "sha512-n1GJHPOvpIfhi3TmrCeh6S6URt9BFCt0KQE3qvexyGCTAKpR4Lg+eWvNZEqu7epxwus/8ElT3hacYEucm49SZg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.62.2.tgz", + "integrity": "sha512-JqgflS8wEB+UXV/vS1RpRbifGBeN4D5lz8D8oOFbFZw4vedvdOgCFAjfBmIMdW3yL10XpQQ0Ambepw6MXrhOnA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.62.2.tgz", + "integrity": "sha512-wnFJkogWvN4jm/hQRF2UBaeUmk20j5+DmHvoyWii2b8HJDyvz1MF2OU/6ynXt2KR63rbZLWkFpoytpdc/yBuSA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.62.2.tgz", + "integrity": "sha512-HVu2bp0zhvJ8xHEV9+UUs7S90VadmBSY3LcIMvozbPo4AuMGDWlz3ymHLHZPX4hR67TKTt8Qp5PJ5RBg/i+RMQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.62.2.tgz", + "integrity": "sha512-mQqqAV8QaoSgr9I2fKDLY2BAVvmKjWoGiu/cSYQonsLvtqwEn1E4QYfnCOcp5zoEqNhsDYin1s6jx/VJmrxlZg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.62.2.tgz", + "integrity": "sha512-IxKLoxCQ2IWi6bT2akyDUBGsOImDKB+sPp4EsTmwFQ/fMwpCKm8uLSSgP/Kx/QYUgKis6SEZ5/Nlhup0DIA0PQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.62.2.tgz", + "integrity": "sha512-Mk5ha2RQSgyFfmYYLkBpPnUk8D8FriBxesO1u9O75X0mHgXL1UQcH5Itl2lurWL2tj0RxV9b9tJgipac0hRY9A==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.62.2.tgz", + "integrity": "sha512-CjvEnqJL/0/TQ3TXX3OPIJ/kmBellrWd4heXUmHeJlTnmwjKpSJzoehLaL6Xk0ZnMHBu9dZuFADNOrtjF4v+2w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.62.2.tgz", + "integrity": "sha512-1SiZbzwdkaDURsew/tSOrooKiYy7EQGT6m8ufavAi9NEyQb/6VuIxFXAL1fqa4iZe3g4NbNk4P7J32z2tw5Mgg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.62.2.tgz", + "integrity": "sha512-nQts12zJ3NQRoE6uYljOH89v7szzLDvG2JD/vsX+vGXU8w/At1GowTZ5/7qeFQ8m7L55rpR8Okugnuo5bgjy2Q==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.62.2.tgz", + "integrity": "sha512-E9/ll019jhPIJgpzfZoIkBGhcz+kKNgVWYRY0zr9srBdPPFVpvOKW8VaJKUbeK+eZXyQF9ltME+Kk6affeaPgg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.62.2.tgz", + "integrity": "sha512-5BqxR/pshjey51iliyzTD5Xi3EN0aLmQ2lZ3lvefVV9c82BvrLo2/6OT55iifpWBufs6kdwWbuOKS841DrmK9A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.62.2.tgz", + "integrity": "sha512-uNN83XxQrRAh/w0/pmAfibcwyb6YWt4gP+dpnQKPVJshAloQ785ii8CT8ZCIxkGg9opVsvAlGhFitSm6D1Jjpg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.62.2.tgz", + "integrity": "sha512-srjEIxSH3LRnJN6THczDHWQplqEMFiAJrTab0msUryh9kwNpkICf3Ea6q6MN/2cZwRFUNx5w+h6Hpi4QuHS6Zg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.62.2.tgz", + "integrity": "sha512-8hOJnxgbyObnCm5AlRA3A931xX19xq80RjVTKgJOvEKWqJruP/Uf12IbAOaDjjEXYRewwHLfmF0YRIdK3OwKWA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.62.2.tgz", + "integrity": "sha512-mmF4AY1i0hG/bLWUctUq59gtmgaSIRa3cu/A3JFRp/sCNEme2bgDEiDS22P9FbnJB8NJNF4jPJiSP5RHQpUTDg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.62.2.tgz", + "integrity": "sha512-DZgkknc6jhHrk46V25vbAM0zZkyP0nSDkJB8/dRkLTxv470dOmWDqGoEJl/9A0dFfS7yE3REOwNDxpHwSLSt0Q==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.62.2.tgz", + "integrity": "sha512-T6xr6ucWSFto+VGajA8YH26LdpHRuP4YLHEKAtCWvJDOlnmWcDZVCI2Jmjr+IFHDlt2zRaTAKE4tfjTaWLgJBg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.62.2.tgz", + "integrity": "sha512-BfzEnDJOt9T8M989/lA37EcJgat01wLRnoi5dQf3QzOH7jzpqTAzdDbVfRljVr5r+jzKqpbHeyOfAaXxAd0PAA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@swc/helpers": { + "version": "0.5.23", + "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.23.tgz", + "integrity": "sha512-5lSsMOTXURePglDfvuAQUqkGek9Hg2kksOYay2m0+XR++b2NWYL/4sWyuvVBIs8oKnJaxkdi9whaL/sqN13afw==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.8.0" + } + }, + "node_modules/@turf/boolean-clockwise": { + "version": "5.1.5", + "resolved": "https://registry.npmjs.org/@turf/boolean-clockwise/-/boolean-clockwise-5.1.5.tgz", + "integrity": "sha512-FqbmEEOJ4rU4/2t7FKx0HUWmjFEVqR+NJrFP7ymGSjja2SQ7Q91nnBihGuT+yuHHl6ElMjQ3ttsB/eTmyCycxA==", + "license": "MIT", + "dependencies": { + "@turf/helpers": "^5.1.5", + "@turf/invariant": "^5.1.5" + } + }, + "node_modules/@turf/clone": { + "version": "5.1.5", + "resolved": "https://registry.npmjs.org/@turf/clone/-/clone-5.1.5.tgz", + "integrity": "sha512-//pITsQ8xUdcQ9pVb4JqXiSqG4dos5Q9N4sYFoWghX21tfOV2dhc5TGqYOhnHrQS7RiKQL1vQ48kIK34gQ5oRg==", + "license": "MIT", + "dependencies": { + "@turf/helpers": "^5.1.5" + } + }, + "node_modules/@turf/helpers": { + "version": "5.1.5", + "resolved": "https://registry.npmjs.org/@turf/helpers/-/helpers-5.1.5.tgz", + "integrity": "sha512-/lF+JR+qNDHZ8bF9d+Cp58nxtZWJ3sqFe6n3u3Vpj+/0cqkjk4nXKYBSY0azm+GIYB5mWKxUXvuP/m0ZnKj1bw==", + "license": "MIT" + }, + "node_modules/@turf/invariant": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@turf/invariant/-/invariant-5.2.0.tgz", + "integrity": "sha512-28RCBGvCYsajVkw2EydpzLdcYyhSA77LovuOvgCJplJWaNVyJYH6BOR3HR9w50MEkPqb/Vc/jdo6I6ermlRtQA==", + "license": "MIT", + "dependencies": { + "@turf/helpers": "^5.1.5" + } + }, + "node_modules/@turf/meta": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@turf/meta/-/meta-5.2.0.tgz", + "integrity": "sha512-ZjQ3Ii62X9FjnK4hhdsbT+64AYRpaI8XMBMcyftEOGSmPMUVnkbvuv3C9geuElAXfQU7Zk1oWGOcrGOD9zr78Q==", + "license": "MIT", + "dependencies": { + "@turf/helpers": "^5.1.5" + } + }, + "node_modules/@turf/rewind": { + "version": "5.1.5", + "resolved": "https://registry.npmjs.org/@turf/rewind/-/rewind-5.1.5.tgz", + "integrity": "sha512-Gdem7JXNu+G4hMllQHXRFRihJl3+pNl7qY+l4qhQFxq+hiU1cQoVFnyoleIqWKIrdK/i2YubaSwc3SCM7N5mMw==", + "license": "MIT", + "dependencies": { + "@turf/boolean-clockwise": "^5.1.5", + "@turf/clone": "^5.1.5", + "@turf/helpers": "^5.1.5", + "@turf/invariant": "^5.1.5", + "@turf/meta": "^5.1.5" + } + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/brotli": { + "version": "1.3.5", + "resolved": "https://registry.npmjs.org/@types/brotli/-/brotli-1.3.5.tgz", + "integrity": "sha512-9xoNr+bcxT236/7ZgcWw/6Pb2RRetE13p4bFy1xYSckKwyOiRfmInay8baUWZgH7/284Wl6IPe7+nOI9+OQg/A==", + "license": "MIT", + "optional": true, + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/command-line-args": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/command-line-args/-/command-line-args-5.2.3.tgz", + "integrity": "sha512-uv0aG6R0Y8WHZLTamZwtfsDLVRnOa+n+n5rEvFWL5Na5gZ8V2Teab/duDPFzIIIhs9qizDpcavCusCLJZu62Kw==", + "license": "MIT" + }, + "node_modules/@types/command-line-usage": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/@types/command-line-usage/-/command-line-usage-5.0.4.tgz", + "integrity": "sha512-BwR5KP3Es/CSht0xqBcUXS3qCAUVXwpRKsV2+arxeb65atasuXG9LykC9Ab10Cw3s2raH92ZqOeILaQbsB2ACg==", + "license": "MIT" + }, + "node_modules/@types/crypto-js": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@types/crypto-js/-/crypto-js-4.2.2.tgz", + "integrity": "sha512-sDOLlVbHhXpAUAL0YHDUUwDZf3iN4Bwi4W6a0W0b+QcAezUbRtH4FVb+9J4h+XFPW7l/gQ9F8qC7P+Ec4k8QVQ==", + "license": "MIT" + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/geojson": { + "version": "7946.0.16", + "resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz", + "integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==", + "license": "MIT" + }, + "node_modules/@types/google.maps": { + "version": "3.65.2", + "resolved": "https://registry.npmjs.org/@types/google.maps/-/google.maps-3.65.2.tgz", + "integrity": "sha512-e52bmOhGCQSNabFpL48iQlwJybq6rfns8NUVJ20MR7CdPlHQ2RmSCnPbJfrUYJfogrE4OiHQTZ4LXpop+eer1w==", + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "26.1.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-26.1.0.tgz", + "integrity": "sha512-O0A1G3xPGy4w7AgQdAQYUlQ+BKk2Oovw8eRpofyp5KdBZULnbe+WqaOVNrm705SHphCiG4XHsACrSmPu1f+Kgw==", + "license": "MIT", + "dependencies": { + "undici-types": "~8.3.0" + } + }, + "node_modules/@types/offscreencanvas": { + "version": "2019.7.3", + "resolved": "https://registry.npmjs.org/@types/offscreencanvas/-/offscreencanvas-2019.7.3.tgz", + "integrity": "sha512-ieXiYmgSRXUDeOntE1InxjWyvEelZGP63M+cGuquuRLuIKKT1osnkXjxev9B7d1nXSug5vpunx+gNlbVxMlC9A==", + "license": "MIT" + }, + "node_modules/@types/pako": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/@types/pako/-/pako-1.0.7.tgz", + "integrity": "sha512-YBtzT2ztNF6R/9+UXj2wTGFnC9NklAnASt3sC0h2m1bbH7G6FyBIkt4AN8ThZpNfxUo1b2iMVO0UawiJymEt8A==", + "license": "MIT" + }, + "node_modules/@types/react": { + "version": "19.2.17", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.17.tgz", + "integrity": "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.2.3", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", + "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^19.2.0" + } + }, + "node_modules/@types/retry": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/@types/retry/-/retry-0.12.0.tgz", + "integrity": "sha512-wWKOClTTiizcZhXnPY4wikVAwmdYHp8q6DmC+EJUzAMsycb7HB32Kh9RN4+0gExjmPmZSAQjgURXIGATPegAvA==", + "license": "MIT" + }, + "node_modules/@types/supercluster": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/@types/supercluster/-/supercluster-7.1.3.tgz", + "integrity": "sha512-Z0pOY34GDFl3Q6hUFYf3HkTwKEE02e7QgtJppBt+beEAxnyOpJua+voGFvxINBHa06GwLFFym7gRPY2SiKIfIA==", + "license": "MIT", + "dependencies": { + "@types/geojson": "*" + } + }, + "node_modules/@vis.gl/react-google-maps": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/@vis.gl/react-google-maps/-/react-google-maps-1.9.0.tgz", + "integrity": "sha512-ELL/zo8mRMPGDQZwHRClf2tE1/PMYYKjg+C/dUn8rmlQ0e+s7rkcNmCgaeBAV48GJzEcrBsr+vjDv4AvzJrlUA==", + "license": "MIT", + "dependencies": { + "@googlemaps/js-api-loader": "^2.0.2", + "@types/google.maps": "^3.64.0", + "fast-equals": "^6.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0 || ^19.0 || ^19.0.0-rc", + "react-dom": ">=16.8.0 || ^19.0 || ^19.0.0-rc" + } + }, + "node_modules/@vis.gl/react-google-maps/node_modules/fast-equals": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/fast-equals/-/fast-equals-6.0.0.tgz", + "integrity": "sha512-PFhhIGgdM79r5Uztdj9Zb6Tt1zKafqVfdMGwVca1z5z6fbX7DmsySSuJd8HiP6I1j505DCS83cLxo5rmSNeVEA==", + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-5.2.0.tgz", + "integrity": "sha512-YmKkfhOAi3wsB1PhJq5Scj3GXMn3WvtQ/JC0xoopuHoXSdmtdStOpFrYaT1kie2YgFBcIe64ROzMYRjCrYOdYw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.29.0", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-rc.3", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.18.0" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/@vitest/expect": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.7.tgz", + "integrity": "sha512-E8eBXaKibuvH2pSZErOjdVb5vF4PbKYcrnluBTYxEk1l/VhhwZg1kZQsdtjq+CsF5CFydf2Rdkz7jDHKSisi3w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/spy": "3.2.7", + "@vitest/utils": "3.2.7", + "chai": "^5.2.0", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.2.7.tgz", + "integrity": "sha512-Trr0hYO9CM3Wj6ksWHRhK9IZpIY6wTMO5u/MqXurMxT57sWBaOPEtP3Oq60ihZuh5JsiagKfz95OcxdEP6dBrA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "3.2.7", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.17" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.7.tgz", + "integrity": "sha512-KUHlwqVu0sRlhCdyPdQ/wBoTfRahjUky1MubOmYw9fWfIZy1gNoHpuaaQBPAaMaVYdQYHJLurzj8ECCj5OwTqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.2.7.tgz", + "integrity": "sha512-sB9y4ovltoQP+WaUPwmSxO9WIg9Ig694Di5PalVPsYHklAdE027mehpWF2SQSVq+k6sFgaivbTjTJwZLSHbedA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "3.2.7", + "pathe": "^2.0.3", + "strip-literal": "^3.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.2.7.tgz", + "integrity": "sha512-7C+MwShwtBSI5Buwoyg3s/iY1eHL9PKAf+O1wVh/TdnjXUtkoL/9YQtre90i4MtNXM6edP1wJ2zOBpfCyhIS7g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "3.2.7", + "magic-string": "^0.30.17", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.2.7.tgz", + "integrity": "sha512-Q2eQGI6d2L/hBtZ0qNuKcAGid68XK6cv1xsoaIma6PaJhHPoqcEJhYpXZ/5myCMqkNgtP6UKuBhbc0nHKnrkuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyspy": "^4.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.2.7.tgz", + "integrity": "sha512-x6BDOd7dyo3PFLY3I9/HJ25X/6OurhGXk2/B9gOZNPF7XDVjeBK4k01lQE5uvDpbuheErh91qYuE1E2OEjK3Rw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "3.2.7", + "loupe": "^3.1.4", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/a5-js": { + "version": "0.7.3", + "resolved": "https://registry.npmjs.org/a5-js/-/a5-js-0.7.3.tgz", + "integrity": "sha512-3aoMwHmNkyuMDHS4q6GRRInpOawamen2pokIbc0MQmR9cqG0Y9+B0bZpzswwetjrSG2ckbYtShH+nKru6+3O5Q==", + "license": "Apache-2.0", + "dependencies": { + "gl-matrix": "^3.4.3" + } + }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/anynum": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/anynum/-/anynum-1.0.1.tgz", + "integrity": "sha512-N6//FLET/tXYNM/F6ABca1oH6fWB+KlTt909Le28WMDBk8oaT4vY17DCrwg2MvmuqUKt3Ni4N5dGJ/EoBgcO6A==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT" + }, + "node_modules/apache-arrow": { + "version": "21.1.0", + "resolved": "https://registry.npmjs.org/apache-arrow/-/apache-arrow-21.1.0.tgz", + "integrity": "sha512-kQrYLxhC+NTVVZ4CCzGF6L/uPVOzJmD1T3XgbiUnP7oTeVFOFgEUu6IKNwCDkpFoBVqDKQivlX4RUFqqnWFlEA==", + "license": "Apache-2.0", + "dependencies": { + "@swc/helpers": "^0.5.11", + "@types/command-line-args": "^5.2.3", + "@types/command-line-usage": "^5.0.4", + "@types/node": "^24.0.3", + "command-line-args": "^6.0.1", + "command-line-usage": "^7.0.1", + "flatbuffers": "^25.1.24", + "json-bignum": "^0.0.3", + "tslib": "^2.6.2" + }, + "bin": { + "arrow2csv": "bin/arrow2csv.js" + } + }, + "node_modules/apache-arrow/node_modules/@types/node": { + "version": "24.13.2", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.2.tgz", + "integrity": "sha512-fRa09kZTgu8o71KFcDjUFuc7F+dEbZYZmkI0mg5YBTRs0yMKjYHsq/c0urDKeDb+D5qVgXOdFcuu+DZPKOITwA==", + "license": "MIT", + "dependencies": { + "undici-types": "~7.18.0" + } + }, + "node_modules/apache-arrow/node_modules/undici-types": { + "version": "7.18.2", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", + "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", + "license": "MIT" + }, + "node_modules/argparse": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", + "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", + "license": "MIT", + "dependencies": { + "sprintf-js": "~1.0.2" + } + }, + "node_modules/array-back": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/array-back/-/array-back-6.2.3.tgz", + "integrity": "sha512-SGDvmg6QTYiTxCBkYVmThcoa67uLl35pyzRHdpCGBOcqFy6BtwnphoFPk7LhJshD+Yk1Kt35WGWeZPTgwR4Fhw==", + "license": "MIT", + "engines": { + "node": ">=12.17" + } + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/baseline-browser-mapping": { + "version": "2.10.41", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.41.tgz", + "integrity": "sha512-WwS7MHhqGHHlaVsqRZnhvCEMS0owDX+SxRlve7JkuH7My1Ara3ZriTmCQupPfYjxMZ8I/tgxtJYr2t7taHaH4A==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/bignumber.js": { + "version": "9.3.1", + "resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-9.3.1.tgz", + "integrity": "sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ==", + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/brotli": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/brotli/-/brotli-1.3.3.tgz", + "integrity": "sha512-oTKjJdShmDuGW94SyyaoQvAjf30dZaHnjJ8uAF+u2/vGJkJbJPJAT1gDiOJP5v1Zb6f9KEyW/1HpuaWIXtGHPg==", + "license": "MIT", + "optional": true, + "dependencies": { + "base64-js": "^1.1.2" + } + }, + "node_modules/browserslist": { + "version": "4.28.4", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.4.tgz", + "integrity": "sha512-MTc8i/x9jBQd1iMw2CFGS+rwMa07eYjLR0CCTLDACl9xhxy+nIs3KeML/biicXtk9JrZ6dnnTatmc7ErPXIxqw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.10.38", + "caniuse-lite": "^1.0.30001799", + "electron-to-chromium": "^1.5.376", + "node-releases": "^2.0.48", + "update-browserslist-db": "^1.2.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/buf-compare": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/buf-compare/-/buf-compare-1.0.1.tgz", + "integrity": "sha512-Bvx4xH00qweepGc43xFvMs5BKASXTbHaHm6+kDYIK9p/4iFwjATQkmPKHQSgJZzKbAymhztRbXUf1Nqhzl73/Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/buffer-equal-constant-time": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", + "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", + "license": "BSD-3-Clause" + }, + "node_modules/cac": { + "version": "6.7.14", + "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", + "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001800", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001800.tgz", + "integrity": "sha512-MMHtuAz9Ys840zAY5F4k6fV5GaivZ9sPk+nz0mY+GYVzRBnYkN0mpqkSR92oWRQ19yQWo4HvBV/FnC16AJX8MA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/chai": { + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", + "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "assertion-error": "^2.0.1", + "check-error": "^2.1.1", + "deep-eql": "^5.0.1", + "loupe": "^3.1.0", + "pathval": "^2.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/chalk-template": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/chalk-template/-/chalk-template-0.4.0.tgz", + "integrity": "sha512-/ghrgmhfY8RaSdeo43hNXxpoHAtxdbskUHjPpfqUWGttFgycUhYPGx3YZBCnUCvOa7Doivn1IZec3DEGFoMgLg==", + "license": "MIT", + "dependencies": { + "chalk": "^4.1.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/chalk-template?sponsor=1" + } + }, + "node_modules/charenc": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/charenc/-/charenc-0.0.2.tgz", + "integrity": "sha512-yrLQ/yVUFXkzg7EDQsPieE/53+0RlaWTs+wBrvW36cyilJ2SaDWfl4Yj7MtLTXleV9uEKefbAGUPv2/iWSooRA==", + "license": "BSD-3-Clause", + "engines": { + "node": "*" + } + }, + "node_modules/check-error": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz", + "integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 16" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "license": "MIT" + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "dev": true, + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/command-line-args": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/command-line-args/-/command-line-args-6.0.2.tgz", + "integrity": "sha512-AIjYVxrV9X752LmPDLbVYv8aMCuHPSLZJXEo2qo/xJfv+NYhaZ4sMSF01rM+gHPaMgvPM0l5D/F+Qx+i2WfSmQ==", + "license": "MIT", + "dependencies": { + "array-back": "^6.2.3", + "find-replace": "^5.0.2", + "lodash.camelcase": "^4.3.0", + "typical": "^7.3.0" + }, + "engines": { + "node": ">=12.20" + }, + "peerDependencies": { + "@75lb/nature": "latest" + }, + "peerDependenciesMeta": { + "@75lb/nature": { + "optional": true + } + } + }, + "node_modules/command-line-usage": { + "version": "7.0.4", + "resolved": "https://registry.npmjs.org/command-line-usage/-/command-line-usage-7.0.4.tgz", + "integrity": "sha512-85UdvzTNx/+s5CkSgBm/0hzP80RFHAa7PsfeADE5ezZF3uHz3/Tqj9gIKGT9PTtpycc3Ua64T0oVulGfKxzfqg==", + "license": "MIT", + "dependencies": { + "array-back": "^6.2.2", + "chalk-template": "^0.4.0", + "table-layout": "^4.1.1", + "typical": "^7.3.0" + }, + "engines": { + "node": ">=12.20.0" + } + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/core-assert": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/core-assert/-/core-assert-0.2.1.tgz", + "integrity": "sha512-IG97qShIP+nrJCXMCgkNZgH7jZQ4n8RpPyPeXX++T6avR/KhLhgLiHKoEn5Rc1KjfycSfA9DMa6m+4C4eguHhw==", + "license": "MIT", + "dependencies": { + "buf-compare": "^1.0.0", + "is-error": "^2.2.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/core-util-is": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", + "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==", + "license": "MIT" + }, + "node_modules/crypt": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/crypt/-/crypt-0.0.2.tgz", + "integrity": "sha512-mCxBlsHFYh9C+HVpiEacem8FEBnMXgU9gy4zmNC+SXAZNB/1idgp/aulFJ4FgCi7GPEVbfyng092GqL2k2rmow==", + "license": "BSD-3-Clause", + "engines": { + "node": "*" + } + }, + "node_modules/cssstyle": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-4.6.0.tgz", + "integrity": "sha512-2z+rWdzbbSZv6/rhtvzvqeZQHrBaqgogqt85sqFNbabZOuFbCVFb8kPeEtZjiKkbrm395irpNKiYeFeLiQnFPg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^3.2.0", + "rrweb-cssom": "^0.8.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/cssstyle/node_modules/rrweb-cssom": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.8.0.tgz", + "integrity": "sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==", + "dev": true, + "license": "MIT" + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/d3-hexbin": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/d3-hexbin/-/d3-hexbin-0.2.2.tgz", + "integrity": "sha512-KS3fUT2ReD4RlGCjvCEm1RgMtp2NFZumdMu4DBzQK8AZv3fXRM6Xm8I4fSU07UXvH4xxg03NwWKWdvxfS/yc4w==", + "license": "BSD-3-Clause" + }, + "node_modules/data-uri-to-buffer": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz", + "integrity": "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, + "node_modules/data-urls": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-5.0.0.tgz", + "integrity": "sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^14.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/date-fns": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.4.0.tgz", + "integrity": "sha512-+1UMbeh68lH1SegH83CGWwpb6OHHbpSgr3+s5Eww5M4CAgswBpoWS0AjTOfEJ33HiYKz1hdj/KTFprzXHmq/6w==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/kossnocorp" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "dev": true, + "license": "MIT" + }, + "node_modules/deep-eql": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", + "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/deep-strict-equal": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/deep-strict-equal/-/deep-strict-equal-0.2.0.tgz", + "integrity": "sha512-3daSWyvZ/zwJvuMGlzG1O+Ow0YSadGfb3jsh9xoCutv2tWyB9dA4YvR9L9/fSdDZa2dByYQe+TqapSGUrjnkoA==", + "license": "MIT", + "dependencies": { + "core-assert": "^0.2.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/draco3d": { + "version": "1.5.7", + "resolved": "https://registry.npmjs.org/draco3d/-/draco3d-1.5.7.tgz", + "integrity": "sha512-m6WCKt/erDXcw+70IJXnG7M3awwQPAsZvJGX5zY7beBqpELw6RDGkYVU0W43AFxye4pDZ5i2Lbyc/NNGqwjUVQ==", + "license": "Apache-2.0" + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/earcut": { + "version": "2.2.4", + "resolved": "https://registry.npmjs.org/earcut/-/earcut-2.2.4.tgz", + "integrity": "sha512-/pjZsA1b4RPHbeWZQn66SWS8nZZWLQQ23oE3Eam7aroEFGEvwKAsJfZ9ytiEMycfzXWpca4FA9QIOehf7PocBQ==", + "license": "ISC" + }, + "node_modules/ecdsa-sig-formatter": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", + "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.385", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.385.tgz", + "integrity": "sha512-78sa/M08MNAYHQfjoWMvOlKQqZ0ElhSm/L5HNUc96VZ3b+KvDVnngFm8sYQy0XrhTRgAhggHr5abA7yTvRdo4Q==", + "dev": true, + "license": "ISC" + }, + "node_modules/entities": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-module-lexer": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "dev": true, + "license": "MIT" + }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/esbuild": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz", + "integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.1", + "@esbuild/android-arm": "0.28.1", + "@esbuild/android-arm64": "0.28.1", + "@esbuild/android-x64": "0.28.1", + "@esbuild/darwin-arm64": "0.28.1", + "@esbuild/darwin-x64": "0.28.1", + "@esbuild/freebsd-arm64": "0.28.1", + "@esbuild/freebsd-x64": "0.28.1", + "@esbuild/linux-arm": "0.28.1", + "@esbuild/linux-arm64": "0.28.1", + "@esbuild/linux-ia32": "0.28.1", + "@esbuild/linux-loong64": "0.28.1", + "@esbuild/linux-mips64el": "0.28.1", + "@esbuild/linux-ppc64": "0.28.1", + "@esbuild/linux-riscv64": "0.28.1", + "@esbuild/linux-s390x": "0.28.1", + "@esbuild/linux-x64": "0.28.1", + "@esbuild/netbsd-arm64": "0.28.1", + "@esbuild/netbsd-x64": "0.28.1", + "@esbuild/openbsd-arm64": "0.28.1", + "@esbuild/openbsd-x64": "0.28.1", + "@esbuild/openharmony-arm64": "0.28.1", + "@esbuild/sunos-x64": "0.28.1", + "@esbuild/win32-arm64": "0.28.1", + "@esbuild/win32-ia32": "0.28.1", + "@esbuild/win32-x64": "0.28.1" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/expect-type": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/extend": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", + "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", + "license": "MIT" + }, + "node_modules/fast-equals": { + "version": "5.4.0", + "resolved": "https://registry.npmjs.org/fast-equals/-/fast-equals-5.4.0.tgz", + "integrity": "sha512-jt2DW/aNFNwke7AUd+Z+e6pz39KO5rzdbbFCg2sGafS4mk13MI7Z8O5z9cADNn5lhGODIgLwug6TZO2ctf7kcw==", + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/fast-xml-builder": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.2.1.tgz", + "integrity": "sha512-tPb5TTWfgfVx5BNSi2xV0eLr89POeXXn0dXIsCJ9m1narrWxeIyx6je9d7Rce/3NyXLbvuQmLkxq+RuxMWejvw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "dependencies": { + "path-expression-matcher": "^1.5.0", + "xml-naming": "^0.1.0" + } + }, + "node_modules/fast-xml-parser": { + "version": "5.10.1", + "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.10.1.tgz", + "integrity": "sha512-IEMIf7298kXuZSRFoGfMYrl7is8LpavODgbNz1cwIudv7KwVFnuU+UsMporfq6PD6aXSlawZlARiA3UywCTfMw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "dependencies": { + "@nodable/entities": "^3.0.0", + "fast-xml-builder": "^1.2.0", + "is-unsafe": "^2.0.0", + "path-expression-matcher": "^1.6.2", + "strnum": "^2.4.1", + "xml-naming": "^0.3.0" + }, + "bin": { + "fxparser": "src/cli/cli.js" + } + }, + "node_modules/fast-xml-parser/node_modules/xml-naming": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/xml-naming/-/xml-naming-0.3.0.tgz", + "integrity": "sha512-ghig2TBE/H11aOVgmahA3MhimvkBr6JIYknH/Dhdk10nXwdbIqBJsbfMxpvFPG8bAw77gN29aQWvKpmVoPlvPQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fetch-blob": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/fetch-blob/-/fetch-blob-3.2.0.tgz", + "integrity": "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/jimmywarting" + }, + { + "type": "paypal", + "url": "https://paypal.me/jimmywarting" + } + ], + "license": "MIT", + "dependencies": { + "node-domexception": "^1.0.0", + "web-streams-polyfill": "^3.0.3" + }, + "engines": { + "node": "^12.20 || >= 14.13" + } + }, + "node_modules/fflate": { + "version": "0.7.4", + "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.7.4.tgz", + "integrity": "sha512-5u2V/CDW15QM1XbbgS+0DfPxVB+jUKhWEKuuFuHncbk3tEEqzmoXL+2KyOFuKGqOnmdIy0/davWF1CkuwtibCw==", + "license": "MIT" + }, + "node_modules/find-replace": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/find-replace/-/find-replace-5.0.2.tgz", + "integrity": "sha512-Y45BAiE3mz2QsrN2fb5QEtO4qb44NcS7en/0y9PEVsg351HsLeVclP8QPMH79Le9sH3rs5RSwJu99W0WPZO43Q==", + "license": "MIT", + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "@75lb/nature": "latest" + }, + "peerDependenciesMeta": { + "@75lb/nature": { + "optional": true + } + } + }, + "node_modules/flatbuffers": { + "version": "25.9.23", + "resolved": "https://registry.npmjs.org/flatbuffers/-/flatbuffers-25.9.23.tgz", + "integrity": "sha512-MI1qs7Lo4Syw0EOzUl0xjs2lsoeqFku44KpngfIduHBYvzm8h2+7K8YMQh1JtVVVrUvhLpNwqVi4DERegUJhPQ==", + "license": "Apache-2.0" + }, + "node_modules/form-data": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz", + "integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.4", + "mime-types": "^2.1.35" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/formdata-polyfill": { + "version": "4.0.10", + "resolved": "https://registry.npmjs.org/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz", + "integrity": "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==", + "license": "MIT", + "dependencies": { + "fetch-blob": "^3.1.2" + }, + "engines": { + "node": ">=12.20.0" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gaxios": { + "version": "7.1.5", + "resolved": "https://registry.npmjs.org/gaxios/-/gaxios-7.1.5.tgz", + "integrity": "sha512-5FZy72Rh8LhtjmvDrKkI+lVhrsQrVKVsItxMoDm5mNQE+xR0WVIIs+jzPSJgBvKVsLi24fZhXJIsNI0bihDzFg==", + "license": "Apache-2.0", + "dependencies": { + "extend": "^3.0.2", + "https-proxy-agent": "^7.0.1", + "node-fetch": "^3.3.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/gcp-metadata": { + "version": "8.1.2", + "resolved": "https://registry.npmjs.org/gcp-metadata/-/gcp-metadata-8.1.2.tgz", + "integrity": "sha512-zV/5HKTfCeKWnxG0Dmrw51hEWFGfcF2xiXqcA3+J90WDuP0SvoiSO5ORvcBsifmx/FoIjgQN3oNOGaQ5PhLFkg==", + "license": "Apache-2.0", + "dependencies": { + "gaxios": "^7.0.0", + "google-logging-utils": "^1.0.0", + "json-bigint": "^1.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gl-matrix": { + "version": "3.4.4", + "resolved": "https://registry.npmjs.org/gl-matrix/-/gl-matrix-3.4.4.tgz", + "integrity": "sha512-latSnyDNt/8zYUB6VIJ6PCh2jBjJX6gnDsoCZ7LyW7GkqrD51EWwa9qCoGixj8YqBtETQK/xY7OmpTF8xz1DdQ==", + "license": "MIT" + }, + "node_modules/google-auth-library": { + "version": "10.9.0", + "resolved": "https://registry.npmjs.org/google-auth-library/-/google-auth-library-10.9.0.tgz", + "integrity": "sha512-xtvUqvINPhTaBm7nXqlYPcrMHJPm1lCNdSovxnKKhTm+4JsvQ+KGVYJViLoH9Yxu8w+T0Qv5HubzYT9BLrppJg==", + "license": "Apache-2.0", + "dependencies": { + "base64-js": "^1.3.0", + "ecdsa-sig-formatter": "^1.0.11", + "gaxios": "^7.1.4", + "gcp-metadata": "8.1.2", + "google-logging-utils": "1.1.3", + "jws": "^4.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/google-logging-utils": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/google-logging-utils/-/google-logging-utils-1.1.3.tgz", + "integrity": "sha512-eAmLkjDjAFCVXg7A1unxHsLf961m6y17QFqXqAXGj/gVkKFrEICfStRfwUlGNfeCEjNRa32JEWOUTlYXPyyKvA==", + "license": "Apache-2.0", + "engines": { + "node": ">=14" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/h3-js": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/h3-js/-/h3-js-4.5.0.tgz", + "integrity": "sha512-uKmdPc+DuarnR+XqZxEuWOMw7KzzKROrx3MLeJpFnMOs78S9M5eZ+X5RieS9UcSFQqbeXzbfWuX/W9Pyajinqw==", + "license": "Apache-2.0", + "engines": { + "node": ">=4", + "npm": ">=3", + "yarn": ">=1.3.0" + } + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "dev": true, + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/html-encoding-sniffer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-4.0.0.tgz", + "integrity": "sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-encoding": "^3.1.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/http-proxy-agent": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/image-size": { + "version": "0.7.5", + "resolved": "https://registry.npmjs.org/image-size/-/image-size-0.7.5.tgz", + "integrity": "sha512-Hiyv+mXHfFEP7LzUL/llg9RwFxxY+o9N3JVLIeG5E7iFIFAalxvRU9UZthBdYDEVnzHMgjnKJPPpay5BWf1g9g==", + "license": "MIT", + "bin": { + "image-size": "bin/image-size.js" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/immediate": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz", + "integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==", + "license": "MIT" + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/is-buffer": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/is-buffer/-/is-buffer-1.1.6.tgz", + "integrity": "sha512-NcdALwpXkTm5Zvvbk7owOUSvVvBKDgKP5/ewfXEznmQFfs4ZRmanOeKBTjRVjka3QFoN6XJ+9F3USqfHqTaU5w==", + "license": "MIT" + }, + "node_modules/is-error": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/is-error/-/is-error-2.2.2.tgz", + "integrity": "sha512-IOQqts/aHWbiisY5DuPJQ0gcbvaLFCa7fBa9xoLfxBZvQ+ZI/Zh9xoI7Gk+G64N0FdK4AbibytHht2tWgpJWLg==", + "license": "MIT" + }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/is-unsafe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/is-unsafe/-/is-unsafe-2.0.0.tgz", + "integrity": "sha512-2LdV822R+wmI86unXA93WCFpL6g+av8ynWk0nrHyJqGop5VoocYsSLFgN8jrfalT6iGeLNM4KXuVSsULP53kEA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT" + }, + "node_modules/isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", + "license": "MIT" + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/jsdom": { + "version": "25.0.1", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-25.0.1.tgz", + "integrity": "sha512-8i7LzZj7BF8uplX+ZyOlIz86V6TAsSs+np6m1kpW9u0JWi4z/1t+FzcK1aek+ybTnAC4KhBL4uXCNT0wcUIeCw==", + "dev": true, + "license": "MIT", + "dependencies": { + "cssstyle": "^4.1.0", + "data-urls": "^5.0.0", + "decimal.js": "^10.4.3", + "form-data": "^4.0.0", + "html-encoding-sniffer": "^4.0.0", + "http-proxy-agent": "^7.0.2", + "https-proxy-agent": "^7.0.5", + "is-potential-custom-element-name": "^1.0.1", + "nwsapi": "^2.2.12", + "parse5": "^7.1.2", + "rrweb-cssom": "^0.7.1", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^5.0.0", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^7.0.0", + "whatwg-encoding": "^3.1.1", + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^14.0.0", + "ws": "^8.18.0", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "canvas": "^2.11.2" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json-bigint": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-bigint/-/json-bigint-1.0.0.tgz", + "integrity": "sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ==", + "license": "MIT", + "dependencies": { + "bignumber.js": "^9.0.0" + } + }, + "node_modules/json-bignum": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/json-bignum/-/json-bignum-0.0.3.tgz", + "integrity": "sha512-2WHyXj3OfHSgNyuzDbSxI1w2jgw5gkWSWhS7Qg4bWXx1nLk3jnbwfUeS0PSba3IzpTUWdHxBieELUzXRjQB2zg==", + "engines": { + "node": ">=0.8" + } + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/jszip": { + "version": "3.10.1", + "resolved": "https://registry.npmjs.org/jszip/-/jszip-3.10.1.tgz", + "integrity": "sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==", + "license": "(MIT OR GPL-3.0-or-later)", + "dependencies": { + "lie": "~3.3.0", + "pako": "~1.0.2", + "readable-stream": "~2.3.6", + "setimmediate": "^1.0.5" + } + }, + "node_modules/jwa": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz", + "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==", + "license": "MIT", + "dependencies": { + "buffer-equal-constant-time": "^1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/jws": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz", + "integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==", + "license": "MIT", + "dependencies": { + "jwa": "^2.0.1", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/kdbush": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/kdbush/-/kdbush-4.1.0.tgz", + "integrity": "sha512-e9vurzrXJQrFX6ckpHP3bvj5l+9CnYzkxDNnNQ1h2QTqdWsUAJgXiKdGNcOa1EY85dU8KbQ+z/FdQdB7P+9yfQ==", + "license": "ISC" + }, + "node_modules/ktx-parse": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/ktx-parse/-/ktx-parse-0.7.1.tgz", + "integrity": "sha512-FeA3g56ksdFNwjXJJsc1CCc7co+AJYDp6ipIp878zZ2bU8kWROatLYf39TQEd4/XRSUvBXovQ8gaVKWPXsCLEQ==", + "license": "MIT" + }, + "node_modules/lie": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz", + "integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==", + "license": "MIT", + "dependencies": { + "immediate": "~3.0.5" + } + }, + "node_modules/lodash.camelcase": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/lodash.camelcase/-/lodash.camelcase-4.3.0.tgz", + "integrity": "sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA==", + "license": "MIT" + }, + "node_modules/long": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/long/-/long-3.2.0.tgz", + "integrity": "sha512-ZYvPPOMqUwPoDsbJaR10iQJYnMuZhRTvHYl62ErLIEX7RgFlziSBUUvrt3OVfc47QlHHpzPZYP17g3Fv7oeJkg==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/loupe": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", + "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/lucide-react": { + "version": "1.23.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.23.0.tgz", + "integrity": "sha512-38BpJcD0JhFosxHApP/BYsBetLpQFRoTRzEzstM/XCc3jsAG7wqaY1lgVwxiUe3xqYE+lNxo2PkCmYwXWrwwIw==", + "license": "ISC", + "peerDependencies": { + "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/lz4js": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/lz4js/-/lz4js-0.2.0.tgz", + "integrity": "sha512-gY2Ia9Lm7Ep8qMiuGRhvUq0Q7qUereeldZPP1PMEJxPtEWHJLqw9pgX68oHajBH0nzJK4MaZEA/YNV3jT8u8Bg==", + "license": "ISC", + "optional": true + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/md5": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/md5/-/md5-2.3.0.tgz", + "integrity": "sha512-T1GITYmFaKuO91vxyoQMFETst+O71VUPEU3ze5GNzDm0OWdP8v1ziTaAEPUr/3kLsY3Sftgz242A1SetQiDL7g==", + "license": "BSD-3-Clause", + "dependencies": { + "charenc": "0.0.2", + "crypt": "0.0.2", + "is-buffer": "~1.1.6" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "dev": true, + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mjolnir.js": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/mjolnir.js/-/mjolnir.js-3.0.0.tgz", + "integrity": "sha512-siX3YCG7N2HnmN1xMH3cK4JkUZJhbkhRFJL+G5N1vH0mh1t5088rJknIoqDFWDIU6NPGvRRgLnYW3ZHjSMEBLA==", + "license": "MIT" + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.16", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz", + "integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/node-domexception": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz", + "integrity": "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==", + "deprecated": "Use your platform's native DOMException instead", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/jimmywarting" + }, + { + "type": "github", + "url": "https://paypal.me/jimmywarting" + } + ], + "license": "MIT", + "engines": { + "node": ">=10.5.0" + } + }, + "node_modules/node-fetch": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-3.3.2.tgz", + "integrity": "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==", + "license": "MIT", + "dependencies": { + "data-uri-to-buffer": "^4.0.0", + "fetch-blob": "^3.1.4", + "formdata-polyfill": "^4.0.10" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/node-fetch" + } + }, + "node_modules/node-releases": { + "version": "2.0.50", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.50.tgz", + "integrity": "sha512-J6l92tKHX6w8Jy5nO1Vuc01NoIiRGi/d6qBKVxh+IQ8Cr3b6HbVNfKiF8ZpFKufTwpwxMmce2W3iQZ861ZRyTg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/nwsapi": { + "version": "2.2.24", + "resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.24.tgz", + "integrity": "sha512-7YRhZ3jS45LwmSCT4b2sVFHt/WuovaktDU07QrtOBY2PXskss5a9jfmR9jptyumwXST+rFjrmppMY1KT/yn35A==", + "dev": true, + "license": "MIT" + }, + "node_modules/p-retry": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/p-retry/-/p-retry-4.6.2.tgz", + "integrity": "sha512-312Id396EbJdvRONlngUx0NydfrIQ5lsYu0znKVUzVvArzEIt08V1qhtyESbGVd1FGX7UKtiFp5uwKZdM8wIuQ==", + "license": "MIT", + "dependencies": { + "@types/retry": "0.12.0", + "retry": "^0.13.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/pako": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz", + "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==", + "license": "(MIT AND Zlib)" + }, + "node_modules/parse5": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz", + "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^6.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/path-expression-matcher": { + "version": "1.6.2", + "resolved": "https://registry.npmjs.org/path-expression-matcher/-/path-expression-matcher-1.6.2.tgz", + "integrity": "sha512-enSlaiat05iasnzmgNxRj8reFdj3puY2QpNgP1aPIaVfT6nn9ICuPoFlKHk8EN22HcwewshO+mN2DGbkCEOtqQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/pathval": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz", + "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.16" + } + }, + "node_modules/pbf": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/pbf/-/pbf-3.3.0.tgz", + "integrity": "sha512-XDF38WCH3z5OV/OVa8GKUNtLAyneuzbCisx7QUCF8Q6Nutx0WnJrQe5O+kOtBlLfRNUws98Y58Lblp+NJG5T4Q==", + "license": "BSD-3-Clause", + "dependencies": { + "ieee754": "^1.1.12", + "resolve-protobuf-schema": "^2.1.0" + }, + "bin": { + "pbf": "bin/pbf" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/playwright-core": { + "version": "1.56.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.56.1.tgz", + "integrity": "sha512-hutraynyn31F+Bifme+Ps9Vq59hKuUCz7H1kDOcBs+2oGguKkWTU50bBWrtz34OUWmIwpBTWDxaRPXrIXkgvmQ==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/postcss": { + "version": "8.5.23", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.23.tgz", + "integrity": "sha512-g50586zr4bZmwFiTlflMu8E0bDTb5I5gertgwAKmsdUlTQIhZtunzUlD1WSzwcVWPoAVpsrA6vlfCD7oXvRwgg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.16", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/process-nextick-args": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", + "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", + "license": "MIT" + }, + "node_modules/protobufjs": { + "version": "7.6.5", + "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.6.5.tgz", + "integrity": "sha512-/FPD0nUc9jH6rfFjji9IBqOz4pcSE3CsT1m7Ep6Mdb0LxSUMj8hgl6GomOvZzpNpAqqGaXA0P3VSrZLFzIhQrw==", + "hasInstallScript": true, + "license": "BSD-3-Clause", + "dependencies": { + "@protobufjs/aspromise": "^1.1.2", + "@protobufjs/base64": "^1.1.2", + "@protobufjs/codegen": "^2.0.5", + "@protobufjs/eventemitter": "^1.1.1", + "@protobufjs/fetch": "^1.1.1", + "@protobufjs/float": "^1.0.2", + "@protobufjs/path": "^1.1.2", + "@protobufjs/pool": "^1.1.0", + "@protobufjs/utf8": "^1.1.1", + "@types/node": ">=13.7.0", + "long": "^5.3.2" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/protobufjs/node_modules/long": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz", + "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==", + "license": "Apache-2.0" + }, + "node_modules/protocol-buffers-schema": { + "version": "3.6.1", + "resolved": "https://registry.npmjs.org/protocol-buffers-schema/-/protocol-buffers-schema-3.6.1.tgz", + "integrity": "sha512-VG2K63Igkiv9p76tk1lilczEK1cT+kCjKtkdhw1dQZV3k3IXJbd3o6Ho8b9zJZaHSnT2hKe4I+ObmX9w6m5SmQ==", + "license": "MIT" + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/react": { + "version": "19.2.7", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.7.tgz", + "integrity": "sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.2.7", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.7.tgz", + "integrity": "sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.27.0" + }, + "peerDependencies": { + "react": "^19.2.7" + } + }, + "node_modules/react-refresh": { + "version": "0.18.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.18.0.tgz", + "integrity": "sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "license": "MIT", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/readable-stream/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "license": "MIT" + }, + "node_modules/resolve-protobuf-schema": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/resolve-protobuf-schema/-/resolve-protobuf-schema-2.1.0.tgz", + "integrity": "sha512-kI5ffTiZWmJaS/huM8wZfEMer1eRd7oJQhDuxeCLe3t7N7mX3z94CN0xPxBQxFYQTSNz9T0i+v6inKqSdK8xrQ==", + "license": "MIT", + "dependencies": { + "protocol-buffers-schema": "^3.3.1" + } + }, + "node_modules/retry": { + "version": "0.13.1", + "resolved": "https://registry.npmjs.org/retry/-/retry-0.13.1.tgz", + "integrity": "sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg==", + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/rollup": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.2.tgz", + "integrity": "sha512-RFnrW4lhXA3s3eqHDZvN654g8OTjzRfqpIRJYczCGB6HzphckVAi/Qh4tbPUbRuDi7s1Llv8g/NspLkttY3gTA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.62.2", + "@rollup/rollup-android-arm64": "4.62.2", + "@rollup/rollup-darwin-arm64": "4.62.2", + "@rollup/rollup-darwin-x64": "4.62.2", + "@rollup/rollup-freebsd-arm64": "4.62.2", + "@rollup/rollup-freebsd-x64": "4.62.2", + "@rollup/rollup-linux-arm-gnueabihf": "4.62.2", + "@rollup/rollup-linux-arm-musleabihf": "4.62.2", + "@rollup/rollup-linux-arm64-gnu": "4.62.2", + "@rollup/rollup-linux-arm64-musl": "4.62.2", + "@rollup/rollup-linux-loong64-gnu": "4.62.2", + "@rollup/rollup-linux-loong64-musl": "4.62.2", + "@rollup/rollup-linux-ppc64-gnu": "4.62.2", + "@rollup/rollup-linux-ppc64-musl": "4.62.2", + "@rollup/rollup-linux-riscv64-gnu": "4.62.2", + "@rollup/rollup-linux-riscv64-musl": "4.62.2", + "@rollup/rollup-linux-s390x-gnu": "4.62.2", + "@rollup/rollup-linux-x64-gnu": "4.62.2", + "@rollup/rollup-linux-x64-musl": "4.62.2", + "@rollup/rollup-openbsd-x64": "4.62.2", + "@rollup/rollup-openharmony-arm64": "4.62.2", + "@rollup/rollup-win32-arm64-msvc": "4.62.2", + "@rollup/rollup-win32-ia32-msvc": "4.62.2", + "@rollup/rollup-win32-x64-gnu": "4.62.2", + "@rollup/rollup-win32-x64-msvc": "4.62.2", + "fsevents": "~2.3.2" + } + }, + "node_modules/rrweb-cssom": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.7.1.tgz", + "integrity": "sha512-TrEMa7JGdVm0UThDJSx7ddw5nVm3UJS9o9CCIZ72B1vSyEZoziDqBYP3XIoi/12lKrJR8rE3jeFHMok2F/Mnsg==", + "dev": true, + "license": "MIT" + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "dev": true, + "license": "MIT" + }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dev": true, + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, + "node_modules/scheduler": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "license": "MIT" + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/setimmediate": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz", + "integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==", + "license": "MIT" + }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, + "node_modules/snappyjs": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/snappyjs/-/snappyjs-0.6.1.tgz", + "integrity": "sha512-YIK6I2lsH072UE0aOFxxY1dPDCS43I5ktqHpeAsuLNYWkE5pGxRGWfDM4/vSUfNzXjC1Ivzt3qx31PCLmc9yqg==", + "license": "MIT" + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/sprintf-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", + "integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==", + "license": "BSD-3-Clause" + }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "dev": true, + "license": "MIT" + }, + "node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, + "node_modules/string_decoder/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "license": "MIT" + }, + "node_modules/strip-literal": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-3.1.0.tgz", + "integrity": "sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "js-tokens": "^9.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/strip-literal/node_modules/js-tokens": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz", + "integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/strnum": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/strnum/-/strnum-2.4.1.tgz", + "integrity": "sha512-M9eUSMT2dCB2cTNPG7UYj6KuK7RJR2SN2+yCV/fTW3xzTCS6EaGZ5pSMgDIjB7r8zSfTGk+dvvn9rTjpVS9Mwg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "dependencies": { + "anynum": "^1.0.1" + } + }, + "node_modules/supercluster": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/supercluster/-/supercluster-8.0.1.tgz", + "integrity": "sha512-IiOea5kJ9iqzD2t7QJq/cREyLHTtSmUT6gQsweojg9WH2sYJqZK9SswTu6jrscO6D1G5v5vYZ9ru/eq85lXeZQ==", + "license": "ISC", + "dependencies": { + "kdbush": "^4.0.2" + } + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "dev": true, + "license": "MIT" + }, + "node_modules/table-layout": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/table-layout/-/table-layout-4.1.1.tgz", + "integrity": "sha512-iK5/YhZxq5GO5z8wb0bY1317uDF3Zjpha0QFFLA8/trAoiLbQD0HUbMesEaxyzUgDxi2QlcbM8IvqOlEjgoXBA==", + "license": "MIT", + "dependencies": { + "array-back": "^6.2.2", + "wordwrapjs": "^5.1.0" + }, + "engines": { + "node": ">=12.17" + } + }, + "node_modules/terra-draw": { + "version": "1.31.2", + "resolved": "https://registry.npmjs.org/terra-draw/-/terra-draw-1.31.2.tgz", + "integrity": "sha512-jyHodIOZVsyqUeZpkZCrsrLrEgcnWxZNlwZH64RUwKWP0o77qhub+iSCpV1XTESXOi2c1LQ87cHe1slk30L1ig==", + "license": "MIT" + }, + "node_modules/terra-draw-google-maps-adapter": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/terra-draw-google-maps-adapter/-/terra-draw-google-maps-adapter-1.6.1.tgz", + "integrity": "sha512-YCmMaOUSnltFcRdCTPwfOgb6CMGLK2gIGwuf7Iuk0sX1dzwChpMZWyEhIJDMJeg0f6gN3XNn1OpOz4jfKCQRBQ==", + "license": "MIT", + "peerDependencies": { + "@googlemaps/js-api-loader": "^1.14.3 || ^2.0.0", + "terra-draw": "^1.0.0" + } + }, + "node_modules/texture-compressor": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/texture-compressor/-/texture-compressor-1.0.2.tgz", + "integrity": "sha512-dStVgoaQ11mA5htJ+RzZ51ZxIZqNOgWKAIvtjLrW1AliQQLCmrDqNzQZ8Jh91YealQ95DXt4MEduLzJmbs6lig==", + "license": "MIT", + "dependencies": { + "argparse": "^1.0.10", + "image-size": "^0.7.4" + }, + "bin": { + "texture-compressor": "bin/texture-compressor.js" + } + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", + "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinypool": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz", + "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + } + }, + "node_modules/tinyrainbow": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-2.0.0.tgz", + "integrity": "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tinyspy": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-4.0.4.tgz", + "integrity": "sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tldts": { + "version": "6.1.86", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-6.1.86.tgz", + "integrity": "sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^6.1.86" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "6.1.86", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-6.1.86.tgz", + "integrity": "sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA==", + "dev": true, + "license": "MIT" + }, + "node_modules/tough-cookie": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-5.1.2.tgz", + "integrity": "sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^6.1.32" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/tr46": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-5.1.1.tgz", + "integrity": "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/typescript": { + "version": "5.8.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz", + "integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/typical": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/typical/-/typical-7.3.0.tgz", + "integrity": "sha512-ya4mg/30vm+DOWfBg4YK3j2WD6TWtRkCbasOJr40CseYENzCUby/7rIvXA99JGsQHeNxLbnXdyLLxKSv3tauFw==", + "license": "MIT", + "engines": { + "node": ">=12.17" + } + }, + "node_modules/undici-types": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz", + "integrity": "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==", + "license": "MIT" + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "license": "MIT" + }, + "node_modules/vite": { + "version": "7.3.6", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.6.tgz", + "integrity": "sha512-4XP60spRGjSZFf1qYH+dJIkK2znL3zQfl9KkOV9MkkRR/3Dls0dxaBsQPTloEc5BLXWPL9vsOxopxyKoMmDueg==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.27.0 || ^0.28.0", + "fdir": "^6.5.0", + "picomatch": "^4.0.3", + "postcss": "^8.5.6", + "rollup": "^4.43.0", + "tinyglobby": "^0.2.15" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "lightningcss": "^1.21.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vite-node": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-3.2.4.tgz", + "integrity": "sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cac": "^6.7.14", + "debug": "^4.4.1", + "es-module-lexer": "^1.7.0", + "pathe": "^2.0.3", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" + }, + "bin": { + "vite-node": "vite-node.mjs" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/vitest": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.7.tgz", + "integrity": "sha512-KrxIJ62Fd89gfysR4WotlgZABiz2dqFPgqGzX7s+CwsqLFomRH7777ZcrOD6+WVAh7khPQP41A+BKbpcJFrdEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/expect": "3.2.7", + "@vitest/mocker": "3.2.7", + "@vitest/pretty-format": "^3.2.7", + "@vitest/runner": "3.2.7", + "@vitest/snapshot": "3.2.7", + "@vitest/spy": "3.2.7", + "@vitest/utils": "3.2.7", + "chai": "^5.2.0", + "debug": "^4.4.1", + "expect-type": "^1.2.1", + "magic-string": "^0.30.17", + "pathe": "^2.0.3", + "picomatch": "^4.0.2", + "std-env": "^3.9.0", + "tinybench": "^2.9.0", + "tinyexec": "^0.3.2", + "tinyglobby": "^0.2.14", + "tinypool": "^1.1.1", + "tinyrainbow": "^2.0.0", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0", + "vite-node": "3.2.4", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@types/debug": "^4.1.12", + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "@vitest/browser": "3.2.7", + "@vitest/ui": "3.2.7", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@types/debug": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, + "node_modules/w3c-xmlserializer": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/web-streams-polyfill": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz", + "integrity": "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==", + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/webidl-conversions": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz", + "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/whatwg-encoding": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz", + "integrity": "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==", + "deprecated": "Use @exodus/bytes instead for a more spec-conformant and faster implementation", + "dev": true, + "license": "MIT", + "dependencies": { + "iconv-lite": "0.6.3" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/whatwg-mimetype": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz", + "integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/whatwg-url": { + "version": "14.2.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-14.2.0.tgz", + "integrity": "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tr46": "^5.1.0", + "webidl-conversions": "^7.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wordwrapjs": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/wordwrapjs/-/wordwrapjs-5.1.1.tgz", + "integrity": "sha512-0yweIbkINJodk27gX9LBGMzyQdBDan3s/dEAiwBOj+Mf0PPyWL6/rikalkv8EeD0E8jm4o5RXEOrFTP3NXbhJg==", + "license": "MIT", + "engines": { + "node": ">=12.17" + } + }, + "node_modules/ws": { + "version": "8.21.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz", + "integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/xml-naming": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/xml-naming/-/xml-naming-0.1.0.tgz", + "integrity": "sha512-k8KO9hrMyNk6tUWqUfkTEZbezRRpONVOzUTnc97VnCvyj6Tf9lyUR9EDAIeiVLv56jsMcoXEwjW8Kv5yPY52lw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true, + "license": "MIT" + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + }, + "node_modules/zod": { + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/zod-to-json-schema": { + "version": "3.25.2", + "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.2.tgz", + "integrity": "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==", + "dev": true, + "license": "ISC", + "peerDependencies": { + "zod": "^3.25.28 || ^4" + } + }, + "node_modules/zstd-codec": { + "version": "0.1.5", + "resolved": "https://registry.npmjs.org/zstd-codec/-/zstd-codec-0.1.5.tgz", + "integrity": "sha512-v3fyjpK8S/dpY/X5WxqTK3IoCnp/ZOLxn144GZVlNUjtwAchzrVo03h+oMATFhCIiJ5KTr4V3vDQQYz4RU684g==", + "license": "MIT", + "optional": true + }, + "node_modules/zustand": { + "version": "5.0.14", + "resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.14.tgz", + "integrity": "sha512-/8tAspM5LMPr28b3fwLYrtdj77ECpfZviaP75CMTnwO8ISyaE4GDIG/9rDDYq/cH9D2Xw2A2RXglLInmVBQB/g==", + "license": "MIT", + "engines": { + "node": ">=12.20.0" + }, + "peerDependencies": { + "@types/react": ">=18.0.0", + "immer": ">=9.0.6", + "react": ">=18.0.0", + "use-sync-external-store": ">=1.2.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "immer": { + "optional": true + }, + "react": { + "optional": true + }, + "use-sync-external-store": { + "optional": true + } + } + } + } +} diff --git a/demos/real-world-reasoning-agent/package.json b/demos/real-world-reasoning-agent/package.json new file mode 100644 index 0000000..08008a0 --- /dev/null +++ b/demos/real-world-reasoning-agent/package.json @@ -0,0 +1,73 @@ +{ + "name": "real-world-reasoning-agent", + "version": "1.0.0", + "description": "Atlas: a Google Maps Platform and Gemini copilot demo for six live-map journeys.", + "scripts": { + "test": "npm run typecheck && npm run test:unit && npm run test:sources", + "test:unit": "vitest run", + "test:fast": "vitest run --changed origin/main --reporter=dot", + "test:changed": "vitest run --changed origin/main", + "test:sources": "node --test scripts/check-skill-sources.test.mjs", + "dev": "vite", + "build": "tsc --noEmit && vite build", + "preview": "npm run build && cd ../.. && node gateway/server.js", + "typecheck": "tsc --noEmit", + "smoke": "node scripts/smoke.mjs", + "smoke:mission": "node scripts/mission-smoke.mjs", + "mission-smoke": "node scripts/mission-smoke.mjs", + "eval": "vitest run src/mission/evalScore.test.ts", + "eval:agent": "vitest run src/ai/rubrics.test.ts --reporter=dot", + "eval:trace": "vitest run src/ai/traceEval.test.ts --reporter=dot", + "eval:explorer": "vitest run src/eval/explorer/grader.test.ts --reporter=dot && vite-node scripts/eval-explorer.ts", + "eval:report": "vite-node scripts/eval-remote.ts" + }, + "repository": { + "type": "git", + "url": "https://github.com/ryanbaumann/fieldwork.git", + "directory": "demos/real-world-reasoning-agent" + }, + "keywords": [ + "google-maps", + "gemini", + "deck.gl", + "react", + "vite", + "maps-demo" + ], + "author": "Ryan Baumann (https://github.com/ryanbaumann)", + "license": "MIT", + "engines": { + "node": ">=20.19" + }, + "dependencies": { + "@deck.gl/aggregation-layers": "9.3.6", + "@deck.gl/core": "9.3.6", + "@deck.gl/geo-layers": "9.3.6", + "@deck.gl/google-maps": "9.3.6", + "@deck.gl/layers": "9.3.6", + "@fontsource-variable/inter": "5.2.8", + "@fontsource-variable/space-grotesk": "5.2.10", + "@google/genai": "2.10.0", + "@googlemaps/markerclusterer": "2.6.2", + "@vis.gl/react-google-maps": "1.9.0", + "lucide-react": "1.23.0", + "react": "19.2.7", + "react-dom": "19.2.7", + "terra-draw": "^1.31.2", + "terra-draw-google-maps-adapter": "^1.6.1", + "zustand": "5.0.14" + }, + "type": "module", + "devDependencies": { + "@a2ui/web_core": "0.10.4", + "@types/google.maps": "3.65.2", + "@types/react": "19.2.17", + "@types/react-dom": "19.2.3", + "@vitejs/plugin-react": "5.2.0", + "jsdom": "25.0.1", + "playwright-core": "1.56.1", + "typescript": "5.8.3", + "vite": "7.3.6", + "vitest": "3.2.7" + } +} diff --git a/demos/real-world-reasoning-agent/scripts/check-skill-sources.mjs b/demos/real-world-reasoning-agent/scripts/check-skill-sources.mjs new file mode 100644 index 0000000..1be4a23 --- /dev/null +++ b/demos/real-world-reasoning-agent/scripts/check-skill-sources.mjs @@ -0,0 +1,58 @@ +#!/usr/bin/env node +import { readFile } from 'node:fs/promises'; +import { pathToFileURL } from 'node:url'; + +export function missingDeclaredPaths(treePaths, declaredPaths) { + return declaredPaths.filter((declared) => + !treePaths.some((actual) => actual === declared || actual.startsWith(`${declared}/`)), + ); +} + +export async function validateSkillSources(manifest, { fetchImpl = fetch } = {}) { + const failures = []; + for (const source of manifest.sources ?? []) { + if (source.type === 'documentation') { + const response = await fetchImpl(source.url, { redirect: 'follow' }); + if (!response.ok) failures.push(`${source.name} (${source.url}): HTTP ${response.status}`); + continue; + } + if (source.type !== 'github') continue; + + const treeUrl = `https://api.github.com/repos/${source.repo}/git/trees/${source.ref}?recursive=1`; + const response = await fetchImpl(treeUrl, { + headers: { accept: 'application/vnd.github+json', 'user-agent': 'atlas-source-integrity-check' }, + }); + if (!response.ok) { + failures.push(`${source.name} (${source.repo}@${source.ref}): HTTP ${response.status}`); + continue; + } + const body = await response.json(); + if (body.truncated) { + failures.push(`${source.name} (${source.repo}@${source.ref}): recursive tree was truncated`); + continue; + } + const treePaths = Array.isArray(body.tree) ? body.tree.map((item) => item.path).filter(Boolean) : []; + for (const path of missingDeclaredPaths(treePaths, source.paths ?? [])) { + failures.push(`${source.name} (${source.repo}@${source.ref}): ${path}`); + } + } + + if (failures.length) { + throw new Error(`Pinned source integrity check failed:\n${failures.map((failure) => `- ${failure}`).join('\n')}`); + } + return { checked: manifest.sources?.length ?? 0 }; +} + +async function main() { + const manifestPath = new URL('../.agents/skill-sources.json', import.meta.url); + const manifest = JSON.parse(await readFile(manifestPath, 'utf8')); + const result = await validateSkillSources(manifest); + console.log(`SOURCE PINS PASS ${result.checked} sources`); +} + +if (import.meta.url === pathToFileURL(process.argv[1]).href) { + main().catch((error) => { + console.error(error instanceof Error ? error.message : String(error)); + process.exitCode = 1; + }); +} diff --git a/demos/real-world-reasoning-agent/scripts/check-skill-sources.test.mjs b/demos/real-world-reasoning-agent/scripts/check-skill-sources.test.mjs new file mode 100644 index 0000000..9f223bd --- /dev/null +++ b/demos/real-world-reasoning-agent/scripts/check-skill-sources.test.mjs @@ -0,0 +1,24 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { missingDeclaredPaths, validateSkillSources } from './check-skill-sources.mjs'; + +test('missingDeclaredPaths accepts a declared directory represented by descendants', () => { + const tree = ['client/web/src/index.ts', 'agent/schema/catalog.json']; + assert.deepEqual(missingDeclaredPaths(tree, ['client/web', 'agent/schema']), []); + assert.deepEqual(missingDeclaredPaths(tree, ['specification']), ['specification']); +}); + +test('validateSkillSources fails loudly with the source, ref, and missing path', async () => { + const manifest = { + sources: [{ name: 'a2ui', type: 'github', repo: 'example/a2ui', ref: 'abc123', paths: ['missing/path'] }], + }; + const fetchImpl = async () => ({ + ok: true, + json: async () => ({ truncated: false, tree: [{ path: 'present/path/file.ts' }] }), + }); + + await assert.rejects( + validateSkillSources(manifest, { fetchImpl }), + /a2ui \(example\/a2ui@abc123\): missing\/path/, + ); +}); diff --git a/demos/real-world-reasoning-agent/scripts/eval-explorer.ts b/demos/real-world-reasoning-agent/scripts/eval-explorer.ts new file mode 100644 index 0000000..7d081f1 --- /dev/null +++ b/demos/real-world-reasoning-agent/scripts/eval-explorer.ts @@ -0,0 +1,20 @@ +import baseline from '../eval/explorer/baseline.json'; +import cases from '../eval/explorer/cases.json'; +import { evaluateExplorerTraces } from '../src/eval/explorer/grader'; +import { executeExplorerEval } from '../src/eval/explorer/harness'; +import type { ExplorerEvalCase } from '../src/eval/explorer/harness'; +import type { ExplorerEvalDimension, ExplorerEvalScore } from '../src/eval/explorer/types'; + +const candidate = evaluateExplorerTraces(await executeExplorerEval(cases as ExplorerEvalCase[])); +const before = baseline as ExplorerEvalScore[]; +const delta = Object.fromEntries((Object.keys(candidate.byDimension) as ExplorerEvalDimension[]).map((dimension) => { + const baselinePassed = before.filter((score) => score.dimensions[dimension] === 'pass').length; + const next = candidate.byDimension[dimension]; + return [dimension, { before: baselinePassed, after: next.passed, applicable: next.applicable, delta: next.passed - baselinePassed }]; +})); + +console.log(JSON.stringify({ dataset: 'eval/explorer/cases.json', baseline: 'eval/explorer/baseline.json', candidate: { + total: candidate.total, perfect: candidate.perfect, p75FirstMapEffectMs: candidate.p75FirstMapEffectMs, +}, dimensions: delta }, null, 2)); + +if (candidate.perfect !== cases.length) process.exitCode = 1; diff --git a/demos/real-world-reasoning-agent/scripts/eval-remote.ts b/demos/real-world-reasoning-agent/scripts/eval-remote.ts new file mode 100644 index 0000000..5bacf2b --- /dev/null +++ b/demos/real-world-reasoning-agent/scripts/eval-remote.ts @@ -0,0 +1,92 @@ +/** + * Eval runner with a HUMAN-APPROVAL gate for paid remote evaluation + * (reliability plan §4: "Require human approval before a paid remote evaluation + * and compare before/after deltas for prompt or tool changes"). + * + * Run with vite-node so it can import the real evaluator (single source of + * truth — never re-implement the grader here): + * + * node_modules/.bin/vite-node scripts/eval-remote.ts # local eval + delta vs baseline + * node_modules/.bin/vite-node scripts/eval-remote.ts --save-baseline + * EVAL_REMOTE_APPROVED=1 vite-node scripts/eval-remote.ts # also runs the paid remote eval + * + * The LOCAL eval (deterministic trace dimensions) always runs — it is free and + * offline. The REMOTE eval (a paid AutoRater over the Agent Platform GenAI + * Evaluation Service) NEVER runs unless a human sets EVAL_REMOTE_APPROVED=1, so + * a routine `npm run eval` can never silently spend money. Deltas are computed + * against .eval/baseline.json (untracked) so prompt/tool changes are judged by + * their effect on the numbers, not a single absolute score. + */ +import { mkdirSync, readFileSync, writeFileSync, existsSync } from 'node:fs'; +import { evaluateTraces, type AgentTrace, type Dimension } from '@/ai/traceEval'; +import dataset from '../eval/traces.json'; + +const OUT = '.eval'; +const BASELINE = `${OUT}/baseline.json`; +const LAST = `${OUT}/last-eval.json`; + +const traces = (dataset as unknown as { traces: AgentTrace[] }).traces; +const report = evaluateTraces(traces); + +mkdirSync(OUT, { recursive: true }); + +type DimReport = Record; +const prev: DimReport | null = existsSync(BASELINE) + ? (JSON.parse(readFileSync(BASELINE, 'utf8')).byDimension as DimReport) + : null; + +console.log(`LOCAL TRACE EVAL — ${report.total} traces, ${report.perfect} perfect`); +for (const dim of Object.keys(report.byDimension) as Dimension[]) { + const cur = report.byDimension[dim]; + const base = prev?.[dim]; + const delta = base ? cur.passed - base.passed : 0; + const sign = delta > 0 ? `+${delta}` : delta < 0 ? `${delta}` : '±0'; + const tag = base ? ` (${sign} vs baseline)` : ' (no baseline)'; + console.log(` ${dim.padEnd(20)} ${cur.passed}/${cur.total}${tag}`); +} +if (report.failures.length) { + console.log('FAILURES:'); + for (const f of report.failures) console.log(` ${f.id} → ${f.dimension}`); +} + +writeFileSync(LAST, JSON.stringify(report, null, 2)); + +if (process.argv.includes('--save-baseline')) { + writeFileSync(BASELINE, JSON.stringify(report, null, 2)); + console.log(`baseline saved → ${BASELINE}`); +} + +// ---- Paid remote eval: gated behind explicit human approval ----------------- +const approved = process.env.EVAL_REMOTE_APPROVED === '1'; +if (!approved) { + console.log( + 'REMOTE EVAL gated — set EVAL_REMOTE_APPROVED=1 to run the paid AutoRater ' + + 'evaluation (Agent Platform GenAI Evaluation Service). Skipped; no spend.', + ); +} else { + // Approval is present, but a paid run must ALSO be configured; never fabricate + // remote scores. Fail loudly with what's missing rather than pretending. + const key = process.env.GEMINI_KEY; + if (!key) { + console.error('REMOTE EVAL approved but GEMINI_KEY is not set — cannot run. Aborting.'); + process.exit(1); + } + console.error( + 'REMOTE EVAL approved, but the remote AutoRater client is not wired in this ' + + 'repo yet. Configure the GenAI Evaluation Service endpoint before enabling ' + + 'this path (see docs/implementation/demo-reliability-hardening.md §4). Aborting.', + ); + process.exit(2); +} + +// Local eval is a gate: non-zero exit if any dimension regressed below baseline. +if (prev) { + const regressed = (Object.keys(report.byDimension) as Dimension[]).filter( + (d) => report.byDimension[d].passed < (prev[d]?.passed ?? 0), + ); + if (regressed.length) { + console.error(`EVAL FAIL — regressions: ${regressed.join(', ')}`); + process.exit(1); + } +} +console.log('EVAL OK'); diff --git a/demos/real-world-reasoning-agent/scripts/mission-smoke.mjs b/demos/real-world-reasoning-agent/scripts/mission-smoke.mjs new file mode 100644 index 0000000..fba3493 --- /dev/null +++ b/demos/real-world-reasoning-agent/scripts/mission-smoke.mjs @@ -0,0 +1,174 @@ +// Grounding Lite, route drawing, and Gemini are deterministic fixtures. The +// Google Maps JavaScript renderer remains live and may incur project costs, so +// every run requires an explicit operator acknowledgement. +import { chromium } from 'playwright-core'; +import { existsSync, mkdirSync } from 'node:fs'; + +const BASE = process.env.SMOKE_URL || 'http://localhost:8080'; +const OUT = process.env.MISSION_SMOKE_OUT || '/tmp/atlas-mission-smoke'; +if (process.env.ALLOW_LIVE_MAPS_BROWSER !== '1') { + throw new Error('Set ALLOW_LIVE_MAPS_BROWSER=1 to acknowledge that the configured Maps JavaScript renderer may incur costs.'); +} +mkdirSync(OUT, { recursive: true }); +const EXEC = process.env.CHROMIUM_PATH || [ + '/usr/bin/google-chrome', '/usr/bin/google-chrome-stable', '/usr/bin/chromium', '/usr/bin/chromium-browser', +].find(existsSync); +if (!EXEC) throw new Error('Chromium not found. Set CHROMIUM_PATH to an installed Chrome/Chromium executable.'); + +const ignored = [ + /Google Maps JavaScript API/i, + /development purposes only/i, + /Receiving end does not exist/i, + /Vector Map.*Falling back to Raster/i, + /map is initialized without a valid Map ID/i, + /Failed to load resource.*503/i, +]; +const errors = []; +const assert = (condition, message) => { if (!condition) throw new Error(message); }; + +const places = [ + { id: 'browser-fixture-a', latitude: 37.797, longitude: -122.395 }, + { id: 'browser-fixture-b', latitude: 37.792, longitude: -122.401 }, + { id: 'browser-fixture-c', latitude: 37.789, longitude: -122.407 }, +]; +const walkMinutes = { 'browser-fixture-a': 9, 'browser-fixture-b': 13, 'browser-fixture-c': 18 }; +const driveMinutes = { 'browser-fixture-a': 8, 'browser-fixture-b': 5, 'browser-fixture-c': 6 }; +const source = (kind) => ({ title: `Deterministic ${kind} fixture`, url: `https://example.test/${kind}` }); + +function mcpResult(value) { + return JSON.stringify({ jsonrpc: '2.0', id: 1, result: { structuredContent: value } }); +} + +async function installProviderFixtures(page) { + await page.route('**/api/real-world-reasoning-agent/capabilities', (route) => route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ maps: true, gemini: true, groundingLite: true }), + })); + await page.route('**/api/real-world-reasoning-agent/gmp/grounding-lite/mcp', async (route) => { + const request = route.request().postDataJSON(); + const name = request?.params?.name; + const args = request?.params?.arguments ?? {}; + let value; + if (name === 'search_places') { + value = { + places: places.map((place) => ({ + id: place.id, + location: { latitude: place.latitude, longitude: place.longitude }, + googleMapsLinks: { placeUrl: `https://example.test/places/${place.id}` }, + attribution: source('place'), + })), + summary: 'Three browser-test candidates were returned by the intercepted provider.', + }; + } else if (name === 'compute_routes') { + const id = args.destination?.place_id; + const minutes = args.travel_mode === 'DRIVE' ? driveMinutes[id] : walkMinutes[id]; + value = { routes: [{ distanceMeters: minutes * 75, duration: `${minutes * 60}s`, attribution: source('route') }] }; + } else if (name === 'lookup_weather') { + value = { + weatherCondition: { description: { text: 'Partly cloudy' } }, + temperature: { degrees: 12, unit: 'CELSIUS' }, + precipitation: { probability: { percent: 20 } }, + attribution: source('weather'), + }; + } else { + return route.fulfill({ status: 400, body: `Unexpected fixture tool: ${name}` }); + } + return route.fulfill({ status: 200, contentType: 'application/json', body: mcpResult(value) }); + }); + // The evidence run remains Live mode, but no paid route drawing or Gemini + // summarization request may leave the browser during deterministic smoke. + await page.route('**://routes.googleapis.com/**', (route) => route.fulfill({ status: 503, body: '{}' })); + await page.route('**/api/real-world-reasoning-agent/ai/**', (route) => route.request().method() === 'POST' + ? route.fulfill({ status: 503, body: '{}' }) + : route.continue()); +} + +async function horizontalSpillers(page) { + return page.evaluate(() => { + const out = []; + for (const element of document.querySelectorAll('body *')) { + if (element.closest('.gm-style, gmp-map-3d')) continue; + const box = element.getBoundingClientRect(); + if (box.width && box.height && box.left < innerWidth - 1 && box.right > innerWidth + 5) out.push(element.tagName.toLowerCase()); + } + return [...new Set(out)].slice(0, 5); + }); +} + +async function launchLiveExplorer(page) { + await installProviderFixtures(page); + page.on('console', (message) => { + if (message.type() === 'error' && !ignored.some((pattern) => pattern.test(message.text()))) errors.push(message.text()); + }); + page.on('pageerror', (error) => errors.push(error.message)); + await page.goto(BASE, { waitUntil: 'domcontentloaded', timeout: 45_000 }); + await page.locator('.atlas-cold-open').waitFor({ state: 'attached', timeout: 15_000 }); + await page.locator('.mission-launch').waitFor({ state: 'visible', timeout: 15_000 }); + assert(await page.locator('textarea').count() === 1, 'Cold open must have one prompt.'); + assert(await page.locator('.mission-launch').count() === 1, 'Cold open must have one primary action.'); + assert(/live/i.test(await page.locator('.mission-capability').innerText()), 'Landing must clearly identify Live mode.'); + assert(await page.getByText(/Sample preview|Sample mission/i).count() === 0, 'Landing must not expose a Sample mode.'); + const startedAt = Date.now(); + await page.locator('.mission-launch').click(); + await page.locator('.genui-surface').waitFor({ timeout: 15_000 }); + await page.getByText(/Rank 1 · .* · 9 min · inside limit/).waitFor({ timeout: 15_000 }); + return Date.now() - startedAt; +} + +const browser = await chromium.launch({ executablePath: EXEC, args: ['--no-sandbox', '--use-gl=angle', '--use-angle=swiftshader'] }); +try { + const desktop = await browser.newPage({ viewport: { width: 1440, height: 900 } }); + const firstEvidenceMs = await launchLiveExplorer(desktop); + await desktop.getByRole('heading', { name: 'Evidence' }).waitFor(); + await desktop.getByText(/Partly cloudy · 12°C/i).waitFor(); + await desktop.locator('.copilot-message').filter({ hasText: /9-minute walk/i }).waitFor({ timeout: 15_000 }); + assert(await desktop.locator('.genui-surface').count() === 1, 'Explorer must own exactly one surface.'); + const evidenceTop = await desktop.getByRole('heading', { name: 'Evidence' }).evaluate((heading) => { + const messages = heading.closest('.agent-canvas__scroll'); + if (!messages) return false; + const headingBox = heading.getBoundingClientRect(); + const messagesBox = messages.getBoundingClientRect(); + return headingBox.top >= messagesBox.top && headingBox.bottom <= messagesBox.bottom; + }); + assert(evidenceTop, 'The first payoff must open at the Evidence heading.'); + // The composer is now the session's single, always-available input rather than + // per-journey chrome that had to be hidden during the first run. What must not + // happen is a SECOND agent surface competing with the evidence one. + assert(await desktop.locator('.copilot-dock__composer:visible').count() === 1, 'There must be exactly one composer.'); + assert(await desktop.locator('.agent-canvas').count() === 1, 'There must be exactly one agent canvas.'); + assert(await desktop.locator('.context-drawer').count() === 0, 'The competing context drawer must be gone.'); + assert(await desktop.locator('.genui-grounding-source').count() >= 5, 'Live evidence must render provider source links.'); + assert(await desktop.locator('.genui-grounding-source').first().getAttribute('translate') !== 'yes', 'Grounding attribution must remain unmodified.'); + assert(await desktop.getByRole('button', { name: /Resume/i }).count() === 0, 'First run must not require Resume.'); + assert(!/tools\/call|render_surface|system prompt/i.test(await desktop.locator('.genui-surface').innerText()), 'Internal orchestration leaked into the surface.'); + await desktop.screenshot({ path: `${OUT}/01-explorer-ready.png`, animations: 'disabled' }); + + await desktop.locator('.genui-surface').evaluate((surface) => { surface.dataset.smokeIdentity = 'canonical'; }); + await desktop.locator('.genui-surface button').filter({ hasText: /driv/i }).click(); + await desktop.getByText(/Rank 1 · .* · 5 min · inside limit/).waitFor({ timeout: 15_000 }); + assert(await desktop.locator('.genui-surface').count() === 1, 'Counterfactual created a second surface.'); + assert(await desktop.locator('.genui-surface').getAttribute('data-smoke-identity') === 'canonical', 'Counterfactual replaced rather than updated the canonical surface.'); + await desktop.screenshot({ path: `${OUT}/02-explorer-drive-counterfactual.png`, animations: 'disabled' }); + await desktop.close(); + + const mobile = await browser.newPage({ viewport: { width: 390, height: 844 } }); + await launchLiveExplorer(mobile); + await mobile.getByText(/Partly cloudy · 12°C/i).waitFor({ timeout: 15_000 }); + const spillers = await horizontalSpillers(mobile); + assert(spillers.length === 0, `Visible app content overflowed mobile: ${spillers.join(', ')}`); + const unlabeled = await mobile.evaluate(() => [...document.querySelectorAll('button, a[href], [role="button"]')] + .filter((element) => { + const box = element.getBoundingClientRect(); + return box.width && box.height && !(element.getAttribute('aria-label') || element.textContent?.trim() || element.querySelector('img[alt]:not([alt=""])')); + }).length); + assert(unlabeled === 0, 'Visible mobile controls need accessible names.'); + await mobile.screenshot({ path: `${OUT}/03-explorer-mobile.png`, animations: 'disabled' }); + await mobile.close(); + + assert(firstEvidenceMs <= 25_000, `First evidence exceeded the provisional evidence gate (${firstEvidenceMs}ms).`); + assert(errors.length === 0, `Unexpected browser errors: ${errors.slice(0, 5).join(' | ')}`); + console.log(JSON.stringify({ status: 'PASS', firstEvidenceMs, mode: 'live-with-intercepted-providers', surfaces: 1, screenshots: OUT }, null, 2)); +} finally { + await browser.close(); +} diff --git a/demos/real-world-reasoning-agent/scripts/smoke.mjs b/demos/real-world-reasoning-agent/scripts/smoke.mjs new file mode 100644 index 0000000..4d2fa7e --- /dev/null +++ b/demos/real-world-reasoning-agent/scripts/smoke.mjs @@ -0,0 +1,61 @@ +// Broad shell smoke for all six journeys. This does not invoke Grounding Lite +// or Gemini, but it does mount the configured Google Maps JavaScript renderer +// and may incur project costs; require explicit operator acknowledgement. +import { chromium } from 'playwright-core'; +import { existsSync, mkdirSync } from 'node:fs'; + +const BASE = process.env.SMOKE_URL || 'http://localhost:8080'; +const OUT = process.env.SMOKE_OUT || '/tmp/atlas-journey-smoke'; +if (process.env.ALLOW_LIVE_MAPS_BROWSER !== '1') { + throw new Error('Set ALLOW_LIVE_MAPS_BROWSER=1 to acknowledge that the configured Maps JavaScript renderer may incur costs.'); +} +mkdirSync(OUT, { recursive: true }); + +const executablePath = process.env.CHROMIUM_PATH || [ + '/usr/bin/google-chrome', '/usr/bin/google-chrome-stable', '/usr/bin/chromium', '/usr/bin/chromium-browser', +].find(existsSync); +if (!executablePath) throw new Error('Chromium not found. Set CHROMIUM_PATH to an installed Chrome/Chromium executable.'); + +const journeys = [ + { id: 'concierge', label: 'Concierge' }, + { id: 'insight', label: 'Insight' }, + { id: 'fleet', label: 'Fleet' }, + { id: 'cinema', label: 'Cinema' }, + { id: 'adstudio', label: 'Ad Studio' }, + { id: 'scout', label: 'Scout' }, +]; +const ignored = [ + /Google Maps JavaScript API/i, + /development purposes only/i, + /Receiving end does not exist/i, + /Vector Map.*Falling back to Raster/i, + /map is initialized without a valid Map ID/i, +]; +const errors = []; +const browser = await chromium.launch({ executablePath, args: ['--no-sandbox', '--use-gl=angle', '--use-angle=swiftshader'] }); +const page = await browser.newPage({ viewport: { width: 1440, height: 900 } }); +page.on('console', (message) => { + if (message.type() === 'error' && !ignored.some((pattern) => pattern.test(message.text()))) errors.push(message.text()); +}); +page.on('pageerror', (error) => errors.push(error.message)); + +try { + for (const journey of journeys) { + const url = new URL(BASE); + url.searchParams.set('scenario', journey.id); + url.searchParams.set('landing', 'true'); + url.searchParams.set('drawer', 'false'); + await page.goto(url.href, { waitUntil: 'domcontentloaded', timeout: 45_000 }); + // The journey rail retired: recipes are chosen from the composer, and the + // active one names itself on the picker trigger. + await page.locator('.recipe-picker__trigger').waitFor({ timeout: 20_000 }); + await page.locator('.recipe-picker__name', { hasText: journey.label }).waitFor({ timeout: 10_000 }); + const mapMounted = await page.evaluate(() => Boolean(document.querySelector('.gm-style, canvas, gmp-map-3d'))); + if (!mapMounted) throw new Error(`${journey.label}: Maps renderer did not mount.`); + await page.screenshot({ path: `${OUT}/${journey.id}.png`, animations: 'disabled' }); + console.log(`✓ ${journey.label}`); + } + if (errors.length) throw new Error(`Unexpected browser errors: ${errors.slice(0, 5).join(' | ')}`); +} finally { + await browser.close(); +} diff --git a/demos/real-world-reasoning-agent/scripts/uiux-audit.mjs b/demos/real-world-reasoning-agent/scripts/uiux-audit.mjs new file mode 100644 index 0000000..515d1e2 --- /dev/null +++ b/demos/real-world-reasoning-agent/scripts/uiux-audit.mjs @@ -0,0 +1,213 @@ +// UI/UX audit for the default one-prompt Live-first explorer. Grounding Lite, +// route drawing, and Gemini are intercepted. The configured Google Maps +// JavaScript renderer remains live and may incur project costs. +// +// npm start & CHROMIUM_PATH=/usr/bin/google-chrome node scripts/uiux-audit.mjs +import { chromium } from 'playwright-core'; +import { existsSync, mkdirSync, writeFileSync } from 'node:fs'; + +const BASE = process.env.SMOKE_URL || 'http://localhost:8080'; +const OUT = (process.env.AUDIT_OUT || '/tmp/atlas-audit').replace(/\/$/, ''); +if (process.env.ALLOW_LIVE_MAPS_BROWSER !== '1') { + throw new Error('Set ALLOW_LIVE_MAPS_BROWSER=1 to acknowledge that the configured Maps JavaScript renderer may incur costs.'); +} +mkdirSync(OUT, { recursive: true }); +const EXEC = process.env.CHROMIUM_PATH || [ + '/usr/bin/google-chrome', '/usr/bin/google-chrome-stable', '/usr/bin/chromium', '/usr/bin/chromium-browser', +].find(existsSync); +if (!EXEC) throw new Error('Chromium not found. Set CHROMIUM_PATH to an installed Chrome/Chromium executable.'); + +const VIEWPORTS = [ + { key: 'xs', width: 320, height: 568 }, + { key: 'sm', width: 390, height: 844 }, + { key: 'md', width: 768, height: 1024 }, + { key: 'lg', width: 1440, height: 900 }, + { key: 'xl', width: 1920, height: 1080 }, +]; +const IGNORE = [ + /Google Maps JavaScript API/i, + /development purposes only/i, + /Receiving end does not exist/i, + /Vector Map.*Falling back to Raster/i, + /map is initialized without a valid Map ID/i, + /Failed to load resource.*503/i, +]; +const severe = (text) => !IGNORE.some((pattern) => pattern.test(text)); + +const places = [ + { id: 'browser-fixture-a', latitude: 37.797, longitude: -122.395, walk: 9 }, + { id: 'browser-fixture-b', latitude: 37.792, longitude: -122.401, walk: 13 }, + { id: 'browser-fixture-c', latitude: 37.789, longitude: -122.407, walk: 18 }, +]; +const source = (kind) => ({ title: `Deterministic ${kind} fixture`, url: `https://example.test/${kind}` }); +const mcpResult = (value) => JSON.stringify({ jsonrpc: '2.0', id: 1, result: { structuredContent: value } }); + +async function installProviderFixtures(page) { + await page.route('**/api/real-world-reasoning-agent/capabilities', (route) => route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ maps: true, gemini: true, groundingLite: true }), + })); + await page.route('**/api/real-world-reasoning-agent/gmp/grounding-lite/mcp', async (route) => { + const request = route.request().postDataJSON(); + const name = request?.params?.name; + const args = request?.params?.arguments ?? {}; + let value; + if (name === 'search_places') { + value = { + places: places.map((place) => ({ + id: place.id, + location: { latitude: place.latitude, longitude: place.longitude }, + googleMapsLinks: { placeUrl: `https://example.test/places/${place.id}` }, + attribution: source('place'), + })), + }; + } else if (name === 'compute_routes') { + const place = places.find((candidate) => candidate.id === args.destination?.place_id); + value = { routes: [{ distanceMeters: place.walk * 75, duration: `${place.walk * 60}s`, attribution: source('route') }] }; + } else if (name === 'lookup_weather') { + value = { + weatherCondition: { description: { text: 'Partly cloudy' } }, + temperature: { degrees: 12, unit: 'CELSIUS' }, + precipitation: { probability: { percent: 20 } }, + attribution: source('weather'), + }; + } else { + return route.fulfill({ status: 400, body: `Unexpected fixture tool: ${name}` }); + } + return route.fulfill({ status: 200, contentType: 'application/json', body: mcpResult(value) }); + }); + await page.route('**://routes.googleapis.com/**', (route) => route.fulfill({ status: 503, body: '{}' })); + await page.route('**/api/real-world-reasoning-agent/ai/**', (route) => route.request().method() === 'POST' + ? route.fulfill({ status: 503, body: '{}' }) + : route.continue()); +} + +// In-page DOM audit. Map-internal nodes are excluded because their layout and +// labels belong to the Google Maps renderer rather than this app shell. +const DOM_AUDIT = () => { + const vw = window.innerWidth; + const vh = window.innerHeight; + const out = { + viewport: { vw, vh }, + docScrollW: document.documentElement.scrollWidth, + horizontalScroll: document.documentElement.scrollWidth > vw + 1, + overflowers: [], + offViewport: [], + tinyTapTargets: [], + unlabeledControls: [], + }; + const desc = (element) => { + const cls = typeof element.className === 'string' ? element.className.split(/\s+/).filter(Boolean).slice(0, 3).join('.') : ''; + const label = element.getAttribute?.('aria-label'); + return `${element.tagName.toLowerCase()}${element.id ? `#${element.id}` : ''}${cls ? `.${cls}` : ''}${label ? `[aria-label="${label}"]` : ''}`; + }; + for (const element of document.querySelectorAll('body *')) { + if (element.closest('.gm-style, gmp-map-3d')) continue; + const box = element.getBoundingClientRect(); + if (!box.width || !box.height) continue; + const style = getComputedStyle(element); + if (style.visibility === 'hidden' || style.display === 'none' || Number(style.opacity) === 0) continue; + if (box.right > vw + 2 && box.width < vw + 40 && box.left >= 0) { + out.overflowers.push({ el: desc(element), right: Math.round(box.right), width: Math.round(box.width) }); + } + const interactive = element.matches('button, a, input, [role="button"], select, textarea, [tabindex]'); + const insideBoundedScroll = Boolean(element.closest('.agent-canvas__scroll')); + if (interactive && !insideBoundedScroll && (box.right < 4 || box.left > vw - 4 || box.bottom < 4 || box.top > vh - 4)) { + out.offViewport.push({ el: desc(element), rect: [Math.round(box.left), Math.round(box.top), Math.round(box.right), Math.round(box.bottom)] }); + } + if (interactive && box.top < vh && box.bottom > 0 && box.left < vw && box.right > 0 + && (box.width < 44 || box.height < 44) && element.offsetParent !== null) { + const text = (element.getAttribute('aria-label') || element.textContent || '').trim().slice(0, 30); + out.tinyTapTargets.push({ el: desc(element), w: Math.round(box.width), h: Math.round(box.height), text }); + } + if (element.matches('button, a[href], input, [role="button"]')) { + const name = (element.getAttribute('aria-label') || element.getAttribute('title') || element.textContent || '').trim(); + if (!name && !element.querySelector('img[alt]:not([alt=""])')) out.unlabeledControls.push({ el: desc(element) }); + } + } + for (const key of ['overflowers', 'offViewport', 'tinyTapTargets', 'unlabeledControls']) out[key] = out[key].slice(0, 25); + return out; +}; + +const report = { base: BASE, mode: 'live-with-intercepted-providers', screens: [] }; +const browser = await chromium.launch({ + executablePath: EXEC, + args: ['--use-gl=angle', '--use-angle=swiftshader', '--ignore-gpu-blocklist', '--no-sandbox'], +}); + +for (const viewport of VIEWPORTS) { + const context = await browser.newContext({ viewport: { width: viewport.width, height: viewport.height }, deviceScaleFactor: 1 }); + const page = await context.newPage(); + await installProviderFixtures(page); + const errors = []; + page.on('console', (message) => { if (message.type() === 'error' && severe(message.text())) errors.push(message.text()); }); + page.on('pageerror', (error) => errors.push(`pageerror: ${error.message}`)); + try { + await page.goto(BASE, { waitUntil: 'domcontentloaded', timeout: 45_000 }); + await page.locator('.atlas-cold-open').waitFor({ state: 'attached', timeout: 20_000 }); + await page.locator('.mission-launch').waitFor({ state: 'visible', timeout: 20_000 }); + + const landingGate = await page.evaluate(() => { + const prompt = document.querySelector('textarea')?.getBoundingClientRect(); + const launch = document.querySelector('.mission-launch')?.getBoundingClientRect(); + return { + prompts: document.querySelectorAll('textarea').length, + primaryActions: document.querySelectorAll('.mission-launch').length, + promptVisible: Boolean(prompt && prompt.top >= 0 && prompt.bottom <= innerHeight), + launchVisible: Boolean(launch && launch.top >= 0 && launch.bottom <= innerHeight), + operationalShellHidden: !document.querySelector('.atlas-statusbar, .copilot-dock, .agent-canvas'), + sampleModeVisible: /sample (?:preview|mission)/i.test(document.body.innerText), + }; + }); + if (landingGate.prompts !== 1 || landingGate.primaryActions !== 1 || !landingGate.promptVisible + || !landingGate.launchVisible || !landingGate.operationalShellHidden || landingGate.sampleModeVisible) { + throw new Error(`one-prompt Live landing gate failed: ${JSON.stringify(landingGate)}`); + } + await page.screenshot({ path: `${OUT}/${viewport.key}-landing.png`, animations: 'disabled' }); + report.screens.push({ viewport: viewport.key, screen: 'landing', dims: [viewport.width, viewport.height], audit: await page.evaluate(DOM_AUDIT), errors: [...errors] }); + + await page.locator('.mission-launch').click(); + await page.getByText(/Rank 1 · .* · 9 min · inside limit/).waitFor({ timeout: 20_000 }); + await page.getByText(/Partly cloudy · 12°C/i).waitFor({ timeout: 15_000 }); + if (await page.locator('.genui-surface').count() !== 1) throw new Error('Explorer must own exactly one evidence surface.'); + if (await page.locator('.genui-grounding-source').count() < 5) throw new Error('Live evidence must keep provider sources visible.'); + await page.screenshot({ path: `${OUT}/${viewport.key}-explorer.png`, animations: 'disabled' }); + report.screens.push({ viewport: viewport.key, screen: 'explorer', dims: [viewport.width, viewport.height], audit: await page.evaluate(DOM_AUDIT), errors: [...errors] }); + console.log(`✓ ${viewport.key} / landing → Live explorer`); + } catch (error) { + console.error(`✗ ${viewport.key}: ${error.message}`); + report.screens.push({ viewport: viewport.key, screen: 'ERROR', error: error.message, errors }); + process.exitCode = 1; + } finally { + await context.close(); + } +} + +await browser.close(); +writeFileSync(`${OUT}/audit.json`, JSON.stringify(report, null, 2)); + +console.log('\n=== DOM AUDIT SUMMARY ==='); +const blockingDefects = []; +for (const screen of report.screens) { + if (!screen.audit) continue; + const audit = screen.audit; + const flags = []; + if (audit.horizontalScroll) flags.push(`H-SCROLL(${audit.docScrollW}>${audit.viewport.vw})`); + if (audit.overflowers.length) flags.push(`overflow:${audit.overflowers.length}`); + if (audit.offViewport.length) flags.push(`offscreen:${audit.offViewport.length}`); + if (audit.tinyTapTargets.length) flags.push(`tinytap:${audit.tinyTapTargets.length}`); + if (audit.unlabeledControls.length) flags.push(`unlabeled:${audit.unlabeledControls.length}`); + if (screen.errors?.length) flags.push(`console:${screen.errors.length}`); + if (flags.length) console.log(` ${screen.viewport}/${screen.screen}: ${flags.join(' ')}`); + const mobile = screen.dims?.[0] <= 820; + if (audit.horizontalScroll || audit.overflowers.length || audit.offViewport.length + || audit.unlabeledControls.length || screen.errors?.length || (mobile && audit.tinyTapTargets.length)) { + blockingDefects.push(`${screen.viewport}/${screen.screen}: ${flags.join(' ')}`); + } +} +if (blockingDefects.length) { + process.exitCode = 1; + console.error('\nBlocking UI defects:\n', blockingDefects.join('\n')); +} +console.log('\nWrote', `${OUT}/audit.json`, 'and PNGs to', OUT); diff --git a/demos/real-world-reasoning-agent/server/groundingLiteGate.mjs b/demos/real-world-reasoning-agent/server/groundingLiteGate.mjs new file mode 100644 index 0000000..f6c1952 --- /dev/null +++ b/demos/real-world-reasoning-agent/server/groundingLiteGate.mjs @@ -0,0 +1,37 @@ +const exactKeys = (value, allowed) => value && typeof value === 'object' && !Array.isArray(value) + && Object.keys(value).every((key) => allowed.includes(key)); +const finite = (value, min, max) => typeof value === 'number' && Number.isFinite(value) && value >= min && value <= max; +const shortString = (value, max = 500) => typeof value === 'string' && value.length > 0 && value.length <= max; + +export function validateGroundingLiteCall(request) { + if (!exactKeys(request, ['jsonrpc', 'id', 'method', 'params']) || request.jsonrpc !== '2.0' + || request.method !== 'tools/call' || !exactKeys(request.params, ['name', 'arguments'])) return false; + const { name, arguments: args } = request.params; + if (name === 'search_places') { + if (!exactKeys(args, ['text_query', 'location_bias', 'language_code', 'region_code']) + || !shortString(args.text_query) || args.text_query.trim().length < 4) return false; + if (args.language_code !== undefined && !/^[a-z]{2}(?:_[A-Z]{2})?$/.test(args.language_code)) return false; + if (args.region_code !== undefined && !/^[A-Z]{2}$/.test(args.region_code)) return false; + if (args.location_bias === undefined) return true; + const circle = args.location_bias?.circle; + const center = circle?.center; + return exactKeys(args.location_bias, ['circle']) && exactKeys(circle, ['center', 'radius_meters']) + && exactKeys(center, ['latitude', 'longitude']) + && finite(center.latitude, -90, 90) && finite(center.longitude, -180, 180) + && (circle.radius_meters === undefined || finite(circle.radius_meters, 1, 50_000)); + } + if (name === 'compute_routes') { + const point = args?.origin?.lat_lng; + return exactKeys(args, ['origin', 'destination', 'travel_mode']) + && exactKeys(args.origin, ['lat_lng']) && exactKeys(point, ['latitude', 'longitude']) + && finite(point.latitude, -90, 90) && finite(point.longitude, -180, 180) + && exactKeys(args.destination, ['place_id']) && shortString(args.destination.place_id, 256) + && (args.travel_mode === 'WALK' || args.travel_mode === 'DRIVE'); + } + if (name === 'lookup_weather') { + return exactKeys(args, ['location', 'units_system']) && exactKeys(args.location, ['place_id']) + && shortString(args.location.place_id, 256) + && (args.units_system === 'METRIC' || args.units_system === 'IMPERIAL'); + } + return false; +} diff --git a/demos/real-world-reasoning-agent/server/groundingLiteGate.test.ts b/demos/real-world-reasoning-agent/server/groundingLiteGate.test.ts new file mode 100644 index 0000000..20418de --- /dev/null +++ b/demos/real-world-reasoning-agent/server/groundingLiteGate.test.ts @@ -0,0 +1,19 @@ +import { describe, expect, it } from 'vitest'; +// @ts-expect-error JavaScript server guard intentionally has no declaration file. +import { validateGroundingLiteCall } from './groundingLiteGate.mjs'; + +const call = (name: string, args: unknown) => ({ jsonrpc: '2.0', id: 1, method: 'tools/call', params: { name, arguments: args } }); + +describe('Grounding Lite proxy argument gate', () => { + it('accepts only bounded arguments for the three explorer tools', () => { + expect(validateGroundingLiteCall(call('search_places', { text_query: 'coffee', location_bias: { circle: { center: { latitude: 1, longitude: 2 }, radius_meters: 8000 } }, language_code: 'en', region_code: 'US' }))).toBe(true); + expect(validateGroundingLiteCall(call('compute_routes', { origin: { lat_lng: { latitude: 1, longitude: 2 } }, destination: { place_id: 'p1' }, travel_mode: 'WALK' }))).toBe(true); + expect(validateGroundingLiteCall(call('lookup_weather', { location: { place_id: 'p1' }, units_system: 'METRIC' }))).toBe(true); + }); + + it('rejects unknown keys, unbounded coordinates, and unsupported tools or modes', () => { + expect(validateGroundingLiteCall(call('search_places', { text_query: 'coffee', unexpected: true }))).toBe(false); + expect(validateGroundingLiteCall(call('compute_routes', { origin: { lat_lng: { latitude: 91, longitude: 2 } }, destination: { place_id: 'p1' }, travel_mode: 'TRANSIT' }))).toBe(false); + expect(validateGroundingLiteCall(call('delete_everything', {}))).toBe(false); + }); +}); diff --git a/demos/real-world-reasoning-agent/server/index.mjs b/demos/real-world-reasoning-agent/server/index.mjs new file mode 100644 index 0000000..592566e --- /dev/null +++ b/demos/real-world-reasoning-agent/server/index.mjs @@ -0,0 +1,316 @@ +import { createServer } from 'node:http'; +import { validateGroundingLiteCall } from './groundingLiteGate.mjs'; +import { createReadStream, existsSync, statSync } from 'node:fs'; +import { join, normalize, extname, sep } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { + allowedGeminiModels, + capGenerateContentRequest, + validateGeminiCredential, + selectGeminiCredential, + validateMetadata, + toProxyDiagRecord, + classifyProxyOutcome, + shouldHeartbeat, + HEARTBEAT_MS, + upstreamHeaders, +} from './lib.mjs'; + +const __dirname = fileURLToPath(new URL('.', import.meta.url)); +const root = normalize(join(__dirname, '..')); +const dist = join(root, 'dist'); +const port = Number(process.env.PORT || 8080); +const gmpKey = (process.env.GMP_SERVER_KEY || '').trim(); +// Separate unrestricted key for server-to-server MCP calls (no HTTP Referer). +// Falls back to GMP_SERVER_KEY for backwards compatibility. +const gmpMcpKey = (process.env.GMP_MCP_KEY || '').trim() || gmpKey; +const groundingLiteEnabled = process.env.GROUNDING_LITE_ENABLED === 'true'; +const geminiKey = (process.env.GEMINI_KEY || '').trim(); +const aiLimit = Number(process.env.AI_RATE_LIMIT || 36000); +const gmpLimit = Number(process.env.GMP_RATE_LIMIT || 180000); +const windowMs = Number(process.env.RATE_LIMIT_WINDOW_MS || 15 * 60 * 1000); +// Hosted-key budget guard. At current 3.6 Flash pricing, the defaults bound a +// UTC day's aggregate request payload to ~1.4 MB and model output to at most +// 50 * 2,048 tokens. This is intentionally conservative for a public demo. +// BYOK traffic uses the user's own project and bypasses these hosted spend caps. +const dailyAiCap = Number(process.env.DAILY_AI_CAP || 50); +const dailyAiInputBytes = Number(process.env.DAILY_AI_INPUT_BYTES || 1_400_000); +const hostedAiMaxOutputTokens = Number(process.env.HOSTED_AI_MAX_OUTPUT_TOKENS || 2_048); +const dailyVideoCap = Number(process.env.DAILY_VIDEO_CAP || 0); +const bodyCap = Number(process.env.BODY_CAP_BYTES || 1024 * 1024); +const timeoutMs = Number(process.env.UPSTREAM_TIMEOUT_MS || 60_000); +// Omni video generation (Interactions API) is far slower than a chat/vision turn, +// so its upstream call gets a longer, separately-tunable timeout. +const videoTimeoutMs = Number(process.env.VIDEO_UPSTREAM_TIMEOUT_MS || 300_000); +// Base allowlist plus any comma-separated ids in GENAI_EXTRA_MODELS, so a deployer +// can enable a heavier "omni" chat/image model without editing code. +const allowedModels = allowedGeminiModels(process.env.GENAI_EXTRA_MODELS); +const buckets = new Map(); +// Min interval between `ok` proxy heartbeats per endpoint; 0 disables them. +const heartbeatMs = Number(process.env.PROXY_HEARTBEAT_MS ?? HEARTBEAT_MS); +const heartbeats = new Map(); +let daily = { day: new Date().toISOString().slice(0, 10), ai: 0, inputBytes: 0, video: 0 }; + +const mime = { '.html': 'text/html; charset=utf-8', '.js': 'text/javascript; charset=utf-8', '.css': 'text/css; charset=utf-8', '.json': 'application/json; charset=utf-8', '.png': 'image/png', '.svg': 'image/svg+xml', '.ico': 'image/x-icon' }; +// Cloud Run / GFE appends the real client IP as the LAST X-Forwarded-For hop. +// Trusting the first (client-supplied) hop lets callers forge a fresh bucket +// per request and defeat rate limiting, so use the last entry. +function ip(req) { const xff = (req.headers['x-forwarded-for'] || '').toString().split(',').map((s) => s.trim()).filter(Boolean); return xff.length ? xff[xff.length - 1] : (req.socket.remoteAddress || 'unknown'); } +function rate(req, kind, limit) { const key = `${kind}:${ip(req)}`; const now = Date.now(); const b = buckets.get(key) || []; const fresh = b.filter((t) => now - t < windowMs); fresh.push(now); buckets.set(key, fresh); return fresh.length <= limit; } +function sameOrigin(req) { const host = req.headers['x-forwarded-host'] || req.headers.host; const source = req.headers.origin || req.headers.referer; if (!source) return true; try { return new URL(source).host === host; } catch { return false; } } +function appendKey(url, key) { url.searchParams.set('key', key); return url; } +function send(res, status, body, headers = {}) { res.writeHead(status, { 'content-type': 'text/plain; charset=utf-8', ...security(), ...headers }); res.end(body); } +function sendJson(res, status, value) { res.writeHead(status, { 'content-type': 'application/json; charset=utf-8', ...security(), 'cache-control': 'no-store' }); res.end(JSON.stringify(value)); } +// Coarsen to the minute so proxy telemetry can't be used for fine-grained +// timing correlation (mirrors TS_BUCKET_MS in src/diagnostics/telemetry.ts). +function minuteBucket() { return Math.floor(Date.now() / 60_000) * 60_000; } +// Emit privacy-safe, structural proxy telemetry. FAILURES are always logged; a +// 2xx would otherwise be dropped (a tile stream would flood logs), so instead we +// emit a rate-limited `ok` HEARTBEAT — at most one per endpoint per interval — so +// the triage loop can tell a healthy, quiet service from a dead/undeployed one. +// Content-free by construction; toProxyDiagRecord fails closed if it ever isn't. +function logProxy(endpoint, status) { + if (classifyProxyOutcome(status) === 'ok' && !shouldHeartbeat(heartbeats, endpoint, Date.now(), heartbeatMs)) return; + const rec = toProxyDiagRecord(endpoint, status, minuteBucket()); + if (rec) console.log(JSON.stringify({ evt: 'proxy', ...rec, at: new Date().toISOString() })); +} +// Log the proxy outcome, then send the (usually error) response, in one call so +// gate rejections (429/403/…) become telemetry without repeating the log line. +function sendProxy(res, endpoint, status, body, headers = {}) { logProxy(endpoint, status); return send(res, status, body, headers); } +function security() { + return { + 'x-content-type-options': 'nosniff', + 'referrer-policy': 'strict-origin-when-cross-origin', + 'x-frame-options': 'DENY', + 'permissions-policy': 'camera=(), microphone=(self), geolocation=(self)', + 'strict-transport-security': 'max-age=31536000; includeSubDomains', + 'content-security-policy': "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval' blob: https://maps.googleapis.com https://www.googletagmanager.com https://www.google-analytics.com; worker-src 'self' blob:; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; img-src 'self' data: blob: https://*.googleapis.com https://*.gstatic.com https://*.googleusercontent.com https://www.google-analytics.com https://www.google.com https://stats.g.doubleclick.net; font-src 'self' data: https://fonts.gstatic.com; connect-src 'self' data: blob: https://*.googleapis.com https://*.gstatic.com https://www.googletagmanager.com https://www.google-analytics.com https://analytics.google.com https://stats.g.doubleclick.net https://www.google.com;" + }; +} +async function body(req) { const chunks = []; let total = 0; for await (const c of req) { total += c.length; if (total > bodyCap) throw new Error('body_too_large'); chunks.push(c); } return Buffer.concat(chunks); } +function resetDaily() { const day = new Date().toISOString().slice(0, 10); if (daily.day !== day) daily = { day, ai: 0, inputBytes: 0, video: 0 }; } +function allowedPhotoTarget(raw) { + try { + const target = new URL(raw); + const ok = target.protocol === 'https:' && (target.hostname === 'places.googleapis.com' || target.hostname === 'googleusercontent.com' || target.hostname.endsWith('.googleusercontent.com')); + if (!ok) return null; + if (target.hostname === 'places.googleapis.com') appendKey(target, gmpKey); + return target; + } catch { + return null; + } +} + +async function proxy(req, res, target, opts = {}) { + const limit = opts.timeoutMs ?? timeoutMs; + const ctrl = new AbortController(); const timer = setTimeout(() => ctrl.abort(), limit); + try { + // Callers that must read the body first (e.g. to inspect the model) pass it + // back in via opts.buffer so we don't try to consume the stream twice. + const buf = opts.buffer !== undefined ? opts.buffer : (['GET','HEAD'].includes(req.method || '') ? undefined : await body(req)); + // Forward ORIGIN-ONLY referer/origin upstream: a replay link's raw ?prompt= + // lives in the full Referer URL and must never reach Google (reliability §5). + // rawHeaders bypasses upstreamHeaders entirely for server-to-server calls + // that must NOT send browser referer/origin (e.g. MCP API-key auth). + const hdrs = opts.rawHeaders ?? { ...upstreamHeaders(req.headers, target), ...opts.headers }; + const up = await fetch(target, { method: req.method, headers: hdrs, body: buf, signal: ctrl.signal }); + const headers = Object.fromEntries(up.headers); delete headers['content-encoding']; delete headers['content-length']; + res.writeHead(up.status, { ...headers, ...security() }); + if (opts.endpoint) logProxy(opts.endpoint, up.status); + if (up.body) { for await (const chunk of up.body) res.write(chunk); } + res.end(); + } catch (e) { + // Once the response has started streaming (headers sent) we can't send an + // error body — a second writeHead throws and leaves the socket open, wedging + // the client on a silent stream. Destroy it instead so the client sees a + // failed read and can retry, rather than hanging forever. + const status = e.message === 'body_too_large' ? 413 : 502; + if (res.headersSent) res.destroy(e); + else send(res, status, 'Upstream request failed'); + if (opts.endpoint) logProxy(opts.endpoint, res.headersSent ? 502 : status); + } + finally { clearTimeout(timer); } +} + +// Build a target URL pinned to `host`. Rejects any `rest` that would let the +// WHATWG URL parser override the host — e.g. a protocol-relative `//evil.example` +// or an absolute `https://evil.example` — which would otherwise leak the API key +// to an attacker-controlled server (SSRF + key exfiltration). +function pinnedTarget(rest, query, host) { + if (!rest.startsWith('/') || rest.startsWith('//')) return null; + const target = new URL(rest + query, host); + if (target.origin !== host) return null; + return target; +} +function gmpTarget(path, query) { + let host = 'https://maps.googleapis.com'; let rest = path; + if (path.startsWith('/gmp/geocode')) rest = '/maps/api/geocode' + path.slice('/gmp/geocode'.length); + else if (path.startsWith('/gmp/airquality')) { host = 'https://airquality.googleapis.com'; rest = path.slice('/gmp/airquality'.length); } + else if (path.startsWith('/gmp/weather')) { host = 'https://weather.googleapis.com'; rest = path.slice('/gmp/weather'.length); } + else if (path.startsWith('/gmp/pollen')) { host = 'https://pollen.googleapis.com'; rest = path.slice('/gmp/pollen'.length); } + else if (path.startsWith('/gmp/solar')) { host = 'https://solar.googleapis.com'; rest = path.slice('/gmp/solar'.length); } + else if (path.startsWith('/gmp/streetview')) rest = path.slice('/gmp/streetview'.length); + else if (path.startsWith('/gmp/staticmap')) rest = path.slice('/gmp/staticmap'.length); + else return null; + const target = pinnedTarget(rest, query, host); if (!target) return null; + return appendKey(target, gmpKey); +} +function aiTarget(path, query) { + if (!path.startsWith('/ai/')) return null; + if (!path.endsWith(':generateContent') && !path.endsWith(':streamGenerateContent')) return null; + const m = path.match(/models\/([^/:]+)/); if (!m || !allowedModels.has(m[1])) return null; + return pinnedTarget(path.replace(/^\/ai/, ''), query, 'https://generativelanguage.googleapis.com'); +} +// The omni video model uses the Interactions API, which POSTs to +// `/{version}/interactions` — there is no `models/` in the path, so the model +// to allowlist lives in the request BODY. This only matches the interactions +// collection endpoint (create); per-id sub-paths (/interactions/...) are not +// exposed. The caller checks the body's `model` against allowedModels. +function aiInteractionsTarget(path, query) { + if (!/^\/ai\/[^/]+\/interactions$/.test(path)) return null; + return pinnedTarget(path.replace(/^\/ai/, ''), query, 'https://generativelanguage.googleapis.com'); +} + +createServer(async (req, res) => { + const url = new URL(req.url || '/', `http://${req.headers.host || 'localhost'}`); + if (url.pathname === '/capabilities') { + if (req.method !== 'GET') return send(res, 405, 'Capabilities only accepts GET'); + return sendJson(res, 200, { maps: Boolean(gmpKey), gemini: Boolean(geminiKey), groundingLite: Boolean(gmpMcpKey) && groundingLiteEnabled }); + } + if (url.pathname.startsWith('/gmp/')) { + if (!gmpKey) return send(res, 500, 'GMP server key is not configured'); + if (url.pathname === '/gmp/grounding-lite/mcp') { + if (req.method !== 'POST') return sendProxy(res, 'gmp', 405, 'Grounding Lite only accepts POST'); + if (!sameOrigin(req) || !rate(req, 'gmp', gmpLimit)) return sendProxy(res, 'gmp', 429, 'The demo is busy right now — try again in a few minutes'); + let buf; + try { buf = await body(req); } catch (e) { return sendProxy(res, 'gmp', e.message === 'body_too_large' ? 413 : 400, 'Invalid request body'); } + let request; + try { request = JSON.parse(buf.toString('utf8')); } catch { return sendProxy(res, 'gmp', 400, 'Invalid MCP JSON'); } + if (!validateGroundingLiteCall(request)) return sendProxy(res, 'gmp', 403, 'MCP tool or arguments are not allowed'); + if (!gmpMcpKey) return sendProxy(res, 'gmp', 500, 'MCP key is not configured'); + // Server-to-server MCP call: authenticate via API key, NOT browser referer. + // Omit referer/origin entirely — an empty referer: '' header triggers key + // referer restrictions ('Requests from referer are blocked'). + return proxy(req, res, new URL('https://mapstools.googleapis.com/mcp'), { + endpoint: 'gmp', + buffer: buf, + rawHeaders: { 'content-type': 'application/json', accept: 'application/json, text/event-stream', 'X-Goog-Api-Key': gmpMcpKey }, + }); + } + if (url.pathname === '/gmp/placephoto') { + if (!sameOrigin(req) || !rate(req, 'gmp', gmpLimit * 10)) return sendProxy(res, 'gmp:photo', 429, 'The demo is busy right now — try again in a few minutes'); + const target = allowedPhotoTarget(url.searchParams.get('url') || ''); if (!target) return sendProxy(res, 'gmp:photo', 400, 'Unsupported photo URL'); + return proxy(req, res, target, { endpoint: 'gmp:photo' }); + } + const isTile = url.pathname.includes('/heatmapTiles/') || url.pathname.includes('/streetview') || url.pathname.includes('/staticmap'); + const endpoint = isTile ? 'gmp:tile' : 'gmp'; + const limit = isTile ? gmpLimit * 10 : gmpLimit; + if (!sameOrigin(req) || !rate(req, 'gmp', limit)) return sendProxy(res, endpoint, 429, 'The demo is busy right now — try again in a few minutes'); + const target = gmpTarget(url.pathname, url.search); if (!target) return sendProxy(res, endpoint, 404, 'Unknown GMP proxy route'); + return proxy(req, res, target, { endpoint }); + } + // Consent-gated browser metadata sink (reliability §5). Accepts ONLY strictly + // validated, sanitized structural metadata AND only when the client asserts + // consent (X-Atlas-Consent: 1). Nothing is forwarded to a third party; the + // server just logs a structured, content-free counter. No consent -> 403. + if (url.pathname === '/metadata') { + if (req.method !== 'POST') return send(res, 405, 'Metadata only accepts POST'); + if (!sameOrigin(req) || !rate(req, 'gmp', gmpLimit)) return send(res, 429, 'The demo is busy right now — try again in a few minutes'); + if (req.headers['x-atlas-consent'] !== '1') return send(res, 403, 'Diagnostics consent required'); + let buf; + try { buf = await body(req); } catch (e) { return send(res, e.message === 'body_too_large' ? 413 : 400, 'Invalid request body'); } + let payload; + try { payload = JSON.parse(buf.toString('utf8')); } catch { return send(res, 400, 'Invalid metadata JSON'); } + const v = validateMetadata(payload); + if (!v.ok) return send(res, 400, `Rejected metadata: ${v.reason}`); + // Log the VALIDATED, content-free sanitized batch so the telemetry-triage job + // can cluster it from production logs. validateMetadata has already proven the + // payload is structural-only (no urls/coords/ids/prompt) — never its content. + console.log(JSON.stringify({ evt: 'diag_batch', records: payload, at: new Date().toISOString() })); + return send(res, 204, ''); + } + if (url.pathname.startsWith('/ai/')) { + resetDaily(); + if (req.method !== 'POST') return send(res, 405, 'AI proxy only accepts POST'); + if (!sameOrigin(req) || !rate(req, 'ai', aiLimit)) return sendProxy(res, 'ai', 429, 'The demo is busy right now — try again in a few minutes'); + const credential = selectGeminiCredential(req.headers, geminiKey); + if (credential.source === 'invalid') return sendProxy(res, 'ai', 401, 'Gemini key is invalid'); + if (credential.source === 'none') return sendProxy(res, 'ai', 500, 'Gemini key is not configured'); + + if (url.pathname === '/ai/validate') { + const result = await validateGeminiCredential(credential.key); + const status = result.ok ? 200 + : result.reason === 'invalid' ? 401 + : result.reason === 'quota' ? 429 + : result.reason === 'model_unavailable' ? 424 + : 502; + return sendJson(res, status, result); + } + + // The hosted demo has a shared spend cap. A personal key uses its owner's + // project quota while remaining subject to this service's per-IP rate limit. + if (credential.source === 'hosted' && daily.ai >= dailyAiCap) { + return sendProxy(res, 'ai', 429, 'The demo is busy right now — try again in a few minutes'); + } + // Interactions API (omni video): the model lives in the body, so buffer + + // parse it, enforce the same allowlist, then forward with the longer video timeout. + const interTarget = aiInteractionsTarget(url.pathname, url.search); + if (interTarget) { + let buf; + try { buf = await body(req); } catch (e) { return send(res, e.message === 'body_too_large' ? 413 : 400, 'Invalid request body'); } + let model; + try { model = JSON.parse(buf.toString('utf8'))?.model; } catch { return send(res, 400, 'Invalid interactions request body'); } + if (typeof model !== 'string' || !allowedModels.has(model)) return sendProxy(res, 'ai', 403, 'AI model or method is not allowed'); + if (credential.source === 'hosted' && daily.video >= dailyVideoCap) { + return sendProxy(res, 'ai', 429, 'Video generation requires a personal Gemini API key on this demo'); + } + if (credential.source === 'hosted' && daily.inputBytes + buf.length > dailyAiInputBytes) { + return sendProxy(res, 'ai', 429, 'The hosted Gemini daily budget is exhausted — connect a personal key to continue'); + } + if (credential.source === 'hosted') { + daily.ai++; + daily.video++; + daily.inputBytes += buf.length; + } + return proxy(req, res, interTarget, { + buffer: buf, + timeoutMs: videoTimeoutMs, + endpoint: 'ai', + headers: { 'x-goog-api-key': credential.key }, + }); + } + const target = aiTarget(url.pathname, url.search); if (!target) return sendProxy(res, 'ai', 403, 'AI model or method is not allowed'); + let buf; + try { buf = await body(req); } catch (e) { return send(res, e.message === 'body_too_large' ? 413 : 400, 'Invalid request body'); } + if (credential.source === 'hosted') { + if (daily.inputBytes + buf.length > dailyAiInputBytes) { + return sendProxy(res, 'ai', 429, 'The hosted Gemini daily budget is exhausted — connect a personal key to continue'); + } + try { buf = capGenerateContentRequest(buf, hostedAiMaxOutputTokens); } + catch { return sendProxy(res, 'ai', 400, 'Invalid Gemini request body'); } + daily.ai++; + daily.inputBytes += buf.length; + } + return proxy(req, res, target, { buffer: buf, endpoint: 'ai', headers: { 'x-goog-api-key': credential.key } }); + } + let reqPath; + try { + let p = decodeURIComponent(url.pathname); + if (p.startsWith('/labs/atlas')) p = p.slice('/labs/atlas'.length); + reqPath = normalize(p === '' || p === '/' ? '/index.html' : p); + } + catch { return send(res, 400, 'Bad Request'); } + const file = normalize(join(dist, reqPath)); + const safe = file === dist || file.startsWith(dist + sep); const finalFile = safe && existsSync(file) && statSync(file).isFile() ? file : join(dist, 'index.html'); + // Fall back to 404 if the SPA shell isn't built (e.g. tests run before `build`) + // rather than serving a missing file — an unhandled stream error would crash + // the whole process. + if (!existsSync(finalFile)) return send(res, 404, 'Not found'); + const ext = extname(finalFile); const cache = ext === '.html' ? 'no-cache' : 'public, max-age=31536000, immutable'; + res.writeHead(200, { ...security(), 'content-type': mime[ext] || 'application/octet-stream', 'cache-control': cache }); + const stream = createReadStream(finalFile); + // A read error after headers are sent can't become an error response; destroy + // the socket instead of letting the unhandled 'error' crash the process. + stream.on('error', () => res.destroy()); + stream.pipe(res); +}).listen(port, () => console.log(`Atlas server listening on :${port}`)); diff --git a/demos/real-world-reasoning-agent/server/lib.mjs b/demos/real-world-reasoning-agent/server/lib.mjs new file mode 100644 index 0000000..4f04521 --- /dev/null +++ b/demos/real-world-reasoning-agent/server/lib.mjs @@ -0,0 +1,255 @@ +// Pure, testable server helpers (no side effects on import — unlike index.mjs, +// which starts listening). Kept separate so server/lib.test.ts can exercise the +// privacy logic without spawning a server. Reliability plan §5. + +/** + * Reduce a possibly-full URL (path + query, e.g. a replay link carrying + * `?prompt=`) to its ORIGIN only. Used to sanitize the Referer/ + * Origin the /ai and /gmp proxies forward upstream, so a raw prompt sitting in + * the address bar is never leaked to Google. Returns '' for empty/invalid input. + */ +export function stripToOrigin(value) { + if (!value) return ''; + try { + return new URL(value).origin; + } catch { + return ''; + } +} + +export const GMP_SOLUTION_ID = 'gmp_git_agentskills_v1'; + +/** Private, same-origin-only header used for a tab-scoped user Gemini key. */ +export const GEMINI_BYOK_HEADER = 'x-atlas-gemini-key'; + +/** Production proxy allowlist. Keep in sync with src/lib/config.ts. */ +export const BASE_GEMINI_MODELS = Object.freeze([ + 'gemini-3.6-flash', + 'gemini-3.5-flash-lite', + 'gemini-3.5-flash', + 'gemini-3.1-flash-lite', + 'gemini-3.1-flash-tts-preview', + 'gemini-3.1-flash-lite-image', + 'gemini-omni-flash-preview', +]); + +/** Models a key must be able to see before Atlas reports it as connected. */ +export const REQUIRED_GEMINI_MODELS = Object.freeze([ + 'gemini-3.6-flash', + 'gemini-3.5-flash-lite', +]); + +export function allowedGeminiModels(extra = '') { + return new Set([ + ...BASE_GEMINI_MODELS, + ...String(extra).split(',').map((value) => value.trim()).filter(Boolean), + ]); +} + +function headerValue(headers, name) { + const value = headers?.[name]; + return Array.isArray(value) ? value[0] : value; +} + +/** Keys are opaque; enforce only safe printable bounds rather than a brittle prefix. */ +export function isPlausibleGeminiKey(value) { + return typeof value === 'string' && /^[\x21-\x7e]{8,512}$/.test(value); +} + +/** Select a user key explicitly supplied for this request, else the hosted key. */ +export function selectGeminiCredential(headers, hostedKey = '') { + const supplied = String(headerValue(headers, GEMINI_BYOK_HEADER) ?? '').trim(); + if (supplied) { + return isPlausibleGeminiKey(supplied) + ? { source: 'byok', key: supplied } + : { source: 'invalid', key: '' }; + } + const hosted = String(hostedKey).trim(); + return hosted + ? { source: 'hosted', key: hosted } + : { source: 'none', key: '' }; +} + +/** + * Parse a generateContent request and clamp its output budget for hosted-key + * traffic. Personal-key traffic bypasses this helper and keeps the caller's + * requested budget. Throws on malformed/non-object JSON so the proxy fails + * closed before contacting Gemini. + */ +export function capGenerateContentRequest(raw, maxOutputTokens) { + const request = JSON.parse(typeof raw === 'string' ? raw : raw.toString('utf8')); + if (!request || typeof request !== 'object' || Array.isArray(request)) { + throw new Error('invalid_generate_content_body'); + } + const configured = Number(request.generationConfig?.maxOutputTokens); + const safeMax = Math.max(1, Math.floor(Number(maxOutputTokens))); + request.generationConfig = { + ...(request.generationConfig && typeof request.generationConfig === 'object' + ? request.generationConfig + : {}), + maxOutputTokens: Number.isFinite(configured) && configured > 0 + ? Math.min(Math.floor(configured), safeMax) + : safeMax, + }; + return Buffer.from(JSON.stringify(request)); +} + +/** + * Validate authentication and both required model routes without generating + * billable content. The key is sent only in an upstream header and never in a URL. + */ +export async function validateGeminiCredential(key, fetchImpl = fetch) { + if (!isPlausibleGeminiKey(key)) return { ok: false, reason: 'invalid' }; + for (const model of REQUIRED_GEMINI_MODELS) { + let response; + try { + response = await fetchImpl( + `https://generativelanguage.googleapis.com/v1beta/models/${encodeURIComponent(model)}`, + { method: 'GET', headers: { 'x-goog-api-key': key, accept: 'application/json' } }, + ); + } catch { + return { ok: false, reason: 'network' }; + } + if (response.ok) continue; + if (response.status === 401 || response.status === 403) return { ok: false, reason: 'invalid' }; + if (response.status === 404) return { ok: false, reason: 'model_unavailable', model }; + if (response.status === 429) return { ok: false, reason: 'quota' }; + return { ok: false, reason: 'upstream' }; + } + return { ok: true }; +} + +/** Build the deliberately small upstream header allowlist for proxy requests. */ +export function upstreamHeaders(incoming, target) { + const headers = { + 'content-type': incoming['content-type'] || '', + accept: incoming.accept || '*/*', + referer: stripToOrigin(incoming.referer), + origin: stripToOrigin(incoming.origin), + }; + const host = target instanceof URL ? target.hostname : new URL(target).hostname; + if (host === 'weather.googleapis.com') { + headers['X-Goog-Maps-Solution-ID'] = GMP_SOLUTION_ID; + } + return headers; +} + +// Only these keys may appear in a browser metadata record; anything else is +// rejected so the endpoint can't be used to smuggle content off-device. +const ALLOWED_KEYS = new Set(['scenario', 'tool', 'status', 'category', 'detailLabels', 'tsBucket']); +const ALLOWED_STATUS = new Set(['running', 'ok', 'error']); +// Content that must never appear in accepted metadata (urls, coords, emails, +// unresolved tokens, place ids). Mirrors src/diagnostics/telemetry FORBIDDEN. +const FORBIDDEN = [ + /https?:\/\//i, + /\bdata:/i, + /@[\w.-]+\.[a-z]{2,}/i, + /-?\d{1,3}\.\d{3,}/, + /\{[a-zA-Z][\w.]*\}/, + /place[_-]?id/i, +]; + +function hasForbidden(text) { + return FORBIDDEN.some((re) => re.test(text)); +} + +/** Max metadata records accepted in a single POST. */ +export const METADATA_MAX_RECORDS = 500; + +/** + * Strictly validate a browser metadata payload (array of sanitized diagnostic + * records). Returns `{ ok: true, count }` or `{ ok: false, reason }`. Rejects + * unknown keys, bad types, oversize batches, and any forbidden content — the + * server accepts ONLY structural metadata, never Maps content or PII. + */ +export function validateMetadata(payload) { + if (!Array.isArray(payload)) return { ok: false, reason: 'expected an array of records' }; + if (payload.length > METADATA_MAX_RECORDS) return { ok: false, reason: 'too many records' }; + for (const rec of payload) { + if (!rec || typeof rec !== 'object' || Array.isArray(rec)) return { ok: false, reason: 'record must be an object' }; + for (const k of Object.keys(rec)) if (!ALLOWED_KEYS.has(k)) return { ok: false, reason: `unexpected key: ${k}` }; + const { scenario, tool, status, category, detailLabels, tsBucket } = rec; + if (typeof scenario !== 'string' || typeof tool !== 'string' || typeof category !== 'string') { + return { ok: false, reason: 'scenario/tool/category must be strings' }; + } + if (!ALLOWED_STATUS.has(status)) return { ok: false, reason: 'invalid status' }; + if (typeof tsBucket !== 'number' || !Number.isFinite(tsBucket)) return { ok: false, reason: 'invalid tsBucket' }; + if (detailLabels !== undefined) { + if (!Array.isArray(detailLabels) || detailLabels.some((l) => typeof l !== 'string')) { + return { ok: false, reason: 'detailLabels must be a string array' }; + } + } + const scan = [scenario, tool, category, ...(detailLabels ?? [])].join(' '); + if (hasForbidden(scan)) return { ok: false, reason: 'forbidden content in metadata' }; + } + return { ok: true, count: payload.length }; +} + +// --- Proxy telemetry (reliability §5 / telemetry-triage) -------------------- +// The /ai and /gmp proxies emit privacy-safe, structural failure signal so the +// triage job can cluster reliability issues from production logs. The ONLY thing +// logged is a coarse endpoint LABEL + a derived failure CLASS — never a path, +// query, url, prompt, or any Maps content. + +/** Fixed, coarse endpoint labels allowed in proxy telemetry. Never a path/query. */ +export const PROXY_ENDPOINTS = new Set(['ai', 'gmp', 'gmp:tile', 'gmp:photo']); + +/** Map an HTTP status to a coarse, content-free failure class. */ +export function classifyProxyOutcome(status) { + if (status === 429) return 'rate_limit'; + if (status === 403) return 'forbidden'; + if (status >= 500) return 'upstream_error'; + if (status >= 400) return 'bad_request'; + if (status >= 200 && status < 300) return 'ok'; + return 'other'; +} + +/** + * Project a proxy outcome into a SanitizedDiagnostic-shaped record so the triage + * reducer treats proxy signal and client diagnostics through ONE pipeline. The + * endpoint must be one of the fixed coarse labels. Returns null if the label is + * unknown OR the derived record trips the FORBIDDEN guard — a leak fails closed + * (belt-and-suspenders; labels are fixed strings so this should never fire). + */ +export function toProxyDiagRecord(endpoint, status, tsBucket = 0) { + if (!PROXY_ENDPOINTS.has(endpoint)) return null; + const cls = classifyProxyOutcome(status); + const record = { + scenario: 'proxy', + tool: endpoint, + status: cls === 'ok' ? 'ok' : 'error', + category: `proxy:${endpoint}:${cls}`, + detailLabels: [], + tsBucket: typeof tsBucket === 'number' && Number.isFinite(tsBucket) ? tsBucket : 0, + }; + if (hasForbidden([record.scenario, record.tool, record.category].join(' '))) return null; + return record; +} + +// --- OK heartbeat ----------------------------------------------------------- +// The proxy logs only on failure by design (a 2xx tile stream would flood +// logs). The downside: a totally healthy service and a dead/misconfigured one +// both produce ZERO proxy lines, so the triage loop can't tell "quiet and well" +// from "not running / telemetry not deployed". A rate-limited `ok` heartbeat +// closes that gap: at most one `ok` sample per endpoint per interval, emitted on +// a successful proxy. It proves the telemetry path is wired end-to-end and gives +// the triage window a positive "traffic flowed" signal. It never becomes an +// issue — buildTriage only proposes ERROR clusters. + +/** Default min interval between `ok` heartbeat samples per endpoint (ms). */ +export const HEARTBEAT_MS = 10 * 60 * 1000; + +/** + * Decide whether to emit an `ok` heartbeat for `endpoint` at `now`, given a Map + * of last-emit timestamps. Returns true (and records `now`) at most once per + * `intervalMs` per endpoint; returns false in between. `intervalMs <= 0` disables + * heartbeats entirely (always false). Mutating the map here keeps the caller a + * one-liner and the throttle testable without a live server. + */ +export function shouldHeartbeat(state, endpoint, now, intervalMs = HEARTBEAT_MS) { + if (!(intervalMs > 0)) return false; + const last = state.get(endpoint); + if (last !== undefined && now - last < intervalMs) return false; + state.set(endpoint, now); + return true; +} diff --git a/demos/real-world-reasoning-agent/server/lib.test.ts b/demos/real-world-reasoning-agent/server/lib.test.ts new file mode 100644 index 0000000..dcc4605 --- /dev/null +++ b/demos/real-world-reasoning-agent/server/lib.test.ts @@ -0,0 +1,214 @@ +import { describe, expect, it } from 'vitest'; +import { + stripToOrigin, + validateMetadata, + METADATA_MAX_RECORDS, + classifyProxyOutcome, + toProxyDiagRecord, + PROXY_ENDPOINTS, + shouldHeartbeat, + HEARTBEAT_MS, + GMP_SOLUTION_ID, + upstreamHeaders, + allowedGeminiModels, + GEMINI_BYOK_HEADER, + selectGeminiCredential, + validateGeminiCredential, + capGenerateContentRequest, +} from './lib.mjs'; + +describe('Gemini credential routing', () => { + it('allowlists the orchestrator and worker defaults', () => { + const models = allowedGeminiModels(); + expect(models.has('gemini-3.6-flash')).toBe(true); + expect(models.has('gemini-3.5-flash-lite')).toBe(true); + }); + + it('prefers a plausible personal key and otherwise uses hosted auth', () => { + expect(selectGeminiCredential({ [GEMINI_BYOK_HEADER]: 'personal-test-key' }, 'hosted-test-key')) + .toEqual({ source: 'byok', key: 'personal-test-key' }); + expect(selectGeminiCredential({}, 'hosted-test-key')) + .toEqual({ source: 'hosted', key: 'hosted-test-key' }); + expect(selectGeminiCredential({ [GEMINI_BYOK_HEADER]: 'bad key' }, 'hosted-test-key').source) + .toBe('invalid'); + }); + + it('validates required models with header auth and never puts a key in the URL', async () => { + const calls = []; + const result = await validateGeminiCredential('personal-test-key', async (url, init) => { + calls.push({ url, init }); + return new Response('{}', { status: 200 }); + }); + expect(result).toEqual({ ok: true }); + expect(calls).toHaveLength(2); + expect(calls.every(({ url }) => !String(url).includes('personal-test-key'))).toBe(true); + expect(calls.every(({ init }) => init.headers['x-goog-api-key'] === 'personal-test-key')).toBe(true); + }); +}); + +describe('hosted Gemini output budget', () => { + it('adds or clamps maxOutputTokens without changing other generation settings', () => { + const added = JSON.parse(capGenerateContentRequest('{"contents":[]}', 2048).toString()); + expect(added.generationConfig.maxOutputTokens).toBe(2048); + + const clamped = JSON.parse(capGenerateContentRequest(JSON.stringify({ + contents: [], + generationConfig: { temperature: 0.2, maxOutputTokens: 8192 }, + }), 2048).toString()); + expect(clamped.generationConfig).toEqual({ temperature: 0.2, maxOutputTokens: 2048 }); + }); + + it('preserves a lower caller budget and rejects malformed JSON', () => { + const request = JSON.parse(capGenerateContentRequest(JSON.stringify({ + generationConfig: { maxOutputTokens: 512 }, + }), 2048).toString()); + expect(request.generationConfig.maxOutputTokens).toBe(512); + expect(() => capGenerateContentRequest('[]', 2048)).toThrow(); + expect(() => capGenerateContentRequest('{', 2048)).toThrow(); + }); +}); + +describe('stripToOrigin', () => { + it('reduces a full replay URL (with a raw prompt in the query) to its origin', () => { + expect(stripToOrigin('https://atlas.app/?scenario=scout&prompt=find%20a%20quiet%20cafe%20near%20me')).toBe( + 'https://atlas.app', + ); + }); + + it('keeps a bare origin unchanged and returns empty for missing/invalid input', () => { + expect(stripToOrigin('https://atlas.app')).toBe('https://atlas.app'); + expect(stripToOrigin('')).toBe(''); + expect(stripToOrigin(undefined)).toBe(''); + expect(stripToOrigin('not a url')).toBe(''); + }); +}); + +describe('upstreamHeaders', () => { + it('injects fixed solution attribution for production Weather proxy calls', () => { + const headers = upstreamHeaders( + { accept: 'application/json', referer: 'https://atlas.app/?prompt=private' }, + new URL('https://weather.googleapis.com/v1/currentConditions:lookup'), + ); + expect(headers).toMatchObject({ + accept: 'application/json', + referer: 'https://atlas.app', + 'X-Goog-Maps-Solution-ID': GMP_SOLUTION_ID, + }); + }); + + it('does not attach the Weather solution ID to unrelated upstreams', () => { + expect(upstreamHeaders({}, new URL('https://routes.googleapis.com/'))) + .not.toHaveProperty('X-Goog-Maps-Solution-ID'); + }); +}); + +describe('validateMetadata', () => { + const ok = { scenario: 'adstudio', tool: 'generate_ad_creatives', status: 'error', category: 'error:generate_ad_creatives', detailLabels: ['Business'], tsBucket: 1_700_000_040_000 }; + + it('accepts a clean array of sanitized records', () => { + expect(validateMetadata([ok])).toEqual({ ok: true, count: 1 }); + }); + + it('rejects non-arrays and oversize batches', () => { + expect(validateMetadata({}).ok).toBe(false); + expect(validateMetadata(new Array(METADATA_MAX_RECORDS + 1).fill(ok)).ok).toBe(false); + }); + + it('rejects unknown keys (no smuggling extra fields)', () => { + expect(validateMetadata([{ ...ok, summary: 'Blue Bottle Coffee' }]).ok).toBe(false); + }); + + it('rejects bad types and invalid status', () => { + expect(validateMetadata([{ ...ok, status: 'weird' }]).ok).toBe(false); + expect(validateMetadata([{ ...ok, tsBucket: 'now' }]).ok).toBe(false); + expect(validateMetadata([{ ...ok, detailLabels: [1, 2] }]).ok).toBe(false); + }); + + it('rejects forbidden content (urls, coords, emails, tokens, place ids)', () => { + expect(validateMetadata([{ ...ok, category: 'http://leak.example' }]).ok).toBe(false); + expect(validateMetadata([{ ...ok, detailLabels: ['37.79557,-122.39374'] }]).ok).toBe(false); + expect(validateMetadata([{ ...ok, detailLabels: ['user@example.com'] }]).ok).toBe(false); + expect(validateMetadata([{ ...ok, detailLabels: ['{business.name}'] }]).ok).toBe(false); + expect(validateMetadata([{ ...ok, tool: 'placeId' }]).ok).toBe(false); + }); +}); + +describe('classifyProxyOutcome', () => { + it('maps statuses to coarse, content-free failure classes', () => { + expect(classifyProxyOutcome(429)).toBe('rate_limit'); + expect(classifyProxyOutcome(403)).toBe('forbidden'); + expect(classifyProxyOutcome(500)).toBe('upstream_error'); + expect(classifyProxyOutcome(502)).toBe('upstream_error'); + expect(classifyProxyOutcome(404)).toBe('bad_request'); + expect(classifyProxyOutcome(400)).toBe('bad_request'); + expect(classifyProxyOutcome(200)).toBe('ok'); + expect(classifyProxyOutcome(204)).toBe('ok'); + expect(classifyProxyOutcome(100)).toBe('other'); + }); +}); + +describe('toProxyDiagRecord', () => { + it('projects a failure into a SanitizedDiagnostic-shaped record', () => { + expect(toProxyDiagRecord('ai', 429, 1_700_000_040_000)).toEqual({ + scenario: 'proxy', + tool: 'ai', + status: 'error', + category: 'proxy:ai:rate_limit', + detailLabels: [], + tsBucket: 1_700_000_040_000, + }); + }); + + it('marks a 2xx as status:ok (the caller decides whether to log it)', () => { + const r = toProxyDiagRecord('gmp:tile', 200, 0); + expect(r?.status).toBe('ok'); + expect(r?.category).toBe('proxy:gmp:tile:ok'); + }); + + it('rejects unknown endpoint labels (no smuggling a path/url as a label)', () => { + expect(toProxyDiagRecord('/gmp/geocode?address=1600', 429)).toBeNull(); + expect(toProxyDiagRecord('http://leak.example', 500)).toBeNull(); + }); + + it('only emits the fixed coarse endpoint labels', () => { + expect([...PROXY_ENDPOINTS]).toEqual(['ai', 'gmp', 'gmp:tile', 'gmp:photo']); + }); + + it('defaults a bad tsBucket to 0 rather than leaking a non-numeric value', () => { + // @ts-expect-error deliberately passing a bad tsBucket + expect(toProxyDiagRecord('ai', 500, 'now').tsBucket).toBe(0); + }); +}); + +describe('shouldHeartbeat', () => { + it('emits the first sample for an endpoint, then throttles within the interval', () => { + const state = new Map(); + expect(shouldHeartbeat(state, 'gmp:tile', 1_000, 10_000)).toBe(true); + expect(shouldHeartbeat(state, 'gmp:tile', 5_000, 10_000)).toBe(false); // < interval + expect(shouldHeartbeat(state, 'gmp:tile', 10_999, 10_000)).toBe(false); // still < interval + expect(shouldHeartbeat(state, 'gmp:tile', 11_000, 10_000)).toBe(true); // interval elapsed + expect(shouldHeartbeat(state, 'gmp:tile', 15_000, 10_000)).toBe(false); // throttled again + }); + + it('tracks each endpoint independently', () => { + const state = new Map(); + expect(shouldHeartbeat(state, 'ai', 0, 10_000)).toBe(true); + expect(shouldHeartbeat(state, 'gmp', 0, 10_000)).toBe(true); // different endpoint, own clock + expect(shouldHeartbeat(state, 'ai', 1_000, 10_000)).toBe(false); + }); + + it('is disabled when the interval is 0 or negative (never emits)', () => { + const state = new Map(); + expect(shouldHeartbeat(state, 'ai', 1_000, 0)).toBe(false); + expect(shouldHeartbeat(state, 'ai', 1_000, -1)).toBe(false); + expect(state.size).toBe(0); // did not record anything + }); + + it('defaults to the 10-minute HEARTBEAT_MS interval', () => { + const state = new Map(); + expect(HEARTBEAT_MS).toBe(10 * 60 * 1000); + expect(shouldHeartbeat(state, 'ai', 0)).toBe(true); + expect(shouldHeartbeat(state, 'ai', HEARTBEAT_MS - 1)).toBe(false); + expect(shouldHeartbeat(state, 'ai', HEARTBEAT_MS)).toBe(true); + }); +}); diff --git a/demos/real-world-reasoning-agent/server/proxy.test.ts b/demos/real-world-reasoning-agent/server/proxy.test.ts new file mode 100644 index 0000000..be1cda5 --- /dev/null +++ b/demos/real-world-reasoning-agent/server/proxy.test.ts @@ -0,0 +1,323 @@ +import { afterEach, describe, expect, it } from 'vitest'; +import { spawn, type ChildProcess } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; +import { dirname, join } from 'node:path'; + +/** + * Server proxy quality gates (reliability plan §4, area 7: session/offline). + * These exercise the /ai proxy's abuse controls END TO END — starting the real + * server/index.mjs as a child process — so the 429 "busy" gate, the daily cap, + * the cross-origin block, and the missing-key guard are covered deterministically + * with NO network and NO real API keys: + * - A dummy GEMINI_KEY is enough to pass the key guard; the rate/cap/origin + * checks all fire BEFORE any upstream fetch. + * - Requests target a NON-allowlisted model so aiTarget() returns null (403) + * without ever calling Google — they still consume a rate-limit slot, which is + * exactly what lets us prove the Nth request flips to 429. + */ + +const here = dirname(fileURLToPath(import.meta.url)); +const SERVER = join(here, 'index.mjs'); +const DISALLOWED = '/ai/v1beta/models/not-a-real-model:generateContent'; +const ALLOWED = '/ai/v1beta/models/gemini-3.6-flash:generateContent'; + +let child: ChildProcess | null = null; +// Cumulative stdout+stderr of the most recently started child, so tests can +// assert the structured telemetry lines it logs (evt:diag_batch / evt:proxy). +let readOutput: () => string = () => ''; + +/** Poll the child's cumulative output until `re` matches, or throw on timeout. */ +async function waitForLog(re: RegExp, timeoutMs = 5_000): Promise { + const deadline = Date.now() + timeoutMs; + for (;;) { + const out = readOutput(); + const line = out.split('\n').find((l) => re.test(l)); + if (line) return line; + if (Date.now() > deadline) throw new Error(`no log line matched ${re} within ${timeoutMs}ms. Output:\n${out || '(none)'}`); + await new Promise((r) => setTimeout(r, 25)); + } +} + +/** + * Start server/index.mjs on an ephemeral port with the given env. Resolves once + * the server logs that it is listening — waiting for the actual "listening" line + * (not polling fetch) is deterministic and robust on a slow, concurrent CI box. + * On early exit or timeout the child's captured output is surfaced in the error. + */ +async function startServer(env: Record): Promise { + const port = 8100 + Math.floor(Math.random() * 800); + const proc = spawn('node', [SERVER], { + env: { ...process.env, PORT: String(port), ...env }, + stdio: ['ignore', 'pipe', 'pipe'], + }); + child = proc; + let out = ''; + readOutput = () => out; + proc.stdout?.on('data', (d) => { out += d.toString(); }); + proc.stderr?.on('data', (d) => { out += d.toString(); }); + + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + cleanup(); + reject(new Error(`server did not start in time. Output:\n${out || '(none)'}`)); + }, 20_000); + const onData = () => { + if (/listening on :\d+/.test(out)) { + cleanup(); + resolve(port); + } + }; + const onExit = (code: number | null) => { + cleanup(); + reject(new Error(`server exited early (code ${code}) before listening. Output:\n${out || '(none)'}`)); + }; + function cleanup() { + clearTimeout(timer); + proc.stdout?.off('data', onData); + proc.stderr?.off('data', onData); + proc.off('exit', onExit); + } + proc.stdout?.on('data', onData); + proc.stderr?.on('data', onData); + proc.on('exit', onExit); + }); +} + +function post(port: number, path: string, headers: Record = {}): Promise { + return fetch(`http://127.0.0.1:${port}${path}`, { + method: 'POST', + headers: { 'content-type': 'application/json', ...headers }, + body: JSON.stringify({ contents: [{ parts: [{ text: 'hi' }] }] }), + }); +} + +afterEach(() => { + child?.kill('SIGKILL'); + child = null; +}); + +describe('/ai proxy quality gates', () => { + it('returns the friendly 429 "busy" message once the per-IP AI rate limit is exceeded', async () => { + const port = await startServer({ GEMINI_KEY: 'dummy', AI_RATE_LIMIT: '2', DAILY_AI_CAP: '1000' }); + // First two consume the window (403: model not allowlisted, but no upstream call); + // the third trips the limiter BEFORE the model check. + const r1 = await post(port, DISALLOWED); + const r2 = await post(port, DISALLOWED); + const r3 = await post(port, DISALLOWED); + expect([r1.status, r2.status]).toEqual([403, 403]); + expect(r3.status).toBe(429); + expect(await r3.text()).toMatch(/busy/i); + }, 25_000); + + it('gates every AI request with 429 once the daily cap is reached', async () => { + const port = await startServer({ GEMINI_KEY: 'dummy', DAILY_AI_CAP: '0', AI_RATE_LIMIT: '1000' }); + const r = await post(port, DISALLOWED); + expect(r.status).toBe(429); + }, 25_000); + + it('never accepts a visitor key from the URL', async () => { + const port = await startServer({ GEMINI_KEY: 'host-key', DAILY_AI_CAP: '0', AI_RATE_LIMIT: '1000' }); + const r = await post(port, `${DISALLOWED}?key=visitor-key`); + expect(r.status).toBe(429); + }, 25_000); + + it('rejects cross-origin AI requests (CSRF/abuse) with 429', async () => { + const port = await startServer({ GEMINI_KEY: 'dummy', AI_RATE_LIMIT: '1000', DAILY_AI_CAP: '1000' }); + const r = await post(port, DISALLOWED, { origin: 'http://evil.example', host: `127.0.0.1:${port}` }); + expect(r.status).toBe(429); + }, 25_000); + + it('fails closed with 500 when the Gemini key is not configured', async () => { + // Explicit empty overrides any GEMINI_KEY inherited from the shell env. + const port = await startServer({ GEMINI_KEY: '', AI_RATE_LIMIT: '1000', DAILY_AI_CAP: '1000' }); + const r = await post(port, DISALLOWED); + expect(r.status).toBe(500); + expect(await r.text()).toMatch(/not configured/i); + }, 25_000); + + it('rejects a malformed personal key without silently falling back to hosted auth', async () => { + const port = await startServer({ GEMINI_KEY: 'hosted-test-key', AI_RATE_LIMIT: '1000', DAILY_AI_CAP: '1000' }); + const r = await post(port, DISALLOWED, { 'x-atlas-gemini-key': 'bad key' }); + expect(r.status).toBe(401); + expect(await r.text()).toMatch(/invalid/i); + }, 25_000); + + it('keeps personal-key calls outside the hosted daily spend cap', async () => { + const port = await startServer({ GEMINI_KEY: 'hosted-test-key', DAILY_AI_CAP: '0', AI_RATE_LIMIT: '1000' }); + const r = await post(port, DISALLOWED, { 'x-atlas-gemini-key': 'personal-test-key' }); + // It reaches the model allowlist (403) instead of the hosted-spend cap (429), + // and the disallowed model guarantees no upstream network request occurs. + expect(r.status).toBe(403); + }, 25_000); + + it('rejects malformed hosted requests before contacting Gemini', async () => { + const port = await startServer({ GEMINI_KEY: 'hosted-test-key', DAILY_AI_CAP: '1000', AI_RATE_LIMIT: '1000' }); + const r = await fetch(`http://127.0.0.1:${port}${ALLOWED}`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: '{', + }); + expect(r.status).toBe(400); + expect(await r.text()).toMatch(/invalid gemini/i); + }, 25_000); + + it('keeps hosted video disabled by default while allowing BYOK to reach the allowlist', async () => { + const port = await startServer({ GEMINI_KEY: 'hosted-test-key', AI_RATE_LIMIT: '1000', DAILY_AI_CAP: '1000' }); + const path = '/ai/v1beta/interactions'; + const hosted = await fetch(`http://127.0.0.1:${port}${path}`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ model: 'gemini-omni-flash-preview' }), + }); + expect(hosted.status).toBe(429); + expect(await hosted.text()).toMatch(/personal gemini api key/i); + + const byok = await fetch(`http://127.0.0.1:${port}${path}`, { + method: 'POST', + headers: { 'content-type': 'application/json', 'x-atlas-gemini-key': 'personal-test-key' }, + body: JSON.stringify({ model: 'not-a-real-model' }), + }); + expect(byok.status).toBe(403); + }, 25_000); + + it('rejects a disallowed model with 403 (never forwarding it upstream)', async () => { + const port = await startServer({ GEMINI_KEY: 'dummy', AI_RATE_LIMIT: '1000', DAILY_AI_CAP: '1000' }); + const r = await post(port, DISALLOWED); + expect(r.status).toBe(403); + expect(await r.text()).toMatch(/not allowed/i); + }, 25_000); + + it('survives a static request when the SPA is not built (no crash on missing file)', async () => { + // Regression: tests run before `build`, so dist/index.html is absent. A read + // error on the fallback file must NOT crash the process. Prove the server is + // still responsive after a static request by making a second request. + const port = await startServer({ GEMINI_KEY: 'dummy', GMP_RATE_LIMIT: '1000' }); + const first = await fetch(`http://127.0.0.1:${port}/some/app/route`); + expect([200, 404]).toContain(first.status); + const second = await fetch(`http://127.0.0.1:${port}/metadata`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: '[]', + }); + expect(second.status).toBe(403); // still alive: consent gate responds + }, 25_000); +}); + +describe('/capabilities preflight', () => { + it('reports only whether live server capabilities are configured', async () => { + const port = await startServer({ GEMINI_KEY: 'dummy', GMP_SERVER_KEY: 'dummy', GROUNDING_LITE_ENABLED: 'true', GMP_RATE_LIMIT: '1000' }); + const response = await fetch(`http://127.0.0.1:${port}/capabilities`); + expect(response.status).toBe(200); + expect(await response.json()).toEqual({ maps: true, gemini: true, groundingLite: true }); + }, 25_000); + + it('fails closed when either server capability is absent', async () => { + const port = await startServer({ GEMINI_KEY: '', GMP_SERVER_KEY: '', GMP_RATE_LIMIT: '1000' }); + const response = await fetch(`http://127.0.0.1:${port}/capabilities`); + expect(await response.json()).toEqual({ maps: false, gemini: false, groundingLite: false }); + }, 25_000); +}); + +describe('/gmp/grounding-lite MCP gate', () => { + it('allows only the three bounded read-only tools without contacting upstream for rejected input', async () => { + const port = await startServer({ GMP_SERVER_KEY: 'dummy', GMP_RATE_LIMIT: '1000' }); + const response = await fetch(`http://127.0.0.1:${port}/gmp/grounding-lite/mcp`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'tools/call', params: { name: 'delete_everything', arguments: {} } }), + }); + expect(response.status).toBe(403); + expect(await response.text()).toMatch(/not allowed/i); + }, 25_000); + + it('rejects non-POST transport calls', async () => { + const port = await startServer({ GMP_SERVER_KEY: 'dummy', GMP_RATE_LIMIT: '1000' }); + const response = await fetch(`http://127.0.0.1:${port}/gmp/grounding-lite/mcp`); + expect(response.status).toBe(405); + }, 25_000); + + it('rejects unknown arguments before any upstream request', async () => { + const port = await startServer({ GMP_SERVER_KEY: 'dummy', GMP_RATE_LIMIT: '1000' }); + const response = await fetch(`http://127.0.0.1:${port}/gmp/grounding-lite/mcp`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'tools/call', params: { + name: 'search_places', arguments: { text_query: 'coffee', arbitrary_radius: 999999 }, + } }), + }); + expect(response.status).toBe(403); + expect(await response.text()).toMatch(/arguments/i); + }, 25_000); +}); + +describe('/metadata consent-gated sink', () => { + const record = { + scenario: 'adstudio', + tool: 'generate_ad_creatives', + status: 'error', + category: 'error:generate_ad_creatives', + detailLabels: ['Business'], + tsBucket: 1_700_000_040_000, + }; + const send = (port: number, body: unknown, headers: Record = {}) => + fetch(`http://127.0.0.1:${port}/metadata`, { + method: 'POST', + headers: { 'content-type': 'application/json', ...headers }, + body: JSON.stringify(body), + }); + + it('rejects without the consent header (403)', async () => { + const port = await startServer({ GEMINI_KEY: 'dummy', GMP_RATE_LIMIT: '1000' }); + const r = await send(port, [record]); + expect(r.status).toBe(403); + expect(await r.text()).toMatch(/consent/i); + }, 25_000); + + it('accepts a clean sanitized batch with consent (204)', async () => { + const port = await startServer({ GEMINI_KEY: 'dummy', GMP_RATE_LIMIT: '1000' }); + const r = await send(port, [record], { 'x-atlas-consent': '1' }); + expect(r.status).toBe(204); + }, 25_000); + + it('logs the validated batch as a structured diag_batch line (triage signal)', async () => { + const port = await startServer({ GEMINI_KEY: 'dummy', GMP_RATE_LIMIT: '1000' }); + const r = await send(port, [record], { 'x-atlas-consent': '1' }); + expect(r.status).toBe(204); + const line = await waitForLog(/"evt":"diag_batch"/); + const parsed = JSON.parse(line); + expect(parsed.records).toHaveLength(1); + expect(parsed.records[0].category).toBe('error:generate_ad_creatives'); + expect(parsed.at).toBeTypeOf('string'); + }, 25_000); + + it('rejects consented-but-dirty content (unknown key or forbidden value) with 400', async () => { + const port = await startServer({ GEMINI_KEY: 'dummy', GMP_RATE_LIMIT: '1000' }); + const withExtra = await send(port, [{ ...record, summary: 'Blue Bottle Coffee' }], { 'x-atlas-consent': '1' }); + expect(withExtra.status).toBe(400); + const withUrl = await send(port, [{ ...record, category: 'http://leak.example' }], { 'x-atlas-consent': '1' }); + expect(withUrl.status).toBe(400); + }, 25_000); +}); + +describe('proxy telemetry', () => { + it('emits a structural proxy line on a disallowed-model 403 (no path/url logged)', async () => { + const port = await startServer({ GEMINI_KEY: 'dummy', AI_RATE_LIMIT: '1000', DAILY_AI_CAP: '1000' }); + const r = await post(port, DISALLOWED); + expect(r.status).toBe(403); + const line = await waitForLog(/"evt":"proxy"/); + const parsed = JSON.parse(line); + expect(parsed.category).toBe('proxy:ai:forbidden'); + expect(parsed.scenario).toBe('proxy'); + // The raw request path must never appear in telemetry. + expect(line).not.toContain('not-a-real-model'); + expect(line).not.toContain('generateContent'); + }, 25_000); + + it('emits proxy:ai:rate_limit when the AI limiter trips', async () => { + const port = await startServer({ GEMINI_KEY: 'dummy', AI_RATE_LIMIT: '0', DAILY_AI_CAP: '1000' }); + const r = await post(port, DISALLOWED); + expect(r.status).toBe(429); + const line = await waitForLog(/proxy:ai:rate_limit/); + expect(JSON.parse(line).category).toBe('proxy:ai:rate_limit'); + }, 25_000); +}); diff --git a/demos/real-world-reasoning-agent/src/App.tsx b/demos/real-world-reasoning-agent/src/App.tsx new file mode 100644 index 0000000..9f01755 --- /dev/null +++ b/demos/real-world-reasoning-agent/src/App.tsx @@ -0,0 +1,168 @@ +import { useEffect, useRef } from 'react'; +import { APIProvider } from '@vis.gl/react-google-maps'; +import { GMP_BROWSER_KEY, MAPS_VERSION, MAPS_LIBRARIES } from '@/lib/config'; +import { useAtlas } from '@/state/store'; +import { SCENARIOS } from '@/scenarios/registry'; +import type { ScenarioId } from '@/lib/types'; +import { MapCanvas } from '@/shell/MapCanvas'; +import { AgentCanvas } from '@/shell/AgentCanvas'; +import { CopilotDock } from '@/shell/CopilotDock'; +import { StatusBar } from '@/shell/StatusBar'; +import { Landing } from '@/shell/Landing'; +import { MapErrorNotice } from '@/shell/MapErrorNotice'; +import { AudioPill } from '@/shell/AudioPill'; +import { AdminPanel } from '@/shell/AdminPanel'; +import { Toasts } from '@/shell/Toasts'; +import { stopSpeech } from '@/ai/tts'; +import { abortCopilot, runPendingReplayPrompt } from '@/ai/session'; +import { genui } from '@/genui/store'; +import { cameraReportForUrl, scrubReplayParams } from '@/lib/share'; + +export default function App() { + const scenario = useAtlas((s) => s.activeScenario); + const setMapMode = useAtlas((s) => s.setMapMode); + const clearMap = useAtlas((s) => s.clearMap); + const setApiHealth = useAtlas((s) => s.setApiHealth); + const landingDismissed = useAtlas((s) => s.landingDismissed); + const setAccent = useRef(''); + const prev = useRef(null); + + // Google Maps calls this global on the window when the browser key is + // missing/invalid/referrer-blocked. It never throws through APIProvider's + // onError in that case, so this is the only reliable signal for auth failure. + useEffect(() => { + window.gm_authFailure = () => setApiHealth('down'); + return () => { + delete window.gm_authFailure; + }; + }, [setApiHealth]); + + // Re-arm the map + copilot dressing whenever the journey changes. + useEffect(() => { + const mod = SCENARIOS[scenario]; + if (prev.current && prev.current !== scenario) { + // Re-arm for the new recipe: cancel the in-flight query (recorded as + // resumable) and silence any speech so nothing bleeds onto the new screen. + // The session transcript deliberately survives — it is one conversation. + abortCopilot(); + stopSpeech(); + SCENARIOS[prev.current].onExit?.(); + clearMap(); + genui().clearScenario(prev.current); + } + setMapMode(mod.mapMode); + document.documentElement.style.setProperty('--accent', mod.accent); + document.documentElement.style.setProperty('--accent-soft', `${mod.accent}28`); + setAccent.current = mod.accent; + mod.onEnter?.(); + prev.current = scenario; + }, [scenario, setMapMode, clearMap]); + + // Replay links (?prompt=) auto-run their prompt once the shell has mounted, so + // a shared run reasons live for the recipient. A short delay lets the map init + // before the first tool call; the model round-trip adds further headroom. + useEffect(() => { + const id = setTimeout(() => runPendingReplayPrompt(), 600); + return () => clearTimeout(id); + }, []); + + // Scrub the raw prompt/mission params from the address bar immediately on + // mount (the pending prompt was already captured into state). Defense-in-depth + // for reliability §5 so the raw prompt doesn't linger in history/Referer. + useEffect(() => { + const scrubbed = scrubReplayParams(window.location.search); + if (scrubbed !== null) { + const pathname = window.location.pathname.replace(/^\/\/+/, '/'); + window.history.replaceState(window.history.state, '', `${pathname}${scrubbed}`); + } + }, []); + + // Synchronize state changes back to URL parameters. + useEffect(() => { + let timeoutId: ReturnType | null = null; + let lastUrl = window.location.search; + + const syncUrl = () => { + const state = useAtlas.getState(); + const params = new URLSearchParams(); + + params.set('scenario', state.activeScenario); + params.set('mode', state.mapMode); + params.set('drawer', String(state.drawerOpen)); + params.set('landing', String(state.landingDismissed)); + params.set('city', state.cityId); + + if (state.tileOverlay) { + params.set('overlay', state.tileOverlay); + } + if (state.selectedMarkerId) { + params.set('marker', state.selectedMarkerId); + } + + const cameraReport = cameraReportForUrl(state.cameraUrlSync, state.cameraReport); + if (cameraReport) { + params.set('lat', cameraReport.lat.toFixed(5)); + params.set('lng', cameraReport.lng.toFixed(5)); + if (cameraReport.zoom !== undefined) { + params.set('zoom', cameraReport.zoom.toFixed(2)); + } + if (cameraReport.heading !== undefined) { + params.set('heading', Math.round(cameraReport.heading).toString()); + } + if (cameraReport.tilt !== undefined) { + params.set('tilt', Math.round(cameraReport.tilt).toString()); + } + } + + const newSearch = `?${params.toString()}`; + if (newSearch !== lastUrl) { + lastUrl = newSearch; + const pathname = window.location.pathname.replace(/^\/\/+/, '/'); + const newUrl = `${pathname}${newSearch}`; + window.history.replaceState(null, '', newUrl); + } + }; + + const debouncedSync = () => { + if (timeoutId) clearTimeout(timeoutId); + timeoutId = setTimeout(syncUrl, 300); + }; + + const unsub = useAtlas.subscribe(() => { + debouncedSync(); + }); + + return () => { + unsub(); + if (timeoutId) clearTimeout(timeoutId); + }; + }, []); + + return ( + setApiHealth('down')} + > + {/* One layout grid: rail | map | agent canvas, with the composer spanning + the bottom. The map is a real grid cell, so its usable rectangle is a + declared value rather than something the camera measures off the DOM. */} +
+
+ + +
+ {landingDismissed ? <> + + + + + + + : null} + +
+
+ ); +} diff --git a/demos/real-world-reasoning-agent/src/ai/adPrompt.test.ts b/demos/real-world-reasoning-agent/src/ai/adPrompt.test.ts new file mode 100644 index 0000000..655ad06 --- /dev/null +++ b/demos/real-world-reasoning-agent/src/ai/adPrompt.test.ts @@ -0,0 +1,63 @@ +import { describe, it, expect } from 'vitest'; +import { adPrompt } from './image'; +import type { PlaceLite } from '@/lib/types'; + +const BUSINESS: PlaceLite = { + id: 'place-1', + name: 'Blue Bottle Coffee', + location: { lat: 37.7955, lng: -122.3937 }, + formattedAddress: '1 Ferry Building, San Francisco, CA 94111', + rating: 4.6, +}; + +describe('adPrompt', () => { + it('always instructs the model to use the attached photo as visual reference', () => { + const prompt = adPrompt(BUSINESS, {}, 'warm golden-hour photo', 'square'); + expect(prompt).toMatch(/attached photo of the actual storefront\/location as visual reference/i); + }); + + it('includes the business name and chosen style/format', () => { + const prompt = adPrompt(BUSINESS, {}, 'bold flat-color poster', 'story'); + expect(prompt).toContain('Blue Bottle Coffee'); + expect(prompt).toContain('bold flat-color poster'); + expect(prompt).toMatch(/9:16/); + }); + + it('includes a rating >= 4.2', () => { + const prompt = adPrompt(BUSINESS, {}, 'style', 'square'); + expect(prompt).toContain('4.6'); + }); + + it('omits ratings below 4.2 (not a strong enough grounded signal)', () => { + const lowRated: PlaceLite = { ...BUSINESS, rating: 3.8 }; + const prompt = adPrompt(lowRated, {}, 'style', 'square'); + expect(prompt).not.toContain('3.8'); + expect(prompt).not.toMatch(/highly rated/); + }); + + it('never fabricates facts beyond what is passed in', () => { + const noFacts: PlaceLite = { id: 'p2', name: 'Some Shop', location: { lat: 0, lng: 0 } }; + const prompt = adPrompt(noFacts, {}, 'style', 'banner'); + // The prompt must not fabricate a positive award/claim; it may (and should) + // carry the "no fake awards" guardrail instruction. + expect(prompt).not.toMatch(/award-winning/i); + expect(prompt).toMatch(/no fake awards, no invented claims/i); + }); + + it('weaves in grounded weather and vibe facts when present', () => { + const prompt = adPrompt( + BUSINESS, + { env: { weather: { tempC: 22, condition: 'Sunny' } }, vibe: 'busy lunchtime foot traffic' }, + 'style', + 'square', + ); + expect(prompt).toMatch(/22°C/); + expect(prompt).toMatch(/sunny/i); + expect(prompt).toMatch(/busy lunchtime foot traffic/); + }); + + it('includes the requested headline as overlay copy when provided', () => { + const prompt = adPrompt(BUSINESS, {}, 'style', 'square', { headline: 'Fresh Roast Daily' }); + expect(prompt).toContain('Fresh Roast Daily'); + }); +}); diff --git a/demos/real-world-reasoning-agent/src/ai/classifyAiFailure.test.ts b/demos/real-world-reasoning-agent/src/ai/classifyAiFailure.test.ts new file mode 100644 index 0000000..a2d06e2 --- /dev/null +++ b/demos/real-world-reasoning-agent/src/ai/classifyAiFailure.test.ts @@ -0,0 +1,32 @@ +import { describe, it, expect } from 'vitest'; +import { classifyAiFailure } from './image'; + +describe('classifyAiFailure', () => { + it('maps HTTP 429 (proxy window or upstream quota) to rate-limited', () => { + expect(classifyAiFailure({ name: 'ApiError', status: 429 })).toBe('rate-limited'); + }); + + it('maps HTTP 503 to unavailable (transient, retryable)', () => { + expect(classifyAiFailure({ name: 'ApiError', status: 503 })).toBe('unavailable'); + }); + + it('treats a real ApiError 429 body (the shape the proxy returns) as rate-limited', () => { + const err = Object.assign(new Error( + '{"error":{"message":"The demo is busy right now — try again in a few minutes","code":429,"status":"Too Many Requests"}}', + ), { name: 'ApiError', status: 429 }); + expect(classifyAiFailure(err)).toBe('rate-limited'); + }); + + it('falls back to message sniffing when status is absent', () => { + expect(classifyAiFailure(new Error('429 Too Many Requests'))).toBe('rate-limited'); + expect(classifyAiFailure(new Error('rate limit exceeded'))).toBe('rate-limited'); + expect(classifyAiFailure(new Error('503 Service Unavailable'))).toBe('unavailable'); + }); + + it('classifies model/key/quota and unknown errors as other', () => { + expect(classifyAiFailure({ status: 403 })).toBe('other'); + expect(classifyAiFailure(new Error('API key not valid'))).toBe('other'); + expect(classifyAiFailure(null)).toBe('other'); + expect(classifyAiFailure(undefined)).toBe('other'); + }); +}); diff --git a/demos/real-world-reasoning-agent/src/ai/client.test.ts b/demos/real-world-reasoning-agent/src/ai/client.test.ts new file mode 100644 index 0000000..f952432 --- /dev/null +++ b/demos/real-world-reasoning-agent/src/ai/client.test.ts @@ -0,0 +1,58 @@ +// @vitest-environment jsdom +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const sdk = vi.hoisted(() => ({ options: [] as unknown[] })); +vi.mock('@google/genai', () => ({ + GoogleGenAI: class { + constructor(options: unknown) { sdk.options.push(options); } + }, +})); + +import { + connectGeminiApiKey, + disconnectGeminiApiKey, + GEMINI_BYOK_HEADER, + genai, + getGeminiCredentialSnapshot, +} from './client'; + +beforeEach(() => { + sdk.options.length = 0; + disconnectGeminiApiKey(); + vi.restoreAllMocks(); +}); + +describe('Gemini credential lifecycle', () => { + it('validates a personal key, rebuilds the client, and keeps auth out of the URL', async () => { + const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValue( + new Response(JSON.stringify({ ok: true }), { status: 200, headers: { 'content-type': 'application/json' } }), + ); + const hosted = genai(); + await connectGeminiApiKey('test-personal-key'); + const personal = genai(); + + expect(personal).not.toBe(hosted); + expect(getGeminiCredentialSnapshot().source).toBe('byok'); + expect(fetchSpy).toHaveBeenCalledWith('/api/real-world-reasoning-agent/ai/validate', expect.objectContaining({ + method: 'POST', + headers: expect.objectContaining({ [GEMINI_BYOK_HEADER]: 'test-personal-key' }), + })); + expect(String(fetchSpy.mock.calls[0][0])).not.toContain('test-personal-key'); + expect(sdk.options.at(-1)).toMatchObject({ + apiKey: 'proxied', + httpOptions: { headers: { [GEMINI_BYOK_HEADER]: 'test-personal-key' } }, + }); + expect(sessionStorage.length).toBe(0); + expect(localStorage.length).toBe(0); + }); + + it('disconnects immediately and returns to the hosted proxy client', async () => { + vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response(JSON.stringify({ ok: true }), { status: 200 })); + await connectGeminiApiKey('test-personal-key'); + genai(); + disconnectGeminiApiKey(); + genai(); + expect(getGeminiCredentialSnapshot().source).toBe('hosted'); + expect(sdk.options.at(-1)).toMatchObject({ apiKey: 'proxied', httpOptions: expect.not.objectContaining({ headers: expect.anything() }) }); + }); +}); diff --git a/demos/real-world-reasoning-agent/src/ai/client.ts b/demos/real-world-reasoning-agent/src/ai/client.ts new file mode 100644 index 0000000..ca52988 --- /dev/null +++ b/demos/real-world-reasoning-agent/src/ai/client.ts @@ -0,0 +1,104 @@ +import { GoogleGenAI } from '@google/genai'; + +export const GEMINI_BYOK_HEADER = 'x-atlas-gemini-key'; + +export type GeminiCredentialSnapshot = { + source: 'hosted' | 'byok'; + epoch: number; +}; + +let singleton: GoogleGenAI | null = null; +let userKey = ''; +let credentialSnapshot: GeminiCredentialSnapshot = { source: 'hosted', epoch: 0 }; +const credentialListeners = new Set<() => void>(); + +export class GeminiCredentialError extends Error { + constructor(public readonly code: 'invalid' | 'quota' | 'model_unavailable' | 'network' | 'unknown') { + super(code); + this.name = 'GeminiCredentialError'; + } +} + +export function getGeminiCredentialSnapshot(): GeminiCredentialSnapshot { + return credentialSnapshot; +} + +export function subscribeGeminiCredential(listener: () => void): () => void { + credentialListeners.add(listener); + return () => credentialListeners.delete(listener); +} + +function publishCredential(source: GeminiCredentialSnapshot['source']): void { + credentialSnapshot = { source, epoch: credentialSnapshot.epoch + 1 }; + singleton = null; + credentialListeners.forEach((listener) => listener()); +} + +/** + * Validate a personal key through the same guarded proxy used by every Atlas AI + * call, then retain it only in this tab's JavaScript memory. The raw key never + * enters Zustand, browser storage, URLs, transcripts, or diagnostics. + */ +export async function connectGeminiApiKey(apiKey: string): Promise { + const key = apiKey.trim(); + if (!/^[\x21-\x7e]{8,512}$/.test(key)) throw new GeminiCredentialError('invalid'); + let response: Response; + try { + response = await fetch('/api/real-world-reasoning-agent/ai/validate', { + method: 'POST', + headers: { [GEMINI_BYOK_HEADER]: key, 'content-type': 'application/json' }, + body: '{}', + cache: 'no-store', + }); + } catch { + throw new GeminiCredentialError('network'); + } + if (!response.ok) { + let reason = ''; + try { + reason = String((await response.json() as { reason?: unknown }).reason ?? ''); + } catch { + // Deliberately ignore upstream prose so a credential can never be echoed. + } + if (response.status === 401 || reason === 'invalid') throw new GeminiCredentialError('invalid'); + if (response.status === 429 || reason === 'quota') throw new GeminiCredentialError('quota'); + if (response.status === 424 || reason === 'model_unavailable') throw new GeminiCredentialError('model_unavailable'); + throw new GeminiCredentialError(response.status >= 500 ? 'network' : 'unknown'); + } + userKey = key; + publishCredential('byok'); +} + +export function disconnectGeminiApiKey(): void { + userKey = ''; + publishCredential('hosted'); +} + +/** + * Compatibility shim for the previous BYOK modal. New UI should use + * connectGeminiApiKey() so the key is validated before activation. The value + * remains tab-memory-only; it is never written to browser storage. + */ +export function setVisitorGeminiKey(key: string): void { + const value = key.trim(); + userKey = value; + publishCredential(value ? 'byok' : 'hosted'); +} + +export function hasVisitorGeminiKey(): boolean { + return Boolean(userKey); +} + +export function genai(): GoogleGenAI { + if (!singleton) { + const baseUrl = typeof window === 'undefined' ? '/api/real-world-reasoning-agent/ai' : new URL('/api/real-world-reasoning-agent/ai', window.location.origin).toString(); + singleton = new GoogleGenAI({ + apiKey: 'proxied', + httpOptions: { + baseUrl, + ...(userKey ? { headers: { [GEMINI_BYOK_HEADER]: userKey } } : {}), + }, + }); + } + return singleton; +} diff --git a/demos/real-world-reasoning-agent/src/ai/engine.loop.test.ts b/demos/real-world-reasoning-agent/src/ai/engine.loop.test.ts new file mode 100644 index 0000000..5c4cd2e --- /dev/null +++ b/demos/real-world-reasoning-agent/src/ai/engine.loop.test.ts @@ -0,0 +1,403 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import type { Part } from '@google/genai'; +import type { ToolDefinition } from '@/lib/types'; + +// The engine's only network boundary is genai() (src/ai/client.ts). Mock it to a +// scripted chat so the REAL tool-loop (sequential execution, store mutation, +// echo-stripping, hop-limit → partial) runs deterministically with no network. +const h = vi.hoisted(() => ({ chat: null as unknown, createCount: 0 })); +vi.mock('@/ai/client', () => ({ + subscribeGeminiCredential: () => () => {}, + genai: () => ({ + chats: { create: () => { h.createCount++; return h.chat; } }, + // suggestFollowups fires post-completion; give it a benign response so the + // floating promise resolves quietly (it normalizes to [] anyway). + models: { generateContent: async () => ({ text: '[]' }) }, + }), +})); + +import { CopilotEngine, MAX_HOPS, MAX_STREAM_RETRIES, markHopLimitPartial, stripInternalPromptEcho } from './engine'; +import { useAtlas } from '@/state/store'; +import { useMission } from '@/mission/store'; + +type ScriptedTurn = Part[]; + +/** A fake @google/genai Chat that yields one scripted chunk per send. */ +function makeChat(turns: ScriptedTurn[] | ((turn: number) => ScriptedTurn)) { + let i = 0; + return { + async sendMessageStream() { + const parts = typeof turns === 'function' ? turns(i) : turns[i] ?? turns[turns.length - 1]; + i++; + return (async function* () { + yield { candidates: [{ content: { parts } }] }; + })(); + }, + }; +} + +const text = (t: string): Part => ({ text: t }); +const fnCall = (name: string, args: Record = {}): Part => ({ functionCall: { name, args } }); + +/** Minimal ToolDefinition whose handler runs `run`. */ +function tool(name: string, run: (args: Record) => unknown): ToolDefinition { + return { + declaration: { name, description: name }, + handler: async (args: Record) => run(args) ?? { ok: true }, + } as unknown as ToolDefinition; +} + +function resetStores(): void { + useAtlas.setState({ + transcript: [], + telemetry: [], + running: false, + resumable: null, + followups: [], + markers: [], + }); + useMission.getState().reset('sf'); +} + +beforeEach(() => { + h.createCount = 0; + resetStores(); +}); + +describe('engine tool-loop', () => { + it('executes batched tool calls sequentially in the model\'s emission order', async () => { + const order: string[] = []; + const tools = [ + tool('tool_a', () => { order.push('a'); }), + tool('tool_b', () => { order.push('b'); }), + ]; + // Turn 0: emit both calls in one turn (a before b). Turn 1: plain answer, no calls → stop. + h.chat = makeChat([[fnCall('tool_a'), fnCall('tool_b')], [text('All done.')]]); + + const engine = new CopilotEngine('scout', '', tools, 'sf'); + await engine.send('do both'); + + expect(order).toEqual(['a', 'b']); + // Telemetry recorded both tool calls as ok, in order. + const names = useAtlas.getState().telemetry.map((e) => e.name); + expect(names).toEqual(['tool_a', 'tool_b']); + expect(useAtlas.getState().running).toBe(false); + }); + + it('does not race consecutive calls to the same state-mutating tool', async () => { + let state = 0; + let active = 0; + let maxActive = 0; + let responseTurn: Part[] = []; + let turn = 0; + const mutate = tool('mutate_state', async (args) => { + const value = Number(args.value); + active++; + maxActive = Math.max(maxActive, active); + try { + const previous = state; + await new Promise((resolve) => setTimeout(resolve, value === 1 ? 10 : 0)); + state = previous + value; + return { value, state }; + } finally { + active--; + } + }); + h.chat = { + async sendMessageStream({ message }: { message: string | Part[] }) { + const parts = turn++ === 0 + ? [fnCall('mutate_state', { value: 1 }), fnCall('mutate_state', { value: 2 })] + : [text('Mutations complete.')]; + if (Array.isArray(message)) responseTurn = message; + return (async function* () { + yield { candidates: [{ content: { parts } }] }; + })(); + }, + }; + + const engine = new CopilotEngine('scout', '', [mutate], 'sf'); + await engine.send('mutate twice'); + + expect(maxActive).toBe(1); + expect(state).toBe(3); + expect(responseTurn.map((part) => part.functionResponse?.response)).toEqual([ + { value: 1, state: 1 }, + { value: 2, state: 3 }, + ]); + }); + + it('does not race consecutive calls to the same state-mutating tool', async () => { + let state = 0; + let active = 0; + let maxActive = 0; + let responseTurn: Part[] = []; + let turn = 0; + const mutate = tool('mutate_state', async (args) => { + const value = Number(args.value); + active++; + maxActive = Math.max(maxActive, active); + try { + const previous = state; + await new Promise((resolve) => setTimeout(resolve, value === 1 ? 10 : 0)); + state = previous + value; + return { value, state }; + } finally { + active--; + } + }); + h.chat = { + async sendMessageStream({ message }: { message: string | Part[] }) { + const parts = turn++ === 0 + ? [fnCall('mutate_state', { value: 1 }), fnCall('mutate_state', { value: 2 })] + : [text('Mutations complete.')]; + if (Array.isArray(message)) responseTurn = message; + return (async function* () { + yield { candidates: [{ content: { parts } }] }; + })(); + }, + }; + + const engine = new CopilotEngine('scout', '', [mutate], 'sf'); + await engine.send('mutate twice'); + + expect(maxActive).toBe(1); + expect(state).toBe(3); + expect(responseTurn.map((part) => part.functionResponse?.response)).toEqual([ + { value: 1, state: 1 }, + { value: 2, state: 3 }, + ]); + }); + + it('aborts a superseded turn before a delayed tool can project stale work', async () => { + let started!: () => void; + const didStart = new Promise((resolve) => { started = resolve; }); + let sawAbort = false; + const delayed: ToolDefinition = { + declaration: { name: 'delayed', description: 'delayed' }, + handler: async (_args, signal) => { + started(); + await new Promise((resolve) => { + signal?.addEventListener('abort', () => { + sawAbort = true; + resolve(); + }, { once: true }); + }); + return { ok: false, error: 'cancelled' }; + }, + } as ToolDefinition; + h.chat = makeChat([[fnCall('delayed')], [text('New turn won.')] ]); + const engine = new CopilotEngine('scout', '', [delayed], 'sf'); + + const oldTurn = engine.send('old'); + await didStart; + await engine.send('new'); + await oldTurn; + + expect(sawAbort).toBe(true); + expect(h.createCount).toBe(2); + expect(useAtlas.getState().transcript.some((message) => message.text === 'New turn won.')).toBe(true); + }); + + it('resets incomplete chat history before resuming an explicitly stopped tool turn', async () => { + let started!: () => void; + const didStart = new Promise((resolve) => { started = resolve; }); + const delayed: ToolDefinition = { + declaration: { name: 'delayed', description: 'delayed' }, + handler: async (_args, signal) => { + started(); + await new Promise((resolve) => + signal?.addEventListener('abort', () => resolve(), { once: true }), + ); + return { ok: false, error: 'cancelled' }; + }, + } as ToolDefinition; + h.chat = makeChat([[fnCall('delayed')], [text('Resumed on clean history.')] ]); + const engine = new CopilotEngine('scout', '', [delayed], 'sf'); + + const stoppedTurn = engine.send('old'); + await didStart; + engine.abort(); + await stoppedTurn; + engine.resume(); + + await vi.waitFor(() => { + expect(useAtlas.getState().transcript.some( + (message) => message.text === 'Resumed on clean history.', + )).toBe(true); + }); + expect(h.createCount).toBe(2); + }); + + it('feeds tool side effects into the store and finishes when the model stops calling', async () => { + const tools = [ + tool('drop_pin', () => { + useAtlas.getState().addMarkers([ + { id: 'm1', position: { lat: 1, lng: 2 }, kind: 'pin', scenario: 'scout' }, + ]); + return { ok: true, added: 1 }; + }), + ]; + h.chat = makeChat([[fnCall('drop_pin')], [text('Pinned it.')]]); + + const engine = new CopilotEngine('scout', '', tools, 'sf'); + await engine.send('pin the spot'); + + expect(useAtlas.getState().markers.map((m) => m.id)).toEqual(['m1']); + const modelMsg = useAtlas.getState().transcript.find((m) => m.role === 'model'); + expect(modelMsg?.text).toBe('Pinned it.'); + }); + + it('strips echoed prompt-envelope tags from the streamed answer', async () => { + h.chat = makeChat([[text('junk Real answer.')]]); + const engine = new CopilotEngine('scout', '', [], 'sf'); + await engine.send('what is here?'); + + const modelMsg = useAtlas.getState().transcript.find((m) => m.role === 'model'); + expect(modelMsg?.text).toBe('Real answer.'); + expect(modelMsg?.text).not.toContain('user_request'); + }); + + it('keeps app-owned orchestration requests out of the user transcript', async () => { + h.chat = makeChat([[text('Started the mission.')], [text('Answered the human.')]]); + const engine = new CopilotEngine('scout', '', [], 'sf'); + await engine.send('Internal mission kickoff', { showUserMessage: false }); + + const transcript = useAtlas.getState().transcript; + expect(transcript.some((message) => message.role === 'user')).toBe(false); + expect(transcript.find((message) => message.role === 'model')?.text).toBe('Started the mission.'); + + await engine.send('Human follow-up'); + expect(useAtlas.getState().transcript.filter((message) => message.role === 'user').map((message) => message.text)) + .toEqual(['Human follow-up']); + }); + + it('completes the canonical first-run fixture within the hop budget without Resume', async () => { + useMission.getState().start({ goal: 'run the synthetic first experience', cityId: 'sf', mode: 'demo' }); + const tools = [ + tool('scout_area', () => ({ ok: true })), + tool('inspect_candidate', () => ({ ok: true })), + tool('score_candidates', () => ({ ok: true })), + tool('compare_sites', () => ({ ok: true })), + ]; + h.chat = makeChat([ + [fnCall('scout_area')], + [fnCall('inspect_candidate', { candidateId: 'demo-a' }), fnCall('inspect_candidate', { candidateId: 'demo-b' })], + [fnCall('score_candidates'), fnCall('compare_sites')], + [text('The synthetic comparison is ready.')], + ]); + + const engine = new CopilotEngine('scout', '', tools, 'sf'); + await engine.send('Start the synthetic fixture', { showUserMessage: false }); + + expect(useAtlas.getState().resumable).toBeNull(); + expect(useMission.getState().mission.status).not.toBe('partial'); + expect(useAtlas.getState().transcript.some((message) => message.role === 'notice')).toBe(false); + expect(useAtlas.getState().telemetry.map((event) => event.name)).toEqual([ + 'scout_area', + 'inspect_candidate', + 'inspect_candidate', + 'score_candidates', + 'compare_sites', + ]); + }); + + it('retries a transient stream error and recovers, restoring apiHealth to ok', async () => { + // Attempt 0 throws a retryable 503; attempt 1 streams a real answer. The + // engine should retry, record a retry_ai_response event that resolves to ok, + // deliver the answer, and clear the degraded health flag. (Reliability plan + // §4, area 2: model stream failure / recovery.) + useAtlas.getState().setApiHealth('degraded'); + let sends = 0; + h.chat = { + async sendMessageStream() { + sends++; + if (sends === 1) throw new Error('503 Service Unavailable'); + return (async function* () { + yield { candidates: [{ content: { parts: [text('Recovered answer.')] } }] }; + })(); + }, + }; + + const engine = new CopilotEngine('scout', '', [], 'sf'); + await engine.send('what is here?'); + + expect(sends).toBe(2); + const modelMsg = useAtlas.getState().transcript.find((m) => m.role === 'model'); + expect(modelMsg?.text).toBe('Recovered answer.'); + const retry = useAtlas.getState().telemetry.find((e) => e.name === 'retry_ai_response'); + expect(retry?.status).toBe('ok'); + expect(retry?.summary).toBe('recovered'); + expect(useAtlas.getState().apiHealth).toBe('ok'); + }); + + it('gives up after MAX_STREAM_RETRIES on a persistent stream error and surfaces it', async () => { + // Every attempt throws a retryable error → after MAX_STREAM_RETRIES the run + // ends, marks the final retry event as error, and stops running. + h.chat = { + async sendMessageStream() { + // 503 (not 429) keeps the exponential backoff short (~0.65s + ~1.3s). + throw new Error('503 Service Unavailable'); + }, + }; + const engine = new CopilotEngine('scout', '', [], 'sf'); + await engine.send('keep failing'); + + const retries = useAtlas.getState().telemetry.filter((e) => e.name === 'retry_ai_response'); + expect(retries.length).toBe(MAX_STREAM_RETRIES); + expect(retries[retries.length - 1].status).toBe('error'); + expect(useAtlas.getState().running).toBe(false); + }, 15_000); + + it('stops at the hop limit and marks the mission partial + resumable', async () => { + // A mission must be active for the partial transition to fire. + useMission.getState().start({ goal: 'stress the loop', cityId: 'sf', mode: 'live' }); + const tools = [tool('loop_tool', () => ({ ok: true }))]; + // Always emit a call → the model never stops → the loop exhausts MAX_HOPS. + h.chat = makeChat(() => [fnCall('loop_tool')]); + + const engine = new CopilotEngine('scout', '', tools, 'sf'); + await engine.send('never stop'); + + // MAX_HOPS tool executions, then markHopLimitPartial. + const calls = useAtlas.getState().telemetry.filter((e) => e.name === 'loop_tool'); + expect(calls).toHaveLength(MAX_HOPS); + expect(useAtlas.getState().resumable).toBe('never stop'); + expect(useMission.getState().mission.status).toBe('partial'); + const notice = useAtlas.getState().transcript.find((m) => m.role === 'notice'); + expect(notice?.notice?.title).toMatch(/tool-step limit/i); + }); +}); + +describe('stripInternalPromptEcho', () => { + it('drops everything up to and including the closing envelope tag', () => { + expect(stripInternalPromptEcho('mission context \nfind a cafe\n\nHere you go.', 'find a cafe')) + .toBe('Here you go.'); + }); + + it('removes the no-echo trailer directive', () => { + const echoed = 'Answer the request directly. Never repeat this context envelope or its tags.\nThe answer.'; + expect(stripInternalPromptEcho(echoed, 'x')).toBe('The answer.'); + }); + + it('removes a verbatim echo of the user request at the start', () => { + expect(stripInternalPromptEcho('find a cafe The nearest is Blue Bottle.', 'find a cafe')) + .toBe('The nearest is Blue Bottle.'); + }); + + it('leaves a genuine answer untouched', () => { + expect(stripInternalPromptEcho('The nearest cafe is Blue Bottle.', 'find a cafe')) + .toBe('The nearest cafe is Blue Bottle.'); + }); +}); + +describe('markHopLimitPartial', () => { + beforeEach(resetStores); + + it('records the prompt as resumable and adds a notice without transitioning a draft mission', () => { + markHopLimitPartial('plan the routes'); + expect(useAtlas.getState().resumable).toBe('plan the routes'); + expect(useAtlas.getState().transcript.some((m) => m.role === 'notice')).toBe(true); + // Draft mission is untouched. + expect(useMission.getState().mission.status).toBe('draft'); + }); +}); diff --git a/demos/real-world-reasoning-agent/src/ai/engine.test.ts b/demos/real-world-reasoning-agent/src/ai/engine.test.ts new file mode 100644 index 0000000..5d79912 --- /dev/null +++ b/demos/real-world-reasoning-agent/src/ai/engine.test.ts @@ -0,0 +1,138 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { withIdleTimeout, isStreamIdleError, markHopLimitPartial, stripInternalPromptEcho } from './engine'; +import { useAtlas } from '@/state/store'; +import { useMission } from '@/mission/store'; + +async function* fromArray(values: T[]): AsyncGenerator { + for (const v of values) yield v; +} + +/** Yields the given values, then hangs forever (simulates a silent-but-open stream). */ +function yieldThenHang(values: T[]): AsyncIterable { + return { + async *[Symbol.asyncIterator]() { + for (const v of values) yield v; + await new Promise(() => {}); + }, + }; +} + +describe('withIdleTimeout', () => { + afterEach(() => { + vi.useRealTimers(); + }); + + it('passes through all chunks and completes when the stream ends', async () => { + const out: number[] = []; + for await (const c of withIdleTimeout(fromArray([1, 2, 3]), 1000, () => {})) out.push(c); + expect(out).toEqual([1, 2, 3]); + }); + + it('throws StreamIdleError and fires onTimeout when the gap exceeds the window', async () => { + vi.useFakeTimers(); + const onTimeout = vi.fn(); + const gen = withIdleTimeout(yieldThenHang([1]), 1000, onTimeout); + + expect(await gen.next()).toEqual({ value: 1, done: false }); // first chunk arrives + + const pending = gen.next(); // no more chunks — now waiting + const settled = pending.then( + () => ({ ok: true }) as const, + (err) => ({ ok: false, err }) as const, + ); // attach handlers now so the mid-advance rejection isn't briefly "unhandled" + + await vi.advanceTimersByTimeAsync(1000); + + const outcome = await settled; + expect(outcome.ok).toBe(false); + expect(outcome.ok === false && isStreamIdleError(outcome.err)).toBe(true); + expect(onTimeout).toHaveBeenCalledTimes(1); + }); + + it('does not fire onTimeout while chunks keep arriving within the window', async () => { + const onTimeout = vi.fn(); + const out: number[] = []; + for await (const c of withIdleTimeout(fromArray([1, 2, 3, 4]), 1000, onTimeout)) out.push(c); + expect(out).toEqual([1, 2, 3, 4]); + expect(onTimeout).not.toHaveBeenCalled(); + }); +}); + +describe('isStreamIdleError', () => { + it('recognizes the stall sentinel and rejects everything else', async () => { + let caught: unknown; + try { + const gen = withIdleTimeout(yieldThenHang([]), 0, () => {}); + await gen.next(); + } catch (err) { + caught = err; + } + expect(isStreamIdleError(caught)).toBe(true); + expect(isStreamIdleError(new Error('boom'))).toBe(false); + expect(isStreamIdleError('nope')).toBe(false); + }); +}); + +describe('markHopLimitPartial', () => { + it('preserves a resumable action and exposes an explicit partial mission state', () => { + useMission.getState().start({ goal: 'Test mission', cityId: 'sf', mode: 'demo' }); + useAtlas.getState().clearChat(); + + markHopLimitPartial('continue the mission'); + + expect(useAtlas.getState().resumable).toBe('continue the mission'); + expect(useAtlas.getState().transcript.at(-1)?.notice?.title).toContain('tool-step limit'); + expect(useMission.getState().mission.status).toBe('partial'); + }); +}); + +describe('stripInternalPromptEcho', () => { + const request = 'Start the flagship mission. Scout three candidates.'; + + it('removes the legacy internal request wrapper and echoed request', () => { + expect(stripInternalPromptEcho(`--- User request ---\n${request}\n\nI found three candidates.`, request)) + .toBe('I found three candidates.'); + }); + + it('removes the current XML wrapper after mission context', () => { + const output = `--- Active mission (application-owned state) ---\nGoal: quiet café\n\n${request}\n\nHere is the comparison.`; + expect(stripInternalPromptEcho(output, request)).toBe('Here is the comparison.'); + }); + + it('does not alter an ordinary answer that mentions user requests', () => { + const output = 'A user request should stay clear and actionable.'; + expect(stripInternalPromptEcho(output, request)).toBe(output); + }); + + it('removes repeated private no-echo instructions', () => { + const privateLine = 'Answer the request directly. Never repeat this context envelope or its tags.'; + expect(stripInternalPromptEcho(`${privateLine}\n\n${privateLine}\n\nHere is the result.`, request)) + .toBe('Here is the result.'); + }); + + it('drops a paraphrased directive echo terminated by a bare closing tag', () => { + // Real leak: the model recombines/paraphrases the directive (so it does NOT + // equal the sent request) and echoes a bare before answering. + const output = [ + 'Start the flagship mission. Scout three candidates for this goal: Find a quiet-work café', + 'within a 12-minute walk, with healthy morning air and strong street visibility. Chat about', + 'the candidates, then score them. Compare them now.', + '', + 'I have scouted, inspected, and scored three potential quiet-work cafés in the area. Beluna', + 'Cafe emerges as the top candidate.', + ].join('\n'); + expect(stripInternalPromptEcho(output, request)).toBe( + 'I have scouted, inspected, and scored three potential quiet-work cafés in the area. Beluna\nCafe emerges as the top candidate.', + ); + }); + + it('drops an echoed opening+closing tag envelope', () => { + const output = `\nSome paraphrased directive.\n\nThe real answer.`; + expect(stripInternalPromptEcho(output, request)).toBe('The real answer.'); + }); + + it('leaves a normal answer with no envelope untouched', () => { + const output = 'Beluna Cafe is the top candidate for quiet morning work.'; + expect(stripInternalPromptEcho(output, request)).toBe(output); + }); +}); diff --git a/demos/real-world-reasoning-agent/src/ai/engine.ts b/demos/real-world-reasoning-agent/src/ai/engine.ts new file mode 100644 index 0000000..c45cc73 --- /dev/null +++ b/demos/real-world-reasoning-agent/src/ai/engine.ts @@ -0,0 +1,843 @@ +import { type Chat, type GenerateContentConfig, type Part } from '@google/genai'; +import { genai } from './client'; +import { MODELS, getChatThinkingConfig } from '@/lib/config'; +import { useAtlas } from '@/state/store'; +import { DEFAULT_CITY_PRESET } from '@/lib/cities'; +import { composeSystemPrompt } from './prompts'; +import { stopSpeech } from './tts'; +import { suggestFollowups } from './followups'; +import type { ScenarioId, ToolDefinition, ToolEventDetail } from '@/lib/types'; +import { atlas } from '@/state/store'; +import { uid } from '@/lib/id'; +import { missionPromptContext, missionStore } from '@/mission/store'; + +export const MAX_HOPS = 8; +export const MAX_STREAM_RETRIES = 2; +const RETRY_BASE_DELAY_MS = 650; +/** + * Max time to wait for the NEXT chunk from a model stream before treating it as + * stalled. Guards against a silent-but-open connection (e.g. an upstream/proxy + * that neither sends bytes nor closes) wedging the run in a "Thinking…" state + * forever. Comfortably above realistic time-to-first-token (incl. thinking) yet + * below the server proxy's hard request timeout, so the client recovers first. + */ +const STREAM_IDLE_TIMEOUT_MS = 35_000; + +/** Thrown by `withIdleTimeout` when a stream goes silent past the idle window. */ +class StreamIdleError extends Error { + constructor() { + super('AI stream stalled — no response received'); + this.name = 'StreamIdleError'; + } +} + +/** Sentinel resolved by the idle timer — kept distinct from any real chunk. */ +const IDLE_SENTINEL = Symbol('stream-idle'); + +/** + * Wrap an async iterable so that if the gap between two chunks exceeds `ms`, it + * throws `StreamIdleError` (and fires `onTimeout`, used to abort the underlying + * fetch). The idle timer *resolves* a sentinel rather than rejecting, so no + * stray rejected promise is ever left unhandled; a genuine read error still + * propagates. The abandoned pending read is swallowed on timeout. + */ +export async function* withIdleTimeout( + stream: AsyncIterable, + ms: number, + onTimeout: () => void, +): AsyncGenerator { + const it = stream[Symbol.asyncIterator](); + for (;;) { + const nextP = it.next(); + let timer: ReturnType | undefined; + let res: IteratorResult | typeof IDLE_SENTINEL; + try { + res = await Promise.race([ + nextP, + new Promise((resolve) => { + timer = setTimeout(() => resolve(IDLE_SENTINEL), ms); + }), + ]); + } finally { + if (timer) clearTimeout(timer); + } + if (res === IDLE_SENTINEL) { + onTimeout(); + void nextP.catch(() => {}); // abandon the in-flight read quietly + throw new StreamIdleError(); + } + if (res.done) return; + yield res.value; + } +} + +/** True for the stall sentinel — kept as a helper so tests/consumers don't import the class. */ +export function isStreamIdleError(err: unknown): boolean { + return err instanceof StreamIdleError; +} + +export class CopilotEngine { + private chat: Chat; + private tools: Map; + /** Chat-level config, re-spread into each request so we can add an abortSignal + * without dropping systemInstruction/tools/thinkingConfig (sendMessageStream + * *replaces* the chat config with any per-request config). */ + private config: GenerateContentConfig; + /** Bumps on every send/abort; an in-flight run whose token is stale bails out. */ + private generation = 0; + private controller: AbortController | null = null; + /** True when an aborted turn may have left chat history mid-stream or with an unanswered functionCall. */ + private chatNeedsReset = false; + private lastUserText = ''; + private lastRequestShowedUserMessage = true; + /** Id of the model message currently streaming, so abort() can un-stick it. */ + private activeMsgId: string | null = null; + + constructor( + private scenario: ScenarioId, + systemAddendum: string, + tools: ToolDefinition[], + cityId: string, + /** Chat model id — defaults to the configured chat model; the admin panel can + * override it live (session.ts rebuilds the engine when it changes). */ + private model: string = MODELS.orchestrator, + /** Thinking-level override ('minimal'|'low'|'medium'|'high'); undefined/'default' + * falls back to env then the per-scenario default. */ + thinkingOverride?: string, + ) { + this.tools = new Map(tools.map((t) => [t.declaration.name ?? '', t])); + + const city = useAtlas.getState().cities.find(c => c.id === cityId) ?? DEFAULT_CITY_PRESET; + // The orchestration agent defaults to Gemini 3.6 Flash at MEDIUM thinking. + // Bounded task agents use the worker profiles in config.ts. + const thinkingConfig = getChatThinkingConfig( + model, + 'orchestration', + thinkingOverride === 'default' ? undefined : thinkingOverride, + ); + + this.config = { + systemInstruction: composeSystemPrompt(systemAddendum, city), + tools: [{ functionDeclarations: tools.map((t) => t.declaration) }], + ...(thinkingConfig ? { thinkingConfig } : {}), + }; + this.chat = genai().chats.create({ model: this.model, config: this.config }); + } + + /** True when there is an interrupted prompt to re-run. */ + hasResumable(): boolean { + return !!atlas().resumable; + } + + /** Re-run the last interrupted prompt from scratch (clears the resume marker). */ + resume(): void { + const prompt = atlas().resumable; + if (!prompt) return; + void this.send(prompt, { showUserMessage: this.lastRequestShowedUserMessage }); + } + + /** + * Stop the in-flight run for this journey: abort the network stream, silence + * speech, un-stick the streaming caret, and record the prompt as resumable so + * the user can re-run it. Safe to call when nothing is running. + */ + abort(): void { + const s = atlas(); + if (!s.running) return; + this.generation++; // invalidate the in-flight run + this.controller?.abort(); + this.controller = null; + this.chatNeedsReset = true; + stopSpeech(); + if (this.activeMsgId) { + s.updateMsg(this.activeMsgId, { streaming: false }); + this.activeMsgId = null; + } + s.setRunning(false); + if (this.lastUserText) s.setResumable(this.lastUserText); + } + + /** Send one user turn; drives the tool loop until the model stops calling. */ + async send(userText: string, options: { showUserMessage?: boolean } = {}): Promise { + const s = atlas(); + // A new turn supersedes any in-flight turn. Abort its shared signal so a + // generated capability adapter cannot project stale effects after await. + const supersedesActiveTurn = this.controller !== null; + this.controller?.abort(); + // The prior chat may now end with a functionCall that never received a + // functionResponse. Start a fresh protocol history for the superseding turn + // instead of sending into that invalid/incomplete history. + if (supersedesActiveTurn || this.chatNeedsReset) { + this.chat = genai().chats.create({ model: this.model, config: this.config }); + this.chatNeedsReset = false; + } + const mine = ++this.generation; + const controller = new AbortController(); + this.controller = controller; + const signal = controller.signal; + this.lastUserText = userText; + this.lastRequestShowedUserMessage = options.showUserMessage ?? true; + + s.setRunning(true); + s.setResumable(null); + s.setFollowups([]); + if (this.lastRequestShowedUserMessage) { + s.addMsg({ id: uid('u'), role: 'user', text: userText, ts: Date.now() }); + } + + const stale = () => mine !== this.generation || signal.aborted; + let completed = false; + + try { + const initialMissionContext = missionPromptContext(); + let message: string | Part[] = initialMissionContext + ? `${initialMissionContext}\n\n\n${userText}\n\nAnswer the request directly. Never repeat this context envelope or its tags.` + : userText; + for (let hop = 0; hop < MAX_HOPS; hop++) { + if (stale()) return; + const modelMsgId = uid('a'); + let modelMsgAdded = false; + const ensureModelMsg = () => { + if (modelMsgAdded) return; + this.activeMsgId = modelMsgId; + s.addMsg({ + id: modelMsgId, + role: 'model', + text: '', + streaming: true, + ts: Date.now(), + }); + modelMsgAdded = true; + }; + + const calls: { name: string; args: Record }[] = []; + await this.consumeModelStream(message, modelMsgId, ensureModelMsg, calls, signal); + if (modelMsgAdded) { + const emitted = atlas().transcript.find((msg) => msg.id === modelMsgId)?.text ?? ''; + s.updateMsg(modelMsgId, { + text: stripInternalPromptEcho(emitted, userText), + streaming: false, + }); + } + if (this.activeMsgId === modelMsgId) this.activeMsgId = null; + + if (stale()) return; + if (!calls.length) { + completed = true; + return; + } + + // Execute every requested tool call SEQUENTIALLY, in the order the model + // emitted it. Tool names do not imply independence: consecutive calls to + // the same handler can still read and mutate shared scenario state. + const responses: Part[] = []; + for (const call of calls) { + if (stale()) return; + const evId = uid('t'); + s.pushTool({ + id: evId, + name: call.name, + status: 'running', + ts: Date.now(), + }); + const tool = this.tools.get(call.name); + try { + const result = tool ? await tool.handler(call.args, signal) : { error: 'unknown tool' }; + s.updateTool(evId, { + status: tool && !isFailedResult(result) ? 'ok' : 'error', + summary: summarize(call.name, result), + details: detailsForTool(call.name, result, call.args), + }); + responses.push({ functionResponse: { name: call.name, response: safe(result) } }); + } catch (err) { + s.updateTool(evId, { status: 'error', summary: String(err) }); + responses.push({ functionResponse: { name: call.name, response: { error: String(err) } } }); + } + } + if (stale()) return; + const latestMissionContext = missionPromptContext(); + message = latestMissionContext ? [...responses, { text: latestMissionContext }] : responses; + } + if (!stale()) markHopLimitPartial(userText); + } catch (err) { + if (stale()) return; // aborted mid-flight — not a real error + if (isRateLimitError(err)) { + s.pushToast('warn', "The shared demo allowance is used up. Add your Gemini API key from AI Studio to continue, or try again later."); + s.setApiHealth('degraded'); + } else if (isStreamIdleError(err)) { + s.pushToast('warn', 'Atlas stalled waiting on a response — please try that again.'); + s.setApiHealth('degraded'); + if (this.lastUserText) s.setResumable(this.lastUserText); + } else { + s.pushToast('bad', `Copilot error: ${err instanceof Error ? err.message : String(err)}`); + } + } finally { + // Only the current run may clear the flag; a superseded run must not stomp + // a newer one (or an abort that already cleaned up). + if (mine === this.generation) { + // Un-stick a model message left mid-stream by an error/stall so its + // blinking caret doesn't persist after the run ends. + if (this.activeMsgId) { + s.updateMsg(this.activeMsgId, { streaming: false }); + this.activeMsgId = null; + } + s.setRunning(false); + this.controller = null; + // Best-effort "next actions" chips off the main chat — never blocks the + // answer and silently no-ops if it fails or a newer turn supersedes it. + if (completed) void this.refreshFollowups(mine); + } + } + } + + /** Populate the follow-up chips for this journey, unless a newer turn started. */ + private async refreshFollowups(mine: number): Promise { + const suggestions = await suggestFollowups(this.scenario); + if (mine !== this.generation) return; // a newer turn (or abort) took over + if (suggestions.length) atlas().setFollowups(suggestions); + } + + private async consumeModelStream( + message: string | Part[], + modelMsgId: string, + ensureModelMsg: () => void, + calls: { name: string; args: Record }[], + signal: AbortSignal, + ): Promise { + const s = atlas(); + for (let attempt = 0; attempt <= MAX_STREAM_RETRIES; attempt++) { + let retryEventId: string | undefined; + let receivedAnyPart = false; + // Fresh per-attempt controller so an idle-timeout abort on one attempt + // doesn't poison the retry. Forwards the run-level abort (Stop / navigate). + const attemptCtrl = new AbortController(); + const forwardAbort = () => attemptCtrl.abort(); + if (signal.aborted) return; + signal.addEventListener('abort', forwardAbort, { once: true }); + try { + if (attempt > 0) { + retryEventId = uid('t'); + s.pushTool({ + id: retryEventId, + name: 'retry_ai_response', + status: 'running', + summary: `attempt ${attempt + 1}/${MAX_STREAM_RETRIES + 1}`, + ts: Date.now(), + }); + } + + const stream = await this.chat.sendMessageStream({ + message, + config: { ...this.config, abortSignal: attemptCtrl.signal }, + }); + const guarded = withIdleTimeout(stream, STREAM_IDLE_TIMEOUT_MS, () => attemptCtrl.abort()); + for await (const chunk of guarded) { + if (signal.aborted) return; + const text = textFromChunkParts(chunk); + const functionCalls = functionCallsFromChunkParts(chunk); + if (text || functionCalls.length) receivedAnyPart = true; + if (text) { + ensureModelMsg(); + s.appendToMsg(modelMsgId, text); + } + for (const fc of functionCalls) { + calls.push({ name: fc.name ?? '', args: (fc.args ?? {}) as Record }); + } + } + if (retryEventId) { + s.updateTool(retryEventId, { status: 'ok', summary: 'recovered' }); + } + if (atlas().apiHealth === 'degraded') s.setApiHealth('ok'); + return; + } catch (err) { + if (signal.aborted) return; // stopped/navigated away — swallow quietly + // A stalled (silent) stream is retryable, just like a transient 5xx/429. + const retryable = isStreamIdleError(err) || isRetryableStreamError(err); + if (retryEventId) { + s.updateTool(retryEventId, { + status: attempt < MAX_STREAM_RETRIES && retryable ? 'ok' : 'error', + summary: isStreamIdleError(err) ? 'stalled' : summarizeStreamError(err), + }); + } + if (receivedAnyPart || attempt >= MAX_STREAM_RETRIES || !retryable) { + throw err; + } + await sleep(isRateLimitError(err) ? 4000 * (attempt + 1) : RETRY_BASE_DELAY_MS * 2 ** attempt); + } finally { + signal.removeEventListener('abort', forwardAbort); + } + } + } +} + +/** + * Removes a model echo of Atlas's private mission-context envelope. The stream is + * hidden while active, so sanitizing once at completion avoids chunk-boundary + * bugs and guarantees internal prompt labels never become answer copy. + */ +export function stripInternalPromptEcho(text: string, userText: string): string { + let visible = text.trim(); + + // Remove the private no-echo trailer wherever it lands in the echo. + visible = visible.replace( + /\s*Answer the request directly\. Never repeat this context envelope or its tags\.\s*/gi, + '\n', + ).trim(); + + // The / tags wrap Atlas's private context envelope + // and NEVER appear in a genuine answer. The model frequently paraphrases and + // recombines the echoed directive, so an exact match against `userText` is + // unreliable. Instead key on the closing tag: everything up to and INCLUDING + // the first is echoed envelope (mission context + directive) — + // drop it. This is robust to any paraphrase of the directive. + const closeMatch = /<\/user_request>/i.exec(visible); + if (closeMatch) { + visible = visible.slice(closeMatch.index + closeMatch[0].length).trimStart(); + } + + // Legacy "--- User request ---" wrapper (older envelope form): drop it and the + // echoed request only when it leads or follows the app-owned mission envelope, + // so ordinary answer prose that mentions "user request" is left untouched. + const legacyMarker = /(?:^|\n)\s*-{2,}\s*User request\s*-{2,}\s*\n?/i; + const legacy = legacyMarker.exec(visible); + if (legacy) { + const prefix = visible.slice(0, legacy.index).trim(); + if (!prefix || /^--- Active mission \(application-owned state\) ---/i.test(prefix)) { + visible = visible.slice(legacy.index + legacy[0].length).trimStart(); + } + } + + // Belt-and-suspenders: a verbatim echo of the request, plus any stray standalone + // envelope tags left anywhere. Those tags are envelope-only, so removing them is + // safe and can never touch real answer content. + if (userText && visible.startsWith(userText)) { + visible = visible.slice(userText.length).trimStart(); + } + visible = visible.replace(/\s*<\/?user_request>\s*/gi, '\n').trim(); + + return visible.trim(); +} + +/** Make loop exhaustion visible and resumable instead of silently ending the turn. */ +export function markHopLimitPartial(userText: string): void { + const state = atlas(); + state.setResumable(userText); + state.addMsg({ + id: uid('notice'), + role: 'notice', + notice: { + title: 'Mission paused at the tool-step limit', + body: 'The completed evidence is preserved. Resume to continue from the next action.', + }, + ts: Date.now(), + }); + if (missionStore().mission.status !== 'draft') missionStore().transition({ type: 'partial' }); +} + +interface StreamChunkShape { + candidates?: { + content?: { + parts?: Part[]; + }; + }[]; +} + +function chunkParts(chunk: StreamChunkShape): Part[] { + return chunk.candidates?.flatMap((candidate) => candidate.content?.parts ?? []) ?? []; +} + +function textFromChunkParts(chunk: StreamChunkShape): string { + return chunkParts(chunk) + .map((part) => ('text' in part && typeof part.text === 'string' ? part.text : '')) + .join(''); +} + +function functionCallsFromChunkParts(chunk: StreamChunkShape): NonNullable[] { + return chunkParts(chunk) + .map((part) => ('functionCall' in part ? part.functionCall : undefined)) + .filter((fc): fc is NonNullable => !!fc); +} + +function safe(v: unknown): Record { + if (v && typeof v === 'object' && !Array.isArray(v)) return v as Record; + return { result: v }; +} + +/** A tool that returned normally but signalled failure via `{ok: false}` — the + * chip should read as an error (✕), not a success (✓) with a "failed" summary. */ +function isFailedResult(result: unknown): boolean { + return !!result && typeof result === 'object' && !Array.isArray(result) && (result as { ok?: unknown }).ok === false; +} + +function summarize(name: string, result: unknown): string { + const r = result as Record; + if (r && typeof r === 'object') { + if ('count' in r) return `${r.count} results`; + if ('added' in r) return `+${r.added} markers`; + if ('distanceMeters' in r && 'durationSeconds' in r) + return `${Math.round(Number(r.distanceMeters) / 100) / 10} km`; + if ('ok' in r) return r.ok ? 'done' : 'failed'; + } + return name; +} + +/** + * Builds rich, clickable detail rows for a tool call so the demo can "show its + * work". Prepends a compact summary of the INPUT ARGS, then tool-specific + * result rows. Lists are kept short (roughly <=6 rows besides list-style tools + * like search_places). Returns undefined when there is nothing showable, which + * renders a non-clickable chip. + */ +function detailsForTool( + name: string, + result: unknown, + args?: Record, +): ToolEventDetail[] | undefined { + const r = (result && typeof result === 'object' && !Array.isArray(result) ? result : {}) as Record; + const rows: ToolEventDetail[] = []; + const input = argsSummary(name, args ?? {}); + if (input) rows.push({ label: 'Input', value: input }); + + // Any tool that failed with a message surfaces the reason. + if (r.ok === false && typeof r.error === 'string' && r.error.trim()) { + rows.push({ label: 'Error', value: r.error }); + return rows; + } + + switch (name) { + case 'search_places': { + if (Array.isArray(r.places)) { + for (const place of r.places.slice(0, 8)) { + const p = place as Record; + const rating = typeof p.rating === 'number' ? `${p.rating.toFixed(1)} stars` : undefined; + const address = typeof p.address === 'string' ? p.address : undefined; + rows.push({ + label: typeof p.name === 'string' ? p.name : 'Unnamed place', + value: [rating, address].filter(Boolean).join(' - ') || undefined, + placeId: typeof p.placeId === 'string' ? p.placeId : undefined, + }); + } + } + break; + } + case 'get_place_details': { + pushDetail(rows, stringDetail('Address', r.address)); + pushDetail(rows, stringDetail('Phone', r.phone)); + pushDetail(rows, stringDetail('Website', r.website)); + if (typeof r.rating === 'number') rows.push({ label: 'Rating', value: `${r.rating.toFixed(1)} stars` }); + if (typeof r.openNow === 'boolean') rows.push({ label: 'Open now', value: r.openNow ? 'Yes' : 'No' }); + break; + } + case 'draw_route': { + pushDetail(rows, labelIf('Distance', kmLabel(r.distanceMeters))); + pushDetail(rows, labelIf('ETA', minLabel(r.durationSeconds))); + break; + } + case 'get_environment': { + pushDetail(rows, snapshotDetail('Air quality', r.airQuality, ['aqi', 'category'])); + pushDetail(rows, snapshotDetail('Weather', r.weather, ['tempC', 'condition'])); + const solar = r.solar as Record | undefined; + if (solar && typeof solar.yearlyEnergyKwh === 'number') { + rows.push({ label: 'Solar potential', value: `${Math.round(solar.yearlyEnergyKwh)} kWh/yr` }); + } + break; + } + case 'ask_maps': + case 'ask_maps_grounding': { + pushDetail(rows, stringDetail('Answer', r.answer)); + if (typeof r.grounded === 'boolean') rows.push({ label: 'Grounded', value: r.grounded ? 'Yes' : 'No' }); + break; + } + case 'render_surface': { + if (Array.isArray(r.created) && r.created.length) rows.push({ label: 'Created', value: `${r.created.length} surface(s)` }); + if (Array.isArray(r.updated) && r.updated.length) rows.push({ label: 'Updated', value: `${r.updated.length} surface(s)` }); + break; + } + case 'analyze_location': { + pushDetail(rows, stringDetail('Address', r.address)); + if (typeof r.livingScore === 'number') rows.push({ label: 'Living score', value: `${Math.round(r.livingScore)}/100` }); + pushDetail(rows, snapshotDetail('Air quality', r.airQuality, ['aqi', 'category'])); + pushDetail(rows, snapshotDetail('Weather', r.weather, ['tempC', 'condition'])); + if (Array.isArray(r.essentials)) rows.push({ label: 'Essentials nearby', value: `${r.essentials.length} found` }); + break; + } + case 'compare_with': { + pushDetail(rows, stringDetail('Address', r.address)); + if (typeof r.livingScore === 'number') rows.push({ label: 'Living score', value: `${Math.round(r.livingScore)}/100` }); + if (typeof r.scoreDeltaVsA === 'number') { + const d = Math.round(r.scoreDeltaVsA); + rows.push({ label: 'Vs. location A', value: `${d >= 0 ? '+' : ''}${d} points` }); + } + break; + } + case 'propose_itinerary': { + if (typeof r.count === 'number') rows.push({ label: 'Stops', value: `${r.count}` }); + pushDetail(rows, labelIf('Total distance', kmLabel(r.totalDistanceMeters))); + pushDetail(rows, labelIf('Total time', minLabel(r.totalDurationSeconds))); + if (Array.isArray(r.stops)) { + for (const stop of r.stops.slice(0, 3)) { + const st = stop as Record; + if (typeof st.name === 'string') rows.push({ label: st.name, value: typeof st.window === 'string' ? st.window : undefined }); + } + } + break; + } + case 'get_fleet_state': { + const k = r.kpis as Record | undefined; + if (k) { + if (typeof k.active === 'number' && typeof k.total === 'number') rows.push({ label: 'Active vans', value: `${k.active}/${k.total}` }); + if (typeof k.onTimePct === 'number') rows.push({ label: 'On-time', value: `${Math.round(k.onTimePct)}%` }); + pushDetail(rows, labelIf('Avg ETA', minLabel(k.avgEtaSeconds))); + if (typeof k.unassigned === 'number') rows.push({ label: 'Unassigned jobs', value: `${k.unassigned}` }); + } + break; + } + case 'eta_matrix': { + if (Array.isArray(r.ranking)) { + for (const row of r.ranking.slice(0, 5)) { + const v = row as Record; + if (typeof v.vanId === 'string') { + rows.push({ + label: v.vanId, + value: [minLabel(v.etaSeconds), kmLabel(v.distanceMeters)].filter(Boolean).join(' · ') || undefined, + }); + } + } + } + break; + } + case 'set_avoid_zone': { + if (r.cleared) rows.push({ label: 'Avoid zone', value: 'Cleared' }); + else if (typeof r.rerouted === 'number') rows.push({ label: 'Rerouted', value: `${r.rerouted} van(s)` }); + break; + } + case 'set_campaign_business': { + pushDetail(rows, stringDetail('Business', r.name)); + pushDetail(rows, stringDetail('Address', r.address)); + if (typeof r.rating === 'number') rows.push({ label: 'Rating', value: `${r.rating.toFixed(1)} stars` }); + break; + } + case 'gather_campaign_facts': { + const biz = r.business as Record | undefined; + if (biz && typeof biz.name === 'string') rows.push({ label: 'Business', value: biz.name }); + pushDetail(rows, stringDetail('Vibe', r.vibe)); + pushDetail(rows, snapshotDetail('Weather', r.weather, ['tempC', 'condition'])); + pushDetail(rows, snapshotDetail('Air quality', r.airQuality, ['aqi', 'category'])); + if (r.hasStreetViewPhoto || r.hasPlacesPhoto) { + rows.push({ + label: 'Reference photos', + value: [r.hasStreetViewPhoto ? 'Street View' : undefined, r.hasPlacesPhoto ? 'Places' : undefined].filter(Boolean).join(' · '), + }); + } + break; + } + case 'generate_ad_creatives': { + if (Array.isArray(r.creatives)) { + for (const creative of r.creatives.slice(0, 5)) { + const c = creative as Record; + rows.push({ + label: typeof c.headline === 'string' && c.headline.trim() ? c.headline : typeof c.style === 'string' ? c.style : 'Creative', + value: typeof c.style === 'string' ? c.style : undefined, + }); + } + } + break; + } + case 'set_geo_targeting': { + pushDetail(rows, stringDetail('Reach', r.reachSummary)); + if (typeof r.ringPoints === 'number') rows.push({ label: 'Ring points', value: `${r.ringPoints}` }); + break; + } + case 'export_campaign': { + const summary = r.summary as Record | undefined; + const biz = summary?.business as Record | undefined; + if (biz && typeof biz.name === 'string') rows.push({ label: 'Business', value: biz.name }); + if (Array.isArray(summary?.creatives)) rows.push({ label: 'Creatives', value: `${summary.creatives.length}` }); + const targeting = summary?.targeting as Record | undefined; + if (targeting && typeof targeting.reachSummary === 'string') rows.push({ label: 'Targeting', value: targeting.reachSummary }); + break; + } + case 'scout_area': { + if (Array.isArray(r.candidates)) { + for (const candidate of r.candidates.slice(0, 6)) { + const c = candidate as Record; + rows.push({ + label: typeof c.label === 'string' ? c.label : 'Candidate', + value: typeof c.address === 'string' ? c.address : undefined, + placeId: typeof c.placeId === 'string' ? c.placeId : undefined, + }); + } + } + break; + } + case 'inspect_candidate': { + if (Array.isArray(r.headingLabels) && r.headingLabels.length) rows.push({ label: 'Headings', value: r.headingLabels.join(', ') }); + const scores = r.scores as Record | undefined; + if (scores) { + const parts = Object.entries(scores) + .filter(([, v]) => typeof v === 'number') + .slice(0, 4) + .map(([k, v]) => `${k} ${v}`); + if (parts.length) rows.push({ label: 'Scores', value: parts.join(' · ') }); + } + pushDetail(rows, stringDetail('Notes', r.notes)); + break; + } + case 'score_candidates': { + if (Array.isArray(r.ranked)) { + for (const row of r.ranked.slice(0, 5)) { + const c = row as Record; + const scores = c.scores as Record | undefined; + const total = scores && typeof scores.total === 'number' ? `${scores.total}` : undefined; + rows.push({ + label: `#${typeof c.rank === 'number' ? c.rank : '?'} ${typeof c.label === 'string' ? c.label : ''}`.trim(), + value: total ? `score ${total}` : undefined, + }); + } + } + break; + } + case 'show_evidence': { + pushDetail(rows, stringDetail('Candidate', r.label)); + const scores = r.scores as Record | undefined; + if (scores && typeof scores.total === 'number') rows.push({ label: 'Total score', value: `${scores.total}` }); + if (Array.isArray(r.frames)) rows.push({ label: 'Evidence frames', value: `${r.frames.length}` }); + break; + } + case 'narrate_stop': { + pushDetail(rows, stringDetail('Narration', r.narration)); + break; + } + case 'start_tour': { + pushDetail(rows, stringDetail('Tour', r.title)); + break; + } + case 'ask_atlas_brief': { + pushDetail(rows, stringDetail('Brief', r.brief)); + break; + } + default: + break; + } + + return rows.length ? rows : undefined; +} + +function pushDetail(rows: ToolEventDetail[], detail: ToolEventDetail | undefined): void { + if (detail) rows.push(detail); +} + +function labelIf(label: string, value: string | undefined): ToolEventDetail | undefined { + return value ? { label, value } : undefined; +} + +function stringDetail(label: string, value: unknown): ToolEventDetail | undefined { + if (typeof value !== 'string' || !value.trim()) return undefined; + return { label, value: value.length > 160 ? value.slice(0, 159).trimEnd() + '…' : value }; +} + +/** Renders a `{key: value}` snapshot (e.g. air quality, weather) into one row. */ +function snapshotDetail(label: string, snapshot: unknown, keys: string[]): ToolEventDetail | undefined { + if (!snapshot || typeof snapshot !== 'object') return undefined; + const s = snapshot as Record; + const parts = keys + .map((k) => { + const v = s[k]; + if (v == null) return undefined; + if (k === 'tempC' && typeof v === 'number') return `${Math.round(v)}°C`; + if (k === 'aqi') return `AQI ${v}`; + return String(v); + }) + .filter(Boolean) as string[]; + return parts.length ? { label, value: parts.join(' · ') } : undefined; +} + +function kmLabel(meters: unknown): string | undefined { + return typeof meters === 'number' ? `${Math.round(meters / 100) / 10} km` : undefined; +} + +function minLabel(seconds: unknown): string | undefined { + return typeof seconds === 'number' ? `${Math.max(1, Math.round(seconds / 60))} min` : undefined; +} + +/** Compact, human-readable summary of a tool call's input args for the chip. */ +function argsSummary(name: string, a: Record): string | undefined { + const str = (k: string) => (typeof a[k] === 'string' && (a[k] as string).trim() ? (a[k] as string) : undefined); + const numv = (k: string) => (typeof a[k] === 'number' ? (a[k] as number) : undefined); + const len = (k: string) => (Array.isArray(a[k]) ? (a[k] as unknown[]).length : undefined); + const coord = () => + typeof a.lat === 'number' && typeof a.lng === 'number' + ? `${(a.lat as number).toFixed(3)}, ${(a.lng as number).toFixed(3)}` + : undefined; + const clamp = (v: string | undefined, n = 64) => (v && v.length > n ? v.slice(0, n - 1).trimEnd() + '…' : v); + const join = (parts: (string | undefined)[]) => parts.filter(Boolean).join(' · ') || undefined; + + switch (name) { + case 'search_places': + return join([str('query'), numv('maxResults') != null ? `max ${numv('maxResults')}` : undefined, a.openNow ? 'open now' : undefined]); + case 'get_place_details': + return str('placeId'); + case 'fly_to': + return str('label') ?? coord(); + case 'draw_route': + return str('travelMode') ? `mode ${str('travelMode')}` : undefined; + case 'get_environment': + case 'analyze_location': + case 'compare_with': + case 'eta_matrix': + return coord(); + case 'ask_maps': + case 'ask_maps_grounding': + return clamp(str('question')); + case 'render_surface': + return len('messages') != null ? `${len('messages')} A2UI message(s)` : undefined; + case 'assign_job': + return str('vanId') && str('jobId') ? `${str('vanId')} → ${str('jobId')}` : undefined; + case 'set_sim_speed': + return numv('multiplier') != null ? `${numv('multiplier')}×` : undefined; + case 'set_geo_targeting': + return join([numv('minutes') != null ? `${numv('minutes')} min` : undefined, str('travelMode')]); + case 'generate_ad_creatives': + return join([len('styles') != null ? `${len('styles')} style(s)` : undefined, str('format')]); + case 'set_campaign_business': + return clamp(str('brief')) ?? str('placeId'); + case 'scout_area': + return join([str('query'), coord()]); + case 'inspect_candidate': + case 'show_evidence': + return str('candidateId'); + case 'propose_itinerary': + return len('stops') != null ? `${len('stops')} stops` : undefined; + case 'set_travel_mode': + return str('mode'); + case 'start_tour': + return str('tourId'); + case 'narrate_stop': + return str('name'); + default: + return undefined; + } +} + +function isRetryableStreamError(err: unknown): boolean { + const msg = err instanceof Error ? err.message : String(err); + return /\b(429|500|502|503|504)\b|service unavailable|temporar/i.test(msg); +} + +function summarizeStreamError(err: unknown): string { + const msg = err instanceof Error ? err.message : String(err); + if (/\b503\b|service unavailable/i.test(msg)) return 'service unavailable'; + if (/\b429\b|quota|rate/i.test(msg)) return 'rate limited'; + return 'transient failure'; +} + +function isRateLimitError(err: unknown): boolean { + const msg = err instanceof Error ? err.message : String(err); + return /\b429\b|rate limit|quota|demo is busy/i.test(msg); +} + +function sleep(ms: number): Promise { + // Global setTimeout (not window.setTimeout) so the stream-retry backoff also + // works outside a browser window (SSR / tests); identical in the browser. + return new Promise((resolve) => setTimeout(resolve, ms)); +} diff --git a/demos/real-world-reasoning-agent/src/ai/followups.test.ts b/demos/real-world-reasoning-agent/src/ai/followups.test.ts new file mode 100644 index 0000000..4794e80 --- /dev/null +++ b/demos/real-world-reasoning-agent/src/ai/followups.test.ts @@ -0,0 +1,92 @@ +import { describe, expect, it } from 'vitest'; +import { parseFollowups, buildPrompt, describeCapabilities } from './followups'; + +describe('parseFollowups', () => { + it('parses a plain JSON array', () => { + expect(parseFollowups('["Show me the fastest route", "Compare two neighborhoods"]')).toEqual([ + 'Show me the fastest route', + 'Compare two neighborhoods', + ]); + }); + + it('extracts the array from a fenced / prose-wrapped response', () => { + const raw = 'Sure! ```json\n["Add a coffee stop", "Extend the trip by a day"]\n``` hope that helps'; + expect(parseFollowups(raw)).toEqual(['Add a coffee stop', 'Extend the trip by a day']); + }); + + it('caps at 3 suggestions and de-dupes case-insensitively', () => { + const raw = JSON.stringify(['A option', 'a option', 'B option', 'C option', 'D option']); + expect(parseFollowups(raw)).toEqual(['A option', 'B option', 'C option']); + }); + + it('drops results below the minimum of 2', () => { + expect(parseFollowups('["only one"]')).toEqual([]); + }); + + it('truncates long suggestions and trims whitespace', () => { + const long = 'x'.repeat(80); + const [only] = parseFollowups(JSON.stringify([` ${long} `, 'second suggestion here'])); + expect(only.length).toBeLessThanOrEqual(64); + expect(only.endsWith('…')).toBe(true); + }); + + it('returns [] for non-array or garbage input', () => { + expect(parseFollowups('not json')).toEqual([]); + expect(parseFollowups('{"foo":"bar"}')).toEqual([]); + expect(parseFollowups('')).toEqual([]); + }); + + it('ignores non-string and empty entries', () => { + expect(parseFollowups('["Keep exploring", 42, "", " ", "Wrap it up"]')).toEqual([ + 'Keep exploring', + 'Wrap it up', + ]); + }); +}); + +describe('buildPrompt', () => { + const prompt = buildPrompt('Scout', 'Score the sites', 'User: find a corner\nAtlas: here'); + + it('embeds the journey title, tagline, and transcript', () => { + expect(prompt).toContain('"Scout"'); + expect(prompt).toContain('Score the sites'); + expect(prompt).toContain('User: find a corner'); + }); + + it('nudges toward cross-journey hops, visible payoffs, and shareable finishes', () => { + expect(prompt).toMatch(/cross-journey hop/i); + expect(prompt).toMatch(/visible payoff/i); + expect(prompt).toMatch(/make the postcard|export the campaign/i); + }); + + it('includes a rule that deepens understanding of a produced result (the WHY)', () => { + expect(prompt).toMatch(/deepens understanding/i); + expect(prompt).toMatch(/why is #1 the winner\?|explain the tradeoffs/i); + }); + + it('embeds the capability list and a "do not invent features" rule when given', () => { + const grounded = buildPrompt('Fleet', 'Dispatch', 'User: help', '- assign_job: Assign a job'); + expect(grounded).toContain('- assign_job: Assign a job'); + expect(grounded).toMatch(/only suggest actions Atlas can actually perform/i); + expect(grounded).toMatch(/never invent/i); + }); + + it('omits the capability block when none is provided (back-compat)', () => { + expect(prompt).not.toMatch(/capabilities in this journey/i); + }); +}); + +describe('describeCapabilities', () => { + it('lists the fleet journey tools without plumbing tools', () => { + const caps = describeCapabilities('fleet'); + expect(caps).toMatch(/assign_job:/); + expect(caps).toMatch(/eta_matrix:/); + // render_surface / show_notice are UI plumbing, not user-facing actions. + expect(caps).not.toMatch(/render_surface/); + expect(caps).not.toMatch(/show_notice/); + }); + + it('does not describe a tool the fleet agent lacks (e.g. a traffic heatmap)', () => { + expect(describeCapabilities('fleet')).not.toMatch(/heatmap/i); + }); +}); diff --git a/demos/real-world-reasoning-agent/src/ai/followups.ts b/demos/real-world-reasoning-agent/src/ai/followups.ts new file mode 100644 index 0000000..072ab96 --- /dev/null +++ b/demos/real-world-reasoning-agent/src/ai/followups.ts @@ -0,0 +1,181 @@ +/** + * Follow-up suggestions: after every copilot turn, propose 2–3 short next actions + * the user can click to keep the conversation moving. Runs as a SEPARATE, cheap + * task-agent call (gemini-3.5-flash-lite at MINIMAL thinking) off the main + * function-calling chat, so it never blocks or slows the streamed answer. Best + * effort — any failure returns an empty list and the chips simply don't show. + */ +import { genai } from './client'; +import { MODELS, getThinkingConfig } from '@/lib/config'; +import { atlas } from '@/state/store'; +import { SCENARIOS } from '@/scenarios/registry'; +import type { ChatMsg, ScenarioId } from '@/lib/types'; + +/** How many recent transcript turns to feed the suggester for context. */ +const HISTORY_TURNS = 8; +/** Hard bounds on what we render — the UI shows at most 3 compact chips. */ +const MIN_SUGGESTIONS = 2; +const MAX_SUGGESTIONS = 3; +const MAX_CHARS = 64; + +/** + * Gemini structured-output schema: force a plain JSON array of 2–3 short strings. + * Lets the cheap flash-lite utility model produce parser-clean output instead of + * fenced/prose-wrapped JSON. `normalize` still clamps/de-dupes defensively. + */ +const FOLLOWUPS_SCHEMA = { + type: 'array', + items: { type: 'string', maxLength: MAX_CHARS }, + minItems: MIN_SUGGESTIONS, + maxItems: MAX_SUGGESTIONS, +} as const; + +/** + * Ask the utility model for 2–3 next-step suggestions grounded in the recent + * transcript and the active journey. Returns clickable, first-person prompts + * (e.g. "Show me the fastest route") or an empty array on any failure. + */ +export async function suggestFollowups( + scenario: ScenarioId, + signal?: AbortSignal, +): Promise { + const transcript = atlas().transcript; + const history = summarizeTranscript(transcript); + if (!history) return []; + + const mod = SCENARIOS[scenario]; + const prompt = buildPrompt(mod.title, mod.tagline, history, describeCapabilities(scenario)); + + try { + const thinkingConfig = getThinkingConfig(MODELS.utility, 'simpleUi'); + const resp = await genai().models.generateContent({ + model: MODELS.utility, + contents: prompt, + config: { + responseMimeType: 'application/json', + // Structured output so the small flash-lite utility model reliably emits a + // JSON array of strings (no prose, no fences, no objects) — the parser + // stays as a defensive fallback for older models that ignore the schema. + responseJsonSchema: FOLLOWUPS_SCHEMA, + ...(thinkingConfig ? { thinkingConfig } : {}), + ...(signal ? { abortSignal: signal } : {}), + }, + }); + if (signal?.aborted) return []; + return parseFollowups(resp.text ?? ''); + } catch { + return []; + } +} + +/** Turn raw model output into 2–3 clean suggestion strings (or [] if unusable). + * Exported for unit testing the JSON-tolerant parsing + normalization. */ +export function parseFollowups(raw: string): string[] { + return normalize(parseSuggestions(raw)); +} + +/** Compact, role-tagged view of the last few turns (user + model text only). */ +function summarizeTranscript(msgs: ChatMsg[]): string { + const lines: string[] = []; + for (const m of msgs) { + if (m.role !== 'user' && m.role !== 'model') continue; + const text = (m.text ?? '').trim(); + if (!text) continue; + const who = m.role === 'user' ? 'User' : 'Atlas'; + lines.push(`${who}: ${text.length > 400 ? text.slice(0, 399).trimEnd() + '…' : text}`); + } + return lines.slice(-HISTORY_TURNS).join('\n'); +} + +/** Plumbing tools that aren't user-facing actions — excluded from the capability + * list so the suggester never proposes "render a surface" / "show a notice". */ +const NON_ACTION_TOOLS = new Set(['render_surface', 'show_notice']); + +/** First sentence of a tool description, for a compact capability line. */ +function firstSentence(desc: string): string { + const trimmed = desc.trim(); + const dot = trimmed.indexOf('. '); + return (dot > 0 ? trimmed.slice(0, dot) : trimmed).trim(); +} + +/** + * A concise, capability list for a journey, derived from its actual tools. Feeds + * the suggester so it never proposes actions the agent can't perform (e.g. the + * Fleet journey has no "traffic heatmap" tool, so it must not suggest one). + * Exported for unit testing. + */ +export function describeCapabilities(scenario: ScenarioId): string { + const mod = SCENARIOS[scenario]; + return mod.tools + .map((t) => t.declaration) + .filter((d) => d.name && !NON_ACTION_TOOLS.has(d.name)) + .map((d) => `- ${d.name}: ${firstSentence(d.description ?? '')}`) + .join('\n'); +} + +/** Build the utility-model prompt. Exported for unit testing the nudge rules. */ +export function buildPrompt(title: string, tagline: string, history: string, capabilities?: string): string { + return [ + `You are Atlas, a Google Maps + Gemini copilot. The user is in the "${title}" journey (${tagline}).`, + 'Based on the conversation so far, propose the 2-3 most useful next actions the user might want to take.', + 'Rules:', + '- Write each as a short first-person request the user could click to send (e.g. "Show me the fastest route", "Compare this with a quieter neighborhood").', + `- Keep each under ${MAX_CHARS} characters, specific to the context, and distinct from each other.`, + '- Do not repeat something the user already asked. Prefer concrete, forward-moving steps.', + "- If a result was just produced, make ONE suggestion a step that deepens understanding of it — the WHY behind the outcome (e.g. \"Why is #1 the winner?\", \"Explain the tradeoffs\") — not only forward \"do\" actions.", + '- Favor steps with a visible payoff: a route drawn, a 3D flyover, a scorecard, a postcard, an exported campaign.', + '- When it fits, suggest a cross-journey hop (e.g. after an ad campaign, "Now scout a site for it").', + '- If the run produced something shareable, offer a finishing move like "Make the postcard" or "Export the campaign".', + ...(capabilities + ? [ + '- CRITICAL: only suggest actions Atlas can actually perform with the capabilities listed below (or a hop to another journey). Never invent features it lacks — no heatmaps, dashboards, alerts, notifications, exports, or bulk edits unless a capability explicitly supports it. If you are unsure an action is supported, do not suggest it.', + '', + "Atlas's capabilities in this journey:", + capabilities, + ] + : []), + 'Respond with ONLY a JSON array of 2-3 strings, nothing else.', + '', + 'Conversation:', + history, + ].join('\n'); +} + +/** Parse the model output into a string array, tolerating fenced/prose wrappers. */ +function parseSuggestions(raw: string): unknown { + const trimmed = raw.trim(); + if (!trimmed) return []; + try { + return JSON.parse(trimmed); + } catch { + const start = trimmed.indexOf('['); + const end = trimmed.lastIndexOf(']'); + if (start >= 0 && end > start) { + try { + return JSON.parse(trimmed.slice(start, end + 1)); + } catch { + return []; + } + } + return []; + } +} + +/** Coerce, trim, de-dupe, length-clamp, and bound to MIN..MAX suggestions. */ +function normalize(value: unknown): string[] { + if (!Array.isArray(value)) return []; + const out: string[] = []; + const seen = new Set(); + for (const item of value) { + if (typeof item !== 'string') continue; + const clean = item.trim().replace(/\s+/g, ' '); + if (!clean) continue; + const capped = clean.length > MAX_CHARS ? clean.slice(0, MAX_CHARS - 1).trimEnd() + '…' : clean; + const key = capped.toLowerCase(); + if (seen.has(key)) continue; + seen.add(key); + out.push(capped); + if (out.length >= MAX_SUGGESTIONS) break; + } + return out.length >= MIN_SUGGESTIONS ? out : []; +} diff --git a/demos/real-world-reasoning-agent/src/ai/grounding.ts b/demos/real-world-reasoning-agent/src/ai/grounding.ts new file mode 100644 index 0000000..6834669 --- /dev/null +++ b/demos/real-world-reasoning-agent/src/ai/grounding.ts @@ -0,0 +1,36 @@ +import { genai } from './client'; +import { MODELS, getThinkingConfig } from '@/lib/config'; +import { atlas, useAtlas } from '@/state/store'; +import { DEFAULT_CITY_PRESET } from '@/lib/cities'; +import type { LatLng } from '@/lib/types'; + +export interface GroundedAnswer { + text: string; + widgetContextToken?: string; +} + +/** + * Nested Gemini call using the `googleMaps` grounding tool. Kept SEPARATE from + * the main function-calling chat (Gemini rejects mixing grounding + function + * declarations in one call). Returns the answer plus the widget context token + * that renders . + */ +export async function askMaps(question: string, at?: LatLng): Promise { + const cityId = atlas().cityId; + const preset = useAtlas.getState().cities.find(c => c.id === cityId) ?? DEFAULT_CITY_PRESET; + const loc = at ?? preset.center; + const thinkingConfig = getThinkingConfig(MODELS.utility, 'simpleUi'); + const resp = await genai().models.generateContent({ + model: MODELS.utility, + contents: question, + config: { + tools: [{ googleMaps: {} }], + toolConfig: { + retrievalConfig: { latLng: { latitude: loc.lat, longitude: loc.lng } }, + }, + ...(thinkingConfig ? { thinkingConfig } : {}), + }, + }); + const token = resp.candidates?.[0]?.groundingMetadata?.googleMapsWidgetContextToken; + return { text: resp.text ?? '', widgetContextToken: token }; +} diff --git a/demos/real-world-reasoning-agent/src/ai/image.ts b/demos/real-world-reasoning-agent/src/ai/image.ts new file mode 100644 index 0000000..89b1adf --- /dev/null +++ b/demos/real-world-reasoning-agent/src/ai/image.ts @@ -0,0 +1,337 @@ +import { genai } from './client'; +import { MODELS } from '@/lib/config'; +import type { EnvironmentSnapshot, PlaceLite } from '@/lib/types'; + +const CONDITIONING_IMAGE_FETCH_TIMEOUT_MS = 8_000; +const CONDITIONED_IMAGE_TIMEOUT_MS = 30_000; +const TEXT_ONLY_IMAGE_TIMEOUT_MS = 45_000; +const UNAVAILABLE_RETRY_DELAY_MS = 800; + +/** + * Why an image call failed, so callers can show an accurate, actionable message + * instead of blaming the key/quota for what is actually a transient rate limit. + * - `rate-limited`: HTTP 429 — the /ai proxy's per-IP window or an upstream + * per-second quota. The demo burst (Scout + grounding + N ad images) exceeded + * AI_RATE_LIMIT; wait and retry, or raise AI_RATE_LIMIT. NOT retried inline + * (the proxy limiter is a 10-min window, so an immediate retry just re-429s + * and burns another slot). + * - `unavailable`: HTTP 503 — a genuinely transient upstream blip; retried once. + * - `other`: model/key/quota/region or an unexpected error — check config. + */ +export type AiFailureKind = 'rate-limited' | 'unavailable' | 'other'; + +/** Classify a GenAI SDK error (ApiError has a numeric `.status`) into an {@link AiFailureKind}. */ +export function classifyAiFailure(err: unknown): AiFailureKind { + const status = (err as { status?: unknown } | null)?.status; + if (status === 429) return 'rate-limited'; + if (status === 503) return 'unavailable'; + // Fall back to sniffing the message when the status didn't survive (e.g. a + // stringified upstream error body). + const msg = err instanceof Error ? err.message : typeof err === 'string' ? err : ''; + if (/\b429\b|too many requests|rate.?limit|\bbusy\b/i.test(msg)) return 'rate-limited'; + if (/\b503\b|unavailable/i.test(msg)) return 'unavailable'; + return 'other'; +} + +const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); + +/** + * Image generation via Nano Banana 2 Lite (gemini-3.1-flash-lite-image). Returns a + * ready-to-use data URL. Real place names are passed as text so the postcard is + * grounded in the user's actual itinerary. Labeled "AI-generated image" in UI. + */ +export async function generateImage( + prompt: string, + onError?: (kind: AiFailureKind) => void, +): Promise { + const call = () => + genai().models.generateContent({ + model: MODELS.image, + contents: prompt, + config: { responseModalities: ['IMAGE', 'TEXT'] }, + }); + try { + let resp; + try { + resp = await call(); + } catch (err) { + // One retry for a transient upstream 503; 429s are not retried (see AiFailureKind). + if (classifyAiFailure(err) !== 'unavailable') throw err; + await delay(UNAVAILABLE_RETRY_DELAY_MS); + resp = await call(); + } + const part = resp.candidates?.[0]?.content?.parts?.find((p) => p.inlineData?.data); + const data = part?.inlineData?.data; + if (!data) return null; + const mime = part?.inlineData?.mimeType ?? 'image/png'; + return `data:${mime};base64,${data}`; + } catch (err) { + // Surface WHY image generation failed (rate limit, model unavailable, quota, + // key/region) instead of swallowing it — otherwise a run just shows blank/ + // error cards. The classified kind lets the UI show an accurate message. + console.error('[generateImage] failed:', err); + onError?.(classifyAiFailure(err)); + return null; + } +} + +/** Build a travel-postcard prompt from real itinerary stop names + a style hint. */ +export function postcardPrompt(cityLabel: string, stopNames: string[], styleHint?: string): string { + const stops = stopNames.slice(0, 6).join(', '); + const style = + styleHint?.trim() || + 'vintage travel poster, warm golden-hour palette, bold clean typography, subtle grain'; + return ( + `Design a single stylized travel postcard for a day in ${cityLabel}. ` + + `Style: ${style}. ` + + `Feature these real stops as small illustrated vignettes with tasteful hand-lettered labels: ${stops}. ` + + `Include a "Greetings from ${cityLabel}" banner. Cohesive, print-ready, no photorealism, no map UI.` + ); +} + +/* ============================================================ Ad Studio ==== */ + +/** + * Fetch an image URL (same-origin `/api/real-world-reasoning-agent/gmp/…` proxy path or an `https:` Google-hosted + * Places photo URI) and convert it to a base64 inlineData part. Returns null on + * any failure or disallowed scheme — callers degrade to a text-only image call. + */ +export interface InlineImageData { + mimeType: string; + data: string; +} + +async function fetchAsInlineData(url: string): Promise { + const isSameOrigin = url.startsWith('/'); + const isHttps = url.startsWith('https:'); + if (!isSameOrigin && !isHttps) return null; + const ctrl = new AbortController(); + const timer = setTimeout(() => ctrl.abort(), CONDITIONING_IMAGE_FETCH_TIMEOUT_MS); + try { + const res = await fetch(url, { signal: ctrl.signal }); + if (!res.ok) return null; + const blob = await res.blob(); + const buf = await blob.arrayBuffer(); + const bytes = new Uint8Array(buf); + let binary = ''; + const CHUNK = 0x8000; + for (let i = 0; i < bytes.length; i += CHUNK) { + binary += String.fromCharCode(...bytes.subarray(i, i + CHUNK)); + } + const data = btoa(binary); + const mimeType = blob.type || 'image/jpeg'; + return { mimeType, data }; + } catch { + return null; + } finally { + clearTimeout(timer); + } +} + +function withTimeout(promise: Promise, timeoutMs: number): Promise { + let timer: ReturnType | undefined; + const timeout = new Promise((resolve) => { + timer = setTimeout(() => resolve(null), timeoutMs); + }); + return Promise.race([promise, timeout]).finally(() => { + if (timer) clearTimeout(timer); + }); +} + +/** + * Result from an ad creative image generation attempt. `usedConditioning` + * tells the UI whether the real storefront/location image made it into the + * successful model request or the call fell back to text-only. + */ +export interface AdImageResult { + dataUrl: string; + usedConditioning: boolean; +} + +/** + * Cache of in-flight/resolved conditioning-image fetches, keyed by URL, so the + * (slow, up-to-8s) fetch can be warmed once during fact-gathering and reused by + * generation without a second network round-trip. Failed fetches are evicted so + * a later retry can refetch. + */ +const conditioningCache = new Map>(); + +/** Warm the conditioning-image cache for a URL (idempotent). Safe to fire-and-forget. */ +export function prefetchAdConditioningImage(url: string): Promise { + let cached = conditioningCache.get(url); + if (!cached) { + cached = fetchAsInlineData(url).then((res) => { + if (!res) conditioningCache.delete(url); // don't cache failures — allow a retry to refetch + return res; + }); + conditioningCache.set(url, cached); + } + return cached; +} + +/** Get the conditioning image for a URL, reusing a prefetched result when available. */ +export function prepareAdConditioningImage(url: string): Promise { + return prefetchAdConditioningImage(url); +} + +/** + * Generate an ad creative image, optionally conditioned on a real photo of the + * business (Street View frame or a Places photo). On ANY failure — fetching the + * conditioning image, or the conditioned generateContent call itself — retries + * once text-only so a campaign never dead-ends on a flaky image fetch/model call. + * `onFallback` fires when the conditioned attempt fails and the text-only retry begins. + */ +export async function generateAdImage( + prompt: string, + conditioningImage?: string | InlineImageData | null, + onFallback?: () => void, + onError?: (kind: AiFailureKind) => void, +): Promise { + const inline = + typeof conditioningImage === 'string' + ? await fetchAsInlineData(conditioningImage) + : conditioningImage ?? null; + + const attempt = async (withImage: boolean): Promise => { + const contents = + withImage && inline ? [{ inlineData: inline }, { text: prompt }] : prompt; + const timeoutMs = withImage ? CONDITIONED_IMAGE_TIMEOUT_MS : TEXT_ONLY_IMAGE_TIMEOUT_MS; + const call = () => + withTimeout( + genai().models.generateContent({ + model: MODELS.image, + contents, + config: { responseModalities: ['IMAGE', 'TEXT'] }, + }), + timeoutMs, + ); + try { + let resp; + try { + resp = await call(); + } catch (err) { + // One retry for a transient upstream 503; 429s are not retried (see AiFailureKind). + if (classifyAiFailure(err) !== 'unavailable') throw err; + await delay(UNAVAILABLE_RETRY_DELAY_MS); + resp = await call(); + } + if (!resp) return null; + const part = resp.candidates?.[0]?.content?.parts?.find((p) => p.inlineData?.data); + const data = part?.inlineData?.data; + if (!data) return null; + const mime = part?.inlineData?.mimeType ?? 'image/png'; + return { dataUrl: `data:${mime};base64,${data}`, usedConditioning: withImage && !!inline }; + } catch (err) { + // Surface the real reason (rate limit, model unavailable, quota, key/region) + // so a wall of failed ad creatives is diagnosable instead of opaque, and + // report the classified kind so the batch toast can be accurate. + console.error(`[generateAdImage] ${withImage ? 'conditioned' : 'text-only'} attempt failed:`, err); + onError?.(classifyAiFailure(err)); + return null; + } + }; + + if (inline) { + const withImage = await attempt(true); + if (withImage) return withImage; + onFallback?.(); // surface the text-only retry to the UI + return attempt(false); // text-only retry + } + return attempt(false); +} + +/** + * Downscale a data URL so its base64 payload stays well under the /ai proxy's + * body cap. No-op (returns input unchanged) outside a DOM environment (SSR/tests) + * or on any canvas failure. + */ +export async function shrinkImage(dataUrl: string, maxDim = 1024): Promise { + if (typeof document === 'undefined') return dataUrl; + try { + const img = new Image(); + const loaded = new Promise((resolve, reject) => { + img.onload = () => resolve(); + img.onerror = () => reject(new Error('image failed to load')); + }); + img.src = dataUrl; + await loaded; + const { naturalWidth: width, naturalHeight: height } = img; + if (!width || !height || (width <= maxDim && height <= maxDim)) return dataUrl; + const scale = maxDim / Math.max(width, height); + const w = Math.max(1, Math.round(width * scale)); + const h = Math.max(1, Math.round(height * scale)); + const canvas = document.createElement('canvas'); + canvas.width = w; + canvas.height = h; + const ctx = canvas.getContext('2d'); + if (!ctx) return dataUrl; + ctx.drawImage(img, 0, 0, w, h); + return canvas.toDataURL('image/jpeg', 0.85); + } catch { + return dataUrl; + } +} + +export type AdFormat = 'square' | 'story' | 'banner'; + +const AD_FORMAT_HINTS: Record = { + square: 'square 1:1 aspect ratio, ideal for a social feed placement', + story: 'vertical 9:16 aspect ratio, full-bleed story/reels format', + banner: 'wide ~1.91:1 banner aspect ratio, ideal for a display placement', +}; + +export interface AdCopyHints { + headline?: string; + body?: string; + cta?: string; +} + +export interface AdFactsForPrompt { + env?: EnvironmentSnapshot; + vibe?: string; +} + +/** + * Pure ad-image prompt builder. Only weaves in facts that were actually passed + * in (grounded); never invents ratings, awards, distances, or foot-traffic + * numbers. Ratings below 4.2 are deliberately omitted (not a strong enough + * signal to lead an ad with). + */ +export function adPrompt( + business: PlaceLite, + facts: AdFactsForPrompt, + style: string, + format: AdFormat, + copy?: AdCopyHints, +): string { + const lines: string[] = []; + lines.push(`Create a single advertising creative image for "${business.name}".`); + lines.push( + 'Use the attached photo of the actual storefront/location as visual reference for the building, ' + + 'signage and setting — keep it recognizably the same real place.', + ); + + const neighborhood = business.formattedAddress?.split(',')[1]?.trim() || business.formattedAddress; + if (neighborhood) lines.push(`It is located in ${neighborhood}.`); + + if (business.rating != null && business.rating >= 4.2) { + lines.push(`It is a well-loved, highly rated spot (${business.rating.toFixed(1)} stars).`); + } + + if (facts.env?.weather?.tempC != null) { + const cond = facts.env.weather.condition ? ` ${facts.env.weather.condition.toLowerCase()}` : ''; + lines.push(`Set the mood for a${cond} ${Math.round(facts.env.weather.tempC)}°C day.`); + } + + if (facts.vibe) lines.push(`Vibe notes grounded in real local context: ${facts.vibe}`); + + lines.push(`Art direction / style: ${style}.`); + lines.push(`Format: ${AD_FORMAT_HINTS[format]}.`); + lines.push( + 'Clean advertising layout with clear negative space reserved for a headline and call-to-action overlay.', + ); + if (copy?.headline) lines.push(`Leave room to overlay this headline text: "${copy.headline}".`); + + lines.push("No fake awards, no invented claims or statistics, no other brands' logos or trademarks."); + return lines.join(' '); +} diff --git a/demos/real-world-reasoning-agent/src/ai/prompts.test.ts b/demos/real-world-reasoning-agent/src/ai/prompts.test.ts new file mode 100644 index 0000000..0a919aa --- /dev/null +++ b/demos/real-world-reasoning-agent/src/ai/prompts.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, it } from 'vitest'; +import { BASE_PERSONA } from './prompts'; + +describe('BASE_PERSONA tool ordering', () => { + it('matches the engine sequential emission-order invariant', () => { + expect(BASE_PERSONA).toContain('sequentially in emission order'); + expect(BASE_PERSONA).not.toContain('run them in parallel'); + }); +}); + +describe('BASE_PERSONA WHY rule', () => { + it('requires a one-line grounded reason citing a specific tool result', () => { + expect(BASE_PERSONA).toMatch(/one-line grounded reason citing a specific tool result/i); + expect(BASE_PERSONA).toMatch(/because/i); + expect(BASE_PERSONA).toMatch(/state the why, not just the what/i); + }); + + it('keeps causality over decoration and drops the "avoid flowery" phrasing', () => { + expect(BASE_PERSONA).toMatch(/short and causal, not decorative/i); + expect(BASE_PERSONA).not.toMatch(/avoid flowery/i); + }); +}); + diff --git a/demos/real-world-reasoning-agent/src/ai/prompts.ts b/demos/real-world-reasoning-agent/src/ai/prompts.ts new file mode 100644 index 0000000..596eacd --- /dev/null +++ b/demos/real-world-reasoning-agent/src/ai/prompts.ts @@ -0,0 +1,34 @@ +import { GENUI_GUIDE } from '@/genui/promptGuide'; + +/** Base persona shared by every journey. Journey system prompts are appended. */ +export const BASE_PERSONA = `You are Atlas, an AI copilot that operates a live Google Map for the user. + +You do not just talk — you ACT by calling tools. When the user asks for anything +spatial (a place, a route, a neighborhood, a city, environmental conditions), call +the appropriate tool(s) rather than answering from memory. + +Hard rules: +- Only state place facts (names, ratings, addresses, hours, coordinates, ETAs) that + come back from a tool call in THIS conversation. Never invent or recall place data. +- Prefer to SHOW: move the camera with fly_to, drop markers, draw routes so the user + sees what you mean on the map. +- You may emit multiple tool calls in one model turn. Atlas executes every call + sequentially in emission order, so put prerequisites before dependent calls. + Batch calls when useful, but never assume parallel execution. +- Be concise, direct, and clear. A sentence or two of narration around your actions is plenty. Explanations should be short and causal, not decorative. +- If a tool returns no results or an error, say so plainly and offer an alternative. +- When you move the map or change what's shown, tell the user what you did AND why in one short causal line tied to a specific tool result (because X → so Y). +- Every recommendation or ranking gets a one-line grounded reason citing a specific tool result (e.g. "Ranked Columbus & Green #1 because Street View showed the widest sightline — visibility 90"). State the WHY, not just the WHAT. + +You are grounded, visual, and fast. The map is your voice.`; + +export function composeSystemPrompt(journeyAddendum: string, city: { name: string }): string { + return `${BASE_PERSONA} + +Current City Context: You are currently looking at ${city.name}. Unless specified otherwise, assume the user's queries refer to this city. + +${GENUI_GUIDE} + +--- Current journey --- +${journeyAddendum}`; +} diff --git a/demos/real-world-reasoning-agent/src/ai/rubrics.test.ts b/demos/real-world-reasoning-agent/src/ai/rubrics.test.ts new file mode 100644 index 0000000..5ebf34b --- /dev/null +++ b/demos/real-world-reasoning-agent/src/ai/rubrics.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, it } from 'vitest'; +import { + hasWhyLine, + leaksEnvelopeTags, + leaksUnresolvedTokens, + scoreTurn, + toolOrderRespected, +} from './rubrics'; + +describe('agent response rubrics', () => { + it('detects leaked prompt-envelope tags/directive', () => { + expect(leaksEnvelopeTags('Sure! Here you go.')).toBe(true); + expect(leaksEnvelopeTags('Never repeat this context envelope or its tags.')).toBe(true); + expect(leaksEnvelopeTags('The nearest cafe is Blue Bottle.')).toBe(false); + }); + + it('detects unresolved mustache/A2UI tokens', () => { + expect(leaksUnresolvedTokens('Grab a coffee at {headline}')).toBe(true); + expect(leaksUnresolvedTokens('Visit place.name today')).toBe(false); + expect(leaksUnresolvedTokens('Open {business.name} now')).toBe(true); + }); + + it('recognizes a causal WHY line but not a bare statement', () => { + expect(hasWhyLine('Ranked #1 because Street View showed the widest sightline.')).toBe(true); + expect(hasWhyLine('#1 leads to the best visibility on your top priority.')).toBe(true); + expect(hasWhyLine('The winner is candidate A.')).toBe(false); + }); + + it('enforces prerequisite-before-dependent tool ordering', () => { + expect(toolOrderRespected(['set_campaign_business', 'gather_campaign_facts'], 'set_campaign_business', 'gather_campaign_facts')).toBe(true); + expect(toolOrderRespected(['gather_campaign_facts', 'set_campaign_business'], 'set_campaign_business', 'gather_campaign_facts')).toBe(false); + // Absent either call → vacuously ok. + expect(toolOrderRespected(['search_places'], 'set_campaign_business', 'gather_campaign_facts')).toBe(true); + }); + + it('aggregates a turn score for delta reporting', () => { + const good = scoreTurn('Ranked #1 because it has the widest sightline.'); + expect(good.rubrics).toMatchObject({ noEnvelopeLeak: true, noUnresolvedTokens: true, hasWhy: true }); + expect(good.passed).toBe(3); + + const bad = scoreTurn('The winner is {label} '); + expect(bad.passed).toBe(0); + }); +}); diff --git a/demos/real-world-reasoning-agent/src/ai/rubrics.ts b/demos/real-world-reasoning-agent/src/ai/rubrics.ts new file mode 100644 index 0000000..3d1ebc3 --- /dev/null +++ b/demos/real-world-reasoning-agent/src/ai/rubrics.ts @@ -0,0 +1,71 @@ +/** + * Agent-response quality rubrics — deterministic, countable checks used by the + * eval flywheel (see ~/.claude/CLAUDE.md "Agent Quality Flywheel") to score agent + * turns as a before/after DELTA rather than by eyeballing a few prompts. + * + * Each rubric is a pure boolean/number over a turn's visible text or emitted tool + * order, so it is stable, cheap, and countable. Keep the optimizer (the prompt or + * code being tuned) SEPARATE from these graders — a self-grader learns to game the + * metric. + */ + +/** Internal prompt-envelope tags/directive that must NEVER reach the user. */ +const ENVELOPE_TAG = /<\/?user_request>|Never repeat this context envelope/i; + +/** True when the answer leaked Atlas's private context-envelope tags/directive. */ +export function leaksEnvelopeTags(text: string): boolean { + return ENVELOPE_TAG.test(text); +} + +/** + * True when an unresolved mustache/A2UI token (e.g. `{headline}`) leaked onto the + * surface — the documented AdCreative regression class (see AGENTS.md/LEARNINGS). + */ +export function leaksUnresolvedTokens(text: string): boolean { + return /\{[a-zA-Z][\w.]*\}/.test(text); +} + +/** + * True when the text carries an explicit causal justification — a "why", not just + * a "what". This is the core WHY rubric the Phase-3 prompt changes target. + */ +export function hasWhyLine(text: string): boolean { + return /\b(because|since|due to|driven by|which means|that'?s why|leads? to|so that|in order to)\b/i.test(text); +} + +/** + * True when `prereq` is emitted before `dependent` in the model's call order (or + * when either is absent). Encodes the AGENTS.md invariant that batched turns must + * order a prerequisite before its dependent (e.g. set_campaign_business before + * gather_campaign_facts). + */ +export function toolOrderRespected(calls: string[], prereq: string, dependent: string): boolean { + const p = calls.indexOf(prereq); + const d = calls.indexOf(dependent); + if (p === -1 || d === -1) return true; + return p < d; +} + +export interface TurnScore { + /** Rubric → passed? (true = good). */ + rubrics: Record; + /** Count of passed rubrics. */ + passed: number; + /** Total rubrics scored. */ + total: number; +} + +/** + * Score one agent turn's visible answer against the response-quality rubrics. + * Returns per-rubric booleans plus a pass count so a run can report a DELTA + * ("WHY-line 4/10 → 9/10") instead of an opaque single number. + */ +export function scoreTurn(text: string): TurnScore { + const rubrics: Record = { + noEnvelopeLeak: !leaksEnvelopeTags(text), + noUnresolvedTokens: !leaksUnresolvedTokens(text), + hasWhy: hasWhyLine(text), + }; + const passed = Object.values(rubrics).filter(Boolean).length; + return { rubrics, passed, total: Object.keys(rubrics).length }; +} diff --git a/demos/real-world-reasoning-agent/src/ai/session.ts b/demos/real-world-reasoning-agent/src/ai/session.ts new file mode 100644 index 0000000..cdcf75c --- /dev/null +++ b/demos/real-world-reasoning-agent/src/ai/session.ts @@ -0,0 +1,121 @@ +import { CopilotEngine } from './engine'; +import { SCENARIOS } from '@/scenarios/registry'; +import { atlas, useAtlas } from '@/state/store'; +import type { ScenarioId } from '@/lib/types'; +import { subscribeGeminiCredential } from './client'; + +// ONE engine for the session. The active recipe selects the system prompt and +// the capability profile (tool set); changing recipes re-arms the same agent +// rather than swapping to a parallel one with its own history. +let engine: CopilotEngine | null = null; +let engineRecipe: ScenarioId | null = null; + +function discardEngine(): void { + engine?.abort(); + engine = null; + engineRecipe = null; +} + +function currentEngine(): CopilotEngine { + const s = atlas(); + const recipe = s.activeScenario; + if (!engine || engineRecipe !== recipe) { + // Re-arming for a different recipe: the SDK chat is rebuilt with that + // recipe's prompt and tools. + engine?.abort(); + const mod = SCENARIOS[recipe]; + engine = new CopilotEngine(recipe, mod.systemPrompt, mod.tools, s.cityId, s.chatModel, s.chatThinking); + engineRecipe = recipe; + } + return engine; +} + +// A city change invalidates grounded context: drop the engine, the session, and +// the map decorations together. +useAtlas.subscribe((state, prev) => { + if (state.cityId !== prev.cityId) { + discardEngine(); + state.clearChat(); + state.clearMap(); + } +}); + +// A personal-key connect/disconnect recreates the SDK client. Discard the cached +// chat at the same boundary so no request continues with stale auth. +subscribeGeminiCredential(discardEngine); + +// Rebuild the engine when the admin panel changes the chat model / thinking level, +// so the next turn uses the new config. Transcript + map are kept (it's a live +// A/B), but the rebuilt engine starts a fresh SDK chat. +useAtlas.subscribe((state, prev) => { + if (state.chatModel !== prev.chatModel || state.chatThinking !== prev.chatThinking) { + discardEngine(); + } +}); + +/** Send text to the session's copilot. */ +export function sendToCopilot(text: string): void { + void currentEngine().send(text); +} + +/** Send application-owned orchestration without presenting it as a human chat message. */ +export function sendInternalToCopilot(text: string): void { + void currentEngine().send(text, { showUserMessage: false }); +} + +// Replay links carry a prompt in the URL (?prompt=). Run it exactly once after +// the app mounts so a shared run reasons live for the recipient. The module-level +// latch survives React strict-mode's double-invoke of mount effects. +let replayFired = false; +export function runPendingReplayPrompt(): void { + if (replayFired) return; + const s = atlas(); + const prompt = s.pendingPrompt; + if (!prompt || !s.landingDismissed) return; + replayFired = true; + s.clearPendingPrompt(); + sendToCopilot(prompt); +} + +/** + * Stop the session's in-flight query: records the prompt as resumable and + * silences speech. No-op when nothing is armed or running. + */ +export function abortCopilot(): void { + engine?.abort(); +} + +/** Re-arm the agent with a clean SDK context, keeping the visible session. */ +export function resetCopilot(): void { + discardEngine(); +} + +/** + * Switch the agent to a different recipe as a deliberate user act. + * + * A recipe change re-briefs the agent with a new prompt and a new capability + * profile, so prior turns are NOT in the rebuilt SDK context. Leaving the old + * transcript on screen therefore showed a conversation the agent could no longer + * remember, and — because the canvas only offers starters when it has nothing to + * show — it also hid the new recipe's suggestions behind the previous journey's + * answer. Clearing here keeps what is on screen equal to what the agent knows. + * + * Deliberately NOT wired into `setScenario`: the mission handoffs + * (`continueMissionInAdStudio`, `revealMissionIn3D`) cross recipes mid-narrative + * and must keep their history — the reveal is the payoff of the transcript above + * it. Only an explicit recipe pick resets. + */ +export function switchRecipe(id: ScenarioId): void { + const s = atlas(); + if (s.activeScenario === id) return; + // Abort before clearing: abort() records the interrupted prompt as resumable, + // and that resumable belongs to the recipe being left behind. + discardEngine(); + s.clearChat(); + s.setScenario(id); +} + +/** Re-run the session's interrupted prompt from scratch. */ +export function resumeCopilot(): void { + engine?.resume(); +} diff --git a/demos/real-world-reasoning-agent/src/ai/stt.test.ts b/demos/real-world-reasoning-agent/src/ai/stt.test.ts new file mode 100644 index 0000000..84bd8d5 --- /dev/null +++ b/demos/real-world-reasoning-agent/src/ai/stt.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, it } from 'vitest'; +import { isVoiceInputSupported, normalizeAudioMime } from './stt'; + +describe('normalizeAudioMime', () => { + it('strips codec parameters to the bare Gemini mime type', () => { + expect(normalizeAudioMime('audio/webm;codecs=opus')).toBe('audio/webm'); + expect(normalizeAudioMime('audio/ogg; codecs=opus')).toBe('audio/ogg'); + }); + + it('passes through a bare mime type unchanged', () => { + expect(normalizeAudioMime('audio/mp4')).toBe('audio/mp4'); + }); + + it('falls back to audio/webm for an empty or missing type', () => { + expect(normalizeAudioMime('')).toBe('audio/webm'); + expect(normalizeAudioMime(undefined as unknown as string)).toBe('audio/webm'); + }); +}); + +describe('isVoiceInputSupported', () => { + it('returns a boolean without throwing in a DOM-less/limited environment', () => { + expect(typeof isVoiceInputSupported()).toBe('boolean'); + }); +}); diff --git a/demos/real-world-reasoning-agent/src/ai/stt.ts b/demos/real-world-reasoning-agent/src/ai/stt.ts new file mode 100644 index 0000000..03ecbc8 --- /dev/null +++ b/demos/real-world-reasoning-agent/src/ai/stt.ts @@ -0,0 +1,198 @@ +/** + * Voice input: capture microphone audio in the browser and transcribe it with a + * low-latency Gemini model. No UI coupling — the CopilotDock drives it through + * `useVoiceInput`. Transcription runs with MINIMAL thinking (see config) because + * the model is transcribing, not reasoning, so any thinking is pure added latency. + * + * Compliance/privacy: audio is captured only after the user taps the mic, sent + * once (inline, base64) through the same-origin /ai proxy, and the microphone + * track is stopped the moment recording ends — nothing is retained client-side. + */ +import { genai } from './client'; +import { MINIMAL_THINKING_CONFIG, MODELS } from '@/lib/config'; + +/** MediaRecorder container/codecs to try, best first. Opus in WebM/OGG is the + * most broadly supported low-bitrate option; mp4/aac covers Safari. We fall back + * to the browser default when none report as supported. */ +const PREFERRED_MIME_TYPES = [ + 'audio/webm;codecs=opus', + 'audio/webm', + 'audio/ogg;codecs=opus', + 'audio/mp4', +]; + +const TRANSCRIBE_INSTRUCTION = + "Transcribe the spoken words in this audio to plain text. Return only the transcript, " + + 'with no preamble, quotation marks, or commentary. If there is no discernible speech, return nothing.'; + +/** True when the browser can record the mic and we can construct a recorder. */ +export function isVoiceInputSupported(): boolean { + return ( + typeof window !== 'undefined' && + typeof navigator !== 'undefined' && + !!navigator.mediaDevices?.getUserMedia && + typeof window.MediaRecorder !== 'undefined' + ); +} + +/** First preferred MediaRecorder mime type the browser reports it can produce. */ +export function pickMimeType(): string | undefined { + if (typeof MediaRecorder === 'undefined' || typeof MediaRecorder.isTypeSupported !== 'function') { + return undefined; + } + return PREFERRED_MIME_TYPES.find((t) => MediaRecorder.isTypeSupported(t)); +} + +/** Gemini wants the bare audio mime type without codec params (e.g. "audio/webm"). */ +export function normalizeAudioMime(blobType: string): string { + const base = (blobType || '').split(';')[0].trim(); + return base || 'audio/webm'; +} + +/** An in-flight microphone recording. `stop` resolves the captured audio; `cancel` + * tears down the mic and discards it. Either one releases the media track. */ +export interface VoiceRecording { + stop(): Promise; + cancel(): void; +} + +export interface StartRecordingOptions { + /** Fires once when the speaker goes quiet after having spoken (end-of-utterance), + * so the caller can auto-stop dictation. No-op if Web Audio is unavailable. */ + onSilence?: () => void; +} + +/** Prompt for mic access and begin recording. Rejects if permission is denied. */ +export async function startRecording(opts: StartRecordingOptions = {}): Promise { + const stream = await navigator.mediaDevices.getUserMedia({ audio: true }); + const mimeType = pickMimeType(); + const recorder = new MediaRecorder(stream, mimeType ? { mimeType } : undefined); + const chunks: Blob[] = []; + recorder.ondataavailable = (e) => { + if (e.data.size) chunks.push(e.data); + }; + recorder.start(); + + const stopSilenceWatch = opts.onSilence ? watchForSilence(stream, opts.onSilence) : undefined; + const release = () => { + stopSilenceWatch?.(); + stream.getTracks().forEach((t) => t.stop()); + }; + + return { + stop: () => + new Promise((resolve) => { + recorder.onstop = () => { + release(); + resolve(new Blob(chunks, { type: recorder.mimeType || mimeType || 'audio/webm' })); + }; + recorder.stop(); + }), + cancel: () => { + try { + recorder.stop(); + } catch { + /* already stopped */ + } + release(); + }, + }; +} + +/** RMS above this counts as speech; sustained below it (after speech) is silence. */ +const SPEECH_RMS = 0.02; +const SILENCE_RMS = 0.012; +/** How long the mic must stay quiet after speech before we call it end-of-utterance. */ +const SILENCE_HANG_MS = 1200; + +/** + * Watch a live mic stream and fire `onSilence` once the speaker finishes talking + * (a short quiet gap after any detected speech). Uses a Web Audio analyser on the + * time-domain signal; returns a teardown fn. Degrades to a no-op when AudioContext + * is unavailable, so tap-to-stop still works. + */ +function watchForSilence(stream: MediaStream, onSilence: () => void): () => void { + const Ctx = window.AudioContext ?? (window as unknown as { webkitAudioContext?: typeof AudioContext }).webkitAudioContext; + if (!Ctx) return () => {}; + let ctx: AudioContext; + try { + ctx = new Ctx(); + } catch { + return () => {}; + } + const source = ctx.createMediaStreamSource(stream); + const analyser = ctx.createAnalyser(); + analyser.fftSize = 2048; + source.connect(analyser); + const buf = new Float32Array(analyser.fftSize); + + let speechStarted = false; + let silentSince = 0; + let fired = false; + let raf = 0; + + const tick = () => { + analyser.getFloatTimeDomainData(buf); + let sum = 0; + for (let i = 0; i < buf.length; i++) sum += buf[i] * buf[i]; + const rms = Math.sqrt(sum / buf.length); + const now = performance.now(); + + if (rms >= SPEECH_RMS) { + speechStarted = true; + silentSince = 0; + } else if (speechStarted && rms < SILENCE_RMS) { + if (!silentSince) { + silentSince = now; + } else if (now - silentSince >= SILENCE_HANG_MS && !fired) { + fired = true; + onSilence(); + return; // stop polling; caller tears down via release() + } + } + raf = requestAnimationFrame(tick); + }; + raf = requestAnimationFrame(tick); + + return () => { + cancelAnimationFrame(raf); + try { + source.disconnect(); + } catch { + /* already disconnected */ + } + void ctx.close(); + }; +} + +function blobToBase64(blob: Blob): Promise { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onloadend = () => { + const result = reader.result as string; // "data:;base64," + const comma = result.indexOf(','); + resolve(comma >= 0 ? result.slice(comma + 1) : result); + }; + reader.onerror = () => reject(reader.error ?? new Error('FileReader failed')); + reader.readAsDataURL(blob); + }); +} + +/** + * Transcribe a captured audio blob to text via the low-latency STT model. Returns + * the trimmed transcript (possibly empty when no speech was detected). Throws only + * on network/model failure so the caller can surface a toast. + */ +export async function transcribe(blob: Blob): Promise { + if (!blob.size) return ''; + const data = await blobToBase64(blob); + const resp = await genai().models.generateContent({ + model: MODELS.stt, + contents: [ + { inlineData: { mimeType: normalizeAudioMime(blob.type), data } }, + { text: TRANSCRIBE_INSTRUCTION }, + ], + config: { thinkingConfig: MINIMAL_THINKING_CONFIG }, + }); + return (resp.text ?? '').trim(); +} diff --git a/demos/real-world-reasoning-agent/src/ai/toolLabels.ts b/demos/real-world-reasoning-agent/src/ai/toolLabels.ts new file mode 100644 index 0000000..ecacea7 --- /dev/null +++ b/demos/real-world-reasoning-agent/src/ai/toolLabels.ts @@ -0,0 +1,48 @@ +/** + * TOOL_RATIONALE — a short purpose string for each agent tool, phrased as the + * WHY behind the action. Where the gerund labels (src/shell/toolLabels.ts) say + * WHAT the agent is doing, these say why it matters, so the live work panel can + * surface the agent's reasoning during a run instead of a static platitude. + * + * Unknown tools have no rationale (rationaleForTool returns undefined); callers + * must render gracefully when absent. + */ +export const TOOL_RATIONALE: Record = { + // Common + search_places: 'Finding real, tool-sourced places instead of guessing', + get_place_details: 'Pulling live details so every fact is verifiable', + get_environment: 'Reading live air, weather and pollen for this spot', + ask_maps: 'Grounding the answer in Google Maps data', + draw_route: 'Showing the real route so the plan is legible', + + // Insight + analyze_location: 'Reading the block from live neighborhood signals', + compare_with: 'Weighing two places on the same evidence', + + // Fleet + get_fleet_state: 'Reading the live fleet before committing anything', + eta_matrix: 'Comparing live ETAs to find the fastest van', + assign_job: 'Committing the best-justified assignment', + + // Concierge + propose_itinerary: 'Ordering real, open places into a walkable day', + + // Ad Studio + gather_campaign_facts: 'Grounding the campaign in verifiable truth', + generate_ad_creatives: 'Conditioning creatives on the real storefront', + set_geo_targeting: 'Sizing reach by real walk/drive time', + + // Cinema + narrate_stop: 'Telling the story from grounded facts only', + + // Scout + scout_area: 'Fanning out for real candidate sites', + inspect_candidate: 'Grounding each site in real Street View + aerial imagery', + score_candidates: 'Weighing evidence against your priorities', + compare_sites: 'Defending the winner with a side-by-side matrix', +}; + +/** Short purpose string for a tool, or undefined when none is defined. */ +export function rationaleForTool(name: string): string | undefined { + return TOOL_RATIONALE[name]; +} diff --git a/demos/real-world-reasoning-agent/src/ai/tools/common.ts b/demos/real-world-reasoning-agent/src/ai/tools/common.ts new file mode 100644 index 0000000..f23389d --- /dev/null +++ b/demos/real-world-reasoning-agent/src/ai/tools/common.ts @@ -0,0 +1,79 @@ +/** + * The common tool set handed to a recipe's copilot. + * + * Everything that touches places, routes, conditions or the map is a capability + * (`src/capabilities/world.ts`, `src/capabilities/presentation.ts`) surfaced + * through `capabilityTool`, so execution returns typed effects and the host + * applies them. These handlers used to reach into the Zustand store directly — + * `search_places` dropped markers and moved the camera as an invisible side + * effect — which made map behaviour unreplayable and untestable without a live + * store. + * + * `ask_maps` is the one remaining hand-written tool: it appends a grounded Maps + * widget to the transcript, a chat effect with no typed equivalent yet. + */ +import type { FunctionDeclaration } from '@google/genai'; +import type { ToolDefinition } from '@/lib/types'; +import { atlas } from '@/state/store'; +import { uid } from '@/lib/id'; +import { askMaps } from '@/ai/grounding'; +import { RENDER_SURFACE_TOOL } from '@/genui/tool'; +import { PRESENTATION_TOOLS_BY_ID, WORLD_TOOLS_BY_ID } from '@/capabilities/atlasAdapter'; + +const num = (v: unknown, d = 0) => (typeof v === 'number' ? v : Number(v) || d); +const str = (v: unknown, d = '') => (typeof v === 'string' ? v : d); + +/** Resolve a capability by the tool name the model sees. */ +export function capabilityTool(name: string): ToolDefinition { + const tool = PRESENTATION_TOOLS_BY_ID.get(name) ?? WORLD_TOOLS_BY_ID.get(name); + if (!tool) throw new Error(`Missing capability: ${name}`); + return tool; +} + +/* -------------------------------------------------------------- ask_maps */ +const askMapsTool: ToolDefinition = { + declaration: { + name: 'ask_maps', + description: + 'Answer an open question about places using Google Maps grounding (reviews, what an area is known for). Renders a Google Maps widget in the chat.', + parametersJsonSchema: { + type: 'object', + properties: { + question: { type: 'string' }, + lat: { type: 'number' }, + lng: { type: 'number' }, + }, + required: ['question'], + }, + }, + handler: async (a) => { + const at = + a.lat != null && a.lng != null ? { lat: num(a.lat), lng: num(a.lng) } : undefined; + const ans = await askMaps(str(a.question), at); + if (ans.widgetContextToken) { + atlas().addMsg({ + id: uid('w'), + role: 'widget', + widgetContextToken: ans.widgetContextToken, + ts: Date.now(), + }); + } + return { answer: ans.text, grounded: !!ans.widgetContextToken }; + }, +}; + +export const COMMON_TOOLS: ToolDefinition[] = [ + capabilityTool('fly_to'), + capabilityTool('search_places'), + capabilityTool('get_place_details'), + capabilityTool('focus_place'), + capabilityTool('add_markers'), + capabilityTool('clear_map'), + capabilityTool('draw_route'), + capabilityTool('get_environment'), + askMapsTool, + capabilityTool('show_notice'), + RENDER_SURFACE_TOOL, +]; + +export const COMMON_DECLARATIONS: FunctionDeclaration[] = COMMON_TOOLS.map((t) => t.declaration); diff --git a/demos/real-world-reasoning-agent/src/ai/traceEval.test.ts b/demos/real-world-reasoning-agent/src/ai/traceEval.test.ts new file mode 100644 index 0000000..7f56e23 --- /dev/null +++ b/demos/real-world-reasoning-agent/src/ai/traceEval.test.ts @@ -0,0 +1,68 @@ +import { describe, expect, it } from 'vitest'; +import dataset from '../../eval/traces.json'; +import { + evaluateTraces, + scoreTrace, + type AgentTrace, + type Dimension, +} from './traceEval'; + +const traces = (dataset as unknown as { traces: AgentTrace[] }).traces; +const byId = (id: string): AgentTrace => { + const t = traces.find((x) => x.id === id); + if (!t) throw new Error(`missing trace ${id}`); + return t; +}; + +/** Which single dimension each fail.* fixture is designed to violate. */ +const EXPECTED_FAILURE: Record = { + 'fail.toolorder.facts-before-business': 'toolOrderOk', + 'fail.grounding.invented-rating': 'grounded', + 'fail.ownership.double-owned-comparison': 'surfaceOwnershipOk', + 'fail.consistency.leaked-token': 'uiFinalConsistent', + 'fail.consistency.claims-absent-surface': 'uiFinalConsistent', + 'fail.mission.partial-no-artifact': 'missionCompleted', +}; + +describe('agent trace evaluator', () => { + it('has unique ids and a well-formed dataset', () => { + const ids = traces.map((t) => t.id); + expect(new Set(ids).size).toBe(ids.length); + for (const t of traces) { + expect(Array.isArray(t.toolCalls)).toBe(true); + expect(Array.isArray(t.surfaces)).toBe(true); + expect(typeof t.finalText).toBe('string'); + } + }); + + it('scores every golden trace perfectly across all five dimensions', () => { + for (const t of traces.filter((x) => x.id.startsWith('golden.'))) { + const score = scoreTrace(t); + expect(score.passed, `${t.id} dims=${JSON.stringify(score.dimensions)}`).toBe(score.total); + } + }); + + it('isolates behaviors: each fail.* trace fails exactly its target dimension', () => { + for (const [id, dim] of Object.entries(EXPECTED_FAILURE)) { + const score = scoreTrace(byId(id)); + expect(score.dimensions[dim], `${id} should FAIL ${dim}`).toBe(false); + const others = (Object.keys(score.dimensions) as Dimension[]).filter((d) => d !== dim); + for (const d of others) { + expect(score.dimensions[d], `${id} should PASS ${d}`).toBe(true); + } + } + }); + + it('aggregates a delta-friendly report', () => { + const report = evaluateTraces(traces); + expect(report.total).toBe(traces.length); + // 3 golden traces are perfect; every fail.* has exactly one failing dimension. + expect(report.perfect).toBe(traces.filter((t) => t.id.startsWith('golden.')).length); + expect(report.failures).toHaveLength(Object.keys(EXPECTED_FAILURE).length); + // Each dimension's pass count never exceeds the total. + for (const d of Object.keys(report.byDimension) as Dimension[]) { + expect(report.byDimension[d].total).toBe(traces.length); + expect(report.byDimension[d].passed).toBeLessThanOrEqual(traces.length); + } + }); +}); diff --git a/demos/real-world-reasoning-agent/src/ai/traceEval.ts b/demos/real-world-reasoning-agent/src/ai/traceEval.ts new file mode 100644 index 0000000..926af21 --- /dev/null +++ b/demos/real-world-reasoning-agent/src/ai/traceEval.ts @@ -0,0 +1,171 @@ +/** + * Independent agent-trace evaluator (reliability plan §4: "trace dataset and + * independent evaluation of mission completion, tool order, grounding, surface + * ownership, and UI/final-response consistency"). + * + * This is a GRADER, kept deliberately separate from the optimizer (the prompts + * and tool code it scores) — a self-grader learns to game the metric (see + * ~/.claude/CLAUDE.md "Agent Quality Flywheel"). Every dimension is a pure, + * countable boolean over a declared trace, so a run reports a DELTA per + * dimension ("grounding 6/10 -> 9/10") instead of one opaque number, and each + * dimension isolates ONE behavior so a regression is attributable. + * + * Traces are declared facts about a journey turn (tool order, final text, the + * surfaces rendered, grounding, mission outcome). Optimization fixtures stay + * generic and synthetic; live Maps integration canaries are not eval data. + */ +import { leaksEnvelopeTags, leaksUnresolvedTokens, toolOrderRespected } from './rubrics'; + +export type Journey = 'scout' | 'adstudio' | 'concierge' | 'insight' | 'fleet' | 'cinema'; + +/** One A2UI surface the turn rendered. `logical` groups instances that must share a single owner. */ +export interface TraceSurface { + /** Catalog component type, e.g. 'AdCreative', 'ComparisonTable', 'MapPreview'. */ + type: string; + /** Logical surface kind, e.g. 'comparison', 'creative', 'evidence'. */ + logical: string; + /** Which layer produced it: the shared A2UI catalog, or a scenario's bespoke surface. */ + owner: 'catalog' | 'scenario'; +} + +export interface AgentTrace { + id: string; + journey: Journey; + userGoal: string; + /** Tool names in the order the model emitted+executed them. */ + toolCalls: string[]; + /** The user-visible final answer text. */ + finalText: string; + surfaces: TraceSurface[]; + /** Surface `type`s the final answer claims to have produced (for UI/response consistency). */ + finalReferences: string[]; + grounding: { + /** Did the declared synthetic turn use its supplied grounded fixture data? */ + used: boolean; + /** Claims the answer made that are NOT backed by grounded data (fabrications). */ + invented: string[]; + /** Whether this journey requires grounding to be considered correct. */ + required: boolean; + }; + mission: { + expectedComplete: boolean; + status: 'none' | 'complete' | 'partial' | 'failed'; + artifacts: string[]; + }; +} + +/** + * Ordered prerequisite chains per journey: if these tools appear, they must + * appear in this relative order (mirrors the engine's sequential-execution + * invariant and the scenario contracts, e.g. "Call set_campaign_business first"). + */ +const PREREQ_CHAINS: Partial> = { + adstudio: ['set_campaign_business', 'gather_campaign_facts', 'generate_ad_creatives', 'export_campaign'], + scout: ['scout_area', 'score_candidates', 'show_evidence'], +}; + +/** True when every consecutive prereq→dependent pair present in the chain is correctly ordered. */ +export function toolOrderOk(trace: AgentTrace): boolean { + const chain = PREREQ_CHAINS[trace.journey]; + if (!chain) return true; + for (let i = 0; i < chain.length - 1; i++) { + if (!toolOrderRespected(trace.toolCalls, chain[i], chain[i + 1])) return false; + } + return true; +} + +/** True when the turn met (or wasn't expected to meet) mission completion with a real artifact. */ +export function missionCompleted(trace: AgentTrace): boolean { + if (!trace.mission.expectedComplete) return trace.mission.status !== 'failed'; + return trace.mission.status === 'complete' && trace.mission.artifacts.length > 0; +} + +/** True when required grounding was used and the answer invented no unbacked claims. */ +export function grounded(trace: AgentTrace): boolean { + if (trace.grounding.invented.length > 0) return false; + return trace.grounding.required ? trace.grounding.used : true; +} + +/** + * True when exactly ONE layer owns each logical surface kind — the generalized + * form of the deferred "one layer responsible for Scout comparison surfaces" + * decision. A `comparison` rendered by both the catalog and a bespoke scenario + * surface is a double-ownership regression. + */ +export function surfaceOwnershipOk(trace: AgentTrace): boolean { + const owners = new Map>(); + for (const s of trace.surfaces) { + const set = owners.get(s.logical) ?? new Set(); + set.add(s.owner); + owners.set(s.logical, set); + } + for (const set of owners.values()) if (set.size > 1) return false; + return true; +} + +/** + * True when the final answer is consistent with what was rendered: no leaked + * internal tokens/envelope tags, and it never claims a surface type it didn't + * actually produce (the "confident but stale final message" failure mode). + */ +export function uiFinalConsistent(trace: AgentTrace): boolean { + if (leaksUnresolvedTokens(trace.finalText) || leaksEnvelopeTags(trace.finalText)) return false; + const present = new Set(trace.surfaces.map((s) => s.type)); + return trace.finalReferences.every((ref) => present.has(ref)); +} + +export const DIMENSIONS = { + missionCompleted, + toolOrderOk, + grounded, + surfaceOwnershipOk, + uiFinalConsistent, +} as const; + +export type Dimension = keyof typeof DIMENSIONS; + +export interface TraceScore { + id: string; + dimensions: Record; + passed: number; + total: number; +} + +/** Score one trace across all five dimensions. */ +export function scoreTrace(trace: AgentTrace): TraceScore { + const dimensions = Object.fromEntries( + (Object.keys(DIMENSIONS) as Dimension[]).map((d) => [d, DIMENSIONS[d](trace)]), + ) as Record; + const passed = Object.values(dimensions).filter(Boolean).length; + return { id: trace.id, dimensions, passed, total: Object.keys(DIMENSIONS).length }; +} + +export interface TraceEvalReport { + total: number; + /** Traces that passed every dimension. */ + perfect: number; + /** Per-dimension pass counts, for delta reporting. */ + byDimension: Record; + /** Every (trace, dimension) failure, for failure analysis. */ + failures: { id: string; dimension: Dimension }[]; + scores: TraceScore[]; +} + +/** Evaluate a dataset of traces and return a delta-friendly report. */ +export function evaluateTraces(traces: AgentTrace[]): TraceEvalReport { + const scores = traces.map(scoreTrace); + const dims = Object.keys(DIMENSIONS) as Dimension[]; + const byDimension = Object.fromEntries( + dims.map((d) => [d, { passed: scores.filter((s) => s.dimensions[d]).length, total: scores.length }]), + ) as Record; + const failures = scores.flatMap((s) => + dims.filter((d) => !s.dimensions[d]).map((d) => ({ id: s.id, dimension: d })), + ); + return { + total: traces.length, + perfect: scores.filter((s) => s.passed === s.total).length, + byDimension, + failures, + scores, + }; +} diff --git a/demos/real-world-reasoning-agent/src/ai/tts.test.tsx b/demos/real-world-reasoning-agent/src/ai/tts.test.tsx new file mode 100644 index 0000000..a2e7f4c --- /dev/null +++ b/demos/real-world-reasoning-agent/src/ai/tts.test.tsx @@ -0,0 +1,242 @@ +/** + * The speech queue's job is to pipeline: synthesis for the next chunk must run + * while the current one is still audible, and playback must still be strictly + * ordered and gate-able. + * + * These tests exist because the queue originally chained synthesis AND playback + * together, so sentence 2's TTS request was not sent until sentence 1 had + * finished playing. Every sentence cost a full round-trip of silence and + * narration drifted far behind its own captions. A "does it eventually speak?" + * test passes right through that, so the pipelining is asserted directly here as + * an ordering property. + * + * `.tsx` deliberately: that is how this repo routes a test to the jsdom project + * (see vitest.config.ts). The queue needs a `window` to detect audio support, and + * the node project runs with `isolate: false`, where stubbing a global would leak + * into every other node test file. + */ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +/** Resolvable promise handle, so a test can control synthesis timing. */ +function deferred() { + let resolve!: () => void; + const promise = new Promise((r) => { + resolve = r; + }); + return { promise, resolve }; +} + +const events: string[] = []; +/** Gates keyed by the text being synthesized, so tests can release out of order. */ +const synthGates = new Map>(); +/** PCM sample value -> source text, so playback can be labelled with its chunk. */ +const codeToText = new Map(); +let nextCode = 0; + +/** + * How many streamed frames the fake model emits per utterance, and how much audio + * each carries. 0.25s per frame matches the module's segment target, so one frame + * becomes one schedulable segment — keeping the arithmetic in these tests obvious. + */ +const FRAMES_PER_UTTERANCE = 3; +const FRAME_SECONDS = 0.25; + +/** Per-utterance gates that hold the stream open *after* the Nth frame. */ +const frameGates = new Map>(); + +/** One 16-bit PCM frame whose every sample encodes `code`, so playback is labellable. */ +function frame(code: number): string { + const samples = Math.ceil(24000 * FRAME_SECONDS); + const bytes = Buffer.alloc(samples * 2); + for (let i = 0; i < samples; i++) bytes.writeInt16LE(code, i * 2); + return bytes.toString('base64'); +} + +const generateContentStream = vi.fn(async ({ contents }: { contents: string }) => { + events.push(`synth:start:${contents}`); + const code = ++nextCode; + codeToText.set(code, contents); + const gate = synthGates.get(contents); + const midGate = frameGates.get(contents); + return (async function* () { + if (gate) await gate.promise; + for (let i = 0; i < FRAMES_PER_UTTERANCE; i++) { + if (i === 1 && midGate) await midGate.promise; + events.push(`synth:frame:${contents}:${i}`); + yield { + candidates: [ + { content: { parts: [{ inlineData: { data: frame(code), mimeType: 'audio/L16;rate=24000' } }] } }, + ], + }; + } + events.push(`synth:done:${contents}`); + })(); +}); + +vi.mock('./client', () => ({ genai: () => ({ models: { generateContentStream } }) })); +vi.mock('@/lib/config', () => ({ MODELS: { tts: 'test-tts' } })); +vi.mock('@/state/store', () => ({ atlas: () => ({ activeScenario: 'cinema' }) })); + +/** + * Minimal WebAudio stub. `start()` decodes the chunk's marker sample back into a + * label and ends on the next microtask, so ordering is observable without timers. + */ +class FakeAudioContext { + state = 'running'; + destination = {}; + /** Real AudioContexts advance this; the scheduler does arithmetic on it. */ + currentTime = 0; + createBuffer(_channels: number, frames: number, sampleRate: number) { + const data = new Float32Array(frames); + return { duration: frames / sampleRate, getChannelData: () => data }; + } + createBufferSource() { + const node: Record = { + buffer: null, + connect: () => undefined, + stop: () => undefined, + onended: null, + start: () => { + const buffer = node.buffer as { getChannelData: () => Float32Array }; + const code = Math.round(buffer.getChannelData()[0] * 32768); + events.push(`play:${codeToText.get(code) ?? `unknown(${code})`}`); + void Promise.resolve().then(() => (node.onended as (() => void) | null)?.()); + }, + }; + return node; + } + resume() { + return Promise.resolve(); + } + suspend() { + return Promise.resolve(); + } +} + +/** Distinct utterances in the order their audio was first heard. */ +function playbackOrder(): string[] { + const seen: string[] = []; + for (const event of events) { + if (!event.startsWith('play:')) continue; + const label = event.slice('play:'.length); + if (seen[seen.length - 1] !== label) seen.push(label); + } + return seen; +} + +describe('speech queue', () => { + let tts: typeof import('./tts'); + + beforeEach(async () => { + events.length = 0; + synthGates.clear(); + codeToText.clear(); + nextCode = 0; + frameGates.clear(); + generateContentStream.mockClear(); + vi.stubGlobal('AudioContext', FakeAudioContext); + window.AudioContext = FakeAudioContext as unknown as typeof AudioContext; + vi.resetModules(); + tts = await import('./tts'); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it('synthesizes the next chunk while the current one is still playing', async () => { + // Hold BOTH syntheses open and enqueue both. If synthesis were chained behind + // playback, chunk two's request could not have been sent yet. + synthGates.set('one', deferred()); + synthGates.set('two', deferred()); + + const first = tts.enqueueSpeech('one'); + const second = tts.enqueueSpeech('two'); + await Promise.resolve(); + + expect(generateContentStream).toHaveBeenCalledTimes(2); + expect(events.filter((e) => e.startsWith('synth:start'))).toEqual(['synth:start:one', 'synth:start:two']); + + synthGates.get('one')!.resolve(); + synthGates.get('two')!.resolve(); + await first; + await second; + }); + + it('plays in enqueue order even when later synthesis finishes first', async () => { + synthGates.set('first', deferred()); + const a = tts.enqueueSpeech('first'); + const b = tts.enqueueSpeech('second'); + + // 'second' resolves synthesis immediately while 'first' is still pending, so + // an unordered queue would let it jump the line. + await Promise.resolve(); + expect(events.some((e) => e.startsWith('play:'))).toBe(false); + + synthGates.get('first')!.resolve(); + await a; + await b; + + expect(playbackOrder()).toEqual(['first', 'second']); + }); + + it('starts playing before synthesis has finished — the whole point of streaming', async () => { + // Hold the stream open after its first frame. Non-streaming synthesis could + // not have produced a single sample of audio at this point; streaming has. + frameGates.set('long narration', deferred()); + const done = tts.enqueueSpeech('long narration'); + + await vi.waitFor(() => expect(events).toContain('play:long narration')); + expect(events).not.toContain('synth:done:long narration'); + + frameGates.get('long narration')!.resolve(); + await done; + expect(events).toContain('synth:done:long narration'); + }); + + it('schedules streamed segments back-to-back on one gapless cursor', async () => { + await tts.enqueueSpeech('one'); + // Each 0.25s frame becomes its own segment, so a single utterance is played as + // several scheduled buffers rather than one — they must not overlap or gap. + const plays = events.filter((e) => e === 'play:one'); + expect(plays.length).toBe(FRAMES_PER_UTTERANCE); + }); + + it('holds playback for an `after` gate without delaying synthesis', async () => { + const gate = deferred(); + const done = tts.enqueueSpeech('narration', 'Kore', { after: gate.promise }); + + // Synthesis must already be finished while the gate is still shut — that is + // what lets Cinema speak the instant the camera settles. + await vi.waitFor(() => expect(events).toContain('synth:done:narration')); + expect(events.some((e) => e.startsWith('play:'))).toBe(false); + + gate.resolve(); + await done; + expect(events).toContain('play:narration'); + }); + + it('drops a chunk whose journey is no longer active', async () => { + await tts.enqueueSpeech('offscreen', 'Kore', { scenario: 'insight' }); + expect(generateContentStream).not.toHaveBeenCalled(); + expect(events).toEqual([]); + }); + + it('reports playing while work is queued and idle once it drains', async () => { + const done = tts.enqueueSpeech('one'); + expect(tts.speechStatus()).toBe('playing'); + await done; + await tts.whenQueueDrained(); + expect(tts.speechStatus()).toBe('idle'); + }); + + it('stopSpeech abandons queued chunks instead of playing them late', async () => { + synthGates.set('one', deferred()); + const first = tts.enqueueSpeech('one'); + tts.stopSpeech(); + synthGates.get('one')!.resolve(); + await first; + expect(events.some((e) => e.startsWith('play:'))).toBe(false); + expect(tts.speechStatus()).toBe('idle'); + }); +}); diff --git a/demos/real-world-reasoning-agent/src/ai/tts.ts b/demos/real-world-reasoning-agent/src/ai/tts.ts new file mode 100644 index 0000000..3ee5347 --- /dev/null +++ b/demos/real-world-reasoning-agent/src/ai/tts.ts @@ -0,0 +1,366 @@ +import { genai } from './client'; +import { MODELS } from '@/lib/config'; +import { atlas } from '@/state/store'; +import type { ScenarioId } from '@/lib/types'; + +/** Options for a speak call. `scenario` scopes audio to that journey: a call + * tagged with a journey is dropped the moment another journey is active, so a + * late-resolving narration never bleeds onto the screen the user switched to. */ +export interface SpeakOptions { + scenario?: ScenarioId; + /** + * Gate that must resolve before this chunk is *played*. Synthesis still starts + * immediately, so the audio is already decoded and waiting when the gate opens. + * + * Cinema uses it to hold narration until the camera has settled on the stop, so + * the voice describes what is on screen. Buffering the raw text until then + * instead meant paying the whole synthesis round-trip after arrival. + */ + after?: Promise; +} + +/** True when this speech no longer belongs on screen (its journey isn't active). */ +function outOfScope(opts?: SpeakOptions): boolean { + return !!opts?.scenario && atlas().activeScenario !== opts.scenario; +} + +/** + * Text-to-speech via the Gemini TTS preview model. Returns 24 kHz mono 16-bit + * PCM (base64) which we decode into a WebAudio buffer and play through a single + * shared queue. Used by Cinema narration and "read it aloud" affordances. + */ + +let ctx: AudioContext | null = null; +let queue: Promise = Promise.resolve(); +let generation = 0; // bumps on stop() to invalidate in-flight synthesis + +/** + * Coalesce arriving PCM into segments of at least this much audio before + * scheduling. The model emits ~40ms frames; batching them keeps the node count in + * the tens rather than the hundreds and buys a small jitter buffer for free. + */ +const SEGMENT_SECONDS = 0.25; +/** Schedule a hair ahead of the clock so a segment is never queued in the past. */ +const SCHEDULE_LEAD_SECONDS = 0.08; + +/** AudioContext time at which the next segment should start — the gapless cursor. */ +let playCursor = 0; +/** Every source currently scheduled or sounding, so stopSpeech can silence all of them. */ +const scheduled = new Set(); +/** Woken by stopSpeech so waiters never strand when playback is abandoned. */ +const stopWaiters = new Set<() => void>(); + +export type SpeechStatus = 'idle' | 'playing' | 'paused'; + +let status: SpeechStatus = 'idle'; +let activeCount = 0; // number of speakQueued calls currently doing work +const listeners = new Set<() => void>(); + +function setStatus(next: SpeechStatus): void { + if (status === next) return; + status = next; + for (const fn of listeners) fn(); +} + +/** Current global speech status, for UI that wants to show pause/resume affordances. */ +export function speechStatus(): SpeechStatus { + return status; +} + +/** Subscribe to speech status changes. Returns an unsubscribe function. */ +export function subscribeSpeech(fn: () => void): () => void { + listeners.add(fn); + return () => listeners.delete(fn); +} + +function audioCtx(): AudioContext { + if (!ctx) ctx = new (window.AudioContext || (window as unknown as { webkitAudioContext: typeof AudioContext }).webkitAudioContext)(); + return ctx; +} + +function b64ToBytes(b64: string): Uint8Array { + const bin = atob(b64); + const out = new Uint8Array(bin.length); + for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i); + return out; +} + +/** 16-bit signed PCM little-endian → AudioBuffer at the given sample rate. */ +function pcmToBuffer(bytes: Uint8Array, sampleRate = 24000): AudioBuffer { + const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); + const frames = Math.floor(bytes.byteLength / 2); + const buf = audioCtx().createBuffer(1, frames, sampleRate); + const ch = buf.getChannelData(0); + for (let i = 0; i < frames; i++) ch[i] = view.getInt16(i * 2, true) / 32768; + return buf; +} + +/** Some responses carry the rate in the mime type (e.g. audio/L16;rate=24000). */ +function rateFromMime(mime?: string): number { + const m = mime?.match(/rate=(\d+)/); + return m ? Number(m[1]) : 24000; +} + +export function stopSpeech(): void { + generation++; + queue = Promise.resolve(); + // A streamed utterance is many scheduled segments, most of which have not begun + // sounding yet. Stopping only the audible one would let the rest play on. + for (const src of scheduled) { + try { + src.stop(); + } catch { + /* already stopped */ + } + } + scheduled.clear(); + playCursor = 0; + for (const wake of [...stopWaiters]) wake(); + stopWaiters.clear(); + if (ctx && ctx.state === 'suspended') void ctx.resume(); + setStatus('idle'); +} + +/** Suspend audio playback in place (does not clear the queue). No-op unless currently playing. */ +export async function pauseSpeech(): Promise { + if (status === 'playing' && ctx) { + await ctx.suspend(); + setStatus('paused'); + } +} + +/** Resume audio playback after pauseSpeech(). No-op unless currently paused. */ +export async function resumeSpeech(): Promise { + if (status === 'paused' && ctx) { + await ctx.resume(); + setStatus('playing'); + } +} + +export function isSpeechSupported(): boolean { + return typeof window !== 'undefined' && !!(window.AudioContext || (window as unknown as { webkitAudioContext?: unknown }).webkitAudioContext); +} + +/** + * Synthesize and play `text`. Resolves when playback ends (or immediately if a + * newer request superseded this one). Never throws — TTS is a nice-to-have. + */ +export async function speak(text: string, voice = 'Kore', opts?: SpeakOptions): Promise { + stopSpeech(); + return speakQueued(text, voice, opts); +} + +/** + * One synthesis in flight. Segments are appended as PCM streams in; the player + * consumes them from `next` and blocks on `wake` when it has caught up. + */ +interface PendingSpeech { + segments: AudioBuffer[]; + next: number; + done: boolean; + wake: (() => void) | null; +} + +function newPending(): PendingSpeech { + return { segments: [], next: 0, done: false, wake: null }; +} + +function announce(p: PendingSpeech): void { + const wake = p.wake; + p.wake = null; + wake?.(); +} + +/** + * Stream synthesis of `text`, appending decoded segments to `p` as they arrive. + * + * This is the whole point of streaming: `generateContent` returns nothing until + * the entire clip is synthesized (measured ~9.8s for ~10s of narration), whereas + * the first streamed frame lands in ~1.2s. Playback can therefore start while the + * rest of the sentence is still being generated. + * + * Never throws — narration is optional and the captions always carry the text. + */ +async function synthesizeInto( + p: PendingSpeech, + text: string, + voice: string, + mine: number, + opts?: SpeakOptions, +): Promise { + try { + const stream = await genai().models.generateContentStream({ + model: MODELS.tts, + contents: text, + config: { + responseModalities: ['AUDIO'], + speechConfig: { voiceConfig: { prebuiltVoiceConfig: { voiceName: voice } } }, + // NOTE: no thinkingConfig here — the TTS model rejects a thinking config + // with a 400, and synthesis has no reasoning phase to tune anyway. + }, + }); + + let carry = new Uint8Array(0); + let rate = 24000; + for await (const chunk of stream) { + if (mine !== generation || outOfScope(opts)) return; // stopped/superseded/left the page + const part = chunk.candidates?.[0]?.content?.parts?.find((item) => item.inlineData?.data); + const data = part?.inlineData?.data; + if (!data) continue; + rate = rateFromMime(part?.inlineData?.mimeType); + + const incoming = b64ToBytes(data); + const merged = new Uint8Array(carry.byteLength + incoming.byteLength); + merged.set(carry); + merged.set(incoming, carry.byteLength); + carry = merged; + + // Emit whole segments only, and never split a 16-bit sample across one. + const target = Math.ceil(rate * SEGMENT_SECONDS) * 2; + if (carry.byteLength >= target) { + const cut = carry.byteLength - (carry.byteLength % 2); + p.segments.push(pcmToBuffer(carry.subarray(0, cut), rate)); + carry = carry.slice(cut); + announce(p); + } + } + if (carry.byteLength >= 2 && mine === generation && !outOfScope(opts)) { + p.segments.push(pcmToBuffer(carry.subarray(0, carry.byteLength - (carry.byteLength % 2)), rate)); + } + } catch { + /* swallow — narration is optional, captions always carry the text */ + } finally { + p.done = true; + announce(p); + } +} + +/** Schedule one segment on the gapless cursor. Returns the node so callers can await its end. */ +function scheduleSegment(buffer: AudioBuffer): AudioBufferSourceNode { + const context = audioCtx(); + const startAt = Math.max(playCursor, context.currentTime + SCHEDULE_LEAD_SECONDS); + const src = context.createBufferSource(); + src.buffer = buffer; + src.connect(context.destination); + src.onended = () => { + scheduled.delete(src); + }; + scheduled.add(src); + src.start(startAt); + playCursor = startAt + buffer.duration; + return src; +} + +/** + * Play everything `p` produces, scheduling segments the moment they exist and + * waiting for more until synthesis reports done. Resolves once the final segment + * has actually finished sounding, so queued callers pace to audio, not to text. + */ +async function playPending(p: PendingSpeech, mine: number, opts?: SpeakOptions): Promise { + await audioCtx().resume(); + if (mine !== generation || outOfScope(opts)) return; + // Never inherit a cursor left in the past by audio that already finished. + playCursor = Math.max(playCursor, audioCtx().currentTime); + + let last: AudioBufferSourceNode | null = null; + for (;;) { + if (mine !== generation || outOfScope(opts)) return; + if (p.next < p.segments.length) { + last = scheduleSegment(p.segments[p.next++]); + continue; + } + if (p.done) break; + // Wait for the next segment, but also register with stopWaiters so an abandoned + // utterance cannot strand this loop (and the queue behind it) forever. + let release: () => void = () => undefined; + await new Promise((resolve) => { + release = resolve; + p.wake = resolve; + stopWaiters.add(resolve); + }); + stopWaiters.delete(release); + } + + if (!last || mine !== generation) return; + const tail = last; + await new Promise((resolve) => { + if (!scheduled.has(tail)) return resolve(); // already finished + const previous = tail.onended; + tail.onended = function (this: AudioScheduledSourceNode, event: Event) { + (previous as ((this: AudioScheduledSourceNode, ev: Event) => void) | null)?.call(this, event); + resolve(); + }; + stopWaiters.add(resolve); + }); +} + +export async function speakQueued(text: string, voice = 'Kore', opts?: SpeakOptions): Promise { + if (!isSpeechSupported() || !text.trim() || outOfScope(opts)) return; + const mine = generation; + activeCount++; + setStatus('playing'); + try { + const pending = newPending(); + const synthesis = synthesizeInto(pending, text, voice, mine, opts); + await playPending(pending, mine, opts); + await synthesis; + } finally { + activeCount--; + if (activeCount === 0 && status !== 'paused') setStatus('idle'); + } +} + +/** + * Queue `text` behind any already-queued speech. Returns a promise that resolves + * once this chunk has finished *playing* (or immediately if a newer request + * superseded it), so callers can pace themselves to actual audio, not just to + * text streaming. + * + * Synthesis starts IMMEDIATELY; only playback is serialized. This used to chain + * `speakQueued` — which synthesizes *and* plays — onto the queue, so the request + * for sentence 2 was not even sent until sentence 1 had finished playing. Every + * sentence therefore cost a full TTS round-trip of silence, and the gap between + * the caption on screen and the voice grew with each one. Cinema streams + * narration a sentence at a time and buffers chunks until the camera arrives, + * then flushes them together, which is exactly the pattern that turned one + * round-trip of latency into ~20s of it. + * + * Fan-out is bounded by how many chunks a caller enqueues before the first + * finishes — one or two sentences per tour stop in practice, so this pipelines + * without needing a semaphore. + */ +export function enqueueSpeech(text: string, voice = 'Kore', opts?: SpeakOptions): Promise { + const mine = generation; + if (!isSpeechSupported() || !text.trim() || outOfScope(opts)) return queue; + activeCount++; + setStatus('playing'); + // Kick synthesis off now, in parallel with whatever is still playing. Attach a + // catch so an early rejection can never surface as an unhandled rejection while + // the segments wait their turn — synthesizeInto already swallows, belt-and-braces. + const pending = newPending(); + const synthesis = synthesizeInto(pending, text, voice, mine, opts).catch(() => undefined); + queue = queue.then(async () => { + try { + if (mine !== generation || outOfScope(opts)) return; + // Hold playback (not synthesis) until the caller's gate opens. + if (opts?.after) await opts.after.catch(() => undefined); + if (mine !== generation || outOfScope(opts)) return; + await playPending(pending, mine, opts); + await synthesis; + } finally { + activeCount--; + if (activeCount === 0 && status !== 'paused') setStatus('idle'); + } + }); + return queue; +} + +/** + * Resolves once everything currently queued has finished playing. Because + * `stopSpeech()` resets the queue to a resolved promise, an awaited drain also + * unblocks immediately when playback is interrupted — keeping callers responsive + * to pause/next/prev. + */ +export function whenQueueDrained(): Promise { + return queue; +} diff --git a/demos/real-world-reasoning-agent/src/ai/video.test.ts b/demos/real-world-reasoning-agent/src/ai/video.test.ts new file mode 100644 index 0000000..2cfbeec --- /dev/null +++ b/demos/real-world-reasoning-agent/src/ai/video.test.ts @@ -0,0 +1,27 @@ +import { describe, it, expect, vi, afterEach } from 'vitest'; + +/** + * The omni video feature is now ON by default (VITE_VIDEO_GEN_ENABLED unset → + * enabled). A deployer can still hard-disable it with VITE_VIDEO_GEN_ENABLED=false, + * and when they do, generateVideo must fail fast with an actionable message BEFORE + * touching the network / SDK — so opting out is a real, billable-call-free kill switch. + * + * The flag is read at module-load time (a config.ts const), so we stub the env and + * re-import the module to exercise the disabled branch deterministically without a + * live SDK/proxy. + */ +describe('generateVideo feature flag', () => { + afterEach(() => { + vi.unstubAllEnvs(); + vi.resetModules(); + }); + + it('throws an actionable error when explicitly disabled', async () => { + vi.stubEnv('VITE_VIDEO_GEN_ENABLED', 'false'); + vi.resetModules(); + const { generateVideo } = await import('./video'); + await expect( + generateVideo({ imageBase64: 'AAAA', prompt: 'slow push-in over the plaza' }), + ).rejects.toThrow(/VITE_VIDEO_GEN_ENABLED/); + }); +}); diff --git a/demos/real-world-reasoning-agent/src/ai/video.ts b/demos/real-world-reasoning-agent/src/ai/video.ts new file mode 100644 index 0000000..9d01ba7 --- /dev/null +++ b/demos/real-world-reasoning-agent/src/ai/video.ts @@ -0,0 +1,71 @@ +/** + * Gemini "omni" video generation — image→video. + * + * `gemini-omni-flash-preview` (MODELS.omni) is a VIDEO generation model driven + * through the @google/genai Interactions API (`ai.interactions.create`), NOT the + * `generateContent` path the rest of the app uses, and NOT an image-grounding + * model. It powers the Cinema "generate a video of this tour" and Scout + * "walkthrough video of the winning site" surfaces. + * + * Gating: inert unless VIDEO_GEN_ENABLED (VITE_VIDEO_GEN_ENABLED=true) AND the + * deployer adds MODELS.omni to the server allowlist (GENAI_EXTRA_MODELS) — the + * /ai proxy 403s any model not on that list. Calling it while disabled throws. + * + * Transport: `ai.interactions.create` POSTs to `/{version}/interactions` (not + * `models/:generateContent`), so the /ai proxy has a dedicated interactions + * branch that pins the host and enforces the same model allowlist by inspecting + * the request body's `model` field (see server/index.mjs). The response's + * SDK-added `output_video` block carries the base64 MP4. + */ +import { genai } from './client'; +import { MODELS, VIDEO_GEN_ENABLED } from '@/lib/config'; + +export interface VideoGenInput { + /** Base64-encoded seed frame (no data: prefix), e.g. a Street View still. */ + imageBase64: string; + imageMimeType?: string; + /** Motion / scene direction, e.g. "slow dolly-in along the storefront at golden hour". */ + prompt: string; + /** image_to_video (default) uses the frame as the first frame; reference_to_video uses it as a subject reference. */ + task?: 'image_to_video' | 'reference_to_video'; +} + +export interface VideoGenResult { + /** data: URL for the generated MP4, ready to drop into a