diff --git a/.claude/agents/dhruva-signals.md b/.claude/agents/dhruva-signals.md new file mode 100644 index 0000000..f4a9269 --- /dev/null +++ b/.claude/agents/dhruva-signals.md @@ -0,0 +1,21 @@ +--- +name: dhruva-signals +description: Dhruva's signals agent for Pegasus. Use for work in /signals — behavioral sentiment analysis (Claude), typing dynamics, the :8002 FastAPI service, Twilio SMS alerts, and the collector.js keystroke tracker. MUST stay inside signals/. +tools: Read, Write, Edit, Bash, Grep, Glob +--- + +You are **Dhruva**, the signals engineer on the Pegasus team. + +**Hard boundary:** You may ONLY create/edit files inside `signals/`. Never touch +`frontend/`, `backend/`, `ml/`, or `shared/`. Ask Jason for contract changes. + +**Your service** (FastAPI on :8002): `/health`, `/signals/analyze` (Claude +`claude-sonnet-4-6` sentiment + energy + flags + `combined_signal_score`, with a +lexical fallback when no API key), `/signals/{user_id}`, `/alert` (Twilio SMS on +red; no-ops to console when creds are absent so the demo never crashes). + +You also own `signals/collector.js` (`KeystrokeTracker`), which the frontend +imports — a synced copy lives at `frontend/src/keystroke.js`. Keep them in sync. + +Read `signals/AGENTS.md`, `AGENTS.md`, and `shared/contract.md` first. +Stage only `signals/`. Commit to `feat/dhruva-sig`, PR into `dev`. diff --git a/.claude/agents/imessage-bridge.md b/.claude/agents/imessage-bridge.md new file mode 100644 index 0000000..f5ce526 --- /dev/null +++ b/.claude/agents/imessage-bridge.md @@ -0,0 +1,27 @@ +--- +name: imessage-bridge +description: Agent for the standalone /imessage bridge. Use for work on the macOS iMessage delivery channel — AppleScript send, chat.db polling, the bridge CLI/loop, phone↔user registry. MUST stay inside imessage/ and only call the backend API. +tools: Read, Write, Edit, Bash, Grep, Glob +--- + +You own the **`imessage/` bridge** — a standalone macOS delivery channel for +Pegasus (owner TBD; not one of the four service lanes). + +**Hard boundary:** ONLY create/edit files inside `imessage/`. Never touch +`frontend/`, `backend/`, `ml/`, `signals/`, or `shared/`. Talk to the **backend +only** (:8001): `POST /users`, `GET /stimulus/today`, `POST /checkin`. + +**How it works (macOS, no API):** send iMessages via AppleScript driving +Messages.app (`send.applescript` + `imessage.send`); receive by polling +`~/Library/Messages/chat.db` read-only (`imessage.fetch_new`), decoding +`attributedBody` when the `text` column is NULL. `bridge.py` provides +`register` / `send` / `send-daily` / `listen`. `registry.py` maps phone ⇄ +user_id and auto-registers new senders. + +**Constraints to respect:** needs Full Disk Access + a signed-in iMessage +account on the Mac; AppleScript send syntax is macOS-version-sensitive; no +keystroke dynamics over iMessage (typing metrics sent as 0). Keep it runnable +and never replay old chat history (start from current max ROWID). + +Read `imessage/AGENTS.md`, `AGENTS.md`, and `shared/contract.md` first. +Stage only `imessage/`. diff --git a/.claude/agents/jason-backend.md b/.claude/agents/jason-backend.md new file mode 100644 index 0000000..75075da --- /dev/null +++ b/.claude/agents/jason-backend.md @@ -0,0 +1,22 @@ +--- +name: jason-backend +description: Jason's backend agent for Pegasus. Use for work in /backend and /shared — the :8001 orchestrator, SQLite persistence, daily stimuli, and the API contract. MUST stay inside backend/ and shared/. +tools: Read, Write, Edit, Bash, Grep, Glob +--- + +You are **Jason**, the backend engineer and contract owner on the Pegasus team. + +**Hard boundary:** You may ONLY create/edit files inside `backend/` and +`shared/`. Never touch `frontend/`, `ml/`, or `signals/` — call their HTTP APIs. + +**Your service** (FastAPI on :8001) is the orchestrator and the ONLY service +the frontend talks to: `/health`, `/users`, `/stimulus/today`, `/checkin` +(fans out to Signals :8002 + ML :8003, persists, alerts on red), `/status/{id}`. + +**You own the contract.** `shared/contract.md` is the source of truth. When you +change it, note which service owner is affected so they stay shape-compatible. +Keep `orchestrator.py` degrading gracefully when a downstream service is down — +the demo must never hard-fail. + +Read `backend/AGENTS.md`, `AGENTS.md`, and `shared/contract.md` first. +Stage only `backend/` and `shared/`. Commit to `feat/jason-api`, PR into `dev`. diff --git a/.claude/agents/rishith-ml.md b/.claude/agents/rishith-ml.md new file mode 100644 index 0000000..8794abc --- /dev/null +++ b/.claude/agents/rishith-ml.md @@ -0,0 +1,29 @@ +--- +name: rishith-ml +description: Rishith's ML + Video agent for Pegasus. Use for work in /ml (TRIBE v2 brain prediction, the 4-stream combined burnout scorer, Claude interventions, :8003) and /video (MediaPipe facial + Whisper voice stress, :8004). MUST stay inside ml/ and video/. +tools: Read, Write, Edit, Bash, Grep, Glob +--- + +You are **Rishith**, the ML / brain-pipeline / video engineer on the Pegasus team. + +**Hard boundary:** You may ONLY create/edit files inside `ml/` and `video/`. +Never touch `frontend/`, `backend/`, `signals/`, or `shared/`. Call their HTTP +API or read (never edit) `shared/contract.md`. If you need a contract change, +report it for Jason — don't edit shared/. + +**ML service (:8003):** `/health`, `/predict` (TRIBE v2 healthy-brain prediction ++ per-region activations), `GET /baseline/{stimulus_id}` (cached, for Brain +View), `/score` (merges imessage + typing + facial + voice + tribe streams via +`combined_scorer.py`, renormalizing over whichever are present). Intervention +(`claude_interpreter.py`, `claude-sonnet-4-6`, offline fallback) is part of `/score`. + +**Video service (:8004):** `/health`, `/analyze/video` (facial+voice), +`/analyze/frame`. MediaPipe Face Mesh (`facial_analyzer.py`) + Whisper/librosa +(`voice_analyzer.py`); heavy models load lazily so `/health` is instant. + +**Headline task:** `ml/tribe_inference.py` ships a seeded STUB (`TribeModel._infer`). +Wire real TRIBE v2 inference there, keeping the return shape so the pipeline is +unaffected. Keep both services' `/health` green and runnable at every commit. + +Read `ml/AGENTS.md`, `video/AGENTS.md`, `AGENTS.md`, and `shared/contract.md` +first. Stage only `ml/` and `video/`. Commit to `feat/rishith-ml`, PR into `dev`. diff --git a/.claude/agents/wesley-frontend.md b/.claude/agents/wesley-frontend.md new file mode 100644 index 0000000..90f4f16 --- /dev/null +++ b/.claude/agents/wesley-frontend.md @@ -0,0 +1,23 @@ +--- +name: wesley-frontend +description: Wesley's frontend agent for Pegasus. Use for work in /frontend — the React (Vite) UI on :3000, the check-in flow, keystroke capture, and the Check Engine Light component. MUST stay inside frontend/. +tools: Read, Write, Edit, Bash, Grep, Glob +--- + +You are **Wesley**, the frontend engineer on the Pegasus team. + +**Hard boundary:** You may ONLY create/edit files inside `frontend/`. Never +touch `backend/`, `ml/`, `signals/`, or `shared/`. You talk to the **backend +only** (:8001) through the Vite `/api` proxy (already configured in +`vite.config.js`) — never call ml/signals directly. + +**The UI flow:** create/get user → `GET /api/stimulus/today` → show stimulus → +capture response with `KeystrokeTracker` (`src/keystroke.js`) → `POST +/api/checkin` → render the Check Engine Light (🟢/🟡/🔴) + intervention via +`EngineLight.jsx`. Keep `src/keystroke.js` in sync with Dhruva's +`signals/collector.js`. + +Run with `npm install && npm run dev` (port 3000). Keep it building. + +Read `frontend/AGENTS.md`, `AGENTS.md`, and `shared/contract.md` first. +Stage only `frontend/`. Commit to `feat/wesley-ui`, PR into `dev`. diff --git a/.gitignore b/.gitignore index 4a533e1..52832eb 100644 --- a/.gitignore +++ b/.gitignore @@ -40,3 +40,19 @@ wandb/ # Logs *.log + +# iMessage bridge runtime (registry.json holds phone numbers — never commit) +imessage/.state.json +imessage/registry.json +imessage/*.scpt + +# Expo / React Native +.expo/ +frontend/.expo/ +frontend/web-build/ +*.tsbuildinfo + +# Local integration venv + sqlite +.venv-int/ +*.db +pegasus.db diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..d5e8f31 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,102 @@ +# Pegasus — Agent Rules (root) + +Read this, then read the `AGENTS.md` in **your** folder. Every teammate runs +their own coding agent; these files keep agents in their lane. + +## What Pegasus is +A mental-health "check engine light." Four signal streams (SMS responses, +typing biometrics, video facial/voice, and the in-app check-in) feed +**TRIBE v2** (a brain foundation model deployed on Modal). We compare the user's +real behavioral signals against TRIBE's healthy-brain prediction → deviation = +burnout score → 🟢/🟡/🔴 + a Claude intervention → SMS alert if red. + +## Team & services +| Owner | Folder(s) | Port(s) | +|---------|---------------------------------------------|----------------| +| Rishith | frontend **data layer** + `ml/` + `video/` | 3000, 8003, 8004 | +| Wesley | frontend **screens/components** | 3000 | +| Jason | `backend/` + `shared/` | 8001 | +| Dhruva | `signals/` (Twilio SMS bot + alerts) | 8002 | + +`frontend/` is an **Expo** app shared by Rishith (services/hooks/types) and +Wesley (App.tsx/screens/components/navigation/utils) — see `frontend/AGENTS.md`. +`imessage/` is a legacy macOS bridge, **superseded by Twilio SMS in `signals/`**. + +## The 5 rules (non-negotiable) +1. **Stay in your files.** Only edit what you own (table above + per-folder + AGENTS.md). Need something from another area? **Call its API / import it — never edit it.** +2. **The contract is law.** `shared/contract.md` is the source of truth. Need a + field changed? Jason changes it there first, then tells the owner. +3. **Branch + PR.** Work on `feat/-*`, PR into `dev`. Never push to `main`. + Stage only your files — never `git add .` / `git add -A`. +4. **Stay runnable.** Your service must start on its port and answer `GET /health`. +5. **Sync every ~45 min** (below) so integration never drifts far. + +## The 45-minute sync (safe — never reverts or deletes) + +> **NON-NEGOTIABLE:** no sync step may ever revert, overwrite, or delete anyone's committed, staged, or uncommitted work. If a command risks that, **stop and `git merge --abort`** — drift is recoverable, lost work is not. + +**Quick path:** run `./sync.sh` from the repo root — it does the whole safe local half (backup → push → ff-only dev → merge dev in) and refuses to do anything destructive. Then open/merge your PR on GitHub. The manual steps below are what it runs. + +```bash +# Run from repo root, on YOUR feature branch (feat/). Replace owned paths with yours. +# Rishith owns: ml/ video/ frontend/src/services/ frontend/src/hooks/ frontend/src/types/ + +# 1. COMMIT your work first — stage ONLY your owned paths (never git add . / -A) +git add -- ml/ video/ frontend/src/services/ frontend/src/hooks/ frontend/src/types/ +git status # verify ONLY your paths are staged +git commit -m "sync: " # skip only if nothing is staged + +# 2. BACKUP branch BEFORE touching dev (your recovery point) — plain push, never force +# NOTE: a git branch backup canNOT capture gitignored/untracked files (e.g. .env). +# If dev introduces a tracked file at a path where you keep an untracked one, see the +# .env warning below and move yours aside FIRST. +git branch "backup/feat--$(date +%Y%m%d-%H%M%S)" +git push origin "backup/feat--$(date +%Y%m%d-%H%M%S)" + +# 3. PUSH your feature branch (plain push only — NEVER --force / --force-with-lease) +git push origin feat/ +# → on GitHub: open / MERGE the PR feat/ → dev (PR only; never push to dev/main) +# → use "Create a merge commit" — NOT squash / rebase-merge (keeps SHAs stable for the team) + +# 4. Update local dev FAST-FORWARD-ONLY (cannot rewrite or diverge), then go back +git fetch origin --prune +git checkout dev +git pull --ff-only origin dev # if this FAILS, dev diverged: do NOT force — just skip it this round +git checkout feat/ + +# 5. MERGE dev INTO your branch (the only real merge), then push the result. +# Disable rerere for THIS merge so a previously-learned one-sided resolution +# can never be silently replayed and drop a teammate's side (see warning below). +git -c rerere.enabled=false -c rerere.autoupdate=false merge --no-ff dev +git push origin feat/ # plain push; open another PR if it brought in mergeable work +``` + +**Never do this (it can delete a teammate's work):** +- **`git push --force` / `--force-with-lease`** — on any branch. Plain `git push` only; brand-new backup refs only. +- **`git reset --hard`** (incl. `reset --hard origin/...`, `@{u}`) — unrecoverable loss of working tree + unpushed commits. +- **`git clean -fd` / `-fdx`** — deletes untracked files and gitignored `.env` keys with no recovery. +- **`git checkout --theirs` / `--ours`** (or `merge -X theirs/ours`) — silently drops one author's whole side. +- **`git checkout -- ` / `git restore` / `git checkout .` to "clean up"** — discards uncommitted edits. +- **`git add .` / `git add -A`** — stage every owned path explicitly; never touch another lane. +- **`git stash drop` / `clear`, `git rebase --skip`** — delete work outright. Use `git stash apply` and `git rebase --abort`. +- **`git branch -D feat/` before its PR merges** — discards commits that only live on that branch. +- **Pushing directly to `dev` or `main`** — integration is PRs only; `main` is protected. + +**Two silent ways work can vanish even WITHOUT any banned command — guard against both:** +- **`git rerere` (resolve-recorded-resolution).** If your *global* git config has `rerere.enabled true` **and** `rerere.autoupdate true`, then the second time the SAME conflict appears (e.g. after you `git merge --abort` and re-sync, or re-sync after discarding an unpushed merge) git will **silently re-apply your earlier resolution and auto-stage it**. If that earlier resolution was one-sided, the file ends up containing only your side — **with NO conflict markers** — and `git diff --name-only --diff-filter=U` is **empty**. An empty diff-filter is therefore **NOT proof the merge is safe**; it can mean rerere dropped the teammate's side. `sync.sh` runs its merge with `-c rerere.enabled=false -c rerere.autoupdate=false` so this can't happen through the script. If you ever merge by hand, do the same, and after any conflict **open each previously-conflicted file and confirm the teammate's lines from dev are still there** before `git commit --no-edit`. +- **A merge overwriting a gitignored/untracked file (e.g. `.env`).** Your real `.env` secrets are gitignored, so they live in **no git object and no branch backup**. If a teammate force-committed a tracked file at that same path into dev, merging dev in will **silently overwrite your local `.env`** on disk with their version — no conflict, no warning, and `git merge --abort` can't help because the merge "succeeds". `sync.sh` detects this (it compares `git diff --name-only HEAD origin/dev` against files that exist on disk but git isn't tracking) and **refuses to merge**, telling you to move the file aside first. If you merge by hand, `cp .env .env.local.bak` before the merge and reconcile afterward. + +**Conflict? Keep BOTH sides, or run `git merge --abort` — both are safe, nothing is lost.** + +## Secrets +Each service reads keys from a `.env` in its folder (gitignored). NEVER commit +`.env` or put keys in code. Templates: `*/.env.example`. + +## Run locally +```bash +./dev.sh # ml + signals + backend (+ WITH_VIDEO=1 for video) +cd frontend && npx expo start +``` +The frontend (Expo) talks ONLY to the backend (:8001); backend fans out to +signals + ml; ml calls TRIBE on Modal; video runs facial/voice on :8004. diff --git a/README.md b/README.md index 4b1bae5..ccec9fb 100644 --- a/README.md +++ b/README.md @@ -1,28 +1,141 @@ -# pegasus +# Pegasus -## Branching model +Pegasus is a multimodal burnout detection and mental wellness platform. It works like a check engine light for the mind — passively collecting behavioral signals throughout the day, fusing them into a single wellness score, and surfacing a grounded, personalized intervention when that score drops. + +## Architecture + +Pegasus is composed of four services that communicate over HTTP: + +| Service | Port | Stack | Owner | +|---|---|---|---| +| Backend orchestrator | 8001 | FastAPI, SQLite | Jason | +| Signals processor | 8002 | FastAPI, HuggingFace | Dhruva | +| ML / TRIBE scorer | 8003 | FastAPI, Modal, HuggingFace | Rishith | +| Video analyzer | 8004 | FastAPI | Rishith | + +The Expo React Native mobile app connects to the backend at port 8001. The SMS bot runs alongside the signals service and polls iMessage via a local AppleScript bridge. + +## Features + +### Multimodal Wellness Scoring + +The scoring engine fuses up to five behavioral streams into a single wellness score from 0 to 100 (higher is better). Each stream contributes a burnout sub-score that is weighted and normalized depending on which signals are actually present in a given check-in. + +| Stream | Weight | Signal | +|---|---|---| +| iMessage / text sentiment | 25% | HuggingFace emotion model (j-hartmann/emotion-english-distilroberta-base) | +| Typing biometrics | 20% | WPM, error rate, hesitation count, key hold time, flight time, correction loops | +| Facial stress | 30% | Video frame analysis via the video service | +| Voice stress | 15% | Audio analysis via the video service | +| TRIBE deviation | 10% | Distance from the user's healthy brain-activity baseline | + +The score is inverted from a burnout reading so that rising numbers always mean the user is doing better. Scores above 70 are green, 40-69 are yellow, and below 40 are red. + +### TRIBE Baseline Model + +TRIBE (Temporal Representation of Integrated Brain Events) v2 is a deployed Modal endpoint that models healthy brain activation patterns across six macro-regions: Attention, Auditory, Emotion, Language, Motor, and Visual. For each daily stimulus, Pegasus calls TRIBE once to establish the user's expected healthy-state baseline, then measures deviation from that baseline as a behavioral burnout signal. TRIBE results are cached per stimulus to avoid repeated GPU calls. + +### Crisis and Masking Detection + +The scoring engine applies a keyword override layer on top of the emotion model to catch signals the model tends to under-read: + +- Crisis terms (suicidal ideation, self-harm language) force the iMessage sub-score to 100 regardless of the emotion model output. +- Concern terms (hopeless, burned out, can't cope, etc.) floor the sub-score proportionally to the number of terms present. +- Masking language (clustered over-reassurance such as "I'm fine, totally fine, don't worry") is detected as a tell and pulls the score out of the green even when the emotion model reads positive. + +When the overall score reaches red, the backend routes the user to human support rather than an automated intervention. + +### RAG Intervention Engine + +When the wellness score drops, Pegasus generates a single, warm, specific recommended action using a retrieval-augmented generation pipeline: + +1. A mental health coping corpus is embedded using sentence-transformers/all-MiniLM-L6-v2. +2. The most relevant snippet is retrieved using cosine similarity. +3. A HuggingFace chat model (Qwen/Qwen2.5-7B-Instruct by default) generates a grounded response using only the retrieved content. + +The pipeline degrades gracefully at every step: if HuggingFace is unreachable, the raw retrieved snippet is returned; if the corpus is unavailable, a level-appropriate canned response is used. The intervention call never raises. + +### Signal Collection + +The signals service collects and analyzes behavioral data from multiple sources: + +- **iMessage polling** — an AppleScript bridge reads the local Messages database and extracts response text, response time, and typing metadata. +- **Keystroke biometrics** — a JavaScript collector (collector.js) captures WPM, error rate, hesitation count, key hold duration, inter-key flight time, burst pattern, and correction loops from in-app typing sessions. +- **Sentiment analysis** — HuggingFace Inference API replaces any Claude-based sentiment pipeline; the model degrades to a neutral 0.5 score if the API is unreachable. +- **Linguistic analysis** — structural and lexical features of response text. +- **Temporal analysis** — time-of-day and response latency patterns. + +The signals service maintains a rolling in-memory history of the last 30 readings per user and exposes them to the backend via a typed JSON contract. + +### SMS Bot + +The SMS bot provides a low-friction check-in channel for users who are not on the mobile app: + +- Outbound check-in prompts are sent via Bloo.io SMS. +- Inbound replies are received via a webhook and routed through the full signal analysis and scoring pipeline. +- Automated reminders run on a configurable cadence. +- Stimulus prompts are sent on the same schedule as the in-app experience. + +### Video Check-in + +The mobile app includes a video check-in flow: + +1. The user records a short clip via the in-app camera (60-second maximum, with a countdown ring). +2. The clip is submitted to the video service, which extracts facial stress and voice stress scores. +3. Results are fused into the wellness score and displayed alongside the brain visualization immediately after analysis. + +### Mobile App + +The Expo React Native app is the primary user-facing interface: + +- **Home screen** — an animated breathing orb displays the current wellness score in the user's level color (green, yellow, red), along with the top behavioral indicators and the current intervention suggestion. A red reading triggers a haptic alert and a full-screen support prompt. +- **Check-in screen** — live camera with face-oval overlay, rotating wellness prompts, recording controls, and a post-analysis results view. +- **Brain screen** — a 3D brain visualization that highlights the TRIBE regions currently flagged as elevated. +- **Metrics screen** — historical wellness score charts and breakdown by signal stream. +- **History screen** — a chronological log of past check-ins and score readings. +- **Chat / Talk screen** — a conversational interface for in-app text check-ins. + +The UI uses a liquid glass visual language with level-reactive accent colors throughout. + +### Backend API + +The FastAPI backend at port 8001 is the single point of integration for the mobile app and SMS bot. It persists all readings to SQLite with WAL mode for concurrency, handles first-time-user race conditions, and exposes the following route groups: + +| Route | Purpose | +|---|---| +| `/stimulus` | Deliver the daily check-in prompt | +| `/response` | Accept a text or keystroke-biometric check-in response | +| `/score` | Return the latest wellness score for a user | +| `/brain` | Return TRIBE brain region data for the 3D visualization | +| `/history` | Return the user's check-in history | +| `/metrics` | Return aggregated score and breakdown data for charts | +| `/video` | Accept a video submission and return facial/voice analysis | + +### Infrastructure + +A launchd supervisor script keeps all four services, the iMessage poller, and the ngrok tunnel alive, restarting any process that exits. The sync script handles safe periodic state synchronization without reverting or deleting data. + +## Branch Structure ``` -main (protected — NEVER push directly) - └── dev (integration branch — PRs only) - ├── feat/rishith-ml → /ml/* - ├── feat/wesley-ui → /frontend/* - ├── feat/jason-api → /backend/*, /shared/* - └── feat/dhruva-sig → /signals/* +main + └── dev + ├── feat/jason-api backend/, shared/ + ├── feat/wesley-ui frontend/ + ├── feat/rishith-ml ml/ + └── feat/dhruva-sig signals/ ``` -## Ownership +`feat/integration` and `feat/scaffold` are integration and scaffolding branches used during active development. + +## License + +MIT License + +Copyright (c) 2024 Pegasus Contributors -| Branch | Owner | Scope | -| ----------------- | ------- | ---------------------- | -| `feat/rishith-ml` | Rishith | `ml/` | -| `feat/wesley-ui` | Wesley | `frontend/` | -| `feat/jason-api` | Jason | `backend/`, `shared/` | -| `feat/dhruva-sig` | Dhruva | `signals/` | +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: -## Rules +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. -- `main` is protected. No direct pushes. Releases only via PR from `dev`. -- `dev` is the integration branch. Feature branches PR into `dev`. -- Stay in your lane: only touch the directories listed for your branch. -- Rebase or merge `dev` into your feature branch regularly to stay current. +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/backend/.env.example b/backend/.env.example new file mode 100644 index 0000000..e27bf7c --- /dev/null +++ b/backend/.env.example @@ -0,0 +1,10 @@ +# Pegasus backend (orchestrator) config +BACKEND_PORT=8001 +SIGNAL_SERVICE_URL=http://localhost:8002 +ML_SERVICE_URL=http://localhost:8003 +DATABASE_URL=sqlite:///./pegasus.db +SERVICE_TIMEOUT=10.0 + +# Optional. Backend does not call Claude directly today; interventions come from +# the ML service. Present only so the file matches the team .env template. +ANTHROPIC_API_KEY= diff --git a/backend/.gitignore b/backend/.gitignore new file mode 100644 index 0000000..0c41a40 --- /dev/null +++ b/backend/.gitignore @@ -0,0 +1,11 @@ +# SQLite database files (created at runtime) +*.db +*.sqlite3 +pegasus.db + +# Local virtualenv +.venv/ +venv/ + +# Env +.env diff --git a/backend/config.py b/backend/config.py new file mode 100644 index 0000000..3c917ce --- /dev/null +++ b/backend/config.py @@ -0,0 +1,29 @@ +"""Central config for the Pegasus backend. Reads from environment / .env.""" +import os + +from dotenv import load_dotenv + +load_dotenv() + +# Where this backend listens. +BACKEND_PORT = int(os.getenv("BACKEND_PORT", "8001")) + +# Downstream services we orchestrate. +SIGNAL_SERVICE = os.getenv("SIGNAL_SERVICE_URL", "http://localhost:8002") +ML_SERVICE = os.getenv("ML_SERVICE_URL", "http://localhost:8003") +VIDEO_SERVICE = os.getenv("VIDEO_SERVICE_URL", "http://localhost:8004") + +# Persistence. +DATABASE_URL = os.getenv("DATABASE_URL", "sqlite:///./pegasus.db") + +# HTTP timeouts (seconds) for calls to downstream services. +SERVICE_TIMEOUT = float(os.getenv("SERVICE_TIMEOUT", "15.0")) +VIDEO_TIMEOUT = float(os.getenv("VIDEO_TIMEOUT", "120.0")) + +# Path to the shared/ folder (canonical contract + stimulus manifest). +_THIS_DIR = os.path.dirname(os.path.abspath(__file__)) +SHARED_DIR = os.getenv("SHARED_DIR", os.path.join(os.path.dirname(_THIS_DIR), "shared")) + +# Burnout level thresholds (score is 0-100, higher = more burnout). +YELLOW_THRESHOLD = 30 +RED_THRESHOLD = 65 diff --git a/backend/crud.py b/backend/crud.py new file mode 100644 index 0000000..bb2730c --- /dev/null +++ b/backend/crud.py @@ -0,0 +1,121 @@ +"""Thin persistence helpers over the SQLAlchemy models.""" +from sqlalchemy.orm import Session + +from models.score_record import ScoreRecord +from models.session import Session as PulseSession +from models.user import User +from models.video_record import VideoRecord + + +def get_user(db: Session, user_id: str) -> User | None: + return db.get(User, user_id) + + +def ensure_user(db: Session, user_id: str, name=None, phone=None) -> User: + user = db.get(User, user_id) + if user is None: + user = User(id=user_id, name=name, phone=phone) + db.add(user) + db.commit() + return user + + +def record_session(db: Session, user_id: str, stimulus_id: str) -> PulseSession: + sess = PulseSession(user_id=user_id, stimulus_id=stimulus_id) + db.add(sess) + db.commit() + return sess + + +def save_score(db: Session, user_id: str, stimulus_id: str, result: dict, source: str) -> ScoreRecord: + rec = ScoreRecord( + user_id=user_id, + stimulus_id=stimulus_id, + score=result["score"], + level=result["level"], + breakdown=result.get("breakdown", {}), + indicators=result.get("top_indicators", []), + intervention=result.get("intervention", ""), + tribe_deviation=result.get("tribe_deviation", 0.0), + behavioral_deviation=result.get("behavioral_deviation", 0.0), + brain_regions_flagged=result.get("brain_regions_flagged", []), + confidence=result.get("confidence", 0.0), + source=source, + ) + db.add(rec) + db.commit() + db.refresh(rec) + return rec + + +def latest_score(db: Session, user_id: str) -> ScoreRecord | None: + return ( + db.query(ScoreRecord) + .filter(ScoreRecord.user_id == user_id) + .order_by(ScoreRecord.timestamp.desc(), ScoreRecord.id.desc()) + .first() + ) + + +def score_history(db: Session, user_id: str, limit: int = 30) -> list[ScoreRecord]: + """Most recent first, up to `limit`.""" + return ( + db.query(ScoreRecord) + .filter(ScoreRecord.user_id == user_id) + .order_by(ScoreRecord.timestamp.desc(), ScoreRecord.id.desc()) + .limit(limit) + .all() + ) + + +def score_trend(db: Session, user_id: str) -> list[ScoreRecord]: + """Oldest first, for charting.""" + return ( + db.query(ScoreRecord) + .filter(ScoreRecord.user_id == user_id) + .order_by(ScoreRecord.timestamp.asc(), ScoreRecord.id.asc()) + .all() + ) + + +def latest_video(db: Session, user_id: str) -> VideoRecord | None: + return ( + db.query(VideoRecord) + .filter(VideoRecord.user_id == user_id) + .order_by(VideoRecord.timestamp.desc(), VideoRecord.id.desc()) + .first() + ) + + +def video_signals_for(db: Session, user_id: str) -> dict | None: + """Normalized latest facial/voice signals for fusion, or None if no video yet.""" + rec = latest_video(db, user_id) + if rec is None: + return None + voice = rec.voice_data or {} + return { + "facial_score": rec.facial_score or 0, + "voice_score": voice.get("voice_stress_score", voice.get("stress_score", 0)) or 0, + "combined_score": rec.combined_score or 0, + } + + +def save_video( + db: Session, + user_id: str, + facial_score: int, + combined_score: int, + facial_data: dict, + voice_data: dict, +) -> VideoRecord: + rec = VideoRecord( + user_id=user_id, + facial_score=facial_score, + combined_score=combined_score, + facial_data=facial_data, + voice_data=voice_data, + ) + db.add(rec) + db.commit() + db.refresh(rec) + return rec diff --git a/backend/main.py b/backend/main.py new file mode 100644 index 0000000..88890e2 --- /dev/null +++ b/backend/main.py @@ -0,0 +1,63 @@ +"""Pegasus backend — FastAPI orchestrator on port 8001. + +Wesley's Expo app and Dhruva's SMS bot both call this service; it calls Dhruva's +signals (8002), Rishith's ML/TRIBE (8003) and video (8004), persists everything +to SQLite, and serves history/metrics/brain views. +""" +import logging +from contextlib import asynccontextmanager + +from fastapi import FastAPI +from fastapi.middleware.cors import CORSMiddleware + +import config +from models.database import init_db +from routes import brain, history, metrics, response, score, stimulus, video + +logging.basicConfig(level=logging.INFO) + + +@asynccontextmanager +async def lifespan(app: FastAPI): + init_db() + yield + + +app = FastAPI(title="Pegasus Backend", version="2.0.0", lifespan=lifespan) + +app.add_middleware( + CORSMiddleware, + allow_origins=["*"], + allow_methods=["*"], + allow_headers=["*"], +) + +app.include_router(stimulus.router, prefix="/stimulus", tags=["stimulus"]) +app.include_router(response.router, prefix="/response", tags=["response"]) +app.include_router(score.router, prefix="/score", tags=["score"]) +app.include_router(brain.router, prefix="/brain", tags=["brain"]) +app.include_router(history.router, prefix="/history", tags=["history"]) +app.include_router(metrics.router, prefix="/metrics", tags=["metrics"]) +app.include_router(video.router, prefix="/video", tags=["video"]) + + +@app.get("/") +def root(): + return {"service": "Pegasus Backend", "version": "2.0.0", "docs": "/docs"} + + +@app.get("/health") +def health(): + return { + "status": "pegasus backend running", + "port": config.BACKEND_PORT, + "signals_url": config.SIGNAL_SERVICE, + "ml_url": config.ML_SERVICE, + "video_url": config.VIDEO_SERVICE, + } + + +if __name__ == "__main__": + import uvicorn + + uvicorn.run("main:app", host="0.0.0.0", port=config.BACKEND_PORT, reload=True) diff --git a/backend/models/__init__.py b/backend/models/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/models/database.py b/backend/models/database.py new file mode 100644 index 0000000..bd05e3b --- /dev/null +++ b/backend/models/database.py @@ -0,0 +1,28 @@ +"""SQLite + SQLAlchemy setup and the FastAPI DB dependency.""" +from sqlalchemy import create_engine +from sqlalchemy.orm import declarative_base, sessionmaker + +from config import DATABASE_URL + +# check_same_thread is a SQLite-only quirk; harmless to compute generally. +_connect_args = {"check_same_thread": False} if DATABASE_URL.startswith("sqlite") else {} + +engine = create_engine(DATABASE_URL, connect_args=_connect_args) +SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) +Base = declarative_base() + + +def get_db(): + """FastAPI dependency: yields a session and always closes it.""" + db = SessionLocal() + try: + yield db + finally: + db.close() + + +def init_db(): + """Create tables. Import models first so they register on Base.metadata.""" + from models import score_record, session, user, video_record # noqa: F401 + + Base.metadata.create_all(bind=engine) diff --git a/backend/models/score_record.py b/backend/models/score_record.py new file mode 100644 index 0000000..f07b1f9 --- /dev/null +++ b/backend/models/score_record.py @@ -0,0 +1,47 @@ +"""Score history table ("scores"). One row per scored response (app or sms).""" +from datetime import datetime, timezone + +from sqlalchemy import JSON, Column, DateTime, Float, Integer, String + +import safety +from models.database import Base + + +class ScoreRecord(Base): + __tablename__ = "scores" + + id = Column(Integer, primary_key=True, autoincrement=True) + user_id = Column(String, index=True) + stimulus_id = Column(String, nullable=True) + + score = Column(Integer) + level = Column(String) + breakdown = Column(JSON, default=dict) # {imessage, typing, facial, voice, tribe} + indicators = Column(JSON, default=list) # top_indicators: list[str] + intervention = Column(String) + tribe_deviation = Column(Float, default=0.0) + behavioral_deviation = Column(Float, default=0.0) + brain_regions_flagged = Column(JSON, default=list) + confidence = Column(Float, default=0.0) + source = Column(String, default="app") # "app" | "sms" + + timestamp = Column(DateTime, default=lambda: datetime.now(timezone.utc)) + + def to_result(self) -> dict: + """Render as a contract `burnout_result` (with helpful extra fields).""" + return { + "user_id": self.user_id, + "stimulus_id": self.stimulus_id, + "score": self.score, + "level": self.level, + "tribe_deviation": self.tribe_deviation, + "behavioral_deviation": self.behavioral_deviation, + "top_indicators": self.indicators or [], + "intervention": self.intervention, + "brain_regions_flagged": self.brain_regions_flagged or [], + "confidence": self.confidence, + "breakdown": self.breakdown or {}, + "support": safety.support_for(self.level), + "source": self.source, + "timestamp": self.timestamp.isoformat() if self.timestamp else None, + } diff --git a/backend/models/session.py b/backend/models/session.py new file mode 100644 index 0000000..0342e57 --- /dev/null +++ b/backend/models/session.py @@ -0,0 +1,16 @@ +"""Session table. One row per stimulus delivered to a user.""" +import uuid +from datetime import datetime, timezone + +from sqlalchemy import Column, DateTime, ForeignKey, String + +from models.database import Base + + +class Session(Base): + __tablename__ = "sessions" + + id = Column(String, primary_key=True, default=lambda: str(uuid.uuid4())) + user_id = Column(String, ForeignKey("users.id"), index=True) + stimulus_id = Column(String, nullable=True) + created_at = Column(DateTime, default=lambda: datetime.now(timezone.utc)) diff --git a/backend/models/user.py b/backend/models/user.py new file mode 100644 index 0000000..bfb27a6 --- /dev/null +++ b/backend/models/user.py @@ -0,0 +1,15 @@ +"""User table. Identified by a client-supplied user_id; phone used for SMS alerts.""" +from datetime import datetime, timezone + +from sqlalchemy import Column, DateTime, String + +from models.database import Base + + +class User(Base): + __tablename__ = "users" + + id = Column(String, primary_key=True, index=True) + phone = Column(String, unique=True, nullable=True) # red-alert SMS via signals service + name = Column(String, nullable=True) + created_at = Column(DateTime, default=lambda: datetime.now(timezone.utc)) diff --git a/backend/models/video_record.py b/backend/models/video_record.py new file mode 100644 index 0000000..422e178 --- /dev/null +++ b/backend/models/video_record.py @@ -0,0 +1,28 @@ +"""Video check-in records ("videos"). Facial + voice stress from the video service.""" +from datetime import datetime, timezone + +from sqlalchemy import JSON, Column, DateTime, Integer, String + +from models.database import Base + + +class VideoRecord(Base): + __tablename__ = "videos" + + id = Column(Integer, primary_key=True, autoincrement=True) + user_id = Column(String, index=True) + facial_score = Column(Integer, default=0) + combined_score = Column(Integer, default=0) + facial_data = Column(JSON, default=dict) + voice_data = Column(JSON, default=dict) + timestamp = Column(DateTime, default=lambda: datetime.now(timezone.utc)) + + def to_dict(self) -> dict: + return { + "user_id": self.user_id, + "facial_score": self.facial_score, + "combined_score": self.combined_score, + "facial": self.facial_data or {}, + "voice": self.voice_data or {}, + "timestamp": self.timestamp.isoformat() if self.timestamp else None, + } diff --git a/backend/requirements.txt b/backend/requirements.txt new file mode 100644 index 0000000..fdacde6 --- /dev/null +++ b/backend/requirements.txt @@ -0,0 +1,7 @@ +fastapi>=0.110 +uvicorn[standard]>=0.27 +sqlalchemy>=2.0 +httpx>=0.27 +python-dotenv>=1.0 +pydantic>=2.6 +python-multipart>=0.0.9 diff --git a/backend/routes/__init__.py b/backend/routes/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/routes/brain.py b/backend/routes/brain.py new file mode 100644 index 0000000..cae1436 --- /dev/null +++ b/backend/routes/brain.py @@ -0,0 +1,43 @@ +"""GET /brain/{user_id} — TRIBE brain data for the user's latest stimulus (prefix /brain).""" +import logging + +from fastapi import APIRouter, Depends +from sqlalchemy.orm import Session + +import crud +from models.database import get_db +from services import ml_client, scoring + +router = APIRouter() +log = logging.getLogger("pegasus.backend") + + +@router.get("/{user_id}") +async def get_brain(user_id: str, db: Session = Depends(get_db)): + rec = crud.latest_score(db, user_id) + if rec is None: + return {"user_id": user_id, "regions": {}, "brain_regions_flagged": [], "source": "none"} + + # Prefer the ML service's real TRIBE baseline; fall back to a synthesized map. + try: + baseline = await ml_client.get_baseline(rec.stimulus_id) + baseline["user_id"] = user_id + baseline["stimulus_id"] = rec.stimulus_id + baseline.setdefault("source", "tribe") + if baseline.get("brain_regions_flagged") and "explanations" not in baseline: + baseline["explanations"] = scoring.region_explanations(baseline["brain_regions_flagged"]) + return baseline + except Exception as exc: # noqa: BLE001 - degrade gracefully + log.warning("ML baseline unavailable, synthesizing brain map: %s", exc) + flagged, regions = scoring.synth_brain(rec.level, rec.score) + flagged = rec.brain_regions_flagged or flagged + return { + "user_id": user_id, + "stimulus_id": rec.stimulus_id, + "level": rec.level, + "score": rec.score, + "regions": regions, + "brain_regions_flagged": flagged, + "explanations": scoring.region_explanations(flagged), + "source": "fallback", + } diff --git a/backend/routes/history.py b/backend/routes/history.py new file mode 100644 index 0000000..19fb22b --- /dev/null +++ b/backend/routes/history.py @@ -0,0 +1,14 @@ +"""GET /history/{user_id} — recent burnout_results, newest first (prefix /history).""" +from fastapi import APIRouter, Depends +from sqlalchemy.orm import Session + +import crud +from models.database import get_db + +router = APIRouter() + + +@router.get("/{user_id}") +def get_history(user_id: str, db: Session = Depends(get_db)): + recs = crud.score_history(db, user_id) + return [r.to_result() for r in recs] diff --git a/backend/routes/metrics.py b/backend/routes/metrics.py new file mode 100644 index 0000000..9bb4665 --- /dev/null +++ b/backend/routes/metrics.py @@ -0,0 +1,26 @@ +"""GET /metrics/{user_id} — trends + latest breakdown (prefix /metrics).""" +from fastapi import APIRouter, Depends +from sqlalchemy.orm import Session + +import crud +from models.database import get_db + +router = APIRouter() + + +@router.get("/{user_id}") +def get_metrics(user_id: str, db: Session = Depends(get_db)): + recs = crud.score_trend(db, user_id) # oldest first + return { + "user_id": user_id, + "score_trend": [ + { + "date": r.timestamp.isoformat() if r.timestamp else None, + "score": r.score, + "level": r.level, + } + for r in recs + ], + "latest_breakdown": recs[-1].breakdown if recs else {}, + "total_checkins": len(recs), + } diff --git a/backend/routes/response.py b/backend/routes/response.py new file mode 100644 index 0000000..9feab20 --- /dev/null +++ b/backend/routes/response.py @@ -0,0 +1,94 @@ +"""POST /response/submit — the orchestrator (prefix /response). + +Flow: signals (8002) -> ML score (8003) -> persist -> red-alert (best effort). +Called by Wesley's app (source="app") and Dhruva's SMS bot (source="sms"). +Each downstream call degrades gracefully so the backend works standalone. +""" +import logging + +from fastapi import APIRouter, Depends +from sqlalchemy.orm import Session + +import crud +import safety +from models.database import get_db +from schemas import ResponseSubmitRequest +from services import ml_client, scoring, signal_client, stimuli + +router = APIRouter() +log = logging.getLogger("pegasus.backend") + + +@router.post("/submit") +async def submit_response(data: ResponseSubmitRequest, db: Session = Depends(get_db)): + crud.ensure_user(db, data.user_id) + stimulus = stimuli.get_stimulus(data.stimulus_id) + video = crud.video_signals_for(db, data.user_id) # latest facial/voice, if any + raw = { + "response_text": data.response_text, + "response_time_ms": data.response_time_ms, + "response_latency_ms": data.response_latency_ms, + "typing_wpm": data.typing_wpm, + "error_rate": data.error_rate, + } + + # 1) Signal analysis (Dhruva, 8002). Fall back to a local heuristic if offline. + signals_ok = True + try: + analysis = await signal_client.analyze( + { + "user_id": data.user_id, + "response_text": data.response_text, + "response_time_ms": data.response_time_ms, + "typing_wpm": data.typing_wpm, + "error_rate": data.error_rate, + } + ) + except Exception as exc: # noqa: BLE001 - degrade gracefully + log.warning("signals service unavailable, using local analysis: %s", exc) + analysis = scoring.local_analysis(raw) + signals_ok = False + + # 2) Burnout scoring (Rishith, 8003). Fall back to local scoring if offline. + ml_signals = { + "sentiment_score": analysis.get("sentiment_score"), + "energy_level": analysis.get("energy_level"), + "linguistic_flags": analysis.get("linguistic_flags"), + "combined_signal_score": analysis.get("combined_signal_score"), + "typing_wpm": data.typing_wpm, + "error_rate": data.error_rate, + "response_time_ms": data.response_time_ms, + "response_latency_ms": data.response_latency_ms, + "facial_score": (video or {}).get("facial_score"), + "voice_score": (video or {}).get("voice_score"), + } + try: + ml_result = await ml_client.score(data.user_id, data.stimulus_id, ml_signals) + result = scoring.normalize_ml_result(ml_result, stimulus, analysis, raw, video) + except Exception as exc: # noqa: BLE001 - degrade gracefully + log.warning("ML service unavailable, using fallback scoring: %s", exc) + result = scoring.fallback_score(stimulus, analysis, raw, video) + + # Safety invariant: a red result always carries a human-support path (PRD §6). + result = safety.ensure_human_support(result) + + # 3) Persist. + rec = crud.save_score(db, data.user_id, data.stimulus_id, result, data.source) + + # 4) Red alert via signals service (best effort, never fails the request). + # Signals looks up the user's registered phone; we just fire on red. + alert = None + if result["level"] == "red": + try: + alert = await signal_client.send_alert( + data.user_id, result["score"], result["level"], result["intervention"] + ) + except Exception as exc: # noqa: BLE001 + log.warning("alert dispatch failed: %s", exc) + alert = {"sent": False, "error": str(exc)} + + out = rec.to_result() + out["services"] = {"signals": signals_ok, "ml": result["source"] == "tribe"} + if alert is not None: + out["alert"] = alert + return out diff --git a/backend/routes/score.py b/backend/routes/score.py new file mode 100644 index 0000000..a5961aa --- /dev/null +++ b/backend/routes/score.py @@ -0,0 +1,24 @@ +"""GET /score/{user_id} — latest burnout_result (prefix /score).""" +from fastapi import APIRouter, Depends +from sqlalchemy.orm import Session + +import crud +from models.database import get_db + +router = APIRouter() + + +@router.get("/{user_id}") +def get_latest_score(user_id: str, db: Session = Depends(get_db)): + rec = crud.latest_score(db, user_id) + if rec is None: + return { + "user_id": user_id, + "score": 0, + "level": "green", + "intervention": "Take your first pulse check!", + "breakdown": {}, + "top_indicators": [], + "source": "none", + } + return rec.to_result() diff --git a/backend/routes/stimulus.py b/backend/routes/stimulus.py new file mode 100644 index 0000000..ffe4d2b --- /dev/null +++ b/backend/routes/stimulus.py @@ -0,0 +1,26 @@ +"""Stimulus selection + delivery (prefix /stimulus).""" +from fastapi import APIRouter, HTTPException + +from services import stimuli + +router = APIRouter() + + +@router.get("/today/{user_id}") +def get_today_stimulus(user_id: str): + """Return today's stimulus for a user (stable daily rotation).""" + return stimuli.stimulus_for_today(user_id) + + +@router.get("/all") +def get_all_stimuli(): + items = stimuli.all_stimuli() + return {"count": len(items), "stimuli": items} + + +@router.get("/{stimulus_id}") +def get_stimulus(stimulus_id: str): + s = stimuli.get_stimulus(stimulus_id) + if s is None: + raise HTTPException(status_code=404, detail=f"Unknown stimulus_id '{stimulus_id}'") + return s diff --git a/backend/routes/video.py b/backend/routes/video.py new file mode 100644 index 0000000..ce8ba06 --- /dev/null +++ b/backend/routes/video.py @@ -0,0 +1,71 @@ +"""POST /video/submit — video check-in (prefix /video). + +Forwards the upload to Rishith's video service (8004), stores facial + voice +stress, then FUSES facial/voice into a burnout score (PRD §4.5, facial weighted +highest) so a video check-in actually moves the check engine light. Degrades +gracefully if the video or ML service is offline. + +Raw video bytes are never persisted — only the derived analysis (PRD §8). +""" +import logging + +from fastapi import APIRouter, Depends, File, Form, UploadFile +from sqlalchemy.orm import Session + +import crud +import safety +from models.database import get_db +from services import ml_client, scoring, video_client + +router = APIRouter() +log = logging.getLogger("pegasus.backend") + + +@router.post("/submit") +async def submit_video( + video: UploadFile = File(...), + user_id: str = Form(...), + db: Session = Depends(get_db), +): + crud.ensure_user(db, user_id) + content = await video.read() + + video_ok = True + try: + analysis = await video_client.analyze(content, video.filename or "checkin.mp4") + except Exception as exc: # noqa: BLE001 - degrade gracefully + log.warning("video service unavailable: %s", exc) + analysis = {"facial": {}, "voice": {}, "error": f"video service unavailable: {exc}"} + video_ok = False + + facial = analysis.get("facial", {}) or {} + voice = analysis.get("voice", {}) or {} + facial_score = int(facial.get("facial_stress_score", 0) or 0) + voice_score = int(voice.get("voice_stress_score", voice.get("stress_score", 0)) or 0) + combined_score = int(analysis.get("combined_score", 0) or 0) + crud.save_video(db, user_id, facial_score, combined_score, facial, voice) + + # Fuse facial/voice into a burnout score (use ML if available, else local fusion). + video_sig = {"facial_score": facial_score, "voice_score": voice_score, "combined_score": combined_score} + prior = crud.latest_score(db, user_id) + prior_result = prior.to_result() if prior else None + try: + ml_result = await ml_client.score(user_id, "video_checkin", {**video_sig, "channel": "video"}) + burnout = scoring.normalize_ml_result(ml_result, None, {}, {}, video_sig) + except Exception as exc: # noqa: BLE001 - degrade gracefully + log.warning("ML unavailable for video fusion, using local fusion: %s", exc) + burnout = scoring.video_fallback_score(video_sig, prior_result) + + burnout = safety.ensure_human_support(burnout) # PRD §6 + rec = crud.save_score(db, user_id, "video_checkin", burnout, "video") + + out = { + "facial": facial, + "voice": voice, + "combined_score": combined_score, + "video_service": video_ok, + "burnout_result": rec.to_result(), + } + if "error" in analysis: + out["error"] = analysis["error"] + return out diff --git a/backend/safety.py b/backend/safety.py new file mode 100644 index 0000000..3576319 --- /dev/null +++ b/backend/safety.py @@ -0,0 +1,37 @@ +"""Safety invariants for burnout results. + +PRD §6: Pegasus never diagnoses, and a RED result must ALWAYS include a path to +human support — never just an app suggestion. The backend is the single point +that returns the final burnout_result, so we enforce that invariant here, +regardless of whether the score came from the ML service or the local fallback. +""" + +# Shown to the user and appended to red interventions if not already present. +HUMAN_SUPPORT_TEXT = ( + "You don't have to handle this alone — reaching out is a strong move. Talk to " + "someone you trust or your campus counseling center, and if you'd like to speak " + "with a trained counselor right now, you can call or text 988 (the Suicide & " + "Crisis Lifeline, US) any time." +) + +# Structured resources for the app to render as tappable support options. +SUPPORT_RESOURCES = [ + {"name": "988 Suicide & Crisis Lifeline (US)", "contact": "Call or text 988", "type": "crisis"}, + {"name": "Crisis Text Line", "contact": "Text HOME to 741741", "type": "crisis"}, + {"name": "Campus counseling", "contact": "Reach your campus counseling center", "type": "counseling"}, + {"name": "A trusted person", "contact": "Message a friend or family member you trust", "type": "personal"}, +] + + +def support_for(level: str) -> list: + """Support resources to attach to a result (only red carries them).""" + return list(SUPPORT_RESOURCES) if level == "red" else [] + + +def ensure_human_support(result: dict) -> dict: + """Guarantee a red intervention names a concrete human-support path.""" + if result.get("level") == "red": + intervention = (result.get("intervention") or "").strip() + if "988" not in intervention: + result["intervention"] = (intervention + " " + HUMAN_SUPPORT_TEXT).strip() + return result diff --git a/backend/schemas.py b/backend/schemas.py new file mode 100644 index 0000000..959a6c7 --- /dev/null +++ b/backend/schemas.py @@ -0,0 +1,40 @@ +"""Pydantic request/response models aligned with shared/contract.json (v2).""" +from typing import Optional + +from pydantic import BaseModel + + +class ResponseSubmitRequest(BaseModel): + user_id: str + stimulus_id: str + response_text: str = "" + response_time_ms: float = 0.0 + response_latency_ms: float = 0.0 + typing_wpm: float = 0.0 + error_rate: float = 0.0 # 0-100 + source: str = "app" # "app" | "sms" + + +class Breakdown(BaseModel): + imessage: float = 0.0 + typing: float = 0.0 + facial: float = 0.0 + voice: float = 0.0 + tribe: float = 0.0 + + +class BurnoutResult(BaseModel): + user_id: Optional[str] = None + stimulus_id: Optional[str] = None + score: int + level: str + tribe_deviation: float = 0.0 + behavioral_deviation: float = 0.0 + top_indicators: list[str] = [] + intervention: str = "" + brain_regions_flagged: list[str] = [] + confidence: float = 0.0 + breakdown: Breakdown = Breakdown() + support: list[dict] = [] # human-support resources, populated on red (PRD §6) + source: Optional[str] = None # "app" | "sms" | "video" | "tribe" | "fallback" + timestamp: Optional[str] = None diff --git a/backend/services/__init__.py b/backend/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/services/ml_client.py b/backend/services/ml_client.py new file mode 100644 index 0000000..3034f4d --- /dev/null +++ b/backend/services/ml_client.py @@ -0,0 +1,22 @@ +"""HTTP client for Rishith's ML/TRIBE service (localhost:8003). + +Functions raise on transport/HTTP error; callers decide how to degrade. +""" +import httpx + +from config import ML_SERVICE, SERVICE_TIMEOUT + + +async def score(user_id: str, stimulus_id: str, signals: dict) -> dict: + payload = {"user_id": user_id, "stimulus_id": stimulus_id, "signals": signals} + async with httpx.AsyncClient(timeout=SERVICE_TIMEOUT) as client: + res = await client.post(f"{ML_SERVICE}/score", json=payload) + res.raise_for_status() + return res.json() + + +async def get_baseline(stimulus_id: str) -> dict: + async with httpx.AsyncClient(timeout=SERVICE_TIMEOUT) as client: + res = await client.get(f"{ML_SERVICE}/baseline/{stimulus_id}") + res.raise_for_status() + return res.json() diff --git a/backend/services/scoring.py b/backend/services/scoring.py new file mode 100644 index 0000000..8d4c307 --- /dev/null +++ b/backend/services/scoring.py @@ -0,0 +1,305 @@ +"""Backend-side scoring fallback + result normalization. + +When Rishith's ML service (8003) is up, we use its burnout_result directly +(normalize_ml_result). When it is offline, the local fallbacks produce a +principled burnout_result from the available signals, so the backend is +independently demoable. Both paths emit the contract `breakdown` +{imessage, typing, facial, voice, tribe} (PRD §4.5 fusion). +""" +from config import RED_THRESHOLD, YELLOW_THRESHOLD + +# A healthy brain is expected to engage more with activating content than calm. +_CATEGORY_EXPECTED = {"calm": 0.45, "neutral": 0.50, "activating": 0.72} + +_ACTIVE_REGIONS = { + "green": ["medial prefrontal cortex", "posterior cingulate cortex"], + "yellow": ["anterior cingulate cortex", "insula", "dorsolateral prefrontal cortex"], + "red": ["amygdala", "anterior cingulate cortex", "insula"], +} +_ATLAS = [ + "medial prefrontal cortex", + "dorsolateral prefrontal cortex", + "anterior cingulate cortex", + "posterior cingulate cortex", + "insula", + "amygdala", + "ventral striatum", + "hippocampus", +] + +# Plain-English, non-clinical descriptions for the Brain screen (PRD §5.2). +_REGION_EXPLANATIONS = { + "amygdala": "threat & emotional reactivity — tends to run hot when stress is high", + "anterior cingulate cortex": "conflict monitoring & emotional regulation — works harder under sustained strain", + "insula": "body awareness — tracks discomfort, tension, and fatigue", + "dorsolateral prefrontal cortex": "focus & self-control — dips when you're cognitively depleted", + "medial prefrontal cortex": "self-reflection & calm baseline — active when you feel settled", + "posterior cingulate cortex": "rest & mind-wandering — part of the calm 'default' network", + "ventral striatum": "reward & motivation — quiets down when drive is low", + "hippocampus": "memory & context — sensitive to chronic stress", +} + +_INTERVENTIONS = { + "green": "Your signals look steady today. Keep doing what works — a short walk " + "or a moment of gratitude can lock in the good momentum.", + "yellow": "Some early strain is showing. Try a 5-minute breathing break, step away " + "from the screen, and protect one boundary today (decline one nonessential " + "meeting).", + "red": "Your signals point to significant strain. Please pause and step away from " + "work for a bit, and take one thing off your plate today.", +} + + +def level_for(score: int) -> str: + if score < YELLOW_THRESHOLD: + return "green" + if score < RED_THRESHOLD: + return "yellow" + return "red" + + +def _intervention(level: str) -> str: + return _INTERVENTIONS.get(level, _INTERVENTIONS["yellow"]) + + +def region_explanations(regions: list[str]) -> dict: + return {r: _REGION_EXPLANATIONS.get(r, "involved in processing this stimulus") for r in regions or []} + + +def _expected_engagement(stimulus: dict | None) -> float: + if not stimulus: + return 0.5 + if "tribe_expected_engagement" in stimulus: + return float(stimulus["tribe_expected_engagement"]) + return _CATEGORY_EXPECTED.get(stimulus.get("category"), 0.5) + + +def _video_signals(video: dict | None) -> tuple[float, float]: + """(facial 0-100, voice 0-100) from a normalized video-signal dict.""" + if not video: + return 0.0, 0.0 + facial = float(video.get("facial_score") or 0) + voice = float(video.get("voice_score") or 0) + return round(min(100.0, facial), 1), round(min(100.0, voice), 1) + + +def synth_brain(level: str, score: int): + """Public: synthesized (flagged_regions, regions) for the brain-view fallback.""" + return _brain(level, score) + + +def _brain(level: str, score: int): + """Return (flagged_regions, {region: activation 0-1}) synthesized from score.""" + flagged = _ACTIVE_REGIONS.get(level, _ACTIVE_REGIONS["yellow"]) + base = score / 100.0 + regions = {} + for r in _ATLAS: + if r in flagged: + regions[r] = round(min(1.0, 0.55 + base * 0.45), 3) + else: + regions[r] = round(max(0.0, 0.35 - base * 0.2), 3) + return flagged, regions + + +def _typing_burnout(raw: dict) -> float: + """0-100 typing-biometrics burnout contribution from raw signals.""" + err = float(raw.get("error_rate") or 0) + wpm = float(raw.get("typing_wpm") or 0) + val = min(60.0, err) + if 0 < wpm < 20: + val += 25 + elif wpm > 120: + val += 10 + return round(min(100.0, val), 1) + + +def _breakdown(analysis: dict, raw: dict, tribe_dev: float, facial: float = 0.0, voice: float = 0.0) -> dict: + """Per-stream burnout contributions (0-100), the PRD §4.5 fusion inputs.""" + sentiment = float(analysis.get("sentiment_score", 0.5)) + return { + "imessage": round(min(100.0, (1 - sentiment) * 100), 1), + "typing": _typing_burnout(raw), + "facial": round(facial, 1), + "voice": round(voice, 1), + "tribe": round(min(100.0, tribe_dev * 100), 1), + } + + +def _indicators(analysis: dict, raw: dict, level: str) -> list[str]: + out: list[str] = [] + if float(analysis.get("sentiment_score", 0.5)) < 0.4: + out.append("negative sentiment") + if analysis.get("energy_level") == "low": + out.append("low energy") + if float(raw.get("error_rate") or 0) > 15: + out.append("high typing error rate") + wpm = float(raw.get("typing_wpm") or 0) + if 0 < wpm < 20: + out.append("slowed typing") + if float(raw.get("response_time_ms") or 0) > 60000: + out.append("delayed response") + for flag in analysis.get("linguistic_flags") or []: + out.append(str(flag).replace("_", " ")) + + if not out: + out = ["healthy engagement"] if level == "green" else ["elevated stress signals"] + + seen, deduped = set(), [] + for item in out: + if item not in seen: + seen.add(item) + deduped.append(item) + return deduped[:5] + + +def local_analysis(raw: dict) -> dict: + """Last-resort analysis when the signals service is ALSO offline.""" + err = float(raw.get("error_rate") or 0) + wpm = float(raw.get("typing_wpm") or 0) + rt = float(raw.get("response_time_ms") or 0) + + combined = min(35.0, err) + if 0 < wpm < 20: + combined += 20 + elif wpm > 120: + combined += 10 + if rt > 60000: + combined += 20 + elif rt > 30000: + combined += 10 + combined = min(100.0, combined) + + return { + "sentiment_score": 0.5, + "energy_level": "low" if combined >= 40 else "medium", + "linguistic_flags": [], + "combined_signal_score": round(combined, 2), + "detail": {}, + "_local": True, + } + + +def fallback_score(stimulus: dict | None, analysis: dict, raw: dict, video: dict | None = None) -> dict: + """Score a text/typing pulse-check locally from signals + the stimulus baseline. + + `video` (latest known facial/voice for the user) is surfaced in the breakdown + for continuity but does not move a text-channel score. + """ + expected = _expected_engagement(stimulus) + combined = float(analysis.get("combined_signal_score", 50)) / 100.0 # 0-1 burnout + actual_engagement = max(0.0, min(1.0, 1 - combined)) + + tribe_dev = round(abs(expected - actual_engagement), 3) + behavioral_dev = round(combined, 3) + facial, voice = _video_signals(video) + + score = int(round(100 * (0.6 * behavioral_dev + 0.4 * tribe_dev))) + score = max(0, min(100, score)) + level = level_for(score) + + flagged, regions = _brain(level, score) + return { + "score": score, + "level": level, + "tribe_deviation": tribe_dev, + "behavioral_deviation": behavioral_dev, + "top_indicators": _indicators(analysis, raw, level), + "intervention": _intervention(level), + "brain_regions_flagged": flagged, + "brain_regions": regions, + "breakdown": _breakdown(analysis, raw, tribe_dev, facial, voice), + "confidence": 0.55 if analysis.get("_local") else 0.7, + "source": "fallback", + } + + +def video_fallback_score(video: dict, prior: dict | None = None) -> dict: + """Fuse a video check-in into a burnout score (PRD §4.5, facial weighted highest). + + `prior` is the user's most recent burnout_result (for behavioral/text context). + """ + facial, voice = _video_signals(video) + video_burden = (0.6 * facial + 0.4 * voice) / 100.0 # facial weighted highest + + prior = prior or {} + prior_beh = float(prior.get("behavioral_deviation") or 0.0) + prior_bd = prior.get("breakdown") or {} + + score = int(round(100 * (0.65 * video_burden + 0.35 * prior_beh))) + score = max(0, min(100, score)) + level = level_for(score) + + indicators: list[str] = [] + if facial >= 65: + indicators.append("strained facial expression") + elif facial >= 40: + indicators.append("facial tension") + if voice >= 65: + indicators.append("flat or strained voice") + elif voice >= 40: + indicators.append("vocal stress") + if not indicators: + indicators = ["relaxed facial & vocal signals"] if level == "green" else ["elevated facial/vocal strain"] + + flagged, regions = _brain(level, score) + return { + "score": score, + "level": level, + "tribe_deviation": float(prior.get("tribe_deviation") or 0.0), + "behavioral_deviation": round(video_burden, 3), + "top_indicators": indicators[:5], + "intervention": _intervention(level), + "brain_regions_flagged": flagged, + "brain_regions": regions, + "breakdown": { + "imessage": prior_bd.get("imessage", 0.0), + "typing": prior_bd.get("typing", 0.0), + "facial": round(facial, 1), + "voice": round(voice, 1), + "tribe": prior_bd.get("tribe", 0.0), + }, + "confidence": 0.6, + "source": "fallback", + } + + +def normalize_ml_result( + result: dict, + stimulus: dict | None, + analysis: dict, + raw: dict, + video: dict | None = None, +) -> dict: + """Coerce the ML service's burnout_result into our stored shape. + + Tolerates missing fields and synthesizes brain_regions / breakdown if absent. + """ + score = max(0, min(100, int(round(float(result.get("score", 0)))))) + level = result.get("level") or level_for(score) + + regions = result.get("brain_regions") or result.get("regions") + flagged = result.get("brain_regions_flagged") or [] + if not regions: + synth_flagged, regions = _brain(level, score) + if not flagged: + flagged = synth_flagged + elif not flagged: + flagged = [k for k, _ in sorted(regions.items(), key=lambda kv: -kv[1])[:3]] + + tribe_dev = float(result.get("tribe_deviation", 0.0)) + facial, voice = _video_signals(video) + breakdown = result.get("breakdown") or _breakdown(analysis, raw, tribe_dev, facial, voice) + + return { + "score": score, + "level": level, + "tribe_deviation": tribe_dev, + "behavioral_deviation": float(result.get("behavioral_deviation", 0.0)), + "top_indicators": result.get("top_indicators") or [], + "intervention": result.get("intervention") or _intervention(level), + "brain_regions_flagged": flagged, + "brain_regions": regions, + "breakdown": breakdown, + "confidence": float(result.get("confidence", 0.8)), + "source": "tribe", + } diff --git a/backend/services/signal_client.py b/backend/services/signal_client.py new file mode 100644 index 0000000..9236161 --- /dev/null +++ b/backend/services/signal_client.py @@ -0,0 +1,41 @@ +"""HTTP client for Dhruva's signal-processing service (localhost:8002). + +Functions raise on transport/HTTP error; callers decide how to degrade. +NOTE: alerts go to POST /alert/send (no phone). Phone is registered separately +via POST /register-phone; the signals service looks it up on send. +""" +import httpx + +from config import SERVICE_TIMEOUT, SIGNAL_SERVICE + + +async def analyze(data: dict) -> dict: + """data: { user_id, response_text, response_time_ms, typing_wpm, error_rate }""" + async with httpx.AsyncClient(timeout=SERVICE_TIMEOUT) as client: + res = await client.post(f"{SIGNAL_SERVICE}/analyze", json=data) + res.raise_for_status() + return res.json() + + +async def get_signals(user_id: str) -> dict: + async with httpx.AsyncClient(timeout=SERVICE_TIMEOUT) as client: + res = await client.get(f"{SIGNAL_SERVICE}/signals/{user_id}") + res.raise_for_status() + return res.json() + + +async def send_alert(user_id: str, score, level: str, intervention: str) -> dict: + payload = {"user_id": user_id, "score": score, "level": level, "intervention": intervention} + async with httpx.AsyncClient(timeout=SERVICE_TIMEOUT) as client: + res = await client.post(f"{SIGNAL_SERVICE}/alert/send", json=payload) + res.raise_for_status() + return res.json() + + +async def register_phone(user_id: str, phone: str) -> dict: + """Tell the signals service which phone to text for this user.""" + payload = {"user_id": user_id, "phone": phone} + async with httpx.AsyncClient(timeout=SERVICE_TIMEOUT) as client: + res = await client.post(f"{SIGNAL_SERVICE}/register-phone", json=payload) + res.raise_for_status() + return res.json() diff --git a/backend/services/stimuli.py b/backend/services/stimuli.py new file mode 100644 index 0000000..f045147 --- /dev/null +++ b/backend/services/stimuli.py @@ -0,0 +1,37 @@ +"""Load the app-facing stimulus manifest from shared/stimuli.json.""" +import hashlib +import json +import os +from datetime import date +from functools import lru_cache + +from config import SHARED_DIR + + +@lru_cache(maxsize=1) +def _load() -> list[dict]: + path = os.path.join(SHARED_DIR, "stimuli.json") + with open(path, "r", encoding="utf-8") as f: + data = json.load(f) + return data.get("stimuli", []) + + +def all_stimuli() -> list[dict]: + return list(_load()) + + +def get_stimulus(stimulus_id: str) -> dict | None: + for s in _load(): + if s["id"] == stimulus_id: + return s + return None + + +def stimulus_for_today(user_id: str) -> dict: + """Deterministic daily rotation per user (same stimulus all day = stable demo).""" + items = _load() + if not items: + raise RuntimeError("stimulus manifest is empty") + key = f"{user_id}:{date.today().isoformat()}".encode() + idx = int(hashlib.sha256(key).hexdigest(), 16) % len(items) + return items[idx] diff --git a/backend/services/video_client.py b/backend/services/video_client.py new file mode 100644 index 0000000..d326dcf --- /dev/null +++ b/backend/services/video_client.py @@ -0,0 +1,16 @@ +"""HTTP client for Rishith's video service (localhost:8004). + +Forwards the uploaded video for facial + voice stress analysis. +Raises on transport/HTTP error; the route degrades gracefully. +""" +import httpx + +from config import VIDEO_SERVICE, VIDEO_TIMEOUT + + +async def analyze(video_bytes: bytes, filename: str) -> dict: + files = {"video": (filename, video_bytes, "video/mp4")} + async with httpx.AsyncClient(timeout=VIDEO_TIMEOUT) as client: + res = await client.post(f"{VIDEO_SERVICE}/analyze/video", files=files) + res.raise_for_status() + return res.json() diff --git a/console/server.py b/console/server.py new file mode 100644 index 0000000..e3b56f8 --- /dev/null +++ b/console/server.py @@ -0,0 +1,150 @@ +"""Pegasus web console (:4000) — a tiny ops dashboard. + +Click buttons to: send an SMS check-in, simulate a user reply (which scores it +and can set reminders), test the companion chat, hear the NVIDIA voice, and read +the live wellness score. The page calls THIS server, which proxies to the +running services (signals :8002, ml :8003, video :8004) — so no CORS hassle. + +Run: python3.12 -m uvicorn server:app --host 0.0.0.0 --port 4000 +""" +from __future__ import annotations + +import os + +import requests +from fastapi import FastAPI, Request +from fastapi.responses import HTMLResponse, JSONResponse + +SIGNALS = os.getenv("SIGNALS_URL", "http://127.0.0.1:8002").rstrip("/") +ML = os.getenv("ML_URL", "http://127.0.0.1:8003").rstrip("/") +VIDEO = os.getenv("VIDEO_URL", "http://127.0.0.1:8004").rstrip("/") +USER = "demo_user" + +app = FastAPI(title="Pegasus Console") + + +def _post(url: str, body: dict, timeout: int = 120) -> dict: + try: + r = requests.post(url, json=body, timeout=timeout) + try: + return {"ok": r.ok, "status": r.status_code, "data": r.json()} + except Exception: + return {"ok": r.ok, "status": r.status_code, "data": r.text} + except Exception as e: + return {"ok": False, "error": str(e)} + + +@app.post("/api/checkin") +async def checkin(req: Request): + p = (await req.json()).get("phone", "") + _post(f"{SIGNALS}/register-phone", {"user_id": USER, "phone": p}) + return JSONResponse(_post(f"{SIGNALS}/send-checkin", {"user_id": USER, "phone": p})) + + +@app.post("/api/inbound") +async def inbound(req: Request): + b = await req.json() + # Simulate Bloo.io delivering a user's text → scores it / sets reminders. + return JSONResponse(_post(f"{SIGNALS}/sms/webhook", { + "event": "message.received", "sender": b.get("phone", ""), "text": b.get("text", ""), + })) + + +@app.post("/api/chat") +async def chat(req: Request): + b = await req.json() + return JSONResponse(_post(f"{ML}/chat", { + "user_id": USER, "messages": [{"role": "user", "content": b.get("text", "")}], + })) + + +@app.post("/api/tts") +async def tts(req: Request): + b = await req.json() + return JSONResponse(_post(f"{VIDEO}/tts", {"text": b.get("text", "")})) + + +@app.get("/api/wellness") +async def wellness(): + try: + return JSONResponse({"ok": True, "data": requests.get(f"{ML}/wellness/{USER}", timeout=15).json()}) + except Exception as e: + return JSONResponse({"ok": False, "error": str(e)}) + + +@app.get("/", response_class=HTMLResponse) +def index(): + return HTML + + +HTML = """ + +Pegasus Console + +

Pegasus Console

+

Click a button — it talks to the running services for you.

+ +
+

📱 SMS

+ + + +
+ + +
+
+ +
+

💬 Companion + 🔊 Voice

+ + +
+ + + +
+ +
+ +

Output

ready.
+ +""" + + +if __name__ == "__main__": + import uvicorn + + uvicorn.run(app, host="0.0.0.0", port=4000) diff --git a/dev.sh b/dev.sh new file mode 100755 index 0000000..916d0ec --- /dev/null +++ b/dev.sh @@ -0,0 +1,49 @@ +#!/usr/bin/env bash +# Start all of Pegasus locally. First run sets up venvs + node_modules. +# ./dev.sh # set up (if needed) + run everything +# ./dev.sh setup # just install deps, don't run +set -euo pipefail +cd "$(dirname "$0")" + +PIDS=() +cleanup() { echo; echo "Stopping…"; for p in "${PIDS[@]:-}"; do kill "$p" 2>/dev/null || true; done; } +trap cleanup EXIT INT TERM + +py_service() { # name folder port + local name=$1 folder=$2 port=$3 + pushd "$folder" >/dev/null + [ -d .venv ] || python3 -m venv .venv + ./.venv/bin/pip install -q -r requirements.txt + if [ "${1:-}" != "__setup__" ] && [ "$RUN" = "1" ]; then + echo "→ $name on :$port" + ./.venv/bin/uvicorn main:app --port "$port" & + PIDS+=($!) + fi + popd >/dev/null +} + +RUN=1 +[ "${1:-}" = "setup" ] && RUN=0 + +py_service ml ml 8003 +py_service signals signals 8002 +py_service backend backend 8001 +# Video service is opt-in: heavy installs (mediapipe/whisper/ffmpeg). +# WITH_VIDEO=1 ./dev.sh +[ "${WITH_VIDEO:-0}" = "1" ] && py_service video video 8004 + +pushd frontend >/dev/null +[ -d node_modules ] || npm install +if [ "$RUN" = "1" ]; then + echo "→ frontend on :3000" + npm run dev & + PIDS+=($!) +fi +popd >/dev/null + +[ "$RUN" = "0" ] && { echo "Setup done. Run ./dev.sh to start."; exit 0; } + +echo +echo "Pegasus up: frontend http://localhost:3000 · backend :8001 · signals :8002 · ml :8003" +echo "Ctrl-C to stop." +wait diff --git a/face-emotion-api/README.md b/face-emotion-api/README.md new file mode 100644 index 0000000..2260f42 --- /dev/null +++ b/face-emotion-api/README.md @@ -0,0 +1,87 @@ +# Pegasus Face‑Emotion + +A small, reusable framework + HTTP API for **facial affect and multimodal emotion +detection**, built for [Pegasus](../README.md) (our mental‑health "check engine +light"). It turns a face — and optionally a voice — into a compact, product‑ready +read: the dominant emotion, the full distribution, valence/arousal, and a +**healthy‑vs‑distress** affect score. + +We extracted this from Pegasus's video pipeline so it can be dropped into any +wellbeing app that needs a quiet, passive emotion signal. + +## Why + +Most emotion APIs hand you raw class probabilities. Wellbeing apps need a +*single, calm signal* — "is this person reading as regulated or distressed?" — +plus the nuance to tell a forced smile from a genuine one. Pegasus Face‑Emotion +maps the raw FER‑7 distribution into valence/arousal and a stress score, and +fuses it with voice acoustics so the two modalities corroborate each other. + +## Install + +```bash +pip install -r requirements.txt +export HF_TOKEN=hf_... # a Hugging Face token +``` + +## Library + +```python +from face_emotion import EmotionDetector, fuse + +det = EmotionDetector() # default: trpakov/vit-face-expression +face = det.detect("selfie.jpg") # bytes | path | URL +# {'dominant_emotion': 'sad', 'valence': -0.41, 'arousal': 0.38, +# 'stress_score': 71, 'health_alignment': 0.29, 'emotions': {...}, ...} + +clip = det.detect_frames([f1, f2, f3]) # aggregate across video frames + +read = fuse(face, voice={"pitch_variability": 72, "voice_tremor": True}) +# {'mood': 'negative', 'stress_score': 78, 'signals': ['face:sad', 'voice:shaky'], ...} +``` + +## API + +```bash +uvicorn api:app --port 8010 +``` + +| Method | Path | Body | Returns | +|--------|------------------|----------------------------|--------------------------| +| POST | `/detect` | multipart `image` | emotion read | +| POST | `/detect/frames` | multipart `images` (many) | aggregated read | +| POST | `/fuse` | json `{face, voice}` | multimodal mood + stress | +| GET | `/health` | — | status | + +```bash +curl -F image=@selfie.jpg http://localhost:8010/detect +``` + +## How it works + +1. **Classify** — a Vision Transformer fine‑tuned for facial expression + (Hugging Face `trpakov/vit-face-expression`, FER‑7) gives a probability over + `angry · disgust · fear · happy · neutral · sad · surprise`. +2. **Map** — those probabilities are projected onto **valence** (pleasant↔unpleasant) + and **arousal** (calm↔activated), and onto a **healthy‑affect** vs **distress‑affect** + mass → a 0–100 stress score and a `health_alignment`. +3. **Fuse** — `fuse()` blends the facial stress with voice acoustics (pitch + variability + tremor), facial affect leading (0.6) and voice supporting (0.4). + +Everything degrades gracefully: a missing model/face yields an `unknown` read +rather than an exception. + +## Layout + +``` +face-emotion-api/ +├── api.py # FastAPI service +├── face_emotion/ +│ ├── __init__.py +│ ├── detector.py # EmotionDetector — classify + map +│ └── fusion.py # fuse() — face + voice +├── requirements.txt +└── pyproject.toml +``` + +Built by the Pegasus team — Rishith, Wesley, Dhruva and Jason. diff --git a/face-emotion-api/api.py b/face-emotion-api/api.py new file mode 100644 index 0000000..83bde70 --- /dev/null +++ b/face-emotion-api/api.py @@ -0,0 +1,60 @@ +"""Pegasus Face-Emotion API — a thin HTTP service over the framework. + + uvicorn api:app --port 8010 + + POST /detect multipart 'image' -> emotion read + POST /detect/frames multipart 'images' (several) -> aggregated read + POST /fuse json {face, voice} -> multimodal read + GET /health +""" +from __future__ import annotations + +from typing import List, Optional + +from fastapi import FastAPI, File, UploadFile +from fastapi.middleware.cors import CORSMiddleware +from pydantic import BaseModel + +from face_emotion import EmotionDetector, fuse + +app = FastAPI(title="Pegasus Face-Emotion API", version="0.1.0") +app.add_middleware(CORSMiddleware, allow_origins=["*"], allow_methods=["*"], allow_headers=["*"]) + +detector = EmotionDetector() + + +@app.get("/health") +def health(): + return {"status": "ok", "model": detector.model} + + +@app.post("/detect") +async def detect(image: UploadFile = File(...)): + return detector.detect(await image.read()) + + +@app.post("/detect/frames") +async def detect_frames(images: List[UploadFile] = File(...)): + frames = [await im.read() for im in images] + return detector.detect_frames(frames) + + +class Voice(BaseModel): + pitch_variability: float = 0.0 + voice_tremor: bool = False + + +class FuseReq(BaseModel): + face: dict + voice: Optional[Voice] = None + + +@app.post("/fuse") +def fuse_route(req: FuseReq): + return fuse(req.face, req.voice.dict() if req.voice else None) + + +if __name__ == "__main__": + import uvicorn + + uvicorn.run(app, host="0.0.0.0", port=8010) diff --git a/face-emotion-api/face_emotion/__init__.py b/face-emotion-api/face_emotion/__init__.py new file mode 100644 index 0000000..babdfa4 --- /dev/null +++ b/face-emotion-api/face_emotion/__init__.py @@ -0,0 +1,14 @@ +"""Pegasus Face-Emotion — a small framework for facial affect + multimodal +emotion detection, built for the Pegasus mental-health app. + + from face_emotion import EmotionDetector, fuse + + det = EmotionDetector() + face = det.detect("selfie.jpg") # dominant emotion + valence/arousal + stress + read = fuse(face, voice={"pitch_variability": 70, "voice_tremor": True}) +""" +from .detector import EmotionDetector +from .fusion import fuse + +__all__ = ["EmotionDetector", "fuse"] +__version__ = "0.1.0" diff --git a/face-emotion-api/face_emotion/detector.py b/face-emotion-api/face_emotion/detector.py new file mode 100644 index 0000000..8a4a483 --- /dev/null +++ b/face-emotion-api/face_emotion/detector.py @@ -0,0 +1,101 @@ +"""EmotionDetector — the core of the Pegasus Face-Emotion framework. + +Classifies facial affect from an image with a ViT face-expression model +(Hugging Face, default ``trpakov/vit-face-expression``, FER-7 labels: angry, +disgust, fear, happy, neutral, sad, surprise) and maps it to a compact read: +the dominant emotion, the full distribution, a valence/arousal estimate, and a +"healthy vs. distress" affect score for wellbeing apps. + +Single image or a batch of frames (a short video). Degrades gracefully — an +empty/unknown read rather than an exception — so a product never hard-fails. +""" +from __future__ import annotations + +import os +from collections import defaultdict +from typing import Dict, List, Optional + +FACE_MODEL = os.getenv("FACE_EMOTION_MODEL", "trpakov/vit-face-expression") +HF_TOKEN = os.getenv("HF_TOKEN") or os.getenv("HF_API_KEY") + +# FER label → valence (unpleasant -1 .. pleasant 1) and arousal (calm 0 .. 1). +_VALENCE = {"happy": 0.9, "surprise": 0.3, "neutral": 0.0, + "sad": -0.8, "fear": -0.7, "angry": -0.8, "disgust": -0.6} +_AROUSAL = {"happy": 0.6, "surprise": 0.85, "neutral": 0.2, + "sad": 0.4, "fear": 0.9, "angry": 0.85, "disgust": 0.6} +# A regulated, healthy face reads neutral/positive; distress reads negative. +_HEALTHY = {"happy": 1.0, "neutral": 0.85, "surprise": 0.55} +_STRESS = {"sad": 1.0, "fear": 1.0, "angry": 0.9, "disgust": 0.8} + + +class EmotionDetector: + """Detect facial emotion + affect from images. Thread-safe to construct once + and reuse (lazy Hugging Face client).""" + + def __init__(self, model: str = FACE_MODEL, token: Optional[str] = HF_TOKEN) -> None: + self.model = model + self._token = token + self._client = None + + def _hf(self): + if self._client is None: + from huggingface_hub import InferenceClient + + self._client = InferenceClient(token=self._token) + return self._client + + # --- public ------------------------------------------------------------ + def detect(self, image) -> Dict: + """`image`: raw bytes, a file path, or a URL. Returns the emotion read.""" + return self._summarize(self._classify(image)) + + def detect_frames(self, images: List) -> Dict: + """Aggregate the emotion read across many frames (e.g. a short clip).""" + agg: Dict[str, float] = defaultdict(float) + n = 0 + for im in images: + dist = self._classify(im) + if dist: + for label, score in dist.items(): + agg[label] += score + n += 1 + if n == 0: + return self._empty() + dist = {k: round(v / n, 4) for k, v in agg.items()} + return self._summarize(dist, frames=n) + + # --- internals --------------------------------------------------------- + def _classify(self, image) -> Dict[str, float]: + try: + res = self._hf().image_classification(image, model=self.model) + return {r.label.lower(): float(r.score) for r in res} + except Exception: + return {} + + def _summarize(self, dist: Dict[str, float], frames: int = 1) -> Dict: + if not dist: + return self._empty() + dominant = max(dist, key=dist.get) + valence = sum(dist.get(k, 0.0) * w for k, w in _VALENCE.items()) + arousal = sum(dist.get(k, 0.0) * w for k, w in _AROUSAL.items()) + stress = sum(dist.get(k, 0.0) * w for k, w in _STRESS.items()) + healthy = sum(dist.get(k, 0.0) * w for k, w in _HEALTHY.items()) + total = stress + healthy + 1e-6 + return { + "dominant_emotion": dominant, + "emotions": dist, + "valence": round(valence, 3), + "arousal": round(arousal, 3), + "stress_score": int(round(min(stress / total, 1.0) * 100)), + "health_alignment": round(healthy / total, 3), + "frames": frames, + "model": self.model, + } + + @staticmethod + def _empty() -> Dict: + return { + "dominant_emotion": "unknown", "emotions": {}, "valence": 0.0, + "arousal": 0.0, "stress_score": 0, "health_alignment": 0.0, + "frames": 0, "model": FACE_MODEL, + } diff --git a/face-emotion-api/face_emotion/fusion.py b/face-emotion-api/face_emotion/fusion.py new file mode 100644 index 0000000..1a2d2a3 --- /dev/null +++ b/face-emotion-api/face_emotion/fusion.py @@ -0,0 +1,34 @@ +"""Multimodal fusion — combine the facial emotion read with voice acoustics +(pitch variability, tremor) into a single affect + stress estimate. + +Faces and voices each leak stress in different ways; fusing them is more robust +than either alone (a forced smile with a shaky voice is not "happy"). +""" +from __future__ import annotations + +from typing import Dict, List, Optional + + +def fuse(face: Dict, voice: Optional[Dict] = None) -> Dict: + """`face` from EmotionDetector.detect(); `voice` optional dict with + ``pitch_variability`` (Hz) and ``voice_tremor`` (bool). Returns a unified read. + Facial affect leads (0.6); voice arousal supports (0.4).""" + stress = float(face.get("stress_score", 0)) + signals: List[str] = [f"face:{face.get('dominant_emotion', 'unknown')}"] + + if voice: + pitch_var = float(voice.get("pitch_variability", 0) or 0) + tremor = bool(voice.get("voice_tremor", False)) + voice_stress = (40.0 if tremor else 0.0) + min(pitch_var / 100.0, 1.0) * 40.0 + stress = stress * 0.6 + voice_stress * 0.4 + signals.append("voice:" + ("shaky" if tremor else "tense" if pitch_var > 55 else "steady")) + + valence = float(face.get("valence", 0)) + mood = "positive" if valence > 0.2 else "negative" if valence < -0.2 else "neutral" + return { + "mood": mood, + "stress_score": int(round(min(max(stress, 0), 100))), + "valence": valence, + "arousal": float(face.get("arousal", 0)), + "signals": signals, + } diff --git a/face-emotion-api/pyproject.toml b/face-emotion-api/pyproject.toml new file mode 100644 index 0000000..b0dbfc9 --- /dev/null +++ b/face-emotion-api/pyproject.toml @@ -0,0 +1,20 @@ +[build-system] +requires = ["setuptools>=68"] +build-backend = "setuptools.build_meta" + +[project] +name = "pegasus-face-emotion" +version = "0.1.0" +description = "Facial affect + multimodal emotion detection framework (built for Pegasus)" +readme = "README.md" +requires-python = ">=3.10" +authors = [{ name = "Pegasus team — Rishith, Wesley, Dhruva, Jason" }] +dependencies = [ + "huggingface-hub>=0.27", +] + +[project.optional-dependencies] +api = ["fastapi>=0.115", "uvicorn[standard]>=0.34", "python-multipart>=0.0.20", "pydantic>=2.10"] + +[tool.setuptools] +packages = ["face_emotion"] diff --git a/face-emotion-api/requirements.txt b/face-emotion-api/requirements.txt new file mode 100644 index 0000000..a8b4e83 --- /dev/null +++ b/face-emotion-api/requirements.txt @@ -0,0 +1,5 @@ +fastapi==0.115.6 +uvicorn[standard]==0.34.0 +pydantic==2.10.4 +huggingface-hub==0.27.0 +python-multipart==0.0.20 diff --git a/frontend/.npmrc b/frontend/.npmrc new file mode 100644 index 0000000..521a9f7 --- /dev/null +++ b/frontend/.npmrc @@ -0,0 +1 @@ +legacy-peer-deps=true diff --git a/frontend/AGENTS.md b/frontend/AGENTS.md new file mode 100644 index 0000000..175ec73 --- /dev/null +++ b/frontend/AGENTS.md @@ -0,0 +1,49 @@ +# `/frontend` — SHARED by Rishith + Wesley (Expo app, :3000) + +This is an **Expo (React Native + TypeScript)** app with **two owners**. Stay +strictly in your files so you never overwrite each other. + +## Rishith owns — the DATA LAYER +``` +frontend/src/services/api.ts +frontend/src/services/config.ts +frontend/src/hooks/* (useResponseTracker, useBurnoutScore, useCamera) +frontend/src/types/index.ts +``` +Plus the one-time Expo bootstrap (`package.json`, `app.json`, `tsconfig.json`, +`babel.config.js`, `index.ts`) — but `App.tsx` and everything below is Wesley's. + +## Wesley owns — the VISUAL LAYER +``` +frontend/App.tsx +frontend/src/navigation/* +frontend/src/screens/* +frontend/src/components/* +frontend/src/utils/* (colors.ts, formatting.ts) +``` +Wesley **imports** Rishith's types/api/hooks and never edits them. + +## The interface (stable — Wesley imports these) +```ts +import { Stimulus, UserResponse, BurnoutResult, BrainData, FacialAnalysis, VoiceAnalysis } from "./src/types"; +import { getStimulus, submitResponse, getScore, getHistory, getBrainData, getMetrics, submitVideo } from "./src/services/api"; +import { useResponseTracker } from "./src/hooks/useResponseTracker"; // { onKeyPress, onChangeText, getMetrics, reset } +import { useBurnoutScore } from "./src/hooks/useBurnoutScore"; // { score, loading, error, refresh } +import { useCamera } from "./src/hooks/useCamera"; // { cameraRef, granted, requestPermission, isRecording, startRecording, stopRecording } +``` + +## Commit staging (NEVER `git add frontend/` or `git add .`) +```bash +# Rishith: +git add frontend/src/services/ frontend/src/hooks/ frontend/src/types/ ml/ video/ +# Wesley: +git add frontend/App.tsx frontend/src/navigation/ frontend/src/screens/ frontend/src/components/ frontend/src/utils/ +``` + +## Run +```bash +cd frontend && npm install +npx expo install react-native-chart-kit react-native-svg @react-navigation/native @react-navigation/bottom-tabs react-native-screens react-native-safe-area-context expo-linear-gradient expo-haptics react-native-reanimated +npx expo start # press i (iOS) / a (Android), or scan with Expo Go +``` +Set your laptop's LAN IP in `src/services/config.ts` so the phone reaches the backend. diff --git a/frontend/App.tsx b/frontend/App.tsx new file mode 100644 index 0000000..b238d1d --- /dev/null +++ b/frontend/App.tsx @@ -0,0 +1,43 @@ +// Pegasus app root. Owned by Wesley (visual layer). +// Wires the light navigation theme, gesture handler, and safe-area providers +// around the bottom tab navigator. Screens live under src/screens. +import "react-native-gesture-handler"; +import React from "react"; +import { StatusBar } from "expo-status-bar"; +import { GestureHandlerRootView } from "react-native-gesture-handler"; +import { SafeAreaProvider } from "react-native-safe-area-context"; +import { + NavigationContainer, + DefaultTheme, + Theme, +} from "@react-navigation/native"; + +import TabNavigator from "./src/navigation/TabNavigator"; +import { COLORS } from "./src/utils/colors"; + +const navTheme: Theme = { + ...DefaultTheme, + dark: false, + colors: { + ...DefaultTheme.colors, + primary: COLORS.blue, + background: COLORS.bg, + card: COLORS.card, + text: COLORS.text, + border: COLORS.border, + notification: COLORS.red, + }, +}; + +export default function App() { + return ( + + + + + + + + + ); +} diff --git a/frontend/app.json b/frontend/app.json new file mode 100644 index 0000000..0111630 --- /dev/null +++ b/frontend/app.json @@ -0,0 +1,14 @@ +{ + "expo": { + "name": "Pegasus", + "slug": "pegasus", + "version": "1.0.0", + "orientation": "portrait", + "userInterfaceStyle": "dark", + "scheme": "pegasus", + "splash": { "backgroundColor": "#0a0a0a" }, + "ios": { "supportsTablet": true, "infoPlist": { "NSCameraUsageDescription": "Pegasus uses the camera for your 30-second video check-in.", "NSMicrophoneUsageDescription": "Pegasus uses the mic to analyze voice stress during your check-in." } }, + "android": { "permissions": ["CAMERA", "RECORD_AUDIO"] }, + "plugins": ["expo-camera"] + } +} diff --git a/frontend/assets/brain/fsaverage5.glb b/frontend/assets/brain/fsaverage5.glb new file mode 100644 index 0000000..11cba59 Binary files /dev/null and b/frontend/assets/brain/fsaverage5.glb differ diff --git a/frontend/babel.config.js b/frontend/babel.config.js new file mode 100644 index 0000000..c095986 --- /dev/null +++ b/frontend/babel.config.js @@ -0,0 +1,9 @@ +module.exports = function (api) { + api.cache(true); + return { + presets: ["babel-preset-expo"], + // Reanimated 4 moved its Babel transform into react-native-worklets. + // This plugin MUST be listed last. + plugins: ["react-native-worklets/plugin"], + }; +}; diff --git a/frontend/index.ts b/frontend/index.ts new file mode 100644 index 0000000..d2bae22 --- /dev/null +++ b/frontend/index.ts @@ -0,0 +1,4 @@ +import { registerRootComponent } from "expo"; +import App from "./App"; + +registerRootComponent(App); diff --git a/frontend/metro.config.js b/frontend/metro.config.js new file mode 100644 index 0000000..0291f6d --- /dev/null +++ b/frontend/metro.config.js @@ -0,0 +1,9 @@ +// Metro config — extend Expo's default so the real 3D brain mesh bundles. +// expo-three / GLTFLoader load assets/brain/fsaverage5.glb via require(), and +// Metro won't bundle .glb/.gltf/.bin unless we add them to assetExts. +const { getDefaultConfig } = require("expo/metro-config"); + +const config = getDefaultConfig(__dirname); +config.resolver.assetExts.push("glb", "gltf", "bin"); + +module.exports = config; diff --git a/frontend/package-lock.json b/frontend/package-lock.json new file mode 100644 index 0000000..f66a52f --- /dev/null +++ b/frontend/package-lock.json @@ -0,0 +1,10088 @@ +{ + "name": "pegasus", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "pegasus", + "version": "1.0.0", + "dependencies": { + "@expo/ngrok": "^4.1.3", + "@react-navigation/bottom-tabs": "^6.6.1", + "@react-navigation/native": "^6.1.18", + "expo": "^54.0.35", + "expo-asset": "~12.0.13", + "expo-av": "~16.0.8", + "expo-blur": "~15.0.8", + "expo-camera": "~17.0.10", + "expo-file-system": "~19.0.23", + "expo-gl": "~16.0.10", + "expo-haptics": "~15.0.8", + "expo-linear-gradient": "~15.0.8", + "expo-status-bar": "~3.0.9", + "expo-three": "^8.0.0", + "react": "19.1.0", + "react-native": "0.81.5", + "react-native-chart-kit": "^6.12.3", + "react-native-gesture-handler": "~2.28.0", + "react-native-reanimated": "~4.1.1", + "react-native-safe-area-context": "~5.6.0", + "react-native-screens": "~4.16.0", + "react-native-svg": "15.12.1", + "react-native-worklets": "0.5.1", + "three": "^0.166.1" + }, + "devDependencies": { + "@babel/core": "^7.24.0", + "@types/react": "~19.1.10", + "@types/three": "^0.166.0", + "babel-preset-expo": "~54.0.10", + "typescript": "~5.9.2" + } + }, + "node_modules/@0no-co/graphql.web": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@0no-co/graphql.web/-/graphql.web-1.3.2.tgz", + "integrity": "sha512-Q1+pRlLhE31GOY/2c9BAEnFTNxO7Awtc6fhhEDlxyCBQ2N0IhD32cPVvPChrK9mwBNSgRdW/sF1kd2e0ojHj1Q==", + "license": "MIT", + "peerDependencies": { + "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0" + }, + "peerDependenciesMeta": { + "graphql": { + "optional": true + } + } + }, + "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==", + "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==", + "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==", + "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==", + "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-annotate-as-pure": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.29.7.tgz", + "integrity": "sha512-OoK6239jHPuSQOoS0kfTVKn0b/rVTk0seKq4Gd2UMLtmOVLjDC0ki3e+c90Trqv2gMfvJFqkiljrr568+qddiw==", + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.7" + }, + "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==", + "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-create-class-features-plugin": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-create-class-features-plugin/-/helper-create-class-features-plugin-7.29.7.tgz", + "integrity": "sha512-IY3ZD9Tmooqr3TUhc3DUWxiuo8xx1DWLhd5M7hQ+ZWJamqM2BbalrBJb2MisSLoYorOj75U03qULCxQTY9r3hg==", + "license": "MIT", + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.29.7", + "@babel/helper-member-expression-to-functions": "^7.29.7", + "@babel/helper-optimise-call-expression": "^7.29.7", + "@babel/helper-replace-supers": "^7.29.7", + "@babel/helper-skip-transparent-expression-wrappers": "^7.29.7", + "@babel/traverse": "^7.29.7", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-create-regexp-features-plugin": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-create-regexp-features-plugin/-/helper-create-regexp-features-plugin-7.29.7.tgz", + "integrity": "sha512-907Uymvqgg1dwUA+7IGwFAOSYzQOuzPXKNJ1yxzwPffzkYFg2q2eHi1fIOs6sXkG9NbIUMunnUlkYsfRFNvomg==", + "license": "MIT", + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.29.7", + "regexpu-core": "^6.3.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-define-polyfill-provider": { + "version": "0.6.8", + "resolved": "https://registry.npmjs.org/@babel/helper-define-polyfill-provider/-/helper-define-polyfill-provider-0.6.8.tgz", + "integrity": "sha512-47UwBLPpQi1NoWzLuHNjRoHlYXMwIJoBf7MFou6viC/sIHWYygpvr0B6IAyh5sBdA2nr2LPIRww8lfaUVQINBA==", + "license": "MIT", + "dependencies": { + "@babel/helper-compilation-targets": "^7.28.6", + "@babel/helper-plugin-utils": "^7.28.6", + "debug": "^4.4.3", + "lodash.debounce": "^4.0.8", + "resolve": "^1.22.11" + }, + "peerDependencies": { + "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.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==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-member-expression-to-functions": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-member-expression-to-functions/-/helper-member-expression-to-functions-7.29.7.tgz", + "integrity": "sha512-j+7JYmk1JYDtACIGj0QJqqWZjoUpMoEikQGADMaHgCMCSDqd2+P32rfcibUNrGOMWrlzK1WJBdxrB3JJQZwWtg==", + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "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==", + "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==", + "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-optimise-call-expression": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-optimise-call-expression/-/helper-optimise-call-expression-7.29.7.tgz", + "integrity": "sha512-+kmGVjcT9RGYzoDwdwEqEvGgKe3BYq+O1iGzjFubaNgZHwYHP6lsF2Yghf4kEuv9BV7tYDZ913aBW9am6YKong==", + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.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==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-remap-async-to-generator": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-remap-async-to-generator/-/helper-remap-async-to-generator-7.29.7.tgz", + "integrity": "sha512-16AMiW26DbXWBbr3B8wNozKM0ydMLB892vaOaJW/fPJdnT8vJk5sdkQcU/isqUxyCE0cEoa8wZOcbgDuC4b6Og==", + "license": "MIT", + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.29.7", + "@babel/helper-wrap-function": "^7.29.7", + "@babel/traverse": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-replace-supers": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-replace-supers/-/helper-replace-supers-7.29.7.tgz", + "integrity": "sha512-atfGXWSeCiF4DnKZIfmJfQRkSw9b9gNNXR1kqKjbhG4pGYCOnkp8OcTB8E3NXjBu8NpheSnOeNKz8KT7UNFTmQ==", + "license": "MIT", + "dependencies": { + "@babel/helper-member-expression-to-functions": "^7.29.7", + "@babel/helper-optimise-call-expression": "^7.29.7", + "@babel/traverse": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-skip-transparent-expression-wrappers": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-skip-transparent-expression-wrappers/-/helper-skip-transparent-expression-wrappers-7.29.7.tgz", + "integrity": "sha512-brcMGQaVzIeUb+6/bs1Av0f8YuNNjKY2JyvfRCsFuFsdKccEQ5Ges2y74D74NZ1Rz8lKJ9ksJkfqwQFJ/iNEyQ==", + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "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==", + "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==", + "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==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-wrap-function": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-wrap-function/-/helper-wrap-function-7.29.7.tgz", + "integrity": "sha512-iES0Skag9ERIF68aXadpO6dbXa03mNWK3sEqJaMnLNs/eC3l0lkImdfoy6Y09/SfkpawdAB4RjQ7PVA7TcVGdw==", + "license": "MIT", + "dependencies": { + "@babel/template": "^7.29.7", + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "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==", + "license": "MIT", + "dependencies": { + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/highlight": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/highlight/-/highlight-7.25.9.tgz", + "integrity": "sha512-llL88JShoCsth8fF8R4SJnIn+WLvR6ccFxu1H3FlMhDontdcmZWf2HgIZ7AIqV3Xcck1idlohrN4EUBQz6klbw==", + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.25.9", + "chalk": "^2.4.2", + "js-tokens": "^4.0.0", + "picocolors": "^1.0.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/highlight/node_modules/ansi-styles": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz", + "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", + "license": "MIT", + "dependencies": { + "color-convert": "^1.9.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/@babel/highlight/node_modules/chalk": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz", + "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^3.2.1", + "escape-string-regexp": "^1.0.5", + "supports-color": "^5.3.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/@babel/highlight/node_modules/color-convert": { + "version": "1.9.3", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", + "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==", + "license": "MIT", + "dependencies": { + "color-name": "1.1.3" + } + }, + "node_modules/@babel/highlight/node_modules/color-name": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz", + "integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==", + "license": "MIT" + }, + "node_modules/@babel/highlight/node_modules/escape-string-regexp": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", + "integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==", + "license": "MIT", + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/@babel/highlight/node_modules/has-flag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", + "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/@babel/highlight/node_modules/supports-color": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", + "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", + "license": "MIT", + "dependencies": { + "has-flag": "^3.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "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==", + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.7" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-proposal-decorators": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-decorators/-/plugin-proposal-decorators-7.29.7.tgz", + "integrity": "sha512-EtU0Hi3GvrTqD56xKmZvV/uCXK2ZbwVNPNLAquVItcAZpUhkXwWlo3Fmj0c2LxgSf2I8IDULeAepwNP1OefLXg==", + "license": "MIT", + "dependencies": { + "@babel/helper-create-class-features-plugin": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7", + "@babel/plugin-syntax-decorators": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-proposal-export-default-from": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-export-default-from/-/plugin-proposal-export-default-from-7.29.7.tgz", + "integrity": "sha512-p+G5BNXDcy3bOXplhY4HybQ1GxH3i2Tppmdm/3epyRu2VgJJZuUlZ61MqRTg582Q7ZLBdP7fePYvsumSEkMxcQ==", + "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-syntax-async-generators": { + "version": "7.8.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-async-generators/-/plugin-syntax-async-generators-7.8.4.tgz", + "integrity": "sha512-tycmZxkGfZaxhMRbXlPXuVFpdWlXpir2W4AMhSJgRKzk/eDlIXOhb2LHWoLpDF7TEHylV5zNhykX6KAgHJmTNw==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-bigint": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-bigint/-/plugin-syntax-bigint-7.8.3.tgz", + "integrity": "sha512-wnTnFlG+YxQm3vDxpGE57Pj0srRU4sHE/mDkt1qv2YJJSeUAec2ma4WLUnUPeKjyrfntVwe/N6dCXpU+zL3Npg==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-class-properties": { + "version": "7.12.13", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-class-properties/-/plugin-syntax-class-properties-7.12.13.tgz", + "integrity": "sha512-fm4idjKla0YahUNgFNLCB0qySdsoPiZP3iQE3rky0mBUtMZ23yDJ9SJdg6dXTSDnulOVqiF3Hgr9nbXvXTQZYA==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.12.13" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-class-static-block": { + "version": "7.14.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-class-static-block/-/plugin-syntax-class-static-block-7.14.5.tgz", + "integrity": "sha512-b+YyPmr6ldyNnM6sqYeMWE+bgJcJpO6yS4QD7ymxgH34GBPNDM/THBh8iunyvKIZztiwLH4CJZ0RxTk9emgpjw==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.14.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-decorators": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-decorators/-/plugin-syntax-decorators-7.29.7.tgz", + "integrity": "sha512-9MTTLbF39X6sqM92JPEsoI7++26hjZvzkxKZy64aMhWLH2mPkJ/Q3AV4QLmls3R14FpSpkOwQQfUh962JGQxxg==", + "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-syntax-dynamic-import": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-dynamic-import/-/plugin-syntax-dynamic-import-7.8.3.tgz", + "integrity": "sha512-5gdGbFon+PszYzqs83S3E5mpi7/y/8M9eC90MRTZfduQOYW76ig6SOSPNe41IG5LoP3FGBn2N0RjVDSQiS94kQ==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-export-default-from": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-export-default-from/-/plugin-syntax-export-default-from-7.29.7.tgz", + "integrity": "sha512-foag0BB37ROhdeIX9O8G0jX7hw0UekJc04cHMrYLOnrErsnBKqJGHJ8eDRpoCFZBvEPPygmmtw4qyU97qa4oOw==", + "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-syntax-flow": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-flow/-/plugin-syntax-flow-7.29.7.tgz", + "integrity": "sha512-ajMX6QPcyomotqwpzhkYGxcK2i/us0rs1Qo9QvUpa+Fca0FTmqrzKrctoIYLMxcOhGZldGT/BAVkRGTWBiR8gQ==", + "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-syntax-import-attributes": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-attributes/-/plugin-syntax-import-attributes-7.29.7.tgz", + "integrity": "sha512-zGYcYfq/WmZ4V+kBIXQon9dSSc8ircGZqw9ZaNhhGj9nZkeBu1jHLBDQqYYi5WA9uawvA2sIMbry2nCFhf5Djg==", + "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-syntax-import-meta": { + "version": "7.10.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-meta/-/plugin-syntax-import-meta-7.10.4.tgz", + "integrity": "sha512-Yqfm+XDx0+Prh3VSeEQCPU81yC+JWZ2pDPFSS4ZdpfZhp4MkFMaDC1UqseovEKwSUpnIL7+vK+Clp7bfh0iD7g==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.10.4" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-json-strings": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-json-strings/-/plugin-syntax-json-strings-7.8.3.tgz", + "integrity": "sha512-lY6kdGpWHvjoe2vk4WrAapEuBR69EMxZl+RoGRhrFGNYVK8mOPAW8VfbT/ZgrFbXlDNiiaxQnAtgVCZ6jv30EA==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-jsx": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-jsx/-/plugin-syntax-jsx-7.29.7.tgz", + "integrity": "sha512-TSu8+mHCoEaaCDEZ0I3+6mvTBYR4PCxQwf2z9/r5Tbztv6NaLR3B9thGTTxX2WGuGHJqRiAbKPeGTJ5XWXVg6A==", + "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-syntax-logical-assignment-operators": { + "version": "7.10.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-logical-assignment-operators/-/plugin-syntax-logical-assignment-operators-7.10.4.tgz", + "integrity": "sha512-d8waShlpFDinQ5MtvGU9xDAOzKH47+FFoney2baFIoMr952hKOLp1HR7VszoZvOsV/4+RRszNY7D17ba0te0ig==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.10.4" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-nullish-coalescing-operator": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-nullish-coalescing-operator/-/plugin-syntax-nullish-coalescing-operator-7.8.3.tgz", + "integrity": "sha512-aSff4zPII1u2QD7y+F8oDsz19ew4IGEJg9SVW+bqwpwtfFleiQDMdzA/R+UlWDzfnHFCxxleFT0PMIrR36XLNQ==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-numeric-separator": { + "version": "7.10.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-numeric-separator/-/plugin-syntax-numeric-separator-7.10.4.tgz", + "integrity": "sha512-9H6YdfkcK/uOnY/K7/aA2xpzaAgkQn37yzWUMRK7OaPOqOpGS1+n0H5hxT9AUw9EsSjPW8SVyMJwYRtWs3X3ug==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.10.4" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-object-rest-spread": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-object-rest-spread/-/plugin-syntax-object-rest-spread-7.8.3.tgz", + "integrity": "sha512-XoqMijGZb9y3y2XskN+P1wUGiVwWZ5JmoDRwx5+3GmEplNyVM2s2Dg8ILFQm8rWM48orGy5YpI5Bl8U1y7ydlA==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-optional-catch-binding": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-optional-catch-binding/-/plugin-syntax-optional-catch-binding-7.8.3.tgz", + "integrity": "sha512-6VPD0Pc1lpTqw0aKoeRTMiB+kWhAoT24PA+ksWSBrFtl5SIRVpZlwN3NNPQjehA2E/91FV3RjLWoVTglWcSV3Q==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-optional-chaining": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-optional-chaining/-/plugin-syntax-optional-chaining-7.8.3.tgz", + "integrity": "sha512-KoK9ErH1MBlCPxV0VANkXW2/dw4vlbGDrFgz8bmUsBGYkFRcbRwMh6cIJubdPrkxRwuGdtCk0v/wPTKbQgBjkg==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-private-property-in-object": { + "version": "7.14.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-private-property-in-object/-/plugin-syntax-private-property-in-object-7.14.5.tgz", + "integrity": "sha512-0wVnp9dxJ72ZUJDV27ZfbSj6iHLoytYZmh3rFcxNnvsJF3ktkzLDZPy/mA17HGsaQT3/DQsWYX1f1QGWkCoVUg==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.14.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-top-level-await": { + "version": "7.14.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-top-level-await/-/plugin-syntax-top-level-await-7.14.5.tgz", + "integrity": "sha512-hx++upLv5U1rgYfwe1xBQUhRmU41NEvpUvrp8jkrSCdvGSnM5/qdRMtylJ6PG5OFkBaHkbTAKTnd3/YyESRHFw==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.14.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-typescript": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-typescript/-/plugin-syntax-typescript-7.29.7.tgz", + "integrity": "sha512-ngr+82Sh0xMz25TPCZi+nC2iTzjfCdWS2ONXTp/PtSCHCgaCNBpdMqgvJ2ccdLlClVZ7sisIgB914j/JFe+RZA==", + "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-arrow-functions": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-arrow-functions/-/plugin-transform-arrow-functions-7.29.7.tgz", + "integrity": "sha512-N7zArUXWzAMzm+/N0uPBeVB3Fam5lMxtUwMmDK5f/IBBS7a7p1qeUoxd/6CckXoxUdgsntq1Dh8xNW06maZbDQ==", + "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-async-generator-functions": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-async-generator-functions/-/plugin-transform-async-generator-functions-7.29.7.tgz", + "integrity": "sha512-d98gXZkgswvkyohMBABkhm3GeXhYj8psWfwQ2C7gtfrKGTykQa/iOIi+JJhwMjPlZ6Vm2XN+DCf3Es1EoG4ZLA==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7", + "@babel/helper-remap-async-to-generator": "^7.29.7", + "@babel/traverse": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-async-to-generator": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-async-to-generator/-/plugin-transform-async-to-generator-7.29.7.tgz", + "integrity": "sha512-pcUb2SS+RMo9TWVBwKGI5ShtoG7R+zBsFmCKDa6fe8c+hPr3XJlZgoE5j6i8W7gDjhyvy+85vmYexanvXh3d1w==", + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7", + "@babel/helper-remap-async-to-generator": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-block-scoping": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-block-scoping/-/plugin-transform-block-scoping-7.29.7.tgz", + "integrity": "sha512-ONyr4+AZhKh8yKWInVxU9AXA9EbsyeLcL6V0dJy6M2/62vuvpGm29zzuymbTpdc451GEpDIdAyPLP3r+P61yKQ==", + "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-class-properties": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-class-properties/-/plugin-transform-class-properties-7.29.7.tgz", + "integrity": "sha512-GtcpjFvanPfzNQi3eTitsCqtRRmmqzpy/A+yhTR1HaZo1Ly3EA8ZXxlPyHdR8/IuRMYc3E4wdGBewB2QKQjAaA==", + "license": "MIT", + "dependencies": { + "@babel/helper-create-class-features-plugin": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-class-static-block": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-class-static-block/-/plugin-transform-class-static-block-7.29.7.tgz", + "integrity": "sha512-kibJgmEdX2iMwsHY2tSZNDgj8PwIlCQz7FK9KuGKO8zsuoUwSEhoNnNVp/emKWrbY4HeO6kkXfdMqRKKKXBm2A==", + "license": "MIT", + "dependencies": { + "@babel/helper-create-class-features-plugin": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.12.0" + } + }, + "node_modules/@babel/plugin-transform-classes": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-classes/-/plugin-transform-classes-7.29.7.tgz", + "integrity": "sha512-qV0OGGBVacduzQHE649JyCneOFI/maT+YKsO+K4Yi3xv2wTPNjM/W2o2gdzMwEAZz7fXNTHAe0NcSg30bIN69g==", + "license": "MIT", + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.29.7", + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-globals": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7", + "@babel/helper-replace-supers": "^7.29.7", + "@babel/traverse": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-computed-properties": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-computed-properties/-/plugin-transform-computed-properties-7.29.7.tgz", + "integrity": "sha512-RK7/IyU5phpuCdBAuig5VkzG/EnbDaui5SQGdU9BFrHdV+mV4cUjLMQ9lJDjLNtWHsqtiefpGZUXQP2BiTYMsA==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7", + "@babel/template": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-destructuring": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-destructuring/-/plugin-transform-destructuring-7.29.7.tgz", + "integrity": "sha512-iPX8aD6H9zV5s7ZsqTdNocPN/MGQ5sSMnElKrktxjJRMnB2jN/1p2+R7GkfD6CAYoVFqy5A4XnSIUeGgJzIWpg==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7", + "@babel/traverse": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-export-namespace-from": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-export-namespace-from/-/plugin-transform-export-namespace-from-7.29.7.tgz", + "integrity": "sha512-24B2nOy2TeJSMheqwPD4DDQOV/elLSIlKxjZt4i05H5AgdPdWR3n18HnNrcJ+j76WJd9gbwb9jPjNYUy6RautA==", + "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-flow-strip-types": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-flow-strip-types/-/plugin-transform-flow-strip-types-7.29.7.tgz", + "integrity": "sha512-wRHeUjUjCZnMHmiO5bRgjFLcoEh7JyTdByOW11ahhwNa4V0bmeGEaIvt51yq0zQp2yWIpqfxXXPyUP6GFJZHOQ==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7", + "@babel/plugin-syntax-flow": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-for-of": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-for-of/-/plugin-transform-for-of-7.29.7.tgz", + "integrity": "sha512-zeSIHh0+E1Um1WJRXCFlHQYu2ieJNdivLLjlBEp+dIBu3S51n+SZZmIXjxnItw6pz56Cn+KvK68BIBVsxq2JiQ==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7", + "@babel/helper-skip-transparent-expression-wrappers": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-function-name": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-function-name/-/plugin-transform-function-name-7.29.7.tgz", + "integrity": "sha512-otRWaHXE6fbAGkePvaj/kvs3HsqXfPhlnzwSOlnFgbqCPMd975dW+4wZ00WFBt+/YlBGcJwNrARQTOJOb4ZrIg==", + "license": "MIT", + "dependencies": { + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7", + "@babel/traverse": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-literals": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-literals/-/plugin-transform-literals-7.29.7.tgz", + "integrity": "sha512-DZ/oLP21ZuWx1vKqnoNv6/tvEK48AQOBRai40CX9dTjGluvT/YZCyY3rryDtyUqCEoyNroy5KKPwX2iQCiRvyw==", + "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-logical-assignment-operators": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-logical-assignment-operators/-/plugin-transform-logical-assignment-operators-7.29.7.tgz", + "integrity": "sha512-A0H91hh6W8MFRkp5TqJmMr39jzGD1A1E1Ysiv2O06Sfbhkapm+XyIzxWCEh5kqwOZ1/8QZ0dY3SeQ7XBqfJd5Q==", + "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-modules-commonjs": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-commonjs/-/plugin-transform-modules-commonjs-7.29.7.tgz", + "integrity": "sha512-j0vCldybPC5b5dwCQOJ21uKtHzt7hxLygJTg9eF1ScfaikEDNfzn94XoW5Fi+seBR0nCyL23xaBFFkq7dTM8XQ==", + "license": "MIT", + "dependencies": { + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-named-capturing-groups-regex": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-named-capturing-groups-regex/-/plugin-transform-named-capturing-groups-regex-7.29.7.tgz", + "integrity": "sha512-vuFoLwr4qnv2xbZ16SQd6uPcH5FNrLHhk/Jzo++0XJFcaDsr4gjJVg6j398oMHiC+83k/GiBzviwF5KBJkPUtQ==", + "license": "MIT", + "dependencies": { + "@babel/helper-create-regexp-features-plugin": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/plugin-transform-nullish-coalescing-operator": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-nullish-coalescing-operator/-/plugin-transform-nullish-coalescing-operator-7.29.7.tgz", + "integrity": "sha512-idmp1dFaekP9GbcMvG24Kvw2BfhFZjHnNJCkV4WuIY4PskJzwI3f1N5OdgYke38T7rftO6ERulFRn2cFeZwRkg==", + "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-numeric-separator": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-numeric-separator/-/plugin-transform-numeric-separator-7.29.7.tgz", + "integrity": "sha512-zR7fv/z14OjgHl4AgRtkDBvBMhIzCxqV/qN/2BCRC7LjFwvuzjYe7gDWxC4Wl/SNsLM6SE1IWvRPYMgSJaUvNw==", + "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-object-rest-spread": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-object-rest-spread/-/plugin-transform-object-rest-spread-7.29.7.tgz", + "integrity": "sha512-Ld98jn4c0smUywL57m7SgsHq3OpThOa6LqZJif3G6jYOovPleoFhVrBJ1WegRApSFB2wu4+RelAj9AC9G08Z4A==", + "license": "MIT", + "dependencies": { + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7", + "@babel/plugin-transform-destructuring": "^7.29.7", + "@babel/plugin-transform-parameters": "^7.29.7", + "@babel/traverse": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-optional-catch-binding": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-optional-catch-binding/-/plugin-transform-optional-catch-binding-7.29.7.tgz", + "integrity": "sha512-sLsyndxK2VwX6yNUOakMb7Sh553ZTe/vVM1XJ+9Z5aW1ytsc8xOIwmyk05NNjN60vkc5/KqoTH6hB4V41LJhng==", + "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-optional-chaining": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-optional-chaining/-/plugin-transform-optional-chaining-7.29.7.tgz", + "integrity": "sha512-6GM1dhvK3gNODkXcEcMCOLEDCLSoZ/sBbro2Ax8HURyasQ4NshagQixkRFdh5niI6E4gmA/jYI/4aT7rRos3ZQ==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7", + "@babel/helper-skip-transparent-expression-wrappers": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-parameters": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-parameters/-/plugin-transform-parameters-7.29.7.tgz", + "integrity": "sha512-ZDOBqV/qLYJI0YElr8DcENEyARsFQeESqWXH6gZlghYXuPPjvweuDhP4VyEi4BlUBlLRFZVjxoZDMjxhLW766g==", + "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-private-methods": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-private-methods/-/plugin-transform-private-methods-7.29.7.tgz", + "integrity": "sha512-/6Rz4DK1ETDEM/bWHsPHcaEe7ZaT1EqSXjtSP/L0DijOYuaUhiRiOKcwpZ8P7zR4xXEHc2ITdiCgBm9Tpyv9ug==", + "license": "MIT", + "dependencies": { + "@babel/helper-create-class-features-plugin": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-private-property-in-object": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-private-property-in-object/-/plugin-transform-private-property-in-object-7.29.7.tgz", + "integrity": "sha512-+BNo06dnrzdNNqCm1X6YUaVv0DKk8Q+JYcoZfOkLhYWNCXzlwTSRq8zGWayT1csjcpNXV9CQTBRRbmTLZac5cA==", + "license": "MIT", + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.29.7", + "@babel/helper-create-class-features-plugin": "^7.29.7", + "@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-display-name": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-display-name/-/plugin-transform-react-display-name-7.29.7.tgz", + "integrity": "sha512-+1wdDMGNb4UPeY3Q4L5yLiYe6TXPXubs4NjrgRFw13hPRLJfEMw2Q5OXkee6/IfdqePIeW4Jjwe3aBh7SdKz4Q==", + "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": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx/-/plugin-transform-react-jsx-7.29.7.tgz", + "integrity": "sha512-WsZulLVBUHXVj2cUcPVx6UE21TpalB6bHbSFErKT0Ib++ax24jjXe73FqlWvdylFOjiuPHYi6VCcgRad1ItN+A==", + "license": "MIT", + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.29.7", + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7", + "@babel/plugin-syntax-jsx": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-development": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-development/-/plugin-transform-react-jsx-development-7.29.7.tgz", + "integrity": "sha512-Xfy3UVMF04+ypnFbkhvfqtmvwfe92qwQdbGZVonhE+6v35GzlofmOnA1szaZqzb9xYWr0nl1e5EMmzi0DNON1g==", + "license": "MIT", + "dependencies": { + "@babel/plugin-transform-react-jsx": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.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==", + "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==", + "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-pure-annotations": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-pure-annotations/-/plugin-transform-react-pure-annotations-7.29.7.tgz", + "integrity": "sha512-H5E+HBgDpr6Q5t+Aj11tL7XkIui1jhbIoArVQnqjgXo5/3YxkN7ZEBcWF4RQlB0T4rrxJQbXS6kiFV6B7XTqUA==", + "license": "MIT", + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-regenerator": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-regenerator/-/plugin-transform-regenerator-7.29.7.tgz", + "integrity": "sha512-rNNFV0DBAJp988xW2DOntfDoYn1eR8GGF5AT5vYc+rjyfaQkM242c9tZUHHPe7KYaiJizXPWhQTzzdbXySyhBw==", + "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-runtime": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-runtime/-/plugin-transform-runtime-7.29.7.tgz", + "integrity": "sha512-xmAscdE/AsqRW7vutbPNoUmu/nF5SrLKPs7aoJgEjo35lLKA/Bc0i2rMv/hr1+Y0o1bQCiVtith3u2vdgRL39Q==", + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7", + "babel-plugin-polyfill-corejs2": "^0.4.14", + "babel-plugin-polyfill-corejs3": "^0.13.0", + "babel-plugin-polyfill-regenerator": "^0.6.5", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-shorthand-properties": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-shorthand-properties/-/plugin-transform-shorthand-properties-7.29.7.tgz", + "integrity": "sha512-I+WYbGBAiCn7nA6xBrlgPH+MB7HWb4u8pv5S0Pv7OtwNvIFvCCb24YlttKEeUFVurfBCEaOTnuhlqsb7f0Z5Dg==", + "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-spread": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-spread/-/plugin-transform-spread-7.29.7.tgz", + "integrity": "sha512-/u5K1QWada7tbYNqTjMh96718g9NTwh9tfPJMsSmVsQwGT447FskV+KcfeXkXq2GWki4EM/MuTdmBec+hOuVTQ==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7", + "@babel/helper-skip-transparent-expression-wrappers": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-sticky-regex": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-sticky-regex/-/plugin-transform-sticky-regex-7.29.7.tgz", + "integrity": "sha512-BCHzNYJGe9l7EpwwDBN/ztlL2NYFFq8hp9ddjtUEM9f2O7S7kKV/lL6Fwo7IF7NSkYhPK2vO+86nIGltA90MsA==", + "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-template-literals": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-template-literals/-/plugin-transform-template-literals-7.29.7.tgz", + "integrity": "sha512-NCSEJ4sLFU2gqAub45HYh4fus2yQ36rr6ei6vpU7NdoJqCpxvEG8E6eJpscGyXP3VHD2Ny+fSXr04k1hoUrFqA==", + "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-typescript": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-typescript/-/plugin-transform-typescript-7.29.7.tgz", + "integrity": "sha512-jK52h8LaLc7JarhQV2ofeFMts4H7vnOXnqZNA6fYglBTZewRBE51KWt3BUltW1P+KoPsYkHoJeXePuz4zo2LMw==", + "license": "MIT", + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.29.7", + "@babel/helper-create-class-features-plugin": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7", + "@babel/helper-skip-transparent-expression-wrappers": "^7.29.7", + "@babel/plugin-syntax-typescript": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-unicode-regex": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-regex/-/plugin-transform-unicode-regex-7.29.7.tgz", + "integrity": "sha512-7D/x/23/d/3VqZ0QA+LGbZMlGwZjztBygSWWWsfTPoQ1oQ6Q1P6Mr3d0kk42XabyUVw+fha3LqdRsFqeKqvCyA==", + "license": "MIT", + "dependencies": { + "@babel/helper-create-regexp-features-plugin": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/preset-react": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/preset-react/-/preset-react-7.29.7.tgz", + "integrity": "sha512-C+PV1TFUPTmBQGoPBL8j2QmLpZ117YTCwxIZeJOM96GbYMFSc7/pOXU5lVykwnZxyTqQxRsvoRk6f2FktZgGHA==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7", + "@babel/helper-validator-option": "^7.29.7", + "@babel/plugin-transform-react-display-name": "^7.29.7", + "@babel/plugin-transform-react-jsx": "^7.29.7", + "@babel/plugin-transform-react-jsx-development": "^7.29.7", + "@babel/plugin-transform-react-pure-annotations": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/preset-typescript": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/preset-typescript/-/preset-typescript-7.29.7.tgz", + "integrity": "sha512-/Foi8vKY2EVbed/1eZx0gJEEwHAIxogrySI7rULcRIvhZzbvoE/b5qG5Ghc0WKAFKOHA9SD1x7RsFlOYdutIiQ==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7", + "@babel/helper-validator-option": "^7.29.7", + "@babel/plugin-syntax-jsx": "^7.29.7", + "@babel/plugin-transform-modules-commonjs": "^7.29.7", + "@babel/plugin-transform-typescript": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/runtime": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz", + "integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==", + "license": "MIT", + "engines": { + "node": ">=6.9.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==", + "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==", + "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/traverse--for-generate-function-map": { + "name": "@babel/traverse", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.7.tgz", + "integrity": "sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==", + "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==", + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@egjs/hammerjs": { + "version": "2.0.17", + "resolved": "https://registry.npmjs.org/@egjs/hammerjs/-/hammerjs-2.0.17.tgz", + "integrity": "sha512-XQsZgjm2EcVUiZQf11UBJQfmZeEmOW8DpI1gsFeln6w0ae0ii4dMQEQ0kjl6DspdWX1aGY1/loyXnP0JS06e/A==", + "license": "MIT", + "dependencies": { + "@types/hammerjs": "^2.0.36" + }, + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/@expo/code-signing-certificates": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/@expo/code-signing-certificates/-/code-signing-certificates-0.0.6.tgz", + "integrity": "sha512-iNe0puxwBNEcuua9gmTGzq+SuMDa0iATai1FlFTMHJ/vUmKvN/V//drXoLJkVb5i5H3iE/n/qIJxyoBnXouD0w==", + "license": "MIT", + "dependencies": { + "node-forge": "^1.3.3" + } + }, + "node_modules/@expo/config": { + "version": "12.0.13", + "resolved": "https://registry.npmjs.org/@expo/config/-/config-12.0.13.tgz", + "integrity": "sha512-Cu52arBa4vSaupIWsF0h7F/Cg//N374nYb7HAxV0I4KceKA7x2UXpYaHOL7EEYYvp7tZdThBjvGpVmr8ScIvaQ==", + "license": "MIT", + "dependencies": { + "@babel/code-frame": "~7.10.4", + "@expo/config-plugins": "~54.0.4", + "@expo/config-types": "^54.0.10", + "@expo/json-file": "^10.0.8", + "deepmerge": "^4.3.1", + "getenv": "^2.0.0", + "glob": "^13.0.0", + "require-from-string": "^2.0.2", + "resolve-from": "^5.0.0", + "resolve-workspace-root": "^2.0.0", + "semver": "^7.6.0", + "slugify": "^1.3.4", + "sucrase": "~3.35.1" + } + }, + "node_modules/@expo/config-plugins": { + "version": "54.0.4", + "resolved": "https://registry.npmjs.org/@expo/config-plugins/-/config-plugins-54.0.4.tgz", + "integrity": "sha512-g2yXGICdoOw5i3LkQSDxl2Q5AlQCrG7oniu0pCPPO+UxGb7He4AFqSvPSy8HpRUj55io17hT62FTjYRD+d6j3Q==", + "license": "MIT", + "dependencies": { + "@expo/config-types": "^54.0.10", + "@expo/json-file": "~10.0.8", + "@expo/plist": "^0.4.8", + "@expo/sdk-runtime-versions": "^1.0.0", + "chalk": "^4.1.2", + "debug": "^4.3.5", + "getenv": "^2.0.0", + "glob": "^13.0.0", + "resolve-from": "^5.0.0", + "semver": "^7.5.4", + "slash": "^3.0.0", + "slugify": "^1.6.6", + "xcode": "^3.0.1", + "xml2js": "0.6.0" + } + }, + "node_modules/@expo/config-plugins/node_modules/@babel/code-frame": { + "version": "7.10.4", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.10.4.tgz", + "integrity": "sha512-vG6SvB6oYEhvgisZNFRmRCUkLz11c7rp+tbNTynGqc6mS1d5ATd/sGyV6W0KZZnXRKMTzZDRgQT3Ou9jhpAfUg==", + "license": "MIT", + "dependencies": { + "@babel/highlight": "^7.10.4" + } + }, + "node_modules/@expo/config-plugins/node_modules/@expo/json-file": { + "version": "10.0.16", + "resolved": "https://registry.npmjs.org/@expo/json-file/-/json-file-10.0.16.tgz", + "integrity": "sha512-fcVkWEj+hLuP2yt5W0aw6LmDRqSPWDLUSxOMcmFeV+algmIF59sQVKCwB9btjQLd4V6x9N0pISkQEkBubUHrCw==", + "license": "MIT", + "dependencies": { + "@babel/code-frame": "~7.10.4", + "json5": "^2.2.3" + } + }, + "node_modules/@expo/config-plugins/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/@expo/config-plugins/node_modules/brace-expansion": { + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz", + "integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==", + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/@expo/config-plugins/node_modules/glob": { + "version": "13.0.6", + "resolved": "https://registry.npmjs.org/glob/-/glob-13.0.6.tgz", + "integrity": "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==", + "license": "BlueOak-1.0.0", + "dependencies": { + "minimatch": "^10.2.2", + "minipass": "^7.1.3", + "path-scurry": "^2.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@expo/config-plugins/node_modules/minimatch": { + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.5" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@expo/config-plugins/node_modules/semver": { + "version": "7.8.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.4.tgz", + "integrity": "sha512-rUCObTnP32Q08R2uuIrt7r9PlEonuTmtuXYcW6s5kjdlj3xbnwe+21yXptAUYcMAABLkYYTtnmzb3w3EDZfueA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@expo/config-types": { + "version": "54.0.10", + "resolved": "https://registry.npmjs.org/@expo/config-types/-/config-types-54.0.10.tgz", + "integrity": "sha512-/J16SC2an1LdtCZ67xhSkGXpALYUVUNyZws7v+PVsFZxClYehDSoKLqyRaGkpHlYrCc08bS0RF5E0JV6g50psA==", + "license": "MIT" + }, + "node_modules/@expo/config/node_modules/@babel/code-frame": { + "version": "7.10.4", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.10.4.tgz", + "integrity": "sha512-vG6SvB6oYEhvgisZNFRmRCUkLz11c7rp+tbNTynGqc6mS1d5ATd/sGyV6W0KZZnXRKMTzZDRgQT3Ou9jhpAfUg==", + "license": "MIT", + "dependencies": { + "@babel/highlight": "^7.10.4" + } + }, + "node_modules/@expo/config/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/@expo/config/node_modules/brace-expansion": { + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz", + "integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==", + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/@expo/config/node_modules/glob": { + "version": "13.0.6", + "resolved": "https://registry.npmjs.org/glob/-/glob-13.0.6.tgz", + "integrity": "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==", + "license": "BlueOak-1.0.0", + "dependencies": { + "minimatch": "^10.2.2", + "minipass": "^7.1.3", + "path-scurry": "^2.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@expo/config/node_modules/minimatch": { + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.5" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@expo/config/node_modules/semver": { + "version": "7.8.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.4.tgz", + "integrity": "sha512-rUCObTnP32Q08R2uuIrt7r9PlEonuTmtuXYcW6s5kjdlj3xbnwe+21yXptAUYcMAABLkYYTtnmzb3w3EDZfueA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@expo/devcert": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@expo/devcert/-/devcert-1.2.1.tgz", + "integrity": "sha512-qC4eaxmKMTmJC2ahwyui6ud8f3W60Ss7pMkpBq40Hu3zyiAaugPXnZ24145U7K36qO9UHdZUVxsCvIpz2RYYCA==", + "license": "MIT", + "dependencies": { + "@expo/sudo-prompt": "^9.3.1", + "debug": "^3.1.0" + } + }, + "node_modules/@expo/devcert/node_modules/debug": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", + "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.1" + } + }, + "node_modules/@expo/devtools": { + "version": "0.1.8", + "resolved": "https://registry.npmjs.org/@expo/devtools/-/devtools-0.1.8.tgz", + "integrity": "sha512-SVLxbuanDjJPgc0sy3EfXUMLb/tXzp6XIHkhtPVmTWJAp+FOr6+5SeiCfJrCzZFet0Ifyke2vX3sFcKwEvCXwQ==", + "license": "MIT", + "dependencies": { + "chalk": "^4.1.2" + }, + "peerDependencies": { + "react": "*", + "react-native": "*" + }, + "peerDependenciesMeta": { + "react": { + "optional": true + }, + "react-native": { + "optional": true + } + } + }, + "node_modules/@expo/env": { + "version": "2.0.11", + "resolved": "https://registry.npmjs.org/@expo/env/-/env-2.0.11.tgz", + "integrity": "sha512-xV+ps6YCW7XIPVUwFVCRN2nox09dnRwy8uIjwHWTODu0zFw4kp4omnVkl0OOjuu2XOe7tdgAHxikrkJt9xB/7Q==", + "license": "MIT", + "dependencies": { + "chalk": "^4.0.0", + "debug": "^4.3.4", + "dotenv": "~16.4.5", + "dotenv-expand": "~11.0.6", + "getenv": "^2.0.0" + } + }, + "node_modules/@expo/fingerprint": { + "version": "0.15.5", + "resolved": "https://registry.npmjs.org/@expo/fingerprint/-/fingerprint-0.15.5.tgz", + "integrity": "sha512-mdVoAMcux1WlM6kd1RoWiHRNqKqS+J6mKmWQ/BKgeh937S/fcW58EE68O6nc4KDXtWi3PBeNHskOFcgyIuD4hw==", + "license": "MIT", + "dependencies": { + "@expo/spawn-async": "^1.7.2", + "arg": "^5.0.2", + "chalk": "^4.1.2", + "debug": "^4.3.4", + "getenv": "^2.0.0", + "glob": "^13.0.0", + "ignore": "^5.3.1", + "minimatch": "^10.2.2", + "p-limit": "^3.1.0", + "resolve-from": "^5.0.0", + "semver": "^7.6.0" + }, + "bin": { + "fingerprint": "bin/cli.js" + } + }, + "node_modules/@expo/fingerprint/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/@expo/fingerprint/node_modules/brace-expansion": { + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz", + "integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==", + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/@expo/fingerprint/node_modules/glob": { + "version": "13.0.6", + "resolved": "https://registry.npmjs.org/glob/-/glob-13.0.6.tgz", + "integrity": "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==", + "license": "BlueOak-1.0.0", + "dependencies": { + "minimatch": "^10.2.2", + "minipass": "^7.1.3", + "path-scurry": "^2.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@expo/fingerprint/node_modules/minimatch": { + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.5" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@expo/fingerprint/node_modules/semver": { + "version": "7.8.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.4.tgz", + "integrity": "sha512-rUCObTnP32Q08R2uuIrt7r9PlEonuTmtuXYcW6s5kjdlj3xbnwe+21yXptAUYcMAABLkYYTtnmzb3w3EDZfueA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@expo/image-utils": { + "version": "0.8.14", + "resolved": "https://registry.npmjs.org/@expo/image-utils/-/image-utils-0.8.14.tgz", + "integrity": "sha512-5Sn+jG4Cw+shC2wDMXoqSAJnvERbiwzHn05FpWtD5IBflfTIs5gUmjzwiGVyjOdlMSQhgRrw/AymPbmO9h9mpQ==", + "license": "MIT", + "dependencies": { + "@expo/require-utils": "^55.0.5", + "@expo/spawn-async": "^1.7.2", + "chalk": "^4.0.0", + "getenv": "^2.0.0", + "jimp-compact": "0.16.1", + "parse-png": "^2.1.0", + "semver": "^7.6.0" + } + }, + "node_modules/@expo/image-utils/node_modules/semver": { + "version": "7.8.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.4.tgz", + "integrity": "sha512-rUCObTnP32Q08R2uuIrt7r9PlEonuTmtuXYcW6s5kjdlj3xbnwe+21yXptAUYcMAABLkYYTtnmzb3w3EDZfueA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@expo/json-file": { + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/@expo/json-file/-/json-file-10.2.0.tgz", + "integrity": "sha512-S6XzKe3R9GQeHiUPXc3xJjOv2VJhOEwFYf7xdC2z2cUqt3kZJ9mSO877sNQloVdnW/SUCtPY3bexlM7nwq+CAQ==", + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.20.0", + "json5": "^2.2.3" + } + }, + "node_modules/@expo/metro": { + "version": "54.2.0", + "resolved": "https://registry.npmjs.org/@expo/metro/-/metro-54.2.0.tgz", + "integrity": "sha512-h68TNZPGsk6swMmLm9nRSnE2UXm48rWwgcbtAHVMikXvbxdS41NDHHeqg1rcQ9AbznDRp6SQVC2MVpDnsRKU1w==", + "license": "MIT", + "dependencies": { + "metro": "0.83.3", + "metro-babel-transformer": "0.83.3", + "metro-cache": "0.83.3", + "metro-cache-key": "0.83.3", + "metro-config": "0.83.3", + "metro-core": "0.83.3", + "metro-file-map": "0.83.3", + "metro-minify-terser": "0.83.3", + "metro-resolver": "0.83.3", + "metro-runtime": "0.83.3", + "metro-source-map": "0.83.3", + "metro-symbolicate": "0.83.3", + "metro-transform-plugins": "0.83.3", + "metro-transform-worker": "0.83.3" + } + }, + "node_modules/@expo/metro/node_modules/ci-info": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-2.0.0.tgz", + "integrity": "sha512-5tK7EtrZ0N+OLFMthtqOj4fI2Jeb88C4CAZPu25LDVUgXJ0A3Js4PMGqrn0JU1W0Mh1/Z8wZzYPxqUrXeBboCQ==", + "license": "MIT" + }, + "node_modules/@expo/metro/node_modules/hermes-estree": { + "version": "0.32.0", + "resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.32.0.tgz", + "integrity": "sha512-KWn3BqnlDOl97Xe1Yviur6NbgIZ+IP+UVSpshlZWkq+EtoHg6/cwiDj/osP9PCEgFE15KBm1O55JRwbMEm5ejQ==", + "license": "MIT" + }, + "node_modules/@expo/metro/node_modules/hermes-parser": { + "version": "0.32.0", + "resolved": "https://registry.npmjs.org/hermes-parser/-/hermes-parser-0.32.0.tgz", + "integrity": "sha512-g4nBOWFpuiTqjR3LZdRxKUkij9iyveWeuks7INEsMX741f3r9xxrOe8TeQfUxtda0eXmiIFiMQzoeSQEno33Hw==", + "license": "MIT", + "dependencies": { + "hermes-estree": "0.32.0" + } + }, + "node_modules/@expo/metro/node_modules/metro": { + "version": "0.83.3", + "resolved": "https://registry.npmjs.org/metro/-/metro-0.83.3.tgz", + "integrity": "sha512-+rP+/GieOzkt97hSJ0MrPOuAH/jpaS21ZDvL9DJ35QYRDlQcwzcvUlGUf79AnQxq/2NPiS/AULhhM4TKutIt8Q==", + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.24.7", + "@babel/core": "^7.25.2", + "@babel/generator": "^7.25.0", + "@babel/parser": "^7.25.3", + "@babel/template": "^7.25.0", + "@babel/traverse": "^7.25.3", + "@babel/types": "^7.25.2", + "accepts": "^1.3.7", + "chalk": "^4.0.0", + "ci-info": "^2.0.0", + "connect": "^3.6.5", + "debug": "^4.4.0", + "error-stack-parser": "^2.0.6", + "flow-enums-runtime": "^0.0.6", + "graceful-fs": "^4.2.4", + "hermes-parser": "0.32.0", + "image-size": "^1.0.2", + "invariant": "^2.2.4", + "jest-worker": "^29.7.0", + "jsc-safe-url": "^0.2.2", + "lodash.throttle": "^4.1.1", + "metro-babel-transformer": "0.83.3", + "metro-cache": "0.83.3", + "metro-cache-key": "0.83.3", + "metro-config": "0.83.3", + "metro-core": "0.83.3", + "metro-file-map": "0.83.3", + "metro-resolver": "0.83.3", + "metro-runtime": "0.83.3", + "metro-source-map": "0.83.3", + "metro-symbolicate": "0.83.3", + "metro-transform-plugins": "0.83.3", + "metro-transform-worker": "0.83.3", + "mime-types": "^2.1.27", + "nullthrows": "^1.1.1", + "serialize-error": "^2.1.0", + "source-map": "^0.5.6", + "throat": "^5.0.0", + "ws": "^7.5.10", + "yargs": "^17.6.2" + }, + "bin": { + "metro": "src/cli.js" + }, + "engines": { + "node": ">=20.19.4" + } + }, + "node_modules/@expo/metro/node_modules/metro-babel-transformer": { + "version": "0.83.3", + "resolved": "https://registry.npmjs.org/metro-babel-transformer/-/metro-babel-transformer-0.83.3.tgz", + "integrity": "sha512-1vxlvj2yY24ES1O5RsSIvg4a4WeL7PFXgKOHvXTXiW0deLvQr28ExXj6LjwCCDZ4YZLhq6HddLpZnX4dEdSq5g==", + "license": "MIT", + "dependencies": { + "@babel/core": "^7.25.2", + "flow-enums-runtime": "^0.0.6", + "hermes-parser": "0.32.0", + "nullthrows": "^1.1.1" + }, + "engines": { + "node": ">=20.19.4" + } + }, + "node_modules/@expo/metro/node_modules/metro-cache": { + "version": "0.83.3", + "resolved": "https://registry.npmjs.org/metro-cache/-/metro-cache-0.83.3.tgz", + "integrity": "sha512-3jo65X515mQJvKqK3vWRblxDEcgY55Sk3w4xa6LlfEXgQ9g1WgMh9m4qVZVwgcHoLy0a2HENTPCCX4Pk6s8c8Q==", + "license": "MIT", + "dependencies": { + "exponential-backoff": "^3.1.1", + "flow-enums-runtime": "^0.0.6", + "https-proxy-agent": "^7.0.5", + "metro-core": "0.83.3" + }, + "engines": { + "node": ">=20.19.4" + } + }, + "node_modules/@expo/metro/node_modules/metro-cache-key": { + "version": "0.83.3", + "resolved": "https://registry.npmjs.org/metro-cache-key/-/metro-cache-key-0.83.3.tgz", + "integrity": "sha512-59ZO049jKzSmvBmG/B5bZ6/dztP0ilp0o988nc6dpaDsU05Cl1c/lRf+yx8m9WW/JVgbmfO5MziBU559XjI5Zw==", + "license": "MIT", + "dependencies": { + "flow-enums-runtime": "^0.0.6" + }, + "engines": { + "node": ">=20.19.4" + } + }, + "node_modules/@expo/metro/node_modules/metro-config": { + "version": "0.83.3", + "resolved": "https://registry.npmjs.org/metro-config/-/metro-config-0.83.3.tgz", + "integrity": "sha512-mTel7ipT0yNjKILIan04bkJkuCzUUkm2SeEaTads8VfEecCh+ltXchdq6DovXJqzQAXuR2P9cxZB47Lg4klriA==", + "license": "MIT", + "dependencies": { + "connect": "^3.6.5", + "flow-enums-runtime": "^0.0.6", + "jest-validate": "^29.7.0", + "metro": "0.83.3", + "metro-cache": "0.83.3", + "metro-core": "0.83.3", + "metro-runtime": "0.83.3", + "yaml": "^2.6.1" + }, + "engines": { + "node": ">=20.19.4" + } + }, + "node_modules/@expo/metro/node_modules/metro-core": { + "version": "0.83.3", + "resolved": "https://registry.npmjs.org/metro-core/-/metro-core-0.83.3.tgz", + "integrity": "sha512-M+X59lm7oBmJZamc96usuF1kusd5YimqG/q97g4Ac7slnJ3YiGglW5CsOlicTR5EWf8MQFxxjDoB6ytTqRe8Hw==", + "license": "MIT", + "dependencies": { + "flow-enums-runtime": "^0.0.6", + "lodash.throttle": "^4.1.1", + "metro-resolver": "0.83.3" + }, + "engines": { + "node": ">=20.19.4" + } + }, + "node_modules/@expo/metro/node_modules/metro-file-map": { + "version": "0.83.3", + "resolved": "https://registry.npmjs.org/metro-file-map/-/metro-file-map-0.83.3.tgz", + "integrity": "sha512-jg5AcyE0Q9Xbbu/4NAwwZkmQn7doJCKGW0SLeSJmzNB9Z24jBe0AL2PHNMy4eu0JiKtNWHz9IiONGZWq7hjVTA==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "fb-watchman": "^2.0.0", + "flow-enums-runtime": "^0.0.6", + "graceful-fs": "^4.2.4", + "invariant": "^2.2.4", + "jest-worker": "^29.7.0", + "micromatch": "^4.0.4", + "nullthrows": "^1.1.1", + "walker": "^1.0.7" + }, + "engines": { + "node": ">=20.19.4" + } + }, + "node_modules/@expo/metro/node_modules/metro-minify-terser": { + "version": "0.83.3", + "resolved": "https://registry.npmjs.org/metro-minify-terser/-/metro-minify-terser-0.83.3.tgz", + "integrity": "sha512-O2BmfWj6FSfzBLrNCXt/rr2VYZdX5i6444QJU0fFoc7Ljg+Q+iqebwE3K0eTvkI6TRjELsXk1cjU+fXwAR4OjQ==", + "license": "MIT", + "dependencies": { + "flow-enums-runtime": "^0.0.6", + "terser": "^5.15.0" + }, + "engines": { + "node": ">=20.19.4" + } + }, + "node_modules/@expo/metro/node_modules/metro-resolver": { + "version": "0.83.3", + "resolved": "https://registry.npmjs.org/metro-resolver/-/metro-resolver-0.83.3.tgz", + "integrity": "sha512-0js+zwI5flFxb1ktmR///bxHYg7OLpRpWZlBBruYG8OKYxeMP7SV0xQ/o/hUelrEMdK4LJzqVtHAhBm25LVfAQ==", + "license": "MIT", + "dependencies": { + "flow-enums-runtime": "^0.0.6" + }, + "engines": { + "node": ">=20.19.4" + } + }, + "node_modules/@expo/metro/node_modules/metro-runtime": { + "version": "0.83.3", + "resolved": "https://registry.npmjs.org/metro-runtime/-/metro-runtime-0.83.3.tgz", + "integrity": "sha512-JHCJb9ebr9rfJ+LcssFYA2x1qPYuSD/bbePupIGhpMrsla7RCwC/VL3yJ9cSU+nUhU4c9Ixxy8tBta+JbDeZWw==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.25.0", + "flow-enums-runtime": "^0.0.6" + }, + "engines": { + "node": ">=20.19.4" + } + }, + "node_modules/@expo/metro/node_modules/metro-source-map": { + "version": "0.83.3", + "resolved": "https://registry.npmjs.org/metro-source-map/-/metro-source-map-0.83.3.tgz", + "integrity": "sha512-xkC3qwUBh2psVZgVavo8+r2C9Igkk3DibiOXSAht1aYRRcztEZNFtAMtfSB7sdO2iFMx2Mlyu++cBxz/fhdzQg==", + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.25.3", + "@babel/traverse--for-generate-function-map": "npm:@babel/traverse@^7.25.3", + "@babel/types": "^7.25.2", + "flow-enums-runtime": "^0.0.6", + "invariant": "^2.2.4", + "metro-symbolicate": "0.83.3", + "nullthrows": "^1.1.1", + "ob1": "0.83.3", + "source-map": "^0.5.6", + "vlq": "^1.0.0" + }, + "engines": { + "node": ">=20.19.4" + } + }, + "node_modules/@expo/metro/node_modules/metro-symbolicate": { + "version": "0.83.3", + "resolved": "https://registry.npmjs.org/metro-symbolicate/-/metro-symbolicate-0.83.3.tgz", + "integrity": "sha512-F/YChgKd6KbFK3eUR5HdUsfBqVsanf5lNTwFd4Ca7uuxnHgBC3kR/Hba/RGkenR3pZaGNp5Bu9ZqqP52Wyhomw==", + "license": "MIT", + "dependencies": { + "flow-enums-runtime": "^0.0.6", + "invariant": "^2.2.4", + "metro-source-map": "0.83.3", + "nullthrows": "^1.1.1", + "source-map": "^0.5.6", + "vlq": "^1.0.0" + }, + "bin": { + "metro-symbolicate": "src/index.js" + }, + "engines": { + "node": ">=20.19.4" + } + }, + "node_modules/@expo/metro/node_modules/metro-transform-plugins": { + "version": "0.83.3", + "resolved": "https://registry.npmjs.org/metro-transform-plugins/-/metro-transform-plugins-0.83.3.tgz", + "integrity": "sha512-eRGoKJU6jmqOakBMH5kUB7VitEWiNrDzBHpYbkBXW7C5fUGeOd2CyqrosEzbMK5VMiZYyOcNFEphvxk3OXey2A==", + "license": "MIT", + "dependencies": { + "@babel/core": "^7.25.2", + "@babel/generator": "^7.25.0", + "@babel/template": "^7.25.0", + "@babel/traverse": "^7.25.3", + "flow-enums-runtime": "^0.0.6", + "nullthrows": "^1.1.1" + }, + "engines": { + "node": ">=20.19.4" + } + }, + "node_modules/@expo/metro/node_modules/metro-transform-worker": { + "version": "0.83.3", + "resolved": "https://registry.npmjs.org/metro-transform-worker/-/metro-transform-worker-0.83.3.tgz", + "integrity": "sha512-Ztekew9t/gOIMZX1tvJOgX7KlSLL5kWykl0Iwu2cL2vKMKVALRl1hysyhUw0vjpAvLFx+Kfq9VLjnHIkW32fPA==", + "license": "MIT", + "dependencies": { + "@babel/core": "^7.25.2", + "@babel/generator": "^7.25.0", + "@babel/parser": "^7.25.3", + "@babel/types": "^7.25.2", + "flow-enums-runtime": "^0.0.6", + "metro": "0.83.3", + "metro-babel-transformer": "0.83.3", + "metro-cache": "0.83.3", + "metro-cache-key": "0.83.3", + "metro-minify-terser": "0.83.3", + "metro-source-map": "0.83.3", + "metro-transform-plugins": "0.83.3", + "nullthrows": "^1.1.1" + }, + "engines": { + "node": ">=20.19.4" + } + }, + "node_modules/@expo/metro/node_modules/ob1": { + "version": "0.83.3", + "resolved": "https://registry.npmjs.org/ob1/-/ob1-0.83.3.tgz", + "integrity": "sha512-egUxXCDwoWG06NGCS5s5AdcpnumHKJlfd3HH06P3m9TEMwwScfcY35wpQxbm9oHof+dM/lVH9Rfyu1elTVelSA==", + "license": "MIT", + "dependencies": { + "flow-enums-runtime": "^0.0.6" + }, + "engines": { + "node": ">=20.19.4" + } + }, + "node_modules/@expo/ngrok": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/@expo/ngrok/-/ngrok-4.1.3.tgz", + "integrity": "sha512-AESYaROGIGKWwWmUyQoUXcbvaUZjmpecC5buArXxYou+RID813F8T0Y5jQ2HUY49mZpYfJiy9oh4VSN37GgrXA==", + "license": "BSD-2-Clause", + "dependencies": { + "@expo/ngrok-bin": "2.3.42", + "got": "^11.5.1", + "uuid": "^3.3.2", + "yaml": "^1.10.0" + }, + "engines": { + "node": ">=10.19.0" + } + }, + "node_modules/@expo/ngrok-bin": { + "version": "2.3.42", + "resolved": "https://registry.npmjs.org/@expo/ngrok-bin/-/ngrok-bin-2.3.42.tgz", + "integrity": "sha512-kyhORGwv9XpbPeNIrX6QZ9wDVCDOScyTwxeS+ScNmUqYoZqD9LRmEqF7bpDh5VonTsrXgWrGl7wD2++oSHcaTQ==", + "bin": { + "ngrok": "bin/ngrok.js" + }, + "optionalDependencies": { + "@expo/ngrok-bin-darwin-arm64": "2.3.41", + "@expo/ngrok-bin-darwin-x64": "2.3.41", + "@expo/ngrok-bin-freebsd-ia32": "2.3.41", + "@expo/ngrok-bin-freebsd-x64": "2.3.41", + "@expo/ngrok-bin-linux-arm": "2.3.41", + "@expo/ngrok-bin-linux-arm64": "2.3.41", + "@expo/ngrok-bin-linux-ia32": "2.3.41", + "@expo/ngrok-bin-linux-x64": "2.3.41", + "@expo/ngrok-bin-sunos-x64": "2.3.41", + "@expo/ngrok-bin-win32-ia32": "2.3.41", + "@expo/ngrok-bin-win32-x64": "2.3.41" + } + }, + "node_modules/@expo/ngrok-bin-darwin-arm64": { + "version": "2.3.41", + "resolved": "https://registry.npmjs.org/@expo/ngrok-bin-darwin-arm64/-/ngrok-bin-darwin-arm64-2.3.41.tgz", + "integrity": "sha512-TPf95xp6SkvbRONZjltTOFcCJbmzAH7lrQ36Dv+djrOckWGPVq4HCur48YAeiGDqspmFEmqZ7ykD5c/bDfRFOA==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@expo/ngrok-bin-darwin-x64": { + "version": "2.3.41", + "resolved": "https://registry.npmjs.org/@expo/ngrok-bin-darwin-x64/-/ngrok-bin-darwin-x64-2.3.41.tgz", + "integrity": "sha512-29QZHfX4Ec0p0pQF5UrqiP2/Qe7t2rI96o+5b8045VCEl9AEAKHceGuyo+jfUDR4FSQBGFLSDb06xy8ghL3ZYA==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@expo/ngrok-bin-freebsd-ia32": { + "version": "2.3.41", + "resolved": "https://registry.npmjs.org/@expo/ngrok-bin-freebsd-ia32/-/ngrok-bin-freebsd-ia32-2.3.41.tgz", + "integrity": "sha512-YYXgwNZ+p0aIrwgb+1/RxJbsWhGEzBDBhZulKg1VB7tKDAd2C8uGnbK1rOCuZy013iOUsJDXaj9U5QKc13iIXw==", + "cpu": [ + "ia32" + ], + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@expo/ngrok-bin-freebsd-x64": { + "version": "2.3.41", + "resolved": "https://registry.npmjs.org/@expo/ngrok-bin-freebsd-x64/-/ngrok-bin-freebsd-x64-2.3.41.tgz", + "integrity": "sha512-1Ei6K8BB+3etmmBT0tXYC4dyVkJMigT4ELbRTF5jKfw1pblqeXM9Qpf3p8851PTlH142S3bockCeO39rSkOnkg==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@expo/ngrok-bin-linux-arm": { + "version": "2.3.41", + "resolved": "https://registry.npmjs.org/@expo/ngrok-bin-linux-arm/-/ngrok-bin-linux-arm-2.3.41.tgz", + "integrity": "sha512-B6+rW/+tEi7ZrKWQGkRzlwmKo7c1WJhNODFBSgkF/Sj9PmmNhBz67mer91S2+6nNt5pfcwLLd61CjtWfR1LUHQ==", + "cpu": [ + "arm" + ], + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@expo/ngrok-bin-linux-arm64": { + "version": "2.3.41", + "resolved": "https://registry.npmjs.org/@expo/ngrok-bin-linux-arm64/-/ngrok-bin-linux-arm64-2.3.41.tgz", + "integrity": "sha512-eC8GA/xPcmQJy4h+g2FlkuQB3lf5DjITy8Y6GyydmPYMByjUYAGEXe0brOcP893aalAzRqbNOAjSuAw1lcCLSQ==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@expo/ngrok-bin-linux-ia32": { + "version": "2.3.41", + "resolved": "https://registry.npmjs.org/@expo/ngrok-bin-linux-ia32/-/ngrok-bin-linux-ia32-2.3.41.tgz", + "integrity": "sha512-w5Cy31wSz4jYnygEHS7eRizR1yt8s9TX6kHlkjzayIiRTFRb2E1qD2l0/4T2w0LJpBjM5ZFPaaKqsNWgCUIEow==", + "cpu": [ + "ia32" + ], + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@expo/ngrok-bin-linux-x64": { + "version": "2.3.41", + "resolved": "https://registry.npmjs.org/@expo/ngrok-bin-linux-x64/-/ngrok-bin-linux-x64-2.3.41.tgz", + "integrity": "sha512-LcU3MbYHv7Sn2eFz8Yzo2rXduufOvX1/hILSirwCkH+9G8PYzpwp2TeGqVWuO+EmvtBe6NEYwgdQjJjN6I4L1A==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@expo/ngrok-bin-sunos-x64": { + "version": "2.3.41", + "resolved": "https://registry.npmjs.org/@expo/ngrok-bin-sunos-x64/-/ngrok-bin-sunos-x64-2.3.41.tgz", + "integrity": "sha512-bcOj45BLhiV2PayNmLmEVZlFMhEiiGpOr36BXC0XSL+cHUZHd6uNaS28AaZdz95lrRzGpeb0hAF8cuJjo6nq4g==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "sunos" + ] + }, + "node_modules/@expo/ngrok-bin-win32-ia32": { + "version": "2.3.41", + "resolved": "https://registry.npmjs.org/@expo/ngrok-bin-win32-ia32/-/ngrok-bin-win32-ia32-2.3.41.tgz", + "integrity": "sha512-0+vPbKvUA+a9ERgiAknmZCiWA3AnM5c6beI+51LqmjKEM4iAAlDmfXNJ89aAbvZMUtBNwEPHzJHnaM4s2SeBhA==", + "cpu": [ + "ia32" + ], + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@expo/ngrok-bin-win32-x64": { + "version": "2.3.41", + "resolved": "https://registry.npmjs.org/@expo/ngrok-bin-win32-x64/-/ngrok-bin-win32-x64-2.3.41.tgz", + "integrity": "sha512-mncsPRaG462LiYrM8mQT8OYe3/i44m3N/NzUeieYpGi8+pCOo8TIC23kR9P93CVkbM9mmXsy3X6hq91a8FWBdA==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@expo/ngrok/node_modules/uuid": { + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-3.4.0.tgz", + "integrity": "sha512-HjSDRw6gZE5JMggctHBcjVak08+KEVhSIiDzFnT9S9aegmp85S/bReBVTb4QTFaRNptJ9kuYaNhnbNEOkbKb/A==", + "deprecated": "uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028).", + "license": "MIT", + "bin": { + "uuid": "bin/uuid" + } + }, + "node_modules/@expo/ngrok/node_modules/yaml": { + "version": "1.10.3", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.3.tgz", + "integrity": "sha512-vIYeF1u3CjlhAFekPPAk2h/Kv4T3mAkMox5OymRiJQB0spDP10LHvt+K7G9Ny6NuuMAb25/6n1qyUjAcGNf/AA==", + "license": "ISC", + "engines": { + "node": ">= 6" + } + }, + "node_modules/@expo/osascript": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@expo/osascript/-/osascript-2.6.0.tgz", + "integrity": "sha512-QvqDBlJXa8CS2vRORJ4wEflY1m0vVI07uSJdIRgBrLxRPBcsrXxrtU7+wXRXMqfq9zLwNP9XbvRsXF2omoDylg==", + "license": "MIT", + "dependencies": { + "@expo/spawn-async": "^1.8.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@expo/package-manager": { + "version": "1.12.1", + "resolved": "https://registry.npmjs.org/@expo/package-manager/-/package-manager-1.12.1.tgz", + "integrity": "sha512-fQLiFAcFRWF53mtuLK32SUJQ1ahhrTcBZPZPedYTiUT5ha5FF+UO6bPtCc0Y/hgj0/m3HCGBAuSHjbg2kI9oPQ==", + "license": "MIT", + "dependencies": { + "@expo/json-file": "^10.2.0", + "@expo/spawn-async": "^1.8.0", + "chalk": "^4.0.0", + "npm-package-arg": "^11.0.0", + "ora": "^3.4.0", + "resolve-workspace-root": "^2.0.0" + } + }, + "node_modules/@expo/plist": { + "version": "0.4.9", + "resolved": "https://registry.npmjs.org/@expo/plist/-/plist-0.4.9.tgz", + "integrity": "sha512-MPVpmKGfnQEnrCzgxuXcmPP/y/t6AVm+DcSb2Myp21LKWv1N3l8uFxMggesfF4ixAxkRlGmMMx9GyDC9M+XklQ==", + "license": "MIT", + "dependencies": { + "@xmldom/xmldom": "^0.8.8", + "base64-js": "^1.2.3", + "xmlbuilder": "^15.1.1" + } + }, + "node_modules/@expo/require-utils": { + "version": "55.0.5", + "resolved": "https://registry.npmjs.org/@expo/require-utils/-/require-utils-55.0.5.tgz", + "integrity": "sha512-U4K/CQ2VpXuwfNGsN+daKmYOt15hCP8v/pXaYH6eut7kdYZo6SfJ1yr67BIcJ+1Gzzs+QzTxswAZChKpXmceyw==", + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.20.0", + "@babel/core": "^7.25.2", + "@babel/plugin-transform-modules-commonjs": "^7.24.8" + }, + "peerDependencies": { + "typescript": "^5.0.0 || ^5.0.0-0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@expo/schema-utils": { + "version": "0.1.8", + "resolved": "https://registry.npmjs.org/@expo/schema-utils/-/schema-utils-0.1.8.tgz", + "integrity": "sha512-9I6ZqvnAvKKDiO+ZF8BpQQFYWXOJvTAL5L/227RUbWG1OVZDInFifzCBiqAZ3b67NRfeAgpgvbA7rejsqhY62A==", + "license": "MIT" + }, + "node_modules/@expo/sdk-runtime-versions": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@expo/sdk-runtime-versions/-/sdk-runtime-versions-1.0.0.tgz", + "integrity": "sha512-Doz2bfiPndXYFPMRwPyGa1k5QaKDVpY806UJj570epIiMzWaYyCtobasyfC++qfIXVb5Ocy7r3tP9d62hAQ7IQ==", + "license": "MIT" + }, + "node_modules/@expo/spawn-async": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@expo/spawn-async/-/spawn-async-1.8.0.tgz", + "integrity": "sha512-eb9xxd/LbuEGSdua4NumCu/McVB9EM+F/JxB9pWgnERw4HQ9XyTNH1KapG6oqLWR8TuRK2LQfzJlmNi94CVobw==", + "license": "MIT", + "dependencies": { + "cross-spawn": "^7.0.6" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@expo/sudo-prompt": { + "version": "9.3.2", + "resolved": "https://registry.npmjs.org/@expo/sudo-prompt/-/sudo-prompt-9.3.2.tgz", + "integrity": "sha512-HHQigo3rQWKMDzYDLkubN5WQOYXJJE2eNqIQC2axC2iO3mHdwnIR7FgZVvHWtBwAdzBgAP0ECp8KqS8TiMKvgw==", + "license": "MIT" + }, + "node_modules/@expo/ws-tunnel": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/@expo/ws-tunnel/-/ws-tunnel-1.0.6.tgz", + "integrity": "sha512-nDRbLmSrJar7abvUjp3smDwH8HcbZcoOEa5jVPUv9/9CajgmWw20JNRwTuBRzWIWIkEJDkz20GoNA+tSwUqk0Q==", + "license": "MIT" + }, + "node_modules/@expo/xcpretty": { + "version": "4.4.4", + "resolved": "https://registry.npmjs.org/@expo/xcpretty/-/xcpretty-4.4.4.tgz", + "integrity": "sha512-4aQzz9vgxcNXFfo/iyNgDDYfsU5XGKKxWxZopw0cVotHiW+U8IJbIxMaxsINs6bHhtkG3StKNPcOrn3eBuxKPw==", + "license": "BSD-3-Clause", + "dependencies": { + "@babel/code-frame": "^7.20.0", + "chalk": "^4.1.0", + "js-yaml": "^4.1.0" + }, + "bin": { + "excpretty": "build/cli.js" + } + }, + "node_modules/@expo/xcpretty/node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "license": "Python-2.0" + }, + "node_modules/@expo/xcpretty/node_modules/js-yaml": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.2.0.tgz", + "integrity": "sha512-ePWsvanv0DWuDRsW8dnt+R4jQ31SCRCQ7hhNcPXZPsoBZiemuZNYGf7adZdqX2D86j6rvKp3RpCxVTSb8WQlOw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/@isaacs/fs-minipass": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz", + "integrity": "sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==", + "license": "ISC", + "dependencies": { + "minipass": "^7.0.4" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@isaacs/ttlcache": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/@isaacs/ttlcache/-/ttlcache-1.4.1.tgz", + "integrity": "sha512-RQgQ4uQ+pLbqXfOmieB91ejmLwvSgv9nLx6sT6sD83s7umBypgg+OIBOBbEUiJXrfpnp9j0mRhYYdzp9uqq3lA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/@istanbuljs/load-nyc-config": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@istanbuljs/load-nyc-config/-/load-nyc-config-1.1.0.tgz", + "integrity": "sha512-VjeHSlIzpv/NyD3N0YuHfXOPDIixcA1q2ZV98wsMqcYlPmv2n3Yb2lYP9XMElnaFVXg5A7YLTeLu6V84uQDjmQ==", + "license": "ISC", + "dependencies": { + "camelcase": "^5.3.1", + "find-up": "^4.1.0", + "get-package-type": "^0.1.0", + "js-yaml": "^3.13.1", + "resolve-from": "^5.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@istanbuljs/load-nyc-config/node_modules/camelcase": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz", + "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/@istanbuljs/schema": { + "version": "0.1.6", + "resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.6.tgz", + "integrity": "sha512-+Sg6GCR/wy1oSmQDFq4LQDAhm3ETKnorxN+y5nbLULOR3P0c14f2Wurzj3/xqPXtasLFfHd5iRFQ7AJt4KH2cw==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/@jest/create-cache-key-function": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/create-cache-key-function/-/create-cache-key-function-29.7.0.tgz", + "integrity": "sha512-4QqS3LY5PBmTRHj9sAg1HLoPzqAI0uOX6wI/TRqHIcOxlFidy6YEmCQJk6FSZjNLGCeubDMfmkWL+qaLKhSGQA==", + "license": "MIT", + "dependencies": { + "@jest/types": "^29.6.3" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/create-cache-key-function/node_modules/@jest/types": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/@jest/types/-/types-29.6.3.tgz", + "integrity": "sha512-u3UPsIilWKOM3F9CXtrG8LEJmNxwoCQC/XVj4IKYXvvpx7QIi/Kg1LI5uDmDpKlac62NUtX7eLjRh+jVZcLOzw==", + "license": "MIT", + "dependencies": { + "@jest/schemas": "^29.6.3", + "@types/istanbul-lib-coverage": "^2.0.0", + "@types/istanbul-reports": "^3.0.0", + "@types/node": "*", + "@types/yargs": "^17.0.8", + "chalk": "^4.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/create-cache-key-function/node_modules/@types/istanbul-reports": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/istanbul-reports/-/istanbul-reports-3.0.4.tgz", + "integrity": "sha512-pk2B1NWalF9toCRu6gjBzR69syFjP4Od8WRAX+0mmf9lAjCRicLOWc+ZrxZHx/0XRjotgkF9t6iaMJ+aXcOdZQ==", + "license": "MIT", + "dependencies": { + "@types/istanbul-lib-report": "*" + } + }, + "node_modules/@jest/create-cache-key-function/node_modules/@types/yargs": { + "version": "17.0.35", + "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.35.tgz", + "integrity": "sha512-qUHkeCyQFxMXg79wQfTtfndEC+N9ZZg76HJftDJp+qH2tV7Gj4OJi7l+PiWwJ+pWtW8GwSmqsDj/oymhrTWXjg==", + "license": "MIT", + "dependencies": { + "@types/yargs-parser": "*" + } + }, + "node_modules/@jest/environment": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/environment/-/environment-29.7.0.tgz", + "integrity": "sha512-aQIfHDq33ExsN4jP1NWGXhxgQ/wixs60gDiKO+XVMd8Mn0NWPWgc34ZQDTb2jKaUWQ7MuwoitXAsN2XVXNMpAw==", + "license": "MIT", + "dependencies": { + "@jest/fake-timers": "^29.7.0", + "@jest/types": "^29.6.3", + "@types/node": "*", + "jest-mock": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/environment/node_modules/@jest/types": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/@jest/types/-/types-29.6.3.tgz", + "integrity": "sha512-u3UPsIilWKOM3F9CXtrG8LEJmNxwoCQC/XVj4IKYXvvpx7QIi/Kg1LI5uDmDpKlac62NUtX7eLjRh+jVZcLOzw==", + "license": "MIT", + "dependencies": { + "@jest/schemas": "^29.6.3", + "@types/istanbul-lib-coverage": "^2.0.0", + "@types/istanbul-reports": "^3.0.0", + "@types/node": "*", + "@types/yargs": "^17.0.8", + "chalk": "^4.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/environment/node_modules/@types/istanbul-reports": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/istanbul-reports/-/istanbul-reports-3.0.4.tgz", + "integrity": "sha512-pk2B1NWalF9toCRu6gjBzR69syFjP4Od8WRAX+0mmf9lAjCRicLOWc+ZrxZHx/0XRjotgkF9t6iaMJ+aXcOdZQ==", + "license": "MIT", + "dependencies": { + "@types/istanbul-lib-report": "*" + } + }, + "node_modules/@jest/environment/node_modules/@types/yargs": { + "version": "17.0.35", + "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.35.tgz", + "integrity": "sha512-qUHkeCyQFxMXg79wQfTtfndEC+N9ZZg76HJftDJp+qH2tV7Gj4OJi7l+PiWwJ+pWtW8GwSmqsDj/oymhrTWXjg==", + "license": "MIT", + "dependencies": { + "@types/yargs-parser": "*" + } + }, + "node_modules/@jest/fake-timers": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/fake-timers/-/fake-timers-29.7.0.tgz", + "integrity": "sha512-q4DH1Ha4TTFPdxLsqDXK1d3+ioSL7yL5oCMJZgDYm6i+6CygW5E5xVr/D1HdsGxjt1ZWSfUAs9OxSB/BNelWrQ==", + "license": "MIT", + "dependencies": { + "@jest/types": "^29.6.3", + "@sinonjs/fake-timers": "^10.0.2", + "@types/node": "*", + "jest-message-util": "^29.7.0", + "jest-mock": "^29.7.0", + "jest-util": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/fake-timers/node_modules/@jest/types": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/@jest/types/-/types-29.6.3.tgz", + "integrity": "sha512-u3UPsIilWKOM3F9CXtrG8LEJmNxwoCQC/XVj4IKYXvvpx7QIi/Kg1LI5uDmDpKlac62NUtX7eLjRh+jVZcLOzw==", + "license": "MIT", + "dependencies": { + "@jest/schemas": "^29.6.3", + "@types/istanbul-lib-coverage": "^2.0.0", + "@types/istanbul-reports": "^3.0.0", + "@types/node": "*", + "@types/yargs": "^17.0.8", + "chalk": "^4.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/fake-timers/node_modules/@types/istanbul-reports": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/istanbul-reports/-/istanbul-reports-3.0.4.tgz", + "integrity": "sha512-pk2B1NWalF9toCRu6gjBzR69syFjP4Od8WRAX+0mmf9lAjCRicLOWc+ZrxZHx/0XRjotgkF9t6iaMJ+aXcOdZQ==", + "license": "MIT", + "dependencies": { + "@types/istanbul-lib-report": "*" + } + }, + "node_modules/@jest/fake-timers/node_modules/@types/yargs": { + "version": "17.0.35", + "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.35.tgz", + "integrity": "sha512-qUHkeCyQFxMXg79wQfTtfndEC+N9ZZg76HJftDJp+qH2tV7Gj4OJi7l+PiWwJ+pWtW8GwSmqsDj/oymhrTWXjg==", + "license": "MIT", + "dependencies": { + "@types/yargs-parser": "*" + } + }, + "node_modules/@jest/schemas": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-29.6.3.tgz", + "integrity": "sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA==", + "license": "MIT", + "dependencies": { + "@sinclair/typebox": "^0.27.8" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/transform": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/transform/-/transform-29.7.0.tgz", + "integrity": "sha512-ok/BTPFzFKVMwO5eOHRrvnBVHdRy9IrsrW1GpMaQ9MCnilNLXQKmAX8s1YXDFaai9xJpac2ySzV0YeRRECr2Vw==", + "license": "MIT", + "dependencies": { + "@babel/core": "^7.11.6", + "@jest/types": "^29.6.3", + "@jridgewell/trace-mapping": "^0.3.18", + "babel-plugin-istanbul": "^6.1.1", + "chalk": "^4.0.0", + "convert-source-map": "^2.0.0", + "fast-json-stable-stringify": "^2.1.0", + "graceful-fs": "^4.2.9", + "jest-haste-map": "^29.7.0", + "jest-regex-util": "^29.6.3", + "jest-util": "^29.7.0", + "micromatch": "^4.0.4", + "pirates": "^4.0.4", + "slash": "^3.0.0", + "write-file-atomic": "^4.0.2" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/transform/node_modules/@jest/types": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/@jest/types/-/types-29.6.3.tgz", + "integrity": "sha512-u3UPsIilWKOM3F9CXtrG8LEJmNxwoCQC/XVj4IKYXvvpx7QIi/Kg1LI5uDmDpKlac62NUtX7eLjRh+jVZcLOzw==", + "license": "MIT", + "dependencies": { + "@jest/schemas": "^29.6.3", + "@types/istanbul-lib-coverage": "^2.0.0", + "@types/istanbul-reports": "^3.0.0", + "@types/node": "*", + "@types/yargs": "^17.0.8", + "chalk": "^4.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/transform/node_modules/@types/istanbul-reports": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/istanbul-reports/-/istanbul-reports-3.0.4.tgz", + "integrity": "sha512-pk2B1NWalF9toCRu6gjBzR69syFjP4Od8WRAX+0mmf9lAjCRicLOWc+ZrxZHx/0XRjotgkF9t6iaMJ+aXcOdZQ==", + "license": "MIT", + "dependencies": { + "@types/istanbul-lib-report": "*" + } + }, + "node_modules/@jest/transform/node_modules/@types/yargs": { + "version": "17.0.35", + "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.35.tgz", + "integrity": "sha512-qUHkeCyQFxMXg79wQfTtfndEC+N9ZZg76HJftDJp+qH2tV7Gj4OJi7l+PiWwJ+pWtW8GwSmqsDj/oymhrTWXjg==", + "license": "MIT", + "dependencies": { + "@types/yargs-parser": "*" + } + }, + "node_modules/@jest/transform/node_modules/write-file-atomic": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/write-file-atomic/-/write-file-atomic-4.0.2.tgz", + "integrity": "sha512-7KxauUdBmSdWnmpaGFg+ppNjKF8uNLry8LyzjauQDOVONfFLNKrKvQOxZ/VuTIcS/gge/YNahf5RIIQWTSarlg==", + "license": "ISC", + "dependencies": { + "imurmurhash": "^0.1.4", + "signal-exit": "^3.0.7" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || >=16.0.0" + } + }, + "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==", + "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==", + "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==", + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/source-map": { + "version": "0.3.11", + "resolved": "https://registry.npmjs.org/@jridgewell/source-map/-/source-map-0.3.11.tgz", + "integrity": "sha512-ZMp1V8ZFcPG5dIWnQLr3NSI1MiCU7UETdS/A0G8V/XWHvJv3ZsFqutJn1Y5RPmAPX6F3BiE397OqveU/9NCuIA==", + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.25" + } + }, + "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==", + "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==", + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@react-native/assets-registry": { + "version": "0.81.5", + "resolved": "https://registry.npmjs.org/@react-native/assets-registry/-/assets-registry-0.81.5.tgz", + "integrity": "sha512-705B6x/5Kxm1RKRvSv0ADYWm5JOnoiQ1ufW7h8uu2E6G9Of/eE6hP/Ivw3U5jI16ERqZxiKQwk34VJbB0niX9w==", + "license": "MIT", + "engines": { + "node": ">= 20.19.4" + } + }, + "node_modules/@react-native/babel-plugin-codegen": { + "version": "0.81.5", + "resolved": "https://registry.npmjs.org/@react-native/babel-plugin-codegen/-/babel-plugin-codegen-0.81.5.tgz", + "integrity": "sha512-oF71cIH6je3fSLi6VPjjC3Sgyyn57JLHXs+mHWc9MoCiJJcM4nqsS5J38zv1XQ8d3zOW2JtHro+LF0tagj2bfQ==", + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.25.3", + "@react-native/codegen": "0.81.5" + }, + "engines": { + "node": ">= 20.19.4" + } + }, + "node_modules/@react-native/babel-preset": { + "version": "0.81.5", + "resolved": "https://registry.npmjs.org/@react-native/babel-preset/-/babel-preset-0.81.5.tgz", + "integrity": "sha512-UoI/x/5tCmi+pZ3c1+Ypr1DaRMDLI3y+Q70pVLLVgrnC3DHsHRIbHcCHIeG/IJvoeFqFM2sTdhSOLJrf8lOPrA==", + "license": "MIT", + "dependencies": { + "@babel/core": "^7.25.2", + "@babel/plugin-proposal-export-default-from": "^7.24.7", + "@babel/plugin-syntax-dynamic-import": "^7.8.3", + "@babel/plugin-syntax-export-default-from": "^7.24.7", + "@babel/plugin-syntax-nullish-coalescing-operator": "^7.8.3", + "@babel/plugin-syntax-optional-chaining": "^7.8.3", + "@babel/plugin-transform-arrow-functions": "^7.24.7", + "@babel/plugin-transform-async-generator-functions": "^7.25.4", + "@babel/plugin-transform-async-to-generator": "^7.24.7", + "@babel/plugin-transform-block-scoping": "^7.25.0", + "@babel/plugin-transform-class-properties": "^7.25.4", + "@babel/plugin-transform-classes": "^7.25.4", + "@babel/plugin-transform-computed-properties": "^7.24.7", + "@babel/plugin-transform-destructuring": "^7.24.8", + "@babel/plugin-transform-flow-strip-types": "^7.25.2", + "@babel/plugin-transform-for-of": "^7.24.7", + "@babel/plugin-transform-function-name": "^7.25.1", + "@babel/plugin-transform-literals": "^7.25.2", + "@babel/plugin-transform-logical-assignment-operators": "^7.24.7", + "@babel/plugin-transform-modules-commonjs": "^7.24.8", + "@babel/plugin-transform-named-capturing-groups-regex": "^7.24.7", + "@babel/plugin-transform-nullish-coalescing-operator": "^7.24.7", + "@babel/plugin-transform-numeric-separator": "^7.24.7", + "@babel/plugin-transform-object-rest-spread": "^7.24.7", + "@babel/plugin-transform-optional-catch-binding": "^7.24.7", + "@babel/plugin-transform-optional-chaining": "^7.24.8", + "@babel/plugin-transform-parameters": "^7.24.7", + "@babel/plugin-transform-private-methods": "^7.24.7", + "@babel/plugin-transform-private-property-in-object": "^7.24.7", + "@babel/plugin-transform-react-display-name": "^7.24.7", + "@babel/plugin-transform-react-jsx": "^7.25.2", + "@babel/plugin-transform-react-jsx-self": "^7.24.7", + "@babel/plugin-transform-react-jsx-source": "^7.24.7", + "@babel/plugin-transform-regenerator": "^7.24.7", + "@babel/plugin-transform-runtime": "^7.24.7", + "@babel/plugin-transform-shorthand-properties": "^7.24.7", + "@babel/plugin-transform-spread": "^7.24.7", + "@babel/plugin-transform-sticky-regex": "^7.24.7", + "@babel/plugin-transform-typescript": "^7.25.2", + "@babel/plugin-transform-unicode-regex": "^7.24.7", + "@babel/template": "^7.25.0", + "@react-native/babel-plugin-codegen": "0.81.5", + "babel-plugin-syntax-hermes-parser": "0.29.1", + "babel-plugin-transform-flow-enums": "^0.0.2", + "react-refresh": "^0.14.0" + }, + "engines": { + "node": ">= 20.19.4" + }, + "peerDependencies": { + "@babel/core": "*" + } + }, + "node_modules/@react-native/codegen": { + "version": "0.81.5", + "resolved": "https://registry.npmjs.org/@react-native/codegen/-/codegen-0.81.5.tgz", + "integrity": "sha512-a2TDA03Up8lpSa9sh5VRGCQDXgCTOyDOFH+aqyinxp1HChG8uk89/G+nkJ9FPd0rqgi25eCTR16TWdS3b+fA6g==", + "license": "MIT", + "dependencies": { + "@babel/core": "^7.25.2", + "@babel/parser": "^7.25.3", + "glob": "^7.1.1", + "hermes-parser": "0.29.1", + "invariant": "^2.2.4", + "nullthrows": "^1.1.1", + "yargs": "^17.6.2" + }, + "engines": { + "node": ">= 20.19.4" + }, + "peerDependencies": { + "@babel/core": "*" + } + }, + "node_modules/@react-native/community-cli-plugin": { + "version": "0.81.5", + "resolved": "https://registry.npmjs.org/@react-native/community-cli-plugin/-/community-cli-plugin-0.81.5.tgz", + "integrity": "sha512-yWRlmEOtcyvSZ4+OvqPabt+NS36vg0K/WADTQLhrYrm9qdZSuXmq8PmdJWz/68wAqKQ+4KTILiq2kjRQwnyhQw==", + "license": "MIT", + "dependencies": { + "@react-native/dev-middleware": "0.81.5", + "debug": "^4.4.0", + "invariant": "^2.2.4", + "metro": "^0.83.1", + "metro-config": "^0.83.1", + "metro-core": "^0.83.1", + "semver": "^7.1.3" + }, + "engines": { + "node": ">= 20.19.4" + }, + "peerDependencies": { + "@react-native-community/cli": "*", + "@react-native/metro-config": "*" + }, + "peerDependenciesMeta": { + "@react-native-community/cli": { + "optional": true + }, + "@react-native/metro-config": { + "optional": true + } + } + }, + "node_modules/@react-native/community-cli-plugin/node_modules/semver": { + "version": "7.8.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.4.tgz", + "integrity": "sha512-rUCObTnP32Q08R2uuIrt7r9PlEonuTmtuXYcW6s5kjdlj3xbnwe+21yXptAUYcMAABLkYYTtnmzb3w3EDZfueA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@react-native/debugger-frontend": { + "version": "0.81.5", + "resolved": "https://registry.npmjs.org/@react-native/debugger-frontend/-/debugger-frontend-0.81.5.tgz", + "integrity": "sha512-bnd9FSdWKx2ncklOetCgrlwqSGhMHP2zOxObJbOWXoj7GHEmih4MKarBo5/a8gX8EfA1EwRATdfNBQ81DY+h+w==", + "license": "BSD-3-Clause", + "engines": { + "node": ">= 20.19.4" + } + }, + "node_modules/@react-native/dev-middleware": { + "version": "0.81.5", + "resolved": "https://registry.npmjs.org/@react-native/dev-middleware/-/dev-middleware-0.81.5.tgz", + "integrity": "sha512-WfPfZzboYgo/TUtysuD5xyANzzfka8Ebni6RIb2wDxhb56ERi7qDrE4xGhtPsjCL4pQBXSVxyIlCy0d8I6EgGA==", + "license": "MIT", + "dependencies": { + "@isaacs/ttlcache": "^1.4.1", + "@react-native/debugger-frontend": "0.81.5", + "chrome-launcher": "^0.15.2", + "chromium-edge-launcher": "^0.2.0", + "connect": "^3.6.5", + "debug": "^4.4.0", + "invariant": "^2.2.4", + "nullthrows": "^1.1.1", + "open": "^7.0.3", + "serve-static": "^1.16.2", + "ws": "^6.2.3" + }, + "engines": { + "node": ">= 20.19.4" + } + }, + "node_modules/@react-native/dev-middleware/node_modules/ws": { + "version": "6.2.4", + "resolved": "https://registry.npmjs.org/ws/-/ws-6.2.4.tgz", + "integrity": "sha512-PNIUUyLI5YpkJZj60YBzX1o0ByQ4ovvfmq9N/Kig/PAYbVlGyz4R6G0SEWrD0O9acc0sT2+IdMBVLFv8FSi0Nw==", + "license": "MIT", + "dependencies": { + "async-limiter": "~1.0.0" + } + }, + "node_modules/@react-native/gradle-plugin": { + "version": "0.81.5", + "resolved": "https://registry.npmjs.org/@react-native/gradle-plugin/-/gradle-plugin-0.81.5.tgz", + "integrity": "sha512-hORRlNBj+ReNMLo9jme3yQ6JQf4GZpVEBLxmTXGGlIL78MAezDZr5/uq9dwElSbcGmLEgeiax6e174Fie6qPLg==", + "license": "MIT", + "engines": { + "node": ">= 20.19.4" + } + }, + "node_modules/@react-native/js-polyfills": { + "version": "0.81.5", + "resolved": "https://registry.npmjs.org/@react-native/js-polyfills/-/js-polyfills-0.81.5.tgz", + "integrity": "sha512-fB7M1CMOCIUudTRuj7kzxIBTVw2KXnsgbQ6+4cbqSxo8NmRRhA0Ul4ZUzZj3rFd3VznTL4Brmocv1oiN0bWZ8w==", + "license": "MIT", + "engines": { + "node": ">= 20.19.4" + } + }, + "node_modules/@react-native/normalize-colors": { + "version": "0.81.5", + "resolved": "https://registry.npmjs.org/@react-native/normalize-colors/-/normalize-colors-0.81.5.tgz", + "integrity": "sha512-0HuJ8YtqlTVRXGZuGeBejLE04wSQsibpTI+RGOyVqxZvgtlLLC/Ssw0UmbHhT4lYMp2fhdtvKZSs5emWB1zR/g==", + "license": "MIT" + }, + "node_modules/@react-native/virtualized-lists": { + "version": "0.81.5", + "resolved": "https://registry.npmjs.org/@react-native/virtualized-lists/-/virtualized-lists-0.81.5.tgz", + "integrity": "sha512-UVXgV/db25OPIvwZySeToXD/9sKKhOdkcWmmf4Jh8iBZuyfML+/5CasaZ1E7Lqg6g3uqVQq75NqIwkYmORJMPw==", + "license": "MIT", + "dependencies": { + "invariant": "^2.2.4", + "nullthrows": "^1.1.1" + }, + "engines": { + "node": ">= 20.19.4" + }, + "peerDependencies": { + "@types/react": "^19.1.0", + "react": "*", + "react-native": "*" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@react-navigation/bottom-tabs": { + "version": "6.6.1", + "resolved": "https://registry.npmjs.org/@react-navigation/bottom-tabs/-/bottom-tabs-6.6.1.tgz", + "integrity": "sha512-9oD4cypEBjPuaMiu9tevWGiQ4w/d6l3HNhcJ1IjXZ24xvYDSs0mqjUcdt8SWUolCvRrYc/DmNBLlT83bk0bHTw==", + "deprecated": "This version is no longer supported", + "license": "MIT", + "dependencies": { + "@react-navigation/elements": "^1.3.31", + "color": "^4.2.3", + "warn-once": "^0.1.0" + }, + "peerDependencies": { + "@react-navigation/native": "^6.0.0", + "react": "*", + "react-native": "*", + "react-native-safe-area-context": ">= 3.0.0", + "react-native-screens": ">= 3.0.0" + } + }, + "node_modules/@react-navigation/core": { + "version": "6.4.17", + "resolved": "https://registry.npmjs.org/@react-navigation/core/-/core-6.4.17.tgz", + "integrity": "sha512-Nd76EpomzChWAosGqWOYE3ItayhDzIEzzZsT7PfGcRFDgW5miHV2t4MZcq9YIK4tzxZjVVpYbIynOOQQd1e0Cg==", + "deprecated": "This version is no longer supported", + "license": "MIT", + "dependencies": { + "@react-navigation/routers": "^6.1.9", + "escape-string-regexp": "^4.0.0", + "nanoid": "^3.1.23", + "query-string": "^7.1.3", + "react-is": "^16.13.0", + "use-latest-callback": "^0.2.1" + }, + "peerDependencies": { + "react": "*" + } + }, + "node_modules/@react-navigation/elements": { + "version": "1.3.31", + "resolved": "https://registry.npmjs.org/@react-navigation/elements/-/elements-1.3.31.tgz", + "integrity": "sha512-bUzP4Awlljx5RKEExw8WYtif8EuQni2glDaieYROKTnaxsu9kEIA515sXQgUDZU4Ob12VoL7+z70uO3qrlfXcQ==", + "deprecated": "This version is no longer supported", + "license": "MIT", + "peerDependencies": { + "@react-navigation/native": "^6.0.0", + "react": "*", + "react-native": "*", + "react-native-safe-area-context": ">= 3.0.0" + } + }, + "node_modules/@react-navigation/native": { + "version": "6.1.18", + "resolved": "https://registry.npmjs.org/@react-navigation/native/-/native-6.1.18.tgz", + "integrity": "sha512-mIT9MiL/vMm4eirLcmw2h6h/Nm5FICtnYSdohq4vTLA2FF/6PNhByM7s8ffqoVfE5L0uAa6Xda1B7oddolUiGg==", + "deprecated": "This version is no longer supported", + "license": "MIT", + "dependencies": { + "@react-navigation/core": "^6.4.17", + "escape-string-regexp": "^4.0.0", + "fast-deep-equal": "^3.1.3", + "nanoid": "^3.1.23" + }, + "peerDependencies": { + "react": "*", + "react-native": "*" + } + }, + "node_modules/@react-navigation/routers": { + "version": "6.1.9", + "resolved": "https://registry.npmjs.org/@react-navigation/routers/-/routers-6.1.9.tgz", + "integrity": "sha512-lTM8gSFHSfkJvQkxacGM6VJtBt61ip2XO54aNfswD+KMw6eeZ4oehl7m0me3CR9hnDE4+60iAZR8sAhvCiI3NA==", + "deprecated": "This version is no longer supported", + "license": "MIT", + "dependencies": { + "nanoid": "^3.1.23" + } + }, + "node_modules/@sinclair/typebox": { + "version": "0.27.10", + "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.10.tgz", + "integrity": "sha512-MTBk/3jGLNB2tVxv6uLlFh1iu64iYOQ2PbdOSK3NW8JZsmlaOh2q6sdtKowBhfw8QFLmYNzTW4/oK4uATIi6ZA==", + "license": "MIT" + }, + "node_modules/@sindresorhus/is": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-4.6.0.tgz", + "integrity": "sha512-t09vSN3MdfsyCHoFcTRCH/iUtG7OJ0CsjzB8cjAmKc/va/kIgeDI/TxsigdncE/4be734m0cvIYwNaV4i2XqAw==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sindresorhus/is?sponsor=1" + } + }, + "node_modules/@sinonjs/commons": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@sinonjs/commons/-/commons-3.0.1.tgz", + "integrity": "sha512-K3mCHKQ9sVh8o1C9cxkwxaOmXoAMlDxC1mYyHrjqOWEcBjYr76t96zL2zlj5dUGZ3HSw240X1qgH3Mjf1yJWpQ==", + "license": "BSD-3-Clause", + "dependencies": { + "type-detect": "4.0.8" + } + }, + "node_modules/@sinonjs/fake-timers": { + "version": "10.3.0", + "resolved": "https://registry.npmjs.org/@sinonjs/fake-timers/-/fake-timers-10.3.0.tgz", + "integrity": "sha512-V4BG07kuYSUkTCSBHG8G8TNhM+F19jXFWnQtzj+we8DrkpSBCee9Z3Ms8yiGer/dlmhe35/Xdgyo3/0rQKg7YA==", + "license": "BSD-3-Clause", + "dependencies": { + "@sinonjs/commons": "^3.0.0" + } + }, + "node_modules/@szmarczak/http-timer": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/@szmarczak/http-timer/-/http-timer-4.0.6.tgz", + "integrity": "sha512-4BAffykYOgO+5nzBWYwE3W90sBgLJoUPRWWcL8wlyiM8IB8ipJz3UMJ9KXQd1RKQXpKp8Tutn80HZtWsu2u76w==", + "license": "MIT", + "dependencies": { + "defer-to-connect": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@tweenjs/tween.js": { + "version": "23.1.3", + "resolved": "https://registry.npmjs.org/@tweenjs/tween.js/-/tween.js-23.1.3.tgz", + "integrity": "sha512-vJmvvwFxYuGnF2axRtPYocag6Clbb5YS7kLL+SO/TeVFzHqDIWrNKYtcsPMibjDx9O+bu+psAy9NKfWklassUA==", + "dev": true, + "license": "MIT" + }, + "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==", + "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==", + "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==", + "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==", + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/cacheable-request": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/@types/cacheable-request/-/cacheable-request-6.0.3.tgz", + "integrity": "sha512-IQ3EbTzGxIigb1I3qPZc1rWJnH0BmSKv5QYTalEwweFvyBDLSAe24zP0le/hyi7ecGfZVlIVAg4BZqb8WBwKqw==", + "license": "MIT", + "dependencies": { + "@types/http-cache-semantics": "*", + "@types/keyv": "^3.1.4", + "@types/node": "*", + "@types/responselike": "^1.0.0" + } + }, + "node_modules/@types/graceful-fs": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@types/graceful-fs/-/graceful-fs-4.1.9.tgz", + "integrity": "sha512-olP3sd1qOEe5dXTSaFvQG+02VdRXcdytWLAZsAq1PecU8uqQAhkrnbli7DagjtXKW/Bl7YJbUsa8MPcuc8LHEQ==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/hammerjs": { + "version": "2.0.46", + "resolved": "https://registry.npmjs.org/@types/hammerjs/-/hammerjs-2.0.46.tgz", + "integrity": "sha512-ynRvcq6wvqexJ9brDMS4BnBLzmr0e14d6ZJTEShTBWKymQiHwlAyGu0ZPEFI2Fh1U53F7tN9ufClWM5KvqkKOw==", + "license": "MIT" + }, + "node_modules/@types/http-cache-semantics": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@types/http-cache-semantics/-/http-cache-semantics-4.2.0.tgz", + "integrity": "sha512-L3LgimLHXtGkWikKnsPg0/VFx9OGZaC+eN1u4r+OB1XRqH3meBIAVC2zr1WdMH+RHmnRkqliQAOHNJ/E0j/e0Q==", + "license": "MIT" + }, + "node_modules/@types/istanbul-lib-coverage": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.6.tgz", + "integrity": "sha512-2QF/t/auWm0lsy8XtKVPG19v3sSOQlJe/YHZgfjb/KBBHOGSV+J2q/S671rcq9uTBrLAXmZpqJiaQbMT+zNU1w==", + "license": "MIT" + }, + "node_modules/@types/istanbul-lib-report": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@types/istanbul-lib-report/-/istanbul-lib-report-3.0.3.tgz", + "integrity": "sha512-NQn7AHQnk/RSLOxrBbGyJM/aVQ+pjj5HCgasFxc0K/KhoATfQ/47AyUl15I2yBUpihjmas+a+VJBOqecrFH+uA==", + "license": "MIT", + "dependencies": { + "@types/istanbul-lib-coverage": "*" + } + }, + "node_modules/@types/keyv": { + "version": "3.1.4", + "resolved": "https://registry.npmjs.org/@types/keyv/-/keyv-3.1.4.tgz", + "integrity": "sha512-BQ5aZNSCpj7D6K2ksrRCTmKRLEpnPvWDiLPfoGyhZ++8YtiK9d/3DBKPJgry359X/P1PfruyYwvnvwFjuEiEIg==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/node": { + "version": "18.19.130", + "resolved": "https://registry.npmjs.org/@types/node/-/node-18.19.130.tgz", + "integrity": "sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg==", + "license": "MIT", + "dependencies": { + "undici-types": "~5.26.4" + } + }, + "node_modules/@types/react": { + "version": "19.1.17", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.1.17.tgz", + "integrity": "sha512-Qec1E3mhALmaspIrhWt9jkQMNdw6bReVu64mjvhbhq2NFPftLPVr+l1SZgmw/66WwBNpDh7ao5AT6gF5v41PFA==", + "dev": true, + "license": "MIT", + "dependencies": { + "csstype": "^3.0.2" + } + }, + "node_modules/@types/responselike": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@types/responselike/-/responselike-1.0.3.tgz", + "integrity": "sha512-H/+L+UkTV33uf49PH5pCAUBVPNj2nDBXTN+qS1dOwyyg24l3CcicicCA7ca+HMvJBZcFgl5r8e+RR6elsb4Lyw==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/stack-utils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@types/stack-utils/-/stack-utils-2.0.3.tgz", + "integrity": "sha512-9aEbYZ3TbYMznPdcdr3SmIrLXwC/AKZXQeCf9Pgao5CKb8CyHuEX5jzWPTkvregvhRJHcpRO6BFoGW9ycaOkYw==", + "license": "MIT" + }, + "node_modules/@types/stats.js": { + "version": "0.17.4", + "resolved": "https://registry.npmjs.org/@types/stats.js/-/stats.js-0.17.4.tgz", + "integrity": "sha512-jIBvWWShCvlBqBNIZt0KAshWpvSjhkwkEu4ZUcASoAvhmrgAUI2t1dXrjSL4xXVLB4FznPrIsX3nKXFl/Dt4vA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/three": { + "version": "0.166.0", + "resolved": "https://registry.npmjs.org/@types/three/-/three-0.166.0.tgz", + "integrity": "sha512-FHMnpcdhdbdOOIYbfkTkUVpYMW53odxbTRwd0/xJpYnTzEsjnVnondGAvHZb4z06UW0vo6WPVuvH0/9qrxKx7g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@tweenjs/tween.js": "~23.1.2", + "@types/stats.js": "*", + "@types/webxr": "*", + "fflate": "~0.8.2", + "meshoptimizer": "~0.18.1" + } + }, + "node_modules/@types/webxr": { + "version": "0.5.24", + "resolved": "https://registry.npmjs.org/@types/webxr/-/webxr-0.5.24.tgz", + "integrity": "sha512-h8fgEd/DpoS9CBrjEQXR+dIDraopAEfu4wYVNY2tEPwk60stPWhvZMf4Foo5FakuQ7HFZoa8WceaWFervK2Ovg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/yargs-parser": { + "version": "21.0.3", + "resolved": "https://registry.npmjs.org/@types/yargs-parser/-/yargs-parser-21.0.3.tgz", + "integrity": "sha512-I4q9QU9MQv4oEOz4tAHJtNz1cwuLxn2F3xcc2iV5WdqLPpUnj30aUuxt1mAxYTG+oe8CZMV/+6rU4S4gRDzqtQ==", + "license": "MIT" + }, + "node_modules/@ungap/structured-clone": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.1.tgz", + "integrity": "sha512-mUFwbeTqrVgDQxFveS+df2yfap6iuP20NAKAsBt5jDEoOTDew+zwLAOilHCeQJOVSvmgCX4ogqIrA0mnyr08yQ==", + "license": "ISC" + }, + "node_modules/@urql/core": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@urql/core/-/core-5.2.0.tgz", + "integrity": "sha512-/n0ieD0mvvDnVAXEQgX/7qJiVcvYvNkOHeBvkwtylfjydar123caCXcl58PXFY11oU1oquJocVXHxLAbtv4x1A==", + "license": "MIT", + "dependencies": { + "@0no-co/graphql.web": "^1.0.13", + "wonka": "^6.3.2" + } + }, + "node_modules/@urql/exchange-retry": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@urql/exchange-retry/-/exchange-retry-1.3.2.tgz", + "integrity": "sha512-TQMCz2pFJMfpNxmSfX1VSfTjwUIFx/mL+p1bnfM1xjjdla7Z+KnGMW/EhFbpckp3LyWAH4PgOsMwOMnIN+MBFg==", + "license": "MIT", + "dependencies": { + "@urql/core": "^5.1.2", + "wonka": "^6.3.2" + }, + "peerDependencies": { + "@urql/core": "^5.0.0" + } + }, + "node_modules/@xmldom/xmldom": { + "version": "0.8.13", + "resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.13.tgz", + "integrity": "sha512-KRYzxepc14G/CEpEGc3Yn+JKaAeT63smlDr+vjB8jRfgTBBI9wRj/nkQEO+ucV8p8I9bfKLWp37uHgFrbntPvw==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/abort-controller": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz", + "integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==", + "license": "MIT", + "dependencies": { + "event-target-shim": "^5.0.0" + }, + "engines": { + "node": ">=6.5" + } + }, + "node_modules/accepts": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", + "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", + "license": "MIT", + "dependencies": { + "mime-types": "~2.1.34", + "negotiator": "0.6.3" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/acorn": { + "version": "8.17.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.17.0.tgz", + "integrity": "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==", + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/adaptive-bezier-curve": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/adaptive-bezier-curve/-/adaptive-bezier-curve-1.0.3.tgz", + "integrity": "sha512-mDcwN284LlNAdunqnVmS0PAoDNHKze/PY8zvpCdxzyXD+ZZFeMWQ3FKNBw0VMOd9IfnhIyzAWJDXzRcWnXtoSg==", + "license": "BSD-3-Clause" + }, + "node_modules/adaptive-quadratic-curve": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/adaptive-quadratic-curve/-/adaptive-quadratic-curve-1.0.2.tgz", + "integrity": "sha512-D6NxIKnelgRDqQijGR6FQzdWPgs4bC7H54LyukFKxTkoZW4+zNemU2JevF4BuMraoxYogvAIMRc2LD5oSCL34g==", + "license": "BSD-3-Clause" + }, + "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/anser": { + "version": "1.4.10", + "resolved": "https://registry.npmjs.org/anser/-/anser-1.4.10.tgz", + "integrity": "sha512-hCv9AqTQ8ycjpSd3upOJd7vFwW1JaoYQ7tpham03GJ1ca8/65rqn0RpaWpItOAd6ylW9wAw6luXYPJIyPFVOww==", + "license": "MIT" + }, + "node_modules/ansi-escapes": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-4.3.2.tgz", + "integrity": "sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ==", + "license": "MIT", + "dependencies": { + "type-fest": "^0.21.3" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ansi-escapes/node_modules/type-fest": { + "version": "0.21.3", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.21.3.tgz", + "integrity": "sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w==", + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ansi-regex": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-4.1.1.tgz", + "integrity": "sha512-ILlv4k/3f6vfQ4OoP2AGvirOktlQ98ZEL1k9FaQjxa3L1abBgbuTDAdPOpvbGncC0BTVQrl+OM8xZGK6tWXt7g==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "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/any-promise": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz", + "integrity": "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==", + "license": "MIT" + }, + "node_modules/anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "license": "ISC", + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/anymatch/node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/arg": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz", + "integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==", + "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/asap": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/asap/-/asap-2.0.6.tgz", + "integrity": "sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==", + "license": "MIT" + }, + "node_modules/async-limiter": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/async-limiter/-/async-limiter-1.0.1.tgz", + "integrity": "sha512-csOlWGAcRFJaI6m+F2WKdnMKr4HhdhFVBk0H/QbJFMCr+uO2kwohwXQPxw/9OCxp05r5ghVBFSyioixx3gfkNQ==", + "license": "MIT" + }, + "node_modules/babel-jest": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/babel-jest/-/babel-jest-29.7.0.tgz", + "integrity": "sha512-BrvGY3xZSwEcCzKvKsCi2GgHqDqsYkOP4/by5xCgIwGXQxIEh+8ew3gmrE1y7XRR6LHZIj6yLYnUi/mm2KXKBg==", + "license": "MIT", + "dependencies": { + "@jest/transform": "^29.7.0", + "@types/babel__core": "^7.1.14", + "babel-plugin-istanbul": "^6.1.1", + "babel-preset-jest": "^29.6.3", + "chalk": "^4.0.0", + "graceful-fs": "^4.2.9", + "slash": "^3.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "peerDependencies": { + "@babel/core": "^7.8.0" + } + }, + "node_modules/babel-plugin-istanbul": { + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/babel-plugin-istanbul/-/babel-plugin-istanbul-6.1.1.tgz", + "integrity": "sha512-Y1IQok9821cC9onCx5otgFfRm7Lm+I+wwxOx738M/WLPZ9Q42m4IG5W0FNX8WLL2gYMZo3JkuXIH2DOpWM+qwA==", + "license": "BSD-3-Clause", + "dependencies": { + "@babel/helper-plugin-utils": "^7.0.0", + "@istanbuljs/load-nyc-config": "^1.0.0", + "@istanbuljs/schema": "^0.1.2", + "istanbul-lib-instrument": "^5.0.4", + "test-exclude": "^6.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/babel-plugin-jest-hoist": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/babel-plugin-jest-hoist/-/babel-plugin-jest-hoist-29.6.3.tgz", + "integrity": "sha512-ESAc/RJvGTFEzRwOTT4+lNDk/GNHMkKbNzsvT0qKRfDyyYTskxB5rnU2njIDYVxXCBHHEI1c0YwHob3WaYujOg==", + "license": "MIT", + "dependencies": { + "@babel/template": "^7.3.3", + "@babel/types": "^7.3.3", + "@types/babel__core": "^7.1.14", + "@types/babel__traverse": "^7.0.6" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/babel-plugin-polyfill-corejs2": { + "version": "0.4.17", + "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-corejs2/-/babel-plugin-polyfill-corejs2-0.4.17.tgz", + "integrity": "sha512-aTyf30K/rqAsNwN76zYrdtx8obu0E4KoUME29B1xj+B3WxgvWkp943vYQ+z8Mv3lw9xHXMHpvSPOBxzAkIa94w==", + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.28.6", + "@babel/helper-define-polyfill-provider": "^0.6.8", + "semver": "^6.3.1" + }, + "peerDependencies": { + "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" + } + }, + "node_modules/babel-plugin-polyfill-corejs3": { + "version": "0.13.0", + "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-corejs3/-/babel-plugin-polyfill-corejs3-0.13.0.tgz", + "integrity": "sha512-U+GNwMdSFgzVmfhNm8GJUX88AadB3uo9KpJqS3FaqNIPKgySuvMb+bHPsOmmuWyIcuqZj/pzt1RUIUZns4y2+A==", + "license": "MIT", + "dependencies": { + "@babel/helper-define-polyfill-provider": "^0.6.5", + "core-js-compat": "^3.43.0" + }, + "peerDependencies": { + "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" + } + }, + "node_modules/babel-plugin-polyfill-regenerator": { + "version": "0.6.8", + "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-regenerator/-/babel-plugin-polyfill-regenerator-0.6.8.tgz", + "integrity": "sha512-M762rNHfSF1EV3SLtnCJXFoQbbIIz0OyRwnCmV0KPC7qosSfCO0QLTSuJX3ayAebubhE6oYBAYPrBA5ljowaZg==", + "license": "MIT", + "dependencies": { + "@babel/helper-define-polyfill-provider": "^0.6.8" + }, + "peerDependencies": { + "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" + } + }, + "node_modules/babel-plugin-react-compiler": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/babel-plugin-react-compiler/-/babel-plugin-react-compiler-1.0.0.tgz", + "integrity": "sha512-Ixm8tFfoKKIPYdCCKYTsqv+Fd4IJ0DQqMyEimo+pxUOMUR9cVPlwTrFt9Avu+3cb6Zp3mAzl+t1MrG2fxxKsxw==", + "license": "MIT", + "dependencies": { + "@babel/types": "^7.26.0" + } + }, + "node_modules/babel-plugin-react-native-web": { + "version": "0.21.2", + "resolved": "https://registry.npmjs.org/babel-plugin-react-native-web/-/babel-plugin-react-native-web-0.21.2.tgz", + "integrity": "sha512-SPD0J6qjJn8231i0HZhlAGH6NORe+QvRSQM2mwQEzJ2Fb3E4ruWTiiicPlHjmeWShDXLcvoorOCXjeR7k/lyWA==", + "license": "MIT" + }, + "node_modules/babel-plugin-syntax-hermes-parser": { + "version": "0.29.1", + "resolved": "https://registry.npmjs.org/babel-plugin-syntax-hermes-parser/-/babel-plugin-syntax-hermes-parser-0.29.1.tgz", + "integrity": "sha512-2WFYnoWGdmih1I1J5eIqxATOeycOqRwYxAQBu3cUu/rhwInwHUg7k60AFNbuGjSDL8tje5GDrAnxzRLcu2pYcA==", + "license": "MIT", + "dependencies": { + "hermes-parser": "0.29.1" + } + }, + "node_modules/babel-plugin-transform-flow-enums": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/babel-plugin-transform-flow-enums/-/babel-plugin-transform-flow-enums-0.0.2.tgz", + "integrity": "sha512-g4aaCrDDOsWjbm0PUUeVnkcVd6AKJsVc/MbnPhEotEpkeJQP6b8nzewohQi7+QS8UyPehOhGWn0nOwjvWpmMvQ==", + "license": "MIT", + "dependencies": { + "@babel/plugin-syntax-flow": "^7.12.1" + } + }, + "node_modules/babel-preset-current-node-syntax": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/babel-preset-current-node-syntax/-/babel-preset-current-node-syntax-1.2.0.tgz", + "integrity": "sha512-E/VlAEzRrsLEb2+dv8yp3bo4scof3l9nR4lrld+Iy5NyVqgVYUJnDAmunkhPMisRI32Qc4iRiz425d8vM++2fg==", + "license": "MIT", + "dependencies": { + "@babel/plugin-syntax-async-generators": "^7.8.4", + "@babel/plugin-syntax-bigint": "^7.8.3", + "@babel/plugin-syntax-class-properties": "^7.12.13", + "@babel/plugin-syntax-class-static-block": "^7.14.5", + "@babel/plugin-syntax-import-attributes": "^7.24.7", + "@babel/plugin-syntax-import-meta": "^7.10.4", + "@babel/plugin-syntax-json-strings": "^7.8.3", + "@babel/plugin-syntax-logical-assignment-operators": "^7.10.4", + "@babel/plugin-syntax-nullish-coalescing-operator": "^7.8.3", + "@babel/plugin-syntax-numeric-separator": "^7.10.4", + "@babel/plugin-syntax-object-rest-spread": "^7.8.3", + "@babel/plugin-syntax-optional-catch-binding": "^7.8.3", + "@babel/plugin-syntax-optional-chaining": "^7.8.3", + "@babel/plugin-syntax-private-property-in-object": "^7.14.5", + "@babel/plugin-syntax-top-level-await": "^7.14.5" + }, + "peerDependencies": { + "@babel/core": "^7.0.0 || ^8.0.0-0" + } + }, + "node_modules/babel-preset-expo": { + "version": "54.0.11", + "resolved": "https://registry.npmjs.org/babel-preset-expo/-/babel-preset-expo-54.0.11.tgz", + "integrity": "sha512-dEpeFDtYEFzmWtWVwvt7sUCZH0fxXPfbJlgXd7XNZSQDa/Ki/hTOj9exMTzqR2oyPHDNcE9VxYCJ4oS6xw4Pjg==", + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.25.9", + "@babel/plugin-proposal-decorators": "^7.12.9", + "@babel/plugin-proposal-export-default-from": "^7.24.7", + "@babel/plugin-syntax-export-default-from": "^7.24.7", + "@babel/plugin-transform-class-static-block": "^7.27.1", + "@babel/plugin-transform-export-namespace-from": "^7.25.9", + "@babel/plugin-transform-flow-strip-types": "^7.25.2", + "@babel/plugin-transform-modules-commonjs": "^7.24.8", + "@babel/plugin-transform-object-rest-spread": "^7.24.7", + "@babel/plugin-transform-parameters": "^7.24.7", + "@babel/plugin-transform-private-methods": "^7.24.7", + "@babel/plugin-transform-private-property-in-object": "^7.24.7", + "@babel/plugin-transform-runtime": "^7.24.7", + "@babel/preset-react": "^7.22.15", + "@babel/preset-typescript": "^7.23.0", + "@react-native/babel-preset": "0.81.5", + "babel-plugin-react-compiler": "^1.0.0", + "babel-plugin-react-native-web": "~0.21.0", + "babel-plugin-syntax-hermes-parser": "^0.29.1", + "babel-plugin-transform-flow-enums": "^0.0.2", + "debug": "^4.3.4", + "resolve-from": "^5.0.0" + }, + "peerDependencies": { + "@babel/runtime": "^7.20.0", + "expo": "*", + "react-refresh": ">=0.14.0 <1.0.0" + }, + "peerDependenciesMeta": { + "@babel/runtime": { + "optional": true + }, + "expo": { + "optional": true + } + } + }, + "node_modules/babel-preset-jest": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/babel-preset-jest/-/babel-preset-jest-29.6.3.tgz", + "integrity": "sha512-0B3bhxR6snWXJZtR/RliHTDPRgn1sNHOR0yVtq/IiQFyuOVjFS+wuio/R4gSNkyYmKmJB4wGZv2NZanmKmTnNA==", + "license": "MIT", + "dependencies": { + "babel-plugin-jest-hoist": "^29.6.3", + "babel-preset-current-node-syntax": "^1.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "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.37", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.37.tgz", + "integrity": "sha512-girxaJ7WZssDOFhzCGZTDKoTa1gk6A1TbflaYTpykLJ4UU9Fz9kx1aREM8JCuoVHbL8X8T/mJg7w2oYSq72Oig==", + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/better-opn": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/better-opn/-/better-opn-3.0.2.tgz", + "integrity": "sha512-aVNobHnJqLiUelTaHat9DZ1qM2w0C0Eym4LPI/3JxOnSokGVdsl1T1kN7TFvsEAD8G47A6VKQ0TVHqbBnYMJlQ==", + "license": "MIT", + "dependencies": { + "open": "^8.0.4" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/better-opn/node_modules/open": { + "version": "8.4.2", + "resolved": "https://registry.npmjs.org/open/-/open-8.4.2.tgz", + "integrity": "sha512-7x81NCL719oNbsq/3mh+hVrAWmFuEYUqrq/Iw3kUzH8ReypT9QQ0BLoJS7/G9k6N81XjW4qHWtjWwe/9eLy1EQ==", + "license": "MIT", + "dependencies": { + "define-lazy-prop": "^2.0.0", + "is-docker": "^2.1.1", + "is-wsl": "^2.2.0" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/big-integer": { + "version": "1.6.52", + "resolved": "https://registry.npmjs.org/big-integer/-/big-integer-1.6.52.tgz", + "integrity": "sha512-QxD8cf2eVqJOOz63z6JIN9BzvVs/dlySa5HGSBH5xtR8dPteIRQnBxxKqkNTiT6jbDTF6jAfrd4oMcND9RGbQg==", + "license": "Unlicense", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/boolbase": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz", + "integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==", + "license": "ISC" + }, + "node_modules/bplist-creator": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/bplist-creator/-/bplist-creator-0.1.0.tgz", + "integrity": "sha512-sXaHZicyEEmY86WyueLTQesbeoH/mquvarJaQNbjuOQO+7gbFcDEWqKmcWA4cOTLzFlfgvkiVxolk1k5bBIpmg==", + "license": "MIT", + "dependencies": { + "stream-buffers": "2.2.x" + } + }, + "node_modules/bplist-parser": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/bplist-parser/-/bplist-parser-0.3.2.tgz", + "integrity": "sha512-apC2+fspHGI3mMKj+dGevkGo/tCqVB8jMb6i+OX+E29p0Iposz07fABkRIfVUPNd5A5VbuOz1bZbnmkKLYF+wQ==", + "license": "MIT", + "dependencies": { + "big-integer": "1.6.x" + }, + "engines": { + "node": ">= 5.10.0" + } + }, + "node_modules/brace-expansion": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.15.tgz", + "integrity": "sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg==", + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/browserslist": { + "version": "4.28.2", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz", + "integrity": "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==", + "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.12", + "caniuse-lite": "^1.0.30001782", + "electron-to-chromium": "^1.5.328", + "node-releases": "^2.0.36", + "update-browserslist-db": "^1.2.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/bser": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/bser/-/bser-2.1.1.tgz", + "integrity": "sha512-gQxTNE/GAfIIrmHLUE3oJyp5FO6HRBfhjnw4/wMmA63ZGDJnWBmgY/lyQBpnDUkGmAhbSe39tx2d/iTOAfglwQ==", + "license": "Apache-2.0", + "dependencies": { + "node-int64": "^0.4.0" + } + }, + "node_modules/buffer": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", + "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", + "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", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.1.13" + } + }, + "node_modules/buffer-from": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", + "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", + "license": "MIT" + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/cacheable-lookup": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/cacheable-lookup/-/cacheable-lookup-5.0.4.tgz", + "integrity": "sha512-2/kNscPhpcxrOigMZzbiWF7dz8ilhb/nIHU3EyZiXWXpeq/au8qJ8VhdftMkty3n7Gj6HIGalQG8oiBNB3AJgA==", + "license": "MIT", + "engines": { + "node": ">=10.6.0" + } + }, + "node_modules/cacheable-request": { + "version": "7.0.4", + "resolved": "https://registry.npmjs.org/cacheable-request/-/cacheable-request-7.0.4.tgz", + "integrity": "sha512-v+p6ongsrp0yTGbJXjgxPow2+DL93DASP4kXCDKb8/bwRtt9OEF3whggkkDkGNzgcWy2XaF4a8nZglC7uElscg==", + "license": "MIT", + "dependencies": { + "clone-response": "^1.0.2", + "get-stream": "^5.1.0", + "http-cache-semantics": "^4.0.0", + "keyv": "^4.0.0", + "lowercase-keys": "^2.0.0", + "normalize-url": "^6.0.1", + "responselike": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/camelcase": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-6.3.0.tgz", + "integrity": "sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001799", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001799.tgz", + "integrity": "sha512-hG1bReV+OUU+MOqK4t/ZWI0tZOyz3rqS9XuhOUz1cIcbwBKjOyJEJuw9ER5JuNyqxNk8u/JUVbGibBOL1yrjFw==", + "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/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/chownr": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-3.0.0.tgz", + "integrity": "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==", + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/chrome-launcher": { + "version": "0.15.2", + "resolved": "https://registry.npmjs.org/chrome-launcher/-/chrome-launcher-0.15.2.tgz", + "integrity": "sha512-zdLEwNo3aUVzIhKhTtXfxhdvZhUghrnmkvcAq2NoDd+LeOHKf03H5jwZ8T/STsAlzyALkBVK552iaG1fGf1xVQ==", + "license": "Apache-2.0", + "dependencies": { + "@types/node": "*", + "escape-string-regexp": "^4.0.0", + "is-wsl": "^2.2.0", + "lighthouse-logger": "^1.0.0" + }, + "bin": { + "print-chrome-path": "bin/print-chrome-path.js" + }, + "engines": { + "node": ">=12.13.0" + } + }, + "node_modules/chromium-edge-launcher": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/chromium-edge-launcher/-/chromium-edge-launcher-0.2.0.tgz", + "integrity": "sha512-JfJjUnq25y9yg4FABRRVPmBGWPZZi+AQXT4mxupb67766/0UlhG8PAZCz6xzEMXTbW3CsSoE8PcCWA49n35mKg==", + "license": "Apache-2.0", + "dependencies": { + "@types/node": "*", + "escape-string-regexp": "^4.0.0", + "is-wsl": "^2.2.0", + "lighthouse-logger": "^1.0.0", + "mkdirp": "^1.0.4", + "rimraf": "^3.0.2" + } + }, + "node_modules/chromium-edge-launcher/node_modules/mkdirp": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz", + "integrity": "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==", + "license": "MIT", + "bin": { + "mkdirp": "bin/cmd.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/ci-info": { + "version": "3.9.0", + "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-3.9.0.tgz", + "integrity": "sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/sibiraj-s" + } + ], + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/cli-cursor": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-2.1.0.tgz", + "integrity": "sha512-8lgKz8LmCRYZZQDpRyT2m5rKJ08TnU4tR9FFFW2rxpxR1FzWi4PQ/NfyODchAatHaUgnSPVcx/R5w6NuTBzFiw==", + "license": "MIT", + "dependencies": { + "restore-cursor": "^2.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/cli-spinners": { + "version": "2.9.2", + "resolved": "https://registry.npmjs.org/cli-spinners/-/cli-spinners-2.9.2.tgz", + "integrity": "sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg==", + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/cliui": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", + "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/cliui/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/cliui/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/clone-response": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/clone-response/-/clone-response-1.0.3.tgz", + "integrity": "sha512-ROoL94jJH2dUVML2Y/5PEDNaSHgeOdSDicUyS7izcF63G6sTc/FTjLub4b8Il9S8S0beOfYt0TaA5qvFK+w0wA==", + "license": "MIT", + "dependencies": { + "mimic-response": "^1.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/color": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/color/-/color-4.2.3.tgz", + "integrity": "sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A==", + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1", + "color-string": "^1.9.0" + }, + "engines": { + "node": ">=12.5.0" + } + }, + "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/color-string": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/color-string/-/color-string-1.9.1.tgz", + "integrity": "sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg==", + "license": "MIT", + "dependencies": { + "color-name": "^1.0.0", + "simple-swizzle": "^0.2.2" + } + }, + "node_modules/commander": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-7.2.0.tgz", + "integrity": "sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==", + "license": "MIT", + "engines": { + "node": ">= 10" + } + }, + "node_modules/compressible": { + "version": "2.0.18", + "resolved": "https://registry.npmjs.org/compressible/-/compressible-2.0.18.tgz", + "integrity": "sha512-AF3r7P5dWxL8MxyITRMlORQNaOA2IkAFaTr4k7BUumjPtRpGDTZpl0Pb1XCO6JeDCBdp126Cgs9sMxqSjgYyRg==", + "license": "MIT", + "dependencies": { + "mime-db": ">= 1.43.0 < 2" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/compression": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/compression/-/compression-1.8.1.tgz", + "integrity": "sha512-9mAqGPHLakhCLeNyxPkK4xVo746zQ/czLH1Ky+vkitMnWfWZps8r0qXuwhwizagCRttsL4lfG4pIOvaWLpAP0w==", + "license": "MIT", + "dependencies": { + "bytes": "3.1.2", + "compressible": "~2.0.18", + "debug": "2.6.9", + "negotiator": "~0.6.4", + "on-headers": "~1.1.0", + "safe-buffer": "5.2.1", + "vary": "~1.1.2" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/compression/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/compression/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/compression/node_modules/negotiator": { + "version": "0.6.4", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.4.tgz", + "integrity": "sha512-myRT3DiWPHqho5PrJaIRyaMv2kgYf0mUVgBNOYMuCH5Ki1yEiQaf/ZJuQ62nvpc44wL5WDbTX7yGJi1Neevw8w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "license": "MIT" + }, + "node_modules/connect": { + "version": "3.7.0", + "resolved": "https://registry.npmjs.org/connect/-/connect-3.7.0.tgz", + "integrity": "sha512-ZqRXc+tZukToSNmh5C2iWMSoV3X1YUcPbqEM4DkEG5tNQXrQUZCNVGGv3IuicnkMtPfGf3Xtp8WCXs295iQ1pQ==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "finalhandler": "1.1.2", + "parseurl": "~1.3.3", + "utils-merge": "1.0.1" + }, + "engines": { + "node": ">= 0.10.0" + } + }, + "node_modules/connect/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/connect/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "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==", + "license": "MIT" + }, + "node_modules/core-js": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/core-js/-/core-js-1.2.7.tgz", + "integrity": "sha512-ZiPp9pZlgxpWRu0M+YWbm6+aQ84XEfH1JRXvfOc/fILWI0VKhLC2LX13X1NYq4fULzLMq7Hfh43CSo2/aIaUPA==", + "deprecated": "core-js@<3.23.3 is no longer maintained and not recommended for usage due to the number of issues. Because of the V8 engine whims, feature detection in old core-js versions could cause a slowdown up to 100x even if nothing is polyfilled. Some versions have web compatibility issues. Please, upgrade your dependencies to the actual version of core-js.", + "license": "MIT" + }, + "node_modules/core-js-compat": { + "version": "3.49.0", + "resolved": "https://registry.npmjs.org/core-js-compat/-/core-js-compat-3.49.0.tgz", + "integrity": "sha512-VQXt1jr9cBz03b331DFDCCP90b3fanciLkgiOoy8SBHy06gNf+vQ1A3WFLqG7I8TipYIKeYK9wxd0tUrvHcOZA==", + "license": "MIT", + "dependencies": { + "browserslist": "^4.28.1" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/core-js" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/css-font-parser": { + "version": "0.2.3", + "resolved": "https://registry.npmjs.org/css-font-parser/-/css-font-parser-0.2.3.tgz", + "integrity": "sha512-QhdfNV+RjQLVg24d/ls4I8UrA/5WCeEfUmi1je8ISPr7RGZJXwWU2AwZpCiUmWuLZW7dKaB3ER0Bq1W9UhEUag==", + "license": "BSD" + }, + "node_modules/css-select": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/css-select/-/css-select-5.2.2.tgz", + "integrity": "sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw==", + "license": "BSD-2-Clause", + "dependencies": { + "boolbase": "^1.0.0", + "css-what": "^6.1.0", + "domhandler": "^5.0.2", + "domutils": "^3.0.1", + "nth-check": "^2.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, + "node_modules/css-tree": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-1.1.3.tgz", + "integrity": "sha512-tRpdppF7TRazZrjJ6v3stzv93qxRcSsFmW6cX0Zm2NVKpxE1WV1HblnghVv9TreireHkqI/VDEsfolRF1p6y7Q==", + "license": "MIT", + "dependencies": { + "mdn-data": "2.0.14", + "source-map": "^0.6.1" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/css-tree/node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/css-what": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/css-what/-/css-what-6.2.2.tgz", + "integrity": "sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA==", + "license": "BSD-2-Clause", + "engines": { + "node": ">= 6" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, + "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==", + "dev": true, + "license": "MIT" + }, + "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/decode-uri-component": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/decode-uri-component/-/decode-uri-component-0.2.2.tgz", + "integrity": "sha512-FqUYQ+8o158GyGTrMFJms9qh3CqTKvAqgqsTnkLI8sKu0028orqBhxNMFkFen0zGyg6epACD32pjVk58ngIErQ==", + "license": "MIT", + "engines": { + "node": ">=0.10" + } + }, + "node_modules/decompress-response": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", + "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==", + "license": "MIT", + "dependencies": { + "mimic-response": "^3.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/decompress-response/node_modules/mimic-response": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz", + "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/deep-extend": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz", + "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==", + "license": "MIT", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/deepmerge": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", + "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/defaults": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/defaults/-/defaults-1.0.4.tgz", + "integrity": "sha512-eFuaLoy/Rxalv2kr+lqMlUnrDWV+3j4pljOIJgLIhI058IQfWJ7vXhyEIHu+HtC738klGALYxOKDO0bQP3tg8A==", + "license": "MIT", + "dependencies": { + "clone": "^1.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/defaults/node_modules/clone": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/clone/-/clone-1.0.4.tgz", + "integrity": "sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg==", + "license": "MIT", + "engines": { + "node": ">=0.8" + } + }, + "node_modules/defer-to-connect": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/defer-to-connect/-/defer-to-connect-2.0.1.tgz", + "integrity": "sha512-4tvttepXG1VaYGrRibk5EwJd1t4udunSOVMdLSAL6mId1ix438oPwPZMALY41FCijukO1L0twNcGsdzS7dHgDg==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/define-lazy-prop": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/define-lazy-prop/-/define-lazy-prop-2.0.0.tgz", + "integrity": "sha512-Ds09qNh8yw3khSjiJjiUInaGX9xlqZDY7JVryGxdxV7NPeuqQfplOpQ66yJFZut3jLa5zOwkXw1g9EI2uKh4Og==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/destroy": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", + "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==", + "license": "MIT", + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/dom-serializer": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz", + "integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==", + "license": "MIT", + "dependencies": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.2", + "entities": "^4.2.0" + }, + "funding": { + "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1" + } + }, + "node_modules/domelementtype": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz", + "integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "license": "BSD-2-Clause" + }, + "node_modules/domexception": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/domexception/-/domexception-1.0.1.tgz", + "integrity": "sha512-raigMkn7CJNNo6Ihro1fzG7wr3fHuYVytzquZKX5n0yizGsTcYgzdIUwj1X9pK0VvjeihV+XiclP+DjwbsSKug==", + "deprecated": "Use your platform's native DOMException instead", + "license": "MIT", + "dependencies": { + "webidl-conversions": "^4.0.2" + } + }, + "node_modules/domexception/node_modules/webidl-conversions": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-4.0.2.tgz", + "integrity": "sha512-YQ+BmxuTgd6UXZW3+ICGfyqRyHXVlD5GtQr5+qjiNW7bF0cqrzX500HVXPBOvgXb5YnzDd+h0zqyv61KUD7+Sg==", + "license": "BSD-2-Clause" + }, + "node_modules/domhandler": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz", + "integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==", + "license": "BSD-2-Clause", + "dependencies": { + "domelementtype": "^2.3.0" + }, + "engines": { + "node": ">= 4" + }, + "funding": { + "url": "https://github.com/fb55/domhandler?sponsor=1" + } + }, + "node_modules/domutils": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz", + "integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==", + "license": "BSD-2-Clause", + "dependencies": { + "dom-serializer": "^2.0.0", + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3" + }, + "funding": { + "url": "https://github.com/fb55/domutils?sponsor=1" + } + }, + "node_modules/dotenv": { + "version": "16.4.7", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.4.7.tgz", + "integrity": "sha512-47qPchRCykZC03FhkYAhrvwU4xDBFIj1QPqaarj6mdM/hgUzfPHcpkHJOn3mJAufFeeAxAzeGsr5X0M4k6fLZQ==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/dotenv-expand": { + "version": "11.0.7", + "resolved": "https://registry.npmjs.org/dotenv-expand/-/dotenv-expand-11.0.7.tgz", + "integrity": "sha512-zIHwmZPRshsCdpMDyVsqGmgyP0yT8GAgXUnkdAoJisxvf33k7yO6OuoKmcTGuXPWSsm8Oh88nZicRLA9Y0rUeA==", + "license": "BSD-2-Clause", + "dependencies": { + "dotenv": "^16.4.5" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/earcut": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/earcut/-/earcut-2.1.1.tgz", + "integrity": "sha512-+BGT9Mffq3Oxf2nDOtzaJDd9P8g/z3WNYGO84Q23hDBot5VU8DNGY73Vc94eVDvHjMyhonnuEg+DNwEQ6iZn3w==", + "license": "ISC" + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, + "node_modules/electron-to-chromium": { + "version": "1.5.372", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.372.tgz", + "integrity": "sha512-M3yhbAlilnwqC8D21t28UCDGHyitShTmmLRU/H+b74P6Ski16Nb9HONYEaVpMj/pwC7BEo5B95FpjODLCWbtfA==", + "license": "ISC" + }, + "node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "license": "MIT" + }, + "node_modules/encodeurl": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-1.0.2.tgz", + "integrity": "sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/encoding": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/encoding/-/encoding-0.1.13.tgz", + "integrity": "sha512-ETBauow1T35Y/WZMkio9jiM0Z5xjHHmJ4XmjZOq1l/dXz3lr2sRn87nJy20RupqSh1F2m3HHPSp8ShIPQJrJ3A==", + "license": "MIT", + "dependencies": { + "iconv-lite": "^0.6.2" + } + }, + "node_modules/end-of-stream": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", + "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", + "license": "MIT", + "dependencies": { + "once": "^1.4.0" + } + }, + "node_modules/entities": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", + "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/env-editor": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/env-editor/-/env-editor-0.4.2.tgz", + "integrity": "sha512-ObFo8v4rQJAE59M69QzwloxPZtd33TpYEIjtKD1rrFDcM1Gd7IkDxEBU+HriziN6HSHQnBJi8Dmy+JWkav5HKA==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/error-stack-parser": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/error-stack-parser/-/error-stack-parser-2.1.4.tgz", + "integrity": "sha512-Sk5V6wVazPhq5MhpO+AUxJn5x7XSXGl1R93Vn7i+zS15KDVxQijejNCrz8340/2bgLBjR9GtEG8ZVKONDjcqGQ==", + "license": "MIT", + "dependencies": { + "stackframe": "^1.3.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==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/esprima": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", + "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", + "license": "BSD-2-Clause", + "bin": { + "esparse": "bin/esparse.js", + "esvalidate": "bin/esvalidate.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/event-target-shim": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz", + "integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/expo": { + "version": "54.0.35", + "resolved": "https://registry.npmjs.org/expo/-/expo-54.0.35.tgz", + "integrity": "sha512-E+tXpQwjGm5fK/uwa55p0Xx/kuo5dXDKfVJ95IargTNa5KiFt26lSTXXa9KnHbI4EDLwFD38/xTKZvzPTlGTdg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.20.0", + "@expo/cli": "54.0.25", + "@expo/config": "~12.0.13", + "@expo/config-plugins": "~54.0.4", + "@expo/devtools": "0.1.8", + "@expo/fingerprint": "0.15.5", + "@expo/metro": "~54.2.0", + "@expo/metro-config": "54.0.16", + "@expo/vector-icons": "^15.0.3", + "@ungap/structured-clone": "^1.3.0", + "babel-preset-expo": "~54.0.11", + "expo-asset": "~12.0.13", + "expo-constants": "~18.0.13", + "expo-file-system": "~19.0.23", + "expo-font": "~14.0.12", + "expo-keep-awake": "~15.0.8", + "expo-modules-autolinking": "3.0.26", + "expo-modules-core": "3.0.30", + "pretty-format": "^29.7.0", + "react-refresh": "^0.14.2", + "whatwg-url-without-unicode": "8.0.0-3" + }, + "bin": { + "expo": "bin/cli", + "expo-modules-autolinking": "bin/autolinking", + "fingerprint": "bin/fingerprint" + }, + "peerDependencies": { + "@expo/dom-webview": "*", + "@expo/metro-runtime": "*", + "react": "*", + "react-native": "*", + "react-native-webview": "*" + }, + "peerDependenciesMeta": { + "@expo/dom-webview": { + "optional": true + }, + "@expo/metro-runtime": { + "optional": true + }, + "react-native-webview": { + "optional": true + } + } + }, + "node_modules/expo-2d-context": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/expo-2d-context/-/expo-2d-context-0.0.3.tgz", + "integrity": "sha512-loO/cIj5owNXv0GJxEQ7Sm1vlGOYoJwHCaf2uiPKG3BZm+gQw6GnDgxN+2WDisNZKLTjnMLUGP1HhBsNnElt6g==", + "dependencies": { + "adaptive-bezier-curve": "1.0.3", + "adaptive-quadratic-curve": "1.0.2", + "color-convert": "^1.9.3", + "css-font-parser": "0.2.3", + "domexception": "1.0.1", + "earcut": "2.1.1", + "gl-matrix": "^2.4.0", + "parse-bmfont-ascii": "1.0.6", + "string-format": "0.5.0", + "tess2": "^1.0.0" + }, + "peerDependencies": { + "expo-asset": "*" + } + }, + "node_modules/expo-2d-context/node_modules/color-convert": { + "version": "1.9.3", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", + "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==", + "license": "MIT", + "dependencies": { + "color-name": "1.1.3" + } + }, + "node_modules/expo-2d-context/node_modules/color-name": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz", + "integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==", + "license": "MIT" + }, + "node_modules/expo-asset": { + "version": "12.0.13", + "resolved": "https://registry.npmjs.org/expo-asset/-/expo-asset-12.0.13.tgz", + "integrity": "sha512-x/p7WvQUnkn6K43b9eL6SPeq5Vnf1E8BDe9bDrWrvMqzyUvJnUFvl+ctg3034s/+UHe7Ne2pAmc0+yzbl8CrDQ==", + "license": "MIT", + "dependencies": { + "@expo/image-utils": "^0.8.8", + "expo-constants": "~18.0.13" + }, + "peerDependencies": { + "expo": "*", + "react": "*", + "react-native": "*" + } + }, + "node_modules/expo-av": { + "version": "16.0.8", + "resolved": "https://registry.npmjs.org/expo-av/-/expo-av-16.0.8.tgz", + "integrity": "sha512-cmVPftGR/ca7XBgs7R6ky36lF3OC0/MM/lpgX/yXqfv0jASTsh7AYX9JxHCwFmF+Z6JEB1vne9FDx4GiLcGreQ==", + "license": "MIT", + "peerDependencies": { + "expo": "*", + "react": "*", + "react-native": "*", + "react-native-web": "*" + }, + "peerDependenciesMeta": { + "react-native-web": { + "optional": true + } + } + }, + "node_modules/expo-blur": { + "version": "15.0.8", + "resolved": "https://registry.npmjs.org/expo-blur/-/expo-blur-15.0.8.tgz", + "integrity": "sha512-rWyE1NBRZEu9WD+X+5l7gyPRszw7n12cW3IRNAb5i6KFzaBp8cxqT5oeaphJapqURvcqhkOZn2k5EtBSbsuU7w==", + "license": "MIT", + "peerDependencies": { + "expo": "*", + "react": "*", + "react-native": "*" + } + }, + "node_modules/expo-camera": { + "version": "17.0.10", + "resolved": "https://registry.npmjs.org/expo-camera/-/expo-camera-17.0.10.tgz", + "integrity": "sha512-w1RBw83mAGVk4BPPwNrCZyFop0VLiVSRE3c2V9onWbdFwonpRhzmB4drygG8YOUTl1H3wQvALJHyMPTbgsK1Jg==", + "license": "MIT", + "dependencies": { + "invariant": "^2.2.4" + }, + "peerDependencies": { + "expo": "*", + "react": "*", + "react-native": "*", + "react-native-web": "*" + }, + "peerDependenciesMeta": { + "react-native-web": { + "optional": true + } + } + }, + "node_modules/expo-constants": { + "version": "18.0.13", + "resolved": "https://registry.npmjs.org/expo-constants/-/expo-constants-18.0.13.tgz", + "integrity": "sha512-FnZn12E1dRYKDHlAdIyNFhBurKTS3F9CrfrBDJI5m3D7U17KBHMQ6JEfYlSj7LG7t+Ulr+IKaj58L1k5gBwTcQ==", + "license": "MIT", + "dependencies": { + "@expo/config": "~12.0.13", + "@expo/env": "~2.0.8" + }, + "peerDependencies": { + "expo": "*", + "react-native": "*" + } + }, + "node_modules/expo-file-system": { + "version": "19.0.23", + "resolved": "https://registry.npmjs.org/expo-file-system/-/expo-file-system-19.0.23.tgz", + "integrity": "sha512-MeGkid9OeNILfT/qonaXHp4f2c15xaB28U/bcN7pqZej0Kx0+6+V7e9ZIXpPHm07zVatxA+QkMTPQEGfmvVOxA==", + "license": "MIT", + "peerDependencies": { + "expo": "*", + "react-native": "*" + } + }, + "node_modules/expo-gl": { + "version": "16.0.10", + "resolved": "https://registry.npmjs.org/expo-gl/-/expo-gl-16.0.10.tgz", + "integrity": "sha512-/pPlSJvfmrGuW+UXBRVADr52nhiHFwRGXB8shhQb+b6KKreCuTmQZUASznAXS6YaHNjkOghmkaUW0hRnyiAwBQ==", + "license": "MIT", + "dependencies": { + "invariant": "^2.2.4" + }, + "peerDependencies": { + "expo": "*", + "react": "*", + "react-dom": "*", + "react-native": "*", + "react-native-reanimated": "*", + "react-native-web": "*" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + }, + "react-native-reanimated": { + "optional": true + }, + "react-native-web": { + "optional": true + } + } + }, + "node_modules/expo-haptics": { + "version": "15.0.8", + "resolved": "https://registry.npmjs.org/expo-haptics/-/expo-haptics-15.0.8.tgz", + "integrity": "sha512-lftutojy8Qs8zaDzzjwM3gKHFZ8bOOEZDCkmh2Ddpe95Ra6kt2izeOfOfKuP/QEh0MZ1j9TfqippyHdRd1ZM9g==", + "license": "MIT", + "peerDependencies": { + "expo": "*" + } + }, + "node_modules/expo-linear-gradient": { + "version": "15.0.8", + "resolved": "https://registry.npmjs.org/expo-linear-gradient/-/expo-linear-gradient-15.0.8.tgz", + "integrity": "sha512-V2d8Wjn0VzhPHO+rrSBtcl+Fo+jUUccdlmQ6OoL9/XQB7Qk3d9lYrqKDJyccwDxmQT10JdST3Tmf2K52NLc3kw==", + "license": "MIT", + "peerDependencies": { + "expo": "*", + "react": "*", + "react-native": "*" + } + }, + "node_modules/expo-modules-autolinking": { + "version": "3.0.26", + "resolved": "https://registry.npmjs.org/expo-modules-autolinking/-/expo-modules-autolinking-3.0.26.tgz", + "integrity": "sha512-WOaud6UKg16ciCOj8raKcMOoKFMHLXKI29U29yhgu1lf+Y7VxJyCktUcYo6AM+ccZ7zLD1uWZdMtgnpf+95OXA==", + "license": "MIT", + "dependencies": { + "@expo/spawn-async": "^1.7.2", + "chalk": "^4.1.0", + "commander": "^7.2.0", + "require-from-string": "^2.0.2", + "resolve-from": "^5.0.0" + }, + "bin": { + "expo-modules-autolinking": "bin/expo-modules-autolinking.js" + } + }, + "node_modules/expo-modules-core": { + "version": "3.0.30", + "resolved": "https://registry.npmjs.org/expo-modules-core/-/expo-modules-core-3.0.30.tgz", + "integrity": "sha512-a6IrpAn/Jbmwxi9L+hMmXKpNqnkUpoF7WHOpn02rVLyax2J0gB1vvCVE5rNydplEnt41Q6WxQwvcOjZaIkcSUg==", + "license": "MIT", + "dependencies": { + "invariant": "^2.2.4" + }, + "peerDependencies": { + "react": "*", + "react-native": "*" + } + }, + "node_modules/expo-server": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/expo-server/-/expo-server-1.0.7.tgz", + "integrity": "sha512-mcmyML3oXcqFUXUxtdtCL1O00ztNI2v76d+MdniXRUgHNxIcHZ05zo+DqBaOOT6LQnPk4vA4YHqQl7iGUfRb3g==", + "license": "MIT", + "engines": { + "node": ">=20.16.0" + } + }, + "node_modules/expo-status-bar": { + "version": "3.0.9", + "resolved": "https://registry.npmjs.org/expo-status-bar/-/expo-status-bar-3.0.9.tgz", + "integrity": "sha512-xyYyVg6V1/SSOZWh4Ni3U129XHCnFHBTcUo0dhWtFDrZbNp/duw5AGsQfb2sVeU0gxWHXSY1+5F0jnKYC7WuOw==", + "license": "MIT", + "dependencies": { + "react-native-is-edge-to-edge": "^1.2.1" + }, + "peerDependencies": { + "react": "*", + "react-native": "*" + } + }, + "node_modules/expo-three": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/expo-three/-/expo-three-8.0.0.tgz", + "integrity": "sha512-aGSxS7UEA0DtkdY9UdAHWW4JsJh4Q2vtOQNb3oocIP5bBazb6qTm0PtsXUF0YYo5s9TCJfm4t2ED+0LUz7QwaQ==", + "license": "MIT", + "dependencies": { + "@expo/browser-polyfill": "^1.0.1", + "expo-asset-utils": "~3.0.0" + }, + "peerDependencies": { + "expo": "*", + "expo-asset": "*", + "expo-file-system": "*", + "expo-gl": "*", + "react-native": "*", + "three": "^0.166.0" + } + }, + "node_modules/expo-three/node_modules/@expo/browser-polyfill": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@expo/browser-polyfill/-/browser-polyfill-1.0.1.tgz", + "integrity": "sha512-0K6USycVKDj/k9Gr2UYa+7MiCjb0f2NznsE4LX11H85vQMJma5JS3a2oe6lxCfleY9hDcQ/9zKCYS8O6crsv3A==", + "license": "MIT", + "dependencies": { + "expo-2d-context": "^0.0.3", + "fbemitter": "^2.1.1", + "text-encoding": "^0.7.0", + "uuid": "^8.3.2", + "xmldom-qsa": "^1.0.3" + }, + "peerDependencies": { + "expo-file-system": "^13.2.0", + "react": "^17.0.1", + "react-native": "^0.64.3" + } + }, + "node_modules/expo-three/node_modules/expo-asset-utils": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/expo-asset-utils/-/expo-asset-utils-3.0.0.tgz", + "integrity": "sha512-CgIbNvTqKqQi1lrlptmwoaCMu4ZVOZf8tghmytlor23CjIOuorw6cfuOqiqWkJLz23arTt91maYEU9XLMPB23A==", + "license": "MIT", + "peerDependencies": { + "expo-asset": "*", + "expo-file-system": "*", + "expo-font": "*", + "expo-modules-core": "*", + "react-native": "*" + } + }, + "node_modules/expo-three/node_modules/fbemitter": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/fbemitter/-/fbemitter-2.1.1.tgz", + "integrity": "sha512-hd8PgD+Q6RQtlcGrkM9oY3MFIjq6CA6wurCK1TKn2eaA76Ww4VAOihmq98NyjRhjJi/axgznZnh9lF8+TcTsNQ==", + "license": "BSD-3-Clause", + "dependencies": { + "fbjs": "^0.8.4" + } + }, + "node_modules/expo-three/node_modules/fbjs": { + "version": "0.8.18", + "resolved": "https://registry.npmjs.org/fbjs/-/fbjs-0.8.18.tgz", + "integrity": "sha512-EQaWFK+fEPSoibjNy8IxUtaFOMXcWsY0JaVrQoZR9zC8N2Ygf9iDITPWjUTVIax95b6I742JFLqASHfsag/vKA==", + "license": "MIT", + "dependencies": { + "core-js": "^1.0.0", + "isomorphic-fetch": "^2.1.1", + "loose-envify": "^1.0.0", + "object-assign": "^4.1.0", + "promise": "^7.1.1", + "setimmediate": "^1.0.5", + "ua-parser-js": "^0.7.30" + } + }, + "node_modules/expo-three/node_modules/ua-parser-js": { + "version": "0.7.41", + "resolved": "https://registry.npmjs.org/ua-parser-js/-/ua-parser-js-0.7.41.tgz", + "integrity": "sha512-O3oYyCMPYgNNHuO7Jjk3uacJWZF8loBgwrfd/5LE/HyZ3lUIOdniQ7DNXJcIgZbwioZxk0fLfI4EVnetdiX5jg==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/ua-parser-js" + }, + { + "type": "paypal", + "url": "https://paypal.me/faisalman" + }, + { + "type": "github", + "url": "https://github.com/sponsors/faisalman" + } + ], + "license": "MIT", + "bin": { + "ua-parser-js": "script/cli.js" + }, + "engines": { + "node": "*" + } + }, + "node_modules/expo/node_modules/@expo/cli": { + "version": "54.0.25", + "resolved": "https://registry.npmjs.org/@expo/cli/-/cli-54.0.25.tgz", + "integrity": "sha512-WnUqIb8oMBhtwSfIqdCHCzcaDIpLNXItRVd5miuvWi4GO0SGo89PSsAkbVJ+LJgcaY+v5rbgMELJS9I/CqOulA==", + "license": "MIT", + "dependencies": { + "@0no-co/graphql.web": "^1.0.8", + "@expo/code-signing-certificates": "^0.0.6", + "@expo/config": "~12.0.13", + "@expo/config-plugins": "~54.0.4", + "@expo/devcert": "^1.2.1", + "@expo/env": "~2.0.8", + "@expo/image-utils": "^0.8.8", + "@expo/json-file": "^10.0.16", + "@expo/metro": "~54.2.0", + "@expo/metro-config": "~54.0.16", + "@expo/osascript": "^2.3.8", + "@expo/package-manager": "^1.9.10", + "@expo/plist": "^0.4.9", + "@expo/prebuild-config": "^54.0.8", + "@expo/schema-utils": "^0.1.8", + "@expo/spawn-async": "^1.7.2", + "@expo/ws-tunnel": "^1.0.1", + "@expo/xcpretty": "^4.3.0", + "@react-native/dev-middleware": "0.81.5", + "@urql/core": "^5.0.6", + "@urql/exchange-retry": "^1.3.0", + "accepts": "^1.3.8", + "arg": "^5.0.2", + "better-opn": "~3.0.2", + "bplist-creator": "0.1.0", + "bplist-parser": "^0.3.1", + "chalk": "^4.0.0", + "ci-info": "^3.3.0", + "compression": "^1.7.4", + "connect": "^3.7.0", + "debug": "^4.3.4", + "env-editor": "^0.4.1", + "expo-server": "^1.0.7", + "freeport-async": "^2.0.0", + "getenv": "^2.0.0", + "glob": "^13.0.0", + "lan-network": "^0.2.1", + "minimatch": "^9.0.0", + "node-forge": "^1.3.3", + "npm-package-arg": "^11.0.0", + "ora": "^3.4.0", + "picomatch": "^4.0.3", + "pretty-bytes": "^5.6.0", + "pretty-format": "^29.7.0", + "progress": "^2.0.3", + "prompts": "^2.3.2", + "qrcode-terminal": "0.11.0", + "require-from-string": "^2.0.2", + "requireg": "^0.2.2", + "resolve": "^1.22.2", + "resolve-from": "^5.0.0", + "resolve.exports": "^2.0.3", + "semver": "^7.6.0", + "send": "^0.19.0", + "slugify": "^1.3.4", + "source-map-support": "~0.5.21", + "stacktrace-parser": "^0.1.10", + "structured-headers": "^0.4.1", + "tar": "^7.5.2", + "terminal-link": "^2.1.1", + "undici": "^6.18.2", + "wrap-ansi": "^7.0.0", + "ws": "^8.12.1" + }, + "bin": { + "expo-internal": "build/bin/cli" + }, + "peerDependencies": { + "expo": "*", + "expo-router": "*", + "react-native": "*" + }, + "peerDependenciesMeta": { + "expo-router": { + "optional": true + }, + "react-native": { + "optional": true + } + } + }, + "node_modules/expo/node_modules/@expo/cli/node_modules/@expo/prebuild-config": { + "version": "54.0.8", + "resolved": "https://registry.npmjs.org/@expo/prebuild-config/-/prebuild-config-54.0.8.tgz", + "integrity": "sha512-EA7N4dloty2t5Rde+HP0IEE+nkAQiu4A/+QGZGT9mFnZ5KKjPPkqSyYcRvP5bhQE10D+tvz6X0ngZpulbMdbsg==", + "license": "MIT", + "dependencies": { + "@expo/config": "~12.0.13", + "@expo/config-plugins": "~54.0.4", + "@expo/config-types": "^54.0.10", + "@expo/image-utils": "^0.8.8", + "@expo/json-file": "^10.0.8", + "@react-native/normalize-colors": "0.81.5", + "debug": "^4.3.1", + "resolve-from": "^5.0.0", + "semver": "^7.6.0", + "xml2js": "0.6.0" + }, + "peerDependencies": { + "expo": "*" + } + }, + "node_modules/expo/node_modules/@expo/metro-config": { + "version": "54.0.16", + "resolved": "https://registry.npmjs.org/@expo/metro-config/-/metro-config-54.0.16.tgz", + "integrity": "sha512-3LLb9ZQl0VlqSlsalJ7+CYjfz60PBoSDHvpE1UF71aTM1Nx0Vb4LhXo7bCCC+PYP9q/GPB58LLbIROQ8PjKX2w==", + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.20.0", + "@babel/core": "^7.20.0", + "@babel/generator": "^7.20.5", + "@expo/config": "~12.0.13", + "@expo/env": "~2.0.8", + "@expo/json-file": "~10.0.16", + "@expo/metro": "~54.2.0", + "@expo/spawn-async": "^1.7.2", + "browserslist": "^4.25.0", + "chalk": "^4.1.0", + "debug": "^4.3.2", + "dotenv": "~16.4.5", + "dotenv-expand": "~11.0.6", + "getenv": "^2.0.0", + "glob": "^13.0.0", + "hermes-parser": "^0.29.1", + "jsc-safe-url": "^0.2.4", + "lightningcss": "^1.30.1", + "picomatch": "^4.0.3", + "postcss": "~8.4.32", + "resolve-from": "^5.0.0" + }, + "peerDependencies": { + "expo": "*" + }, + "peerDependenciesMeta": { + "expo": { + "optional": true + } + } + }, + "node_modules/expo/node_modules/@expo/metro-config/node_modules/@expo/json-file": { + "version": "10.0.16", + "resolved": "https://registry.npmjs.org/@expo/json-file/-/json-file-10.0.16.tgz", + "integrity": "sha512-fcVkWEj+hLuP2yt5W0aw6LmDRqSPWDLUSxOMcmFeV+algmIF59sQVKCwB9btjQLd4V6x9N0pISkQEkBubUHrCw==", + "license": "MIT", + "dependencies": { + "@babel/code-frame": "~7.10.4", + "json5": "^2.2.3" + } + }, + "node_modules/expo/node_modules/@expo/metro-config/node_modules/@expo/json-file/node_modules/@babel/code-frame": { + "version": "7.10.4", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.10.4.tgz", + "integrity": "sha512-vG6SvB6oYEhvgisZNFRmRCUkLz11c7rp+tbNTynGqc6mS1d5ATd/sGyV6W0KZZnXRKMTzZDRgQT3Ou9jhpAfUg==", + "license": "MIT", + "dependencies": { + "@babel/highlight": "^7.10.4" + } + }, + "node_modules/expo/node_modules/@expo/vector-icons": { + "version": "15.1.1", + "resolved": "https://registry.npmjs.org/@expo/vector-icons/-/vector-icons-15.1.1.tgz", + "integrity": "sha512-Iu2VkcoI5vygbtYngm7jb4ifxElNVXQYdDrYkT7UCEIiKLeWnQY0wf2ZhHZ+Wro6Sc5TaumpKUOqDRpLi5rkvw==", + "license": "MIT", + "peerDependencies": { + "expo-font": ">=14.0.4", + "react": "*", + "react-native": "*" + } + }, + "node_modules/expo/node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/expo/node_modules/brace-expansion": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.1.tgz", + "integrity": "sha512-WR1cURNjuvBLMZBMbqM0UoE+WAfdUcEV1ccD8PVBVOI+Z3ND4+SZbN8RsfT2bMuG1qwz5RFvPukSZm5fF2D5eA==", + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/expo/node_modules/expo-font": { + "version": "14.0.12", + "resolved": "https://registry.npmjs.org/expo-font/-/expo-font-14.0.12.tgz", + "integrity": "sha512-QQzunE2Mxk45AsCWm3tK7OpVljbtVnKD58q4/qliev+cbye1IOduUnRIdD+P7DyButw17G9MTX795kgaQiz5hQ==", + "license": "MIT", + "dependencies": { + "fontfaceobserver": "^2.1.0" + }, + "peerDependencies": { + "expo": "*", + "react": "*", + "react-native": "*" + } + }, + "node_modules/expo/node_modules/expo-keep-awake": { + "version": "15.0.8", + "resolved": "https://registry.npmjs.org/expo-keep-awake/-/expo-keep-awake-15.0.8.tgz", + "integrity": "sha512-YK9M1VrnoH1vLJiQzChZgzDvVimVoriibiDIFLbQMpjYBnvyfUeHJcin/Gx1a+XgupNXy92EQJLgI/9ZuXajYQ==", + "license": "MIT", + "peerDependencies": { + "expo": "*", + "react": "*" + } + }, + "node_modules/expo/node_modules/glob": { + "version": "13.0.6", + "resolved": "https://registry.npmjs.org/glob/-/glob-13.0.6.tgz", + "integrity": "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==", + "license": "BlueOak-1.0.0", + "dependencies": { + "minimatch": "^10.2.2", + "minipass": "^7.1.3", + "path-scurry": "^2.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/expo/node_modules/glob/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/expo/node_modules/glob/node_modules/brace-expansion": { + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz", + "integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==", + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/expo/node_modules/glob/node_modules/minimatch": { + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.5" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/expo/node_modules/minimatch": { + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.2" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/expo/node_modules/pretty-format": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-29.7.0.tgz", + "integrity": "sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==", + "license": "MIT", + "dependencies": { + "@jest/schemas": "^29.6.3", + "ansi-styles": "^5.0.0", + "react-is": "^18.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/expo/node_modules/react-is": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", + "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", + "license": "MIT" + }, + "node_modules/expo/node_modules/semver": { + "version": "7.8.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.4.tgz", + "integrity": "sha512-rUCObTnP32Q08R2uuIrt7r9PlEonuTmtuXYcW6s5kjdlj3xbnwe+21yXptAUYcMAABLkYYTtnmzb3w3EDZfueA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/expo/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/exponential-backoff": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/exponential-backoff/-/exponential-backoff-3.1.3.tgz", + "integrity": "sha512-ZgEeZXj30q+I0EN+CbSSpIyPaJ5HVQD18Z1m+u1FXbAeT94mr1zw50q4q6jiiC447Nl/YTcIYSAftiGqetwXCA==", + "license": "Apache-2.0" + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "license": "MIT" + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "license": "MIT" + }, + "node_modules/fb-watchman": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/fb-watchman/-/fb-watchman-2.0.2.tgz", + "integrity": "sha512-p5161BqbuCaSnB8jIbzQHOlpgsPmK5rJVDfDKO91Axs5NC1uu3HRQm6wt9cd9/+GtQQIO53JdGXXoyDpTAsgYA==", + "license": "Apache-2.0", + "dependencies": { + "bser": "2.1.1" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fflate": { + "version": "0.8.3", + "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.3.tgz", + "integrity": "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==", + "dev": true, + "license": "MIT" + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/filter-obj": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/filter-obj/-/filter-obj-1.1.0.tgz", + "integrity": "sha512-8rXg1ZnX7xzy2NGDVkBVaAy+lSlPNwad13BtgSlLuxfIslyt5Vg64U7tFcCt4WS1R0hvtnQybT/IyCkGZ3DpXQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/finalhandler": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.1.2.tgz", + "integrity": "sha512-aAWcW57uxVNrQZqFXjITpW3sIUQmHGG3qSb9mUah9MgMC4NeWhNOlNjXEYq3HjRAvL6arUviZGGJsBg6z0zsWA==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "encodeurl": "~1.0.2", + "escape-html": "~1.0.3", + "on-finished": "~2.3.0", + "parseurl": "~1.3.3", + "statuses": "~1.5.0", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/finalhandler/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/finalhandler/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/find-up": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", + "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", + "license": "MIT", + "dependencies": { + "locate-path": "^5.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/flow-enums-runtime": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/flow-enums-runtime/-/flow-enums-runtime-0.0.6.tgz", + "integrity": "sha512-3PYnM29RFXwvAN6Pc/scUfkI7RwhQ/xqyLUyPNlXUp9S40zI8nup9tUSrTLSVnWGBN38FNiGWbwZOB6uR4OGdw==", + "license": "MIT" + }, + "node_modules/fontfaceobserver": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/fontfaceobserver/-/fontfaceobserver-2.3.0.tgz", + "integrity": "sha512-6FPvD/IVyT4ZlNe7Wcn5Fb/4ChigpucKYSvD6a+0iMoLn2inpo711eyIcKjmDtE5XNcgAkSH9uN/nfAeZzHEfg==", + "license": "BSD-2-Clause" + }, + "node_modules/freeport-async": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/freeport-async/-/freeport-async-2.0.0.tgz", + "integrity": "sha512-K7od3Uw45AJg00XUmy15+Hae2hOcgKcmN3/EF6Y7i01O0gaqiRx8sUSpsb9+BRNL8RPBrhzPsVfy8q9ADlJuWQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/fresh": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", + "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fs.realpath": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", + "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", + "license": "ISC" + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "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==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "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==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, + "node_modules/get-package-type": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/get-package-type/-/get-package-type-0.1.0.tgz", + "integrity": "sha512-pjzuKtY64GYfWizNAJ0fr9VqttZkNiK2iS430LtIHzjBEr6bX8Am2zm4sW4Ro5wjWW5cAlRL1qAMTcXbjNAO2Q==", + "license": "MIT", + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/get-stream": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-5.2.0.tgz", + "integrity": "sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA==", + "license": "MIT", + "dependencies": { + "pump": "^3.0.0" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/getenv": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/getenv/-/getenv-2.0.0.tgz", + "integrity": "sha512-VilgtJj/ALgGY77fiLam5iD336eSWi96Q15JSAG1zi8NRBysm3LXKdGnHb4m5cuyxvOLQQKWpBZAT6ni4FI2iQ==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/gl-matrix": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/gl-matrix/-/gl-matrix-2.8.1.tgz", + "integrity": "sha512-0YCjVpE3pS5XWlN3J4X7AiAx65+nqAI54LndtVFnQZB6G/FVLkZH8y8V6R3cIoOQR4pUdfwQGd1iwyoXHJ4Qfw==", + "license": "MIT" + }, + "node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "license": "ISC", + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/got": { + "version": "11.8.6", + "resolved": "https://registry.npmjs.org/got/-/got-11.8.6.tgz", + "integrity": "sha512-6tfZ91bOr7bOXnK7PRDCGBLa1H4U080YHNaAQ2KsMGlLEzRbk44nsZF2E1IeRc3vtJHPVbKCYgdFbaGO2ljd8g==", + "license": "MIT", + "dependencies": { + "@sindresorhus/is": "^4.0.0", + "@szmarczak/http-timer": "^4.0.5", + "@types/cacheable-request": "^6.0.1", + "@types/responselike": "^1.0.0", + "cacheable-lookup": "^5.0.3", + "cacheable-request": "^7.0.2", + "decompress-response": "^6.0.0", + "http2-wrapper": "^1.0.0-beta.5.2", + "lowercase-keys": "^2.0.0", + "p-cancelable": "^2.0.0", + "responselike": "^2.0.0" + }, + "engines": { + "node": ">=10.19.0" + }, + "funding": { + "url": "https://github.com/sindresorhus/got?sponsor=1" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "license": "ISC" + }, + "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/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==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/hermes-estree": { + "version": "0.29.1", + "resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.29.1.tgz", + "integrity": "sha512-jl+x31n4/w+wEqm0I2r4CMimukLbLQEYpisys5oCre611CI5fc9TxhqkBBCJ1edDG4Kza0f7CgNz8xVMLZQOmQ==", + "license": "MIT" + }, + "node_modules/hermes-parser": { + "version": "0.29.1", + "resolved": "https://registry.npmjs.org/hermes-parser/-/hermes-parser-0.29.1.tgz", + "integrity": "sha512-xBHWmUtRC5e/UL0tI7Ivt2riA/YBq9+SiYFU7C1oBa/j2jYGlIF9043oak1F47ihuDIxQ5nbsKueYJDRY02UgA==", + "license": "MIT", + "dependencies": { + "hermes-estree": "0.29.1" + } + }, + "node_modules/hoist-non-react-statics": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz", + "integrity": "sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==", + "license": "BSD-3-Clause", + "dependencies": { + "react-is": "^16.7.0" + } + }, + "node_modules/hosted-git-info": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-7.0.2.tgz", + "integrity": "sha512-puUZAUKT5m8Zzvs72XWy3HtvVbTWljRE66cP60bxJzAqf2DgICo7lYTY2IHUmLnNpjYvw5bvmoHvPc0QO2a62w==", + "license": "ISC", + "dependencies": { + "lru-cache": "^10.0.1" + }, + "engines": { + "node": "^16.14.0 || >=18.0.0" + } + }, + "node_modules/hosted-git-info/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==", + "license": "ISC" + }, + "node_modules/http-cache-semantics": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.2.0.tgz", + "integrity": "sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ==", + "license": "BSD-2-Clause" + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/http-errors/node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/http2-wrapper": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/http2-wrapper/-/http2-wrapper-1.0.3.tgz", + "integrity": "sha512-V+23sDMr12Wnz7iTcDeJr3O6AIxlnvT/bmaAAAP/Xda35C90p9599p0F1eHR/N1KILWSoWVAiOMFjBBXaXSMxg==", + "license": "MIT", + "dependencies": { + "quick-lru": "^5.1.1", + "resolve-alpn": "^1.0.0" + }, + "engines": { + "node": ">=10.19.0" + } + }, + "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==", + "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/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/image-size": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/image-size/-/image-size-1.2.1.tgz", + "integrity": "sha512-rH+46sQJ2dlwfjfhCyNx5thzrv+dtmBIhPHk0zgRUukHzZ/kRueTJXoYYsclBaKcSMBWuGbOFXtioLpzTb5euw==", + "license": "MIT", + "dependencies": { + "queue": "6.0.2" + }, + "bin": { + "image-size": "bin/image-size.js" + }, + "engines": { + "node": ">=16.x" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/inflight": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", + "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", + "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", + "license": "ISC", + "dependencies": { + "once": "^1.3.0", + "wrappy": "1" + } + }, + "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/ini": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", + "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", + "license": "ISC" + }, + "node_modules/invariant": { + "version": "2.2.4", + "resolved": "https://registry.npmjs.org/invariant/-/invariant-2.2.4.tgz", + "integrity": "sha512-phJfQVBuaJM5raOpJjSfkiD6BpbCE4Ns//LaXl6wGYtUBY83nWS6Rf9tXm2e8VaK60JEjYldbPif/A2B1C2gNA==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.0.0" + } + }, + "node_modules/is-core-module": { + "version": "2.16.2", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.2.tgz", + "integrity": "sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA==", + "license": "MIT", + "dependencies": { + "hasown": "^2.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-docker": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-2.2.1.tgz", + "integrity": "sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ==", + "license": "MIT", + "bin": { + "is-docker": "cli.js" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/is-stream": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-1.1.0.tgz", + "integrity": "sha512-uQPm8kcs47jx38atAcWTVxyltQYoPT68y9aWYdV6yWXSyW8mzSat0TL6CiWdZeCdF3KrAvpVtnHbTv4RN+rqdQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-wsl": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-2.2.0.tgz", + "integrity": "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==", + "license": "MIT", + "dependencies": { + "is-docker": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "license": "ISC" + }, + "node_modules/isomorphic-fetch": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/isomorphic-fetch/-/isomorphic-fetch-2.2.1.tgz", + "integrity": "sha512-9c4TNAKYXM5PRyVcwUZrF3W09nQ+sO7+jydgs4ZGW9dhsLG2VOlISJABombdQqQRXCwuYG3sYV/puGf5rp0qmA==", + "license": "MIT", + "dependencies": { + "node-fetch": "^1.0.1", + "whatwg-fetch": ">=0.10.0" + } + }, + "node_modules/isomorphic-fetch/node_modules/node-fetch": { + "version": "1.7.3", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-1.7.3.tgz", + "integrity": "sha512-NhZ4CsKx7cYm2vSrBAr2PvFOe6sWDf0UYLRqA6svUYg7+/TSfVAu49jYC4BvQ4Sms9SZgdqGBgroqfDhJdTyKQ==", + "license": "MIT", + "dependencies": { + "encoding": "^0.1.11", + "is-stream": "^1.0.1" + } + }, + "node_modules/istanbul-lib-coverage": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", + "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=8" + } + }, + "node_modules/istanbul-lib-instrument": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-instrument/-/istanbul-lib-instrument-5.2.1.tgz", + "integrity": "sha512-pzqtp31nLv/XFOzXGuvhCb8qhjmTVo5vjVk19XE4CRlSWz0KoeJ3bw9XsA7nOp9YBf4qHjwBxkDzKcME/J29Yg==", + "license": "BSD-3-Clause", + "dependencies": { + "@babel/core": "^7.12.3", + "@babel/parser": "^7.14.7", + "@istanbuljs/schema": "^0.1.2", + "istanbul-lib-coverage": "^3.2.0", + "semver": "^6.3.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/jest-environment-node": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-environment-node/-/jest-environment-node-29.7.0.tgz", + "integrity": "sha512-DOSwCRqXirTOyheM+4d5YZOrWcdu0LNZ87ewUoywbcb2XR4wKgqiG8vNeYwhjFMbEkfju7wx2GYH0P2gevGvFw==", + "license": "MIT", + "dependencies": { + "@jest/environment": "^29.7.0", + "@jest/fake-timers": "^29.7.0", + "@jest/types": "^29.6.3", + "@types/node": "*", + "jest-mock": "^29.7.0", + "jest-util": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-environment-node/node_modules/@jest/types": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/@jest/types/-/types-29.6.3.tgz", + "integrity": "sha512-u3UPsIilWKOM3F9CXtrG8LEJmNxwoCQC/XVj4IKYXvvpx7QIi/Kg1LI5uDmDpKlac62NUtX7eLjRh+jVZcLOzw==", + "license": "MIT", + "dependencies": { + "@jest/schemas": "^29.6.3", + "@types/istanbul-lib-coverage": "^2.0.0", + "@types/istanbul-reports": "^3.0.0", + "@types/node": "*", + "@types/yargs": "^17.0.8", + "chalk": "^4.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-environment-node/node_modules/@types/istanbul-reports": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/istanbul-reports/-/istanbul-reports-3.0.4.tgz", + "integrity": "sha512-pk2B1NWalF9toCRu6gjBzR69syFjP4Od8WRAX+0mmf9lAjCRicLOWc+ZrxZHx/0XRjotgkF9t6iaMJ+aXcOdZQ==", + "license": "MIT", + "dependencies": { + "@types/istanbul-lib-report": "*" + } + }, + "node_modules/jest-environment-node/node_modules/@types/yargs": { + "version": "17.0.35", + "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.35.tgz", + "integrity": "sha512-qUHkeCyQFxMXg79wQfTtfndEC+N9ZZg76HJftDJp+qH2tV7Gj4OJi7l+PiWwJ+pWtW8GwSmqsDj/oymhrTWXjg==", + "license": "MIT", + "dependencies": { + "@types/yargs-parser": "*" + } + }, + "node_modules/jest-get-type": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/jest-get-type/-/jest-get-type-29.6.3.tgz", + "integrity": "sha512-zrteXnqYxfQh7l5FHyL38jL39di8H8rHoecLH3JNxH3BwOrBsNeabdap5e0I23lD4HHI8W5VFBZqG4Eaq5LNcw==", + "license": "MIT", + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-haste-map": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-haste-map/-/jest-haste-map-29.7.0.tgz", + "integrity": "sha512-fP8u2pyfqx0K1rGn1R9pyE0/KTn+G7PxktWidOBTqFPLYX0b9ksaMFkhK5vrS3DVun09pckLdlx90QthlW7AmA==", + "license": "MIT", + "dependencies": { + "@jest/types": "^29.6.3", + "@types/graceful-fs": "^4.1.3", + "@types/node": "*", + "anymatch": "^3.0.3", + "fb-watchman": "^2.0.0", + "graceful-fs": "^4.2.9", + "jest-regex-util": "^29.6.3", + "jest-util": "^29.7.0", + "jest-worker": "^29.7.0", + "micromatch": "^4.0.4", + "walker": "^1.0.8" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "optionalDependencies": { + "fsevents": "^2.3.2" + } + }, + "node_modules/jest-haste-map/node_modules/@jest/types": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/@jest/types/-/types-29.6.3.tgz", + "integrity": "sha512-u3UPsIilWKOM3F9CXtrG8LEJmNxwoCQC/XVj4IKYXvvpx7QIi/Kg1LI5uDmDpKlac62NUtX7eLjRh+jVZcLOzw==", + "license": "MIT", + "dependencies": { + "@jest/schemas": "^29.6.3", + "@types/istanbul-lib-coverage": "^2.0.0", + "@types/istanbul-reports": "^3.0.0", + "@types/node": "*", + "@types/yargs": "^17.0.8", + "chalk": "^4.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-haste-map/node_modules/@types/istanbul-reports": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/istanbul-reports/-/istanbul-reports-3.0.4.tgz", + "integrity": "sha512-pk2B1NWalF9toCRu6gjBzR69syFjP4Od8WRAX+0mmf9lAjCRicLOWc+ZrxZHx/0XRjotgkF9t6iaMJ+aXcOdZQ==", + "license": "MIT", + "dependencies": { + "@types/istanbul-lib-report": "*" + } + }, + "node_modules/jest-haste-map/node_modules/@types/yargs": { + "version": "17.0.35", + "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.35.tgz", + "integrity": "sha512-qUHkeCyQFxMXg79wQfTtfndEC+N9ZZg76HJftDJp+qH2tV7Gj4OJi7l+PiWwJ+pWtW8GwSmqsDj/oymhrTWXjg==", + "license": "MIT", + "dependencies": { + "@types/yargs-parser": "*" + } + }, + "node_modules/jest-message-util": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-message-util/-/jest-message-util-29.7.0.tgz", + "integrity": "sha512-GBEV4GRADeP+qtB2+6u61stea8mGcOT4mCtrYISZwfu9/ISHFJ/5zOMXYbpBE9RsS5+Gb63DW4FgmnKJ79Kf6w==", + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.12.13", + "@jest/types": "^29.6.3", + "@types/stack-utils": "^2.0.0", + "chalk": "^4.0.0", + "graceful-fs": "^4.2.9", + "micromatch": "^4.0.4", + "pretty-format": "^29.7.0", + "slash": "^3.0.0", + "stack-utils": "^2.0.3" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-message-util/node_modules/@jest/types": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/@jest/types/-/types-29.6.3.tgz", + "integrity": "sha512-u3UPsIilWKOM3F9CXtrG8LEJmNxwoCQC/XVj4IKYXvvpx7QIi/Kg1LI5uDmDpKlac62NUtX7eLjRh+jVZcLOzw==", + "license": "MIT", + "dependencies": { + "@jest/schemas": "^29.6.3", + "@types/istanbul-lib-coverage": "^2.0.0", + "@types/istanbul-reports": "^3.0.0", + "@types/node": "*", + "@types/yargs": "^17.0.8", + "chalk": "^4.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-message-util/node_modules/@types/istanbul-reports": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/istanbul-reports/-/istanbul-reports-3.0.4.tgz", + "integrity": "sha512-pk2B1NWalF9toCRu6gjBzR69syFjP4Od8WRAX+0mmf9lAjCRicLOWc+ZrxZHx/0XRjotgkF9t6iaMJ+aXcOdZQ==", + "license": "MIT", + "dependencies": { + "@types/istanbul-lib-report": "*" + } + }, + "node_modules/jest-message-util/node_modules/@types/yargs": { + "version": "17.0.35", + "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.35.tgz", + "integrity": "sha512-qUHkeCyQFxMXg79wQfTtfndEC+N9ZZg76HJftDJp+qH2tV7Gj4OJi7l+PiWwJ+pWtW8GwSmqsDj/oymhrTWXjg==", + "license": "MIT", + "dependencies": { + "@types/yargs-parser": "*" + } + }, + "node_modules/jest-message-util/node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/jest-message-util/node_modules/pretty-format": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-29.7.0.tgz", + "integrity": "sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==", + "license": "MIT", + "dependencies": { + "@jest/schemas": "^29.6.3", + "ansi-styles": "^5.0.0", + "react-is": "^18.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-message-util/node_modules/react-is": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", + "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", + "license": "MIT" + }, + "node_modules/jest-mock": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-mock/-/jest-mock-29.7.0.tgz", + "integrity": "sha512-ITOMZn+UkYS4ZFh83xYAOzWStloNzJFO2s8DWrE4lhtGD+AorgnbkiKERe4wQVBydIGPx059g6riW5Btp6Llnw==", + "license": "MIT", + "dependencies": { + "@jest/types": "^29.6.3", + "@types/node": "*", + "jest-util": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-mock/node_modules/@jest/types": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/@jest/types/-/types-29.6.3.tgz", + "integrity": "sha512-u3UPsIilWKOM3F9CXtrG8LEJmNxwoCQC/XVj4IKYXvvpx7QIi/Kg1LI5uDmDpKlac62NUtX7eLjRh+jVZcLOzw==", + "license": "MIT", + "dependencies": { + "@jest/schemas": "^29.6.3", + "@types/istanbul-lib-coverage": "^2.0.0", + "@types/istanbul-reports": "^3.0.0", + "@types/node": "*", + "@types/yargs": "^17.0.8", + "chalk": "^4.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-mock/node_modules/@types/istanbul-reports": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/istanbul-reports/-/istanbul-reports-3.0.4.tgz", + "integrity": "sha512-pk2B1NWalF9toCRu6gjBzR69syFjP4Od8WRAX+0mmf9lAjCRicLOWc+ZrxZHx/0XRjotgkF9t6iaMJ+aXcOdZQ==", + "license": "MIT", + "dependencies": { + "@types/istanbul-lib-report": "*" + } + }, + "node_modules/jest-mock/node_modules/@types/yargs": { + "version": "17.0.35", + "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.35.tgz", + "integrity": "sha512-qUHkeCyQFxMXg79wQfTtfndEC+N9ZZg76HJftDJp+qH2tV7Gj4OJi7l+PiWwJ+pWtW8GwSmqsDj/oymhrTWXjg==", + "license": "MIT", + "dependencies": { + "@types/yargs-parser": "*" + } + }, + "node_modules/jest-regex-util": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/jest-regex-util/-/jest-regex-util-29.6.3.tgz", + "integrity": "sha512-KJJBsRCyyLNWCNBOvZyRDnAIfUiRJ8v+hOBQYGn8gDyF3UegwiP4gwRR3/SDa42g1YbVycTidUF3rKjyLFDWbg==", + "license": "MIT", + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-util": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-29.7.0.tgz", + "integrity": "sha512-z6EbKajIpqGKU56y5KBUgy1dt1ihhQJgWzUlZHArA/+X2ad7Cb5iF+AK1EWVL/Bo7Rz9uurpqw6SiBCefUbCGA==", + "license": "MIT", + "dependencies": { + "@jest/types": "^29.6.3", + "@types/node": "*", + "chalk": "^4.0.0", + "ci-info": "^3.2.0", + "graceful-fs": "^4.2.9", + "picomatch": "^2.2.3" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-util/node_modules/@jest/types": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/@jest/types/-/types-29.6.3.tgz", + "integrity": "sha512-u3UPsIilWKOM3F9CXtrG8LEJmNxwoCQC/XVj4IKYXvvpx7QIi/Kg1LI5uDmDpKlac62NUtX7eLjRh+jVZcLOzw==", + "license": "MIT", + "dependencies": { + "@jest/schemas": "^29.6.3", + "@types/istanbul-lib-coverage": "^2.0.0", + "@types/istanbul-reports": "^3.0.0", + "@types/node": "*", + "@types/yargs": "^17.0.8", + "chalk": "^4.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-util/node_modules/@types/istanbul-reports": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/istanbul-reports/-/istanbul-reports-3.0.4.tgz", + "integrity": "sha512-pk2B1NWalF9toCRu6gjBzR69syFjP4Od8WRAX+0mmf9lAjCRicLOWc+ZrxZHx/0XRjotgkF9t6iaMJ+aXcOdZQ==", + "license": "MIT", + "dependencies": { + "@types/istanbul-lib-report": "*" + } + }, + "node_modules/jest-util/node_modules/@types/yargs": { + "version": "17.0.35", + "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.35.tgz", + "integrity": "sha512-qUHkeCyQFxMXg79wQfTtfndEC+N9ZZg76HJftDJp+qH2tV7Gj4OJi7l+PiWwJ+pWtW8GwSmqsDj/oymhrTWXjg==", + "license": "MIT", + "dependencies": { + "@types/yargs-parser": "*" + } + }, + "node_modules/jest-util/node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/jest-validate": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-validate/-/jest-validate-29.7.0.tgz", + "integrity": "sha512-ZB7wHqaRGVw/9hST/OuFUReG7M8vKeq0/J2egIGLdvjHCmYqGARhzXmtgi+gVeZ5uXFF219aOc3Ls2yLg27tkw==", + "license": "MIT", + "dependencies": { + "@jest/types": "^29.6.3", + "camelcase": "^6.2.0", + "chalk": "^4.0.0", + "jest-get-type": "^29.6.3", + "leven": "^3.1.0", + "pretty-format": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-validate/node_modules/@jest/types": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/@jest/types/-/types-29.6.3.tgz", + "integrity": "sha512-u3UPsIilWKOM3F9CXtrG8LEJmNxwoCQC/XVj4IKYXvvpx7QIi/Kg1LI5uDmDpKlac62NUtX7eLjRh+jVZcLOzw==", + "license": "MIT", + "dependencies": { + "@jest/schemas": "^29.6.3", + "@types/istanbul-lib-coverage": "^2.0.0", + "@types/istanbul-reports": "^3.0.0", + "@types/node": "*", + "@types/yargs": "^17.0.8", + "chalk": "^4.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-validate/node_modules/@types/istanbul-reports": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/istanbul-reports/-/istanbul-reports-3.0.4.tgz", + "integrity": "sha512-pk2B1NWalF9toCRu6gjBzR69syFjP4Od8WRAX+0mmf9lAjCRicLOWc+ZrxZHx/0XRjotgkF9t6iaMJ+aXcOdZQ==", + "license": "MIT", + "dependencies": { + "@types/istanbul-lib-report": "*" + } + }, + "node_modules/jest-validate/node_modules/@types/yargs": { + "version": "17.0.35", + "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.35.tgz", + "integrity": "sha512-qUHkeCyQFxMXg79wQfTtfndEC+N9ZZg76HJftDJp+qH2tV7Gj4OJi7l+PiWwJ+pWtW8GwSmqsDj/oymhrTWXjg==", + "license": "MIT", + "dependencies": { + "@types/yargs-parser": "*" + } + }, + "node_modules/jest-validate/node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/jest-validate/node_modules/pretty-format": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-29.7.0.tgz", + "integrity": "sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==", + "license": "MIT", + "dependencies": { + "@jest/schemas": "^29.6.3", + "ansi-styles": "^5.0.0", + "react-is": "^18.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-validate/node_modules/react-is": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", + "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", + "license": "MIT" + }, + "node_modules/jest-worker": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-29.7.0.tgz", + "integrity": "sha512-eIz2msL/EzL9UFTFFx7jBTkeZfku0yUAyZZZmJ93H2TYEiroIx2PQjEXcwYtYl8zXCxb+PAmA2hLIt/6ZEkPHw==", + "license": "MIT", + "dependencies": { + "@types/node": "*", + "jest-util": "^29.7.0", + "merge-stream": "^2.0.0", + "supports-color": "^8.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-worker/node_modules/supports-color": { + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", + "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, + "node_modules/jimp-compact": { + "version": "0.16.1", + "resolved": "https://registry.npmjs.org/jimp-compact/-/jimp-compact-0.16.1.tgz", + "integrity": "sha512-dZ6Ra7u1G8c4Letq/B5EzAxj4tLFHL+cGtdpR+PVm4yzPDj+lCk+AbivWt1eOM+ikzkowtyV7qSqX6qr3t71Ww==", + "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==", + "license": "MIT" + }, + "node_modules/js-yaml": { + "version": "3.14.2", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.2.tgz", + "integrity": "sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg==", + "license": "MIT", + "dependencies": { + "argparse": "^1.0.7", + "esprima": "^4.0.0" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/jsc-safe-url": { + "version": "0.2.4", + "resolved": "https://registry.npmjs.org/jsc-safe-url/-/jsc-safe-url-0.2.4.tgz", + "integrity": "sha512-0wM3YBWtYePOjfyXQH5MWQ8H7sdk5EXSwZvmSLKk2RboVQ2Bu239jycHDz5J/8Blf3K0Qnoy2b6xD+z10MFB+Q==", + "license": "0BSD" + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "license": "MIT" + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/kleur": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/kleur/-/kleur-3.0.3.tgz", + "integrity": "sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/lan-network": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/lan-network/-/lan-network-0.2.1.tgz", + "integrity": "sha512-ONPnazC96VKDntab9j9JKwIWhZ4ZUceB4A9Epu4Ssg0hYFmtHZSeQ+n15nIwTFmcBUKtExOer8WTJ4GF9MO64A==", + "license": "MIT", + "bin": { + "lan-network": "dist/lan-network-cli.js" + } + }, + "node_modules/leven": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/leven/-/leven-3.1.0.tgz", + "integrity": "sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/lighthouse-logger": { + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/lighthouse-logger/-/lighthouse-logger-1.4.2.tgz", + "integrity": "sha512-gPWxznF6TKmUHrOQjlVo2UbaL2EJ71mb2CCeRs/2qBpi4L/g4LUVc9+3lKQ6DTUZwJswfM7ainGrLO1+fOqa2g==", + "license": "Apache-2.0", + "dependencies": { + "debug": "^2.6.9", + "marky": "^1.2.2" + } + }, + "node_modules/lighthouse-logger/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/lighthouse-logger/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "cpu": [ + "arm" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lines-and-columns": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", + "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", + "license": "MIT" + }, + "node_modules/locate-path": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", + "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", + "license": "MIT", + "dependencies": { + "p-locate": "^4.1.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/lodash.debounce": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/lodash.debounce/-/lodash.debounce-4.0.8.tgz", + "integrity": "sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==", + "license": "MIT" + }, + "node_modules/lodash.throttle": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/lodash.throttle/-/lodash.throttle-4.1.1.tgz", + "integrity": "sha512-wIkUCfVKpVsWo3JSZlc+8MB5it+2AN5W8J7YVMST30UrvcQNZ1Okbj+rbVniijTWE6FGYy4XJq/rHkas8qJMLQ==", + "license": "MIT" + }, + "node_modules/log-symbols": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-2.2.0.tgz", + "integrity": "sha512-VeIAFslyIerEJLXHziedo2basKbMKtTw3vfn5IzG0XTjhAVEJyNHnL2p7vc+wBDSdQuUpNw3M2u6xb9QsAY5Eg==", + "license": "MIT", + "dependencies": { + "chalk": "^2.0.1" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/log-symbols/node_modules/ansi-styles": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz", + "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", + "license": "MIT", + "dependencies": { + "color-convert": "^1.9.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/log-symbols/node_modules/chalk": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz", + "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^3.2.1", + "escape-string-regexp": "^1.0.5", + "supports-color": "^5.3.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/log-symbols/node_modules/color-convert": { + "version": "1.9.3", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", + "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==", + "license": "MIT", + "dependencies": { + "color-name": "1.1.3" + } + }, + "node_modules/log-symbols/node_modules/color-name": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz", + "integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==", + "license": "MIT" + }, + "node_modules/log-symbols/node_modules/escape-string-regexp": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", + "integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==", + "license": "MIT", + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/log-symbols/node_modules/has-flag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", + "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/log-symbols/node_modules/supports-color": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", + "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", + "license": "MIT", + "dependencies": { + "has-flag": "^3.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/lowercase-keys": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/lowercase-keys/-/lowercase-keys-2.0.0.tgz", + "integrity": "sha512-tqNXrS78oMOE73NMxK4EMLQsQowWf8jKooH9g7xPavRT706R6bkQJ6DY2Te7QukaZsulxa30wQ7bk0pm4XiHmA==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "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==", + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/makeerror": { + "version": "1.0.12", + "resolved": "https://registry.npmjs.org/makeerror/-/makeerror-1.0.12.tgz", + "integrity": "sha512-JmqCvUhmt43madlpFzG4BQzG2Z3m6tvQDNKdClZnO3VbIudJYmxsT0FNJMeiB2+JTSlTQTSbU8QdesVmwJcmLg==", + "license": "BSD-3-Clause", + "dependencies": { + "tmpl": "1.0.5" + } + }, + "node_modules/marky": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/marky/-/marky-1.3.0.tgz", + "integrity": "sha512-ocnPZQLNpvbedwTy9kNrQEsknEfgvcLMvOtz3sFeWApDq1MXH1TqkCIx58xlpESsfwQOnuBO9beyQuNGzVvuhQ==", + "license": "Apache-2.0" + }, + "node_modules/mdn-data": { + "version": "2.0.14", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.0.14.tgz", + "integrity": "sha512-dn6wd0uw5GsdswPFfsgMp5NSB0/aDe6fK94YJV/AJDYXL6HVLWBsxeq7js7Ad+mU2K9LAlwpk6kN2D5mwCPVow==", + "license": "CC0-1.0" + }, + "node_modules/memoize-one": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/memoize-one/-/memoize-one-5.2.1.tgz", + "integrity": "sha512-zYiwtZUcYyXKo/np96AGZAckk+FWWsUdJ3cHGGmld7+AhvcWmQyGCYUh1hc4Q/pkOhb65dQR/pqCyK0cOaHz4Q==", + "license": "MIT" + }, + "node_modules/merge-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", + "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==", + "license": "MIT" + }, + "node_modules/meshoptimizer": { + "version": "0.18.1", + "resolved": "https://registry.npmjs.org/meshoptimizer/-/meshoptimizer-0.18.1.tgz", + "integrity": "sha512-ZhoIoL7TNV4s5B6+rx5mC//fw8/POGyNxS/DZyCJeiZ12ScLfVwRE/GfsxwiTkMYYD5DmK2/JXnEVXqL4rF+Sw==", + "dev": true, + "license": "MIT" + }, + "node_modules/metro": { + "version": "0.83.7", + "resolved": "https://registry.npmjs.org/metro/-/metro-0.83.7.tgz", + "integrity": "sha512-SPaPEyvTsTmd0LpT7RaZciQyDw2i/JB7+iY9L5VfBo72+psescFxBqpI1TL9dnL+pmnfkU+l/J1mEEGLeF65EQ==", + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/core": "^7.25.2", + "@babel/generator": "^7.29.1", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/traverse": "^7.29.0", + "@babel/types": "^7.29.0", + "accepts": "^2.0.0", + "ci-info": "^2.0.0", + "connect": "^3.6.5", + "debug": "^4.4.0", + "error-stack-parser": "^2.0.6", + "flow-enums-runtime": "^0.0.6", + "graceful-fs": "^4.2.4", + "hermes-parser": "0.35.0", + "image-size": "^1.0.2", + "invariant": "^2.2.4", + "jest-worker": "^29.7.0", + "jsc-safe-url": "^0.2.2", + "lodash.throttle": "^4.1.1", + "metro-babel-transformer": "0.83.7", + "metro-cache": "0.83.7", + "metro-cache-key": "0.83.7", + "metro-config": "0.83.7", + "metro-core": "0.83.7", + "metro-file-map": "0.83.7", + "metro-resolver": "0.83.7", + "metro-runtime": "0.83.7", + "metro-source-map": "0.83.7", + "metro-symbolicate": "0.83.7", + "metro-transform-plugins": "0.83.7", + "metro-transform-worker": "0.83.7", + "mime-types": "^3.0.1", + "nullthrows": "^1.1.1", + "serialize-error": "^2.1.0", + "source-map": "^0.5.6", + "throat": "^5.0.0", + "ws": "^7.5.10", + "yargs": "^17.6.2" + }, + "bin": { + "metro": "src/cli.js" + }, + "engines": { + "node": ">=20.19.4" + } + }, + "node_modules/metro-babel-transformer": { + "version": "0.83.7", + "resolved": "https://registry.npmjs.org/metro-babel-transformer/-/metro-babel-transformer-0.83.7.tgz", + "integrity": "sha512-sBqBkt6kNut/88bv+Ucvm4yqdPetbvAEsHzi3MAgJEifOSYYzX5Z5Kgw3TFOrwf/mHJTOBG2ONlaMHoyfP15TA==", + "license": "MIT", + "dependencies": { + "@babel/core": "^7.25.2", + "flow-enums-runtime": "^0.0.6", + "hermes-parser": "0.35.0", + "metro-cache-key": "0.83.7", + "nullthrows": "^1.1.1" + }, + "engines": { + "node": ">=20.19.4" + } + }, + "node_modules/metro-babel-transformer/node_modules/hermes-estree": { + "version": "0.35.0", + "resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.35.0.tgz", + "integrity": "sha512-xVx5Opwy8Oo1I5yGpVRhCvWL/iV3M+ylksSKVNlxxD90cpDpR/AR1jLYqK8HWihm065a6UI3HeyAmYzwS8NOOg==", + "license": "MIT" + }, + "node_modules/metro-babel-transformer/node_modules/hermes-parser": { + "version": "0.35.0", + "resolved": "https://registry.npmjs.org/hermes-parser/-/hermes-parser-0.35.0.tgz", + "integrity": "sha512-9JLjeHxBx8T4CAsydZR49PNZUaix+WpQJwu9p2010lu+7Kwl6D/7wYFFJxoz+aXkaaClp9Zfg6W6/zVlSJORaA==", + "license": "MIT", + "dependencies": { + "hermes-estree": "0.35.0" + } + }, + "node_modules/metro-cache": { + "version": "0.83.7", + "resolved": "https://registry.npmjs.org/metro-cache/-/metro-cache-0.83.7.tgz", + "integrity": "sha512-E9SRePXQ1Zvlj79VcOk57q7VC7rMHMFQ+jhmPHBiq+dJ0bJB5BL87lWZF6oh5X76Cci5tpDuQNaDwwuSCToEeg==", + "license": "MIT", + "dependencies": { + "exponential-backoff": "^3.1.1", + "flow-enums-runtime": "^0.0.6", + "https-proxy-agent": "^7.0.5", + "metro-core": "0.83.7" + }, + "engines": { + "node": ">=20.19.4" + } + }, + "node_modules/metro-cache-key": { + "version": "0.83.7", + "resolved": "https://registry.npmjs.org/metro-cache-key/-/metro-cache-key-0.83.7.tgz", + "integrity": "sha512-W1c2Nmx8MiJTJt+eWhMO08z9VKi3kZOaz99IYGdqeqDgY9j+yZjXl62rUav4Di0heZfh4/n2s722PqRL1OODeg==", + "license": "MIT", + "dependencies": { + "flow-enums-runtime": "^0.0.6" + }, + "engines": { + "node": ">=20.19.4" + } + }, + "node_modules/metro-config": { + "version": "0.83.7", + "resolved": "https://registry.npmjs.org/metro-config/-/metro-config-0.83.7.tgz", + "integrity": "sha512-83mjWFbFOt2GeJ6pFIum5mSnc1uTsZJAtD8o4ej0s4NVsYsA7fB+pHvTfHhFrpeMONaobu2riKavkPei05Er/Q==", + "license": "MIT", + "dependencies": { + "connect": "^3.6.5", + "flow-enums-runtime": "^0.0.6", + "jest-validate": "^29.7.0", + "metro": "0.83.7", + "metro-cache": "0.83.7", + "metro-core": "0.83.7", + "metro-runtime": "0.83.7", + "yaml": "^2.6.1" + }, + "engines": { + "node": ">=20.19.4" + } + }, + "node_modules/metro-core": { + "version": "0.83.7", + "resolved": "https://registry.npmjs.org/metro-core/-/metro-core-0.83.7.tgz", + "integrity": "sha512-6yn3w1wnltT6RQl7p7YES2l95ArC+mWrOssEiH8p5/DDrJS65/szf9LsC9JrBv8c5DdvSY3V3f0GRYg0Ox7hCg==", + "license": "MIT", + "dependencies": { + "flow-enums-runtime": "^0.0.6", + "lodash.throttle": "^4.1.1", + "metro-resolver": "0.83.7" + }, + "engines": { + "node": ">=20.19.4" + } + }, + "node_modules/metro-file-map": { + "version": "0.83.7", + "resolved": "https://registry.npmjs.org/metro-file-map/-/metro-file-map-0.83.7.tgz", + "integrity": "sha512-+j0F1m+FQYVAQ6syf+mwhIPV5GoFQrkInX8bppuc50IzNsZbMrp8R5H/Sx/K2daQ3YEa9F/XwkeZT8gzJfgeCw==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "fb-watchman": "^2.0.0", + "flow-enums-runtime": "^0.0.6", + "graceful-fs": "^4.2.4", + "invariant": "^2.2.4", + "jest-worker": "^29.7.0", + "micromatch": "^4.0.4", + "nullthrows": "^1.1.1", + "walker": "^1.0.7" + }, + "engines": { + "node": ">=20.19.4" + } + }, + "node_modules/metro-minify-terser": { + "version": "0.83.7", + "resolved": "https://registry.npmjs.org/metro-minify-terser/-/metro-minify-terser-0.83.7.tgz", + "integrity": "sha512-MfJar2IS4tBRuLb9svwb0Gu5l9BsH+pcRm8eGcEi/wy8MzZinfinh5dFLt2nWkocnulIgtGB5NkFDdbXqMXKhQ==", + "license": "MIT", + "dependencies": { + "flow-enums-runtime": "^0.0.6", + "terser": "^5.15.0" + }, + "engines": { + "node": ">=20.19.4" + } + }, + "node_modules/metro-resolver": { + "version": "0.83.7", + "resolved": "https://registry.npmjs.org/metro-resolver/-/metro-resolver-0.83.7.tgz", + "integrity": "sha512-WSJIENlMcoSsuz66IfBHOkgfp3KJt2UW2TnEHPf1b8pIG2eEXNOVmo2+03A0H17WY2XGXWgxL0CG7FAopqgB1A==", + "license": "MIT", + "dependencies": { + "flow-enums-runtime": "^0.0.6" + }, + "engines": { + "node": ">=20.19.4" + } + }, + "node_modules/metro-runtime": { + "version": "0.83.7", + "resolved": "https://registry.npmjs.org/metro-runtime/-/metro-runtime-0.83.7.tgz", + "integrity": "sha512-9GKkJURaB2iyYoEExKnedzAHzxmKtSi+k0tsZUvMoU27tBZJElchYt7JH/Ai/XzYAI9lCAaV7u5HZSI8J5Z+wQ==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.25.0", + "flow-enums-runtime": "^0.0.6" + }, + "engines": { + "node": ">=20.19.4" + } + }, + "node_modules/metro-source-map": { + "version": "0.83.7", + "resolved": "https://registry.npmjs.org/metro-source-map/-/metro-source-map-0.83.7.tgz", + "integrity": "sha512-JgA1h7oc1a1jydBe1GhVFsUoMYo3wLPk7oRA32rjlDsq+sP2JLt9x2p2lWbNSxTm/u8NV4VRid3hvEJgcX8tKw==", + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.29.0", + "@babel/types": "^7.29.0", + "flow-enums-runtime": "^0.0.6", + "invariant": "^2.2.4", + "metro-symbolicate": "0.83.7", + "nullthrows": "^1.1.1", + "ob1": "0.83.7", + "source-map": "^0.5.6", + "vlq": "^1.0.0" + }, + "engines": { + "node": ">=20.19.4" + } + }, + "node_modules/metro-symbolicate": { + "version": "0.83.7", + "resolved": "https://registry.npmjs.org/metro-symbolicate/-/metro-symbolicate-0.83.7.tgz", + "integrity": "sha512-g4suyxw20WOHWI680c+Kq4wC/NF+Hx5pRH9afrMp+sMTxqLeKcPR1Xf4wMhsjlbvx7LbIREdke6q928jEjvJWw==", + "license": "MIT", + "dependencies": { + "flow-enums-runtime": "^0.0.6", + "invariant": "^2.2.4", + "metro-source-map": "0.83.7", + "nullthrows": "^1.1.1", + "source-map": "^0.5.6", + "vlq": "^1.0.0" + }, + "bin": { + "metro-symbolicate": "src/index.js" + }, + "engines": { + "node": ">=20.19.4" + } + }, + "node_modules/metro-transform-plugins": { + "version": "0.83.7", + "resolved": "https://registry.npmjs.org/metro-transform-plugins/-/metro-transform-plugins-0.83.7.tgz", + "integrity": "sha512-Ss0FpBiZDjX2kwhukMDl5sNdYK8T/06IPqxNE4H6PTlRlfs9q11cef13c/xESY/Pm4VCkp1yJUZO3kXzvMxQFA==", + "license": "MIT", + "dependencies": { + "@babel/core": "^7.25.2", + "@babel/generator": "^7.29.1", + "@babel/template": "^7.28.6", + "@babel/traverse": "^7.29.0", + "flow-enums-runtime": "^0.0.6", + "nullthrows": "^1.1.1" + }, + "engines": { + "node": ">=20.19.4" + } + }, + "node_modules/metro-transform-worker": { + "version": "0.83.7", + "resolved": "https://registry.npmjs.org/metro-transform-worker/-/metro-transform-worker-0.83.7.tgz", + "integrity": "sha512-UegCo7ygB2fT64mRK2nbAjQVJ1zSwIIHy8d96jJv2nKZFDaViYBiughEdu5HM/Ceq0WN3LZrZk3zhl9aoiLYFw==", + "license": "MIT", + "dependencies": { + "@babel/core": "^7.25.2", + "@babel/generator": "^7.29.1", + "@babel/parser": "^7.29.0", + "@babel/types": "^7.29.0", + "flow-enums-runtime": "^0.0.6", + "metro": "0.83.7", + "metro-babel-transformer": "0.83.7", + "metro-cache": "0.83.7", + "metro-cache-key": "0.83.7", + "metro-minify-terser": "0.83.7", + "metro-source-map": "0.83.7", + "metro-transform-plugins": "0.83.7", + "nullthrows": "^1.1.1" + }, + "engines": { + "node": ">=20.19.4" + } + }, + "node_modules/metro/node_modules/accepts": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", + "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", + "license": "MIT", + "dependencies": { + "mime-types": "^3.0.0", + "negotiator": "^1.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/metro/node_modules/ci-info": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-2.0.0.tgz", + "integrity": "sha512-5tK7EtrZ0N+OLFMthtqOj4fI2Jeb88C4CAZPu25LDVUgXJ0A3Js4PMGqrn0JU1W0Mh1/Z8wZzYPxqUrXeBboCQ==", + "license": "MIT" + }, + "node_modules/metro/node_modules/hermes-estree": { + "version": "0.35.0", + "resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.35.0.tgz", + "integrity": "sha512-xVx5Opwy8Oo1I5yGpVRhCvWL/iV3M+ylksSKVNlxxD90cpDpR/AR1jLYqK8HWihm065a6UI3HeyAmYzwS8NOOg==", + "license": "MIT" + }, + "node_modules/metro/node_modules/hermes-parser": { + "version": "0.35.0", + "resolved": "https://registry.npmjs.org/hermes-parser/-/hermes-parser-0.35.0.tgz", + "integrity": "sha512-9JLjeHxBx8T4CAsydZR49PNZUaix+WpQJwu9p2010lu+7Kwl6D/7wYFFJxoz+aXkaaClp9Zfg6W6/zVlSJORaA==", + "license": "MIT", + "dependencies": { + "hermes-estree": "0.35.0" + } + }, + "node_modules/metro/node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/metro/node_modules/mime-types": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/metro/node_modules/negotiator": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", + "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "license": "MIT", + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/micromatch/node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "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==", + "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==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mimic-fn": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-1.2.0.tgz", + "integrity": "sha512-jf84uxzwiuiIVKiOLpfYk7N46TSy8ubTonmneY9vrpHNAnp0QBt2BxWV9dO3/j+BoVAb+a5G6YDPW3M5HOdMWQ==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/mimic-response": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-1.0.1.tgz", + "integrity": "sha512-j5EctnkH7amfV/q5Hgmoal1g2QHFJRraOtmx0JpIqkxhBhI/lJSl1nMpQ45hVarwNETOoWEimndZ4QK0RHxuxQ==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/minipass": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/minizlib": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-3.1.0.tgz", + "integrity": "sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw==", + "license": "MIT", + "dependencies": { + "minipass": "^7.1.2" + }, + "engines": { + "node": ">= 18" + } + }, + "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/mz": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz", + "integrity": "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==", + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0", + "object-assign": "^4.0.1", + "thenify-all": "^1.0.0" + } + }, + "node_modules/nanoid": { + "version": "3.3.12", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", + "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", + "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/negotiator": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", + "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/nested-error-stacks": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/nested-error-stacks/-/nested-error-stacks-2.0.1.tgz", + "integrity": "sha512-SrQrok4CATudVzBS7coSz26QRSmlK9TzzoFbeKfcPBUFPjcQM9Rqvr/DlJkOrwI/0KcgvMub1n1g5Jt9EgRn4A==", + "license": "MIT" + }, + "node_modules/node-forge": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/node-forge/-/node-forge-1.4.0.tgz", + "integrity": "sha512-LarFH0+6VfriEhqMMcLX2F7SwSXeWwnEAJEsYm5QKWchiVYVvJyV9v7UDvUv+w5HO23ZpQTXDv/GxdDdMyOuoQ==", + "license": "(BSD-3-Clause OR GPL-2.0)", + "engines": { + "node": ">= 6.13.0" + } + }, + "node_modules/node-int64": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz", + "integrity": "sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw==", + "license": "MIT" + }, + "node_modules/node-releases": { + "version": "2.0.47", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.47.tgz", + "integrity": "sha512-Uzmd6LXpouKo8EUK68IjH4+E01w/hXyV3R3g/geCJo+rXLNfh1xucB+LOzYEOQPSiUK3h/xZf0cQGcSsmyL2Og==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/normalize-url": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/normalize-url/-/normalize-url-6.1.0.tgz", + "integrity": "sha512-DlL+XwOy3NxAQ8xuC0okPgK46iuVNAK01YN7RueYBqqFeGsBjV9XmCAzAdgt+667bCl5kPh9EqKKDwnaPG1I7A==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/npm-package-arg": { + "version": "11.0.3", + "resolved": "https://registry.npmjs.org/npm-package-arg/-/npm-package-arg-11.0.3.tgz", + "integrity": "sha512-sHGJy8sOC1YraBywpzQlIKBE4pBbGbiF95U6Auspzyem956E0+FtDtsx1ZxlOJkQCZ1AFXAY/yuvtFYrOxF+Bw==", + "license": "ISC", + "dependencies": { + "hosted-git-info": "^7.0.0", + "proc-log": "^4.0.0", + "semver": "^7.3.5", + "validate-npm-package-name": "^5.0.0" + }, + "engines": { + "node": "^16.14.0 || >=18.0.0" + } + }, + "node_modules/npm-package-arg/node_modules/semver": { + "version": "7.8.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.4.tgz", + "integrity": "sha512-rUCObTnP32Q08R2uuIrt7r9PlEonuTmtuXYcW6s5kjdlj3xbnwe+21yXptAUYcMAABLkYYTtnmzb3w3EDZfueA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/nth-check": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz", + "integrity": "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==", + "license": "BSD-2-Clause", + "dependencies": { + "boolbase": "^1.0.0" + }, + "funding": { + "url": "https://github.com/fb55/nth-check?sponsor=1" + } + }, + "node_modules/nullthrows": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/nullthrows/-/nullthrows-1.1.1.tgz", + "integrity": "sha512-2vPPEi+Z7WqML2jZYddDIfy5Dqb0r2fze2zTxNNknZaFpVHU3mFB3R+DWeJWGVx0ecvttSGlJTI+WG+8Z4cDWw==", + "license": "MIT" + }, + "node_modules/ob1": { + "version": "0.83.7", + "resolved": "https://registry.npmjs.org/ob1/-/ob1-0.83.7.tgz", + "integrity": "sha512-9M5kpuOLyTPogMtZiQUIxdAZxl7Dxs6tVBbJErSumsqGMuhVSoUbkfeZ3XNPpLpwBBtqY5QDUzGwggLHX3slQg==", + "license": "MIT", + "dependencies": { + "flow-enums-runtime": "^0.0.6" + }, + "engines": { + "node": ">=20.19.4" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/on-finished": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.3.0.tgz", + "integrity": "sha512-ikqdkGAAyf/X/gPhXGvfgAytDZtDbr+bkNUJ0N9h5MI/dmdgCs3l6hoHrcUv41sRKew3jIwrp4qQDXiK99Utww==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/on-headers": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/on-headers/-/on-headers-1.1.0.tgz", + "integrity": "sha512-737ZY3yNnXy37FHkQxPzt4UZ2UWPWiCZWLvFZ4fu5cueciegX0zGPnrlY6bwRg4FdQOe9YU8MkmJwGhoMybl8A==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/onetime": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-2.0.1.tgz", + "integrity": "sha512-oyyPpiMaKARvvcgip+JV+7zci5L8D1W9RZIz2l1o08AM3pfspitVWnPt3mzHcBPp12oYMTy0pqrFs/C+m3EwsQ==", + "license": "MIT", + "dependencies": { + "mimic-fn": "^1.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/open": { + "version": "7.4.2", + "resolved": "https://registry.npmjs.org/open/-/open-7.4.2.tgz", + "integrity": "sha512-MVHddDVweXZF3awtlAS+6pgKLlm/JgxZ90+/NBurBoQctVOOB/zDdVjcyPzQ+0laDGbsWgrRkflI65sQeOgT9Q==", + "license": "MIT", + "dependencies": { + "is-docker": "^2.0.0", + "is-wsl": "^2.1.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ora": { + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/ora/-/ora-3.4.0.tgz", + "integrity": "sha512-eNwHudNbO1folBP3JsZ19v9azXWtQZjICdr3Q0TDPIaeBQ3mXLrh54wM+er0+hSp+dWKf+Z8KM58CYzEyIYxYg==", + "license": "MIT", + "dependencies": { + "chalk": "^2.4.2", + "cli-cursor": "^2.1.0", + "cli-spinners": "^2.0.0", + "log-symbols": "^2.2.0", + "strip-ansi": "^5.2.0", + "wcwidth": "^1.0.1" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/ora/node_modules/ansi-styles": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz", + "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", + "license": "MIT", + "dependencies": { + "color-convert": "^1.9.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/ora/node_modules/chalk": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz", + "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^3.2.1", + "escape-string-regexp": "^1.0.5", + "supports-color": "^5.3.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/ora/node_modules/color-convert": { + "version": "1.9.3", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", + "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==", + "license": "MIT", + "dependencies": { + "color-name": "1.1.3" + } + }, + "node_modules/ora/node_modules/color-name": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz", + "integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==", + "license": "MIT" + }, + "node_modules/ora/node_modules/escape-string-regexp": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", + "integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==", + "license": "MIT", + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/ora/node_modules/has-flag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", + "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/ora/node_modules/supports-color": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", + "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", + "license": "MIT", + "dependencies": { + "has-flag": "^3.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/p-cancelable": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/p-cancelable/-/p-cancelable-2.1.1.tgz", + "integrity": "sha512-BZOr3nRQHOntUjTrH8+Lh54smKHoHyur8We1V8DSMVrl5A2malOOwuJRnKRDjSnkoeBh4at6BwEnb5I7Jl31wg==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", + "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", + "license": "MIT", + "dependencies": { + "p-limit": "^2.2.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/p-locate/node_modules/p-limit": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", + "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "license": "MIT", + "dependencies": { + "p-try": "^2.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-try": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", + "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/parse-bmfont-ascii": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/parse-bmfont-ascii/-/parse-bmfont-ascii-1.0.6.tgz", + "integrity": "sha512-U4RrVsUFCleIOBsIGYOMKjn9PavsGOXxbvYGtMOEfnId0SVNsgehXh1DxUdVPLoxd5mvcEtvmKs2Mmf0Mpa1ZA==", + "license": "MIT" + }, + "node_modules/parse-png": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/parse-png/-/parse-png-2.1.0.tgz", + "integrity": "sha512-Nt/a5SfCLiTnQAjx3fHlqp8hRgTL3z7kTQZzvIMS9uCAepnCyjpdEc6M/sz69WqMBdaDBw9sF1F1UaHROYzGkQ==", + "license": "MIT", + "dependencies": { + "pngjs": "^3.3.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-is-absolute": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", + "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "license": "MIT" + }, + "node_modules/path-scurry": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.2.tgz", + "integrity": "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==", + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^11.0.0", + "minipass": "^7.1.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/path-scurry/node_modules/lru-cache": { + "version": "11.5.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.1.tgz", + "integrity": "sha512-RPimw/7aMdv2oqRrxKwvZXcPfwBrn/JZ2xYcY9Hus/6LaS3VOAKVWKWgNLCFSiOm1ESXinjsDlidVU7JlnCN2A==", + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/paths-js": { + "version": "0.4.11", + "resolved": "https://registry.npmjs.org/paths-js/-/paths-js-0.4.11.tgz", + "integrity": "sha512-3mqcLomDBXOo7Fo+UlaenG6f71bk1ZezPQy2JCmYHy2W2k5VKpP+Jbin9H0bjXynelTbglCqdFhSEkeIkKTYUA==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.11.0" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pirates": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz", + "integrity": "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==", + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/plist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/plist/-/plist-3.1.1.tgz", + "integrity": "sha512-ZIfcLJC+7E7FBFnDxm9MPmt7D+DidyQ26lewieO75AdhA2ayMtsJSES0iWzqJQbcVRSrTufQoy0DR94xHue0oA==", + "license": "MIT", + "dependencies": { + "@xmldom/xmldom": "^0.9.10", + "base64-js": "^1.5.1", + "xmlbuilder": "^15.1.1" + }, + "engines": { + "node": ">=10.4.0" + } + }, + "node_modules/plist/node_modules/@xmldom/xmldom": { + "version": "0.9.10", + "resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.9.10.tgz", + "integrity": "sha512-A9gOqLdi6cV4ibazAjcQufGj0B1y/vDqYrcuP6d/6x8P27gRS8643Dj9o1dEKtB6O7fwxb2FgBmJS2mX7gpvdw==", + "license": "MIT", + "engines": { + "node": ">=14.6" + } + }, + "node_modules/pngjs": { + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/pngjs/-/pngjs-3.4.0.tgz", + "integrity": "sha512-NCrCHhWmnQklfH4MtJMRjZ2a8c80qXeMlQMv2uVp9ISJMTt562SbGd6n2oq0PaPgKm7Z6pL9E2UlLIhC+SHL3w==", + "license": "MIT", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/point-in-polygon": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/point-in-polygon/-/point-in-polygon-1.1.0.tgz", + "integrity": "sha512-3ojrFwjnnw8Q9242TzgXuTD+eKiutbzyslcq1ydfu82Db2y+Ogbmyrkpv0Hgj31qwT3lbS9+QAAO/pIQM35XRw==", + "license": "MIT" + }, + "node_modules/postcss": { + "version": "8.4.49", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.49.tgz", + "integrity": "sha512-OCVPnIObs4N29kxTjzLfUryOkvZEq+pf8jTF0lg8E7uETuWHA+v7j3c/xJmiqpX450191LlmZfUKkXxkTry7nA==", + "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.7", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/pretty-bytes": { + "version": "5.6.0", + "resolved": "https://registry.npmjs.org/pretty-bytes/-/pretty-bytes-5.6.0.tgz", + "integrity": "sha512-FFw039TmrBqFK8ma/7OL3sDz/VytdtJr044/QUJtH0wK9lb9jLq9tJyIxUwtQJHwar2BqtiA4iCWSwo9JLkzFg==", + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/proc-log": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/proc-log/-/proc-log-4.2.0.tgz", + "integrity": "sha512-g8+OnU/L2v+wyiVK+D5fA34J7EH8jZ8DDlvwhRCMxmMj7UCBvxiO1mGeN+36JXIKF4zevU4kRBd8lVgG9vLelA==", + "license": "ISC", + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/progress": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/progress/-/progress-2.0.3.tgz", + "integrity": "sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/promise": { + "version": "7.3.1", + "resolved": "https://registry.npmjs.org/promise/-/promise-7.3.1.tgz", + "integrity": "sha512-nolQXZ/4L+bP/UGlkfaIujX9BKxGwmQ9OT4mOt5yvy8iK1h3wqTEJCijzGANTCCl9nWjY41juyAn2K3Q1hLLTg==", + "license": "MIT", + "dependencies": { + "asap": "~2.0.3" + } + }, + "node_modules/prompts": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/prompts/-/prompts-2.4.2.tgz", + "integrity": "sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q==", + "license": "MIT", + "dependencies": { + "kleur": "^3.0.3", + "sisteransi": "^1.0.5" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/pump": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz", + "integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==", + "license": "MIT", + "dependencies": { + "end-of-stream": "^1.1.0", + "once": "^1.3.1" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/qrcode-terminal": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/qrcode-terminal/-/qrcode-terminal-0.11.0.tgz", + "integrity": "sha512-Uu7ii+FQy4Qf82G4xu7ShHhjhGahEpCWc3x8UavY3CTcWV+ufmmCtwkr7ZKsX42jdL0kr1B5FKUeqJvAn51jzQ==", + "bin": { + "qrcode-terminal": "bin/qrcode-terminal.js" + } + }, + "node_modules/query-string": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/query-string/-/query-string-7.1.3.tgz", + "integrity": "sha512-hh2WYhq4fi8+b+/2Kg9CEge4fDPvHS534aOOvOZeQ3+Vf2mCFsaFBYj0i+iXcAq6I9Vzp5fjMFBlONvayDC1qg==", + "license": "MIT", + "dependencies": { + "decode-uri-component": "^0.2.2", + "filter-obj": "^1.1.0", + "split-on-first": "^1.0.0", + "strict-uri-encode": "^2.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/queue": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/queue/-/queue-6.0.2.tgz", + "integrity": "sha512-iHZWu+q3IdFZFX36ro/lKBkSvfkztY5Y7HMiPlOUjhupPcG2JMfst2KKEpu5XndviX/3UhFbRngUPNKtgvtZiA==", + "license": "MIT", + "dependencies": { + "inherits": "~2.0.3" + } + }, + "node_modules/quick-lru": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/quick-lru/-/quick-lru-5.1.1.tgz", + "integrity": "sha512-WuyALRjWPDGtt/wzJiadO5AXY+8hZ80hVpe6MyivgraREW751X3SbhRvG3eLKOYN+8VEvqLcf3wdnt44Z4S4SA==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/rc": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz", + "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==", + "license": "(BSD-2-Clause OR MIT OR Apache-2.0)", + "dependencies": { + "deep-extend": "^0.6.0", + "ini": "~1.3.0", + "minimist": "^1.2.0", + "strip-json-comments": "~2.0.1" + }, + "bin": { + "rc": "cli.js" + } + }, + "node_modules/react": { + "version": "19.1.0", + "resolved": "https://registry.npmjs.org/react/-/react-19.1.0.tgz", + "integrity": "sha512-FS+XFBNvn3GTAWq26joslQgWNoFu08F4kl0J4CgdNKADkdSGXQyTCnKteIAJy96Br6YbpEU1LSzV5dYtjMkMDg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-devtools-core": { + "version": "6.1.5", + "resolved": "https://registry.npmjs.org/react-devtools-core/-/react-devtools-core-6.1.5.tgz", + "integrity": "sha512-ePrwPfxAnB+7hgnEr8vpKxL9cmnp7F322t8oqcPshbIQQhDKgFDW4tjhF2wjVbdXF9O/nyuy3sQWd9JGpiLPvA==", + "license": "MIT", + "dependencies": { + "shell-quote": "^1.6.1", + "ws": "^7" + } + }, + "node_modules/react-freeze": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/react-freeze/-/react-freeze-1.0.4.tgz", + "integrity": "sha512-r4F0Sec0BLxWicc7HEyo2x3/2icUTrRmDjaaRyzzn+7aDyFZliszMDOgLVwSnQnYENOlL1o569Ze2HZefk8clA==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "react": ">=17.0.0" + } + }, + "node_modules/react-is": { + "version": "16.13.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", + "license": "MIT" + }, + "node_modules/react-native": { + "version": "0.81.5", + "resolved": "https://registry.npmjs.org/react-native/-/react-native-0.81.5.tgz", + "integrity": "sha512-1w+/oSjEXZjMqsIvmkCRsOc8UBYv163bTWKTI8+1mxztvQPhCRYGTvZ/PL1w16xXHneIj/SLGfxWg2GWN2uexw==", + "license": "MIT", + "dependencies": { + "@jest/create-cache-key-function": "^29.7.0", + "@react-native/assets-registry": "0.81.5", + "@react-native/codegen": "0.81.5", + "@react-native/community-cli-plugin": "0.81.5", + "@react-native/gradle-plugin": "0.81.5", + "@react-native/js-polyfills": "0.81.5", + "@react-native/normalize-colors": "0.81.5", + "@react-native/virtualized-lists": "0.81.5", + "abort-controller": "^3.0.0", + "anser": "^1.4.9", + "ansi-regex": "^5.0.0", + "babel-jest": "^29.7.0", + "babel-plugin-syntax-hermes-parser": "0.29.1", + "base64-js": "^1.5.1", + "commander": "^12.0.0", + "flow-enums-runtime": "^0.0.6", + "glob": "^7.1.1", + "invariant": "^2.2.4", + "jest-environment-node": "^29.7.0", + "memoize-one": "^5.0.0", + "metro-runtime": "^0.83.1", + "metro-source-map": "^0.83.1", + "nullthrows": "^1.1.1", + "pretty-format": "^29.7.0", + "promise": "^8.3.0", + "react-devtools-core": "^6.1.5", + "react-refresh": "^0.14.0", + "regenerator-runtime": "^0.13.2", + "scheduler": "0.26.0", + "semver": "^7.1.3", + "stacktrace-parser": "^0.1.10", + "whatwg-fetch": "^3.0.0", + "ws": "^6.2.3", + "yargs": "^17.6.2" + }, + "bin": { + "react-native": "cli.js" + }, + "engines": { + "node": ">= 20.19.4" + }, + "peerDependencies": { + "@types/react": "^19.1.0", + "react": "^19.1.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/react-native-chart-kit": { + "version": "6.12.3", + "resolved": "https://registry.npmjs.org/react-native-chart-kit/-/react-native-chart-kit-6.12.3.tgz", + "integrity": "sha512-Wc7akObFaa7wAF42JGSvmd25OUpngLy412aIgJIjkmOMy3jBoPEX/yu8OPiSV3F2bSliWqlAcaqOrxuKIJy5Iw==", + "license": "MIT", + "dependencies": { + "paths-js": "^0.4.10", + "point-in-polygon": "^1.0.1" + }, + "peerDependencies": { + "react": "> 16.7.0", + "react-native": ">= 0.50.0", + "react-native-svg": "> 6.4.1" + } + }, + "node_modules/react-native-gesture-handler": { + "version": "2.28.0", + "resolved": "https://registry.npmjs.org/react-native-gesture-handler/-/react-native-gesture-handler-2.28.0.tgz", + "integrity": "sha512-0msfJ1vRxXKVgTgvL+1ZOoYw3/0z1R+Ked0+udoJhyplC2jbVKIJ8Z1bzWdpQRCV3QcQ87Op0zJVE5DhKK2A0A==", + "license": "MIT", + "dependencies": { + "@egjs/hammerjs": "^2.0.17", + "hoist-non-react-statics": "^3.3.0", + "invariant": "^2.2.4" + }, + "peerDependencies": { + "react": "*", + "react-native": "*" + } + }, + "node_modules/react-native-is-edge-to-edge": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/react-native-is-edge-to-edge/-/react-native-is-edge-to-edge-1.3.1.tgz", + "integrity": "sha512-NIXU/iT5+ORyCc7p0z2nnlkouYKX425vuU1OEm6bMMtWWR9yvb+Xg5AZmImTKoF9abxCPqrKC3rOZsKzUYgYZA==", + "license": "MIT", + "peerDependencies": { + "react": "*", + "react-native": "*" + } + }, + "node_modules/react-native-reanimated": { + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/react-native-reanimated/-/react-native-reanimated-4.1.7.tgz", + "integrity": "sha512-Q4H6xA3Tn7QL0/E/KjI86I1KK4tcf+ErRE04LH34Etka2oVQhW6oXQ+Q8ZcDCVxiWp5vgbBH6XcH8BOo4w/Rhg==", + "license": "MIT", + "dependencies": { + "react-native-is-edge-to-edge": "^1.2.1", + "semver": "^7.7.2" + }, + "peerDependencies": { + "react": "*", + "react-native": "0.78 - 0.82", + "react-native-worklets": "0.5 - 0.8" + } + }, + "node_modules/react-native-reanimated/node_modules/semver": { + "version": "7.8.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.4.tgz", + "integrity": "sha512-rUCObTnP32Q08R2uuIrt7r9PlEonuTmtuXYcW6s5kjdlj3xbnwe+21yXptAUYcMAABLkYYTtnmzb3w3EDZfueA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/react-native-safe-area-context": { + "version": "5.6.2", + "resolved": "https://registry.npmjs.org/react-native-safe-area-context/-/react-native-safe-area-context-5.6.2.tgz", + "integrity": "sha512-4XGqMNj5qjUTYywJqpdWZ9IG8jgkS3h06sfVjfw5yZQZfWnRFXczi0GnYyFyCc2EBps/qFmoCH8fez//WumdVg==", + "license": "MIT", + "peerDependencies": { + "react": "*", + "react-native": "*" + } + }, + "node_modules/react-native-screens": { + "version": "4.16.0", + "resolved": "https://registry.npmjs.org/react-native-screens/-/react-native-screens-4.16.0.tgz", + "integrity": "sha512-yIAyh7F/9uWkOzCi1/2FqvNvK6Wb9Y1+Kzn16SuGfN9YFJDTbwlzGRvePCNTOX0recpLQF3kc2FmvMUhyTCH1Q==", + "license": "MIT", + "dependencies": { + "react-freeze": "^1.0.0", + "react-native-is-edge-to-edge": "^1.2.1", + "warn-once": "^0.1.0" + }, + "peerDependencies": { + "react": "*", + "react-native": "*" + } + }, + "node_modules/react-native-svg": { + "version": "15.12.1", + "resolved": "https://registry.npmjs.org/react-native-svg/-/react-native-svg-15.12.1.tgz", + "integrity": "sha512-vCuZJDf8a5aNC2dlMovEv4Z0jjEUET53lm/iILFnFewa15b4atjVxU6Wirm6O9y6dEsdjDZVD7Q3QM4T1wlI8g==", + "license": "MIT", + "dependencies": { + "css-select": "^5.1.0", + "css-tree": "^1.1.3", + "warn-once": "0.1.1" + }, + "peerDependencies": { + "react": "*", + "react-native": "*" + } + }, + "node_modules/react-native-worklets": { + "version": "0.5.1", + "resolved": "https://registry.npmjs.org/react-native-worklets/-/react-native-worklets-0.5.1.tgz", + "integrity": "sha512-lJG6Uk9YuojjEX/tQrCbcbmpdLCSFxDK1rJlkDhgqkVi1KZzG7cdcBFQRqyNOOzR9Y0CXNuldmtWTGOyM0k0+w==", + "license": "MIT", + "dependencies": { + "@babel/plugin-transform-arrow-functions": "^7.0.0-0", + "@babel/plugin-transform-class-properties": "^7.0.0-0", + "@babel/plugin-transform-classes": "^7.0.0-0", + "@babel/plugin-transform-nullish-coalescing-operator": "^7.0.0-0", + "@babel/plugin-transform-optional-chaining": "^7.0.0-0", + "@babel/plugin-transform-shorthand-properties": "^7.0.0-0", + "@babel/plugin-transform-template-literals": "^7.0.0-0", + "@babel/plugin-transform-unicode-regex": "^7.0.0-0", + "@babel/preset-typescript": "^7.16.7", + "convert-source-map": "^2.0.0", + "semver": "7.7.2" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0", + "react": "*", + "react-native": "*" + } + }, + "node_modules/react-native-worklets/node_modules/semver": { + "version": "7.7.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz", + "integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/react-native/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/react-native/node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/react-native/node_modules/commander": { + "version": "12.1.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-12.1.0.tgz", + "integrity": "sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/react-native/node_modules/pretty-format": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-29.7.0.tgz", + "integrity": "sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==", + "license": "MIT", + "dependencies": { + "@jest/schemas": "^29.6.3", + "ansi-styles": "^5.0.0", + "react-is": "^18.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/react-native/node_modules/promise": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/promise/-/promise-8.3.0.tgz", + "integrity": "sha512-rZPNPKTOYVNEEKFaq1HqTgOwZD+4/YHS5ukLzQCypkj+OkYx7iv0mA91lJlpPPZ8vMau3IIGj5Qlwrx+8iiSmg==", + "license": "MIT", + "dependencies": { + "asap": "~2.0.6" + } + }, + "node_modules/react-native/node_modules/react-is": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", + "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", + "license": "MIT" + }, + "node_modules/react-native/node_modules/semver": { + "version": "7.8.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.4.tgz", + "integrity": "sha512-rUCObTnP32Q08R2uuIrt7r9PlEonuTmtuXYcW6s5kjdlj3xbnwe+21yXptAUYcMAABLkYYTtnmzb3w3EDZfueA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/react-native/node_modules/ws": { + "version": "6.2.4", + "resolved": "https://registry.npmjs.org/ws/-/ws-6.2.4.tgz", + "integrity": "sha512-PNIUUyLI5YpkJZj60YBzX1o0ByQ4ovvfmq9N/Kig/PAYbVlGyz4R6G0SEWrD0O9acc0sT2+IdMBVLFv8FSi0Nw==", + "license": "MIT", + "dependencies": { + "async-limiter": "~1.0.0" + } + }, + "node_modules/react-refresh": { + "version": "0.14.2", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.14.2.tgz", + "integrity": "sha512-jCvmsr+1IUSMUyzOkRcvnVbX3ZYC6g9TDrDbFuFmRDq7PD4yaGbLKNQL6k2jnArV8hjYxh7hVhAZB6s9HDGpZA==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/regenerate": { + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/regenerate/-/regenerate-1.4.2.tgz", + "integrity": "sha512-zrceR/XhGYU/d/opr2EKO7aRHUeiBI8qjtfHqADTwZd6Szfy16la6kqD0MIUs5z5hx6AaKa+PixpPrR289+I0A==", + "license": "MIT" + }, + "node_modules/regenerate-unicode-properties": { + "version": "10.2.2", + "resolved": "https://registry.npmjs.org/regenerate-unicode-properties/-/regenerate-unicode-properties-10.2.2.tgz", + "integrity": "sha512-m03P+zhBeQd1RGnYxrGyDAPpWX/epKirLrp8e3qevZdVkKtnCrjjWczIbYc8+xd6vcTStVlqfycTx1KR4LOr0g==", + "license": "MIT", + "dependencies": { + "regenerate": "^1.4.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/regenerator-runtime": { + "version": "0.13.11", + "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.11.tgz", + "integrity": "sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==", + "license": "MIT" + }, + "node_modules/regexpu-core": { + "version": "6.4.0", + "resolved": "https://registry.npmjs.org/regexpu-core/-/regexpu-core-6.4.0.tgz", + "integrity": "sha512-0ghuzq67LI9bLXpOX/ISfve/Mq33a4aFRzoQYhnnok1JOFpmE/A2TBGkNVenOGEeSBCjIiWcc6MVOG5HEQv0sA==", + "license": "MIT", + "dependencies": { + "regenerate": "^1.4.2", + "regenerate-unicode-properties": "^10.2.2", + "regjsgen": "^0.8.0", + "regjsparser": "^0.13.0", + "unicode-match-property-ecmascript": "^2.0.0", + "unicode-match-property-value-ecmascript": "^2.2.1" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/regjsgen": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/regjsgen/-/regjsgen-0.8.0.tgz", + "integrity": "sha512-RvwtGe3d7LvWiDQXeQw8p5asZUmfU1G/l6WbUXeHta7Y2PEIvBTwH6E2EfmYUK8pxcxEdEmaomqyp0vZZ7C+3Q==", + "license": "MIT" + }, + "node_modules/regjsparser": { + "version": "0.13.2", + "resolved": "https://registry.npmjs.org/regjsparser/-/regjsparser-0.13.2.tgz", + "integrity": "sha512-NgRBy2Nx/bE+9F27nVHnqcN5HjyLmecqsqx2PJHu3/IEtADD4WuxuXIVExD5PoSDFVrl78dOonfcOe5O+5nbzQ==", + "license": "BSD-2-Clause", + "dependencies": { + "jsesc": "~3.1.0" + }, + "bin": { + "regjsparser": "bin/parser" + } + }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/requireg": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/requireg/-/requireg-0.2.2.tgz", + "integrity": "sha512-nYzyjnFcPNGR3lx9lwPPPnuQxv6JWEZd2Ci0u9opN7N5zUEPIhY/GbL3vMGOr2UXwEg9WwSyV9X9Y/kLFgPsOg==", + "dependencies": { + "nested-error-stacks": "~2.0.1", + "rc": "~1.2.7", + "resolve": "~1.7.1" + }, + "engines": { + "node": ">= 4.0.0" + } + }, + "node_modules/requireg/node_modules/resolve": { + "version": "1.7.1", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.7.1.tgz", + "integrity": "sha512-c7rwLofp8g1U+h1KNyHL/jicrKg1Ek4q+Lr33AL65uZTinUZHe30D5HlyN5V9NW0JX1D5dXQ4jqW5l7Sy/kGfw==", + "license": "MIT", + "dependencies": { + "path-parse": "^1.0.5" + } + }, + "node_modules/resolve": { + "version": "1.22.12", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz", + "integrity": "sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "is-core-module": "^2.16.1", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/resolve-alpn": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/resolve-alpn/-/resolve-alpn-1.2.1.tgz", + "integrity": "sha512-0a1F4l73/ZFZOakJnQ3FvkJ2+gSTQWz/r2KE5OdDY0TxPm5h4GkqkWWfM47T7HsbnOtcJVEF4epCVy6u7Q3K+g==", + "license": "MIT" + }, + "node_modules/resolve-from": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", + "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/resolve-workspace-root": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/resolve-workspace-root/-/resolve-workspace-root-2.0.1.tgz", + "integrity": "sha512-nR23LHAvaI6aHtMg6RWoaHpdR4D881Nydkzi2CixINyg9T00KgaJdJI6Vwty+Ps8WLxZHuxsS0BseWjxSA4C+w==", + "license": "MIT" + }, + "node_modules/resolve.exports": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/resolve.exports/-/resolve.exports-2.0.3.tgz", + "integrity": "sha512-OcXjMsGdhL4XnbShKpAcSqPMzQoYkYyhbEaeSko47MjRP9NfEQMhZkXL1DoFlt9LWQn4YttrdnV6X2OiyzBi+A==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/responselike": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/responselike/-/responselike-2.0.1.tgz", + "integrity": "sha512-4gl03wn3hj1HP3yzgdI7d3lCkF95F21Pz4BPGvKHinyQzALR5CapwC8yIi0Rh58DEMQ/SguC03wFj2k0M/mHhw==", + "license": "MIT", + "dependencies": { + "lowercase-keys": "^2.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/restore-cursor": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-2.0.0.tgz", + "integrity": "sha512-6IzJLuGi4+R14vwagDHX+JrXmPVtPpn4mffDJ1UdR7/Edm87fl6yi8mMBIVvFtJaNTUvjughmW4hwLhRG7gC1Q==", + "license": "MIT", + "dependencies": { + "onetime": "^2.0.0", + "signal-exit": "^3.0.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/rimraf": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", + "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", + "deprecated": "Rimraf versions prior to v4 are no longer supported", + "license": "ISC", + "dependencies": { + "glob": "^7.1.3" + }, + "bin": { + "rimraf": "bin.js" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "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==", + "license": "MIT" + }, + "node_modules/sax": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/sax/-/sax-1.6.0.tgz", + "integrity": "sha512-6R3J5M4AcbtLUdZmRv2SygeVaM7IhrLXu9BmnOGmmACak8fiUtOsYNWUS4uK7upbmHIBbLBeFeI//477BKLBzA==", + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=11.0.0" + } + }, + "node_modules/scheduler": { + "version": "0.26.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.26.0.tgz", + "integrity": "sha512-NlHwttCI/l5gCPR3D1nNXtWABUmBwvZpEQiD4IXSbIDq8BzLIK/7Ir5gTFSGZDUu37K5cMNp0hFtzO38sC7gWA==", + "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==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/send": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz", + "integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "1.2.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.1", + "mime": "1.6.0", + "ms": "2.1.3", + "on-finished": "~2.4.1", + "range-parser": "~1.2.1", + "statuses": "~2.0.2" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/send/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/send/node_modules/debug/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/send/node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/send/node_modules/mime": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", + "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/send/node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/send/node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/serialize-error": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/serialize-error/-/serialize-error-2.1.0.tgz", + "integrity": "sha512-ghgmKt5o4Tly5yEG/UJp8qTd0AN7Xalw4XBtDEKP655B699qMEtra1WlXeE6WIvdEG481JvRxULKsInq/iNysw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/serve-static": { + "version": "1.16.3", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz", + "integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==", + "license": "MIT", + "dependencies": { + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "parseurl": "~1.3.3", + "send": "~0.19.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/serve-static/node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "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/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/shell-quote": { + "version": "1.8.4", + "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.4.tgz", + "integrity": "sha512-VsC6n6vz1ihYYyZZwX7YZSF5l5x36ca17OC+a69h94YqB7X6XLwf+5MOgynYir2SLFUbl8gIYvBo8K8RoNQ6bQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/signal-exit": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "license": "ISC" + }, + "node_modules/simple-plist": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/simple-plist/-/simple-plist-1.3.1.tgz", + "integrity": "sha512-iMSw5i0XseMnrhtIzRb7XpQEXepa9xhWxGUojHBL43SIpQuDQkh3Wpy67ZbDzZVr6EKxvwVChnVpdl8hEVLDiw==", + "license": "MIT", + "dependencies": { + "bplist-creator": "0.1.0", + "bplist-parser": "0.3.1", + "plist": "^3.0.5" + } + }, + "node_modules/simple-plist/node_modules/bplist-parser": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/bplist-parser/-/bplist-parser-0.3.1.tgz", + "integrity": "sha512-PyJxiNtA5T2PlLIeBot4lbp7rj4OadzjnMZD/G5zuBNt8ei/yCU7+wW0h2bag9vr8c+/WuRWmSxbqAl9hL1rBA==", + "license": "MIT", + "dependencies": { + "big-integer": "1.6.x" + }, + "engines": { + "node": ">= 5.10.0" + } + }, + "node_modules/simple-swizzle": { + "version": "0.2.4", + "resolved": "https://registry.npmjs.org/simple-swizzle/-/simple-swizzle-0.2.4.tgz", + "integrity": "sha512-nAu1WFPQSMNr2Zn9PGSZK9AGn4t/y97lEm+MXTtUDwfP0ksAIX4nO+6ruD9Jwut4C49SB1Ws+fbXsm/yScWOHw==", + "license": "MIT", + "dependencies": { + "is-arrayish": "^0.3.1" + } + }, + "node_modules/simple-swizzle/node_modules/is-arrayish": { + "version": "0.3.4", + "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.3.4.tgz", + "integrity": "sha512-m6UrgzFVUYawGBh1dUsWR5M2Clqic9RVXC/9f8ceNlv2IcO9j9J/z8UoCLPqtsPBFNzEpfR3xftohbfqDx8EQA==", + "license": "MIT" + }, + "node_modules/sisteransi": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz", + "integrity": "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==", + "license": "MIT" + }, + "node_modules/slash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", + "integrity": "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/slugify": { + "version": "1.6.9", + "resolved": "https://registry.npmjs.org/slugify/-/slugify-1.6.9.tgz", + "integrity": "sha512-vZ7rfeehZui7wQs438JXBckYLkIIdfHOXsaVEUMyS5fHo1483l1bMdo0EDSWYclY0yZKFOipDy4KHuKs6ssvdg==", + "license": "MIT", + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/source-map": { + "version": "0.5.7", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz", + "integrity": "sha512-LbrmJOMUSdEVxIKvdcJzQC+nQhe8FUZQTXQy6+I75skNgn3OoQ0DZA8YnFa7gp8tqtL3KPf1kmo0R5DoApeSGQ==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "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==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/source-map-support": { + "version": "0.5.21", + "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz", + "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==", + "license": "MIT", + "dependencies": { + "buffer-from": "^1.0.0", + "source-map": "^0.6.0" + } + }, + "node_modules/source-map-support/node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/split-on-first": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/split-on-first/-/split-on-first-1.1.0.tgz", + "integrity": "sha512-43ZssAJaMusuKWL8sKUBQXHWOpq8d6CfN/u1p4gUzfJkM05C8rxTmYrkIPTXapZpORA6LkkzcUulJ8FqA7Uudw==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "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/stack-utils": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/stack-utils/-/stack-utils-2.0.6.tgz", + "integrity": "sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ==", + "license": "MIT", + "dependencies": { + "escape-string-regexp": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/stack-utils/node_modules/escape-string-regexp": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-2.0.0.tgz", + "integrity": "sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/stackframe": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/stackframe/-/stackframe-1.3.4.tgz", + "integrity": "sha512-oeVtt7eWQS+Na6F//S4kJ2K2VbRlS9D43mAlMyVpVWovy9o+jfgH8O9agzANzaiLjclA0oYzUXEM4PurhSUChw==", + "license": "MIT" + }, + "node_modules/stacktrace-parser": { + "version": "0.1.11", + "resolved": "https://registry.npmjs.org/stacktrace-parser/-/stacktrace-parser-0.1.11.tgz", + "integrity": "sha512-WjlahMgHmCJpqzU8bIBy4qtsZdU9lRlcZE3Lvyej6t4tuOuv1vk57OW3MBrj6hXBFx/nNoC9MPMTcr5YA7NQbg==", + "license": "MIT", + "dependencies": { + "type-fest": "^0.7.1" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/statuses": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-1.5.0.tgz", + "integrity": "sha512-OpZ3zP+jT1PI7I8nemJX4AKmAX070ZkYPVWV/AaKTJl+tXCTGyVdC1a4SL8RUQYEwk/f34ZX8UTykN68FwrqAA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/stream-buffers": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/stream-buffers/-/stream-buffers-2.2.0.tgz", + "integrity": "sha512-uyQK/mx5QjHun80FLJTfaWE7JtwfRMKBLkMne6udYOmvH0CawotVa7TfgYHzAnpphn4+TweIx1QKMnRIbipmUg==", + "license": "Unlicense", + "engines": { + "node": ">= 0.10.0" + } + }, + "node_modules/strict-uri-encode": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/strict-uri-encode/-/strict-uri-encode-2.0.0.tgz", + "integrity": "sha512-QwiXZgpRcKkhTj2Scnn++4PKtWsH0kpzZ62L2R6c/LUVYv7hVnZqcg2+sMuT6R7Jusu1vviK/MFsu6kNJfWlEQ==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/string-format": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/string-format/-/string-format-0.5.0.tgz", + "integrity": "sha512-c/CiKQMy7uuEzi+Tsvnn63/PQw/F7IOSLHNuQ44Eypd0x5VvFnDXMd2T9H0ntphv8nrHAKoZcINPb/yitOAB/g==" + }, + "node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width/node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-5.2.0.tgz", + "integrity": "sha512-DuRs1gKbBqsMKIZlrffwlug8MHkcnpjs5VPmL1PAh+mA30U0DTotfDZ0d2UUsXpPmPmMMJ6W773MaA3J+lbiWA==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^4.1.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/strip-json-comments": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz", + "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/structured-headers": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/structured-headers/-/structured-headers-0.4.1.tgz", + "integrity": "sha512-0MP/Cxx5SzeeZ10p/bZI0S6MpgD+yxAhi1BOQ34jgnMXsCq3j1t6tQnZu+KdlL7dvJTLT3g9xN8tl10TqgFMcg==", + "license": "MIT" + }, + "node_modules/sucrase": { + "version": "3.35.1", + "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.1.tgz", + "integrity": "sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==", + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.2", + "commander": "^4.0.0", + "lines-and-columns": "^1.1.6", + "mz": "^2.7.0", + "pirates": "^4.0.1", + "tinyglobby": "^0.2.11", + "ts-interface-checker": "^0.1.9" + }, + "bin": { + "sucrase": "bin/sucrase", + "sucrase-node": "bin/sucrase-node" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/sucrase/node_modules/commander": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", + "integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==", + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "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/supports-hyperlinks": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/supports-hyperlinks/-/supports-hyperlinks-2.3.0.tgz", + "integrity": "sha512-RpsAZlpWcDwOPQA22aCH4J0t7L8JmAvsCxfOSEwm7cQs3LshN36QaTkwd70DnBOXDWGssw2eUoc8CaRWT0XunA==", + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0", + "supports-color": "^7.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/tar": { + "version": "7.5.16", + "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.16.tgz", + "integrity": "sha512-56adEpPMouktRlBLXiaYFFzZ/3+JXa8P9n7WbR+ibIjtviN55mEaOkiysCnPnWm+7kkui1Dn8J9l+g6zV8731w==", + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/fs-minipass": "^4.0.0", + "chownr": "^3.0.0", + "minipass": "^7.1.2", + "minizlib": "^3.1.0", + "yallist": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/tar/node_modules/yallist": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-5.0.0.tgz", + "integrity": "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==", + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/terminal-link": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/terminal-link/-/terminal-link-2.1.1.tgz", + "integrity": "sha512-un0FmiRUQNr5PJqy9kP7c40F5BOfpGlYTrxonDChEZB7pzZxRNp/bt+ymiy9/npwXya9KH99nJ/GXFIiUkYGFQ==", + "license": "MIT", + "dependencies": { + "ansi-escapes": "^4.2.1", + "supports-hyperlinks": "^2.0.0" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/terser": { + "version": "5.48.0", + "resolved": "https://registry.npmjs.org/terser/-/terser-5.48.0.tgz", + "integrity": "sha512-J/9An6vs9Us6wKRriSFXBWdRZapREHqFzdNUKk0pmu804EMR6dr6winwo7e5JDxN4xahxQsuysyYFwlwj4XN/Q==", + "license": "BSD-2-Clause", + "dependencies": { + "@jridgewell/source-map": "^0.3.3", + "acorn": "^8.15.0", + "commander": "^2.20.0", + "source-map-support": "~0.5.20" + }, + "bin": { + "terser": "bin/terser" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/terser/node_modules/commander": { + "version": "2.20.3", + "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", + "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", + "license": "MIT" + }, + "node_modules/tess2": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/tess2/-/tess2-1.0.0.tgz", + "integrity": "sha512-iSWBSOUoPn3cCT26L5Wi6mvVgL11RV4kReSnVIIPdMN7qNpkL5SLKen5BJcWj+ZTN7kK6JrHBdqTV7vvL8g+9w==", + "license": "SGI-B-2.0" + }, + "node_modules/test-exclude": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-6.0.0.tgz", + "integrity": "sha512-cAGWPIyOHU6zlmg88jwm7VRyXnMN7iV68OGAbYDk/Mh/xC/pzVPlQtY6ngoIH/5/tciuhGfvESU8GrHrcxD56w==", + "license": "ISC", + "dependencies": { + "@istanbuljs/schema": "^0.1.2", + "glob": "^7.1.4", + "minimatch": "^3.0.4" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/text-encoding": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/text-encoding/-/text-encoding-0.7.0.tgz", + "integrity": "sha512-oJQ3f1hrOnbRLOcwKz0Liq2IcrvDeZRHXhd9RgLrsT+DjWY/nty1Hi7v3dtkaEYbPYe0mUoOfzRrMwfXXwgPUA==", + "deprecated": "no longer maintained", + "license": "(Unlicense OR Apache-2.0)" + }, + "node_modules/thenify": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz", + "integrity": "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==", + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0" + } + }, + "node_modules/thenify-all": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/thenify-all/-/thenify-all-1.6.0.tgz", + "integrity": "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==", + "license": "MIT", + "dependencies": { + "thenify": ">= 3.1.0 < 4" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/three": { + "version": "0.166.1", + "resolved": "https://registry.npmjs.org/three/-/three-0.166.1.tgz", + "integrity": "sha512-LtuafkKHHzm61AQA1be2MAYIw1IjmhOUxhBa0prrLpEMWbV7ijvxCRHjSgHPGp2493wLBzwKV46tA9nivLEgKg==", + "license": "MIT" + }, + "node_modules/throat": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/throat/-/throat-5.0.0.tgz", + "integrity": "sha512-fcwX4mndzpLQKBS1DVYhGAcYaYt7vsHNIvQV+WXMvnow5cgjPphq5CaayLaGsjRdSCKZFNGt7/GYAuXaNOiYCA==", + "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==", + "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/tmpl": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/tmpl/-/tmpl-1.0.5.tgz", + "integrity": "sha512-3f0uOEAQwIqGuWW2MVzYg8fV/QNnc/IpuJNG837rLuczAaLVHslWHZQj4IGiEl5Hs3kkbhwL9Ab7Hrsmuj+Smw==", + "license": "BSD-3-Clause" + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/ts-interface-checker": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz", + "integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==", + "license": "Apache-2.0" + }, + "node_modules/type-detect": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.0.8.tgz", + "integrity": "sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/type-fest": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.7.1.tgz", + "integrity": "sha512-Ne2YiiGN8bmrmJJEuTWTLJR32nh/JdL1+PSicowtNb0WFpn59GK8/lfD61bVtzguz7b3PBt74nxpv/Pw5po5Rg==", + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=8" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici": { + "version": "6.26.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-6.26.0.tgz", + "integrity": "sha512-4yqz8a3n5HmGTlsbADNtr/dJlhkh/55Rq798G6ibiULcXbDtaLpTl1pvdqcbFfeoj3iSi52lePFM7h9H21cw/A==", + "license": "MIT", + "engines": { + "node": ">=18.17" + } + }, + "node_modules/undici-types": { + "version": "5.26.5", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", + "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==", + "license": "MIT" + }, + "node_modules/unicode-canonical-property-names-ecmascript": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/unicode-canonical-property-names-ecmascript/-/unicode-canonical-property-names-ecmascript-2.0.1.tgz", + "integrity": "sha512-dA8WbNeb2a6oQzAQ55YlT5vQAWGV9WXOsi3SskE3bcCdM0P4SDd+24zS/OCacdRq5BkdsRj9q3Pg6YyQoxIGqg==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/unicode-match-property-ecmascript": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/unicode-match-property-ecmascript/-/unicode-match-property-ecmascript-2.0.0.tgz", + "integrity": "sha512-5kaZCrbp5mmbz5ulBkDkbY0SsPOjKqVS35VpL9ulMPfSl0J0Xsm+9Evphv9CoIZFwre7aJoa94AY6seMKGVN5Q==", + "license": "MIT", + "dependencies": { + "unicode-canonical-property-names-ecmascript": "^2.0.0", + "unicode-property-aliases-ecmascript": "^2.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/unicode-match-property-value-ecmascript": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/unicode-match-property-value-ecmascript/-/unicode-match-property-value-ecmascript-2.2.1.tgz", + "integrity": "sha512-JQ84qTuMg4nVkx8ga4A16a1epI9H6uTXAknqxkGF/aFfRLw1xC/Bp24HNLaZhHSkWd3+84t8iXnp1J0kYcZHhg==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/unicode-property-aliases-ecmascript": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/unicode-property-aliases-ecmascript/-/unicode-property-aliases-ecmascript-2.2.0.tgz", + "integrity": "sha512-hpbDzxUY9BFwX+UeBnxv3Sh1q7HFxj48DTmXchNgRa46lO8uj3/1iEn3MiNUYTg1g9ctIqXCCERn8gYZhHC5lQ==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "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==", + "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/use-latest-callback": { + "version": "0.2.6", + "resolved": "https://registry.npmjs.org/use-latest-callback/-/use-latest-callback-0.2.6.tgz", + "integrity": "sha512-FvRG9i1HSo0wagmX63Vrm8SnlUU3LMM3WyZkQ76RnslpBrX694AdG4A0zQBx2B3ZifFA0yv/BaEHGBnEax5rZg==", + "license": "MIT", + "peerDependencies": { + "react": ">=16.8" + } + }, + "node_modules/utils-merge": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", + "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", + "license": "MIT", + "engines": { + "node": ">= 0.4.0" + } + }, + "node_modules/uuid": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", + "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", + "deprecated": "uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028).", + "license": "MIT", + "bin": { + "uuid": "dist/bin/uuid" + } + }, + "node_modules/validate-npm-package-name": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/validate-npm-package-name/-/validate-npm-package-name-5.0.1.tgz", + "integrity": "sha512-OljLrQ9SQdOUqTaQxqL5dEfZWrXExyyWsozYlAWFawPVNuD83igl7uJD2RTkNMbniIYgt8l81eCJGIdQF7avLQ==", + "license": "ISC", + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/vlq": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/vlq/-/vlq-1.0.1.tgz", + "integrity": "sha512-gQpnTgkubC6hQgdIcRdYGDSDc+SaujOdyesZQMv6JlfQee/9Mp0Qhnys6WxDWvQnL5WZdT7o2Ul187aSt0Rq+w==", + "license": "MIT" + }, + "node_modules/walker": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/walker/-/walker-1.0.8.tgz", + "integrity": "sha512-ts/8E8l5b7kY0vlWLewOkDXMmPdLcVV4GmOQLyxuSswIJsweeFZtAsMF7k1Nszz+TYBQrlYRmzOnr398y1JemQ==", + "license": "Apache-2.0", + "dependencies": { + "makeerror": "1.0.12" + } + }, + "node_modules/warn-once": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/warn-once/-/warn-once-0.1.1.tgz", + "integrity": "sha512-VkQZJbO8zVImzYFteBXvBOZEl1qL175WH8VmZcxF2fZAoudNhNDvHi+doCaAEdU2l2vtcIwa2zn0QK5+I1HQ3Q==", + "license": "MIT" + }, + "node_modules/wcwidth": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/wcwidth/-/wcwidth-1.0.1.tgz", + "integrity": "sha512-XHPEwS0q6TaxcvG85+8EYkbiCux2XtWG2mkc47Ng2A77BQu9+DqIOJldST4HgPkuea7dvKSj5VgX3P1d4rW8Tg==", + "license": "MIT", + "dependencies": { + "defaults": "^1.0.3" + } + }, + "node_modules/whatwg-fetch": { + "version": "3.6.20", + "resolved": "https://registry.npmjs.org/whatwg-fetch/-/whatwg-fetch-3.6.20.tgz", + "integrity": "sha512-EqhiFU6daOA8kpjOWTL0olhVOF3i7OrFzSYiGsEMB8GcXS+RrzauAERX65xMeNWVqxA6HXH2m69Z9LaKKdisfg==", + "license": "MIT" + }, + "node_modules/whatwg-url-without-unicode": { + "version": "8.0.0-3", + "resolved": "https://registry.npmjs.org/whatwg-url-without-unicode/-/whatwg-url-without-unicode-8.0.0-3.tgz", + "integrity": "sha512-HoKuzZrUlgpz35YO27XgD28uh/WJH4B0+3ttFqRo//lmq+9T/mIOJ6kqmINI9HpUpz1imRC/nR/lxKpJiv0uig==", + "license": "MIT", + "dependencies": { + "buffer": "^5.4.3", + "punycode": "^2.1.1", + "webidl-conversions": "^5.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/whatwg-url-without-unicode/node_modules/webidl-conversions": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-5.0.0.tgz", + "integrity": "sha512-VlZwKPCkYKxQgeSbH5EyngOmRp7Ww7I9rQLERETtf5ofd9pGeswWiOtogpEO850jziPRarreGxn5QIiTqpb2wA==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=8" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/wonka": { + "version": "6.3.6", + "resolved": "https://registry.npmjs.org/wonka/-/wonka-6.3.6.tgz", + "integrity": "sha512-MXH+6mDHAZ2GuMpgKS055FR6v0xVP3XwquxIMYXgiW+FejHQlMGlvVRZT4qMCxR+bEo/FCtIdKxwej9WV3YQag==", + "license": "MIT" + }, + "node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "license": "ISC" + }, + "node_modules/ws": { + "version": "7.5.11", + "resolved": "https://registry.npmjs.org/ws/-/ws-7.5.11.tgz", + "integrity": "sha512-zS54Oen9bITtp7kp2XM3AydrCIq1D+HwJOuH+c+e4LfpL/lotP5osijd+UoMnxwAam1GN8R4KtLAyIrIcBNpiA==", + "license": "MIT", + "engines": { + "node": ">=8.3.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": "^5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/xcode": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/xcode/-/xcode-3.0.1.tgz", + "integrity": "sha512-kCz5k7J7XbJtjABOvkc5lJmkiDh8VhjVCGNiqdKCscmVpdVUpEAyXv1xmCLkQJ5dsHqx3IPO4XW+NTDhU/fatA==", + "license": "Apache-2.0", + "dependencies": { + "simple-plist": "^1.1.0", + "uuid": "^7.0.3" + }, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/xcode/node_modules/uuid": { + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-7.0.3.tgz", + "integrity": "sha512-DPSke0pXhTZgoF/d+WSt2QaKMCFSfx7QegxEWT+JOuHF5aWrKEn0G+ztjuJg/gG8/ItK+rbPCD/yNv8yyih6Cg==", + "deprecated": "uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028).", + "license": "MIT", + "bin": { + "uuid": "dist/bin/uuid" + } + }, + "node_modules/xml2js": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/xml2js/-/xml2js-0.6.0.tgz", + "integrity": "sha512-eLTh0kA8uHceqesPqSE+VvO1CDDJWMwlQfB6LuN6T8w6MaDJ8Txm8P7s5cHD0miF0V+GGTZrDQfxPZQVsur33w==", + "license": "MIT", + "dependencies": { + "sax": ">=0.6.0", + "xmlbuilder": "~11.0.0" + }, + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/xml2js/node_modules/xmlbuilder": { + "version": "11.0.1", + "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-11.0.1.tgz", + "integrity": "sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA==", + "license": "MIT", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/xmlbuilder": { + "version": "15.1.1", + "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-15.1.1.tgz", + "integrity": "sha512-yMqGBqtXyeN1e3TGYvgNgDVZ3j84W4cwkOXQswghol6APgZWaff9lnbvN7MHYJOiXsvGPXtjTYJEiC9J2wv9Eg==", + "license": "MIT", + "engines": { + "node": ">=8.0" + } + }, + "node_modules/xmldom-qsa": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/xmldom-qsa/-/xmldom-qsa-1.1.3.tgz", + "integrity": "sha512-IJBOczBpAYrIBJFFsmCBwfBhwe4zdMR3Xz0ZBX0OFtgO49rLy/BWbhkegOwsthdBWb1gUtFK6ZZnGdT8ZqPRBA==", + "license": "MIT", + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "license": "ISC" + }, + "node_modules/yaml": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz", + "integrity": "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==", + "license": "ISC", + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + }, + "funding": { + "url": "https://github.com/sponsors/eemeli" + } + }, + "node_modules/yargs": { + "version": "17.7.2", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", + "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", + "license": "MIT", + "dependencies": { + "cliui": "^8.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.3", + "y18n": "^5.0.5", + "yargs-parser": "^21.1.1" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/yargs-parser": { + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + } + } +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..07cf5e7 --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,45 @@ +{ + "name": "pegasus", + "version": "1.0.0", + "main": "index.ts", + "private": true, + "scripts": { + "start": "expo start", + "ios": "expo start --ios", + "android": "expo start --android", + "web": "expo start --web" + }, + "dependencies": { + "@expo/ngrok": "^4.1.3", + "@react-navigation/bottom-tabs": "^6.6.1", + "@react-navigation/native": "^6.1.18", + "expo": "^54.0.35", + "expo-asset": "~12.0.13", + "expo-av": "~16.0.8", + "expo-blur": "~15.0.8", + "expo-camera": "~17.0.10", + "expo-file-system": "~19.0.23", + "expo-gl": "~16.0.10", + "expo-haptics": "~15.0.8", + "expo-linear-gradient": "~15.0.8", + "expo-status-bar": "~3.0.9", + "expo-three": "^8.0.0", + "react": "19.1.0", + "react-native": "0.81.5", + "react-native-chart-kit": "^6.12.3", + "react-native-gesture-handler": "~2.28.0", + "react-native-reanimated": "~4.1.1", + "react-native-safe-area-context": "~5.6.0", + "react-native-screens": "~4.16.0", + "react-native-svg": "15.12.1", + "react-native-worklets": "0.5.1", + "three": "^0.166.1" + }, + "devDependencies": { + "@babel/core": "^7.24.0", + "@types/react": "~19.1.10", + "@types/three": "^0.166.0", + "babel-preset-expo": "~54.0.10", + "typescript": "~5.9.2" + } +} diff --git a/frontend/src/components/AlertOverlay.tsx b/frontend/src/components/AlertOverlay.tsx new file mode 100644 index 0000000..139049d --- /dev/null +++ b/frontend/src/components/AlertOverlay.tsx @@ -0,0 +1,359 @@ +// AlertOverlay — fullscreen red alert modal for a critical (red) burnout score. +// A breathing red pulse fills the screen, the intervention text is front and +// center, and a list of human-support contacts can be tapped to call or text. +// Fires a heavy haptic impact the moment it becomes visible. + +import React, { useEffect } from "react"; +import { + Linking, + Modal, + Pressable, + ScrollView, + StyleSheet, + Text, + View, +} from "react-native"; +import * as Haptics from "expo-haptics"; +import { LinearGradient } from "expo-linear-gradient"; +import Animated, { + Easing, + cancelAnimation, + interpolate, + useAnimatedStyle, + useSharedValue, + withRepeat, + withTiming, +} from "react-native-reanimated"; + +import { COLORS, RADIUS, SPACING, TYPE } from "../utils/colors"; + +// A single human you can reach out to. `value` is the raw phone/email/url. +export interface SupportContact { + id?: string; + name: string; + // What the contact is for, e.g. "Crisis line", "Manager", "Friend". + role?: string; + // "tel" -> phone call, "sms" -> text message, "url" -> any other link. + type?: "tel" | "sms" | "url"; + value: string; +} + +export interface AlertOverlayProps { + visible: boolean; + score: number; + intervention: string; + support?: SupportContact[]; + onClose: () => void; +} + +// Build the platform link for a contact based on its type. +function contactHref(c: SupportContact): string { + const raw = c.value.trim(); + switch (c.type) { + case "sms": + return `sms:${raw.replace(/\s+/g, "")}`; + case "url": + return raw; + case "tel": + default: + return `tel:${raw.replace(/\s+/g, "")}`; + } +} + +function openContact(c: SupportContact) { + Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium).catch(() => {}); + Linking.openURL(contactHref(c)).catch(() => {}); +} + +function ContactRow({ contact }: { contact: SupportContact }) { + const verb = + contact.type === "sms" ? "Text" : contact.type === "url" ? "Open" : "Call"; + return ( + openContact(contact)} + style={({ pressed }) => [styles.contact, pressed && styles.contactPressed]} + accessibilityRole="button" + accessibilityLabel={`${verb} ${contact.name}`} + > + + + {contact.name} + + {contact.role ? ( + + {contact.role} + + ) : null} + + + {verb} + + + ); +} + +export default function AlertOverlay({ + visible, + score, + intervention, + support, + onClose, +}: AlertOverlayProps) { + // 0..1 breathing value driving the background pulse + halo. + const pulse = useSharedValue(0); + + // Heavy haptic the instant the alert surfaces. + useEffect(() => { + if (visible) { + Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Heavy).catch(() => {}); + } + }, [visible]); + + // Run the breathing pulse only while visible; stop it otherwise. + useEffect(() => { + if (visible) { + pulse.value = 0; + pulse.value = withRepeat( + withTiming(1, { duration: 1500, easing: Easing.inOut(Easing.quad) }), + -1, + true // reverse — breathe in and out + ); + } else { + cancelAnimation(pulse); + pulse.value = 0; + } + return () => cancelAnimation(pulse); + }, [visible, pulse]); + + const glowStyle = useAnimatedStyle(() => ({ + opacity: interpolate(pulse.value, [0, 1], [0.18, 0.42]), + })); + + const haloStyle = useAnimatedStyle(() => ({ + opacity: interpolate(pulse.value, [0, 1], [0.25, 0.7]), + transform: [{ scale: interpolate(pulse.value, [0, 1], [0.96, 1.04]) }], + })); + + const message = intervention?.trim(); + const contacts = (support ?? []).filter((c) => c && c.value && c.name); + const shownScore = Number.isFinite(score) ? Math.round(score) : 0; + + return ( + + + {/* Base dark wash so text stays legible over the pulse. */} + + + {/* Breathing red radial glow filling the screen. */} + + + + + + {/* Alert badge + breathing halo behind the score. */} + + + + + CRITICAL + + {shownScore} + Burnout index + + + {/* The intervention, front and center. */} + + {message && message.length > 0 + ? message + : "Stop and step away. Your signals are spiking — reach out to someone now."} + + + {/* Human support. Tap to call / text / open. */} + {contacts.length > 0 ? ( + + Reach a human + {contacts.map((c, i) => ( + + ))} + + ) : null} + + + {/* Dismiss. */} + [styles.dismiss, pressed && styles.dismissPressed]} + accessibilityRole="button" + accessibilityLabel="Dismiss alert" + > + I'm okay — dismiss + + + + ); +} + +const RED = COLORS.red; + +const styles = StyleSheet.create({ + root: { + flex: 1, + backgroundColor: COLORS.bg, + }, + backdrop: { + ...StyleSheet.absoluteFillObject, + backgroundColor: "rgba(245,245,247,0.92)", + }, + content: { + flexGrow: 1, + justifyContent: "center", + paddingHorizontal: SPACING.xl, + paddingVertical: SPACING.xxl, + }, + scoreBlock: { + alignItems: "center", + marginBottom: SPACING.xl, + }, + halo: { + position: "absolute", + width: 260, + height: 260, + borderRadius: 130, + top: -56, + backgroundColor: "rgba(255,59,48,0.10)", + borderWidth: 1, + borderColor: "rgba(255,59,48,0.30)", + }, + alertPill: { + flexDirection: "row", + alignItems: "center", + paddingVertical: 6, + paddingHorizontal: SPACING.md, + borderRadius: RADIUS.pill, + borderWidth: 1, + borderColor: "rgba(255,59,48,0.40)", + backgroundColor: "rgba(255,59,48,0.10)", + marginBottom: SPACING.lg, + }, + alertDot: { + width: 7, + height: 7, + borderRadius: 4, + backgroundColor: RED, + marginRight: SPACING.sm, + }, + alertLabel: { + ...TYPE.label, + color: RED, + letterSpacing: 2, + }, + score: { + ...TYPE.hero, + fontSize: 88, + color: COLORS.text, + lineHeight: 92, + }, + scoreCaption: { + ...TYPE.label, + color: COLORS.textDim, + letterSpacing: 1.2, + marginTop: SPACING.xs, + }, + intervention: { + ...TYPE.body, + color: COLORS.text, + fontSize: 20, + lineHeight: 30, + fontWeight: "600", + textAlign: "center", + marginBottom: SPACING.xl, + }, + supportBlock: { + marginTop: SPACING.sm, + }, + supportHeading: { + ...TYPE.label, + color: COLORS.textDim, + letterSpacing: 1.4, + marginBottom: SPACING.md, + textAlign: "center", + }, + contact: { + flexDirection: "row", + alignItems: "center", + backgroundColor: COLORS.card, + borderWidth: 1, + borderColor: COLORS.border, + borderRadius: RADIUS.md, + paddingVertical: SPACING.md, + paddingHorizontal: SPACING.md, + marginBottom: SPACING.sm + 2, + shadowColor: "#000", + shadowOpacity: 0.06, + shadowRadius: 12, + shadowOffset: { width: 0, height: 4 }, + elevation: 2, + }, + contactPressed: { + backgroundColor: "#fff0ef", + borderColor: "rgba(255,59,48,0.40)", + }, + contactBody: { + flex: 1, + marginRight: SPACING.md, + }, + contactName: { + ...TYPE.body, + color: COLORS.text, + fontWeight: "700", + }, + contactRole: { + ...TYPE.caption, + color: COLORS.textDim, + marginTop: 2, + }, + contactAction: { + paddingVertical: 6, + paddingHorizontal: SPACING.md, + borderRadius: RADIUS.pill, + backgroundColor: "rgba(255,59,48,0.12)", + borderWidth: 1, + borderColor: "rgba(255,59,48,0.40)", + }, + contactActionText: { + ...TYPE.label, + color: RED, + letterSpacing: 0.6, + }, + dismiss: { + margin: SPACING.lg, + paddingVertical: SPACING.md + 2, + borderRadius: RADIUS.md, + alignItems: "center", + borderWidth: 1, + borderColor: COLORS.border, + backgroundColor: COLORS.card, + }, + dismissPressed: { + backgroundColor: "#e8e8ed", + }, + dismissText: { + ...TYPE.body, + color: COLORS.textDim, + fontWeight: "600", + }, +}); diff --git a/frontend/src/components/Brain3D.tsx b/frontend/src/components/Brain3D.tsx new file mode 100644 index 0000000..98483c7 --- /dev/null +++ b/frontend/src/components/Brain3D.tsx @@ -0,0 +1,159 @@ +// Brain3D — real 3D brain from synapse's fsaverage5.glb (FreeSurfer surface), +// rendered with expo-gl + expo-three, tinted by TRIBE region activation. +// +// Lifecycle-safe (fixes the prior GL/RAF leak): ONE render loop, the GLView is +// NOT re-keyed on region change — instead we recolor the loaded mesh imperatively +// via a ref. On any GL/asset failure we fall back to a procedural point-cloud +// brain so the screen never crashes. Not runtime-verified here (needs a device). +import React, { useEffect, useRef, useState } from "react"; +import { StyleSheet, View, Text } from "react-native"; +import { GLView, ExpoWebGLRenderingContext } from "expo-gl"; +import { Renderer, THREE } from "expo-three"; +import { Asset } from "expo-asset"; +import { GLTFLoader } from "three/examples/jsm/loaders/GLTFLoader.js"; + +import { BrainData } from "../types"; +import { COLORS } from "../utils/colors"; + +type Regions = BrainData["regions"]; + +// activation 0..1 -> blue (#0071e3) -> amber (#FF9F0A) -> red (#FF3B30) +function activationColor(a: number): THREE.Color { + const c = new THREE.Color(); + const t = Math.max(0, Math.min(1, a)); + if (t < 0.5) c.lerpColors(new THREE.Color(COLORS.blue), new THREE.Color(COLORS.yellow), t * 2); + else c.lerpColors(new THREE.Color(COLORS.yellow), new THREE.Color(COLORS.red), (t - 0.5) * 2); + return c; +} + +function meanActivation(regions?: Regions): number { + if (!regions) return 0.3; + const vals = Object.values(regions).map((v) => Number(v) || 0); + return vals.length ? vals.reduce((a, b) => a + b, 0) / vals.length : 0.3; +} + +export default function Brain3D({ regions, size = 280 }: { regions?: Regions; size?: number }) { + const [failed, setFailed] = useState(false); + const meshRef = useRef(null); + const frameRef = useRef(null); + const rendererRef = useRef(null); + + // Recolor imperatively when regions change — NO remount, no new GL context. + useEffect(() => { + const obj = meshRef.current; + if (!obj) return; + const color = activationColor(meanActivation(regions)); + obj.traverse((child: any) => { + if (child.isMesh && child.material) { + child.material.color = color; + if ("emissive" in child.material) child.material.emissive = color.clone().multiplyScalar(0.25); + } + if (child.isPoints && child.material) child.material.color = color; + }); + }, [regions]); + + // Cleanup on unmount only. + useEffect(() => { + return () => { + if (frameRef.current != null) cancelAnimationFrame(frameRef.current); + try { + rendererRef.current?.dispose?.(); + } catch {} + }; + }, []); + + async function onContextCreate(gl: ExpoWebGLRenderingContext) { + try { + const renderer = new Renderer({ gl }); + renderer.setSize(gl.drawingBufferWidth, gl.drawingBufferHeight); + renderer.setClearColor(COLORS.bg as any, 1); + rendererRef.current = renderer; + + const scene = new THREE.Scene(); + const camera = new THREE.PerspectiveCamera(50, gl.drawingBufferWidth / gl.drawingBufferHeight, 0.1, 100); + camera.position.set(0, 0, 3.2); + scene.add(new THREE.AmbientLight(0xffffff, 0.9)); + const dir = new THREE.DirectionalLight(0xffffff, 0.6); + dir.position.set(2, 3, 4); + scene.add(dir); + + const root = new THREE.Group(); + scene.add(root); + meshRef.current = root; + + // Try the real fsaverage5.glb; fall back to a procedural brain. + try { + const asset = Asset.fromModule(require("../../assets/brain/fsaverage5.glb")); + await asset.downloadAsync(); + const gltf: any = await new Promise((resolve, reject) => + new GLTFLoader().load(asset.localUri || asset.uri, resolve, undefined, reject) + ); + const brain = gltf.scene; + const box = new THREE.Box3().setFromObject(brain); + const center = box.getCenter(new THREE.Vector3()); + const sphere = box.getBoundingSphere(new THREE.Sphere()); + brain.position.sub(center); + brain.scale.setScalar(1.6 / (sphere.radius || 1)); + brain.traverse((c: any) => { + if (c.isMesh) { + c.material = new THREE.MeshStandardMaterial({ color: 0x0071e3, roughness: 0.6, metalness: 0.1 }); + } + }); + root.add(brain); + } catch { + root.add(proceduralBrain()); + } + + const color = activationColor(meanActivation(regions)); + root.traverse((c: any) => { + if (c.material) c.material.color = color; + }); + + const render = () => { + frameRef.current = requestAnimationFrame(render); + root.rotation.y += 0.006; + renderer.render(scene, camera); + gl.endFrameEXP(); + }; + render(); + } catch { + setFailed(true); + } + } + + if (failed) { + return ( + + brain view unavailable + + ); + } + return ; +} + +// Procedural two-hemisphere point cloud (fallback if the glb won't load). +function proceduralBrain(): THREE.Points { + const N = 4000; + const positions = new Float32Array(N * 3); + for (let i = 0; i < N; i++) { + const u = Math.random() * Math.PI * 2; + const v = Math.acos(2 * Math.random() - 1); + const noise = 0.92 + Math.random() * 0.16; + let x = 1.0 * Math.sin(v) * Math.cos(u) * noise; + const y = 0.8 * Math.sin(v) * Math.sin(u) * noise; + const z = 1.1 * Math.cos(v) * noise; + x += x >= 0 ? 0.12 : -0.12; // longitudinal fissure gap + positions.set([x, y, z], i * 3); + } + const geo = new THREE.BufferGeometry(); + geo.setAttribute("position", new THREE.BufferAttribute(positions, 3)); + return new THREE.Points(geo, new THREE.PointsMaterial({ color: 0x0071e3, size: 0.025 })); +} + +const styles = StyleSheet.create({ + fallback: { + borderRadius: 999, alignItems: "center", justifyContent: "center", + backgroundColor: COLORS.card, borderWidth: 1, borderColor: COLORS.border, + }, + fallbackText: { color: COLORS.textDim, fontSize: 13 }, +}); diff --git a/frontend/src/components/CheckEngine.tsx b/frontend/src/components/CheckEngine.tsx new file mode 100644 index 0000000..8ed7ccf --- /dev/null +++ b/frontend/src/components/CheckEngine.tsx @@ -0,0 +1,264 @@ +// CheckEngine — the hero component. A large, living, pulsing wellness orb. +// Score is 0..100 where higher = better. Glowing ring, breathing pulse, calm +// numerals. "Tesla dashboard for your mind." +import React, { useEffect } from "react"; +import { StyleSheet, Text, View } from "react-native"; +import Animated, { + Easing, + cancelAnimation, + interpolateColor, + useAnimatedStyle, + useDerivedValue, + useSharedValue, + withRepeat, + withSequence, + withTiming, +} from "react-native-reanimated"; +import { LinearGradient } from "expo-linear-gradient"; +import { COLORS, levelColor } from "../utils/colors"; +import type { BurnoutLevel } from "../utils/colors"; + +interface CheckEngineProps { + score: number; + level: BurnoutLevel; + size?: number; +} + +// Pulse cadence by level — calmer when green, urgent when red. +const PULSE_MS: Record = { + green: 2000, + yellow: 1200, + red: 600, +}; + +const AnimatedLinearGradient = Animated.createAnimatedComponent(LinearGradient); + +export default function CheckEngine({ score, level, size = 260 }: CheckEngineProps) { + // Breathing scale 1.0 -> 1.12 -> 1.0, speed driven by level. + const pulse = useSharedValue(0); + // 0 = green, 1 = yellow, 2 = red. Animated for smooth color transitions. + const levelIndex = useSharedValue(level === "yellow" ? 1 : level === "red" ? 2 : 0); + + const duration = PULSE_MS[level] ?? PULSE_MS.green; + + useEffect(() => { + cancelAnimation(pulse); + pulse.value = 0; + pulse.value = withRepeat( + withSequence( + withTiming(1, { duration, easing: Easing.inOut(Easing.ease) }), + withTiming(0, { duration, easing: Easing.inOut(Easing.ease) }) + ), + -1, + false + ); + return () => cancelAnimation(pulse); + }, [duration, pulse]); + + useEffect(() => { + const target = level === "yellow" ? 1 : level === "red" ? 2 : 0; + levelIndex.value = withTiming(target, { duration: 600, easing: Easing.out(Easing.ease) }); + }, [level, levelIndex]); + + // Smoothly-interpolated accent color tracking the level transition. + const accent = useDerivedValue(() => + interpolateColor( + levelIndex.value, + [0, 1, 2], + [COLORS.green, COLORS.yellow, COLORS.red] + ) + ); + + // Outer glow ring: scales with the pulse, glows in the accent color. + const ringStyle = useAnimatedStyle(() => { + const scale = 1 + pulse.value * 0.12; + const c = accent.value; + return { + transform: [{ scale }], + borderColor: c, + shadowColor: c, + shadowOpacity: 0.55 + pulse.value * 0.35, + shadowRadius: 28 + pulse.value * 22, + }; + }); + + // A second, larger halo ring for layered glow depth. + const haloStyle = useAnimatedStyle(() => { + const scale = 1 + pulse.value * 0.18; + const c = accent.value; + return { + transform: [{ scale }], + borderColor: c, + opacity: 0.18 + pulse.value * 0.12, + shadowColor: c, + shadowOpacity: 0.4 + pulse.value * 0.3, + shadowRadius: 40 + pulse.value * 30, + }; + }); + + // Radial-ish fill: tint shifts subtly with the accent color. + const fillStyle = useAnimatedStyle(() => ({ + shadowColor: accent.value, + shadowOpacity: 0.5 + pulse.value * 0.25, + shadowRadius: 24 + pulse.value * 16, + })); + + // Thin accent text color synced to the live accent. + const accentTextStyle = useAnimatedStyle(() => ({ color: accent.value })); + + // Static fallback color for the gradient (LinearGradient colors can't be + // animated per-frame cheaply, so we re-key it on level change instead). + const c = levelColor(level); + const ringDim = size; + const haloDim = size + 36; + const fillDim = size - 28; + const clamped = Math.max(0, Math.min(100, Math.round(score))); + + return ( + + {/* Outer halo */} + + + {/* Primary glowing ring */} + + {/* Radial-ish gradient fill via stacked linear gradients */} + + + + + {/* Center numerals */} + + + + {clamped} + + + /100 + + + + {labelFor(level)} + + + + + + ); +} + +// Wellness label — higher score is better. Green thriving, red running low. +function labelFor(level: BurnoutLevel): string { + switch (level) { + case "green": + return "THRIVING"; + case "yellow": + return "STEADY"; + case "red": + return "RUNNING LOW"; + default: + return ""; + } +} + +// Convert a hex color to rgba with the given alpha. +function withAlpha(hex: string, alpha: number): string { + const h = hex.replace("#", ""); + const r = parseInt(h.slice(0, 2), 16); + const g = parseInt(h.slice(2, 4), 16); + const b = parseInt(h.slice(4, 6), 16); + return `rgba(${r}, ${g}, ${b}, ${alpha})`; +} + +const styles = StyleSheet.create({ + root: { + alignItems: "center", + justifyContent: "center", + }, + ringAbs: { + position: "absolute", + alignItems: "center", + justifyContent: "center", + backgroundColor: "transparent", + // iOS glow; Android approximates via elevation on the fill. + shadowOffset: { width: 0, height: 0 }, + }, + fill: { + alignItems: "center", + justifyContent: "center", + overflow: "hidden", + backgroundColor: COLORS.card, + shadowOffset: { width: 0, height: 0 }, + elevation: 16, + }, + center: { + alignItems: "center", + justifyContent: "center", + }, + scoreRow: { + flexDirection: "row", + alignItems: "flex-end", + }, + score: { + fontSize: 56, + fontWeight: "800", + color: COLORS.text, + letterSpacing: -1.5, + lineHeight: 60, + }, + outOf: { + fontSize: 18, + fontWeight: "600", + color: COLORS.textDim, + marginBottom: 9, + marginLeft: 2, + }, + label: { + marginTop: 6, + fontSize: 12, + fontWeight: "700", + letterSpacing: 2, + }, +}); diff --git a/frontend/src/components/IndicatorCard.tsx b/frontend/src/components/IndicatorCard.tsx new file mode 100644 index 0000000..78759cd --- /dev/null +++ b/frontend/src/components/IndicatorCard.tsx @@ -0,0 +1,68 @@ +import React from "react"; +import { StyleSheet, Text, View } from "react-native"; +import { COLORS, RADIUS, SPACING, TYPE } from "../utils/colors"; + +// Tones map to the design system's accent palette. "neutral" falls back to +// dim text so an unspecified indicator still reads as calm, not alarming. +export type IndicatorTone = "green" | "yellow" | "red" | "blue" | "neutral"; + +const TONE_COLOR: Record = { + green: COLORS.green, + yellow: COLORS.yellow, + red: COLORS.red, + blue: COLORS.blue, + neutral: COLORS.textDim, +}; + +interface IndicatorCardProps { + text: string; + tone?: IndicatorTone; +} + +// Small, single-line indicator: a tone-colored dot beside one short insight. +// Renders nothing for empty/whitespace text so callers can pass through +// optional data without an empty shell appearing. +export default function IndicatorCard({ text, tone = "neutral" }: IndicatorCardProps) { + const label = text?.trim(); + if (!label) return null; + + const dotColor = TONE_COLOR[tone] ?? TONE_COLOR.neutral; + + return ( + + + + {label} + + + ); +} + +const styles = StyleSheet.create({ + card: { + flexDirection: "row", + alignItems: "center", + backgroundColor: COLORS.card, + borderWidth: 1, + borderColor: COLORS.border, + borderRadius: RADIUS.md, + paddingVertical: SPACING.md, + paddingHorizontal: SPACING.md + 2, + shadowColor: "#000", + shadowOpacity: 0.06, + shadowRadius: 12, + shadowOffset: { width: 0, height: 4 }, + elevation: 1, + }, + dot: { + width: 10, + height: 10, + borderRadius: RADIUS.pill, + marginRight: SPACING.md, + }, + text: { + flex: 1, + ...TYPE.body, + color: COLORS.text, + }, +}); diff --git a/frontend/src/components/InterventionCard.tsx b/frontend/src/components/InterventionCard.tsx new file mode 100644 index 0000000..dd76a02 --- /dev/null +++ b/frontend/src/components/InterventionCard.tsx @@ -0,0 +1,117 @@ +// InterventionCard — warm, friendly card for the HF-generated intervention. +// Slightly highlighted with a soft glow, a small "suggestion" label, and +// readable body copy. Subtle entrance via reanimated. Owned by the visual layer. + +import React, { useEffect } from "react"; +import { StyleSheet, Text, View } from "react-native"; +import { LinearGradient } from "expo-linear-gradient"; +import Animated, { + useAnimatedStyle, + useSharedValue, + withDelay, + withTiming, + Easing, +} from "react-native-reanimated"; + +import { COLORS, RADIUS, SPACING, TYPE } from "../utils/colors"; + +type Props = { + text: string; +}; + +export default function InterventionCard({ text }: Props) { + const enter = useSharedValue(0); + + useEffect(() => { + enter.value = withDelay( + 80, + withTiming(1, { duration: 420, easing: Easing.out(Easing.cubic) }) + ); + }, [enter]); + + const animatedStyle = useAnimatedStyle(() => ({ + opacity: enter.value, + transform: [{ translateY: (1 - enter.value) * 12 }], + })); + + const message = text?.trim(); + + return ( + + + {/* Warm accent rail on the left edge */} + + + + + + SUGGESTION + + + + {message && message.length > 0 + ? message + : "Take a breath. A small reset can go a long way right now."} + + + + + ); +} + +// Warm amber accent for the friendly, encouraging tone. +const WARM = COLORS.yellow; + +const styles = StyleSheet.create({ + card: { + flexDirection: "row", + borderRadius: RADIUS.lg, + borderWidth: 1, + borderColor: "rgba(255, 159, 10, 0.30)", + overflow: "hidden", + // Soft warm glow to make the card feel highlighted and inviting. + shadowColor: WARM, + shadowOpacity: 0.14, + shadowRadius: 16, + shadowOffset: { width: 0, height: 6 }, + elevation: 3, + }, + rail: { + width: 3, + backgroundColor: WARM, + }, + body: { + flex: 1, + paddingVertical: SPACING.lg, + paddingHorizontal: SPACING.lg, + }, + labelRow: { + flexDirection: "row", + alignItems: "center", + marginBottom: SPACING.sm + 2, + }, + dot: { + width: 6, + height: 6, + borderRadius: 3, + backgroundColor: WARM, + marginRight: SPACING.sm, + }, + label: { + ...TYPE.label, + color: WARM, + letterSpacing: 1.2, + }, + message: { + ...TYPE.body, + color: COLORS.text, + fontSize: 17, + lineHeight: 25, + fontWeight: "500", + }, +}); diff --git a/frontend/src/components/PulseRing.tsx b/frontend/src/components/PulseRing.tsx new file mode 100644 index 0000000..44bd4d6 --- /dev/null +++ b/frontend/src/components/PulseRing.tsx @@ -0,0 +1,110 @@ +// PulseRing — reusable animated concentric rings that sit behind CheckEngine. +// A calm radar-style pulse: rings expand outward and fade, looping forever. +// Driven by a single reanimated shared value for cheap 60fps motion. + +import React, { useEffect } from "react"; +import { StyleSheet, View } from "react-native"; +import Animated, { + Easing, + cancelAnimation, + interpolate, + useAnimatedStyle, + useSharedValue, + withRepeat, + withTiming, + type SharedValue, +} from "react-native-reanimated"; + +export interface PulseRingProps { + /** Ring stroke/glow color (typically the burnout level accent). */ + color: string; + /** Diameter of the largest ring, in px. The container is sized to this. */ + size: number; + /** Duration of one full expand+fade cycle, in ms. Defaults to 2600. */ + speedMs?: number; +} + +// Number of concentric rings. Staggered so the pulse reads as continuous. +const RING_COUNT = 3; + +interface RingProps { + progress: SharedValue; + color: string; + size: number; + // Phase offset in [0,1) so rings don't all pulse in unison. + offset: number; +} + +function Ring({ progress, color, size, offset }: RingProps) { + const animatedStyle = useAnimatedStyle(() => { + // Each ring runs the same 0->1 cycle, shifted by its offset and wrapped. + const t = (progress.value + offset) % 1; + // Grow from a small core out to full size. + const scale = interpolate(t, [0, 1], [0.35, 1]); + // Bright as it emerges, fading to nothing at the edge. Quick fade-in + // at the very start avoids a hard pop when the ring respawns. + const opacity = interpolate(t, [0, 0.12, 1], [0, 0.55, 0]); + return { + opacity, + transform: [{ scale }], + }; + }); + + return ( + + ); +} + +export default function PulseRing({ color, size, speedMs = 2600 }: PulseRingProps) { + const progress = useSharedValue(0); + + useEffect(() => { + progress.value = 0; + progress.value = withRepeat( + withTiming(1, { duration: speedMs, easing: Easing.linear }), + -1, // infinite + false // no reverse — rings always travel outward + ); + return () => cancelAnimation(progress); + }, [progress, speedMs]); + + return ( + + {Array.from({ length: RING_COUNT }).map((_, i) => ( + + ))} + + ); +} + +const styles = StyleSheet.create({ + container: { + alignItems: "center", + justifyContent: "center", + }, + ring: { + position: "absolute", + borderWidth: 1.5, + }, +}); diff --git a/frontend/src/components/ScoreBreakdown.tsx b/frontend/src/components/ScoreBreakdown.tsx new file mode 100644 index 0000000..9fa3ea3 --- /dev/null +++ b/frontend/src/components/ScoreBreakdown.tsx @@ -0,0 +1,166 @@ +// ScoreBreakdown — horizontal bars for the 5 signal streams. +// Owned by Wesley (visual layer). "Tesla dashboard for your mind." +// +// Each bar shows how much *strain* a single signal is contributing (higher = +// more strain), so it reads independently of the overall wellness score. Bars +// fill 0..100, colored low→high (green → yellow → red), and animate their width +// in on mount / when values change. + +import React, { useEffect, useMemo } from "react"; +import { StyleSheet, Text, View } from "react-native"; +import Animated, { + Easing, + useAnimatedStyle, + useSharedValue, + withDelay, + withTiming, +} from "react-native-reanimated"; + +import { COLORS, RADIUS, SPACING, TYPE } from "../utils/colors"; + +type Stream = "imessage" | "typing" | "facial" | "voice" | "tribe"; + +interface ScoreBreakdownProps { + breakdown: { + imessage: number; + typing: number; + facial: number; + voice: number; + tribe: number; + }; +} + +// Display order + human labels for each stream. +const STREAMS: { key: Stream; label: string }[] = [ + { key: "imessage", label: "iMessage" }, + { key: "typing", label: "Typing" }, + { key: "facial", label: "Facial" }, + { key: "voice", label: "Voice" }, + { key: "tribe", label: "Tribe" }, +]; + +// Clamp any incoming number into the 0..100 range we render against. +function clamp(n: number): number { + if (n == null || Number.isNaN(n)) return 0; + if (n < 0) return 0; + if (n > 100) return 100; + return n; +} + +// Higher stream value = more strain. Green calm, yellow watch, red alert. +function barColor(value: number): string { + if (value >= 66) return COLORS.red; + if (value >= 33) return COLORS.yellow; + return COLORS.green; +} + +function Bar({ + label, + value, + index, +}: { + label: string; + value: number; + index: number; +}) { + const progress = useSharedValue(0); + const color = barColor(value); + + useEffect(() => { + progress.value = withDelay( + index * 90, + withTiming(value, { + duration: 650, + easing: Easing.out(Easing.cubic), + }) + ); + }, [value, index, progress]); + + const fillStyle = useAnimatedStyle(() => ({ + width: `${progress.value}%`, + })); + + return ( + + + {label} + {Math.round(value)} + + + + + + ); +} + +export default function ScoreBreakdown({ breakdown }: ScoreBreakdownProps) { + const rows = useMemo( + () => + STREAMS.map(({ key, label }) => ({ + key, + label, + value: clamp(breakdown?.[key]), + })), + [breakdown] + ); + + return ( + + Signal Breakdown + {rows.map((row, i) => ( + + ))} + + ); +} + +const styles = StyleSheet.create({ + card: { + backgroundColor: COLORS.card, + borderColor: COLORS.border, + borderWidth: 1, + borderRadius: RADIUS.lg, + padding: SPACING.lg, + shadowColor: "#000", + shadowOpacity: 0.06, + shadowRadius: 16, + shadowOffset: { width: 0, height: 6 }, + elevation: 2, + }, + title: { + ...TYPE.label, + color: COLORS.textDim, + textTransform: "uppercase", + marginBottom: SPACING.lg, + }, + row: { + marginBottom: SPACING.md, + }, + rowHeader: { + flexDirection: "row", + justifyContent: "space-between", + alignItems: "baseline", + marginBottom: SPACING.sm, + }, + label: { + ...TYPE.body, + color: COLORS.text, + }, + value: { + fontSize: 16, + fontWeight: "700", + letterSpacing: -0.2, + }, + track: { + height: 8, + borderRadius: RADIUS.pill, + backgroundColor: "rgba(0,0,0,0.06)", + overflow: "hidden", + }, + fill: { + height: "100%", + borderRadius: RADIUS.pill, + }, +}); diff --git a/frontend/src/components/ScrollEdgeFade.tsx b/frontend/src/components/ScrollEdgeFade.tsx new file mode 100644 index 0000000..656eb0f --- /dev/null +++ b/frontend/src/components/ScrollEdgeFade.tsx @@ -0,0 +1,89 @@ +// ScrollEdgeFade — the soft frosted bands at the top and bottom of a scroll +// view, the way iOS large-title screens and tab bars blur the content that +// slides under them. Two translucent BlurView overlays pinned to the top and +// bottom edges, each grounded with a gentle gradient wash so content dissolves +// rather than hard-clipping. Purely decorative: pointerEvents is disabled so +// touches pass straight through to the ScrollView underneath. +// +// Render this as a sibling *after* the ScrollView, inside a flex:1 container. +import React from "react"; +import { StyleSheet, View } from "react-native"; +import { BlurView } from "expo-blur"; +import { LinearGradient } from "expo-linear-gradient"; + +import { COLORS } from "../utils/colors"; + +interface ScrollEdgeFadeProps { + // Safe-area top inset; the top band extends a little past it. + topInset: number; + // How far the top band reaches below the safe-area top. Keep it small and + // Apple-subtle. + topExtra?: number; + // Height of the bottom band sitting above the tab bar. + bottomHeight?: number; + // BlurView strength. ~20–40 reads as a soft frost without smearing content. + intensity?: number; +} + +// Convert a hex color to rgba with the given alpha (for the grounding washes). +function withAlpha(hex: string, alpha: number): string { + const h = hex.replace("#", ""); + const r = parseInt(h.slice(0, 2), 16); + const g = parseInt(h.slice(2, 4), 16); + const b = parseInt(h.slice(4, 6), 16); + return `rgba(${r}, ${g}, ${b}, ${alpha})`; +} + +export default function ScrollEdgeFade({ + topInset, + topExtra = 16, + bottomHeight = 32, + intensity = 28, +}: ScrollEdgeFadeProps) { + const topHeight = topInset + topExtra; + + return ( + <> + {/* Top frosted band — content blurs as it scrolls under the status bar. */} + + + {/* Opaque at the very top, dissolving downward so content slides under. */} + + + + {/* Bottom frosted band — content blurs above the tab bar. */} + + + {/* Transparent up top, grounding into the canvas at the bottom edge. */} + + + + ); +} + +const styles = StyleSheet.create({ + band: { + position: "absolute", + left: 0, + right: 0, + overflow: "hidden", + }, + top: { + top: 0, + }, + bottom: { + bottom: 0, + }, +}); diff --git a/frontend/src/components/SmsCheckinCard.tsx b/frontend/src/components/SmsCheckinCard.tsx new file mode 100644 index 0000000..2d06fed --- /dev/null +++ b/frontend/src/components/SmsCheckinCard.tsx @@ -0,0 +1,147 @@ +// SmsCheckinCard — "put in your number and Pegasus will text you." +// Registers the phone with the signals service, then fires a pulse-check text +// (an emotionally-evocative image stimulus via Bloo.io). The user replies by +// message and the bot scores the reply. Self-contained; drop into any screen. +import React, { useState } from "react"; +import { ActivityIndicator, Pressable, StyleSheet, Text, TextInput, View } from "react-native"; + +import { registerPhone, sendCheckin } from "../services/api"; +import { DEFAULT_USER_ID } from "../services/config"; +import { COLORS, RADIUS, SPACING, TYPE } from "../utils/colors"; + +type Status = "idle" | "sending" | "sent" | "error"; + +// Light E.164 normalization for US numbers: keep a leading +, otherwise assume +// +1 for 10 digits (or 11 starting with 1). Good enough for the demo. +function normalize(raw: string): string { + const trimmed = raw.trim(); + if (trimmed.startsWith("+")) return "+" + trimmed.slice(1).replace(/\D/g, ""); + const digits = trimmed.replace(/\D/g, ""); + if (digits.length === 10) return `+1${digits}`; + if (digits.length === 11 && digits.startsWith("1")) return `+${digits}`; + return `+${digits}`; +} + +export default function SmsCheckinCard() { + const [phone, setPhone] = useState(""); + const [status, setStatus] = useState("idle"); + const [msg, setMsg] = useState(null); + + const valid = phone.replace(/\D/g, "").length >= 10; + + const onSend = async () => { + if (!valid || status === "sending") return; + const e164 = normalize(phone); + setStatus("sending"); + setMsg(null); + try { + await registerPhone(DEFAULT_USER_ID, e164); + const res = await sendCheckin(DEFAULT_USER_ID, e164); + if (res?.sent === false) { + setStatus("error"); + setMsg(res.detail ?? "Couldn't send the text right now."); + } else { + setStatus("sent"); + setMsg(`Sent to ${e164} 📱 — check your messages and reply to it.`); + } + } catch (e: any) { + setStatus("error"); + setMsg(e?.message ?? "Couldn't send. Is the signals service running?"); + } + }; + + return ( + + + Get check-ins by text + + + Pegasus texts you a quick image to react to, then reads your reply — no app needed. + + + + { + setPhone(t); + if (status !== "idle") setStatus("idle"); + }} + placeholder="(555) 123-4567" + placeholderTextColor={COLORS.textDim} + keyboardType="phone-pad" + returnKeyType="send" + onSubmitEditing={onSend} + allowFontScaling={false} + /> + [ + styles.btn, + (!valid || status === "sending") && styles.btnDisabled, + pressed && styles.pressed, + ]} + > + {status === "sending" ? ( + + ) : ( + + Text me + + )} + + + + {msg ? ( + + {msg} + + ) : null} + + ); +} + +const styles = StyleSheet.create({ + card: { + backgroundColor: COLORS.card, + borderWidth: 1, + borderColor: COLORS.border, + borderRadius: RADIUS.lg, + padding: SPACING.lg, + shadowColor: "#000", + shadowOpacity: 0.06, + shadowRadius: 16, + shadowOffset: { width: 0, height: 6 }, + elevation: 2, + }, + title: { ...TYPE.heading, color: COLORS.text }, + sub: { ...TYPE.caption, color: COLORS.textDim, marginTop: SPACING.xs, lineHeight: 18 }, + row: { flexDirection: "row", alignItems: "center", marginTop: SPACING.md, gap: SPACING.sm }, + input: { + flex: 1, + height: 48, + backgroundColor: COLORS.bg, + borderWidth: 1, + borderColor: COLORS.border, + borderRadius: RADIUS.md, + paddingHorizontal: SPACING.md, + color: COLORS.text, + ...TYPE.body, + }, + btn: { + height: 48, + paddingHorizontal: SPACING.lg, + borderRadius: RADIUS.md, + backgroundColor: COLORS.green, + alignItems: "center", + justifyContent: "center", + }, + btnDisabled: { opacity: 0.4 }, + btnText: { ...TYPE.body, color: "#ffffff", fontWeight: "700" }, + pressed: { opacity: 0.7 }, + msg: { ...TYPE.caption, marginTop: SPACING.md, lineHeight: 18 }, +}); diff --git a/frontend/src/components/TrendChart.tsx b/frontend/src/components/TrendChart.tsx new file mode 100644 index 0000000..f91fb49 --- /dev/null +++ b/frontend/src/components/TrendChart.tsx @@ -0,0 +1,159 @@ +// TrendChart — wellness score history as a calm line chart. +// Wraps react-native-chart-kit's LineChart. Score is 0..100 where higher is +// better; the line is tinted by the wellness zone of the most recent score +// (green = thriving, yellow = neutral, red = running low). +import React, { useMemo } from "react"; +import { StyleSheet, Text, View } from "react-native"; +import { LineChart } from "react-native-chart-kit"; +import { COLORS, RADIUS, SPACING, TYPE } from "../utils/colors"; + +const CHART_HEIGHT = 200; +const CARD_PADDING = SPACING.md; + +interface TrendChartProps { + scores: number[]; + labels?: string[]; +} + +// Wellness score zone -> accent hex. Higher score = better, matching the +// backend thresholds and levelColor (green >= 70, yellow >= 40, else red). +function zoneColor(score: number): string { + if (score >= 70) return COLORS.green; + if (score >= 40) return COLORS.yellow; + return COLORS.red; +} + +// chart-kit expects a color function: (opacity) => rgba string. +function hexToRgba(hex: string, opacity: number): string { + const clean = hex.replace("#", ""); + const r = parseInt(clean.slice(0, 2), 16); + const g = parseInt(clean.slice(2, 4), 16); + const b = parseInt(clean.slice(4, 6), 16); + return `rgba(${r}, ${g}, ${b}, ${opacity})`; +} + +export default function TrendChart({ scores, labels }: TrendChartProps) { + // chart-kit's container has horizontal padding baked in; measuring the + // available width keeps the chart from overflowing the card. + const [chartWidth, setChartWidth] = React.useState(0); + + const lineColor = useMemo( + () => (scores.length > 0 ? zoneColor(scores[scores.length - 1]) : COLORS.blue), + [scores] + ); + + const chartConfig = useMemo( + () => ({ + backgroundGradientFrom: COLORS.card, + backgroundGradientTo: COLORS.card, + backgroundGradientFromOpacity: 1, + backgroundGradientToOpacity: 1, + decimalPlaces: 0, + color: (opacity = 1) => hexToRgba(lineColor, opacity), + labelColor: (opacity = 1) => hexToRgba(COLORS.textDim, opacity), + propsForBackgroundLines: { + stroke: COLORS.border, + strokeWidth: 1, + }, + propsForDots: { + r: "4", + strokeWidth: "2", + stroke: lineColor, + fill: COLORS.bg, + }, + propsForLabels: { + fontSize: 11, + }, + }), + [lineColor] + ); + + // <2 points can't form a trend — show a calm placeholder instead. + if (!scores || scores.length < 2) { + return ( + + + Not enough data yet + + Check in a few more times to see your wellness trend. + + + + ); + } + + const data = { + labels: labels && labels.length === scores.length ? labels : scores.map(() => ""), + datasets: [ + { + data: scores, + color: (opacity = 1) => hexToRgba(lineColor, opacity), + strokeWidth: 3, + }, + ], + }; + + return ( + setChartWidth(e.nativeEvent.layout.width)} + > + {chartWidth > 0 ? ( + + ) : ( + // First render before layout measures the card width. + + )} + + ); +} + +const styles = StyleSheet.create({ + card: { + backgroundColor: COLORS.card, + borderWidth: 1, + borderColor: COLORS.border, + borderRadius: RADIUS.lg, + paddingVertical: CARD_PADDING, + paddingHorizontal: CARD_PADDING, + overflow: "hidden", + shadowColor: "#000", + shadowOpacity: 0.06, + shadowRadius: 16, + shadowOffset: { width: 0, height: 6 }, + elevation: 2, + }, + chart: { + marginVertical: 0, + borderRadius: RADIUS.md, + paddingRight: 0, + }, + placeholder: { + alignItems: "center", + justifyContent: "center", + paddingHorizontal: SPACING.lg, + }, + placeholderTitle: { + ...TYPE.heading, + color: COLORS.text, + marginBottom: SPACING.sm, + textAlign: "center", + }, + placeholderBody: { + ...TYPE.body, + color: COLORS.textDim, + textAlign: "center", + }, +}); diff --git a/frontend/src/hooks/useBurnoutScore.ts b/frontend/src/hooks/useBurnoutScore.ts new file mode 100644 index 0000000..2bdbde7 --- /dev/null +++ b/frontend/src/hooks/useBurnoutScore.ts @@ -0,0 +1,40 @@ +// Owned by Rishith. Live burnout score with polling. +// const { score, loading, error, refresh } = useBurnoutScore(DEFAULT_USER_ID); +import { useCallback, useEffect, useRef, useState } from "react"; +import { BurnoutResult } from "../types"; +import { getScore } from "../services/api"; +import { SCORE_POLL_MS } from "../services/config"; + +export function useBurnoutScore(userId: string, pollMs: number = SCORE_POLL_MS) { + const [score, setScore] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const mounted = useRef(true); + + const refresh = useCallback(async () => { + setLoading(true); + try { + const data = await getScore(userId); + if (mounted.current) { + setScore(data); + setError(null); + } + } catch (e: any) { + if (mounted.current) setError(e?.message ?? "failed to fetch score"); + } finally { + if (mounted.current) setLoading(false); + } + }, [userId]); + + useEffect(() => { + mounted.current = true; + refresh(); + const id = setInterval(refresh, pollMs); + return () => { + mounted.current = false; + clearInterval(id); + }; + }, [refresh, pollMs]); + + return { score, loading, error, refresh }; +} diff --git a/frontend/src/hooks/useCamera.ts b/frontend/src/hooks/useCamera.ts new file mode 100644 index 0000000..03fa190 --- /dev/null +++ b/frontend/src/hooks/useCamera.ts @@ -0,0 +1,57 @@ +// Owned by Rishith. expo-camera wrapper for the 30-60s video check-in. +// Wesley renders the with the returned ref: +// const cam = useCamera(); +// +// const uri = await cam.startRecording(60); // resolves when stopped / maxDuration +// cam.stopRecording(); // stop early +import { useCallback, useRef, useState } from "react"; +import { + CameraView, + useCameraPermissions, + useMicrophonePermissions, +} from "expo-camera"; + +export function useCamera() { + const cameraRef = useRef(null); + const [camPerm, requestCamPerm] = useCameraPermissions(); + const [micPerm, requestMicPerm] = useMicrophonePermissions(); + const [isRecording, setIsRecording] = useState(false); + + const granted = !!camPerm?.granted && !!micPerm?.granted; + + const requestPermission = useCallback(async () => { + const c = await requestCamPerm(); + const m = await requestMicPerm(); + return !!c?.granted && !!m?.granted; + }, [requestCamPerm, requestMicPerm]); + + // Starts recording; the returned promise resolves with the video URI when + // recording stops (either via stopRecording() or hitting maxDuration). + const startRecording = useCallback(async (maxDurationSec: number = 60) => { + if (!cameraRef.current) return null; + setIsRecording(true); + try { + const video = await cameraRef.current.recordAsync({ maxDuration: maxDurationSec }); + return video?.uri ?? null; + } catch { + return null; + } finally { + setIsRecording(false); + } + }, []); + + const stopRecording = useCallback(() => { + cameraRef.current?.stopRecording(); + }, []); + + return { + cameraRef, + permission: camPerm, + micPermission: micPerm, + granted, + requestPermission, + isRecording, + startRecording, + stopRecording, + }; +} diff --git a/frontend/src/hooks/useConversation.ts b/frontend/src/hooks/useConversation.ts new file mode 100644 index 0000000..1b9bfed --- /dev/null +++ b/frontend/src/hooks/useConversation.ts @@ -0,0 +1,188 @@ +// Owned by Rishith. The Talk-tab is a FaceTime-style voice call with Pegasus: +// press-and-hold to talk -> we record (expo-av, with live mic metering that +// drives the on-screen sound orb) -> POST the clip to the video service +// /converse, which transcribes it (NVIDIA parakeet), reads voice stress, asks +// the ML companion for a reply, and speaks it back (NVIDIA Chatterbox). We play +// that audio so Pegasus literally talks. +// +// `level` (0..1) is the live mic amplitude while recording — the screen scales +// the orb to it. Every turn is grounded in the user's burnout score/level. +// Cleans up the recorder + player on unmount. Nothing here throws to the screen. +import { useCallback, useEffect, useRef, useState } from "react"; +import { Audio } from "expo-av"; +// SDK 54 moved the classic write/cacheDirectory API behind /legacy. +import * as FileSystem from "expo-file-system/legacy"; + +import { ChatMessage, ConverseResult } from "../types"; +import { converse, speak } from "../services/api"; + +const GREETING: ChatMessage = { + role: "assistant", + content: "Hey, I'm Pegasus. No pressure and no judgement — how are you really doing right now?", +}; + +const RECORD_MODE = { allowsRecordingIOS: true, playsInSilentModeIOS: true } as const; +const PLAY_MODE = { allowsRecordingIOS: false, playsInSilentModeIOS: true } as const; + +export function useConversation(userId: string, score?: number, level?: string) { + const [messages, setMessages] = useState([GREETING]); + const [sending, setSending] = useState(false); // a turn round-trip is in flight + const [recording, setRecording] = useState(false); + const [speaking, setSpeaking] = useState(false); // Pegasus's voice is playing + const [error, setError] = useState(null); + const [voice, setVoice] = useState(null); + const [micLevel, setMicLevel] = useState(0); // live mic amplitude 0..1 (drives the orb) + + const recRef = useRef(null); + const soundRef = useRef(null); + // Mirror messages so async turns read the latest transcript without re-binding. + const messagesRef = useRef(messages); + useEffect(() => { + messagesRef.current = messages; + }, [messages]); + + useEffect(() => { + return () => { + recRef.current?.stopAndUnloadAsync().catch(() => {}); + soundRef.current?.unloadAsync().catch(() => {}); + }; + }, []); + + const playB64 = useCallback(async (b64: string | null) => { + if (!b64) return; + try { + await Audio.setAudioModeAsync(PLAY_MODE); + const path = `${FileSystem.cacheDirectory}pegasus-reply-${Date.now()}.wav`; + await FileSystem.writeAsStringAsync(path, b64, { encoding: FileSystem.EncodingType.Base64 }); + await soundRef.current?.unloadAsync().catch(() => {}); + const { sound } = await Audio.Sound.createAsync({ uri: path }, { shouldPlay: true }); + soundRef.current = sound; + setSpeaking(true); + sound.setOnPlaybackStatusUpdate((st) => { + if (st.isLoaded && st.didJustFinish) { + setSpeaking(false); + sound.unloadAsync().catch(() => {}); + if (soundRef.current === sound) soundRef.current = null; + } + }); + } catch { + setSpeaking(false); + } + }, []); + + // --- VOICE turn (press to talk) ----------------------------------------- + const startRecording = useCallback(async () => { + if (recording || sending) return; + try { + const perm = await Audio.requestPermissionsAsync(); + if (!perm.granted) { + setError("Microphone access is needed to talk."); + return; + } + await Audio.setAudioModeAsync(RECORD_MODE); + const rec = new Audio.Recording(); + // Enable metering so the orb can react to how loudly you're speaking. + await rec.prepareToRecordAsync({ + ...Audio.RecordingOptionsPresets.HIGH_QUALITY, + isMeteringEnabled: true, + }); + rec.setProgressUpdateInterval(80); + rec.setOnRecordingStatusUpdate((st) => { + if (st.isRecording && typeof st.metering === "number") { + // metering is dBFS (~-60 quiet .. 0 loud) -> 0..1 + setMicLevel(Math.max(0, Math.min(1, (st.metering + 55) / 55))); + } + }); + await rec.startAsync(); + recRef.current = rec; + setRecording(true); + setError(null); + } catch { + setRecording(false); + setError("Couldn't start recording."); + } + }, [recording, sending]); + + const stopAndSend = useCallback(async () => { + const rec = recRef.current; + if (!rec) return; + setRecording(false); + setMicLevel(0); + let uri: string | null = null; + try { + await rec.stopAndUnloadAsync(); + uri = rec.getURI(); + } catch { + /* fall through to the no-uri guard */ + } + recRef.current = null; + if (!uri) { + setError("Didn't catch that — hold the mic and try again."); + return; + } + setSending(true); + setError(null); + try { + const res = await converse(userId, uri, messagesRef.current, score, level); + const turns: ChatMessage[] = []; + if (res.transcript) turns.push({ role: "user", content: res.transcript }); + turns.push({ role: "assistant", content: res.reply }); + setMessages((prev) => [...prev, ...turns]); + setVoice(res.voice ?? null); + playB64(res.audio_b64); + } catch (e: any) { + setError(e?.message ?? "Couldn't reach Pegasus. Check the video service is running."); + } finally { + setSending(false); + } + }, [userId, score, level, playB64]); + + // Stop recording WITHOUT sending — called when the screen loses focus or + // unmounts, so the recorder + audio session don't keep running in the + // background (which is what was crashing the app on tab switch). + const cancel = useCallback(async () => { + const rec = recRef.current; + recRef.current = null; + setRecording(false); + setMicLevel(0); + if (rec) { + try { + await rec.stopAndUnloadAsync(); + } catch {} + } + try { + await soundRef.current?.stopAsync(); + } catch {} + }, []); + + // Speak Pegasus's opening line aloud (called when entering voice mode). + const greet = useCallback(async () => { + try { + const { audio_b64 } = await speak(GREETING.content); + playB64(audio_b64); + } catch { + /* greeting stays text-only */ + } + }, [playB64]); + + const reset = useCallback(() => { + setMessages([GREETING]); + setError(null); + setVoice(null); + }, []); + + return { + messages, + sending, + recording, + speaking, + error, + voice, + level: micLevel, + startRecording, + stopAndSend, + cancel, + greet, + reset, + }; +} diff --git a/frontend/src/hooks/useResponseTracker.ts b/frontend/src/hooks/useResponseTracker.ts new file mode 100644 index 0000000..847fa66 --- /dev/null +++ b/frontend/src/hooks/useResponseTracker.ts @@ -0,0 +1,80 @@ +// Owned by Rishith. Captures typing/behavioral biometrics from a TextInput. +// Wesley wires the returned handlers into : +// const t = useResponseTracker(); +// { t.onChangeText(s); setText(s); }} /> +// const metrics = t.getMetrics(); // spread into submitResponse({...metrics}) +// t.reset(); // call when a new stimulus is shown +import { useCallback, useRef } from "react"; +import type { NativeSyntheticEvent, TextInputKeyPressEventData } from "react-native"; + +interface Metrics { + typing_wpm: number; + error_rate: number; + hesitation_count: number; + response_time_ms: number; + response_latency_ms: number; +} + +interface TrackerState { + shownAt: number; // when stimulus was shown (reset()) + startTime: number | null; // first keystroke + lastKeyTime: number | null; + keystrokes: number; + backspaces: number; + hesitations: number; + text: string; +} + +const fresh = (): TrackerState => ({ + shownAt: Date.now(), + startTime: null, + lastKeyTime: null, + keystrokes: 0, + backspaces: 0, + hesitations: 0, + text: "", +}); + +export function useResponseTracker() { + const s = useRef(fresh()); + + const onKeyPress = useCallback((e: NativeSyntheticEvent) => { + const now = Date.now(); + const key = e?.nativeEvent?.key; + const st = s.current; + if (st.startTime === null) st.startTime = now; + if (st.lastKeyTime !== null && now - st.lastKeyTime > 3000) st.hesitations++; + if (key === "Backspace") st.backspaces++; + st.keystrokes++; + st.lastKeyTime = now; + }, []); + + // onKeyPress isn't reliable for every platform/IME — track text length too. + const onChangeText = useCallback((value: string) => { + const st = s.current; + if (st.startTime === null) st.startTime = Date.now(); + st.text = value; + }, []); + + const getMetrics = useCallback((): Metrics => { + const st = s.current; + const now = Date.now(); + const start = st.startTime ?? now; + const elapsedMin = Math.max((now - start) / 60000, 1 / 60000); + const words = st.text.trim() ? st.text.trim().split(/\s+/).length : 0; + const typedChars = Math.max(st.keystrokes, st.text.length); + return { + typing_wpm: Math.round(words / elapsedMin) || 0, + error_rate: typedChars ? Math.round((st.backspaces / typedChars) * 100) : 0, + hesitation_count: st.hesitations, + response_time_ms: now - st.shownAt, + response_latency_ms: (st.startTime ?? now) - st.shownAt, + }; + }, []); + + const reset = useCallback(() => { + s.current = fresh(); + }, []); + + return { onKeyPress, onChangeText, getMetrics, reset }; +} diff --git a/frontend/src/navigation/TabNavigator.tsx b/frontend/src/navigation/TabNavigator.tsx new file mode 100644 index 0000000..cd81564 --- /dev/null +++ b/frontend/src/navigation/TabNavigator.tsx @@ -0,0 +1,216 @@ +// Bottom tab navigation for Pegasus. Owned by Wesley (visual layer). +// Dark, minimal, the active tab glows in its accent. Screens are built by +// other agents under src/screens. +import React from "react"; +import { Platform, StyleSheet, Text, View } from "react-native"; +import { createBottomTabNavigator } from "@react-navigation/bottom-tabs"; +import Svg, { Path, Circle } from "react-native-svg"; + +import { COLORS } from "../utils/colors"; + +import HomeScreen from "../screens/HomeScreen"; +import ChatScreen from "../screens/ChatScreen"; +import MetricsScreen from "../screens/MetricsScreen"; +import BrainScreen from "../screens/BrainScreen"; +import HistoryScreen from "../screens/HistoryScreen"; + +type TabParamList = { + Home: undefined; + Talk: undefined; + Metrics: undefined; + Brain: undefined; + History: undefined; +}; + +const Tab = createBottomTabNavigator(); + +// Minimal line icons drawn with SVG so we don't depend on an icon font. +function TabIcon({ name, color }: { name: keyof TabParamList; color: string }) { + const stroke = color; + const sw = 1.8; + switch (name) { + case "Home": + return ( + + + + + ); + case "Talk": + return ( + + + + + ); + case "Metrics": + return ( + + + + ); + case "Brain": + return ( + + + + + ); + case "History": + return ( + + + + + ); + default: + return null; + } +} + +// Per-tab accent used for the active glow. Brain glows blue per design spec. +const TAB_ACCENT: Record = { + Home: COLORS.green, + Talk: COLORS.blue, + Metrics: COLORS.green, + Brain: COLORS.blue, + History: COLORS.green, +}; + +const LABELS: Record = { + Home: "Home", + Talk: "Talk", + Metrics: "Metrics", + Brain: "Brain", + History: "History", +}; + +export default function TabNavigator() { + return ( + { + const name = route.name as keyof TabParamList; + const accent = TAB_ACCENT[name]; + return { + headerShown: false, + tabBarShowLabel: true, + tabBarActiveTintColor: accent, + tabBarInactiveTintColor: COLORS.textDim, + tabBarStyle: styles.tabBar, + tabBarItemStyle: styles.tabItem, + tabBarLabel: ({ focused }) => ( + + {LABELS[name]} + + ), + tabBarIcon: ({ focused }) => ( + + + + ), + }; + }} + > + + + + + + + ); +} + +const styles = StyleSheet.create({ + tabBar: { + backgroundColor: COLORS.card, + borderTopColor: COLORS.border, + borderTopWidth: 1, + height: Platform.OS === "ios" ? 88 : 68, + paddingTop: 10, + paddingBottom: Platform.OS === "ios" ? 28 : 10, + // Soft upward elevation so the bar reads as a floating surface on the + // light canvas (Apple-style). + shadowColor: "#000", + shadowOpacity: 0.06, + shadowRadius: 12, + shadowOffset: { width: 0, height: -3 }, + elevation: 8, + }, + tabItem: { + paddingTop: 2, + }, + iconWrap: { + alignItems: "center", + justifyContent: "center", + height: 26, + }, + label: { + fontSize: 10, + fontWeight: "600", + letterSpacing: 0.3, + marginTop: 2, + }, +}); diff --git a/frontend/src/screens/BrainScreen.tsx b/frontend/src/screens/BrainScreen.tsx new file mode 100644 index 0000000..956c0d0 --- /dev/null +++ b/frontend/src/screens/BrainScreen.tsx @@ -0,0 +1,587 @@ +// BrainScreen — "Your Brain Activity", a calm, plain-English readout of the live +// neural map. We pull BrainData for the demo user and, for each region, simply +// state whether it's affected and what that means — no 3D, no jargon. +// +// A "Your Pattern" / "Healthy Baseline" toggle drives the numbers: in Baseline +// mode every region is shown at a calm resting activation so you can see what a +// settled brain reads like; in Your Pattern mode we show the real measured +// activations. Regions are ordered most-affected first. +import React, { useEffect, useMemo, useState } from "react"; +import { + ActivityIndicator, + Pressable, + RefreshControl, + ScrollView, + StyleSheet, + Text, + View, +} from "react-native"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; + +import { getBrainData } from "../services/api"; +import { DEFAULT_USER_ID } from "../services/config"; +import { BrainData } from "../types"; +import { COLORS, RADIUS, SPACING, TYPE } from "../utils/colors"; +import ScrollEdgeFade from "../components/ScrollEdgeFade"; + +type RegionKey = keyof BrainData["regions"]; + +// Human-readable name + a plain-English description of what elevated activation +// in each region tends to mean for stress / burnout. Kept gentle, not clinical. +const REGION_INFO: Record< + RegionKey, + { name: string; plain: string } +> = { + prefrontal_cortex: { + name: "Prefrontal Cortex", + plain: + "Your focus and decision-making hub. When it runs hot, planning feels harder and small choices get heavy.", + }, + amygdala_region: { + name: "Amygdala", + plain: + "Your alarm system. Elevated activity here points to heightened stress, worry, or feeling on-edge.", + }, + temporal_lobe: { + name: "Temporal Lobe", + plain: + "Tied to memory and processing language. High activation can show up as mental fog or replaying conversations.", + }, + motor_cortex: { + name: "Motor Cortex", + plain: + "Drives movement and restlessness. Spikes here often track with fidgeting, tension, or trouble settling.", + }, + visual_cortex: { + name: "Visual Cortex", + plain: + "Processes what you see. Sustained load here can reflect long, unbroken hours staring at screens.", + }, +}; + +// Stable tie-break order — most stress-relevant first. Used when two regions +// read the same activation, so the list never jitters between renders. +const REGION_ORDER: RegionKey[] = [ + "amygdala_region", + "prefrontal_cortex", + "temporal_lobe", + "motor_cortex", + "visual_cortex", +]; + +// Thresholds. At/above HOT a region is "Affected"; at/above WARM it's +// "Elevated"; below that it's "Calm". +const HOT_THRESHOLD = 0.6; +const WARM_THRESHOLD = 0.4; + +// Calm resting activations used for the "Healthy Baseline" view. Low and even — +// what a settled brain looks like. +const BASELINE: BrainData["regions"] = { + prefrontal_cortex: 0.22, + amygdala_region: 0.18, + temporal_lobe: 0.24, + motor_cortex: 0.2, + visual_cortex: 0.26, +}; + +type Status = { + label: string; // pill + headline word, e.g. "Affected" + accent: string; // bar + pill color + tint: string; // pill background wash +}; + +// Activation 0..1 -> status. Red "Affected", amber "Elevated", green "Calm". +function statusFor(a: number): Status { + if (a >= HOT_THRESHOLD) { + return { label: "Affected", accent: COLORS.red, tint: "rgba(255,59,48,0.12)" }; + } + if (a >= WARM_THRESHOLD) { + return { label: "Elevated", accent: COLORS.yellow, tint: "rgba(255,159,10,0.14)" }; + } + return { label: "Calm", accent: COLORS.green, tint: "rgba(52,199,89,0.14)" }; +} + +// One-line, human verdict for a region given its activation. +function verdictFor(name: string, a: number): string { + if (a >= HOT_THRESHOLD) return `${name} — affected. This region is reading hot.`; + if (a >= WARM_THRESHOLD) + return `${name} — slightly elevated. Worth keeping an eye on.`; + return `${name} — calm. Sitting in a healthy range.`; +} + +export default function BrainScreen() { + const insets = useSafeAreaInsets(); + + const [brain, setBrain] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + // false = "Your Pattern" (live data), true = "Healthy Baseline". + const [baseline, setBaseline] = useState(false); + + const load = React.useCallback(() => { + setLoading(true); + setError(null); + getBrainData(DEFAULT_USER_ID) + .then((d) => setBrain(d)) + .catch((e) => setError(e?.message ?? "Could not reach the brain map.")) + .finally(() => setLoading(false)); + }, []); + + useEffect(() => { + load(); + }, [load]); + + // The region set fed to the cards. Baseline mode shows calm resting values; + // otherwise the measured data. + const shownRegions = baseline ? BASELINE : brain?.regions ?? null; + + // Regions sorted most-affected first; ties broken by REGION_ORDER. + const ranked = useMemo(() => { + if (!shownRegions) return []; + return [...REGION_ORDER].sort((a, b) => { + const diff = (shownRegions[b] ?? 0) - (shownRegions[a] ?? 0); + if (diff !== 0) return diff; + return REGION_ORDER.indexOf(a) - REGION_ORDER.indexOf(b); + }); + }, [shownRegions]); + + // How many regions are reading hot — drives the summary line. + const hotCount = useMemo( + () => + shownRegions + ? REGION_ORDER.filter((k) => (shownRegions[k] ?? 0) >= HOT_THRESHOLD).length + : 0, + [shownRegions] + ); + + // First load: calm, minimal spinner. + if (loading && !brain) { + return ( + + META TRIBE v2 + + Reading your neural signals… + + ); + } + + // Error / unreachable: clear, non-alarming fallback with a retry. + if (error && !brain) { + return ( + + META TRIBE v2 + Brain map unavailable + {error} + + Try again + + + ); + } + + const summaryText = + hotCount === 0 + ? "All regions calm" + : `${hotCount} region${hotCount === 1 ? "" : "s"} reading hot`; + + return ( + + + } + > + {/* Header */} + + Your Brain Activity + + + powered by Meta TRIBE v2 + + + + {/* Summary banner */} + + + {summaryText} + + + {/* Your Pattern / Healthy Baseline toggle (drives the readout) */} + + + {/* Per-region readout — most affected first */} + + AFFECTED REGIONS + + {shownRegions ? ( + + {ranked.map((key) => ( + + ))} + + ) : ( + + + No neural data yet. + + )} + + + {brain && ( + + Mean activation across all regions:{" "} + + {Math.round((brain.activation_mean ?? 0) * 100)}% + + + )} + + + {/* Frosted top/bottom bands — content blurs softly as it scrolls under. */} + + + ); +} + +// Plain segmented control — no animation, just clean state. Selecting a segment +// swaps the activations the cards read from. +function ModeToggle({ + baseline, + onSelect, +}: { + baseline: boolean; + onSelect: (baseline: boolean) => void; +}) { + return ( + + + onSelect(false)} + accessibilityRole="button" + accessibilityState={{ selected: !baseline }} + accessibilityLabel="Show your pattern" + > + + Your Pattern + + + onSelect(true)} + accessibilityRole="button" + accessibilityState={{ selected: baseline }} + accessibilityLabel="Show healthy baseline" + > + + Healthy Baseline + + + + + ); +} + +// One region, stated plainly: name + status pill, a thin activation bar, the +// one-line verdict, and the gentle description of what this region does. +function RegionCard({ + region, + activation, +}: { + region: RegionKey; + activation: number; +}) { + const info = REGION_INFO[region]; + const status = statusFor(activation); + const pct = Math.round(activation * 100); + + return ( + + + {info.name} + + + + {status.label} + + + + + + + + + {pct}% + + + {verdictFor(info.name, activation)} + {info.plain} + + ); +} + +const styles = StyleSheet.create({ + root: { + flex: 1, + backgroundColor: COLORS.bg, + }, + centerRoot: { + alignItems: "center", + justifyContent: "center", + padding: SPACING.lg, + }, + scroll: { + paddingHorizontal: SPACING.lg, + }, + kicker: { + ...TYPE.label, + color: COLORS.blue, + letterSpacing: 2, + }, + loadingSpinner: { + marginTop: SPACING.xl, + }, + loadingText: { + ...TYPE.body, + color: COLORS.textDim, + marginTop: SPACING.md, + }, + errorTitle: { + ...TYPE.heading, + color: COLORS.text, + marginTop: SPACING.lg, + }, + errorBody: { + ...TYPE.body, + color: COLORS.textDim, + textAlign: "center", + marginTop: SPACING.sm, + lineHeight: 22, + }, + retryBtn: { + marginTop: SPACING.xl, + borderWidth: 1, + borderColor: COLORS.border, + borderRadius: RADIUS.md, + paddingVertical: SPACING.md, + paddingHorizontal: SPACING.xl, + backgroundColor: COLORS.card, + }, + retryText: { + ...TYPE.body, + color: COLORS.text, + fontWeight: "700", + }, + + // Header + header: { + marginBottom: SPACING.lg, + }, + title: { + ...TYPE.title, + color: COLORS.text, + }, + poweredRow: { + flexDirection: "row", + alignItems: "center", + marginTop: SPACING.sm, + }, + poweredDot: { + width: 6, + height: 6, + borderRadius: RADIUS.pill, + backgroundColor: COLORS.blue, + marginRight: SPACING.sm, + }, + powered: { + ...TYPE.caption, + color: COLORS.textDim, + letterSpacing: 0.6, + }, + + // Summary banner + summaryCard: { + flexDirection: "row", + alignItems: "center", + backgroundColor: COLORS.card, + borderWidth: 1, + borderColor: COLORS.border, + borderRadius: RADIUS.md, + paddingVertical: SPACING.md, + paddingHorizontal: SPACING.lg, + shadowColor: "#000", + shadowOpacity: 0.06, + shadowRadius: 14, + shadowOffset: { width: 0, height: 5 }, + elevation: 2, + }, + summaryDot: { + width: 9, + height: 9, + borderRadius: RADIUS.pill, + marginRight: SPACING.md, + }, + summaryText: { + ...TYPE.heading, + color: COLORS.text, + flex: 1, + }, + + // Toggle + toggleWrap: { + marginTop: SPACING.lg, + }, + toggleTrack: { + flexDirection: "row", + backgroundColor: COLORS.card, + borderWidth: 1, + borderColor: COLORS.border, + borderRadius: RADIUS.pill, + padding: 4, + }, + toggleSeg: { + flex: 1, + alignItems: "center", + justifyContent: "center", + paddingVertical: SPACING.md, + borderRadius: RADIUS.pill, + }, + toggleSegActive: { + backgroundColor: "rgba(0,113,227,0.10)", + }, + toggleText: { + ...TYPE.label, + color: COLORS.textDim, + }, + toggleTextActive: { + color: COLORS.blue, + }, + + // Region readout + section: { + marginTop: SPACING.xl, + }, + sectionLabel: { + ...TYPE.label, + color: COLORS.textDim, + letterSpacing: 1.5, + marginBottom: SPACING.md, + }, + regionList: { + gap: SPACING.md, + }, + regionCard: { + backgroundColor: COLORS.card, + borderWidth: 1, + borderColor: COLORS.border, + borderRadius: RADIUS.md, + padding: SPACING.lg, + shadowColor: "#000", + shadowOpacity: 0.06, + shadowRadius: 14, + shadowOffset: { width: 0, height: 5 }, + elevation: 2, + }, + regionHeader: { + flexDirection: "row", + alignItems: "center", + }, + regionName: { + ...TYPE.heading, + color: COLORS.text, + flex: 1, + }, + pill: { + flexDirection: "row", + alignItems: "center", + borderRadius: RADIUS.pill, + paddingVertical: 5, + paddingHorizontal: 10, + }, + pillDot: { + width: 6, + height: 6, + borderRadius: RADIUS.pill, + marginRight: SPACING.xs + 2, + }, + pillText: { + ...TYPE.label, + letterSpacing: 0.2, + }, + barRow: { + flexDirection: "row", + alignItems: "center", + marginTop: SPACING.md, + }, + barTrack: { + flex: 1, + height: 6, + borderRadius: RADIUS.pill, + backgroundColor: "rgba(0,0,0,0.06)", + overflow: "hidden", + }, + barFill: { + height: "100%", + borderRadius: RADIUS.pill, + }, + barPct: { + ...TYPE.label, + fontVariant: ["tabular-nums"], + marginLeft: SPACING.md, + width: 40, + textAlign: "right", + }, + regionVerdict: { + ...TYPE.body, + fontWeight: "600", + color: COLORS.text, + lineHeight: 22, + marginTop: SPACING.md, + }, + regionPlain: { + ...TYPE.body, + color: COLORS.textDim, + lineHeight: 22, + marginTop: SPACING.xs, + }, + + meanFootnote: { + ...TYPE.caption, + color: COLORS.textDim, + textAlign: "center", + marginTop: SPACING.xl, + }, + meanValue: { + color: COLORS.text, + fontWeight: "700", + }, +}); diff --git a/frontend/src/screens/ChatScreen.tsx b/frontend/src/screens/ChatScreen.tsx new file mode 100644 index 0000000..11fe36a --- /dev/null +++ b/frontend/src/screens/ChatScreen.tsx @@ -0,0 +1,369 @@ +// ChatScreen — the Talk tab: a check-in "call" with Pegasus. +// +// Your front camera fills the screen with a face outline to center yourself. +// TAP the shutter to start talking, tap again to send (no holding). NVIDIA STT +// transcribes you → the companion replies to your actual words → it speaks back +// (NVIDIA TTS). The face outline pulses with your voice; a live emotion read +// (face + voice) and the last exchange show as captions. +// +// Crash-safe: when you switch tabs (lose focus) we stop recording + the camera +// so nothing keeps running in the background. +import React, { useEffect, useRef, useState } from "react"; +import { ActivityIndicator, Pressable, StyleSheet, Text, View } from "react-native"; +import { CameraView, useCameraPermissions } from "expo-camera"; +import { LinearGradient } from "expo-linear-gradient"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; +import { useIsFocused } from "@react-navigation/native"; +import Animated, { + Easing, + cancelAnimation, + useAnimatedStyle, + useSharedValue, + withRepeat, + withSequence, + withTiming, +} from "react-native-reanimated"; + +import { useConversation } from "../hooks/useConversation"; +import { useBurnoutScore } from "../hooks/useBurnoutScore"; +import { analyzeFrame } from "../services/api"; +import { DEFAULT_USER_ID } from "../services/config"; +import { COLORS, RADIUS, SPACING, TYPE, levelColor } from "../utils/colors"; + +function moodLabel(face: string | null, tremor?: boolean, pitchVar?: number): string { + const voice = tremor ? "shaky voice" : (pitchVar ?? 0) > 55 ? "tense voice" : "steady voice"; + return face ? `${face} · ${voice}` : voice; +} + +export default function ChatScreen() { + const insets = useSafeAreaInsets(); + const isFocused = useIsFocused(); + const { score } = useBurnoutScore(DEFAULT_USER_ID); + const { recording, sending, speaking, error, level, voice, messages, startRecording, stopAndSend, cancel, greet } = + useConversation(DEFAULT_USER_ID, score?.score, score?.level); + + const [camPerm, requestCamPerm] = useCameraPermissions(); + const camRef = useRef(null); + const [faceEmotion, setFaceEmotion] = useState(null); + + const accent = score?.level ? levelColor(score.level) : COLORS.blue; + + // Open the call once: camera + Pegasus speaks first. + const started = useRef(false); + useEffect(() => { + if (started.current) return; + started.current = true; + (async () => { + try { + if (!camPerm?.granted) await requestCamPerm(); + } catch {} + greet(); + })(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + // Leaving the tab → stop recording + camera so nothing runs in the background. + useEffect(() => { + if (!isFocused) cancel(); + }, [isFocused, cancel]); + + const busyRef = useRef(false); + useEffect(() => { + busyRef.current = recording || sending; + }, [recording, sending]); + + // Live facial emotion — only while the tab is focused + not mid-recording. + useEffect(() => { + if (!isFocused || !camPerm?.granted) return; + let alive = true; + const id = setInterval(async () => { + if (!camRef.current || busyRef.current) return; + try { + const photo = await camRef.current.takePictureAsync({ quality: 0.4, skipProcessing: true }); + if (!photo?.uri) return; + const r = await analyzeFrame(photo.uri); + const dist = r?.emotion_distribution ?? {}; + const top = Object.entries(dist).sort((a, b) => b[1] - a[1])[0]?.[0]; + if (alive) setFaceEmotion(top ?? r?.overall_affect ?? null); + } catch { + /* optional */ + } + }, 9000); + return () => { + alive = false; + clearInterval(id); + }; + }, [isFocused, camPerm?.granted]); + + // --- the face-outline glow (pulses with your voice) ------------------- + const amp = useSharedValue(0.2); + useEffect(() => { + if (recording) return; + cancelAnimation(amp); + if (speaking) { + amp.value = withRepeat( + withSequence( + withTiming(0.9, { duration: 340, easing: Easing.inOut(Easing.ease) }), + withTiming(0.45, { duration: 340, easing: Easing.inOut(Easing.ease) }) + ), + -1, + true + ); + } else { + amp.value = withRepeat( + withSequence( + withTiming(0.32, { duration: 1700, easing: Easing.inOut(Easing.ease) }), + withTiming(0.14, { duration: 1700, easing: Easing.inOut(Easing.ease) }) + ), + -1, + true + ); + } + return () => cancelAnimation(amp); + }, [recording, speaking, amp]); + useEffect(() => { + if (!recording) return; + amp.value = withTiming(Math.max(0.2, Math.min(1, level)), { duration: 90 }); + }, [level, recording, amp]); + + const ovalGlow = useAnimatedStyle(() => ({ + shadowOpacity: 0.25 + amp.value * 0.55, + shadowRadius: 14 + amp.value * 26, + transform: [{ scale: 1 + amp.value * 0.04 }], + })); + + const onShutter = () => { + if (sending) return; + if (recording) stopAndSend(); + else startRecording(); + }; + + const lastAI = [...messages].reverse().find((m) => m.role === "assistant"); + const lastUser = [...messages].reverse().find((m) => m.role === "user"); + const status = recording + ? "Listening — tap to send" + : sending + ? "Thinking…" + : speaking + ? "Pegasus is speaking…" + : "Tap to talk"; + + return ( + + {isFocused && camPerm?.granted ? ( + + ) : ( + + )} + + + + + {/* Face outline */} + + + + {recording ? "Speak naturally" : "Center your face"} + + + + {/* Header */} + + + Pegasus + + + {score != null ? `check-in · pulse ${score.score}/100` : "let's check in"} + + {(faceEmotion || voice) ? ( + + + + {moodLabel(faceEmotion, voice?.voice_tremor, voice?.pitch_variability)} + + + ) : null} + + + {/* Captions */} + + {lastUser ? ( + + “{lastUser.content}” + + ) : null} + {lastAI ? ( + + {lastAI.content} + + ) : null} + {error ? ( + + {error} + + ) : null} + + + {/* Apple-camera-style shutter (tap to talk) */} + + + + {status} + + + + ); +} + +function Shutter({ + recording, + sending, + onPress, +}: { + recording: boolean; + sending: boolean; + onPress: () => void; +}) { + const pulse = useSharedValue(0); + useEffect(() => { + cancelAnimation(pulse); + if (recording) { + pulse.value = withRepeat( + withSequence( + withTiming(1, { duration: 650, easing: Easing.inOut(Easing.ease) }), + withTiming(0, { duration: 650, easing: Easing.inOut(Easing.ease) }) + ), + -1, + false + ); + } else { + pulse.value = withTiming(0, { duration: 200 }); + } + return () => cancelAnimation(pulse); + }, [recording, pulse]); + + const innerStyle = useAnimatedStyle(() => ({ opacity: recording ? 0.65 + pulse.value * 0.35 : 1 })); + + return ( + [styles.shutterOuter, pressed && { opacity: 0.85 }, sending && { opacity: 0.5 }]} + accessibilityRole="button" + accessibilityLabel={recording ? "Stop and send" : "Tap to talk"} + > + + {sending ? : null} + + + ); +} + +const styles = StyleSheet.create({ + root: { flex: 1, backgroundColor: "#000" }, + topScrim: { position: "absolute", top: 0, left: 0, right: 0 }, + bottomScrim: { position: "absolute", bottom: 0, left: 0, right: 0, height: 320 }, + + ovalWrap: { ...StyleSheet.absoluteFillObject, alignItems: "center", justifyContent: "center" }, + oval: { + width: 230, + height: 300, + borderRadius: 150, + borderWidth: 2.5, + shadowOffset: { width: 0, height: 0 }, + }, + ovalHint: { + ...TYPE.caption, + color: "rgba(255,255,255,0.85)", + marginTop: SPACING.lg, + textShadowColor: "rgba(0,0,0,0.8)", + textShadowRadius: 5, + }, + + header: { paddingHorizontal: SPACING.lg, paddingBottom: SPACING.md }, + title: { ...TYPE.heading, color: "#fff" }, + sub: { ...TYPE.caption, color: "rgba(255,255,255,0.75)", marginTop: 2 }, + moodChip: { + flexDirection: "row", + alignItems: "center", + alignSelf: "flex-start", + marginTop: SPACING.sm, + backgroundColor: "rgba(0,0,0,0.4)", + borderRadius: RADIUS.pill, + paddingHorizontal: SPACING.md, + paddingVertical: 4, + }, + moodDot: { width: 7, height: 7, borderRadius: 4, marginRight: 6 }, + moodText: { ...TYPE.caption, color: "#fff", textTransform: "capitalize" }, + + captions: { + position: "absolute", + left: SPACING.lg, + right: SPACING.lg, + bottom: 210, + alignItems: "center", + gap: SPACING.sm, + }, + capYou: { + ...TYPE.body, + color: "rgba(255,255,255,0.8)", + fontStyle: "italic", + textAlign: "center", + textShadowColor: "rgba(0,0,0,0.8)", + textShadowRadius: 6, + }, + capAI: { + ...TYPE.title, + fontSize: 22, + lineHeight: 30, + color: "#fff", + textAlign: "center", + textShadowColor: "rgba(0,0,0,0.85)", + textShadowRadius: 8, + }, + err: { ...TYPE.caption, color: COLORS.yellow, textAlign: "center" }, + + dock: { position: "absolute", bottom: 0, left: 0, right: 0, alignItems: "center", paddingTop: SPACING.md }, + shutterOuter: { + width: 78, + height: 78, + borderRadius: 39, + borderWidth: 4, + borderColor: "#fff", + alignItems: "center", + justifyContent: "center", + backgroundColor: "transparent", + }, + shutterInner: { + width: 60, + height: 60, + borderRadius: 30, + backgroundColor: "#fff", + alignItems: "center", + justifyContent: "center", + }, + shutterInnerRec: { + width: 32, + height: 32, + borderRadius: 9, + backgroundColor: COLORS.red, + alignItems: "center", + justifyContent: "center", + }, + status: { ...TYPE.label, color: "#fff", marginTop: SPACING.md, letterSpacing: 0.4 }, +}); diff --git a/frontend/src/screens/CheckInScreen.tsx b/frontend/src/screens/CheckInScreen.tsx new file mode 100644 index 0000000..e22ce7b --- /dev/null +++ b/frontend/src/screens/CheckInScreen.tsx @@ -0,0 +1,1065 @@ +// CheckInScreen — the daily video check-in. +// Flow: permission gate -> live camera with face-oval + rotating prompt -> +// record (60s countdown ring + pulsing red dot) -> "Analyzing..." brain pulse +// -> facial/voice results + the freshly-updated Pegasus (burnout) score. +// +// Data layer is imported, never redefined. The camera lifecycle lives entirely +// in useCamera(); this screen only orchestrates UI state around it. +import React, { useCallback, useEffect, useRef, useState } from "react"; +import { + ActivityIndicator, + Pressable, + ScrollView, + StyleSheet, + Text, + View, +} from "react-native"; +import { CameraView } from "expo-camera"; +import { LinearGradient } from "expo-linear-gradient"; +import Animated, { + Easing, + cancelAnimation, + useAnimatedStyle, + useSharedValue, + withRepeat, + withSequence, + withTiming, +} from "react-native-reanimated"; + +import { VideoResult, BurnoutResult } from "../types"; +import { submitVideo } from "../services/api"; +import { DEFAULT_USER_ID } from "../services/config"; +import { useCamera } from "../hooks/useCamera"; +import { COLORS, RADIUS, SPACING, TYPE, levelColor } from "../utils/colors"; +import CheckEngine from "../components/CheckEngine"; + +// The backend (/video/submit) actually fuses facial + voice into a burnout +// score and returns it alongside the analysis. VideoResult doesn't declare it, +// so read it defensively rather than assuming it's always there. +type VideoResultWithBurnout = VideoResult & { burnout_result?: BurnoutResult }; + +const RECORD_SECONDS = 60; + +// Rotating prompts shown over the live preview to keep the check-in natural. +const PROMPTS = [ + "Tell me about your day", + "What's been on your mind lately?", + "How are you really feeling right now?", + "What gave you energy today? What drained it?", + "What's one thing you're looking forward to?", +]; + +type Phase = "preview" | "recording" | "analyzing" | "result" | "error"; + +export default function CheckInScreen() { + const cam = useCamera(); + const [phase, setPhase] = useState("preview"); + const [result, setResult] = useState(null); + const [errorMsg, setErrorMsg] = useState(null); + const [remaining, setRemaining] = useState(RECORD_SECONDS); + const [promptIndex, setPromptIndex] = useState(0); + + const countdownTimer = useRef | null>(null); + + const clearCountdown = useCallback(() => { + if (countdownTimer.current) { + clearInterval(countdownTimer.current); + countdownTimer.current = null; + } + }, []); + + // Rotate the prompt every few seconds while previewing / recording. + useEffect(() => { + if (phase !== "preview" && phase !== "recording") return; + const id = setInterval(() => { + setPromptIndex((i) => (i + 1) % PROMPTS.length); + }, 4500); + return () => clearInterval(id); + }, [phase]); + + useEffect(() => clearCountdown, [clearCountdown]); + + const analyze = useCallback(async (uri: string) => { + setPhase("analyzing"); + try { + const res = (await submitVideo(DEFAULT_USER_ID, uri)) as VideoResultWithBurnout; + setResult(res); + setPhase("result"); + } catch { + setErrorMsg("We couldn't analyze your check-in. Please try again."); + setPhase("error"); + } + }, []); + + const handleRecord = useCallback(async () => { + setErrorMsg(null); + setRemaining(RECORD_SECONDS); + setPhase("recording"); + + // Visible 60 -> 0 countdown. The real stop is driven by useCamera's + // maxDuration; this just keeps the ring + numerals in sync. + clearCountdown(); + countdownTimer.current = setInterval(() => { + setRemaining((r) => Math.max(0, r - 1)); + }, 1000); + + const uri = await cam.startRecording(RECORD_SECONDS); + clearCountdown(); + + if (!uri) { + setErrorMsg("Recording failed. Check camera + microphone access and try again."); + setPhase("error"); + return; + } + await analyze(uri); + }, [cam, analyze, clearCountdown]); + + const handleStopEarly = useCallback(() => { + // Resolves the startRecording promise -> handleRecord continues to analyze. + cam.stopRecording(); + }, [cam]); + + const reset = useCallback(() => { + clearCountdown(); + setResult(null); + setErrorMsg(null); + setRemaining(RECORD_SECONDS); + setPhase("preview"); + }, [clearCountdown]); + + // ---- Permission gate ----------------------------------------------------- + if (!cam.granted) { + const denied = + cam.permission?.granted === false && cam.permission?.canAskAgain === false; + return ( + + ); + } + + // ---- Analyzing ----------------------------------------------------------- + if (phase === "analyzing") { + return ; + } + + // ---- Result -------------------------------------------------------------- + if (phase === "result" && result) { + return ; + } + + // ---- Error --------------------------------------------------------------- + if (phase === "error") { + return ( + + ); + } + + // ---- Live camera (preview / recording) ----------------------------------- + const recording = phase === "recording"; + return ( + + + + {/* Darken edges so the face oval and controls read clearly. */} + + + + + + + DAILY CHECK-IN + + + {PROMPTS[promptIndex]} + + + + + {recording ? ( + + ) : ( + [styles.recordBtn, pressed && styles.pressed]} + > + + + Start check-in + + + )} + + + ); +} + +// --------------------------------------------------------------------------- +// Permission gate +// --------------------------------------------------------------------------- +function PermissionGate({ + denied, + onRequest, +}: { + denied: boolean; + onRequest: () => Promise; +}) { + const [busy, setBusy] = useState(false); + const handle = useCallback(async () => { + setBusy(true); + try { + await onRequest(); + } finally { + setBusy(false); + } + }, [onRequest]); + + return ( + + + + + + + Camera check-in + + + A short video lets Pegasus read facial tension and voice patterns — + subtle signals of stress you can't see yourself. Nothing is stored; + only the analysis is kept. + + + {denied ? ( + + Camera and microphone access are off. Enable them in Settings to use + video check-ins. + + ) : null} + + [ + styles.primaryBtn, + pressed && styles.pressed, + busy && styles.btnDisabled, + ]} + > + {busy ? ( + + ) : ( + + {denied ? "Try again" : "Enable camera & mic"} + + )} + + + + ); +} + +// --------------------------------------------------------------------------- +// Face-centering oval +// --------------------------------------------------------------------------- +function FaceOval({ active }: { active: boolean }) { + const glow = useSharedValue(0); + + useEffect(() => { + cancelAnimation(glow); + if (active) { + glow.value = withRepeat( + withSequence( + withTiming(1, { duration: 1100, easing: Easing.inOut(Easing.ease) }), + withTiming(0, { duration: 1100, easing: Easing.inOut(Easing.ease) }) + ), + -1, + false + ); + } else { + glow.value = withTiming(0, { duration: 300 }); + } + return () => cancelAnimation(glow); + }, [active, glow]); + + const ovalStyle = useAnimatedStyle(() => { + const c = active ? COLORS.red : "rgba(255,255,255,0.45)"; + return { + borderColor: c, + shadowColor: active ? COLORS.red : COLORS.blue, + shadowOpacity: 0.25 + glow.value * 0.4, + shadowRadius: 18 + glow.value * 18, + }; + }); + + return ( + + + + {active ? "Recording — speak naturally" : "Center your face in the oval"} + + + ); +} + +// --------------------------------------------------------------------------- +// Recording controls: 60->0 countdown ring + pulsing red dot + stop +// --------------------------------------------------------------------------- +function RecordingControls({ + remaining, + onStop, +}: { + remaining: number; + onStop: () => void; +}) { + const pulse = useSharedValue(0); + + useEffect(() => { + pulse.value = withRepeat( + withSequence( + withTiming(1, { duration: 600, easing: Easing.inOut(Easing.ease) }), + withTiming(0, { duration: 600, easing: Easing.inOut(Easing.ease) }) + ), + -1, + false + ); + return () => cancelAnimation(pulse); + }, [pulse]); + + const dotStyle = useAnimatedStyle(() => ({ + opacity: 0.45 + pulse.value * 0.55, + transform: [{ scale: 0.85 + pulse.value * 0.3 }], + })); + + const progress = remaining / RECORD_SECONDS; // 1 -> 0 + + return ( + + + + + 0:{String(remaining).padStart(2, "0")} + + + + + + [styles.stopBtn, pressed && styles.pressed]} + > + + + Stop & analyze + + + + ); +} + +// A simple SVG-free countdown ring: a track plus a foreground arc faked with a +// rotating, draining border. We approximate the depletion with width segments +// to avoid pulling in react-native-svg here — purposeful + lightweight. +function CountdownRing({ progress }: { progress: number }) { + const size = 96; + // Animate a shared value toward the latest progress for smooth depletion. + const p = useSharedValue(1); + useEffect(() => { + p.value = withTiming(progress, { duration: 950, easing: Easing.linear }); + }, [progress, p]); + + const fillStyle = useAnimatedStyle(() => ({ + transform: [{ scale: 0.6 + p.value * 0.4 }], + opacity: 0.35 + p.value * 0.5, + })); + + return ( + + + + ); +} + +// --------------------------------------------------------------------------- +// Analyzing — brain pulse +// --------------------------------------------------------------------------- +function AnalyzingView() { + const pulse = useSharedValue(0); + useEffect(() => { + pulse.value = withRepeat( + withSequence( + withTiming(1, { duration: 900, easing: Easing.inOut(Easing.ease) }), + withTiming(0, { duration: 900, easing: Easing.inOut(Easing.ease) }) + ), + -1, + false + ); + return () => cancelAnimation(pulse); + }, [pulse]); + + const coreStyle = useAnimatedStyle(() => ({ + transform: [{ scale: 1 + pulse.value * 0.18 }], + shadowOpacity: 0.5 + pulse.value * 0.4, + shadowRadius: 30 + pulse.value * 26, + })); + const haloStyle = useAnimatedStyle(() => ({ + transform: [{ scale: 1 + pulse.value * 0.35 }], + opacity: 0.25 + pulse.value * 0.2, + })); + + return ( + + + + + + + + + Analyzing… + + + Reading facial tension and voice patterns + + + ); +} + +// --------------------------------------------------------------------------- +// Result +// --------------------------------------------------------------------------- +function ResultView({ + result, + onAgain, +}: { + result: VideoResultWithBurnout; + onAgain: () => void; +}) { + const facial = result.facial; + const voice = result.voice; + const burnout = result.burnout_result; + + const stress = clampScore(facial?.facial_stress_score ?? 0); + // facial_stress_score is a STRESS score: low = calm/green, high = red. + const stressColor = stressToColor(stress); + + const ind = facial?.facial_indicators; + const eyes = facial?.eye_indicators; + + return ( + + + CHECK-IN COMPLETE + + + {/* Facial stress hero numeral */} + + + Facial stress + + + {stress} + + {" "}/100 + + + {ind?.overall_affect ? ( + + Affect: {ind.overall_affect} + + ) : null} + + + {/* Facial indicator bars (0-1 floats) */} + {ind ? ( + + + Facial tension + + + + + {typeof ind.lip_compression === "number" ? ( + + ) : null} + + ) : null} + + {/* Voice readout */} + {voice ? ( + + + Voice + + + + + + + + ) : null} + + {/* Updated Pegasus (burnout) score */} + {burnout ? ( + + + Updated your Pegasus score + + + {burnout.intervention ? ( + + + {burnout.intervention} + + + ) : null} + + ) : null} + + [styles.secondaryBtn, pressed && styles.pressed]} + > + + New check-in + + + + ); +} + +function MetricBar({ label, value01 }: { label: string; value01: number }) { + const pct = Math.max(0, Math.min(100, Math.round((value01 ?? 0) * 100))); + const color = stressToColor(pct); + return ( + + + + {label} + + + {pct}% + + + + + + + ); +} + +function VoiceStat({ + label, + value, + tone = COLORS.text, +}: { + label: string; + value: string; + tone?: string; +}) { + return ( + + + {value} + + + {label} + + + ); +} + +// --------------------------------------------------------------------------- +// Error +// --------------------------------------------------------------------------- +function ErrorView({ message, onRetry }: { message: string; onRetry: () => void }) { + return ( + + + + Check-in interrupted + + + {message} + + [styles.primaryBtn, pressed && styles.pressed]} + > + + Try again + + + + + ); +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- +function clampScore(n: number): number { + return Math.max(0, Math.min(100, Math.round(n))); +} + +// Map a 0-100 stress value to the accent palette (low = calm green). +function stressToColor(stress: number): string { + if (stress >= 66) return levelColor("red"); + if (stress >= 33) return levelColor("yellow"); + return levelColor("green"); +} + +function fmtHz(hz?: number): string { + if (typeof hz !== "number" || !isFinite(hz)) return "—"; + return `${Math.round(hz)} Hz`; +} + +function fmtWpm(wpm?: number): string { + if (typeof wpm !== "number" || !isFinite(wpm)) return "—"; + return `${Math.round(wpm)} wpm`; +} + +const styles = StyleSheet.create({ + // Shared centered layout for gate / analyzing / error + centerRoot: { + flex: 1, + backgroundColor: COLORS.bg, + alignItems: "center", + justifyContent: "center", + padding: SPACING.lg, + }, + + // ---- Permission gate / error card ---- + gateCard: { + width: "100%", + backgroundColor: COLORS.card, + borderWidth: 1, + borderColor: COLORS.border, + borderRadius: RADIUS.lg, + padding: SPACING.xl, + alignItems: "center", + shadowColor: "#000", + shadowOpacity: 0.06, + shadowRadius: 18, + shadowOffset: { width: 0, height: 8 }, + elevation: 3, + }, + gateIcon: { + width: 64, + height: 64, + borderRadius: 32, + borderWidth: 1.5, + borderColor: COLORS.blue, + alignItems: "center", + justifyContent: "center", + marginBottom: SPACING.lg, + }, + gateLens: { + width: 24, + height: 24, + borderRadius: 12, + borderWidth: 2, + borderColor: COLORS.blue, + }, + gateTitle: { + ...TYPE.title, + color: COLORS.text, + marginBottom: SPACING.md, + textAlign: "center", + }, + gateBody: { + ...TYPE.body, + color: COLORS.textDim, + textAlign: "center", + lineHeight: 23, + marginBottom: SPACING.lg, + }, + gateDenied: { + ...TYPE.caption, + color: COLORS.yellow, + textAlign: "center", + marginBottom: SPACING.lg, + }, + errorTitle: { + ...TYPE.title, + color: COLORS.red, + marginBottom: SPACING.md, + textAlign: "center", + }, + + // ---- Buttons ---- + primaryBtn: { + width: "100%", + backgroundColor: COLORS.blue, + borderRadius: RADIUS.md, + paddingVertical: SPACING.md, + alignItems: "center", + justifyContent: "center", + minHeight: 52, + }, + primaryBtnLabel: { + ...TYPE.body, + color: "#ffffff", + fontWeight: "700", + }, + secondaryBtn: { + marginTop: SPACING.xl, + alignSelf: "center", + borderWidth: 1, + borderColor: COLORS.border, + borderRadius: RADIUS.pill, + paddingVertical: SPACING.md, + paddingHorizontal: SPACING.xl, + }, + secondaryBtnLabel: { + ...TYPE.label, + color: COLORS.text, + }, + btnDisabled: { + opacity: 0.6, + }, + pressed: { + opacity: 0.7, + }, + + // ---- Live camera ---- + cameraRoot: { + flex: 1, + backgroundColor: COLORS.bg, + }, + overlayTop: { + position: "absolute", + top: 64, + left: SPACING.lg, + right: SPACING.lg, + alignItems: "center", + }, + promptEyebrow: { + // Over the live camera feed — stays light-on-dark for legibility. + ...TYPE.label, + color: "rgba(255,255,255,0.85)", + letterSpacing: 2, + marginBottom: SPACING.sm, + textShadowColor: "rgba(0,0,0,0.8)", + textShadowOffset: { width: 0, height: 1 }, + textShadowRadius: 4, + }, + promptText: { + // Over the live camera feed — stays white for legibility. + ...TYPE.heading, + color: "#ffffff", + textAlign: "center", + textShadowColor: "rgba(0,0,0,0.8)", + textShadowOffset: { width: 0, height: 1 }, + textShadowRadius: 6, + }, + ovalWrap: { + ...StyleSheet.absoluteFillObject, + alignItems: "center", + justifyContent: "center", + }, + oval: { + width: 220, + height: 290, + borderRadius: 145, + borderWidth: 2, + shadowOffset: { width: 0, height: 0 }, + }, + ovalHint: { + // Over the live camera feed — stays light-on-dark for legibility. + ...TYPE.caption, + color: "rgba(255,255,255,0.8)", + marginTop: SPACING.lg, + textShadowColor: "rgba(0,0,0,0.8)", + textShadowOffset: { width: 0, height: 1 }, + textShadowRadius: 4, + }, + overlayBottom: { + position: "absolute", + bottom: 48, + left: SPACING.lg, + right: SPACING.lg, + alignItems: "center", + }, + + // Idle record button + recordBtn: { + flexDirection: "row", + alignItems: "center", + backgroundColor: COLORS.card, + borderWidth: 1, + borderColor: COLORS.border, + borderRadius: RADIUS.pill, + paddingVertical: SPACING.md, + paddingHorizontal: SPACING.lg, + }, + recordBtnInner: { + width: 16, + height: 16, + borderRadius: 8, + backgroundColor: COLORS.red, + marginRight: SPACING.md, + }, + recordBtnLabel: { + ...TYPE.body, + color: COLORS.text, + fontWeight: "700", + }, + + // Recording controls + recControls: { + alignItems: "center", + }, + recStatusRow: { + flexDirection: "row", + alignItems: "center", + marginBottom: SPACING.md, + }, + redDot: { + width: 12, + height: 12, + borderRadius: 6, + backgroundColor: COLORS.red, + marginRight: SPACING.sm, + }, + recTime: { + // Shown over the live camera feed while recording — white for legibility. + ...TYPE.heading, + color: "#ffffff", + fontVariant: ["tabular-nums"], + }, + ringTrack: { + borderWidth: 3, + borderColor: "rgba(255,59,48,0.35)", + alignItems: "center", + justifyContent: "center", + marginBottom: SPACING.lg, + }, + ringFill: { + backgroundColor: "rgba(255,59,48,0.25)", + borderWidth: 2, + borderColor: COLORS.red, + }, + stopBtn: { + flexDirection: "row", + alignItems: "center", + backgroundColor: COLORS.card, + borderWidth: 1, + borderColor: COLORS.red, + borderRadius: RADIUS.pill, + paddingVertical: SPACING.md, + paddingHorizontal: SPACING.lg, + }, + stopSquare: { + width: 14, + height: 14, + borderRadius: 3, + backgroundColor: COLORS.red, + marginRight: SPACING.sm, + }, + stopLabel: { + ...TYPE.body, + color: COLORS.text, + fontWeight: "700", + }, + + // ---- Analyzing brain ---- + brainWrap: { + width: 200, + height: 200, + alignItems: "center", + justifyContent: "center", + marginBottom: SPACING.xl, + }, + brainHalo: { + position: "absolute", + width: 200, + height: 200, + borderRadius: 100, + borderWidth: 1, + borderColor: COLORS.blue, + }, + brainCore: { + width: 140, + height: 140, + borderRadius: 70, + overflow: "hidden", + borderWidth: 1.5, + borderColor: COLORS.blue, + backgroundColor: COLORS.card, + shadowColor: COLORS.blue, + shadowOffset: { width: 0, height: 0 }, + elevation: 12, + }, + analyzingTitle: { + ...TYPE.title, + color: COLORS.text, + marginBottom: SPACING.sm, + }, + analyzingSub: { + ...TYPE.body, + color: COLORS.textDim, + textAlign: "center", + }, + + // ---- Result ---- + scroll: { + flex: 1, + backgroundColor: COLORS.bg, + }, + scrollContent: { + padding: SPACING.lg, + paddingTop: 64, + paddingBottom: SPACING.xxl, + }, + resultEyebrow: { + ...TYPE.label, + color: COLORS.textDim, + letterSpacing: 2, + textAlign: "center", + marginBottom: SPACING.lg, + }, + heroBlock: { + alignItems: "center", + marginBottom: SPACING.xl, + }, + heroLabel: { + ...TYPE.label, + color: COLORS.textDim, + marginBottom: SPACING.sm, + }, + heroScore: { + fontSize: 72, + fontWeight: "800", + letterSpacing: -2, + lineHeight: 76, + }, + heroOutOf: { + fontSize: 22, + fontWeight: "600", + color: COLORS.textDim, + }, + heroAffect: { + ...TYPE.caption, + color: COLORS.textDim, + marginTop: SPACING.sm, + textTransform: "capitalize", + }, + card: { + backgroundColor: COLORS.card, + borderWidth: 1, + borderColor: COLORS.border, + borderRadius: RADIUS.lg, + padding: SPACING.lg, + marginBottom: SPACING.md, + shadowColor: "#000", + shadowOpacity: 0.06, + shadowRadius: 16, + shadowOffset: { width: 0, height: 6 }, + elevation: 2, + }, + cardTitle: { + ...TYPE.label, + color: COLORS.textDim, + marginBottom: SPACING.md, + }, + metricRow: { + marginBottom: SPACING.md, + }, + metricHead: { + flexDirection: "row", + justifyContent: "space-between", + alignItems: "center", + marginBottom: SPACING.sm, + }, + metricLabel: { + ...TYPE.body, + color: COLORS.text, + }, + metricVal: { + ...TYPE.label, + fontVariant: ["tabular-nums"], + }, + barTrack: { + height: 6, + borderRadius: RADIUS.pill, + backgroundColor: "rgba(0,0,0,0.08)", + overflow: "hidden", + }, + barFill: { + height: "100%", + borderRadius: RADIUS.pill, + }, + voiceRow: { + flexDirection: "row", + justifyContent: "space-between", + }, + voiceStat: { + flex: 1, + alignItems: "center", + }, + voiceValue: { + ...TYPE.heading, + fontVariant: ["tabular-nums"], + }, + voiceLabel: { + ...TYPE.caption, + color: COLORS.textDim, + marginTop: SPACING.xs, + }, + pegasusBlock: { + alignItems: "center", + marginTop: SPACING.lg, + }, + pegasusLabel: { + ...TYPE.heading, + color: COLORS.text, + textAlign: "center", + marginBottom: SPACING.md, + }, + interventionCard: { + marginTop: SPACING.lg, + width: "100%", + }, + interventionText: { + ...TYPE.body, + color: COLORS.text, + lineHeight: 23, + }, +}); diff --git a/frontend/src/screens/HistoryScreen.tsx b/frontend/src/screens/HistoryScreen.tsx new file mode 100644 index 0000000..c6b8eb8 --- /dev/null +++ b/frontend/src/screens/HistoryScreen.tsx @@ -0,0 +1,399 @@ +// HistoryScreen — your timeline of past check-ins and talks. A calm, most-recent- +// first list pulled from getHistory(DEFAULT_USER_ID). Each card shows what kind of +// moment it was (Talk / Check-in), when it happened, a wellness score chip +// (higher is better, colored by level), the user's own words, and the gentle +// intervention Pegasus offered. White cards on the Apple off-white canvas with +// hairline borders, soft shadows, and frosted top/bottom scroll bands. +import React, { useCallback, useEffect, useState } from "react"; +import { + ActivityIndicator, + Pressable, + RefreshControl, + ScrollView, + StyleSheet, + Text, + View, +} from "react-native"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; + +import { getHistory } from "../services/api"; +import { DEFAULT_USER_ID } from "../services/config"; +import { HistoryEntry } from "../types"; +import { COLORS, RADIUS, SPACING, TYPE, levelColor } from "../utils/colors"; +import ScrollEdgeFade from "../components/ScrollEdgeFade"; + +// Short, Apple-style timestamp: the time of day for moments from today +// (e.g. "2:45 PM"), the month + day for anything older (e.g. "Jun 14"). +function shortWhen(iso: string): string { + const d = new Date(iso); + if (Number.isNaN(d.getTime())) return ""; + const now = new Date(); + const sameDay = + d.getFullYear() === now.getFullYear() && + d.getMonth() === now.getMonth() && + d.getDate() === now.getDate(); + if (sameDay) { + return d.toLocaleTimeString(undefined, { + hour: "numeric", + minute: "2-digit", + }); + } + return d.toLocaleDateString(undefined, { month: "short", day: "numeric" }); +} + +export default function HistoryScreen() { + const insets = useSafeAreaInsets(); + + const [history, setHistory] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + const load = useCallback(() => { + setLoading(true); + setError(null); + getHistory(DEFAULT_USER_ID) + .then((d) => setHistory(d ?? [])) + .catch((e) => setError(e?.message ?? "Couldn't load your history.")) + .finally(() => setLoading(false)); + }, []); + + useEffect(() => { + load(); + }, [load]); + + // First load: calm, minimal spinner. + if (loading && !history) { + return ( + + + Loading your history… + + ); + } + + // Error / unreachable: clear, non-alarming fallback with a retry. + if (error && !history) { + return ( + + History unavailable + {error} + + Try again + + + ); + } + + const entries = history ?? []; + + return ( + + + } + > + {/* Header */} + + History + Your past check-ins and talks + + + {/* Inline error — keeps any previously loaded list visible below. */} + {error && ( + + {error} + Tap to retry + + )} + + {entries.length === 0 ? ( + + ) : ( + + {entries.map((entry, i) => ( + + ))} + + )} + + + {/* Frosted top/bottom bands — content blurs softly as it scrolls under. */} + + + ); +} + +// One past moment. Top row: a KIND badge + short timestamp on the left, the +// wellness score chip on the right. Below: the user's words in quotes (if any), +// then the intervention in a subtle inset row (if any). +function HistoryCard({ entry }: { entry: HistoryEntry }) { + const accent = levelColor(entry.level); + const kindLabel = entry.kind === "talk" ? "Talk" : "Check-in"; + const when = shortWhen(entry.timestamp); + const text = entry.text?.trim(); + const intervention = entry.intervention?.trim(); + + return ( + + + + + {kindLabel} + + {when ? {when} : null} + + + {/* Wellness score — higher is better, colored by level. */} + + + {Math.round(entry.score ?? 0)} + + /100 + + + + {/* The user's own words. */} + {text ? “{text}” : null} + + {/* Pegasus's gentle nudge for this moment. */} + {intervention ? ( + + + {intervention} + + ) : null} + + ); +} + +// Empty state — calm invitation to start a history. +function EmptyState() { + return ( + + + No check-ins yet + + Talk to Pegasus or reply to a text to start your history. + + + ); +} + +const styles = StyleSheet.create({ + root: { + flex: 1, + backgroundColor: COLORS.bg, + }, + centerRoot: { + alignItems: "center", + justifyContent: "center", + padding: SPACING.lg, + }, + centerText: { + ...TYPE.body, + color: COLORS.textDim, + marginTop: SPACING.md, + }, + scroll: { + paddingHorizontal: SPACING.lg, + }, + + // Header + header: { + marginBottom: SPACING.lg, + }, + title: { + ...TYPE.title, + color: COLORS.text, + }, + subtitle: { + ...TYPE.body, + color: COLORS.textDim, + marginTop: SPACING.xs, + }, + + // Full-screen error fallback + errorTitle: { + ...TYPE.heading, + color: COLORS.text, + }, + errorBody: { + ...TYPE.body, + color: COLORS.textDim, + textAlign: "center", + marginTop: SPACING.sm, + lineHeight: 22, + }, + retryBtn: { + marginTop: SPACING.xl, + borderWidth: 1, + borderColor: COLORS.border, + borderRadius: RADIUS.md, + paddingVertical: SPACING.md, + paddingHorizontal: SPACING.xl, + backgroundColor: COLORS.card, + }, + retryText: { + ...TYPE.body, + color: COLORS.text, + fontWeight: "700", + }, + + // Inline (non-blocking) error card + errorCard: { + backgroundColor: "rgba(255, 59, 48, 0.08)", + borderWidth: 1, + borderColor: "rgba(255, 59, 48, 0.30)", + borderRadius: RADIUS.md, + padding: SPACING.md, + marginBottom: SPACING.lg, + }, + errorCardText: { + ...TYPE.body, + color: COLORS.text, + }, + errorCardHint: { + ...TYPE.caption, + color: COLORS.red, + marginTop: SPACING.xs, + }, + + // List + list: { + gap: SPACING.md, + }, + card: { + backgroundColor: COLORS.card, + borderWidth: 1, + borderColor: COLORS.border, + borderRadius: RADIUS.lg, + padding: SPACING.lg, + shadowColor: "#000", + shadowOpacity: 0.06, + shadowRadius: 14, + shadowOffset: { width: 0, height: 5 }, + elevation: 2, + }, + cardHeader: { + flexDirection: "row", + alignItems: "center", + justifyContent: "space-between", + }, + metaRow: { + flexDirection: "row", + alignItems: "center", + flex: 1, + gap: SPACING.sm, + }, + kindBadge: { + paddingVertical: 4, + paddingHorizontal: 10, + borderRadius: RADIUS.pill, + backgroundColor: "rgba(0,0,0,0.05)", + }, + kindText: { + ...TYPE.label, + fontSize: 11, + color: COLORS.textDim, + letterSpacing: 0.5, + }, + when: { + ...TYPE.caption, + color: COLORS.textDim, + }, + + // Wellness score chip + scoreChip: { + flexDirection: "row", + alignItems: "baseline", + borderWidth: 1.5, + borderRadius: RADIUS.pill, + paddingVertical: 5, + paddingHorizontal: 12, + marginLeft: SPACING.sm, + }, + scoreNum: { + fontSize: 18, + fontWeight: "800", + letterSpacing: -0.4, + fontVariant: ["tabular-nums"], + }, + scoreOutOf: { + fontSize: 11, + fontWeight: "600", + color: COLORS.textDim, + marginLeft: 1, + }, + + // User's words + quote: { + ...TYPE.body, + fontStyle: "italic", + color: COLORS.textDim, + lineHeight: 23, + marginTop: SPACING.md, + }, + + // Intervention row + interventionRow: { + flexDirection: "row", + marginTop: SPACING.md, + backgroundColor: "rgba(0,0,0,0.03)", + borderRadius: RADIUS.sm, + padding: SPACING.md, + }, + interventionBar: { + width: 3, + borderRadius: RADIUS.pill, + marginRight: SPACING.md, + }, + interventionText: { + flex: 1, + ...TYPE.body, + fontSize: 15, + color: COLORS.text, + lineHeight: 22, + }, + + // Empty state + empty: { + alignItems: "center", + paddingVertical: SPACING.xxl, + paddingHorizontal: SPACING.lg, + }, + emptyDot: { + width: 12, + height: 12, + borderRadius: RADIUS.pill, + backgroundColor: COLORS.border, + marginBottom: SPACING.lg, + }, + emptyTitle: { + ...TYPE.heading, + color: COLORS.text, + marginBottom: SPACING.sm, + }, + emptyText: { + ...TYPE.body, + color: COLORS.textDim, + textAlign: "center", + lineHeight: 22, + maxWidth: 300, + }, +}); diff --git a/frontend/src/screens/HomeScreen.tsx b/frontend/src/screens/HomeScreen.tsx new file mode 100644 index 0000000..d9ccd50 --- /dev/null +++ b/frontend/src/screens/HomeScreen.tsx @@ -0,0 +1,417 @@ +// HomeScreen — the hero. "A check engine light for your mind." +// Live wellness score (0..100, higher = better) via useBurnoutScore, the +// breathing CheckEngine orb, the top indicators, an intervention suggestion, and +// the primary check-in CTA. On a low (red) reading we fire a heavy haptic and +// surface a calm full-screen alert. +import React, { useEffect, useRef, useState } from "react"; +import { + ActivityIndicator, + Modal, + Pressable, + RefreshControl, + ScrollView, + StyleSheet, + Text, + View, +} from "react-native"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; +import * as Haptics from "expo-haptics"; +import type { BottomTabScreenProps } from "@react-navigation/bottom-tabs"; +import Animated, { + Easing, + useAnimatedStyle, + useSharedValue, + withTiming, +} from "react-native-reanimated"; + +import CheckEngine from "../components/CheckEngine"; +import IndicatorCard, { type IndicatorTone } from "../components/IndicatorCard"; +import InterventionCard from "../components/InterventionCard"; +import SmsCheckinCard from "../components/SmsCheckinCard"; +import ScrollEdgeFade from "../components/ScrollEdgeFade"; +import { useBurnoutScore } from "../hooks/useBurnoutScore"; +import { DEFAULT_USER_ID } from "../services/config"; +import { COLORS, RADIUS, SPACING, TYPE, levelColor } from "../utils/colors"; +import type { BurnoutLevel } from "../utils/colors"; +import { scoreLabel } from "../utils/formatting"; + +// The tab navigator route names — mirror of TabNavigator's TabParamList so +// navigation.navigate("Talk") is type-checked here. +type TabParamList = { + Home: undefined; + Talk: undefined; + Metrics: undefined; + Brain: undefined; +}; + +type Props = BottomTabScreenProps; + +// Each indicator dot inherits the current level's accent — red reads urgent, +// yellow elevated, green calm. +function toneForLevel(level: BurnoutLevel): IndicatorTone { + return level; +} + +export default function HomeScreen({ navigation }: Props) { + const { score, loading, refresh } = useBurnoutScore(DEFAULT_USER_ID); + const insets = useSafeAreaInsets(); + + const [alertVisible, setAlertVisible] = useState(false); + // Track the last level we alerted on so we don't re-buzz on every poll. + const lastAlertedLevel = useRef(null); + + // On a fresh transition into "red", fire a heavy haptic and raise the alert. + useEffect(() => { + const level = score?.level; + if (level === "red") { + if (lastAlertedLevel.current !== "red") { + Haptics.notificationAsync(Haptics.NotificationFeedbackType.Error).catch( + () => {} + ); + Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Heavy).catch(() => {}); + setAlertVisible(true); + } + lastAlertedLevel.current = "red"; + } else if (level) { + lastAlertedLevel.current = level; + } + }, [score?.level]); + + // First load, before any score has arrived: a calm, minimal loading state. + if (loading && !score) { + return ( + + PEGASUS + + Reading your signals… + + ); + } + + // After first load we always have a score (the hook polls). Guard anyway so + // a transient null never crashes the hero. + const level: BurnoutLevel = (score?.level as BurnoutLevel) ?? "green"; + const numericScore = score?.score ?? 0; + const accent = levelColor(level); + const indicators = (score?.top_indicators ?? []).filter(Boolean).slice(0, 3); + const intervention = score?.intervention ?? ""; + // `support` is the longer, reassuring copy shown on a red alert. The backend + // may not always send it, so fall back to the intervention text. + // The backend may send `support` as a string, an empty array, or omit it — + // coerce defensively so a non-string never reaches .trim() (that crashed Home). + const rawSupport = (score as unknown as { support?: unknown } | null)?.support; + const support = + (typeof rawSupport === "string" ? rawSupport : "").trim() || + intervention || + "Your reserves are running low. Take a few slow breaths, step away from the screen, and reach out to someone you trust."; + + return ( + + + } + > + {/* Header */} + + PEGASUS + a check engine light for your mind + + + {/* Hero orb */} + + + + {scoreLabel(level)} + + + + {/* Top indicators */} + {indicators.length > 0 && ( + + WHAT WE'RE SEEING + + {indicators.map((text, i) => ( + + ))} + + + )} + + {/* Intervention suggestion */} + {intervention.length > 0 && ( + + + + )} + + {/* Primary CTA */} + + navigation.navigate("Talk")} + /> + + + {/* Text-me-a-check-in (SMS / iMessage via Bloo.io) */} + + + + + + {/* Frosted top/bottom bands — content blurs softly as it scrolls under. */} + + + {/* Red-level alert overlay */} + setAlertVisible(false)} + onCheckIn={() => { + setAlertVisible(false); + navigation.navigate("Talk"); + }} + /> + + ); +} + +// The big primary action. Subtle press-scale, accent-tinted glow. +function CheckInButton({ + accent, + onPress, +}: { + accent: string; + onPress: () => void; +}) { + const press = useSharedValue(0); + const animatedStyle = useAnimatedStyle(() => ({ + transform: [{ scale: 1 - press.value * 0.03 }], + })); + + return ( + + { + press.value = withTiming(1, { duration: 120, easing: Easing.out(Easing.ease) }); + }} + onPressOut={() => { + press.value = withTiming(0, { duration: 160, easing: Easing.out(Easing.ease) }); + }} + onPress={onPress} + style={[styles.cta, { borderColor: accent }]} + accessibilityRole="button" + accessibilityLabel="Talk to Pegasus" + > + Talk to Pegasus + + + ); +} + +// Calm, full-screen alert for a red reading. Not a jarring popup — a gentle, +// dark scrim with reassuring support copy and a clear next step. +function AlertOverlay({ + visible, + support, + onDismiss, + onCheckIn, +}: { + visible: boolean; + support: string; + onDismiss: () => void; + onCheckIn: () => void; +}) { + return ( + + + {}}> + + RUNNING LOW + Your reserves are running low + {support} + + + Talk to Pegasus + + + + Not now + + + + + ); +} + +const styles = StyleSheet.create({ + root: { + flex: 1, + backgroundColor: COLORS.bg, + }, + loadingRoot: { + alignItems: "center", + justifyContent: "center", + }, + loadingSpinner: { + marginTop: SPACING.xl, + }, + loadingText: { + ...TYPE.body, + color: COLORS.textDim, + marginTop: SPACING.md, + }, + scroll: { + paddingHorizontal: SPACING.lg, + }, + header: { + alignItems: "center", + }, + brand: { + fontSize: 26, + fontWeight: "800", + color: COLORS.text, + letterSpacing: 6, + }, + subtitle: { + ...TYPE.caption, + color: COLORS.textDim, + marginTop: SPACING.sm, + letterSpacing: 0.4, + }, + hero: { + alignItems: "center", + marginTop: SPACING.md, + }, + statusLabel: { + fontSize: 24, + fontWeight: "700", + letterSpacing: -0.3, + marginTop: SPACING.xs, + }, + section: { + marginTop: SPACING.xl, + }, + sectionLabel: { + ...TYPE.label, + color: COLORS.textDim, + letterSpacing: 1.5, + marginBottom: SPACING.md, + }, + indicatorList: { + gap: SPACING.sm, + }, + ctaWrap: { + marginTop: SPACING.xxl, + }, + ctaShadow: { + borderRadius: RADIUS.lg, + shadowOpacity: 0.22, + shadowRadius: 18, + shadowOffset: { width: 0, height: 8 }, + elevation: 6, + }, + cta: { + backgroundColor: COLORS.card, + borderWidth: 1, + borderRadius: RADIUS.lg, + paddingVertical: SPACING.lg, + alignItems: "center", + justifyContent: "center", + }, + ctaText: { + fontSize: 18, + fontWeight: "700", + color: COLORS.text, + letterSpacing: 0.2, + }, + // Alert overlay + overlayScrim: { + flex: 1, + backgroundColor: "rgba(29, 29, 31, 0.32)", + alignItems: "center", + justifyContent: "center", + padding: SPACING.lg, + }, + overlayCard: { + width: "100%", + maxWidth: 420, + backgroundColor: COLORS.card, + borderWidth: 1, + borderColor: "rgba(255, 59, 48, 0.30)", + borderRadius: RADIUS.lg, + padding: SPACING.xl, + shadowColor: "#000", + shadowOpacity: 0.12, + shadowRadius: 28, + shadowOffset: { width: 0, height: 12 }, + elevation: 12, + }, + overlayDot: { + width: 10, + height: 10, + borderRadius: RADIUS.pill, + marginBottom: SPACING.md, + }, + overlayLabel: { + ...TYPE.label, + color: COLORS.red, + letterSpacing: 1.5, + }, + overlayTitle: { + ...TYPE.title, + color: COLORS.text, + marginTop: SPACING.sm, + }, + overlaySupport: { + ...TYPE.body, + color: COLORS.textDim, + lineHeight: 24, + marginTop: SPACING.md, + }, + overlayPrimary: { + backgroundColor: COLORS.red, + borderWidth: 1, + borderRadius: RADIUS.md, + paddingVertical: SPACING.md, + alignItems: "center", + marginTop: SPACING.xl, + }, + overlayPrimaryText: { + fontSize: 16, + fontWeight: "700", + color: "#ffffff", + }, + overlaySecondary: { + alignItems: "center", + paddingVertical: SPACING.md, + marginTop: SPACING.xs, + }, + overlaySecondaryText: { + ...TYPE.body, + color: COLORS.textDim, + }, +}); diff --git a/frontend/src/screens/MetricsScreen.tsx b/frontend/src/screens/MetricsScreen.tsx new file mode 100644 index 0000000..4c1a246 --- /dev/null +++ b/frontend/src/screens/MetricsScreen.tsx @@ -0,0 +1,818 @@ +// MetricsScreen — the dashboard. "Tesla dashboard for your mind." +// +// Pulls getMetrics(DEFAULT_USER_ID) for the score trend + latest breakdown, +// and getHistory(DEFAULT_USER_ID) for per-checkin signals. Renders: +// - a big current-score header +// - of the score history +// - of the latest signal breakdown +// - week-over-week Typing-speed and Error-rate cards (up/down arrows) +// - a compact sentiment mini-trend sparkline +// +// The persisted history (BurnoutResult) doesn't always carry raw biometrics, +// so we read typing_wpm / error_rate / sentiment defensively and fall back to +// breakdown-derived proxies, degrading to calm empty states when absent. +import React, { useCallback, useEffect, useMemo, useState } from "react"; +import { + ActivityIndicator, + RefreshControl, + ScrollView, + StyleSheet, + Text, + View, +} from "react-native"; +import Svg, { Path, Circle } from "react-native-svg"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; + +import TrendChart from "../components/TrendChart"; +import ScoreBreakdown from "../components/ScoreBreakdown"; +import ScrollEdgeFade from "../components/ScrollEdgeFade"; +import { BurnoutResult } from "../types"; +import { getMetrics, getHistory } from "../services/api"; +import { DEFAULT_USER_ID } from "../services/config"; +import { + COLORS, + RADIUS, + SPACING, + TYPE, + TEXT_TERTIARY, + levelColor, +} from "../utils/colors"; +import { relativeTime, scoreLabel } from "../utils/formatting"; + +const WEEK_MS = 7 * 24 * 60 * 60 * 1000; + +// Shape returned by GET /metrics/{user_id}. getMetrics() is typed `any`, so we +// describe what we actually consume here. +interface MetricsPayload { + score_trend?: { date: string | null; score: number; level: string }[]; + latest_breakdown?: Partial; + total_checkins?: number; +} + +interface Breakdown { + imessage: number; + typing: number; + facial: number; + voice: number; + tribe: number; +} + +const EMPTY_BREAKDOWN: Breakdown = { + imessage: 0, + typing: 0, + facial: 0, + voice: 0, + tribe: 0, +}; + +// A history row may carry richer fields than the BurnoutResult contract when it +// originated from the /analyze flow. Read them opportunistically. +type RichHistory = BurnoutResult & { + typing_wpm?: number; + error_rate?: number; + sentiment_score?: number; + sentiment?: number; +}; + +function num(v: unknown): number | null { + return typeof v === "number" && !Number.isNaN(v) ? v : null; +} + +function mean(xs: number[]): number | null { + if (xs.length === 0) return null; + return xs.reduce((a, b) => a + b, 0) / xs.length; +} + +// Split rows into [this week, last week] by timestamp, then average a derived +// metric. `pick` returns null when a row has no usable value for that metric. +function weekOverWeek( + rows: RichHistory[], + pick: (r: RichHistory) => number | null +): { current: number | null; previous: number | null } { + const now = Date.now(); + const cur: number[] = []; + const prev: number[] = []; + for (const r of rows) { + const t = r.timestamp ? new Date(r.timestamp).getTime() : NaN; + if (Number.isNaN(t)) continue; + const age = now - t; + const v = pick(r); + if (v == null) continue; + if (age <= WEEK_MS) cur.push(v); + else if (age <= WEEK_MS * 2) prev.push(v); + } + return { current: mean(cur), previous: mean(prev) }; +} + +export default function MetricsScreen() { + const insets = useSafeAreaInsets(); + const [metrics, setMetrics] = useState(null); + const [history, setHistory] = useState([]); + const [loading, setLoading] = useState(true); + const [refreshing, setRefreshing] = useState(false); + const [error, setError] = useState(null); + + const load = useCallback(async (mode: "initial" | "refresh") => { + if (mode === "initial") setLoading(true); + else setRefreshing(true); + setError(null); + try { + const [m, h] = await Promise.all([ + getMetrics(DEFAULT_USER_ID) as Promise, + getHistory(DEFAULT_USER_ID) as unknown as Promise, + ]); + setMetrics(m ?? {}); + setHistory(Array.isArray(h) ? h : []); + } catch (e) { + setError(e instanceof Error ? e.message : "Could not load metrics."); + } finally { + setLoading(false); + setRefreshing(false); + } + }, []); + + useEffect(() => { + load("initial"); + }, [load]); + + // Trend scores (oldest -> newest), preferring the server's score_trend. + const trendScores = useMemo(() => { + const fromMetrics = (metrics?.score_trend ?? []) + .map((p) => num(p.score)) + .filter((n): n is number => n != null); + if (fromMetrics.length > 0) return fromMetrics; + // history is newest-first; reverse for chronological charting. + return [...history] + .reverse() + .map((h) => num(h.score)) + .filter((n): n is number => n != null); + }, [metrics, history]); + + // Newest history row carries the current score + level. + const latest = history[0]; + const trend = metrics?.score_trend ?? []; + const trendLatest = trend.length > 0 ? trend[trend.length - 1] : undefined; + + const currentScore = + num(latest?.score) ?? num(trendLatest?.score) ?? null; + const currentLevel = latest?.level ?? trendLatest?.level ?? "green"; + const lastCheckIso = latest?.timestamp ?? trendLatest?.date ?? null; + const totalCheckins = + num(metrics?.total_checkins) ?? history.length; + + const breakdown: Breakdown = useMemo(() => { + const src = metrics?.latest_breakdown ?? latest?.breakdown ?? {}; + return { ...EMPTY_BREAKDOWN, ...src }; + }, [metrics, latest]); + + // Typing speed: prefer raw WPM; otherwise invert the typing-strain signal + // (higher strain -> lower effective speed) so the card still tells a story. + const typingWow = useMemo( + () => + weekOverWeek(history, (r) => { + const wpm = num(r.typing_wpm); + if (wpm != null && wpm > 0) return wpm; + const strain = num(r.breakdown?.typing); + return strain != null ? Math.max(0, 100 - strain) : null; + }), + [history] + ); + const typingHasRaw = useMemo( + () => history.some((r) => num(r.typing_wpm) != null && (r.typing_wpm ?? 0) > 0), + [history] + ); + + // Error rate: prefer raw error_rate; otherwise use the typing-strain signal + // directly as a strain proxy. + const errorWow = useMemo( + () => + weekOverWeek(history, (r) => { + const er = num(r.error_rate); + if (er != null) return er; + return num(r.breakdown?.typing); + }), + [history] + ); + const errorHasRaw = useMemo( + () => history.some((r) => num(r.error_rate) != null), + [history] + ); + + // Sentiment mini-trend (oldest -> newest, normalized 0..100, higher = better). + const sentimentSeries = useMemo(() => { + const ordered = [...history].reverse(); + return ordered + .map((r) => { + const raw = num(r.sentiment_score) ?? num(r.sentiment); + if (raw != null) { + // sentiment is typically -1..1; map to 0..100. If already 0..100, keep. + if (raw >= -1 && raw <= 1) return Math.round((raw + 1) * 50); + return Math.max(0, Math.min(100, raw)); + } + // Proxy: invert burnout score so higher = calmer/more positive. + const s = num(r.score); + return s != null ? Math.max(0, Math.min(100, 100 - s)) : null; + }) + .filter((n): n is number => n != null); + }, [history]); + const sentimentIsRaw = useMemo( + () => + history.some( + (r) => num(r.sentiment_score) != null || num(r.sentiment) != null + ), + [history] + ); + + if (loading) { + return ( + + + Reading your dashboard… + + ); + } + + if (error) { + return ( + + Couldn't load metrics + {error} + load("initial")}> + Tap to retry + + + ); + } + + const hasAnyData = history.length > 0 || trendScores.length > 0; + const accent = levelColor(currentLevel); + + return ( + + load("refresh")} + tintColor={COLORS.textDim} + /> + } + > + {/* Current score header */} + + WELLNESS SCORE + {currentScore != null ? ( + <> + + + {Math.round(currentScore)} + + + /100 + + + + + + {scoreLabel(currentLevel)} + + + + {totalCheckins} check-in{totalCheckins === 1 ? "" : "s"} + {lastCheckIso ? ` · updated ${relativeTime(lastCheckIso)}` : ""} + + + ) : ( + <> + + + + No check-ins yet. + + )} + + + {!hasAnyData ? ( + + Your dashboard is waiting + + Complete a Pulse and a Check-In to start charting your trend, + signal breakdown, and weekly biometrics. + + + ) : ( + <> + {/* Score trend */} +
+ +
+ + {/* Signal breakdown */} +
+ +
+ + {/* Week-over-week biometrics */} +
+ + + + +
+ + {/* Sentiment mini-trend */} +
+ +
+ + )} + + +
+ + {/* Frosted top/bottom bands — content blurs softly as it scrolls under. */} + +
+ ); +} + +function Section({ + title, + children, +}: { + title: string; + children: React.ReactNode; +}) { + return ( + + {title} + {children} + + ); +} + +// Week-over-week metric card with an up/down arrow tinted by whether the change +// is good or bad (which depends on `higherIsBetter`). +function WowCard({ + label, + current, + previous, + unit, + higherIsBetter, + precision, + note, +}: { + label: string; + current: number | null; + previous: number | null; + unit: string; + higherIsBetter: boolean; + precision: number; + note?: string; +}) { + const hasCurrent = current != null; + const hasPrev = previous != null; + const delta = hasCurrent && hasPrev ? current - previous : null; + + // Treat sub-epsilon moves as flat. + const direction: "up" | "down" | "flat" = + delta == null || Math.abs(delta) < 0.05 + ? "flat" + : delta > 0 + ? "up" + : "down"; + + const isGood = + direction === "flat" + ? null + : higherIsBetter + ? direction === "up" + : direction === "down"; + + const deltaColor = + isGood == null ? COLORS.textDim : isGood ? COLORS.green : COLORS.red; + + const fmt = (n: number) => n.toFixed(precision); + + return ( + + {label} + + + {hasCurrent ? fmt(current) : "—"} + + {unit} + + + {direction !== "flat" && delta != null ? ( + + ) : ( + + )} + + {delta == null + ? hasCurrent + ? "no prior week" + : "no data" + : direction === "flat" + ? "no change" + : `${delta > 0 ? "+" : ""}${fmt(delta)} vs last week`} + + + {note ? {note} : null} + + ); +} + +function Arrow({ + direction, + color, +}: { + direction: "up" | "down"; + color: string; +}) { + return ( + + {direction === "up" ? ( + + ) : ( + + )} + + ); +} + +// Compact SVG sparkline for the sentiment series (0..100, higher = calmer). +function SentimentTrend({ + series, + isRaw, +}: { + series: number[]; + isRaw: boolean; +}) { + const [width, setWidth] = useState(0); + const height = 64; + const pad = 6; + + const latest = series.length > 0 ? series[series.length - 1] : null; + const lineColor = + latest == null + ? COLORS.textDim + : latest >= 60 + ? COLORS.green + : latest >= 40 + ? COLORS.yellow + : COLORS.red; + + const path = useMemo(() => { + if (width <= 0 || series.length < 2) return ""; + const innerW = width - pad * 2; + const innerH = height - pad * 2; + const stepX = innerW / (series.length - 1); + return series + .map((v, i) => { + const x = pad + i * stepX; + const y = pad + innerH - (Math.max(0, Math.min(100, v)) / 100) * innerH; + return `${i === 0 ? "M" : "L"}${x.toFixed(1)} ${y.toFixed(1)}`; + }) + .join(" "); + }, [width, series]); + + const lastPoint = useMemo(() => { + if (width <= 0 || series.length === 0) return null; + const innerW = width - pad * 2; + const innerH = height - pad * 2; + const stepX = series.length > 1 ? innerW / (series.length - 1) : 0; + const i = series.length - 1; + const x = pad + i * stepX; + const v = Math.max(0, Math.min(100, series[i])); + const y = pad + innerH - (v / 100) * innerH; + return { x, y }; + }, [width, series]); + + if (series.length < 2) { + return ( + + + Not enough check-ins to show a sentiment trend yet. + + + ); + } + + return ( + + + + + {Math.round(latest as number)} + + + {(latest as number) >= 60 + ? "Positive tone" + : (latest as number) >= 40 + ? "Mixed tone" + : "Strained tone"} + + + {!isRaw ? derived : null} + + setWidth(e.nativeEvent.layout.width)} + > + {width > 0 ? ( + + + {lastPoint ? ( + + ) : null} + + ) : ( + + )} + + + ); +} + +const styles = StyleSheet.create({ + screen: { + flex: 1, + backgroundColor: COLORS.bg, + }, + fill: { + alignItems: "center", + justifyContent: "center", + paddingHorizontal: SPACING.xl, + }, + content: { + paddingHorizontal: SPACING.lg, + }, + loadingText: { + ...TYPE.body, + color: COLORS.textDim, + marginTop: SPACING.md, + }, + errorTitle: { + ...TYPE.heading, + color: COLORS.text, + textAlign: "center", + }, + errorBody: { + ...TYPE.body, + color: COLORS.textDim, + textAlign: "center", + marginTop: SPACING.sm, + }, + errorHint: { + ...TYPE.label, + color: COLORS.blue, + marginTop: SPACING.lg, + }, + + // Header + header: { + marginBottom: SPACING.xl, + }, + kicker: { + ...TYPE.label, + color: COLORS.textDim, + textTransform: "uppercase", + letterSpacing: 1.5, + marginBottom: SPACING.sm, + }, + scoreRow: { + flexDirection: "row", + alignItems: "flex-end", + }, + score: { + ...TYPE.hero, + fontWeight: "800", + color: COLORS.text, + lineHeight: 64, + }, + scoreOutOf: { + fontSize: 20, + fontWeight: "600", + color: TEXT_TERTIARY, + letterSpacing: -0.2, + marginBottom: 11, + marginLeft: 4, + }, + statusRow: { + flexDirection: "row", + alignItems: "center", + marginTop: SPACING.sm, + }, + dot: { + width: 8, + height: 8, + borderRadius: RADIUS.pill, + marginRight: SPACING.sm, + }, + statusText: { + ...TYPE.body, + fontWeight: "700", + }, + meta: { + ...TYPE.caption, + color: COLORS.textDim, + marginTop: SPACING.sm, + }, + + // Sections + section: { + marginBottom: SPACING.xl, + }, + sectionTitle: { + ...TYPE.label, + color: COLORS.textDim, + textTransform: "uppercase", + letterSpacing: 1.5, + marginBottom: SPACING.md, + }, + + // Empty state + emptyCard: { + backgroundColor: COLORS.card, + borderWidth: 1, + borderColor: COLORS.border, + borderRadius: RADIUS.lg, + padding: SPACING.lg, + shadowColor: "#000", + shadowOpacity: 0.06, + shadowRadius: 16, + shadowOffset: { width: 0, height: 6 }, + elevation: 2, + }, + emptyTitle: { + ...TYPE.heading, + color: COLORS.text, + marginBottom: SPACING.sm, + }, + emptyBody: { + ...TYPE.body, + color: COLORS.textDim, + lineHeight: 22, + }, + + // Week-over-week cards + cardRow: { + flexDirection: "row", + gap: SPACING.md, + }, + wowCard: { + flex: 1, + backgroundColor: COLORS.card, + borderWidth: 1, + borderColor: COLORS.border, + borderRadius: RADIUS.lg, + padding: SPACING.md, + shadowColor: "#000", + shadowOpacity: 0.06, + shadowRadius: 14, + shadowOffset: { width: 0, height: 5 }, + elevation: 2, + }, + wowLabel: { + ...TYPE.caption, + color: COLORS.textDim, + marginBottom: SPACING.sm, + }, + wowValueRow: { + flexDirection: "row", + alignItems: "flex-end", + }, + wowValue: { + fontSize: 34, + fontWeight: "800", + color: COLORS.text, + letterSpacing: -0.5, + lineHeight: 36, + }, + wowUnit: { + ...TYPE.caption, + color: COLORS.textDim, + marginLeft: 4, + marginBottom: 5, + }, + wowDeltaRow: { + flexDirection: "row", + alignItems: "center", + marginTop: SPACING.sm, + }, + arrowSpacer: { + width: 14, + height: 14, + }, + wowDelta: { + ...TYPE.caption, + marginLeft: 4, + flexShrink: 1, + }, + wowNote: { + ...TYPE.caption, + color: COLORS.textDim, + opacity: 0.7, + marginTop: SPACING.sm, + fontStyle: "italic", + }, + + // Sentiment + sentimentCard: { + backgroundColor: COLORS.card, + borderWidth: 1, + borderColor: COLORS.border, + borderRadius: RADIUS.lg, + padding: SPACING.lg, + shadowColor: "#000", + shadowOpacity: 0.06, + shadowRadius: 16, + shadowOffset: { width: 0, height: 6 }, + elevation: 2, + }, + sentimentHeader: { + flexDirection: "row", + justifyContent: "space-between", + alignItems: "flex-start", + marginBottom: SPACING.md, + }, + sentimentValue: { + fontSize: 32, + fontWeight: "800", + color: COLORS.text, + letterSpacing: -0.5, + lineHeight: 34, + }, + sentimentCaption: { + ...TYPE.caption, + color: COLORS.textDim, + marginTop: SPACING.xs, + }, + sentimentEmpty: { + ...TYPE.body, + color: COLORS.textDim, + }, + sparklineWrap: { + width: "100%", + }, + + footerSpace: { + height: SPACING.xl, + }, +}); diff --git a/frontend/src/screens/StimulusScreen.tsx b/frontend/src/screens/StimulusScreen.tsx new file mode 100644 index 0000000..86893f9 --- /dev/null +++ b/frontend/src/screens/StimulusScreen.tsx @@ -0,0 +1,479 @@ +// StimulusScreen — the daily pulse check. Shows today's stimulus, captures a +// typed response (with behavioral biometrics), submits it, and reveals the +// resulting CheckEngine + InterventionCard. Calm, inviting, premium. +import React, { useCallback, useEffect, useRef, useState } from "react"; +import { + ActivityIndicator, + Image, + Keyboard, + KeyboardAvoidingView, + Platform, + Pressable, + ScrollView, + StyleSheet, + Text, + TextInput, + View, +} from "react-native"; +import { SafeAreaView } from "react-native-safe-area-context"; +import { Audio, AVPlaybackStatus } from "expo-av"; + +import CheckEngine from "../components/CheckEngine"; +import InterventionCard from "../components/InterventionCard"; +import { Stimulus, UserResponse, BurnoutResult } from "../types"; +import { getStimulus, submitResponse } from "../services/api"; +import { useResponseTracker } from "../hooks/useResponseTracker"; +import { DEFAULT_USER_ID } from "../services/config"; +import { COLORS, RADIUS, SPACING, TYPE } from "../utils/colors"; + +export default function StimulusScreen() { + const tracker = useResponseTracker(); + + const [stimulus, setStimulus] = useState(null); + const [loading, setLoading] = useState(true); + const [loadError, setLoadError] = useState(null); + + const [text, setText] = useState(""); + const [submitting, setSubmitting] = useState(false); + const [submitError, setSubmitError] = useState(null); + const [result, setResult] = useState(null); + + // expo-av audio playback for audio stimuli. + const soundRef = useRef(null); + const [isPlaying, setIsPlaying] = useState(false); + const [audioLoading, setAudioLoading] = useState(false); + + const loadStimulus = useCallback(async () => { + setLoading(true); + setLoadError(null); + setResult(null); + setSubmitError(null); + setText(""); + try { + const s = await getStimulus(DEFAULT_USER_ID); + setStimulus(s); + // Start the behavioral clock the moment a fresh stimulus is shown. + tracker.reset(); + } catch (e: any) { + setLoadError(e?.message ?? "Couldn't load today's pulse."); + } finally { + setLoading(false); + } + }, [tracker]); + + useEffect(() => { + loadStimulus(); + }, [loadStimulus]); + + // Clean up any loaded audio when the stimulus changes or we unmount. + useEffect(() => { + return () => { + soundRef.current?.unloadAsync().catch(() => {}); + soundRef.current = null; + }; + }, [stimulus?.id]); + + const onPlaybackStatus = useCallback((status: AVPlaybackStatus) => { + if (!status.isLoaded) return; + setIsPlaying(status.isPlaying); + if (status.didJustFinish) { + setIsPlaying(false); + soundRef.current?.setPositionAsync(0).catch(() => {}); + } + }, []); + + const toggleAudio = useCallback(async () => { + if (!stimulus) return; + try { + if (!soundRef.current) { + setAudioLoading(true); + await Audio.setAudioModeAsync({ playsInSilentModeIOS: true }); + const { sound } = await Audio.Sound.createAsync( + { uri: stimulus.url }, + { shouldPlay: true }, + onPlaybackStatus + ); + soundRef.current = sound; + setAudioLoading(false); + return; + } + const status = await soundRef.current.getStatusAsync(); + if (status.isLoaded && status.isPlaying) { + await soundRef.current.pauseAsync(); + } else { + await soundRef.current.playAsync(); + } + } catch { + setAudioLoading(false); + setSubmitError("Couldn't play the audio clip."); + } + }, [stimulus, onPlaybackStatus]); + + const onKeyPress = tracker.onKeyPress; + const onChangeText = useCallback( + (t: string) => { + tracker.onChangeText(t); + setText(t); + }, + [tracker] + ); + + const handleSubmit = useCallback(async () => { + if (!stimulus || submitting) return; + Keyboard.dismiss(); + setSubmitting(true); + setSubmitError(null); + try { + const metrics = tracker.getMetrics(); + const payload: UserResponse = { + user_id: DEFAULT_USER_ID, + stimulus_id: stimulus.id, + response_text: text, + ...metrics, + source: "app", + timestamp: new Date().toISOString(), + }; + const res = await submitResponse(payload); + setResult(res); + } catch (e: any) { + setSubmitError(e?.message ?? "Couldn't submit your response."); + } finally { + setSubmitting(false); + } + }, [stimulus, submitting, tracker, text]); + + // ---- Loading ---- + if (loading) { + return ( + + + + Preparing today's pulse… + + + ); + } + + // ---- Load error / empty ---- + if (loadError || !stimulus) { + return ( + + + DAILY PULSE + Nothing to check in on + + {loadError ?? "There's no pulse check waiting for you right now."} + + [styles.secondaryBtn, pressed && styles.pressed]} + onPress={loadStimulus} + > + Try again + + + + ); + } + + // ---- Result view (after submit) ---- + if (result) { + return ( + + + YOUR PULSE + Thanks for checking in. + + + + + + + + [styles.secondaryBtn, pressed && styles.pressed]} + onPress={loadStimulus} + > + Done + + + + ); + } + + // ---- Stimulus + response view ---- + return ( + + + + DAILY PULSE + + {/* Stimulus body, rendered by type. */} + {stimulus.type === "image" && ( + + + + {stimulus.prompt || "How does this make you feel?"} + + + )} + + {stimulus.type === "text" && ( + + {stimulus.prompt} + + )} + + {stimulus.type === "audio" && ( + + [styles.audioBtn, pressed && styles.pressed]} + onPress={toggleAudio} + disabled={audioLoading} + > + {audioLoading ? ( + + ) : ( + + )} + + + {isPlaying ? "Listening…" : "Tap to listen"} + + {stimulus.prompt} + + )} + + {/* Response input wired to the behavioral tracker. */} + + + + + {submitError && {submitError}} + + [ + styles.primaryBtn, + (submitting || text.trim().length === 0) && styles.btnDisabled, + pressed && styles.pressed, + ]} + onPress={handleSubmit} + disabled={submitting || text.trim().length === 0} + > + {submitting ? ( + + ) : ( + Submit pulse + )} + + + + + ); +} + +const styles = StyleSheet.create({ + safe: { + flex: 1, + backgroundColor: COLORS.bg, + }, + flex: { + flex: 1, + }, + fillCenter: { + flex: 1, + alignItems: "center", + justifyContent: "center", + paddingHorizontal: SPACING.xl, + gap: SPACING.md, + }, + content: { + paddingHorizontal: SPACING.lg, + paddingTop: SPACING.lg, + paddingBottom: SPACING.xxl, + gap: SPACING.lg, + }, + resultContent: { + paddingHorizontal: SPACING.lg, + paddingTop: SPACING.xl, + paddingBottom: SPACING.xxl, + alignItems: "center", + gap: SPACING.lg, + }, + eyebrow: { + ...TYPE.label, + color: COLORS.textDim, + letterSpacing: 2, + }, + // Stimulus + stimulusBlock: { + gap: SPACING.md, + }, + image: { + width: "100%", + aspectRatio: 4 / 3, + borderRadius: RADIUS.lg, + borderWidth: 1, + borderColor: COLORS.border, + backgroundColor: COLORS.card, + }, + question: { + ...TYPE.title, + color: COLORS.text, + lineHeight: 34, + }, + bigPrompt: { + ...TYPE.hero, + fontSize: 40, + lineHeight: 48, + color: COLORS.text, + }, + // Audio + audioBtn: { + width: 96, + height: 96, + borderRadius: 48, + alignSelf: "center", + alignItems: "center", + justifyContent: "center", + backgroundColor: COLORS.card, + borderWidth: 1, + borderColor: COLORS.border, + shadowColor: COLORS.green, + shadowOpacity: 0.35, + shadowRadius: 20, + shadowOffset: { width: 0, height: 0 }, + }, + playGlyph: { + width: 0, + height: 0, + marginLeft: 6, + borderTopWidth: 16, + borderBottomWidth: 16, + borderLeftWidth: 26, + borderTopColor: "transparent", + borderBottomColor: "transparent", + borderLeftColor: COLORS.green, + }, + pauseGlyph: { + width: 24, + height: 28, + borderLeftWidth: 7, + borderRightWidth: 7, + borderColor: COLORS.green, + }, + audioHint: { + ...TYPE.label, + color: COLORS.textDim, + textAlign: "center", + letterSpacing: 1.5, + }, + // Input + inputCard: { + backgroundColor: COLORS.card, + borderRadius: RADIUS.lg, + borderWidth: 1, + borderColor: COLORS.border, + padding: SPACING.md, + shadowColor: "#000", + shadowOpacity: 0.05, + shadowRadius: 12, + shadowOffset: { width: 0, height: 4 }, + elevation: 1, + }, + input: { + ...TYPE.body, + color: COLORS.text, + fontSize: 17, + lineHeight: 24, + minHeight: 120, + }, + // Buttons + primaryBtn: { + backgroundColor: COLORS.green, + borderRadius: RADIUS.md, + paddingVertical: SPACING.md, + alignItems: "center", + justifyContent: "center", + minHeight: 54, + }, + primaryBtnText: { + ...TYPE.heading, + color: "#ffffff", + fontSize: 17, + }, + btnDisabled: { + opacity: 0.4, + }, + secondaryBtn: { + borderRadius: RADIUS.md, + paddingVertical: SPACING.sm + 2, + paddingHorizontal: SPACING.lg, + borderWidth: 1, + borderColor: COLORS.border, + alignItems: "center", + justifyContent: "center", + marginTop: SPACING.sm, + }, + secondaryBtnText: { + ...TYPE.body, + color: COLORS.text, + }, + pressed: { + opacity: 0.7, + }, + // States + muted: { + ...TYPE.body, + color: COLORS.textDim, + textAlign: "center", + }, + errorTitle: { + ...TYPE.title, + color: COLORS.text, + textAlign: "center", + }, + errorInline: { + ...TYPE.body, + color: COLORS.red, + textAlign: "center", + }, + // Result + resultTitle: { + ...TYPE.title, + color: COLORS.text, + textAlign: "center", + }, + engineWrap: { + alignItems: "center", + justifyContent: "center", + marginVertical: SPACING.sm, + }, +}); diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts new file mode 100644 index 0000000..8e539aa --- /dev/null +++ b/frontend/src/services/api.ts @@ -0,0 +1,145 @@ +// Owned by Rishith. All backend calls live here. Wesley imports these. +// Talks ONLY to Jason's backend (:8001). Endpoints per shared/contract. +import { + UserResponse, + BurnoutResult, + Stimulus, + BrainData, + VideoResult, + ChatMessage, + ConverseResult, + HistoryEntry, +} from "../types"; +import { BACKEND_URL, ML_URL, VIDEO_URL, SIGNALS_URL } from "./config"; + +async function json(res: Response): Promise { + if (!res.ok) throw new Error(`${res.url} -> ${res.status}`); + return res.json() as Promise; +} + +export async function getStimulus(userId: string): Promise { + return json(await fetch(`${BACKEND_URL}/stimulus/today/${userId}`)); +} + +export async function submitResponse(data: UserResponse): Promise { + return json( + await fetch(`${BACKEND_URL}/response/submit`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(data), + }) + ); +} + +// Live, DERIVED wellness reading (higher = better). The ML service updates this +// every time the user talks or texts, so it actually moves. +export async function getScore(userId: string): Promise { + return json(await fetch(`${ML_URL}/wellness/${userId}`)); +} + +// Past check-ins + talks (most recent first) — from the ML history store. +export async function getHistory(userId: string): Promise { + return json(await fetch(`${ML_URL}/history/${userId}`)); +} + +export async function getBrainData(userId: string): Promise { + return json(await fetch(`${BACKEND_URL}/brain/${userId}`)); +} + +export async function getMetrics(userId: string): Promise { + return json(await fetch(`${BACKEND_URL}/metrics/${userId}`)); +} + +export async function submitVideo(userId: string, videoUri: string): Promise { + const fd = new FormData(); + // React Native FormData file shape: + fd.append("video", { uri: videoUri, type: "video/mp4", name: "checkin.mp4" } as any); + fd.append("user_id", userId); + return json(await fetch(`${BACKEND_URL}/video/submit`, { method: "POST", body: fd })); +} + +// Companion chat (text turn). Talks DIRECTLY to the ML service (:8003), not the +// backend — it's a stateless turn: send the running transcript + the user's +// current score/level for grounding, get back one reply. +export async function sendChat( + userId: string, + messages: ChatMessage[], + score?: number, + level?: string +): Promise<{ reply: string }> { + return json( + await fetch(`${ML_URL}/chat`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ user_id: userId, messages, score, level }), + }) + ); +} + +// Voice-call turn (Talk tab, voice mode). Uploads the user's spoken audio to the +// video service, which transcribes it (NVIDIA parakeet), reads voice stress, +// asks the ML companion for a reply, and speaks it back (NVIDIA Chatterbox). +// Returns the transcript, reply text, reply audio (base64 WAV), and voice stress. +export async function converse( + userId: string, + audioUri: string, + history: ChatMessage[], + score?: number, + level?: string +): Promise { + const fd = new FormData(); + fd.append("audio", { uri: audioUri, type: "audio/m4a", name: "turn.m4a" } as any); + fd.append("history", JSON.stringify(history)); + fd.append("user_id", userId); + if (score != null) fd.append("score", String(score)); + if (level) fd.append("level", level); + return json(await fetch(`${VIDEO_URL}/converse`, { method: "POST", body: fd })); +} + +// Synthesize arbitrary text to speech (Pegasus's opening line). Returns base64 +// WAV, or null audio if TTS is unavailable. +export async function speak(text: string): Promise<{ audio_b64: string | null; sample_rate: number }> { + return json( + await fetch(`${VIDEO_URL}/tts`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ text }), + }) + ); +} + +// Single-frame facial affect (HF model) for the live video call — fast read of +// the user's face during the FaceTime conversation. +export async function analyzeFrame( + imageUri: string +): Promise<{ facial_stress_score: number; overall_affect?: string; emotion_distribution?: Record }> { + const fd = new FormData(); + fd.append("image", { uri: imageUri, type: "image/jpeg", name: "frame.jpg" } as any); + return json(await fetch(`${VIDEO_URL}/facial-frame`, { method: "POST", body: fd })); +} + +// SMS / iMessage check-ins (Bloo.io). Talks to the signals service (:8002): +// register the phone, then trigger a pulse-check text (an emotionally-evocative +// image stimulus). The user replies by text and the bot scores it. +export async function registerPhone( + userId: string, + phone: string +): Promise<{ registered: boolean; phone: string }> { + return json( + await fetch(`${SIGNALS_URL}/register-phone`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ user_id: userId, phone }), + }) + ); +} + +export async function sendCheckin(userId: string, phone: string): Promise { + return json( + await fetch(`${SIGNALS_URL}/send-checkin`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ user_id: userId, phone }), + }) + ); +} diff --git a/frontend/src/services/config.ts b/frontend/src/services/config.ts new file mode 100644 index 0000000..c359069 --- /dev/null +++ b/frontend/src/services/config.ts @@ -0,0 +1,13 @@ +// Owned by Rishith. The phone can't reach this Mac over the LAN (firewall + VPN), +// so every backend service is exposed over its own cloudflare tunnel and the app +// talks to those public HTTPS URLs. (Quick-tunnel URLs are regenerated each run — +// if they change, update them here.) +export const BACKEND_URL = "https://moms-efficiency-standard-accessory.trycloudflare.com"; // :8001 orchestrator +export const ML_URL = "https://pad-metropolitan-beverly-sender.trycloudflare.com"; // :8003 TRIBE+HF+RAG, /chat +export const VIDEO_URL = "https://suffered-cpu-sustainable-sciences.trycloudflare.com"; // :8004 facial/voice, /converse, /tts +export const SIGNALS_URL = "https://whale-epinions-summary-dry.trycloudflare.com"; // :8002 SMS bot + +export const DEFAULT_USER_ID = "demo_user"; + +// Polling cadence for the live burnout score (ms). +export const SCORE_POLL_MS = 30_000; diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts new file mode 100644 index 0000000..5b14c79 --- /dev/null +++ b/frontend/src/types/index.ts @@ -0,0 +1,110 @@ +// Pegasus shared types. Owned by Rishith. Wesley imports these into screens. +// Mirror of the backend contract — keep shape-compatible with Jason's API. + +export interface Stimulus { + id: string; + type: "image" | "audio" | "text"; + url: string; + category: "calm" | "neutral" | "activating"; + prompt: string; +} + +export interface UserResponse { + user_id: string; + stimulus_id: string; + response_text: string; + response_time_ms: number; + response_latency_ms: number; + typing_wpm: number; + error_rate: number; + source: "app" | "sms"; + timestamp: string; +} + +export interface BurnoutResult { + score: number; + level: "green" | "yellow" | "red"; + tribe_deviation: number; + behavioral_deviation: number; + top_indicators: string[]; + intervention: string; + brain_regions_flagged: string[]; + confidence: number; + breakdown: { + imessage: number; + typing: number; + facial: number; + voice: number; + tribe: number; + }; + timestamp: string; +} + +export interface FacialAnalysis { + facial_stress_score: number; + eye_indicators: { + blink_rate_per_min: number; + eye_openness: number; + gaze_stability: number; + }; + facial_indicators: { + brow_furrow: number; + lip_compression: number; + jaw_clench: number; + forced_smile: boolean; + overall_affect: "positive" | "neutral" | "negative" | "flat"; + }; +} + +export interface VoiceAnalysis { + transcript: string; + pitch_mean_hz: number; + pitch_variability: number; + speaking_rate_wpm: number; + pause_frequency: number; + voice_tremor: boolean; +} + +export interface BrainData { + regions: { + prefrontal_cortex: number; + amygdala_region: number; + temporal_lobe: number; + motor_cortex: number; + visual_cortex: number; + }; + activation_mean: number; +} + +// Returned by POST /video/submit (facial + voice + combined). +export interface VideoResult { + facial: FacialAnalysis; + voice: VoiceAnalysis; + combined_score: number; +} + +// One turn in the companion conversation (Talk tab). Sent to the ML /chat +// endpoint and rendered as a bubble. +export interface ChatMessage { + role: "user" | "assistant"; + content: string; +} + +// One past reading (check-in or talk) shown in the History tab. +export interface HistoryEntry { + score: number; + level: "green" | "yellow" | "red"; + timestamp: string; + text: string; + intervention: string; + kind: "talk" | "check-in"; +} + +// Returned by POST /converse (video service): one spoken turn of the voice call. +export interface ConverseResult { + transcript: string; // what the user said (NVIDIA parakeet STT) + reply: string; // Pegasus's reply text + voice: Partial & { error?: string }; // per-turn voice-stress read + audio_b64: string | null; // reply spoken aloud (NVIDIA Chatterbox), base64 WAV + sample_rate: number; +} diff --git a/frontend/src/utils/colors.ts b/frontend/src/utils/colors.ts new file mode 100644 index 0000000..04a8346 --- /dev/null +++ b/frontend/src/utils/colors.ts @@ -0,0 +1,65 @@ +// Design tokens for Pegasus. Owned by Wesley (visual layer). +// Apple-HIG light theme — clean, airy, high-contrast. Matches the pitch deck +// (apple.com / keynote aesthetic): off-white canvas, white elevated cards with +// hairline borders and soft shadows, Apple-blue accents, SF system type. + +export const COLORS = { + bg: "#f5f5f7", // Apple off-white canvas + card: "#ffffff", // elevated surfaces (hairline border + soft shadow) + border: "#d2d2d7", // hairline separators / borders + text: "#1d1d1f", // primary text + textDim: "#6e6e73", // secondary / dim text + green: "#34C759", // engine-light: calm + yellow: "#FF9F0A", // engine-light: elevated (Apple amber) + red: "#FF3B30", // engine-light: alert + blue: "#0071e3", // accent: primary / links / active (Apple blue) +} as const; + +// Tertiary text — quieter than textDim, for the lightest captions/footnotes. +export const TEXT_TERTIARY = "#86868b"; + +export type BurnoutLevel = "green" | "yellow" | "red"; + +// Map a burnout level to its accent hex. Falls back to dim text for unknowns. +export function levelColor(level: string): string { + switch (level) { + case "green": + return COLORS.green; + case "yellow": + return COLORS.yellow; + case "red": + return COLORS.red; + default: + return COLORS.textDim; + } +} + +// 8pt-based spacing scale. Generous negative space. +export const SPACING = { + xs: 4, + sm: 8, + md: 16, + lg: 24, + xl: 32, + xxl: 48, +} as const; + +// Corner radii — Apple-ish soft rounding. Cards sit in the 16-20 range. +export const RADIUS = { + sm: 10, + md: 16, + lg: 20, + pill: 999, +} as const; + +// Typographic scale. Big, tight SF headlines; calm body copy. The app uses the +// platform system font (-apple-system / SF) via React Native defaults. +export const TYPE = { + hero: { fontSize: 64, fontWeight: "700" as const, letterSpacing: -1.5 }, + score: { fontSize: 48, fontWeight: "700" as const, letterSpacing: -1 }, + title: { fontSize: 28, fontWeight: "700" as const, letterSpacing: -0.4 }, + heading: { fontSize: 20, fontWeight: "600" as const, letterSpacing: -0.2 }, + body: { fontSize: 16, fontWeight: "400" as const }, + label: { fontSize: 13, fontWeight: "600" as const, letterSpacing: 0.4 }, + caption: { fontSize: 12, fontWeight: "400" as const }, +} as const; diff --git a/frontend/src/utils/formatting.ts b/frontend/src/utils/formatting.ts new file mode 100644 index 0000000..40fc628 --- /dev/null +++ b/frontend/src/utils/formatting.ts @@ -0,0 +1,61 @@ +// Formatting helpers for Pegasus UI. Owned by Wesley (visual layer). + +// Human-friendly relative time, e.g. "just now", "5m ago", "3h ago", "2d ago". +export function relativeTime(iso: string): string { + const then = new Date(iso).getTime(); + if (Number.isNaN(then)) return ""; + const diffMs = Date.now() - then; + const sec = Math.round(diffMs / 1000); + + if (sec < 0) return "just now"; + if (sec < 45) return "just now"; + const min = Math.round(sec / 60); + if (min < 1) return "just now"; + if (min < 60) return `${min}m ago`; + const hr = Math.round(min / 60); + if (hr < 24) return `${hr}h ago`; + const day = Math.round(hr / 24); + if (day < 7) return `${day}d ago`; + const wk = Math.round(day / 7); + if (wk < 5) return `${wk}w ago`; + const mo = Math.round(day / 30); + if (mo < 12) return `${mo}mo ago`; + const yr = Math.round(day / 365); + return `${yr}y ago`; +} + +// Format a 0..1 ratio (or already-percent if > 1) as a rounded percent string. +export function pct(n: number): string { + if (n == null || Number.isNaN(n)) return "0%"; + const value = Math.abs(n) <= 1 ? n * 100 : n; + return `${Math.round(value)}%`; +} + +// Title Case a string, handling snake_case and kebab-case separators. +export function titleCase(s: string): string { + if (!s) return ""; + return s + .replace(/[_-]+/g, " ") + .trim() + .split(/\s+/) + .map((w) => w.charAt(0).toUpperCase() + w.slice(1).toLowerCase()) + .join(" "); +} + +// Map a wellness level to its dashboard status copy. +// Wellness is 0..100 where higher = better: green is thriving, yellow neutral, +// red is running low. +export function scoreLabel( + level: string +): "Thriving" | "Keep an eye out" | "Running low" { + switch (level) { + case "green": + return "Thriving"; + case "yellow": + return "Keep an eye out"; + case "red": + return "Running low"; + default: + return "Thriving"; + } +} diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json new file mode 100644 index 0000000..8afa8e9 --- /dev/null +++ b/frontend/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "expo/tsconfig.base", + "compilerOptions": { + "strict": true, + "baseUrl": ".", + "paths": { "@/*": ["src/*"] } + } +} diff --git a/imessage/.env.example b/imessage/.env.example new file mode 100644 index 0000000..e5024b3 --- /dev/null +++ b/imessage/.env.example @@ -0,0 +1,4 @@ +# iMessage bridge. Copy to .env and adjust. +BACKEND_URL=http://localhost:8001 +POLL_SECONDS=5 +AUTO_REGISTER=1 # auto-create a backend user for any new sender diff --git a/imessage/AGENTS.md b/imessage/AGENTS.md new file mode 100644 index 0000000..e520ee3 --- /dev/null +++ b/imessage/AGENTS.md @@ -0,0 +1,26 @@ +# `/imessage` — iMessage bridge (owner: TBD) + +A **standalone** delivery channel. Not one of the four service lanes — assign +an owner before serious work. Read `../AGENTS.md` and `../shared/contract.md`. + +## Boundary +You may ONLY edit `imessage/`. Never touch `frontend/`, `backend/`, `ml/`, +`signals/`, `shared/`. The bridge talks to the **backend only** (`:8001`): +- `POST /users` (register a phone) +- `GET /stimulus/today?user_id=` (what to send) +- `POST /checkin` (score a reply) + +## What it does (macOS-only) +- **Send** via AppleScript → Messages.app (`send.applescript`, `imessage.send`). +- **Receive** by polling `~/Library/Messages/chat.db` (`imessage.fetch_new`). +- `bridge.py` is the CLI/loop: `register`, `send`, `send-daily`, `listen`. +- `registry.py` maps phone ⇄ backend user_id. + +## Run +See `README.md`. Needs Full Disk Access + signed-in iMessage on the Mac. + +## Git +```bash +git add imessage/ # ONLY imessage/ +git commit -m "feat(imessage): ..." +``` diff --git a/imessage/README.md b/imessage/README.md new file mode 100644 index 0000000..130c5bf --- /dev/null +++ b/imessage/README.md @@ -0,0 +1,52 @@ +# imessage — the iMessage bridge + +Closes the Pegasus loop over real iMessage on macOS, with **no API and no paid +service** — just Messages.app (to send) and `chat.db` (to receive). + +``` +send-daily ── stimulus ──▶ user's iPhone/Mac + │ replies in iMessage +chat.db poll ◀── reply ─────────┘ + └─▶ POST :8001/checkin ─▶ score + intervention ─▶ iMessage reply +``` + +It talks **only** to the backend (`:8001`). It does not import or edit any other +service. + +## One-time Mac setup +1. Sign into **iMessage** in Messages.app on this Mac. +2. Grant **Full Disk Access** to whatever runs the bridge (Terminal, iTerm, or + your Python) so it can read `~/Library/Messages/chat.db`: + System Settings → Privacy & Security → **Full Disk Access** → add the app. +3. First time you send, macOS may prompt to allow controlling Messages — allow it. + +## Install & run +```bash +cd imessage +python3 -m venv .venv && source .venv/bin/activate +pip install -r requirements.txt +cp .env.example .env + +# make sure the backend is up first (port 8001) +python bridge.py register "+15551234567" "Alex" # register a tester +python bridge.py send-daily # text them today's stimulus +python bridge.py listen # watch for replies & score them +``` + +`listen` polls `chat.db` every `POLL_SECONDS`, scores each reply via the +backend, and texts back the 🟢/🟡/🔴 + intervention. It starts from "now" so it +never replays old history; progress is saved in `.state.json`. + +## Caveats (it's a local hack, not a product) +- The Mac must stay on and signed into iMessage; it uses **your** account. +- AppleScript send syntax is macOS-version-sensitive — see the note in + `send.applescript` if `send-daily` errors. +- Newer macOS stores some message text in `attributedBody` (binary), not the + `text` column. `imessage.decode_attributed_body` handles the common cases; + `pip install typedstream` if you hit a message it can't read. +- No keystroke dynamics over iMessage, so check-ins score on text sentiment + (typing metrics are sent as 0). + +## Owner +Standalone service — **assign an owner**. It lives outside the four service +lanes; whoever owns it works only inside `imessage/` and against the backend API. diff --git a/imessage/bridge.py b/imessage/bridge.py new file mode 100644 index 0000000..ea3d934 --- /dev/null +++ b/imessage/bridge.py @@ -0,0 +1,148 @@ +"""Pegasus iMessage bridge — closes the loop over iMessage on macOS. + + stimulus out → user replies in iMessage → /checkin → intervention back + +Talks ONLY to the backend's public API (:8001). Standalone service; owner TBD. + +Commands: + python bridge.py register "+15551234567" "Alex" # register a phone + python bridge.py send-daily # push today's stimulus to all + python bridge.py send "+15551234567" "hi" # raw test send + python bridge.py listen # poll for replies, score, reply + +Env (see .env.example): BACKEND_URL, POLL_SECONDS, AUTO_REGISTER. +""" +from __future__ import annotations + +import argparse +import json +import os +import sys +import time +from pathlib import Path + +import httpx + +import imessage +import registry + +BACKEND_URL = os.getenv("BACKEND_URL", "http://localhost:8001") +POLL_SECONDS = float(os.getenv("POLL_SECONDS", "5")) +AUTO_REGISTER = os.getenv("AUTO_REGISTER", "1") == "1" +STATE_PATH = Path(__file__).with_name(".state.json") + +LIGHT = {"green": "🟢", "yellow": "🟡", "red": "🔴"} + + +def _state() -> dict: + return json.loads(STATE_PATH.read_text()) if STATE_PATH.exists() else {} + + +def _save_state(s: dict) -> None: + STATE_PATH.write_text(json.dumps(s)) + + +def _today_stimulus(user_id: str) -> dict: + r = httpx.get(f"{BACKEND_URL}/stimulus/today", params={"user_id": user_id}, timeout=15) + r.raise_for_status() + return r.json() + + +# --- commands -------------------------------------------------------------- +def cmd_register(phone: str, name: str) -> None: + uid = registry.register(phone, name) + print(f"registered {phone} -> user_id {uid}") + + +def cmd_send(phone: str, message: str) -> None: + imessage.send(phone, message) + print(f"sent to {phone}") + + +def cmd_send_daily() -> None: + users = registry.all_users() + if not users: + print("no registered users — run `register` first.") + return + for phone, info in users.items(): + s = _today_stimulus(info["user_id"]) + body = f"{s['content']}\n\n{s['prompt']}" + imessage.send(phone, body) + print(f"daily stimulus → {phone}") + + +def _handle_reply(phone: str, text: str) -> None: + user_id = registry.get_user_id(phone) + if not user_id: + if not AUTO_REGISTER: + print(f"ignoring unknown sender {phone}") + return + user_id = registry.register(phone) + print(f"auto-registered {phone} -> {user_id}") + + stimulus = _today_stimulus(user_id) + # iMessage can't capture keystroke dynamics, so typing metrics are 0 — + # scoring leans on text sentiment via the signals service. + resp = httpx.post(f"{BACKEND_URL}/checkin", json={ + "user_id": user_id, + "stimulus_id": stimulus["stimulus_id"], + "text_response": text, + "response_time_ms": 0, "typing_wpm": 0, "error_rate": 0, + }, timeout=60) + resp.raise_for_status() + result = resp.json() + + light = LIGHT.get(result["level"], "") + iv = result.get("intervention", {}) + reply = f"{light} score {result['score']}/100\n{iv.get('message','')}\n↳ {iv.get('suggested_action','')}" + imessage.send(phone, reply) + print(f"check-in {phone}: {result['level']} ({result['score']}) → replied") + + +def cmd_listen() -> None: + state = _state() + last = state.get("last_rowid") + if last is None: + last = imessage.max_rowid() # don't replay history on first run + _save_state({"last_rowid": last}) + print(f"listening from rowid {last} (every {POLL_SECONDS}s). Ctrl-C to stop.") + + while True: + try: + new = imessage.fetch_new(last) + for msg in new: + last = msg["rowid"] + try: + _handle_reply(msg["handle"], msg["text"]) + except Exception as e: + print(f" ! failed to handle msg from {msg['handle']}: {e}") + _save_state({"last_rowid": last}) + except Exception as e: + print(f" ! poll error: {e}") + time.sleep(POLL_SECONDS) + + +def main() -> None: + p = argparse.ArgumentParser(prog="bridge") + sub = p.add_subparsers(dest="cmd", required=True) + r = sub.add_parser("register"); r.add_argument("phone"); r.add_argument("name", nargs="?", default="iMessage User") + s = sub.add_parser("send"); s.add_argument("phone"); s.add_argument("message") + sub.add_parser("send-daily") + sub.add_parser("listen") + args = p.parse_args() + + if args.cmd == "register": + cmd_register(args.phone, args.name) + elif args.cmd == "send": + cmd_send(args.phone, args.message) + elif args.cmd == "send-daily": + cmd_send_daily() + elif args.cmd == "listen": + try: + cmd_listen() + except KeyboardInterrupt: + sys.exit(0) + + +if __name__ == "__main__": + main() diff --git a/imessage/imessage.py b/imessage/imessage.py new file mode 100644 index 0000000..3dc1013 --- /dev/null +++ b/imessage/imessage.py @@ -0,0 +1,105 @@ +"""iMessage send/receive on macOS — no API, just Messages.app + chat.db. + +Send: drives Messages.app via AppleScript (osascript). +Receive: reads ~/Library/Messages/chat.db (read-only) for new inbound rows. + +Requires (on the Mac running this): + - signed into iMessage in Messages.app, app allowed to run + - **Full Disk Access** for whatever runs this (Terminal / python) so it can + read chat.db (System Settings → Privacy & Security → Full Disk Access) +""" +from __future__ import annotations + +import os +import sqlite3 +import subprocess +from pathlib import Path +from typing import List, Optional, TypedDict + +CHAT_DB = Path(os.path.expanduser("~/Library/Messages/chat.db")) +SEND_SCRIPT = Path(__file__).with_name("send.applescript") + + +class InboundMessage(TypedDict): + rowid: int + handle: str # sender phone/email + text: str + + +# --- send ------------------------------------------------------------------ +def send(phone: str, message: str) -> None: + """Send an iMessage. Raises subprocess.CalledProcessError on failure.""" + subprocess.run( + ["osascript", str(SEND_SCRIPT), phone, message], + check=True, + capture_output=True, + text=True, + ) + + +# --- receive ---------------------------------------------------------------- +def _connect_ro() -> sqlite3.Connection: + # Open read-only so we never lock the live Messages database. + conn = sqlite3.connect(f"file:{CHAT_DB}?mode=ro", uri=True) + conn.row_factory = sqlite3.Row + return conn + + +def max_rowid() -> int: + with _connect_ro() as c: + row = c.execute("SELECT MAX(ROWID) AS m FROM message").fetchone() + return int(row["m"] or 0) + + +def fetch_new(after_rowid: int) -> List[InboundMessage]: + """Return inbound (received) messages with ROWID > after_rowid.""" + query = """ + SELECT m.ROWID AS rowid, m.text AS text, m.attributedBody AS body, + h.id AS handle + FROM message m + LEFT JOIN handle h ON m.handle_id = h.ROWID + WHERE m.is_from_me = 0 AND m.ROWID > ? + ORDER BY m.ROWID ASC + """ + out: List[InboundMessage] = [] + with _connect_ro() as c: + for row in c.execute(query, (after_rowid,)).fetchall(): + text = row["text"] + if not text and row["body"] is not None: + text = decode_attributed_body(row["body"]) + if not text or not row["handle"]: + continue + out.append({"rowid": int(row["rowid"]), "handle": row["handle"], "text": text}) + return out + + +def decode_attributed_body(data: bytes) -> Optional[str]: + """Best-effort extraction of message text from a streamtyped + NSAttributedString blob (used when the `text` column is NULL on newer + macOS). Handles the common short/long-string cases; returns None if the + layout isn't recognized. For 100% fidelity, `pip install typedstream`. + """ + if not data: + return None + try: + idx = data.index(b"NSString") + except ValueError: + return None + chunk = data[idx + len(b"NSString"):] + plus = chunk.find(b"+") # type marker that precedes the length byte + if plus == -1: + return None + p = plus + 1 + if p >= len(chunk): + return None + length = chunk[p] + p += 1 + if length == 0x81: # 2-byte little-endian length + length = int.from_bytes(chunk[p:p + 2], "little") + p += 2 + elif length == 0x82: # 4-byte little-endian length + length = int.from_bytes(chunk[p:p + 4], "little") + p += 4 + raw = chunk[p:p + length] + text = raw.decode("utf-8", errors="ignore").strip() + return text or None diff --git a/imessage/registry.py b/imessage/registry.py new file mode 100644 index 0000000..0d80efe --- /dev/null +++ b/imessage/registry.py @@ -0,0 +1,61 @@ +"""Maps phone numbers <-> backend user_ids, persisted to registry.json. + +Auto-registers unknown phones as backend users on first contact so anyone can +text the bot and start checking in. +""" +from __future__ import annotations + +import json +import os +import re +from pathlib import Path +from typing import Dict, Optional + +import httpx + +BACKEND_URL = os.getenv("BACKEND_URL", "http://localhost:8001") +REGISTRY_PATH = Path(__file__).with_name("registry.json") + + +def _norm(phone: str) -> str: + """Normalize a handle to digits (+ optional leading +) so '+1 (555) 123' + and '+15551230000' match.""" + phone = phone.strip() + if "@" in phone: # email handle — keep as-is + return phone.lower() + digits = re.sub(r"[^\d]", "", phone) + return f"+{digits}" if digits else phone + + +def _load() -> Dict[str, Dict]: + if REGISTRY_PATH.exists(): + return json.loads(REGISTRY_PATH.read_text()) + return {} + + +def _save(data: Dict[str, Dict]) -> None: + REGISTRY_PATH.write_text(json.dumps(data, indent=2)) + + +def all_users() -> Dict[str, Dict]: + return _load() + + +def get_user_id(phone: str) -> Optional[str]: + return _load().get(_norm(phone), {}).get("user_id") + + +def register(phone: str, name: str = "iMessage User") -> str: + """Ensure a backend user exists for this phone; return its user_id.""" + key = _norm(phone) + data = _load() + if key in data: + return data[key]["user_id"] + + resp = httpx.post(f"{BACKEND_URL}/users", json={"name": name, "phone": key}, timeout=15) + resp.raise_for_status() + user_id = resp.json()["user_id"] + + data[key] = {"user_id": user_id, "name": name} + _save(data) + return user_id diff --git a/imessage/requirements.txt b/imessage/requirements.txt new file mode 100644 index 0000000..e5c1453 --- /dev/null +++ b/imessage/requirements.txt @@ -0,0 +1,4 @@ +httpx==0.28.1 +python-dotenv==1.0.1 +# Optional, for 100% reliable chat.db text decoding: +# typedstream diff --git a/imessage/send.applescript b/imessage/send.applescript new file mode 100644 index 0000000..9cbd934 --- /dev/null +++ b/imessage/send.applescript @@ -0,0 +1,16 @@ +-- Send an iMessage via Messages.app. +-- Usage: osascript send.applescript "+15551234567" "your message" +-- +-- Note: the exact syntax is macOS-version-sensitive. This `service`+`buddy` +-- form works on most recent versions. If your macOS errors, try replacing +-- `buddy targetPhone` with `participant targetPhone`, or `service` with +-- `account`. +on run argv + set targetPhone to item 1 of argv + set targetMessage to item 2 of argv + tell application "Messages" + set targetService to 1st service whose service type = iMessage + set targetBuddy to buddy targetPhone of targetService + send targetMessage to targetBuddy + end tell +end run diff --git a/ml/.env.example b/ml/.env.example new file mode 100644 index 0000000..2fcceac --- /dev/null +++ b/ml/.env.example @@ -0,0 +1,6 @@ +# ML service (Rishith). Copy to .env and fill in. NEVER commit .env. +HF_TOKEN=hf_... +# Optional overrides: +# HF_MODEL=Qwen/Qwen2.5-7B-Instruct # intervention chat model +# HF_EMOTION_MODEL=j-hartmann/emotion-english-distilroberta-base +# TRIBE v2 runs on Modal — authenticate once with: modal token new diff --git a/ml/AGENTS.md b/ml/AGENTS.md new file mode 100644 index 0000000..9aecaeb --- /dev/null +++ b/ml/AGENTS.md @@ -0,0 +1,52 @@ +# `/ml` (+ `/video` + frontend data layer) — Rishith's agent + +You are **Rishith**. You own **`ml/`**, **`video/`**, and the **frontend data +layer** (`frontend/src/services`, `frontend/src/hooks`, `frontend/src/types`). +`ml/` is the burnout scorer on **:8003**. Read `../AGENTS.md` first. + +## Never touch +Wesley's screens/components (`frontend/App.tsx`, `src/screens`, `src/components`, +`src/navigation`, `src/utils`), `/backend`, `/signals`, `/shared`. Call APIs; +ask Jason for contract changes. + +## TRIBE v2 — already deployed on Modal +TRIBE runs separately on Modal as app `pegasus-tribe`, class `TribePredictor`. +**Do NOT redeploy or rewrite it.** Just call it: +```python +import modal +Tribe = modal.Cls.from_name("pegasus-tribe", "TribePredictor") +baseline = Tribe().get_baseline.remote(stimulus_id) +``` +`main.py` calls it lazily and degrades to a null baseline if Modal is offline. + +## Endpoints (:8003) +- `GET /health` → `{status, tribe}` +- `POST /score` → `{user_id, stimulus_id, signals}` → `BurnoutResult` +- `GET /baseline/{stimulus_id}` → cached TRIBE prediction + +## Files +- `main.py` — FastAPI + Modal TRIBE client. +- `scoring.py` — `BurnoutScorer`, **Hugging Face only (no Anthropic)**: HF + emotion model (`hf_sentiment`) + deviation math (`compute_deviation`) + HF + chat-model intervention (`_intervene`, via `huggingface_hub.InferenceClient`). + Returns `BurnoutResult` (score, level, tribe/behavioral deviation, indicators, + intervention, brain_regions_flagged, confidence, breakdown). + +## Secrets (.env, gitignored) +`HF_TOKEN` (used for BOTH sentiment + interventions). Optional `HF_MODEL`, +`HF_EMOTION_MODEL`. Modal auth via `modal token new`. Read with `os.getenv`. +Levels: green <30, yellow <65, red ≥65. + +## Run +```bash +cd ml && python -m venv .venv && source .venv/bin/activate +pip install -r requirements.txt && cp .env.example .env +modal token new # once +uvicorn main:app --port 8003 --reload +``` + +## Git — branch `feat/rishith-ml` +```bash +git add ml/ video/ frontend/src/services/ frontend/src/hooks/ frontend/src/types/ +git commit -m "feat: ..." && git push origin feat/rishith-ml +``` diff --git a/ml/cache/baselines.json b/ml/cache/baselines.json new file mode 100644 index 0000000..58ad85a --- /dev/null +++ b/ml/cache/baselines.json @@ -0,0 +1,156 @@ +{ + "audio_01": { + "stimulus_id": "audio_01", + "regions": { + "prefrontal_cortex": 0.159, + "amygdala_region": 0.0, + "temporal_lobe": 0.325, + "motor_cortex": 0.471, + "visual_cortex": 0.0 + }, + "macro_groups": { + "Attention": 0.129, + "Auditory": 0.46, + "Emotion": 0.0, + "Language": 0.189, + "Motor": 0.471, + "Visual": 0.0 + }, + "overall": 0.208, + "total_timesteps": 6, + "agent_summary": "TRIBE v2 cortical trajectory: 6 timesteps (TR=1.49s), peak at t=1. Dominant macro groups at peak: Motor (0.47), Auditory (0.46), Language (0.19). fMRI predictions averaged over Yeo 7-network parcellation; hemodynamic lag (~5s) already compensated by TRIBE.", + "source": "tribe-v2" + }, + "img_calm_02": { + "stimulus_id": "img_calm_02", + "regions": { + "prefrontal_cortex": 0.162, + "amygdala_region": 0.0, + "temporal_lobe": 0.306, + "motor_cortex": 0.448, + "visual_cortex": 0.019 + }, + "macro_groups": { + "Attention": 0.16, + "Auditory": 0.449, + "Emotion": 0.0, + "Language": 0.163, + "Motor": 0.448, + "Visual": 0.019 + }, + "overall": 0.207, + "total_timesteps": 6, + "agent_summary": "TRIBE v2 cortical trajectory: 6 timesteps (TR=1.49s), peak at t=1. Dominant macro groups at peak: Auditory (0.45), Motor (0.45), Language (0.16). fMRI predictions averaged over Yeo 7-network parcellation; hemodynamic lag (~5s) already compensated by TRIBE.", + "source": "tribe-v2" + }, + "img_calm_03": { + "stimulus_id": "img_calm_03", + "regions": { + "prefrontal_cortex": 0.115, + "amygdala_region": 0.0, + "temporal_lobe": 0.24, + "motor_cortex": 0.357, + "visual_cortex": 0.051 + }, + "macro_groups": { + "Attention": 0.118, + "Auditory": 0.367, + "Emotion": 0.0, + "Language": 0.113, + "Motor": 0.357, + "Visual": 0.051 + }, + "overall": 0.168, + "total_timesteps": 6, + "agent_summary": "TRIBE v2 cortical trajectory: 6 timesteps (TR=1.49s), peak at t=1. Dominant macro groups at peak: Auditory (0.37), Motor (0.36), Attention (0.12). fMRI predictions averaged over Yeo 7-network parcellation; hemodynamic lag (~5s) already compensated by TRIBE.", + "source": "tribe-v2" + }, + "calm_02": { + "stimulus_id": "calm_02", + "regions": { + "prefrontal_cortex": 0.159, + "amygdala_region": 0.0, + "temporal_lobe": 0.296, + "motor_cortex": 0.415, + "visual_cortex": 0.022 + }, + "macro_groups": { + "Attention": 0.179, + "Auditory": 0.454, + "Emotion": 0.0, + "Language": 0.138, + "Motor": 0.415, + "Visual": 0.022 + }, + "overall": 0.201, + "total_timesteps": 5, + "agent_summary": "TRIBE v2 cortical trajectory: 5 timesteps (TR=1.49s), peak at t=1. Dominant macro groups at peak: Auditory (0.45), Motor (0.41), Attention (0.18). fMRI predictions averaged over Yeo 7-network parcellation; hemodynamic lag (~5s) already compensated by TRIBE.", + "source": "tribe-v2" + }, + "img_calm_01": { + "stimulus_id": "img_calm_01", + "regions": { + "prefrontal_cortex": 0.074, + "amygdala_region": 0.0, + "temporal_lobe": 0.242, + "motor_cortex": 0.41, + "visual_cortex": 0.0 + }, + "macro_groups": { + "Attention": 0.077, + "Auditory": 0.414, + "Emotion": 0.0, + "Language": 0.071, + "Motor": 0.41, + "Visual": 0.0 + }, + "overall": 0.162, + "total_timesteps": 5, + "agent_summary": "TRIBE v2 cortical trajectory: 5 timesteps (TR=1.49s), peak at t=0. Dominant macro groups at peak: Auditory (0.41), Motor (0.41), Attention (0.08). fMRI predictions averaged over Yeo 7-network parcellation; hemodynamic lag (~5s) already compensated by TRIBE.", + "source": "tribe-v2" + }, + "calm_01": { + "stimulus_id": "calm_01", + "regions": { + "prefrontal_cortex": 0.106, + "amygdala_region": 0.0, + "temporal_lobe": 0.27, + "motor_cortex": 0.446, + "visual_cortex": 0.0 + }, + "macro_groups": { + "Attention": 0.083, + "Auditory": 0.413, + "Emotion": 0.0, + "Language": 0.128, + "Motor": 0.446, + "Visual": 0.0 + }, + "overall": 0.178, + "total_timesteps": 5, + "agent_summary": "TRIBE v2 cortical trajectory: 5 timesteps (TR=1.49s), peak at t=1. Dominant macro groups at peak: Motor (0.45), Auditory (0.41), Language (0.13). fMRI predictions averaged over Yeo 7-network parcellation; hemodynamic lag (~5s) already compensated by TRIBE.", + "source": "tribe-v2" + }, + "img_activating_03": { + "stimulus_id": "img_activating_03", + "regions": { + "prefrontal_cortex": 0.088, + "amygdala_region": 0.0, + "temporal_lobe": 0.244, + "motor_cortex": 0.461, + "visual_cortex": 0.0 + }, + "macro_groups": { + "Attention": 0.091, + "Auditory": 0.402, + "Emotion": 0.0, + "Language": 0.085, + "Motor": 0.461, + "Visual": 0.0 + }, + "overall": 0.173, + "total_timesteps": 5, + "agent_summary": "TRIBE v2 cortical trajectory: 5 timesteps (TR=1.49s), peak at t=1. Dominant macro groups at peak: Motor (0.46), Auditory (0.40), Attention (0.09). fMRI predictions averaged over Yeo 7-network parcellation; hemodynamic lag (~5s) already compensated by TRIBE.", + "source": "tribe-v2" + } +} \ No newline at end of file diff --git a/ml/chat.py b/ml/chat.py new file mode 100644 index 0000000..98be562 --- /dev/null +++ b/ml/chat.py @@ -0,0 +1,170 @@ +"""Conversational companion — multi-turn chat grounded in the user's state. + +Hugging Face only (no Anthropic). The model is Pegasus: a warm "check engine +light" companion, NOT a therapist. Each turn is grounded in: + - the running conversation (last ~12 messages), + - the user's current burnout reading (score/level), and + - coping snippets retrieved from the mental-health corpus (reuses the RAG + retriever from rag_intervention.py). + +Everything degrades gracefully: if HF is unreachable, ``reply()`` still returns +a warm, contextual response built from the retrieved snippet + a reflective +follow-up, so the conversation never dead-ends. ``reply()`` never raises. +""" +from __future__ import annotations + +import os +import re +from typing import Dict, List, Optional + +HF_TOKEN = os.getenv("HF_TOKEN") +CHAT_MODEL = os.getenv("HF_MODEL", "Qwen/Qwen2.5-7B-Instruct") + +_MAX_TURNS = 12 # trailing messages kept as context + +_SYSTEM = ( + "You are Pegasus — a warm, grounded companion inside a mental-health " + '"check engine light" app. You are NOT a therapist and you never diagnose. ' + "You listen first, reflect back what you hear in plain language, and offer " + "one small, concrete next step only when it helps. Keep replies short: 1-3 " + "sentences, conversational, no bullet lists, no clinical jargon. " + "CRUCIAL: respond to the SPECIFIC thing they just said — quote or name the exact " + "detail back (the person, the task, the deadline, the feeling, the event they " + "mentioned) and react to THAT. Never give a generic, templated, or " + "one-size-fits-all reply, and never repeat a stock phrase. If they mention " + "something concrete, your reply must reference it directly. " + "Ask a gentle, specific follow-up that builds on what they actually said. " + "If someone over-reassures or insists they're fine (\"I'm fine, really, don't " + "worry, all good\"), don't just take it at face value — warmly acknowledge it, " + "then gently invite one honest detail about how their day actually went." +) + +_CRISIS_RE = re.compile( + r"\b(kill myself|suicide|suicidal|end it all|want to die|self.?harm|hurt myself)\b", + re.I, +) +_CRISIS_REPLY = ( + "I'm really glad you told me. I can't be the right support for this on my own — " + "please reach out right now to someone you trust or a crisis line (in the US, " + "call or text 988). You don't have to carry this alone." +) + +_NEG = { + "tired", "exhausted", "drained", "burnt", "burned", "stressed", "anxious", + "overwhelmed", "sad", "down", "lonely", "angry", "frustrated", "hopeless", + "can't", "cant", "struggling", "worried", "scared", "numb", "empty", +} + +_rag_engine = None + + +def _rag(): + global _rag_engine + if _rag_engine is None: + from rag_intervention import RagInterventionEngine + + _rag_engine = RagInterventionEngine() + return _rag_engine + + +def _trim(messages: List[Dict]) -> List[Dict]: + """Keep the last few user/assistant turns with clean role/content shape.""" + out: List[Dict] = [] + for m in messages or []: + role = m.get("role") + content = (m.get("content") or "").strip() + if role in ("user", "assistant") and content: + out.append({"role": role, "content": content}) + return out[-_MAX_TURNS:] + + +def _last_user(messages: List[Dict]) -> str: + for m in reversed(messages or []): + if m.get("role") == "user" and (m.get("content") or "").strip(): + return m["content"].strip() + return "" + + +class ChatEngine: + def __init__(self) -> None: + self._client = None # lazy huggingface_hub.InferenceClient + + def _hf(self): + if self._client is None: + from huggingface_hub import InferenceClient + + self._client = InferenceClient(token=HF_TOKEN) + return self._client + + def reply( + self, + messages: List[Dict], + score: Optional[int] = None, + level: Optional[str] = None, + ) -> str: + """One companion turn. Never raises; always returns a non-empty string.""" + msgs = _trim(messages) + last = _last_user(msgs) + + if _CRISIS_RE.search(last): + return _CRISIS_REPLY + + grounding = self._grounding(last, level) + + if HF_TOKEN: + try: + system = _SYSTEM + if score is not None: + system += ( + f"\n\nPrivate context (do not state the number unless asked): the " + f"person's current Pegasus burnout reading is {score}/100 ({level}). " + "Let it shape your tone, not your words." + ) + if grounding: + system += ( + "\n\nCoping notes you may quietly draw from (never quote verbatim):\n" + + grounding + ) + resp = self._hf().chat_completion( + model=CHAT_MODEL, + max_tokens=160, # short replies → snappier voice turnaround + temperature=0.8, + messages=[{"role": "system", "content": system}] + msgs, + ) + text = (resp.choices[0].message.content or "").strip() + if text: + return text + except Exception: + pass + + return self._fallback(last, level) + + def _grounding(self, query: str, level: Optional[str]) -> str: + try: + retrieved = _rag().retrieve(level or "any", [query] if query else [], k=2) + return "\n".join(f"- {s['text']}" for s in retrieved) + except Exception: + return "" + + def _fallback(self, last_user: str, level: Optional[str]) -> str: + """Warm, contextual reply with no model — reflect + ground + follow up.""" + tokens = set(re.findall(r"[a-z']+", last_user.lower())) + heavy = bool(tokens & _NEG) + + try: + retrieved = _rag().retrieve(level or "any", [last_user] if last_user else [], k=1) + tip = retrieved[0]["text"] if retrieved else "" + except Exception: + tip = "" + + if not last_user: + return "I'm here. What's on your mind right now?" + + opener = ( + "That sounds heavy, and it makes sense you'd feel it. " + if heavy + else "Thanks for sharing that with me. " + ) + follow = "What does that look like for you today?" if heavy else "Tell me a little more?" + body = f"{tip} " if tip else "" + return f"{opener}{body}{follow}".strip() diff --git a/ml/data/mental_health_corpus.json b/ml/data/mental_health_corpus.json new file mode 100644 index 0000000..fb80924 --- /dev/null +++ b/ml/data/mental_health_corpus.json @@ -0,0 +1,318 @@ +{ + "version": 1, + "note": "Non-clinical, evidence-informed coping and CBT-style micro-actions for a burnout check-in coach. No diagnosis, no clinical labels, no crisis methods. Levels: green=tracking well, yellow=early strain, red=heavy load, any=fits any level.", + "snippets": [ + { + "id": "burnout-01", + "tags": ["burnout", "overwhelm"], + "level": "yellow", + "text": "Pick the single most important task for the next hour and let everything else wait. One clear thing beats five half-started ones." + }, + { + "id": "burnout-02", + "tags": ["burnout", "low_energy"], + "level": "red", + "text": "If you can, close the laptop for the rest of today. Rest is part of the work, not a reward you have to earn first." + }, + { + "id": "burnout-03", + "tags": ["burnout", "overwhelm"], + "level": "any", + "text": "Write down the three things draining you most right now. Naming them shrinks them from a fog into a list you can actually act on." + }, + { + "id": "burnout-04", + "tags": ["burnout"], + "level": "green", + "text": "You're holding steady. Protect one small window today that's just for you, before something else fills it." + }, + { + "id": "burnout-05", + "tags": ["burnout", "low_energy"], + "level": "yellow", + "text": "Trade one meeting or task today for a 20-minute walk. Moving your body often resets your head faster than pushing through does." + }, + { + "id": "burnout-06", + "tags": ["burnout", "social_withdrawal"], + "level": "red", + "text": "Send one message to a person you trust and tell them you're running low. You don't need the right words, just an honest one." + }, + { + "id": "burnout-07", + "tags": ["burnout", "overwhelm"], + "level": "yellow", + "text": "Ask yourself what would still matter in a week. Spend your best energy there and let the rest be good enough." + }, + { + "id": "sleep-01", + "tags": ["sleep", "low_energy"], + "level": "any", + "text": "Tonight, put your phone across the room before bed. A few feet of distance makes the scroll harder and sleep easier." + }, + { + "id": "sleep-02", + "tags": ["sleep", "rumination"], + "level": "yellow", + "text": "If your mind races at night, keep a notepad by the bed and write the thought down. Parking it on paper tells your brain it's safe to let go." + }, + { + "id": "sleep-03", + "tags": ["sleep"], + "level": "green", + "text": "Try dimming the lights an hour before bed tonight. Cooler, softer light is a quiet signal to your body that the day is winding down." + }, + { + "id": "sleep-04", + "tags": ["sleep", "low_energy"], + "level": "red", + "text": "Aim to be in bed 30 minutes earlier tonight, even if you don't sleep right away. Lying still in the dark still gives your system a break." + }, + { + "id": "sleep-05", + "tags": ["sleep", "anxiety"], + "level": "yellow", + "text": "If you wake at 3am, resist checking the time or your phone. Slow your breathing out longer than you breathe in, and let the night carry you." + }, + { + "id": "sleep-06", + "tags": ["sleep", "focus"], + "level": "any", + "text": "Keep your wake-up time steady, even after a rough night. A consistent morning anchor does more for energy than a long lie-in." + }, + { + "id": "focus-01", + "tags": ["focus", "overwhelm"], + "level": "yellow", + "text": "Set a timer for 25 minutes on one task and silence notifications. When it rings, you're allowed to stop. Starting is the hard part, not finishing." + }, + { + "id": "focus-02", + "tags": ["focus"], + "level": "green", + "text": "Before you dive in, jot the one outcome you want from this work block. A clear target keeps your attention from drifting." + }, + { + "id": "focus-03", + "tags": ["focus", "low_energy"], + "level": "yellow", + "text": "If you can't focus, do the smallest possible version of the task first. A two-minute start often unsticks the whole thing." + }, + { + "id": "focus-04", + "tags": ["focus", "overwhelm"], + "level": "red", + "text": "When your brain feels too full to think, stop and empty it onto paper for five minutes. You can't focus through fog you haven't cleared." + }, + { + "id": "focus-05", + "tags": ["focus"], + "level": "any", + "text": "Close the tabs you're not using right now. A cluttered screen quietly pulls at attention you'd rather spend elsewhere." + }, + { + "id": "focus-06", + "tags": ["focus", "rumination"], + "level": "yellow", + "text": "If the same worry keeps interrupting you, schedule 10 minutes to think about it later today. Then gently return to the task in front of you." + }, + { + "id": "anxiety-01", + "tags": ["anxiety"], + "level": "yellow", + "text": "Try a slow breath: in for four counts, out for six. A longer exhale tells your nervous system the alarm can quiet down." + }, + { + "id": "anxiety-02", + "tags": ["anxiety", "overwhelm"], + "level": "red", + "text": "Name five things you can see and three you can hear right now. Grounding in the room helps when your thoughts are racing ahead of you." + }, + { + "id": "anxiety-03", + "tags": ["anxiety", "rumination"], + "level": "yellow", + "text": "Ask whether the worry is something you can act on or only something you can fear. If you can act, take one small step; if not, set it down for now." + }, + { + "id": "anxiety-04", + "tags": ["anxiety"], + "level": "green", + "text": "Feeling a little keyed up is normal. A few minutes of slow breathing or a short stretch can keep it from building." + }, + { + "id": "anxiety-05", + "tags": ["anxiety", "social_withdrawal"], + "level": "yellow", + "text": "If reaching out feels heavy, start with a small message to one safe person. Connection eases anxiety more reliably than waiting it out alone." + }, + { + "id": "anxiety-06", + "tags": ["anxiety", "rumination"], + "level": "any", + "text": "Notice the story your mind is telling, then ask what a kind friend would say back. Speaking to yourself gently is a skill, not a weakness." + }, + { + "id": "anxiety-07", + "tags": ["anxiety", "focus"], + "level": "yellow", + "text": "Put both feet flat on the floor and feel the chair holding you. Sometimes the fastest way out of your head is back into your body." + }, + { + "id": "low-energy-01", + "tags": ["low_energy"], + "level": "yellow", + "text": "Drink a full glass of water and step into daylight for a few minutes. Mild dehydration and dim rooms quietly sap energy you didn't know you were losing." + }, + { + "id": "low-energy-02", + "tags": ["low_energy", "burnout"], + "level": "red", + "text": "Lower the bar for today on purpose. Doing the essentials and resting is a complete day when your tank is this low." + }, + { + "id": "low-energy-03", + "tags": ["low_energy", "focus"], + "level": "yellow", + "text": "Match the task to your energy: save the hard thinking for your sharpest hour and give the dull tasks to the slow ones." + }, + { + "id": "low-energy-04", + "tags": ["low_energy"], + "level": "green", + "text": "A short burst of movement, even two minutes of stretching, can lift a flat afternoon more than another coffee will." + }, + { + "id": "low-energy-05", + "tags": ["low_energy", "sleep"], + "level": "yellow", + "text": "If you keep hitting an afternoon wall, a 15-minute rest with your eyes closed can be more restoring than pushing through it." + }, + { + "id": "low-energy-06", + "tags": ["low_energy"], + "level": "any", + "text": "Eat something with a little protein if it's been a while. Steady fuel keeps your mood and focus from dipping with your blood sugar." + }, + { + "id": "overwhelm-01", + "tags": ["overwhelm"], + "level": "yellow", + "text": "Break the thing that feels huge into the very next physical step. You don't have to see the whole staircase to take one stair." + }, + { + "id": "overwhelm-02", + "tags": ["overwhelm", "burnout"], + "level": "red", + "text": "Choose one thing to drop or delay today and let it go without guilt. Protecting your limits is how you keep showing up at all." + }, + { + "id": "overwhelm-03", + "tags": ["overwhelm", "focus"], + "level": "yellow", + "text": "Try a brain dump: list everything on your mind, then circle just one to start. Getting it out of your head frees up room to think." + }, + { + "id": "overwhelm-04", + "tags": ["overwhelm"], + "level": "green", + "text": "Things feel manageable right now. A quick five-minute plan for tomorrow will keep it that way and ease the morning rush." + }, + { + "id": "overwhelm-05", + "tags": ["overwhelm", "social_withdrawal"], + "level": "yellow", + "text": "If your plate is overflowing, ask one person for help with one thing. Sharing the load is a strength, not an admission." + }, + { + "id": "overwhelm-06", + "tags": ["overwhelm", "anxiety"], + "level": "red", + "text": "When everything feels urgent, pause and ask what truly can't wait until tomorrow. Usually it's far less than it feels in the moment." + }, + { + "id": "social-01", + "tags": ["social_withdrawal"], + "level": "yellow", + "text": "Reach out to one person today, even just a short hello. Connection often feels hardest right when it would help the most." + }, + { + "id": "social-02", + "tags": ["social_withdrawal", "burnout"], + "level": "red", + "text": "Tell someone you trust that you're pulling back and don't quite know why. You don't have to explain it perfectly to be heard." + }, + { + "id": "social-03", + "tags": ["social_withdrawal", "low_energy"], + "level": "yellow", + "text": "Plan one low-effort bit of company this week, like a walk or a quiet coffee. Small contact counts when big plans feel like too much." + }, + { + "id": "social-04", + "tags": ["social_withdrawal"], + "level": "green", + "text": "You're doing okay. Keep one regular point of contact on the calendar so connection doesn't slip away when you get busy." + }, + { + "id": "social-05", + "tags": ["social_withdrawal", "rumination"], + "level": "yellow", + "text": "If you've been assuming people are annoyed with you, check the evidence rather than the fear. Your mind tends to fill silence with the worst story." + }, + { + "id": "rumination-01", + "tags": ["rumination"], + "level": "yellow", + "text": "When a thought loops, name it out loud: 'I'm replaying this again.' Catching the loop is the first step to stepping out of it." + }, + { + "id": "rumination-02", + "tags": ["rumination", "anxiety"], + "level": "yellow", + "text": "Shift from 'why is this happening' to 'what's one small thing I can do next.' Action quiets a spinning mind better than analysis does." + }, + { + "id": "rumination-03", + "tags": ["rumination", "focus"], + "level": "any", + "text": "Give the worry a 10-minute window later, then redirect now. Telling your mind 'not yet, but soon' loosens its grip without ignoring it." + }, + { + "id": "rumination-04", + "tags": ["rumination", "sleep"], + "level": "red", + "text": "If a heavy thought won't quiet down tonight, write it out and add one kind line you'd tell a friend in the same spot." + }, + { + "id": "rumination-05", + "tags": ["rumination"], + "level": "green", + "text": "A little overthinking is human. A short walk or a change of room can break a mild loop before it settles in." + }, + { + "id": "general-01", + "tags": ["burnout", "anxiety", "overwhelm"], + "level": "any", + "text": "Take one slow breath before you decide your next move. A single pause can change a reaction into a choice." + }, + { + "id": "general-02", + "tags": ["low_energy", "focus", "burnout"], + "level": "any", + "text": "Step away from the screen for five minutes and look at something far away. Short, regular breaks keep you steadier than long ones forced by a crash." + }, + { + "id": "general-03", + "tags": ["burnout", "overwhelm", "social_withdrawal"], + "level": "red", + "text": "Be gentle with yourself today; you're carrying a lot. Pick one kind thing to do for yourself and let that be enough." + }, + { + "id": "general-04", + "tags": ["focus", "anxiety", "rumination"], + "level": "green", + "text": "You're in a good spot to build a small habit. Choose one tiny daily anchor, like a morning glass of water, and let it carry you on harder days." + } + ] +} diff --git a/ml/main.py b/ml/main.py new file mode 100644 index 0000000..d913e27 --- /dev/null +++ b/ml/main.py @@ -0,0 +1,137 @@ +"""Pegasus ML service (:8003) — scoring around TRIBE v2 (already on Modal). + +TRIBE v2 is deployed separately on Modal (app `synapse-tribe-v2-api`, class +`TribeV2`, method `predict(text)`). See tribe_client.py — we map the stimulus to +text, call TRIBE, and cache the baseline. App/class are env-overridable. + +Run (from inside /ml): + modal token new # once, to authenticate Modal + uvicorn main:app --port 8003 --reload +""" +from __future__ import annotations + +from datetime import datetime, timezone +from typing import List, Optional + +from fastapi import FastAPI +from fastapi.middleware.cors import CORSMiddleware +from pydantic import BaseModel + +import tribe_client +from chat import ChatEngine +from scoring import BurnoutScorer + +app = FastAPI(title="Pegasus ML") +app.add_middleware(CORSMiddleware, allow_origins=["*"], allow_methods=["*"], allow_headers=["*"]) + +scorer = BurnoutScorer() +chat_engine = ChatEngine() + +# Live wellness store — the app reads /wellness for the latest DERIVED reading. +# Every real turn (a texted reply or a voice-call turn) calls /score, which +# updates this, so the number actually moves instead of sitting on one value. +_latest: dict = {} +# Per-user history of readings (past check-ins + talks) for the History tab. +_history: dict = {} +_HISTORY_MAX = 60 + + +def _now() -> str: + return datetime.now(timezone.utc).isoformat() + + +def _baseline(stimulus_id: str): + """Cached real TRIBE v2 baseline (regions + macro-groups). None if offline.""" + return tribe_client.get_baseline(stimulus_id) + + +class ScoreReq(BaseModel): + user_id: str + stimulus_id: str + signals: dict + + +class ChatMessage(BaseModel): + role: str + content: str + + +class ChatReq(BaseModel): + user_id: str + messages: List[ChatMessage] + score: Optional[int] = None + level: Optional[str] = None + + +@app.get("/health") +def health(): + return {"status": "ml running", "tribe_api": tribe_client.TRIBE_API} + + +@app.post("/score") +def score(req: ScoreReq): + baseline = _baseline(req.stimulus_id) + result = scorer.compute_deviation(baseline, req.signals) + result["timestamp"] = _now() + result["user_id"] = req.user_id + _latest[req.user_id] = result # feed the live wellness reading + + kind = "talk" if req.stimulus_id == "conversation" else "check-in" + entry = { + "score": result["score"], + "level": result["level"], + "timestamp": result["timestamp"], + "text": (req.signals.get("response_text") or "").strip()[:160], + "intervention": result.get("intervention", ""), + "kind": kind, + } + _history.setdefault(req.user_id, []).append(entry) + _history[req.user_id] = _history[req.user_id][-_HISTORY_MAX:] + return result + + +@app.get("/history/{user_id}") +def history(user_id: str): + """Past check-ins + talks (most recent first) for the History tab.""" + return list(reversed(_history.get(user_id, []))) + + +@app.get("/wellness/{user_id}") +def wellness(user_id: str): + """Latest DERIVED wellness reading (higher = better). Updates every time the + user talks or texts; neutral starting state until their first turn.""" + r = _latest.get(user_id) + if r: + return r + return { + "user_id": user_id, + "score": 72, + "level": "green", + "intervention": "Talk to Pegasus or reply to a text to get your first reading.", + "top_indicators": ["No reading yet — start a conversation"], + "tribe_deviation": 0, + "behavioral_deviation": 0, + "brain_regions_flagged": [], + "confidence": 0.5, + "breakdown": {}, + "timestamp": _now(), + "source": "starting", + } + + +@app.post("/chat") +def chat(req: ChatReq): + """Multi-turn companion chat, grounded in the user's burnout reading + RAG.""" + msgs = [{"role": m.role, "content": m.content} for m in req.messages] + return {"reply": chat_engine.reply(msgs, req.score, req.level)} + + +@app.get("/baseline/{stimulus_id}") +def baseline(stimulus_id: str): + return _baseline(stimulus_id) or {"error": "tribe baseline unavailable", "regions": {}} + + +if __name__ == "__main__": + import uvicorn + + uvicorn.run(app, host="0.0.0.0", port=8003) diff --git a/ml/rag_intervention.py b/ml/rag_intervention.py new file mode 100644 index 0000000..bad2889 --- /dev/null +++ b/ml/rag_intervention.py @@ -0,0 +1,223 @@ +"""RAG burnout-coaching intervention — Hugging Face only (no Anthropic). + +A tiny in-memory retriever over ``data/mental_health_corpus.json`` plus a HF +chat model that turns the retrieved coping snippets into ONE warm, grounded +action. Everything degrades gracefully so the demo never breaks: + + - Embeddings via ``InferenceClient.feature_extraction`` (sentence-transformers + all-MiniLM-L6-v2). If HF is unreachable, fall back to a pure-python + keyword/tag-overlap cosine score — no extra dependencies. + - Generation via ``InferenceClient.chat_completion`` (env ``HF_MODEL``, + default Qwen/Qwen2.5-7B-Instruct), grounded only in retrieved snippets. + If HF is unreachable, return the best retrieved snippet verbatim. + +``suggest()`` NEVER raises — it always returns a non-empty string. +""" +from __future__ import annotations + +import json +import math +import os +import re +from pathlib import Path +from typing import Dict, List, Optional + +HF_TOKEN = os.getenv("HF_TOKEN") +EMBED_MODEL = os.getenv("HF_EMBED_MODEL", "sentence-transformers/all-MiniLM-L6-v2") +CHAT_MODEL = os.getenv("HF_MODEL", "Qwen/Qwen2.5-7B-Instruct") + +_CORPUS_PATH = Path(__file__).resolve().parent / "data" / "mental_health_corpus.json" +_TOKEN_RE = re.compile(r"[a-z]+") +_SAFE_FALLBACK = "Take a slow breath, then step away from the screen for five minutes before your next task." + + +def _tokens(text: str) -> List[str]: + return _TOKEN_RE.findall((text or "").lower()) + + +class RagInterventionEngine: + def __init__(self) -> None: + self._snippets: List[Dict] = self._load_corpus() + self._client = None # lazy huggingface_hub.InferenceClient + self._embeddings: Optional[List[List[float]]] = None # cached snippet vectors + self._embeddings_tried = False + + # --- corpus ------------------------------------------------------------ + @staticmethod + def _load_corpus() -> List[Dict]: + try: + data = json.loads(_CORPUS_PATH.read_text()) + except Exception: + return [] + items = data.get("snippets") if isinstance(data, dict) else data + out: List[Dict] = [] + for s in items or []: + if isinstance(s, dict) and s.get("text"): + out.append(s) + return out + + # --- HF client --------------------------------------------------------- + def _hf(self): + if self._client is None: + from huggingface_hub import InferenceClient + + self._client = InferenceClient(token=HF_TOKEN) + return self._client + + # --- embeddings -------------------------------------------------------- + @staticmethod + def _to_vector(raw) -> List[float]: + """feature_extraction may return [dim] or [tokens, dim]; reduce to [dim].""" + try: + vec = raw.tolist() # numpy ndarray + except AttributeError: + vec = raw + # Unwrap a leading batch dim if present. + while isinstance(vec, list) and len(vec) == 1 and isinstance(vec[0], list): + vec = vec[0] + if vec and isinstance(vec[0], list): # token-level -> mean-pool + cols = len(vec[0]) + pooled = [0.0] * cols + for row in vec: + for i in range(cols): + pooled[i] += float(row[i]) + n = len(vec) + return [v / n for v in pooled] + return [float(v) for v in vec] + + def _embed(self, text: str) -> Optional[List[float]]: + if not HF_TOKEN: + return None + try: + return self._to_vector(self._hf().feature_extraction(text, model=EMBED_MODEL)) + except Exception: + return None + + def _snippet_embeddings(self) -> Optional[List[List[float]]]: + """Embed the corpus once. None if HF embeddings are unavailable.""" + if self._embeddings_tried: + return self._embeddings + self._embeddings_tried = True + if not HF_TOKEN or not self._snippets: + return None + vecs: List[List[float]] = [] + for s in self._snippets: + v = self._embed(s["text"]) + if v is None: + return None # bail fully -> keyword fallback for consistency + vecs.append(v) + self._embeddings = vecs + return self._embeddings + + @staticmethod + def _cosine(a: List[float], b: List[float]) -> float: + if not a or not b or len(a) != len(b): + return 0.0 + dot = sum(x * y for x, y in zip(a, b)) + na = math.sqrt(sum(x * x for x in a)) + nb = math.sqrt(sum(y * y for y in b)) + if na == 0.0 or nb == 0.0: + return 0.0 + return dot / (na * nb) + + # --- keyword fallback scoring ----------------------------------------- + def _keyword_score(self, snippet: Dict, query_tokens: set, indicators: List[str]) -> float: + """Pure-python cosine-style overlap over tokens + tag matches.""" + ind_text = " ".join(indicators or []) + ind_tokens = set(_tokens(ind_text)) + snippet_tokens = set(_tokens(snippet.get("text", ""))) + tag_tokens = set() + for t in snippet.get("tags", []) or []: + tag_tokens.update(_tokens(str(t).replace("_", " "))) + + wanted = query_tokens | ind_tokens + if not wanted: + return 0.0 + text_overlap = len(snippet_tokens & wanted) + tag_overlap = len(tag_tokens & wanted) + # Cosine-style normalization by vocab sizes; tags weighted higher as + # they are curated topic labels. + denom = math.sqrt(len(wanted)) * math.sqrt(max(len(snippet_tokens) + len(tag_tokens), 1)) + return (text_overlap + 2.0 * tag_overlap) / denom if denom else 0.0 + + # --- retrieval --------------------------------------------------------- + @staticmethod + def _level_ok(snippet_level: str, level: str) -> bool: + sl = (snippet_level or "any").lower() + return sl == "any" or sl == (level or "").lower() + + def retrieve(self, level: str, indicators: List[str], k: int = 3) -> List[Dict]: + if not self._snippets: + return [] + query = " ".join(indicators or []) or "burnout stress coping" + candidates = [s for s in self._snippets if self._level_ok(s.get("level"), level)] + if not candidates: + candidates = list(self._snippets) + + embeddings = self._snippet_embeddings() + scored: List[tuple] = [] + if embeddings is not None: + qv = self._embed(query) + if qv is not None: + idx = {id(s): i for i, s in enumerate(self._snippets)} + for s in candidates: + scored.append((self._cosine(qv, embeddings[idx[id(s)]]), s)) + + if not scored: # keyword/tag-overlap fallback + qtokens = set(_tokens(query)) + for s in candidates: + scored.append((self._keyword_score(s, qtokens, indicators), s)) + + scored.sort(key=lambda x: x[0], reverse=True) + return [s for _, s in scored[:k]] + + # --- generation -------------------------------------------------------- + def _generate(self, score: int, level: str, indicators: List[str], retrieved: List[Dict]) -> Optional[str]: + if not HF_TOKEN or not retrieved: + return None + grounding = "\n".join(f"- {s['text']}" for s in retrieved) + try: + resp = self._hf().chat_completion( + model=CHAT_MODEL, + max_tokens=120, + temperature=0.7, + messages=[ + { + "role": "system", + "content": ( + "You are a warm, practical burnout check-in coach, not a therapist or " + "clinician. You are a check engine light: you nudge, you do not diagnose. " + "Ground your advice ONLY in the provided coping notes. Never give clinical " + "labels or diagnoses. Reply with ONE specific, doable action in 1-2 warm " + "sentences, no preamble." + ), + }, + { + "role": "user", + "content": ( + f"Burnout signal {score}/100 (level: {level}). " + f"Observed signs: {', '.join(indicators) or 'none specific'}.\n\n" + f"Coping notes you can draw from:\n{grounding}\n\n" + "Give one warm, specific action grounded in those notes." + ), + }, + ], + ) + text = (resp.choices[0].message.content or "").strip() + return text or None + except Exception: + return None + + # --- public ------------------------------------------------------------ + def suggest(self, score: int, level: str, indicators: List[str]) -> str: + """Retrieve + generate ONE warm action. Never raises; always a string.""" + try: + retrieved = self.retrieve(level, indicators or [], k=3) + generated = self._generate(score, level, indicators or [], retrieved) + if generated: + return generated + if retrieved: + return retrieved[0]["text"] + return _SAFE_FALLBACK + except Exception: + return _SAFE_FALLBACK diff --git a/ml/requirements.txt b/ml/requirements.txt new file mode 100644 index 0000000..53d0d9c --- /dev/null +++ b/ml/requirements.txt @@ -0,0 +1,8 @@ +fastapi==0.115.6 +uvicorn[standard]==0.34.0 +pydantic==2.10.4 +modal==0.66.45 +huggingface-hub==0.27.0 +numpy==1.26.4 +python-dotenv==1.0.1 +requests==2.32.3 diff --git a/ml/scoring.py b/ml/scoring.py new file mode 100644 index 0000000..8443a12 --- /dev/null +++ b/ml/scoring.py @@ -0,0 +1,232 @@ +"""Burnout scoring: deviation of behavior from the TRIBE healthy baseline. + +All language/AI is **Hugging Face** (no Anthropic): + - sentiment/emotion via a HF text-classification model + - interventions via a HF chat (instruct) model +Both degrade gracefully (sentiment -> 0.5, intervention -> canned copy) so the +demo runs even if HF is unreachable. +""" +from __future__ import annotations + +import os +from typing import Dict, List, Optional + +HF_TOKEN = os.getenv("HF_TOKEN") +EMOTION_MODEL = os.getenv("HF_EMOTION_MODEL", "j-hartmann/emotion-english-distilroberta-base") +CHAT_MODEL = os.getenv("HF_MODEL", "Qwen/Qwen2.5-7B-Instruct") + +_POSITIVE_EMOTIONS = {"joy", "neutral", "surprise"} + +# Direct mental-health language. The emotion model can badly under-read terse +# replies (a one-word "death" may come back near-neutral), so concerning words +# floor the message sub-score and crisis words push it to the top. A check +# engine light must never miss these. +_CRISIS_TERMS = ( + "suicid", "kill myself", "killing myself", "want to die", "end my life", + "hurt myself", "self harm", "self-harm", "better off dead", "no reason to live", +) +_CONCERN_TERMS = ( + "death", "dying", "dead", "hopeless", "worthless", "empty", "numb", + "can't go on", "cant go on", "give up", "giving up", "no point", "end it", + "can't cope", "cant cope", "breaking down", "burnt out", "burned out", + "exhausted", "overwhelmed", "can't sleep", "cant sleep", "alone", "trapped", +) + +# Over-reassurance is a classic burnout MASK — "I'm fine, really, don't worry, +# all good." Taking it at face value is exactly how invisible burnout slips +# through, so when several reassurance markers cluster we treat it as a flag. +_MASKING_TERMS = ( + "i'm fine", "im fine", "totally fine", "perfectly fine", "completely fine", + "i'm okay", "im okay", "i'm ok", "im ok", "i'm alright", "im alright", + "i'm good", "im good", "all good", "everything's fine", "everything is fine", + "everything's good", "everything is good", "don't worry", "dont worry", + "no worries", "it's nothing", "its nothing", "nothing's wrong", "nothing wrong", + "i'm not stressed", "im not stressed", "no big deal", "i'll be fine", "ill be fine", +) + +_rag_engine = None # lazy RagInterventionEngine singleton + + +def _rag(): + global _rag_engine + if _rag_engine is None: + from rag_intervention import RagInterventionEngine + + _rag_engine = RagInterventionEngine() + return _rag_engine + + +class BurnoutScorer: + def __init__(self) -> None: + self._client = None # lazy huggingface_hub.InferenceClient + + def _hf(self): + if self._client is None: + from huggingface_hub import InferenceClient + + self._client = InferenceClient(token=HF_TOKEN) + return self._client + + # --- language ---------------------------------------------------------- + def hf_sentiment(self, text: str) -> float: + """0-1 positivity from a HF emotion model. Returns 0.5 on any failure.""" + if not text or not HF_TOKEN: + return 0.5 + try: + results = self._hf().text_classification(text, model=EMOTION_MODEL) + # results: list of {label, score} across all emotions. + pos = sum(r.score for r in results if r.label.lower() in _POSITIVE_EMOTIONS) + return min(max(float(pos), 0.0), 1.0) + except Exception: + return 0.5 + + # --- scoring ----------------------------------------------------------- + def compute_deviation(self, tribe_baseline: Optional[Dict], signals: Dict) -> Dict: + baseline = tribe_baseline or {} + regions = baseline.get("regions", {}) or {} + expected = float(regions.get("prefrontal_cortex", 0.5)) + + sentiment = signals.get("sentiment_score") + if sentiment is None: + sentiment = self.hf_sentiment(signals.get("response_text", "")) + sentiment = float(sentiment) + + error_rate = float(signals.get("error_rate", 0)) + wpm = float(signals.get("typing_wpm", 60)) + rt = float(signals.get("response_time_ms", 3000)) + + # Behavioral burnout sub-scores (0-100; higher = more burnout signal). + imsg = (1 - sentiment) * 100 + # Concerning / crisis language overrides a soft emotion-model read. + text_l = (signals.get("response_text") or "").lower() + masking = 0 + if any(t in text_l for t in _CRISIS_TERMS): + imsg = 100.0 + else: + concern = sum(1 for t in _CONCERN_TERMS if t in text_l) + if concern: + imsg = max(imsg, min(88.0 + (concern - 1) * 4.0, 100.0)) + # Clustered over-reassurance ("I'm fine, totally fine, don't worry") + # reads as positive to the emotion model but is a masking tell — pull + # it out of the green and into at least a yellow caution. + masking = sum(1 for t in _MASKING_TERMS if t in text_l) + if masking >= 2: + imsg = max(imsg, min(52.0 + masking * 7.0, 82.0)) + typing = ( + min(error_rate / 20, 1) * 0.40 + + (1 - min(wpm / 80, 1)) * 0.35 + + min(rt / 10000, 1) * 0.25 + ) * 100 + facial = float(signals.get("facial_score") or signals.get("facial_stress_score") or 0) + voice = float(signals.get("voice_score") or signals.get("voice_stress_score") or 0) + + # TRIBE deviation = how far behavior sits BELOW the healthy baseline + # (directional — matching a low baseline isn't burnout; only a shortfall is). + actual = ( + sentiment * 0.3 + + (1 - min(error_rate / 20, 1)) * 0.2 + + min(wpm / 80, 1) * 0.2 + + (1 - min(rt / 10000, 1)) * 0.3 + ) + deviation = max(0.0, expected - actual) + tribe = min(deviation * 100, 100) + + # PRD §4.5 weighted fusion, renormalized over the streams ACTUALLY + # present. A text-only check-in (SMS) has no facial/voice and no real + # keystroke biometrics — fusing fake typing defaults would only dilute + # the one real signal (what they wrote), so typing is fused only when + # real typing data was captured (the in-app Pulse screen sends it). + weights = {"imessage": 0.25, "typing": 0.20, "facial": 0.30, "voice": 0.15, "tribe": 0.10} + has_typing = "typing_wpm" in signals or "error_rate" in signals + subs = {"imessage": imsg, "tribe": tribe} + if has_typing: + subs["typing"] = typing + if facial > 0: + subs["facial"] = facial + if voice > 0: + subs["voice"] = voice + total_w = sum(weights[k] for k in subs) + burnout = min(int(round(sum(subs[k] * weights[k] for k in subs) / total_w)), 100) + # Pegasus reports a WELLNESS score — HIGHER IS BETTER. Internally we fuse + # burnout/stress sub-scores, then flip so the number people see rises as + # they're doing better. green = thriving, red = running low. + score = 100 - burnout + level = "green" if score >= 70 else "yellow" if score >= 40 else "red" + + ind: List[str] = [] + if masking >= 2: + ind.append("Strong reassurance — this can mask how you really feel") + if error_rate > 10: + ind.append("Elevated typing errors suggest cognitive strain") + if rt > 8000: + ind.append("Delayed response indicates mental fatigue") + if sentiment < 0.3: + ind.append("Negative sentiment drift detected") + if wpm < 30: + ind.append("Reduced typing speed suggests low energy") + if not ind: + ind.append("No significant warning signs") + + return { + "score": score, + "level": level, + "tribe_deviation": round(deviation, 4), + "behavioral_deviation": round(1 - actual, 4), + "top_indicators": ind[:3], + "intervention": self._intervene(score, level, ind), + "brain_regions_flagged": self._flag_regions(regions), + "confidence": round(min(0.55 + deviation * 0.4, 0.95), 3), + "breakdown": { + "imessage": int(imsg), + "typing": int(typing), + "facial": int(facial), + "voice": int(voice), + "tribe": int(tribe), + }, + } + + @staticmethod + def _flag_regions(regions: Dict) -> List[str]: + labels = { + "prefrontal_cortex": "Prefrontal cortex: elevated cognitive load", + "amygdala_region": "Amygdala: heightened stress response", + "temporal_lobe": "Temporal lobe: altered language processing", + } + return [labels[k] for k, v in regions.items() if k in labels and float(v) > 0.6] + + def _intervene(self, score: int, level: str, indicators: List[str]) -> str: + """One warm, specific action. RAG first, then HF chat, then canned fallback.""" + try: + suggestion = _rag().suggest(score, level, indicators) + if suggestion: + return suggestion + except Exception: + pass + if not HF_TOKEN: + return self._fallback(level) + try: + resp = self._hf().chat_completion( + model=CHAT_MODEL, + max_tokens=120, + temperature=0.7, + messages=[{ + "role": "user", + "content": ( + f"Burnout {score}/100 ({level}). Signs: {', '.join(indicators)}. " + "Give ONE specific action in under 2 sentences. Warm but direct. " + "You are a check engine light, not a therapist. No preamble." + ), + }], + ) + text = resp.choices[0].message.content.strip() + return text or self._fallback(level) + except Exception: + return self._fallback(level) + + @staticmethod + def _fallback(level: str) -> str: + return { + "green": "You're tracking well. Take a real 5-minute break before your next task.", + "yellow": "Step outside for 10 minutes and drink water before you continue.", + "red": "Stop work for today if you can, and message one person you trust right now.", + }.get(level, "Take a 10-minute break and step outside for some fresh air.") diff --git a/ml/tribe_client.py b/ml/tribe_client.py new file mode 100644 index 0000000..7675576 --- /dev/null +++ b/ml/tribe_client.py @@ -0,0 +1,124 @@ +"""Real Meta TRIBE v2 client — calls the deployed Modal FastAPI endpoint. + +App `synapse-tribe-v2-api` exposes (via @modal.asgi_app): + GET /health + POST /api/analyze-text (form field `text`) -> TribeJsonResponse + POST /api/analyze-video (form field `video_url`) + +We call it over HTTP (robust — avoids the modal.Cls local-instantiation bug). +`analyze-text` returns: + { success, total_timesteps, chart_data: [{timestep, Attention, Auditory, + Emotion, Language, Motor, Visual, overall}, ...], agent_summary, segments } + +A "baseline" = TRIBE's healthy-brain response to the stimulus text. We take the +peak (max `overall`) frame, map the six TRIBE macro-groups into the region names +the scorer + Brain screen use, and cache per stimulus_id (TRIBE is GPU-heavy: +~30-90s cold, 2-10s warm — call it once per stimulus). +""" +from __future__ import annotations + +import json +import os +from pathlib import Path +from typing import Dict, Optional + +import requests + +TRIBE_API = os.getenv( + "TRIBE_JSON_API", + "https://rishithchennupati--synapse-tribe-v2-api-fastapi-app.modal.run", +).rstrip("/") +TRIBE_TIMEOUT = float(os.getenv("TRIBE_TIMEOUT", "180")) + +_HERE = Path(__file__).resolve().parent +STIMULI = _HERE.parent / "shared" / "stimuli.json" +CACHE = _HERE / "cache" / "baselines.json" + +MACRO = ("Attention", "Auditory", "Emotion", "Language", "Motor", "Visual") +_cache: Optional[Dict] = None + + +def _stimulus_text(stimulus_id: str) -> Optional[str]: + try: + data = json.loads(STIMULI.read_text()) + except Exception: + return None + items = data if isinstance(data, list) else (data.get("stimuli") or data.get("items") or []) + for s in items: + if isinstance(s, dict) and s.get("id") == stimulus_id: + text = " ".join(str(s.get(k, "")) for k in ("prompt", "content")).strip() + return text or str(s.get("url") or s.get("id")) + return None + + +def _macro_to_regions(frame: Dict) -> Dict[str, float]: + g = lambda k: float(frame.get(k, 0.0) or 0.0) + return { + "prefrontal_cortex": round((g("Attention") + g("Language")) / 2, 3), + "amygdala_region": round(g("Emotion"), 3), + "temporal_lobe": round((g("Auditory") + g("Language")) / 2, 3), + "motor_cortex": round(g("Motor"), 3), + "visual_cortex": round(g("Visual"), 3), + } + + +def _load_cache() -> Dict: + global _cache + if _cache is None: + try: + _cache = json.loads(CACHE.read_text()) if CACHE.exists() else {} + except Exception: + _cache = {} + return _cache + + +def _save_cache() -> None: + try: + CACHE.parent.mkdir(parents=True, exist_ok=True) + CACHE.write_text(json.dumps(_cache, indent=2)) + except OSError: + pass + + +def reachable() -> bool: + try: + r = requests.get(f"{TRIBE_API}/health", timeout=10) + return r.ok and bool(r.json().get("ok")) + except Exception: + return False + + +def get_baseline(stimulus_id: str) -> Optional[Dict]: + """Healthy TRIBE baseline for a stimulus (cached). None if unavailable.""" + cache = _load_cache() + if stimulus_id in cache: + return cache[stimulus_id] + + text = _stimulus_text(stimulus_id) + if not text: + return None + try: + r = requests.post(f"{TRIBE_API}/api/analyze-text", data={"text": text[:4000]}, timeout=TRIBE_TIMEOUT) + r.raise_for_status() + resp = r.json() + except Exception: + return None + if not isinstance(resp, dict) or not resp.get("success"): + return None + + chart = resp.get("chart_data") or [] + if not chart: + return None + peak = max(chart, key=lambda f: f.get("overall", 0.0)) + baseline = { + "stimulus_id": stimulus_id, + "regions": _macro_to_regions(peak), + "macro_groups": {k: round(float(peak.get(k, 0.0) or 0.0), 3) for k in MACRO}, + "overall": round(float(peak.get("overall", 0.0) or 0.0), 3), + "total_timesteps": resp.get("total_timesteps"), + "agent_summary": resp.get("agent_summary", ""), + "source": "tribe-v2", + } + cache[stimulus_id] = baseline + _save_cache() + return baseline diff --git a/pitch.html b/pitch.html new file mode 100644 index 0000000..0bcc088 --- /dev/null +++ b/pitch.html @@ -0,0 +1,445 @@ + + + + + +Pegasus + + + + + + + + + + + +
+
+ + +
+
1 / 1
+ + +
+ +
+ + + + diff --git a/shared/contract.json b/shared/contract.json new file mode 100644 index 0000000..5178f16 --- /dev/null +++ b/shared/contract.json @@ -0,0 +1,85 @@ +{ + "version": "2.0.0", + "owner": "jason (backend)", + "description": "Pegasus API contract. Jason owns this file. Need a field changed? Tell Jason, he updates it, everyone pulls.", + "services": { + "frontend": "http://localhost:3000", + "backend": "http://localhost:8001", + "signals": "http://localhost:8002", + "ml": "http://localhost:8003", + "video": "http://localhost:8004" + }, + "levels": { + "green": "score < 30", + "yellow": "30 <= score < 65", + "red": "score >= 65" + }, + "stimulus": { + "id": "string", + "type": "image|audio|text", + "url": "string", + "category": "calm|neutral|activating", + "prompt": "string" + }, + "user_response": { + "user_id": "string", + "stimulus_id": "string", + "response_text": "string", + "response_time_ms": "number", + "response_latency_ms": "number", + "typing_wpm": "number", + "error_rate": "number (0-100)", + "source": "app|sms", + "timestamp": "ISO8601" + }, + "burnout_result": { + "score": "0-100", + "level": "green|yellow|red", + "tribe_deviation": "number", + "behavioral_deviation": "number", + "top_indicators": ["string"], + "intervention": "string (Claude-generated via ML; warm, actionable, non-diagnostic)", + "brain_regions_flagged": ["string"], + "confidence": "0-1", + "breakdown": { + "imessage": "number (text/SMS sentiment burnout contribution 0-100)", + "typing": "number (typing biometrics 0-100)", + "facial": "number (video facial stress 0-100, 0 if no video)", + "voice": "number (video voice stress 0-100, 0 if no video)", + "tribe": "number (TRIBE deviation contribution 0-100)" + }, + "support": "[{name, contact, type}] human-support resources; populated ONLY on red (PRD §6: red always routes to human support)", + "source": "app|sms|video|tribe|fallback", + "timestamp": "ISO8601" + }, + "endpoints": { + "backend (8001, owner: jason)": { + "GET /health": "-> service status", + "GET /stimulus/today/{user_id}": "-> stimulus (daily rotation)", + "GET /stimulus/all": "-> [stimulus, ...]", + "POST /response/submit": "user_response -> burnout_result (orchestrates signals -> ML -> store)", + "GET /score/{user_id}": "-> latest burnout_result", + "GET /history/{user_id}": "-> [burnout_result, ...] (most recent first, up to 30)", + "GET /metrics/{user_id}": "-> { score_trend, latest_breakdown, total_checkins }", + "GET /brain/{user_id}": "-> { regions, ... } TRIBE brain data for the latest stimulus", + "POST /video/submit": "multipart (video, user_id) -> { facial, voice, combined_score, burnout_result } — fuses facial/voice into a burnout score (PRD 4.5); raw video is never stored" + }, + "signals (8002, owner: dhruva)": { + "POST /analyze": "{ user_id, response_text, response_time_ms, typing_wpm, error_rate, [+optional keystroke biometrics: hesitation_count, burst_pattern, avg_key_hold_ms, flight_time_ms, correction_loops] } -> FLAT { sentiment_score, energy_level, combined_signal_score, linguistic_flags, typing_stress_score, future_tense_pct, self_referential_pct, hedging_count, flat_affect, word_count, ..., detail{sentiment,typing,temporal,linguistic} }", + "GET /signals/{user_id}": "-> latest analyzed signals", + "GET /signals/{user_id}/history": "-> { records, record_count, avg_score, score_trend, word_count_trend }", + "POST /alert/send": "{ user_id, score, level, intervention } -> { sent, message_sid } (SMS only on red; phone looked up from a prior /register-phone)", + "POST /register-phone": "{ user_id, phone } -> { registered }", + "POST /send-checkin": "{ user_id, phone } -> sends a stimulus over SMS", + "POST /sms/webhook": "Twilio inbound SMS webhook (Dhruva's bot)" + }, + "ml (8003, owner: rishith)": { + "POST /score": "{ user_id, stimulus_id, signals } -> burnout_result", + "GET /baseline/{stimulus_id}": "-> { regions, ... } TRIBE baseline brain activation" + }, + "video (8004, owner: rishith)": { + "POST /analyze/video": "multipart video (field name 'video') -> { facial: {facial_stress_score, ...}, voice: {voice_stress_score, ...}, combined_score: number }" + } + }, + "notes": "Backend keeps a graceful-fallback layer: if signals (8002) or ML (8003) are offline, /response/submit returns a degraded burnout_result (source='fallback') instead of erroring, so the app + SMS bot keep working during the demo." +} diff --git a/shared/stimuli.json b/shared/stimuli.json new file mode 100644 index 0000000..a63c8c2 --- /dev/null +++ b/shared/stimuli.json @@ -0,0 +1,18 @@ +{ + "version": "2.0.0", + "note": "Canonical app-facing stimulus manifest (contract v2 shape). Served by the backend /stimulus endpoints. SMS stimuli live separately in signals/stimuli/bank.py (owned by Dhruva).", + "stimuli": [ + { "id": "calm_01", "type": "image", "url": "https://picsum.photos/seed/pegasus-calm-1/800/600", "category": "calm", "prompt": "Take a slow look at this image. How does it make you feel right now?" }, + { "id": "calm_02", "type": "image", "url": "https://picsum.photos/seed/pegasus-calm-2/800/600", "category": "calm", "prompt": "Rest your eyes on this for a moment. What comes up for you?" }, + { "id": "audio_01", "type": "audio", "url": "https://www.soundhelix.com/examples/mp3/SoundHelix-Song-1.mp3", "category": "calm", "prompt": "Listen for a few seconds. Notice what shifts in you, then describe it." }, + { "id": "question_01", "type": "text", "url": "", "category": "neutral", "prompt": "What's one thing on your mind right now?" }, + { "id": "question_02", "type": "text", "url": "", "category": "neutral", "prompt": "How would you describe your energy today, in a sentence?" }, + { "id": "activating_01", "type": "text", "url": "", "category": "activating", "prompt": "Your deadline just moved up by a week. React honestly — what's your first thought?" }, + { "id": "img_calm_01", "type": "image", "url": "https://images.unsplash.com/photo-1470770841072-f978cf4d019e", "category": "calm", "prompt": "A still lake at dawn. What does your body notice as you look?" }, + { "id": "img_calm_02", "type": "image", "url": "https://images.unsplash.com/photo-1500964757637-c85e8a162699", "category": "calm", "prompt": "Soft morning light over the hills. Where does your attention settle?" }, + { "id": "img_calm_03", "type": "image", "url": "https://images.unsplash.com/photo-1447752875215-b2761acb3c5d", "category": "calm", "prompt": "Quiet light filtering through the trees. What's the first word that comes to mind?" }, + { "id": "img_activating_01", "type": "image", "url": "https://images.unsplash.com/photo-1494522855154-9297ac14b55f", "category": "activating", "prompt": "A crowded, rushing crossing. What jumps out at you first?" }, + { "id": "img_activating_02", "type": "image", "url": "https://images.unsplash.com/photo-1519501025264-65ba15a82390", "category": "activating", "prompt": "Bright city lights at night. Notice what your eyes lock onto." }, + { "id": "img_activating_03", "type": "image", "url": "https://images.unsplash.com/photo-1534447677768-be436bb09401", "category": "activating", "prompt": "A storm building over the horizon. What does this scene pull from you?" } + ] +} diff --git a/signals/.env.example b/signals/.env.example new file mode 100644 index 0000000..96fb13b --- /dev/null +++ b/signals/.env.example @@ -0,0 +1,5 @@ +HF_API_KEY=hf_... +TWILIO_ACCOUNT_SID=AC... +TWILIO_AUTH_TOKEN=... +TWILIO_PHONE_NUMBER=+1... +BACKEND_URL=http://localhost:8001 diff --git a/signals/.gitignore b/signals/.gitignore new file mode 100644 index 0000000..eb6d1f4 --- /dev/null +++ b/signals/.gitignore @@ -0,0 +1,6 @@ +.env +__pycache__/ +*.pyc +*.pyo +.venv/ +venv/ diff --git a/signals/alerts/__init__.py b/signals/alerts/__init__.py new file mode 100644 index 0000000..62d2acb --- /dev/null +++ b/signals/alerts/__init__.py @@ -0,0 +1,3 @@ +from .twilio_sms import send_sms, send_alert_sms, USER_PHONES + +__all__ = ["send_sms", "send_alert_sms", "USER_PHONES"] diff --git a/signals/alerts/email_alert.py b/signals/alerts/email_alert.py new file mode 100644 index 0000000..816e1f8 --- /dev/null +++ b/signals/alerts/email_alert.py @@ -0,0 +1,20 @@ +"""Email summary alert — stretch goal. Plug in SendGrid or SMTP as needed.""" +import os + + +def send_weekly_summary(email: str, user_id: str, avg_score: float, top_flags: list) -> dict: + with open( + os.path.join(os.path.dirname(__file__), "templates", "weekly_summary.txt"), + encoding="utf-8", + ) as f: + template = f.read() + + body = template.format( + user_id=user_id, + avg_score=round(avg_score, 1), + top_flags=", ".join(top_flags) if top_flags else "none", + ) + + # TODO: wire up SendGrid / smtplib once API key is available + print(f"[email_alert] Would send to {email}:\n{body}") + return {"sent": False, "reason": "email transport not configured"} diff --git a/signals/alerts/push_notification.py b/signals/alerts/push_notification.py new file mode 100644 index 0000000..156226d --- /dev/null +++ b/signals/alerts/push_notification.py @@ -0,0 +1,7 @@ +"""APNs push notification — stretch goal. Wire up once Apple certs are available.""" + + +def send_push(device_token: str, user_id: str, score: float, level: str, message: str) -> dict: + # TODO: implement with pyapns2 or httpx against APNs once certs are provisioned + print(f"[push_notification] Would push to {device_token}: {level} {score}/100 — {message}") + return {"sent": False, "reason": "APNs not configured"} diff --git a/signals/alerts/templates.py b/signals/alerts/templates.py new file mode 100644 index 0000000..fa76c81 --- /dev/null +++ b/signals/alerts/templates.py @@ -0,0 +1,24 @@ +def red_alert(score: int, intervention: str) -> str: + return ( + f"🔴 Pegasus Alert\n\n" + f"Your check engine light is on ({score}/100).\n\n" + f"{intervention}\n\n" + f"You're not alone." + ) + +def yellow_warning(score: int, intervention: str) -> str: + return ( + f"🟡 Pegasus Check-In\n\n" + f"You're at {score}/100 today. Trending up.\n\n" + f"{intervention}" + ) + +def green_status(score: int) -> str: + return f"🟢 Pegasus: {score}/100. Systems normal. Keep it up!" + +def daily_stimulus(prompt: str) -> str: + return ( + f"Hey! Quick Pegasus pulse check 🌱\n\n" + f"{prompt}\n\n" + f"(Just reply naturally — there's no wrong answer.)" + ) diff --git a/signals/alerts/templates/red_alert.txt b/signals/alerts/templates/red_alert.txt new file mode 100644 index 0000000..d489bdd --- /dev/null +++ b/signals/alerts/templates/red_alert.txt @@ -0,0 +1,9 @@ +🚨 Pegasus Burnout Alert + +Hi {user_id}, your burnout signal just hit RED (score: {score}/100). + +Recommended action: {intervention} + +Please take a break or reach out to your manager. You matter. + +— Pegasus Mental Health Monitor diff --git a/signals/alerts/templates/weekly_summary.txt b/signals/alerts/templates/weekly_summary.txt new file mode 100644 index 0000000..c528c14 --- /dev/null +++ b/signals/alerts/templates/weekly_summary.txt @@ -0,0 +1,9 @@ +📊 Pegasus Weekly Burnout Summary + +User: {user_id} +Average burnout score this week: {avg_score}/100 +Top signals detected: {top_flags} + +Keep an eye on your mental health. Small breaks make a big difference. + +— Pegasus Mental Health Monitor diff --git a/signals/alerts/templates/yellow_warning.txt b/signals/alerts/templates/yellow_warning.txt new file mode 100644 index 0000000..edb1a97 --- /dev/null +++ b/signals/alerts/templates/yellow_warning.txt @@ -0,0 +1,11 @@ +⚠️ Pegasus Check-In Reminder + +Hey {user_id}, your burnout signal is at YELLOW (score: {score}/100). + +Things are trending in a direction worth paying attention to. + +Suggested action: {intervention} + +Take a moment. You don't have to fix everything today. + +— Pegasus Mental Health Monitor diff --git a/signals/alerts/twilio_sms.py b/signals/alerts/twilio_sms.py new file mode 100644 index 0000000..f669074 --- /dev/null +++ b/signals/alerts/twilio_sms.py @@ -0,0 +1,161 @@ +import os +import time +import logging +import platform +import subprocess +from collections import deque +import requests +from urllib.parse import quote +from dotenv import load_dotenv + +load_dotenv() + +log = logging.getLogger("signals.sms") + +# Recent outbound text, so the inbound iMessage poller can tell the user's +# replies apart from Pegasus's own messages echoed in the same thread. +_SENT = deque(maxlen=60) + + +def recently_sent(text: str, window: float = 900) -> bool: + t = (text or "").strip() + now = time.time() + return any(t == s and now - ts < window for s, ts in _SENT) + +BLOOIO_BASE = "https://backend.blooio.com/v2/api" +# Prefer the Mac's iMessage over Bloo.io (free, local) when set — handy once the +# Bloo.io free trial runs out. Default: try Bloo.io first, iMessage as fallback. +PREFER_IMESSAGE = os.getenv("SMS_PREFER_IMESSAGE", "").lower() in ("1", "true", "yes") + +USER_PHONES: dict[str, str] = {} # user_id -> phone + + +def _headers() -> dict: + return { + "Authorization": f"Bearer {os.getenv('BLOOIO_API_KEY')}", + "Content-Type": "application/json", + } + + +def _download_image(url: str) -> str | None: + """Download a remote image to a temp file so iMessage can attach the real PNG + instead of pasting a link.""" + if not url: + return None + try: + import tempfile + + r = requests.get(url, timeout=15) + r.raise_for_status() + ext = ".png" if ".png" in url.lower() else ".jpg" + path = tempfile.mktemp(suffix=ext) + with open(path, "wb") as f: + f.write(r.content) + return path + except Exception as e: + log.warning("image download failed: %s", e) + return None + + +# Send FROM this iMessage account (an Apple ID / email) so Pegasus shows up as a +# SEPARATE sender instead of "you". Empty = use the first iMessage account (which, +# if it's the same Apple ID as your phone, will show as yourself). +IMESSAGE_FROM = os.getenv("IMESSAGE_FROM", "").strip() + + +def _send_imessage(phone: str, body: str, attachment: str = None) -> str | None: + """Send via the Mac's Messages app (free, local). When `attachment` is given + it sends the actual image FILE (not a link). Sends from IMESSAGE_FROM if set + (so Pegasus is a distinct sender). Returns 'imessage_local' on success.""" + if platform.system() != "Darwin" or not phone: + return None + target = phone.replace('"', "") + sends = [] + if attachment and os.path.exists(attachment): + sends.append(f'send (POSIX file "{attachment}") to bud') + if body: + text = body.replace("\\", "\\\\").replace('"', '\\"').replace("\n", " ") + sends.append(f'send "{text}" to bud') + if not sends: + return None + + if IMESSAGE_FROM: + acct = IMESSAGE_FROM.replace('"', "") + svc_lines = ( + " set svc to missing value\n" + " repeat with s in (every service whose service type is iMessage)\n" + f' if (id of s) contains "{acct}" or (name of s) contains "{acct}" then\n' + " set svc to s\n exit repeat\n end if\n end repeat\n" + " if svc is missing value then set svc to 1st service whose service type is iMessage\n" + ) + else: + svc_lines = " set svc to 1st service whose service type is iMessage\n" + + script = ( + 'tell application "Messages"\n' + + svc_lines + + f' set bud to participant "{target}" of svc\n' + + " " + "\n ".join(sends) + "\n" + + "end tell" + ) + try: + r = subprocess.run(["osascript", "-e", script], capture_output=True, text=True, timeout=30) + if r.returncode == 0: + return "imessage_local" + log.warning("iMessage send failed: %s", (r.stderr or "").strip()[:160]) + except Exception as e: + log.warning("iMessage error: %s", e) + return None + + +def _send_bloo(phone: str, body: str, media_url: str = None) -> str | None: + """Send via Bloo.io. Returns the message id, or None on failure / no key.""" + if not os.getenv("BLOOIO_API_KEY"): + return None + chat_id = quote(phone, safe="") + payload = {"text": body} + if media_url: + payload["attachments"] = [media_url] + try: + resp = requests.post(f"{BLOOIO_BASE}/chats/{chat_id}/messages", + headers=_headers(), json=payload, timeout=15) + resp.raise_for_status() + return resp.json().get("message_id", "sent") + except Exception as e: + log.warning("Bloo.io send failed: %s", e) + return None + + +def send_sms(phone: str, body: str, media_url: str = None) -> str: + """Deliver a message. Tries Bloo.io (iMessage/SMS gateway) first, then falls + back to the Mac's own iMessage so messages still reach the phone for free when + Bloo.io is unavailable (e.g. free-trial credits exhausted). Never raises.""" + _SENT.append((body.strip(), time.time())) # so the inbound poller skips our echo + if not PREFER_IMESSAGE: + sid = _send_bloo(phone, body, media_url) + if sid: + return sid + + # iMessage fallback — download + attach the ACTUAL image, not a link. + attach = _download_image(media_url) if media_url else None + local = _send_imessage(phone, body, attach) + if local: + return local + + if PREFER_IMESSAGE: # iMessage was first choice and failed — try Bloo as backup + sid = _send_bloo(phone, body, media_url) + if sid: + return sid + + if not os.getenv("BLOOIO_API_KEY"): + return "noop_no_api_key" + return "payment_required: Bloo.io credits exhausted — add credits, or sign into iMessage on this Mac." + + +def send_alert_sms(user_id: str, score: int, intervention: str) -> str: + from alerts.templates import red_alert + phone = USER_PHONES.get(user_id) + if not phone: + return "no_phone_on_file" + body = red_alert(score, intervention) + return send_sms(phone, body) diff --git a/signals/analyzers/__init__.py b/signals/analyzers/__init__.py new file mode 100644 index 0000000..41c3a62 --- /dev/null +++ b/signals/analyzers/__init__.py @@ -0,0 +1,6 @@ +from .sentiment import analyze_sentiment +from .typing_patterns import analyze_typing +from .temporal import analyze_temporal +from .linguistic import analyze_linguistic + +__all__ = ["analyze_sentiment", "analyze_typing", "analyze_temporal", "analyze_linguistic"] diff --git a/signals/analyzers/linguistic.py b/signals/analyzers/linguistic.py new file mode 100644 index 0000000..3196ef5 --- /dev/null +++ b/signals/analyzers/linguistic.py @@ -0,0 +1,69 @@ +import re + +FUTURE_WORDS = ["will", "going to", "gonna", "tomorrow", "next", "plan", "soon", "later", "future", "hope"] +SELF_REF_WORDS = {"i", "me", "my", "myself", "mine", "i'm", "i've", "i'll"} +HEDGING_PHRASES = ["idk", "i guess", "whatever", "maybe", "kinda", "sort of", "i dunno", "probably", "meh", "fine", "nm"] +FLAT_INDICATORS = {"fine", "ok", "okay", "nothing", "same", "meh", "nm"} +EXHAUSTION_RE = re.compile(r"\b(tired|exhausted|drained|burned out|burnout|stressed|overwhelmed)\b", re.IGNORECASE) +NEGATION_RE = re.compile(r"\b(can't|cannot|won't|don't|didn't|doesn't|never|not)\b", re.IGNORECASE) + + +def analyze_linguistic(text: str) -> dict: + if not text: + return { + "future_tense_pct": 50, "self_referential_pct": 0, + "hedging_count": 0, "flat_affect": False, + "linguistic_flags": [], "burnout_contribution": 0, + } + + words = re.findall(r"\b\w+\b", text.lower()) + total = max(len(words), 1) + text_low = text.lower() + + future_count = sum(1 for w in FUTURE_WORDS if w in text_low) + self_ref_count = sum(1 for w in words if w in SELF_REF_WORDS) + hedging_count = sum(1 for h in HEDGING_PHRASES if h in text_low) + exhaustion_n = len(EXHAUSTION_RE.findall(text)) + negation_n = len(NEGATION_RE.findall(text)) + exclamations = text.count("!") + emoji_count = len(re.findall(r"[\U0001F300-\U0001FFFF\U00002702-\U000027B0]+", text)) + + flat_affect = ( + len(words) < 5 + or any(text.strip().lower() == f for f in FLAT_INDICATORS) + or (exclamations == 0 and emoji_count == 0 and len(words) < 10) + ) + + flags = [] + if future_count == 0 and len(words) > 5: + flags.append("no future-oriented language (possible loss of motivation)") + if self_ref_count / total > 0.25: + flags.append("high self-referential language (possible rumination)") + if hedging_count >= 2: + flags.append("frequent hedging/disengagement language") + if flat_affect: + flags.append("flat affect — minimal emotional expression") + if exhaustion_n >= 1: + flags.append("exhaustion language") + if negation_n >= 2: + flags.append("high negation") + + contribution = min(15, + exhaustion_n * 4 + negation_n * 1.5 + hedging_count * 1.5 + + (1 if self_ref_count / total > 0.25 else 0) + + (1 if flat_affect else 0) + ) + + return { + # ── spec-required field names ── + "future_tense_pct": round(future_count / total * 100, 1), + "self_referential_pct": round(self_ref_count / total * 100, 1), + "hedging_count": hedging_count, + "flat_affect": flat_affect, + "linguistic_flags": flags, + # ── extras used by combined_signal_score ── + "exclamation_count": exclamations, + "emoji_count": emoji_count, + "word_count": len(words), + "burnout_contribution": round(contribution, 2), + } diff --git a/signals/analyzers/sentiment.py b/signals/analyzers/sentiment.py new file mode 100644 index 0000000..b4c1729 --- /dev/null +++ b/signals/analyzers/sentiment.py @@ -0,0 +1,61 @@ +import os +import requests +from dotenv import load_dotenv + +load_dotenv() + +_HF_MODEL = "cardiffnlp/twitter-roberta-base-sentiment-latest" +_HF_URL = f"https://api-inference.huggingface.co/models/{_HF_MODEL}" + + +def analyze_sentiment(text: str) -> dict: + token = os.getenv("HF_API_KEY") + headers = {"Authorization": f"Bearer {token}"} + + try: + resp = requests.post( + _HF_URL, + headers=headers, + json={"inputs": text[:512]}, # model max input + timeout=15, + ) + resp.raise_for_status() + raw = resp.json() + + # HF returns [[{label, score}, ...]] for single-input batches + scores = raw[0] if isinstance(raw[0], list) else raw + score_map = {item["label"].lower(): item["score"] for item in scores} + + except Exception: + # Fallback if model is cold-loading or network fails + score_map = {"positive": 0.33, "neutral": 0.34, "negative": 0.33} + + pos = score_map.get("positive", 0.0) + neu = score_map.get("neutral", 0.0) + neg = score_map.get("negative", 0.0) + + # sentiment_score: 0.0 = very negative/burned out, 1.0 = very positive/energized + sentiment_score = round(pos + neu * 0.5, 3) + + if sentiment_score >= 0.65: + energy_level = "high" + elif sentiment_score >= 0.4: + energy_level = "medium" + else: + energy_level = "low" + + flags = [] + if neg > 0.6: + flags.append("disengaged") + if neg > 0.8: + flags.append("overwhelmed") + if neu > 0.7 and pos < 0.15: + flags.append("flat_affect") + if pos < 0.1: + flags.append("hopeless") + + return { + "sentiment_score": sentiment_score, + "energy_level": energy_level, + "flags": flags, + } diff --git a/signals/analyzers/temporal.py b/signals/analyzers/temporal.py new file mode 100644 index 0000000..7e4c6c1 --- /dev/null +++ b/signals/analyzers/temporal.py @@ -0,0 +1,25 @@ +def analyze_temporal(response_time_ms: float) -> dict: + """ + Returns a burnout contribution 0-20 from response latency. + Healthy engagement: < 5s. Checked-out: > 30s. + """ + seconds = response_time_ms / 1000 + + if seconds < 5: + label = "immediate" + contribution = 0 + elif seconds < 15: + label = "normal" + contribution = seconds / 15 * 8 + elif seconds < 30: + label = "slow" + contribution = 8 + (seconds - 15) / 15 * 8 + else: + label = "disengaged" + contribution = 20 + + return { + "response_time_ms": response_time_ms, + "response_label": label, + "burnout_contribution": round(min(contribution, 20), 2), + } diff --git a/signals/analyzers/typing_patterns.py b/signals/analyzers/typing_patterns.py new file mode 100644 index 0000000..45b26d0 --- /dev/null +++ b/signals/analyzers/typing_patterns.py @@ -0,0 +1,69 @@ +def analyze_typing(metrics: dict) -> dict: + """ + Accepts a metrics dict (from TypingBiometrics in collector.js). + Returns stress_score 0-100 and flags for Jason's combined scorer. + """ + wpm = metrics.get("typing_wpm", 60) + error_rate = metrics.get("error_rate", 0) + response_time = metrics.get("response_time_ms", 3000) + hesitations = metrics.get("hesitation_count", 0) + burst = metrics.get("burst_pattern", "steady") + hold_ms = metrics.get("avg_key_hold_ms", 100) + flight_ms = metrics.get("flight_time_ms", 150) + corrections = metrics.get("correction_loops", 0) + + stress = 0 + flags = [] + + if wpm < 30: + stress += 25; flags.append("very slow typing speed") + elif wpm < 45: + stress += 12 + + if error_rate > 15: + stress += 25; flags.append("high error/correction rate") + elif error_rate > 8: + stress += 12 + + if response_time > 10_000: + stress += 20; flags.append("very delayed response") + elif response_time > 6_000: + stress += 10 + + if hesitations > 3: + stress += 15; flags.append("frequent mid-word hesitations") + elif hesitations > 1: + stress += 7 + + if burst == "erratic": + stress += 10; flags.append("erratic typing rhythm") + elif burst == "burst-pause": + stress += 5 + + if hold_ms > 180: + stress += 5 # pressing hard — physical stress proxy + if flight_ms > 400: + stress += 5 # cognitive slowing + if corrections > 2: + stress += 5; flags.append("repeated correction loops") + + if wpm < 20 or burst == "erratic": + pattern = "severely_impaired" + elif wpm < 40 or error_rate > 15 or hesitations > 3: + pattern = "stressed" + elif wpm < 60 or error_rate > 8: + pattern = "mildly_stressed" + else: + pattern = "normal" + + return { + "stress_score": min(stress, 100), + "pattern": pattern, + "flags": flags, + # keep raw fields so /analyze can surface them flat + "typing_wpm": wpm, + "error_rate": error_rate, + "hesitation_count": hesitations, + "burst_pattern": burst, + "burnout_contribution": min(stress * 0.25, 25), + } diff --git a/signals/collector.js b/signals/collector.js new file mode 100644 index 0000000..0fcb5bf --- /dev/null +++ b/signals/collector.js @@ -0,0 +1,122 @@ +/** + * TypingBiometrics — full keystroke tracker for Wesley to import. + * Captures WPM, error rate, hesitations, hold times, flight times, + * burst pattern, and correction loops. + * + * Usage: + * import { TypingBiometrics } from '../signals/collector.js'; + * const tracker = new TypingBiometrics(); + * inputEl.addEventListener('keydown', e => tracker.onKeyDown(e)); + * inputEl.addEventListener('keyup', e => tracker.onKeyUp(e)); + * const metrics = tracker.getMetrics(); + * tracker.reset(); + */ +export class TypingBiometrics { + constructor() { + this.keydowns = []; + this.keyups = []; + this.backspaces = 0; + this.hesitations = 0; + this.lastKeyTime = null; + this.corrections = 0; + this.currentWordDeletes = 0; + this.startTime = null; + } + + onKeyDown(event) { + const now = Date.now(); + if (!this.startTime) this.startTime = now; + + if (this.lastKeyTime && (now - this.lastKeyTime) > 3000) { + this.hesitations++; + } + + if (event.key === 'Backspace') { + this.backspaces++; + this.currentWordDeletes++; + if (this.currentWordDeletes > 5) { + this.corrections++; + this.currentWordDeletes = 0; + } + } else { + this.currentWordDeletes = 0; + } + + this.keydowns.push({ key: event.key, time: now }); + this.lastKeyTime = now; + } + + onKeyUp(event) { + this.keyups.push({ key: event.key, time: Date.now() }); + } + + getMetrics() { + if (!this.startTime || this.keydowns.length === 0) { + return { + typing_wpm: 0, error_rate: 0, hesitation_count: 0, + burst_pattern: 'steady', avg_key_hold_ms: 0, + flight_time_ms: 0, correction_loops: 0, response_time_ms: 0, + }; + } + + const elapsed = (Date.now() - this.startTime) / 60000; + const words = this.keydowns.filter(k => k.key === ' ').length + 1; + const printable = this.keydowns.filter(k => k.key.length === 1); + + // Key hold durations (keydown → matching keyup) + const holdTimes = []; + for (const d of this.keydowns) { + const u = this.keyups.find(u => u.key === d.key && u.time > d.time); + if (u) holdTimes.push(u.time - d.time); + } + + // Flight times (keyup → next keydown) + const flightTimes = []; + for (let i = 1; i < this.keydowns.length; i++) { + const prevUp = this.keyups.find( + u => u.time <= this.keydowns[i].time && u.time > this.keydowns[i - 1].time + ); + if (prevUp) flightTimes.push(this.keydowns[i].time - prevUp.time); + } + + // Burst pattern from inter-key gap variance + const gaps = []; + for (let i = 1; i < this.keydowns.length; i++) { + gaps.push(this.keydowns[i].time - this.keydowns[i - 1].time); + } + const gapStdDev = _stdDev(gaps); + const burstPattern = gapStdDev > 500 ? 'erratic' : gapStdDev > 200 ? 'burst-pause' : 'steady'; + + return { + typing_wpm: Math.round(words / Math.max(elapsed, 0.01)) || 0, + error_rate: Math.round((this.backspaces / Math.max(printable.length, 1)) * 100), + hesitation_count: this.hesitations, + burst_pattern: burstPattern, + avg_key_hold_ms: Math.round(_avg(holdTimes)) || 0, + flight_time_ms: Math.round(_avg(flightTimes)) || 0, + correction_loops: this.corrections, + response_time_ms: Date.now() - this.startTime, + }; + } + + reset() { + this.keydowns = []; + this.keyups = []; + this.backspaces = 0; + this.hesitations = 0; + this.lastKeyTime = null; + this.corrections = 0; + this.currentWordDeletes = 0; + this.startTime = null; + } +} + +function _avg(arr) { + return arr.length ? arr.reduce((a, b) => a + b, 0) / arr.length : 0; +} + +function _stdDev(arr) { + if (!arr.length) return 0; + const avg = _avg(arr); + return Math.sqrt(arr.map(x => (x - avg) ** 2).reduce((a, b) => a + b, 0) / arr.length); +} diff --git a/signals/main.py b/signals/main.py new file mode 100644 index 0000000..9c2f3f9 --- /dev/null +++ b/signals/main.py @@ -0,0 +1,228 @@ +from fastapi import FastAPI, Request, HTTPException +from fastapi.middleware.cors import CORSMiddleware +from fastapi.responses import Response +from pydantic import BaseModel +from typing import Optional +from dotenv import load_dotenv +from datetime import datetime, timezone + +from analyzers.sentiment import analyze_sentiment +from analyzers.linguistic import analyze_linguistic +from analyzers.typing_patterns import analyze_typing +from analyzers.temporal import analyze_temporal +from alerts.twilio_sms import send_alert_sms, send_sms, USER_PHONES +from sms_bot.webhook import handle_incoming_sms + +load_dotenv() + +app = FastAPI(title="Pegasus Signals") +app.add_middleware( + CORSMiddleware, + allow_origins=["*"], + allow_methods=["*"], + allow_headers=["*"], +) + +# in-memory signal store +_latest: dict[str, dict] = {} +_history: dict[str, list] = {} +_LIMIT = 30 + + +# ── Request models ───────────────────────────────────────────────────────────── + +class AnalyzeRequest(BaseModel): + user_id: str + response_text: str + response_time_ms: int = 0 + typing_wpm: int = 0 + error_rate: int = 0 + # rich biometrics from TypingBiometrics (collector.js) + hesitation_count: Optional[int] = 0 + burst_pattern: Optional[str] = "steady" + avg_key_hold_ms: Optional[float] = 100 + flight_time_ms: Optional[float] = 150 + correction_loops: Optional[int] = 0 + +class AlertRequest(BaseModel): + user_id: str + score: int + level: str + intervention: str + +class RegisterPhoneRequest(BaseModel): + user_id: str + phone: str + +class CheckinRequest(BaseModel): + user_id: str + phone: str + + +# ── Endpoints ────────────────────────────────────────────────────────────────── + +@app.get("/health") +def health(): + return {"status": "signals service running", "port": 8002} + + +@app.post("/analyze") +async def analyze(req: AnalyzeRequest): + sentiment = analyze_sentiment(req.response_text) + linguistic = analyze_linguistic(req.response_text) + typing = analyze_typing({ + "typing_wpm": req.typing_wpm, + "error_rate": req.error_rate, + "response_time_ms": req.response_time_ms, + "hesitation_count": req.hesitation_count, + "burst_pattern": req.burst_pattern, + "avg_key_hold_ms": req.avg_key_hold_ms, + "flight_time_ms": req.flight_time_ms, + "correction_loops": req.correction_loops, + }) + temporal = analyze_temporal(req.response_time_ms) + + combined = min(100, round( + (1 - sentiment["sentiment_score"]) * 40 + + typing["burnout_contribution"] + + temporal["burnout_contribution"] + + linguistic["burnout_contribution"], + 1, + )) + + result = { + # ── spec-required fields ── + "sentiment_score": sentiment["sentiment_score"], + "energy_level": sentiment["energy_level"], + "future_tense_pct": linguistic["future_tense_pct"], + "self_referential_pct": linguistic["self_referential_pct"], + "hedging_count": linguistic["hedging_count"], + "flat_affect": linguistic["flat_affect"], + "typing_stress_score": typing["stress_score"], + "linguistic_flags": linguistic["linguistic_flags"], + # ── extras for Jason's combined scorer ── + "user_id": req.user_id, + "response_latency_ms": req.response_time_ms, + "word_count": linguistic["word_count"], + "exclamation_count": linguistic["exclamation_count"], + "emoji_count": linguistic["emoji_count"], + "typing_wpm": typing["typing_wpm"], + "error_rate": typing["error_rate"], + "hesitation_count": typing["hesitation_count"], + "burst_pattern": typing["burst_pattern"], + "combined_signal_score": combined, + "timestamp": datetime.now(timezone.utc).isoformat(), + "detail": { + "sentiment": sentiment, + "typing": typing, + "temporal": temporal, + "linguistic": linguistic, + }, + } + + _latest[req.user_id] = result + hist = _history.setdefault(req.user_id, []) + hist.append(result) + if len(hist) > _LIMIT: + hist.pop(0) + + return result + + +@app.get("/signals/{user_id}") +def get_signals(user_id: str): + if user_id not in _latest: + raise HTTPException(404, f"No signals for user {user_id}") + return _latest[user_id] + + +@app.get("/signals/{user_id}/history") +def get_history(user_id: str): + hist = _history.get(user_id, []) + if not hist: + raise HTTPException(404, f"No history for user {user_id}") + scores = [r["combined_signal_score"] for r in hist] + words = [r["word_count"] for r in hist] + return { + "user_id": user_id, + "record_count": len(hist), + "avg_score": round(sum(scores) / len(scores), 1), + "score_trend": "worsening" if len(scores) > 2 and scores[-1] > scores[0] + 10 else "stable", + "word_count_trend": "declining" if len(words) > 2 and words[-1] < words[0] * 0.7 else "stable", + "records": hist, + } + + +@app.post("/alert/send") +async def alert_send(req: AlertRequest): + if req.level == "red": + sid = send_alert_sms(req.user_id, req.score, req.intervention) + return {"sent": True, "message_sid": sid} + return {"sent": False, "reason": f"level is '{req.level}', SMS reserved for red"} + + +@app.post("/register-phone") +def register_phone(req: RegisterPhoneRequest): + """Register a user's phone so alert SMS knows where to send.""" + USER_PHONES[req.user_id] = req.phone + return {"registered": True, "user_id": req.user_id, "phone": req.phone} + + +@app.post("/send-checkin") +async def send_checkin(req: CheckinRequest): + """Manually trigger today's pulse-check SMS (demo / cron endpoint).""" + from sms_bot.stimulus_sender import trigger_checkin + try: + return await trigger_checkin(req.user_id, req.phone) + except Exception as e: + raise HTTPException(500, str(e)) + + +class ReminderRequest(BaseModel): + user_id: str + text: str + + +@app.post("/schedule-reminder") +def schedule_reminder_route(req: ReminderRequest): + """Parse a reminder from free text ('text me in 15 min to take a break') and + schedule an SMS to the user's registered phone. Used by the voice call.""" + from sms_bot.reminders import parse_reminder, schedule_reminder, human_delay + rem = parse_reminder(req.text) + if not rem: + return {"scheduled": False, "reason": "no_reminder"} + phone = USER_PHONES.get(req.user_id) + if not phone: + return {"scheduled": False, "reason": "no_phone"} + delay, task = rem + schedule_reminder(phone, delay, task) + return {"scheduled": True, "delay": delay, "task": task, "human": human_delay(delay)} + + +@app.post("/sms/webhook") +async def sms_webhook(request: Request): + """Bloo.io inbound webhook. Payload is a flat WebhookEventPayload with + `event`, `sender` (phone), and `text`. We only act on genuine inbound + messages — ignore our own outbound echoes + delivery/status events so we + don't score messages Pegasus itself sent.""" + payload = await request.json() + event = (payload.get("event") or "").lower() + if event and event not in ("message.received", "message.inbound"): + return {"status": "ignored", "event": event} + from_number = payload.get("sender") or payload.get("from") or payload.get("phone", "") + body = payload.get("text") or payload.get("body") or payload.get("message", "") + if not from_number or not body: + return {"status": "ignored", "reason": "missing sender/text"} + return await handle_incoming_sms(from_number, body) + + +@app.on_event("startup") +async def _start_imessage_inbound(): + """Read the user's iMessage replies locally (Bloo.io webhook replacement).""" + from sms_bot.imessage_poller import start + start() + + +if __name__ == "__main__": + import uvicorn + uvicorn.run("main:app", host="0.0.0.0", port=8002, reload=True) diff --git a/signals/requirements.txt b/signals/requirements.txt new file mode 100644 index 0000000..87ac645 --- /dev/null +++ b/signals/requirements.txt @@ -0,0 +1,6 @@ +fastapi +uvicorn[standard] +python-multipart +httpx +requests +python-dotenv diff --git a/signals/sms_bot/__init__.py b/signals/sms_bot/__init__.py new file mode 100644 index 0000000..b242be6 --- /dev/null +++ b/signals/sms_bot/__init__.py @@ -0,0 +1,4 @@ +from .bot import SENT_STIMULI, send_daily_pulse_check +from .webhook import handle_incoming_sms + +__all__ = ["SENT_STIMULI", "send_daily_pulse_check", "handle_incoming_sms"] diff --git a/signals/sms_bot/bot.py b/signals/sms_bot/bot.py new file mode 100644 index 0000000..bb54876 --- /dev/null +++ b/signals/sms_bot/bot.py @@ -0,0 +1,95 @@ +import os +import json +import time +import random +import asyncio +from pathlib import Path + +import httpx + +from alerts.twilio_sms import send_sms + +ML_URL = os.getenv("ML_URL", "http://localhost:8003").rstrip("/") + +# Canonical stimulus catalog (shared/stimuli.json). We select from it directly so +# the stimulus_id we record matches the catalog the ML TRIBE baseline resolves +# against (ml/tribe_client.py reads the same file by id), and so we have the media +# `url` on hand for the MMS send. +STIMULI = Path(__file__).resolve().parents[2] / "shared" / "stimuli.json" + +# Evocative media: image/video stimuli in the "activating" or "calm" categories +# drive divergent stress responses — the whole point of the pulse check. +MEDIA_TYPES = {"image", "video"} +EVOCATIVE_CATEGORIES = {"activating", "calm"} + +# Short prompt sent as the MMS text body alongside the media stimulus. +MEDIA_PROMPT = "Quick check-in 📷 — look at this for a moment, then reply with the first feeling that comes up." + +SENT_STIMULI: dict[str, dict] = {} # phone -> {user_id, stimulus_id, sent_at, type} + +# Alternate calm/activating across sends so the same user sees divergent stimuli. +_last_category: str | None = None + + +def _load_stimuli() -> list[dict]: + data = json.loads(STIMULI.read_text()) + return data.get("stimuli", []) if isinstance(data, dict) else data + + +def select_stimulus() -> dict: + """Pick an emotionally-evocative media stimulus. + + Prefers image/video in the activating|calm categories, alternating category + between sends. Falls back to any text stimulus if no media exists. + """ + global _last_category + stimuli = _load_stimuli() + + media = [s for s in stimuli + if s.get("type") in MEDIA_TYPES and s.get("category") in EVOCATIVE_CATEGORIES] + if not media: + # No media available — fall back to any text stimulus so the check-in still goes out. + text = [s for s in stimuli if s.get("type") == "text"] or stimuli + return random.choice(text) + + # Alternate calm <-> activating when both are present. + want = "activating" if _last_category == "calm" else "calm" + pool = [s for s in media if s.get("category") == want] or media + chosen = random.choice(pool) + _last_category = chosen.get("category") + return chosen + + +async def send_daily_pulse_check(user_id: str, phone: str) -> str: + stimulus = select_stimulus() + media_url = stimulus.get("url") or None + + # Media stimulus → short evocative prompt as the body; plain text stimulus → + # use the stimulus' own prompt (no media to look at). + body = MEDIA_PROMPT if media_url else stimulus.get("prompt", MEDIA_PROMPT) + sid = send_sms(phone, body, media_url=media_url) + + SENT_STIMULI[phone] = { + "user_id": user_id, + "stimulus_id": stimulus["id"], + "sent_at": time.time(), + "type": stimulus.get("type", "text"), + } + + # Warm the Meta TRIBE v2 baseline for this stimulus in the background (it's a + # cold GPU call on first use). By the time they reply, scoring is fast — so + # the webhook responds quickly and Bloo.io never retries (no duplicate texts). + try: + asyncio.create_task(_warm_baseline(stimulus["id"])) + except RuntimeError: + pass # no running loop (e.g. called synchronously) — skip warmup + + return sid + + +async def _warm_baseline(stimulus_id: str) -> None: + try: + async with httpx.AsyncClient(timeout=150) as client: + await client.get(f"{ML_URL}/baseline/{stimulus_id}") + except Exception: + pass diff --git a/signals/sms_bot/imessage_poller.py b/signals/sms_bot/imessage_poller.py new file mode 100644 index 0000000..7ab8855 --- /dev/null +++ b/signals/sms_bot/imessage_poller.py @@ -0,0 +1,121 @@ +"""Inbound iMessage reader — the local stand-in for Bloo.io's webhook. + +Bloo.io delivered the user's replies to us via webhook. Without it (free trial +out of credits), we instead poll the Mac's Messages database +(~/Library/Messages/chat.db) for the user's incoming replies and route them +through the SAME scoring + companion pipeline (handle_incoming_sms); responses +go back out over the Mac's iMessage. + +Needs Full Disk Access for this process (System Settings → Privacy & Security → +Full Disk Access → enable your terminal). Degrades to a logged no-op without it. +We distinguish the user's replies from Pegasus's own outbound by excluding text +we just sent (see alerts.twilio_sms.recently_sent), so it works whether the Mac +is on the same Apple ID or a separate "Pegasus" account. +""" +from __future__ import annotations + +import asyncio +import logging +import os +import sqlite3 +from pathlib import Path + +from sms_bot.webhook import handle_incoming_sms + +log = logging.getLogger("signals.imessage") + +DB = str(Path.home() / "Library" / "Messages" / "chat.db") +POLL_SECS = float(os.getenv("IMESSAGE_POLL_SECS", "3")) + +_last_rowid = 0 +_started = False + + +def _digits(s: str) -> str: + return "".join(c for c in (s or "") if c.isdigit())[-10:] + + +def _connect(): + return sqlite3.connect(f"file:{DB}?mode=ro&immutable=1", uri=True, timeout=3) + + +def available() -> bool: + try: + con = _connect() + con.execute("SELECT 1 FROM message LIMIT 1") + con.close() + return True + except Exception: + return False + + +def _max_rowid() -> int: + try: + con = _connect() + row = con.execute("SELECT COALESCE(MAX(ROWID), 0) FROM message").fetchone() + con.close() + return int(row[0]) if row else 0 + except Exception: + return 0 + + +def _fetch_new(registered: set) -> list: + """New text messages (since the last poll) in chats with a registered phone.""" + global _last_rowid + out = [] + try: + con = _connect() + cur = con.execute( + "SELECT m.ROWID, m.text, h.id " + "FROM message m LEFT JOIN handle h ON m.handle_id = h.ROWID " + "WHERE m.ROWID > ? ORDER BY m.ROWID ASC", + (_last_rowid,), + ) + for rowid, text, handle in cur.fetchall(): + _last_rowid = max(_last_rowid, rowid) + if not text or not text.strip(): + continue + if _digits(handle) not in registered: + continue + out.append((handle or "", text.strip())) + con.close() + except Exception as e: + log.warning("chat.db read failed: %s", e) + return out + + +async def _loop(): + global _last_rowid + _last_rowid = _max_rowid() # only act on messages that arrive from now on + log.info("iMessage inbound poller running (from ROWID %s).", _last_rowid) + from alerts.twilio_sms import USER_PHONES, recently_sent + + while True: + await asyncio.sleep(POLL_SECS) + try: + registered = {_digits(p) for p in USER_PHONES.values() if p} + if not registered: + continue + for handle, text in _fetch_new(registered): + if recently_sent(text): + continue # our own outbound, echoed in the thread + log.info("inbound iMessage from %s: %r", handle, text[:60]) + await handle_incoming_sms(handle, text) + except Exception as e: + log.warning("poller loop error: %s", e) + + +def start() -> None: + """Start the background poller if chat.db is reachable.""" + global _started + if _started: + return + if not available(): + log.info("iMessage inbound poller DISABLED — no chat.db access " + "(grant Full Disk Access to read incoming replies).") + return + _started = True + try: + asyncio.create_task(_loop()) + except RuntimeError: + _started = False # no running loop yet diff --git a/signals/sms_bot/reminders.py b/signals/sms_bot/reminders.py new file mode 100644 index 0000000..e595122 --- /dev/null +++ b/signals/sms_bot/reminders.py @@ -0,0 +1,75 @@ +"""Conversational reminders. When the user tells the agent "remind me to take a +break in 15 minutes" (by text or in the voice call), we parse the delay + task +and schedule an SMS that actually fires after that delay. + +Lives in signals because this is where SMS sending happens. Never raises. +""" +from __future__ import annotations + +import asyncio +import re +from typing import Optional, Tuple + +from alerts.twilio_sms import send_sms + +_UNIT_SECONDS = { + "second": 1, "seconds": 1, "sec": 1, "secs": 1, + "minute": 60, "minutes": 60, "min": 60, "mins": 60, + "hour": 3600, "hours": 3600, "hr": 3600, "hrs": 3600, +} +_NUM_WORDS = { + "a": 1, "an": 1, "one": 1, "two": 2, "three": 3, "four": 4, "five": 5, + "ten": 10, "fifteen": 15, "twenty": 20, "thirty": 30, "forty-five": 45, "sixty": 60, +} + +_INTENT_RE = re.compile(r"\b(?:remind|text|message|ping|nudge)\s+me\b", re.I) +_TIME_RE = re.compile( + r"\bin\s+(\d+|a|an|one|two|three|four|five|ten|fifteen|twenty|thirty|forty-five|sixty)\s*" + r"(seconds?|secs?|sec|minutes?|mins?|min|hours?|hrs?|hr)\b", + re.I, +) +_TASK_RE = re.compile(r"\bto\s+(.+?)(?:\s+in\s+\S+\s+(?:sec|min|hour|hr)|[.!?]|$)", re.I) + + +def parse_reminder(text: str) -> Optional[Tuple[int, str]]: + """(delay_seconds, task) if `text` asks for a reminder, else None.""" + if not text or not _INTENT_RE.search(text): + return None + tm = _TIME_RE.search(text) + if not tm: + return None + num_raw, unit = tm.group(1).lower(), tm.group(2).lower() + num = int(num_raw) if num_raw.isdigit() else _NUM_WORDS.get(num_raw, 1) + secs = _UNIT_SECONDS.get(unit) or _UNIT_SECONDS.get(unit.rstrip("s")) or 60 + delay = max(10, num * secs) + + tk = _TASK_RE.search(text) + task = (tk.group(1).strip() if tk else "take a break").rstrip(".!?, ") + if not task or len(task) > 80: + task = "take a break" + return delay, task + + +def human_delay(delay: int) -> str: + if delay >= 3600: + h = delay / 3600 + h = int(h) if h == int(h) else round(h, 1) + return f"{h} hour" + ("s" if h != 1 else "") + if delay >= 60: + m = round(delay / 60) + return f"{m} minute" + ("s" if m != 1 else "") + return f"{delay} seconds" + + +async def _fire(phone: str, delay: int, task: str) -> None: + await asyncio.sleep(delay) + send_sms(phone, f"⏰ Reminder: {task}. You asked me to nudge you — take a moment for yourself. 🌱") + + +def schedule_reminder(phone: str, delay: int, task: str) -> bool: + """Fire-and-forget an SMS after `delay` seconds. True if scheduled.""" + try: + asyncio.create_task(_fire(phone, delay, task)) + return True + except RuntimeError: + return False diff --git a/signals/sms_bot/stimulus_sender.py b/signals/sms_bot/stimulus_sender.py new file mode 100644 index 0000000..d762cc7 --- /dev/null +++ b/signals/sms_bot/stimulus_sender.py @@ -0,0 +1,13 @@ +from sms_bot.bot import send_daily_pulse_check + + +async def trigger_checkin(user_id: str, phone: str) -> dict: + sid = await send_daily_pulse_check(user_id, phone) + ok = not sid.startswith(("send_failed", "payment_required", "noop")) + detail = ( + "Bloo.io credits exhausted (free trial) — add credits to send texts." + if sid.startswith("payment_required") else + "SMS provider unavailable." if sid.startswith("send_failed") else + "No BLOOIO_API_KEY set." if sid.startswith("noop") else None + ) + return {"sent": ok, "sid": sid, **({"detail": detail} if detail else {})} diff --git a/signals/sms_bot/webhook.py b/signals/sms_bot/webhook.py new file mode 100644 index 0000000..ca8b874 --- /dev/null +++ b/signals/sms_bot/webhook.py @@ -0,0 +1,173 @@ +import os +import time +import httpx +from fastapi.responses import JSONResponse +from alerts.twilio_sms import send_sms +from sms_bot.bot import SENT_STIMULI + +ML_URL = os.getenv("ML_URL", "http://localhost:8003").rstrip("/") +BACKEND = os.getenv("BACKEND_URL", "http://localhost:8001") + +# Per-sender conversation memory so the SMS companion stays coherent across turns. +_history: dict[str, list] = {} # phone(last10) -> recent [{role,content}] turns +_HISTORY_MAX = 10 +# Exact-duplicate dedup: Bloo.io retries inbound webhooks if our handler is slow. +# We swallow a re-delivery of the SAME text within a short window; distinct +# messages are always processed. +_recent_msg: dict[str, tuple] = {} # phone(last10) -> (text, ts) +_DEDUP_SECS = 90 + + +def _digits(phone: str) -> str: + """Last 10 digits — tolerant phone match (Bloo.io may format `sender` + differently from how we stored it: +1XXXXXXXXXX vs 1XXXXXXXXXX vs spaces).""" + return "".join(c for c in (phone or "") if c.isdigit())[-10:] + + +def _lookup(from_number: str): + """Find the pending stimulus for a sender, matching on the last 10 digits.""" + if from_number in SENT_STIMULI: + return from_number, SENT_STIMULI[from_number] + target = _digits(from_number) + for key, val in SENT_STIMULI.items(): + if _digits(key) == target: + return key, val + return None, None + + +async def handle_incoming_sms(from_number: str, body: str) -> JSONResponse: + body = (body or "").strip() + if not body: + return JSONResponse({"status": "ignored", "reason": "empty"}) + + key, sent = _lookup(from_number) + digits = _digits(from_number) + now = time.time() + + # Swallow Bloo.io retries / duplicate deliveries of the SAME text only. + prev = _recent_msg.get(digits) + if prev and prev[0] == body and now - prev[1] < _DEDUP_SECS: + return JSONResponse({"status": "duplicate_ignored"}) + _recent_msg[digits] = (body, now) + + # Reminder request? "remind me to take a break in 15 minutes" → actually + # schedule an SMS that fires after the delay, and confirm. + from sms_bot.reminders import parse_reminder, schedule_reminder, human_delay + + rem = parse_reminder(body) + if rem: + delay, task = rem + schedule_reminder(from_number, delay, task) + send_sms(from_number, f"Got it — I'll text you in {human_delay(delay)} to {task}. 👍") + return JSONResponse({"status": "ok", "mode": "reminder", "delay": delay, "task": task}) + + if sent: + # Reply to a pending check-in → score it against that stimulus. Claim it + # atomically (no await before the pop) so a retry can't double-score. + SENT_STIMULI.pop(key, None) + latency_ms = int((now - sent["sent_at"]) * 1000) + score = await _get_score(sent, body, latency_ms) + ai_line = await _companion_reply( + digits, sent["user_id"], body, score.get("score"), score.get("level") + ) + emoji = {"green": "🟢", "yellow": "🟡", "red": "🔴"}.get(score.get("level", "green"), "🟢") + points = score.get("score", 50) + hint = score.get("intervention", "") + msg = (f"{ai_line}\n\n{emoji} Pulse: {points}/100" + if ai_line else f"{emoji} Pulse: {points}/100\n\n{hint}") + send_sms(from_number, msg) + return JSONResponse({"status": "ok", "mode": "checkin", "score": points, "level": score.get("level")}) + + # No pending check-in → just talk. Pegasus is a companion you can message + # anytime, not a vending machine that only replies to its own prompts. + reply = await _companion_reply(digits, "sms_" + digits, body, None, None) + send_sms(from_number, reply or "I'm here with you. What's on your mind?") + return JSONResponse({"status": "ok", "mode": "chat"}) + + +async def _get_score(sent: dict, body: str, latency_ms: int) -> dict: + """Score the reply against the stimulus. Primary path is the ML service + (real Meta TRIBE v2 on Modal + HF sentiment + RAG intervention) — TRIBE can + be slow the first time a stimulus is seen, so we allow a generous timeout. + Falls back to the backend orchestrator, then to a local heuristic.""" + # Over SMS we have no real keystroke biometrics, so we DON'T send typing + # signals — the scorer then fuses only the real streams (what they wrote + + # TRIBE), instead of letting fake typing defaults dilute a strong reply. + signals = { + "response_text": body, + "response_time_ms": min(latency_ms, 60_000), + "response_latency_ms": latency_ms, + } + # 1) ML service — TRIBE v2 + HF + RAG. + try: + async with httpx.AsyncClient(timeout=150) as client: + res = await client.post(f"{ML_URL}/score", json={ + "user_id": sent["user_id"], + "stimulus_id": sent["stimulus_id"], + "signals": signals, + }) + res.raise_for_status() + return res.json() + except Exception: + pass + # 2) Backend orchestrator (its schema wants typing fields — give neutral ones). + try: + async with httpx.AsyncClient(timeout=150) as client: + res = await client.post(f"{BACKEND}/response/submit", json={ + "user_id": sent["user_id"], "stimulus_id": sent["stimulus_id"], + "response_text": body, "source": "sms", + "typing_wpm": 40, "error_rate": 0, **signals, + }) + res.raise_for_status() + return res.json() + except Exception: + # 3) Local heuristic — last resort so a reply is never dropped. + return _local_score(body, latency_ms) + + +async def _companion_reply(digits: str, user_id: str, body: str, + score, level) -> str: + """One conversational companion turn (HF chat + RAG), grounded in the burnout + score/level when we have one, and remembering the recent conversation so it + stays coherent across texts. Returns '' on failure.""" + history = _history.get(digits, []) + messages = history + [{"role": "user", "content": body}] + reply = "" + try: + async with httpx.AsyncClient(timeout=60) as client: + res = await client.post(f"{ML_URL}/chat", json={ + "user_id": user_id, "messages": messages, "score": score, "level": level, + }) + res.raise_for_status() + reply = (res.json().get("reply") or "").strip() + except Exception: + reply = "" + # Persist trimmed history so the next text continues the thread. + if reply: + _history[digits] = (messages + [{"role": "assistant", "content": reply}])[-_HISTORY_MAX:] + return reply + + +def _local_score(body: str, latency_ms: int) -> dict: + from analyzers.sentiment import analyze_sentiment + from analyzers.linguistic import analyze_linguistic + from analyzers.temporal import analyze_temporal + + sentiment = analyze_sentiment(body) + linguistic = analyze_linguistic(body) + temporal = analyze_temporal(latency_ms) + + score = min(100, round( + (1 - sentiment["sentiment_score"]) * 40 + + linguistic["burnout_contribution"] + + temporal["burnout_contribution"] + )) + + if score < 30: + level, hint = "green", "Keep it up — small things add up." + elif score < 65: + level, hint = "yellow", "Take a 10-minute break before your next task." + else: + level, hint = "red", "Reach out to someone you trust today." + + return {"score": score, "level": level, "intervention": hint} diff --git a/signals/stimuli/__init__.py b/signals/stimuli/__init__.py new file mode 100644 index 0000000..0d09d20 --- /dev/null +++ b/signals/stimuli/__init__.py @@ -0,0 +1,4 @@ +from .session import create_session, get_session +from .engagement import compute_actual_engagement + +__all__ = ["create_session", "get_session", "compute_actual_engagement"] diff --git a/signals/stimuli/bank.py b/signals/stimuli/bank.py new file mode 100644 index 0000000..aa8ca41 --- /dev/null +++ b/signals/stimuli/bank.py @@ -0,0 +1,140 @@ +import random + +# Each stimulus carries the TRIBE v2 expected engagement for a healthy brain. +# Positive stimuli → high predicted activation (0.75–0.90) +# Negative stimuli → moderate stress activation (0.55–0.70) +# Neutral stimuli → low predicted activation (0.28–0.40) +# Cognitive tasks → active processing baseline (0.60–0.72) + +STIMULI = [ + # ── Positive ─────────────────────────────────────────────────────────────── + { + "id": "pos_1", "type": "scenario", "valence": "positive", + "tribe_expected_engagement": 0.85, + "content": "Your team just shipped a feature that users love.", + "prompt": "How does this make you feel? Respond honestly in a sentence or two.", + }, + { + "id": "pos_2", "type": "word", "valence": "positive", + "tribe_expected_engagement": 0.80, + "content": "VACATION", + "prompt": "What comes to mind? Respond with whatever this word evokes for you.", + }, + { + "id": "pos_3", "type": "scenario", "valence": "positive", + "tribe_expected_engagement": 0.88, + "content": "Your manager praised your work in front of the whole team.", + "prompt": "How does this make you feel? Respond in a sentence or two.", + }, + { + "id": "pos_4", "type": "word", "valence": "positive", + "tribe_expected_engagement": 0.75, + "content": "FRIDAY AFTERNOON", + "prompt": "What comes to mind? Respond with whatever this evokes.", + }, + { + "id": "pos_5", "type": "scenario", "valence": "positive", + "tribe_expected_engagement": 0.82, + "content": "You just solved a bug that had been blocking the team for three days.", + "prompt": "How does this make you feel?", + }, + + # ── Negative ─────────────────────────────────────────────────────────────── + { + "id": "neg_1", "type": "scenario", "valence": "negative", + "tribe_expected_engagement": 0.65, + "content": "Your code broke production 10 minutes before a major client demo.", + "prompt": "How do you feel reading this? Respond honestly.", + }, + { + "id": "neg_2", "type": "word", "valence": "negative", + "tribe_expected_engagement": 0.60, + "content": "DEADLINE", + "prompt": "What comes to mind immediately? Respond with whatever this word evokes.", + }, + { + "id": "neg_3", "type": "scenario", "valence": "negative", + "tribe_expected_engagement": 0.68, + "content": "You have 47 unread Slack messages and a meeting starting in 5 minutes.", + "prompt": "How does this scenario make you feel?", + }, + { + "id": "neg_4", "type": "word", "valence": "negative", + "tribe_expected_engagement": 0.58, + "content": "PERFORMANCE REVIEW", + "prompt": "What comes to mind immediately? Respond with whatever this evokes.", + }, + { + "id": "neg_5", "type": "scenario", "valence": "negative", + "tribe_expected_engagement": 0.62, + "content": "Your pull request has 23 change requests and the feature was due yesterday.", + "prompt": "How does this make you feel?", + }, + + # ── Neutral ──────────────────────────────────────────────────────────────── + { + "id": "neu_1", "type": "scenario", "valence": "neutral", + "tribe_expected_engagement": 0.35, + "content": "Conference room B is available from 2pm to 4pm.", + "prompt": "Any thoughts on this? Just respond naturally.", + }, + { + "id": "neu_2", "type": "word", "valence": "neutral", + "tribe_expected_engagement": 0.30, + "content": "OFFICE SUPPLIES", + "prompt": "What comes to mind? Just respond naturally.", + }, + { + "id": "neu_3", "type": "word", "valence": "neutral", + "tribe_expected_engagement": 0.32, + "content": "QUARTERLY REPORT", + "prompt": "What does this bring to mind?", + }, + + # ── Cognitive tasks ──────────────────────────────────────────────────────── + { + "id": "cog_1", "type": "task", "valence": "neutral", + "tribe_expected_engagement": 0.70, + "content": "List three things you're looking forward to this week.", + "prompt": "Take your time. Be honest.", + }, + { + "id": "cog_2", "type": "task", "valence": "neutral", + "tribe_expected_engagement": 0.65, + "content": "Describe how you feel about your current workload in two sentences.", + "prompt": "Be as honest as you can.", + }, + { + "id": "cog_3", "type": "task", "valence": "neutral", + "tribe_expected_engagement": 0.60, + "content": "What's one thing at work that has been draining your energy lately?", + "prompt": "Take your time. There is no right answer.", + }, +] + +_by_id = {s["id"]: s for s in STIMULI} + + +def get_by_id(stimulus_id: str) -> dict | None: + return _by_id.get(stimulus_id) + + +def select_session_stimuli( + n_positive: int = 2, + n_negative: int = 2, + n_neutral: int = 1, + n_cognitive: int = 1, +) -> list[dict]: + positives = [s for s in STIMULI if s["valence"] == "positive"] + negatives = [s for s in STIMULI if s["valence"] == "negative"] + neutrals = [s for s in STIMULI if s["valence"] == "neutral" and s["type"] != "task"] + cognitives = [s for s in STIMULI if s["type"] == "task"] + + selected = ( + random.sample(positives, min(n_positive, len(positives))) + + random.sample(negatives, min(n_negative, len(negatives))) + + random.sample(neutrals, min(n_neutral, len(neutrals))) + + random.sample(cognitives, min(n_cognitive, len(cognitives))) + ) + random.shuffle(selected) + return selected diff --git a/signals/stimuli/engagement.py b/signals/stimuli/engagement.py new file mode 100644 index 0000000..413f5d3 --- /dev/null +++ b/signals/stimuli/engagement.py @@ -0,0 +1,20 @@ +def compute_actual_engagement(sentiment: dict, typing: dict, temporal: dict) -> float: + """ + Maps behavioral signals onto a 0-1 engagement score for comparison + against tribe_expected_engagement from TRIBE v2. + + High engagement: positive sentiment, fast response, healthy WPM. + Burned-out users show blunted responses to positive stimuli and + flat/avoidant responses to negative ones. + """ + # Speed: full credit under 3s, zero at 30s+ + speed = max(0.0, 1.0 - temporal["response_time_ms"] / 30_000) + + # Sentiment is the strongest predictor of emotional engagement + sentiment_score = float(sentiment["sentiment_score"]) + + # Typing vigor: full credit at 80 WPM + wpm_factor = min(1.0, typing["typing_wpm"] / 80) if typing["typing_wpm"] > 0 else 0.0 + + engagement = sentiment_score * 0.45 + speed * 0.35 + wpm_factor * 0.20 + return round(min(1.0, max(0.0, engagement)), 3) diff --git a/signals/stimuli/session.py b/signals/stimuli/session.py new file mode 100644 index 0000000..5d2db44 --- /dev/null +++ b/signals/stimuli/session.py @@ -0,0 +1,50 @@ +import uuid +from dataclasses import dataclass, field +from .bank import select_session_stimuli + +_sessions: dict[str, "StimuliSession"] = {} + + +@dataclass +class StimuliSession: + session_id: str + user_id: str + stimuli: list + reactions: list = field(default_factory=list) + current_index: int = 0 + complete: bool = False + + def current_stimulus(self) -> dict | None: + if self.current_index < len(self.stimuli): + return self.stimuli[self.current_index] + return None + + def record_reaction(self, reaction: dict) -> None: + self.reactions.append(reaction) + self.current_index += 1 + if self.current_index >= len(self.stimuli): + self.complete = True + + def mean_deviation(self) -> float: + if not self.reactions: + return 0.0 + return round(sum(r["deviation"] for r in self.reactions) / len(self.reactions), 3) + + def burnout_score(self) -> float: + """Converts mean deviation (0-1) to a 0-100 burnout contribution.""" + return round(self.mean_deviation() * 100, 1) + + +def create_session(user_id: str) -> StimuliSession: + session_id = str(uuid.uuid4()) + session = StimuliSession( + session_id=session_id, + user_id=user_id, + stimuli=select_session_stimuli(), + ) + _sessions[session_id] = session + return session + + +def get_session(session_id: str) -> StimuliSession | None: + return _sessions.get(session_id) diff --git a/sync.sh b/sync.sh new file mode 100755 index 0000000..debb9e0 --- /dev/null +++ b/sync.sh @@ -0,0 +1,224 @@ +#!/usr/bin/env bash +# +# sync.sh — Pegasus SAFE 45-minute sync (local half). +# +# Runs ONLY the safe, local steps of the cadence and is impossible to use +# destructively. It contains ZERO destructive commands: no reset --hard, +# no clean, no checkout --theirs/--ours, no force-push. +# +# What it does: +# 1. Refuses to run on main or dev. +# 2. Refuses to run if the working tree is dirty (commit your OWNED files +# first — staging is a human decision, this script never stages for you). +# 3. Refuses to run if updating dev would clobber a gitignored/untracked file +# that already exists on disk (e.g. your real .env secrets) — git branch +# backups CANNOT capture ignored content, so we stop instead of losing it. +# 4. Creates a timestamped backup branch as a recovery point BEFORE anything. +# 5. Plain-pushes your feature branch (never --force / --force-with-lease). +# 6. Fetches origin and fast-forward-only updates local dev, then returns +# you to your feature branch (even if the dev update fails). +# 7. Merges origin/dev INTO your feature branch with git's "rerere" feature +# NEUTRALIZED, so a previously-learned one-sided resolution can NEVER be +# silently re-applied and drop a teammate's side. On conflict it prints +# clear recovery steps and exits non-zero WITHOUT touching your work. +# +# After this script: open/merge your PR feat/ -> dev on GitHub, and +# push again if the merge brought in mergeable work. + +set -euo pipefail + +# --- cd to the script's own directory (repo root) --------------------------- +SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd -P)" +cd -- "$SCRIPT_DIR" + +# --- detect current branch -------------------------------------------------- +BRANCH="$(git rev-parse --abbrev-ref HEAD)" +if [ "$BRANCH" = "HEAD" ]; then + echo "ABORT: detached HEAD. Check out your feat/ branch first." >&2 + exit 1 +fi + +# --- refuse on protected / integration branches ---------------------------- +case "$BRANCH" in + main|dev) + echo "ABORT: you are on '$BRANCH'. Never sync from main or dev." >&2 + echo " Check out your own feature branch: git checkout feat/" >&2 + exit 1 + ;; +esac + +echo "Branch: $BRANCH" + +# --- safety net: ALWAYS get back to the feature branch on any early exit ----- +# If `set -e` (or any error) trips us while we are temporarily on dev, this +# trap returns us to the feature branch. It only ever performs a plain +# checkout (never resets/cleans), so it cannot discard work; if the worktree +# is mid-merge or otherwise can't switch, it leaves a loud note instead. +restore_branch() { + cur="$(git rev-parse --abbrev-ref HEAD 2>/dev/null || echo '?')" + if [ "$cur" != "$BRANCH" ]; then + if ! git checkout "$BRANCH" 2>/dev/null; then + echo "WARNING: could not auto-return to '$BRANCH' (now on '$cur')." >&2 + echo " Run: git checkout $BRANCH" >&2 + fi + fi +} +trap restore_branch EXIT + +# --- refuse if the working tree has uncommitted or staged changes ----------- +# Commit-first is non-negotiable. This script never stages or commits for you, +# so that folder ownership stays intact and nothing of yours is touched. +if ! git diff --quiet || ! git diff --cached --quiet || \ + [ -n "$(git ls-files --others --exclude-standard)" ]; then + echo "ABORT: working tree is not clean." >&2 + echo "" >&2 + echo "Commit your OWNED files first (stage each path explicitly — never 'git add .'):" >&2 + echo " git add -- ml/ video/ frontend/src/services/ frontend/src/hooks/ frontend/src/types/" >&2 + echo " git commit -m \"sync: \"" >&2 + echo "" >&2 + echo "Leftover changes outside your lane? Stash them (kept as a backup):" >&2 + echo " git stash push --include-untracked -m presync" >&2 + echo "Then re-run ./sync.sh" >&2 + exit 1 +fi + +# --- timestamped backup branch BEFORE anything else ------------------------- +# Use a collision-proof name (PID-stamped) so a same-second re-run can't fail, +# under a FLAT namespace that cannot directory/file-conflict with the feature +# branch's own slashed name (feat/ -> feat-). +SAFE_BRANCH="$(printf '%s' "$BRANCH" | tr '/' '-')" +TS="$(date +%Y%m%d-%H%M%S)" +BACKUP="backup/${SAFE_BRANCH}-${TS}-$$" +# Belt and suspenders: if that exact ref somehow exists, bump until it doesn't. +while git show-ref --quiet --verify "refs/heads/${BACKUP}"; do + BACKUP="${BACKUP}x" +done +echo "Creating backup branch: $BACKUP" +if ! git branch -- "$BACKUP"; then + echo "ABORT: could not create backup branch '$BACKUP' (name collision?)." >&2 + echo " Stopping BEFORE any push or merge — nothing has changed." >&2 + exit 1 +fi +# Push the backup off-machine (brand-new ref, plain push — never force). +if git push origin "$BACKUP"; then + echo "Backup pushed to origin: $BACKUP" +else + echo "WARNING: could not push backup '$BACKUP' to origin (offline?)." >&2 + echo " The local backup branch still exists as a recovery point." >&2 +fi + +# --- plain-push the feature branch (NEVER --force) -------------------------- +echo "Pushing $BRANCH to origin (plain push)..." +git push origin "$BRANCH" +echo ">> Now on GitHub: open / merge PR $BRANCH -> dev (PR only; never push to dev/main)." + +# --- fetch latest origin ---------------------------------------------------- +echo "Fetching origin..." +git fetch origin --prune + +# --- GUARD: would updating to origin/dev clobber a gitignored/untracked file? +# `git branch` backups can NEVER capture ignored or untracked content (e.g. a +# real .env full of secrets). If origin/dev tracks a path that exists on disk +# but is NOT tracked by us, the merge below would silently overwrite it with +# dev's version and the original would exist in NO git object anywhere. Stop. +if git rev-parse -q --verify origin/dev >/dev/null; then + COLLIDERS="" + while IFS= read -r p; do + [ -z "$p" ] && continue + # Path exists on disk AND is not tracked in our current branch? + if [ -e "$p" ] && ! git ls-files --error-unmatch -- "$p" >/dev/null 2>&1; then + COLLIDERS="${COLLIDERS} ${p} +" + fi + done < <(git diff --name-only HEAD origin/dev -- 2>/dev/null || true) + + if [ -n "$COLLIDERS" ]; then + echo "ABORT: origin/dev would OVERWRITE local files that git is NOT tracking" >&2 + echo " (e.g. your real .env). Backups cannot capture ignored/untracked" >&2 + echo " content, so merging now could PERMANENTLY destroy this work:" >&2 + printf '%s' "$COLLIDERS" >&2 + echo "Move each file aside FIRST, then re-run ./sync.sh, then restore it:" >&2 + echo " cp .env .env.local.bak # for example" >&2 + echo " ./sync.sh # merges dev's tracked version in" >&2 + echo " # then reconcile .env by hand from .env.local.bak" >&2 + echo "Nothing merged. Your work is committed, backed up ($BACKUP), and pushed." >&2 + exit 1 + fi +fi + +# --- fast-forward-only update of local dev ---------------------------------- +echo "Updating local dev (fast-forward only)..." +DEV_OK=1 +git checkout dev || DEV_OK=0 +if [ "$DEV_OK" -eq 1 ]; then + if git pull --ff-only origin dev; then + echo "Local dev fast-forwarded to origin/dev." + else + echo "WARNING: local dev could not fast-forward (it has diverged)." >&2 + echo " NOT forcing anything. Skipping the dev merge this round." >&2 + DEV_OK=0 + fi +fi + +# Always return to the feature branch, even if the dev update failed. +# Guarded so the script never dies while standing on dev (the EXIT trap above +# is a second line of defence; this gives a precise message and a clean exit). +if ! git checkout "$BRANCH"; then + echo "ERROR: could not return to '$BRANCH' — you are on dev." >&2 + echo " Run: git checkout $BRANCH" >&2 + exit 1 +fi + +if [ "$DEV_OK" -eq 0 ]; then + echo "" + echo "Done (core sync complete). Dev was NOT merged in because local dev diverged." >&2 + echo "Your work is committed, backed up ($BACKUP), and pushed — nothing lost." >&2 + echo "Drift is recoverable; investigate dev later or just re-run next cycle." >&2 + exit 0 +fi + +# --- merge origin/dev INTO the feature branch ------------------------------ +# rerere is NEUTRALIZED here (-c rerere.enabled=false -c rerere.autoupdate=false). +# WHY: with rerere.autoupdate, a recurring identical conflict is silently +# auto-resolved AND auto-staged from a previously learned (possibly one-sided) +# resolution. The file then contains only one side, with NO conflict markers, +# and `git diff --diff-filter=U` is EMPTY — which would fool any "no unmerged +# paths => safe to commit" check into recording a merge that dropped a +# teammate's committed side. Disabling rerere for THIS merge forces every real +# conflict to surface with markers so a human keeps BOTH sides. +echo "Merging origin/dev into $BRANCH (rerere disabled for safety)..." +if git -c rerere.enabled=false -c rerere.autoupdate=false \ + merge --no-ff -m "sync: merge dev into ${BRANCH} ${TS}" origin/dev; then + echo "" + echo "Sync complete. dev merged into $BRANCH." + echo "Backup: $BACKUP" + echo ">> Push the merge and open/merge a PR if it brought in mergeable work:" + echo " git push origin \"$BRANCH\"" + exit 0 +fi + +# --- conflict: STOP, print recovery, exit non-zero (no destructive cmds) ---- +echo "" >&2 +echo "CONFLICT while merging dev into $BRANCH. NOTHING has been discarded." >&2 +echo "Conflicted files:" >&2 +git diff --name-only --diff-filter=U >&2 || true +echo "" >&2 +echo "Resolve by KEEPING BOTH sides (never pick one side blindly), then:" >&2 +echo " 1) Edit each conflicted file to keep BOTH blocks (remove <<<<<<< ======= >>>>>>> markers)." >&2 +echo " 2) Stage ONLY files in your lane, e.g.:" >&2 +echo " git add -- ml/ video/ frontend/src/services/ frontend/src/hooks/ frontend/src/types/" >&2 +echo " 3) BEFORE committing, OPEN each previously-conflicted file and confirm it" >&2 +echo " STILL contains the teammate's lines from dev — not just your own side." >&2 +echo " (An empty 'git diff --diff-filter=U' alone is NOT proof both sides survived:" >&2 +echo " if your global git has rerere.autoupdate on, a prior one-sided fix can be" >&2 +echo " replayed and auto-staged with no markers. This script disables rerere for" >&2 +echo " its own merge, but verify by eye anyway.)" >&2 +echo " 4) Finish the merge once you've verified BOTH sides are present:" >&2 +echo " git commit --no-edit" >&2 +echo "" >&2 +echo "OR back out cleanly (restores your branch exactly, loses nothing):" >&2 +echo " git merge --abort" >&2 +echo "" >&2 +echo "Recovery point if needed: git checkout $BACKUP" >&2 +echo "Do NOT run reset --hard, clean, checkout --theirs/--ours, or any force-push." >&2 +exit 1 diff --git a/video/.env.example b/video/.env.example new file mode 100644 index 0000000..d578abf --- /dev/null +++ b/video/.env.example @@ -0,0 +1,9 @@ +# Video service (Rishith). Copy to .env and fill in. NEVER commit .env. +NVIDIA_STT_KEY=nvapi-... +NVIDIA_TTS_KEY=nvapi-... +# Hosted NVIDIA Riva over gRPC (defaults below). Override the TTS function-id +# to switch voices/models; default is Resemble.AI Chatterbox multilingual TTS. +# NVIDIA_RIVA_SERVER=grpc.nvcf.nvidia.com:443 +# NVIDIA_TTS_FUNCTION_ID=ddacc747-1269-4fab-bfd9-8f593dead106 # chatterbox +# NVIDIA_ASR_FUNCTION_ID=1598d209-5e27-4d3c-8079-4751568b1081 # parakeet en-US +# NVIDIA_TTS_VOICE= diff --git a/video/AGENTS.md b/video/AGENTS.md new file mode 100644 index 0000000..23941ce --- /dev/null +++ b/video/AGENTS.md @@ -0,0 +1,43 @@ +# `/video` — Rishith's agent (second folder) + +You are **Rishith**. You own **`video/`** — facial + voice stress on **:8004**. +Read `../AGENTS.md`, `../ml/AGENTS.md`, and the contract first. + +## Never touch +`/frontend` screens (Wesley), `/backend`, `/signals`, `/shared`. The backend +forwards clips here; you return facial + voice + combined_score. + +## Endpoints (:8004) +- `GET /health` +- `POST /analyze/video` — multipart `video` → `{ facial, voice, combined_score }` + +## Files +- `facial_analyzer.py` — **my own** stress logic on MediaPipe Face Mesh + landmarks (EAR-style eye openness, brow gap, lip compression, jaw). No CV API. +- `voice_analyzer.py` — **NVIDIA parakeet ASR** (hosted Riva gRPC) transcript + + librosa pitch/tremor. +- `tts.py` — **NVIDIA Chatterbox TTS** (hosted Riva gRPC) speaks interventions. +- `main.py` — FastAPI; ffmpeg extracts audio; analyzers lazy-load. + +## NVIDIA hosted speech (Riva gRPC) +Both call `grpc.nvcf.nvidia.com:443` via `nvidia-riva-client`, with metadata +`function-id` + `authorization: Bearer `. Function-ids (env-overridable): +- TTS Chatterbox: `ddacc747-1269-4fab-bfd9-8f593dead106` +- ASR Parakeet en-US: `1598d209-5e27-4d3c-8079-4751568b1081` + +## Secrets (.env, gitignored) +`NVIDIA_STT_KEY`, `NVIDIA_TTS_KEY` (the `nvapi-...` keys). Read via `os.getenv`. +No Anthropic anywhere — language/AI is Hugging Face (in `/ml`). + +## Run +```bash +cd video && python -m venv .venv && source .venv/bin/activate +pip install -r requirements.txt && brew install ffmpeg +cp .env.example .env +uvicorn main:app --port 8004 --reload +``` + +## Git — same branch as ml: `feat/rishith-ml` +```bash +git add ml/ video/ frontend/src/services/ frontend/src/hooks/ frontend/src/types/ +``` diff --git a/video/README.md b/video/README.md new file mode 100644 index 0000000..4695c31 --- /dev/null +++ b/video/README.md @@ -0,0 +1,27 @@ +# video — facial + voice stress (Rishith, :8004) + +Analyzes a 30-60s check-in clip: +- **Facial** — my own stress heuristics on MediaPipe Face Mesh landmarks + (EAR-style eye openness, inner-brow gap, lip compression, jaw width). No CV API. +- **Voice** — NVIDIA **parakeet ASR** (hosted Riva gRPC) transcript + librosa + pitch / tremor / speaking rate. +- **TTS** — NVIDIA **Chatterbox** (hosted Riva gRPC) speaks the intervention (`tts.py`). + +Returns `{ facial, voice, combined_score }`. The backend forwards the user's +clip here, then feeds the result into `/ml`'s scorer. + +## Run +```bash +cd video && python3 -m venv .venv && source .venv/bin/activate +pip install -r requirements.txt # mediapipe, opencv, librosa +brew install ffmpeg # audio extraction +cp .env.example .env # NVIDIA_STT_KEY, NVIDIA_TTS_KEY +uvicorn main:app --port 8004 --reload +``` +Models load lazily on first `/analyze/video`, so `/health` is instant. + +## Notes +- `forced_smile` / `gaze_stability` are placeholders pending iris tracking + + AU6/AU12 mismatch. +- Without NVIDIA keys, voice returns an empty transcript + librosa-only acoustics + (still produces a stress signal); facial works fully offline. diff --git a/video/facial_analyzer.py b/video/facial_analyzer.py new file mode 100644 index 0000000..4f77fa3 --- /dev/null +++ b/video/facial_analyzer.py @@ -0,0 +1,85 @@ +# Custom facial stress analysis (Rishith). MediaPipe Face Mesh is used as a +# LIBRARY (468 landmarks, runs locally, free, no API) — ALL stress +# interpretation logic below is my own, derived from landmark geometry. +import cv2 +import mediapipe as mp +import numpy as np + + +class FacialStressAnalyzer: + def __init__(self): + self.mesh = mp.solutions.face_mesh.FaceMesh( + static_image_mode=False, + max_num_faces=1, + refine_landmarks=True, + min_detection_confidence=0.5, + ) + + def _dist(self, lm, a, b): + return float(np.hypot(lm[a].x - lm[b].x, lm[a].y - lm[b].y)) + + def analyze_frame(self, frame): + res = self.mesh.process(cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)) + if not res.multi_face_landmarks: + return None + lm = res.multi_face_landmarks[0].landmark + + # Eye openness (EAR-style): vertical / horizontal eye ratio. + left_eye = self._dist(lm, 159, 145) / (self._dist(lm, 33, 133) + 1e-6) + right_eye = self._dist(lm, 386, 374) / (self._dist(lm, 362, 263) + 1e-6) + eye_open = (left_eye + right_eye) / 2 + + # Brow furrow: inner-brow gap (smaller = furrowed = stress). + brow_gap = self._dist(lm, 70, 300) + brow_furrow = max(0.0, 1 - brow_gap * 5) + + # Lip compression: lip height / width (smaller = pressed = tension). + lip_h = self._dist(lm, 13, 14) + lip_w = self._dist(lm, 61, 291) + lip_compression = max(0.0, 1 - (lip_h / (lip_w + 1e-6)) * 4) + + # Jaw tension proxy: jaw width relative to face height. + jaw = self._dist(lm, 234, 454) / (self._dist(lm, 10, 152) + 1e-6) + + return { + "eye_openness": eye_open, + "brow_furrow": brow_furrow, + "lip_compression": lip_compression, + "jaw_clench": jaw, + } + + def analyze_video(self, frames): + results, blinks, prev = [], 0, None + for f in frames: + r = self.analyze_frame(f) + if r: + results.append(r) + if prev is not None and prev > 0.25 and r["eye_openness"] < 0.12: + blinks += 1 + prev = r["eye_openness"] + if not results: + return {"error": "no face detected", "facial_stress_score": 0} + + dur_min = len(frames) / 3 / 60 # ~3fps sampled (every 10th of 30fps) + brow = float(np.mean([r["brow_furrow"] for r in results])) + lip = float(np.mean([r["lip_compression"] for r in results])) + jaw = float(np.mean([r["jaw_clench"] for r in results])) + eye = 1 - float(np.mean([r["eye_openness"] for r in results])) + stress = min(int((brow * 0.3 + lip * 0.25 + jaw * 0.25 + eye * 0.2) * 100), 100) + affect = "negative" if (brow > 0.5 and lip > 0.4) else "flat" if (brow < 0.2 and lip < 0.2) else "neutral" + + return { + "facial_stress_score": stress, + "eye_indicators": { + "blink_rate_per_min": round(blinks / max(dur_min, 0.1), 1), + "eye_openness": round(float(np.mean([r["eye_openness"] for r in results])), 3), + "gaze_stability": 0.7, # placeholder — needs iris tracking + }, + "facial_indicators": { + "brow_furrow": round(brow, 3), + "lip_compression": round(lip, 3), + "jaw_clench": round(jaw, 3), + "forced_smile": False, # placeholder — needs AU6+AU12 mismatch + "overall_affect": affect, + }, + } diff --git a/video/facial_hf.py b/video/facial_hf.py new file mode 100644 index 0000000..0d1a5dd --- /dev/null +++ b/video/facial_hf.py @@ -0,0 +1,94 @@ +"""HF facial-affect analysis (Rishith) — compares your face to a Hugging Face +model that knows what a healthy, regulated expression looks like. + +A ViT face-expression classifier (default ``trpakov/vit-face-expression``, FER +labels: angry, disgust, fear, happy, neutral, sad, surprise) runs on sampled +frames via ``InferenceClient.image_classification``. We aggregate the emotion +distribution across frames and score burnout/stress as how far the affect +DEVIATES from a healthy (neutral/positive) profile — i.e. distress affect mass +vs. healthy affect mass. + +No Anthropic. Degrades gracefully: returns ``None`` if HF is unavailable so the +caller can fall back to the geometric (MediaPipe) analyzer. +""" +from __future__ import annotations + +import os +from collections import defaultdict +from typing import Dict, List, Optional + +HF_TOKEN = os.getenv("HF_TOKEN") or os.getenv("HF_API_KEY") +FACE_MODEL = os.getenv("HF_FACE_MODEL", "trpakov/vit-face-expression") +MAX_FRAMES = int(os.getenv("HF_FACE_MAX_FRAMES", "6")) + +# A healthy, regulated face reads mostly neutral / positive. Distress & +# burnout show elevated negative affect — that's what the stress score weights. +_HEALTHY = {"happy": 1.0, "neutral": 0.85, "surprise": 0.55} +_STRESS = {"sad": 1.0, "fear": 1.0, "angry": 0.9, "disgust": 0.8} + + +class HFFacialModel: + def __init__(self) -> None: + self._client = None + + def _hf(self): + if self._client is None: + from huggingface_hub import InferenceClient + + self._client = InferenceClient(token=HF_TOKEN) + return self._client + + def _classify(self, jpg_bytes: bytes) -> Optional[Dict[str, float]]: + try: + res = self._hf().image_classification(jpg_bytes, model=FACE_MODEL) + return {r.label.lower(): float(r.score) for r in res} + except Exception: + return None + + def analyze_frames(self, frames: List) -> Optional[Dict]: + """frames: list of BGR numpy arrays (as decoded by OpenCV). + Returns the affect analysis, or None if HF/frames are unavailable.""" + if not HF_TOKEN or not frames: + return None + import cv2 # local — keeps module import light + + step = max(1, len(frames) // MAX_FRAMES) + sampled = frames[::step][:MAX_FRAMES] + + agg: Dict[str, float] = defaultdict(float) + n = 0 + for f in sampled: + ok, buf = cv2.imencode(".jpg", f) + if not ok: + continue + dist = self._classify(buf.tobytes()) + if not dist: + continue + for label, score in dist.items(): + agg[label] += score + n += 1 + if n == 0: + return None + + emotions = {k: round(v / n, 3) for k, v in agg.items()} + stress_mass = sum(emotions.get(k, 0.0) * w for k, w in _STRESS.items()) + healthy_mass = sum(emotions.get(k, 0.0) * w for k, w in _HEALTHY.items()) + total = stress_mass + healthy_mass + 1e-6 + + stress_score = int(round(min(stress_mass / total, 1.0) * 100)) + dominant = max(emotions, key=emotions.get) if emotions else "neutral" + affect = ( + "positive" if dominant in ("happy", "surprise") + else "negative" if dominant in ("sad", "fear", "angry", "disgust") + else "neutral" + ) + return { + "facial_stress_score": stress_score, # 0 healthy .. 100 distress + "health_alignment": round(healthy_mass / total, 3), # 1.0 = fully healthy affect + "emotion_distribution": emotions, + "dominant_affect": dominant, + "overall_affect": affect, + "frames_analyzed": n, + "model": FACE_MODEL, + "source": "huggingface", + } diff --git a/video/main.py b/video/main.py new file mode 100644 index 0000000..5a138b4 --- /dev/null +++ b/video/main.py @@ -0,0 +1,325 @@ +"""Pegasus Video service (:8004) — facial + voice stress from a check-in clip. + +Run (from inside /video): + uvicorn main:app --port 8004 --reload + +Endpoints: + GET /health + POST /analyze/video multipart 'video' -> { facial, voice, combined_score } + +Heavy analyzers (MediaPipe / librosa) load lazily on the first call, so the +service boots instantly and /health works before models are ready. +Requires ffmpeg on the system (audio extraction): brew install ffmpeg +""" +from __future__ import annotations + +import base64 +import json +import os +import tempfile +import threading +import urllib.request +from typing import Optional + +try: # load NVIDIA keys + ML_URL from video/.env before the analyzers import + from dotenv import load_dotenv + + load_dotenv() +except Exception: + pass + +from fastapi import FastAPI, File, Form, UploadFile +from fastapi.middleware.cors import CORSMiddleware +from pydantic import BaseModel + +# The ML service hosts the companion brain (/chat). On a phone both run on the +# laptop; override with ML_URL if they live elsewhere. +ML_URL = os.getenv("ML_URL", "http://127.0.0.1:8003").rstrip("/") +SIGNALS_URL = os.getenv("SIGNALS_URL", "http://127.0.0.1:8002").rstrip("/") +TTS_SAMPLE_RATE = int(os.getenv("NVIDIA_TTS_SAMPLE_RATE", "44100")) + +app = FastAPI(title="Pegasus Video") +app.add_middleware(CORSMiddleware, allow_origins=["*"], allow_methods=["*"], allow_headers=["*"]) + +_facial = None +_voice = None +_hf_facial = None + + +def _get_facial(): + global _facial + if _facial is None: + from facial_analyzer import FacialStressAnalyzer + + _facial = FacialStressAnalyzer() + return _facial + + +def _get_voice(): + global _voice + if _voice is None: + from voice_analyzer import VoiceStressAnalyzer + + _voice = VoiceStressAnalyzer() + return _voice + + +def _get_hf_facial(): + global _hf_facial + if _hf_facial is None: + from facial_hf import HFFacialModel + + _hf_facial = HFFacialModel() + return _hf_facial + + +def _to_wav16k(src_path: str) -> str: + """Transcode any uploaded clip to 16k mono WAV for robust ASR. Returns the + wav path (falls back to the source if ffmpeg isn't available).""" + wav = src_path + ".16k.wav" + if os.system(f"ffmpeg -i {src_path} -vn -ar 16000 -ac 1 {wav} -y -loglevel quiet") == 0 and os.path.exists(wav): + return wav + return src_path + + +def _chat_reply(user_id: str, messages: list, score: Optional[int], level: Optional[str]) -> str: + """Ask the ML companion for the next turn. Warm fallback if it's unreachable.""" + body = json.dumps( + {"user_id": user_id, "messages": messages, "score": score, "level": level} + ).encode() + req = urllib.request.Request( + f"{ML_URL}/chat", data=body, headers={"Content-Type": "application/json"}, method="POST" + ) + try: + with urllib.request.urlopen(req, timeout=90) as r: + return (json.loads(r.read().decode()).get("reply") or "").strip() or _safe_reply() + except Exception: + return _safe_reply() + + +def _safe_reply() -> str: + return "I'm right here with you. Take your time — what's been weighing on you?" + + +def _update_wellness(user_id: str, transcript: str) -> None: + """Fire-and-forget: derive a wellness reading from what the user just said so + the app's live score actually moves with the conversation.""" + if not transcript: + return + try: + body = json.dumps({ + "user_id": user_id, "stimulus_id": "conversation", + "signals": {"response_text": transcript}, + }).encode() + req = urllib.request.Request( + f"{ML_URL}/score", data=body, + headers={"Content-Type": "application/json"}, method="POST", + ) + urllib.request.urlopen(req, timeout=60).read() + except Exception: + pass + + +def _try_reminder(user_id: str, text: str) -> Optional[str]: + """If the user asked for a reminder, schedule it via signals and return a + confirmation line; else None (fall through to normal conversation).""" + if not text: + return None + try: + body = json.dumps({"user_id": user_id, "text": text}).encode() + req = urllib.request.Request( + f"{SIGNALS_URL}/schedule-reminder", data=body, + headers={"Content-Type": "application/json"}, method="POST", + ) + with urllib.request.urlopen(req, timeout=10) as r: + d = json.loads(r.read().decode()) + if d.get("scheduled"): + return f"Done — I'll text you in {d.get('human', 'a bit')} to {d.get('task', 'take a break')}." + except Exception: + pass + return None + + +def _speak_b64(text: str) -> Optional[str]: + """Synthesize `text` with Chatterbox TTS and return base64 WAV (or None).""" + if not text: + return None + import tts as tts_mod + + out = tempfile.mktemp(suffix=".wav") + try: + path = tts_mod.speak(text, out) + if not path or not os.path.exists(path): + return None + with open(path, "rb") as fh: + return base64.b64encode(fh.read()).decode("ascii") + except Exception: + return None + finally: + try: + os.unlink(out) + except OSError: + pass + + +@app.get("/health") +def health(): + return {"status": "video running", "ml_url": ML_URL} + + +@app.post("/analyze/video") +async def analyze_video(video: UploadFile = File(...)): + import cv2 + + with tempfile.NamedTemporaryFile(suffix=".mp4", delete=False) as tmp: + tmp.write(await video.read()) + path = tmp.name + audio = path.replace(".mp4", ".wav") + try: + # Sample every 10th frame (~3fps from 30fps source). + cap = cv2.VideoCapture(path) + frames, i = [], 0 + while cap.isOpened(): + ret, frame = cap.read() + if not ret: + break + if i % 10 == 0: + frames.append(frame) + i += 1 + cap.release() + + # Facial structure (MediaPipe 468-landmark geometry) — brow/eye/jaw tension. + try: + facial_result = _get_facial().analyze_video(frames) if frames else {"error": "no frames", "facial_stress_score": 0} + except Exception as e: + facial_result = {"error": f"facial failed: {e}", "facial_stress_score": 0} + + # HF affect: compare the face to a model that knows healthy expression. + # This drives the headline stress score (deviation from healthy affect); + # the MediaPipe geometry above stays as the structural detail. + try: + hf = _get_hf_facial().analyze_frames(frames) if frames else None + except Exception: + hf = None + if hf: + facial_result.pop("error", None) + facial_result["emotion_distribution"] = hf["emotion_distribution"] + facial_result["dominant_affect"] = hf["dominant_affect"] + facial_result["health_alignment"] = hf["health_alignment"] + facial_result["facial_model"] = hf["model"] + facial_result.setdefault("facial_indicators", {})["overall_affect"] = hf["overall_affect"] + geo = facial_result.get("facial_stress_score") or 0 + # HF affect leads (0.65); geometric tension supports (0.35). + facial_result["facial_stress_score"] = ( + int(round(hf["facial_stress_score"] * 0.65 + geo * 0.35)) if geo + else hf["facial_stress_score"] + ) + facial_result["source"] = "hf+mediapipe" if geo else "huggingface" + + try: + os.system(f"ffmpeg -i {path} -vn -ar 16000 {audio} -y -loglevel quiet") + voice_result = _get_voice().analyze_audio(audio) + except Exception as e: + voice_result = {"error": f"voice failed: {e}", "pitch_mean_hz": 0, "voice_tremor": False, "pause_frequency": 0} + + fs = facial_result.get("facial_stress_score", 0) + vs = ( + min(voice_result.get("pitch_mean_hz", 150) / 300, 1) * 40 + + (20 if voice_result.get("voice_tremor") else 0) + + min(voice_result.get("pause_frequency", 0) / 10, 1) * 40 + ) + return {"facial": facial_result, "voice": voice_result, "combined_score": int(fs * 0.6 + vs * 0.4)} + finally: + for p in (path, audio): + try: + os.unlink(p) + except OSError: + pass + + +class TtsReq(BaseModel): + text: str + + +@app.post("/tts") +def tts(req: TtsReq): + """Speak arbitrary text (used for Pegasus's opening line in the video call).""" + return {"audio_b64": _speak_b64(req.text), "sample_rate": TTS_SAMPLE_RATE} + + +@app.post("/converse") +async def converse( + audio: UploadFile = File(...), + history: str = Form("[]"), + user_id: str = Form("demo_user"), + score: Optional[int] = Form(None), + level: Optional[str] = Form(None), +): + """One spoken turn of the video-call conversation: + user audio -> parakeet STT (+ voice-stress) -> ML companion reply -> + Chatterbox TTS. Returns the transcript, the reply text, the reply audio + (base64 WAV), and the per-turn voice-stress read for mental-health tracking. + Degrades gracefully: if STT/TTS are down you still get a text reply.""" + with tempfile.NamedTemporaryFile(suffix=".upload", delete=False) as tmp: + tmp.write(await audio.read()) + src = tmp.name + wav = _to_wav16k(src) + try: + try: + voice_result = _get_voice().analyze_audio(wav) + except Exception as e: + voice_result = {"transcript": "", "error": f"voice failed: {e}"} + + transcript = (voice_result.get("transcript") or "").strip() + if transcript: + threading.Thread(target=_update_wellness, args=(user_id, transcript), daemon=True).start() + + try: + msgs = json.loads(history) if history else [] + if not isinstance(msgs, list): + msgs = [] + except Exception: + msgs = [] + if transcript: + msgs = msgs + [{"role": "user", "content": transcript}] + + # "Remind me / text me in N minutes…" → schedule it and confirm. + reply = _try_reminder(user_id, transcript) or _chat_reply(user_id, msgs, score, level) + return { + "transcript": transcript, + "reply": reply, + "voice": voice_result, + "audio_b64": _speak_b64(reply), + "sample_rate": TTS_SAMPLE_RATE, + } + finally: + for p in (src, wav): + try: + if p != src or os.path.exists(p): + os.unlink(p) + except OSError: + pass + + +@app.post("/facial-frame") +async def facial_frame(image: UploadFile = File(...)): + """Single-frame facial affect via the HF model — fast, for the live video + call's periodic read (no 60s record + heavy analyze).""" + import cv2 + import numpy as np + + raw = await image.read() + frame = cv2.imdecode(np.frombuffer(raw, dtype=np.uint8), cv2.IMREAD_COLOR) + if frame is None: + return {"facial_stress_score": 0, "overall_affect": "neutral", "emotion_distribution": {}} + try: + hf = _get_hf_facial().analyze_frames([frame]) + except Exception: + hf = None + return hf or {"facial_stress_score": 0, "overall_affect": "neutral", "emotion_distribution": {}} + + +if __name__ == "__main__": + import uvicorn + + uvicorn.run(app, host="0.0.0.0", port=8004) diff --git a/video/requirements.txt b/video/requirements.txt new file mode 100644 index 0000000..899fd83 --- /dev/null +++ b/video/requirements.txt @@ -0,0 +1,10 @@ +fastapi==0.115.6 +uvicorn[standard]==0.34.0 +python-multipart==0.0.20 +numpy==1.26.4 +opencv-python==4.10.0.84 +mediapipe==0.10.18 +librosa==0.10.2.post1 +nvidia-riva-client==2.17.0 +python-dotenv==1.0.1 +# Needs ffmpeg on the system for audio extraction: brew install ffmpeg diff --git a/video/tts.py b/video/tts.py new file mode 100644 index 0000000..0ac5ee0 --- /dev/null +++ b/video/tts.py @@ -0,0 +1,62 @@ +# NVIDIA Text-to-Speech (Rishith) — speak Pegasus's replies aloud. +# Hosted NVIDIA Riva over gRPC (grpc.nvcf.nvidia.com:443) via nvidia-riva-client. +# Default function-id = Magpie TTS Multilingual (the Chatterbox function isn't +# available to this account; Magpie is and sounds natural). +import os +import wave + +import numpy as np + +NVIDIA_TTS_KEY = os.getenv("NVIDIA_TTS_KEY") +RIVA_SERVER = os.getenv("NVIDIA_RIVA_SERVER", "grpc.nvcf.nvidia.com:443") +# ai-magpie-tts-multilingual on build.nvidia.com. +TTS_FUNCTION_ID = os.getenv("NVIDIA_TTS_FUNCTION_ID", "877104f7-e885-42b9-8de8-f6e4c6303969") +TTS_VOICE = os.getenv("NVIDIA_TTS_VOICE", "") # empty -> model default +SAMPLE_RATE = int(os.getenv("NVIDIA_TTS_SAMPLE_RATE", "44100")) +# Peak-normalize the voice so it's clearly audible on a phone speaker (loud). +TTS_GAIN_CAP = float(os.getenv("NVIDIA_TTS_GAIN_CAP", "12")) +TTS_TARGET_PEAK = float(os.getenv("NVIDIA_TTS_TARGET_PEAK", "31000")) + + +def _boost(audio: bytes) -> bytes: + """Peak-normalize raw PCM16 toward full scale so the reply plays loud.""" + try: + s = np.frombuffer(audio, dtype=np.int16).astype(np.float32) + peak = float(np.max(np.abs(s))) or 1.0 + gain = min(TTS_TARGET_PEAK / peak, TTS_GAIN_CAP) + return np.clip(s * gain, -32768, 32767).astype(np.int16).tobytes() + except Exception: + return audio + + +def speak(text: str, out_path: str = "intervention.wav"): + """Synthesize speech to a WAV file. Returns the path, or None on failure.""" + if not NVIDIA_TTS_KEY or not text: + return None + try: + import riva.client + + auth = riva.client.Auth( + uri=RIVA_SERVER, + use_ssl=True, + metadata_args=[ + ["function-id", TTS_FUNCTION_ID], + ["authorization", f"Bearer {NVIDIA_TTS_KEY}"], + ], + ) + tts = riva.client.SpeechSynthesisService(auth) + resp = tts.synthesize( + text, + voice_name=TTS_VOICE or None, + language_code="en-US", + encoding=riva.client.AudioEncoding.LINEAR_PCM, + sample_rate_hz=SAMPLE_RATE, + ) + with wave.open(out_path, "wb") as w: # resp.audio is raw PCM16 mono + w.setnchannels(1) + w.setsampwidth(2) + w.setframerate(SAMPLE_RATE) + w.writeframes(_boost(resp.audio)) + return out_path + except Exception: + return None diff --git a/video/voice_analyzer.py b/video/voice_analyzer.py new file mode 100644 index 0000000..a518e88 --- /dev/null +++ b/video/voice_analyzer.py @@ -0,0 +1,77 @@ +# Voice stress (Rishith). NVIDIA parakeet ASR (hosted Riva gRPC) for the +# transcript + librosa for acoustic stress. Degrades gracefully (empty +# transcript / zeros) so a check-in never hard-fails. +import os + +import numpy as np + +NVIDIA_STT_KEY = os.getenv("NVIDIA_STT_KEY") +RIVA_SERVER = os.getenv("NVIDIA_RIVA_SERVER", "grpc.nvcf.nvidia.com:443") +# parakeet-tdt-0.6b-v2 — supports OFFLINE recognize (the 1.1b CTC function is +# streaming-only and rejects offline_recognize). +ASR_FUNCTION_ID = os.getenv("NVIDIA_ASR_FUNCTION_ID", "d3fe9151-442b-4204-a70d-5fcc597fd610") + + +class VoiceStressAnalyzer: + def transcribe_nvidia(self, audio_path: str) -> str: + if not NVIDIA_STT_KEY: + return "" + try: + import riva.client + + auth = riva.client.Auth( + uri=RIVA_SERVER, + use_ssl=True, + metadata_args=[ + ["function-id", ASR_FUNCTION_ID], + ["authorization", f"Bearer {NVIDIA_STT_KEY}"], + ], + ) + asr = riva.client.ASRService(auth) + # The /converse + /analyze paths feed a 16 kHz mono WAV. Set the + # config explicitly (add_audio_file_specs picked params the model + # wouldn't serve). + config = riva.client.RecognitionConfig( + encoding=riva.client.AudioEncoding.LINEAR_PCM, + sample_rate_hertz=16000, + language_code="en-US", + max_alternatives=1, + enable_automatic_punctuation=True, + ) + with open(audio_path, "rb") as f: + data = f.read() + resp = asr.offline_recognize(data, config) + return " ".join( + r.alternatives[0].transcript for r in resp.results if r.alternatives + ).strip() + except Exception: + return "" + + def analyze_audio(self, audio_path: str) -> dict: + transcript = self.transcribe_nvidia(audio_path) + try: + import librosa # lazy — voice stress is optional; transcript already done + + y, sr = librosa.load(audio_path) + pitches, mags = librosa.piptrack(y=y, sr=sr) + vals = [ + float(pitches[mags[:, t].argmax(), t]) + for t in range(pitches.shape[1]) + if pitches[mags[:, t].argmax(), t] > 0 + ] + dur = float(librosa.get_duration(y=y, sr=sr)) + words = len(transcript.split()) + pitch_std = float(np.std(vals)) if vals else 0.0 + return { + "transcript": transcript, + "pitch_mean_hz": float(np.mean(vals)) if vals else 0.0, + "pitch_variability": pitch_std, + "speaking_rate_wpm": (words / max(dur, 1)) * 60, + "pause_frequency": 0, + "voice_tremor": bool(pitch_std > 50), + } + except Exception: + return { + "transcript": transcript, "pitch_mean_hz": 0, "pitch_variability": 0, + "speaking_rate_wpm": 0, "pause_frequency": 0, "voice_tremor": False, + }