diff --git a/.easignore b/.easignore new file mode 100644 index 00000000..ea15bfd9 --- /dev/null +++ b/.easignore @@ -0,0 +1,73 @@ +# .easignore at repo root — used by EAS because this is a monorepo and +# the git root is the upload root. Must cover both the monorepo top-level +# and the app/ subdir (EAS ignores .gitignore when this file exists). + +# --- Sibling projects not needed for the Expo build --- +backends/ +extras/ +tests/ +Docs/ +sdk/ +untracked/ + +# --- Root-level junk / non-code artifacts --- +*.log +*.env* +asc-api-key.p8 +**/asc-api-key.p8 +*.m4a +*.wav +plan.md +sample-voice-response.json +deepgram_response.json +init-feedback +*.ipa +*.apk +*.aab +connection-logging-*.md +memory-service-settings.md +docs-consolidation-analysis.md +BLE_OPTIMIZATION.md +.github/ +.cursor/ +.claude/ + +# --- Inside app/ (the EAS project) --- +# Dependencies — reinstalled on EAS build server +app/node_modules/ +# Expo / Metro +app/.expo/ +app/dist/ +app/web-build/ +app/.metro-health-check* +# Native build outputs — regenerated on EAS +app/android/.gradle/ +app/android/build/ +app/android/app/build/ +app/android/app/.cxx/ +app/ios/build/ +app/ios/Pods/ +app/ios/DerivedData/ +app/ios/*.xcworkspace/xcuserdata/ +app/ios/*.xcodeproj/xcuserdata/ +app/ios/*.xcodeproj/project.xcworkspace/xcuserdata/ +# Local artifacts +app/*.ipa +app/*.apk +app/*.aab +app/build-*.ipa +# Credentials (EAS uses server-side credentials) +app/*.jks +app/*.p8 +app/*.p12 +app/*.key +app/*.mobileprovision +app/*.pem +# Logs +app/npm-debug.* +app/yarn-debug.* +app/yarn-error.* + +# --- OS / editor --- +.DS_Store +**/.DS_Store diff --git a/.env.template b/.env.template index 1a638bb5..7f256bf1 100644 --- a/.env.template +++ b/.env.template @@ -32,7 +32,6 @@ BACKEND_PORT=8000 WEBUI_PORT=5173 SPEAKER_PORT=8085 MONGODB_PORT=27017 -QDRANT_PORT=6333 NGROK_PORT=4040 # Kubernetes node ports (for LoadBalancer services) @@ -64,7 +63,7 @@ ADMIN_EMAIL=admin@example.com ADMIN_PASSWORD=secure-admin-password # CORS origins (auto-generated based on DOMAIN and ports) -CORS_ORIGINS=http://${DOMAIN}:${WEBUI_PORT},http://${DOMAIN}:3000,http://localhost:${WEBUI_PORT},http://localhost:3000 +CORS_ORIGINS=http://${DOMAIN}:${WEBUI_PORT},http://localhost:${WEBUI_PORT} # ======================================== # LLM CONFIGURATION @@ -107,28 +106,13 @@ PARAKEET_ASR_URL=http://host.docker.internal:8767 MONGODB_URI=mongodb://mongo:${MONGODB_PORT} MONGODB_K8S_URI=mongodb://mongodb.${INFRASTRUCTURE_NAMESPACE}.svc.cluster.local:27017/chronicle -# Qdrant configuration -QDRANT_BASE_URL=qdrant -QDRANT_K8S_URL=qdrant.${INFRASTRUCTURE_NAMESPACE}.svc.cluster.local - -# Neo4j configuration (optional) -NEO4J_HOST=neo4j-mem0 -NEO4J_USER=neo4j -NEO4J_PASSWORD=neo4j-password - # ======================================== # MEMORY PROVIDER CONFIGURATION # ======================================== -# Memory Provider: chronicle or openmemory_mcp +# Memory Provider: chronicle (agentic Markdown vault) — the only provider MEMORY_PROVIDER=chronicle -# OpenMemory MCP configuration (when MEMORY_PROVIDER=openmemory_mcp) -OPENMEMORY_MCP_URL=http://host.docker.internal:8765 -OPENMEMORY_CLIENT_NAME=chronicle -OPENMEMORY_USER_ID=openmemory -OPENMEMORY_TIMEOUT=30 - # ======================================== # SPEAKER RECOGNITION CONFIGURATION # ======================================== diff --git a/.github/workflows/advanced-docker-compose-build.yml b/.github/workflows/advanced-docker-compose-build.yml index 21ff96e4..2c3e5067 100644 --- a/.github/workflows/advanced-docker-compose-build.yml +++ b/.github/workflows/advanced-docker-compose-build.yml @@ -140,13 +140,14 @@ jobs: OWNER_LC=$(echo "$OWNER" | tr '[:upper:]' '[:lower:]') # CUDA variants from pyproject.toml - CUDA_VARIANTS=("cpu" "cu121" "cu126" "cu128") + CUDA_VARIANTS=("cpu" "cu126" "cu128") # Base services (no CUDA variants, no profiles) - # Image names must match what docker-compose.yml expects (chronicle-backend, chronicle-webui) + # Image names must match what docker-compose.yml expects (chronicle-backend). + # The webui is dev-server only (webui-dev, built locally from Dockerfile.dev), + # so there is no prebuilt webui image to distribute. base_service_specs=( "chronicle-backend|chronicle-backend|docker-compose.yml|." - "webui|chronicle-webui|docker-compose.yml|." ) # Build and push base services @@ -193,10 +194,10 @@ jobs: docker system prune -af || true done - # Build and push parakeet-asr with CUDA variants (cu121, cu126, cu128) + # Build and push parakeet-asr with CUDA variants (cu126, cu128) echo "::group::Building and pushing parakeet-asr CUDA variants" cd ../../extras/asr-services - for cuda_variant in cu121 cu126 cu128; do + for cuda_variant in cu126 cu128; do echo "Building parakeet-asr-${cuda_variant}" export PYTORCH_CUDA_VERSION="${cuda_variant}" docker compose build parakeet-asr @@ -289,12 +290,10 @@ jobs: ### Core Services \`\`\`bash docker pull ghcr.io/${OWNER_LC}/chronicle-backend:${VERSION} - docker pull ghcr.io/${OWNER_LC}/chronicle-webui:${VERSION} \`\`\` ### Parakeet ASR (pick your CUDA version) \`\`\`bash - docker pull ghcr.io/${OWNER_LC}/chronicle-asr-nemo-cu121:${VERSION} docker pull ghcr.io/${OWNER_LC}/chronicle-asr-nemo-cu126:${VERSION} docker pull ghcr.io/${OWNER_LC}/chronicle-asr-nemo-cu128:${VERSION} \`\`\` @@ -302,7 +301,6 @@ jobs: ### Speaker Recognition (pick your variant) \`\`\`bash docker pull ghcr.io/${OWNER_LC}/chronicle-speaker-cpu:${VERSION} - docker pull ghcr.io/${OWNER_LC}/chronicle-speaker-cu121:${VERSION} docker pull ghcr.io/${OWNER_LC}/chronicle-speaker-cu126:${VERSION} docker pull ghcr.io/${OWNER_LC}/chronicle-speaker-cu128:${VERSION} \`\`\` diff --git a/.github/workflows/full-tests-with-api.yml b/.github/workflows/full-tests-with-api.yml index b5881fcd..080c92f5 100644 --- a/.github/workflows/full-tests-with-api.yml +++ b/.github/workflows/full-tests-with-api.yml @@ -129,7 +129,6 @@ jobs: docker compose -f docker-compose-test.yml logs workers-test > logs/workers.log 2>&1 || true docker compose -f docker-compose-test.yml logs mongo-test > logs/mongo.log 2>&1 || true docker compose -f docker-compose-test.yml logs redis-test > logs/redis.log 2>&1 || true - docker compose -f docker-compose-test.yml logs qdrant-test > logs/qdrant.log 2>&1 || true docker compose -f docker-compose-test.yml logs speaker-service-test > logs/speaker.log 2>&1 || true echo "✓ Logs saved to backends/advanced/logs/" ls -lh logs/ diff --git a/.github/workflows/ios-testflight.yml b/.github/workflows/ios-testflight.yml new file mode 100644 index 00000000..8c468e35 --- /dev/null +++ b/.github/workflows/ios-testflight.yml @@ -0,0 +1,44 @@ +name: iOS TestFlight Deploy + +permissions: + contents: read + +on: + push: + branches: [main] + paths: ['app/**'] + workflow_dispatch: + +jobs: + build-and-submit: + runs-on: ubuntu-latest + defaults: + run: + working-directory: ./app + + steps: + - name: Setup repo + uses: actions/checkout@v4 + + - name: Setup node + uses: actions/setup-node@v4.0.2 + with: + node-version: 20.x + cache: 'npm' + cache-dependency-path: ./app/package-lock.json + + - name: Setup Expo + uses: expo/expo-github-action@v8 + with: + expo-version: latest + eas-version: latest + token: ${{ secrets.EXPO_TOKEN }} + + - name: Install dependencies + run: npm ci + + - name: Write ASC API Key + run: echo "${{ secrets.ASC_API_KEY_P8 }}" > asc-api-key.p8 + + - name: Build and submit to TestFlight + run: eas build --platform ios --profile testflight --auto-submit --non-interactive diff --git a/.github/workflows/pr-tests-with-api.yml b/.github/workflows/pr-tests-with-api.yml index aeb45b1c..e2df940c 100644 --- a/.github/workflows/pr-tests-with-api.yml +++ b/.github/workflows/pr-tests-with-api.yml @@ -123,7 +123,6 @@ jobs: docker compose -f docker-compose-test.yml logs workers-test > logs/workers.log 2>&1 || true docker compose -f docker-compose-test.yml logs mongo-test > logs/mongo.log 2>&1 || true docker compose -f docker-compose-test.yml logs redis-test > logs/redis.log 2>&1 || true - docker compose -f docker-compose-test.yml logs qdrant-test > logs/qdrant.log 2>&1 || true docker compose -f docker-compose-test.yml logs speaker-service-test > logs/speaker.log 2>&1 || true echo "✓ Logs saved to backends/advanced/logs/" ls -lh logs/ diff --git a/.github/workflows/robot-tests.yml b/.github/workflows/robot-tests.yml index 603025b8..71554a79 100644 --- a/.github/workflows/robot-tests.yml +++ b/.github/workflows/robot-tests.yml @@ -96,7 +96,6 @@ jobs: docker compose -f docker-compose-test.yml logs workers-test > logs/workers.log 2>&1 || true docker compose -f docker-compose-test.yml logs mongo-test > logs/mongo.log 2>&1 || true docker compose -f docker-compose-test.yml logs redis-test > logs/redis.log 2>&1 || true - docker compose -f docker-compose-test.yml logs qdrant-test > logs/qdrant.log 2>&1 || true docker compose -f docker-compose-test.yml logs speaker-service-test > logs/speaker.log 2>&1 || true echo "✓ Logs saved to backends/advanced/logs/" ls -lh logs/ diff --git a/.gitignore b/.gitignore index 61ea187c..66c21082 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,8 @@ **/__pycache__ *.wav +# Wake-word notification tones are bundled assets, not user audio — keep them tracked +# so they're baked into the wakeword-service image (and served to all clients). +!extras/wakeword-service/tones/*.wav **/*.env !**/.env.template **/memory_config.yaml @@ -15,6 +18,10 @@ config/config.yml config/plugins.yml !config/plugins.yml.template +# Advertised services — per-machine runtime state, regenerated by the node agent on +# every start (services.py / edge/service_manager.py). No defaults, so no template. +config/advertised-services.json + # Individual plugin configs (may contain user-specific settings) backends/advanced/src/advanced_omi_backend/plugins/*/config.yml !backends/advanced/src/advanced_omi_backend/plugins/*/config.yml.template @@ -27,7 +34,6 @@ plugins/*/config.yml.backup example/* **/node_modules/* **/ollama-data/* -**/qdrant_data/* **/model_cache/* .vscode/* **/audio_chunks/* @@ -54,6 +60,7 @@ untracked/* backends/advanced/data/* backends/advanced/diarization_config.json extras/local-wearable-client/devices.yml +extras/llm-services/cache/** !extras/local-wearable-client/devices.yml.template extras/havpe-relay/firmware/secrets.yaml extras/test-audios/* @@ -65,6 +72,9 @@ extras/test-audios/* extras/speaker-omni-experimental/data/* extras/speaker-omni-experimental/cache/* +# Discovery agent PID file +edge/.discovery-agent.pid + # AI Stuff .claude @@ -81,6 +91,10 @@ backends/advanced/ssl/* extras/speaker-recognition/nginx.conf extras/speaker-recognition/Caddyfile +# Generated compose overrides (e.g. Tailscale socket mount for Caddy-managed certs) +backends/advanced/docker-compose.override.yml +extras/speaker-recognition/docker-compose.override.yml + # Cache extras/speaker-recognition/cache/* extras/speaker-recognition/outputs/* @@ -88,7 +102,7 @@ extras/speaker-recognition/outputs/* # my backup backends/advanced/src/_webui_original/* -backends/advanced-backend/data/neo4j_data/* +backends/advanced-backend/data/falkordb_data/* backends/advanced-backend/data/speaker_model_cache/ *.bin diff --git a/CLAUDE.md b/CLAUDE.md index f12a3f1d..7e690c01 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -20,7 +20,7 @@ Chronicle includes an **interactive setup wizard** for easy configuration. The w - Authentication setup (admin account, JWT secrets) - Transcription provider configuration (Deepgram or offline ASR) - LLM provider setup (OpenAI or Ollama) -- Memory provider selection (Chronicle Native with Qdrant or OpenMemory MCP) +- Memory configuration (agentic Markdown vault — Chronicle's single memory provider) - Network configuration and HTTPS setup - Optional services (speaker recognition, Parakeet ASR) @@ -47,7 +47,7 @@ The initialization system uses a **root orchestrator pattern**: - **`wizard.py`**: Root setup orchestrator for service selection and delegation - **`backends/advanced/init.py`**: Backend configuration wizard - **`extras/speaker-recognition/init.py`**: Speaker recognition setup -- **Service setup scripts**: Individual setup for ASR services and OpenMemory MCP +- **Service setup scripts**: Individual setup for ASR services Key features: - Interactive prompts with validation @@ -129,7 +129,7 @@ make logs SERVICE= # View specific service logs #### Test Environment Test services use isolated ports and database: -- **Ports:** Backend (8001), MongoDB (27018), Redis (6380), Qdrant (6337/6338) +- **Ports:** Backend (8001), MongoDB (27018), Redis (6380) - **Database:** `test_db` (separate from production) - **Credentials:** `test-admin@example.com` / `test-admin-password-123` @@ -162,6 +162,13 @@ docker compose up --build # HAVPE Relay (ESP32 bridge) cd extras/havpe-relay docker compose up --build + +# TTS Services (text-to-speech, run ONE provider at a time on port 8770) +cd extras/tts +docker compose up tada-tts -d --build # HumeAI TADA (GPU, voice cloning) +docker compose up fish-tts -d --build # Fish Speech (GPU, 50+ langs, emotion tags) +docker compose up kittentts-tts -d --build # KittenTTS (~25MB CPU ONNX, no GPU) +docker compose up kokoro-tts -d --build # Kokoro-82M (<~1GB VRAM GPU/CPU, preset voices) ``` ## Architecture Overview @@ -173,10 +180,10 @@ docker compose up --build - **Job Tracker**: Tracks pipeline jobs with stage events (audio → transcription → memory) and completion status - **Task Management**: BackgroundTaskManager tracks all async tasks to prevent orphaned processes - **Unified Transcription**: Deepgram transcription with fallback to offline ASR services -- **Memory System**: Pluggable providers (Chronicle native or OpenMemory MCP) +- **Memory System**: Single agentic Markdown vault — a tool-calling memory agent records conversations and surgically edits Obsidian-style People/Topic/Category notes; a read-only retrieval agent drives ripgrep over the vault to answer queries - **Authentication**: Email-based login with MongoDB ObjectId user system - **Client Management**: Auto-generated client IDs as `{user_id_suffix}-{device_name}`, centralized ClientManager -- **Data Storage**: MongoDB (`audio_chunks` collection for conversations), vector storage (Qdrant or OpenMemory) +- **Data Storage**: MongoDB (conversations, `audio_chunks`, chat, annotations), disk WAV files, and the Markdown vault (`data/conversation_docs//`) as the memory source of truth - **Web Interface**: React-based web dashboard with authentication and real-time monitoring ### Service Dependencies @@ -184,9 +191,8 @@ docker compose up --build Required: - MongoDB: User data and conversations - Redis: Job queues (RQ workers) and session state - - Qdrant: Vector storage for memory search - FastAPI Backend: Core audio processing - - LLM Service: Memory extraction and action items (OpenAI or Ollama) + - LLM Service: Memory agent (vault read/write) and action items (OpenAI or Ollama) Recommended: - Transcription: Deepgram or offline ASR services @@ -195,7 +201,6 @@ Optional: - Parakeet ASR: Offline transcription service - Speaker Recognition: Voice identification service - Caddy: HTTPS reverse proxy (auto-configured when HTTPS enabled) - - OpenMemory MCP: For cross-client memory compatibility ``` ## Data Flow Architecture @@ -206,8 +211,8 @@ Optional: 4. **Speech-Driven Conversation Creation**: User-facing conversations only created when speech is detected 5. **Dual Storage System**: Audio sessions always stored in `audio_chunks`, conversations created in `conversations` collection only with speech 6. **Versioned Processing**: Transcript and memory versions tracked with active version pointers -7. **Memory Processing**: Pluggable providers (Chronicle native with individual facts or OpenMemory MCP delegation) -8. **Memory Storage**: Direct Qdrant (Chronicle) or OpenMemory server (MCP provider) +7. **Memory Processing**: A tool-calling memory agent records each conversation and surgically edits People/Topic/Category notes in the Markdown vault +8. **Memory Storage**: Obsidian-style Markdown vault at `data/conversation_docs//` — the single source of truth, searched by a read-only retrieval agent via ripgrep 9. **Audio Optimization**: Speech segment extraction removes silence automatically 10. **Task Tracking**: BackgroundTaskManager ensures proper cleanup of all async operations @@ -256,51 +261,31 @@ DEEPGRAM_API_KEY=your-deepgram-key-here # Optional: TRANSCRIPTION_PROVIDER=deepgram # Memory Provider -MEMORY_PROVIDER=chronicle # or openmemory_mcp +MEMORY_PROVIDER=chronicle # agentic Markdown vault (only valid value) # Database MONGODB_URI=mongodb://mongo:27017 # Database name: chronicle -QDRANT_BASE_URL=qdrant # Network Configuration HOST_IP=localhost BACKEND_PUBLIC_PORT=8000 -WEBUI_PORT=3010 # Production port (5173 is Vite dev server only) -CORS_ORIGINS=http://localhost:3010,http://localhost:8000 +WEBUI_PORT=5173 # Vite dev server (the only webui; fronted by Caddy for HTTPS) +CORS_ORIGINS=http://localhost:5173,http://localhost:8000 ``` ### Memory Provider Configuration -Chronicle supports two pluggable memory backends: +Chronicle has a single memory provider, `chronicle`: an **agentic Markdown vault**. The vault (Obsidian-style notes at `data/conversation_docs//`) is the single source of truth. A tool-calling memory agent records each conversation and surgically edits People/Topic/Category notes; a read-only retrieval agent drives ripgrep over the vault to synthesize answers. There is no provider choice to configure — only an LLM for the agents to use. -#### Chronicle Memory Provider (Default) ```bash -# Use Chronicle memory provider (default) +# Memory provider (only valid value) MEMORY_PROVIDER=chronicle -# LLM Configuration for memory extraction +# LLM Configuration for the memory agent LLM_PROVIDER=openai OPENAI_API_KEY=your-openai-key-here OPENAI_MODEL=gpt-4o-mini - -# Vector Storage -QDRANT_BASE_URL=qdrant -``` - -#### OpenMemory MCP Provider -```bash -# Use OpenMemory MCP provider -MEMORY_PROVIDER=openmemory_mcp - -# OpenMemory MCP Server Configuration -OPENMEMORY_MCP_URL=http://host.docker.internal:8765 -OPENMEMORY_CLIENT_NAME=chronicle -OPENMEMORY_USER_ID=openmemory -OPENMEMORY_TIMEOUT=30 - -# OpenAI key for OpenMemory server -OPENAI_API_KEY=your-openai-key-here ``` ### Transcription Provider Configuration @@ -357,7 +342,7 @@ SPEAKER_SERVICE_URL=http://speaker-recognition:8085 - **GET /readiness**: Service dependency validation - **WS /ws**: Audio streaming endpoint with codec parameter (Wyoming protocol, supports pcm and opus codecs) - **GET /api/conversations**: User's conversations with transcripts -- **GET /api/memories/search**: Semantic memory search with relevance scoring +- **GET /api/memories/search**: Agentic vault search (retrieval agent over the Markdown vault) - **POST /auth/jwt/login**: Email-based login (returns JWT token) ### Authentication Flow @@ -458,6 +443,51 @@ The relay will automatically: - Forward ESP32 audio to the backend with proper authentication - Handle token refresh and reconnection +## TTS Services + +Provider-based text-to-speech (`extras/tts/`), built on the same provider pattern as `extras/asr-services/`. Run **one provider at a time**, all serving on port `8770` (configurable via `TTS_PORT`). + +### Providers + +| Provider | Service | Hardware | Highlights | +|----------|---------|----------|-----------| +| **TADA** (HumeAI) | `tada-tts` | GPU | Zero-shot voice cloning, 1:1 token alignment (no hallucinations), MIT. `tada-1b` (English) / `tada-3b-ml` (9 langs). Needs `HF_TOKEN` (Llama 3.2 base is gated). | +| **Fish Speech** (Fish Audio) | `fish-tts` | GPU | Dual-AR, 50+ langs, inline emotion/prosody tags (`[laugh]`, `[whispers]`), streaming. `s2-pro` (default) / `openaudio-s1-mini` / `fish-speech-1.5`. Optional `torch.compile`. | +| **KittenTTS** (KittenML) | `kittentts-tts` | CPU | Ultra-light (~25MB) ONNX, no GPU/API key, preset voices, English only. Uses dedicated `KITTEN_TTS_*` env vars. | +| **Kokoro** (hexgrad) | `kokoro-tts` | GPU/CPU | Lightweight (~82M, **<~1GB VRAM**) StyleTTS2, preset voices, 8 langs, Apache-2.0. Quality-per-VRAM sweet spot. Uses dedicated `KOKORO_TTS_*` env vars. | + +### Setup & Run + +```bash +cd extras/tts + +# Configure (selects provider, model, CUDA version) +uv run --with-requirements ../../setup-requirements.txt python init.py + +# Start ONE provider +docker compose up tada-tts -d --build # or fish-tts / kittentts-tts + +# Test +curl http://localhost:8770/health +curl -X POST http://localhost:8770/synthesize -F "text=Hello world." -o output.wav +``` + +### API Endpoints + +| Endpoint | Method | Description | +|----------|--------|-------------| +| `/health` | GET | Service health (`healthy` / `initializing`) | +| `/info` | GET | Model id, provider, capabilities, supported languages | +| `/synthesize` | POST | Generate speech (multipart form) | + +**POST /synthesize** — `text` (required); optional `reference_audio` (WAV) + `reference_text` for voice cloning; optional generation params (`temperature`, `top_p`, `repetition_penalty`, `seed`, `max_new_tokens`). Returns WAV bytes with `X-Sample-Rate`, `X-Provider`, `X-Model` headers. + +**Notes:** +- Not registered in `services.py` — manage with `docker compose` directly (like the HAVPE relay). +- GPU providers require CUDA 12.6+ (`PYTORCH_CUDA_VERSION=cu126`/`cu128`); `cu121` is unsupported (torch>=2.7). +- Add a provider by creating `extras/tts/providers/{name}/` with `service.py`, `synthesizer.py`, and `Dockerfile` (subclass `BaseTTSService`). +- An optional `edge-agent` sidecar (`--profile edge`) advertises the service on the Tailnet. + ## Distributed Deployment ### Single Machine vs Distributed Setup @@ -515,14 +545,14 @@ tailscale ip -4 **Service Examples:** - GPU machine: LLM inference, ASR, speaker recognition - Backend machine: FastAPI, WebUI, databases -- Database machine: MongoDB, Qdrant (optional separation) +- Database machine: MongoDB (optional separation) ## Development Notes ### Package Management - **Backend**: Uses `uv` for Python dependency management (faster than pip) - **Mobile**: Uses `npm` with React Native and Expo -- **Docker**: Primary deployment method with docker-compose +- **Containers**: The service lifecycle (`services.py`/`status.py`/service-manager) supports **both Docker and Podman**, selected via `container_engine: docker|podman` in `config/config.yml` (or the `CONTAINER_ENGINE`/`COMPOSE_CMD` env vars). The repo's compose files run unmodified under either engine. For Podman it drives `podman-compose` and needs CDI for GPU. See **[@Docs/podman.md](Docs/podman.md)** for rootless/GPU setup and migration notes. ### Testing Strategy - **Makefile-Based**: All test operations through simple `make` commands (`make test`, `make start`, `make stop`) @@ -571,10 +601,12 @@ For detailed technical documentation, see: - **[@Docs/overview.md](Docs/overview.md)**: Architecture overview and technical deep dive - **[@Docs/init-system.md](Docs/init-system.md)**: Initialization system and service management - **[@Docs/ssl-certificates.md](Docs/ssl-certificates.md)**: HTTPS/SSL setup details +- **[@Docs/podman.md](Docs/podman.md)**: Running with Podman instead of Docker (engine selection, rootless/GPU setup) - **[@Docs/audio-pipeline-architecture.md](Docs/audio-pipeline-architecture.md)**: Audio pipeline design - **[@backends/advanced/Docs/auth.md](backends/advanced/Docs/auth.md)**: Authentication architecture - **[backends/advanced/Docs/architecture.md](backends/advanced/Docs/architecture.md)**: Backend architecture details - **[backends/advanced/Docs/memories.md](backends/advanced/Docs/memories.md)**: Memory system documentation +- **[backends/advanced/Docs/data-audit.md](backends/advanced/Docs/data-audit.md)**: Data Audit (VAD analysis, split/merge, audio preview) - **[backends/advanced/Docs/plugin-development-guide.md](backends/advanced/Docs/plugin-development-guide.md)**: Plugin development guide ## Robot Framework Testing @@ -608,9 +640,12 @@ Check backends/advanced/Docs for up to date information on advanced backend. All docker projects have .dockerignore following the exclude pattern. That means files need to be included for them to be visible to docker. The uv package manager is used for all python projects. Wherever you'd call `python3 main.py` you'd call `uv run python main.py` +**Container Engine (Docker or Podman):** +- The project supports **both Docker and Podman**. The active engine is set by `container_engine` in `config/config.yml` (default `docker`); prefer the lifecycle scripts (`./start.sh`/`./stop.sh`/`./restart.sh`) which route through the selected engine. For one-off manual commands under Podman use `podman-compose` (not `docker compose`). See **[@Docs/podman.md](Docs/podman.md)**. + **Docker Build Guidelines:** -- Use `docker compose build` without `--no-cache` by default for faster builds +- Use `docker compose build` (or `podman-compose build`) without `--no-cache` by default for faster builds - Only use `--no-cache` when explicitly needed (e.g., if cached layers are causing issues or when troubleshooting build problems) -- Docker's build cache is efficient and saves significant time during development +- The build cache is efficient and saves significant time during development - Remember that whenever there's a python command, you should use uv run python3 instead diff --git a/CONNECTION_RESILIENCE_AND_UX_PLAN.md b/CONNECTION_RESILIENCE_AND_UX_PLAN.md new file mode 100644 index 00000000..0731b748 --- /dev/null +++ b/CONNECTION_RESILIENCE_AND_UX_PLAN.md @@ -0,0 +1,294 @@ +# Connection Resilience & UX — Unified Plan + +> **Implementation status (branch `fix/optimizations-2`): IMPLEMENTED.** +> Done: Phase 0 (backend pong), Track 1 (1A BLE device-forget, 1B central auth +> manager + SecureStore + logout/forget split, 1C persistent WS retry, 1D zombie +> ping/pong, 1E foreground/relaunch reconnect, 1F Connection Doctor probe ladder +> + Tailscale-aware messaging), Track 2 (QR JSON bundle parse, SM URL/token +> storage, `serviceManager.ts` proxy+direct client, `NetworkOverview` screen with +> start/stop/restart, webui QR bundle), Track 3 (wearable + HAVPE relay reconnect). +> App `tsc --noEmit` clean; all edited Python `py_compile`s. +> **Two deliberate deferrals:** (a) 1F "Settings home" *relocation* of auth/URL into +> a separate route + compact chip — pure navigation reorg, the diagnosis/QR value +> shipped in-place; (b) Track 2 SM **token** in the QR — the SM token is a +> server-side secret not exposed to the browser; minting it into a QR is a security +> decision left to the user. The QR carries `backendUrl` + `serviceManagerUrl`; the +> backend-proxy transport works token-free whenever the backend is up. + +This is the **authoritative, consolidated** plan. It merges two prior efforts: + +- `RECONNECT_FIXES_PLAN.md` — the **mechanical reconnection** layer (8 gaps: never + permanently give up, never destroy saved state on one failure, detect zombie sockets, + recover on foreground/relaunch). Spans mobile app + wearable client + HAVPE relay. +- `MOBILE_APP_CONNECTION_UX_PLAN.md` — the **auth + diagnosis + network-control** layer + (silent token refresh so "logged in means logged in", honest actionable failures / + Connection Doctor, QR-first config, service-manager control). App-only (+ one webui change). + +Both originals are now superseded by this file and may be deleted once this is approved. + +--- + +## Why a merge (the collision) + +The two plans operate at different layers but **rewrite the same app code** — the +token-refresh / reconnect path in `app/src/hooks/useAudioStreamer.ts` (`attemptReLogin`, +`attemptReconnect`, `onclose`, NetInfo). + +- The UX plan's **Phase 1 central auth manager** is the natural *home* for re-login. +- The reconnect plan's **A2/A3** call re-login *during* a reconnect. + +If we land the mechanical reconnect first against today's inline `attemptReLogin`, Phase 1 +then tears it out — pure churn. So the merged order is: + +> **Build the central auth manager first**, then layer WS-reconnect on top of it. Anything +> that does *not* touch auth — BLE device-forget (A1), the backend `pong` reply, and the +> entire device-client track (B/C) — is independent and can proceed in parallel. + +Guiding principles (carried from both plans): +- **Persistent retry** — never permanently give up; never delete saved state on a single + failure; always keep a recovery path alive. +- **Set-once, then invisible** — configure backend URL + creds once (ideally by QR); if the + app says you're connected, actions work; failures are honest and actionable. + +--- + +## Tracks (what can run in parallel) + +| Track | Content | Depends on | Parallelizable? | +|-------|---------|-----------|-----------------| +| **0 · Backend pong** | `websocket_controller.py` reply `{type:'pong'}` (2 sites) | nothing | ✅ anytime | +| **1 · App auth core** | Auth manager → mechanical reconnect (A1–A4) → Connection Doctor | Track 0 for A2 | ⛓ internally ordered | +| **2 · App network control** | QR bundle → SM client → Network Overview → remote start | Track 1 auth manager | after Track 1 | +| **3 · Device clients** | Wearable (B1/B2/B3) + HAVPE relay (C1/C2) reconnect | Track 0 (uses pong) optional | ✅ fully independent of app | + +Track 3 has **no app dependency** and can be implemented start-to-finish alongside Track 1. + +--- + +## Phase 0 — Backend `pong` reply *(standalone, unblocks A2)* + +**File:** `backends/advanced/src/advanced_omi_backend/controllers/websocket_controller.py` +(`:1719-1721` and the second ping site `:1772`) + +Today the backend receives app-level `{type:'ping'}` and just logs it — no reply, so the +client can't tell a half-open socket from a healthy idle one. + +**Fix:** at *both* ping sites, reply `{type:'pong'}` over the same socket (mirror how other +control replies are sent). No client behavior depends on it until A2 ships, so this is safe +to land immediately. + +**Verify:** `cd tests && make test-quick` (WS control-message regression) + a manual +`wscat` ping to `/ws` confirming the `{type:"pong"}` reply. + +--- + +## Track 1 — App connection core (ordered) + +### 1A. BLE device-forget on a single launch-reconnect failure *(independent — can land first)* +**File:** `app/src/hooks/useAutoReconnect.ts:183-198` +**Problem:** the launch auto-reconnect `catch` (`:189-192`) calls +`saveLastConnectedDeviceId(null)` + `setLastKnownDeviceId(null)` — one failed attempt +(device briefly out of range at startup) permanently forgets the device. The continuous +backoff retry loop (`:90-168`) only fires on a `connected→disconnected` transition, so it +never covers a launch attempt that never connected. + +**Fix:** +- Extract the disconnect-retry machinery (`:103-167`) into a + `scheduleRetry(deviceId, isQuickFailure)` callback (backoff refs, countdown timer, + `connectToDevice` call). +- In the launch `catch`, **stop wiping the device**; call `scheduleRetry(lastKnownDeviceId, true)`. + The device id survives; retries continue with capped backoff (10s→5min). +- `handleCancelAutoReconnect` (`:207-216`) and the explicit Disconnect button + (`index.tsx:373-377,391-396`) remain the *only* paths that clear the saved device. + +> Pure BLE, no auth dependency. This is the highest-value mechanical fix and can ship before +> the auth manager. (This was the in-flight edit; re-do it cleanly here.) + +### 1B. Central auth / connection manager *(foundation for everything below)* +Fixes UX issues A1–A3 (creds disappear on logout, forced relogin while "logged in", +plaintext password). + +- **New** `app/src/contexts/AuthContext.tsx` (or `services/auth.ts`): single source of truth + for `{ backendUrl, email, token, status }`. +- **Silent refresh everywhere:** lift `useAudioStreamer.ts::attemptReLogin` into the manager + as a `fetchAuthed()` wrapper that retries once on 401/expiry using stored creds, then + surfaces a real failure only if re-login fails. *All* calls (health, future overview) go + through it. JWTs are 1h, so this is what makes "logged in" actually mean logged in. +- **Honest status:** "Logged in as X" reflects token validity / refreshing, not mere presence. +- **Split logout from forget:** `clearAuthData()` → + - **Log out** = clear token only; keep email (+ optionally password) → re-login is one tap. + - **Forget account** = clear everything (today's behavior, made explicit). +- **SecureStore:** move password (+ token) to `expo-secure-store`; keep email in AsyncStorage. + +**Touchpoints:** `app/src/utils/storage.ts` (SecureStore + split clear fns), +`app/src/components/AuthSection.tsx` (consume manager; logout vs forget), +`app/src/hooks/useAudioStreamer.ts` (relocate `attemptReLogin`). + +### 1C. Persistent WS retry — remove permanent give-up *(A3; consumes 1B)* +**File:** `app/src/hooks/useAudioStreamer.ts:255-262` +**Problem:** after 10 attempts it sets `manuallyStoppedRef = true`, which also disables the +NetInfo recovery path (`:455`) and the AppState path (1E). It should keep trying. + +**Fix:** +- Remove `manuallyStoppedRef.current = true` on exhaustion. Keep backoff **capped** at + `MAX_RECONNECT_MS` (30s); attempts continue indefinitely while a session is intended. +- After N attempts, downgrade to a low-frequency keep-trying state and post + "Connection lost — still retrying" **once** (guard with a ref) instead of giving up. +- Re-login on reconnect now goes through the **1B auth manager**, not inline. +- `manuallyStoppedRef` is set only by `stopStreaming()` (`:219`) — genuine user stop. + +### 1D. Zombie WebSocket detection *(A2; client side; backend done in Phase 0)* +**File:** `app/src/hooks/useAudioStreamer.ts:326-334` (+ `onmessage` `:348-370`) +**Problem:** the 25s heartbeat is fire-and-forget; a silently dead TCP keeps +`readyState === OPEN` so nothing reconnects until NetInfo happens to fire. + +**Fix (client):** add `lastPongRef`; stamp it on `{type:'pong'}` in `ws.onmessage`; in the +heartbeat interval, if `now - lastPongRef > 2×HEARTBEAT_MS`, `ws.close()` (→ `onclose` → +existing `attemptReconnect`). Reset `lastPongRef` on `onopen`. (Backend reply = Phase 0.) + +### 1E. App-lifecycle (foreground / relaunch) reconnect *(A4)* +**Files:** `app/src/hooks/useAudioStreamer.ts` (WS) + `app/src/hooks/useAutoReconnect.ts` (BLE) +**Problem:** no `AppState` listener anywhere. On iOS the JS runtime suspends in background; +on resume nothing re-establishes the socket. (True iOS *background streaming* needs +background modes and is **out of scope** — we handle *resume on foreground* + warm relaunch.) + +**Fix:** +- **WS:** `AppState` listener mirroring the NetInfo effect (`:452-465`): on `'active'`, if + `currentUrlRef` set, not manually stopped, and `readyState !== OPEN` → `attemptReconnect()`. +- **BLE:** `AppState` listener: on `'active'`, if a `lastKnownDeviceId` exists and isn't + connected, reset `triedAutoReconnectForCurrentId=false` so the launch-reconnect effect + (`:171-205`) re-fires. +- `onDeviceConnect`'s existing audio-pipeline auto-restart (`index.tsx:87-97`) means BLE + recovery transitively resumes streaming. + +### 1F. Connection Doctor + Settings home *(UX C1, C2, A4-UX; consumes 1B)* +**File:** `app/src/components/BackendStatus.tsx` (today: single `fetch(${baseUrl}/health)`) +**Problem:** any failure collapses to a bare "Network request failed" — no diagnosis, no +recovery path (this is the originating incident: backend reachable by browser/curl, app +just says "network failed"). + +**Fix:** +- Replace the single `/health` fetch with a **probe ladder** → classified, actionable + messages: *offline* → "You're offline"; *tailnet unreachable* → **"Can't reach your + tailnet — is Tailscale connected?"** [Open Tailscale] [Scan QR]; *backend down* → + **"Backend is down"** [Start backend] (Track 2); *ok* → connected. +- **QR scan reachable in every state** (un-gate it). Manual URL typing = last resort. +- Move backend URL + auth into a dedicated **Settings / Connection** screen; collapse the + main surface to a compact status chip when healthy. Set-once, not constantly present. +- (Optional) weak native VPN-interface hint (`utun*`/`tun*`) as a secondary signal — "a VPN + is up", not "Tailscale specifically" (no official API exists; documented limitation). + +--- + +## Track 2 — App network control (after Track 1 auth manager) + +### 2A. Richer QR bundle *(UX Phase 3; web + app)* +- `backends/advanced/webui/src/pages/System.tsx`: QR payload becomes JSON + `{ backendUrl, serviceManagerUrl, smToken }` (SM token from the System page's existing SM + config). Keep the copyable plain URL for fallback. +- `app/src/components/QRScanner.tsx` + `app/src/utils/urlConversion.ts`: parse **both** a + plain URL (legacy) and the JSON bundle; persist SM URL + token (token in SecureStore). + +### 2B. Service-manager client + Network Overview *(UX Phase 4, fixes C3)* +**New** `app/src/services/serviceManager.ts` with **two auto-selected transports**: +- **via backend proxy** `/api/admin/services` (existing JWT) when backend is up — no SM token; +- **direct to SM** (`/node`, `/cluster`, `/services`) with stored token when backend is down; +- SM URL auto-derived from backend host `:8775`, confirmed via the **unauthed** `/health`. + +**Network Overview screen:** status table of nodes/services (backend, ASR, speaker, +wakeword, …) from `/cluster` + `/services`, each with running/health. + +> Architectural note: the SM solves *"backend down"*, **not** *"Tailscale down"* (reaching +> the SM still needs the tailnet — that case is handled by 1F's guidance). + +### 2C. Remote start / restart *(UX Phase 5, fixes C4)* +In the overview, a down backend shows **Start** → direct `POST /services/backend/start` to +the SM (the only path that works when the backend is down) → poll `GET /operations/{id}` → +reflect progress. Same pattern restarts any service. + +--- + +## Track 3 — Device clients (independent of the app) + +### 3A. Wearable client — backend-WS reconnect + mid-session JWT refresh *(B1/B2)* +**Files:** `extras/local-wearable-client/backend_sender.py:212-296`, `main.py:245-256` +**Problem:** `stream_to_backend` opens the WS once; on WS error the wrapper just logs +(`main.py:255-256`) and the backend stays dead until the whole BLE session cycles. No +mid-session token refresh. + +**Fix:** +- Wrap the `websockets.connect` block (`backend_sender.py:234-296`) in a capped + exponential-backoff reconnect loop. On a WS drop while the audio generator is still + producing, re-fetch the JWT (`get_jwt_token`, `:217` — this **is** the mid-session refresh, + solving B2) and re-dial **without ending the BLE session**. +- Drain-vs-reconnect: the queue-backed generator (`main.py:246-251`) yields `None` only at + true session end → treat `None` as "stop", WS exceptions as "reconnect". Drop audio during + the gap (matches current outage behavior; document the choice). +- Reset backoff after a healthy connection duration (mirror app's `MIN_HEALTHY_DURATION`). + +### 3B. (optional) headless `run()` BLE backoff parity *(B3, low priority)* +**File:** `extras/local-wearable-client/main.py:584-621` — headless path rescans at a fixed +`scan_interval` with no backoff. The launchd default is the menu app (`menu_app.py:468-488`, +already has backoff), so add the same exponential backoff for parity only if cheap. + +### 3C. HAVPE relay — backend-WS reconnect *(C1)* +**File:** `extras/havpe-relay/relay_core.py:300-376` (`run_device_session`) +**Problem:** `websockets.connect` (`:301`) is opened once; any WS close tears down the +session (`:364-376`) and waits passively for the ESP32 to re-dial. +**Fix:** wrap WS-connect + task group (`:301-360`) in an inner capped-backoff retry loop, +keeping the device `reader`/`writer` alive across WS reconnects. Re-fetch the token +(`get_jwt_token`, `:282`) on each re-dial. Break only on device disconnect +(`IncompleteReadError`/reader EOF) or idle timeout — not on a WS blip. + +### 3D. HAVPE relay — ESPHome API reconnect within a session *(C2, lower priority)* +**Files:** `extras/havpe-relay/relay_core.py:304-321`, `device_controller.py:33-44` +**Problem:** one API connect timeout → audio-only for the entire session; no reconnect. +**Fix:** use `aioesphomeapi`'s `ReconnectLogic` (or a lightweight periodic re-`connect()`) +so button/dial/LED/speaker recover mid-session. + +> Note: device→relay audio TCP is server-side (relay can't initiate); the firmware re-dials +> and the idle-timeout (`relay_core.py:45`) reaps zombies. No change needed there. + +--- + +## Suggested implementation order + +1. **Phase 0** backend `pong` — tiny, standalone, unblocks 1D. +2. **1A** BLE device-forget — highest mechanical value, no auth dependency. +3. **1B** central auth manager — foundation; removes daily auth pain (A1–A3 UX). +4. **1C → 1D → 1E** mechanical WS reconnect on top of the auth manager. +5. **1F** Connection Doctor + Settings home — fixes the originating "useless message" incident. +6. **2A → 2B → 2C** network-control story, each building on the last. +- **Track 3 (3A, 3C, then 3D/3B)** in parallel throughout — no app dependency. + +Each item is independently shippable. + +--- + +## Verification + +**App mechanical (1A,1C,1D,1E):** dev client on a physical phone (BLE needs hardware): +- 1A: connect device, force-quit, walk out of range, relaunch in range → reconnects and is + **not** forgotten if the first attempt fails (`lastKnownDeviceId` persists). +- 1C: backend down for >10 reconnect attempts, then back → client resumes without a manual + restart (previously gave up permanently). +- 1D: start streaming, kill the backend's TCP path abruptly (drop wifi / `kill -STOP`) so no + close frame → within ~50s client detects missed pongs, closes, reconnects on return. +- 1E: background the app (iOS), foreground → WS re-establishes; toggle BLE off/on while + backgrounded then foreground → device reconnects. + +**App auth/UX (1B,1F):** log out → email retained, one-tap re-login; let a token expire (1h +or shortened TTL) and hit a non-streaming action → silent refresh, no forced relogin; point +the app at an unreachable tailnet → Connection Doctor says *tailnet unreachable* with +actions, not "Network request failed". + +**Network control (2A–2C):** scan the JSON QR → backend URL + SM URL + token persisted; take +the backend down → Overview shows it down with **Start** → SM starts it → status flips healthy. + +**Device clients (3A,3C):** `cd extras/local-wearable-client && uv run python main.py run` +against a local backend; mid-stream `./restart.sh` → client re-dials and resumes without +dropping BLE; run >1h (or shorten token TTL) to confirm mid-session re-auth. HAVPE: ESP32 +dialed in, restart backend mid-session → relay re-dials, ESP32 stays connected; kill/restore +the ESPHome API → button/LED recover (3D). + +**Regression:** `cd tests && make test-quick` after the Phase 0 `pong` change. diff --git a/Docs/audio-pipeline-architecture.md b/Docs/audio-pipeline-architecture.md index bc12ad82..2f32be28 100644 --- a/Docs/audio-pipeline-architecture.md +++ b/Docs/audio-pipeline-architecture.md @@ -70,7 +70,8 @@ Chronicle's audio pipeline is built on: ├───────────────────────┤ │ MongoDB: conversations│ │ Disk: WAV files │ - │ Qdrant: Memories │ + │ FalkorDB: Memories + │ + │ Knowledge Graph │ └───────────────────────┘ ``` @@ -213,7 +214,7 @@ Polls `TranscriptionResultsAggregator` at 1s intervals. Speech criteria: word co |-----|-------------|------------| | `transcribe_full_audio_job` | Batch transcribes full audio (file uploads only). Dispatches `transcript.batch` plugin event. | **Raises** → blocks entire chain | | `recognize_speakers_job` | Sends audio + segments to speaker service, updates speaker labels | Returns dict → chain continues | -| `memory_extraction_job` | LLM extracts facts, stores in Qdrant/OpenMemory. Dispatches `memory.processed` plugin event | Returns dict → chain continues | +| `memory_extraction_job` | LLM extracts facts, stores in FalkorDB (Chronicle native) or OpenMemory. Dispatches `memory.processed` plugin event | Returns dict → chain continues | | `generate_title_summary_job` | LLM generates title/summary, updates MongoDB | Returns dict → chain continues | | `dispatch_conversation_complete_event_job` | Dispatches `conversation.complete` plugin event | Returns dict | @@ -257,10 +258,10 @@ Location: `backends/advanced/data/chunks/` (volume-mounted) Format: `{timestamp_ms}_{client_id}_{conversation_id}.wav` Created by `audio_streaming_persistence_job()`, read by post-conversation jobs. Manual cleanup only. -### Vector Storage +### Memory Storage -- **Qdrant** (Chronicle native): Container `qdrant`, ports 6333/6334, user-specific collections -- **OpenMemory MCP**: Container `openmemory-mcp`, port 8765, cross-client storage +- **FalkorDB** (Chronicle native): Container `falkordb`, port 6379. Graph (ConvDoc/ConvChunk/ConvEntity + Entity/Relationship KG) with embedded vector + BM25 indexes. Hybrid search. +- **OpenMemory MCP**: Container `openmemory-mcp`, port 8765, cross-client storage (uses its own internal Qdrant). Both written by `memory_extraction_job()`, read by `/api/memories/search`. diff --git a/Docs/init-system.md b/Docs/init-system.md index 8f838518..4952b53f 100644 --- a/Docs/init-system.md +++ b/Docs/init-system.md @@ -5,6 +5,7 @@ - **👉 [Start Here: Quick Start Guide](../quickstart.md)** - Main setup path for new users - **📚 [Full Documentation](../CLAUDE.md)** - Comprehensive reference - **🏗️ [Architecture Details](overview.md)** - Technical deep dive +- **🐧 [Running with Podman](podman.md)** - Use Podman instead of Docker (engine selection, rootless/GPU) --- @@ -15,7 +16,7 @@ Chronicle uses a unified initialization system with clean separation of concerns - **Configuration** (`wizard.py`) - Set up service configurations, API keys, and .env files - **Service Management** (`services.py`) - Start, stop, and manage running services -The root orchestrator handles service selection and delegates configuration to individual service scripts. In general, setup scripts only configure and do not start services automatically. Exceptions: `extras/asr-services` and `extras/openmemory-mcp` are startup scripts. This prevents unnecessary resource usage and gives you control over when services actually run. +The root orchestrator handles service selection and delegates configuration to individual service scripts. In general, setup scripts only configure and do not start services automatically. Exception: `extras/asr-services` is a startup script. This prevents unnecessary resource usage and gives you control over when services actually run. > **New to Chronicle?** Most users should start with the [Quick Start Guide](../quickstart.md) instead of this detailed reference. @@ -30,7 +31,6 @@ The root orchestrator handles service selection and delegates configuration to i - **Backend**: `backends/advanced/init.py` - Complete Python-based interactive setup - **Speaker Recognition**: `extras/speaker-recognition/init.py` - Python-based interactive setup - **ASR Services**: `extras/asr-services/setup.sh` - Service startup script -- **OpenMemory MCP**: `extras/openmemory-mcp/setup.sh` - External server startup ## Usage @@ -66,16 +66,12 @@ cd extras/speaker-recognition # ASR Services only cd extras/asr-services ./setup.sh - -# OpenMemory MCP only -cd extras/openmemory-mcp -./setup.sh ``` ## Service Details ### Advanced Backend -- **Interactive setup** for authentication, LLM, transcription, and memory providers +- **Interactive setup** for authentication, LLM, and transcription (memory is the agentic Markdown vault — no provider choice) - **Accepts arguments**: `--speaker-service-url`, `--parakeet-asr-url` - **Generates**: Complete `.env` file with all required configuration - **Default ports**: Backend (8000), WebUI (5173) @@ -92,12 +88,6 @@ cd extras/openmemory-mcp - **Purpose**: Offline speech-to-text processing - **No configuration required** -### OpenMemory MCP -- **Starts**: External OpenMemory MCP server -- **Service port**: 8765 -- **WebUI**: Available at http://localhost:8765 -- **Purpose**: Cross-client memory compatibility - ## Automatic URL Coordination When using the orchestrated setup, service URLs are automatically configured: @@ -127,7 +117,6 @@ Note (Linux): If `host.docker.internal` is unavailable, add `extra_hosts: - "hos | **Advanced Backend** | 8000 | 5173 | http://localhost:8000 (API), http://localhost:5173 (Dashboard) | | **Speaker Recognition** | 8085 | 5175* | http://localhost:8085 (API), http://localhost:5175 (WebUI) | | **Parakeet ASR** | 8767 | - | http://localhost:8767 (API) | -| **OpenMemory MCP** | 8765 | 8765 | http://localhost:8765 (API + WebUI) | *Speaker Recognition WebUI port is configurable via REACT_UI_PORT @@ -148,7 +137,86 @@ See [ssl-certificates.md](ssl-certificates.md) for HTTPS/SSL setup details. Services use `host.docker.internal` for inter-container communication: - `http://127.0.0.1:8085` - Speaker Recognition - `http://host.docker.internal:8767` - Parakeet ASR -- `http://host.docker.internal:8765` - OpenMemory MCP + +## Node Agent (WebUI control + Tailnet advertising) + +The **node agent** (`edge/service_manager.py`) is a small host-side HTTP API that does +two jobs for one machine: + +1. **Control** — lets the WebUI System page start/stop/restart services and switch the + active ASR/TTS provider (it wraps `services.py`). +2. **Advertise** — announces this node's services (and itself, as `chronicle-node`) on + the Tailnet via minidisc, so the backend/other nodes discover them. The advertised + labels carry **live** state — `running` and `health` — refreshed on a timer + (`ADVERTISE_REFRESH_SECS`, default 30s), not just what's enabled. This folds in the + old standalone discovery agent, which has been removed. + +It must run natively on the host: docker compose needs host bind-mount paths, and (on +Docker Desktop/WSL2) a container can't bind the Tailscale interface to advertise. + +- **Started automatically** by `./start.sh` / `./restart.sh` on **any** start (so a + service-only node with no backend still advertises); stopped by `./stop.sh` (full + `stop --all` only — a backend-only stop leaves it up and advertising) +- **Manual control**: `uv run --with-requirements setup-requirements.txt python services.py manager start|stop|restart` +- **Identity / cluster**: `GET /node` (host, Tailscale name/IP, arch, GPU) and `GET /cluster` (live tailnet view); both token-gated. `GET /health` is unauthed. +- **Port**: 8775 (override with `SERVICE_MANAGER_PORT`) +- **Auth**: bearer token, auto-generated into `backends/advanced/.env` as `SERVICE_MANAGER_TOKEN` on first start; the backend reads the same value and proxies admin-only requests (`/api/admin/services/*`) to the agent +- **Distributed setups**: run the agent on the machine that hosts the services and point the backend at it via `SERVICE_MANAGER_URL` (e.g. `http://gpu-box.ts.net:8775`); copy the token into both machines' configuration +- **Logs / PID**: `edge/service-manager.log`, `edge/.service-manager.pid` +- **Remote service nodes**: a GPU box / RPi that runs a single service joins the cluster either via the wizard (*Setup type → Join a cluster*) or the one-liner `edge/install.sh ` — **both default to the node agent** (advertise + control + reboot persistence) and need no `SERVICE_MANAGER_URL` wiring. The legacy advertise-only `edge-agent` sidecar (`--profile edge`) is the secondary fallback via `edge/install.sh --advertise-only`, for boxes where you don't want a host process (no control, no WSL2). See [edge/README.md](../edge/README.md). + +### Auto-start on boot (systemd user services) + +`manager install` installs **two** systemd *user* services (with linger, so they +start without an interactive login): + +1. **`chronicle-service-manager`** — the node agent itself. It runs **natively on the + host**, not in Docker, so it does **not** come back after a reboot on its own; a + fresh boot otherwise leaves the WebUI System page showing "Service manager not up, + use ./start.sh". `Type=exec`, started immediately on install. +2. **`chronicle-stack`** — a `Type=oneshot` that runs `services.py start --all` on + boot to bring the **container stack** back (the enabled set from `config.yml`, + exactly what `./start.sh` does). Under **Docker** the containers' `restart:` + policy revives them on boot, so this is belt-and-suspenders; under **rootless + Podman** it's essential — Podman is daemonless and nothing re-applies `restart:` + policies after a reboot (see [podman.md](podman.md)). Ordered + `After=chronicle-service-manager.service`; enabled for boot only (installing it + does **not** kick off a full `start --all` — `./start.sh` owns the running stack). + +The wizard offers both near the end of setup ("Auto-start on boot"); you can also do +it manually: + +```bash +# Install both units (~/.config/systemd/user/chronicle-{service-manager,stack}.service), +# enable linger, start the agent now and register the stack for boot +uv run --with-requirements setup-requirements.txt python services.py manager install + +# Remove both +uv run --with-requirements setup-requirements.txt python services.py manager uninstall +``` + +Once installed, `./start.sh` defers to systemd (`systemctl --user start`) instead of +spawning a background process, `./stop.sh --all` leaves the managed agent running, +and `./status.sh` reports the agent as `(systemd user service)` and shows whether +stack-on-boot is enabled. Manage them directly with `systemctl --user +status|stop|restart chronicle-service-manager` (or `chronicle-stack`); view logs with +`journalctl --user -u chronicle-service-manager` (or `-u chronicle-stack`). + +> **Upgrading from the old two-agent layout:** the standalone `chronicle-discovery` +> systemd unit is obsolete (the node agent advertises now). `./start.sh` and +> `services.py manager install` auto-disable and remove a leftover `chronicle-discovery` +> unit, so no manual cleanup is needed. + +> **Requires a systemd user instance.** On a normal Linux host this is available out +> of the box. On **WSL**, enable systemd first: add a `[boot]` section with +> `systemd=true` to `/etc/wsl.conf`, run `wsl --shutdown`, then reopen the terminal. +> If it's unavailable, the wizard/CLI prints this hint and skips installation. + +From the WebUI (System page → External Services) you can start/stop/restart any +enabled service and switch ASR/TTS providers. Provider switches write the new +`ASR_PROVIDER`/`TTS_PROVIDER` to the service's `.env`, stop the old container, and +start the new one (they share a port). Tick "Build images" if the new provider's +image hasn't been built yet. ## Service Management @@ -198,14 +266,14 @@ uv run --with-requirements setup-requirements.txt python services.py restart bac uv run --with-requirements setup-requirements.txt python services.py stop --all # Stop specific services -uv run --with-requirements setup-requirements.txt python services.py stop asr-services openmemory-mcp +uv run --with-requirements setup-requirements.txt python services.py stop asr-services speaker-recognition ``` **Important Notes:** -- **Restart** restarts containers without rebuilding - use for configuration changes (.env updates) -- **For code changes**, use `./stop.sh` then `./start.sh` to rebuild images +- **Restart** recreates containers in place (`up --force-recreate`) without rebuilding the image — it re-reads `.env`/config and picks up **volume-mounted code** (e.g. the backend's `./src`), so it's enough for most config and code changes +- **For dependency/Dockerfile changes** (anything baked into the image), use `./stop.sh` then `./start.sh` to rebuild images - Convenience scripts handle common operations; use direct commands for specific service selection ### Manual Service Management @@ -220,9 +288,6 @@ cd extras/speaker-recognition && docker compose up --build -d # ASR Services (only if using offline transcription) cd extras/asr-services && docker compose up --build -d - -# OpenMemory MCP (only if using openmemory_mcp provider) -cd extras/openmemory-mcp && docker compose up --build -d ``` ## Configuration Files diff --git a/Docs/overview.md b/Docs/overview.md index 927bbf16..78487675 100644 --- a/Docs/overview.md +++ b/Docs/overview.md @@ -10,14 +10,14 @@ The goal is a personal AI that gets better the more you use it: the more context - **Multimodal**: Audio is the primary input today, but the architecture supports images, visual context, and other data sources. - **Memories from everything**: Events produce memories. A conversation yields facts about people, plans, and preferences. A photo yields location, context, and associations. - **Self-hosted**: Runs on your hardware, your data stays with you. -- **Hackable**: Designed to be forked, modified, and extended. Pluggable providers for transcription, LLM, memory storage, and analysis. +- **Hackable**: Designed to be forked, modified, and extended. Pluggable providers for transcription, LLM, and analysis; memories live in an agentic Markdown vault. ## How It Works ``` Audio/Images/Data → Ingestion → Processing → Memories ↓ - Vector Store + Markdown Vault ↓ Retrieval & Search ``` @@ -28,14 +28,14 @@ Audio/Images/Data → Ingestion → Processing → Memories 2. **Transcription**: Deepgram (cloud) or Parakeet (local) converts speech to text 3. **Speaker Recognition**: Optional identification of who said what (pyannote) 4. **Memory Extraction**: LLM extracts facts, preferences, and context from transcripts -5. **Storage**: Memories stored as vectors in Qdrant for semantic search +5. **Storage**: Memories recorded as notes in an agentic Markdown vault (Obsidian-style, at `data/conversation_docs//`) ### Image Pipeline (In Development) 1. **Import**: Zip upload, or sync from external services (e.g., Immich) 2. **Analysis**: Extract EXIF metadata, captions, detected objects 3. **Memory Extraction**: Same LLM pipeline, different source type -4. **Storage**: Same vector store, queryable alongside conversation memories +4. **Storage**: Same Markdown vault, queryable alongside conversation memories ## Architecture @@ -51,13 +51,14 @@ Audio/Images/Data → Ingestion → Processing → Memories │ └──────────────┘ └──────┬───────┘ │ │ │ │ │ ┌──────────────┐ ┌──────▼───────┐ ┌──────────┐ │ -│ │ Web UI │ │ Workers │ │ Qdrant │ │ -│ │ (React) │ │ (RQ/Redis) │ │ (Vector) │ │ +│ │ Web UI │ │ Workers │ │ Markdown │ │ +│ │ (React) │ │ (RQ/Redis) │ │ Vault │ │ +│ │ │ │ │ │ (memory) │ │ │ └──────────────┘ └──────────────┘ └──────────┘ │ │ │ │ Transcription: Deepgram (cloud) or Parakeet (local) │ │ LLM: OpenAI (cloud) or Ollama (local) │ -│ Optional: Speaker Recognition, OpenMemory MCP │ +│ Optional: Speaker Recognition │ └─────────────────────────────────────────────────────┘ ``` @@ -70,8 +71,9 @@ Audio/Images/Data → Ingestion → Processing → Memories | **Mobile App** | `app/` | React Native app for OMI device pairing | | **Speaker Recognition** | `extras/speaker-recognition/` | Voice identification service | | **ASR Services** | `extras/asr-services/` | Local speech-to-text (Parakeet) | -| **OpenMemory MCP** | `extras/openmemory-mcp/` | Cross-client memory compatibility | +| **TTS Services** | `extras/tts/` | Text-to-speech (TADA, Fish Speech, KittenTTS) | | **HAVPE Relay** | `extras/havpe-relay/` | ESP32 audio bridge | +| **Vault Sync** | `extras/vault-sync/` | macOS menu bar app — syncs your conversation_docs vault to Obsidian via Syncthing | ### Pluggable Providers @@ -79,8 +81,9 @@ Chronicle is designed around swappable providers: - **Transcription**: Deepgram API or local Parakeet ASR - **LLM**: OpenAI or local Ollama -- **Memory Storage**: Chronicle native (Qdrant) or OpenMemory MCP +- **Memory Storage**: agentic Markdown vault (the single source of truth) - **Speaker Recognition**: pyannote-based service (optional) +- **Text-to-Speech**: TADA, Fish Speech (GPU), or KittenTTS (CPU) — optional ## Repository Structure @@ -94,8 +97,9 @@ chronicle/ ├── extras/ │ ├── speaker-recognition/ # Voice identification │ ├── asr-services/ # Local ASR (Parakeet) -│ ├── openmemory-mcp/ # External memory server -│ └── havpe-relay/ # ESP32 audio bridge +│ ├── tts/ # Text-to-speech (TADA, Fish Speech, KittenTTS) +│ ├── havpe-relay/ # ESP32 audio bridge +│ └── vault-sync/ # macOS menu bar app: vault ⇄ Obsidian via Syncthing ├── config/ # Central configuration ├── Docs/ # Documentation ├── tests/ # Integration tests (Robot Framework) diff --git a/Docs/podman.md b/Docs/podman.md new file mode 100644 index 00000000..9c84d8ae --- /dev/null +++ b/Docs/podman.md @@ -0,0 +1,161 @@ +# Running Chronicle with Podman + +Chronicle's service lifecycle (`services.py`, `status.py`, the service-manager +agent) works with either **Docker** (default) or **Podman**. This is useful where +Docker Desktop is unavailable (e.g. locked-down work machines) or when you prefer a +single rootless engine across machines. + +Choosing the engine does **not** require editing any compose files — the repo's +existing compose definitions (profiles, healthchecks, `host.docker.internal`, and +NVIDIA `deploy.resources` GPU blocks) run unmodified under Podman. + +## Selecting the engine + +Precedence: environment variable → `config/config.yml` → `docker` default. + +```yaml +# config/config.yml +container_engine: podman # "docker" (default) or "podman" +# compose_cmd: podman-compose # optional; default derives from container_engine +``` + +Or per-shell: + +```bash +export CONTAINER_ENGINE=podman # used for `network`/`inspect`/`ps` +export COMPOSE_CMD="podman-compose" # used for up/down/build/restart +``` + +`config.yml` is the recommended home because the **service-manager** and +**discovery** agents run as systemd *user* services and do not inherit your shell +environment. + +### Why `podman-compose` (not `docker compose` against the podman socket) + +On Ubuntu 24.04 (Podman 4.9), `docker compose` talking to Podman's Docker-compat +socket does **not** perform CDI GPU injection — GPU services start without a GPU. +`podman-compose` drives the `podman` CLI directly, where CDI works. So for podman, +the compose front-end is `podman-compose`. The only consequence is that +`podman-compose`'s `ps` output is not docker-compatible; Chronicle handles this +internally by querying `podman ps` scoped to the compose project label. + +## Host setup (rootless) + +1. **Install the engine + compose + (GPU) toolkit:** + ```bash + sudo apt install -y podman + uv tool install podman-compose # or: sudo apt install podman-compose + # GPU hosts only: + sudo apt install -y nvidia-container-toolkit + ``` + +2. **GPU hosts — generate the CDI spec** (works in WSL2 with the NVIDIA WSL driver): + ```bash + sudo nvidia-ctk cdi generate --output=/etc/cdi/nvidia.yaml + nvidia-ctk cdi list # expect nvidia.com/gpu=all + # quick check: + podman run --rm --device nvidia.com/gpu=all \ + docker.io/nvidia/cuda:12.6.0-base-ubuntu24.04 nvidia-smi + ``` + +3. **Clear any Docker Desktop credential helper** (otherwise image pulls fail + invoking `docker-credential-desktop`): + ```bash + # if ~/.docker/config.json contains "credsStore": "desktop..." + cp ~/.docker/config.json ~/.docker/config.json.bak + echo '{}' > ~/.docker/config.json + ``` + +4. **Select podman** in `config/config.yml` (`container_engine: podman`). + +5. **Start services as usual:** `./start.sh`, `./status.sh`, `./stop.sh` — they all + route through the selected engine. + +## Migrating an existing Docker install + +- **Data ownership.** Docker creates bind-mounted data (`backends/advanced/data/...`) + owned by `root` (root-running containers) or by a service UID like `999` + (mongo/redis drop privileges at runtime). Rootless Podman remaps container UIDs + through your subuid range, so it can't write either until re-owned. `podman + unshare chown` alone fails on the root-owned dirs because real root isn't mapped + into your namespace — so it's two steps: + ```bash + # 1) real root pulls everything back to your user (fixes root-owned dirs; + # container-root maps to you, so root-running services are then correct) + sudo chown -R "$(id -un):$(id -gn)" backends/advanced/data + # 2) remap services that run as a non-root UID inside (mongo + redis = 999), + # set from inside the podman user namespace + podman unshare chown -R 999:999 backends/advanced/data/mongo_data + podman unshare chown -R 999:999 backends/advanced/data/redis_data + ``` + General rule for any straggler that can't write: `podman unshare chown -R + : `. +- **Free the ports.** Stop/decommission Docker Desktop (or `docker compose down` + the old stack) so Podman can bind 6379/27017/8000/etc. +- **Docker Desktop PATH shims.** `/usr/bin/docker` and `/usr/bin/docker-compose` are + often Docker Desktop symlinks that go stale when it's removed. They don't affect + Podman, but a stale `docker-compose` shim is why a self-contained tool + (`podman-compose`) is used rather than borrowing Docker Desktop's binary. + +## Caveats + +- **Privileged ports (Caddy).** Rootless `ip_unprivileged_port_start` is `1024`, so + Caddy's `443` binds but its `80` HTTP→HTTPS redirect does not. Only relevant with + the `https`/Caddy profile. If you need it: + ```bash + sudo sysctl net.ipv4.ip_unprivileged_port_start=80 # persist in /etc/sysctl.d + ``` +- **inotify watch exhaustion (WebUI 502 / Vite crash loop).** Containers share the + host's `fs.inotify.max_user_watches` pool (default 65536). A host-side IDE file + watcher (Cursor/VSCode server) recursively watching this large repo can consume + nearly the entire pool, starving the webui container's Vite dev server → it + crash-loops with `ENOSPC` on `watch` and Caddy returns 502. (Instances are not the + issue — those stay low.) Two fixes, complementary: + ```bash + # immediate: raise the watch limit (VSCode/Cursor docs recommend this on big repos) + echo 'fs.inotify.max_user_watches=524288' | sudo tee /etc/sysctl.d/99-inotify.conf && sudo sysctl --system + ``` + Also add `files.watcherExclude` in the IDE for heavy paths (`**/node_modules`, + `**/.venv`, `**/data/**`, `**/model_cache/**`, `golden_data`, `experiments`) so the + IDE isn't watching tens of thousands of model/data files. +- **Reboot persistence.** Docker's daemon restarts `restart: unless-stopped` + containers on boot. Rootless Podman is daemonless — nothing re-applies `restart:` + policies after a reboot (the policy still covers crash-restart while the box is + up, just not the reboot gap). Instead of Podman's own `podman-restart.service` + (which only matches `restart-policy=always`, *not* `unless-stopped`), Chronicle + ships a **`chronicle-stack`** systemd *user* oneshot that runs `services.py start + --all` on boot — the same path as `./start.sh`, respecting the `config.yml` + enabled set. It installs (with linger) alongside the node agent via the wizard's + "Auto-start on boot" prompt or `services.py manager install`, and is ordered + `After=chronicle-service-manager.service`. So no compose-file edits or + `podman-restart.service` are needed. Manage/inspect it with `systemctl --user + status chronicle-stack` and `journalctl --user -u chronicle-stack`. +- **Implicit `default` network.** docker-compose silently creates a `_default` + network for services that declare no `networks:`. podman-compose instead errors + `missing networks: default` once any *other* network (e.g. external + `chronicle-network`) is present. Fix: declare `default: {}` in that compose file's + top-level `networks:` (a no-op under docker). Only `extras/langfuse` needed this; + all compose files now parse under both engines. +- **Docker Desktop auto-start.** On Windows, Docker Desktop relaunches on login and + its `restart: unless-stopped` containers reclaim host ports (27017/6379/…) via the + WSL relay, blocking Podman. Uninstall it (or disable login-start + `compose down` + the old stack) — note its `/usr/bin/docker{,-compose}` symlinks then dangle + harmlessly. + +## Toward seamless setup (TODO) + +The rootless-podman host prerequisites above are currently manual. To make podman a +one-command choice for the user, the wizard / `services.py` should automate them when +`container_engine: podman` is selected. Each is independently scriptable: + +| Manual step today | Who should automate it | Notes | +|---|---|---| +| `unqualified-search-registries=["docker.io"]` + `short-name-mode="permissive"` | wizard (write `~/.config/containers/registries.conf` if absent) | idempotent; required for builds to resolve short names | +| `nvidia-ctk cdi generate` (GPU hosts) | wizard (already detects CUDA via `setup_utils.detect_cuda_version`) | needs sudo; prompt + run | +| `sysctl net.ipv4.ip_unprivileged_port_start=80` | `services.py` preflight when the `https`/Caddy profile is active | needs sudo; only if Caddy enabled. Could also drop Caddy's `:80` listener for rootless | +| Data-dir ownership remap (`sudo chown` + `podman unshare chown 999`) | a `services.py migrate-podman` helper (one-shot) | only when migrating an existing docker install | +| Clear Docker Desktop `credsStore` | wizard (detect `desktop` credsStore, offer to neutralize) | harmless on fresh hosts | + +Until automated, `Docs/podman.md` (this file) is the checklist. The **engine +abstraction itself** (`container_engine`/`compose_cmd`) is already seamless — only the +host prerequisites need wiring into setup. diff --git a/Docs/ssl-certificates.md b/Docs/ssl-certificates.md index 1980c833..0051e271 100644 --- a/Docs/ssl-certificates.md +++ b/Docs/ssl-certificates.md @@ -1,6 +1,7 @@ # SSL Certificates & HTTPS -Chronicle uses automatic HTTPS setup for secure microphone access and remote connections. +Chronicle uses **Caddy** for automatic HTTPS — for both the advanced backend and the +speaker-recognition service — so certificates are obtained and renewed automatically. ## Why HTTPS is Needed @@ -10,64 +11,79 @@ Modern browsers require HTTPS for: - **Remote access** via Tailscale/VPN - **Production deployments** -## SSL Implementation +Note: the **native mobile app** does not require HTTPS — only browsers do. -### Advanced Backend → Caddy +## How certificates are managed -The main backend uses **Caddy** for automatic HTTPS: +Both services front their containers with Caddy: -**Configuration**: `backends/advanced/Caddyfile` -**Activation**: Caddy starts when using `--profile https` or when wizard enables HTTPS -**Certificate**: Self-signed for local/Tailscale IPs, automatic Let's Encrypt for domains +| Service | Config | HTTP → HTTPS ports | +|---------|--------|--------------------| +| Advanced Backend | `backends/advanced/Caddyfile` | `80` → `443` | +| Speaker Recognition | `extras/speaker-recognition/Caddyfile` | `8081` → `8444` | -**Ports**: -- `443` - HTTPS (main access) -- `80` - HTTP (redirects to HTTPS) +The wizard records the chosen approach as `HTTPS_CERT_MODE` in each service's `.env`: -**Access**: `https://localhost` or `https://your-tailscale-ip` +### `caddy` mode (default) -### Speaker Recognition → nginx +Caddy obtains **and auto-renews** the certificate itself — no cert files on disk, no +renewal cron. The Caddyfile has no `tls` directive; Caddy picks the right source from +the site address: -The speaker recognition service uses **nginx** for HTTPS: +- **`*.ts.net` (Tailscale)** → fetched from the local `tailscaled` at TLS-handshake + time. The wizard mounts the host `tailscaled.sock` into the Caddy container via a + generated `docker-compose.override.yml`. Trusted on all devices in your tailnet. +- **Real domain** → Let's Encrypt via ACME (requires ports 80/443 reachable). Trusted + everywhere. +- **IP address / `localhost`** → Caddy's internal CA (self-signed). Browsers show a + warning you can accept; the native app connects fine. -**Configuration**: `extras/speaker-recognition/nginx.conf` -**Certificate**: Self-signed via `ssl/generate-ssl.sh` +Renewal is automatic (~30 days before expiry), so a Caddy-managed deployment never hits +an expired cert. -**Ports**: -- `8444` - HTTPS -- `8081` - HTTP (redirects to HTTPS) +### `static` mode (Docker Desktop + Tailscale fallback) -**Access**: `https://localhost:8444` +On Docker Desktop (macOS/Windows) the `tailscaled` socket isn't reachable from the +Docker VM, so Caddy can't fetch a `*.ts.net` cert. There the wizard issues the cert on +the host (`tailscale cert` → `certs/server.crt`/`server.key`, mounted into Caddy) and the +Caddyfile includes a `tls /certs/...` directive. `services.py` renews it on every +`./start.sh` / `./restart.sh` if it's within 21 days of expiry — no cron needed, since +those boxes restart frequently. ## Setup via Wizard -When you run `./wizard.sh`, the setup wizard: +When you run `./wizard.sh`, the wizard: 1. Asks if you want to enable HTTPS -2. Prompts for your Tailscale IP or domain -3. Generates SSL certificates automatically -4. Configures Caddy/nginx as needed +2. Prompts for your Tailscale name, domain, or IP +3. Picks `caddy` or `static` mode automatically (based on the address and whether the + `tailscaled` socket is available) +4. Generates the Caddyfile (and, for Caddy-managed Tailscale, the socket-mount override) 5. Updates CORS settings for HTTPS origins -**No manual setup required** - the wizard handles everything. +**No manual certificate generation required.** ## Browser Certificate Warnings -Since we use self-signed certificates for local/Tailscale IPs, browsers will show security warnings: +For `*.ts.net` and real domains the certificate is publicly trusted — no warning. For an +IP or `localhost` address Caddy serves a self-signed internal-CA cert, so browsers warn: 1. Click "Advanced" -2. Click "Proceed to localhost (unsafe)" or similar +2. Click "Proceed … (unsafe)" 3. Microphone access will now work -For production with real domains, Caddy automatically obtains valid Let's Encrypt certificates. - ## Troubleshooting **HTTPS not working**: -- Check Caddy/nginx containers are running: `docker compose ps` -- Verify certificates exist: `ls backends/advanced/ssl/` or `ls extras/speaker-recognition/ssl/` +- Check the Caddy containers are running: `docker compose ps` (look for `caddy`) +- Confirm the served cert: `echo | openssl s_client -connect localhost:443 -servername 2>/dev/null | openssl x509 -noout -issuer -enddate` +- For Caddy-managed Tailscale, confirm the socket is mounted: `docker inspect --format '{{range .Mounts}}{{.Destination}} {{end}}'` should list `/var/run/tailscale/tailscaled.sock` - Check you're using `https://` not `http://` +**Tailscale cert won't issue** (`500 ... failed to create DNS record`): +- Ensure **MagicDNS** and **HTTPS Certificates** are enabled at https://login.tailscale.com/admin/dns +- These 500s are usually transient on Tailscale's side; retry after a short wait + **Microphone not accessible**: - Ensure you're accessing via HTTPS (not HTTP) -- Accept browser certificate warning -- Verify you're not using `localhost` from remote device (use Tailscale IP instead) +- Accept the browser certificate warning (IP/localhost only) +- From a remote device use the Tailscale name/IP, not `localhost` diff --git a/Makefile b/Makefile index ae9e784d..79617249 100644 --- a/Makefile +++ b/Makefile @@ -74,11 +74,11 @@ help: ## Show detailed help for all targets @echo "🏗️ KUBERNETES SETUP:" @echo " setup-k8s Complete initial Kubernetes setup" @echo " - Configures insecure registry access" - @echo " - Sets up infrastructure services (MongoDB, Qdrant)" + @echo " - Sets up infrastructure services (MongoDB, FalkorDB)" @echo " - Creates shared models PVC" @echo " - Sets up cross-namespace RBAC" @echo " - Generates and applies configuration" - @echo " setup-infrastructure Deploy infrastructure services (MongoDB, Qdrant)" + @echo " setup-infrastructure Deploy infrastructure services (MongoDB, FalkorDB)" @echo " setup-rbac Set up cross-namespace RBAC" @echo " setup-storage-pvc Create shared models PVC" @echo @@ -149,7 +149,7 @@ setup-k8s: ## Initial Kubernetes setup (registry + infrastructure) @echo @echo "📋 Setup includes:" @echo " • Insecure registry configuration" - @echo " • Infrastructure services (MongoDB, Qdrant)" + @echo " • Infrastructure services (MongoDB, FalkorDB)" @echo " • Shared models PVC for speaker recognition" @echo " • Cross-namespace RBAC" @echo " • Configuration generation and application" @@ -178,13 +178,12 @@ setup-k8s: ## Initial Kubernetes setup (registry + infrastructure) @echo " • Run 'make k8s-status' to check cluster status" @echo " • Run 'make help' for more options" -setup-infrastructure: ## Set up infrastructure services (MongoDB, Qdrant) +setup-infrastructure: ## Set up infrastructure services (MongoDB, FalkorDB) @echo "🏗️ Setting up infrastructure services..." - @echo "Deploying MongoDB and Qdrant to $(INFRASTRUCTURE_NAMESPACE) namespace..." + @echo "Deploying MongoDB and FalkorDB to $(INFRASTRUCTURE_NAMESPACE) namespace..." @set -a; source skaffold.env; set +a; skaffold run --profile=infrastructure --default-repo=$(CONTAINER_REGISTRY) @echo "⏳ Waiting for infrastructure services to be ready..." @kubectl wait --for=condition=ready pod -l app.kubernetes.io/name=mongodb -n $(INFRASTRUCTURE_NAMESPACE) --timeout=300s || echo "⚠️ MongoDB not ready yet" - @kubectl wait --for=condition=ready pod -l app.kubernetes.io/name=qdrant -n $(INFRASTRUCTURE_NAMESPACE) --timeout=300s || echo "⚠️ Qdrant not ready yet" @echo "✅ Infrastructure services deployed" setup-rbac: ## Set up cross-namespace RBAC diff --git a/README-K8S.md b/README-K8S.md index 6a36e038..aa3509a0 100644 --- a/README-K8S.md +++ b/README-K8S.md @@ -313,7 +313,7 @@ chronicle/ # cp backends/advanced/.env.template backends/advanced/.env # Note: Most environment variables are automatically set by Skaffold during deployment - # including MONGODB_URI, QDRANT_BASE_URL, and other Kubernetes-specific values + # including MONGODB_URI and other Kubernetes-specific values ``` 2. **Configure Skaffold Environment** @@ -335,8 +335,8 @@ chronicle/ HF_TOKEN=hf_your_huggingface_token_here DEEPGRAM_API_KEY=your_deepgram_api_key_here - # Note: MONGODB_URI and QDRANT_BASE_URL are automatically generated - # by Skaffold based on your infrastructure namespace and service names + # Note: MONGODB_URI is automatically generated by Skaffold based on + # your infrastructure namespace and service names ``` 3. **Configuration Variables Reference** @@ -346,7 +346,7 @@ chronicle/ - `BACKEND_IP`: IP address of your Kubernetes control plane - `BACKEND_NODEPORT`: Port for backend service (30000-32767) - `WEBUI_NODEPORT`: Port for WebUI service (30000-32767) - - `INFRASTRUCTURE_NAMESPACE`: Namespace for MongoDB and Qdrant + - `INFRASTRUCTURE_NAMESPACE`: Namespace for MongoDB and FalkorDB - `APPLICATION_NAMESPACE`: Namespace for your application **Optional Variables (for Speaker Recognition):** @@ -357,7 +357,6 @@ chronicle/ **Automatically Generated:** - `MONGODB_URI`: Generated from infrastructure namespace - - `QDRANT_BASE_URL`: Generated from infrastructure namespace - `IMAGE_REPO_*`: Generated from Skaffold build process - `IMAGE_TAG_*`: Generated from Skaffold build process @@ -462,7 +461,7 @@ This directory contains standalone Kubernetes manifests that are not managed by ./scripts/deploy-all-services.sh # This will automatically: - # - Deploy infrastructure (MongoDB, Qdrant) + # - Deploy infrastructure (MongoDB, FalkorDB) # - Deploy main application (Backend, WebUI) # - Deploy additional services (if configured) # - Wait for each service to be ready diff --git a/README.md b/README.md index 3ff922d3..550691d6 100644 --- a/README.md +++ b/README.md @@ -85,9 +85,9 @@ chronicle/ │ ┌────────────────────┴────────────────┐ │ │ │ │ │ │ ┌────▼─────┐ ┌───────────┐ ┌──────────▼──┐ │ -│ │ Deepgram │ │ OpenAI │ │ Qdrant │ │ -│ │ STT │ │ LLM │ │ (Vector │ │ -│ │ │ │ │ │ Store) │ │ +│ │ Deepgram │ │ OpenAI │ │ FalkorDB │ │ +│ │ STT │ │ LLM │ │ (Graph + │ │ +│ │ │ │ │ │ Vector) │ │ │ └──────────┘ └───────────┘ └─────────────┘ │ │ │ │ Optional Services: │ diff --git a/app/.easignore b/app/.easignore new file mode 100644 index 00000000..54c84429 --- /dev/null +++ b/app/.easignore @@ -0,0 +1,46 @@ +# NOTE: .easignore fully replaces .gitignore for EAS uploads. +# Must exclude everything we don't want in the build tarball. + +# Dependencies — reinstalled on EAS build server +node_modules/ + +# Expo +.expo/ +dist/ +web-build/ + +# Native build outputs — regenerated on EAS build servers +android/.gradle/ +android/build/ +android/app/build/ +android/app/.cxx/ +ios/build/ +ios/Pods/ +ios/DerivedData/ +ios/*.xcworkspace/xcuserdata/ +ios/*.xcodeproj/xcuserdata/ +ios/*.xcodeproj/project.xcworkspace/xcuserdata/ + +# Local artifacts +*.ipa +*.apk +*.aab +build-*.ipa + +# Credentials (EAS uses server-side credentials) +*.jks +*.p8 +*.p12 +*.key +*.mobileprovision +*.pem + +# Metro / debug +.metro-health-check* +npm-debug.* +yarn-debug.* +yarn-error.* + +# OS / editor +.DS_Store +*.log diff --git a/app/app.json b/app/app.json index 237eab68..3772aa2f 100644 --- a/app/app.json +++ b/app/app.json @@ -2,7 +2,7 @@ "expo": { "name": "chronicle", "slug": "friend-lite-app", - "version": "1.0.0", + "version": "1.0.8", "scheme": "chronicle", "orientation": "portrait", "icon": "./assets/icon.png", @@ -35,6 +35,15 @@ }, "package": "com.cupbearer5517.chronicle", "permissions": [ + "android.permission.BLUETOOTH", + "android.permission.BLUETOOTH_ADMIN", + "android.permission.BLUETOOTH_CONNECT", + "android.permission.ACCESS_NETWORK_STATE", + "android.permission.FOREGROUND_SERVICE", + "android.permission.FOREGROUND_SERVICE_DATA_SYNC", + "android.permission.POST_NOTIFICATIONS", + "android.permission.RECORD_AUDIO", + "android.permission.CAMERA", "android.permission.BLUETOOTH", "android.permission.BLUETOOTH_ADMIN", "android.permission.BLUETOOTH_CONNECT", @@ -44,10 +53,8 @@ "android.permission.POST_NOTIFICATIONS", "android.permission.RECORD_AUDIO", "android.permission.CAMERA" - ], - "usesCleartextTraffic": true + ] }, - "newArchEnabled": true, "plugins": [ [ "@siteed/expo-audio-studio", @@ -57,6 +64,7 @@ "enableBackgroundAudio": true, "enableDeviceDetection": true, "iosBackgroundModes": { + "useAudio": true, "useProcessing": true }, "iosConfig": { @@ -88,6 +96,7 @@ } } ], + "expo-audio", [ "expo-build-properties", { @@ -108,13 +117,19 @@ } ], "expo-image-picker", - "./plugins/with-ats" + "./plugins/with-ats", + "expo-asset", + "expo-secure-store" ], "owner": "cupbearer5517", "extra": { "eas": { "projectId": "05d8598e-6fe7-4373-81e4-1654f3d8e181" } + }, + "runtimeVersion": "1.0.0", + "updates": { + "enabled": false } } } diff --git a/app/app/_layout.tsx b/app/app/_layout.tsx index b8110d09..49bc72d5 100644 --- a/app/app/_layout.tsx +++ b/app/app/_layout.tsx @@ -1,23 +1,36 @@ +import { useEffect } from "react"; import { Stack } from "expo-router"; import { useTheme } from "@/theme"; import { ConnectionLogProvider } from "@/contexts/ConnectionLogContext"; +import { AppSettingsProvider } from "@/contexts/AppSettingsContext"; +import ErrorBoundary from "@/components/ErrorBoundary"; +import { initLogger, logInfo } from "@/utils/logger"; export default function RootLayout() { const { colors, isDark } = useTheme(); + useEffect(() => { + initLogger().then(() => logInfo('RootLayout', 'app mounted')); + }, []); + return ( - - - - - - + + + + + + + + + + + ); } diff --git a/app/app/diagnostics.tsx b/app/app/diagnostics.tsx index c8c0d59a..d780a528 100644 --- a/app/app/diagnostics.tsx +++ b/app/app/diagnostics.tsx @@ -1,7 +1,8 @@ import React from 'react'; -import { View, Text, FlatList, TouchableOpacity, StyleSheet, SafeAreaView } from 'react-native'; +import { View, Text, FlatList, TouchableOpacity, StyleSheet, SafeAreaView, Share, Platform, Alert } from 'react-native'; import { useTheme, ThemeColors } from '@/theme'; import { useConnectionLog, ConnectionEvent, ConnectionEventType } from '@/contexts/ConnectionLogContext'; +import { getLogPath, readLog, clearLog } from '@/utils/logger'; const EVENT_BADGE_COLORS: Record = { scan_start: '#007AFF', @@ -19,6 +20,13 @@ const EVENT_BADGE_COLORS: Record = { reconnect_attempt: '#FF9500', reconnect_backoff: '#FF9500', bt_state_change: '#5856D6', + ws_connecting: '#FF9500', + ws_open: '#34C759', + ws_close: '#FF3B30', + ws_error: '#FF3B30', + ws_reconnect: '#FF9500', + ws_reauth: '#AF52DE', + net_change: '#5856D6', }; function formatTime(date: Date): string { @@ -87,8 +95,44 @@ export default function DiagnosticsScreen() { const { colors } = useTheme(); const { events, clearEvents } = useConnectionLog(); + const shareLogFile = async () => { + try { + const contents = await readLog(); + if (!contents) { + Alert.alert('No log yet', 'The crash log file is empty.'); + return; + } + if (Platform.OS === 'ios') { + await Share.share({ url: `file://${getLogPath()}`, message: contents.slice(-4000) }); + } else { + await Share.share({ message: contents.slice(-4000) }); + } + } catch (err) { + Alert.alert('Share failed', String(err)); + } + }; + + const wipeLogFile = async () => { + Alert.alert('Clear crash log?', 'Removes the on-device crash log file.', [ + { text: 'Cancel', style: 'cancel' }, + { text: 'Clear', style: 'destructive', onPress: async () => { await clearLog(); } }, + ]); + }; + return ( + + Crash Log + {getLogPath()} + + + Share Log File + + + Clear File + + + Connection Log ({events.length}) @@ -144,4 +188,34 @@ const screenStyles = StyleSheet.create({ fontSize: 15, textAlign: 'center', }, + logBar: { + paddingHorizontal: 16, + paddingVertical: 10, + borderBottomWidth: 1, + }, + logBarTitle: { + fontSize: 15, + fontWeight: '600', + }, + logBarPath: { + fontSize: 10, + fontFamily: 'monospace', + marginTop: 2, + marginBottom: 8, + }, + logBarRow: { + flexDirection: 'row', + gap: 8, + }, + logBtn: { + flex: 1, + paddingHorizontal: 12, + paddingVertical: 8, + borderRadius: 6, + alignItems: 'center', + }, + logBtnText: { + fontSize: 13, + fontWeight: '500', + }, }); diff --git a/app/app/index.tsx b/app/app/index.tsx index 1c4dc58e..0a302cf4 100644 --- a/app/app/index.tsx +++ b/app/app/index.tsx @@ -3,18 +3,20 @@ import { Text, View, SafeAreaView, ScrollView, Platform, FlatList, ActivityIndic import { OmiConnection } from 'friend-lite-react-native'; import { State as BluetoothState } from 'react-native-ble-plx'; import { Link } from 'expo-router'; +import Constants from 'expo-constants'; import { useTheme, ThemeColors } from '@/theme'; // Hooks import { useBluetoothManager } from '@/hooks/useBluetoothManager'; import { useDeviceScanning } from '@/hooks/useDeviceScanning'; import { useDeviceConnection } from '@/hooks/useDeviceConnection'; -import { useAppSettings } from '@/hooks/useAppSettings'; +import { useSharedAppSettings } from '@/contexts/AppSettingsContext'; import { useAutoReconnect } from '@/hooks/useAutoReconnect'; import { useAudioStreamingOrchestrator } from '@/hooks/useAudioStreamingOrchestrator'; import { useAudioListener } from '@/hooks/useAudioListener'; import { useAudioStreamer } from '@/hooks/useAudioStreamer'; import { usePhoneAudioRecorder } from '@/hooks/usePhoneAudioRecorder'; +import { usePhoneAudioDevices } from '@/hooks/usePhoneAudioDevices'; import { useBatteryMonitor } from '@/hooks/useBatteryMonitor'; import { saveLastConnectedDeviceId } from '@/utils/storage'; @@ -23,51 +25,106 @@ import BluetoothStatusBanner from '@/components/BluetoothStatusBanner'; import ScanControls from '@/components/ScanControls'; import DeviceListItem from '@/components/DeviceListItem'; import DeviceDetails from '@/components/DeviceDetails'; -import AuthSection from '@/components/AuthSection'; -import BackendStatus from '@/components/BackendStatus'; -import ObsidianIngest from '@/components/ObsidianIngest'; import PhoneAudioButton from '@/components/PhoneAudioButton'; +import PhoneAudioMicPicker from '@/components/PhoneAudioMicPicker'; + +// True once the app is pointed at a real backend (not empty and not the +// localhost placeholder a fresh install ships with). Mirrors the +// "not configured" logic in BackendStatus. +function isBackendConfigured(url: string | undefined): boolean { + const trimmed = (url || '').trim(); + if (!trimmed) return false; + try { + const base = trimmed.replace('ws://', 'http://').replace('wss://', 'https://').split('/ws')[0]; + const host = new URL(base).hostname; + return host !== 'localhost' && host !== '127.0.0.1' && host !== '::1'; + } catch { + return true; + } +} export default function App() { const { colors } = useTheme(); const s = createStyles(colors); const omiConnection = useRef(new OmiConnection()).current; const [showOnlyOmi, setShowOnlyOmi] = useState(false); + const [activeTab, setActiveTab] = useState<'backend' | 'connection'>('backend'); // Bluetooth const { bleManager, bluetoothState, permissionGranted, requestBluetoothPermission, isPermissionsLoading } = useBluetoothManager(); + // Settings (must be before audioStreamer so the token refresh callback can reference it) + const settings = useSharedAppSettings(); + // Audio - const audioStreamer = useAudioStreamer(); + const audioStreamer = useAudioStreamer({ + autoReconnectEnabled: settings.autoReconnectEnabled, + onTokenRefreshed: (newToken) => { + // Update app-level auth state when auto-re-login refreshes the token + if (settings.currentUserEmail) { + settings.handleAuthStatusChange(true, settings.currentUserEmail, newToken); + } + }, + }); const phoneAudioRecorder = usePhoneAudioRecorder(); + const phoneAudioDevices = usePhoneAudioDevices(); const { isListeningAudio: isOmiAudioListenerActive, audioPacketsReceived, startAudioListener: originalStartAudioListener, stopAudioListener: originalStopAudioListener, isRetrying: isAudioListenerRetrying, retryAttempts: audioListenerRetryAttempts } = useAudioListener(omiConnection, () => !!deviceConnection.connectedDeviceId); // Refs for disconnect cleanup const isOmiAudioListenerActiveRef = useRef(isOmiAudioListenerActive); const isAudioStreamingRef = useRef(audioStreamer.isStreaming); + // Track if audio pipeline was active before BLE disconnect (for auto-restart on reconnect) + const wasStreamingBeforeDisconnectRef = useRef(false); useEffect(() => { isOmiAudioListenerActiveRef.current = isOmiAudioListenerActive; }, [isOmiAudioListenerActive]); useEffect(() => { isAudioStreamingRef.current = audioStreamer.isStreaming; }, [audioStreamer.isStreaming]); - // Settings - const settings = useAppSettings(); + // Refs to break the declaration-order cycle: + // onDeviceConnect/onDeviceDisconnect need orchestrator + autoReconnect, + // but deviceConnection (which needs those callbacks) must be declared + // before orchestrator and autoReconnect. + type OrchestratorHandle = ReturnType; + type AutoReconnectHandle = ReturnType; + const orchestratorRef = useRef(null); + const autoReconnectRef = useRef(null); // Device callbacks const onDeviceConnect = useCallback(async () => { const deviceIdToSave = omiConnection.connectedDeviceId; if (deviceIdToSave) { await saveLastConnectedDeviceId(deviceIdToSave); - autoReconnect.setLastKnownDeviceId(deviceIdToSave); - autoReconnect.setTriedAutoReconnectForCurrentId(false); + autoReconnectRef.current?.setLastKnownDeviceId(deviceIdToSave); + autoReconnectRef.current?.setTriedAutoReconnectForCurrentId(false); + } + + // Auto-restart audio pipeline if it was active before BLE disconnect + if (wasStreamingBeforeDisconnectRef.current) { + wasStreamingBeforeDisconnectRef.current = false; + console.log('[App] BLE reconnected — auto-restarting audio pipeline'); + // Short delay to let BLE connection stabilize + setTimeout(() => { + orchestratorRef.current?.handleStartAudioListeningAndStreaming().catch(err => { + console.error('[App] Failed to auto-restart audio pipeline:', err); + }); + }, 1000); } }, [omiConnection]); const onDeviceDisconnect = useCallback(async () => { + // Remember if audio was active so we can auto-restart on reconnect + if (isOmiAudioListenerActiveRef.current || isAudioStreamingRef.current) { + wasStreamingBeforeDisconnectRef.current = true; + } + + // Stop audio listener (BLE is gone, can't read audio) if (isOmiAudioListenerActiveRef.current) await originalStopAudioListener(); - if (isAudioStreamingRef.current) audioStreamer.stopStreaming(); + + // Keep WebSocket alive — it will reconnect or idle until BLE comes back. + // Only stop WebSocket for phone audio mode (no BLE needed there). if (phoneAudioRecorder.isRecording) { + audioStreamer.stopStreaming(); await phoneAudioRecorder.stopRecording(); - orchestrator.setIsPhoneAudioMode(false); + orchestratorRef.current?.setIsPhoneAudioMode(false); } }, [originalStopAudioListener, audioStreamer.stopStreaming, phoneAudioRecorder.stopRecording, phoneAudioRecorder.isRecording]); @@ -86,6 +143,7 @@ export default function App() { permissionGranted, deviceConnection, scanning: false, + autoReconnectEnabled: settings.autoReconnectEnabled, }); // Scanning @@ -99,9 +157,14 @@ export default function App() { phoneAudioRecorder, originalStartAudioListener, originalStopAudioListener, + resolvePhoneInputDeviceId: phoneAudioDevices.resolveEffectiveDeviceId, settings, }); + // Keep forward-declared refs in sync so device callbacks can call through. + orchestratorRef.current = orchestrator; + autoReconnectRef.current = autoReconnect; + // Cleanup const cleanupRefs = useRef({ omiConnection, bleManager, disconnectFromDevice: deviceConnection.disconnectFromDevice, stopAudioStreaming: audioStreamer.stopStreaming, stopPhoneAudio: phoneAudioRecorder.stopRecording }); useEffect(() => { cleanupRefs.current = { omiConnection, bleManager, disconnectFromDevice: deviceConnection.disconnectFromDevice, stopAudioStreaming: audioStreamer.stopStreaming, stopPhoneAudio: phoneAudioRecorder.stopRecording }; }); @@ -127,10 +190,27 @@ export default function App() { if (!showOnlyOmi) return scannedDevices; return scannedDevices.filter(d => { const name = d.name?.toLowerCase() || ''; - return name.includes('omi') || name.includes('friend') || name.includes('neo'); + return name.includes('omi') || name.includes('friend') || name.includes('neo') || name.includes('elato'); }); }, [scannedDevices, showOnlyOmi]); + const bluetoothReady = bluetoothState === BluetoothState.PoweredOn && permissionGranted; + // A fresh install points at localhost (the phone itself), which can never be a + // real backend — treat that (and empty) as "not paired yet" so the setup card + // and health pill reflect reality. + const backendConfigured = isBackendConfigured(settings.webSocketUrl); + const isOperational = bluetoothReady && backendConfigured; + const healthLabel = isOperational ? 'System Operational' : 'Action Needed'; + const healthTone = isOperational ? colors.success : colors.warning; + const batteryDisplay = deviceConnection.connectedDeviceId + ? batteryMonitor.batteryLevel >= 0 ? `${batteryMonitor.batteryLevel}%` : '...' + : '--'; + const streamDisplay = audioStreamer.isStreaming + ? 'Streaming' + : (phoneAudioRecorder.isRecording || orchestrator.isPhoneAudioMode) + ? 'Phone Mic' + : 'Idle'; + // Loading / auto-reconnect screens if (isPermissionsLoading && bluetoothState === BluetoothState.Unknown) { return ( @@ -145,57 +225,117 @@ export default function App() { ); } - if (autoReconnect.isAttemptingAutoReconnect) { - return ( - - - - - Reconnecting to {autoReconnect.lastKnownDeviceId?.substring(0, 10)}... - - - Cancel - - - - ); - } - return ( + - - Chronicle - - - Logs - - + + + + Chronicle + v{Constants.expoConfig?.version ?? ''} + + + + + Diagnostics + + + + + + + + + + + + + {healthLabel} + - - + + {activeTab === 'backend' ? 'Backend Dashboard' : 'Connection Center'} + + {activeTab === 'backend' + ? 'Control center for backend, audio streaming, and wakeword behavior.' + : 'Manage Bluetooth pairing, reconnect flow, and device audio routing.'} + + + + {streamDisplay} + Audio State + + + + {batteryDisplay} + Battery + + + + {deviceConnection.connectedDeviceId ? 'Connected' : 'Idle'} + Device + + + - {settings.isAuthenticated && } + {activeTab === 'backend' && ( + <> + {(!backendConfigured || !settings.isAuthenticated) && ( + + + + {!backendConfigured ? 'Connect to your backend' : 'Sign in to your backend'} + + + {!backendConfigured + ? 'Scan the QR code from your Chronicle dashboard to pair this app.' + : 'Log in to access advanced backend features.'} + + Open Settings ⚙ + + + )} - + Audio Deck + - - + {!deviceConnection.connectedDeviceId && !deviceConnection.isConnecting && ( + + )} + + )} + + {activeTab === 'connection' && ( + <> + Bluetooth + + - {autoReconnect.isRetryingConnection && ( + {(autoReconnect.isAttemptingAutoReconnect || autoReconnect.isRetryingConnection) && ( - Reconnecting in {autoReconnect.retryBackoffSeconds}s... (attempt {autoReconnect.connectionRetryCount}) + {autoReconnect.isRetryingConnection + ? `Reconnecting in ${autoReconnect.retryBackoffSeconds}s... (attempt ${autoReconnect.connectionRetryCount})` + : `Reconnecting to ${autoReconnect.lastKnownDeviceId?.substring(0, 10) ?? 'device'}...`} Found Devices - Show only OMI/Friend/Neo + Show only OMI/Friend/Neo/Elato - {showOnlyOmi ? `No OMI/Friend/Neo devices found. ${scannedDevices.length} other device(s) hidden by filter.` : 'No devices found.'} + {showOnlyOmi ? `No OMI/Friend/Neo/Elato devices found. ${scannedDevices.length} other device(s) hidden by filter.` : 'No devices found.'} )} @@ -283,31 +423,49 @@ export default function App() { )} - {deviceConnection.connectedDeviceId && ( - + {deviceConnection.connectedDeviceId && ( + + )} + )} + + setActiveTab('connection')} + > + Connection + + setActiveTab('backend')} + > + Backend + + ); } @@ -317,34 +475,181 @@ const createStyles = (colors: ThemeColors) => StyleSheet.create({ flex: 1, backgroundColor: colors.background, }, + pulseBackground: { + position: 'absolute', + width: 440, + height: 440, + borderRadius: 220, + backgroundColor: colors.primary, + opacity: 0.05, + top: -140, + right: -140, + }, content: { - padding: 20, + padding: 16, paddingTop: Platform.OS === 'android' ? 30 : 10, - paddingBottom: 50, + paddingBottom: 110, + }, + headerCard: { + marginBottom: 14, + padding: 14, + borderRadius: 14, + backgroundColor: colors.card, + borderWidth: 1, + borderColor: colors.cardBorder, }, titleRow: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', - marginBottom: 20, + marginBottom: 10, + }, + brandRow: { + flexDirection: 'row', + alignItems: 'baseline', }, title: { - fontSize: 24, + fontSize: 26, fontWeight: 'bold', color: colors.text, }, + versionText: { + fontSize: 12, + color: colors.textTertiary, + marginLeft: 8, + }, + headerActions: { + flexDirection: 'row', + alignItems: 'center', + gap: 8, + }, diagButton: { paddingVertical: 6, paddingHorizontal: 12, - borderRadius: 6, + borderRadius: 10, backgroundColor: colors.inputBackground, borderWidth: 1, borderColor: colors.inputBorder, }, diagButtonText: { + fontSize: 12, + color: colors.primary, + fontWeight: '700', + }, + gearButton: { + width: 34, + height: 34, + borderRadius: 10, + backgroundColor: colors.inputBackground, + borderWidth: 1, + borderColor: colors.inputBorder, + alignItems: 'center', + justifyContent: 'center', + }, + gearButtonText: { + fontSize: 16, + color: colors.primary, + fontWeight: '700', + }, + setupCard: { + marginBottom: 20, + padding: 16, + borderRadius: 12, + backgroundColor: colors.card, + borderWidth: 1, + borderColor: colors.primary, + }, + setupTitle: { + fontSize: 16, + fontWeight: '700', + color: colors.text, + }, + setupSubtitle: { + marginTop: 6, + fontSize: 13, + color: colors.textSecondary, + }, + setupCta: { + marginTop: 12, fontSize: 14, + fontWeight: '700', + color: colors.primary, + }, + healthPill: { + alignSelf: 'flex-start', + flexDirection: 'row', + alignItems: 'center', + borderWidth: 1, + borderRadius: 16, + paddingHorizontal: 10, + paddingVertical: 4, + }, + healthDot: { + width: 8, + height: 8, + borderRadius: 4, + marginRight: 8, + }, + healthText: { + fontSize: 12, + fontWeight: '700', + }, + heroCard: { + marginBottom: 16, + padding: 14, + borderRadius: 14, + backgroundColor: colors.card, + borderWidth: 1, + borderColor: colors.cardBorder, + }, + heroTitle: { + fontSize: 20, + fontWeight: '700', + color: colors.text, + }, + heroSubtitle: { + marginTop: 4, + fontSize: 13, color: colors.textSecondary, - fontWeight: '500', + }, + heroMetricsRow: { + flexDirection: 'row', + alignItems: 'center', + marginTop: 14, + paddingTop: 12, + borderTopWidth: 1, + borderTopColor: colors.separator, + }, + metricBlock: { + flex: 1, + alignItems: 'center', + }, + metricValue: { + fontSize: 14, + fontWeight: '700', + color: colors.text, + }, + metricLabel: { + marginTop: 4, + fontSize: 11, + color: colors.textTertiary, + textTransform: 'uppercase', + letterSpacing: 0.6, + }, + metricDivider: { + width: 1, + height: 28, + backgroundColor: colors.separator, + marginHorizontal: 4, + }, + sectionLabel: { + fontSize: 12, + color: colors.textTertiary, + textTransform: 'uppercase', + letterSpacing: 0.8, + marginBottom: 8, + marginTop: 2, + fontWeight: '700', }, section: { marginBottom: 25, @@ -455,4 +760,42 @@ const createStyles = (colors: ThemeColors) => StyleSheet.create({ textAlign: 'center', fontWeight: '500', }, + bottomNav: { + position: 'absolute', + left: 14, + right: 14, + bottom: 14, + flexDirection: 'row', + justifyContent: 'space-between', + backgroundColor: colors.card, + borderWidth: 1, + borderColor: colors.cardBorder, + borderRadius: 14, + padding: 8, + }, + navItem: { + flex: 1, + borderRadius: 10, + alignItems: 'center', + justifyContent: 'center', + paddingVertical: 10, + }, + navItemText: { + fontSize: 12, + color: colors.textSecondary, + fontWeight: '700', + }, + navItemActive: { + flex: 1, + borderRadius: 10, + alignItems: 'center', + justifyContent: 'center', + paddingVertical: 10, + backgroundColor: colors.primary, + }, + navItemActiveText: { + fontSize: 12, + color: 'white', + fontWeight: '700', + }, }); diff --git a/app/app/settings.tsx b/app/app/settings.tsx new file mode 100644 index 00000000..27fab940 --- /dev/null +++ b/app/app/settings.tsx @@ -0,0 +1,64 @@ +import React from 'react'; +import { SafeAreaView, ScrollView, KeyboardAvoidingView, Platform, StyleSheet, Text } from 'react-native'; +import { useTheme, ThemeColors } from '@/theme'; +import { useSharedAppSettings } from '@/contexts/AppSettingsContext'; + +import BackendStatus from '@/components/BackendStatus'; +import AuthSection from '@/components/AuthSection'; +import SystemAdminControls from '@/components/SystemAdminControls'; +import NetworkOverview from '@/components/NetworkOverview'; + +export default function SettingsScreen() { + const { colors } = useTheme(); + const s = createStyles(colors); + const settings = useSharedAppSettings(); + + return ( + + + + Connection + + + + Administration + + + + + + ); +} + +const createStyles = (colors: ThemeColors) => StyleSheet.create({ + container: { + flex: 1, + backgroundColor: colors.background, + }, + content: { + padding: 16, + paddingBottom: 40, + }, + sectionLabel: { + fontSize: 12, + color: colors.textTertiary, + textTransform: 'uppercase', + letterSpacing: 0.8, + marginBottom: 8, + marginTop: 2, + fontWeight: '700', + }, +}); diff --git a/app/eas.json b/app/eas.json index 810d897a..f8a09cba 100644 --- a/app/eas.json +++ b/app/eas.json @@ -14,6 +14,15 @@ "production": { "autoIncrement": true }, + "testflight": { + "distribution": "store", + "autoIncrement": true, + "channel": "testflight", + "ios": { + "simulator": false, + "image": "macos-sequoia-15.6-xcode-26.2" + } + }, "local": { "distribution": "internal", "android": { @@ -26,6 +35,14 @@ } }, "submit": { - "production": {} + "production": {}, + "testflight": { + "ios": { + "ascAppId": "6761143188", + "ascApiKeyIssuerId": "70f6a927-18f2-46a8-be51-d72a6bef8e45", + "ascApiKeyId": "NZBJ66ADDC", + "ascApiKeyPath": "./asc-api-key.p8" + } + } } } diff --git a/app/metro.config.js b/app/metro.config.js index 50c9dc06..3cbbc744 100644 --- a/app/metro.config.js +++ b/app/metro.config.js @@ -16,10 +16,7 @@ config.resolver.nodeModulesPaths = [ path.resolve(workspaceRoot, 'node_modules'), ]; -// 3. Force Metro to resolve (sub)dependencies only from the `nodeModulesPaths` -config.resolver.disableHierarchicalLookup = true; - -// 4. Extra modules to include in the bundle +// 3. Extra modules to include in the bundle // config.resolver.extraNodeModules = { // '@omiai/omi-react-native': path.resolve(workspaceRoot), // 'base-64': path.resolve(projectRoot, 'node_modules/base-64'), diff --git a/app/package-lock.json b/app/package-lock.json index 27c99921..e634050d 100644 --- a/app/package-lock.json +++ b/app/package-lock.json @@ -10,57 +10,52 @@ "dependencies": { "@notifee/react-native": "^9.1.8", "@react-native-async-storage/async-storage": "^2.1.2", - "@react-native-community/netinfo": "^11.4.1", + "@react-native-community/netinfo": "11.5.2", "@react-native/virtualized-lists": "^0.80.2", - "@siteed/expo-audio-studio": "^2.18.1", + "@siteed/expo-audio-studio": "^2.18.6", "deprecated-react-native-prop-types": "^5.0.0", - "expo": "~53.0.9", - "expo-build-properties": "~0.14.8", - "expo-camera": "~16.1.11", - "expo-dev-client": "~5.2.4", - "expo-image-picker": "~16.1.4", - "expo-router": "~5.0.6", - "expo-status-bar": "~2.2.3", + "expo": "~55.0.15", + "expo-asset": "~55.0.17", + "expo-audio": "~55.0.14", + "expo-build-properties": "~55.0.13", + "expo-camera": "~55.0.15", + "expo-constants": "~55.0.14", + "expo-dev-client": "~55.0.27", + "expo-file-system": "~55.0.16", + "expo-image-picker": "~55.0.18", + "expo-linking": "~55.0.13", + "expo-modules-core": "~55.0.22", + "expo-router": "~55.0.12", + "expo-secure-store": "~55.0.14", + "expo-status-bar": "~55.0.5", + "expo-updates": "~55.0.20", "friend-lite-react-native": "^1.0.2", "install": "^0.13.0", "promise": "^8.3.0", - "react": "19.0.0", - "react-native": "0.79.6", + "react": "^19.2.5", + "react-dom": "^19.2.5", + "react-native": "0.83.4", "react-native-base64": "^0.2.1", "react-native-ble-plx": "^3.5.0", - "react-native-safe-area-context": "5.4.0", - "react-native-screens": "~4.11.1", + "react-native-safe-area-context": "~5.6.2", + "react-native-screens": "~4.23.0", "setimmediate": "^1.0.5", "webidl-conversions": "^7.0.0" }, "devDependencies": { "@babel/core": "^7.20.0", "@react-native-community/cli": "latest", - "@types/react": "~19.0.10", - "typescript": "~5.8.3" - } - }, - "node_modules/@0no-co/graphql.web": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/@0no-co/graphql.web/-/graphql.web-1.2.0.tgz", - "integrity": "sha512-/1iHy9TTr63gE1YcR5idjx8UREz1s0kFhydf3bBLCXyqjhkIc6igAzTOx3zPifCwFR87tsh/4Pa9cNts6d2otw==", - "license": "MIT", - "peerDependencies": { - "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0" - }, - "peerDependenciesMeta": { - "graphql": { - "optional": true - } + "@types/react": "~19.2.0", + "typescript": "~5.9.2" } }, "node_modules/@babel/code-frame": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz", - "integrity": "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==", + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", + "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", "license": "MIT", "dependencies": { - "@babel/helper-validator-identifier": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" }, @@ -69,30 +64,30 @@ } }, "node_modules/@babel/compat-data": { - "version": "7.28.4", - "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.28.4.tgz", - "integrity": "sha512-YsmSKC29MJwf0gF8Rjjrg5LQCmyh+j/nD8/eP7f+BeoQTKYqs9RoWbjGOdy0+1Ekr68RJZMUOPVQaQisnIo4Rw==", + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.0.tgz", + "integrity": "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==", "license": "MIT", "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/core": { - "version": "7.28.4", - "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.4.tgz", - "integrity": "sha512-2BCOP7TN8M+gVDj7/ht3hsaO/B/n5oDbiAyyvnRlNOs+u1o+JWNYTQrmpuNp1/Wq2gcFrI01JAW+paEKDMx/CA==", + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz", + "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", "license": "MIT", "peer": true, "dependencies": { - "@babel/code-frame": "^7.27.1", - "@babel/generator": "^7.28.3", - "@babel/helper-compilation-targets": "^7.27.2", - "@babel/helper-module-transforms": "^7.28.3", - "@babel/helpers": "^7.28.4", - "@babel/parser": "^7.28.4", - "@babel/template": "^7.27.2", - "@babel/traverse": "^7.28.4", - "@babel/types": "^7.28.4", + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-compilation-targets": "^7.28.6", + "@babel/helper-module-transforms": "^7.28.6", + "@babel/helpers": "^7.28.6", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/traverse": "^7.29.0", + "@babel/types": "^7.29.0", "@jridgewell/remapping": "^2.3.5", "convert-source-map": "^2.0.0", "debug": "^4.1.0", @@ -109,13 +104,13 @@ } }, "node_modules/@babel/generator": { - "version": "7.28.3", - "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.28.3.tgz", - "integrity": "sha512-3lSpxGgvnmZznmBkCRnVREPUFJv2wrv9iAoFDvADJc0ypmdOxdUtcLeBgBJ6zE0PMeTKnxeQzyk0xTBq4Ep7zw==", + "version": "7.29.1", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz", + "integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==", "license": "MIT", "dependencies": { - "@babel/parser": "^7.28.3", - "@babel/types": "^7.28.2", + "@babel/parser": "^7.29.0", + "@babel/types": "^7.29.0", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" @@ -137,12 +132,12 @@ } }, "node_modules/@babel/helper-compilation-targets": { - "version": "7.27.2", - "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.27.2.tgz", - "integrity": "sha512-2+1thGUUWWjLTYTHZWK1n8Yga0ijBz1XAhUXcKy81rd5g6yh7hGqMp45v7cadSbEHc9G3OTv45SyneRN3ps4DQ==", + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz", + "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==", "license": "MIT", "dependencies": { - "@babel/compat-data": "^7.27.2", + "@babel/compat-data": "^7.28.6", "@babel/helper-validator-option": "^7.27.1", "browserslist": "^4.24.0", "lru-cache": "^5.1.1", @@ -153,17 +148,17 @@ } }, "node_modules/@babel/helper-create-class-features-plugin": { - "version": "7.28.3", - "resolved": "https://registry.npmjs.org/@babel/helper-create-class-features-plugin/-/helper-create-class-features-plugin-7.28.3.tgz", - "integrity": "sha512-V9f6ZFIYSLNEbuGA/92uOvYsGCJNsuA8ESZ4ldc09bWk/j8H8TKiPw8Mk1eG6olpnO0ALHJmYfZvF4MEE4gajg==", + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-create-class-features-plugin/-/helper-create-class-features-plugin-7.28.6.tgz", + "integrity": "sha512-dTOdvsjnG3xNT9Y0AUg1wAl38y+4Rl4sf9caSQZOXdNqVn+H+HbbJ4IyyHaIqNR6SW9oJpA/RuRjsjCw2IdIow==", "license": "MIT", "dependencies": { "@babel/helper-annotate-as-pure": "^7.27.3", - "@babel/helper-member-expression-to-functions": "^7.27.1", + "@babel/helper-member-expression-to-functions": "^7.28.5", "@babel/helper-optimise-call-expression": "^7.27.1", - "@babel/helper-replace-supers": "^7.27.1", + "@babel/helper-replace-supers": "^7.28.6", "@babel/helper-skip-transparent-expression-wrappers": "^7.27.1", - "@babel/traverse": "^7.28.3", + "@babel/traverse": "^7.28.6", "semver": "^6.3.1" }, "engines": { @@ -174,13 +169,13 @@ } }, "node_modules/@babel/helper-create-regexp-features-plugin": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-create-regexp-features-plugin/-/helper-create-regexp-features-plugin-7.27.1.tgz", - "integrity": "sha512-uVDC72XVf8UbrH5qQTc18Agb8emwjTiZrQE11Nv3CuBEZmVvTwwE9CBUEvHku06gQCAyYf8Nv6ja1IN+6LMbxQ==", + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-create-regexp-features-plugin/-/helper-create-regexp-features-plugin-7.28.5.tgz", + "integrity": "sha512-N1EhvLtHzOvj7QQOUCCS3NrPJP8c5W6ZXCHDn7Yialuy1iu4r5EmIYkXlKNqT99Ciw+W0mDqWoR6HWMZlFP3hw==", "license": "MIT", "dependencies": { - "@babel/helper-annotate-as-pure": "^7.27.1", - "regexpu-core": "^6.2.0", + "@babel/helper-annotate-as-pure": "^7.27.3", + "regexpu-core": "^6.3.1", "semver": "^6.3.1" }, "engines": { @@ -191,16 +186,16 @@ } }, "node_modules/@babel/helper-define-polyfill-provider": { - "version": "0.6.5", - "resolved": "https://registry.npmjs.org/@babel/helper-define-polyfill-provider/-/helper-define-polyfill-provider-0.6.5.tgz", - "integrity": "sha512-uJnGFcPsWQK8fvjgGP5LZUZZsYGIoPeRjSF5PGwrelYgq7Q15/Ft9NGFp1zglwgIv//W0uG4BevRuSJRyylZPg==", + "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.27.2", - "@babel/helper-plugin-utils": "^7.27.1", - "debug": "^4.4.1", + "@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.10" + "resolve": "^1.22.11" }, "peerDependencies": { "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" @@ -216,40 +211,40 @@ } }, "node_modules/@babel/helper-member-expression-to-functions": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-member-expression-to-functions/-/helper-member-expression-to-functions-7.27.1.tgz", - "integrity": "sha512-E5chM8eWjTp/aNoVpcbfM7mLxu9XGLWYise2eBKGQomAk/Mb4XoxyqXTZbuTohbsl8EKqdlMhnDI2CCLfcs9wA==", + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-member-expression-to-functions/-/helper-member-expression-to-functions-7.28.5.tgz", + "integrity": "sha512-cwM7SBRZcPCLgl8a7cY0soT1SptSzAlMH39vwiRpOQkJlh53r5hdHwLSCZpQdVLT39sZt+CRpNwYG4Y2v77atg==", "license": "MIT", "dependencies": { - "@babel/traverse": "^7.27.1", - "@babel/types": "^7.27.1" + "@babel/traverse": "^7.28.5", + "@babel/types": "^7.28.5" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-module-imports": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.27.1.tgz", - "integrity": "sha512-0gSFWUPNXNopqtIPQvlD5WgXYI5GY2kP2cCvoT8kczjbfcfuIljTbcWrulD1CIPIX2gt1wghbDy08yE1p+/r3w==", + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz", + "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==", "license": "MIT", "dependencies": { - "@babel/traverse": "^7.27.1", - "@babel/types": "^7.27.1" + "@babel/traverse": "^7.28.6", + "@babel/types": "^7.28.6" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-module-transforms": { - "version": "7.28.3", - "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.3.tgz", - "integrity": "sha512-gytXUbs8k2sXS9PnQptz5o0QnpLL51SwASIORY6XaBKF88nsOT0Zw9szLqlSGQDP/4TljBAD5y98p2U1fqkdsw==", + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz", + "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==", "license": "MIT", "dependencies": { - "@babel/helper-module-imports": "^7.27.1", - "@babel/helper-validator-identifier": "^7.27.1", - "@babel/traverse": "^7.28.3" + "@babel/helper-module-imports": "^7.28.6", + "@babel/helper-validator-identifier": "^7.28.5", + "@babel/traverse": "^7.28.6" }, "engines": { "node": ">=6.9.0" @@ -271,9 +266,9 @@ } }, "node_modules/@babel/helper-plugin-utils": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.27.1.tgz", - "integrity": "sha512-1gn1Up5YXka3YYAHGKpbideQ5Yjf1tDa9qYcgysz+cNCXukyLl6DjPXhD3VRwSb8c0J9tA4b2+rHEZtc6R0tlw==", + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.28.6.tgz", + "integrity": "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==", "license": "MIT", "engines": { "node": ">=6.9.0" @@ -297,14 +292,14 @@ } }, "node_modules/@babel/helper-replace-supers": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-replace-supers/-/helper-replace-supers-7.27.1.tgz", - "integrity": "sha512-7EHz6qDZc8RYS5ElPoShMheWvEgERonFCs7IAonWLLUTXW59DP14bCZt89/GKyreYn8g3S83m21FelHKbeDCKA==", + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-replace-supers/-/helper-replace-supers-7.28.6.tgz", + "integrity": "sha512-mq8e+laIk94/yFec3DxSjCRD2Z0TAjhVbEJY3UQrlwVo15Lmt7C2wAUbK4bjnTs4APkwsYLTahXRraQXhb1WCg==", "license": "MIT", "dependencies": { - "@babel/helper-member-expression-to-functions": "^7.27.1", + "@babel/helper-member-expression-to-functions": "^7.28.5", "@babel/helper-optimise-call-expression": "^7.27.1", - "@babel/traverse": "^7.27.1" + "@babel/traverse": "^7.28.6" }, "engines": { "node": ">=6.9.0" @@ -336,9 +331,9 @@ } }, "node_modules/@babel/helper-validator-identifier": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.27.1.tgz", - "integrity": "sha512-D2hP9eA+Sqx1kBZgzxZh0y1trbuU+JoDkiEwqhQ36nodYqJwyEIhPSdMNd7lOm/4io72luTPWH20Yda0xOuUow==", + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", "license": "MIT", "engines": { "node": ">=6.9.0" @@ -354,125 +349,39 @@ } }, "node_modules/@babel/helper-wrap-function": { - "version": "7.28.3", - "resolved": "https://registry.npmjs.org/@babel/helper-wrap-function/-/helper-wrap-function-7.28.3.tgz", - "integrity": "sha512-zdf983tNfLZFletc0RRXYrHrucBEg95NIFMkn6K9dbeMYnsgHaSBGcQqdsCSStG2PYwRre0Qc2NNSCXbG+xc6g==", + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-wrap-function/-/helper-wrap-function-7.28.6.tgz", + "integrity": "sha512-z+PwLziMNBeSQJonizz2AGnndLsP2DeGHIxDAn+wdHOGuo4Fo1x1HBPPXeE9TAOPHNNWQKCSlA2VZyYyyibDnQ==", "license": "MIT", "dependencies": { - "@babel/template": "^7.27.2", - "@babel/traverse": "^7.28.3", - "@babel/types": "^7.28.2" + "@babel/template": "^7.28.6", + "@babel/traverse": "^7.28.6", + "@babel/types": "^7.28.6" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helpers": { - "version": "7.28.4", - "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.28.4.tgz", - "integrity": "sha512-HFN59MmQXGHVyYadKLVumYsA9dBFun/ldYxipEjzA4196jpLZd8UjEEBLkbEkvfYreDqJhZxYAWFPtrfhNpj4w==", - "license": "MIT", - "dependencies": { - "@babel/template": "^7.27.2", - "@babel/types": "^7.28.4" - }, - "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==", + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.2.tgz", + "integrity": "sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw==", "license": "MIT", "dependencies": { - "@babel/helper-validator-identifier": "^7.25.9", - "chalk": "^2.4.2", - "js-tokens": "^4.0.0", - "picocolors": "^1.0.0" + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.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.28.4", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.4.tgz", - "integrity": "sha512-yZbBqeM6TkpP9du/I2pUZnJsRMGGvOuIrhjzC1AwHwW+6he4mni6Bp/m8ijn0iOuZuPI2BfkCoSRunpyjnrQKg==", + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.2.tgz", + "integrity": "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==", "license": "MIT", "dependencies": { - "@babel/types": "^7.28.4" + "@babel/types": "^7.29.0" }, "bin": { "parser": "bin/babel-parser.js" @@ -482,14 +391,14 @@ } }, "node_modules/@babel/plugin-proposal-decorators": { - "version": "7.28.0", - "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-decorators/-/plugin-proposal-decorators-7.28.0.tgz", - "integrity": "sha512-zOiZqvANjWDUaUS9xMxbMcK/Zccztbe/6ikvUXaG9nsPH3w6qh5UaPGAnirI/WhIbZ8m3OHU0ReyPrknG+ZKeg==", + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-decorators/-/plugin-proposal-decorators-7.29.0.tgz", + "integrity": "sha512-CVBVv3VY/XRMxRYq5dwr2DS7/MvqPm23cOCjbwNnVrfOqcWlnefua1uUs0sjdKOGjvPUG633o07uWzJq4oI6dA==", "license": "MIT", "dependencies": { - "@babel/helper-create-class-features-plugin": "^7.27.1", - "@babel/helper-plugin-utils": "^7.27.1", - "@babel/plugin-syntax-decorators": "^7.27.1" + "@babel/helper-create-class-features-plugin": "^7.28.6", + "@babel/helper-plugin-utils": "^7.28.6", + "@babel/plugin-syntax-decorators": "^7.28.6" }, "engines": { "node": ">=6.9.0" @@ -565,12 +474,12 @@ } }, "node_modules/@babel/plugin-syntax-decorators": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-decorators/-/plugin-syntax-decorators-7.27.1.tgz", - "integrity": "sha512-YMq8Z87Lhl8EGkmb0MwYkt36QnxC+fzCgrl66ereamPlYToRpIk5nUjKUY3QKLWq8mwUB1BgbeXcTJhZOCDg5A==", + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-decorators/-/plugin-syntax-decorators-7.28.6.tgz", + "integrity": "sha512-71EYI0ONURHJBL4rSFXnITXqXrrY8q4P0q006DPfN+Rk+ASM+++IBXem/ruokgBZR8YNEWZ8R6B+rCb8VcUTqA==", "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" + "@babel/helper-plugin-utils": "^7.28.6" }, "engines": { "node": ">=6.9.0" @@ -592,12 +501,12 @@ } }, "node_modules/@babel/plugin-syntax-export-default-from": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-export-default-from/-/plugin-syntax-export-default-from-7.27.1.tgz", - "integrity": "sha512-eBC/3KSekshx19+N40MzjWqJd7KTEdOoLesAfa4IDFI8eRz5a47i5Oszus6zG/cwIXN63YhgLOMSSNJx49sENg==", + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-export-default-from/-/plugin-syntax-export-default-from-7.28.6.tgz", + "integrity": "sha512-Svlx1fjJFnNz0LZeUaybRukSxZI3KkpApUmIRzEdXC5k8ErTOz0OD0kNrICi5Vc3GlpP5ZCeRyRO+mfWTSz+iQ==", "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" + "@babel/helper-plugin-utils": "^7.28.6" }, "engines": { "node": ">=6.9.0" @@ -607,12 +516,12 @@ } }, "node_modules/@babel/plugin-syntax-flow": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-flow/-/plugin-syntax-flow-7.27.1.tgz", - "integrity": "sha512-p9OkPbZ5G7UT1MofwYFigGebnrzGJacoBSQM0/6bi/PUMVE+qlWDD/OalvQKbwgQzU6dl0xAv6r4X7Jme0RYxA==", + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-flow/-/plugin-syntax-flow-7.28.6.tgz", + "integrity": "sha512-D+OrJumc9McXNEBI/JmFnc/0uCM2/Y3PEBG3gfV3QIYkKv5pvnpzFrl1kYCrcHJP8nOeFB/SHi1IHz29pNGuew==", "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" + "@babel/helper-plugin-utils": "^7.28.6" }, "engines": { "node": ">=6.9.0" @@ -622,12 +531,12 @@ } }, "node_modules/@babel/plugin-syntax-import-attributes": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-attributes/-/plugin-syntax-import-attributes-7.27.1.tgz", - "integrity": "sha512-oFT0FrKHgF53f4vOsZGi2Hh3I35PfSmVs4IBFLFj4dnafP+hIWDLg3VyKmUHfLoLHlyxY4C7DGtmHuJgn+IGww==", + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-attributes/-/plugin-syntax-import-attributes-7.28.6.tgz", + "integrity": "sha512-jiLC0ma9XkQT3TKJ9uYvlakm66Pamywo+qwL+oL8HJOvc6TWdZXVfhqJr8CCzbSGUAbDOzlGHJC1U+vRfLQDvw==", "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" + "@babel/helper-plugin-utils": "^7.28.6" }, "engines": { "node": ">=6.9.0" @@ -661,12 +570,12 @@ } }, "node_modules/@babel/plugin-syntax-jsx": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-jsx/-/plugin-syntax-jsx-7.27.1.tgz", - "integrity": "sha512-y8YTNIeKoyhGd9O0Jiyzyyqk8gdjnumGTQPsz0xOZOQ2RmkVJeZ1vmmfIvFEKqucBG6axJGBZDE/7iI5suUI/w==", + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-jsx/-/plugin-syntax-jsx-7.28.6.tgz", + "integrity": "sha512-wgEmr06G6sIpqr8YDwA2dSRTE3bJ+V0IfpzfSY3Lfgd7YWOaAdlykvJi13ZKBt8cZHfgH1IXN+CL656W3uUa4w==", "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" + "@babel/helper-plugin-utils": "^7.28.6" }, "engines": { "node": ">=6.9.0" @@ -778,12 +687,12 @@ } }, "node_modules/@babel/plugin-syntax-typescript": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-typescript/-/plugin-syntax-typescript-7.27.1.tgz", - "integrity": "sha512-xfYCBMxveHrRMnAWl1ZlPXOZjzkN82THFvLhQhFXFt81Z5HnN+EtUkZhv/zcKpmT3fzmWZB0ywiBrbC3vogbwQ==", + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-typescript/-/plugin-syntax-typescript-7.28.6.tgz", + "integrity": "sha512-+nDNmQye7nlnuuHDboPbGm00Vqg3oO8niRRL27/4LYHUsHYh0zJ1xWOz0uRwNFmM1Avzk8wZbc6rdiYhomzv/A==", "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" + "@babel/helper-plugin-utils": "^7.28.6" }, "engines": { "node": ">=6.9.0" @@ -808,14 +717,14 @@ } }, "node_modules/@babel/plugin-transform-async-generator-functions": { - "version": "7.28.0", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-async-generator-functions/-/plugin-transform-async-generator-functions-7.28.0.tgz", - "integrity": "sha512-BEOdvX4+M765icNPZeidyADIvQ1m1gmunXufXxvRESy/jNNyfovIqUyE7MVgGBjWktCoJlzvFA1To2O4ymIO3Q==", + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-async-generator-functions/-/plugin-transform-async-generator-functions-7.29.0.tgz", + "integrity": "sha512-va0VdWro4zlBr2JsXC+ofCPB2iG12wPtVGTWFx2WLDOM3nYQZZIGP82qku2eW/JR83sD+k2k+CsNtyEbUqhU6w==", "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1", + "@babel/helper-plugin-utils": "^7.28.6", "@babel/helper-remap-async-to-generator": "^7.27.1", - "@babel/traverse": "^7.28.0" + "@babel/traverse": "^7.29.0" }, "engines": { "node": ">=6.9.0" @@ -825,13 +734,13 @@ } }, "node_modules/@babel/plugin-transform-async-to-generator": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-async-to-generator/-/plugin-transform-async-to-generator-7.27.1.tgz", - "integrity": "sha512-NREkZsZVJS4xmTr8qzE5y8AfIPqsdQfRuUiLRTEzb7Qii8iFWCyDKaUV2c0rCuh4ljDZ98ALHP/PetiBV2nddA==", + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-async-to-generator/-/plugin-transform-async-to-generator-7.28.6.tgz", + "integrity": "sha512-ilTRcmbuXjsMmcZ3HASTe4caH5Tpo93PkTxF9oG2VZsSWsahydmcEHhix9Ik122RcTnZnUzPbmux4wh1swfv7g==", "license": "MIT", "dependencies": { - "@babel/helper-module-imports": "^7.27.1", - "@babel/helper-plugin-utils": "^7.27.1", + "@babel/helper-module-imports": "^7.28.6", + "@babel/helper-plugin-utils": "^7.28.6", "@babel/helper-remap-async-to-generator": "^7.27.1" }, "engines": { @@ -842,12 +751,12 @@ } }, "node_modules/@babel/plugin-transform-block-scoping": { - "version": "7.28.4", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-block-scoping/-/plugin-transform-block-scoping-7.28.4.tgz", - "integrity": "sha512-1yxmvN0MJHOhPVmAsmoW5liWwoILobu/d/ShymZmj867bAdxGbehIrew1DuLpw2Ukv+qDSSPQdYW1dLNE7t11A==", + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-block-scoping/-/plugin-transform-block-scoping-7.28.6.tgz", + "integrity": "sha512-tt/7wOtBmwHPNMPu7ax4pdPz6shjFrmHDghvNC+FG9Qvj7D6mJcoRQIF5dy4njmxR941l6rgtvfSB2zX3VlUIw==", "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" + "@babel/helper-plugin-utils": "^7.28.6" }, "engines": { "node": ">=6.9.0" @@ -857,13 +766,13 @@ } }, "node_modules/@babel/plugin-transform-class-properties": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-class-properties/-/plugin-transform-class-properties-7.27.1.tgz", - "integrity": "sha512-D0VcalChDMtuRvJIu3U/fwWjf8ZMykz5iZsg77Nuj821vCKI3zCyRLwRdWbsuJ/uRwZhZ002QtCqIkwC/ZkvbA==", + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-class-properties/-/plugin-transform-class-properties-7.28.6.tgz", + "integrity": "sha512-dY2wS3I2G7D697VHndN91TJr8/AAfXQNt5ynCTI/MpxMsSzHp+52uNivYT5wCPax3whc47DR8Ba7cmlQMg24bw==", "license": "MIT", "dependencies": { - "@babel/helper-create-class-features-plugin": "^7.27.1", - "@babel/helper-plugin-utils": "^7.27.1" + "@babel/helper-create-class-features-plugin": "^7.28.6", + "@babel/helper-plugin-utils": "^7.28.6" }, "engines": { "node": ">=6.9.0" @@ -872,18 +781,34 @@ "@babel/core": "^7.0.0-0" } }, + "node_modules/@babel/plugin-transform-class-static-block": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-class-static-block/-/plugin-transform-class-static-block-7.28.6.tgz", + "integrity": "sha512-rfQ++ghVwTWTqQ7w8qyDxL1XGihjBss4CmTgGRCTAC9RIbhVpyp4fOeZtta0Lbf+dTNIVJer6ych2ibHwkZqsQ==", + "license": "MIT", + "dependencies": { + "@babel/helper-create-class-features-plugin": "^7.28.6", + "@babel/helper-plugin-utils": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.12.0" + } + }, "node_modules/@babel/plugin-transform-classes": { - "version": "7.28.4", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-classes/-/plugin-transform-classes-7.28.4.tgz", - "integrity": "sha512-cFOlhIYPBv/iBoc+KS3M6et2XPtbT2HiCRfBXWtfpc9OAyostldxIf9YAYB6ypURBBbx+Qv6nyrLzASfJe+hBA==", + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-classes/-/plugin-transform-classes-7.28.6.tgz", + "integrity": "sha512-EF5KONAqC5zAqT783iMGuM2ZtmEBy+mJMOKl2BCvPZ2lVrwvXnB6o+OBWCS+CoeCCpVRF2sA2RBKUxvT8tQT5Q==", "license": "MIT", "dependencies": { "@babel/helper-annotate-as-pure": "^7.27.3", - "@babel/helper-compilation-targets": "^7.27.2", + "@babel/helper-compilation-targets": "^7.28.6", "@babel/helper-globals": "^7.28.0", - "@babel/helper-plugin-utils": "^7.27.1", - "@babel/helper-replace-supers": "^7.27.1", - "@babel/traverse": "^7.28.4" + "@babel/helper-plugin-utils": "^7.28.6", + "@babel/helper-replace-supers": "^7.28.6", + "@babel/traverse": "^7.28.6" }, "engines": { "node": ">=6.9.0" @@ -893,13 +818,13 @@ } }, "node_modules/@babel/plugin-transform-computed-properties": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-computed-properties/-/plugin-transform-computed-properties-7.27.1.tgz", - "integrity": "sha512-lj9PGWvMTVksbWiDT2tW68zGS/cyo4AkZ/QTp0sQT0mjPopCmrSkzxeXkznjqBxzDI6TclZhOJbBmbBLjuOZUw==", + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-computed-properties/-/plugin-transform-computed-properties-7.28.6.tgz", + "integrity": "sha512-bcc3k0ijhHbc2lEfpFHgx7eYw9KNXqOerKWfzbxEHUGKnS3sz9C4CNL9OiFN1297bDNfUiSO7DaLzbvHQQQ1BQ==", "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1", - "@babel/template": "^7.27.1" + "@babel/helper-plugin-utils": "^7.28.6", + "@babel/template": "^7.28.6" }, "engines": { "node": ">=6.9.0" @@ -909,13 +834,13 @@ } }, "node_modules/@babel/plugin-transform-destructuring": { - "version": "7.28.0", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-destructuring/-/plugin-transform-destructuring-7.28.0.tgz", - "integrity": "sha512-v1nrSMBiKcodhsyJ4Gf+Z0U/yawmJDBOTpEB3mcQY52r9RIyPneGyAS/yM6seP/8I+mWI3elOMtT5dB8GJVs+A==", + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-destructuring/-/plugin-transform-destructuring-7.28.5.tgz", + "integrity": "sha512-Kl9Bc6D0zTUcFUvkNuQh4eGXPKKNDOJQXVyyM4ZAQPMveniJdxi8XMJwLo+xSoW3MIq81bD33lcUe9kZpl0MCw==", "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.27.1", - "@babel/traverse": "^7.28.0" + "@babel/traverse": "^7.28.5" }, "engines": { "node": ">=6.9.0" @@ -1004,12 +929,12 @@ } }, "node_modules/@babel/plugin-transform-logical-assignment-operators": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-logical-assignment-operators/-/plugin-transform-logical-assignment-operators-7.27.1.tgz", - "integrity": "sha512-SJvDs5dXxiae4FbSL1aBJlG4wvl594N6YEVVn9e3JGulwioy6z3oPjx/sQBO3Y4NwUu5HNix6KJ3wBZoewcdbw==", + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-logical-assignment-operators/-/plugin-transform-logical-assignment-operators-7.28.6.tgz", + "integrity": "sha512-+anKKair6gpi8VsM/95kmomGNMD0eLz1NQ8+Pfw5sAwWH9fGYXT50E55ZpV0pHUHWf6IUTWPM+f/7AAff+wr9A==", "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" + "@babel/helper-plugin-utils": "^7.28.6" }, "engines": { "node": ">=6.9.0" @@ -1019,13 +944,13 @@ } }, "node_modules/@babel/plugin-transform-modules-commonjs": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-commonjs/-/plugin-transform-modules-commonjs-7.27.1.tgz", - "integrity": "sha512-OJguuwlTYlN0gBZFRPqwOGNWssZjfIUdS7HMYtN8c1KmwpwHFBwTeFZrg9XZa+DFTitWOW5iTAG7tyCUPsCCyw==", + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-commonjs/-/plugin-transform-modules-commonjs-7.28.6.tgz", + "integrity": "sha512-jppVbf8IV9iWWwWTQIxJMAJCWBuuKx71475wHwYytrRGQ2CWiDvYlADQno3tcYpS/T2UUWFQp3nVtYfK/YBQrA==", "license": "MIT", "dependencies": { - "@babel/helper-module-transforms": "^7.27.1", - "@babel/helper-plugin-utils": "^7.27.1" + "@babel/helper-module-transforms": "^7.28.6", + "@babel/helper-plugin-utils": "^7.28.6" }, "engines": { "node": ">=6.9.0" @@ -1035,13 +960,13 @@ } }, "node_modules/@babel/plugin-transform-named-capturing-groups-regex": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-named-capturing-groups-regex/-/plugin-transform-named-capturing-groups-regex-7.27.1.tgz", - "integrity": "sha512-SstR5JYy8ddZvD6MhV0tM/j16Qds4mIpJTOd1Yu9J9pJjH93bxHECF7pgtc28XvkzTD6Pxcm/0Z73Hvk7kb3Ng==", + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-named-capturing-groups-regex/-/plugin-transform-named-capturing-groups-regex-7.29.0.tgz", + "integrity": "sha512-1CZQA5KNAD6ZYQLPw7oi5ewtDNxH/2vuCh+6SmvgDfhumForvs8a1o9n0UrEoBD8HU4djO2yWngTQlXl1NDVEQ==", "license": "MIT", "dependencies": { - "@babel/helper-create-regexp-features-plugin": "^7.27.1", - "@babel/helper-plugin-utils": "^7.27.1" + "@babel/helper-create-regexp-features-plugin": "^7.28.5", + "@babel/helper-plugin-utils": "^7.28.6" }, "engines": { "node": ">=6.9.0" @@ -1051,12 +976,12 @@ } }, "node_modules/@babel/plugin-transform-nullish-coalescing-operator": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-nullish-coalescing-operator/-/plugin-transform-nullish-coalescing-operator-7.27.1.tgz", - "integrity": "sha512-aGZh6xMo6q9vq1JGcw58lZ1Z0+i0xB2x0XaauNIUXd6O1xXc3RwoWEBlsTQrY4KQ9Jf0s5rgD6SiNkaUdJegTA==", + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-nullish-coalescing-operator/-/plugin-transform-nullish-coalescing-operator-7.28.6.tgz", + "integrity": "sha512-3wKbRgmzYbw24mDJXT7N+ADXw8BC/imU9yo9c9X9NKaLF1fW+e5H1U5QjMUBe4Qo4Ox/o++IyUkl1sVCLgevKg==", "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" + "@babel/helper-plugin-utils": "^7.28.6" }, "engines": { "node": ">=6.9.0" @@ -1066,12 +991,12 @@ } }, "node_modules/@babel/plugin-transform-numeric-separator": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-numeric-separator/-/plugin-transform-numeric-separator-7.27.1.tgz", - "integrity": "sha512-fdPKAcujuvEChxDBJ5c+0BTaS6revLV7CJL08e4m3de8qJfNIuCc2nc7XJYOjBoTMJeqSmwXJ0ypE14RCjLwaw==", + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-numeric-separator/-/plugin-transform-numeric-separator-7.28.6.tgz", + "integrity": "sha512-SJR8hPynj8outz+SlStQSwvziMN4+Bq99it4tMIf5/Caq+3iOc0JtKyse8puvyXkk3eFRIA5ID/XfunGgO5i6w==", "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" + "@babel/helper-plugin-utils": "^7.28.6" }, "engines": { "node": ">=6.9.0" @@ -1081,16 +1006,16 @@ } }, "node_modules/@babel/plugin-transform-object-rest-spread": { - "version": "7.28.4", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-object-rest-spread/-/plugin-transform-object-rest-spread-7.28.4.tgz", - "integrity": "sha512-373KA2HQzKhQCYiRVIRr+3MjpCObqzDlyrM6u4I201wL8Mp2wHf7uB8GhDwis03k2ti8Zr65Zyyqs1xOxUF/Ew==", + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-object-rest-spread/-/plugin-transform-object-rest-spread-7.28.6.tgz", + "integrity": "sha512-5rh+JR4JBC4pGkXLAcYdLHZjXudVxWMXbB6u6+E9lRL5TrGVbHt1TjxGbZ8CkmYw9zjkB7jutzOROArsqtncEA==", "license": "MIT", "dependencies": { - "@babel/helper-compilation-targets": "^7.27.2", - "@babel/helper-plugin-utils": "^7.27.1", - "@babel/plugin-transform-destructuring": "^7.28.0", + "@babel/helper-compilation-targets": "^7.28.6", + "@babel/helper-plugin-utils": "^7.28.6", + "@babel/plugin-transform-destructuring": "^7.28.5", "@babel/plugin-transform-parameters": "^7.27.7", - "@babel/traverse": "^7.28.4" + "@babel/traverse": "^7.28.6" }, "engines": { "node": ">=6.9.0" @@ -1100,12 +1025,12 @@ } }, "node_modules/@babel/plugin-transform-optional-catch-binding": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-optional-catch-binding/-/plugin-transform-optional-catch-binding-7.27.1.tgz", - "integrity": "sha512-txEAEKzYrHEX4xSZN4kJ+OfKXFVSWKB2ZxM9dpcE3wT7smwkNmXo5ORRlVzMVdJbD+Q8ILTgSD7959uj+3Dm3Q==", + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-optional-catch-binding/-/plugin-transform-optional-catch-binding-7.28.6.tgz", + "integrity": "sha512-R8ja/Pyrv0OGAvAXQhSTmWyPJPml+0TMqXlO5w+AsMEiwb2fg3WkOvob7UxFSL3OIttFSGSRFKQsOhJ/X6HQdQ==", "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" + "@babel/helper-plugin-utils": "^7.28.6" }, "engines": { "node": ">=6.9.0" @@ -1115,12 +1040,12 @@ } }, "node_modules/@babel/plugin-transform-optional-chaining": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-optional-chaining/-/plugin-transform-optional-chaining-7.27.1.tgz", - "integrity": "sha512-BQmKPPIuc8EkZgNKsv0X4bPmOoayeu4F1YCwx2/CfmDSXDbp7GnzlUH+/ul5VGfRg1AoFPsrIThlEBj2xb4CAg==", + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-optional-chaining/-/plugin-transform-optional-chaining-7.28.6.tgz", + "integrity": "sha512-A4zobikRGJTsX9uqVFdafzGkqD30t26ck2LmOzAuLL8b2x6k3TIqRiT2xVvA9fNmFeTX484VpsdgmKNA0bS23w==", "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1", + "@babel/helper-plugin-utils": "^7.28.6", "@babel/helper-skip-transparent-expression-wrappers": "^7.27.1" }, "engines": { @@ -1146,13 +1071,13 @@ } }, "node_modules/@babel/plugin-transform-private-methods": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-private-methods/-/plugin-transform-private-methods-7.27.1.tgz", - "integrity": "sha512-10FVt+X55AjRAYI9BrdISN9/AQWHqldOeZDUoLyif1Kn05a56xVBXb8ZouL8pZ9jem8QpXaOt8TS7RHUIS+GPA==", + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-private-methods/-/plugin-transform-private-methods-7.28.6.tgz", + "integrity": "sha512-piiuapX9CRv7+0st8lmuUlRSmX6mBcVeNQ1b4AYzJxfCMuBfB0vBXDiGSmm03pKJw1v6cZ8KSeM+oUnM6yAExg==", "license": "MIT", "dependencies": { - "@babel/helper-create-class-features-plugin": "^7.27.1", - "@babel/helper-plugin-utils": "^7.27.1" + "@babel/helper-create-class-features-plugin": "^7.28.6", + "@babel/helper-plugin-utils": "^7.28.6" }, "engines": { "node": ">=6.9.0" @@ -1162,14 +1087,14 @@ } }, "node_modules/@babel/plugin-transform-private-property-in-object": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-private-property-in-object/-/plugin-transform-private-property-in-object-7.27.1.tgz", - "integrity": "sha512-5J+IhqTi1XPa0DXF83jYOaARrX+41gOewWbkPyjMNRDqgOCqdffGh8L3f/Ek5utaEBZExjSAzcyjmV9SSAWObQ==", + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-private-property-in-object/-/plugin-transform-private-property-in-object-7.28.6.tgz", + "integrity": "sha512-b97jvNSOb5+ehyQmBpmhOCiUC5oVK4PMnpRvO7+ymFBoqYjeDHIU9jnrNUuwHOiL9RpGDoKBpSViarV+BU+eVA==", "license": "MIT", "dependencies": { - "@babel/helper-annotate-as-pure": "^7.27.1", - "@babel/helper-create-class-features-plugin": "^7.27.1", - "@babel/helper-plugin-utils": "^7.27.1" + "@babel/helper-annotate-as-pure": "^7.27.3", + "@babel/helper-create-class-features-plugin": "^7.28.6", + "@babel/helper-plugin-utils": "^7.28.6" }, "engines": { "node": ">=6.9.0" @@ -1194,16 +1119,16 @@ } }, "node_modules/@babel/plugin-transform-react-jsx": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx/-/plugin-transform-react-jsx-7.27.1.tgz", - "integrity": "sha512-2KH4LWGSrJIkVf5tSiBFYuXDAoWRq2MMwgivCf+93dd0GQi8RXLjKA/0EvRnVV5G0hrHczsquXuD01L8s6dmBw==", + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx/-/plugin-transform-react-jsx-7.28.6.tgz", + "integrity": "sha512-61bxqhiRfAACulXSLd/GxqmAedUSrRZIu/cbaT18T1CetkTmtDN15it7i80ru4DVqRK1WMxQhXs+Lf9kajm5Ow==", "license": "MIT", "dependencies": { - "@babel/helper-annotate-as-pure": "^7.27.1", - "@babel/helper-module-imports": "^7.27.1", - "@babel/helper-plugin-utils": "^7.27.1", - "@babel/plugin-syntax-jsx": "^7.27.1", - "@babel/types": "^7.27.1" + "@babel/helper-annotate-as-pure": "^7.27.3", + "@babel/helper-module-imports": "^7.28.6", + "@babel/helper-plugin-utils": "^7.28.6", + "@babel/plugin-syntax-jsx": "^7.28.6", + "@babel/types": "^7.28.6" }, "engines": { "node": ">=6.9.0" @@ -1274,12 +1199,12 @@ } }, "node_modules/@babel/plugin-transform-regenerator": { - "version": "7.28.4", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-regenerator/-/plugin-transform-regenerator-7.28.4.tgz", - "integrity": "sha512-+ZEdQlBoRg9m2NnzvEeLgtvBMO4tkFBw5SQIUgLICgTrumLoU7lr+Oghi6km2PFj+dbUt2u1oby2w3BDO9YQnA==", + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-regenerator/-/plugin-transform-regenerator-7.29.0.tgz", + "integrity": "sha512-FijqlqMA7DmRdg/aINBSs04y8XNTYw/lr1gJ2WsmBnnaNw1iS43EPkJW+zK7z65auG3AWRFXWj+NcTQwYptUog==", "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" + "@babel/helper-plugin-utils": "^7.28.6" }, "engines": { "node": ">=6.9.0" @@ -1289,13 +1214,13 @@ } }, "node_modules/@babel/plugin-transform-runtime": { - "version": "7.28.3", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-runtime/-/plugin-transform-runtime-7.28.3.tgz", - "integrity": "sha512-Y6ab1kGqZ0u42Zv/4a7l0l72n9DKP/MKoKWaUSBylrhNZO2prYuqFOLbn5aW5SIFXwSH93yfjbgllL8lxuGKLg==", + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-runtime/-/plugin-transform-runtime-7.29.0.tgz", + "integrity": "sha512-jlaRT5dJtMaMCV6fAuLbsQMSwz/QkvaHOHOSXRitGGwSpR1blCY4KUKoyP2tYO8vJcqYe8cEj96cqSztv3uF9w==", "license": "MIT", "dependencies": { - "@babel/helper-module-imports": "^7.27.1", - "@babel/helper-plugin-utils": "^7.27.1", + "@babel/helper-module-imports": "^7.28.6", + "@babel/helper-plugin-utils": "^7.28.6", "babel-plugin-polyfill-corejs2": "^0.4.14", "babel-plugin-polyfill-corejs3": "^0.13.0", "babel-plugin-polyfill-regenerator": "^0.6.5", @@ -1324,12 +1249,12 @@ } }, "node_modules/@babel/plugin-transform-spread": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-spread/-/plugin-transform-spread-7.27.1.tgz", - "integrity": "sha512-kpb3HUqaILBJcRFVhFUs6Trdd4mkrzcGXss+6/mxUd273PfbWqSDHRzMT2234gIg2QYfAjvXLSquP1xECSg09Q==", + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-spread/-/plugin-transform-spread-7.28.6.tgz", + "integrity": "sha512-9U4QObUC0FtJl05AsUcodau/RWDytrU6uKgkxu09mLR9HLDAtUMoPuuskm5huQsoktmsYpI+bGmq+iapDcriKA==", "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1", + "@babel/helper-plugin-utils": "^7.28.6", "@babel/helper-skip-transparent-expression-wrappers": "^7.27.1" }, "engines": { @@ -1355,16 +1280,16 @@ } }, "node_modules/@babel/plugin-transform-typescript": { - "version": "7.28.0", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-typescript/-/plugin-transform-typescript-7.28.0.tgz", - "integrity": "sha512-4AEiDEBPIZvLQaWlc9liCavE0xRM0dNca41WtBeM3jgFptfUOSG9z0uteLhq6+3rq+WB6jIvUwKDTpXEHPJ2Vg==", + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-typescript/-/plugin-transform-typescript-7.28.6.tgz", + "integrity": "sha512-0YWL2RFxOqEm9Efk5PvreamxPME8OyY0wM5wh5lHjF+VtVhdneCWGzZeSqzOfiobVqQaNCd2z0tQvnI9DaPWPw==", "license": "MIT", "dependencies": { "@babel/helper-annotate-as-pure": "^7.27.3", - "@babel/helper-create-class-features-plugin": "^7.27.1", - "@babel/helper-plugin-utils": "^7.27.1", + "@babel/helper-create-class-features-plugin": "^7.28.6", + "@babel/helper-plugin-utils": "^7.28.6", "@babel/helper-skip-transparent-expression-wrappers": "^7.27.1", - "@babel/plugin-syntax-typescript": "^7.27.1" + "@babel/plugin-syntax-typescript": "^7.28.6" }, "engines": { "node": ">=6.9.0" @@ -1390,14 +1315,14 @@ } }, "node_modules/@babel/preset-react": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/preset-react/-/preset-react-7.27.1.tgz", - "integrity": "sha512-oJHWh2gLhU9dW9HHr42q0cI0/iHHXTLGe39qvpAZZzagHy0MzYLCnCVV0symeRvzmjHyVU7mw2K06E6u/JwbhA==", + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/preset-react/-/preset-react-7.28.5.tgz", + "integrity": "sha512-Z3J8vhRq7CeLjdC58jLv4lnZ5RKFUJWqH5emvxmv9Hv3BD1T9R/Im713R4MTKwvFaV74ejZ3sM01LyEKk4ugNQ==", "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.27.1", "@babel/helper-validator-option": "^7.27.1", - "@babel/plugin-transform-react-display-name": "^7.27.1", + "@babel/plugin-transform-react-display-name": "^7.28.0", "@babel/plugin-transform-react-jsx": "^7.27.1", "@babel/plugin-transform-react-jsx-development": "^7.27.1", "@babel/plugin-transform-react-pure-annotations": "^7.27.1" @@ -1410,16 +1335,16 @@ } }, "node_modules/@babel/preset-typescript": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/preset-typescript/-/preset-typescript-7.27.1.tgz", - "integrity": "sha512-l7WfQfX0WK4M0v2RudjuQK4u99BS6yLHYEmdtVPP7lKV013zr9DygFuWNlnbvQ9LR+LS0Egz/XAvGx5U9MX0fQ==", + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/preset-typescript/-/preset-typescript-7.28.5.tgz", + "integrity": "sha512-+bQy5WOI2V6LJZpPVxY+yp66XdZ2yifu0Mc1aP5CQKgjn4QM5IN2i5fAZ4xKop47pr8rpVhiAeu+nDQa12C8+g==", "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.27.1", "@babel/helper-validator-option": "^7.27.1", "@babel/plugin-syntax-jsx": "^7.27.1", "@babel/plugin-transform-modules-commonjs": "^7.27.1", - "@babel/plugin-transform-typescript": "^7.27.1" + "@babel/plugin-transform-typescript": "^7.28.5" }, "engines": { "node": ">=6.9.0" @@ -1429,185 +1354,381 @@ } }, "node_modules/@babel/runtime": { - "version": "7.28.4", - "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.4.tgz", - "integrity": "sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ==", + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.2.tgz", + "integrity": "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==", "license": "MIT", + "peer": true, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/template": { - "version": "7.27.2", - "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.27.2.tgz", - "integrity": "sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw==", + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", + "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==", "license": "MIT", "dependencies": { - "@babel/code-frame": "^7.27.1", - "@babel/parser": "^7.27.2", - "@babel/types": "^7.27.1" + "@babel/code-frame": "^7.28.6", + "@babel/parser": "^7.28.6", + "@babel/types": "^7.28.6" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/traverse": { - "version": "7.28.4", - "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.28.4.tgz", - "integrity": "sha512-YEzuboP2qvQavAcjgQNVgsvHIDv6ZpwXvcvjmyySP2DIMuByS/6ioU5G9pYrWHM6T2YDfc7xga9iNzYOs12CFQ==", + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz", + "integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==", "license": "MIT", "dependencies": { - "@babel/code-frame": "^7.27.1", - "@babel/generator": "^7.28.3", + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", "@babel/helper-globals": "^7.28.0", - "@babel/parser": "^7.28.4", - "@babel/template": "^7.27.2", - "@babel/types": "^7.28.4", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0", "debug": "^4.3.1" }, "engines": { "node": ">=6.9.0" } }, - "node_modules/@babel/traverse--for-generate-function-map": { - "name": "@babel/traverse", - "version": "7.28.4", - "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.28.4.tgz", - "integrity": "sha512-YEzuboP2qvQavAcjgQNVgsvHIDv6ZpwXvcvjmyySP2DIMuByS/6ioU5G9pYrWHM6T2YDfc7xga9iNzYOs12CFQ==", + "node_modules/@babel/types": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", + "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", "license": "MIT", "dependencies": { - "@babel/code-frame": "^7.27.1", - "@babel/generator": "^7.28.3", - "@babel/helper-globals": "^7.28.0", - "@babel/parser": "^7.28.4", - "@babel/template": "^7.27.2", - "@babel/types": "^7.28.4", - "debug": "^4.3.1" + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" }, "engines": { "node": ">=6.9.0" } }, - "node_modules/@babel/types": { - "version": "7.28.4", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.4.tgz", - "integrity": "sha512-bkFqkLhh3pMBUQQkpVgWDWq/lqzc2678eUyDlTBhRqhCHFguYYGM0Efga7tYk4TogG/3x0EEl66/OQ+WGbWB/Q==", + "node_modules/@expo-google-fonts/material-symbols": { + "version": "0.4.32", + "resolved": "https://registry.npmjs.org/@expo-google-fonts/material-symbols/-/material-symbols-0.4.32.tgz", + "integrity": "sha512-+RtX6GNteOQEycWKcliKtHYQ3NvFamuD4ZCb7MLwzImkZwxRXjUO1kSVV5FFB2koK5WNYwUCye2KkDp8LFD1SQ==", + "license": "MIT AND Apache-2.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": { - "@babel/helper-string-parser": "^7.27.1", - "@babel/helper-validator-identifier": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" + "node-forge": "^1.3.3" } }, - "node_modules/@expo/cli": { - "version": "0.24.21", - "resolved": "https://registry.npmjs.org/@expo/cli/-/cli-0.24.21.tgz", - "integrity": "sha512-DT6K9vgFHqqWL/19mU1ofRcPoO1pn4qmgi76GtuiNU4tbBe/02mRHwFsQw7qRfFAT28If5e/wiwVozgSuZVL8g==", + "node_modules/@expo/config": { + "version": "55.0.15", + "resolved": "https://registry.npmjs.org/@expo/config/-/config-55.0.15.tgz", + "integrity": "sha512-lHc0ELIQ8126jYOMZpLv3WIuvordW98jFg5aT/J1/12n2ycuXu01XLZkJsdw0avO34cusUYb1It+MvY8JiMduA==", "license": "MIT", "dependencies": { - "@0no-co/graphql.web": "^1.0.8", - "@babel/runtime": "^7.20.0", - "@expo/code-signing-certificates": "^0.0.5", - "@expo/config": "~11.0.13", - "@expo/config-plugins": "~10.1.2", - "@expo/devcert": "^1.1.2", - "@expo/env": "~1.0.7", - "@expo/image-utils": "^0.7.6", - "@expo/json-file": "^9.1.5", - "@expo/metro-config": "~0.20.17", - "@expo/osascript": "^2.2.5", - "@expo/package-manager": "^1.8.6", - "@expo/plist": "^0.3.5", - "@expo/prebuild-config": "^9.0.11", - "@expo/schema-utils": "^0.1.0", - "@expo/spawn-async": "^1.7.2", - "@expo/ws-tunnel": "^1.0.1", - "@expo/xcpretty": "^4.3.0", - "@react-native/dev-middleware": "0.79.6", - "@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", - "freeport-async": "^2.0.0", + "@expo/config-plugins": "~55.0.8", + "@expo/config-types": "^55.0.5", + "@expo/json-file": "^10.0.13", + "@expo/require-utils": "^55.0.4", + "deepmerge": "^4.3.1", "getenv": "^2.0.0", - "glob": "^10.4.2", - "lan-network": "^0.1.6", - "minimatch": "^9.0.0", - "node-forge": "^1.3.1", - "npm-package-arg": "^11.0.0", - "ora": "^3.4.0", - "picomatch": "^3.0.1", - "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", + "glob": "^13.0.0", + "resolve-workspace-root": "^2.0.0", "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.4.3", - "terminal-link": "^2.1.1", - "undici": "^6.18.2", - "wrap-ansi": "^7.0.0", - "ws": "^8.12.1" - }, - "bin": { - "expo-internal": "build/bin/cli" - } - }, - "node_modules/@expo/cli/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" + "slugify": "^1.3.4" } }, - "node_modules/@expo/cli/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==", + "node_modules/@expo/config-plugins": { + "version": "55.0.8", + "resolved": "https://registry.npmjs.org/@expo/config-plugins/-/config-plugins-55.0.8.tgz", + "integrity": "sha512-8WfWTRntTCcowfOS+tHdB0z98gKetTwktg4G5TWkCkXVa8Jt1NUnvzaaU4UHk2vbR2U4N84RyZJFizSwfF6C9g==", "license": "MIT", "dependencies": { - "color-convert": "^1.9.0" - }, - "engines": { - "node": ">=4" + "@expo/config-types": "^55.0.5", + "@expo/json-file": "~10.0.13", + "@expo/plist": "^0.5.2", + "@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", + "slugify": "^1.6.6", + "xcode": "^3.0.1", + "xml2js": "0.6.0" } }, - "node_modules/@expo/cli/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" + "node_modules/@expo/config-plugins/node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" }, "engines": { - "node": ">=4" + "node": ">=10" } }, - "node_modules/@expo/cli/node_modules/color-convert": { + "node_modules/@expo/config-types": { + "version": "55.0.5", + "resolved": "https://registry.npmjs.org/@expo/config-types/-/config-types-55.0.5.tgz", + "integrity": "sha512-sCmSUZG4mZ/ySXvfyyBdhjivz8Q539X1NondwDdYG7s3SBsk+wsgPJzYsqgAG/P9+l0xWjUD2F+kQ1cAJ6NNLg==", + "license": "MIT" + }, + "node_modules/@expo/config/node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "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": "55.0.2", + "resolved": "https://registry.npmjs.org/@expo/devtools/-/devtools-55.0.2.tgz", + "integrity": "sha512-4VsFn9MUriocyuhyA+ycJP3TJhUsOFHDc270l9h3LhNpXMf6wvIdGcA0QzXkZtORXmlDybWXRP2KT1k36HcQkA==", + "license": "MIT", + "dependencies": { + "chalk": "^4.1.2" + }, + "peerDependencies": { + "react": "*", + "react-native": "*" + }, + "peerDependenciesMeta": { + "react": { + "optional": true + }, + "react-native": { + "optional": true + } + } + }, + "node_modules/@expo/dom-webview": { + "version": "55.0.5", + "resolved": "https://registry.npmjs.org/@expo/dom-webview/-/dom-webview-55.0.5.tgz", + "integrity": "sha512-lt3uxYOCk3wmWvtOOvsC35CKGbDAOx5C2EaY8SH1JVSfBzqmF8Cs0Xp1MPxncDPMyxpMiWx5SvvV/iLF1rJU4A==", + "license": "MIT", + "peerDependencies": { + "expo": "*", + "react": "*", + "react-native": "*" + } + }, + "node_modules/@expo/env": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/@expo/env/-/env-2.1.2.tgz", + "integrity": "sha512-RJtGFfj/ygO/6zcVbV3cckHf4THcEkv5IZft1GjCB3dfT6axvzvIwXE9EiQqQYmGHcQ+ZrvC8xZcIhiHba0pYg==", + "license": "MIT", + "dependencies": { + "chalk": "^4.0.0", + "debug": "^4.3.4", + "getenv": "^2.0.0" + }, + "engines": { + "node": ">=20.12.0" + } + }, + "node_modules/@expo/fingerprint": { + "version": "0.16.6", + "resolved": "https://registry.npmjs.org/@expo/fingerprint/-/fingerprint-0.16.6.tgz", + "integrity": "sha512-nRITNbnu3RKSHPvKVehrSU4KG2VY9V8nvULOHBw98ukHCAU4bGrU5APvcblOkX3JAap+xEHsg/mZvqlvkLInmQ==", + "license": "MIT", + "dependencies": { + "@expo/env": "^2.0.11", + "@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", + "resolve-from": "^5.0.0", + "semver": "^7.6.0" + }, + "bin": { + "fingerprint": "bin/cli.js" + } + }, + "node_modules/@expo/fingerprint/node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "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.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@expo/json-file": { + "version": "10.0.13", + "resolved": "https://registry.npmjs.org/@expo/json-file/-/json-file-10.0.13.tgz", + "integrity": "sha512-pX/XjQn7tgNw6zuuV2ikmegmwe/S7uiwhrs2wXrANMkq7ozrA+JcZwgW9Q/8WZgciBzfAhNp5hnackHcrmapQA==", + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.20.0", + "json5": "^2.2.3" + } + }, + "node_modules/@expo/local-build-cache-provider": { + "version": "55.0.11", + "resolved": "https://registry.npmjs.org/@expo/local-build-cache-provider/-/local-build-cache-provider-55.0.11.tgz", + "integrity": "sha512-rJ4RTCrkeKaXaido/bVyhl90ZRtVTOEbj59F1PWVjIEIVgjdlfc1J3VD9v7hEsbf/+8Tbr/PgvWhT6Visi5sLQ==", + "license": "MIT", + "dependencies": { + "@expo/config": "~55.0.15", + "chalk": "^4.1.2" + } + }, + "node_modules/@expo/log-box": { + "version": "55.0.10", + "resolved": "https://registry.npmjs.org/@expo/log-box/-/log-box-55.0.10.tgz", + "integrity": "sha512-7jdikExgIrCIF5e3P1qMwcUZ2tcxrNdVqE9Y8kNMUHqZ+ipMlin+SiZwJKHM1+am4CYGjhdyrzbnIpvEcLDYcg==", + "license": "MIT", + "peer": true, + "dependencies": { + "@expo/dom-webview": "^55.0.5", + "anser": "^1.4.9", + "stacktrace-parser": "^0.1.10" + }, + "peerDependencies": { + "@expo/dom-webview": "^55.0.5", + "expo": "*", + "react": "*", + "react-native": "*" + } + }, + "node_modules/@expo/metro": { + "version": "55.0.0", + "resolved": "https://registry.npmjs.org/@expo/metro/-/metro-55.0.0.tgz", + "integrity": "sha512-wohGl+4y17rGHU+lq8UqC5neOXL/HOThorDYXTMbOcBL1jYwcK11MBc151gDMpjpgdVUzgHne0H5RfCIhIN4hA==", + "license": "MIT", + "dependencies": { + "metro": "0.83.5", + "metro-babel-transformer": "0.83.5", + "metro-cache": "0.83.5", + "metro-cache-key": "0.83.5", + "metro-config": "0.83.5", + "metro-core": "0.83.5", + "metro-file-map": "0.83.5", + "metro-minify-terser": "0.83.5", + "metro-resolver": "0.83.5", + "metro-runtime": "0.83.5", + "metro-source-map": "0.83.5", + "metro-symbolicate": "0.83.5", + "metro-transform-plugins": "0.83.5", + "metro-transform-worker": "0.83.5" + } + }, + "node_modules/@expo/osascript": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/@expo/osascript/-/osascript-2.4.2.tgz", + "integrity": "sha512-/XP7PSYF2hzOZzqfjgkoWtllyeTN8dW3aM4P6YgKcmmPikKL5FdoyQhti4eh6RK5a5VrUXJTOlTNIpIHsfB5Iw==", + "license": "MIT", + "dependencies": { + "@expo/spawn-async": "^1.7.2" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@expo/package-manager": { + "version": "1.10.4", + "resolved": "https://registry.npmjs.org/@expo/package-manager/-/package-manager-1.10.4.tgz", + "integrity": "sha512-y9Mr4Kmpk4abAVZrNNPCdzOZr8nLLyi18p1SXr0RCVA8IfzqZX/eY4H+50a0HTmXqIsPZrQdcdb4I3ekMS9GvQ==", + "license": "MIT", + "dependencies": { + "@expo/json-file": "^10.0.13", + "@expo/spawn-async": "^1.7.2", + "chalk": "^4.0.0", + "npm-package-arg": "^11.0.0", + "ora": "^3.4.0", + "resolve-workspace-root": "^2.0.0" + } + }, + "node_modules/@expo/package-manager/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/@expo/package-manager/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/@expo/package-manager/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==", @@ -1616,13 +1737,13 @@ "color-name": "1.1.3" } }, - "node_modules/@expo/cli/node_modules/color-name": { + "node_modules/@expo/package-manager/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/cli/node_modules/escape-string-regexp": { + "node_modules/@expo/package-manager/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==", @@ -1631,7 +1752,7 @@ "node": ">=0.8.0" } }, - "node_modules/@expo/cli/node_modules/has-flag": { + "node_modules/@expo/package-manager/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==", @@ -1640,7 +1761,7 @@ "node": ">=4" } }, - "node_modules/@expo/cli/node_modules/log-symbols": { + "node_modules/@expo/package-manager/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==", @@ -1652,7 +1773,7 @@ "node": ">=4" } }, - "node_modules/@expo/cli/node_modules/log-symbols/node_modules/chalk": { + "node_modules/@expo/package-manager/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==", @@ -1666,7 +1787,7 @@ "node": ">=4" } }, - "node_modules/@expo/cli/node_modules/mimic-fn": { + "node_modules/@expo/package-manager/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==", @@ -1675,7 +1796,7 @@ "node": ">=4" } }, - "node_modules/@expo/cli/node_modules/onetime": { + "node_modules/@expo/package-manager/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==", @@ -1687,7 +1808,7 @@ "node": ">=4" } }, - "node_modules/@expo/cli/node_modules/ora": { + "node_modules/@expo/package-manager/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==", @@ -1704,7 +1825,7 @@ "node": ">=6" } }, - "node_modules/@expo/cli/node_modules/ora/node_modules/chalk": { + "node_modules/@expo/package-manager/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==", @@ -1718,7 +1839,7 @@ "node": ">=4" } }, - "node_modules/@expo/cli/node_modules/restore-cursor": { + "node_modules/@expo/package-manager/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==", @@ -1731,31 +1852,7 @@ "node": ">=4" } }, - "node_modules/@expo/cli/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/@expo/cli/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/@expo/cli/node_modules/supports-color": { + "node_modules/@expo/package-manager/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==", @@ -1767,84 +1864,48 @@ "node": ">=4" } }, - "node_modules/@expo/cli/node_modules/ws": { - "version": "8.18.3", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz", - "integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==", - "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/@expo/code-signing-certificates": { - "version": "0.0.5", - "resolved": "https://registry.npmjs.org/@expo/code-signing-certificates/-/code-signing-certificates-0.0.5.tgz", - "integrity": "sha512-BNhXkY1bblxKZpltzAx98G2Egj9g1Q+JRcvR7E99DOj862FTCX+ZPsAUtPTr7aHxwtrL7+fL3r0JSmM9kBm+Bw==", + "node_modules/@expo/plist": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/@expo/plist/-/plist-0.5.2.tgz", + "integrity": "sha512-o4xdVdBpe4aTl3sPMZ2u3fJH4iG1I768EIRk1xRZP+GaFI93MaR3JvoFibYqxeTmLQ1p1kNEVqylfUjezxx45g==", "license": "MIT", "dependencies": { - "node-forge": "^1.2.1", - "nullthrows": "^1.1.1" + "@xmldom/xmldom": "^0.8.8", + "base64-js": "^1.5.1", + "xmlbuilder": "^15.1.1" } }, - "node_modules/@expo/config": { - "version": "11.0.13", - "resolved": "https://registry.npmjs.org/@expo/config/-/config-11.0.13.tgz", - "integrity": "sha512-TnGb4u/zUZetpav9sx/3fWK71oCPaOjZHoVED9NaEncktAd0Eonhq5NUghiJmkUGt3gGSjRAEBXiBbbY9/B1LA==", + "node_modules/@expo/prebuild-config": { + "version": "55.0.15", + "resolved": "https://registry.npmjs.org/@expo/prebuild-config/-/prebuild-config-55.0.15.tgz", + "integrity": "sha512-UcCzVhVBE42UbY5U3t/q1Rk2fSFW/B50LJpB6oFpXhImJfvLKu7ayOFU9XcHd38K89i4GqSia/xXuxQvu4RUBg==", "license": "MIT", "dependencies": { - "@babel/code-frame": "~7.10.4", - "@expo/config-plugins": "~10.1.2", - "@expo/config-types": "^53.0.5", - "@expo/json-file": "^9.1.5", - "deepmerge": "^4.3.1", - "getenv": "^2.0.0", - "glob": "^10.4.2", - "require-from-string": "^2.0.2", + "@expo/config": "~55.0.15", + "@expo/config-plugins": "~55.0.8", + "@expo/config-types": "^55.0.5", + "@expo/image-utils": "^0.8.13", + "@expo/json-file": "^10.0.13", + "@react-native/normalize-colors": "0.83.4", + "debug": "^4.3.1", "resolve-from": "^5.0.0", - "resolve-workspace-root": "^2.0.0", "semver": "^7.6.0", - "slugify": "^1.3.4", - "sucrase": "3.35.0" + "xml2js": "0.6.0" + }, + "peerDependencies": { + "expo": "*" } }, - "node_modules/@expo/config-plugins": { - "version": "10.1.2", - "resolved": "https://registry.npmjs.org/@expo/config-plugins/-/config-plugins-10.1.2.tgz", - "integrity": "sha512-IMYCxBOcnuFStuK0Ay+FzEIBKrwW8OVUMc65+v0+i7YFIIe8aL342l7T4F8lR4oCfhXn7d6M5QPgXvjtc/gAcw==", - "license": "MIT", - "dependencies": { - "@expo/config-types": "^53.0.5", - "@expo/json-file": "~9.1.5", - "@expo/plist": "^0.3.5", - "@expo/sdk-runtime-versions": "^1.0.0", - "chalk": "^4.1.2", - "debug": "^4.3.5", - "getenv": "^2.0.0", - "glob": "^10.4.2", - "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/prebuild-config/node_modules/@react-native/normalize-colors": { + "version": "0.83.4", + "resolved": "https://registry.npmjs.org/@react-native/normalize-colors/-/normalize-colors-0.83.4.tgz", + "integrity": "sha512-9ezxaHjxqTkTOLg62SGg7YhFaE+fxa/jlrWP0nwf7eGFHlGOiTAaRR2KUfiN3K05e+EMbEhgcH/c7bgaXeGyJw==", + "license": "MIT" }, - "node_modules/@expo/config-plugins/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==", + "node_modules/@expo/prebuild-config/node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", "license": "ISC", "bin": { "semver": "bin/semver.js" @@ -1853,922 +1914,855 @@ "node": ">=10" } }, - "node_modules/@expo/config-types": { - "version": "53.0.5", - "resolved": "https://registry.npmjs.org/@expo/config-types/-/config-types-53.0.5.tgz", - "integrity": "sha512-kqZ0w44E+HEGBjy+Lpyn0BVL5UANg/tmNixxaRMLS6nf37YsDrLk2VMAmeKMMk5CKG0NmOdVv3ngeUjRQMsy9g==", - "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==", + "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/highlight": "^7.10.4" - } - }, - "node_modules/@expo/config/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" + "@babel/code-frame": "^7.20.0", + "@babel/core": "^7.25.2", + "@babel/plugin-transform-modules-commonjs": "^7.24.8" }, - "engines": { - "node": ">=10" + "peerDependencies": { + "typescript": "^5.0.0 || ^5.0.0-0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } } }, - "node_modules/@expo/devcert": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/@expo/devcert/-/devcert-1.2.0.tgz", - "integrity": "sha512-Uilcv3xGELD5t/b0eM4cxBFEKQRIivB3v7i+VhWLV/gL98aw810unLKKJbGAxAIhY6Ipyz8ChWibFsKFXYwstA==", + "node_modules/@expo/schema-utils": { + "version": "55.0.3", + "resolved": "https://registry.npmjs.org/@expo/schema-utils/-/schema-utils-55.0.3.tgz", + "integrity": "sha512-l9KHVjTo6MvoeyvwNr6AjckGJm8NIcqZ3QSAh51cWozXW9v2AUjyCyqYtFtyntLWRZ0x/ByYJishpQo4ZQq45Q==", + "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": { - "@expo/sudo-prompt": "^9.3.1", - "debug": "^3.1.0", - "glob": "^10.4.2" + "cross-spawn": "^7.0.6" + }, + "engines": { + "node": ">=12" } }, - "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", + "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.3", + "resolved": "https://registry.npmjs.org/@expo/xcpretty/-/xcpretty-4.4.3.tgz", + "integrity": "sha512-wC562eD3gS6vO2tWHToFhlFnmHKfKHgF1oyvojeSkLK/ZYop1bMU+7cOMiF9Sq70CzcsLy/EMRy/uRc76QmNRw==", + "license": "BSD-3-Clause", "dependencies": { - "ms": "^2.1.1" + "@babel/code-frame": "^7.20.0", + "chalk": "^4.1.0", + "js-yaml": "^4.1.0" + }, + "bin": { + "excpretty": "build/cli.js" } }, - "node_modules/@expo/env": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/@expo/env/-/env-1.0.7.tgz", - "integrity": "sha512-qSTEnwvuYJ3umapO9XJtrb1fAqiPlmUUg78N0IZXXGwQRt+bkp0OBls+Y5Mxw/Owj8waAM0Z3huKKskRADR5ow==", - "license": "MIT", + "node_modules/@hapi/hoek": { + "version": "9.3.0", + "resolved": "https://registry.npmjs.org/@hapi/hoek/-/hoek-9.3.0.tgz", + "integrity": "sha512-/c6rf4UJlmHlC9b5BaNvzAcFv7HZ2QHaV0D4/HNlBdvFnvQq8RI4kYdhyPCl7Xj+oWvTWQ8ujhqS53LIgAe6KQ==", + "devOptional": true, + "license": "BSD-3-Clause" + }, + "node_modules/@hapi/topo": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@hapi/topo/-/topo-5.1.0.tgz", + "integrity": "sha512-foQZKJig7Ob0BMAYBfcJk8d77QtOe7Wo4ox7ff1lQYoNNAb6jwcY1ncdoy2e9wQZzvNy7ODZCYJkK8kzmcAnAg==", + "devOptional": true, + "license": "BSD-3-Clause", "dependencies": { - "chalk": "^4.0.0", - "debug": "^4.3.4", - "dotenv": "~16.4.5", - "dotenv-expand": "~11.0.6", - "getenv": "^2.0.0" + "@hapi/hoek": "^9.0.0" } }, - "node_modules/@expo/fingerprint": { - "version": "0.13.4", - "resolved": "https://registry.npmjs.org/@expo/fingerprint/-/fingerprint-0.13.4.tgz", - "integrity": "sha512-MYfPYBTMfrrNr07DALuLhG6EaLVNVrY/PXjEzsjWdWE4ZFn0yqI0IdHNkJG7t1gePT8iztHc7qnsx+oo/rDo6w==", - "license": "MIT", - "dependencies": { - "@expo/spawn-async": "^1.7.2", - "arg": "^5.0.2", - "chalk": "^4.1.2", - "debug": "^4.3.4", - "find-up": "^5.0.0", - "getenv": "^2.0.0", - "glob": "^10.4.2", - "ignore": "^5.3.1", - "minimatch": "^9.0.0", - "p-limit": "^3.1.0", - "resolve-from": "^5.0.0", - "semver": "^7.6.0" - }, - "bin": { - "fingerprint": "bin/cli.js" + "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/@expo/fingerprint/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==", + "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", - "bin": { - "semver": "bin/semver.js" + "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": ">=10" + "node": ">=8" } }, - "node_modules/@expo/image-utils": { - "version": "0.7.6", - "resolved": "https://registry.npmjs.org/@expo/image-utils/-/image-utils-0.7.6.tgz", - "integrity": "sha512-GKnMqC79+mo/1AFrmAcUcGfbsXXTRqOMNS1umebuevl3aaw+ztsYEFEiuNhHZW7PQ3Xs3URNT513ZxKhznDscw==", + "node_modules/@istanbuljs/load-nyc-config/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": { - "@expo/spawn-async": "^1.7.2", - "chalk": "^4.0.0", - "getenv": "^2.0.0", - "jimp-compact": "0.16.1", - "parse-png": "^2.1.0", - "resolve-from": "^5.0.0", - "semver": "^7.6.0", - "temp-dir": "~2.0.0", - "unique-string": "~2.0.0" + "sprintf-js": "~1.0.2" } }, - "node_modules/@expo/image-utils/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" - }, + "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": ">=10" + "node": ">=6" } }, - "node_modules/@expo/json-file": { - "version": "9.1.5", - "resolved": "https://registry.npmjs.org/@expo/json-file/-/json-file-9.1.5.tgz", - "integrity": "sha512-prWBhLUlmcQtvN6Y7BpW2k9zXGd3ySa3R6rAguMJkp1z22nunLN64KYTUWfijFlprFoxm9r2VNnGkcbndAlgKA==", + "node_modules/@istanbuljs/load-nyc-config/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": { - "@babel/code-frame": "~7.10.4", - "json5": "^2.2.3" + "locate-path": "^5.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=8" } }, - "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==", + "node_modules/@istanbuljs/load-nyc-config/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": { - "@babel/highlight": "^7.10.4" + "argparse": "^1.0.7", + "esprima": "^4.0.0" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" } }, - "node_modules/@expo/metro-config": { - "version": "0.20.17", - "resolved": "https://registry.npmjs.org/@expo/metro-config/-/metro-config-0.20.17.tgz", - "integrity": "sha512-lpntF2UZn5bTwrPK6guUv00Xv3X9mkN3YYla+IhEHiYXWyG7WKOtDU0U4KR8h3ubkZ6SPH3snDyRyAzMsWtZFA==", + "node_modules/@istanbuljs/load-nyc-config/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": { - "@babel/core": "^7.20.0", - "@babel/generator": "^7.20.5", - "@babel/parser": "^7.20.0", - "@babel/types": "^7.20.0", - "@expo/config": "~11.0.12", - "@expo/env": "~1.0.7", - "@expo/json-file": "~9.1.5", - "@expo/spawn-async": "^1.7.2", - "chalk": "^4.1.0", - "debug": "^4.3.2", - "dotenv": "~16.4.5", - "dotenv-expand": "~11.0.6", - "getenv": "^2.0.0", - "glob": "^10.4.2", - "jsc-safe-url": "^0.2.4", - "lightningcss": "~1.27.0", - "minimatch": "^9.0.0", - "postcss": "~8.4.32", - "resolve-from": "^5.0.0" - } - }, - "node_modules/@expo/metro-runtime": { - "version": "5.0.4", - "resolved": "https://registry.npmjs.org/@expo/metro-runtime/-/metro-runtime-5.0.4.tgz", - "integrity": "sha512-r694MeO+7Vi8IwOsDIDzH/Q5RPMt1kUDYbiTJwnO15nIqiDwlE8HU55UlRhffKZy6s5FmxQsZ8HA+T8DqUW8cQ==", - "license": "MIT", - "peerDependencies": { - "react-native": "*" + "p-locate": "^4.1.0" + }, + "engines": { + "node": ">=8" } }, - "node_modules/@expo/osascript": { - "version": "2.2.5", - "resolved": "https://registry.npmjs.org/@expo/osascript/-/osascript-2.2.5.tgz", - "integrity": "sha512-Bpp/n5rZ0UmpBOnl7Li3LtM7la0AR3H9NNesqL+ytW5UiqV/TbonYW3rDZY38u4u/lG7TnYflVIVQPD+iqZJ5w==", + "node_modules/@istanbuljs/load-nyc-config/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": { - "@expo/spawn-async": "^1.7.2", - "exec-async": "^2.2.0" + "p-try": "^2.0.0" }, "engines": { - "node": ">=12" + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/@expo/package-manager": { - "version": "1.8.6", - "resolved": "https://registry.npmjs.org/@expo/package-manager/-/package-manager-1.8.6.tgz", - "integrity": "sha512-gcdICLuL+nHKZagPIDC5tX8UoDDB8vNA5/+SaQEqz8D+T2C4KrEJc2Vi1gPAlDnKif834QS6YluHWyxjk0yZlQ==", + "node_modules/@istanbuljs/load-nyc-config/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": { - "@expo/json-file": "^9.1.5", - "@expo/spawn-async": "^1.7.2", - "chalk": "^4.0.0", - "npm-package-arg": "^11.0.0", - "ora": "^3.4.0", - "resolve-workspace-root": "^2.0.0" + "p-limit": "^2.2.0" + }, + "engines": { + "node": ">=8" } }, - "node_modules/@expo/package-manager/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==", + "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": ">=6" + "node": ">=8" } }, - "node_modules/@expo/package-manager/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==", + "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": { - "color-convert": "^1.9.0" + "@jest/types": "^29.6.3" }, "engines": { - "node": ">=4" + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, - "node_modules/@expo/package-manager/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==", + "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": { - "restore-cursor": "^2.0.0" + "@jest/fake-timers": "^29.7.0", + "@jest/types": "^29.6.3", + "@types/node": "*", + "jest-mock": "^29.7.0" }, "engines": { - "node": ">=4" + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, - "node_modules/@expo/package-manager/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==", + "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": { - "color-name": "1.1.3" - } - }, - "node_modules/@expo/package-manager/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/package-manager/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/@expo/package-manager/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", + "@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": ">=4" + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, - "node_modules/@expo/package-manager/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==", + "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": { - "chalk": "^2.0.1" + "@sinclair/typebox": "^0.27.8" }, "engines": { - "node": ">=4" + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, - "node_modules/@expo/package-manager/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==", + "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": { - "ansi-styles": "^3.2.1", - "escape-string-regexp": "^1.0.5", - "supports-color": "^5.3.0" + "@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": ">=4" + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, - "node_modules/@expo/package-manager/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==", + "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": ">=4" + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, - "node_modules/@expo/package-manager/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==", + "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": { - "mimic-fn": "^1.0.0" - }, - "engines": { - "node": ">=4" + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" } }, - "node_modules/@expo/package-manager/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==", + "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": { - "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" + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" } }, - "node_modules/@expo/package-manager/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==", + "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", - "dependencies": { - "ansi-styles": "^3.2.1", - "escape-string-regexp": "^1.0.5", - "supports-color": "^5.3.0" - }, "engines": { - "node": ">=4" + "node": ">=6.0.0" } }, - "node_modules/@expo/package-manager/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==", + "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": { - "onetime": "^2.0.0", - "signal-exit": "^3.0.2" - }, - "engines": { - "node": ">=4" + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.25" } }, - "node_modules/@expo/package-manager/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==", + "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": { - "ansi-regex": "^4.1.0" - }, - "engines": { - "node": ">=6" + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" } }, - "node_modules/@expo/package-manager/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==", + "node_modules/@nodable/entities": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@nodable/entities/-/entities-2.1.0.tgz", + "integrity": "sha512-nyT7T3nbMyBI/lvr6L5TyWbFJAI9FTgVRakNoBqCD+PmID8DzFrrNdLLtHMwMszOtqZa8PAOV24ZqDnQrhQINA==", + "devOptional": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/nodable" + } + ], + "license": "MIT" + }, + "node_modules/@nodelib/fs.scandir": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "devOptional": true, "license": "MIT", "dependencies": { - "has-flag": "^3.0.0" + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" }, "engines": { - "node": ">=4" + "node": ">= 8" } }, - "node_modules/@expo/plist": { - "version": "0.3.5", - "resolved": "https://registry.npmjs.org/@expo/plist/-/plist-0.3.5.tgz", - "integrity": "sha512-9RYVU1iGyCJ7vWfg3e7c/NVyMFs8wbl+dMWZphtFtsqyN9zppGREU3ctlD3i8KUE0sCUTVnLjCWr+VeUIDep2g==", + "node_modules/@nodelib/fs.stat": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "devOptional": true, "license": "MIT", - "dependencies": { - "@xmldom/xmldom": "^0.8.8", - "base64-js": "^1.2.3", - "xmlbuilder": "^15.1.1" + "engines": { + "node": ">= 8" } }, - "node_modules/@expo/prebuild-config": { - "version": "9.0.11", - "resolved": "https://registry.npmjs.org/@expo/prebuild-config/-/prebuild-config-9.0.11.tgz", - "integrity": "sha512-0DsxhhixRbCCvmYskBTq8czsU0YOBsntYURhWPNpkl0IPVpeP9haE5W4OwtHGzXEbmHdzaoDwNmVcWjS/mqbDw==", + "node_modules/@nodelib/fs.walk": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "devOptional": true, "license": "MIT", "dependencies": { - "@expo/config": "~11.0.13", - "@expo/config-plugins": "~10.1.2", - "@expo/config-types": "^53.0.5", - "@expo/image-utils": "^0.7.6", - "@expo/json-file": "^9.1.5", - "@react-native/normalize-colors": "0.79.5", - "debug": "^4.3.1", - "resolve-from": "^5.0.0", - "semver": "^7.6.0", - "xml2js": "0.6.0" - } - }, - "node_modules/@expo/prebuild-config/node_modules/@react-native/normalize-colors": { - "version": "0.79.5", - "resolved": "https://registry.npmjs.org/@react-native/normalize-colors/-/normalize-colors-0.79.5.tgz", - "integrity": "sha512-nGXMNMclZgzLUxijQQ38Dm3IAEhgxuySAWQHnljFtfB0JdaMwpe0Ox9H7Tp2OgrEA+EMEv+Od9ElKlHwGKmmvQ==", - "license": "MIT" - }, - "node_modules/@expo/prebuild-config/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" + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" }, "engines": { - "node": ">=10" + "node": ">= 8" } }, - "node_modules/@expo/schema-utils": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/@expo/schema-utils/-/schema-utils-0.1.0.tgz", - "integrity": "sha512-Me2avOfbcVT/O5iRmPKLCCSvbCfVfxIstGMlzVJOffplaZX1+ut8D18siR1wx5fkLMTWKs14ozEz11cGUY7hcw==", - "license": "MIT" + "node_modules/@notifee/react-native": { + "version": "9.1.8", + "resolved": "https://registry.npmjs.org/@notifee/react-native/-/react-native-9.1.8.tgz", + "integrity": "sha512-Az/dueoPerJsbbjRxu8a558wKY+gONUrfoy3Hs++5OqbeMsR0dYe6P+4oN6twrLFyzAhEA1tEoZRvQTFDRmvQg==", + "license": "Apache-2.0", + "peerDependencies": { + "react-native": "*" + } }, - "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==", + "node_modules/@radix-ui/primitive": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.3.tgz", + "integrity": "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==", "license": "MIT" }, - "node_modules/@expo/server": { - "version": "0.6.3", - "resolved": "https://registry.npmjs.org/@expo/server/-/server-0.6.3.tgz", - "integrity": "sha512-Ea7NJn9Xk1fe4YeJ86rObHSv/bm3u/6WiQPXEqXJ2GrfYpVab2Swoh9/PnSM3KjR64JAgKjArDn1HiPjITCfHA==", + "node_modules/@radix-ui/react-collection": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.1.7.tgz", + "integrity": "sha512-Fh9rGN0MoI4ZFUNyfFVNU4y9LUz93u9/0K+yLgA2bwRojxM8JU1DyvvMBabnZPBgMWREAJvU2jjVzq+LrFUglw==", "license": "MIT", "dependencies": { - "abort-controller": "^3.0.0", - "debug": "^4.3.4", - "source-map-support": "~0.5.21", - "undici": "^6.18.2 || ^7.0.0" - } - }, - "node_modules/@expo/spawn-async": { - "version": "1.7.2", - "resolved": "https://registry.npmjs.org/@expo/spawn-async/-/spawn-async-1.7.2.tgz", - "integrity": "sha512-QdWi16+CHB9JYP7gma19OVVg0BFkvU8zNj9GjWorYI8Iv8FUxjOCcYRuAmX4s/h91e4e7BPsskc8cSrZYho9Ew==", - "license": "MIT", - "dependencies": { - "cross-spawn": "^7.0.3" + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-slot": "1.2.3" }, - "engines": { - "node": ">=12" + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } } }, - "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/vector-icons": { - "version": "14.1.0", - "resolved": "https://registry.npmjs.org/@expo/vector-icons/-/vector-icons-14.1.0.tgz", - "integrity": "sha512-7T09UE9h8QDTsUeMGymB4i+iqvtEeaO5VvUjryFB4tugDTG/bkzViWA74hm5pfjjDEhYMXWaX112mcvhccmIwQ==", + "node_modules/@radix-ui/react-collection/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, "peerDependencies": { - "expo-font": "*", - "react": "*", - "react-native": "*" + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } } }, - "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.3.2", - "resolved": "https://registry.npmjs.org/@expo/xcpretty/-/xcpretty-4.3.2.tgz", - "integrity": "sha512-ReZxZ8pdnoI3tP/dNnJdnmAk7uLT4FjsKDGW7YeDdvdOMz2XCQSmSCM9IWlrXuWtMF9zeSB6WJtEhCQ41gQOfw==", - "license": "BSD-3-Clause", - "dependencies": { - "@babel/code-frame": "7.10.4", - "chalk": "^4.1.0", - "find-up": "^5.0.0", - "js-yaml": "^4.1.0" + "node_modules/@radix-ui/react-compose-refs": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.2.tgz", + "integrity": "sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, - "bin": { - "excpretty": "build/cli.js" + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } } }, - "node_modules/@expo/xcpretty/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==", + "node_modules/@radix-ui/react-context": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz", + "integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==", "license": "MIT", - "dependencies": { - "@babel/highlight": "^7.10.4" + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } } }, - "node_modules/@hapi/hoek": { - "version": "9.3.0", - "resolved": "https://registry.npmjs.org/@hapi/hoek/-/hoek-9.3.0.tgz", - "integrity": "sha512-/c6rf4UJlmHlC9b5BaNvzAcFv7HZ2QHaV0D4/HNlBdvFnvQq8RI4kYdhyPCl7Xj+oWvTWQ8ujhqS53LIgAe6KQ==", - "devOptional": true, - "license": "BSD-3-Clause" - }, - "node_modules/@hapi/topo": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/@hapi/topo/-/topo-5.1.0.tgz", - "integrity": "sha512-foQZKJig7Ob0BMAYBfcJk8d77QtOe7Wo4ox7ff1lQYoNNAb6jwcY1ncdoy2e9wQZzvNy7ODZCYJkK8kzmcAnAg==", - "devOptional": true, - "license": "BSD-3-Clause", + "node_modules/@radix-ui/react-dialog": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dialog/-/react-dialog-1.1.15.tgz", + "integrity": "sha512-TCglVRtzlffRNxRMEyR36DGBLJpeusFcgMVD9PZEzAKnUs1lKCgX5u9BmC2Yg+LL9MgZDugFFs1Vl+Jp4t/PGw==", + "license": "MIT", "dependencies": { - "@hapi/hoek": "^9.0.0" + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-dismissable-layer": "1.1.11", + "@radix-ui/react-focus-guards": "1.1.3", + "@radix-ui/react-focus-scope": "1.1.7", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-portal": "1.1.9", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-slot": "1.2.3", + "@radix-ui/react-use-controllable-state": "1.2.2", + "aria-hidden": "^1.2.4", + "react-remove-scroll": "^2.6.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } } }, - "node_modules/@isaacs/cliui": { - "version": "8.0.2", - "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", - "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", - "license": "ISC", + "node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", "dependencies": { - "string-width": "^5.1.2", - "string-width-cjs": "npm:string-width@^4.2.0", - "strip-ansi": "^7.0.1", - "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", - "wrap-ansi": "^8.1.0", - "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" + "@radix-ui/react-compose-refs": "1.1.2" }, - "engines": { - "node": ">=12" + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } } }, - "node_modules/@isaacs/cliui/node_modules/ansi-styles": { - "version": "6.2.3", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", - "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "node_modules/@radix-ui/react-direction": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-direction/-/react-direction-1.1.1.tgz", + "integrity": "sha512-1UEWRX6jnOA2y4H5WczZ44gOOjTEmlqv1uNW4GAJEO5+bauCBhv8snY65Iw5/VOS/ghKN9gr2KjnLKxrsvoMVw==", "license": "MIT", - "engines": { - "node": ">=12" + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } } }, - "node_modules/@isaacs/cliui/node_modules/wrap-ansi": { - "version": "8.1.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", - "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", + "node_modules/@radix-ui/react-dismissable-layer": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.11.tgz", + "integrity": "sha512-Nqcp+t5cTB8BinFkZgXiMJniQH0PsUt2k51FUhbdfeKvc4ACcG2uQniY/8+h1Yv6Kza4Q7lD7PQV0z0oicE0Mg==", "license": "MIT", "dependencies": { - "ansi-styles": "^6.1.0", - "string-width": "^5.0.1", - "strip-ansi": "^7.0.1" + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-escape-keydown": "1.1.1" }, - "engines": { - "node": ">=12" + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, - "funding": { - "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } } }, - "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" + "node_modules/@radix-ui/react-focus-guards": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-guards/-/react-focus-guards-1.1.3.tgz", + "integrity": "sha512-0rFg/Rj2Q62NCm62jZw0QX7a3sz6QCQU0LpZdNrJX8byRGaGVTqbrW9jAoIAHyMQqsNpeZ81YgSizOt5WXq0Pw==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, - "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" + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } } }, - "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", + "node_modules/@radix-ui/react-focus-scope": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-scope/-/react-focus-scope-1.1.7.tgz", + "integrity": "sha512-t2ODlkXBQyn7jkl6TNaw/MtVEVvIGelJDCG41Okq/KwUsJBwQ4XVZsHAVUkK4mBv3ewiAS3PGuUWuY2BoK4ZUw==", + "license": "MIT", "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" + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1" }, - "engines": { - "node": ">=8" + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } } }, - "node_modules/@istanbuljs/load-nyc-config/node_modules/argparse": { - "version": "1.0.10", - "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", - "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", + "node_modules/@radix-ui/react-id": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-1.1.1.tgz", + "integrity": "sha512-kGkGegYIdQsOb4XjsfM97rXsiHaBwco+hFI66oO4s9LU+PLAC5oJ7khdOVFxkhsmlbpUqDAvXw11CluXP+jkHg==", "license": "MIT", "dependencies": { - "sprintf-js": "~1.0.2" + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } } }, - "node_modules/@istanbuljs/load-nyc-config/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==", + "node_modules/@radix-ui/react-portal": { + "version": "1.1.9", + "resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.1.9.tgz", + "integrity": "sha512-bpIxvq03if6UNwXZ+HTK71JLh4APvnXntDc6XOX8UVq4XQOVl7lwok0AvIl+b8zgCw3fSaVTZMpAPPagXbKmHQ==", "license": "MIT", "dependencies": { - "locate-path": "^5.0.0", - "path-exists": "^4.0.0" + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-layout-effect": "1.1.1" }, - "engines": { - "node": ">=8" + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } } }, - "node_modules/@istanbuljs/load-nyc-config/node_modules/js-yaml": { - "version": "3.14.1", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.1.tgz", - "integrity": "sha512-okMH7OXXJ7YrN9Ok3/SXrnu4iX9yOk+25nqX4imS2npuvTYDmo/QEZoqwZkYaIDk3jVvBOTOIEgEhaLOynBS9g==", + "node_modules/@radix-ui/react-presence": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.5.tgz", + "integrity": "sha512-/jfEwNDdQVBCNvjkGit4h6pMOzq8bHkopq458dPt2lMjx+eBQUohZNG9A7DtO/O5ukSbxuaNGXMjHicgwy6rQQ==", "license": "MIT", "dependencies": { - "argparse": "^1.0.7", - "esprima": "^4.0.0" + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-use-layout-effect": "1.1.1" }, - "bin": { - "js-yaml": "bin/js-yaml.js" + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } } }, - "node_modules/@istanbuljs/load-nyc-config/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==", + "node_modules/@radix-ui/react-primitive": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", + "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", "license": "MIT", "dependencies": { - "p-locate": "^4.1.0" + "@radix-ui/react-slot": "1.2.3" }, - "engines": { - "node": ">=8" + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } } }, - "node_modules/@istanbuljs/load-nyc-config/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==", + "node_modules/@radix-ui/react-primitive/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", "license": "MIT", "dependencies": { - "p-try": "^2.0.0" + "@radix-ui/react-compose-refs": "1.1.2" }, - "engines": { - "node": ">=6" + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } } }, - "node_modules/@istanbuljs/load-nyc-config/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==", + "node_modules/@radix-ui/react-roving-focus": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/@radix-ui/react-roving-focus/-/react-roving-focus-1.1.11.tgz", + "integrity": "sha512-7A6S9jSgm/S+7MdtNDSb+IU859vQqJ/QAtcYQcfFC6W8RS4IxIZDldLR0xqCFZ6DCyrQLjLPsxtTNch5jVA4lA==", "license": "MIT", "dependencies": { - "p-limit": "^2.2.0" + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-collection": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-controllable-state": "1.2.2" }, - "engines": { - "node": ">=8" - } - }, - "node_modules/@istanbuljs/schema": { - "version": "0.1.3", - "resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.3.tgz", - "integrity": "sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA==", - "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" + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } } }, - "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==", + "node_modules/@radix-ui/react-slot": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.4.tgz", + "integrity": "sha512-Jl+bCv8HxKnlTLVrcDE8zTMJ09R9/ukw4qBs/oZClOfoQk/cOTbDn+NceXfV7j09YPVQUryJPHurafcSg6EVKA==", "license": "MIT", "dependencies": { - "@jest/fake-timers": "^29.7.0", - "@jest/types": "^29.6.3", - "@types/node": "*", - "jest-mock": "^29.7.0" + "@radix-ui/react-compose-refs": "1.1.2" }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "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" + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } } }, - "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==", + "node_modules/@radix-ui/react-use-callback-ref": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.1.tgz", + "integrity": "sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg==", "license": "MIT", - "dependencies": { - "@sinclair/typebox": "^0.27.8" + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } } }, - "node_modules/@jest/transform": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/@jest/transform/-/transform-29.7.0.tgz", - "integrity": "sha512-ok/BTPFzFKVMwO5eOHRrvnBVHdRy9IrsrW1GpMaQ9MCnilNLXQKmAX8s1YXDFaai9xJpac2ySzV0YeRRECr2Vw==", + "node_modules/@radix-ui/react-use-controllable-state": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.2.2.tgz", + "integrity": "sha512-BjasUjixPFdS+NKkypcyyN5Pmg83Olst0+c6vGov0diwTEo6mgdqVR6hxcEgFuh4QrAs7Rc+9KuGJ9TVCj0Zzg==", "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" + "@radix-ui/react-use-effect-event": "0.0.2", + "@radix-ui/react-use-layout-effect": "1.1.1" }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "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" + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.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.30", - "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.30.tgz", - "integrity": "sha512-GQ7Nw5G2lTu/BtHTKfXhKHok2WGetd4XYcVKGx00SjAk8GMwgJM3zr6zORiPGuOE+/vkc90KtTosSSvaCjKb2Q==", - "license": "MIT", - "dependencies": { - "@jridgewell/resolve-uri": "^3.1.0", - "@jridgewell/sourcemap-codec": "^1.4.14" + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } } }, - "node_modules/@nodelib/fs.scandir": { - "version": "2.1.5", - "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", - "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", - "devOptional": true, + "node_modules/@radix-ui/react-use-effect-event": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-effect-event/-/react-use-effect-event-0.0.2.tgz", + "integrity": "sha512-Qp8WbZOBe+blgpuUT+lw2xheLP8q0oatc9UpmiemEICxGvFLYmHm9QowVZGHtJlGbS6A6yJ3iViad/2cVjnOiA==", "license": "MIT", "dependencies": { - "@nodelib/fs.stat": "2.0.5", - "run-parallel": "^1.1.9" + "@radix-ui/react-use-layout-effect": "1.1.1" }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/@nodelib/fs.stat": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", - "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", - "devOptional": true, - "license": "MIT", - "engines": { - "node": ">= 8" + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } } }, - "node_modules/@nodelib/fs.walk": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", - "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", - "devOptional": true, + "node_modules/@radix-ui/react-use-escape-keydown": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-escape-keydown/-/react-use-escape-keydown-1.1.1.tgz", + "integrity": "sha512-Il0+boE7w/XebUHyBjroE+DbByORGR9KKmITzbR7MyQ4akpORYP/ZmbhAr0DG7RmmBqoOnZdy2QlvajJ2QA59g==", "license": "MIT", "dependencies": { - "@nodelib/fs.scandir": "2.1.5", - "fastq": "^1.6.0" + "@radix-ui/react-use-callback-ref": "1.1.1" }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/@notifee/react-native": { - "version": "9.1.8", - "resolved": "https://registry.npmjs.org/@notifee/react-native/-/react-native-9.1.8.tgz", - "integrity": "sha512-Az/dueoPerJsbbjRxu8a558wKY+gONUrfoy3Hs++5OqbeMsR0dYe6P+4oN6twrLFyzAhEA1tEoZRvQTFDRmvQg==", - "license": "Apache-2.0", - "peerDependencies": { - "react-native": "*" - } - }, - "node_modules/@pkgjs/parseargs": { - "version": "0.11.0", - "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", - "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", - "license": "MIT", - "optional": true, - "engines": { - "node": ">=14" - } - }, - "node_modules/@radix-ui/react-compose-refs": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.2.tgz", - "integrity": "sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg==", - "license": "MIT", "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" @@ -2779,14 +2773,11 @@ } } }, - "node_modules/@radix-ui/react-slot": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.0.tgz", - "integrity": "sha512-ujc+V6r0HNDviYqIK3rW4ffgYiZ8g5DEHrGJVk4x7kTlLXRDILnKX9vAUYeIsLOoDpDJ0ujpqMkjH4w2ofuo6w==", + "node_modules/@radix-ui/react-use-layout-effect": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.1.tgz", + "integrity": "sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ==", "license": "MIT", - "dependencies": { - "@radix-ui/react-compose-refs": "1.1.2" - }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" @@ -2810,26 +2801,26 @@ } }, "node_modules/@react-native-community/cli": { - "version": "20.0.1", - "resolved": "https://registry.npmjs.org/@react-native-community/cli/-/cli-20.0.1.tgz", - "integrity": "sha512-Q7UnBqOO/JsWfgmO9qZjrKgMi/0U9ih0FywXXheml8VH1hn/pBXKIeO/BvzA6g5gHIvBZ/6KyhdGoNok1R/ZJw==", + "version": "20.1.3", + "resolved": "https://registry.npmjs.org/@react-native-community/cli/-/cli-20.1.3.tgz", + "integrity": "sha512-sLo8cu9JyFNfuuF1C+8NJ4DHE/PEFaXGd4enkcxi/OJjGG8+sOQrdjNQ4i+cVh/2c+ah1mEMwsYjc3z0+/MqSg==", "devOptional": true, "license": "MIT", "peer": true, "dependencies": { - "@react-native-community/cli-clean": "20.0.1", - "@react-native-community/cli-config": "20.0.1", - "@react-native-community/cli-doctor": "20.0.1", - "@react-native-community/cli-server-api": "20.0.1", - "@react-native-community/cli-tools": "20.0.1", - "@react-native-community/cli-types": "20.0.1", - "chalk": "^4.1.2", + "@react-native-community/cli-clean": "20.1.3", + "@react-native-community/cli-config": "20.1.3", + "@react-native-community/cli-doctor": "20.1.3", + "@react-native-community/cli-server-api": "20.1.3", + "@react-native-community/cli-tools": "20.1.3", + "@react-native-community/cli-types": "20.1.3", "commander": "^9.4.1", "deepmerge": "^4.3.0", "execa": "^5.0.0", "find-up": "^5.0.0", "fs-extra": "^8.1.0", "graceful-fs": "^4.1.3", + "picocolors": "^1.1.1", "prompts": "^2.4.2", "semver": "^7.5.2" }, @@ -2837,91 +2828,91 @@ "rnc-cli": "build/bin.js" }, "engines": { - "node": ">=18" + "node": ">=20.19.4" } }, "node_modules/@react-native-community/cli-clean": { - "version": "20.0.1", - "resolved": "https://registry.npmjs.org/@react-native-community/cli-clean/-/cli-clean-20.0.1.tgz", - "integrity": "sha512-/4Q28dDz9k++cbTSDiEQ+i2n4XlRy1keXi8VIpGVTRxAuxX+KNtNlCk5MnmPoiOZDvaRRc7xTUebUCMC03HFeQ==", + "version": "20.1.3", + "resolved": "https://registry.npmjs.org/@react-native-community/cli-clean/-/cli-clean-20.1.3.tgz", + "integrity": "sha512-sFLdLzapfC0scjgzBJJWYDY2RhHPjuuPkA5r6q0gc/UQH/izXpMpLrhh1DW84cMDraNACK0U62tU7ebNaQ1LMQ==", "devOptional": true, "license": "MIT", "dependencies": { - "@react-native-community/cli-tools": "20.0.1", - "chalk": "^4.1.2", + "@react-native-community/cli-tools": "20.1.3", "execa": "^5.0.0", - "fast-glob": "^3.3.2" + "fast-glob": "^3.3.2", + "picocolors": "^1.1.1" } }, "node_modules/@react-native-community/cli-config": { - "version": "20.0.1", - "resolved": "https://registry.npmjs.org/@react-native-community/cli-config/-/cli-config-20.0.1.tgz", - "integrity": "sha512-DMjbgqFcWlCmoDAn9CgcPoEMMfRE0HgCECAeVvby+DOtcses/QxTPX1L9s2hZU16AOUSNxZEXqLzAw7pqGJl0A==", + "version": "20.1.3", + "resolved": "https://registry.npmjs.org/@react-native-community/cli-config/-/cli-config-20.1.3.tgz", + "integrity": "sha512-n73nW0cG92oNF0r994pPqm0DjAShOm3F8LSffDYhJqNAno+h/csmv/37iL4NtSpmKIO8xqsG3uVTXz9X/hzNaQ==", "devOptional": true, "license": "MIT", "dependencies": { - "@react-native-community/cli-tools": "20.0.1", - "chalk": "^4.1.2", + "@react-native-community/cli-tools": "20.1.3", "cosmiconfig": "^9.0.0", "deepmerge": "^4.3.0", "fast-glob": "^3.3.2", - "joi": "^17.2.1" + "joi": "^17.2.1", + "picocolors": "^1.1.1" } }, "node_modules/@react-native-community/cli-config-android": { - "version": "20.0.1", - "resolved": "https://registry.npmjs.org/@react-native-community/cli-config-android/-/cli-config-android-20.0.1.tgz", - "integrity": "sha512-2Opfc38FZq2chMXUSY75Fzcgwe2G96sd1O4sAkq7UeoP6HGjAT2hh4xu3lzTmevUhS0T5EzIDUdYT55wTJf60A==", + "version": "20.1.3", + "resolved": "https://registry.npmjs.org/@react-native-community/cli-config-android/-/cli-config-android-20.1.3.tgz", + "integrity": "sha512-DNHDP+OWLyhKShGciBqPcxhxfp1Z/7GQcb4F+TGyCeKQAr+JdnUjRXN3X+YCU/v+g2kbYYyRJKlGabzkVvdrAw==", "devOptional": true, "license": "MIT", "dependencies": { - "@react-native-community/cli-tools": "20.0.1", - "chalk": "^4.1.2", + "@react-native-community/cli-tools": "20.1.3", "fast-glob": "^3.3.2", - "fast-xml-parser": "^4.4.1" + "fast-xml-parser": "^5.3.6", + "picocolors": "^1.1.1" } }, "node_modules/@react-native-community/cli-config-apple": { - "version": "20.0.1", - "resolved": "https://registry.npmjs.org/@react-native-community/cli-config-apple/-/cli-config-apple-20.0.1.tgz", - "integrity": "sha512-gtNRlNQxhA57y3vRxjTkHPusLEkLQqqkHOOE0LLeTuMQ/X8q0tdPKfFjdHDXSwjz4wXkDN327kxTPx0UPqohhg==", + "version": "20.1.3", + "resolved": "https://registry.npmjs.org/@react-native-community/cli-config-apple/-/cli-config-apple-20.1.3.tgz", + "integrity": "sha512-QX9B83nAfCPs0KiaYz61kAEHWr9sttooxzRzNdQwvZTwnsIpvWOT9GvMMj/19OeXiQzMJBzZX0Pgt6+spiUsDQ==", "devOptional": true, "license": "MIT", "dependencies": { - "@react-native-community/cli-tools": "20.0.1", - "chalk": "^4.1.2", + "@react-native-community/cli-tools": "20.1.3", "execa": "^5.0.0", - "fast-glob": "^3.3.2" + "fast-glob": "^3.3.2", + "picocolors": "^1.1.1" } }, "node_modules/@react-native-community/cli-doctor": { - "version": "20.0.1", - "resolved": "https://registry.npmjs.org/@react-native-community/cli-doctor/-/cli-doctor-20.0.1.tgz", - "integrity": "sha512-ADzNiHpbq/CUtjJEBGQ8KxwZv7JbyIXVnsatMlP/xP5A+FbMq4YOA2FyjqSpfqha60yRMqSkc5tTLdIKtsC5yA==", + "version": "20.1.3", + "resolved": "https://registry.npmjs.org/@react-native-community/cli-doctor/-/cli-doctor-20.1.3.tgz", + "integrity": "sha512-EI+mAPWn255/WZ4CQohy1I049yiaxVr41C3BeQ2BCyhxODIDR8XRsLzYb1t9MfqK/C3ZncUN2mPSRXFeKPPI1w==", "devOptional": true, "license": "MIT", "dependencies": { - "@react-native-community/cli-config": "20.0.1", - "@react-native-community/cli-platform-android": "20.0.1", - "@react-native-community/cli-platform-apple": "20.0.1", - "@react-native-community/cli-platform-ios": "20.0.1", - "@react-native-community/cli-tools": "20.0.1", - "chalk": "^4.1.2", + "@react-native-community/cli-config": "20.1.3", + "@react-native-community/cli-platform-android": "20.1.3", + "@react-native-community/cli-platform-apple": "20.1.3", + "@react-native-community/cli-platform-ios": "20.1.3", + "@react-native-community/cli-tools": "20.1.3", "command-exists": "^1.2.8", "deepmerge": "^4.3.0", "envinfo": "^7.13.0", "execa": "^5.0.0", "node-stream-zip": "^1.9.1", "ora": "^5.4.1", + "picocolors": "^1.1.1", "semver": "^7.5.2", "wcwidth": "^1.0.1", "yaml": "^2.2.1" } }, "node_modules/@react-native-community/cli-doctor/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==", + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", "devOptional": true, "license": "ISC", "bin": { @@ -2932,52 +2923,52 @@ } }, "node_modules/@react-native-community/cli-platform-android": { - "version": "20.0.1", - "resolved": "https://registry.npmjs.org/@react-native-community/cli-platform-android/-/cli-platform-android-20.0.1.tgz", - "integrity": "sha512-Rfyi1J+TKTiDjiZ2JpodwQHp5ETv7MWoOcWLK4JeeBHQLCYlVII+7RSg44tFc0JC2/bCXscqfQOR1aYZr0sPTg==", + "version": "20.1.3", + "resolved": "https://registry.npmjs.org/@react-native-community/cli-platform-android/-/cli-platform-android-20.1.3.tgz", + "integrity": "sha512-bzB9ELPOISuqgtDZXFPQlkuxx1YFkNx3cNgslc5ElCrk+5LeCLQLIBh/dmIuK8rwUrPcrramjeBj++Noc+TaAA==", "devOptional": true, "license": "MIT", "dependencies": { - "@react-native-community/cli-config-android": "20.0.1", - "@react-native-community/cli-tools": "20.0.1", - "chalk": "^4.1.2", + "@react-native-community/cli-config-android": "20.1.3", + "@react-native-community/cli-tools": "20.1.3", "execa": "^5.0.0", - "logkitty": "^0.7.1" + "logkitty": "^0.7.1", + "picocolors": "^1.1.1" } }, "node_modules/@react-native-community/cli-platform-apple": { - "version": "20.0.1", - "resolved": "https://registry.npmjs.org/@react-native-community/cli-platform-apple/-/cli-platform-apple-20.0.1.tgz", - "integrity": "sha512-pzPsdmYhVrg9oluL0ofWopVrbzYOJg+RBoJuoBOjpI7JWUduS8A3uLUAR8ysT+lo1x0+aztD4App9nKyY+vfbw==", + "version": "20.1.3", + "resolved": "https://registry.npmjs.org/@react-native-community/cli-platform-apple/-/cli-platform-apple-20.1.3.tgz", + "integrity": "sha512-XJ+DqAD4hkplWVXK5AMgN7pP9+4yRSe5KfZ/b42+ofkDBI55ALlUmX+9HWE3fMuRjcotTCoNZqX2ov97cFDXpQ==", "devOptional": true, "license": "MIT", "dependencies": { - "@react-native-community/cli-config-apple": "20.0.1", - "@react-native-community/cli-tools": "20.0.1", - "chalk": "^4.1.2", + "@react-native-community/cli-config-apple": "20.1.3", + "@react-native-community/cli-tools": "20.1.3", "execa": "^5.0.0", - "fast-xml-parser": "^4.4.1" + "fast-xml-parser": "^5.3.6", + "picocolors": "^1.1.1" } }, "node_modules/@react-native-community/cli-platform-ios": { - "version": "20.0.1", - "resolved": "https://registry.npmjs.org/@react-native-community/cli-platform-ios/-/cli-platform-ios-20.0.1.tgz", - "integrity": "sha512-kvpUBcqOC5CE8xWCoimyP2M/gr3TVeoLRKhliixnpx7fc9cB6R6GWHgzrNDVf4z5SAQW64UFvswquPo3dk+YOg==", + "version": "20.1.3", + "resolved": "https://registry.npmjs.org/@react-native-community/cli-platform-ios/-/cli-platform-ios-20.1.3.tgz", + "integrity": "sha512-2qL48SINotuHbZO73cgqSwqd/OWNx0xTbFSdujhpogV4p8BNwYYypfjh4vJY5qJEB5PxuoVkMXT+aCADpg9nBg==", "devOptional": true, "license": "MIT", "dependencies": { - "@react-native-community/cli-platform-apple": "20.0.1" + "@react-native-community/cli-platform-apple": "20.1.3" } }, "node_modules/@react-native-community/cli-server-api": { - "version": "20.0.1", - "resolved": "https://registry.npmjs.org/@react-native-community/cli-server-api/-/cli-server-api-20.0.1.tgz", - "integrity": "sha512-x5wr71LJzVBGLVLFPU9iGBkY1Raw2RCd1KDKKnbYMbX/KiAgYnhW3flF0RIX+LpIkdZ8hIIcj7SETaByAAR1FA==", + "version": "20.1.3", + "resolved": "https://registry.npmjs.org/@react-native-community/cli-server-api/-/cli-server-api-20.1.3.tgz", + "integrity": "sha512-hsNsdUKZDd2T99OuNuiXz4VuvLa1UN0zcxefmPjXQgI0byrBLzzDr+o7p03sKuODSzKi2h+BMnUxiS07HACQLA==", "devOptional": true, "license": "MIT", "dependencies": { - "@react-native-community/cli-tools": "20.0.1", - "body-parser": "^1.20.3", + "@react-native-community/cli-tools": "20.1.3", + "body-parser": "^2.2.2", "compression": "^1.7.1", "connect": "^3.6.5", "errorhandler": "^1.5.1", @@ -2985,32 +2976,33 @@ "open": "^6.2.0", "pretty-format": "^29.7.0", "serve-static": "^1.13.1", + "strict-url-sanitise": "0.0.1", "ws": "^6.2.3" } }, "node_modules/@react-native-community/cli-tools": { - "version": "20.0.1", - "resolved": "https://registry.npmjs.org/@react-native-community/cli-tools/-/cli-tools-20.0.1.tgz", - "integrity": "sha512-yrSOkVfGm8yG88DRc4DjfM4XFmRpIXGkB1StKfU8aUPzO5Pbp8cobYYdsCeK234vp9/SZu535uRrno6Or53+Jw==", + "version": "20.1.3", + "resolved": "https://registry.npmjs.org/@react-native-community/cli-tools/-/cli-tools-20.1.3.tgz", + "integrity": "sha512-EAn0vPCMxtHhfWk2UwLmSUfPfLUnFgC7NjiVJVTKJyVk5qGnkPfoT8te/1IUXFTysUB0F0RIi+NgDB4usFOLeA==", "devOptional": true, "license": "MIT", "dependencies": { "@vscode/sudo-prompt": "^9.0.0", "appdirsjs": "^1.2.4", - "chalk": "^4.1.2", "execa": "^5.0.0", "find-up": "^5.0.0", "launch-editor": "^2.9.1", "mime": "^2.4.1", "ora": "^5.4.1", + "picocolors": "^1.1.1", "prompts": "^2.4.2", "semver": "^7.5.2" } }, "node_modules/@react-native-community/cli-tools/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==", + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", "devOptional": true, "license": "ISC", "bin": { @@ -3021,9 +3013,9 @@ } }, "node_modules/@react-native-community/cli-types": { - "version": "20.0.1", - "resolved": "https://registry.npmjs.org/@react-native-community/cli-types/-/cli-types-20.0.1.tgz", - "integrity": "sha512-nvIk3axp9gXyZ3Ri4UrfiCam8bYWOL0z4B1pOhSKW/9wrg2v2E9SK27xf3GZGAP/XuWKT7tuyRWHx1KZEQsfkg==", + "version": "20.1.3", + "resolved": "https://registry.npmjs.org/@react-native-community/cli-types/-/cli-types-20.1.3.tgz", + "integrity": "sha512-IdAcegf0pH1hVraxWTG1ACLkYC0LDQfqtaEf42ESyLIF3Xap70JzL/9tAlxw7lSCPZPFWhrcgU0TBc4SkC/ecw==", "devOptional": true, "license": "MIT", "dependencies": { @@ -3031,9 +3023,9 @@ } }, "node_modules/@react-native-community/cli/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==", + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", "devOptional": true, "license": "ISC", "bin": { @@ -3044,40 +3036,41 @@ } }, "node_modules/@react-native-community/netinfo": { - "version": "11.4.1", - "resolved": "https://registry.npmjs.org/@react-native-community/netinfo/-/netinfo-11.4.1.tgz", - "integrity": "sha512-B0BYAkghz3Q2V09BF88RA601XursIEA111tnc2JOaN7axJWmNefmfjZqw/KdSxKZp7CZUuPpjBmz/WCR9uaHYg==", + "version": "11.5.2", + "resolved": "https://registry.npmjs.org/@react-native-community/netinfo/-/netinfo-11.5.2.tgz", + "integrity": "sha512-/g0m65BtX9HU+bPiCH2517bOHpEIUsGrWFXDzi1a5nNKn5KujQgm04WhL7/OSXWKHyrT8VVtUoJA0XKRxueBpQ==", "license": "MIT", "peerDependencies": { + "react": "*", "react-native": ">=0.59" } }, "node_modules/@react-native/assets-registry": { - "version": "0.79.6", - "resolved": "https://registry.npmjs.org/@react-native/assets-registry/-/assets-registry-0.79.6.tgz", - "integrity": "sha512-UVSP1224PWg0X+mRlZNftV5xQwZGfawhivuW8fGgxNK9MS/U84xZ+16lkqcPh1ank6MOt239lIWHQ1S33CHgqA==", + "version": "0.83.4", + "resolved": "https://registry.npmjs.org/@react-native/assets-registry/-/assets-registry-0.83.4.tgz", + "integrity": "sha512-aqKtpbJDSQeSX/Dwv0yMe1/Rd2QfXi12lnyZDXNn/OEKz59u6+LuPBVgO/9CRyclHmdlvwg8c7PJ9eX2ZMnjWg==", "license": "MIT", "engines": { - "node": ">=18" + "node": ">= 20.19.4" } }, "node_modules/@react-native/babel-plugin-codegen": { - "version": "0.79.6", - "resolved": "https://registry.npmjs.org/@react-native/babel-plugin-codegen/-/babel-plugin-codegen-0.79.6.tgz", - "integrity": "sha512-CS5OrgcMPixOyUJ/Sk/HSsKsKgyKT5P7y3CojimOQzWqRZBmoQfxdST4ugj7n1H+ebM2IKqbgovApFbqXsoX0g==", + "version": "0.83.4", + "resolved": "https://registry.npmjs.org/@react-native/babel-plugin-codegen/-/babel-plugin-codegen-0.83.4.tgz", + "integrity": "sha512-UFsK+c1rvT84XZfzpmwKePsc5nTr5LK7hh18TI0DooNlVcztDbMDsQZpDnhO/gmk7aTbWEqO5AB3HJ7tvGp+Jg==", "license": "MIT", "dependencies": { "@babel/traverse": "^7.25.3", - "@react-native/codegen": "0.79.6" + "@react-native/codegen": "0.83.4" }, "engines": { - "node": ">=18" + "node": ">= 20.19.4" } }, "node_modules/@react-native/babel-preset": { - "version": "0.79.6", - "resolved": "https://registry.npmjs.org/@react-native/babel-preset/-/babel-preset-0.79.6.tgz", - "integrity": "sha512-H+FRO+r2Ql6b5IwfE0E7D52JhkxjeGSBSUpCXAI5zQ60zSBJ54Hwh2bBJOohXWl4J+C7gKYSAd2JHMUETu+c/A==", + "version": "0.83.4", + "resolved": "https://registry.npmjs.org/@react-native/babel-preset/-/babel-preset-0.83.4.tgz", + "integrity": "sha512-SXPFn3Jp4gOzlBDnDOKPzMfxQPKJMYJs05EmEeFB/6km46xZ9l+2YKXwAwxfNhHnmwNf98U/bnVndU95I0TMCw==", "license": "MIT", "dependencies": { "@babel/core": "^7.25.2", @@ -3121,54 +3114,74 @@ "@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.79.6", - "babel-plugin-syntax-hermes-parser": "0.25.1", + "@react-native/babel-plugin-codegen": "0.83.4", + "babel-plugin-syntax-hermes-parser": "0.32.0", "babel-plugin-transform-flow-enums": "^0.0.2", "react-refresh": "^0.14.0" }, "engines": { - "node": ">=18" + "node": ">= 20.19.4" }, "peerDependencies": { "@babel/core": "*" } }, "node_modules/@react-native/codegen": { - "version": "0.79.6", - "resolved": "https://registry.npmjs.org/@react-native/codegen/-/codegen-0.79.6.tgz", - "integrity": "sha512-iRBX8Lgbqypwnfba7s6opeUwVyaR23mowh9ILw7EcT2oLz3RqMmjJdrbVpWhGSMGq2qkPfqAH7bhO8C7O+xfjQ==", + "version": "0.83.4", + "resolved": "https://registry.npmjs.org/@react-native/codegen/-/codegen-0.83.4.tgz", + "integrity": "sha512-CJ7XutzIqJPz3Lp/5TOiRWlU/JAjTboMT1BHNLSXjYHXwTmgHM3iGEbpCOtBMjWvsojRTJyRO/G3ghInIIXEYg==", "license": "MIT", "dependencies": { "@babel/core": "^7.25.2", "@babel/parser": "^7.25.3", "glob": "^7.1.1", - "hermes-parser": "0.25.1", + "hermes-parser": "0.32.0", "invariant": "^2.2.4", "nullthrows": "^1.1.1", "yargs": "^17.6.2" }, "engines": { - "node": ">=18" + "node": ">= 20.19.4" }, "peerDependencies": { "@babel/core": "*" } }, + "node_modules/@react-native/codegen/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/@react-native/codegen/node_modules/brace-expansion": { - "version": "1.1.12", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", - "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "version": "1.1.14", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz", + "integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==", "license": "MIT", "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, + "node_modules/@react-native/codegen/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/@react-native/codegen/node_modules/glob": { "version": "7.2.3", "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", - "deprecated": "Glob versions prior to v9 are no longer supported", + "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", @@ -3185,10 +3198,25 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/@react-native/codegen/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/@react-native/codegen/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/@react-native/codegen/node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "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" @@ -3197,52 +3225,105 @@ "node": "*" } }, + "node_modules/@react-native/codegen/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/@react-native/codegen/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/@react-native/codegen/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/@react-native/codegen/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/@react-native/codegen/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/@react-native/community-cli-plugin": { - "version": "0.79.6", - "resolved": "https://registry.npmjs.org/@react-native/community-cli-plugin/-/community-cli-plugin-0.79.6.tgz", - "integrity": "sha512-ZHVst9vByGsegeaddkD2YbZ6NvYb4n3pD9H7Pit94u+NlByq2uBJghoOjT6EKqg+UVl8tLRdi88cU2pDPwdHqA==", + "version": "0.83.4", + "resolved": "https://registry.npmjs.org/@react-native/community-cli-plugin/-/community-cli-plugin-0.83.4.tgz", + "integrity": "sha512-8os0weQEnjUhWy7Db881+JKRwNHVGM40VtTRvltAyA/YYkrGg4kPCqiTybMxQDEcF3rnviuxHyI+ITiglfmgmQ==", "license": "MIT", "dependencies": { - "@react-native/dev-middleware": "0.79.6", - "chalk": "^4.0.0", - "debug": "^2.2.0", + "@react-native/dev-middleware": "0.83.4", + "debug": "^4.4.0", "invariant": "^2.2.4", - "metro": "^0.82.0", - "metro-config": "^0.82.0", - "metro-core": "^0.82.0", + "metro": "^0.83.3", + "metro-config": "^0.83.3", + "metro-core": "^0.83.3", "semver": "^7.1.3" }, "engines": { - "node": ">=18" + "node": ">= 20.19.4" }, "peerDependencies": { - "@react-native-community/cli": "*" + "@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/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/@react-native/community-cli-plugin/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/@react-native/community-cli-plugin/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==", + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", "license": "ISC", "bin": { "semver": "bin/semver.js" @@ -3252,51 +3333,62 @@ } }, "node_modules/@react-native/debugger-frontend": { - "version": "0.79.6", - "resolved": "https://registry.npmjs.org/@react-native/debugger-frontend/-/debugger-frontend-0.79.6.tgz", - "integrity": "sha512-lIK/KkaH7ueM22bLO0YNaQwZbT/oeqhaghOvmZacaNVbJR1Cdh/XAqjT8FgCS+7PUnbxA8B55NYNKGZG3O2pYw==", + "version": "0.83.4", + "resolved": "https://registry.npmjs.org/@react-native/debugger-frontend/-/debugger-frontend-0.83.4.tgz", + "integrity": "sha512-mCE2s/S7SEjax3gZb6LFAraAI3x13gRVWJWqT0HIm71e4ITObENNTDuMw4mvZ/wr4Gz2wv4FcBH5/Nla9LXOcg==", "license": "BSD-3-Clause", "engines": { - "node": ">=18" + "node": ">= 20.19.4" + } + }, + "node_modules/@react-native/debugger-shell": { + "version": "0.83.4", + "resolved": "https://registry.npmjs.org/@react-native/debugger-shell/-/debugger-shell-0.83.4.tgz", + "integrity": "sha512-FtAnrvXqy1xeZ+onwilvxEeeBsvBlhtfrHVIC2R/BOJAK9TbKEtFfjio0wsn3DQIm+UZq48DSa+p9jJZ2aJUww==", + "license": "MIT", + "dependencies": { + "cross-spawn": "^7.0.6", + "fb-dotslash": "0.5.8" + }, + "engines": { + "node": ">= 20.19.4" } }, "node_modules/@react-native/dev-middleware": { - "version": "0.79.6", - "resolved": "https://registry.npmjs.org/@react-native/dev-middleware/-/dev-middleware-0.79.6.tgz", - "integrity": "sha512-BK3GZBa9c7XSNR27EDRtxrgyyA3/mf1j3/y+mPk7Ac0Myu85YNrXnC9g3mL5Ytwo0g58TKrAIgs1fF2Q5Mn6mQ==", + "version": "0.83.4", + "resolved": "https://registry.npmjs.org/@react-native/dev-middleware/-/dev-middleware-0.83.4.tgz", + "integrity": "sha512-3s9nXZc/kj986nI2RPqxiIJeTS3o7pvZDxbHu7GE9WVIGX9YucA1l/tEiXd7BAm3TBFOfefDOT08xD46wH+R3Q==", "license": "MIT", "dependencies": { "@isaacs/ttlcache": "^1.4.1", - "@react-native/debugger-frontend": "0.79.6", + "@react-native/debugger-frontend": "0.83.4", + "@react-native/debugger-shell": "0.83.4", "chrome-launcher": "^0.15.2", "chromium-edge-launcher": "^0.2.0", "connect": "^3.6.5", - "debug": "^2.2.0", + "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" + "ws": "^7.5.10" }, "engines": { - "node": ">=18" + "node": ">= 20.19.4" } }, - "node_modules/@react-native/dev-middleware/node_modules/debug": { - "version": "2.6.9", - "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", - "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "node_modules/@react-native/dev-middleware/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": { - "ms": "2.0.0" + "is-docker": "^2.0.0" + }, + "engines": { + "node": ">=8" } }, - "node_modules/@react-native/dev-middleware/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/@react-native/dev-middleware/node_modules/open": { "version": "7.4.2", "resolved": "https://registry.npmjs.org/open/-/open-7.4.2.tgz", @@ -3313,22 +3405,43 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/@react-native/dev-middleware/node_modules/ws": { + "version": "7.5.10", + "resolved": "https://registry.npmjs.org/ws/-/ws-7.5.10.tgz", + "integrity": "sha512-+dbF1tHwZpXcbOJdVOkzLDxZP1ailvSxM6ZweXTegylPny803bFhA+vqBYw4s31NSAk4S2Qz+AKXK9a4wkdjcQ==", + "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/@react-native/gradle-plugin": { - "version": "0.79.6", - "resolved": "https://registry.npmjs.org/@react-native/gradle-plugin/-/gradle-plugin-0.79.6.tgz", - "integrity": "sha512-C5odetI6py3CSELeZEVz+i00M+OJuFZXYnjVD4JyvpLn462GesHRh+Se8mSkU5QSaz9cnpMnyFLJAx05dokWbA==", + "version": "0.83.4", + "resolved": "https://registry.npmjs.org/@react-native/gradle-plugin/-/gradle-plugin-0.83.4.tgz", + "integrity": "sha512-AhaSWw2k3eMKqZ21IUdM7rpyTYOpAfsBbIIiom1QQii3QccX0uW2AWTcRhfuWRxqr2faGFaOBYedWl2fzp5hgw==", "license": "MIT", "engines": { - "node": ">=18" + "node": ">= 20.19.4" } }, "node_modules/@react-native/js-polyfills": { - "version": "0.79.6", - "resolved": "https://registry.npmjs.org/@react-native/js-polyfills/-/js-polyfills-0.79.6.tgz", - "integrity": "sha512-6wOaBh1namYj9JlCNgX2ILeGUIwc6OP6MWe3Y5jge7Xz9fVpRqWQk88Q5Y9VrAtTMTcxoX3CvhrfRr3tGtSfQw==", + "version": "0.83.4", + "resolved": "https://registry.npmjs.org/@react-native/js-polyfills/-/js-polyfills-0.83.4.tgz", + "integrity": "sha512-wYUdv0rt4MjhKhQloO1AnGDXhZQOFZHDxm86dEtEA0WcsCdVrFdRULFM+rKUC/QQtJW2rS6WBqtBusgtrsDADg==", "license": "MIT", "engines": { - "node": ">=18" + "node": ">= 20.19.4" } }, "node_modules/@react-native/normalize-colors": { @@ -3338,9 +3451,9 @@ "license": "MIT" }, "node_modules/@react-native/virtualized-lists": { - "version": "0.80.2", - "resolved": "https://registry.npmjs.org/@react-native/virtualized-lists/-/virtualized-lists-0.80.2.tgz", - "integrity": "sha512-kXsIV2eB73QClbbH/z/lRhZkyj3Dke4tarM5w2yXSNwJthMPMfj4KqLZ6Lnf0nmPPjz7qo/voKtlrGqlM822Rg==", + "version": "0.80.3", + "resolved": "https://registry.npmjs.org/@react-native/virtualized-lists/-/virtualized-lists-0.80.3.tgz", + "integrity": "sha512-ueFQDENYCeZnmfMiGV7HjpcN37cFmuxAw2JEEvBp0LygrFoMpp5lXvKz8FclpamX7DAvwhNW4+w+v/rtgeyuAQ==", "license": "MIT", "dependencies": { "invariant": "^2.2.4", @@ -3361,17 +3474,17 @@ } }, "node_modules/@react-navigation/bottom-tabs": { - "version": "7.14.0", - "resolved": "https://registry.npmjs.org/@react-navigation/bottom-tabs/-/bottom-tabs-7.14.0.tgz", - "integrity": "sha512-oG2VdoInuIyK0o9o90Yo47hTCS+sPyVE7k8eSB37vt3pq3uQxjh8V3xJpsQfOfNlRUXOPB/ejH93nSBlP7ZHmQ==", + "version": "7.15.9", + "resolved": "https://registry.npmjs.org/@react-navigation/bottom-tabs/-/bottom-tabs-7.15.9.tgz", + "integrity": "sha512-Ou28A1aZLj5wiFQ3F93aIsrI4NCwn3IJzkkjNo9KLFXsc0Yks+UqrVaFlffHFLsrbajuGRG/OQpnMA1ljayY5Q==", "license": "MIT", "dependencies": { - "@react-navigation/elements": "^2.9.5", + "@react-navigation/elements": "^2.9.14", "color": "^4.2.3", "sf-symbols-typescript": "^2.1.0" }, "peerDependencies": { - "@react-navigation/native": "^7.1.28", + "@react-navigation/native": "^7.2.2", "react": ">= 18.2.0", "react-native": "*", "react-native-safe-area-context": ">= 4.0.0", @@ -3379,9 +3492,9 @@ } }, "node_modules/@react-navigation/core": { - "version": "7.14.0", - "resolved": "https://registry.npmjs.org/@react-navigation/core/-/core-7.14.0.tgz", - "integrity": "sha512-tMpzskBzVp0E7CRNdNtJIdXjk54Kwe/TF9ViXAef+YFM1kSfGv4e/B2ozfXE+YyYgmh4WavTv8fkdJz1CNyu+g==", + "version": "7.17.2", + "resolved": "https://registry.npmjs.org/@react-navigation/core/-/core-7.17.2.tgz", + "integrity": "sha512-Rt2OZwcgOmjv401uLGAKaRM6xo0fiBce/A7LfRHI1oe5FV+KooWcgAoZ2XOtgKj6UzVMuQWt3b2e6rxo/mDJRA==", "license": "MIT", "dependencies": { "@react-navigation/routers": "^7.5.3", @@ -3397,16 +3510,10 @@ "react": ">= 18.2.0" } }, - "node_modules/@react-navigation/core/node_modules/react-is": { - "version": "19.2.4", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.4.tgz", - "integrity": "sha512-W+EWGn2v0ApPKgKKCy/7s7WHXkboGcsrXE+2joLyVxkbyVQfO3MUEaUQDHoSmb8TFFrSKYa9mw64WZHNHSDzYA==", - "license": "MIT" - }, "node_modules/@react-navigation/elements": { - "version": "2.9.5", - "resolved": "https://registry.npmjs.org/@react-navigation/elements/-/elements-2.9.5.tgz", - "integrity": "sha512-iHZU8rRN1014Upz73AqNVXDvSMZDh5/ktQ1CMe21rdgnOY79RWtHHBp9qOS3VtqlUVYGkuX5GEw5mDt4tKdl0g==", + "version": "2.9.14", + "resolved": "https://registry.npmjs.org/@react-navigation/elements/-/elements-2.9.14.tgz", + "integrity": "sha512-lKqzu+su2pI/YIZmR7L7xdOs4UL+rVXKJAMpRMBrwInEy96SjIFst6QDGpE89Dunnu3VjVpjWfByo9f2GWBHDQ==", "license": "MIT", "dependencies": { "color": "^4.2.3", @@ -3415,7 +3522,7 @@ }, "peerDependencies": { "@react-native-masked-view/masked-view": ">= 0.2.0", - "@react-navigation/native": "^7.1.28", + "@react-navigation/native": "^7.2.2", "react": ">= 18.2.0", "react-native": "*", "react-native-safe-area-context": ">= 4.0.0" @@ -3427,13 +3534,13 @@ } }, "node_modules/@react-navigation/native": { - "version": "7.1.28", - "resolved": "https://registry.npmjs.org/@react-navigation/native/-/native-7.1.28.tgz", - "integrity": "sha512-d1QDn+KNHfHGt3UIwOZvupvdsDdiHYZBEj7+wL2yDVo3tMezamYy60H9s3EnNVE1Ae1ty0trc7F2OKqo/RmsdQ==", + "version": "7.2.2", + "resolved": "https://registry.npmjs.org/@react-navigation/native/-/native-7.2.2.tgz", + "integrity": "sha512-kem1Ko2BcbAjmbQIv66dNmr6EtfDut3QU0qjsVhMnLLhktwyXb6FzZYp8gTrUb6AvkAbaJoi+BF5Pl55pAUa5w==", "license": "MIT", "peer": true, "dependencies": { - "@react-navigation/core": "^7.14.0", + "@react-navigation/core": "^7.17.2", "escape-string-regexp": "^4.0.0", "fast-deep-equal": "^3.1.3", "nanoid": "^3.3.11", @@ -3445,18 +3552,18 @@ } }, "node_modules/@react-navigation/native-stack": { - "version": "7.13.0", - "resolved": "https://registry.npmjs.org/@react-navigation/native-stack/-/native-stack-7.13.0.tgz", - "integrity": "sha512-5OOp1IKEd5woHl9hGBU0qCAfrQ4+7Tqej0HzDzGQeXzS8tg9gq84x1qUdRvFk5BXbhuAyvJliY9F1/I07d2X0A==", + "version": "7.14.11", + "resolved": "https://registry.npmjs.org/@react-navigation/native-stack/-/native-stack-7.14.11.tgz", + "integrity": "sha512-1ufBtJ7KbVFlQhXsYSYHqjgkmP30AzJSgW48YjWMQZ3NZGAyYe34w9Wd4KpdebQCfDClPe9maU+8crA/awa6lQ==", "license": "MIT", "dependencies": { - "@react-navigation/elements": "^2.9.5", + "@react-navigation/elements": "^2.9.14", "color": "^4.2.3", "sf-symbols-typescript": "^2.1.0", "warn-once": "^0.1.1" }, "peerDependencies": { - "@react-navigation/native": "^7.1.28", + "@react-navigation/native": "^7.2.2", "react": ">= 18.2.0", "react-native": "*", "react-native-safe-area-context": ">= 4.0.0", @@ -3497,9 +3604,9 @@ "license": "BSD-3-Clause" }, "node_modules/@sinclair/typebox": { - "version": "0.27.8", - "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.8.tgz", - "integrity": "sha512-+Fj43pSMwJs4KRrH/938Uf+uAELIgVBmQzg/q1YG10djyfA3TnrU8N8XzqCh/okZdszqBQTZf96idMfE5lnwTA==", + "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/@sinonjs/commons": { @@ -3521,13 +3628,13 @@ } }, "node_modules/@siteed/expo-audio-studio": { - "version": "2.18.1", - "resolved": "https://registry.npmjs.org/@siteed/expo-audio-studio/-/expo-audio-studio-2.18.1.tgz", - "integrity": "sha512-gyi02iaj/ZEpMOnFM7jlgXWhy4I6pvu7dK7f95nQgFkuSDogmM/pA/n+lS31ui5HzDbVRle426EpQ1hShVmqDA==", + "version": "2.18.6", + "resolved": "https://registry.npmjs.org/@siteed/expo-audio-studio/-/expo-audio-studio-2.18.6.tgz", + "integrity": "sha512-8NeJfuuP4Pbpx88QuPWiCm/OwcV41YJZ1MqgTa3+W1Evleqb6W33T/W2+9iLqKck2g1doW2J9esP92TMDZVEmQ==", + "deprecated": "Use @siteed/audio-studio instead", "license": "MIT", "peerDependencies": { - "expo": "*", - "expo-modules-core": "~2.4.0", + "expo": ">=52.0.0", "react": "*", "react-native": "*" } @@ -3573,6 +3680,12 @@ "@babel/types": "^7.28.2" } }, + "node_modules/@types/emscripten": { + "version": "1.41.5", + "resolved": "https://registry.npmjs.org/@types/emscripten/-/emscripten-1.41.5.tgz", + "integrity": "sha512-cMQm7pxu6BxtHyqJ7mQZ2kXWV5SLmugybFdHCBbJ5eHzOo6VhBckEgAT3//rP5FwPHNPeEiq4SmQ5ucBwsOo4Q==", + "license": "MIT" + }, "node_modules/@types/graceful-fs": { "version": "4.1.9", "resolved": "https://registry.npmjs.org/@types/graceful-fs/-/graceful-fs-4.1.9.tgz", @@ -3606,30 +3719,24 @@ "@types/istanbul-lib-report": "*" } }, - "node_modules/@types/json-schema": { - "version": "7.0.15", - "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", - "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", - "license": "MIT" - }, "node_modules/@types/node": { - "version": "24.3.1", - "resolved": "https://registry.npmjs.org/@types/node/-/node-24.3.1.tgz", - "integrity": "sha512-3vXmQDXy+woz+gnrTvuvNrPzekOi+Ds0ReMxw0LzBiK3a+1k0kQn9f2NWk+lgD4rJehFUmYy2gMhJ2ZI+7YP9g==", + "version": "25.6.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.6.0.tgz", + "integrity": "sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ==", "license": "MIT", "dependencies": { - "undici-types": "~7.10.0" + "undici-types": "~7.19.0" } }, "node_modules/@types/react": { - "version": "19.0.14", - "resolved": "https://registry.npmjs.org/@types/react/-/react-19.0.14.tgz", - "integrity": "sha512-ixLZ7zG7j1fM0DijL9hDArwhwcCb4vqmePgwtV0GfnkHRSCUEv4LvzarcTdhoqgyMznUx/EhoTUv31CKZzkQlw==", + "version": "19.2.14", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", + "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", "devOptional": true, "license": "MIT", "peer": true, "dependencies": { - "csstype": "^3.0.2" + "csstype": "^3.2.2" } }, "node_modules/@types/stack-utils": { @@ -3639,9 +3746,9 @@ "license": "MIT" }, "node_modules/@types/yargs": { - "version": "17.0.33", - "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.33.tgz", - "integrity": "sha512-WpxBCKWPLr4xSsHgz511rFJAM+wS28w2zEO1QDNY5zM/S8ok70NNfztH0xwhqKyaK0OHCbN98LDAZuy1ctxDkA==", + "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": "*" @@ -3653,40 +3760,23 @@ "integrity": "sha512-I4q9QU9MQv4oEOz4tAHJtNz1cwuLxn2F3xcc2iV5WdqLPpUnj30aUuxt1mAxYTG+oe8CZMV/+6rU4S4gRDzqtQ==", "license": "MIT" }, - "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/@ungap/structured-clone": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.0.tgz", + "integrity": "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==", + "license": "ISC" }, "node_modules/@vscode/sudo-prompt": { - "version": "9.3.1", - "resolved": "https://registry.npmjs.org/@vscode/sudo-prompt/-/sudo-prompt-9.3.1.tgz", - "integrity": "sha512-9ORTwwS74VaTn38tNbQhsA5U44zkJfcb0BdTSyyG6frP4e8KMtHuTXYmwefe5dpL8XB1aGSIVTaLjD3BbWb5iA==", + "version": "9.3.2", + "resolved": "https://registry.npmjs.org/@vscode/sudo-prompt/-/sudo-prompt-9.3.2.tgz", + "integrity": "sha512-gcXoCN00METUNFeQOFJ+C9xUI0DKB+0EGMVg7wbVYRHBw2Eq3fKisDZOkRdOz3kqXRKOENMfShPOmypw1/8nOw==", "devOptional": true, "license": "MIT" }, "node_modules/@xmldom/xmldom": { - "version": "0.8.11", - "resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.11.tgz", - "integrity": "sha512-cQzWCtO6C8TQiYl1ruKNn2U6Ao4o4WBBcbL61yJl84x+j5sOWWFU9X7DpND8XZG3daDppSsigMdfAIl2upQBRw==", + "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" @@ -3727,9 +3817,9 @@ } }, "node_modules/acorn": { - "version": "8.15.0", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", - "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", + "version": "8.16.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", + "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", "license": "MIT", "bin": { "acorn": "bin/acorn" @@ -3747,52 +3837,6 @@ "node": ">= 14" } }, - "node_modules/ajv": { - "version": "8.17.1", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz", - "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", - "license": "MIT", - "peer": true, - "dependencies": { - "fast-deep-equal": "^3.1.3", - "fast-uri": "^3.0.1", - "json-schema-traverse": "^1.0.0", - "require-from-string": "^2.0.2" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/epoberezkin" - } - }, - "node_modules/ajv-formats": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-2.1.1.tgz", - "integrity": "sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==", - "license": "MIT", - "dependencies": { - "ajv": "^8.0.0" - }, - "peerDependencies": { - "ajv": "^8.0.0" - }, - "peerDependenciesMeta": { - "ajv": { - "optional": true - } - } - }, - "node_modules/ajv-keywords": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-5.1.0.tgz", - "integrity": "sha512-YCS/JNFAUyr5vAuhk1DWm1CBxRHW9LbJ2ozWeemrIqpbsqKjHVxYPyi5GC0rjZIT5JxJ3virVTS8wk4i/Z+krw==", - "license": "MIT", - "dependencies": { - "fast-deep-equal": "^3.1.3" - }, - "peerDependencies": { - "ajv": "^8.8.2" - } - }, "node_modules/anser": { "version": "1.4.10", "resolved": "https://registry.npmjs.org/anser/-/anser-1.4.10.tgz", @@ -3838,29 +3882,6 @@ "strip-ansi": "^5.0.0" } }, - "node_modules/ansi-fragments/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==", - "devOptional": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/ansi-fragments/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==", - "devOptional": true, - "license": "MIT", - "dependencies": { - "ansi-regex": "^4.1.0" - }, - "engines": { - "node": ">=6" - } - }, "node_modules/ansi-regex": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", @@ -3885,12 +3906,6 @@ "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", @@ -3904,18 +3919,6 @@ "node": ">= 8" } }, - "node_modules/anymatch/node_modules/picomatch": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", - "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", - "license": "MIT", - "engines": { - "node": ">=8.6" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, "node_modules/appdirsjs": { "version": "1.2.7", "resolved": "https://registry.npmjs.org/appdirsjs/-/appdirsjs-1.2.7.tgz", @@ -3935,6 +3938,18 @@ "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", "license": "Python-2.0" }, + "node_modules/aria-hidden": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/aria-hidden/-/aria-hidden-1.2.6.tgz", + "integrity": "sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/asap": { "version": "2.0.6", "resolved": "https://registry.npmjs.org/asap/-/asap-2.0.6.tgz", @@ -3955,6 +3970,7 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/async-limiter/-/async-limiter-1.0.1.tgz", "integrity": "sha512-csOlWGAcRFJaI6m+F2WKdnMKr4HhdhFVBk0H/QbJFMCr+uO2kwohwXQPxw/9OCxp05r5ghVBFSyioixx3gfkNQ==", + "devOptional": true, "license": "MIT" }, "node_modules/babel-jest": { @@ -4010,13 +4026,13 @@ } }, "node_modules/babel-plugin-polyfill-corejs2": { - "version": "0.4.14", - "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-corejs2/-/babel-plugin-polyfill-corejs2-0.4.14.tgz", - "integrity": "sha512-Co2Y9wX854ts6U8gAAPXfn0GmAyctHuK8n0Yhfjd6t30g7yvKjspvvOo9yG+z52PZRgFErt7Ka2pYnXCjLKEpg==", + "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.27.7", - "@babel/helper-define-polyfill-provider": "^0.6.5", + "@babel/compat-data": "^7.28.6", + "@babel/helper-define-polyfill-provider": "^0.6.8", "semver": "^6.3.1" }, "peerDependencies": { @@ -4037,30 +4053,54 @@ } }, "node_modules/babel-plugin-polyfill-regenerator": { - "version": "0.6.5", - "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-regenerator/-/babel-plugin-polyfill-regenerator-0.6.5.tgz", - "integrity": "sha512-ISqQ2frbiNU9vIJkzg7dlPpznPZ4jOiUQ1uSmB0fEHeowtN3COYRsXr/xexn64NpU13P06jc/L5TgiJXOgrbEg==", + "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.5" + "@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.19.13", - "resolved": "https://registry.npmjs.org/babel-plugin-react-native-web/-/babel-plugin-react-native-web-0.19.13.tgz", - "integrity": "sha512-4hHoto6xaN23LCyZgL9LJZc3olmAxd7b6jDzlZnKXAh4rRAbZRKNBJoOOdp46OBqgy+K0t0guTj5/mhA8inymQ==", + "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.25.1", - "resolved": "https://registry.npmjs.org/babel-plugin-syntax-hermes-parser/-/babel-plugin-syntax-hermes-parser-0.25.1.tgz", - "integrity": "sha512-IVNpGzboFLfXZUAwkLFcI/bnqVbwky0jP3eBno4HKtqvQJAHBLdgxiG6lQ4to0+Q/YCN3PO0od5NZwIKyY4REQ==", + "version": "0.32.0", + "resolved": "https://registry.npmjs.org/babel-plugin-syntax-hermes-parser/-/babel-plugin-syntax-hermes-parser-0.32.0.tgz", + "integrity": "sha512-m5HthL++AbyeEA2FcdwOLfVFvWYECOBObLHNqdR8ceY4TsEdn4LdX2oTvbB2QJSSElE2AWA/b2MXZ/PF/CqLZg==", + "license": "MIT", + "dependencies": { + "hermes-parser": "0.32.0" + } + }, + "node_modules/babel-plugin-syntax-hermes-parser/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/babel-plugin-syntax-hermes-parser/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-parser": "0.25.1" + "hermes-estree": "0.32.0" } }, "node_modules/babel-plugin-transform-flow-enums": { @@ -4098,43 +4138,6 @@ "@babel/core": "^7.0.0 || ^8.0.0-0" } }, - "node_modules/babel-preset-expo": { - "version": "13.2.4", - "resolved": "https://registry.npmjs.org/babel-preset-expo/-/babel-preset-expo-13.2.4.tgz", - "integrity": "sha512-3IKORo3KR+4qtLdCkZNDj8KeA43oBn7RRQejFGWfiZgu/NeaRUSri8YwYjZqybm7hn3nmMv9OLahlvXBX23o5Q==", - "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-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.79.6", - "babel-plugin-react-native-web": "~0.19.13", - "babel-plugin-syntax-hermes-parser": "^0.25.1", - "babel-plugin-transform-flow-enums": "^0.0.2", - "debug": "^4.3.4", - "react-refresh": "^0.14.2", - "resolve-from": "^5.0.0" - }, - "peerDependencies": { - "babel-plugin-react-compiler": "^19.0.0-beta-e993439-20250405" - }, - "peerDependenciesMeta": { - "babel-plugin-react-compiler": { - "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", @@ -4152,10 +4155,22 @@ } }, "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" + "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/barcode-detector": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/barcode-detector/-/barcode-detector-3.1.2.tgz", + "integrity": "sha512-Q5kjXpVH5I3ItykNzbWmfWnNryFN1ZTWp10k9/PKJuS0RnoKR7jTrHEJODR4fn04bRomq7TJwie/Dr9fj/GoGQ==", + "license": "MIT", + "dependencies": { + "zxing-wasm": "3.0.2" + } }, "node_modules/base-64": { "version": "1.0.0", @@ -4183,6 +4198,18 @@ ], "license": "MIT" }, + "node_modules/baseline-browser-mapping": { + "version": "2.10.20", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.20.tgz", + "integrity": "sha512-1AaXxEPfXT+GvTBJFuy4yXVHWJBXa4OdbIebGN/wX5DlsIkU0+wzGnd2lOzokSk51d5LUmqjgBLRLlypLUqInQ==", + "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", @@ -4195,6 +4222,18 @@ "node": ">=12.0.0" } }, + "node_modules/better-opn/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/better-opn/node_modules/open": { "version": "8.4.2", "resolved": "https://registry.npmjs.org/open/-/open-8.4.2.tgz", @@ -4234,47 +4273,30 @@ } }, "node_modules/body-parser": { - "version": "1.20.3", - "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.3.tgz", - "integrity": "sha512-7rAxByjUMqQ3/bHJy7D6OGXvx/MMc4IqBn/X0fcM1QUcAItpZrBEYhWGem+tzXH90c+G01ypMcYJBO9Y30203g==", + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz", + "integrity": "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==", "devOptional": true, "license": "MIT", "dependencies": { - "bytes": "3.1.2", - "content-type": "~1.0.5", - "debug": "2.6.9", - "depd": "2.0.0", - "destroy": "1.2.0", - "http-errors": "2.0.0", - "iconv-lite": "0.4.24", - "on-finished": "2.4.1", - "qs": "6.13.0", - "raw-body": "2.5.2", - "type-is": "~1.6.18", - "unpipe": "1.0.0" + "bytes": "^3.1.2", + "content-type": "^1.0.5", + "debug": "^4.4.3", + "http-errors": "^2.0.0", + "iconv-lite": "^0.7.0", + "on-finished": "^2.4.1", + "qs": "^6.14.1", + "raw-body": "^3.0.1", + "type-is": "^2.0.1" }, "engines": { - "node": ">= 0.8", - "npm": "1.2.8000 || >= 1.4.16" - } - }, - "node_modules/body-parser/node_modules/debug": { - "version": "2.6.9", - "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", - "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", - "devOptional": true, - "license": "MIT", - "dependencies": { - "ms": "2.0.0" + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, - "node_modules/body-parser/node_modules/ms": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", - "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", - "devOptional": true, - "license": "MIT" - }, "node_modules/bplist-creator": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/bplist-creator/-/bplist-creator-0.1.0.tgz", @@ -4285,9 +4307,9 @@ } }, "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==", + "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" @@ -4297,12 +4319,15 @@ } }, "node_modules/brace-expansion": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", - "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz", + "integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==", "license": "MIT", "dependencies": { - "balanced-match": "^1.0.0" + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" } }, "node_modules/braces": { @@ -4318,9 +4343,9 @@ } }, "node_modules/browserslist": { - "version": "4.25.4", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.25.4.tgz", - "integrity": "sha512-4jYpcjabC606xJ3kw2QwGEZKX0Aw7sgQdZCvIK9dhVSPh76BKo+C+btT1RRofH7B+8iNpEbgGNVWiLki5q93yg==", + "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", @@ -4338,10 +4363,11 @@ "license": "MIT", "peer": true, "dependencies": { - "caniuse-lite": "^1.0.30001737", - "electron-to-chromium": "^1.5.211", - "node-releases": "^2.0.19", - "update-browserslist-db": "^1.1.3" + "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" @@ -4363,6 +4389,7 @@ "version": "5.7.1", "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", + "devOptional": true, "funding": [ { "type": "github", @@ -4429,39 +4456,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/caller-callsite": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/caller-callsite/-/caller-callsite-2.0.0.tgz", - "integrity": "sha512-JuG3qI4QOftFsZyOn1qq87fq5grLIyk1JYd5lJmdA+fG7aQ9pA/i3JIJGcO3q0MrRcHlOt1U+ZeHW8Dq9axALQ==", - "license": "MIT", - "dependencies": { - "callsites": "^2.0.0" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/caller-callsite/node_modules/callsites": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/callsites/-/callsites-2.0.0.tgz", - "integrity": "sha512-ksWePWBloaWPxJYQ8TL0JHvtci6G5QTKwQ95RcWAa/lzoAKuAOflGdAK92hpHXjkwb8zLxoLNUoNYZgVsaJzvQ==", - "license": "MIT", - "engines": { - "node": ">=4" - } - }, - "node_modules/caller-path": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/caller-path/-/caller-path-2.0.0.tgz", - "integrity": "sha512-MCL3sf6nCSXOwCTzvPKhN18TU7AHTvdtam8DAogxcrJ8Rjfbbg7Lgng64H9Iy+vUV6VGFClN/TyxBkAebLRR4A==", - "license": "MIT", - "dependencies": { - "caller-callsite": "^2.0.0" - }, - "engines": { - "node": ">=4" - } - }, "node_modules/callsites": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", @@ -4473,18 +4467,21 @@ } }, "node_modules/camelcase": { - "version": "5.3.1", - "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz", - "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==", + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-6.3.0.tgz", + "integrity": "sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==", "license": "MIT", "engines": { - "node": ">=6" + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, "node_modules/caniuse-lite": { - "version": "1.0.30001741", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001741.tgz", - "integrity": "sha512-QGUGitqsc8ARjLdgAfxETDhRbJ0REsP6O3I96TAth/mVjh2cYzN2u+3AzPP3aVSm2FehEItaJw1xd+IGBXWeSw==", + "version": "1.0.30001788", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001788.tgz", + "integrity": "sha512-6q8HFp+lOQtcf7wBK+uEenxymVWkGKkjFpCvw5W25cmMwEDU45p1xQFBQv8JDlMMry7eNxyBaR+qxgmTUZkIRQ==", "funding": [ { "type": "opencollective", @@ -4517,15 +4514,6 @@ "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", @@ -4544,6 +4532,18 @@ "node": ">=12.13.0" } }, + "node_modules/chrome-launcher/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/chromium-edge-launcher": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/chromium-edge-launcher/-/chromium-edge-launcher-0.2.0.tgz", @@ -4558,21 +4558,24 @@ "rimraf": "^3.0.2" } }, - "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" - } - ], + "node_modules/chromium-edge-launcher/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/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/cli-cursor": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-3.1.0.tgz", @@ -4605,52 +4608,22 @@ "license": "MIT" }, "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==", + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz", + "integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==", + "devOptional": true, "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/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/cliui/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/cliui/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" + "strip-ansi": "^6.0.0", + "wrap-ansi": "^6.2.0" } }, "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==", + "devOptional": true, "license": "MIT", "dependencies": { "ansi-regex": "^5.0.1" @@ -4831,12 +4804,12 @@ "license": "MIT" }, "node_modules/core-js-compat": { - "version": "3.45.1", - "resolved": "https://registry.npmjs.org/core-js-compat/-/core-js-compat-3.45.1.tgz", - "integrity": "sha512-tqTt5T4PzsMIZ430XGviK4vzYSoeNJ6CXODi6c/voxOT6IZqBht5/EKaSNnYiEjjRYxjVz7DQIsOsY0XNi8PIA==", + "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.25.3" + "browserslist": "^4.28.1" }, "funding": { "type": "opencollective", @@ -4844,9 +4817,9 @@ } }, "node_modules/cosmiconfig": { - "version": "9.0.0", - "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-9.0.0.tgz", - "integrity": "sha512-itvL5h8RETACmOTFc4UfIyB2RfEHi71Ax6E/PivVxq9NseKbOWpeyHEOIbmAw1rs8Ak0VursQNww7lf7YtUwzg==", + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-9.0.1.tgz", + "integrity": "sha512-hr4ihw+DBqcvrsEDioRO31Z17x71pUYoNe/4h6Z0wB72p7MU7/9gH8Q3s12NFhHPfYBBOV3qyfUxmr/Yn3shnQ==", "devOptional": true, "license": "MIT", "dependencies": { @@ -4884,33 +4857,24 @@ "node": ">= 8" } }, - "node_modules/crypto-random-string": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/crypto-random-string/-/crypto-random-string-2.0.0.tgz", - "integrity": "sha512-v1plID3y9r/lPhviJ1wrXpLeyUIGAZ2SHNYTEapm7/8A9nLPoyvVp3RK/EPFqn5kEznyWgYZNsRtYYIWbuG8KA==", - "license": "MIT", - "engines": { - "node": ">=8" - } - }, "node_modules/csstype": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", - "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==", + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", "devOptional": true, "license": "MIT" }, "node_modules/dayjs": { - "version": "1.11.18", - "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.18.tgz", - "integrity": "sha512-zFBQ7WFRvVRhKcWoUh+ZA1g2HVgUbsZm9sbddh8EC5iv93sui8DVVz1Npvz+r6meo9VKfa8NyLWBsQK1VvIKPA==", + "version": "1.11.20", + "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.20.tgz", + "integrity": "sha512-YbwwqR/uYpeoP4pu043q+LTDLFBLApUP6VxRihdfNTqu4ubqMlGDLd6ErXhEgsyvY0K6nCs7nggYumAN+9uEuQ==", "devOptional": true, "license": "MIT" }, "node_modules/debug": { - "version": "4.4.1", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz", - "integrity": "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==", + "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" @@ -4943,15 +4907,6 @@ "node": ">=0.10" } }, - "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", @@ -5016,43 +4971,25 @@ } }, "node_modules/detect-libc": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-1.0.3.tgz", - "integrity": "sha512-pGjwhsmsp4kL2RTz08wcOlGN83otlqHeD/Z5T8GXZB+/YcpQ/dgo+lbU8ZsGxV0HIvqqxo9l7mqYwyYMD9bKDg==", + "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", - "bin": { - "detect-libc": "bin/detect-libc.js" - }, "engines": { - "node": ">=0.10" + "node": ">=8" } }, - "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/detect-node-es": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/detect-node-es/-/detect-node-es-1.1.0.tgz", + "integrity": "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==", + "license": "MIT" }, - "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/dnssd-advertise": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/dnssd-advertise/-/dnssd-advertise-1.1.4.tgz", + "integrity": "sha512-AmGyK9WpNf06WeP5TjHZq/wNzP76OuEeaiTlKr9E/EEelYLczywUKoqRz+DPRq/ErssjT4lU+/W7wzJW+7K/ZA==", + "license": "MIT" }, "node_modules/dunder-proto": { "version": "1.0.1", @@ -5069,12 +5006,6 @@ "node": ">= 0.4" } }, - "node_modules/eastasianwidth": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", - "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", - "license": "MIT" - }, "node_modules/ee-first": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", @@ -5082,15 +5013,15 @@ "license": "MIT" }, "node_modules/electron-to-chromium": { - "version": "1.5.214", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.214.tgz", - "integrity": "sha512-TpvUNdha+X3ybfU78NoQatKvQEm1oq3lf2QbnmCEdw+Bd9RuIAY+hJTvq1avzHM0f7EJfnH3vbCnbzKzisc/9Q==", + "version": "1.5.340", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.340.tgz", + "integrity": "sha512-908qahOGocRMinT2nM3ajCEM99H4iPdv84eagPP3FfZy/1ZGeOy2CZYzjhms81ckOPCXPlW7LkY4XpxD8r1DrA==", "license": "ISC" }, "node_modules/emoji-regex": { - "version": "9.2.2", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", - "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", + "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": { @@ -5102,15 +5033,6 @@ "node": ">= 0.8" } }, - "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/env-paths": { "version": "2.2.1", "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz", @@ -5122,9 +5044,9 @@ } }, "node_modules/envinfo": { - "version": "7.14.0", - "resolved": "https://registry.npmjs.org/envinfo/-/envinfo-7.14.0.tgz", - "integrity": "sha512-CO40UI41xDQzhLB1hWyqUKgFhs250pNcGbyGKe1l/e4FSaI/+YE4IMG76GDt0In67WLPACIITC+sOi08x4wIvg==", + "version": "7.21.0", + "resolved": "https://registry.npmjs.org/envinfo/-/envinfo-7.21.0.tgz", + "integrity": "sha512-Lw7I8Zp5YKHFCXL7+Dz95g4CcbMEpgvqZNNq3AmlT5XAV6CgAAk6gyAMqn2zjw08K9BHfcNuKrMiCPLByGafow==", "devOptional": true, "license": "MIT", "bin": { @@ -5135,9 +5057,10 @@ } }, "node_modules/error-ex": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.2.tgz", - "integrity": "sha512-7dFHNmqeFSEt2ZBsCriorKnn3Z2pj+fd9kmI6QoWw4//DL+icEBfc0U7qJCisqrTsKTjw4fNFy2pW9OqStD84g==", + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.4.tgz", + "integrity": "sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==", + "devOptional": true, "license": "MIT", "dependencies": { "is-arrayish": "^0.2.1" @@ -5153,17 +5076,21 @@ } }, "node_modules/errorhandler": { - "version": "1.5.1", - "resolved": "https://registry.npmjs.org/errorhandler/-/errorhandler-1.5.1.tgz", - "integrity": "sha512-rcOwbfvP1WTViVoUjcfZicVzjhjTuhSMntHh6mW3IrEiyE6mJyXvsToJUJGlGlw/2xU9P5whlWNGlIDVeCiT4A==", + "version": "1.5.2", + "resolved": "https://registry.npmjs.org/errorhandler/-/errorhandler-1.5.2.tgz", + "integrity": "sha512-kNAL7hESndBCrWwS72QyV3IVOTrVmj9D062FV5BQswNL5zEdeRmz/WJFyh6Aj/plvvSOrzddkxW57HgkZcR9Fw==", "devOptional": true, "license": "MIT", "dependencies": { - "accepts": "~1.3.7", + "accepts": "~1.3.8", "escape-html": "~1.0.3" }, "engines": { "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, "node_modules/es-define-property": { @@ -5180,7 +5107,6 @@ "version": "1.3.0", "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", - "devOptional": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -5257,12 +5183,6 @@ "node": ">=6" } }, - "node_modules/exec-async": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/exec-async/-/exec-async-2.2.0.tgz", - "integrity": "sha512-87OpwcEiMia/DeiKFzaQNBNFeN3XkkpYIh9FyOqq5mS2oKv3CBE67PXoEKcr6nodWdXNogTiQ0jE2NGuoffXPw==", - "license": "MIT" - }, "node_modules/execa": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/execa/-/execa-5.1.1.tgz", @@ -5288,29 +5208,35 @@ } }, "node_modules/expo": { - "version": "53.0.22", - "resolved": "https://registry.npmjs.org/expo/-/expo-53.0.22.tgz", - "integrity": "sha512-sJ2I4W/e5iiM4u/wYCe3qmW4D7WPCRqByPDD0hJcdYNdjc9HFFFdO4OAudZVyC/MmtoWZEIH5kTJP1cw9FjzYA==", + "version": "55.0.15", + "resolved": "https://registry.npmjs.org/expo/-/expo-55.0.15.tgz", + "integrity": "sha512-sHIvqG477UU1jZHhaexXbUgsU7y+xnYZqDW1HrUkEBYiuEb5lobvWLmwea76EBVkityQx46UDtepFtarpUJQqQ==", "license": "MIT", "peer": true, "dependencies": { "@babel/runtime": "^7.20.0", - "@expo/cli": "0.24.21", - "@expo/config": "~11.0.13", - "@expo/config-plugins": "~10.1.2", - "@expo/fingerprint": "0.13.4", - "@expo/metro-config": "0.20.17", - "@expo/vector-icons": "^14.0.0", - "babel-preset-expo": "~13.2.4", - "expo-asset": "~11.1.7", - "expo-constants": "~17.1.7", - "expo-file-system": "~18.1.11", - "expo-font": "~13.3.2", - "expo-keep-awake": "~14.1.4", - "expo-modules-autolinking": "2.1.14", - "expo-modules-core": "2.5.0", - "react-native-edge-to-edge": "1.6.0", - "whatwg-url-without-unicode": "8.0.0-3" + "@expo/cli": "55.0.24", + "@expo/config": "~55.0.15", + "@expo/config-plugins": "~55.0.8", + "@expo/devtools": "55.0.2", + "@expo/fingerprint": "0.16.6", + "@expo/local-build-cache-provider": "55.0.11", + "@expo/log-box": "55.0.10", + "@expo/metro": "~55.0.0", + "@expo/metro-config": "55.0.16", + "@expo/vector-icons": "^15.0.2", + "@ungap/structured-clone": "^1.3.0", + "babel-preset-expo": "~55.0.17", + "expo-asset": "~55.0.15", + "expo-constants": "~55.0.14", + "expo-file-system": "~55.0.16", + "expo-font": "~55.0.6", + "expo-keep-awake": "~55.0.6", + "expo-modules-autolinking": "55.0.17", + "expo-modules-core": "55.0.22", + "pretty-format": "^29.7.0", + "react-refresh": "^0.14.2", + "whatwg-url-minimum": "^0.1.1" }, "bin": { "expo": "bin/cli", @@ -5325,429 +5251,978 @@ "react-native-webview": "*" }, "peerDependenciesMeta": { - "@expo/dom-webview": { - "optional": true - }, - "@expo/metro-runtime": { + "@expo/dom-webview": { + "optional": true + }, + "@expo/metro-runtime": { + "optional": true + }, + "react-native-webview": { + "optional": true + } + } + }, + "node_modules/expo-asset": { + "version": "55.0.17", + "resolved": "https://registry.npmjs.org/expo-asset/-/expo-asset-55.0.17.tgz", + "integrity": "sha512-pK9HHJuFqjE8kDUcbMFsZj3Cz8WdXpvZHZmYl7ouFQp59P83BvHln6VnqPDGlO+/4929G0Lm8ZUzbONuNRhi9w==", + "license": "MIT", + "peer": true, + "dependencies": { + "@expo/image-utils": "^0.8.14", + "expo-constants": "~55.0.16" + }, + "peerDependencies": { + "expo": "*", + "react": "*", + "react-native": "*" + } + }, + "node_modules/expo-audio": { + "version": "55.0.14", + "resolved": "https://registry.npmjs.org/expo-audio/-/expo-audio-55.0.14.tgz", + "integrity": "sha512-Biy6ffKXrnKHgcWSVWLKVdWLNhV/pj1JWJeotY6nDR6fVe8mjXQDCvi6EbaSFPdffVHym6UB2siKzWUNSnG+kQ==", + "license": "MIT", + "peerDependencies": { + "expo": "*", + "expo-asset": "*", + "react": "*", + "react-native": "*" + } + }, + "node_modules/expo-build-properties": { + "version": "55.0.13", + "resolved": "https://registry.npmjs.org/expo-build-properties/-/expo-build-properties-55.0.13.tgz", + "integrity": "sha512-UYZhUKyh7YQhbJdkBvo68WUQ7fOtZeSV7F8kfYkjEiN/ADRHG0WfEIiddvGfi9cH/5iwpptv/+Lu5cx6uvfegA==", + "license": "MIT", + "dependencies": { + "@expo/schema-utils": "^55.0.3", + "resolve-from": "^5.0.0", + "semver": "^7.6.0" + }, + "peerDependencies": { + "expo": "*" + } + }, + "node_modules/expo-build-properties/node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/expo-camera": { + "version": "55.0.15", + "resolved": "https://registry.npmjs.org/expo-camera/-/expo-camera-55.0.15.tgz", + "integrity": "sha512-WRVsZf+2p7EsxudwyiUMYijJS8M98t/BVP6yG7N+08JSUotkGjmZcemom1gM36uy27P8QsSVP0hD+FravmQiBA==", + "license": "MIT", + "dependencies": { + "barcode-detector": "^3.0.0" + }, + "peerDependencies": { + "expo": "*", + "react": "*", + "react-native": "*", + "react-native-web": "*" + }, + "peerDependenciesMeta": { + "react-native-web": { + "optional": true + } + } + }, + "node_modules/expo-constants": { + "version": "55.0.16", + "resolved": "https://registry.npmjs.org/expo-constants/-/expo-constants-55.0.16.tgz", + "integrity": "sha512-Z15/No94UHoogD+pulxjudGAeOHTEIWZgb/vnX48Wx5D+apWTeCbnKxQZZtGQlosvduYL5kaic2/W8U+NHfBQQ==", + "license": "MIT", + "peer": true, + "dependencies": { + "@expo/env": "~2.1.2" + }, + "peerDependencies": { + "expo": "*", + "react-native": "*" + } + }, + "node_modules/expo-dev-client": { + "version": "55.0.27", + "resolved": "https://registry.npmjs.org/expo-dev-client/-/expo-dev-client-55.0.27.tgz", + "integrity": "sha512-Ala2ER6v1NPFySdnuQN/GpQZI9r7E9eSMvzD32vv8A4FJkPfzjvj6U5uH4IytQQLVTa837fknXRvOMwRb9ZYQQ==", + "license": "MIT", + "dependencies": { + "expo-dev-launcher": "55.0.28", + "expo-dev-menu": "55.0.23", + "expo-dev-menu-interface": "55.0.2", + "expo-manifests": "~55.0.15", + "expo-updates-interface": "~55.1.5" + }, + "peerDependencies": { + "expo": "*" + } + }, + "node_modules/expo-dev-launcher": { + "version": "55.0.28", + "resolved": "https://registry.npmjs.org/expo-dev-launcher/-/expo-dev-launcher-55.0.28.tgz", + "integrity": "sha512-jwCD7YeGqZMWrS4KODb51kPBDta8bDrPjhYCnodglcQ0jhPVsAnVSbtsQ2/TaaYUUtv+ipm1ufhNTFtcwya9kA==", + "license": "MIT", + "dependencies": { + "@expo/schema-utils": "^55.0.3", + "expo-dev-menu": "55.0.23", + "expo-manifests": "~55.0.15" + }, + "peerDependencies": { + "expo": "*" + } + }, + "node_modules/expo-dev-menu": { + "version": "55.0.23", + "resolved": "https://registry.npmjs.org/expo-dev-menu/-/expo-dev-menu-55.0.23.tgz", + "integrity": "sha512-m6B2EwkoX9hwzP50EZPX8vc/szGfvNFsUTw3ExTWnxiJ9/IM0WfCFYjt8siFjMHcyblIjBt/XLN6mw6q8m2AEg==", + "license": "MIT", + "dependencies": { + "expo-dev-menu-interface": "55.0.2" + }, + "peerDependencies": { + "expo": "*" + } + }, + "node_modules/expo-dev-menu-interface": { + "version": "55.0.2", + "resolved": "https://registry.npmjs.org/expo-dev-menu-interface/-/expo-dev-menu-interface-55.0.2.tgz", + "integrity": "sha512-DomUNvGzY/xliwnMdbAYY780sCv19N7zIbifc0ClcoCzJZpNSCkvJ2qGIFRPyM/7DmqmlHGCKi8di7kYYLKNEg==", + "license": "MIT", + "peerDependencies": { + "expo": "*" + } + }, + "node_modules/expo-eas-client": { + "version": "55.0.5", + "resolved": "https://registry.npmjs.org/expo-eas-client/-/expo-eas-client-55.0.5.tgz", + "integrity": "sha512-wRagCeSbSnSGVXgP7V+qiGfXzZ9hTVKWvKIOP7lwrX3MIEenNmNlO4D3RVC3aNU2GhmO3ZCZIIEre80KZoUUHA==", + "license": "MIT" + }, + "node_modules/expo-file-system": { + "version": "55.0.16", + "resolved": "https://registry.npmjs.org/expo-file-system/-/expo-file-system-55.0.16.tgz", + "integrity": "sha512-EetQ/zVFK07Vmz4Yke0fvoES4xVwScTdd0PMoLekuMX7puE4op75pNnEdh1M0AeWzkqLrBoZIaU2ynSrKN5VZg==", + "license": "MIT", + "peerDependencies": { + "expo": "*", + "react-native": "*" + } + }, + "node_modules/expo-font": { + "version": "55.0.6", + "resolved": "https://registry.npmjs.org/expo-font/-/expo-font-55.0.6.tgz", + "integrity": "sha512-x9czUA3UQWjIwa0ZUEs/eWJNqB4mAue/m4ltESlNPLZhHL0nWWqIfsyHmklTLFH7mVfcHSJvew6k+pR2FE1zVw==", + "license": "MIT", + "peer": true, + "dependencies": { + "fontfaceobserver": "^2.1.0" + }, + "peerDependencies": { + "expo": "*", + "react": "*", + "react-native": "*" + } + }, + "node_modules/expo-glass-effect": { + "version": "55.0.10", + "resolved": "https://registry.npmjs.org/expo-glass-effect/-/expo-glass-effect-55.0.10.tgz", + "integrity": "sha512-5kL/jATvgJWdrqPdxixrECJqD2l8cfQ4ALr1DK7qi9XkyI97ejXvUjB2VsfEePNy3Fg+/VwzA3n3L7Nv3tAPkw==", + "license": "MIT", + "peerDependencies": { + "expo": "*", + "react": "*", + "react-native": "*" + } + }, + "node_modules/expo-image": { + "version": "55.0.8", + "resolved": "https://registry.npmjs.org/expo-image/-/expo-image-55.0.8.tgz", + "integrity": "sha512-fNdvdYVcGn3g1x6o5AXHKzk4xX8U6rg2W9vFdE1pQO80kWCNReh003ypqSrGy4dD+zA8FtZjrNF3oMDGnPpIGQ==", + "license": "MIT", + "dependencies": { + "sf-symbols-typescript": "^2.2.0" + }, + "peerDependencies": { + "expo": "*", + "react": "*", + "react-native": "*", + "react-native-web": "*" + }, + "peerDependenciesMeta": { + "react-native-web": { + "optional": true + } + } + }, + "node_modules/expo-image-loader": { + "version": "55.0.0", + "resolved": "https://registry.npmjs.org/expo-image-loader/-/expo-image-loader-55.0.0.tgz", + "integrity": "sha512-NOjp56wDrfuA5aiNAybBIjqIn1IxKeGJ8CECWZncQ/GzjZfyTYAHTCyeApYkdKkMBLHINzI4BbTGSlbCa0fXXQ==", + "license": "MIT", + "peerDependencies": { + "expo": "*" + } + }, + "node_modules/expo-image-picker": { + "version": "55.0.18", + "resolved": "https://registry.npmjs.org/expo-image-picker/-/expo-image-picker-55.0.18.tgz", + "integrity": "sha512-lGpPGRu+7mE8qN0ma2boRsCmfOGbdHZ2bXTpWVeWly0JCZdogGlTrYFnhTqgS8+lmiRb/UCOs7iTm2P5Rra6kw==", + "license": "MIT", + "dependencies": { + "expo-image-loader": "~55.0.0" + }, + "peerDependencies": { + "expo": "*" + } + }, + "node_modules/expo-json-utils": { + "version": "55.0.2", + "resolved": "https://registry.npmjs.org/expo-json-utils/-/expo-json-utils-55.0.2.tgz", + "integrity": "sha512-QJMOZOPOG7CTnKcrdVaiummn2va1MCO56z++eyWkDv3GBRODldM6MFMDf/jTREWthFc2Nxo6TuyWRrEV9S6n/Q==", + "license": "MIT" + }, + "node_modules/expo-linking": { + "version": "55.0.13", + "resolved": "https://registry.npmjs.org/expo-linking/-/expo-linking-55.0.13.tgz", + "integrity": "sha512-xbOqNWQCC5RGtXSW83ZCKOjRivyxO2zBouRYy/hgbsyrHUJhztMAjlq8RKYDUL8D6QVsH9Q81SNoq4Zhcn+4HQ==", + "license": "MIT", + "peer": true, + "dependencies": { + "expo-constants": "~55.0.14", + "invariant": "^2.2.4" + }, + "peerDependencies": { + "react": "*", + "react-native": "*" + } + }, + "node_modules/expo-manifests": { + "version": "55.0.15", + "resolved": "https://registry.npmjs.org/expo-manifests/-/expo-manifests-55.0.15.tgz", + "integrity": "sha512-p40ftXpgLTFGddFy35MYZMyjm/E6IQdn3l6fBZZ6zeraEzYLt+VLHYsplOL9ccTYvUSWKN9aOWRpoEYpyGVBVw==", + "license": "MIT", + "dependencies": { + "@expo/config": "~55.0.14", + "expo-json-utils": "~55.0.2" + }, + "peerDependencies": { + "expo": "*" + } + }, + "node_modules/expo-modules-autolinking": { + "version": "55.0.17", + "resolved": "https://registry.npmjs.org/expo-modules-autolinking/-/expo-modules-autolinking-55.0.17.tgz", + "integrity": "sha512-VhlEVGnP+xBjfSKDKNN7GAPKN2whIfV08jsZvNj7UGyJWpZYiO6Emx1FLP5xd1+JZVpIrt/kxR641kdcPo7Ehw==", + "license": "MIT", + "dependencies": { + "@expo/require-utils": "^55.0.4", + "@expo/spawn-async": "^1.7.2", + "chalk": "^4.1.0", + "commander": "^7.2.0" + }, + "bin": { + "expo-modules-autolinking": "bin/expo-modules-autolinking.js" + } + }, + "node_modules/expo-modules-autolinking/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/expo-modules-core": { + "version": "55.0.22", + "resolved": "https://registry.npmjs.org/expo-modules-core/-/expo-modules-core-55.0.22.tgz", + "integrity": "sha512-NC5GyvCHvnOvi5MtgLv68oUSrRP/0UORGzU/MX+7BIA8ctgBPxKSjPXPSfhwk3gMzj7eHBhYwlu0HJsIEnVd9A==", + "license": "MIT", + "dependencies": { + "invariant": "^2.2.4" + }, + "peerDependencies": { + "react": "*", + "react-native": "*", + "react-native-worklets": "^0.7.4 || ^0.8.0" + }, + "peerDependenciesMeta": { + "react-native-worklets": { + "optional": true + } + } + }, + "node_modules/expo-router": { + "version": "55.0.12", + "resolved": "https://registry.npmjs.org/expo-router/-/expo-router-55.0.12.tgz", + "integrity": "sha512-Bm6IhI0Kl5/tDlCHPms8jDqy1O6HLHIOrMsEmmAQ5Lgg5UBtDfRThEyHPVOLNTOs8e7/bG/Ftz6a4UgQVA+NhQ==", + "license": "MIT", + "dependencies": { + "@expo/metro-runtime": "^55.0.9", + "@expo/schema-utils": "^55.0.3", + "@radix-ui/react-slot": "^1.2.0", + "@radix-ui/react-tabs": "^1.1.12", + "@react-navigation/bottom-tabs": "^7.15.5", + "@react-navigation/native": "^7.1.33", + "@react-navigation/native-stack": "^7.14.5", + "client-only": "^0.0.1", + "debug": "^4.3.4", + "escape-string-regexp": "^4.0.0", + "expo-glass-effect": "^55.0.10", + "expo-image": "^55.0.8", + "expo-server": "^55.0.7", + "expo-symbols": "^55.0.7", + "fast-deep-equal": "^3.1.3", + "invariant": "^2.2.4", + "nanoid": "^3.3.8", + "query-string": "^7.1.3", + "react-fast-compare": "^3.2.2", + "react-native-is-edge-to-edge": "^1.2.1", + "semver": "~7.6.3", + "server-only": "^0.0.1", + "sf-symbols-typescript": "^2.1.0", + "shallowequal": "^1.1.0", + "use-latest-callback": "^0.2.1", + "vaul": "^1.1.2" + }, + "peerDependencies": { + "@expo/log-box": "55.0.10", + "@expo/metro-runtime": "^55.0.9", + "@react-navigation/drawer": "^7.9.4", + "@testing-library/react-native": ">= 13.2.0", + "expo": "*", + "expo-constants": "^55.0.13", + "expo-linking": "^55.0.12", + "react": "*", + "react-dom": "*", + "react-native": "*", + "react-native-gesture-handler": "*", + "react-native-reanimated": "*", + "react-native-safe-area-context": ">= 5.4.0", + "react-native-screens": "*", + "react-native-web": "*", + "react-server-dom-webpack": "~19.0.4 || ~19.1.5 || ~19.2.4" + }, + "peerDependenciesMeta": { + "@react-navigation/drawer": { + "optional": true + }, + "@testing-library/react-native": { + "optional": true + }, + "react-dom": { + "optional": true + }, + "react-native-gesture-handler": { + "optional": true + }, + "react-native-reanimated": { + "optional": true + }, + "react-native-web": { + "optional": true + }, + "react-server-dom-webpack": { + "optional": true + } + } + }, + "node_modules/expo-router/node_modules/@expo/metro-runtime": { + "version": "55.0.9", + "resolved": "https://registry.npmjs.org/@expo/metro-runtime/-/metro-runtime-55.0.9.tgz", + "integrity": "sha512-H37b2Mc/8GiQbwtUFzUTxA3KsAMZu00SRg/RhbHa9xVE7J0n5ZX4NHy0LJEFAbkzTb1TUy1hLpo3oEKnG+rLyg==", + "license": "MIT", + "dependencies": { + "@expo/log-box": "55.0.10", + "anser": "^1.4.9", + "pretty-format": "^29.7.0", + "stacktrace-parser": "^0.1.10", + "whatwg-fetch": "^3.0.0" + }, + "peerDependencies": { + "expo": "*", + "react": "*", + "react-dom": "*", + "react-native": "*" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + } + } + }, + "node_modules/expo-router/node_modules/@radix-ui/react-tabs": { + "version": "1.1.13", + "resolved": "https://registry.npmjs.org/@radix-ui/react-tabs/-/react-tabs-1.1.13.tgz", + "integrity": "sha512-7xdcatg7/U+7+Udyoj2zodtI9H/IIopqo+YOIcZOq1nJwXWBZ9p8xiu5llXlekDbZkca79a/fozEYQXIA4sW6A==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-roving-focus": "1.1.11", + "@radix-ui/react-use-controllable-state": "1.2.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/expo-router/node_modules/semver": { + "version": "7.6.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.3.tgz", + "integrity": "sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/expo-secure-store": { + "version": "55.0.14", + "resolved": "https://registry.npmjs.org/expo-secure-store/-/expo-secure-store-55.0.14.tgz", + "integrity": "sha512-OKp9pDiTa4kgChop8+pTRJGBPhkJUcAxP5c6JbivNr4bmx3I+gKmAj1ov4KOXkY95TpWdHO+GQ4+0BgSY2P3JQ==", + "license": "MIT", + "peerDependencies": { + "expo": "*" + } + }, + "node_modules/expo-server": { + "version": "55.0.7", + "resolved": "https://registry.npmjs.org/expo-server/-/expo-server-55.0.7.tgz", + "integrity": "sha512-Cc1btFyPsD9P4DT2xd1pG/uR96TLVMx0W+dPm9Gjk1uDV9xuzvMcUsY7nf9bt4U5pGyWWkCXmPJcKwWfdl51Pw==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=20.16.0" + } + }, + "node_modules/expo-status-bar": { + "version": "55.0.5", + "resolved": "https://registry.npmjs.org/expo-status-bar/-/expo-status-bar-55.0.5.tgz", + "integrity": "sha512-qb0c3rJO2b7CC0gUVGi1JYp92oLenWdYGyk8l4YQs6U+uaXUTPv6aaFa3KkT2HON10re3AxxPNJci8rsz6kPxg==", + "license": "MIT", + "dependencies": { + "react-native-is-edge-to-edge": "^1.2.1" + }, + "peerDependencies": { + "react": "*", + "react-native": "*" + } + }, + "node_modules/expo-structured-headers": { + "version": "55.0.2", + "resolved": "https://registry.npmjs.org/expo-structured-headers/-/expo-structured-headers-55.0.2.tgz", + "integrity": "sha512-KITovrWigTOtsII5hRQ9/3ydaNcxCux5g6O+eTPLyjnye9dpkDKl5GmCLVPVKIL/d7253OtbGtWMD4m0gha5pw==", + "license": "MIT" + }, + "node_modules/expo-symbols": { + "version": "55.0.7", + "resolved": "https://registry.npmjs.org/expo-symbols/-/expo-symbols-55.0.7.tgz", + "integrity": "sha512-y4ALLbncSGQzhFLw1PaIBbO39xzaw3ie249HmK6zK/WLJYfw4Z/9UU4iPKO3KCE4FyCKIzd+yRsvzvlri23YrQ==", + "license": "MIT", + "dependencies": { + "@expo-google-fonts/material-symbols": "^0.4.1", + "sf-symbols-typescript": "^2.0.0" + }, + "peerDependencies": { + "expo": "*", + "expo-font": "*", + "react": "*", + "react-native": "*" + } + }, + "node_modules/expo-updates": { + "version": "55.0.20", + "resolved": "https://registry.npmjs.org/expo-updates/-/expo-updates-55.0.20.tgz", + "integrity": "sha512-bRVsm+2ax3rQkErV+YX9uw+2N5DJ7C2S4ETPZPFbnLubSCJtlPuMHZ2SDQvmh3mAOl0yURKkbIMWVCvT89GmIQ==", + "license": "MIT", + "dependencies": { + "@expo/code-signing-certificates": "^0.0.6", + "@expo/plist": "^0.5.2", + "@expo/spawn-async": "^1.7.2", + "arg": "^4.1.0", + "chalk": "^4.1.2", + "debug": "^4.3.4", + "expo-eas-client": "~55.0.5", + "expo-manifests": "~55.0.15", + "expo-structured-headers": "~55.0.2", + "expo-updates-interface": "~55.1.5", + "getenv": "^2.0.0", + "glob": "^13.0.0", + "ignore": "^5.3.1", + "resolve-from": "^5.0.0" + }, + "bin": { + "expo-updates": "bin/cli.js" + }, + "peerDependencies": { + "expo": "*", + "react": "*", + "react-native": "*" + } + }, + "node_modules/expo-updates-interface": { + "version": "55.1.5", + "resolved": "https://registry.npmjs.org/expo-updates-interface/-/expo-updates-interface-55.1.5.tgz", + "integrity": "sha512-YOk9vhplWi0djoeqxMlEQgcDFeOGhnj4dWU0v1QvF5RqpqwLGdx780E0k3zL85xw6LXljVN78d6g8z51qIZu5g==", + "license": "MIT", + "peerDependencies": { + "expo": "*" + } + }, + "node_modules/expo-updates/node_modules/arg": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/arg/-/arg-4.1.3.tgz", + "integrity": "sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==", + "license": "MIT" + }, + "node_modules/expo/node_modules/@expo/cli": { + "version": "55.0.24", + "resolved": "https://registry.npmjs.org/@expo/cli/-/cli-55.0.24.tgz", + "integrity": "sha512-Z6Xh0WNTg1LvoZQ77zO3snF2cFiv1xf0VguDlwTL1Ql87oMOp30f7mjl9jeaSHqoWkgiAbmxgCKKIGjVX/keiA==", + "license": "MIT", + "dependencies": { + "@expo/code-signing-certificates": "^0.0.6", + "@expo/config": "~55.0.15", + "@expo/config-plugins": "~55.0.8", + "@expo/devcert": "^1.2.1", + "@expo/env": "~2.1.1", + "@expo/image-utils": "^0.8.13", + "@expo/json-file": "^10.0.13", + "@expo/log-box": "55.0.10", + "@expo/metro": "~55.0.0", + "@expo/metro-config": "~55.0.16", + "@expo/osascript": "^2.4.2", + "@expo/package-manager": "^1.10.4", + "@expo/plist": "^0.5.2", + "@expo/prebuild-config": "^55.0.15", + "@expo/require-utils": "^55.0.4", + "@expo/router-server": "^55.0.14", + "@expo/schema-utils": "^55.0.3", + "@expo/spawn-async": "^1.7.2", + "@expo/ws-tunnel": "^1.0.1", + "@expo/xcpretty": "^4.4.0", + "@react-native/dev-middleware": "0.83.4", + "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", + "dnssd-advertise": "^1.1.4", + "expo-server": "^55.0.7", + "fetch-nodeshim": "^0.4.10", + "getenv": "^2.0.0", + "glob": "^13.0.0", + "lan-network": "^0.2.1", + "multitars": "^0.2.3", + "node-forge": "^1.3.3", + "npm-package-arg": "^11.0.0", + "ora": "^3.4.0", + "picomatch": "^4.0.3", + "pretty-format": "^29.7.0", + "progress": "^2.0.3", + "prompts": "^2.3.2", + "resolve-from": "^5.0.0", + "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", + "terminal-link": "^2.1.1", + "toqr": "^0.1.1", + "wrap-ansi": "^7.0.0", + "ws": "^8.12.1", + "zod": "^3.25.76" + }, + "bin": { + "expo-internal": "build/bin/cli" + }, + "peerDependencies": { + "expo": "*", + "expo-router": "*", + "react-native": "*" + }, + "peerDependenciesMeta": { + "expo-router": { "optional": true }, - "react-native-webview": { + "react-native": { "optional": true } } }, - "node_modules/expo-asset": { - "version": "11.1.7", - "resolved": "https://registry.npmjs.org/expo-asset/-/expo-asset-11.1.7.tgz", - "integrity": "sha512-b5P8GpjUh08fRCf6m5XPVAh7ra42cQrHBIMgH2UXP+xsj4Wufl6pLy6jRF5w6U7DranUMbsXm8TOyq4EHy7ADg==", + "node_modules/expo/node_modules/@expo/cli/node_modules/@expo/router-server": { + "version": "55.0.14", + "resolved": "https://registry.npmjs.org/@expo/router-server/-/router-server-55.0.14.tgz", + "integrity": "sha512-YJjbeLMLp+ZjCnajHI+jEppNzXY372K0u4I4fLKGnA/loFX14aouDsg4tqZVGlZx6NUpnN8Bb3Tmw2BLTXT5Qw==", "license": "MIT", "dependencies": { - "@expo/image-utils": "^0.7.6", - "expo-constants": "~17.1.7" + "debug": "^4.3.4" }, "peerDependencies": { + "@expo/metro-runtime": "^55.0.9", "expo": "*", + "expo-constants": "^55.0.13", + "expo-font": "^55.0.6", + "expo-router": "*", + "expo-server": "^55.0.7", "react": "*", - "react-native": "*" - } - }, - "node_modules/expo-build-properties": { - "version": "0.14.8", - "resolved": "https://registry.npmjs.org/expo-build-properties/-/expo-build-properties-0.14.8.tgz", - "integrity": "sha512-GTFNZc5HaCS9RmCi6HspCe2+isleuOWt2jh7UEKHTDQ9tdvzkIoWc7U6bQO9lH3Mefk4/BcCUZD/utl7b1wdqw==", - "license": "MIT", - "dependencies": { - "ajv": "^8.11.0", - "semver": "^7.6.0" - }, - "peerDependencies": { - "expo": "*" - } - }, - "node_modules/expo-build-properties/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" + "react-dom": "*", + "react-server-dom-webpack": "~19.0.1 || ~19.1.2 || ~19.2.1" }, - "engines": { - "node": ">=10" + "peerDependenciesMeta": { + "@expo/metro-runtime": { + "optional": true + }, + "expo-router": { + "optional": true + }, + "react-dom": { + "optional": true + }, + "react-server-dom-webpack": { + "optional": true + } } }, - "node_modules/expo-camera": { - "version": "16.1.11", - "resolved": "https://registry.npmjs.org/expo-camera/-/expo-camera-16.1.11.tgz", - "integrity": "sha512-etA5ZKoC6nPBnWWqiTmlX//zoFZ6cWQCCIdmpUHTGHAKd4qZNCkhPvBWbi8o32pDe57lix1V4+TPFgEcvPwsaA==", + "node_modules/expo/node_modules/@expo/metro-config": { + "version": "55.0.16", + "resolved": "https://registry.npmjs.org/@expo/metro-config/-/metro-config-55.0.16.tgz", + "integrity": "sha512-JaWDw0dmYZ5pOqA+3/Efvl8JzCVgWQVPogHFjTRC5azUgAsFV+T7moOaZTSgg4d+5TjFZjZbMZg4SUomE7LiGg==", "license": "MIT", "dependencies": { - "invariant": "^2.2.4" + "@babel/code-frame": "^7.20.0", + "@babel/core": "^7.20.0", + "@babel/generator": "^7.20.5", + "@expo/config": "~55.0.15", + "@expo/env": "~2.1.1", + "@expo/json-file": "~10.0.13", + "@expo/metro": "~55.0.0", + "@expo/spawn-async": "^1.7.2", + "browserslist": "^4.25.0", + "chalk": "^4.1.0", + "debug": "^4.3.2", + "getenv": "^2.0.0", + "glob": "^13.0.0", + "hermes-parser": "^0.32.0", + "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": "*", - "react": "*", - "react-native": "*", - "react-native-web": "*" + "expo": "*" }, "peerDependenciesMeta": { - "react-native-web": { + "expo": { "optional": true } } }, - "node_modules/expo-constants": { - "version": "17.1.7", - "resolved": "https://registry.npmjs.org/expo-constants/-/expo-constants-17.1.7.tgz", - "integrity": "sha512-byBjGsJ6T6FrLlhOBxw4EaiMXrZEn/MlUYIj/JAd+FS7ll5X/S4qVRbIimSJtdW47hXMq0zxPfJX6njtA56hHA==", + "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", - "peer": true, - "dependencies": { - "@expo/config": "~11.0.12", - "@expo/env": "~1.0.7" - }, "peerDependencies": { - "expo": "*", + "expo-font": ">=14.0.4", + "react": "*", "react-native": "*" } }, - "node_modules/expo-dev-client": { - "version": "5.2.4", - "resolved": "https://registry.npmjs.org/expo-dev-client/-/expo-dev-client-5.2.4.tgz", - "integrity": "sha512-s/N/nK5LPo0QZJpV4aPijxyrzV4O49S3dN8D2fljqrX2WwFZzWwFO6dX1elPbTmddxumdcpczsdUPY+Ms8g43g==", - "license": "MIT", - "dependencies": { - "expo-dev-launcher": "5.1.16", - "expo-dev-menu": "6.1.14", - "expo-dev-menu-interface": "1.10.0", - "expo-manifests": "~0.16.6", - "expo-updates-interface": "~1.1.0" - }, - "peerDependencies": { - "expo": "*" - } - }, - "node_modules/expo-dev-launcher": { - "version": "5.1.16", - "resolved": "https://registry.npmjs.org/expo-dev-launcher/-/expo-dev-launcher-5.1.16.tgz", - "integrity": "sha512-tbCske9pvbozaEblyxoyo/97D6od9Ma4yAuyUnXtRET1CKAPKYS+c4fiZ+I3B4qtpZwN3JNFUjG3oateN0y6Hg==", + "node_modules/expo/node_modules/babel-preset-expo": { + "version": "55.0.17", + "resolved": "https://registry.npmjs.org/babel-preset-expo/-/babel-preset-expo-55.0.17.tgz", + "integrity": "sha512-voPAKycqeqOE+4g/nW6gGaNPMnj3MYCYbVEZlZDUlztGVxlKKkUD+xwlK0ZU/uy6HxAY+tjBEpvsabD5g6b2oQ==", "license": "MIT", "dependencies": { - "ajv": "8.11.0", - "expo-dev-menu": "6.1.14", - "expo-manifests": "~0.16.6", + "@babel/generator": "^7.20.5", + "@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.83.4", + "babel-plugin-react-compiler": "^1.0.0", + "babel-plugin-react-native-web": "~0.21.0", + "babel-plugin-syntax-hermes-parser": "^0.32.0", + "babel-plugin-transform-flow-enums": "^0.0.2", + "debug": "^4.3.4", "resolve-from": "^5.0.0" }, "peerDependencies": { - "expo": "*" - } - }, - "node_modules/expo-dev-launcher/node_modules/ajv": { - "version": "8.11.0", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.11.0.tgz", - "integrity": "sha512-wGgprdCvMalC0BztXvitD2hC04YffAvtsUn93JbGXYLAtCUO4xd17mCCZQxUOItiBwZvJScWo8NIvQMQ71rdpg==", - "license": "MIT", - "dependencies": { - "fast-deep-equal": "^3.1.1", - "json-schema-traverse": "^1.0.0", - "require-from-string": "^2.0.2", - "uri-js": "^4.2.2" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/epoberezkin" - } - }, - "node_modules/expo-dev-menu": { - "version": "6.1.14", - "resolved": "https://registry.npmjs.org/expo-dev-menu/-/expo-dev-menu-6.1.14.tgz", - "integrity": "sha512-yonNMg2GHJZtuisVowdl1iQjZfYP85r1D1IO+ar9D9zlrBPBJhq2XEju52jd1rDmDkmDuEhBSbPNhzIcsBNiPg==", - "license": "MIT", - "dependencies": { - "expo-dev-menu-interface": "1.10.0" + "@babel/runtime": "^7.20.0", + "expo": "*", + "expo-widgets": "^55.0.13", + "react-refresh": ">=0.14.0 <1.0.0" }, - "peerDependencies": { - "expo": "*" - } - }, - "node_modules/expo-dev-menu-interface": { - "version": "1.10.0", - "resolved": "https://registry.npmjs.org/expo-dev-menu-interface/-/expo-dev-menu-interface-1.10.0.tgz", - "integrity": "sha512-NxtM/qot5Rh2cY333iOE87dDg1S8CibW+Wu4WdLua3UMjy81pXYzAGCZGNOeY7k9GpNFqDPNDXWyBSlk9r2pBg==", - "license": "MIT", - "peerDependencies": { - "expo": "*" + "peerDependenciesMeta": { + "@babel/runtime": { + "optional": true + }, + "expo": { + "optional": true + }, + "expo-widgets": { + "optional": true + } } }, - "node_modules/expo-file-system": { - "version": "18.1.11", - "resolved": "https://registry.npmjs.org/expo-file-system/-/expo-file-system-18.1.11.tgz", - "integrity": "sha512-HJw/m0nVOKeqeRjPjGdvm+zBi5/NxcdPf8M8P3G2JFvH5Z8vBWqVDic2O58jnT1OFEy0XXzoH9UqFu7cHg9DTQ==", + "node_modules/expo/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", - "peerDependencies": { - "expo": "*", - "react-native": "*" + "engines": { + "node": ">=8" } }, - "node_modules/expo-font": { - "version": "13.3.2", - "resolved": "https://registry.npmjs.org/expo-font/-/expo-font-13.3.2.tgz", - "integrity": "sha512-wUlMdpqURmQ/CNKK/+BIHkDA5nGjMqNlYmW0pJFXY/KE/OG80Qcavdu2sHsL4efAIiNGvYdBS10WztuQYU4X0A==", + "node_modules/expo/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", - "peer": true, "dependencies": { - "fontfaceobserver": "^2.1.0" + "restore-cursor": "^2.0.0" }, - "peerDependencies": { - "expo": "*", - "react": "*" - } - }, - "node_modules/expo-image-loader": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/expo-image-loader/-/expo-image-loader-5.1.0.tgz", - "integrity": "sha512-sEBx3zDQIODWbB5JwzE7ZL5FJD+DK3LVLWBVJy6VzsqIA6nDEnSFnsnWyCfCTSvbGigMATs1lgkC2nz3Jpve1Q==", - "license": "MIT", - "peerDependencies": { - "expo": "*" + "engines": { + "node": ">=4" } }, - "node_modules/expo-image-picker": { - "version": "16.1.4", - "resolved": "https://registry.npmjs.org/expo-image-picker/-/expo-image-picker-16.1.4.tgz", - "integrity": "sha512-bTmmxtw1AohUT+HxEBn2vYwdeOrj1CLpMXKjvi9FKSoSbpcarT4xxI0z7YyGwDGHbrJqyyic3I9TTdP2J2b4YA==", + "node_modules/expo/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": { - "expo-image-loader": "~5.1.0" - }, - "peerDependencies": { - "expo": "*" + "color-name": "1.1.3" } }, - "node_modules/expo-json-utils": { - "version": "0.15.0", - "resolved": "https://registry.npmjs.org/expo-json-utils/-/expo-json-utils-0.15.0.tgz", - "integrity": "sha512-duRT6oGl80IDzH2LD2yEFWNwGIC2WkozsB6HF3cDYNoNNdUvFk6uN3YiwsTsqVM/D0z6LEAQ01/SlYvN+Fw0JQ==", + "node_modules/expo/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-keep-awake": { - "version": "14.1.4", - "resolved": "https://registry.npmjs.org/expo-keep-awake/-/expo-keep-awake-14.1.4.tgz", - "integrity": "sha512-wU9qOnosy4+U4z/o4h8W9PjPvcFMfZXrlUoKTMBW7F4pLqhkkP/5G4EviPZixv4XWFMjn1ExQ5rV6BX8GwJsWA==", + "node_modules/expo/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", - "peerDependencies": { - "expo": "*", - "react": "*" + "engines": { + "node": ">=0.8.0" } }, - "node_modules/expo-linking": { - "version": "8.0.11", - "resolved": "https://registry.npmjs.org/expo-linking/-/expo-linking-8.0.11.tgz", - "integrity": "sha512-+VSaNL5om3kOp/SSKO5qe6cFgfSIWnnQDSbA7XLs3ECkYzXRquk5unxNS3pg7eK5kNUmQ4kgLI7MhTggAEUBLA==", + "node_modules/expo/node_modules/expo-keep-awake": { + "version": "55.0.6", + "resolved": "https://registry.npmjs.org/expo-keep-awake/-/expo-keep-awake-55.0.6.tgz", + "integrity": "sha512-acJjeHqkNxMVckEcJhGQeIksqqsarscSHJtT559bNgyiM4r14dViQ66su7bb6qDVeBt0K7z3glXI1dHVck1Zgg==", "license": "MIT", - "dependencies": { - "expo-constants": "~18.0.12", - "invariant": "^2.2.4" - }, "peerDependencies": { - "react": "*", - "react-native": "*" + "expo": "*", + "react": "*" } }, - "node_modules/expo-linking/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==", + "node_modules/expo/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", - "dependencies": { - "@babel/highlight": "^7.10.4" + "engines": { + "node": ">=4" } }, - "node_modules/expo-linking/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/node_modules/hermes-estree": { + "version": "0.32.1", + "resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.32.1.tgz", + "integrity": "sha512-ne5hkuDxheNBAikDjqvCZCwihnz0vVu9YsBzAEO1puiyFR4F1+PAz/SiPHSsNTuOveCYGRMX8Xbx4LOubeC0Qg==", + "license": "MIT" }, - "node_modules/expo-linking/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==", + "node_modules/expo/node_modules/hermes-parser": { + "version": "0.32.1", + "resolved": "https://registry.npmjs.org/hermes-parser/-/hermes-parser-0.32.1.tgz", + "integrity": "sha512-175dz634X/W5AiwrpLdoMl/MOb17poLHyIqgyExlE8D9zQ1OPnoORnGMB5ltRKnpvQzBjMYvT2rN/sHeIfZW5Q==", "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" + "hermes-estree": "0.32.1" } }, - "node_modules/expo-linking/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-linking/node_modules/@expo/env": { - "version": "2.0.8", - "resolved": "https://registry.npmjs.org/@expo/env/-/env-2.0.8.tgz", - "integrity": "sha512-5VQD6GT8HIMRaSaB5JFtOXuvfDVU80YtZIuUT/GDhUF782usIXY13Tn3IdDz1Tm/lqA9qnRZQ1BF4t7LlvdJPA==", + "node_modules/expo/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": "^4.0.0", - "debug": "^4.3.4", - "dotenv": "~16.4.5", - "dotenv-expand": "~11.0.6", - "getenv": "^2.0.0" + "chalk": "^2.0.1" + }, + "engines": { + "node": ">=4" } }, - "node_modules/expo-linking/node_modules/@expo/json-file": { - "version": "10.0.8", - "resolved": "https://registry.npmjs.org/@expo/json-file/-/json-file-10.0.8.tgz", - "integrity": "sha512-9LOTh1PgKizD1VXfGQ88LtDH0lRwq9lsTb4aichWTWSWqy3Ugfkhfm3BhzBIkJJfQQ5iJu3m/BoRlEIjoCGcnQ==", + "node_modules/expo/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": { - "@babel/code-frame": "~7.10.4", - "json5": "^2.2.3" + "color-convert": "^1.9.0" + }, + "engines": { + "node": ">=4" } }, - "node_modules/expo-linking/node_modules/@expo/plist": { - "version": "0.4.8", - "resolved": "https://registry.npmjs.org/@expo/plist/-/plist-0.4.8.tgz", - "integrity": "sha512-pfNtErGGzzRwHP+5+RqswzPDKkZrx+Cli0mzjQaus1ZWFsog5ibL+nVT3NcporW51o8ggnt7x813vtRbPiyOrQ==", + "node_modules/expo/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": { - "@xmldom/xmldom": "^0.8.8", - "base64-js": "^1.2.3", - "xmlbuilder": "^15.1.1" + "ansi-styles": "^3.2.1", + "escape-string-regexp": "^1.0.5", + "supports-color": "^5.3.0" + }, + "engines": { + "node": ">=4" } }, - "node_modules/expo-linking/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==", + "node_modules/expo/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": "18 || 20 || >=22" + "node": ">=4" } }, - "node_modules/expo-linking/node_modules/brace-expansion": { - "version": "5.0.3", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.3.tgz", - "integrity": "sha512-fy6KJm2RawA5RcHkLa1z/ScpBeA762UF9KmZQxwIbDtRJrgLzM10depAiEQ+CXYcoiqW1/m96OAAoke2nE9EeA==", + "node_modules/expo/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": { - "balanced-match": "^4.0.2" + "mimic-fn": "^1.0.0" }, "engines": { - "node": "18 || 20 || >=22" + "node": ">=4" } }, - "node_modules/expo-linking/node_modules/commander": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", - "integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==", + "node_modules/expo/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": ">=6" } }, - "node_modules/expo-linking/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==", + "node_modules/expo/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": { - "@expo/config": "~12.0.13", - "@expo/env": "~2.0.8" + "color-convert": "^1.9.0" }, - "peerDependencies": { - "expo": "*", - "react-native": "*" + "engines": { + "node": ">=4" } }, - "node_modules/expo-linking/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", + "node_modules/expo/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": { - "minimatch": "^10.2.2", - "minipass": "^7.1.3", - "path-scurry": "^2.0.2" - }, - "engines": { - "node": "18 || 20 || >=22" + "ansi-styles": "^3.2.1", + "escape-string-regexp": "^1.0.5", + "supports-color": "^5.3.0" }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/expo-linking/node_modules/lru-cache": { - "version": "11.2.6", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.6.tgz", - "integrity": "sha512-ESL2CrkS/2wTPfuend7Zhkzo2u0daGJ/A2VucJOgQ/C48S/zB8MMeMHSGKYpXhIjbPxfuezITkaBH1wqv00DDQ==", - "license": "BlueOak-1.0.0", "engines": { - "node": "20 || >=22" + "node": ">=4" } }, - "node_modules/expo-linking/node_modules/minimatch": { - "version": "10.2.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.2.tgz", - "integrity": "sha512-+G4CpNBxa5MprY+04MbgOw1v7So6n5JY166pFi9KfYwT78fxScCeSNQSNzp6dpPSW2rONOps6Ocam1wFhCgoVw==", - "license": "BlueOak-1.0.0", - "dependencies": { - "brace-expansion": "^5.0.2" - }, + "node_modules/expo/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": "18 || 20 || >=22" + "node": ">=12" }, "funding": { - "url": "https://github.com/sponsors/isaacs" + "url": "https://github.com/sponsors/jonschlinkert" } }, - "node_modules/expo-linking/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", + "node_modules/expo/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": { - "lru-cache": "^11.0.0", - "minipass": "^7.1.2" + "onetime": "^2.0.0", + "signal-exit": "^3.0.2" }, "engines": { - "node": "18 || 20 || >=22" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" + "node": ">=4" } }, - "node_modules/expo-linking/node_modules/semver": { + "node_modules/expo/node_modules/semver": { "version": "7.7.4", "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", @@ -5759,168 +6234,72 @@ "node": ">=10" } }, - "node_modules/expo-linking/node_modules/sucrase": { - "version": "3.35.1", - "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.1.tgz", - "integrity": "sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==", + "node_modules/expo/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": { - "@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" + "has-flag": "^3.0.0" }, "engines": { - "node": ">=16 || 14 >=14.17" + "node": ">=4" } }, - "node_modules/expo-manifests": { - "version": "0.16.6", - "resolved": "https://registry.npmjs.org/expo-manifests/-/expo-manifests-0.16.6.tgz", - "integrity": "sha512-1A+do6/mLUWF9xd3uCrlXr9QFDbjbfqAYmUy8UDLOjof1lMrOhyeC4Yi6WexA/A8dhZEpIxSMCKfn7G4aHAh4w==", + "node_modules/expo/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": { - "@expo/config": "~11.0.12", - "expo-json-utils": "~0.15.0" + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" }, - "peerDependencies": { - "expo": "*" + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" } }, - "node_modules/expo-modules-autolinking": { - "version": "2.1.14", - "resolved": "https://registry.npmjs.org/expo-modules-autolinking/-/expo-modules-autolinking-2.1.14.tgz", - "integrity": "sha512-nT5ERXwc+0ZT/pozDoJjYZyUQu5RnXMk9jDGm5lg+PiKvsrCTSA/2/eftJGMxLkTjVI2MXp5WjSz3JRjbA7UXA==", + "node_modules/expo/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": { - "@expo/spawn-async": "^1.7.2", - "chalk": "^4.1.0", - "commander": "^7.2.0", - "find-up": "^5.0.0", - "glob": "^10.4.2", - "require-from-string": "^2.0.2", - "resolve-from": "^5.0.0" + "ansi-regex": "^5.0.1" }, - "bin": { - "expo-modules-autolinking": "bin/expo-modules-autolinking.js" - } - }, - "node_modules/expo-modules-autolinking/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/expo-modules-core": { - "version": "2.4.2", - "resolved": "https://registry.npmjs.org/expo-modules-core/-/expo-modules-core-2.4.2.tgz", - "integrity": "sha512-RCb0wniYCJkxwpXrkiBA/WiNGxzYsEpL0sB50gTnS/zEfX3DImS2npc4lfZ3hPZo1UF9YC6OSI9Do+iacV0NUg==", - "license": "MIT", - "peer": true, - "dependencies": { - "invariant": "^2.2.4" + "node": ">=8" } }, - "node_modules/expo-router": { - "version": "5.0.7", - "resolved": "https://registry.npmjs.org/expo-router/-/expo-router-5.0.7.tgz", - "integrity": "sha512-NlEgRXCKtseDuIHBp87UfkvqsuVrc0MYG+zg33dopaN6wik4RkrWWxUYdNPHub0s/7qMye6zZBY4ZCrXwd/xpA==", + "node_modules/expo/node_modules/ws": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.20.0.tgz", + "integrity": "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==", "license": "MIT", - "dependencies": { - "@expo/metro-runtime": "5.0.4", - "@expo/server": "^0.6.2", - "@radix-ui/react-slot": "1.2.0", - "@react-navigation/bottom-tabs": "^7.3.10", - "@react-navigation/native": "^7.1.6", - "@react-navigation/native-stack": "^7.3.10", - "client-only": "^0.0.1", - "invariant": "^2.2.4", - "react-fast-compare": "^3.2.2", - "react-native-is-edge-to-edge": "^1.1.6", - "schema-utils": "^4.0.1", - "semver": "~7.6.3", - "server-only": "^0.0.1", - "shallowequal": "^1.1.0" + "engines": { + "node": ">=10.0.0" }, "peerDependencies": { - "@react-navigation/drawer": "^7.3.9", - "expo": "*", - "expo-constants": "*", - "expo-linking": "*", - "react-native-reanimated": "*", - "react-native-safe-area-context": "*", - "react-native-screens": "*" + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" }, "peerDependenciesMeta": { - "@react-navigation/drawer": { - "optional": true - }, - "@testing-library/jest-native": { + "bufferutil": { "optional": true }, - "react-native-reanimated": { + "utf-8-validate": { "optional": true } } }, - "node_modules/expo-router/node_modules/semver": { - "version": "7.6.3", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.3.tgz", - "integrity": "sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A==", - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/expo-status-bar": { - "version": "2.2.3", - "resolved": "https://registry.npmjs.org/expo-status-bar/-/expo-status-bar-2.2.3.tgz", - "integrity": "sha512-+c8R3AESBoduunxTJ8353SqKAKpxL6DvcD8VKBuh81zzJyUUbfB4CVjr1GufSJEKsMzNPXZU+HJwXx7Xh7lx8Q==", - "license": "MIT", - "dependencies": { - "react-native-edge-to-edge": "1.6.0", - "react-native-is-edge-to-edge": "^1.1.6" - }, - "peerDependencies": { - "react": "*", - "react-native": "*" - } - }, - "node_modules/expo-updates-interface": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/expo-updates-interface/-/expo-updates-interface-1.1.0.tgz", - "integrity": "sha512-DeB+fRe0hUDPZhpJ4X4bFMAItatFBUPjw/TVSbJsaf3Exeami+2qbbJhWkcTMoYHOB73nOIcaYcWXYJnCJXO0w==", - "license": "MIT", - "peerDependencies": { - "expo": "*" - } - }, - "node_modules/expo/node_modules/expo-modules-core": { - "version": "2.5.0", - "resolved": "https://registry.npmjs.org/expo-modules-core/-/expo-modules-core-2.5.0.tgz", - "integrity": "sha512-aIbQxZE2vdCKsolQUl6Q9Farlf8tjh/ROR4hfN1qT7QBGPl1XrJGnaOKkcgYaGrlzCPg/7IBe0Np67GzKMZKKQ==", - "license": "MIT", - "dependencies": { - "invariant": "^2.2.4" - } - }, "node_modules/exponential-backoff": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/exponential-backoff/-/exponential-backoff-3.1.2.tgz", - "integrity": "sha512-8QxYTVXUkuy7fIIoitQkPwGonB8F3Zj8eEO8Sqg9Zv/bkI7RJAzowee4gr81Hak/dUTpA2Z7VfQgoijjPNlUZA==", + "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": { @@ -5952,26 +6331,26 @@ "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", "license": "MIT" }, - "node_modules/fast-uri": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz", - "integrity": "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==", + "node_modules/fast-xml-builder": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.1.5.tgz", + "integrity": "sha512-4TJn/8FKLeslLAH3dnohXqE3QSoxkhvaMzepOIZytwJXZO69Bfz0HBdDHzOTOon6G59Zrk6VQ2bEiv1t61rfkA==", + "devOptional": true, "funding": [ { "type": "github", - "url": "https://github.com/sponsors/fastify" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/fastify" + "url": "https://github.com/sponsors/NaturalIntelligence" } ], - "license": "BSD-3-Clause" + "license": "MIT", + "dependencies": { + "path-expression-matcher": "^1.1.3" + } }, "node_modules/fast-xml-parser": { - "version": "4.5.3", - "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-4.5.3.tgz", - "integrity": "sha512-RKihhV+SHsIUGXObeVy9AXiBbFwkVk7Syp8XgwN5U3JV416+Gwp/GO9i0JYKmikykgz/UHRrrV4ROuZEo/T0ig==", + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.7.1.tgz", + "integrity": "sha512-8Cc3f8GUGUULg34pBch/KGyPLglS+OFs05deyOlY7fL2MTagYPKrVQNmR1fLF/yJ9PH5ZSTd3YDF6pnmeZU+zA==", "devOptional": true, "funding": [ { @@ -5981,22 +6360,37 @@ ], "license": "MIT", "dependencies": { - "strnum": "^1.1.1" + "@nodable/entities": "^2.1.0", + "fast-xml-builder": "^1.1.5", + "path-expression-matcher": "^1.5.0", + "strnum": "^2.2.3" }, "bin": { "fxparser": "src/cli/cli.js" } }, "node_modules/fastq": { - "version": "1.19.1", - "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.19.1.tgz", - "integrity": "sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ==", + "version": "1.20.1", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", + "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==", "devOptional": true, "license": "ISC", "dependencies": { "reusify": "^1.0.4" } }, + "node_modules/fb-dotslash": { + "version": "0.5.8", + "resolved": "https://registry.npmjs.org/fb-dotslash/-/fb-dotslash-0.5.8.tgz", + "integrity": "sha512-XHYLKk9J4BupDxi9bSEhkfss0m+Vr9ChTrjhf9l2iw3jB5C7BnY4GVPoMcqbrTutsKJso6yj2nAB6BI/F2oZaA==", + "license": "(MIT OR Apache-2.0)", + "bin": { + "dotslash": "bin/dotslash" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/fb-watchman": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/fb-watchman/-/fb-watchman-2.0.2.tgz", @@ -6006,22 +6400,11 @@ "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/fetch-nodeshim": { + "version": "0.4.10", + "resolved": "https://registry.npmjs.org/fetch-nodeshim/-/fetch-nodeshim-0.4.10.tgz", + "integrity": "sha512-m6I8ALe4L4XpdETy7MJZWs6L1IVMbjs99bwbpIKphxX+0CTns4IKDWJY0LWfr4YsFjfg+z1TjzTMU8lKl8rG0w==", + "license": "MIT" }, "node_modules/fill-range": { "version": "7.1.1", @@ -6093,6 +6476,7 @@ "version": "5.0.0", "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "devOptional": true, "license": "MIT", "dependencies": { "locate-path": "^6.0.0", @@ -6117,43 +6501,6 @@ "integrity": "sha512-6FPvD/IVyT4ZlNe7Wcn5Fb/4ChigpucKYSvD6a+0iMoLn2inpo711eyIcKjmDtE5XNcgAkSH9uN/nfAeZzHEfg==", "license": "BSD-2-Clause" }, - "node_modules/foreground-child": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", - "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", - "license": "ISC", - "dependencies": { - "cross-spawn": "^7.0.6", - "signal-exit": "^4.0.1" - }, - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/foreground-child/node_modules/signal-exit": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", - "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", - "license": "ISC", - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "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", @@ -6268,6 +6615,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/get-nonce": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-nonce/-/get-nonce-1.0.1.tgz", + "integrity": "sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/get-package-type": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/get-package-type/-/get-package-type-0.1.0.tgz", @@ -6314,20 +6670,17 @@ } }, "node_modules/glob": { - "version": "10.4.5", - "resolved": "https://registry.npmjs.org/glob/-/glob-10.4.5.tgz", - "integrity": "sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==", - "license": "ISC", + "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": { - "foreground-child": "^3.1.0", - "jackspeak": "^3.1.2", - "minimatch": "^9.0.4", - "minipass": "^7.1.2", - "package-json-from-dist": "^1.0.0", - "path-scurry": "^1.11.1" + "minimatch": "^10.2.2", + "minipass": "^7.1.3", + "path-scurry": "^2.0.2" }, - "bin": { - "glob": "dist/esm/bin.mjs" + "engines": { + "node": "18 || 20 || >=22" }, "funding": { "url": "https://github.com/sponsors/isaacs" @@ -6388,9 +6741,9 @@ } }, "node_modules/hasown": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", - "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.3.tgz", + "integrity": "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==", "license": "MIT", "dependencies": { "function-bind": "^1.1.2" @@ -6399,19 +6752,25 @@ "node": ">= 0.4" } }, + "node_modules/hermes-compiler": { + "version": "0.14.1", + "resolved": "https://registry.npmjs.org/hermes-compiler/-/hermes-compiler-0.14.1.tgz", + "integrity": "sha512-+RPPQlayoZ9n6/KXKt5SFILWXCGJ/LV5d24L5smXrvTDrPS4L6dSctPczXauuvzFP3QEJbD1YO7Z3Ra4a+4IhA==", + "license": "MIT" + }, "node_modules/hermes-estree": { - "version": "0.25.1", - "resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.25.1.tgz", - "integrity": "sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw==", + "version": "0.33.3", + "resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.33.3.tgz", + "integrity": "sha512-6kzYZHCk8Fy1Uc+t3HGYyJn3OL4aeqKLTyina4UFtWl8I0kSL7OmKThaiX+Uh2f8nGw3mo4Ifxg0M5Zk3/Oeqg==", "license": "MIT" }, "node_modules/hermes-parser": { - "version": "0.25.1", - "resolved": "https://registry.npmjs.org/hermes-parser/-/hermes-parser-0.25.1.tgz", - "integrity": "sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==", + "version": "0.33.3", + "resolved": "https://registry.npmjs.org/hermes-parser/-/hermes-parser-0.33.3.tgz", + "integrity": "sha512-Yg3HgaG4CqgyowtYjX/FsnPAuZdHOqSMtnbpylbptsQ9nwwSKsy6uRWcGO5RK0EqiX12q8HvDWKgeAVajRO5DA==", "license": "MIT", "dependencies": { - "hermes-estree": "0.25.1" + "hermes-estree": "0.33.3" } }, "node_modules/hosted-git-info": { @@ -6433,25 +6792,29 @@ "license": "ISC" }, "node_modules/http-errors": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz", - "integrity": "sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==", + "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.1", - "toidentifier": "1.0.1" + "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.1", - "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz", - "integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==", + "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" @@ -6481,22 +6844,27 @@ } }, "node_modules/iconv-lite": { - "version": "0.4.24", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", - "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz", + "integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==", "devOptional": true, "license": "MIT", "dependencies": { - "safer-buffer": ">= 2.1.2 < 3" + "safer-buffer": ">= 2.1.2 < 3.0.0" }, "engines": { "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, "node_modules/ieee754": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "devOptional": true, "funding": [ { "type": "github", @@ -6590,12 +6958,6 @@ "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/install": { "version": "0.13.0", "resolved": "https://registry.npmjs.org/install/-/install-0.13.0.tgz", @@ -6618,6 +6980,7 @@ "version": "0.2.1", "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==", + "devOptional": true, "license": "MIT" }, "node_modules/is-core-module": { @@ -6635,15 +6998,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/is-directory": { - "version": "0.3.1", - "resolved": "https://registry.npmjs.org/is-directory/-/is-directory-0.3.1.tgz", - "integrity": "sha512-yVChGzahRFvbkscn2MlwGismPO12i9+znNruC5gVEntG3qu0xQMzsGg/JFbrsqDOHtHFPci+V5aP5T9I+yeKqw==", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/is-docker": { "version": "2.2.1", "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-2.2.1.tgz", @@ -6747,15 +7101,13 @@ } }, "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==", + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-1.1.0.tgz", + "integrity": "sha512-gfygJYZ2gLTDlmbWMI0CE2MwnFzSN/2SZfkMlItC4K/JBlsWVDB0bO6XhqcY13YXE7iMcAJnzTCJjPiTeJJ0Mw==", + "devOptional": true, "license": "MIT", - "dependencies": { - "is-docker": "^2.0.0" - }, "engines": { - "node": ">=8" + "node": ">=4" } }, "node_modules/isexe": { @@ -6789,21 +7141,6 @@ "node": ">=8" } }, - "node_modules/jackspeak": { - "version": "3.4.3", - "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", - "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", - "license": "BlueOak-1.0.0", - "dependencies": { - "@isaacs/cliui": "^8.0.2" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - }, - "optionalDependencies": { - "@pkgjs/parseargs": "^0.11.0" - } - }, "node_modules/jest-environment-node": { "version": "29.7.0", "resolved": "https://registry.npmjs.org/jest-environment-node/-/jest-environment-node-29.7.0.tgz", @@ -6915,16 +7252,19 @@ "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, - "node_modules/jest-util/node_modules/picomatch": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", - "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "node_modules/jest-util/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.6" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" + "node": ">=8" } }, "node_modules/jest-validate": { @@ -6944,18 +7284,6 @@ "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, - "node_modules/jest-validate/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/jest-worker": { "version": "29.7.0", "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-29.7.0.tgz", @@ -7013,9 +7341,9 @@ "license": "MIT" }, "node_modules/js-yaml": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", - "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", + "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", "license": "MIT", "dependencies": { "argparse": "^2.0.1" @@ -7042,12 +7370,6 @@ "node": ">=6" } }, - "node_modules/json-parse-better-errors": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/json-parse-better-errors/-/json-parse-better-errors-1.0.2.tgz", - "integrity": "sha512-mrqyZKfX5EhL7hvqcV6WG1yYjnjeuYDzDhhcAAUrq8Po85NBQBJP+ZDUT75qZQ98IkUoBqdkExkukOU7Ts2wrw==", - "license": "MIT" - }, "node_modules/json-parse-even-better-errors": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", @@ -7055,12 +7377,6 @@ "devOptional": true, "license": "MIT" }, - "node_modules/json-schema-traverse": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", - "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", - "license": "MIT" - }, "node_modules/json5": { "version": "2.2.3", "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", @@ -7093,18 +7409,18 @@ } }, "node_modules/lan-network": { - "version": "0.1.7", - "resolved": "https://registry.npmjs.org/lan-network/-/lan-network-0.1.7.tgz", - "integrity": "sha512-mnIlAEMu4OyEvUNdzco9xpuB9YVcPkQec+QsgycBCtPZvEqWPCDPfbAE4OJMdBBWpZWtpCn1xw9jJYlwjWI5zQ==", + "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/launch-editor": { - "version": "2.11.1", - "resolved": "https://registry.npmjs.org/launch-editor/-/launch-editor-2.11.1.tgz", - "integrity": "sha512-SEET7oNfgSaB6Ym0jufAdCeo3meJVeCaaDyzRygy0xsp2BFKCprcfHljTq4QkzTLUxEKkFK6OK4811YM2oSrRg==", + "version": "2.13.2", + "resolved": "https://registry.npmjs.org/launch-editor/-/launch-editor-2.13.2.tgz", + "integrity": "sha512-4VVDnbOpLXy/s8rdRCSXb+zfMeFR0WlJWpET1iA9CQdlZDfwyLjUuGQzXU4VeOoey6AicSAluWan7Etga6Kcmg==", "devOptional": true, "license": "MIT", "dependencies": { @@ -7147,12 +7463,12 @@ "license": "MIT" }, "node_modules/lightningcss": { - "version": "1.27.0", - "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.27.0.tgz", - "integrity": "sha512-8f7aNmS1+etYSLHht0fQApPc2kNO8qGRutifN5rVIc6Xo6ABsEbqOr758UwI7ALVbTt4x1fllKt0PYgzD9S3yQ==", + "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": "^1.0.3" + "detect-libc": "^2.0.3" }, "engines": { "node": ">= 12.0.0" @@ -7162,22 +7478,43 @@ "url": "https://opencollective.com/parcel" }, "optionalDependencies": { - "lightningcss-darwin-arm64": "1.27.0", - "lightningcss-darwin-x64": "1.27.0", - "lightningcss-freebsd-x64": "1.27.0", - "lightningcss-linux-arm-gnueabihf": "1.27.0", - "lightningcss-linux-arm64-gnu": "1.27.0", - "lightningcss-linux-arm64-musl": "1.27.0", - "lightningcss-linux-x64-gnu": "1.27.0", - "lightningcss-linux-x64-musl": "1.27.0", - "lightningcss-win32-arm64-msvc": "1.27.0", - "lightningcss-win32-x64-msvc": "1.27.0" + "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.27.0", - "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.27.0.tgz", - "integrity": "sha512-Gl/lqIXY+d+ySmMbgDf0pgaWSqrWYxVHoc88q+Vhf2YNzZ8DwoRzGt5NZDVqqIW5ScpSnmmjcgXP87Dn2ylSSQ==", + "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" ], @@ -7195,9 +7532,9 @@ } }, "node_modules/lightningcss-darwin-x64": { - "version": "1.27.0", - "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.27.0.tgz", - "integrity": "sha512-0+mZa54IlcNAoQS9E0+niovhyjjQWEMrwW0p2sSdLRhLDc8LMQ/b67z7+B5q4VmjYCMSfnFi3djAAQFIDuj/Tg==", + "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" ], @@ -7215,9 +7552,9 @@ } }, "node_modules/lightningcss-freebsd-x64": { - "version": "1.27.0", - "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.27.0.tgz", - "integrity": "sha512-n1sEf85fePoU2aDN2PzYjoI8gbBqnmLGEhKq7q0DKLj0UTVmOTwDC7PtLcy/zFxzASTSBlVQYJUhwIStQMIpRA==", + "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" ], @@ -7235,9 +7572,9 @@ } }, "node_modules/lightningcss-linux-arm-gnueabihf": { - "version": "1.27.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.27.0.tgz", - "integrity": "sha512-MUMRmtdRkOkd5z3h986HOuNBD1c2lq2BSQA1Jg88d9I7bmPGx08bwGcnB75dvr17CwxjxD6XPi3Qh8ArmKFqCA==", + "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" ], @@ -7255,9 +7592,9 @@ } }, "node_modules/lightningcss-linux-arm64-gnu": { - "version": "1.27.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.27.0.tgz", - "integrity": "sha512-cPsxo1QEWq2sfKkSq2Bq5feQDHdUEwgtA9KaB27J5AX22+l4l0ptgjMZZtYtUnteBofjee+0oW1wQ1guv04a7A==", + "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" ], @@ -7275,9 +7612,9 @@ } }, "node_modules/lightningcss-linux-arm64-musl": { - "version": "1.27.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.27.0.tgz", - "integrity": "sha512-rCGBm2ax7kQ9pBSeITfCW9XSVF69VX+fm5DIpvDZQl4NnQoMQyRwhZQm9pd59m8leZ1IesRqWk2v/DntMo26lg==", + "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" ], @@ -7295,9 +7632,9 @@ } }, "node_modules/lightningcss-linux-x64-gnu": { - "version": "1.27.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.27.0.tgz", - "integrity": "sha512-Dk/jovSI7qqhJDiUibvaikNKI2x6kWPN79AQiD/E/KeQWMjdGe9kw51RAgoWFDi0coP4jinaH14Nrt/J8z3U4A==", + "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" ], @@ -7315,9 +7652,9 @@ } }, "node_modules/lightningcss-linux-x64-musl": { - "version": "1.27.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.27.0.tgz", - "integrity": "sha512-QKjTxXm8A9s6v9Tg3Fk0gscCQA1t/HMoF7Woy1u68wCk5kS4fR+q3vXa1p3++REW784cRAtkYKrPy6JKibrEZA==", + "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" ], @@ -7335,9 +7672,9 @@ } }, "node_modules/lightningcss-win32-arm64-msvc": { - "version": "1.27.0", - "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.27.0.tgz", - "integrity": "sha512-/wXegPS1hnhkeG4OXQKEMQeJd48RDC3qdh+OA8pCuOPCyvnm/yEayrJdJVqzBsqpy1aJklRCVxscpFur80o6iQ==", + "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" ], @@ -7355,9 +7692,9 @@ } }, "node_modules/lightningcss-win32-x64-msvc": { - "version": "1.27.0", - "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.27.0.tgz", - "integrity": "sha512-/OJLj94Zm/waZShL8nB5jsNj3CfNATLCTyFxZyouilfTmSoLDX7VlVAmhPHoZWVFp4vdmoiEbPEYC8HID3m6yw==", + "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" ], @@ -7378,12 +7715,14 @@ "version": "1.2.4", "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", + "devOptional": true, "license": "MIT" }, "node_modules/locate-path": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "devOptional": true, "license": "MIT", "dependencies": { "p-locate": "^5.0.0" @@ -7401,214 +7740,42 @@ "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": "4.1.0", - "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-4.1.0.tgz", - "integrity": "sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg==", - "devOptional": true, - "license": "MIT", - "dependencies": { - "chalk": "^4.1.0", - "is-unicode-supported": "^0.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/logkitty": { - "version": "0.7.1", - "resolved": "https://registry.npmjs.org/logkitty/-/logkitty-0.7.1.tgz", - "integrity": "sha512-/3ER20CTTbahrCrpYfPn7Xavv9diBROZpoXGVZDWMw4b/X4uuUwAC0ki85tgsdMRONURyIJbcOvS94QsUBYPbQ==", - "devOptional": true, - "license": "MIT", - "dependencies": { - "ansi-fragments": "^0.2.1", - "dayjs": "^1.8.15", - "yargs": "^15.1.0" - }, - "bin": { - "logkitty": "bin/logkitty.js" - } - }, - "node_modules/logkitty/node_modules/cliui": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz", - "integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==", - "devOptional": true, - "license": "ISC", - "dependencies": { - "string-width": "^4.2.0", - "strip-ansi": "^6.0.0", - "wrap-ansi": "^6.2.0" - } - }, - "node_modules/logkitty/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==", - "devOptional": true, - "license": "MIT" - }, - "node_modules/logkitty/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==", - "devOptional": true, - "license": "MIT", - "dependencies": { - "locate-path": "^5.0.0", - "path-exists": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/logkitty/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==", - "devOptional": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/logkitty/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==", - "devOptional": true, - "license": "MIT", - "dependencies": { - "p-locate": "^4.1.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/logkitty/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==", - "devOptional": true, - "license": "MIT", - "dependencies": { - "p-try": "^2.0.0" - }, - "engines": { - "node": ">=6" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/logkitty/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==", - "devOptional": true, - "license": "MIT", - "dependencies": { - "p-limit": "^2.2.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/logkitty/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==", - "devOptional": true, - "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/logkitty/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==", - "devOptional": true, - "license": "MIT", - "dependencies": { - "ansi-regex": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/logkitty/node_modules/wrap-ansi": { - "version": "6.2.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz", - "integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==", - "devOptional": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^4.0.0", - "string-width": "^4.1.0", - "strip-ansi": "^6.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/logkitty/node_modules/y18n": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz", - "integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==", - "devOptional": true, - "license": "ISC" + "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/logkitty/node_modules/yargs": { - "version": "15.4.1", - "resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz", - "integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==", + "node_modules/log-symbols": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-4.1.0.tgz", + "integrity": "sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg==", "devOptional": true, "license": "MIT", "dependencies": { - "cliui": "^6.0.0", - "decamelize": "^1.2.0", - "find-up": "^4.1.0", - "get-caller-file": "^2.0.1", - "require-directory": "^2.1.1", - "require-main-filename": "^2.0.0", - "set-blocking": "^2.0.0", - "string-width": "^4.2.0", - "which-module": "^2.0.0", - "y18n": "^4.0.0", - "yargs-parser": "^18.1.2" + "chalk": "^4.1.0", + "is-unicode-supported": "^0.1.0" }, "engines": { - "node": ">=8" + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/logkitty/node_modules/yargs-parser": { - "version": "18.1.3", - "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz", - "integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==", + "node_modules/logkitty": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/logkitty/-/logkitty-0.7.1.tgz", + "integrity": "sha512-/3ER20CTTbahrCrpYfPn7Xavv9diBROZpoXGVZDWMw4b/X4uuUwAC0ki85tgsdMRONURyIJbcOvS94QsUBYPbQ==", "devOptional": true, - "license": "ISC", + "license": "MIT", "dependencies": { - "camelcase": "^5.0.0", - "decamelize": "^1.2.0" + "ansi-fragments": "^0.2.1", + "dayjs": "^1.8.15", + "yargs": "^15.1.0" }, - "engines": { - "node": ">=6" + "bin": { + "logkitty": "bin/logkitty.js" } }, "node_modules/loose-envify": { @@ -7658,13 +7825,13 @@ } }, "node_modules/media-typer": { - "version": "0.3.0", - "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", - "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz", + "integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==", "devOptional": true, "license": "MIT", "engines": { - "node": ">= 0.6" + "node": ">= 0.8" } }, "node_modules/memoize-one": { @@ -7702,19 +7869,19 @@ } }, "node_modules/metro": { - "version": "0.82.5", - "resolved": "https://registry.npmjs.org/metro/-/metro-0.82.5.tgz", - "integrity": "sha512-8oAXxL7do8QckID/WZEKaIFuQJFUTLzfVcC48ghkHhNK2RGuQq8Xvf4AVd+TUA0SZtX0q8TGNXZ/eba1ckeGCg==", + "version": "0.83.5", + "resolved": "https://registry.npmjs.org/metro/-/metro-0.83.5.tgz", + "integrity": "sha512-BgsXevY1MBac/3ZYv/RfNFf/4iuW9X7f4H8ZNkiH+r667HD9sVujxcmu4jvEzGCAm4/WyKdZCuyhAcyhTHOucQ==", "license": "MIT", "dependencies": { - "@babel/code-frame": "^7.24.7", + "@babel/code-frame": "^7.29.0", "@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", + "@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", "chalk": "^4.0.0", "ci-info": "^2.0.0", "connect": "^3.6.5", @@ -7722,25 +7889,25 @@ "error-stack-parser": "^2.0.6", "flow-enums-runtime": "^0.0.6", "graceful-fs": "^4.2.4", - "hermes-parser": "0.29.1", + "hermes-parser": "0.33.3", "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.82.5", - "metro-cache": "0.82.5", - "metro-cache-key": "0.82.5", - "metro-config": "0.82.5", - "metro-core": "0.82.5", - "metro-file-map": "0.82.5", - "metro-resolver": "0.82.5", - "metro-runtime": "0.82.5", - "metro-source-map": "0.82.5", - "metro-symbolicate": "0.82.5", - "metro-transform-plugins": "0.82.5", - "metro-transform-worker": "0.82.5", - "mime-types": "^2.1.27", + "metro-babel-transformer": "0.83.5", + "metro-cache": "0.83.5", + "metro-cache-key": "0.83.5", + "metro-config": "0.83.5", + "metro-core": "0.83.5", + "metro-file-map": "0.83.5", + "metro-resolver": "0.83.5", + "metro-runtime": "0.83.5", + "metro-source-map": "0.83.5", + "metro-symbolicate": "0.83.5", + "metro-transform-plugins": "0.83.5", + "metro-transform-worker": "0.83.5", + "mime-types": "^3.0.1", "nullthrows": "^1.1.1", "serialize-error": "^2.1.0", "source-map": "^0.5.6", @@ -7752,175 +7919,88 @@ "metro": "src/cli.js" }, "engines": { - "node": ">=18.18" + "node": ">=20.19.4" } }, "node_modules/metro-babel-transformer": { - "version": "0.82.5", - "resolved": "https://registry.npmjs.org/metro-babel-transformer/-/metro-babel-transformer-0.82.5.tgz", - "integrity": "sha512-W/scFDnwJXSccJYnOFdGiYr9srhbHPdxX9TvvACOFsIXdLilh3XuxQl/wXW6jEJfgIb0jTvoTlwwrqvuwymr6Q==", + "version": "0.83.5", + "resolved": "https://registry.npmjs.org/metro-babel-transformer/-/metro-babel-transformer-0.83.5.tgz", + "integrity": "sha512-d9FfmgUEVejTiSb7bkQeLRGl6aeno2UpuPm3bo3rCYwxewj03ymvOn8s8vnS4fBqAPQ+cE9iQM40wh7nGXR+eA==", "license": "MIT", "dependencies": { "@babel/core": "^7.25.2", "flow-enums-runtime": "^0.0.6", - "hermes-parser": "0.29.1", + "hermes-parser": "0.33.3", "nullthrows": "^1.1.1" }, "engines": { - "node": ">=18.18" - } - }, - "node_modules/metro-babel-transformer/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/metro-babel-transformer/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": ">=20.19.4" } }, "node_modules/metro-cache": { - "version": "0.82.5", - "resolved": "https://registry.npmjs.org/metro-cache/-/metro-cache-0.82.5.tgz", - "integrity": "sha512-AwHV9607xZpedu1NQcjUkua8v7HfOTKfftl6Vc9OGr/jbpiJX6Gpy8E/V9jo/U9UuVYX2PqSUcVNZmu+LTm71Q==", + "version": "0.83.5", + "resolved": "https://registry.npmjs.org/metro-cache/-/metro-cache-0.83.5.tgz", + "integrity": "sha512-oH+s4U+IfZyg8J42bne2Skc90rcuESIYf86dYittcdWQtPfcaFXWpByPyTuWk3rR1Zz3Eh5HOrcVImfEhhJLng==", "license": "MIT", "dependencies": { "exponential-backoff": "^3.1.1", "flow-enums-runtime": "^0.0.6", "https-proxy-agent": "^7.0.5", - "metro-core": "0.82.5" + "metro-core": "0.83.5" }, "engines": { - "node": ">=18.18" + "node": ">=20.19.4" } }, "node_modules/metro-cache-key": { - "version": "0.82.5", - "resolved": "https://registry.npmjs.org/metro-cache-key/-/metro-cache-key-0.82.5.tgz", - "integrity": "sha512-qpVmPbDJuRLrT4kcGlUouyqLGssJnbTllVtvIgXfR7ZuzMKf0mGS+8WzcqzNK8+kCyakombQWR0uDd8qhWGJcA==", + "version": "0.83.5", + "resolved": "https://registry.npmjs.org/metro-cache-key/-/metro-cache-key-0.83.5.tgz", + "integrity": "sha512-Ycl8PBajB7bhbAI7Rt0xEyiF8oJ0RWX8EKkolV1KfCUlC++V/GStMSGpPLwnnBZXZWkCC5edBPzv1Hz1Yi0Euw==", "license": "MIT", "dependencies": { "flow-enums-runtime": "^0.0.6" }, "engines": { - "node": ">=18.18" + "node": ">=20.19.4" } }, "node_modules/metro-config": { - "version": "0.82.5", - "resolved": "https://registry.npmjs.org/metro-config/-/metro-config-0.82.5.tgz", - "integrity": "sha512-/r83VqE55l0WsBf8IhNmc/3z71y2zIPe5kRSuqA5tY/SL/ULzlHUJEMd1szztd0G45JozLwjvrhAzhDPJ/Qo/g==", + "version": "0.83.5", + "resolved": "https://registry.npmjs.org/metro-config/-/metro-config-0.83.5.tgz", + "integrity": "sha512-JQ/PAASXH7yczgV6OCUSRhZYME+NU8NYjI2RcaG5ga4QfQ3T/XdiLzpSb3awWZYlDCcQb36l4Vl7i0Zw7/Tf9w==", "license": "MIT", "dependencies": { "connect": "^3.6.5", - "cosmiconfig": "^5.0.5", "flow-enums-runtime": "^0.0.6", "jest-validate": "^29.7.0", - "metro": "0.82.5", - "metro-cache": "0.82.5", - "metro-core": "0.82.5", - "metro-runtime": "0.82.5" - }, - "engines": { - "node": ">=18.18" - } - }, - "node_modules/metro-config/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/metro-config/node_modules/cosmiconfig": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-5.2.1.tgz", - "integrity": "sha512-H65gsXo1SKjf8zmrJ67eJk8aIRKV5ff2D4uKZIBZShbhGSpEmsQOPW/SKMKYhSTrqR7ufy6RP69rPogdaPh/kA==", - "license": "MIT", - "dependencies": { - "import-fresh": "^2.0.0", - "is-directory": "^0.3.1", - "js-yaml": "^3.13.1", - "parse-json": "^4.0.0" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/metro-config/node_modules/import-fresh": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-2.0.0.tgz", - "integrity": "sha512-eZ5H8rcgYazHbKC3PG4ClHNykCSxtAhxSSEM+2mb+7evD2CKF5V7c0dNum7AdpDh0ZdICwZY9sRSn8f+KH96sg==", - "license": "MIT", - "dependencies": { - "caller-path": "^2.0.0", - "resolve-from": "^3.0.0" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/metro-config/node_modules/js-yaml": { - "version": "3.14.1", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.1.tgz", - "integrity": "sha512-okMH7OXXJ7YrN9Ok3/SXrnu4iX9yOk+25nqX4imS2npuvTYDmo/QEZoqwZkYaIDk3jVvBOTOIEgEhaLOynBS9g==", - "license": "MIT", - "dependencies": { - "argparse": "^1.0.7", - "esprima": "^4.0.0" - }, - "bin": { - "js-yaml": "bin/js-yaml.js" - } - }, - "node_modules/metro-config/node_modules/parse-json": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-4.0.0.tgz", - "integrity": "sha512-aOIos8bujGN93/8Ox/jPLh7RwVnPEysynVFE+fQZyg6jKELEHwzgKdLRFHUgXJL6kylijVSBC4BvN9OmsB48Rw==", - "license": "MIT", - "dependencies": { - "error-ex": "^1.3.1", - "json-parse-better-errors": "^1.0.1" + "metro": "0.83.5", + "metro-cache": "0.83.5", + "metro-core": "0.83.5", + "metro-runtime": "0.83.5", + "yaml": "^2.6.1" }, "engines": { - "node": ">=4" - } - }, - "node_modules/metro-config/node_modules/resolve-from": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-3.0.0.tgz", - "integrity": "sha512-GnlH6vxLymXJNMBo7XP1fJIzBFbdYt49CuTwmB/6N53t+kMPRMFKz783LlQ4tv28XoQfMWinAJX6WCGf2IlaIw==", - "license": "MIT", - "engines": { - "node": ">=4" + "node": ">=20.19.4" } }, "node_modules/metro-core": { - "version": "0.82.5", - "resolved": "https://registry.npmjs.org/metro-core/-/metro-core-0.82.5.tgz", - "integrity": "sha512-OJL18VbSw2RgtBm1f2P3J5kb892LCVJqMvslXxuxjAPex8OH7Eb8RBfgEo7VZSjgb/LOf4jhC4UFk5l5tAOHHA==", + "version": "0.83.5", + "resolved": "https://registry.npmjs.org/metro-core/-/metro-core-0.83.5.tgz", + "integrity": "sha512-YcVcLCrf0ed4mdLa82Qob0VxYqfhmlRxUS8+TO4gosZo/gLwSvtdeOjc/Vt0pe/lvMNrBap9LlmvZM8FIsMgJQ==", "license": "MIT", "dependencies": { "flow-enums-runtime": "^0.0.6", "lodash.throttle": "^4.1.1", - "metro-resolver": "0.82.5" + "metro-resolver": "0.83.5" }, "engines": { - "node": ">=18.18" + "node": ">=20.19.4" } }, "node_modules/metro-file-map": { - "version": "0.82.5", - "resolved": "https://registry.npmjs.org/metro-file-map/-/metro-file-map-0.82.5.tgz", - "integrity": "sha512-vpMDxkGIB+MTN8Af5hvSAanc6zXQipsAUO+XUx3PCQieKUfLwdoa8qaZ1WAQYRpaU+CJ8vhBcxtzzo3d9IsCIQ==", + "version": "0.83.5", + "resolved": "https://registry.npmjs.org/metro-file-map/-/metro-file-map-0.83.5.tgz", + "integrity": "sha512-ZEt8s3a1cnYbn40nyCD+CsZdYSlwtFh2kFym4lo+uvfM+UMMH+r/BsrC6rbNClSrt+B7rU9T+Te/sh/NL8ZZKQ==", "license": "MIT", "dependencies": { "debug": "^4.4.0", @@ -7934,77 +8014,76 @@ "walker": "^1.0.7" }, "engines": { - "node": ">=18.18" + "node": ">=20.19.4" } }, "node_modules/metro-minify-terser": { - "version": "0.82.5", - "resolved": "https://registry.npmjs.org/metro-minify-terser/-/metro-minify-terser-0.82.5.tgz", - "integrity": "sha512-v6Nx7A4We6PqPu/ta1oGTqJ4Usz0P7c+3XNeBxW9kp8zayS3lHUKR0sY0wsCHInxZlNAEICx791x+uXytFUuwg==", + "version": "0.83.5", + "resolved": "https://registry.npmjs.org/metro-minify-terser/-/metro-minify-terser-0.83.5.tgz", + "integrity": "sha512-Toe4Md1wS1PBqbvB0cFxBzKEVyyuYTUb0sgifAZh/mSvLH84qA1NAWik9sISWatzvfWf3rOGoUoO5E3f193a3Q==", "license": "MIT", "dependencies": { "flow-enums-runtime": "^0.0.6", "terser": "^5.15.0" }, "engines": { - "node": ">=18.18" + "node": ">=20.19.4" } }, "node_modules/metro-resolver": { - "version": "0.82.5", - "resolved": "https://registry.npmjs.org/metro-resolver/-/metro-resolver-0.82.5.tgz", - "integrity": "sha512-kFowLnWACt3bEsuVsaRNgwplT8U7kETnaFHaZePlARz4Fg8tZtmRDUmjaD68CGAwc0rwdwNCkWizLYpnyVcs2g==", + "version": "0.83.5", + "resolved": "https://registry.npmjs.org/metro-resolver/-/metro-resolver-0.83.5.tgz", + "integrity": "sha512-7p3GtzVUpbAweJeCcUJihJeOQl1bDuimO5ueo1K0BUpUtR41q5EilbQ3klt16UTPPMpA+tISWBtsrqU556mY1A==", "license": "MIT", "dependencies": { "flow-enums-runtime": "^0.0.6" }, "engines": { - "node": ">=18.18" + "node": ">=20.19.4" } }, "node_modules/metro-runtime": { - "version": "0.82.5", - "resolved": "https://registry.npmjs.org/metro-runtime/-/metro-runtime-0.82.5.tgz", - "integrity": "sha512-rQZDoCUf7k4Broyw3Ixxlq5ieIPiR1ULONdpcYpbJQ6yQ5GGEyYjtkztGD+OhHlw81LCR2SUAoPvtTus2WDK5g==", + "version": "0.83.5", + "resolved": "https://registry.npmjs.org/metro-runtime/-/metro-runtime-0.83.5.tgz", + "integrity": "sha512-f+b3ue9AWTVlZe2Xrki6TAoFtKIqw30jwfk7GQ1rDUBQaE0ZQ+NkiMEtb9uwH7uAjJ87U7Tdx1Jg1OJqUfEVlA==", "license": "MIT", "dependencies": { "@babel/runtime": "^7.25.0", "flow-enums-runtime": "^0.0.6" }, "engines": { - "node": ">=18.18" + "node": ">=20.19.4" } }, "node_modules/metro-source-map": { - "version": "0.82.5", - "resolved": "https://registry.npmjs.org/metro-source-map/-/metro-source-map-0.82.5.tgz", - "integrity": "sha512-wH+awTOQJVkbhn2SKyaw+0cd+RVSCZ3sHVgyqJFQXIee/yLs3dZqKjjeKKhhVeudgjXo7aE/vSu/zVfcQEcUfw==", + "version": "0.83.5", + "resolved": "https://registry.npmjs.org/metro-source-map/-/metro-source-map-0.83.5.tgz", + "integrity": "sha512-VT9bb2KO2/4tWY9Z2yeZqTUao7CicKAOps9LUg2aQzsz+04QyuXL3qgf1cLUVRjA/D6G5u1RJAlN1w9VNHtODQ==", "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", + "@babel/traverse": "^7.29.0", + "@babel/types": "^7.29.0", "flow-enums-runtime": "^0.0.6", "invariant": "^2.2.4", - "metro-symbolicate": "0.82.5", + "metro-symbolicate": "0.83.5", "nullthrows": "^1.1.1", - "ob1": "0.82.5", + "ob1": "0.83.5", "source-map": "^0.5.6", "vlq": "^1.0.0" }, "engines": { - "node": ">=18.18" + "node": ">=20.19.4" } }, "node_modules/metro-symbolicate": { - "version": "0.82.5", - "resolved": "https://registry.npmjs.org/metro-symbolicate/-/metro-symbolicate-0.82.5.tgz", - "integrity": "sha512-1u+07gzrvYDJ/oNXuOG1EXSvXZka/0JSW1q2EYBWerVKMOhvv9JzDGyzmuV7hHbF2Hg3T3S2uiM36sLz1qKsiw==", + "version": "0.83.5", + "resolved": "https://registry.npmjs.org/metro-symbolicate/-/metro-symbolicate-0.83.5.tgz", + "integrity": "sha512-EMIkrjNRz/hF+p0RDdxoE60+dkaTLPN3vaaGkFmX5lvFdO6HPfHA/Ywznzkev+za0VhPQ5KSdz49/MALBRteHA==", "license": "MIT", "dependencies": { "flow-enums-runtime": "^0.0.6", "invariant": "^2.2.4", - "metro-source-map": "0.82.5", + "metro-source-map": "0.83.5", "nullthrows": "^1.1.1", "source-map": "^0.5.6", "vlq": "^1.0.0" @@ -8013,69 +8092,129 @@ "metro-symbolicate": "src/index.js" }, "engines": { - "node": ">=18.18" + "node": ">=20.19.4" } }, "node_modules/metro-transform-plugins": { - "version": "0.82.5", - "resolved": "https://registry.npmjs.org/metro-transform-plugins/-/metro-transform-plugins-0.82.5.tgz", - "integrity": "sha512-57Bqf3rgq9nPqLrT2d9kf/2WVieTFqsQ6qWHpEng5naIUtc/Iiw9+0bfLLWSAw0GH40iJ4yMjFcFJDtNSYynMA==", + "version": "0.83.5", + "resolved": "https://registry.npmjs.org/metro-transform-plugins/-/metro-transform-plugins-0.83.5.tgz", + "integrity": "sha512-KxYKzZL+lt3Os5H2nx7YkbkWVduLZL5kPrE/Yq+Prm/DE1VLhpfnO6HtPs8vimYFKOa58ncl60GpoX0h7Wm0Vw==", "license": "MIT", "dependencies": { "@babel/core": "^7.25.2", - "@babel/generator": "^7.25.0", - "@babel/template": "^7.25.0", - "@babel/traverse": "^7.25.3", + "@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": ">=18.18" + "node": ">=20.19.4" } }, "node_modules/metro-transform-worker": { - "version": "0.82.5", - "resolved": "https://registry.npmjs.org/metro-transform-worker/-/metro-transform-worker-0.82.5.tgz", - "integrity": "sha512-mx0grhAX7xe+XUQH6qoHHlWedI8fhSpDGsfga7CpkO9Lk9W+aPitNtJWNGrW8PfjKEWbT9Uz9O50dkI8bJqigw==", + "version": "0.83.5", + "resolved": "https://registry.npmjs.org/metro-transform-worker/-/metro-transform-worker-0.83.5.tgz", + "integrity": "sha512-8N4pjkNXc6ytlP9oAM6MwqkvUepNSW39LKYl9NjUMpRDazBQ7oBpQDc8Sz4aI8jnH6AGhF7s1m/ayxkN1t04yA==", "license": "MIT", "dependencies": { "@babel/core": "^7.25.2", - "@babel/generator": "^7.25.0", - "@babel/parser": "^7.25.3", - "@babel/types": "^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.82.5", - "metro-babel-transformer": "0.82.5", - "metro-cache": "0.82.5", - "metro-cache-key": "0.82.5", - "metro-minify-terser": "0.82.5", - "metro-source-map": "0.82.5", - "metro-transform-plugins": "0.82.5", + "metro": "0.83.5", + "metro-babel-transformer": "0.83.5", + "metro-cache": "0.83.5", + "metro-cache-key": "0.83.5", + "metro-minify-terser": "0.83.5", + "metro-source-map": "0.83.5", + "metro-transform-plugins": "0.83.5", "nullthrows": "^1.1.1" }, "engines": { - "node": ">=18.18" + "node": ">=20.19.4" } }, - "node_modules/metro/node_modules/ci-info": { + "node_modules/metro/node_modules/accepts": { "version": "2.0.0", - "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-2.0.0.tgz", - "integrity": "sha512-5tK7EtrZ0N+OLFMthtqOj4fI2Jeb88C4CAZPu25LDVUgXJ0A3Js4PMGqrn0JU1W0Mh1/Z8wZzYPxqUrXeBboCQ==", - "license": "MIT" + "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/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/metro/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/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/metro/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/metro/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==", + "node_modules/metro/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": { - "hermes-estree": "0.29.1" + "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/metro/node_modules/ws": { @@ -8099,6 +8238,42 @@ } } }, + "node_modules/metro/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/metro/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/metro/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/micromatch": { "version": "4.0.8", "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", @@ -8112,18 +8287,6 @@ "node": ">=8.6" } }, - "node_modules/micromatch/node_modules/picomatch": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", - "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", - "license": "MIT", - "engines": { - "node": ">=8.6" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, "node_modules/mime": { "version": "2.6.0", "resolved": "https://registry.npmjs.org/mime/-/mime-2.6.0.tgz", @@ -8178,29 +8341,20 @@ } }, "node_modules/minimatch": { - "version": "9.0.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", - "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", - "license": "ISC", + "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": "^2.0.1" + "brace-expansion": "^5.0.5" }, "engines": { - "node": ">=16 || 14 >=14.17" + "node": "18 || 20 || >=22" }, "funding": { "url": "https://github.com/sponsors/isaacs" } }, - "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", @@ -8210,18 +8364,6 @@ "node": ">=16 || 14 >=14.17" } }, - "node_modules/minizlib": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-3.0.2.tgz", - "integrity": "sha512-oG62iEk+CYt5Xj2YqI5Xi9xWUeZhDI8jjQmC5oThVH5JGCTgIjr7ciJDzC7MBzYd//WvR1OTmP5Q38Q8ShQtVA==", - "license": "MIT", - "dependencies": { - "minipass": "^7.1.2" - }, - "engines": { - "node": ">= 18" - } - }, "node_modules/mkdirp": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz", @@ -8240,16 +8382,11 @@ "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/multitars": { + "version": "0.2.5", + "resolved": "https://registry.npmjs.org/multitars/-/multitars-0.2.5.tgz", + "integrity": "sha512-T/i4uZOzd4j2VnS28eAOJS0MgeAbcsFIijRPeLRhVv54hP9OqsC/FjYK0JmMTWxGhF2fv34oH1mtR6XLBKkNlw==", + "license": "MIT" }, "node_modules/nanoid": { "version": "3.3.11", @@ -8278,12 +8415,6 @@ "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/nocache": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/nocache/-/nocache-3.0.4.tgz", @@ -8295,9 +8426,9 @@ } }, "node_modules/node-forge": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/node-forge/-/node-forge-1.3.1.tgz", - "integrity": "sha512-dPEtOeMvF9VMcYV/1Wb8CPoVAXtp6MKMlcbAt4ddqmGqUJ6fQZFXkNZNkNlfevtNkGtaSoXf/vNNNSvgrdXwtA==", + "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" @@ -8310,9 +8441,9 @@ "license": "MIT" }, "node_modules/node-releases": { - "version": "2.0.20", - "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.20.tgz", - "integrity": "sha512-7gK6zSXEH6neM212JgfYFXe+GmZQM+fia5SsusuBIUgnPheLFBmIPhtFoAQRj8/7wASYQnbDlHPVwY0BefoFgA==", + "version": "2.0.37", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.37.tgz", + "integrity": "sha512-1h5gKZCF+pO/o3Iqt5Jp7wc9rH3eJJ0+nh/CIoiRwjRxde/hAHyLPXYN4V3CqKAbiZPSeJFSWHmJsbkicta0Eg==", "license": "MIT" }, "node_modules/node-stream-zip": { @@ -8354,9 +8485,9 @@ } }, "node_modules/npm-package-arg/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==", + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", "license": "ISC", "bin": { "semver": "bin/semver.js" @@ -8385,15 +8516,15 @@ "license": "MIT" }, "node_modules/ob1": { - "version": "0.82.5", - "resolved": "https://registry.npmjs.org/ob1/-/ob1-0.82.5.tgz", - "integrity": "sha512-QyQQ6e66f+Ut/qUVjEce0E/wux5nAGLXYZDn1jr15JWstHsCH3l6VVrg8NKDptW9NEiBXKOJeGF/ydxeSDF3IQ==", + "version": "0.83.5", + "resolved": "https://registry.npmjs.org/ob1/-/ob1-0.83.5.tgz", + "integrity": "sha512-vNKPYC8L5ycVANANpF/S+WZHpfnRWKx/F3AYP4QMn6ZJTh+l2HOrId0clNkEmua58NB9vmI9Qh7YOoV/4folYg==", "license": "MIT", "dependencies": { "flow-enums-runtime": "^0.0.6" }, "engines": { - "node": ">=18.18" + "node": ">=20.19.4" } }, "node_modules/object-assign": { @@ -8474,17 +8605,7 @@ "is-wsl": "^1.1.0" }, "engines": { - "node": ">=8" - } - }, - "node_modules/open/node_modules/is-wsl": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-1.1.0.tgz", - "integrity": "sha512-gfygJYZ2gLTDlmbWMI0CE2MwnFzSN/2SZfkMlItC4K/JBlsWVDB0bO6XhqcY13YXE7iMcAJnzTCJjPiTeJJ0Mw==", - "devOptional": true, - "license": "MIT", - "engines": { - "node": ">=4" + "node": ">=8" } }, "node_modules/ora": { @@ -8528,6 +8649,7 @@ "version": "3.1.0", "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "devOptional": true, "license": "MIT", "dependencies": { "yocto-queue": "^0.1.0" @@ -8543,6 +8665,7 @@ "version": "5.0.0", "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "devOptional": true, "license": "MIT", "dependencies": { "p-limit": "^3.0.2" @@ -8563,12 +8686,6 @@ "node": ">=6" } }, - "node_modules/package-json-from-dist": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", - "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", - "license": "BlueOak-1.0.0" - }, "node_modules/parent-module": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", @@ -8631,6 +8748,22 @@ "node": ">=8" } }, + "node_modules/path-expression-matcher": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/path-expression-matcher/-/path-expression-matcher-1.5.0.tgz", + "integrity": "sha512-cbrerZV+6rvdQrrD+iGMcZFEiiSrbv9Tfdkvnusy6y0x0GKBXREFg/Y65GhIfm0tnLntThhzCnfKwp1WRjeCyQ==", + "devOptional": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/path-is-absolute": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", @@ -8656,26 +8789,29 @@ "license": "MIT" }, "node_modules/path-scurry": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", - "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", + "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": "^10.2.0", - "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" + "lru-cache": "^11.0.0", + "minipass": "^7.1.2" }, "engines": { - "node": ">=16 || 14 >=14.18" + "node": "18 || 20 || >=22" }, "funding": { "url": "https://github.com/sponsors/isaacs" } }, "node_modules/path-scurry/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" + "version": "11.3.5", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.3.5.tgz", + "integrity": "sha512-NxVFwLAnrd9i7KUBxC4DrUhmgjzOs+1Qm50D3oF1/oL+r1NpZ4gA7xvG0/zJ8evR7zIKn4vLf7qTNduWFtCrRw==", + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } }, "node_modules/picocolors": { "version": "1.1.1", @@ -8684,13 +8820,12 @@ "license": "ISC" }, "node_modules/picomatch": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-3.0.1.tgz", - "integrity": "sha512-I3EurrIQMlRc9IaAZnqRR044Phh2DXY+55o7uJ0V+hYZAcQYSuFWsc9q5PvyDHUSCe1Qxn/iBz+78s86zWnGag==", + "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", - "peer": true, "engines": { - "node": ">=10" + "node": ">=8.6" }, "funding": { "url": "https://github.com/sponsors/jonschlinkert" @@ -8756,18 +8891,6 @@ "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/pretty-format": { "version": "29.7.0", "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-29.7.0.tgz", @@ -8794,6 +8917,12 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, + "node_modules/pretty-format/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/proc-log": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/proc-log/-/proc-log-4.2.0.tgz", @@ -8851,31 +8980,14 @@ "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", "license": "MIT" }, - "node_modules/punycode": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", - "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", - "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/qs": { - "version": "6.13.0", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.13.0.tgz", - "integrity": "sha512-+38qI9SOr8tfZ4QmJNplMUxqjbe7LKvvZgWdExBOmd+egZTtjLB67Gu0HRX3u/XOq7UU2Nx6nsjvS16Z9uwfpg==", + "version": "6.15.1", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.1.tgz", + "integrity": "sha512-6YHEFRL9mfgcAvql/XhwTvf5jKcOiiupt2FiJxHkiX1z4j7WL8J/jRHYLluORvc1XxB5rV20KoeK00gVJamspg==", "devOptional": true, "license": "BSD-3-Clause", "dependencies": { - "side-channel": "^1.0.6" + "side-channel": "^1.1.0" }, "engines": { "node": ">=0.6" @@ -8942,40 +9054,25 @@ } }, "node_modules/raw-body": { - "version": "2.5.2", - "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.2.tgz", - "integrity": "sha512-8zGqypfENjCIqGhgXToC8aB2r7YrBX+AQAfIPs/Mlk+BtPTztOvTS01NRW/3Eh60J+a48lt8qsCzirQ6loCVfA==", + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz", + "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==", "devOptional": true, "license": "MIT", "dependencies": { - "bytes": "3.1.2", - "http-errors": "2.0.0", - "iconv-lite": "0.4.24", - "unpipe": "1.0.0" + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.7.0", + "unpipe": "~1.0.0" }, "engines": { - "node": ">= 0.8" - } - }, - "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": ">= 0.10" } }, "node_modules/react": { - "version": "19.0.0", - "resolved": "https://registry.npmjs.org/react/-/react-19.0.0.tgz", - "integrity": "sha512-V8AVnmPIICiWpGfm6GLzCR/W5FXLchHop40W4nXBmdlEceh16rCN8O8LNWm5bh5XUX91fh7KpA+W0TgMKmgTpQ==", + "version": "19.2.5", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.5.tgz", + "integrity": "sha512-llUJLzz1zTUBrskt2pwZgLq59AemifIftw4aB7JxOqf1HY2FDaGDxgwpAPVzHU1kdWabH7FauP4i1oEeer2WCA==", "license": "MIT", "peer": true, "engines": { @@ -9013,6 +9110,19 @@ } } }, + "node_modules/react-dom": { + "version": "19.2.5", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.5.tgz", + "integrity": "sha512-J5bAZz+DXMMwW/wV3xzKke59Af6CHY7G4uYLN1OvBcKEsWOs4pQExj86BBKamxl/Ik5bx9whOrvBlSDfWzgSag==", + "license": "MIT", + "peer": true, + "dependencies": { + "scheduler": "^0.27.0" + }, + "peerDependencies": { + "react": "^19.2.5" + } + }, "node_modules/react-fast-compare": { "version": "3.2.2", "resolved": "https://registry.npmjs.org/react-fast-compare/-/react-fast-compare-3.2.2.tgz", @@ -9032,64 +9142,63 @@ } }, "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==", + "version": "19.2.5", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.5.tgz", + "integrity": "sha512-Dn0t8IQhCmeIT3wu+Apm1/YVsJXsGWi6k4sPdnBIdqMVtHtv0IGi6dcpNpNkNac0zB2uUAqNX3MHzN8c+z2rwQ==", "license": "MIT" }, "node_modules/react-native": { - "version": "0.79.6", - "resolved": "https://registry.npmjs.org/react-native/-/react-native-0.79.6.tgz", - "integrity": "sha512-kvIWSmf4QPfY41HC25TR285N7Fv0Pyn3DAEK8qRL9dA35usSaxsJkHfw+VqnonqJjXOaoKCEanwudRAJ60TBGA==", + "version": "0.83.4", + "resolved": "https://registry.npmjs.org/react-native/-/react-native-0.83.4.tgz", + "integrity": "sha512-H5Wco3UJyY6zZsjoBayY8RM9uiAEQ3FeG4G2NAt+lr9DO43QeqPlVe9xxxYEukMkEmeIhNjR70F6bhXuWArOMQ==", "license": "MIT", "peer": true, "dependencies": { "@jest/create-cache-key-function": "^29.7.0", - "@react-native/assets-registry": "0.79.6", - "@react-native/codegen": "0.79.6", - "@react-native/community-cli-plugin": "0.79.6", - "@react-native/gradle-plugin": "0.79.6", - "@react-native/js-polyfills": "0.79.6", - "@react-native/normalize-colors": "0.79.6", - "@react-native/virtualized-lists": "0.79.6", + "@react-native/assets-registry": "0.83.4", + "@react-native/codegen": "0.83.4", + "@react-native/community-cli-plugin": "0.83.4", + "@react-native/gradle-plugin": "0.83.4", + "@react-native/js-polyfills": "0.83.4", + "@react-native/normalize-colors": "0.83.4", + "@react-native/virtualized-lists": "0.83.4", "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.25.1", + "babel-plugin-syntax-hermes-parser": "0.32.0", "base64-js": "^1.5.1", - "chalk": "^4.0.0", "commander": "^12.0.0", - "event-target-shim": "^5.0.1", "flow-enums-runtime": "^0.0.6", "glob": "^7.1.1", + "hermes-compiler": "0.14.1", "invariant": "^2.2.4", "jest-environment-node": "^29.7.0", "memoize-one": "^5.0.0", - "metro-runtime": "^0.82.0", - "metro-source-map": "^0.82.0", + "metro-runtime": "^0.83.3", + "metro-source-map": "^0.83.3", "nullthrows": "^1.1.1", "pretty-format": "^29.7.0", "promise": "^8.3.0", - "react-devtools-core": "^6.1.1", + "react-devtools-core": "^6.1.5", "react-refresh": "^0.14.0", "regenerator-runtime": "^0.13.2", - "scheduler": "0.25.0", + "scheduler": "0.27.0", "semver": "^7.1.3", "stacktrace-parser": "^0.1.10", "whatwg-fetch": "^3.0.0", - "ws": "^6.2.3", + "ws": "^7.5.10", "yargs": "^17.6.2" }, "bin": { "react-native": "cli.js" }, "engines": { - "node": ">=18" + "node": ">= 20.19.4" }, "peerDependencies": { - "@types/react": "^19.0.0", - "react": "^19.0.0" + "@types/react": "^19.1.1", + "react": "^19.2.0" }, "peerDependenciesMeta": { "@types/react": { @@ -9098,15 +9207,15 @@ } }, "node_modules/react-native-base64": { - "version": "0.2.1", - "resolved": "https://registry.npmjs.org/react-native-base64/-/react-native-base64-0.2.1.tgz", - "integrity": "sha512-eHgt/MA8y5ZF0aHfZ1aTPcIkDWxza9AaEk4GcpIX+ZYfZ04RcaNahO+527KR7J44/mD3efYfM23O2C1N44ByWA==", + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/react-native-base64/-/react-native-base64-0.2.2.tgz", + "integrity": "sha512-9iDzlDQrJqRlgoi7GnO4dqK/7/6lpA3DFrArhp85tDB7ZI6wLr7luHihb/pX6jhm4zlHqOz2OYSGJ6PSgyUO1g==", "license": "MIT" }, "node_modules/react-native-ble-plx": { - "version": "3.5.0", - "resolved": "https://registry.npmjs.org/react-native-ble-plx/-/react-native-ble-plx-3.5.0.tgz", - "integrity": "sha512-PeSnRswHLwLRVMQkOfDaRICtrGmo94WGKhlSC09XmHlqX2EuYgH+vNJpGcLkd8lyiYpEJyf8wlFAdj9Akliwmw==", + "version": "3.5.1", + "resolved": "https://registry.npmjs.org/react-native-ble-plx/-/react-native-ble-plx-3.5.1.tgz", + "integrity": "sha512-SxksmrUt9jG6DOarrrdkb5c/HBLSfZOKauo/9VQSSi3WJA4bmF78GkrtXrgSoGNk0m1ksacFTjB5DuL39xZq/g==", "license": "MIT", "engines": { "node": ">= 18.0.0" @@ -9116,75 +9225,269 @@ "react-native": "*" } }, - "node_modules/react-native-edge-to-edge": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/react-native-edge-to-edge/-/react-native-edge-to-edge-1.6.0.tgz", - "integrity": "sha512-2WCNdE3Qd6Fwg9+4BpbATUxCLcouF6YRY7K+J36KJ4l3y+tWN6XCqAC4DuoGblAAbb2sLkhEDp4FOlbOIot2Og==", + "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-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", + "peer": true, + "peerDependencies": { + "react": "*", + "react-native": "*" + } + }, + "node_modules/react-native-screens": { + "version": "4.23.0", + "resolved": "https://registry.npmjs.org/react-native-screens/-/react-native-screens-4.23.0.tgz", + "integrity": "sha512-XhO3aK0UeLpBn4kLecd+J+EDeRRJlI/Ro9Fze06vo1q163VeYtzfU9QS09/VyDFMWR1qxDC1iazCArTPSFFiPw==", + "license": "MIT", + "peer": true, + "dependencies": { + "react-freeze": "^1.0.0", + "warn-once": "^0.1.0" + }, + "peerDependencies": { + "react": "*", + "react-native": "*" + } + }, + "node_modules/react-native/node_modules/@react-native/normalize-colors": { + "version": "0.83.4", + "resolved": "https://registry.npmjs.org/@react-native/normalize-colors/-/normalize-colors-0.83.4.tgz", + "integrity": "sha512-9ezxaHjxqTkTOLg62SGg7YhFaE+fxa/jlrWP0nwf7eGFHlGOiTAaRR2KUfiN3K05e+EMbEhgcH/c7bgaXeGyJw==", + "license": "MIT" + }, + "node_modules/react-native/node_modules/@react-native/virtualized-lists": { + "version": "0.83.4", + "resolved": "https://registry.npmjs.org/@react-native/virtualized-lists/-/virtualized-lists-0.83.4.tgz", + "integrity": "sha512-vNF/8kokMW8JEjG4n+j7veLTjHRRABlt4CaTS6+wtqzvWxCJHNIC8fhCqrDPn9fIn8sNePd8DyiFVX5L9TBBRA==", + "license": "MIT", + "dependencies": { + "invariant": "^2.2.4", + "nullthrows": "^1.1.1" + }, + "engines": { + "node": ">= 20.19.4" + }, "peerDependencies": { + "@types/react": "^19.2.0", "react": "*", "react-native": "*" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/react-native/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/react-native/node_modules/brace-expansion": { + "version": "1.1.14", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz", + "integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==", + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/react-native/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/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/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/react-native/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/react-native/node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/react-native/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/react-native/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/react-native/node_modules/ws": { + "version": "7.5.10", + "resolved": "https://registry.npmjs.org/ws/-/ws-7.5.10.tgz", + "integrity": "sha512-+dbF1tHwZpXcbOJdVOkzLDxZP1ailvSxM6ZweXTegylPny803bFhA+vqBYw4s31NSAk4S2Qz+AKXK9a4wkdjcQ==", + "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/react-native/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/react-native-is-edge-to-edge": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/react-native-is-edge-to-edge/-/react-native-is-edge-to-edge-1.2.1.tgz", - "integrity": "sha512-FLbPWl/MyYQWz+KwqOZsSyj2JmLKglHatd3xLZWskXOpRaio4LfEDEz8E/A6uD8QoTHW6Aobw1jbEwK7KMgR7Q==", + "node_modules/react-native/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", - "peerDependencies": { - "react": "*", - "react-native": "*" + "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/react-native-safe-area-context": { - "version": "5.4.0", - "resolved": "https://registry.npmjs.org/react-native-safe-area-context/-/react-native-safe-area-context-5.4.0.tgz", - "integrity": "sha512-JaEThVyJcLhA+vU0NU8bZ0a1ih6GiF4faZ+ArZLqpYbL6j7R3caRqj+mE3lEtKCuHgwjLg3bCxLL1GPUJZVqUA==", - "license": "MIT", - "peer": true, - "peerDependencies": { - "react": "*", - "react-native": "*" + "node_modules/react-native/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/react-native-screens": { - "version": "4.11.1", - "resolved": "https://registry.npmjs.org/react-native-screens/-/react-native-screens-4.11.1.tgz", - "integrity": "sha512-F0zOzRVa3ptZfLpD0J8ROdo+y1fEPw+VBFq1MTY/iyDu08al7qFUO5hLMd+EYMda5VXGaTFCa8q7bOppUszhJw==", + "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", "peer": true, - "dependencies": { - "react-freeze": "^1.0.0", - "react-native-is-edge-to-edge": "^1.1.7", - "warn-once": "^0.1.0" - }, - "peerDependencies": { - "react": "*", - "react-native": "*" + "engines": { + "node": ">=0.10.0" } }, - "node_modules/react-native/node_modules/@react-native/normalize-colors": { - "version": "0.79.6", - "resolved": "https://registry.npmjs.org/@react-native/normalize-colors/-/normalize-colors-0.79.6.tgz", - "integrity": "sha512-0v2/ruY7eeKun4BeKu+GcfO+SHBdl0LJn4ZFzTzjHdWES0Cn+ONqKljYaIv8p9MV2Hx/kcdEvbY4lWI34jC/mQ==", - "license": "MIT" - }, - "node_modules/react-native/node_modules/@react-native/virtualized-lists": { - "version": "0.79.6", - "resolved": "https://registry.npmjs.org/@react-native/virtualized-lists/-/virtualized-lists-0.79.6.tgz", - "integrity": "sha512-khA/Hrbb+rB68YUHrLubfLgMOD9up0glJhw25UE3Kntj32YDyuO0Tqc81ryNTcCekFKJ8XrAaEjcfPg81zBGPw==", + "node_modules/react-remove-scroll": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.7.2.tgz", + "integrity": "sha512-Iqb9NjCCTt6Hf+vOdNIZGdTiH1QSqr27H/Ek9sv/a97gfueI/5h1s3yRi1nngzMUaOOToin5dI1dXKdXiF+u0Q==", "license": "MIT", "dependencies": { - "invariant": "^2.2.4", - "nullthrows": "^1.1.1" + "react-remove-scroll-bar": "^2.3.7", + "react-style-singleton": "^2.2.3", + "tslib": "^2.1.0", + "use-callback-ref": "^1.3.3", + "use-sidecar": "^1.1.3" }, "engines": { - "node": ">=18" + "node": ">=10" }, "peerDependencies": { - "@types/react": "^19.0.0", - "react": "*", - "react-native": "*" + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "peerDependenciesMeta": { "@types/react": { @@ -9192,77 +9495,48 @@ } } }, - "node_modules/react-native/node_modules/brace-expansion": { - "version": "1.1.12", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", - "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.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==", + "node_modules/react-remove-scroll-bar": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/react-remove-scroll-bar/-/react-remove-scroll-bar-2.3.8.tgz", + "integrity": "sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q==", "license": "MIT", - "engines": { - "node": ">=18" - } - }, - "node_modules/react-native/node_modules/glob": { - "version": "7.2.3", - "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", - "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", - "deprecated": "Glob versions prior to v9 are no longer supported", - "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" + "react-style-singleton": "^2.2.2", + "tslib": "^2.0.0" }, "engines": { - "node": "*" + "node": ">=10" }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/react-native/node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", - "license": "ISC", - "dependencies": { - "brace-expansion": "^1.1.7" + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" }, - "engines": { - "node": "*" + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } } }, - "node_modules/react-native/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" + "node_modules/react-style-singleton": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/react-style-singleton/-/react-style-singleton-2.2.3.tgz", + "integrity": "sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ==", + "license": "MIT", + "dependencies": { + "get-nonce": "^1.0.0", + "tslib": "^2.0.0" }, "engines": { "node": ">=10" - } - }, - "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" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } } }, "node_modules/readable-stream": { @@ -9287,9 +9561,9 @@ "license": "MIT" }, "node_modules/regenerate-unicode-properties": { - "version": "10.2.0", - "resolved": "https://registry.npmjs.org/regenerate-unicode-properties/-/regenerate-unicode-properties-10.2.0.tgz", - "integrity": "sha512-DqHn3DwbmmPVzeKj9woBadqmXxLvQoQIwu7nopMc72ztvxVmVk2SBhSnx67zuye5TP+lJsb/TBQsjLKhnDf3MA==", + "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" @@ -9305,17 +9579,17 @@ "license": "MIT" }, "node_modules/regexpu-core": { - "version": "6.2.0", - "resolved": "https://registry.npmjs.org/regexpu-core/-/regexpu-core-6.2.0.tgz", - "integrity": "sha512-H66BPQMrv+V16t8xtmq+UC0CBpiTBA60V8ibS1QVReIp8T1z8hwFxqcGzm9K6lgsN7sB5edVH8a+ze6Fqm4weA==", + "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.0", + "regenerate-unicode-properties": "^10.2.2", "regjsgen": "^0.8.0", - "regjsparser": "^0.12.0", + "regjsparser": "^0.13.0", "unicode-match-property-ecmascript": "^2.0.0", - "unicode-match-property-value-ecmascript": "^2.1.0" + "unicode-match-property-value-ecmascript": "^2.2.1" }, "engines": { "node": ">=4" @@ -9328,29 +9602,17 @@ "license": "MIT" }, "node_modules/regjsparser": { - "version": "0.12.0", - "resolved": "https://registry.npmjs.org/regjsparser/-/regjsparser-0.12.0.tgz", - "integrity": "sha512-cnE+y8bz4NhMjISKbgeVJtqNbtf5QpjZP+Bslo+UqkIt9QPnX9q095eiRRASJG1/tz6dlNr6Z5NsBiWYokp6EQ==", + "version": "0.13.1", + "resolved": "https://registry.npmjs.org/regjsparser/-/regjsparser-0.13.1.tgz", + "integrity": "sha512-dLsljMd9sqwRkby8zhO1gSg3PnJIBFid8f4CQj/sXx+7cKx+E7u0PKhZ+U4wmhx7EfmtvnA318oVaIkAB1lRJw==", "license": "BSD-2-Clause", "dependencies": { - "jsesc": "~3.0.2" + "jsesc": "~3.1.0" }, "bin": { "regjsparser": "bin/parser" } }, - "node_modules/regjsparser/node_modules/jsesc": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.0.2.tgz", - "integrity": "sha512-xKqzzWXDttJuOcawBt4KnKHHIf5oQ/Cxax+0PWFG+DFDgHNAdi+TXECADI+RYiFUMmx8792xsMbbgXj4CwnP4g==", - "license": "MIT", - "bin": { - "jsesc": "bin/jsesc" - }, - "engines": { - "node": ">=6" - } - }, "node_modules/require-directory": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", @@ -9360,15 +9622,6 @@ "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/require-main-filename": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz", @@ -9376,35 +9629,14 @@ "devOptional": true, "license": "ISC" }, - "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.10", - "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.10.tgz", - "integrity": "sha512-NPRy+/ncIMeDlTAsuqwKIiferiawhefFJtkNSW0qZJEqMEb+qBt/77B/jGeeek+F0uOeN05CDa6HXbbIgtVX4w==", + "version": "1.22.12", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz", + "integrity": "sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==", "license": "MIT", "dependencies": { - "is-core-module": "^2.16.0", + "es-errors": "^1.3.0", + "is-core-module": "^2.16.1", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" }, @@ -9428,20 +9660,11 @@ } }, "node_modules/resolve-workspace-root": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/resolve-workspace-root/-/resolve-workspace-root-2.0.0.tgz", - "integrity": "sha512-IsaBUZETJD5WsI11Wt8PKHwaIe45or6pwNc8yflvLJ4DWtImK9kuLoH5kUva/2Mmx/RdIyr4aONNSa2v9LTJsw==", + "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/restore-cursor": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-3.1.0.tgz", @@ -9483,10 +9706,16 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/rimraf/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/rimraf/node_modules/brace-expansion": { - "version": "1.1.12", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", - "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "version": "1.1.14", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz", + "integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==", "license": "MIT", "dependencies": { "balanced-match": "^1.0.0", @@ -9497,7 +9726,7 @@ "version": "7.2.3", "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", - "deprecated": "Glob versions prior to v9 are no longer supported", + "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", @@ -9515,9 +9744,9 @@ } }, "node_modules/rimraf/node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "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" @@ -9578,36 +9807,20 @@ "license": "MIT" }, "node_modules/sax": { - "version": "1.4.1", - "resolved": "https://registry.npmjs.org/sax/-/sax-1.4.1.tgz", - "integrity": "sha512-+aWOz7yVScEGoKNd4PA10LZ8sk0A/z5+nXQG5giUO5rprX9jgYsTdov9qCchZiPIZezbZH+jRut8nPodFAX4Jg==", - "license": "ISC" + "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.25.0", - "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.25.0.tgz", - "integrity": "sha512-xFVuu11jh+xcO7JOAGJNOXld8/TcEHK/4CituBUeUb5hqxJLj9YuemAEuvm9gQ/+pgXYfbQuqAkiYu+u7YEsNA==", + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", "license": "MIT" }, - "node_modules/schema-utils": { - "version": "4.3.3", - "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-4.3.3.tgz", - "integrity": "sha512-eflK8wEtyOE6+hsaRVPxvUKYCpRgzLqDTb8krvAsRIwOGlHoSgYLgBXoubGgLd2fT41/OUYdb48v4k4WWHQurA==", - "license": "MIT", - "dependencies": { - "@types/json-schema": "^7.0.9", - "ajv": "^8.9.0", - "ajv-formats": "^2.1.1", - "ajv-keywords": "^5.1.0" - }, - "engines": { - "node": ">= 10.13.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/webpack" - } - }, "node_modules/semver": { "version": "6.3.1", "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", @@ -9618,9 +9831,9 @@ } }, "node_modules/send": { - "version": "0.19.1", - "resolved": "https://registry.npmjs.org/send/-/send-0.19.1.tgz", - "integrity": "sha512-p4rRk4f23ynFEfcD9LA0xRYngj+IyGiEYyqqOak8kaN0TvNmuxC2dcVeBn62GpCeR2CpWqyHCNScTP91QbAVFg==", + "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", @@ -9629,88 +9842,19 @@ "encodeurl": "~2.0.0", "escape-html": "~1.0.3", "etag": "~1.8.1", - "fresh": "0.5.2", - "http-errors": "2.0.0", - "mime": "1.6.0", - "ms": "2.1.3", - "on-finished": "2.4.1", - "range-parser": "~1.2.1", - "statuses": "2.0.1" - }, - "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/statuses": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz", - "integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==", - "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.2", - "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.2.tgz", - "integrity": "sha512-VqpjJZKadQB/PEbEwvFdO43Ax5dFBZ2UECszz8bQ7pi7wt//PWe1P6MN7eCnjsatYtBT6EuiClbjSWP2WrIoTw==", - "license": "MIT", - "dependencies": { - "encodeurl": "~2.0.0", - "escape-html": "~1.0.3", - "parseurl": "~1.3.3", - "send": "0.19.0" + "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/serve-static/node_modules/debug": { + "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==", @@ -9719,13 +9863,13 @@ "ms": "2.0.0" } }, - "node_modules/serve-static/node_modules/debug/node_modules/ms": { + "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/serve-static/node_modules/encodeurl": { + "node_modules/send/node_modules/encodeurl": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", @@ -9734,7 +9878,7 @@ "node": ">= 0.8" } }, - "node_modules/serve-static/node_modules/mime": { + "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==", @@ -9746,43 +9890,43 @@ "node": ">=4" } }, - "node_modules/serve-static/node_modules/send": { - "version": "0.19.0", - "resolved": "https://registry.npmjs.org/send/-/send-0.19.0.tgz", - "integrity": "sha512-dW41u5VfLXu8SJh5bwRmyYUbAoSB3c9uQh6L8h/KtsFREPWpbX1lrljJo186Jc4nmci/sGUZ9a0a0J2zgfq2hw==", + "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", - "dependencies": { - "debug": "2.6.9", - "depd": "2.0.0", - "destroy": "1.2.0", - "encodeurl": "~1.0.2", - "escape-html": "~1.0.3", - "etag": "~1.8.1", - "fresh": "0.5.2", - "http-errors": "2.0.0", - "mime": "1.6.0", - "ms": "2.1.3", - "on-finished": "2.4.1", - "range-parser": "~1.2.1", - "statuses": "2.0.1" - }, "engines": { - "node": ">= 0.8.0" + "node": ">= 0.8" } }, - "node_modules/serve-static/node_modules/send/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==", + "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.8" + "node": ">=0.10.0" } }, - "node_modules/serve-static/node_modules/statuses": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz", - "integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==", + "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" @@ -9882,14 +10026,14 @@ } }, "node_modules/side-channel-list": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", - "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", "devOptional": true, "license": "MIT", "dependencies": { "es-errors": "^1.3.0", - "object-inspect": "^1.13.3" + "object-inspect": "^1.13.4" }, "engines": { "node": ">= 0.4" @@ -9954,18 +10098,6 @@ "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", @@ -10042,9 +10174,9 @@ "license": "MIT" }, "node_modules/slugify": { - "version": "1.6.6", - "resolved": "https://registry.npmjs.org/slugify/-/slugify-1.6.6.tgz", - "integrity": "sha512-h+z7HKHYXj6wJU+AnS/+IH8Uh9fdcX1Lrhg1/VMdf9PwoBQXFcXiAdsy2tSK0P6gKwJLXp02r90ahUCqHk9rrw==", + "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" @@ -10168,6 +10300,13 @@ "node": ">=4" } }, + "node_modules/strict-url-sanitise": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/strict-url-sanitise/-/strict-url-sanitise-0.0.1.tgz", + "integrity": "sha512-nuFtF539K8jZg3FjaWH/L8eocCR6gegz5RDOsaWxfdbF5Jqr2VXWxZayjTwUzsWJDC91k2EbnJXp6FuWW+Z4hg==", + "devOptional": true, + "license": "MIT" + }, "node_modules/string_decoder": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", @@ -10179,24 +10318,6 @@ } }, "node_modules/string-width": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", - "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", - "license": "MIT", - "dependencies": { - "eastasianwidth": "^0.2.0", - "emoji-regex": "^9.2.2", - "strip-ansi": "^7.0.1" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/string-width-cjs": { - "name": "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==", @@ -10210,13 +10331,7 @@ "node": ">=8" } }, - "node_modules/string-width-cjs/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/string-width-cjs/node_modules/is-fullwidth-code-point": { + "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==", @@ -10225,7 +10340,7 @@ "node": ">=8" } }, - "node_modules/string-width-cjs/node_modules/strip-ansi": { + "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==", @@ -10238,43 +10353,24 @@ } }, "node_modules/strip-ansi": { - "version": "7.1.2", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.2.tgz", - "integrity": "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==", - "license": "MIT", - "dependencies": { - "ansi-regex": "^6.0.1" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/strip-ansi?sponsor=1" - } - }, - "node_modules/strip-ansi-cjs": { - "name": "strip-ansi", - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "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": "^5.0.1" + "ansi-regex": "^4.1.0" }, "engines": { - "node": ">=8" + "node": ">=6" } }, "node_modules/strip-ansi/node_modules/ansi-regex": { - "version": "6.2.2", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", - "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "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": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-regex?sponsor=1" + "node": ">=6" } }, "node_modules/strip-final-newline": { @@ -10287,19 +10383,10 @@ "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/strnum": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/strnum/-/strnum-1.1.2.tgz", - "integrity": "sha512-vrN+B7DBIoTTZjnPNewwhx6cBA/H+IS7rfW68n7XxC1y7uoiGQBxaKzqucGUgavX15dJgiGztLJ8vxuEzwqBdA==", + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/strnum/-/strnum-2.2.3.tgz", + "integrity": "sha512-oKx6RUCuHfT3oyVjtnrmn19H1SiCqgJSg+54XqURKp5aCMbrXrhLjRN9TjuwMjiYstZ0MzDrHqkGZ5dFTKd+zg==", "devOptional": true, "funding": [ { @@ -10315,37 +10402,6 @@ "integrity": "sha512-0MP/Cxx5SzeeZ10p/bZI0S6MpgD+yxAhi1BOQ34jgnMXsCq3j1t6tQnZu+KdlL7dvJTLT3g9xN8tl10TqgFMcg==", "license": "MIT" }, - "node_modules/sucrase": { - "version": "3.35.0", - "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.0.tgz", - "integrity": "sha512-8EbVDiu9iN/nESwxeSxDKe0dunta1GOlHufmSSXxMD2z2/tMZpDMpvXQGsc+ajGo8y2uYUmixaSRUc/QPoQ0GA==", - "license": "MIT", - "dependencies": { - "@jridgewell/gen-mapping": "^0.3.2", - "commander": "^4.0.0", - "glob": "^10.3.10", - "lines-and-columns": "^1.1.6", - "mz": "^2.7.0", - "pirates": "^4.0.1", - "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", @@ -10383,54 +10439,16 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/tar": { - "version": "7.4.3", - "resolved": "https://registry.npmjs.org/tar/-/tar-7.4.3.tgz", - "integrity": "sha512-5S7Va8hKfV7W5U6g3aYxXmlPoZVAwUMy9AOKyF2fVuZa2UD3qZjg578OrLRt8PcNN1PleVaL/5/yYATNL0ICUw==", - "license": "ISC", - "dependencies": { - "@isaacs/fs-minipass": "^4.0.0", - "chownr": "^3.0.0", - "minipass": "^7.1.2", - "minizlib": "^3.0.1", - "mkdirp": "^3.0.1", - "yallist": "^5.0.0" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/tar/node_modules/mkdirp": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-3.0.1.tgz", - "integrity": "sha512-+NsyUUAZDmo6YVHzL/stxSu3t9YS1iljliy3BSDrXJ/dkn1KYdmtZODGGjLcc9XLgVVpH4KshHB8XmZgMhaBXg==", + "node_modules/tagged-tag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/tagged-tag/-/tagged-tag-1.0.0.tgz", + "integrity": "sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng==", "license": "MIT", - "bin": { - "mkdirp": "dist/cjs/src/bin.js" - }, "engines": { - "node": ">=10" + "node": ">=20" }, "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "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/temp-dir": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/temp-dir/-/temp-dir-2.0.0.tgz", - "integrity": "sha512-aoBAniQmmwtcKp/7BzsH8Cxzv8OL736p7v1ihGb5e9DJ9kTwGWHrQrVB5+lfVDzfGrdRzXch+ig7LHaY1JTOrg==", - "license": "MIT", - "engines": { - "node": ">=8" + "url": "https://github.com/sponsors/sindresorhus" } }, "node_modules/terminal-link": { @@ -10450,9 +10468,9 @@ } }, "node_modules/terser": { - "version": "5.44.0", - "resolved": "https://registry.npmjs.org/terser/-/terser-5.44.0.tgz", - "integrity": "sha512-nIVck8DK+GM/0Frwd+nIhZ84pR/BX7rmXMfYwyg+Sri5oGVE99/E3KvXqpC2xHFxyqXyGHTKBSioxxplrO4I4w==", + "version": "5.46.1", + "resolved": "https://registry.npmjs.org/terser/-/terser-5.46.1.tgz", + "integrity": "sha512-vzCjQO/rgUuK9sf8VJZvjqiqiHFaZLnOiimmUuOKODxWL8mm/xua7viT7aqX7dgPY60otQjUotzFMmCB4VdmqQ==", "license": "BSD-2-Clause", "dependencies": { "@jridgewell/source-map": "^0.3.3", @@ -10487,10 +10505,16 @@ "node": ">=8" } }, + "node_modules/test-exclude/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/test-exclude/node_modules/brace-expansion": { - "version": "1.1.12", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", - "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "version": "1.1.14", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz", + "integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==", "license": "MIT", "dependencies": { "balanced-match": "^1.0.0", @@ -10501,7 +10525,7 @@ "version": "7.2.3", "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", - "deprecated": "Glob versions prior to v9 are no longer supported", + "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", @@ -10519,9 +10543,9 @@ } }, "node_modules/test-exclude/node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "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" @@ -10530,61 +10554,12 @@ "node": "*" } }, - "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/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.15", - "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", - "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", - "license": "MIT", - "dependencies": { - "fdir": "^6.5.0", - "picomatch": "^4.0.3" - }, - "engines": { - "node": ">=12.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/SuperchupuDev" - } - }, - "node_modules/tinyglobby/node_modules/picomatch": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", - "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, "node_modules/tmpl": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/tmpl/-/tmpl-1.0.5.tgz", @@ -10612,11 +10587,17 @@ "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/toqr": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/toqr/-/toqr-0.1.1.tgz", + "integrity": "sha512-FWAPzCIHZHnrE/5/w9MPk0kK25hSQSH2IKhYh9PyjS3SG/+IEMvlwIHbhz+oF7xl54I+ueZlVnMjyzdSwLmAwA==", + "license": "MIT" + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" }, "node_modules/type-detect": { "version": "4.0.8", @@ -10637,24 +10618,42 @@ } }, "node_modules/type-is": { - "version": "1.6.18", - "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", - "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.1.tgz", + "integrity": "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==", "devOptional": true, "license": "MIT", "dependencies": { - "media-typer": "0.3.0", - "mime-types": "~2.1.24" + "content-type": "^1.0.5", + "media-typer": "^1.1.0", + "mime-types": "^3.0.0" }, "engines": { "node": ">= 0.6" } }, + "node_modules/type-is/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==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/typescript": { - "version": "5.8.3", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz", - "integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==", - "dev": true, + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "devOptional": true, "license": "Apache-2.0", "peer": true, "bin": { @@ -10665,19 +10664,10 @@ "node": ">=14.17" } }, - "node_modules/undici": { - "version": "6.21.3", - "resolved": "https://registry.npmjs.org/undici/-/undici-6.21.3.tgz", - "integrity": "sha512-gBLkYIlEnSp8pFbT64yFgGE6UIB9tAkhukC23PmMDCe5Nd+cRqKxSjw5y54MK2AZMgZfJWMaNE4nYUHgi1XEOw==", - "license": "MIT", - "engines": { - "node": ">=18.17" - } - }, "node_modules/undici-types": { - "version": "7.10.0", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.10.0.tgz", - "integrity": "sha512-t5Fy/nfn+14LuOc2KNYg75vZqClpAiqscVvMygNnlsHBFpSXdJaYtXMcdNLpl/Qvc3P2cB3s6lOV51nqsFq4ag==", + "version": "7.19.2", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.19.2.tgz", + "integrity": "sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg==", "license": "MIT" }, "node_modules/unicode-canonical-property-names-ecmascript": { @@ -10703,35 +10693,23 @@ } }, "node_modules/unicode-match-property-value-ecmascript": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/unicode-match-property-value-ecmascript/-/unicode-match-property-value-ecmascript-2.2.0.tgz", - "integrity": "sha512-4IehN3V/+kkr5YeSSDDQG8QLqO26XpL2XP3GQtqwlT/QYSECAwFztxVHjlbh0+gjJ3XmNLS0zDsbgs9jWKExLg==", + "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.1.0", - "resolved": "https://registry.npmjs.org/unicode-property-aliases-ecmascript/-/unicode-property-aliases-ecmascript-2.1.0.tgz", - "integrity": "sha512-6t3foTQI9qne+OZoVQB/8x8rk2k1eVy1gRXhV3oFQ5T6R1dqQ1xtin3XqSlx3+ATBkliTaR/hHyJBm+LVPNM8w==", + "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/unique-string": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/unique-string/-/unique-string-2.0.0.tgz", - "integrity": "sha512-uNaeirEPvpZWSgzwsPGtU2zVSTrn/8L5q/IexZmH0eH6SA73CmAA5U4GwORTxQAZs95TAXLNqeLoPPNO5gZfWg==", - "license": "MIT", - "dependencies": { - "crypto-random-string": "^2.0.0" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/universalify": { "version": "0.1.2", "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.1.2.tgz", @@ -10752,9 +10730,9 @@ } }, "node_modules/update-browserslist-db": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.3.tgz", - "integrity": "sha512-UxhIZQ+QInVdunkDAaiazvvT/+fXL5Osr0JZlJulepYu6Jd7qJtDZjlur0emRlT71EN3ScPoE7gvsuIKKNavKw==", + "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", @@ -10781,13 +10759,25 @@ "browserslist": ">= 4.21.0" } }, - "node_modules/uri-js": { - "version": "4.4.1", - "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", - "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", - "license": "BSD-2-Clause", + "node_modules/use-callback-ref": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/use-callback-ref/-/use-callback-ref-1.3.3.tgz", + "integrity": "sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg==", + "license": "MIT", "dependencies": { - "punycode": "^2.1.0" + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } } }, "node_modules/use-latest-callback": { @@ -10796,7 +10786,29 @@ "integrity": "sha512-FvRG9i1HSo0wagmX63Vrm8SnlUU3LMM3WyZkQ76RnslpBrX694AdG4A0zQBx2B3ZifFA0yv/BaEHGBnEax5rZg==", "license": "MIT", "peerDependencies": { - "react": ">=16.8" + "react": ">=16.8" + } + }, + "node_modules/use-sidecar": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/use-sidecar/-/use-sidecar-1.1.3.tgz", + "integrity": "sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ==", + "license": "MIT", + "dependencies": { + "detect-node-es": "^1.1.0", + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } } }, "node_modules/use-sync-external-store": { @@ -10851,6 +10863,19 @@ "node": ">= 0.8" } }, + "node_modules/vaul": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vaul/-/vaul-1.1.2.tgz", + "integrity": "sha512-ZFkClGpWyI2WUQjdLJ/BaGuV6AVQiJ3uELGk3OYtP+B6yCO7Cmn9vPFXVJkRaGkOJu3m8bQMgtyzNHixULceQA==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-dialog": "^1.1.1" + }, + "peerDependencies": { + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc" + } + }, "node_modules/vlq": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/vlq/-/vlq-1.0.1.tgz", @@ -10896,28 +10921,11 @@ "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/whatwg-url-minimum": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/whatwg-url-minimum/-/whatwg-url-minimum-0.1.1.tgz", + "integrity": "sha512-u2FNVjFVFZhdjb502KzXy1gKn1mEisQRJssmSJT8CPhZdZa0AP6VCbWlXERKyGu0l09t0k50FiDiralpGhBxgA==", + "license": "MIT" }, "node_modules/which": { "version": "2.0.2", @@ -10941,113 +10949,17 @@ "devOptional": true, "license": "ISC" }, - "node_modules/wonka": { - "version": "6.3.5", - "resolved": "https://registry.npmjs.org/wonka/-/wonka-6.3.5.tgz", - "integrity": "sha512-SSil+ecw6B4/Dm7Pf2sAshKQ5hWFvfyGlfPbEd6A14dOH6VDjrmbY86u6nZvy9omGwwIPFR8V41+of1EezgoUw==", - "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-cjs": { - "name": "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==", + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz", + "integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==", + "devOptional": true, "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-cjs/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/wrap-ansi-cjs/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/wrap-ansi-cjs/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/wrap-ansi-cjs/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/wrap-ansi/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/wrap-ansi/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/wrap-ansi/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" } @@ -11056,6 +10968,7 @@ "version": "6.0.1", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "devOptional": true, "license": "MIT", "dependencies": { "ansi-regex": "^5.0.1" @@ -11087,6 +11000,7 @@ "version": "6.2.3", "resolved": "https://registry.npmjs.org/ws/-/ws-6.2.3.tgz", "integrity": "sha512-jmTjYU0j60B+vHey6TfR3Z7RD61z/hmxBS3VMSGIrroOWXQEneK1zNuotOUrGyBHQj0yrpsLHPWtigEFd13ndA==", + "devOptional": true, "license": "MIT", "dependencies": { "async-limiter": "~1.0.0" @@ -11137,13 +11051,11 @@ } }, "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" - } + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz", + "integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==", + "devOptional": true, + "license": "ISC" }, "node_modules/yallist": { "version": "3.1.1", @@ -11152,81 +11064,118 @@ "license": "ISC" }, "node_modules/yaml": { - "version": "2.8.1", - "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.1.tgz", - "integrity": "sha512-lcYcMxX2PO9XMGvAJkJ3OsNMw+/7FKes7/hgerGUYWIoWu5j/+YQqcZr5JnPZWzOsEBgMbSbiSTn/dv/69Mkpw==", - "devOptional": true, + "version": "2.8.3", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.3.tgz", + "integrity": "sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg==", "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==", + "version": "15.4.1", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz", + "integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==", + "devOptional": true, "license": "MIT", "dependencies": { - "cliui": "^8.0.1", - "escalade": "^3.1.1", - "get-caller-file": "^2.0.5", + "cliui": "^6.0.0", + "decamelize": "^1.2.0", + "find-up": "^4.1.0", + "get-caller-file": "^2.0.1", "require-directory": "^2.1.1", - "string-width": "^4.2.3", - "y18n": "^5.0.5", - "yargs-parser": "^21.1.1" + "require-main-filename": "^2.0.0", + "set-blocking": "^2.0.0", + "string-width": "^4.2.0", + "which-module": "^2.0.0", + "y18n": "^4.0.0", + "yargs-parser": "^18.1.2" }, "engines": { - "node": ">=12" + "node": ">=8" } }, "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==", + "version": "18.1.3", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz", + "integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==", + "devOptional": true, "license": "ISC", + "dependencies": { + "camelcase": "^5.0.0", + "decamelize": "^1.2.0" + }, "engines": { - "node": ">=12" + "node": ">=6" } }, - "node_modules/yargs/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/yargs-parser/node_modules/camelcase": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz", + "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">=6" + } }, - "node_modules/yargs/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==", + "node_modules/yargs/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==", + "devOptional": true, "license": "MIT", + "dependencies": { + "locate-path": "^5.0.0", + "path-exists": "^4.0.0" + }, "engines": { "node": ">=8" } }, - "node_modules/yargs/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==", + "node_modules/yargs/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==", + "devOptional": true, "license": "MIT", "dependencies": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.1" + "p-locate": "^4.1.0" }, "engines": { "node": ">=8" } }, - "node_modules/yargs/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==", + "node_modules/yargs/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==", + "devOptional": true, "license": "MIT", "dependencies": { - "ansi-regex": "^5.0.1" + "p-try": "^2.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/yargs/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==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "p-limit": "^2.2.0" }, "engines": { "node": ">=8" @@ -11236,6 +11185,7 @@ "version": "0.1.0", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "devOptional": true, "license": "MIT", "engines": { "node": ">=10" @@ -11243,6 +11193,43 @@ "funding": { "url": "https://github.com/sponsors/sindresorhus" } + }, + "node_modules/zod": { + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/zxing-wasm": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/zxing-wasm/-/zxing-wasm-3.0.2.tgz", + "integrity": "sha512-2YMAriaYHX9wrBY2k7H0epSo+dyCaCZg/vOtt+nEDXM9ul480gkXz/9SkwpOeHcD2H5qqDG8lWDSBwpTcZpa6w==", + "license": "MIT", + "dependencies": { + "@types/emscripten": "^1.41.5", + "type-fest": "^5.5.0" + }, + "peerDependencies": { + "@types/emscripten": ">=1.39.6" + } + }, + "node_modules/zxing-wasm/node_modules/type-fest": { + "version": "5.6.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-5.6.0.tgz", + "integrity": "sha512-8ZiHFm91orbSAe2PSAiSVBVko18pbhbiB3U9GglSzF/zCGkR+rxpHx6sEMCUm4kxY4LjDIUGgCfUMtwfZfjfUA==", + "license": "(MIT OR CC0-1.0)", + "dependencies": { + "tagged-tag": "^1.0.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } } } } diff --git a/app/package.json b/app/package.json index dc260c19..3dc9015d 100644 --- a/app/package.json +++ b/app/package.json @@ -11,34 +11,43 @@ "dependencies": { "@notifee/react-native": "^9.1.8", "@react-native-async-storage/async-storage": "^2.1.2", - "@react-native-community/netinfo": "^11.4.1", + "@react-native-community/netinfo": "11.5.2", "@react-native/virtualized-lists": "^0.80.2", - "@siteed/expo-audio-studio": "^2.18.1", + "@siteed/expo-audio-studio": "^2.18.6", "deprecated-react-native-prop-types": "^5.0.0", - "expo": "~53.0.9", - "expo-router": "~5.0.6", - "expo-build-properties": "~0.14.8", - "expo-dev-client": "~5.2.4", - "expo-status-bar": "~2.2.3", + "expo": "~55.0.15", + "expo-asset": "~55.0.17", + "expo-audio": "~55.0.14", + "expo-build-properties": "~55.0.13", + "expo-camera": "~55.0.15", + "expo-constants": "~55.0.14", + "expo-dev-client": "~55.0.27", + "expo-file-system": "~55.0.16", + "expo-image-picker": "~55.0.18", + "expo-linking": "~55.0.13", + "expo-modules-core": "~55.0.22", + "expo-router": "~55.0.12", + "expo-secure-store": "~55.0.14", + "expo-status-bar": "~55.0.5", + "expo-updates": "~55.0.20", "friend-lite-react-native": "^1.0.2", "install": "^0.13.0", "promise": "^8.3.0", - "react": "19.0.0", - "react-native": "0.79.6", + "react": "^19.2.5", + "react-dom": "^19.2.5", + "react-native": "0.83.4", "react-native-base64": "^0.2.1", "react-native-ble-plx": "^3.5.0", + "react-native-safe-area-context": "~5.6.2", + "react-native-screens": "~4.23.0", "setimmediate": "^1.0.5", - "webidl-conversions": "^7.0.0", - "react-native-screens": "~4.11.1", - "react-native-safe-area-context": "5.4.0", - "expo-camera": "~16.1.11", - "expo-image-picker": "~16.1.4" + "webidl-conversions": "^7.0.0" }, "devDependencies": { "@babel/core": "^7.20.0", "@react-native-community/cli": "latest", - "@types/react": "~19.0.10", - "typescript": "~5.8.3" + "@types/react": "~19.2.0", + "typescript": "~5.9.2" }, "private": true } diff --git a/app/src/components/AuthSection.tsx b/app/src/components/AuthSection.tsx index 91c583fb..52643691 100644 --- a/app/src/components/AuthSection.tsx +++ b/app/src/components/AuthSection.tsx @@ -1,6 +1,7 @@ import React, { useState, useEffect } from 'react'; import { View, Text, TextInput, TouchableOpacity, StyleSheet, Alert, ActivityIndicator } from 'react-native'; -import { saveAuthEmail, saveAuthPassword, saveJwtToken, getAuthEmail, getAuthPassword, clearAuthData } from '../utils/storage'; +import { getAuthEmail, getAuthPassword } from '../utils/storage'; +import { login, logout, forgetAccount } from '../services/auth'; import { useTheme, ThemeColors } from '../theme'; interface AuthSectionProps { @@ -44,30 +45,8 @@ export const AuthSection: React.FC = ({ setIsLoggingIn(true); try { - const baseUrl = backendUrl.replace('ws://', 'http://').replace('wss://', 'https://').split('/ws')[0]; - const loginUrl = `${baseUrl}/auth/jwt/login`; - const formData = new URLSearchParams(); - formData.append('username', email.trim()); - formData.append('password', password.trim()); - - const response = await fetch(loginUrl, { - method: 'POST', - headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, - body: formData.toString(), - }); - - if (!response.ok) { - const errorText = await response.text(); - throw new Error(`Login failed: ${response.status} ${response.statusText} - ${errorText}`); - } - - const authData = await response.json(); - const jwtToken = authData.access_token; - if (!jwtToken) throw new Error('No access token received from server'); - - await saveAuthEmail(email.trim()); - await saveAuthPassword(password.trim()); - await saveJwtToken(jwtToken); + // login() persists email + password (SecureStore) + token centrally. + const jwtToken = await login(email.trim(), password.trim(), backendUrl); onAuthStatusChange(true, email.trim(), jwtToken); } catch (error) { Alert.alert('Login Failed', error instanceof Error ? error.message : 'An unknown error occurred during login.'); @@ -76,17 +55,42 @@ export const AuthSection: React.FC = ({ } }; + // Log out: clear the token only. Email + password are kept (in state and in + // storage) so re-login is one tap and silent refresh keeps working. const handleLogout = async () => { try { - await clearAuthData(); - setEmail(''); - setPassword(''); - onAuthStatusChange(false, null, null); + await logout(); + onAuthStatusChange(false, email || null, null); } catch (error) { - Alert.alert('Logout Error', 'Failed to clear authentication data.'); + Alert.alert('Logout Error', 'Failed to log out.'); } }; + // Forget account: wipe email + password + token entirely. + const handleForgetAccount = () => { + Alert.alert( + 'Forget Account', + 'This clears your saved email and password from this device. You will need to enter them again to log in.', + [ + { text: 'Cancel', style: 'cancel' }, + { + text: 'Forget', + style: 'destructive', + onPress: async () => { + try { + await forgetAccount(); + setEmail(''); + setPassword(''); + onAuthStatusChange(false, null, null); + } catch (error) { + Alert.alert('Error', 'Failed to clear account data.'); + } + }, + }, + ] + ); + }; + if (isAuthenticated && currentUserEmail) { return ( @@ -101,6 +105,9 @@ export const AuthSection: React.FC = ({ Logout + + Forget account + ); } @@ -241,6 +248,13 @@ const createStyles = (colors: ThemeColors) => StyleSheet.create({ flex: 1, marginRight: 10, }, + forgetText: { + fontSize: 12, + color: colors.textTertiary, + marginTop: 12, + textAlign: 'center', + textDecorationLine: 'underline', + }, }); export default AuthSection; diff --git a/app/src/components/BackendStatus.tsx b/app/src/components/BackendStatus.tsx index e0eec8d5..2aa0c0ad 100644 --- a/app/src/components/BackendStatus.tsx +++ b/app/src/components/BackendStatus.tsx @@ -1,8 +1,11 @@ import React, { useState, useEffect } from 'react'; -import { View, Text, TextInput, TouchableOpacity, StyleSheet, Alert, ActivityIndicator } from 'react-native'; +import { View, Text, TextInput, TouchableOpacity, StyleSheet, Alert, ActivityIndicator, Linking } from 'react-native'; +import NetInfo from '@react-native-community/netinfo'; import { useTheme, ThemeColors } from '../theme'; import { QRScanner } from './QRScanner'; import { httpUrlToWebSocketUrl } from '../utils/urlConversion'; +import { saveServiceManagerUrl, saveServiceManagerToken } from '../utils/storage'; +import { isServiceManagerReachable } from '../services/serviceManager'; interface BackendStatusProps { backendUrl: string; @@ -10,12 +13,59 @@ interface BackendStatusProps { jwtToken: string | null; } +// Connection Doctor classifications, from a layered probe ladder. +type HealthState = + | 'unknown' + | 'checking' + | 'healthy' + | 'auth_required' + | 'not_configured' // no real backend set yet (fresh install / localhost) + | 'offline' // device has no network at all + | 'backend_down' // host reachable (SM answers) but the backend isn't + | 'unreachable' // couldn't reach the host at all + | 'unhealthy'; // backend host reachable but returned an error + interface HealthStatus { - status: 'unknown' | 'checking' | 'healthy' | 'unhealthy' | 'auth_required'; + status: HealthState; message: string; + detail?: string; // actionable next step lastChecked?: Date; } +// A fresh install points at localhost, which on a phone is the phone itself and +// can never be a real backend — treat that (and empty) as "not configured". +const isNotConfigured = (url: string): boolean => { + const trimmed = (url || '').trim(); + if (!trimmed) return true; + try { + const base = trimmed + .replace('ws://', 'http://') + .replace('wss://', 'https://') + .split('/ws')[0]; + const host = new URL(base).hostname; + return host === 'localhost' || host === '127.0.0.1' || host === '::1'; + } catch { + return false; + } +}; + +// A tailnet host: Tailscale CGNAT range (100.64.0.0/10) or a *.ts.net MagicDNS name. +const isTailnetHost = (baseUrl: string): boolean => { + try { + const host = new URL(baseUrl).hostname; + if (host.endsWith('.ts.net')) return true; + const m = host.match(/^(\d+)\.(\d+)\.\d+\.\d+$/); + if (m) { + const a = Number(m[1]); + const b = Number(m[2]); + return a === 100 && b >= 64 && b <= 127; + } + return false; + } catch { + return false; + } +}; + export const BackendStatus: React.FC = ({ backendUrl, onBackendUrlChange, @@ -30,23 +80,45 @@ export const BackendStatus: React.FC = ({ }); const [showQRScanner, setShowQRScanner] = useState(false); + // Connection Doctor: a layered probe ladder that classifies *why* a connection + // failed and what to do next, instead of a single fetch that collapses every + // failure into "Network request failed". const checkBackendHealth = async (showAlert: boolean = false) => { - if (!backendUrl.trim()) { - setHealthStatus({ status: 'unhealthy', message: 'Backend URL not set' }); + // Fresh install / localhost: not an error, just not set up yet. + if (isNotConfigured(backendUrl)) { + setHealthStatus({ + status: 'not_configured', + message: 'Not connected yet', + detail: 'Make sure Tailscale is connected, then scan the QR code from your Chronicle dashboard (System page).', + }); + if (showAlert) { + Alert.alert('Not Connected', 'Scan the QR code from your Chronicle dashboard to connect this app.'); + } return; } setHealthStatus({ status: 'checking', message: 'Checking connection...' }); - try { - let baseUrl = backendUrl.trim(); - if (baseUrl.startsWith('ws://')) baseUrl = baseUrl.replace('ws://', 'http://'); - else if (baseUrl.startsWith('wss://')) baseUrl = baseUrl.replace('wss://', 'https://'); - baseUrl = baseUrl.split('/ws')[0]; + let baseUrl = backendUrl.trim(); + if (baseUrl.startsWith('ws://')) baseUrl = baseUrl.replace('ws://', 'http://'); + else if (baseUrl.startsWith('wss://')) baseUrl = baseUrl.replace('wss://', 'https://'); + baseUrl = baseUrl.split('/ws')[0]; + + // Rung 1: is the device online at all? + const netState = await NetInfo.fetch(); + if (!netState.isConnected) { + setHealthStatus({ status: 'offline', message: "You're offline", detail: 'Check Wi-Fi or cellular data.', lastChecked: new Date() }); + if (showAlert) Alert.alert('Offline', 'Your device has no network connection.'); + return; + } - const healthUrl = `${baseUrl}/health`; - console.log('[BackendStatus] Checking health at:', healthUrl); + // Rung 2: can we reach the backend host, and is it healthy? + const healthUrl = `${baseUrl}/health`; + console.log('[BackendStatus] Checking health at:', healthUrl); + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), 8000); + try { const response = await fetch(healthUrl, { method: 'GET', headers: { @@ -54,6 +126,7 @@ export const BackendStatus: React.FC = ({ 'Content-Type': 'application/json', ...(jwtToken ? { 'Authorization': `Bearer ${jwtToken}` } : {}), }, + signal: controller.signal, }); if (response.ok) { @@ -61,25 +134,68 @@ export const BackendStatus: React.FC = ({ setHealthStatus({ status: 'healthy', message: `Connected (${healthData.status || 'OK'})`, lastChecked: new Date() }); if (showAlert) Alert.alert('Connection Success', 'Successfully connected to backend!'); } else if (response.status === 401 || response.status === 403) { - setHealthStatus({ status: 'auth_required', message: 'Authentication required', lastChecked: new Date() }); + setHealthStatus({ status: 'auth_required', message: 'Authentication required', detail: 'Log in below to access the backend.', lastChecked: new Date() }); if (showAlert) Alert.alert('Authentication Required', 'Please login to access the backend.'); } else { - throw new Error(`HTTP ${response.status}: ${response.statusText}`); + // Host reachable (it answered) but returned an error. + setHealthStatus({ + status: 'unhealthy', + message: `Backend reachable but unhealthy (HTTP ${response.status})`, + detail: 'The server is up but not ready. Check its logs.', + lastChecked: new Date(), + }); + if (showAlert) Alert.alert('Backend Unhealthy', `The backend responded with HTTP ${response.status}.`); } } catch (error) { + // Couldn't reach the backend. Don't blame Tailscale on a guess — probe the + // service-manager on the SAME host (port 8775). If it answers, the host + // (and tailnet) are reachable and it's the backend specifically that's + // down. If it doesn't, the host itself is unreachable. console.log('[BackendStatus] Health check error:', error); - let errorMessage = 'Connection failed'; - if (error instanceof Error) { - console.log('[BackendStatus] Error name:', error.name, 'message:', error.message); - if (error.message.includes('Network request failed')) errorMessage = 'Network request failed - check URL and network connection'; - else if (error.name === 'AbortError') errorMessage = 'Request timeout'; - else errorMessage = error.message; + const timedOut = error instanceof Error && error.name === 'AbortError'; + const tailnet = isTailnetHost(baseUrl); + const hostReachable = await isServiceManagerReachable(baseUrl); + + let host = baseUrl; + try { host = new URL(baseUrl).host; } catch {} + + if (hostReachable) { + // Tailnet/host is fine; the backend just isn't up. + setHealthStatus({ + status: 'backend_down', + message: `Backend is down at ${host}`, + detail: 'The machine is reachable but the backend isn’t running. Start it (Network Overview below) or check its logs.', + lastChecked: new Date(), + }); + if (showAlert) Alert.alert('Backend Down', `${host} is reachable but the backend isn’t responding.`); + } else { + const message = `Can't reach ${host}${timedOut ? ' (timed out)' : ''}`; + const detail = tailnet + ? 'The machine isn’t reachable. Check that Tailscale is connected on BOTH this phone and the backend machine, and that the backend machine is online. If you used a raw 100.x IP over HTTPS, try the machine’s …ts.net name instead (a self-signed cert on an IP is rejected by the app).' + : 'Confirm the backend machine is online and the URL is correct, or scan a QR code.'; + setHealthStatus({ status: 'unreachable', message, detail, lastChecked: new Date() }); + if (showAlert) Alert.alert('Connection Failed', `${message}\n\n${detail}`); } - setHealthStatus({ status: 'unhealthy', message: errorMessage, lastChecked: new Date() }); - if (showAlert) { - Alert.alert('Connection Failed', `Could not connect to backend: ${errorMessage}\n\nMake sure the backend is running and accessible.`); + } finally { + clearTimeout(timeout); + } + }; + + const openTailscale = async () => { + // Guard with canOpenURL: an un-whitelisted scheme makes iOS reject the deep + // link with a scary "could not verify" error. If we can't open it, just guide. + try { + if (await Linking.canOpenURL('tailscale://')) { + await Linking.openURL('tailscale://'); + return; } + } catch { + // fall through to guidance } + Alert.alert( + 'Open Tailscale', + 'Open the Tailscale app from your home screen and make sure it shows "Connected", then come back and tap Test Connection.' + ); }; useEffect(() => { @@ -93,8 +209,12 @@ export const BackendStatus: React.FC = ({ switch (status) { case 'healthy': return colors.success; case 'checking': return colors.warning; - case 'unhealthy': return colors.danger; case 'auth_required': return colors.warning; + case 'not_configured': return colors.warning; + case 'offline': return colors.danger; + case 'backend_down': return colors.danger; + case 'unreachable': return colors.danger; + case 'unhealthy': return colors.danger; default: return colors.disabled; } }; @@ -103,18 +223,16 @@ export const BackendStatus: React.FC = ({ Backend Connection - Backend URL: - + {/* Primary path: scan the QR. */} + setShowQRScanner(true)} + > + Scan QR Code + + + Find this QR on your Chronicle dashboard → System page (“Connect App”). + @@ -128,35 +246,55 @@ export const BackendStatus: React.FC = ({ )} + {healthStatus.detail && ( + {healthStatus.detail} + )} {healthStatus.lastChecked && ( Last checked: {healthStatus.lastChecked.toLocaleTimeString()} )} - setShowQRScanner(true)} - > - Scan QR Code - + {(healthStatus.status === 'unreachable' || healthStatus.status === 'not_configured') && ( + + Open Tailscale + + )} checkBackendHealth(true)} disabled={healthStatus.status === 'checking'} > - {healthStatus.status === 'checking' ? 'Checking...' : 'Test Connection'} + {healthStatus.status === 'checking' ? 'Checking...' : 'Test Connection'} - - Enter the WebSocket URL or scan a QR code from the Chronicle dashboard. - + {/* Manual fallback: type the URL. */} + Or enter the backend URL manually: + { - const wsUrl = httpUrlToWebSocketUrl(httpUrl); + onScanned={(config) => { + const wsUrl = httpUrlToWebSocketUrl(config.backendUrl); onBackendUrlChange(wsUrl); + // Persist service-manager config (if the QR bundle carried it) so the + // app can start a down backend directly. + if (config.serviceManagerUrl !== undefined) { + saveServiceManagerUrl(config.serviceManagerUrl || null); + } + if (config.smToken !== undefined) { + saveServiceManagerToken(config.smToken || null); + } }} onClose={() => setShowQRScanner(false)} /> @@ -227,6 +365,18 @@ const createStyles = (colors: ThemeColors) => StyleSheet.create({ fontSize: 14, fontWeight: '500', }, + detailText: { + fontSize: 13, + color: colors.textSecondary, + marginTop: 8, + }, + qrNote: { + fontSize: 12, + color: colors.textTertiary, + marginTop: 8, + marginBottom: 15, + textAlign: 'center', + }, lastCheckedText: { fontSize: 12, color: colors.textTertiary, diff --git a/app/src/components/DeviceDetails.tsx b/app/src/components/DeviceDetails.tsx index 4d2c6b5b..b0d1a2c5 100644 --- a/app/src/components/DeviceDetails.tsx +++ b/app/src/components/DeviceDetails.tsx @@ -23,6 +23,8 @@ interface DeviceDetailsProps { onSetUserId: (userId: string) => void; isAudioListenerRetrying?: boolean; audioListenerRetryAttempts?: number; + autoReconnectEnabled?: boolean; + onToggleAutoReconnect?: () => void; } export const DeviceDetails: React.FC = ({ @@ -44,7 +46,9 @@ export const DeviceDetails: React.FC = ({ userId, onSetUserId, isAudioListenerRetrying, - audioListenerRetryAttempts + audioListenerRetryAttempts, + autoReconnectEnabled = true, + onToggleAutoReconnect, }) => { const { colors } = useTheme(); const s = createStyles(colors); @@ -108,19 +112,41 @@ export const DeviceDetails: React.FC = ({ - - - {isListeningAudio ? "Stop Audio Listener" : - isAudioListenerRetrying ? "Stop Retry" : "Start Audio Listener"} + + + + {isListeningAudio ? "Stop Audio Listener" : + isAudioListenerRetrying ? "Stop Retry" : "Start Audio Listener"} + + + + {onToggleAutoReconnect && ( + + {autoReconnectEnabled ? '🔒' : '🔓'} + + )} + + + {onToggleAutoReconnect && ( + + {autoReconnectEnabled + ? '🔒 Stays connected — auto-reconnects if the connection drops.' + : '🔓 Connect once — won’t auto-reconnect if it drops.'} - + )} {isAudioListenerRetrying && ( @@ -208,6 +234,37 @@ const createStyles = (colors: ThemeColors) => StyleSheet.create({ fontSize: 16, fontWeight: '600', }, + listenerRow: { + flexDirection: 'row', + alignItems: 'stretch', + marginTop: 15, + gap: 10, + }, + listenerButton: { + flex: 1, + justifyContent: 'center', + }, + lockButton: { + width: 52, + borderRadius: 8, + alignItems: 'center', + justifyContent: 'center', + borderWidth: 1, + borderColor: colors.inputBorder, + backgroundColor: colors.inputBackground, + }, + lockButtonOn: { + borderColor: colors.primary, + backgroundColor: colors.card, + }, + lockIcon: { + fontSize: 22, + }, + lockHint: { + fontSize: 12, + color: colors.textTertiary, + marginTop: 8, + }, infoContainerSM: { marginTop: 10, padding: 10, diff --git a/app/src/components/ErrorBoundary.tsx b/app/src/components/ErrorBoundary.tsx new file mode 100644 index 00000000..b9742ad4 --- /dev/null +++ b/app/src/components/ErrorBoundary.tsx @@ -0,0 +1,86 @@ +import React from 'react'; +import { View, Text, ScrollView, TouchableOpacity, StyleSheet, Share, Platform } from 'react-native'; +import { logError, getLogPath, readLog } from '@/utils/logger'; + +interface Props { + children: React.ReactNode; +} + +interface State { + error: Error | null; + info: React.ErrorInfo | null; +} + +export default class ErrorBoundary extends React.Component { + state: State = { error: null, info: null }; + + static getDerivedStateFromError(error: Error): Partial { + return { error }; + } + + componentDidCatch(error: Error, info: React.ErrorInfo): void { + this.setState({ info }); + const msg = `React render error: ${error.name}: ${error.message}\ncomponentStack: ${info.componentStack ?? 'unknown'}\nstack: ${error.stack ?? 'no stack'}`; + logError('ErrorBoundary', msg); + } + + reset = () => this.setState({ error: null, info: null }); + + share = async () => { + try { + const contents = await readLog(); + if (Platform.OS === 'ios') { + await Share.share({ url: `file://${getLogPath()}`, message: contents.slice(-4000) }); + } else { + await Share.share({ message: contents.slice(-4000) }); + } + } catch (err) { + logError('ErrorBoundary', `share failed: ${String(err)}`); + } + }; + + render() { + const { error, info } = this.state; + if (!error) return this.props.children; + + return ( + + Chronicle crashed + The error has been written to the on-device log. + + {error.name}: {error.message} + {error.stack ? {error.stack} : null} + {info?.componentStack ? ( + <> + Component stack + {info.componentStack} + + ) : null} + + Log: {getLogPath()} + + + Share Log + + + Try Again + + + + ); + } +} + +const styles = StyleSheet.create({ + container: { flex: 1, backgroundColor: '#1c1c1e', padding: 16, paddingTop: 60 }, + title: { color: '#FF3B30', fontSize: 22, fontWeight: '700', marginBottom: 4 }, + subtitle: { color: '#aeaeb2', fontSize: 13, marginBottom: 12 }, + scroll: { flex: 1, backgroundColor: '#2c2c2e', borderRadius: 8 }, + errorHeading: { color: '#fff', fontSize: 14, fontWeight: '600', marginTop: 8 }, + stack: { color: '#d1d1d6', fontSize: 11, fontFamily: Platform.OS === 'ios' ? 'Menlo' : 'monospace', marginTop: 4 }, + path: { color: '#8e8e93', fontSize: 11, marginTop: 10, marginBottom: 6 }, + row: { flexDirection: 'row', gap: 8 }, + btn: { flex: 1, backgroundColor: '#0A84FF', paddingVertical: 12, borderRadius: 8, alignItems: 'center' }, + btnSecondary: { backgroundColor: '#3a3a3c' }, + btnText: { color: '#fff', fontSize: 15, fontWeight: '600' }, +}); diff --git a/app/src/components/NetworkOverview.tsx b/app/src/components/NetworkOverview.tsx new file mode 100644 index 00000000..c1b7fcd2 --- /dev/null +++ b/app/src/components/NetworkOverview.tsx @@ -0,0 +1,384 @@ +import React, { useCallback, useEffect, useState } from 'react'; +import { ActivityIndicator, Alert, StyleSheet, Text, TouchableOpacity, View } from 'react-native'; +import { useTheme, ThemeColors } from '../theme'; +import { + listServices, + serviceAction, + getOperation, + ServiceInfo, + ServicesResult, +} from '../services/serviceManager'; + +interface NetworkOverviewProps { + backendUrl: string; +} + +// Poll an async operation to completion (best-effort; bounded). +const waitForOperation = async ( + backendUrl: string, + operationId: string, + node?: string | null +): Promise => { + for (let i = 0; i < 30; i++) { + await new Promise(r => setTimeout(r, 2000)); + try { + const op = await getOperation(backendUrl, operationId, node); + const status = (op.status || '').toLowerCase(); + if (status && status !== 'running' && status !== 'pending' && status !== 'in_progress') { + return; + } + } catch { + return; // stop polling on error; the refresh will reflect reality + } + } +}; + +const NetworkOverview: React.FC = ({ backendUrl }) => { + const { colors } = useTheme(); + const s = createStyles(colors); + + const [result, setResult] = useState(null); + const [loading, setLoading] = useState(false); + const [busyService, setBusyService] = useState(null); + const [expanded, setExpanded] = useState(false); + + const refresh = useCallback(async () => { + if (!backendUrl.trim()) { + setResult({ available: false, reason: 'no_backend_url' }); + return; + } + setLoading(true); + try { + setResult(await listServices(backendUrl)); + } catch (e) { + setResult({ available: false, reason: e instanceof Error ? e.message : 'error' }); + } finally { + setLoading(false); + } + }, [backendUrl]); + + useEffect(() => { + refresh(); + }, [refresh]); + + const runAction = useCallback( + async (service: ServiceInfo, action: 'start' | 'stop' | 'restart') => { + setBusyService(service.name); + try { + const res = await serviceAction(backendUrl, service.name, action, service.node); + const op = res.operation; + if (op?.id) { + await waitForOperation(backendUrl, op.id, op.node ?? service.node); + } + await refresh(); + } catch (e) { + Alert.alert('Action Failed', e instanceof Error ? e.message : 'Failed to control service.'); + } finally { + setBusyService(null); + } + }, + [backendUrl, refresh] + ); + + const services = result?.services || []; + const total = services.length; + const upCount = services.filter(svc => svc.health && svc.health !== 'stopped').length; + + // Group services by the node (host) they run on. + const groups = services.reduce>((acc, svc) => { + const key = svc.node || 'This device'; + (acc[key] = acc[key] || []).push(svc); + return acc; + }, {}); + + const renderServiceRow = (svc: ServiceInfo) => { + // The agent reports `health` (healthy|partial|starting|unhealthy|stopped), + // not a running boolean. Anything other than "stopped" means it's up. + const health = svc.health || 'unknown'; + const up = health !== 'stopped' && health !== 'unknown'; + const isBusy = busyService === svc.name; + const badgeColor = + health === 'healthy' + ? colors.success + : health === 'stopped' || health === 'unhealthy' + ? colors.danger + : health === 'unknown' + ? colors.disabled + : colors.warning; // partial | starting + return ( + + + + {svc.name} + {svc.description ? {svc.description} : null} + {svc.enabled === false ? not enabled in config : null} + + + {health} + + + + {isBusy ? ( + + ) : up ? ( + <> + runAction(svc, 'restart')}> + Restart + + runAction(svc, 'stop')}> + Stop + + + ) : ( + runAction(svc, 'start')} + > + Start + + )} + + + ); + }; + + const renderUnavailable = () => { + const reason = result?.reason || 'unavailable'; + const hint = + reason === 'not_configured' + ? 'Scan a QR code from the dashboard System page to add a service-manager token.' + : reason === 'no_backend_url' + ? 'Set a backend URL first.' + : 'The service manager is unreachable from here.'; + return ( + + Network control unavailable ({reason}). + {hint} + + ); + }; + + return ( + + + setExpanded(e => !e)} + activeOpacity={0.7} + > + {expanded ? '▾' : '▸'} + Network Overview + + + {loading ? 'Refreshing…' : 'Refresh'} + + + + {loading && !result ? : null} + + {result && !result.available ? renderUnavailable() : null} + + {result?.available ? ( + <> + setExpanded(e => !e)} activeOpacity={0.7}> + + {total === 0 + ? 'No services reported.' + : `${total} service${total === 1 ? '' : 's'} · ${upCount} up · ${total - upCount} stopped${expanded ? '' : ' · tap to manage'}`} + + + {expanded && total > 0 + ? Object.entries(groups).map(([node, svcs]) => { + const groupUp = svcs.filter(svc => svc.health && svc.health !== 'stopped').length; + return ( + + + + + {node} + {svcs[0]?.remote ? REMOTE : null} + + {groupUp}/{svcs.length} up + + {svcs.map(renderServiceRow)} + + ); + }) + : null} + + ) : null} + + ); +}; + +const createStyles = (colors: ThemeColors) => StyleSheet.create({ + section: { + marginBottom: 25, + padding: 15, + backgroundColor: colors.card, + borderRadius: 10, + shadowColor: '#000', + shadowOffset: { width: 0, height: 1 }, + shadowOpacity: 0.1, + shadowRadius: 3, + elevation: 2, + }, + header: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + }, + headerLeft: { + flexDirection: 'row', + alignItems: 'center', + flex: 1, + }, + chevron: { + fontSize: 14, + color: colors.textSecondary, + marginRight: 8, + width: 14, + }, + sectionTitle: { + fontSize: 18, + fontWeight: '600', + color: colors.text, + }, + refreshText: { + fontSize: 13, + color: colors.primary, + fontWeight: '600', + }, + summary: { + fontSize: 13, + color: colors.textSecondary, + marginTop: 8, + }, + group: { + marginTop: 16, + backgroundColor: colors.background, + borderRadius: 10, + borderLeftWidth: 3, + borderLeftColor: colors.primary, + padding: 10, + }, + groupHeaderRow: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + marginBottom: 10, + }, + groupHeaderLeft: { + flexDirection: 'row', + alignItems: 'center', + flex: 1, + }, + nodeDot: { + width: 8, + height: 8, + borderRadius: 4, + backgroundColor: colors.primary, + marginRight: 8, + }, + groupHeader: { + fontSize: 14, + fontWeight: '700', + color: colors.text, + }, + remoteTag: { + marginLeft: 8, + fontSize: 10, + fontWeight: '700', + color: colors.textTertiary, + borderWidth: 1, + borderColor: colors.separator, + borderRadius: 4, + paddingHorizontal: 5, + paddingVertical: 1, + overflow: 'hidden', + }, + groupCount: { + fontSize: 12, + fontWeight: '600', + color: colors.textTertiary, + }, + row: { + marginBottom: 10, + padding: 10, + borderWidth: 1, + borderColor: colors.separator, + borderRadius: 8, + backgroundColor: colors.card, + }, + rowHeader: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'flex-start', + }, + rowHeaderText: { + flex: 1, + marginRight: 8, + }, + serviceName: { + fontSize: 14, + fontWeight: '600', + color: colors.text, + }, + descText: { + fontSize: 12, + color: colors.textTertiary, + marginTop: 2, + }, + badge: { + paddingVertical: 3, + paddingHorizontal: 8, + borderRadius: 10, + }, + badgeText: { + color: '#fff', + fontSize: 11, + fontWeight: '700', + }, + nodeText: { + fontSize: 12, + color: colors.textTertiary, + marginTop: 4, + }, + actions: { + flexDirection: 'row', + gap: 8, + marginTop: 10, + }, + actionButton: { + paddingVertical: 6, + paddingHorizontal: 12, + borderRadius: 6, + borderWidth: 1, + borderColor: colors.inputBorder, + backgroundColor: colors.inputBackground, + }, + startButton: { + backgroundColor: colors.primary, + borderColor: colors.primary, + }, + actionText: { + color: colors.textSecondary, + fontSize: 12, + fontWeight: '600', + }, + startText: { + color: '#fff', + }, + emptyText: { + color: colors.textTertiary, + fontSize: 13, + fontStyle: 'italic', + }, + hintText: { + color: colors.textSecondary, + fontSize: 12, + marginTop: 6, + }, +}); + +export default NetworkOverview; diff --git a/app/src/components/ObsidianIngest.tsx b/app/src/components/ObsidianIngest.tsx index 038274bd..bb322125 100644 --- a/app/src/components/ObsidianIngest.tsx +++ b/app/src/components/ObsidianIngest.tsx @@ -65,7 +65,7 @@ export const ObsidianIngest: React.FC = ({ onPress={handleIngest} disabled={loading} > - {loading ? 'Starting Ingestion...' : 'Ingest to Neo4j'} + {loading ? 'Starting Ingestion...' : 'Ingest to FalkorDB'} diff --git a/app/src/components/PhoneAudioMicPicker.tsx b/app/src/components/PhoneAudioMicPicker.tsx new file mode 100644 index 00000000..7cdd8d69 --- /dev/null +++ b/app/src/components/PhoneAudioMicPicker.tsx @@ -0,0 +1,155 @@ +import React from 'react'; +import { View, Text, TouchableOpacity, StyleSheet, ActivityIndicator } from 'react-native'; +import { useTheme, ThemeColors } from '../theme'; +import type { AudioDevice } from '@siteed/expo-audio-studio'; +import { AUTO_DEVICE_ID, isBluetoothInput } from '../hooks/usePhoneAudioDevices'; + +interface PhoneAudioMicPickerProps { + devices: AudioDevice[]; + selectedDeviceId: string; + effectiveDevice: AudioDevice | null; + loading: boolean; + /** Disable changing the mic (e.g. while streaming is active). */ + disabled: boolean; + onSelect: (id: string) => void; + onRefresh: () => void; +} + +const deviceLabel = (device: AudioDevice): string => { + const base = device.name?.trim() || 'Unknown device'; + if (isBluetoothInput(device)) return `🎧 ${base}`; + return `🎙 ${base}`; +}; + +const PhoneAudioMicPicker: React.FC = ({ + devices, + selectedDeviceId, + effectiveDevice, + loading, + disabled, + onSelect, + onRefresh, +}) => { + const { colors } = useTheme(); + const s = createStyles(colors); + + const autoSelected = selectedDeviceId === AUTO_DEVICE_ID; + const autoSubtitle = effectiveDevice + ? `using ${effectiveDevice.name}` + : 'using phone mic'; + + const renderChip = (id: string, label: string, key: string, subtitle?: string) => { + const isSelected = selectedDeviceId === id; + return ( + !disabled && onSelect(id)} + disabled={disabled} + activeOpacity={0.7} + > + + {label} + + {subtitle ? ( + + {subtitle} + + ) : null} + + ); + }; + + return ( + + + Microphone + + {loading ? ( + + ) : ( + Refresh + )} + + + + + {renderChip(AUTO_DEVICE_ID, 'Auto', 'auto', autoSelected ? autoSubtitle : undefined)} + {devices.map((d) => renderChip(d.id, deviceLabel(d), d.id))} + + + {devices.length === 0 && !loading && ( + + No input devices detected. Connect your Bluetooth headset, then tap Refresh. + + )} + + ); +}; + +const createStyles = (colors: ThemeColors) => + StyleSheet.create({ + container: { + marginTop: -4, + marginBottom: 10, + paddingHorizontal: 20, + }, + header: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + marginBottom: 8, + }, + label: { + fontSize: 13, + fontWeight: '600', + color: colors.textSecondary, + }, + refresh: { + fontSize: 13, + color: colors.primary, + fontWeight: '500', + }, + chipRow: { + flexDirection: 'row', + flexWrap: 'wrap', + gap: 8, + }, + chip: { + paddingVertical: 6, + paddingHorizontal: 12, + borderRadius: 16, + borderWidth: 1, + borderColor: colors.inputBorder, + backgroundColor: colors.inputBackground, + maxWidth: 220, + }, + chipSelected: { + borderColor: colors.primary, + backgroundColor: colors.primary, + }, + chipDisabled: { + opacity: 0.5, + }, + chipText: { + fontSize: 13, + color: colors.text, + fontWeight: '500', + }, + chipTextSelected: { + color: '#FFFFFF', + }, + chipSubtitle: { + fontSize: 10, + color: colors.textTertiary, + marginTop: 1, + }, + hint: { + marginTop: 8, + fontSize: 12, + color: colors.textTertiary, + fontStyle: 'italic', + }, + }); + +export default PhoneAudioMicPicker; diff --git a/app/src/components/QRScanner.tsx b/app/src/components/QRScanner.tsx index 87bedd2d..88110a7f 100644 --- a/app/src/components/QRScanner.tsx +++ b/app/src/components/QRScanner.tsx @@ -9,12 +9,12 @@ import { } from 'react-native'; import { CameraView, useCameraPermissions, scanFromURLAsync } from 'expo-camera'; import * as ImagePicker from 'expo-image-picker'; -import { isValidBackendUrl } from '../utils/urlConversion'; +import { parseScannedConfig, ScannedBackendConfig } from '../utils/urlConversion'; import { useTheme, ThemeColors } from '../theme'; interface QRScannerProps { visible: boolean; - onScanned: (url: string) => void; + onScanned: (config: ScannedBackendConfig) => void; onClose: () => void; } @@ -34,8 +34,9 @@ export const QRScanner: React.FC = ({ visible, onScanned, onClos if (scanned) return; setScanned(true); - if (isValidBackendUrl(data)) { - onScanned(data); + const config = parseScannedConfig(data); + if (config) { + onScanned(config); onClose(); } else { Alert.alert( diff --git a/app/src/components/SystemAdminControls.tsx b/app/src/components/SystemAdminControls.tsx new file mode 100644 index 00000000..73b31e66 --- /dev/null +++ b/app/src/components/SystemAdminControls.tsx @@ -0,0 +1,429 @@ +import React, { useCallback, useEffect, useMemo, useState } from 'react'; +import { Alert, StyleSheet, Text, TouchableOpacity, View, ActivityIndicator } from 'react-native'; +import { useTheme, ThemeColors } from '../theme'; + +interface WakewordEntry { + name: string; + mode: 'dispatch' | 'collect_only' | 'off'; + collect_only?: boolean; + disabled?: boolean; +} + +interface WakewordModeResponse { + global_mode?: 'dispatch' | 'collect_only' | 'off' | 'mixed'; + wakewords?: WakewordEntry[]; +} + +interface WakewordModelsResponse { + wakewords?: Array<{ + name: string; + collect_only?: boolean; + disabled?: boolean; + }>; +} + +interface SystemAdminControlsProps { + backendUrl: string; + jwtToken: string | null; +} + +const toHttpBaseUrl = (backendUrl: string): string | null => { + const trimmed = backendUrl.trim(); + if (!trimmed) return null; + if (trimmed.startsWith('ws://')) return trimmed.replace('ws://', 'http://').split('/ws')[0]; + if (trimmed.startsWith('wss://')) return trimmed.replace('wss://', 'https://').split('/ws')[0]; + if (trimmed.startsWith('http://') || trimmed.startsWith('https://')) return trimmed.split('/ws')[0]; + return null; +}; + +const normalizeMode = (entry: WakewordEntry): 'dispatch' | 'collect_only' | 'off' => { + if (entry.mode) return entry.mode; + if (entry.disabled) return 'off'; + if (entry.collect_only) return 'collect_only'; + return 'dispatch'; +}; + +const SystemAdminControls: React.FC = ({ backendUrl, jwtToken }) => { + const { colors } = useTheme(); + const s = createStyles(colors); + + const baseUrl = useMemo(() => toHttpBaseUrl(backendUrl), [backendUrl]); + + const [wakewords, setWakewords] = useState([]); + const [loadingModes, setLoadingModes] = useState(false); + const [updatingWord, setUpdatingWord] = useState(null); + const [modeError, setModeError] = useState(null); + const [wakewordUnavailable, setWakewordUnavailable] = useState(false); + const [supportsModeEndpoint, setSupportsModeEndpoint] = useState(null); + const [restarting, setRestarting] = useState(false); + + const authHeaders = useMemo(() => { + const headers: Record = { 'Content-Type': 'application/json' }; + if (jwtToken) headers.Authorization = `Bearer ${jwtToken}`; + return headers; + }, [jwtToken]); + + const applyModeResponse = useCallback((data: WakewordModeResponse) => { + const rows = (data.wakewords || []).map((w) => ({ + ...w, + mode: normalizeMode(w), + })); + setWakewords(rows); + }, []); + + const fetchModes = useCallback(async () => { + if (!baseUrl) { + setWakewords([]); + setModeError('Set backend URL first.'); + return; + } + + setLoadingModes(true); + setModeError(null); + setWakewordUnavailable(false); + try { + const modeResponse = await fetch(`${baseUrl}/api/wakeword/mode`, { + method: 'GET', + headers: authHeaders, + }); + + if (modeResponse.ok) { + const data = (await modeResponse.json()) as WakewordModeResponse; + applyModeResponse(data); + setSupportsModeEndpoint(true); + return; + } + + // Backward-compatibility: older backends may not have /mode yet. + if (modeResponse.status === 404) { + const modelsResponse = await fetch(`${baseUrl}/api/wakeword/models`, { + method: 'GET', + headers: authHeaders, + }); + + if (modelsResponse.ok) { + const modelsData = (await modelsResponse.json()) as WakewordModelsResponse; + const rows: WakewordEntry[] = (modelsData.wakewords || []).map((w) => ({ + name: w.name, + mode: w.disabled ? 'off' : w.collect_only ? 'collect_only' : 'dispatch', + collect_only: !!w.collect_only, + disabled: !!w.disabled, + })); + setWakewords(rows); + setSupportsModeEndpoint(false); + return; + } + + if (modelsResponse.status === 404) { + // Simple backend or wakeword service not enabled. + setWakewordUnavailable(true); + setWakewords([]); + setSupportsModeEndpoint(false); + return; + } + + const body = await modelsResponse.text(); + throw new Error(`Failed to fetch wakeword models (${modelsResponse.status}): ${body}`); + } + + const body = await modeResponse.text(); + throw new Error(`Failed to fetch wakeword mode (${modeResponse.status}): ${body}`); + } catch (error) { + const message = error instanceof Error ? error.message : 'Failed to load wakeword modes'; + setModeError(message); + setWakewords([]); + setWakewordUnavailable(false); + setSupportsModeEndpoint(null); + } finally { + setLoadingModes(false); + } + }, [applyModeResponse, authHeaders, baseUrl]); + + useEffect(() => { + fetchModes(); + }, [fetchModes]); + + const setWordMode = useCallback(async (wakeword: string, mode: 'dispatch' | 'collect_only' | 'off') => { + if (!baseUrl) { + Alert.alert('Backend URL Required', 'Set backend URL first.'); + return; + } + + setUpdatingWord(wakeword); + setModeError(null); + try { + if (supportsModeEndpoint !== false) { + const response = await fetch(`${baseUrl}/api/wakeword/mode`, { + method: 'POST', + headers: authHeaders, + body: JSON.stringify({ wakeword, mode }), + }); + + if (response.ok) { + const data = (await response.json()) as WakewordModeResponse; + applyModeResponse(data); + setSupportsModeEndpoint(true); + return; + } + + // Fall back for older backend that lacks /mode. + if (response.status !== 404) { + const text = await response.text(); + throw new Error(text || `Failed to set ${wakeword} mode`); + } + } + + setSupportsModeEndpoint(false); + + const postDisabled = async (disabled: boolean, strict: boolean): Promise => { + const resp = await fetch(`${baseUrl}/api/wakeword/disabled`, { + method: 'POST', + headers: authHeaders, + body: JSON.stringify({ wakeword, disabled }), + }); + if (resp.status === 404 && !strict) return; + if (!resp.ok) { + const text = await resp.text(); + if (resp.status === 404 && strict) { + throw new Error("This backend doesn't support per-wakeword OFF yet."); + } + throw new Error(text || `Failed to update disabled state for ${wakeword}`); + } + }; + + const postCollectOnly = async (collectOnly: boolean): Promise => { + const resp = await fetch(`${baseUrl}/api/wakeword/collect_only`, { + method: 'POST', + headers: authHeaders, + body: JSON.stringify({ wakeword, collect_only: collectOnly }), + }); + if (!resp.ok) { + const text = await resp.text(); + throw new Error(text || `Failed to set collect_only for ${wakeword}`); + } + }; + + if (mode === 'dispatch') { + await postDisabled(false, false); + await postCollectOnly(false); + } else if (mode === 'collect_only') { + await postDisabled(false, false); + await postCollectOnly(true); + } else { + await postCollectOnly(false); + await postDisabled(true, true); + } + + await fetchModes(); + } catch (error) { + const message = error instanceof Error ? error.message : 'Failed to update wakeword mode'; + setModeError(message); + Alert.alert('Wakeword Update Failed', message); + } finally { + setUpdatingWord(null); + } + }, [applyModeResponse, authHeaders, baseUrl, fetchModes, supportsModeEndpoint]); + + const restartBackend = useCallback(async () => { + if (!baseUrl) { + Alert.alert('Backend URL Required', 'Set backend URL first.'); + return; + } + + Alert.alert( + 'Restart Backend?', + 'This will briefly disconnect active sessions and streams.', + [ + { text: 'Cancel', style: 'cancel' }, + { + text: 'Restart', + style: 'destructive', + onPress: async () => { + setRestarting(true); + try { + const response = await fetch(`${baseUrl}/api/admin/system/restart-backend`, { + method: 'POST', + headers: authHeaders, + }); + const body = await response.text(); + if (!response.ok) { + throw new Error(body || `Restart failed (${response.status})`); + } + Alert.alert('Backend Restart Requested', 'Restart has been scheduled.'); + } catch (error) { + const message = error instanceof Error ? error.message : 'Failed to restart backend'; + Alert.alert('Restart Failed', message); + } finally { + setRestarting(false); + } + }, + }, + ] + ); + }, [authHeaders, baseUrl]); + + return ( + + System Controls + + + {restarting ? : Restart Backend} + + + + + Wakeword Modes + + {loadingModes ? 'Refreshing...' : 'Refresh'} + + + + {wakewordUnavailable ? ( + Wakeword controls are not available on this backend. + ) : null} + + {loadingModes ? ( + + ) : wakewords.length === 0 && !wakewordUnavailable ? ( + No wakewords found. + ) : ( + wakewords.map((w) => ( + + {w.name} + + {(['dispatch', 'collect_only', 'off'] as const).map((mode) => { + const isActive = w.mode === mode; + const isUpdating = updatingWord === w.name; + return ( + setWordMode(w.name, mode)} + disabled={isUpdating} + > + + {mode === 'collect_only' ? 'collect' : mode} + + + ); + })} + + + )) + )} + + {modeError ? {modeError} : null} + + + ); +}; + +const createStyles = (colors: ThemeColors) => StyleSheet.create({ + section: { + marginBottom: 25, + padding: 15, + backgroundColor: colors.card, + borderRadius: 10, + shadowColor: '#000', + shadowOffset: { width: 0, height: 1 }, + shadowOpacity: 0.1, + shadowRadius: 3, + elevation: 2, + }, + sectionTitle: { + fontSize: 18, + fontWeight: '600', + marginBottom: 12, + color: colors.text, + }, + restartButton: { + backgroundColor: colors.danger, + paddingVertical: 10, + paddingHorizontal: 14, + borderRadius: 8, + alignItems: 'center', + }, + restartButtonText: { + color: '#fff', + fontSize: 14, + fontWeight: '700', + }, + subSection: { + marginTop: 16, + borderTopWidth: 1, + borderTopColor: colors.separator, + paddingTop: 12, + }, + subSectionHeader: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + marginBottom: 10, + }, + subSectionTitle: { + fontSize: 15, + fontWeight: '600', + color: colors.textSecondary, + }, + refreshText: { + fontSize: 13, + color: colors.primary, + fontWeight: '600', + }, + row: { + marginBottom: 10, + padding: 10, + borderWidth: 1, + borderColor: colors.separator, + borderRadius: 8, + }, + wordName: { + fontSize: 14, + fontWeight: '600', + color: colors.text, + marginBottom: 8, + }, + modeButtons: { + flexDirection: 'row', + gap: 8, + }, + modeButton: { + paddingVertical: 6, + paddingHorizontal: 10, + borderRadius: 6, + borderWidth: 1, + borderColor: colors.inputBorder, + backgroundColor: colors.inputBackground, + }, + modeButtonActive: { + backgroundColor: colors.primary, + borderColor: colors.primary, + }, + modeButtonText: { + color: colors.textSecondary, + fontSize: 12, + fontWeight: '600', + }, + modeButtonTextActive: { + color: '#fff', + }, + emptyText: { + color: colors.textTertiary, + fontSize: 13, + fontStyle: 'italic', + }, + errorText: { + marginTop: 8, + fontSize: 12, + color: colors.danger, + }, + disabledButton: { + opacity: 0.6, + }, +}); + +export default SystemAdminControls; diff --git a/app/src/contexts/AppSettingsContext.tsx b/app/src/contexts/AppSettingsContext.tsx new file mode 100644 index 00000000..b6c2e7e1 --- /dev/null +++ b/app/src/contexts/AppSettingsContext.tsx @@ -0,0 +1,22 @@ +import React, { createContext, useContext } from 'react'; +import { useAppSettings, AppSettings } from '@/hooks/useAppSettings'; + +// Shared app-settings state. useAppSettings holds local state hydrated from +// storage, so each call site would otherwise get its own copy — meaning a login +// or QR scan performed on the Settings screen wouldn't be reflected on the home +// screen until it remounted. Providing a single instance via context keeps both +// screens in sync. +const AppSettingsContext = createContext(null); + +export const AppSettingsProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => { + const settings = useAppSettings(); + return {children}; +}; + +export const useSharedAppSettings = (): AppSettings => { + const ctx = useContext(AppSettingsContext); + if (!ctx) { + throw new Error('useSharedAppSettings must be used within an AppSettingsProvider'); + } + return ctx; +}; diff --git a/app/src/contexts/ConnectionLogContext.tsx b/app/src/contexts/ConnectionLogContext.tsx index dc9314c4..3d43bcde 100644 --- a/app/src/contexts/ConnectionLogContext.tsx +++ b/app/src/contexts/ConnectionLogContext.tsx @@ -1,4 +1,5 @@ import React, { createContext, useContext, useCallback, useRef, useState } from 'react'; +import { logInfo } from '@/utils/logger'; export interface ConnectionEvent { id: string; @@ -25,7 +26,15 @@ export type ConnectionEventType = | 'health_ping' | 'reconnect_attempt' | 'reconnect_backoff' - | 'bt_state_change'; + | 'bt_state_change' + // WebSocket (audio streaming) lifecycle — distinct from the BLE events above + | 'ws_connecting' + | 'ws_open' + | 'ws_close' + | 'ws_error' + | 'ws_reconnect' + | 'ws_reauth' + | 'net_change'; const MAX_EVENTS = 200; let eventCounter = 0; @@ -57,6 +66,14 @@ export const ConnectionLogProvider: React.FC<{ children: React.ReactNode }> = ({ eventsRef.current = [event, ...eventsRef.current].slice(0, MAX_EVENTS); setEvents(eventsRef.current); + + const extras = [ + event.deviceName ? `device="${event.deviceName}"` : null, + event.deviceId ? `id=${event.deviceId}` : null, + event.rssi != null ? `rssi=${event.rssi}` : null, + details ? `details="${details}"` : null, + ].filter(Boolean).join(' '); + logInfo('ConnectionLog', `${type}${extras ? ' ' + extras : ''}`); }, []); const clearEvents = useCallback(() => { diff --git a/app/src/hooks/useAppSettings.ts b/app/src/hooks/useAppSettings.ts index f0223890..da01ba02 100644 --- a/app/src/hooks/useAppSettings.ts +++ b/app/src/hooks/useAppSettings.ts @@ -6,7 +6,13 @@ import { getUserId, getAuthEmail, getJwtToken, + getAutoReconnectEnabled, + saveAutoReconnectEnabled, } from '../utils/storage'; +import { recoverBackendUrl } from '../services/serviceManager'; +import { httpUrlToWebSocketUrl } from '../utils/urlConversion'; + +const DEFAULT_WS_URL = 'ws://localhost:8000/ws'; export interface AppSettings { webSocketUrl: string; @@ -14,9 +20,11 @@ export interface AppSettings { isAuthenticated: boolean; currentUserEmail: string | null; jwtToken: string | null; + autoReconnectEnabled: boolean; handleSetAndSaveWebSocketUrl: (url: string) => Promise; handleSetAndSaveUserId: (id: string) => Promise; handleAuthStatusChange: (authenticated: boolean, email: string | null, token: string | null) => void; + handleToggleAutoReconnect: () => void; } export const useAppSettings = (): AppSettings => { @@ -25,16 +33,31 @@ export const useAppSettings = (): AppSettings => { const [isAuthenticated, setIsAuthenticated] = useState(false); const [currentUserEmail, setCurrentUserEmail] = useState(null); const [jwtToken, setJwtToken] = useState(null); + const [autoReconnectEnabled, setAutoReconnectEnabled] = useState(true); useEffect(() => { const loadSettings = async () => { const storedWsUrl = await getWebSocketUrl(); - if (storedWsUrl) { - setWebSocketUrl(storedWsUrl); + const hasRealUrl = !!storedWsUrl && storedWsUrl !== DEFAULT_WS_URL; + if (hasRealUrl) { + setWebSocketUrl(storedWsUrl as string); } else { - const defaultUrl = 'ws://localhost:8000/ws'; - setWebSocketUrl(defaultUrl); - await saveWebSocketUrl(defaultUrl); + setWebSocketUrl(DEFAULT_WS_URL); + await saveWebSocketUrl(DEFAULT_WS_URL); + // Backend URL is missing/placeholder — try to recover it from stored + // service-manager credentials (the SM host is the backend host). This is + // a no-op (returns fast) when no SM URL is stored. Non-blocking so app + // startup isn't delayed by network probes. + recoverBackendUrl() + .then(async recovered => { + if (recovered) { + const wsUrl = httpUrlToWebSocketUrl(recovered); + setWebSocketUrl(wsUrl); + await saveWebSocketUrl(wsUrl); + console.log('[AppSettings] Recovered backend URL from service-manager creds'); + } + }) + .catch(() => {}); } const storedUserId = await getUserId(); @@ -47,6 +70,8 @@ export const useAppSettings = (): AppSettings => { setJwtToken(storedToken); setIsAuthenticated(true); } + + setAutoReconnectEnabled(await getAutoReconnectEnabled()); }; loadSettings(); }, []); @@ -67,14 +92,24 @@ export const useAppSettings = (): AppSettings => { setJwtToken(token); }, []); + const handleToggleAutoReconnect = useCallback(() => { + setAutoReconnectEnabled(prev => { + const next = !prev; + saveAutoReconnectEnabled(next); + return next; + }); + }, []); + return { webSocketUrl, userId, isAuthenticated, currentUserEmail, jwtToken, + autoReconnectEnabled, handleSetAndSaveWebSocketUrl, handleSetAndSaveUserId, handleAuthStatusChange, + handleToggleAutoReconnect, }; }; diff --git a/app/src/hooks/useAudioStreamer.ts b/app/src/hooks/useAudioStreamer.ts index f22371c0..8090faf8 100644 --- a/app/src/hooks/useAudioStreamer.ts +++ b/app/src/hooks/useAudioStreamer.ts @@ -1,8 +1,18 @@ // useAudioStreamer.ts import { useState, useRef, useCallback, useEffect } from 'react'; -import { PermissionsAndroid, Platform } from 'react-native'; +import { AppState, PermissionsAndroid, Platform } from 'react-native'; import notifee, { AndroidImportance } from '@notifee/react-native'; import NetInfo from '@react-native-community/netinfo'; +import { refreshToken } from '../services/auth'; +import { playDownlinkAudio } from '../utils/audioPlayback'; +import { useConnectionLog, ConnectionEventType, ConnectionEvent } from '../contexts/ConnectionLogContext'; + +interface UseAudioStreamerOptions { + /** Called when a new JWT token is obtained via auto-re-login */ + onTokenRefreshed?: (token: string) => void; + /** When false, the socket connects once and does NOT auto-reconnect on drop. */ + autoReconnectEnabled?: boolean; +} interface UseAudioStreamer { isStreaming: boolean; @@ -90,7 +100,7 @@ async function stopForegroundServiceNotification() { /** -------------------- Hook -------------------- */ -export const useAudioStreamer = (): UseAudioStreamer => { +export const useAudioStreamer = (options?: UseAudioStreamerOptions): UseAudioStreamer => { const [isStreaming, setIsStreaming] = useState(false); const [isConnecting, setIsConnecting] = useState(false); const [error, setError] = useState(null); @@ -108,6 +118,24 @@ export const useAudioStreamer = (): UseAudioStreamer => { const MAX_RECONNECT_MS = 30000; const HEARTBEAT_MS = 25000; + // Track if we received an auth error so onclose doesn't blindly reconnect + const authFailedRef = useRef(false); + + // User preference: when false, connect once (no auto-reconnect on drop). + const autoReconnectEnabledRef = useRef(options?.autoReconnectEnabled ?? true); + useEffect(() => { + autoReconnectEnabledRef.current = options?.autoReconnectEnabled ?? true; + }, [options?.autoReconnectEnabled]); + + // Zombie-socket detection: timestamp of the last pong from the backend. + const lastPongRef = useRef(0); + // Last known network reachability, so we only log actual online/offline + // transitions (NetInfo fires on many intermediate changes). + const lastNetOnlineRef = useRef(null); + // Notify the user only once when reconnect attempts cross the threshold + // (we keep retrying indefinitely rather than giving up). + const exhaustedNotifiedRef = useRef(false); + // Guard state updates after unmount const mountedRef = useRef(true); useEffect(() => { @@ -120,6 +148,20 @@ export const useAudioStreamer = (): UseAudioStreamer => { if (mountedRef.current) setter(val); }, []); + // Diagnostics: surface WebSocket/network lifecycle in the in-app Connection Log + // (and, via the context, the persistent crash-log file). Held in a ref so the + // logging helper stays stable and we don't have to thread `addEvent` through + // every callback's dependency array. + const { addEvent } = useConnectionLog(); + const addEventRef = useRef(addEvent); + useEffect(() => { addEventRef.current = addEvent; }, [addEvent]); + const logEvent = useCallback( + (type: ConnectionEventType, details?: string, extra?: Partial) => { + try { addEventRef.current?.(type, details, extra); } catch {} + }, + [] + ); + // Helper: background-safe, optional notification for errors/info (NEW) const notifyInfo = useCallback(async (title: string, body: string) => { try { @@ -133,6 +175,27 @@ export const useAudioStreamer = (): UseAudioStreamer => { } }, []); + // Helper: re-authenticate via the central auth service (silent refresh from + // stored credentials), then rebuild the WebSocket URL with the fresh token. + const attemptReLogin = useCallback(async (): Promise => { + const newToken = await refreshToken(); + if (!newToken) { + console.warn('[AudioStreamer] Re-login failed (no token)'); + return false; + } + + options?.onTokenRefreshed?.(newToken); + + // Rebuild the current URL with the new token + currentUrlRef.current = currentUrlRef.current.replace( + /([?&])token=[^&]*/, + `$1token=${encodeURIComponent(newToken)}` + ); + + console.log('[AudioStreamer] Re-login successful, token refreshed'); + return true; + }, [options]); + // Helper: send Wyoming protocol events (UNCHANGED logic) const sendWyomingEvent = useCallback(async (event: WyomingEvent, payload?: Uint8Array) => { if (!websocketRef.current || websocketRef.current.readyState !== WebSocket.OPEN) { @@ -185,26 +248,37 @@ export const useAudioStreamer = (): UseAudioStreamer => { await stopForegroundServiceNotification(); }, [sendWyomingEvent, setStateSafe]); - // Reconnect (CHANGED): exponential backoff + no Alerts + optional notification on max attempts + // Reconnect (persistent): exponential backoff capped at MAX_RECONNECT_MS, and + // we NEVER permanently give up — giving up would also disable the NetInfo and + // AppState recovery paths. After MAX_RECONNECT_ATTEMPTS we keep retrying at the + // capped interval and notify the user once. const attemptReconnect = useCallback(() => { if (manuallyStoppedRef.current || !currentUrlRef.current) { console.log('[AudioStreamer] Not reconnecting: manually stopped or missing URL'); return; } - if (reconnectAttemptsRef.current >= MAX_RECONNECT_ATTEMPTS) { - console.log('[AudioStreamer] Reconnect attempts exhausted'); - notifyInfo('Connection lost', 'Failed to reconnect after multiple attempts.'); - manuallyStoppedRef.current = true; - setStateSafe(setIsStreaming, false); + // "Connect once" mode: don't auto-reconnect on drop. + if (!autoReconnectEnabledRef.current) { + console.log('[AudioStreamer] Auto-reconnect disabled (connect-once mode)'); setStateSafe(setIsConnecting, false); return; } const attempt = reconnectAttemptsRef.current + 1; - const delay = Math.min(MAX_RECONNECT_MS, BASE_RECONNECT_MS * Math.pow(2, reconnectAttemptsRef.current)); reconnectAttemptsRef.current = attempt; - console.log(`[AudioStreamer] Reconnect attempt ${attempt}/${MAX_RECONNECT_ATTEMPTS} in ${delay}ms`); + // Cap the exponent so the delay saturates at MAX_RECONNECT_MS rather than + // overflowing once attempts grow large. + const exponent = Math.min(attempt - 1, 10); + const delay = Math.min(MAX_RECONNECT_MS, BASE_RECONNECT_MS * Math.pow(2, exponent)); + + if (attempt > MAX_RECONNECT_ATTEMPTS && !exhaustedNotifiedRef.current) { + exhaustedNotifiedRef.current = true; + notifyInfo('Connection lost', 'Still trying to reconnect…'); + } + + console.log(`[AudioStreamer] Reconnect attempt ${attempt} in ${delay}ms`); + logEvent('ws_reconnect', `Attempt ${attempt} in ${Math.round(delay / 1000)}s`); if (reconnectTimeoutRef.current) clearTimeout(reconnectTimeoutRef.current); setStateSafe(setIsConnecting, true); @@ -218,7 +292,7 @@ export const useAudioStreamer = (): UseAudioStreamer => { }); } }, delay); - }, [notifyInfo, setStateSafe]); + }, [notifyInfo, setStateSafe, logEvent]); // Start (CHANGED): start/refresh FGS before connecting; remove Alerts; set heartbeat const startStreaming = useCallback(async (url: string): Promise => { @@ -231,12 +305,14 @@ export const useAudioStreamer = (): UseAudioStreamer => { currentUrlRef.current = trimmed; manuallyStoppedRef.current = false; + authFailedRef.current = false; // Network gate const netState = await NetInfo.fetch(); if (!netState.isConnected || !netState.isInternetReachable) { const errorMsg = 'No internet connection.'; setStateSafe(setError, errorMsg); + logEvent('ws_error', 'Connect aborted: no internet connection'); return Promise.reject(new Error(errorMsg)); } @@ -249,25 +325,39 @@ export const useAudioStreamer = (): UseAudioStreamer => { setStateSafe(setIsConnecting, true); setStateSafe(setError, null); + logEvent('ws_connecting', reconnectAttemptsRef.current > 0 ? 'Opening socket (reconnect)' : 'Opening socket'); + return new Promise((resolve, reject) => { try { const ws = new WebSocket(trimmed); ws.onopen = async () => { console.log('[AudioStreamer] WebSocket open'); + logEvent('ws_open', 'WebSocket connected'); websocketRef.current = ws; reconnectAttemptsRef.current = 0; + exhaustedNotifiedRef.current = false; + lastPongRef.current = Date.now(); // assume healthy at open setStateSafe(setIsConnecting, false); setStateSafe(setIsStreaming, true); setStateSafe(setError, null); - // Start heartbeat + // Start heartbeat. Each tick also checks for a half-open (zombie) + // socket: if the backend hasn't ponged within 2 heartbeats the TCP + // connection is dead even though readyState still reads OPEN, so we + // force-close to trigger onclose → reconnect. if (heartbeatRef.current) clearInterval(heartbeatRef.current); heartbeatRef.current = setInterval(() => { try { - if (websocketRef.current?.readyState === WebSocket.OPEN) { - websocketRef.current.send(JSON.stringify({ type: 'ping', t: Date.now() })); + const sock = websocketRef.current; + if (sock?.readyState !== WebSocket.OPEN) return; + if (lastPongRef.current && Date.now() - lastPongRef.current > 2 * HEARTBEAT_MS) { + console.warn('[AudioStreamer] No pong within 2 heartbeats; closing zombie socket'); + logEvent('ws_error', 'No pong within 2 heartbeats; closing zombie socket'); + try { sock.close(4000, 'zombie-no-pong'); } catch {} + return; } + sock.send(JSON.stringify({ type: 'ping', t: Date.now() })); } catch {} }, HEARTBEAT_MS); @@ -284,13 +374,38 @@ export const useAudioStreamer = (): UseAudioStreamer => { }; ws.onmessage = (event) => { - // Handle server messages if needed - console.log('[AudioStreamer] Message:', event.data); + // Parse server messages to detect auth errors + try { + const msg = JSON.parse(event.data); + // Heartbeat reply — proves the socket is alive end-to-end. + if (msg.type === 'pong') { + lastPongRef.current = Date.now(); + return; + } + if (msg.type === 'error' && (msg.error === 'token_expired' || msg.error === 'authentication_failed' || msg.error === 'user_not_found')) { + console.warn(`[AudioStreamer] Auth error from server: ${msg.error} — ${msg.message}`); + authFailedRef.current = true; + setStateSafe(setError, msg.message || 'Session expired. Re-authenticating...'); + return; + } + // Backend→device downlink: play synthesized audio (e.g. TTS reply) + // out of the phone speaker, just like the HAVPE relay does on-device. + if (msg.type === 'play-audio' && msg.data) { + playDownlinkAudio(msg.data).catch((e) => + console.warn('[AudioStreamer] Failed to play downlink audio:', e) + ); + return; + } + } catch { + // Not JSON, that's fine (e.g. binary messages) + } + console.log('[AudioStreamer] Message:', typeof event.data === 'string' ? event.data.substring(0, 200) : '(binary)'); }; ws.onerror = (e) => { const msg = (e as any).message || 'WebSocket connection error.'; console.error('[AudioStreamer] Error:', msg); + logEvent('ws_error', msg); setStateSafe(setError, msg); setStateSafe(setIsConnecting, false); setStateSafe(setIsStreaming, false); @@ -301,27 +416,58 @@ export const useAudioStreamer = (): UseAudioStreamer => { ws.onclose = (event) => { console.log('[AudioStreamer] Closed. Code:', event.code, 'Reason:', event.reason); const isManual = event.code === 1000 && event.reason === 'manual-stop'; + logEvent('ws_close', `Closed code=${event.code}${event.reason ? ` reason="${event.reason}"` : ''}${isManual ? ' (manual)' : ''}`); setStateSafe(setIsConnecting, false); setStateSafe(setIsStreaming, false); if (websocketRef.current === ws) websocketRef.current = null; + // Auth failure: try re-login instead of blind reconnect + if (authFailedRef.current && !manuallyStoppedRef.current && autoReconnectEnabledRef.current) { + authFailedRef.current = false; + console.log('[AudioStreamer] Auth failure detected, attempting re-login...'); + logEvent('ws_reauth', 'Auth failed; attempting re-login'); + setStateSafe(setError, 'Session expired. Re-authenticating...'); + attemptReLogin().then(success => { + if (success && currentUrlRef.current) { + console.log('[AudioStreamer] Re-login succeeded, reconnecting...'); + logEvent('ws_reauth', 'Re-login succeeded; reconnecting'); + reconnectAttemptsRef.current = 0; + startStreaming(currentUrlRef.current).catch(err => { + console.error('[AudioStreamer] Reconnect after re-login failed:', err); + setStateSafe(setError, 'Re-authentication succeeded but reconnection failed.'); + }); + } else { + console.warn('[AudioStreamer] Re-login failed. Please log in manually.'); + logEvent('ws_reauth', 'Re-login failed; manual login required'); + setStateSafe(setError, 'Session expired. Please log in again in Settings.'); + notifyInfo('Session Expired', 'Please open the app and log in again.'); + } + }); + return; + } + if (!isManual && !manuallyStoppedRef.current) { - setStateSafe(setError, 'Connection closed; attempting to reconnect.'); - attemptReconnect(); + if (autoReconnectEnabledRef.current) { + setStateSafe(setError, 'Connection closed; attempting to reconnect.'); + attemptReconnect(); + } else { + setStateSafe(setError, 'Connection closed.'); + } } }; } catch (e) { const msg = (e as any).message || 'Failed to create WebSocket.'; console.error('[AudioStreamer] Create WS error:', msg); + logEvent('ws_error', `Failed to create WebSocket: ${msg}`); setStateSafe(setError, msg); setStateSafe(setIsConnecting, false); setStateSafe(setIsStreaming, false); reject(new Error(msg)); } }); - }, [attemptReconnect, sendWyomingEvent, setStateSafe, stopStreaming]); + }, [attemptReconnect, attemptReLogin, notifyInfo, sendWyomingEvent, setStateSafe, stopStreaming, logEvent]); const sendAudio = useCallback(async (audioBytes: Uint8Array) => { if (websocketRef.current && websocketRef.current.readyState === WebSocket.OPEN && audioBytes.length > 0) { @@ -349,6 +495,13 @@ export const useAudioStreamer = (): UseAudioStreamer => { useEffect(() => { const sub = NetInfo.addEventListener(state => { const online = !!state.isConnected && !!state.isInternetReachable; + + // Log only real transitions so the diagnostics aren't flooded. + if (lastNetOnlineRef.current !== online) { + lastNetOnlineRef.current = online; + logEvent('net_change', online ? `Network online (${state.type})` : `Network offline (${state.type})`); + } + if (online && !manuallyStoppedRef.current) { // If socket isn’t open, try to reconnect with backoff const ready = websocketRef.current?.readyState; @@ -359,7 +512,23 @@ export const useAudioStreamer = (): UseAudioStreamer => { } }); return () => sub(); - }, [attemptReconnect]); + }, [attemptReconnect, logEvent]); + + /** App-lifecycle reconnect: on return to foreground, if a session is intended + * but the socket isn't open (the JS VM may have been suspended in background), + * reconnect. */ + useEffect(() => { + const subscription = AppState.addEventListener('change', nextState => { + if (nextState !== 'active') return; + if (manuallyStoppedRef.current || !currentUrlRef.current) return; + if (websocketRef.current?.readyState !== WebSocket.OPEN) { + console.log('[AudioStreamer] Foregrounded; scheduling reconnect'); + logEvent('ws_reconnect', 'App foregrounded; socket not open, reconnecting'); + attemptReconnect(); + } + }); + return () => subscription.remove(); + }, [attemptReconnect, logEvent]); /** Cleanup on unmount (CHANGED): don’t auto-stop streaming; just clear timers */ useEffect(() => { diff --git a/app/src/hooks/useAudioStreamingOrchestrator.ts b/app/src/hooks/useAudioStreamingOrchestrator.ts index bff49ee5..bc151044 100644 --- a/app/src/hooks/useAudioStreamingOrchestrator.ts +++ b/app/src/hooks/useAudioStreamingOrchestrator.ts @@ -17,11 +17,16 @@ interface OrchestratorParams { }; phoneAudioRecorder: { isRecording: boolean; - startRecording: (onData: (pcmBuffer: Uint8Array) => Promise) => Promise; + startRecording: ( + onData: (pcmBuffer: Uint8Array) => Promise, + options?: { deviceId?: string } + ) => Promise; stopRecording: () => Promise; }; originalStartAudioListener: (onAudioData: (bytes: Uint8Array) => void) => Promise; originalStopAudioListener: () => Promise; + /** Resolves which input device to record from (undefined = system default mic). */ + resolvePhoneInputDeviceId?: () => Promise; settings: AppSettings; } @@ -40,6 +45,7 @@ export const useAudioStreamingOrchestrator = ({ phoneAudioRecorder, originalStartAudioListener, originalStopAudioListener, + resolvePhoneInputDeviceId, settings, }: OrchestratorParams): AudioOrchestrator => { const [isPhoneAudioMode, setIsPhoneAudioMode] = useState(false); @@ -124,13 +130,15 @@ export const useAudioStreamingOrchestrator = ({ try { const finalUrl = buildPhoneWebSocketUrl(settings.webSocketUrl); + // Resolve the input device (auto-prefers Bluetooth headset mic) before recording. + const deviceId = resolvePhoneInputDeviceId ? await resolvePhoneInputDeviceId() : undefined; await audioStreamer.startStreaming(finalUrl); await phoneAudioRecorder.startRecording(async (pcmBuffer) => { const wsReady = audioStreamer.getWebSocketReadyState(); if (wsReady === WebSocket.OPEN && pcmBuffer.length > 0) { await audioStreamer.sendAudio(pcmBuffer); } - }); + }, { deviceId }); setIsPhoneAudioMode(true); } catch (error) { Alert.alert('Error', 'Could not start phone audio streaming.'); @@ -138,7 +146,7 @@ export const useAudioStreamingOrchestrator = ({ if (phoneAudioRecorder.isRecording) await phoneAudioRecorder.stopRecording(); setIsPhoneAudioMode(false); } - }, [audioStreamer, phoneAudioRecorder, settings.webSocketUrl, buildPhoneWebSocketUrl]); + }, [audioStreamer, phoneAudioRecorder, settings.webSocketUrl, buildPhoneWebSocketUrl, resolvePhoneInputDeviceId]); const handleStopPhoneAudioStreaming = useCallback(async () => { await phoneAudioRecorder.stopRecording(); diff --git a/app/src/hooks/useAutoReconnect.ts b/app/src/hooks/useAutoReconnect.ts index c45a1b62..902c5d48 100644 --- a/app/src/hooks/useAutoReconnect.ts +++ b/app/src/hooks/useAutoReconnect.ts @@ -1,4 +1,5 @@ import { useState, useEffect, useCallback, useRef } from 'react'; +import { AppState } from 'react-native'; import { State as BluetoothState } from 'react-native-ble-plx'; import { saveLastConnectedDeviceId, getLastConnectedDeviceId } from '../utils/storage'; import { useConnectionLog } from '../contexts/ConnectionLogContext'; @@ -17,6 +18,8 @@ interface UseAutoReconnectParams { disconnectFromDevice: () => Promise; }; scanning: boolean; + /** When false, the device connects once and does NOT auto-reconnect on drop/launch. */ + autoReconnectEnabled?: boolean; } export interface AutoReconnectState { @@ -36,6 +39,7 @@ export const useAutoReconnect = ({ permissionGranted, deviceConnection, scanning, + autoReconnectEnabled = true, }: UseAutoReconnectParams): AutoReconnectState => { const [lastKnownDeviceId, setLastKnownDeviceId] = useState(null); const [isAttemptingAutoReconnect, setIsAttemptingAutoReconnect] = useState(false); @@ -51,6 +55,27 @@ export const useAutoReconnect = ({ const retryTimerRef = useRef | null>(null); const countdownTimerRef = useRef | null>(null); const prevConnectedRef = useRef(null); + // Mirror of lastKnownDeviceId for use inside async timers (avoids stale closures + // and lets a scheduled retry detect that the user cleared/changed the device). + const lastKnownDeviceIdRef = useRef(null); + // Self-reference so a failed retry can reschedule itself for persistent retry. + const scheduleRetryRef = useRef<((deviceId: string, isQuickFailure: boolean) => void) | null>(null); + // User preference mirror for use inside async timers / mount-only listeners. + const autoReconnectEnabledRef = useRef(autoReconnectEnabled); + useEffect(() => { + autoReconnectEnabledRef.current = autoReconnectEnabled; + }, [autoReconnectEnabled]); + + // Mirror of the connected device id for use inside the (mount-only) AppState listener. + const connectedDeviceIdRef = useRef(null); + + useEffect(() => { + lastKnownDeviceIdRef.current = lastKnownDeviceId; + }, [lastKnownDeviceId]); + + useEffect(() => { + connectedDeviceIdRef.current = deviceConnection.connectedDeviceId; + }, [deviceConnection.connectedDeviceId]); const clearRetryTimers = useCallback(() => { if (retryTimerRef.current) { @@ -71,62 +96,52 @@ export const useAutoReconnect = ({ clearRetryTimers(); }, [clearRetryTimers]); - // Load last device on mount - useEffect(() => { - const load = async () => { - const deviceId = await getLastConnectedDeviceId(); - if (deviceId) { - setLastKnownDeviceId(deviceId); - setTriedAutoReconnectForCurrentId(false); - } else { - setLastKnownDeviceId(null); - setTriedAutoReconnectForCurrentId(true); + // Schedule a (re)connect attempt with capped backoff. Used by BOTH the + // disconnect-recovery path and the launch-reconnect path. On failure it + // reschedules itself, so a single failed attempt never permanently gives up + // and never forgets the saved device — only explicit user action clears it. + const scheduleRetry = useCallback( + (deviceId: string, isQuickFailure: boolean) => { + if (!deviceId) return; + // "Connect once" mode: don't auto-reconnect on drop/launch failure. + if (!autoReconnectEnabledRef.current) { + clearRetryTimers(); + return; } - }; - load(); - }, []); - // Track connection start/end for backoff calculation - useEffect(() => { - const currentConnected = deviceConnection.connectedDeviceId; - const prevConnected = prevConnectedRef.current; - prevConnectedRef.current = currentConnected; - - // Connection established - if (currentConnected && !prevConnected) { - connectionStartTimeRef.current = Date.now(); - clearRetryTimers(); - return; - } - - // Connection lost (unexpected disconnect) - if (!currentConnected && prevConnected && lastKnownDeviceId) { - const startTime = connectionStartTimeRef.current; - connectionStartTimeRef.current = null; - const duration = startTime ? Date.now() - startTime : 0; - - if (duration >= MIN_HEALTHY_DURATION) { - // Healthy connection — reset backoff - backoffMsRef.current = 0; - setConnectionRetryCount(0); + // Adjust backoff: a quick failure (or a never-connected launch attempt) + // grows the delay; a healthy connection that dropped resets it. + if (isQuickFailure) { + backoffMsRef.current = + backoffMsRef.current === 0 + ? BACKOFF_INITIAL + : Math.min(backoffMsRef.current * 2, BACKOFF_MAX); } else { - // Quick failure — increase backoff - if (backoffMsRef.current === 0) { - backoffMsRef.current = BACKOFF_INITIAL; - } else { - backoffMsRef.current = Math.min(backoffMsRef.current * 2, BACKOFF_MAX); - } + backoffMsRef.current = 0; } const delay = backoffMsRef.current; - const deviceId = lastKnownDeviceId; - addEvent('reconnect_backoff', `Scheduling retry in ${delay / 1000}s (device: ${deviceId})`, { deviceId }); + addEvent( + 'reconnect_backoff', + `Scheduling retry in ${delay / 1000}s (device: ${deviceId})`, + { deviceId } + ); + + // Replace any in-flight timers before scheduling a new attempt. + if (retryTimerRef.current) { + clearTimeout(retryTimerRef.current); + retryTimerRef.current = null; + } + if (countdownTimerRef.current) { + clearInterval(countdownTimerRef.current); + countdownTimerRef.current = null; + } setIsRetryingConnection(true); setRetryBackoffSeconds(Math.ceil(delay / 1000)); setConnectionRetryCount(c => c + 1); - // Countdown timer for UI + // Countdown timer for UI. const countdownEnd = Date.now() + delay; countdownTimerRef.current = setInterval(() => { const remaining = Math.max(0, Math.ceil((countdownEnd - Date.now()) / 1000)); @@ -137,7 +152,6 @@ export const useAutoReconnect = ({ } }, 1000); - // Schedule reconnect retryTimerRef.current = setTimeout(async () => { retryTimerRef.current = null; if (countdownTimerRef.current) { @@ -145,31 +159,82 @@ export const useAutoReconnect = ({ countdownTimerRef.current = null; } - if (!deviceId) { + // Honor cancellation: the user may have cleared/changed the device while + // we were waiting (handleCancelAutoReconnect / explicit disconnect). + if (lastKnownDeviceIdRef.current !== deviceId) { setIsRetryingConnection(false); + setRetryBackoffSeconds(0); return; } setIsAttemptingAutoReconnect(true); setIsRetryingConnection(false); setRetryBackoffSeconds(0); - addEvent('reconnect_attempt', `Retrying connection to ${deviceId} (attempt ${connectionRetryCount})`, { deviceId }); + addEvent('reconnect_attempt', `Retrying connection to ${deviceId}`, { deviceId }); try { await deviceConnection.connectToDevice(deviceId); } catch (error) { console.error(`[AutoReconnect] Retry failed for ${deviceId}:`, error); - // Let the next disconnect cycle handle further retries + // Persistent retry: reschedule (unless the user cleared the device). + if (lastKnownDeviceIdRef.current === deviceId) { + scheduleRetryRef.current?.(deviceId, true); + } } finally { setIsAttemptingAutoReconnect(false); } }, delay); + }, + [addEvent, deviceConnection] + ); + + useEffect(() => { + scheduleRetryRef.current = scheduleRetry; + }, [scheduleRetry]); + + // Load last device on mount + useEffect(() => { + const load = async () => { + const deviceId = await getLastConnectedDeviceId(); + if (deviceId) { + setLastKnownDeviceId(deviceId); + setTriedAutoReconnectForCurrentId(false); + } else { + setLastKnownDeviceId(null); + setTriedAutoReconnectForCurrentId(true); + } + }; + load(); + }, []); + + // Track connection start/end for backoff calculation + useEffect(() => { + const currentConnected = deviceConnection.connectedDeviceId; + const prevConnected = prevConnectedRef.current; + prevConnectedRef.current = currentConnected; + + // Connection established + if (currentConnected && !prevConnected) { + connectionStartTimeRef.current = Date.now(); + clearRetryTimers(); + return; + } + + // Connection lost (unexpected disconnect) — schedule a recovery attempt. + // scheduleRetry handles backoff growth and persistent self-rescheduling. + if (!currentConnected && prevConnected && lastKnownDeviceId) { + const startTime = connectionStartTimeRef.current; + connectionStartTimeRef.current = null; + const duration = startTime ? Date.now() - startTime : 0; + const isQuickFailure = duration < MIN_HEALTHY_DURATION; + scheduleRetryRef.current?.(lastKnownDeviceId, isQuickFailure); } }, [deviceConnection.connectedDeviceId]); // Auto-reconnect on app launch (existing behavior) useEffect(() => { if ( + autoReconnectEnabled && bluetoothState === BluetoothState.PoweredOn && permissionGranted && lastKnownDeviceId && @@ -188,8 +253,11 @@ export const useAutoReconnect = ({ await deviceConnection.connectToDevice(lastKnownDeviceId); } catch (error) { console.error(`[AutoReconnect] Error reconnecting to ${lastKnownDeviceId}:`, error); - await saveLastConnectedDeviceId(null); - setLastKnownDeviceId(null); + // Persistent retry: do NOT forget the device on a single failure (it may + // simply be out of range at launch). Schedule a backoff retry instead; + // only explicit user action (handleCancelAutoReconnect / Disconnect) + // clears the saved device. + scheduleRetryRef.current?.(lastKnownDeviceId, true); } finally { setIsAttemptingAutoReconnect(false); } @@ -197,6 +265,7 @@ export const useAutoReconnect = ({ attemptAutoConnect(); } }, [ + autoReconnectEnabled, bluetoothState, permissionGranted, lastKnownDeviceId, deviceConnection.connectedDeviceId, deviceConnection.isConnecting, scanning, deviceConnection.connectToDevice, @@ -215,6 +284,22 @@ export const useAutoReconnect = ({ setIsAttemptingAutoReconnect(false); }, [deviceConnection, lastKnownDeviceId, resetBackoff]); + // Reconnect on return to foreground. On iOS the JS runtime suspends in the + // background; on resume nothing would otherwise re-establish the BLE link. + // Resetting triedAutoReconnectForCurrentId lets the launch-reconnect effect + // re-fire for a known-but-disconnected device. + useEffect(() => { + const subscription = AppState.addEventListener('change', nextState => { + if (nextState !== 'active') return; + if (!autoReconnectEnabledRef.current) return; + const deviceId = lastKnownDeviceIdRef.current; + if (deviceId && !connectedDeviceIdRef.current) { + setTriedAutoReconnectForCurrentId(false); + } + }); + return () => subscription.remove(); + }, []); + // Cleanup timers on unmount useEffect(() => { return () => { diff --git a/app/src/hooks/usePhoneAudioDevices.ts b/app/src/hooks/usePhoneAudioDevices.ts new file mode 100644 index 00000000..a4e8d128 --- /dev/null +++ b/app/src/hooks/usePhoneAudioDevices.ts @@ -0,0 +1,122 @@ +// usePhoneAudioDevices.ts +// Enumerates available audio INPUT devices (phone mic, Bluetooth headset, wired, etc.) +// and tracks which one to use for phone-audio streaming. +// +// Selection model: +// - 'auto' → prefer an available Bluetooth input if present, else system default mic. +// - → use that specific device when available, else fall back to system default. +import { useState, useRef, useEffect, useCallback } from 'react'; +import { audioDeviceManager } from '@siteed/expo-audio-studio'; +import type { AudioDevice } from '@siteed/expo-audio-studio'; + +export const AUTO_DEVICE_ID = 'auto'; + +/** Heuristic: is this input device a Bluetooth headset/earbud mic? */ +export const isBluetoothInput = (device: AudioDevice): boolean => { + const type = (device.type || '').toLowerCase(); + const name = (device.name || '').toLowerCase(); + return ( + type.includes('bluetooth') || + type.includes('bt') || + name.includes('bluetooth') || + name.includes('airpod') || + name.includes('headset') || + name.includes('headphone') || + name.includes('buds') || + name.includes('hands-free') + ); +}; + +/** + * Resolve the device that should actually be used given the current selection. + * Returns null when the system default mic should be used (no explicit deviceId). + */ +export const pickPreferredDevice = ( + devices: AudioDevice[], + selectedDeviceId: string +): AudioDevice | null => { + if (selectedDeviceId && selectedDeviceId !== AUTO_DEVICE_ID) { + const explicit = devices.find((d) => d.id === selectedDeviceId && d.isAvailable); + if (explicit) return explicit; + // Selected device is gone — fall through to auto behaviour. + } + const bluetooth = devices.find((d) => isBluetoothInput(d) && d.isAvailable); + if (bluetooth) return bluetooth; + return null; // system default +}; + +export interface UsePhoneAudioDevices { + devices: AudioDevice[]; + selectedDeviceId: string; + setSelectedDeviceId: (id: string) => void; + /** The device 'auto' would resolve to right now (for UI display), or null for default mic. */ + effectiveDevice: AudioDevice | null; + loading: boolean; + refresh: () => Promise; + /** Resolve the deviceId to pass to the recorder at start time (undefined = system default). */ + resolveEffectiveDeviceId: () => Promise; +} + +export const usePhoneAudioDevices = (): UsePhoneAudioDevices => { + const [devices, setDevices] = useState([]); + const [selectedDeviceId, setSelectedDeviceId] = useState(AUTO_DEVICE_ID); + const [loading, setLoading] = useState(false); + + const selectedRef = useRef(selectedDeviceId); + const mountedRef = useRef(true); + useEffect(() => { + selectedRef.current = selectedDeviceId; + }, [selectedDeviceId]); + + const refresh = useCallback(async (): Promise => { + setLoading(true); + try { + const list = await audioDeviceManager.getAvailableDevices({ refresh: true }); + if (mountedRef.current) setDevices(list); + return list; + } catch (error) { + console.warn('[PhoneAudioDevices] Failed to enumerate devices:', error); + return []; + } finally { + if (mountedRef.current) setLoading(false); + } + }, []); + + useEffect(() => { + mountedRef.current = true; + refresh(); + // React to devices connecting/disconnecting (e.g. Bluetooth headset paired while app open) + const removeListener = audioDeviceManager.addDeviceChangeListener((list) => { + if (mountedRef.current) setDevices(list); + }); + return () => { + mountedRef.current = false; + removeListener(); + }; + }, [refresh]); + + const resolveEffectiveDeviceId = useCallback(async (): Promise => { + // Refresh right before starting so we pick up a just-connected headset. + let list = devices; + try { + list = await audioDeviceManager.getAvailableDevices({ refresh: true }); + if (mountedRef.current) setDevices(list); + } catch (error) { + console.warn('[PhoneAudioDevices] resolve refresh failed, using cached list:', error); + } + const picked = pickPreferredDevice(list, selectedRef.current); + return picked?.id; + }, [devices]); + + const effectiveDevice = pickPreferredDevice(devices, selectedDeviceId); + + return { + devices, + selectedDeviceId, + setSelectedDeviceId, + effectiveDevice, + loading, + refresh, + resolveEffectiveDeviceId, + }; +}; diff --git a/app/src/hooks/usePhoneAudioRecorder.ts b/app/src/hooks/usePhoneAudioRecorder.ts index 25bc3755..b091bdd3 100644 --- a/app/src/hooks/usePhoneAudioRecorder.ts +++ b/app/src/hooks/usePhoneAudioRecorder.ts @@ -12,12 +12,20 @@ import type { AudioDataEvent } from '@siteed/expo-audio-studio'; import base64 from 'react-native-base64'; +interface StartRecordingOptions { + /** Specific input device to record from. Omit/undefined to use the system default mic. */ + deviceId?: string; +} + interface UsePhoneAudioRecorder { isRecording: boolean; isInitializing: boolean; error: string | null; audioLevel: number; - startRecording: (onAudioData: (pcmBuffer: Uint8Array) => void) => Promise; + startRecording: ( + onAudioData: (pcmBuffer: Uint8Array) => void, + options?: StartRecordingOptions + ) => Promise; stopRecording: () => Promise; } @@ -119,7 +127,10 @@ export const usePhoneAudioRecorder = (): UsePhoneAudioRecorder => { }, []); // Start recording from phone microphone - EXACT 2025 guide pattern - const startRecording = useCallback(async (onAudioData: (pcmBuffer: Uint8Array) => void): Promise => { + const startRecording = useCallback(async ( + onAudioData: (pcmBuffer: Uint8Array) => void, + options?: StartRecordingOptions + ): Promise => { if (isRecording) { console.log('[PhoneAudioRecorder] Already recording, stopping first...'); await stopRecording(); @@ -136,7 +147,10 @@ export const usePhoneAudioRecorder = (): UsePhoneAudioRecorder => { throw new Error('Microphone permission denied'); } - console.log('[PhoneAudioRecorder] Starting audio recording...'); + console.log( + '[PhoneAudioRecorder] Starting audio recording...', + options?.deviceId ? `deviceId=${options.deviceId}` : 'deviceId=default' + ); // EXACT config from 2025 guide + processing for audio levels const config = { @@ -146,6 +160,19 @@ export const usePhoneAudioRecorder = (): UsePhoneAudioRecorder => { encoding: "pcm_16bit" as const, enableProcessing: true, // Enable audio analysis for live RMS intervalAnalysis: 500, // Analysis every 500ms + // Route to the chosen input device (e.g. a Bluetooth headset mic). When the + // device drops mid-stream, fall back to the default mic instead of stopping. + ...(options?.deviceId ? { deviceId: options.deviceId } : {}), + deviceDisconnectionBehavior: 'fallback' as const, + // iOS requires PlayAndRecord + AllowBluetooth for the OS to route input to a + // Bluetooth (HFP/SCO) headset mic; without it iOS keeps the built-in mic. + ios: { + audioSession: { + category: 'PlayAndRecord' as const, + mode: 'Default' as const, + categoryOptions: ['AllowBluetooth', 'DefaultToSpeaker'] as ('AllowBluetooth' | 'DefaultToSpeaker')[], + }, + }, onAudioStream: async (event: AudioDataEvent) => { // EXACT payload handling from guide const payload = typeof event.data === "string" diff --git a/app/src/services/auth.ts b/app/src/services/auth.ts new file mode 100644 index 00000000..285f78b5 --- /dev/null +++ b/app/src/services/auth.ts @@ -0,0 +1,192 @@ +// auth.ts — central authentication / token manager. +// +// Single source of truth for obtaining a *valid* JWT. JWTs are short-lived +// (1h), so "we have a token" is not the same as "we are logged in". Everything +// that needs auth should go through getValidToken()/fetchAuthed() so a token +// that has expired (or a 401) is silently refreshed from stored credentials — +// the user only sees a real failure if re-login itself fails. +// +// Framework-agnostic (no React) so hooks, screens and background tasks can all +// share it. + +import { + getAuthEmail, + getAuthPassword, + getWebSocketUrl, + getJwtToken, + saveAuthEmail, + saveAuthPassword, + saveJwtToken, + clearToken, + clearAuthData, +} from '../utils/storage'; + +/** Convert a ws(s):// or http(s):// URL (optionally ending in /ws) to an HTTP base URL. */ +export const deriveBaseUrl = (url: string): string => { + return url + .replace('ws://', 'http://') + .replace('wss://', 'https://') + .split('/ws')[0]; +}; + +/** Best-effort decode of a JWT's `exp` (epoch seconds). Returns null if undecodable. */ +const getTokenExpiry = (token: string): number | null => { + try { + const payload = token.split('.')[1]; + if (!payload) return null; + // base64url → base64 + const base64 = payload.replace(/-/g, '+').replace(/_/g, '/'); + const json = typeof atob === 'function' ? atob(base64) : null; + if (!json) return null; + const claims = JSON.parse(json); + return typeof claims.exp === 'number' ? claims.exp : null; + } catch { + return null; + } +}; + +/** True if the token is missing or within `skewSeconds` of expiry. */ +export const isTokenExpired = (token: string | null, skewSeconds = 60): boolean => { + if (!token) return true; + const exp = getTokenExpiry(token); + if (exp === null) return false; // can't tell → assume usable, rely on 401 retry + return Date.now() / 1000 >= exp - skewSeconds; +}; + +/** Listeners notified whenever a fresh token is obtained (login or refresh). */ +type TokenListener = (token: string) => void; +const tokenListeners = new Set(); + +export const onTokenRefreshed = (listener: TokenListener): (() => void) => { + tokenListeners.add(listener); + return () => tokenListeners.delete(listener); +}; + +const notifyToken = (token: string) => { + tokenListeners.forEach(l => { + try { + l(token); + } catch (e) { + console.warn('[Auth] token listener error:', e); + } + }); +}; + +// De-dupe concurrent refreshes: many callers may hit a 401 at once. +let refreshInFlight: Promise | null = null; + +/** + * Authenticate with the backend and persist email/password/token. + * Returns the new token. Throws on failure (used by the interactive login form). + */ +export const login = async ( + email: string, + password: string, + backendUrl: string +): Promise => { + const baseUrl = deriveBaseUrl(backendUrl); + const loginUrl = `${baseUrl}/auth/jwt/login`; + const body = `username=${encodeURIComponent(email)}&password=${encodeURIComponent(password)}`; + + const response = await fetch(loginUrl, { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body, + }); + + if (!response.ok) { + const text = await response.text().catch(() => ''); + throw new Error(`Login failed: ${response.status} ${response.statusText} ${text}`.trim()); + } + + const data = await response.json(); + const token = data.access_token; + if (!token) throw new Error('No access token received from server'); + + await saveAuthEmail(email); + await saveAuthPassword(password); + await saveJwtToken(token); + notifyToken(token); + return token; +}; + +/** + * Silently re-authenticate using stored credentials. Returns the new token, or + * null if credentials are missing or re-login fails. Concurrent callers share a + * single in-flight request. + */ +export const refreshToken = async (): Promise => { + if (refreshInFlight) return refreshInFlight; + + refreshInFlight = (async () => { + try { + const email = await getAuthEmail(); + const password = await getAuthPassword(); + const wsUrl = await getWebSocketUrl(); + if (!email || !password || !wsUrl) { + console.log('[Auth] Cannot refresh: missing stored credentials'); + return null; + } + const token = await login(email, password, wsUrl); + console.log('[Auth] Token refreshed'); + return token; + } catch (e) { + console.warn('[Auth] Token refresh failed:', e); + return null; + } finally { + refreshInFlight = null; + } + })(); + + return refreshInFlight; +}; + +/** + * Return a token that is valid right now: the stored token if it's not near + * expiry, otherwise a freshly refreshed one. Returns null only if we have no + * way to authenticate. + */ +export const getValidToken = async (): Promise => { + const token = await getJwtToken(); + if (token && !isTokenExpired(token)) return token; + return await refreshToken(); +}; + +/** + * fetch() wrapper that attaches a valid bearer token and transparently retries + * once on a 401 after refreshing. Use for ALL authenticated API calls so the UI + * never has to think about token expiry. + */ +export const fetchAuthed = async ( + input: string, + init: RequestInit = {} +): Promise => { + const withAuth = async (token: string | null): Promise => { + const headers = new Headers(init.headers || {}); + if (token) headers.set('Authorization', `Bearer ${token}`); + return fetch(input, { ...init, headers }); + }; + + let token = await getValidToken(); + let response = await withAuth(token); + + if (response.status === 401) { + // Token rejected despite our expiry check — force a refresh and retry once. + token = await refreshToken(); + if (token) { + response = await withAuth(token); + } + } + + return response; +}; + +/** Log out: drop the token but keep email + password for one-tap re-login. */ +export const logout = async (): Promise => { + await clearToken(); +}; + +/** Forget account: clear email, password and token. */ +export const forgetAccount = async (): Promise => { + await clearAuthData(); +}; diff --git a/app/src/services/serviceManager.ts b/app/src/services/serviceManager.ts new file mode 100644 index 00000000..c3e12ad4 --- /dev/null +++ b/app/src/services/serviceManager.ts @@ -0,0 +1,255 @@ +// serviceManager.ts — client for the host service-manager agent (edge/service_manager.py). +// +// Two transports, auto-selected: +// • via backend proxy (/api/admin/services*) using the existing admin JWT — +// works whenever the backend is up, needs no SM token. +// • direct to the SM agent (host:8775) using a stored SM token — the only path +// that works when the BACKEND ITSELF is down (i.e. the "start the backend" +// case). The SM URL is taken from storage, or derived from the backend host +// on :8775 and confirmed via the unauthed /health probe. + +import { fetchAuthed, deriveBaseUrl } from './auth'; +import { getServiceManagerUrl, getServiceManagerToken } from '../utils/storage'; + +export interface ServiceInfo { + name: string; + // The agent reports health as a string, not a running boolean. + health?: 'healthy' | 'partial' | 'starting' | 'unhealthy' | 'stopped' | string; + enabled?: boolean; + health_detail?: string; + node?: string; + remote?: boolean; + provider?: unknown; + description?: string; + ports?: unknown; + [key: string]: unknown; +} + +export interface ServicesResult { + available: boolean; + reason?: string; + services?: ServiceInfo[]; + [key: string]: unknown; +} + +export interface ServiceOperation { + id?: string; + node?: string | null; + status?: string; + [key: string]: unknown; +} + +export interface ServiceActionResult { + operation?: ServiceOperation; + [key: string]: unknown; +} + +/** Derive the SM URL from a backend URL by swapping to its host on :8775. + * The agent serves plain HTTP on the tailnet (no TLS), so we always use http. */ +export const deriveServiceManagerUrl = (backendUrl: string): string | null => { + try { + const base = deriveBaseUrl(backendUrl); + const u = new URL(base); + return `http://${u.hostname}:8775`; + } catch { + return null; + } +}; + +/** Resolve the SM base URL: stored value first, else derived from the backend. */ +const resolveSmUrl = async (backendUrl: string): Promise => { + const stored = await getServiceManagerUrl(); + if (stored) return stored.replace(/\/+$/, ''); + return deriveServiceManagerUrl(backendUrl); +}; + +/** True if the SM agent answers its unauthed /health. */ +export const isServiceManagerReachable = async (backendUrl: string): Promise => { + const smUrl = await resolveSmUrl(backendUrl); + if (!smUrl) return false; + try { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), 6000); + const resp = await fetch(`${smUrl}/health`, { signal: controller.signal }); + clearTimeout(timer); + return resp.ok; + } catch { + return false; + } +}; + +const directHeaders = (token: string): HeadersInit => ({ + 'Content-Type': 'application/json', + Authorization: `Bearer ${token}`, +}); + +interface NodeInfo { + host?: string; + tailscale?: { dns?: string | null; ip?: string | null }; +} + +/** Probe a candidate backend base URL's /health (short timeout). */ +const probeHealth = async (baseUrl: string): Promise => { + try { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), 5000); + const resp = await fetch(`${baseUrl}/health`, { signal: controller.signal }); + clearTimeout(timer); + // 200 (healthy) or 401/403 (reachable but needs auth) both prove the host. + return resp.ok || resp.status === 401 || resp.status === 403; + } catch { + return false; + } +}; + +/** + * Recover the backend HTTP base URL when it's been lost but the service-manager + * URL + token survive. The SM host IS the backend host, so we ask the SM /node + * for its canonical Tailscale name/IP and probe the usual backend addresses + * (HTTPS on :443, HTTP on :8000) until one answers /health. Returns the working + * HTTP base URL, or null if recovery isn't possible. + * + * The token alone cannot recover anything — it carries no address — which is why + * we always persist the SM URL beside it. + */ +export const recoverBackendUrl = async (): Promise => { + const smUrl = (await getServiceManagerUrl())?.replace(/\/+$/, ''); + const token = await getServiceManagerToken(); + if (!smUrl) return null; + + // Candidate hosts, best first: the SM URL's own host always works as a + // fallback; /node may add the canonical MagicDNS name and tailnet IP. + const hosts: string[] = []; + try { + hosts.push(new URL(smUrl).hostname); + } catch { + // ignore malformed SM URL + } + + if (token) { + try { + const resp = await fetch(`${smUrl}/node`, { headers: directHeaders(token) }); + if (resp.ok) { + const node = (await resp.json()) as NodeInfo; + const dns = node.tailscale?.dns; + const ip = node.tailscale?.ip; + if (dns) hosts.unshift(dns); + if (ip) hosts.push(ip); + } + } catch (e) { + console.log('[ServiceManager] /node lookup failed during recovery:', e); + } + } + + // De-dupe while preserving order, then probe candidate URLs. + const seen = new Set(); + for (const host of hosts) { + if (!host || seen.has(host)) continue; + seen.add(host); + for (const candidate of [`https://${host}`, `http://${host}:8000`]) { + if (await probeHealth(candidate)) { + console.log('[ServiceManager] Recovered backend URL:', candidate); + return candidate; + } + } + } + + return null; +}; + +/** + * List host-managed services. Prefers the backend proxy; on proxy failure (e.g. + * backend down) falls back to a direct SM call when a token is available. + */ +export const listServices = async (backendUrl: string): Promise => { + const base = deriveBaseUrl(backendUrl); + + // Transport 1: backend proxy (admin JWT). + try { + const resp = await fetchAuthed(`${base}/api/admin/services`); + if (resp.ok) { + return (await resp.json()) as ServicesResult; + } + } catch (e) { + console.log('[ServiceManager] proxy listServices failed, trying direct:', e); + } + + // Transport 2: direct to SM (needs URL + token). + const smUrl = await resolveSmUrl(backendUrl); + const token = await getServiceManagerToken(); + if (smUrl && token) { + const resp = await fetch(`${smUrl}/services`, { headers: directHeaders(token) }); + if (resp.ok) { + return { available: true, ...(await resp.json()) }; + } + return { available: false, reason: `sm_http_${resp.status}` }; + } + + return { available: false, reason: 'unreachable' }; +}; + +/** Start/stop/restart a service. Proxy first, direct fallback. */ +export const serviceAction = async ( + backendUrl: string, + name: string, + action: 'start' | 'stop' | 'restart', + node?: string +): Promise => { + const base = deriveBaseUrl(backendUrl); + const body = JSON.stringify(node ? { node } : {}); + + try { + const resp = await fetchAuthed(`${base}/api/admin/services/${name}/${action}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body, + }); + if (resp.ok) return (await resp.json()) as ServiceActionResult; + // A 4xx/5xx from a reachable backend is a real error — surface it. + if (resp.status !== 502 && resp.status !== 503) { + throw new Error(`Service action failed (HTTP ${resp.status})`); + } + } catch (e) { + console.log('[ServiceManager] proxy serviceAction failed, trying direct:', e); + } + + const smUrl = await resolveSmUrl(backendUrl); + const token = await getServiceManagerToken(); + if (smUrl && token) { + const resp = await fetch(`${smUrl}/services/${name}/${action}`, { + method: 'POST', + headers: directHeaders(token), + body, + }); + if (resp.ok) return (await resp.json()) as ServiceActionResult; + throw new Error(`Service action failed (HTTP ${resp.status})`); + } + + throw new Error('Cannot reach the service manager (no stored token).'); +}; + +/** Poll a long-running start/stop/build operation. Proxy first, direct fallback. */ +export const getOperation = async ( + backendUrl: string, + operationId: string, + node?: string | null +): Promise => { + const base = deriveBaseUrl(backendUrl); + const query = node ? `?node=${encodeURIComponent(node)}` : ''; + + try { + const resp = await fetchAuthed(`${base}/api/admin/services/operations/${operationId}${query}`); + if (resp.ok) return (await resp.json()) as ServiceOperation; + } catch (e) { + console.log('[ServiceManager] proxy getOperation failed, trying direct:', e); + } + + const smUrl = await resolveSmUrl(backendUrl); + const token = await getServiceManagerToken(); + if (smUrl && token) { + const resp = await fetch(`${smUrl}/operations/${operationId}`, { headers: directHeaders(token) }); + if (resp.ok) return (await resp.json()) as ServiceOperation; + } + + throw new Error('Cannot poll operation status.'); +}; diff --git a/app/src/utils/audioPlayback.ts b/app/src/utils/audioPlayback.ts new file mode 100644 index 00000000..bf3624ca --- /dev/null +++ b/app/src/utils/audioPlayback.ts @@ -0,0 +1,116 @@ +// audioPlayback.ts +// +// Plays "downlink" audio that the backend pushes to a connected device over the +// audio WebSocket. This mirrors what the HAVPE relay does: the backend publishes +// a `play-audio` control frame to `device:downlink:{client_id}`, the WebSocket +// controller forwards it to the device, and the device plays the audio locally. +// +// On the phone the audio is delivered inline as base64-encoded WAV bytes +// (`{ type: "play-audio", data: { audio_b64, format } }`). We stage the bytes to a +// temp file in the cache directory and play them through the speaker with +// expo-audio. + +import { createAudioPlayer, setAudioModeAsync, type AudioPlayer } from 'expo-audio'; +import { File, Paths } from 'expo-file-system'; + +export interface PlayAudioData { + /** Base64-encoded audio bytes (e.g. a synthesized TTS reply). */ + audio_b64?: string; + /** Optional URL to fetch audio from instead of inline bytes. */ + url?: string; + /** Container format / file extension (defaults to "wav"). */ + format?: string; +} + +// Configure the audio session once. We want playback to come out of the speaker +// while the microphone is simultaneously recording for the streaming session. +let audioModeConfigured = false; +async function ensureAudioMode(): Promise { + if (audioModeConfigured) return; + try { + await setAudioModeAsync({ + playsInSilentMode: true, // iOS: play even when the ringer switch is silenced + allowsRecording: true, // iOS: keep mic capture working during playback + shouldRouteThroughEarpiece: false, // Android: route to the loudspeaker + }); + audioModeConfigured = true; + } catch (e) { + console.warn('[AudioPlayback] Failed to configure audio mode:', e); + } +} + +// Monotonic counter so concurrent/overlapping replies don't clobber each other's +// temp files before playback finishes. +let fileSeq = 0; + +/** + * Play a backend `play-audio` downlink message through the phone speaker. + * Fire-and-forget: errors are logged, never thrown. + */ +export async function playDownlinkAudio(data: PlayAudioData): Promise { + const { audio_b64, url, format = 'wav' } = data || {}; + + let source: string | null = null; + let tempFile: File | null = null; + + if (audio_b64) { + try { + const file = new File(Paths.cache, `downlink_${Date.now()}_${fileSeq++}.${format}`); + if (file.exists) file.delete(); + file.write(audio_b64, { encoding: 'base64' }); + tempFile = file; + source = file.uri; + } catch (e) { + console.warn('[AudioPlayback] Failed to stage downlink audio bytes:', e); + return; + } + } else if (url) { + source = url; + } else { + console.warn('[AudioPlayback] play-audio message had no audio_b64 or url'); + return; + } + + await ensureAudioMode(); + + let player: AudioPlayer; + try { + player = createAudioPlayer(source); + } catch (e) { + console.warn('[AudioPlayback] Failed to create audio player:', e); + if (tempFile) { + try { + tempFile.delete(); + } catch {} + } + return; + } + + const cleanup = () => { + try { + player.remove(); + } catch {} + if (tempFile) { + try { + tempFile.delete(); + } catch {} + tempFile = null; + } + }; + + try { + const sub = player.addListener('playbackStatusUpdate', (status) => { + if (status.didJustFinish) { + try { + sub.remove(); + } catch {} + cleanup(); + } + }); + console.log(`[AudioPlayback] ▶️ Playing downlink audio (${format}) from ${source}`); + player.play(); + } catch (e) { + console.warn('[AudioPlayback] Failed to play downlink audio:', e); + cleanup(); + } +} diff --git a/app/src/utils/deviceType.ts b/app/src/utils/deviceType.ts index e0648e4c..e1a07481 100644 --- a/app/src/utils/deviceType.ts +++ b/app/src/utils/deviceType.ts @@ -3,6 +3,6 @@ export type DeviceType = 'neo' | 'omi' | 'unknown'; export function detectDeviceType(name: string | null): DeviceType { const lower = (name || '').toLowerCase(); if (lower.includes('neo')) return 'neo'; - if (lower.includes('omi') || lower.includes('friend')) return 'omi'; + if (lower.includes('omi') || lower.includes('friend') || lower.includes('elato')) return 'omi'; return 'unknown'; } diff --git a/app/src/utils/logger.ts b/app/src/utils/logger.ts new file mode 100644 index 00000000..a61ad76d --- /dev/null +++ b/app/src/utils/logger.ts @@ -0,0 +1,173 @@ +import * as FileSystem from 'expo-file-system/legacy'; +import * as Updates from 'expo-updates'; +import { Platform } from 'react-native'; +import Constants from 'expo-constants'; + +const LOG_DIR = `${FileSystem.documentDirectory}chronicle-logs/`; +const LOG_FILE = `${LOG_DIR}chronicle-log.txt`; +const LOG_FILE_OLD = `${LOG_DIR}chronicle-log.old.txt`; +const MAX_LOG_BYTES = 1_000_000; + +type Level = 'INFO' | 'WARN' | 'ERROR' | 'FATAL'; + +let initialized = false; +let writeQueue: Promise = Promise.resolve(); +let sessionId = ''; + +function ts(): string { + return new Date().toISOString(); +} + +function formatLine(level: Level, tag: string, msg: string): string { + return `${ts()} [${level}] [${tag}] ${msg}\n`; +} + +async function rotateIfNeeded(): Promise { + try { + const info = await FileSystem.getInfoAsync(LOG_FILE); + if (info.exists && !info.isDirectory && (info.size ?? 0) > MAX_LOG_BYTES) { + const oldInfo = await FileSystem.getInfoAsync(LOG_FILE_OLD); + if (oldInfo.exists) { + await FileSystem.deleteAsync(LOG_FILE_OLD, { idempotent: true }); + } + await FileSystem.moveAsync({ from: LOG_FILE, to: LOG_FILE_OLD }); + } + } catch { + // Best-effort rotation; never block logging + } +} + +function enqueueWrite(line: string): void { + writeQueue = writeQueue + .then(async () => { + await rotateIfNeeded(); + const info = await FileSystem.getInfoAsync(LOG_FILE); + if (info.exists) { + const existing = await FileSystem.readAsStringAsync(LOG_FILE); + await FileSystem.writeAsStringAsync(LOG_FILE, existing + line); + } else { + await FileSystem.writeAsStringAsync(LOG_FILE, line); + } + }) + .catch((err) => { + // Last-resort fallback so we don't kill the promise chain + console.warn('[logger] write failed', err); + }); +} + +export function log(level: Level, tag: string, msg: string): void { + const line = formatLine(level, tag, msg); + // Always mirror to console so Metro/devtools see it too + if (level === 'ERROR' || level === 'FATAL') console.error(line.trim()); + else if (level === 'WARN') console.warn(line.trim()); + else console.log(line.trim()); + if (!initialized) return; + enqueueWrite(line); +} + +export const logInfo = (tag: string, msg: string) => log('INFO', tag, msg); +export const logWarn = (tag: string, msg: string) => log('WARN', tag, msg); +export const logError = (tag: string, msg: string) => log('ERROR', tag, msg); + +export function getLogPath(): string { + return LOG_FILE; +} + +export function getOldLogPath(): string { + return LOG_FILE_OLD; +} + +async function ensureDir(): Promise { + const info = await FileSystem.getInfoAsync(LOG_DIR); + if (!info.exists) { + await FileSystem.makeDirectoryAsync(LOG_DIR, { intermediates: true }); + } +} + +function describeUpdatesState(): string { + try { + const parts: string[] = [ + `isEmbeddedLaunch=${Updates.isEmbeddedLaunch}`, + `updateId=${Updates.updateId ?? 'null'}`, + `channel=${Updates.channel ?? 'null'}`, + `runtimeVersion=${Updates.runtimeVersion ?? 'null'}`, + `createdAt=${Updates.createdAt ? Updates.createdAt.toISOString() : 'null'}`, + ]; + return parts.join(' '); + } catch (err) { + return `describeUpdatesState error: ${String(err)}`; + } +} + +function installGlobalErrorHandler(): void { + const g: any = global as any; + const prev = g.ErrorUtils?.getGlobalHandler?.(); + g.ErrorUtils?.setGlobalHandler?.((error: Error, isFatal?: boolean) => { + try { + const msg = `${isFatal ? 'FATAL' : 'NON-FATAL'} uncaught JS error: ${error?.name}: ${error?.message}\nstack: ${error?.stack ?? 'no stack'}`; + log(isFatal ? 'FATAL' : 'ERROR', 'GlobalError', msg); + } catch { + // swallow — nothing we can do here + } + if (prev) prev(error, isFatal); + }); + + const rejectionTracking = (g as any).HermesInternal; + // Unhandled promise rejection — RN exposes via process in newer versions + if (typeof g.addEventListener === 'function') { + g.addEventListener('unhandledrejection', (ev: any) => { + try { + const reason = ev?.reason; + const msg = `Unhandled promise rejection: ${reason?.message ?? String(reason)}\nstack: ${reason?.stack ?? 'no stack'}`; + log('ERROR', 'UnhandledRejection', msg); + } catch { + // ignore + } + }); + } +} + +export async function initLogger(): Promise { + if (initialized) return; + try { + await ensureDir(); + initialized = true; + sessionId = Math.random().toString(36).slice(2, 10); + const header = [ + '', + '==================== NEW SESSION ====================', + `sessionId=${sessionId}`, + `time=${ts()}`, + `platform=${Platform.OS} ${Platform.Version}`, + `appVersion=${Constants.expoConfig?.version ?? 'unknown'}`, + `nativeAppVersion=${(Constants as any).nativeAppVersion ?? 'unknown'}`, + `nativeBuildVersion=${(Constants as any).nativeBuildVersion ?? 'unknown'}`, + `updates: ${describeUpdatesState()}`, + '=====================================================', + '', + ].join('\n'); + enqueueWrite(header); + installGlobalErrorHandler(); + } catch (err) { + console.warn('[logger] init failed', err); + } +} + +export async function readLog(): Promise { + try { + const info = await FileSystem.getInfoAsync(LOG_FILE); + if (!info.exists) return ''; + return await FileSystem.readAsStringAsync(LOG_FILE); + } catch (err) { + return `failed to read log: ${String(err)}`; + } +} + +export async function clearLog(): Promise { + try { + await FileSystem.deleteAsync(LOG_FILE, { idempotent: true }); + await FileSystem.deleteAsync(LOG_FILE_OLD, { idempotent: true }); + } catch (err) { + console.warn('[logger] clear failed', err); + } +} diff --git a/app/src/utils/storage.ts b/app/src/utils/storage.ts index c61e78bd..9ef3e79d 100644 --- a/app/src/utils/storage.ts +++ b/app/src/utils/storage.ts @@ -1,12 +1,48 @@ import AsyncStorage from '@react-native-async-storage/async-storage'; +import { Platform } from 'react-native'; +import * as SecureStore from 'expo-secure-store'; const LAST_CONNECTED_DEVICE_ID_KEY = 'LAST_CONNECTED_DEVICE_ID'; const WEBSOCKET_URL_KEY = 'WEBSOCKET_URL_KEY'; const DEEPGRAM_API_KEY_KEY = 'DEEPGRAM_API_KEY_KEY'; const USER_ID_KEY = 'USER_ID_KEY'; const AUTH_EMAIL_KEY = 'AUTH_EMAIL_KEY'; +const SERVICE_MANAGER_URL_KEY = 'SERVICE_MANAGER_URL_KEY'; +const AUTO_RECONNECT_ENABLED_KEY = 'AUTO_RECONNECT_ENABLED_KEY'; +// SecureStore keys must be alphanumeric + ._- (no other punctuation). const AUTH_PASSWORD_KEY = 'AUTH_PASSWORD_KEY'; const JWT_TOKEN_KEY = 'JWT_TOKEN_KEY'; +const SERVICE_MANAGER_TOKEN_KEY = 'SERVICE_MANAGER_TOKEN_KEY'; + +/** + * Secret storage helpers. Secrets (password, JWT) live in the OS keychain via + * expo-secure-store on native. SecureStore is unavailable on web, so there we + * fall back to AsyncStorage (the web build is dev-only). + */ +const secureAvailable = Platform.OS !== 'web'; + +const secureSet = async (key: string, value: string): Promise => { + if (secureAvailable) { + await SecureStore.setItemAsync(key, value); + } else { + await AsyncStorage.setItem(key, value); + } +}; + +const secureGet = async (key: string): Promise => { + if (secureAvailable) { + return await SecureStore.getItemAsync(key); + } + return await AsyncStorage.getItem(key); +}; + +const secureRemove = async (key: string): Promise => { + if (secureAvailable) { + await SecureStore.deleteItemAsync(key); + } else { + await AsyncStorage.removeItem(key); + } +}; export const saveLastConnectedDeviceId = async (deviceId: string | null): Promise => { try { @@ -139,14 +175,14 @@ export const getAuthEmail = async (): Promise => { } }; -// Authentication Password +// Authentication Password (SecureStore-backed) export const saveAuthPassword = async (password: string | null): Promise => { try { if (password) { - await AsyncStorage.setItem(AUTH_PASSWORD_KEY, password); + await secureSet(AUTH_PASSWORD_KEY, password); console.log('[Storage] Auth password saved.'); // Don't log password for security } else { - await AsyncStorage.removeItem(AUTH_PASSWORD_KEY); + await secureRemove(AUTH_PASSWORD_KEY); console.log('[Storage] Auth password removed.'); } } catch (error) { @@ -156,7 +192,7 @@ export const saveAuthPassword = async (password: string | null): Promise = export const getAuthPassword = async (): Promise => { try { - const password = await AsyncStorage.getItem(AUTH_PASSWORD_KEY); + const password = await secureGet(AUTH_PASSWORD_KEY); if (password) { console.log('[Storage] Retrieved auth password.'); } @@ -167,14 +203,14 @@ export const getAuthPassword = async (): Promise => { } }; -// JWT Token +// JWT Token (SecureStore-backed) export const saveJwtToken = async (token: string | null): Promise => { try { if (token) { - await AsyncStorage.setItem(JWT_TOKEN_KEY, token); + await secureSet(JWT_TOKEN_KEY, token); console.log('[Storage] JWT token saved.'); } else { - await AsyncStorage.removeItem(JWT_TOKEN_KEY); + await secureRemove(JWT_TOKEN_KEY); console.log('[Storage] JWT token removed.'); } } catch (error) { @@ -184,7 +220,7 @@ export const saveJwtToken = async (token: string | null): Promise => { export const getJwtToken = async (): Promise => { try { - const token = await AsyncStorage.getItem(JWT_TOKEN_KEY); + const token = await secureGet(JWT_TOKEN_KEY); if (token) { console.log('[Storage] Retrieved JWT token.'); } @@ -195,13 +231,92 @@ export const getJwtToken = async (): Promise => { } }; -// Clear all authentication data +// Auto-reconnect preference: true (default) = stay connected / persistent +// reconnect; false = connect once (no auto-reconnect on drop). +export const saveAutoReconnectEnabled = async (enabled: boolean): Promise => { + try { + await AsyncStorage.setItem(AUTO_RECONNECT_ENABLED_KEY, enabled ? '1' : '0'); + } catch (error) { + console.error('[Storage] Error saving auto-reconnect preference:', error); + } +}; + +export const getAutoReconnectEnabled = async (): Promise => { + try { + const v = await AsyncStorage.getItem(AUTO_RECONNECT_ENABLED_KEY); + return v === null ? true : v === '1'; // default ON + } catch (error) { + console.error('[Storage] Error retrieving auto-reconnect preference:', error); + return true; + } +}; + +// Service Manager URL (non-secret; derivable from backend host but persisted +// when delivered via QR so "start the backend" works when the backend is down). +export const saveServiceManagerUrl = async (url: string | null): Promise => { + try { + if (url) { + await AsyncStorage.setItem(SERVICE_MANAGER_URL_KEY, url); + console.log('[Storage] Service manager URL saved:', url); + } else { + await AsyncStorage.removeItem(SERVICE_MANAGER_URL_KEY); + } + } catch (error) { + console.error('[Storage] Error saving service manager URL:', error); + } +}; + +export const getServiceManagerUrl = async (): Promise => { + try { + return await AsyncStorage.getItem(SERVICE_MANAGER_URL_KEY); + } catch (error) { + console.error('[Storage] Error retrieving service manager URL:', error); + return null; + } +}; + +// Service Manager bearer token (secret → SecureStore). +export const saveServiceManagerToken = async (token: string | null): Promise => { + try { + if (token) { + await secureSet(SERVICE_MANAGER_TOKEN_KEY, token); + console.log('[Storage] Service manager token saved.'); + } else { + await secureRemove(SERVICE_MANAGER_TOKEN_KEY); + } + } catch (error) { + console.error('[Storage] Error saving service manager token:', error); + } +}; + +export const getServiceManagerToken = async (): Promise => { + try { + return await secureGet(SERVICE_MANAGER_TOKEN_KEY); + } catch (error) { + console.error('[Storage] Error retrieving service manager token:', error); + return null; + } +}; + +// Log out: clear the token only. Email and password are kept so re-login is one +// tap (and silent refresh can re-authenticate). Use clearAuthData() to fully +// forget the account. +export const clearToken = async (): Promise => { + try { + await secureRemove(JWT_TOKEN_KEY); + console.log('[Storage] JWT token cleared (logout).'); + } catch (error) { + console.error('[Storage] Error clearing token:', error); + } +}; + +// Forget account: clear ALL authentication data (email + password + token). export const clearAuthData = async (): Promise => { try { await Promise.all([ AsyncStorage.removeItem(AUTH_EMAIL_KEY), - AsyncStorage.removeItem(AUTH_PASSWORD_KEY), - AsyncStorage.removeItem(JWT_TOKEN_KEY) + secureRemove(AUTH_PASSWORD_KEY), + secureRemove(JWT_TOKEN_KEY), ]); console.log('[Storage] All auth data cleared.'); } catch (error) { diff --git a/app/src/utils/urlConversion.ts b/app/src/utils/urlConversion.ts index 530e53fc..ca0809b6 100644 --- a/app/src/utils/urlConversion.ts +++ b/app/src/utils/urlConversion.ts @@ -26,6 +26,51 @@ export function httpUrlToWebSocketUrl(httpUrl: string): string { return url } +/** A backend configuration parsed from a scanned QR code. */ +export interface ScannedBackendConfig { + /** HTTP(S) base URL of the backend. */ + backendUrl: string + /** Optional service-manager URL (lets the app start a down backend). */ + serviceManagerUrl?: string + /** Optional service-manager bearer token. */ + smToken?: string +} + +/** + * Parse a scanned QR payload. Supports two formats: + * 1. A bare HTTP(S) URL (legacy). + * 2. A JSON bundle: { backendUrl, serviceManagerUrl?, smToken? }. + * Returns null if the payload contains no valid backend URL. + */ +export function parseScannedConfig(data: string): ScannedBackendConfig | null { + const trimmed = (data || '').trim() + if (!trimmed) return null + + // Try JSON bundle first. + if (trimmed.startsWith('{')) { + try { + const obj = JSON.parse(trimmed) + if (obj && typeof obj.backendUrl === 'string' && isValidBackendUrl(obj.backendUrl)) { + return { + backendUrl: obj.backendUrl.trim(), + serviceManagerUrl: + typeof obj.serviceManagerUrl === 'string' ? obj.serviceManagerUrl.trim() : undefined, + smToken: typeof obj.smToken === 'string' ? obj.smToken : undefined, + } + } + return null + } catch { + return null + } + } + + // Fall back to a plain URL. + if (isValidBackendUrl(trimmed)) { + return { backendUrl: trimmed } + } + return null +} + /** * Validates that a scanned string looks like a valid HTTP(S) backend URL. */ diff --git a/backends/README.md b/backends/README.md index 9b1af2bc..6ee1c429 100644 --- a/backends/README.md +++ b/backends/README.md @@ -11,7 +11,7 @@ Full-featured backend with comprehensive AI capabilities: - Speaker recognition and enrollment - Web UI for management and monitoring - RESTful API with WebSocket support -- MongoDB and Qdrant integration +- MongoDB and FalkorDB integration ### [Simple Backend](simple/) Lightweight backend for basic audio capture: diff --git a/backends/advanced/.env.template b/backends/advanced/.env.template index 8da68285..34d13dfb 100644 --- a/backends/advanced/.env.template +++ b/backends/advanced/.env.template @@ -51,10 +51,9 @@ HF_TOKEN= # Optional Services # ======================================== -# Neo4j configuration (if using Neo4j for Obsidian or Knowledge Graph) -NEO4J_HOST=neo4j -NEO4J_USER=neo4j -NEO4J_PASSWORD= +# FalkorDB configuration (if using FalkorDB for Obsidian or Knowledge Graph) +FALKORDB_HOST=falkordb +FALKORDB_PORT=6379 # Langfuse (for LLM observability and prompt management) LANGFUSE_HOST= @@ -74,3 +73,32 @@ GALILEO_LOG_STREAM=default # Tailscale auth key (for remote service access) TS_AUTHKEY= + +# --- Vault Sync (Syncthing) --------------------------------------------------- +# Shares your Obsidian vault (data/conversation_docs/{user_id}) with your Mac so you +# can open it in Obsidian. Enable with: docker compose --profile vault-sync up -d +# API key the backend uses to drive the server Syncthing (any long random string). +VAULT_SYNC_API_KEY= +# Address your Mac dials to reach this server's Syncthing, host:port on port 22000. +# Usually your backend's Tailscale name, e.g. tcp://my-host.ts.net:22000 +# Leave empty to rely on Syncthing's own discovery/relays. +VAULT_SYNC_ADDRESS= +# Match the owner of data/conversation_docs so Syncthing can read/write it (0 = root). +# VAULT_SYNC_PUID=0 +# VAULT_SYNC_PGID=0 + +# --- Service Manager (start/stop services from the WebUI) --------------------- +# Host-side agent (edge/service_manager.py) that the System page uses to +# start/stop/restart services and switch ASR/TTS providers. Started +# automatically by ./start.sh; the token is auto-generated on first start. +# Only set the URL if the agent runs on a different machine (distributed setup). +# SERVICE_MANAGER_URL=http://host.docker.internal:8775 +# SERVICE_MANAGER_TOKEN= + +# --- Cross-service error reporting (System Errors page) ----------------------- +# Sidecar services (ASR, speaker-recognition) can POST their ERROR/CRITICAL logs +# to POST /api/admin/system-events/ingest so they appear on the admin "System +# Errors" page. That endpoint is gated by a shared token. By default it reuses +# SERVICE_MANAGER_TOKEN above; set this only to use a dedicated secret. The same +# value goes in each service's CHRONICLE_INGEST_TOKEN. +# SYSTEM_EVENT_INGEST_TOKEN= diff --git a/backends/advanced/Caddyfile.template b/backends/advanced/Caddyfile.template index 46e1f3a9..ab2f07e3 100644 --- a/backends/advanced/Caddyfile.template +++ b/backends/advanced/Caddyfile.template @@ -7,14 +7,10 @@ # 3. Browser will warn about self-signed cert - accept it # 4. Microphone access will now work via HTTPS # -# NOTE: If using Caddy, update docker-compose.yml webui build args: -# VITE_BACKEND_URL: "" (empty for same-origin through Caddy) -# -# For production, replace 'localhost' with your domain name and Caddy +# For a real domain, replace 'localhost' with your domain name and Caddy # will automatically obtain Let's Encrypt certificates. localhost TAILSCALE_IP { - tls /certs/server.crt /certs/server.key # WebSocket endpoints - proxy to backend with upgrade support handle /ws* { @@ -55,52 +51,12 @@ localhost TAILSCALE_IP { reverse_proxy chronicle-backend:8000 } - # Everything else - proxy to webui + # Everything else -> dev webui (Vite, hot reload). The HMR websocket is + # proxied transparently (Caddy auto-upgrades); VITE_HMR_PORT=443 makes the + # browser connect HMR over wss through Caddy. This is the default so HTTPS + + # hot reload work together with no prod build/rebuild. (The wizard already + # configures webui-dev for same-origin + HMR when HTTPS is enabled.) handle { - reverse_proxy webui:80 + reverse_proxy webui-dev:5173 } } - -# Production configuration (uncomment and modify for your domain) -# yourdomain.com { -# # Caddy automatically obtains Let's Encrypt certificates -# -# # WebSocket endpoints -# handle /ws* { -# reverse_proxy chronicle-backend:8000 -# } -# -# # API endpoints -# handle /api/* { -# reverse_proxy chronicle-backend:8000 -# } -# -# # Auth endpoints -# handle /auth/* { -# reverse_proxy chronicle-backend:8000 -# } -# -# # Health checks -# handle /health { -# reverse_proxy chronicle-backend:8000 -# } -# -# handle /readiness { -# reverse_proxy chronicle-backend:8000 -# } -# -# # Users endpoints -# handle /users/* { -# reverse_proxy chronicle-backend:8000 -# } -# -# # Audio files -# handle /audio/* { -# reverse_proxy chronicle-backend:8000 -# } -# -# # Everything else - webui -# handle { -# reverse_proxy webui:80 -# } -# } diff --git a/backends/advanced/Dockerfile b/backends/advanced/Dockerfile index 7208e696..d83df3d1 100644 --- a/backends/advanced/Dockerfile +++ b/backends/advanced/Dockerfile @@ -20,11 +20,25 @@ WORKDIR /app # Copy dependency files first (cache-friendly) COPY pyproject.toml uv.lock ./ -# Export locked deps to requirements.txt (handles extras, git sources, custom indexes) -# Install to system Python (no venv) - container IS the isolation +# Build a project .venv from the lockfile (includes git deps like Graphiti) RUN --mount=type=cache,target=/root/.cache/uv \ - uv export --frozen --no-dev --extra deepgram --extra galileo --no-emit-project -o requirements.txt && \ - uv pip install --system -r requirements.txt + uv sync --frozen --no-dev --extra deepgram --extra galileo --extra benchmark --no-install-project + + +# ============================================ +# notesmd-cli builder - vault note rename/move with backlink rewrite +# ============================================ +# The memory agent uses `notesmd-cli move` to rename a person note and rewrite every +# [[wikilink]] to it across the vault in one shot (verified non-destructive), and the +# fork adds `create --template` (seed a note from a Templates/ note + {{date}}/{{title}}). +# Pinned to the commit vendored in untracked/; vendor/ in the repo means the build is hermetic. +FROM golang:1.25-bookworm AS notesmd-builder +ARG NOTESMD_REPO=https://github.com/AnkushMalaker/notesmd-cli +ARG NOTESMD_REF=2566ac2 +RUN git clone ${NOTESMD_REPO} /src \ + && cd /src \ + && git checkout ${NOTESMD_REF} \ + && CGO_ENABLED=0 go build -trimpath -mod=vendor -o /out/notesmd-cli . # ============================================ @@ -36,22 +50,29 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ libsndfile1 \ curl \ ffmpeg \ + ripgrep \ + libc++1 \ && rm -rf /var/lib/apt/lists/* +# notesmd-cli on PATH so the memory agent auto-detects it (shutil.which). +COPY --from=notesmd-builder /out/notesmd-cli /usr/local/bin/notesmd-cli + WORKDIR /app -# Copy installed packages from builder -COPY --from=builder /usr/local/lib/python3.12/site-packages /usr/local/lib/python3.12/site-packages -COPY --from=builder /usr/local/bin /usr/local/bin +# Copy locked virtual environment from builder +COPY --from=builder /app/.venv /app/.venv -# Source layout needs PYTHONPATH -ENV PYTHONPATH=/app/src +# Source layout + venv defaults for runtime shells and commands +ENV VIRTUAL_ENV=/app/.venv +ENV PATH="/app/.venv/bin:${PATH}" +ENV PYTHONPATH=/app/src:/app # Copy application code COPY . . -# Copy configuration files if they exist -COPY diarization_config.json* ./ +# diarization_config.json (if present) is already included by "COPY . ." above. +# A separate optional-glob COPY ("json*") errors under buildah/podman when the +# file is absent, and is redundant under docker — so it's intentionally omitted. # Copy and make startup script executable COPY start.sh ./ @@ -69,27 +90,34 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ libsndfile1 \ curl \ ffmpeg \ + ripgrep \ + libc++1 \ build-essential \ git \ && rm -rf /var/lib/apt/lists/* +# notesmd-cli on PATH so the memory agent auto-detects it (shutil.which). +COPY --from=notesmd-builder /out/notesmd-cli /usr/local/bin/notesmd-cli + WORKDIR /app -# For dev, install deps + test group using uv temporarily +# For dev, create project .venv with test deps COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /bin/ COPY pyproject.toml uv.lock ./ RUN --mount=type=cache,target=/root/.cache/uv \ - uv export --frozen --extra deepgram --extra galileo --group test --no-emit-project -o requirements.txt && \ - uv pip install --system -r requirements.txt && \ + uv sync --frozen --extra deepgram --extra galileo --extra benchmark --group test --no-install-project && \ rm /bin/uv /bin/uvx -ENV PYTHONPATH=/app/src +ENV VIRTUAL_ENV=/app/.venv +ENV PATH="/app/.venv/bin:${PATH}" +ENV PYTHONPATH=/app/src:/app # Copy application code COPY . . -# Copy configuration files if they exist -COPY diarization_config.json* ./ +# diarization_config.json (if present) is already included by "COPY . ." above. +# A separate optional-glob COPY ("json*") errors under buildah/podman when the +# file is absent, and is redundant under docker — so it's intentionally omitted. # Copy and make startup script executable COPY start.sh ./ diff --git a/backends/advanced/Docs/README.md b/backends/advanced/Docs/README.md index c79c29c6..09bc934a 100644 --- a/backends/advanced/Docs/README.md +++ b/backends/advanced/Docs/README.md @@ -27,10 +27,10 @@ Welcome to chronicle! This guide provides the optimal reading sequence to unders ## 🔧 **Core Components Deep Dive** ### 3. **[Memory System](./memories.md)** -**Memory extraction and semantic search** +**Memory extraction and vault retrieval** - How conversations become memories -- Mem0 integration and vector storage +- Agentic Markdown vault (the single source of truth) - Configuration and customization options - **Code References**: - `src/advanced_omi_backend/memory/memory_service.py` (main processing) @@ -138,7 +138,7 @@ backends/advanced-backend/ - **Memory Trigger**: Memory processing in `src/advanced_omi_backend/controllers/memory_controller.py` ### **Data Storage** -- **Memories**: `src/advanced_omi_backend/memory/memory_service.py` → Mem0 → Qdrant +- **Memories**: `src/advanced_omi_backend/services/memory/providers/chronicle.py` → agentic Markdown vault at `data/conversation_docs//` (the single source of truth) ### **Configuration** - **Loading**: `src/advanced_omi_backend/model_registry.py` diff --git a/backends/advanced/Docs/UI.md b/backends/advanced/Docs/UI.md index 66d9e316..5559b452 100644 --- a/backends/advanced/Docs/UI.md +++ b/backends/advanced/Docs/UI.md @@ -40,27 +40,21 @@ The Chronicle web dashboard provides a comprehensive interface for managing conv - Advanced filtering and search capabilities ### 2. Memories Tab -**Purpose**: Browse and search extracted conversation memories with advanced filtering +**Purpose**: Browse and search the agentic Markdown vault (Obsidian-style notes that are the single source of truth for memories) **Core Search Features**: -- **Text Search**: Traditional keyword-based memory filtering -- **Semantic Search Button**: AI-powered contextual memory search with relevance scoring -- **Dual-layer Filtering**: Combine semantic results with text search for precise filtering -- **Relevance Threshold Slider**: 0-100% threshold to filter semantic results by confidence -- **Active Filter Indicators**: Visual feedback showing active semantic filters with clear buttons -- **Memory Count Display**: Shows "X of Y memories" with total count from native providers +- **Text Search**: Keyword-based filtering of vault notes +- **Vault Search Button**: AI-powered search that runs a read-only retrieval agent (ripgrep over the vault) and returns a synthesized answer plus cited note paths +- **Memory Count Display**: Shows the notes/memories surfaced from the vault **Advanced Features**: -- **Live Filtering**: Real-time results as threshold slider moves -- **Reset Functionality**: Clear all filters and return to full memory list -- Memory categorization and tagging -- Temporal filtering and sorting -- Memory source tracking (which conversation) +- **Reset Functionality**: Clear filters and return to the full memory list +- Memory source tracking (which conversation / note) - Export capabilities **UI Improvements**: -- **Search Input Enhancement**: Semantic search button integrated into search field -- **Visual Feedback**: Loading states, relevance scores, and filter status indicators +- **Search Input Enhancement**: Vault search button integrated into search field +- **Visual Feedback**: Loading states and cited note paths - **Responsive Design**: Works across desktop and mobile devices **Admin Features**: @@ -117,7 +111,6 @@ The Chronicle web dashboard provides a comprehensive interface for managing conv - **Queue Statistics**: Processing queue metrics, backlogs, and throughput - **Service Health**: Real-time health checks for all dependencies: - MongoDB connectivity and response times - - Qdrant vector database status and performance - Ollama/OpenAI API availability and model status - ASR service connectivity and transcription status - **Recovery Metrics**: Automatic recovery attempts and success rates @@ -238,7 +231,7 @@ ADMIN_PASSWORD=your-admin-password #### API Errors - Check backend logs for detailed error information -- Verify all required services are running (MongoDB, Qdrant, etc.) +- Verify all required services are running (MongoDB, Redis, etc.) - Test API endpoints directly with curl for debugging ### Debug Steps diff --git a/backends/advanced/Docs/architecture.md b/backends/advanced/Docs/architecture.md index 2d7474f2..da52b6a1 100644 --- a/backends/advanced/Docs/architecture.md +++ b/backends/advanced/Docs/architecture.md @@ -77,8 +77,8 @@ graph TB CONV_REPO[ConversationRepository
Clean Data Access] end - subgraph "Qdrant" - MEM[Memory Vectors] + subgraph "Markdown Vault" + MEM[conversation_docs
Obsidian-style notes] end end @@ -140,7 +140,7 @@ graph TB CONV_REPO -->|access| AC_COL MP --> OAI MP --> OLL - MP -->|orchestrate by mem0| MEM + MP -->|memory agent writes notes| MEM MP -->|update via| CONV_REPO %% Cropping Flow @@ -194,8 +194,8 @@ graph TB #### React Dashboard (`webui/`) - **Modern Interface**: Complete React/TypeScript web-based management interface -- **Advanced Memory Search**: Semantic search with relevance threshold filtering and live results -- **Memory Count Display**: Total count tracking with dual-layer filtering capabilities +- **Advanced Memory Search**: Agentic vault search (ripgrep over notes, synthesized answer with cited note paths) +- **Memory Count Display**: Notes/memories surfaced from the vault - **Authentication Integration**: Login with backend JWT tokens and user management - **Real-Time Monitoring**: Live client status and conversation management - **Live Recording**: Real-time audio recording with WebSocket streaming (HTTPS) @@ -545,11 +545,10 @@ stateDiagram-v2 #### Memory Management (`src/advanced_omi_backend/memory/memory_service.py`) - **User-Centric Storage**: All memories keyed by database user_id (not client_id) -- **Conversation Summarization**: Automatic memory extraction using mem0 framework -- **Vector Storage**: Semantic memory search with Qdrant embeddings -- **Client Metadata**: Client information stored in memory metadata for reference -- **User Isolation**: Complete data separation between users via user_id -- **Temporal Memory**: Long-term conversation history with semantic retrieval +- **Agentic Vault Writes**: A tool-calling memory agent records each conversation as `Conversations/.md` and edits `People/.md`, `Topics/.md`, and `/.md` notes +- **Single Source of Truth**: The Markdown vault at `data/conversation_docs//` is the only memory store +- **Agentic Retrieval**: A read-only retrieval agent drives ripgrep (grep/glob/read_note) over the vault and synthesizes an answer with cited note paths +- **User Isolation**: Complete data separation between users via per-user vault folders - **Processing Trigger**: Conversation end events processed by `src/advanced_omi_backend/controllers/memory_controller.py` > 📖 **Read more**: [Memory System Documentation](./memories.md) for detailed memory extraction and storage @@ -589,7 +588,7 @@ graph LR WebUI[webui
React Dashboard] Proxy[nginx
Load Balancer] Mongo[mongo:4.4.18
Primary Database] - Qdrant[qdrant
Vector Store] + Vault[Markdown Vault
conversation_docs] end subgraph "External Services" @@ -609,7 +608,7 @@ graph LR Proxy --> Backend Proxy --> WebUI Backend --> Mongo - Backend --> Qdrant + Backend --> Vault Backend -.->|Optional| Ollama Backend -.->|Optional| ASRService ``` @@ -633,8 +632,8 @@ graph LR #### Infrastructure Containers - **MongoDB 4.4.18**: Primary data storage with persistence -- **Qdrant Latest**: Vector database for memory embeddings -- **Neo4j 5.15**: Graph database for memory relationships and entity connections +- **Redis**: Job queues (RQ workers) and session state +- **Markdown Vault**: Per-user Obsidian-style notes on disk at `data/conversation_docs//` (the single memory store) - **Nginx Alpine**: Reverse proxy and load balancing (serves React app in production, proxies API calls to backend) ## Detailed Data Flow Architecture @@ -683,7 +682,7 @@ flowchart TB MemoryService[Memory Service
🕐 Init timeout: 60s
🕐 Processing timeout: 20min] MemoryValidation[Memory Validation
📏 Min conversation length] LLMProcessor[Ollama LLM
🔄 Circuit breaker protection] - VectorStore[Qdrant Vector Store
🔍 Semantic search] + VaultStore[Markdown Vault
📝 Agent-written notes] end end @@ -692,7 +691,7 @@ flowchart TB %% Data Storage subgraph "💾 Data Storage Layer" MongoDB[(MongoDB
Users & Conversations
🕐 Health check: 5s)] - QdrantDB[(Qdrant
Vector Embeddings
🔍 Semantic memory)] + VaultDB[(Markdown Vault
conversation_docs
📝 Memory notes)] AudioFiles[Audio Files
📁 Chunk storage + cropping] end @@ -725,7 +724,7 @@ flowchart TB MemoryService -->|🕐 20min timeout| LLMProcessor LLMProcessor -->|❌ Model stopped
🔄 Circuit breaker trip| CircuitBreaker LLMProcessor -->|❌ Empty response
🔄 Fallback memory| MemoryService - LLMProcessor -->|✅ Memory extracted| VectorStore + LLMProcessor -->|✅ Memory extracted| VaultStore MemoryService -->|📊 Track processing| QueueTracker @@ -737,7 +736,7 @@ flowchart TB %% Storage Integration MemoryService -->|💾 Store memories| MongoDB - VectorStore -->|💾 Embeddings| QdrantDB + VaultStore -->|💾 Notes| VaultDB QueueTracker -->|📊 Metrics & tracking| SQLiteTracking ClientState -->|📁 Audio segments| AudioFiles @@ -803,11 +802,11 @@ flowchart TB 3. **User Resolution**: Client-ID to database user mapping for proper data association 4. **Versioned Processing**: Multiple transcript and memory versions with provider/model tracking 5. **LLM Processing**: Ollama-based conversation summarization with user context (only for validated transcripts) -6. **Vector Storage**: Semantic embeddings stored in Qdrant keyed by user_id +6. **Vault Storage**: A tool-calling memory agent writes notes into the per-user Markdown vault (`data/conversation_docs//`) 7. **Metadata Enhancement**: Client information and user email stored in metadata 8. **Reprocessing Capabilities**: Re-run transcript or memory extraction with different parameters 9. **Version Management**: Active version pointers with automatic legacy field population -10. **Search & Retrieval**: User-scoped semantic memory search capabilities +10. **Search & Retrieval**: User-scoped agentic vault search (ripgrep over notes, synthesized answer with cited paths) ### User Management & Security 1. **Registration**: Admin-controlled user creation with email/password and auto-generated user_id @@ -900,13 +899,8 @@ MONGODB_URI=mongodb://mongo:27017 # LLM Processing OLLAMA_BASE_URL=http://ollama:11434 -# Vector Storage -QDRANT_BASE_URL=qdrant - -# Graph Storage for Memory Relationships -NEO4J_HOST=neo4j-mem0 -NEO4J_USER=neo4j -NEO4J_PASSWORD=your-neo4j-password +# Memory Storage: per-user Markdown vault on disk (data/conversation_docs//) +# No vector/graph database required. # Transcription Services (Deepgram Primary, Wyoming Fallback) DEEPGRAM_API_KEY=your-deepgram-api-key-here @@ -921,8 +915,6 @@ DEEPGRAM_API_KEY=your-deepgram-api-key-here #### Enhanced Services (Optional but Recommended) - **Ollama**: Memory processing -- **Qdrant**: Vector storage for semantic memory search -- **Neo4j**: Graph database for memory relationships and entity connections - **Deepgram**: Primary speech-to-text transcription service (WebSocket streaming) - **Wyoming ASR**: Fallback transcription service (self-hosted) @@ -975,7 +967,7 @@ src/advanced_omi_backend/ │ └── /{conversation_id}/activate-memory # Switch memory version ├── /memories # Memory management and search │ ├── /admin # Admin view (all users) -│ └── /search # Semantic memory search +│ └── /search # Agentic vault search ├── /admin/ # Admin compatibility endpoints │ ├── /memories # Consolidated admin memory view │ └── /memories/debug # Legacy debug endpoint @@ -995,7 +987,7 @@ src/advanced_omi_backend/ #### Memory Management - `GET /api/memories` - User memories (with user_id filter for admin) - `GET /api/memories/admin` - All memories grouped by user (admin only) -- `GET /api/memories/search?query=` - Semantic memory search +- `GET /api/memories/search?query=` - Agentic vault search #### Audio & Conversations - `GET /api/conversations` - User conversations (speech-detected only) diff --git a/backends/advanced/Docs/memories.md b/backends/advanced/Docs/memories.md index 3da4e188..5d87a172 100644 --- a/backends/advanced/Docs/memories.md +++ b/backends/advanced/Docs/memories.md @@ -1,677 +1,109 @@ -# Memory Service Configuration and Customization +# Memory Service: The Agentic Markdown Vault > 📖 **Prerequisite**: Read [quickstart.md](./quickstart.md) first for system overview. -This document explains how to configure and customize the memory service in the chronicle backend. +This document explains how Chronicle stores and retrieves memories. + +Chronicle has **one** memory provider: `chronicle`. It is an **agentic Markdown vault** — a directory of Obsidian-style notes that is the single source of truth for memories. There is no separate vector database, no embeddings, and no hybrid search index. Memories are plain Markdown files; both writing and reading are driven by tool-calling LLM agents. **Code References**: -- **Main Implementation**: `src/memory/memory_service.py` -- **Event Coordination**: `src/advanced_omi_backend/transcript_coordinator.py` (zero-polling async events) -- **Repository Layer**: `src/advanced_omi_backend/conversation_repository.py` (clean data access) -- **Processing Manager**: `src/advanced_omi_backend/processors.py` (MemoryProcessor class) -- **Conversation Management**: `src/advanced_omi_backend/conversation_manager.py` (lifecycle coordination) -- **Configuration**: `config/config.yml` (memory section) + `src/model_registry.py` +- **Provider**: `src/advanced_omi_backend/services/memory/providers/chronicle.py` +- **Memory agents**: `src/advanced_omi_backend/services/memory/agent/` (write agent + read/retrieval agent) +- **Memory extraction job**: runs in the post-conversation RQ chain (`memory_extraction_job`), calls `memory_service.add_memory()` +- **Configuration**: `config/config.yml` (memory + LLM sections) + `src/model_registry.py` ## Overview -The memory service uses [Mem0](https://mem0.ai/) to store, retrieve, and search conversation memories. It integrates with Ollama for embeddings and LLM processing, and Qdrant for vector storage. - -**Key Architecture Changes**: -1. **Event-Driven Processing**: Memories use asyncio events instead of polling/retry mechanisms -2. **Repository Pattern**: Clean data access through ConversationRepository -3. **User-Centric Storage**: All memories keyed by user_id instead of client_id -4. **Single Source of Truth**: MongoDB as the only transcript storage (no duplicates) - -## Architecture - ``` -┌─────────────────┐ ┌──────────────────┐ ┌─────────────────┐ -│ Audio Stream │ │ TranscriptCoord │ │ ConversationRepo│ -│ (WebSocket) │───▶│ (Event-Driven) │───▶│ (MongoDB Access)│ -└─────────────────┘ └──────────────────┘ └─────────────────┘ - │ │ - ▼ ▼ - ┌──────────────────┐ ┌──────────────────┐ - │ MemoryProcessor │ │ Conversation │ - │ (Event-Waiting) │◄───│ Manager │ - └──────────────────┘ └──────────────────┘ - │ - ▼ - ┌──────────────────┐ - │ Ollama + Mem0 │───▶ ┌─────────────────┐ - │ (LLM + Embeddings)│ │ Qdrant │ - └──────────────────┘ │ (Vector Store) │ - │ (user_id keyed)│ - └─────────────────┘ +Conversation transcript + │ + ▼ memory_extraction_job → memory_service.add_memory() +┌─────────────────────────────┐ +│ Write agent (_add_memory_ │ tool-calling LLM +│ agent) │ • record conversation note +│ │ • surgically edit People/ +│ │ Topics/Category notes +└──────────────┬──────────────┘ + ▼ + data/conversation_docs// ← the vault (source of truth) + Conversations/.md + People/.md + Topics/.md + /.md + ▲ + │ ripgrep (grep / glob / read_note tools) +┌──────────────┴──────────────┐ +│ Read agent (_search_vault_ │ tool-calling LLM +│ grep) │ • greps the vault +│ │ • reads relevant notes +│ │ • synthesizes an answer +└─────────────────────────────┘ + ▲ + /api/memories/search and chat `search_memories` tool ``` -**Key Flow:** -1. **Audio** → **TranscriptCoordinator** signals completion -2. **ConversationManager** waits for event, queues memory processing -3. **MemoryProcessor** uses **ConversationRepository** for data access -4. **Mem0 + Ollama** extract and store memories in **Qdrant** - -## Configuration - -### Environment Variables - -The memory service is configured via environment variables: - -```bash -# Ollama Configuration -OLLAMA_BASE_URL=http://192.168.0.110:11434 +## Storage: the vault layout -# Qdrant Configuration (optional) -QDRANT_BASE_URL=localhost +The vault lives on disk at: -# Mem0 Organization Settings (optional) -MEM0_ORGANIZATION_ID=chronicle-org -MEM0_PROJECT_ID=audio-conversations -MEM0_APP_ID=omi-backend - -# Disable telemetry (privacy) -MEM0_TELEMETRY=False -``` - -### Memory Service Configuration - -The core configuration is in `src/memory/memory_service.py:45-81`: - -```python -MEM0_CONFIG = { - "llm": { - "provider": "ollama", - "config": { - "model": "llama3.1:latest", - "ollama_base_url": OLLAMA_BASE_URL, - "temperature": 0, - }, - }, - "embedder": { - "provider": "ollama", - "config": { - "model": "nomic-embed-text:latest", - "embedding_dims": 768, - "ollama_base_url": OLLAMA_BASE_URL, - }, - }, - "vector_store": { - "provider": "qdrant", - "config": { - "collection_name": "chronicle_memories", - "embedding_model_dims": 768, - "host": QDRANT_BASE_URL, - "port": 6333, - }, - }, -} ``` - -## Mem0 Custom Prompts Configuration - -### Understanding Mem0 Prompts - -Mem0 uses two types of custom prompts: - -1. **`custom_fact_extraction_prompt`**: Controls how facts are extracted from conversations -2. **`custom_update_memory_prompt`**: Controls how memories are updated/merged - -### Key Discovery: Fact Extraction Format - -The `custom_fact_extraction_prompt` must follow a specific JSON format with few-shot examples: - -```python -custom_fact_extraction_prompt = """ -Please extract relevant facts from the conversation. -Here are some few shot examples: - -Input: Hi. -Output: {"facts" : []} - -Input: I need to buy groceries tomorrow. -Output: {"facts" : ["Need to buy groceries tomorrow"]} - -Input: The meeting is at 3 PM on Friday. -Output: {"facts" : ["Meeting scheduled for 3 PM on Friday"]} - -Now extract facts from the following conversation. Return only JSON format with "facts" key. -""" +data/conversation_docs// ``` -### Configuration Parameters - -Mem0 configuration requires these specific parameters: - -- `custom_fact_extraction_prompt`: For fact extraction (if enabled) -- `version`: Should be set to "v1.1" -- Standard LLM, embedder, and vector_store configurations - -### Common Issues - -1. **Using `custom_prompt` instead of `custom_fact_extraction_prompt`**: Will cause empty results -2. **Missing JSON format examples**: Facts won't be extracted properly -3. **Setting `custom_fact_extraction_prompt` to empty string**: Disables fact extraction entirely +It is per-user (keyed by the MongoDB ObjectId `user_id`) and organized into note types: -## Customization Options +| Note type | Path | Contents | +|-----------|------|----------| +| **Conversations** | `Conversations/.md` | One note per conversation — the record of what was discussed. | +| **People** | `People/.md` | A note per person mentioned, accumulating facts about them over time. | +| **Topics** | `Topics/.md` | A note per recurring topic. | +| **Categories** | `/.md` | Other category notes (e.g. places, projects, preferences). | -### 1. LLM Model Configuration +These are ordinary Markdown files — readable, editable, and grep-able. Because the vault is the system of record, memories survive as durable text rather than as opaque vector rows. -#### Change the LLM Model +## Write path: the memory agent -To use a different Ollama model for memory processing: +Memory extraction runs as part of the post-conversation RQ pipeline. After a conversation closes, `memory_extraction_job` calls `memory_service.add_memory()`, which invokes the **write agent** (`_add_memory_agent` in `providers/chronicle.py`). -```python -# In memory_service.py -MEM0_CONFIG["llm"]["config"]["model"] = "llama3.2:latest" # or any other model -``` - -#### Switch to OpenAI GPT-4o (Recommended for JSON Reliability) - -For better JSON parsing and reduced errors, switch to OpenAI: +The write agent is a **tool-calling LLM**. Given the conversation transcript and metadata, it: -```bash -# In your .env file -LLM_PROVIDER=openai -OPENAI_API_KEY=your-openai-api-key -OPENAI_MODEL=gpt-5-mini # Recommended for reliable JSON output - -# Alternative models -# OPENAI_MODEL=gpt-5-mini # Faster, cheaper option -# OPENAI_MODEL=gpt-3.5-turbo # Budget option -``` +1. Records the conversation as a new `Conversations/.md` note. +2. **Surgically edits** existing People / Topics / Category notes — adding or updating facts in place rather than blindly appending — and creates new notes when a person/topic/category is seen for the first time. -Or configure via `config/config.yml` (memory block): +This is LLM-driven extraction: the agent decides what is worth remembering and where it belongs in the vault. -```yaml -memory_extraction: - llm_settings: - model: "gpt-5-mini" # When LLM_PROVIDER=openai - temperature: 0.1 +## Read path: the retrieval agent +Search is served by the **read agent** (`_search_vault_grep`), a read-only tool-calling LLM that operates over the vault with three tools: -fact_extraction: - enabled: true # Safe to enable with GPT-4o - llm_settings: - model: "gpt-5-mini" - temperature: 0.0 -``` - -#### Adjust LLM Parameters - -```python -MEM0_CONFIG["llm"]["config"].update({ - "temperature": 0.1, # Higher for more creative summaries - "top_p": 0.9, # Nucleus sampling -}) -``` +- `grep` — full-text ripgrep across the notes +- `glob` — find notes by path/name pattern +- `read_note` — read a specific note's contents -#### Benefits of OpenAI GPT-4o +Given a query, the agent greps the vault, reads the relevant notes, and **synthesizes an answer**. The result returned to the caller is: -**Improved JSON Reliability:** -- Consistent JSON formatting reduces parsing errors -- Better instruction following for structured output -- Built-in understanding of JSON requirements -- Reduced need to disable fact extraction +- the **synthesized answer** as the top result, plus +- the **notes it read** (cited note paths) as supporting context. -**When to Use GPT-4o:** -- Experiencing frequent JSON parsing errors -- Want to enable fact extraction safely -- Require consistent structured output +There is no vector similarity score — relevance comes from the agent's reasoning over the text it retrieves. -**Monitoring JSON Success:** -```bash -# Check for parsing errors -docker logs advanced-backend | grep "JSONDecodeError" - -# Verify OpenAI usage -docker logs advanced-backend | grep "Using OpenAI provider" - -docker logs advanced-backend | grep "OpenAI response" -``` - -### 2. Embedding Model Configuration - -#### Change Embedding Model - -```python -MEM0_CONFIG["embedder"]["config"]["model"] = "mxbai-embed-large:latest" -``` - -#### Adjust Embedding Dimensions - -```python -# Must match your embedding model's output dimensions -MEM0_CONFIG["embedder"]["config"]["embedding_dims"] = 1024 -MEM0_CONFIG["vector_store"]["config"]["embedding_model_dims"] = 1024 -``` - -### 3. Memory Processing Customization - -#### Custom Memory Prompt - -You can customize how memories are extracted from conversations: - -```python -# In src/memory/memory_service.py:207-225 (_add_memory_to_store function) -process_memory.add( - transcript, - user_id=user_id, # Database user_id (not client_id) - metadata={ - "client_id": client_id, # Stored in metadata - "user_email": user_email, - # ... other metadata - }, - prompt="Please extract key information and relationships from this conversation" -) -``` - -#### Memory Metadata - -Enrich memories with custom metadata: - -```python -metadata = { - "source": "offline_streaming", - "client_id": client_id, # Client ID stored in metadata - "user_email": user_email, # User email for identification - "audio_uuid": audio_uuid, - "timestamp": int(time.time()), - "conversation_context": "audio_transcription", - "device_type": "audio_recording", - "mood": "professional", # Custom field - "topics": ["sales", "meetings"], # Custom field - "organization_id": MEM0_ORGANIZATION_ID, - "project_id": MEM0_PROJECT_ID, - "app_id": MEM0_APP_ID, -} -``` - -### 4. Vector Store Configuration - -#### Change Collection Name - -```python -MEM0_CONFIG["vector_store"]["config"]["collection_name"] = "my_custom_memories" -``` - -#### Qdrant Advanced Configuration - -```python -MEM0_CONFIG["vector_store"]["config"].update({ - "url": "http://localhost:6333", # Full URL - "api_key": "your-api-key", # If using Qdrant Cloud - "prefer_grpc": True, # Use gRPC instead of HTTP -}) -``` - -### 5. Search and Retrieval Customization - -#### Custom Search Filters - -```python -def search_memories_with_filters(self, query: str, user_id: str, topic: str = None): - filters = {} - - if topic: - filters["metadata.topics"] = {"$in": [topic]} - - return self.memory.search( - query=query, - user_id=user_id, - filters=filters, - limit=20 - ) -``` - -#### Memory Ranking - -```python -def get_important_memories(self, user_id: str): - """Get memories sorted by importance/frequency""" - memories = self.memory.get_all(user_id=user_id) - - # Custom scoring logic - for memory in memories: - score = 0 - if "meeting" in memory.get('memory', '').lower(): - score += 2 - if "deadline" in memory.get('memory', '').lower(): - score += 3 - memory['importance_score'] = score - - return sorted(memories, key=lambda x: x.get('importance_score', 0), reverse=True) -``` +## Chat integration -## User-Centric Memory Architecture - -### Key Changes - -**All memories are now keyed by database user_id instead of client_id:** - -- **Memory Storage**: `user_id` parameter identifies the memory owner -- **Client Information**: Stored in metadata for reference and debugging -- **User Email**: Included in metadata for easy identification -- **Backward Compatibility**: Admin debug shows both user and client information - -### Client-User Mapping - -The system maintains a mapping between client IDs and database users: - -```python -# Client ID format: objectid_suffix-device_name -client_id = "cd7994-laptop" # Maps to user_id="507f1f77bcf86cd799439011" (ObjectId) - -# Memory storage uses database user_id (full ObjectId) -process_memory.add( - transcript, - user_id="507f1f77bcf86cd799439011", # Database user_id (MongoDB ObjectId) - metadata={ - "client_id": "cd7994-laptop", # Client reference - "user_email": "user@example.com", - # ... other metadata - } -) -``` - -## Memory Types and Structure - -### Standard Memory Structure - -```json -{ - "id": "01b76e66-8a9c-4567-b890-123456789abc", - "memory": "Planning a vacation to Italy in September", - "user_id": "abc123", - "created_at": "2025-07-10T07:44:15.316499-07:00", - "metadata": { - "source": "offline_streaming", - "client_id": "abc123-laptop", - "user_email": "user@example.com", - "audio_uuid": "test_audio_6e38c2c8", - "timestamp": 1720616655, - "conversation_context": "audio_transcription", - "device_type": "audio_recording", - "organization_id": "chronicle-org", - "project_id": "audio-conversations", - "app_id": "omi-backend" - } -} -``` - - -## Advanced Customization - -### 1. Custom Memory Processing Pipeline - -Create a custom processing function: - -```python -def custom_memory_processor(transcript: str, client_id: str, audio_uuid: str, user_id: str, user_email: str): - # Extract entities - entities = extract_named_entities(transcript) - - # Classify conversation type - conv_type = classify_conversation(transcript) - - # Generate custom summary - summary = generate_custom_summary(transcript, conv_type) - - # Store with enriched metadata - process_memory.add( - summary, - user_id=user_id, # Database user_id - metadata={ - "client_id": client_id, - "user_email": user_email, - "entities": entities, - "conversation_type": conv_type, - "audio_uuid": audio_uuid, - "processing_version": "v2.0" - } - ) -``` - -### 2. Multiple Memory Collections - -Configure different collections for different types of memories: - -```python -def init_specialized_memory_services(): - # Personal memories - personal_config = MEM0_CONFIG.copy() - personal_config["vector_store"]["config"]["collection_name"] = "personal_memories" - - # Work memories - work_config = MEM0_CONFIG.copy() - work_config["vector_store"]["config"]["collection_name"] = "work_memories" - work_config["custom_prompt"] = "Focus on work-related tasks, meetings, and projects" - - return { - "personal": Memory.from_config(personal_config), - "work": Memory.from_config(work_config) - } -``` - -### 3. Memory Lifecycle Management - -Implement automatic memory cleanup: - -```python -def cleanup_old_memories(self, user_id: str, days_old: int = 365): - """Remove memories older than specified days""" - cutoff_timestamp = int(time.time()) - (days_old * 24 * 60 * 60) - - memories = self.get_all_memories(user_id) - for memory in memories: - if memory.get('metadata', {}).get('timestamp', 0) < cutoff_timestamp: - self.delete_memory(memory['id']) -``` - -## Testing Memory Configuration - -Use the provided test script to verify your configuration: - -```bash -# Run the memory test script -python test_memory_creation.py -``` - -This will: -- Test connectivity to Ollama and Qdrant -- Create sample memories with database user IDs (not client IDs) -- Test memory retrieval and search functionality -- Verify the new user-centric memory structure and metadata -- Validate client-user mapping functionality - -## Troubleshooting - -### Common Issues - -1. **Connection Timeouts** - - Check Ollama is running: `curl http://localhost:11434/api/version` - - Check Qdrant is accessible: `curl http://localhost:6333/collections` - -2. **Memory Not Created** - - Check Ollama has required models: `ollama list` - - Verify Qdrant collection exists - - Check memory service logs for errors - -3. **Search Not Working** - - Ensure embedding model is available in Ollama - - Check vector dimensions match between embedder and Qdrant - - Verify collection has vectors: `curl http://localhost:6333/collections/chronicle_memories` - -### Required Ollama Models - -Make sure these models are available: - -```bash -# LLM for memory processing -ollama pull llama3.1:latest - -# Embedding model for semantic search -ollama pull nomic-embed-text:latest -``` - -### Memory Service Logs - -Enable debug logging to troubleshoot issues: - -```python -import logging -logging.getLogger("memory_service").setLevel(logging.DEBUG) -``` - -## Performance Optimization - -### 1. Batch Processing - -Process multiple memories at once: - -```python -async def batch_add_memories(self, transcripts_data: List[Dict]): - tasks = [] - for data in transcripts_data: - task = self.add_memory( - data['transcript'], - data['client_id'], - data['audio_uuid'], - data['user_id'], # Database user_id - data['user_email'] # User email - ) - tasks.append(task) - - results = await asyncio.gather(*tasks, return_exceptions=True) - return results -``` - -### 2. Memory Compression - -Implement memory consolidation: - -```python -def consolidate_memories(self, user_id: str, time_window_hours: int = 24): - """Consolidate related memories from the same time period""" - recent_memories = self.get_recent_memories(user_id, time_window_hours) - - if len(recent_memories) > 5: # If many memories in short time - consolidated = self.summarize_memories(recent_memories) - - # Delete individual memories and store consolidated version - for memory in recent_memories: - self.delete_memory(memory['id']) - - return self.add_consolidated_memory(consolidated, user_id) -``` +Chat is always **agentic / tool-calling**. The chat LLM is given a `search_memories` tool; when it needs context about the user it calls that tool, which runs the same agentic vault search and returns the synthesized answer plus the cited note paths. The chat model then incorporates that into its reply. ## API Endpoints -The memory service exposes these endpoints with enhanced search capabilities: - -- `GET /api/memories` - Get user memories with total count support (keyed by database user_id) -- `GET /api/memories/search?query={query}&limit={limit}` - **Semantic memory search** with relevance scoring (user-scoped) -- `GET /api/memories/unfiltered` - User's memories without filtering for debugging -- `DELETE /api/memories/{memory_id}` - Delete specific memory (requires authentication) -- `GET /api/memories/admin` - Admin view of all memories across all users (superuser only) - -### Enhanced Search Features - -**Semantic Search (`/api/memories/search`)**: -- **Relevance Scoring**: Returns similarity scores from vector database (0.0-1.0 range) -- **Configurable Limits**: Supports `limit` parameter for result count control -- **User Scoped**: Results automatically filtered by authenticated user -- **Vector-based**: Uses embeddings for contextual understanding beyond keyword matching - -**Memory Count API**: -- **Chronicle Provider**: Native Qdrant count API provides accurate total counts -- **OpenMemory MCP Provider**: Count support varies by OpenMemory implementation -- **Response Format**: `{"memories": [...], "total_count": 42}` when supported - -### Admin Endpoints - -#### All Memories Endpoint (`/api/memories/admin`) - -Returns all memories across all users in a clean, searchable format: - -```json -{ - "total_memories": 25, - "total_users": 3, - "memories": [ - { - "id": "memory-uuid", - "memory": "Planning vacation to Italy in September", - "user_id": "abc123", - "created_at": "2025-07-10T14:30:00Z", - "owner_user_id": "abc123", - "owner_email": "user@example.com", - "owner_display_name": "John Doe", - "metadata": { - "client_id": "abc123-laptop", - "user_email": "user@example.com", - "audio_uuid": "audio-uuid" - } - } - ] -} -``` - -#### Admin Endpoint Details - -The admin endpoint provides comprehensive memory information: - -```json -{ - "total_users": 2, - "total_memories": 15, - "admin_user": { - "id": "admin1", - "email": "admin@example.com", - "is_superuser": true - }, - "users_with_memories": [ - { - "user_id": "abc123", - "email": "user@example.com", - "memory_count": 10, - "memories": [...], - "registered_clients": [ - { - "client_id": "abc123-laptop", - "device_name": "laptop", - "last_seen": "2025-07-10T14:30:00Z" - } - ], - "client_count": 1 - } - ] -} -``` - -## Conclusion +- `GET /api/memories/search?query={query}&limit={limit}` — runs the agentic vault search and returns the synthesized answer plus the notes the read agent consulted. +- Other `/api/memories/*` management endpoints operate over the vault notes. -The memory service is highly customizable and can be adapted for various use cases. Key areas for customization include: +## Vault sync to Obsidian (separate feature) -- LLM and embedding models -- Memory processing prompts -- Metadata enrichment -- Search and retrieval logic -- Storage collections and structure +The vault is designed to be edited and viewed directly. The optional **vault-sync** feature (`extras/vault-sync/`, macOS menu bar app) syncs `data/conversation_docs/` to an Obsidian vault via Syncthing, so you can browse and hand-edit your memory notes in Obsidian. Human edits made in Obsidian sync back into the vault. This sync is independent of the memory provider itself — the vault on the backend remains the source of truth. -For more advanced use cases, consider implementing custom processing pipelines, multiple memory types, or integration with external knowledge bases. +## What was removed -## Migration from Client-Based to User-Based Storage - -If migrating from an existing system where memories were keyed by client_id: - -1. **Clean existing data**: Remove old memories from Qdrant -2. **Restart services**: Ensure new architecture is active -3. **Test with fresh data**: Verify memories are properly keyed by user_id -4. **Admin verification**: Use `/api/memories/admin` to confirm proper storage - -The new architecture ensures proper user isolation and simplifies admin debugging while maintaining all client information in metadata. +For historical context, the previous architecture used **FalkorDB** hybrid search (vector + BM25 + entity-graph BFS over ConvDoc/ConvChunk/ConvEntity nodes and a knowledge graph), plus alternative providers (OpenMemory MCP, Graphiti) and Qdrant/Mem0 vector storage. **All of these have been removed.** There is now a single `chronicle` provider backed entirely by the Markdown vault; the `falkordb` container and `FALKORDB_*` environment variables no longer exist. +## Configuration -Both load all user memories and view all memories are helpful -Both views complement each other - the debug view helps you understand how the system is working, while the clean view -helps you understand what content is being stored. +The memory provider is `chronicle` and requires only an LLM (for the write and read agents) and the vault directory. LLM selection follows the standard backend LLM configuration (`LLM_PROVIDER`, `OPENAI_API_KEY`/`OPENAI_MODEL` or Ollama settings) in `config/config.yml` and `.env`. No vector store, embedding model, or graph database needs to be configured. diff --git a/backends/advanced/Docs/plugin-development-guide.md b/backends/advanced/Docs/plugin-development-guide.md index 32fa700d..14df1024 100644 --- a/backends/advanced/Docs/plugin-development-guide.md +++ b/backends/advanced/Docs/plugin-development-guide.md @@ -189,7 +189,7 @@ async def on_conversation_complete(self, context: PluginContext): **Use Cases**: - Memory indexing -- Knowledge graph updates +- Vault note post-processing - Memory notifications - Analytics diff --git a/backends/advanced/docker-compose-test.yml b/backends/advanced/docker-compose-test.yml index 88604400..6bf75b87 100644 --- a/backends/advanced/docker-compose-test.yml +++ b/backends/advanced/docker-compose-test.yml @@ -25,8 +25,6 @@ services: environment: # Override with test-specific settings - MONGODB_URI=mongodb://mongo-test:27017/test_db - - QDRANT_BASE_URL=qdrant-test - - QDRANT_PORT=6333 - REDIS_URL=redis://redis-test:6379/0 - DEBUG_DIR=/app/debug # Fixed: match plugin database mount path # Test configuration file @@ -34,6 +32,7 @@ services: # Import API keys from environment - DEEPGRAM_API_KEY=${DEEPGRAM_API_KEY} - OPENAI_API_KEY=${OPENAI_API_KEY} + - SMALLEST_API_KEY=${SMALLEST_API_KEY} - GROQ_API_KEY=${GROQ_API_KEY} # Authentication (test-specific) - AUTH_SECRET_KEY=test-jwt-signing-key-for-integration-tests @@ -44,8 +43,6 @@ services: - PARAKEET_ASR_URL=${PARAKEET_ASR_URL} # Memory provider configuration - MEMORY_PROVIDER=${MEMORY_PROVIDER:-chronicle} - - OPENMEMORY_MCP_URL=${OPENMEMORY_MCP_URL:-http://host.docker.internal:8765} - - OPENMEMORY_USER_ID=${OPENMEMORY_USER_ID:-openmemory} # Speaker recognition controlled by config.yml (disabled in test config for CI performance) - SPEAKER_SERVICE_URL=http://speaker-service-test:8085 - CORS_ORIGINS=http://localhost:3001,http://localhost:8001,https://localhost:3001,https://localhost:8001 @@ -66,23 +63,24 @@ services: - LANGFUSE_PUBLIC_KEY=pk-lf-test-public-key - LANGFUSE_SECRET_KEY=sk-lf-test-secret-key depends_on: - qdrant-test: - condition: service_started mongo-test: condition: service_healthy redis-test: condition: service_started - langfuse-web-test: - condition: service_healthy + # langfuse-web-test removed from the gate: langfuse is optional observability and + # the backend degrades gracefully without it. Requiring it pulled in the whole + # langfuse stack (postgres/clickhouse/minio) — unnecessary for the e2e and heavy. healthcheck: test: ["CMD", "curl", "-f", "http://localhost:8000/readiness"] interval: 10s timeout: 5s retries: 10 start_period: 60s - restart: unless-stopped webui-test: + # Gated behind a profile: the static webui Dockerfile was removed (webui is now + # dev-mode only) and the API/e2e tests don't need a UI. Nothing depends on it. + profiles: ["webui"] build: context: ./webui dockerfile: Dockerfile @@ -98,19 +96,9 @@ services: condition: service_healthy mongo-test: condition: service_healthy - qdrant-test: - condition: service_started redis-test: condition: service_started - qdrant-test: - image: qdrant/qdrant:latest - ports: - - "6337:6333" # gRPC - avoid conflict with dev 6333 - - "6338:6334" # HTTP - avoid conflict with dev 6334 - volumes: - - ./data/test_qdrant_data:/qdrant/storage - mongo-test: image: mongo:8.0.14 ports: @@ -167,7 +155,6 @@ services: timeout: 10s retries: 5 start_period: 60s - restart: unless-stopped profiles: - speaker # Optional service - only start when explicitly enabled @@ -182,7 +169,6 @@ services: interval: 10s timeout: 5s retries: 3 - restart: unless-stopped mock-llm: build: @@ -195,7 +181,6 @@ services: interval: 10s timeout: 5s retries: 3 - restart: unless-stopped mock-asr: build: @@ -212,14 +197,12 @@ services: interval: 10s timeout: 5s retries: 3 - restart: unless-stopped # --- Langfuse observability stack (test) --- # Without this, Langfuse SDK defaults to cloud.langfuse.com and each # get_prompt() call blocks ~12s on DNS timeout, adding ~39s to title/summary jobs. langfuse-postgres-test: image: docker.io/postgres:16 - restart: unless-stopped environment: POSTGRES_USER: postgres POSTGRES_PASSWORD: postgres @@ -234,7 +217,6 @@ services: clickhouse-test: image: docker.io/clickhouse/clickhouse-server:24.12 - restart: unless-stopped environment: CLICKHOUSE_DB: default CLICKHOUSE_USER: clickhouse @@ -251,7 +233,6 @@ services: minio-test: image: docker.io/minio/minio:RELEASE.2025-01-20T14-49-07Z - restart: unless-stopped entrypoint: sh command: -c 'mkdir -p /data/langfuse && minio server --address ":9000" --console-address ":9001" /data' environment: @@ -268,7 +249,6 @@ services: langfuse-redis-test: image: docker.io/redis:7 - restart: unless-stopped command: > --requirepass myredissecret healthcheck: @@ -279,7 +259,6 @@ services: langfuse-worker-test: image: docker.io/langfuse/langfuse-worker:3 - restart: unless-stopped depends_on: langfuse-postgres-test: condition: service_healthy @@ -323,7 +302,6 @@ services: langfuse-web-test: image: docker.io/langfuse/langfuse:3 - restart: unless-stopped depends_on: langfuse-postgres-test: condition: service_healthy @@ -373,14 +351,13 @@ services: environment: # Same environment as backend - MONGODB_URI=mongodb://mongo-test:27017/test_db - - QDRANT_BASE_URL=qdrant-test - - QDRANT_PORT=6333 - REDIS_URL=redis://redis-test:6379/0 - DEBUG_DIR=/app/debug # Fixed: match plugin database mount path # Test configuration file - CONFIG_FILE=${TEST_CONFIG_FILE:-/app/test-configs/deepgram-openai.yml} - DEEPGRAM_API_KEY=${DEEPGRAM_API_KEY} - OPENAI_API_KEY=${OPENAI_API_KEY} + - SMALLEST_API_KEY=${SMALLEST_API_KEY} - GROQ_API_KEY=${GROQ_API_KEY} - AUTH_SECRET_KEY=test-jwt-signing-key-for-integration-tests - ADMIN_PASSWORD=test-admin-password-123 @@ -388,8 +365,6 @@ services: - TRANSCRIPTION_PROVIDER=${TRANSCRIPTION_PROVIDER:-deepgram} - PARAKEET_ASR_URL=${PARAKEET_ASR_URL} - MEMORY_PROVIDER=${MEMORY_PROVIDER:-chronicle} - - OPENMEMORY_MCP_URL=${OPENMEMORY_MCP_URL:-http://host.docker.internal:8765} - - OPENMEMORY_USER_ID=${OPENMEMORY_USER_ID:-openmemory} # Speaker recognition controlled by config.yml (disabled in test config for CI performance) - SPEAKER_SERVICE_URL=http://speaker-service-test:8085 # Set inactivity timeout for tests (20 seconds of audio time) @@ -415,9 +390,8 @@ services: condition: service_healthy redis-test: condition: service_started - qdrant-test: - condition: service_started - restart: unless-stopped + # falkordb-test removed: it's no longer defined (FalkorDB was dropped when memory + # moved to the Markdown vault); this was a stale depends_on that blocks startup. # caddy: # image: caddy:2-alpine diff --git a/backends/advanced/docker-compose.yml b/backends/advanced/docker-compose.yml index 11314e68..944ac5e6 100644 --- a/backends/advanced/docker-compose.yml +++ b/backends/advanced/docker-compose.yml @@ -1,30 +1,4 @@ services: - tailscale: - image: tailscale/tailscale:latest - container_name: advanced-tailscale - hostname: chronicle-tailscale - environment: - - TS_AUTHKEY=${TS_AUTHKEY} - - TS_STATE_DIR=/var/lib/tailscale - - TS_USERSPACE=false - - TS_ACCEPT_DNS=true - volumes: - - tailscale-state:/var/lib/tailscale - devices: - - /dev/net/tun:/dev/net/tun - cap_add: - - NET_ADMIN - restart: unless-stopped - profiles: - - tailscale # Optional profile - ports: - - "18123:18123" # HA proxy port - command: > - sh -c "tailscaled & - tailscale up --authkey=$${TS_AUTHKEY} --accept-dns=true && - apk add --no-cache socat 2>/dev/null || true && - socat TCP-LISTEN:18123,fork,reuseaddr TCP:100.99.62.5:8123" - chronicle-backend: image: ${CHRONICLE_REGISTRY:-}chronicle-backend:${CHRONICLE_TAG:-latest} build: @@ -37,11 +11,14 @@ services: - .env volumes: - ./src:/app/src # Mount source code for development + - ./benchmark:/app/benchmark # LongMemEval benchmark harness (Phase A+) - ./data/audio_chunks:/app/audio_chunks - ./data/debug_dir:/app/debug_dir - ./data:/app/data - ../../config:/app/config # Mount entire config directory (includes config.yml, defaults.yml, plugins.yml) - ../../plugins:/app/plugins # External plugins directory + - ../../discovery.py:/app/discovery.py:ro # Service discovery module + - /var/run/tailscale/tailscaled.sock:/var/run/tailscale/tailscaled.sock:ro # Tailscale socket for minidisc environment: - DEEPGRAM_API_KEY=${DEEPGRAM_API_KEY} - PARAKEET_ASR_URL=${PARAKEET_ASR_URL} @@ -52,16 +29,24 @@ services: - ADMIN_EMAIL=${ADMIN_EMAIL} - AUTH_SECRET_KEY=${AUTH_SECRET_KEY} - OPENAI_API_KEY=${OPENAI_API_KEY} - - NEO4J_HOST=${NEO4J_HOST} - - NEO4J_USER=${NEO4J_USER} - - NEO4J_PASSWORD=${NEO4J_PASSWORD} - HA_TOKEN=${HA_TOKEN} - - CORS_ORIGINS=http://localhost:3010,http://localhost:8000,http://192.168.1.153:3010,http://192.168.1.153:8000,https://localhost:3010,https://localhost:8000,https://100.105.225.45,https://localhost + - CORS_ORIGINS=http://localhost:5173,http://localhost:8000,http://192.168.1.153:5173,http://192.168.1.153:8000,https://localhost:5173,https://localhost:8000,https://100.105.225.45,https://localhost - REDIS_URL=redis://redis:6379/0 - MONGODB_URI=mongodb://mongo:27017 + # Vault sync broker -> server Syncthing REST API (internal docker network) + - VAULT_SYNC_SYNCTHING_URL=http://vault-syncthing:8384 + - VAULT_SYNC_API_KEY=${VAULT_SYNC_API_KEY:-} + - VAULT_SYNC_ADDRESS=${VAULT_SYNC_ADDRESS:-} + # Wake-word data-collection proxy -> standalone wakeword-service (chronicle-network) + - WAKEWORD_SERVICE_URL=${WAKEWORD_SERVICE_URL:-http://chronicle-wakeword-service:8770} + # TTS service (kitten/etc) for spoken replies on the device. Empty → the backend + # discovers chronicle-tts on the Tailnet (set explicitly by the wizard for a + # local/own/pinned endpoint). + - CHRONICLE_TTS_URL=${CHRONICLE_TTS_URL:-} + # Host service-manager agent (start/stop services from the WebUI). + # Token comes from .env via env_file (auto-generated by services.py). + - SERVICE_MANAGER_URL=${SERVICE_MANAGER_URL:-http://host.docker.internal:8775} depends_on: - qdrant: - condition: service_started mongo: condition: service_healthy redis: @@ -95,10 +80,13 @@ services: volumes: - ./src:/app/src - ./worker_orchestrator.py:/app/worker_orchestrator.py + - ./worker_healthcheck.py:/app/worker_healthcheck.py # Container healthcheck probe - ./data/audio_chunks:/app/audio_chunks - ./data:/app/data - ../../config:/app/config # Mount entire config directory (includes config.yml, defaults.yml, plugins.yml) - ../../plugins:/app/plugins # External plugins directory + - ../../discovery.py:/app/discovery.py:ro # Service discovery module + - /var/run/tailscale/tailscaled.sock:/var/run/tailscale/tailscaled.sock:ro # Tailscale socket for minidisc environment: - DEEPGRAM_API_KEY=${DEEPGRAM_API_KEY} - PARAKEET_ASR_URL=${PARAKEET_ASR_URL} @@ -107,15 +95,14 @@ services: - HA_TOKEN=${HA_TOKEN} - REDIS_URL=redis://redis:6379/0 - MONGODB_URI=mongodb://mongo:27017 - # Neo4j configuration (for knowledge graph) - - NEO4J_HOST=${NEO4J_HOST} - - NEO4J_USER=${NEO4J_USER} - - NEO4J_PASSWORD=${NEO4J_PASSWORD} # Worker orchestrator configuration (optional - defaults shown) - WORKER_CHECK_INTERVAL=${WORKER_CHECK_INTERVAL:-10} - MIN_RQ_WORKERS=${MIN_RQ_WORKERS:-6} - WORKER_STARTUP_GRACE_PERIOD=${WORKER_STARTUP_GRACE_PERIOD:-30} - WORKER_SHUTDOWN_TIMEOUT=${WORKER_SHUTDOWN_TIMEOUT:-30} + # TTS service (kitten/etc) for spoken replies on the device — dispatcher runs here. + # Empty → discover chronicle-tts on the Tailnet (wizard sets it for local/pinned). + - CHRONICLE_TTS_URL=${CHRONICLE_TTS_URL:-} extra_hosts: - "host.docker.internal:host-gateway" # Access host services depends_on: @@ -123,9 +110,16 @@ services: condition: service_healthy mongo: condition: service_healthy - qdrant: - condition: service_started restart: unless-stopped + # Probe the actual work, not just the process: fails if the RQ worker fleet + # shrank below MIN_RQ_WORKERS or a stream-consumer heartbeat went stale + # (wedged-but-alive). start_period covers orchestrator startup + worker boot. + healthcheck: + test: ["CMD", "python", "worker_healthcheck.py"] + interval: 30s + timeout: 10s + start_period: 90s + retries: 3 # Annotation Cron Scheduler # Runs periodic jobs for AI-powered annotation suggestions: @@ -155,27 +149,32 @@ services: profiles: - annotation # Optional profile - enable with: docker compose --profile annotation up - webui: - image: ${CHRONICLE_REGISTRY:-}chronicle-webui:${CHRONICLE_TAG:-latest} + # Lightweight intent-router microservice: classifies a voice command as a + # home-automation request vs a general agent/chat query (sub-ms Model2Vec + + # logreg). Kept out of the backend image so the ML deps don't bloat it. The + # Home Assistant plugin calls it at http://intent-router:8791/classify. + intent-router: build: - context: ./webui + context: ../../extras/intent-router dockerfile: Dockerfile - args: - # Direct access (http://localhost:3010): - # - VITE_BACKEND_URL=http://localhost:8000 - # - BACKEND_URL=http://localhost:8000 - # For Caddy HTTPS (https://localhost), use: - - VITE_BACKEND_URL= - - BACKEND_URL= + image: chronicle-intent-router:latest + container_name: chronicle-intent-router + volumes: + - ../../extras/intent-router:/app # live code + retrained classifier (no rebuild) + environment: + - INTENT_ROUTER_PORT=8791 + - ROUTER_HOME_THRESHOLD=${ROUTER_HOME_THRESHOLD:-0.5} + - HF_HOME=/opt/hf + - HF_HUB_OFFLINE=1 ports: - # - "${WEBUI_PORT:-3010}:80" - - 3010:80 - depends_on: - chronicle-backend: - condition: service_healthy + - "8791:8791" restart: unless-stopped - profiles: - - prod + healthcheck: + test: ["CMD", "python", "-c", "import urllib.request,sys; sys.exit(0 if urllib.request.urlopen('http://localhost:8791/health').status==200 else 1)"] + interval: 30s + timeout: 5s + retries: 3 + start_period: 25s # Caddy reverse proxy - provides HTTPS for microphone access # Access at: https://localhost (accepts self-signed cert warning) @@ -198,15 +197,19 @@ services: profiles: - https - # Development webui with hot reload (default) - # Source is volume-mounted, changes appear instantly without rebuilds - # Production static build available via: docker compose --profile prod up webui + # WebUI with hot reload — source is volume-mounted, changes appear instantly + # without rebuilds. This is the only webui (home use); served on :5173 and + # fronted by Caddy for HTTPS. webui-dev: build: context: ./webui dockerfile: Dockerfile.dev ports: - - "${WEBUI_PORT:-3010}:5173" + - "${WEBUI_DEV_PORT:-5173}:5173" + environment: + - VITE_BACKEND_URL=${VITE_BACKEND_URL-http://${HOST_IP}:${BACKEND_PUBLIC_PORT:-8000}} + - VITE_ALLOWED_HOSTS=${VITE_ALLOWED_HOSTS:-localhost 127.0.0.1} + - VITE_HMR_PORT=${VITE_HMR_PORT:-5173} volumes: - ./webui/src:/app/src - ./webui/public:/app/public @@ -215,15 +218,6 @@ services: condition: service_healthy restart: unless-stopped - qdrant: - image: qdrant/qdrant:latest - ports: - - "6033:6033" # gRPC - - "6034:6034" # HTTP - volumes: - - ./data/qdrant_data:/qdrant/storage - restart: unless-stopped - mongo: image: mongo:8.0.14 ports: @@ -252,32 +246,54 @@ services: timeout: 3s retries: 5 - neo4j: - image: neo4j:5.15-community - hostname: neo4j + # Vault sync - Syncthing instance that shares each user's Obsidian vault + # (data/conversation_docs/{user_id}) with their Mac so it can be opened in Obsidian. + # Configured exclusively by the backend's /api/vault-sync broker - not by hand. + # REST API stays on the internal docker network (reachable as vault-syncthing:8384); + # only the sync protocol port 22000 is published (reach it over Tailscale). + # Enable with: docker compose --profile vault-sync up -d + vault-syncthing: + image: syncthing/syncthing:latest + container_name: chronicle-vault-syncthing + hostname: chronicle-vault-syncthing + environment: + - STGUIADDRESS=0.0.0.0:8384 # REST API reachable from the backend container + - STGUIAPIKEY=${VAULT_SYNC_API_KEY} # backend authenticates to Syncthing with this + - PUID=${VAULT_SYNC_PUID:-0} # match conversation_docs file ownership + - PGID=${VAULT_SYNC_PGID:-0} + volumes: + - ./data/syncthing_config:/var/syncthing/config + - ./data/conversation_docs:/vaults # backend exposes per-user folders under here ports: - - "7474:7474" # HTTP - - "7687:7687" # Bolt + - "22000:22000/tcp" # sync protocol (reachable via Tailscale) + - "22000:22000/udp" # QUIC + - "21027:21027/udp" # local network discovery + restart: unless-stopped + profiles: + - vault-sync + + tailscale: + image: tailscale/tailscale:latest + container_name: advanced-tailscale + hostname: chronicle-tailscale environment: - - NEO4J_AUTH=neo4j/${NEO4J_PASSWORD:-password} - - NEO4J_PLUGINS=["apoc"] - - NEO4J_dbms_security_procedures_unrestricted=apoc.* - - NEO4J_dbms_security_procedures_allowlist=apoc.* - - NEO4J_server_default__listen__address=0.0.0.0 - - NEO4J_server_bolt_listen__address=0.0.0.0:7687 - - NEO4J_server_http_listen__address=0.0.0.0:7474 - - NEO4J_dbms_memory_heap_initial__size=512m - - NEO4J_dbms_memory_heap_max__size=2G + - TS_AUTHKEY=${TS_AUTHKEY} + - TS_STATE_DIR=/var/lib/tailscale + - TS_USERSPACE=false + - TS_ACCEPT_DNS=true volumes: - - ./data/neo4j_data:/data - - ./data/neo4j_logs:/logs + - tailscale-state:/var/lib/tailscale + devices: + - /dev/net/tun:/dev/net/tun + cap_add: + - NET_ADMIN restart: unless-stopped - healthcheck: - test: ["CMD", "wget", "-q", "--spider", "http://localhost:7474"] - interval: 15s - timeout: 10s - retries: 5 - start_period: 30s + profiles: + - tailscale # Optional profile + command: > + sh -c "tailscaled & + tailscale up --authkey=$${TS_AUTHKEY} --accept-dns=true && + sleep infinity" # ollama: # image: ollama/ollama:latest @@ -303,6 +319,8 @@ networks: external: true volumes: + ollama_data: + driver: local caddy_data: driver: local caddy_config: diff --git a/backends/advanced/init.py b/backends/advanced/init.py index 49cc706f..34525519 100644 --- a/backends/advanced/init.py +++ b/backends/advanced/init.py @@ -23,9 +23,13 @@ # Add repo root to path for imports sys.path.insert(0, str(Path(__file__).resolve().parent.parent.parent)) from config_manager import ConfigManager -from setup_utils import detect_tailscale_info, mask_value +from setup_utils import decide_cert_mode, detect_tailscale_info, mask_value from setup_utils import prompt_password as util_prompt_password -from setup_utils import prompt_with_existing_masked, read_env_value +from setup_utils import ( + prompt_with_existing_masked, + read_env_value, + tailscale_socket_path, +) class ChronicleSetup: @@ -226,6 +230,28 @@ def setup_authentication(self): self.console.print("[green][SUCCESS][/green] Admin account configured") + def _asr_url_for( + self, env_key: str, default: str = "http://host.docker.internal:8767" + ): + """Resolve an offline ASR provider's URL env value from the wizard's source choice. + + - --asr-discover → '' (left empty so the backend discovers chronicle-asr on + the Tailnet live — 'configure from the Tailnet later') + - --asr-url → that URL (own / picked-from-Tailnet endpoint) + - otherwise → prompt (interactive standalone run), defaulting to local + """ + if getattr(self.args, "asr_discover", False): + self.console.print( + f"[blue][INFO][/blue] {env_key} left empty — backend will discover " + "chronicle-asr on the Tailnet at runtime" + ) + return "" + if getattr(self.args, "asr_url", None): + self.console.print(f"[green]✅[/green] {env_key} = {self.args.asr_url}") + return self.args.asr_url + existing = read_env_value(".env", env_key) or default + return self.prompt_value(f"{env_key}", existing) + def setup_transcription(self): """Configure transcription provider - updates config.yml and .env""" # Check if transcription provider was provided via command line @@ -249,8 +275,14 @@ def setup_transcription(self): choice = "4" elif provider == "smallest": choice = "5" - elif provider == "none": + elif provider == "gemma4": choice = "6" + elif provider == "af-next": + choice = "7" + elif provider == "granite": + choice = "8" + elif provider == "none": + choice = "9" else: choice = "1" # Default to Deepgram else: @@ -280,13 +312,30 @@ def setup_transcription(self): smallest_desc = "Smallest.ai Pulse (cloud-based, fast, requires API key)" + gemma4_desc = ( + "Offline (Gemma 4 E2B-it - GPU required, prompt-based diarization)" + ) + + af_next_desc = ( + "Offline (Audio Flamingo Next - GPU required, timestamped diarization; " + "NONCOMMERCIAL license)" + ) + + granite_desc = ( + "Offline (IBM Granite Speech - GPU recommended, LLM-backbone; " + "en/fr/de/es/pt)" + ) + choices = { "1": "Deepgram (recommended - high quality, cloud-based)", "2": parakeet_desc, "3": vibevoice_desc, "4": qwen3_desc, "5": smallest_desc, - "6": "None (skip transcription setup)", + "6": gemma4_desc, + "7": af_next_desc, + "8": granite_desc, + "9": "None (skip transcription setup)", } choice = self.prompt_choice( @@ -324,14 +373,9 @@ def setup_transcription(self): elif choice == "2": self.console.print("[blue][INFO][/blue] Offline Parakeet ASR selected") - existing_parakeet_url = ( - read_env_value(".env", "PARAKEET_ASR_URL") - or "http://host.docker.internal:8767" - ) - parakeet_url = self.prompt_value("Parakeet ASR URL", existing_parakeet_url) - - # Write URL to .env for ${PARAKEET_ASR_URL} placeholder in config.yml - self.config["PARAKEET_ASR_URL"] = parakeet_url + # Write URL to .env for ${PARAKEET_ASR_URL} placeholder in config.yml. + # Empty ("" from --asr-discover) → runtime Tailnet discovery. + self.config["PARAKEET_ASR_URL"] = self._asr_url_for("PARAKEET_ASR_URL") # Update config.yml to use Parakeet self.config_manager.update_config_defaults({"stt": "stt-parakeet-batch"}) @@ -379,20 +423,17 @@ def setup_transcription(self): self.console.print( "[blue][INFO][/blue] Qwen3-ASR selected (52 languages, streaming + batch via vLLM)" ) - existing_qwen3_url_raw = read_env_value(".env", "QWEN3_ASR_URL") - existing_qwen3_url = ( - f"http://{existing_qwen3_url_raw}" - if existing_qwen3_url_raw - else "http://host.docker.internal:8767" + qwen3_url = self._asr_url_for("QWEN3_ASR_URL") + # Stored without scheme (resolved_url re-adds it); empty → Tailnet discovery. + self.config["QWEN3_ASR_URL"] = ( + qwen3_url.replace("http://", "").rstrip("/") if qwen3_url else "" ) - qwen3_url = self.prompt_value("Qwen3-ASR URL", existing_qwen3_url) - - # Write URL to .env for ${QWEN3_ASR_URL} placeholder in config.yml - self.config["QWEN3_ASR_URL"] = qwen3_url.replace("http://", "").rstrip("/") - - # Also set streaming URL (same host, port 8769) - stream_host = qwen3_url.replace("http://", "").split(":")[0] - self.config["QWEN3_ASR_STREAM_URL"] = f"{stream_host}:8769" + # Streaming companion (same host, port 8769); empty when discovering. + if qwen3_url: + stream_host = qwen3_url.replace("http://", "").split(":")[0] + self.config["QWEN3_ASR_STREAM_URL"] = f"{stream_host}:8769" + else: + self.config["QWEN3_ASR_STREAM_URL"] = "" # Update config.yml to use Qwen3-ASR self.config_manager.update_config_defaults({"stt": "stt-qwen3-asr"}) @@ -440,6 +481,116 @@ def setup_transcription(self): ) elif choice == "6": + self.console.print( + "[blue][INFO][/blue] Gemma 4 E2B-it selected (prompt-based diarization, batch + streaming)" + ) + self.config["GEMMA4_ASR_URL"] = self._asr_url_for("GEMMA4_ASR_URL") + + # The same gemma4-asr service serves both batch (/transcribe) and + # streaming (/stream), so enable both defaults at once. + self.config_manager.update_config_defaults( + {"stt": "stt-gemma4", "stt_stream": "stt-gemma4-stream"} + ) + + # Gemma 4 is an LLM-backbone ASR (capability "context_prompt"): it takes + # free-form context, NOT acoustic keyword boosting. Unlike VibeVoice/ + # Deepgram, it would echo a wake-word boost list into the transcript, so + # the backend withholds that list and uses this context string instead. + existing_gemma4_context = ( + self.config_manager.get_full_config() + .get("backend", {}) + .get("asr", {}) + .get("context", {}) + .get("stt-gemma4", "") + ) + self.console.print( + "[blue][INFO][/blue] Gemma 4 takes free-form context (domain, names, " + "jargon) to disambiguate recognition. It informs transcription but is " + "never transcribed. Leave blank to skip." + ) + gemma4_context = self.prompt_value( + "Gemma 4 ASR context (optional)", existing_gemma4_context + ) + self.config_manager.update_backend_config( + {"asr": {"context": {"stt-gemma4": gemma4_context.strip()}}} + ) + + self.console.print( + "[green][SUCCESS][/green] Gemma 4 configured in config.yml and .env" + ) + self.console.print("[blue][INFO][/blue] Set defaults.stt: stt-gemma4") + self.console.print( + "[blue][INFO][/blue] Set defaults.stt_stream: stt-gemma4-stream" + ) + self.console.print( + "[yellow][WARNING][/yellow] Remember to start Gemma 4 ASR: cd ../../extras/asr-services && docker compose up gemma4-asr -d" + ) + + elif choice == "7": + self.console.print( + "[blue][INFO][/blue] Audio Flamingo Next selected " + "(timestamped diarization, prompt-driven)" + ) + self.console.print( + "[yellow][WARNING][/yellow] AF-Next is licensed under the NVIDIA OneWay " + "Noncommercial License — research use only. Do not deploy in commercial " + "products." + ) + self.config["AF_NEXT_ASR_URL"] = self._asr_url_for("AF_NEXT_ASR_URL") + + self.config_manager.update_config_defaults({"stt": "stt-af-next"}) + + self.console.print( + "[green][SUCCESS][/green] Audio Flamingo Next configured in config.yml and .env" + ) + self.console.print("[blue][INFO][/blue] Set defaults.stt: stt-af-next") + self.console.print( + "[yellow][WARNING][/yellow] Remember to start AF-Next: " + "cd ../../extras/asr-services && docker compose up af-next-asr -d" + ) + + elif choice == "8": + self.console.print( + "[blue][INFO][/blue] IBM Granite Speech selected " + "(LLM-backbone ASR; en/fr/de/es/pt)" + ) + self.config["GRANITE_ASR_URL"] = self._asr_url_for("GRANITE_ASR_URL") + + self.config_manager.update_config_defaults({"stt": "stt-granite"}) + + # Granite is an LLM-backbone ASR (capability "context_prompt"): it takes + # free-form context, NOT acoustic keyword boosting. Like Gemma 4 it would + # echo a wake-word boost list into the transcript, so the backend + # withholds that list and uses this context string instead. + existing_granite_context = ( + self.config_manager.get_full_config() + .get("backend", {}) + .get("asr", {}) + .get("context", {}) + .get("stt-granite", "") + ) + self.console.print( + "[blue][INFO][/blue] Granite Speech takes free-form context (domain, " + "names, jargon) to disambiguate recognition. It informs transcription " + "but is never transcribed. Leave blank to skip." + ) + granite_context = self.prompt_value( + "Granite ASR context (optional)", existing_granite_context + ) + self.config_manager.update_backend_config( + {"asr": {"context": {"stt-granite": granite_context.strip()}}} + ) + + self.console.print( + "[green][SUCCESS][/green] Granite Speech configured in config.yml and .env" + ) + self.console.print("[blue][INFO][/blue] Set defaults.stt: stt-granite") + self.console.print( + "[yellow][WARNING][/yellow] Remember to start Granite ASR: " + "cd ../../extras/asr-services && docker compose up granite-asr -d" + ) + + elif choice == "9": self.console.print("[blue][INFO][/blue] Skipping transcription setup") def setup_streaming_provider(self): @@ -464,6 +615,8 @@ def setup_streaming_provider(self): "deepgram": "stt-deepgram-stream", "smallest": "stt-smallest-stream", "qwen3-asr": "stt-qwen3-asr", + "gemma4": "stt-gemma4-stream", + "nemotron": "stt-nemotron-stream", } stream_stt = provider_to_stt_stream.get(streaming_provider) @@ -539,6 +692,47 @@ def setup_streaming_provider(self): ) stream_host = qwen3_url.replace("http://", "").rstrip("/") self.config["QWEN3_ASR_STREAM_URL"] = stream_host + elif streaming_provider == "gemma4": + # Streaming shares the gemma4-asr service (the /stream WS endpoint). + existing_url = read_env_value(".env", "GEMMA4_ASR_URL") + if not existing_url: + gemma4_url = self.prompt_value( + "Gemma 4 ASR URL", "host.docker.internal:8767" + ) + self.config["GEMMA4_ASR_URL"] = gemma4_url.replace( + "http://", "" + ).rstrip("/") + elif streaming_provider == "nemotron": + # Nemotron serves batch + streaming from one container on 8772. + existing_url = read_env_value(".env", "NEMOTRON_ASR_STREAM_URL") + if not existing_url: + nemotron_url = self.prompt_value( + "Nemotron ASR URL", "host.docker.internal:8772" + ) + self.config["NEMOTRON_ASR_STREAM_URL"] = nemotron_url.replace( + "http://", "" + ).rstrip("/") + + def setup_live_segmentation(self): + """Configure the live transcription path (defaults.live_segmentation). + + Writes "windowed_batch" when the wizard selected it (no streaming ASR), so the + windowed-batch worker transcribes fixed windows of streamed audio. Defaults to + "streaming_stt" otherwise. + """ + mode = getattr(self.args, "live_segmentation", None) + if not mode: + return + + self.config_manager.update_config_defaults({"live_segmentation": mode}) + self.console.print( + f"[blue][INFO][/blue] Set defaults.live_segmentation: {mode}" + ) + if mode == "windowed_batch": + self.console.print( + "[blue][INFO][/blue] Continuous audio will be transcribed in windows " + "(no streaming ASR required)" + ) def setup_llm(self): """Configure LLM provider - updates config.yml and .env""" @@ -554,6 +748,7 @@ def setup_llm(self): "none": "4", "llamacpp": "5", "custom": "3", + "gemma4-unified": "6", }.get(provider, "1") else: # Standalone init.py run — read existing config as default @@ -765,89 +960,292 @@ def setup_llm(self): self.config_manager.update_config_defaults( {"llm": "llamacpp-llm", "embedding": "llamacpp-embed"} ) - self.console.print( - "[green][SUCCESS][/green] llama.cpp configured in config.yml" + # Re-sync the llamacpp-llm/-embed entries from defaults.yml. config.yml + # model entries override defaults *by name*, so a stale copy (e.g. one + # predating the LLM_BASE_URL templating) would shadow the default and + # silently ignore the endpoint chosen below. Re-syncing guarantees + # model_url follows LLM_BASE_URL (and restores the discovery_* keys). + synced = self.config_manager.sync_models_from_defaults( + ["llamacpp-llm", "llamacpp-embed"] ) + if synced: + self.console.print( + "[blue][INFO][/blue] Re-synced model entries from defaults.yml: " + f"{', '.join(synced)} (model_url now follows LLM_BASE_URL)" + ) + # Source of the llama.cpp endpoint (the llamacpp-llm entry reads LLM_BASE_URL): + # --llm-discover → '' (backend discovers chronicle-llm on the Tailnet) + # --llm-base-url URL → pin a remote/own endpoint + # otherwise → leave LLM_BASE_URL alone (host-local default applies) + if getattr(self.args, "llm_discover", False): + self.config["LLM_BASE_URL"] = "" + self.console.print( + "[blue][INFO][/blue] LLM_BASE_URL left empty — backend will discover " + "chronicle-llm on the Tailnet at runtime" + ) + elif getattr(self.args, "llm_base_url", None): + self.config["LLM_BASE_URL"] = self.args.llm_base_url + self.console.print( + f"[green]✅[/green] LLM_BASE_URL = {self.args.llm_base_url}" + ) self.console.print("[blue][INFO][/blue] Set defaults.llm: llamacpp-llm") self.console.print( "[blue][INFO][/blue] Set defaults.embedding: llamacpp-embed" ) + + elif choice == "6": self.console.print( - "[blue][INFO][/blue] LLM services will be configured via extras/llm-services" + "[blue][INFO][/blue] Gemma 4 unified STT+LLM mode selected" ) - - def setup_memory(self): - """Configure memory provider - updates config.yml""" - # Check if memory provider was provided via command line (from wizard.py) - if hasattr(self.args, "memory_provider") and self.args.memory_provider: - provider = self.args.memory_provider self.console.print( - f"[green]✅[/green] Memory provider: {provider} (configured via wizard)" + "[blue][INFO][/blue] LLM requests will use the same Gemma 4 ASR service" ) - choice = {"chronicle": "1", "openmemory_mcp": "2"}.get(provider, "1") - else: - # Standalone init.py run — read existing config as default - existing_choice = "1" - full_config = self.config_manager.get_full_config() - existing_provider = full_config.get("memory", {}).get( - "provider", "chronicle" + # gemma4-llm model definition exists in defaults.yml, pointing to GEMMA4_ASR_URL + self.config_manager.update_config_defaults( + {"llm": "gemma4-llm", "embedding": "local-embed"} + ) + self.console.print( + "[green][SUCCESS][/green] Gemma 4 unified mode configured in config.yml" + ) + self.console.print("[blue][INFO][/blue] Set defaults.llm: gemma4-llm") + self.console.print( + "[blue][INFO][/blue] Set defaults.embedding: local-embed (Ollama)" + ) + self.console.print( + "[yellow][WARNING][/yellow] Embeddings require Ollama running with nomic-embed-text model" ) - if existing_provider == "openmemory_mcp": - existing_choice = "2" - self.print_section("Memory Storage Configuration") + def setup_fast_llm(self): + """Optionally configure a separate fast LLM for quick, latency-sensitive + tasks (wake-word follow-ups). Default: reuse the main LLM.""" + self.print_section("Fast LLM (optional)") + self.console.print( + "[blue][INFO][/blue] A 'fast LLM' handles quick tasks like wake-word " + "follow-up commands (e.g. saying 'warmer' after an action runs)." + ) + self.console.print( + "By default this reuses your main LLM. You can point it at a faster/cheaper " + "model instead (e.g. an 'instant' hosted model or a small local one)." + ) + self.console.print() - choices = { - "1": "Chronicle Native (Qdrant + custom extraction)", - "2": "OpenMemory MCP (cross-client compatible, external server)", - } + full_config = self.config_manager.get_full_config() + existing_fast = full_config.get("defaults", {}).get("fast_llm", "") - choice = self.prompt_choice( - "Choose your memory storage backend:", choices, existing_choice - ) + choices = { + "1": "Reuse my main LLM (recommended)", + "2": "Use a separate fast LLM (OpenAI-compatible endpoint)", + } + choice = self.prompt_choice( + "Fast LLM for quick tasks?", choices, "2" if existing_fast else "1" + ) if choice == "1": + # Empty default -> followup_resolution falls back to defaults.llm. + self.config_manager.update_config_defaults({"fast_llm": ""}) self.console.print( - "[blue][INFO][/blue] Chronicle Native memory provider selected" + "[blue][INFO][/blue] Fast LLM = main LLM (defaults.fast_llm cleared)" ) + return - qdrant_url = self.prompt_value("Qdrant URL", "qdrant") - self.config["QDRANT_BASE_URL"] = qdrant_url + existing_model = next( + (m for m in full_config.get("models", []) if m.get("name") == "fast-llm"), + {}, + ) - # Update config.yml (also updates .env automatically) - self.config_manager.update_memory_config({"provider": "chronicle"}) + base_url = self.prompt_value( + "Fast LLM API Base URL", + existing_model.get("model_url", "https://api.openai.com/v1"), + ) + if not base_url: self.console.print( - "[green][SUCCESS][/green] Chronicle memory provider configured in config.yml and .env" + "[yellow][WARNING][/yellow] No base URL - keeping main LLM for fast tasks" ) + self.config_manager.update_config_defaults({"fast_llm": ""}) + return - elif choice == "2": - self.console.print("[blue][INFO][/blue] OpenMemory MCP selected") + api_key = self.prompt_with_existing_masked( + prompt_text="Fast LLM API Key (leave empty if not required)", + env_key="FAST_LLM_API_KEY", + placeholders=["your_fast_llm_api_key_here"], + is_password=True, + default="", + ) + if api_key: + self.config["FAST_LLM_API_KEY"] = api_key + + model_name = self.prompt_value( + "Fast LLM model name (e.g., gpt-5.5, gpt-4o-mini, llama-3.1-8b-instant)", + existing_model.get("model_name", "gpt-5.5"), + ) + if not model_name: + self.console.print( + "[yellow][WARNING][/yellow] No model name - keeping main LLM for fast tasks" + ) + self.config_manager.update_config_defaults({"fast_llm": ""}) + return + + fast_model = { + "name": "fast-llm", + "description": "Fast LLM for quick tasks (wake-word follow-ups)", + "model_type": "llm", + "model_provider": "openai", + "api_family": "openai", + "model_name": model_name, + "model_url": base_url, + "api_key": "${oc.env:FAST_LLM_API_KEY,''}", + "model_params": {"temperature": 0.1, "max_tokens": 200}, + "model_output": "json", + } + self.config_manager.add_or_update_model(fast_model) + self.config_manager.update_config_defaults({"fast_llm": "fast-llm"}) + self.console.print( + "[green][SUCCESS][/green] Separate fast LLM configured " + "(defaults.fast_llm: fast-llm)" + ) + + def setup_fallback_llm(self): + """Configure a fallback LLM that calls retry against when the primary + LLM is unreachable (connection failure, timeout, 5xx). + Default: OpenAI gpt-5-nano.""" + self.print_section("Fallback LLM (recommended)") + self.console.print( + "[blue][INFO][/blue] If your main LLM is unreachable (e.g. a local " + "model is down), LLM calls are retried once against a fallback model." + ) + self.console.print( + "The default fallback is OpenAI gpt-5-nano (cheap, always available). " + "It uses your OPENAI_API_KEY." + ) + self.console.print() + + full_config = self.config_manager.get_full_config() + defaults_cfg = full_config.get("defaults", {}) + existing_fb = defaults_cfg.get("fallback_llm") or "" + explicitly_disabled = "fallback_llm" in defaults_cfg and not existing_fb + existing_model = next( + ( + m + for m in full_config.get("models", []) + if m.get("name") == "fallback-llm" + ), + {}, + ) + is_custom = bool(existing_fb) and ( + existing_fb != "fallback-llm" + or ( + existing_model + and existing_model.get("model_name") not in ("", "gpt-5-nano") + ) + ) + + choices = { + "1": "OpenAI gpt-5-nano (recommended)", + "2": "Custom OpenAI-compatible endpoint", + "3": "No fallback", + } + default_choice = "3" if explicitly_disabled else ("2" if is_custom else "1") + choice = self.prompt_choice( + "Fallback LLM when the main LLM is down?", choices, default_choice + ) - mcp_url = self.prompt_value( - "OpenMemory MCP server URL", "http://host.docker.internal:8765" + if choice == "3": + self.config_manager.update_config_defaults({"fallback_llm": ""}) + self.console.print( + "[blue][INFO][/blue] No fallback LLM — calls fail if the main " + "LLM is unreachable (defaults.fallback_llm cleared)" ) - client_name = self.prompt_value("OpenMemory client name", "chronicle") - user_id = self.prompt_value("OpenMemory user ID", "openmemory") - timeout = self.prompt_value("OpenMemory timeout (seconds)", "30") + return - # Update config.yml with OpenMemory MCP settings (also updates .env automatically) - self.config_manager.update_memory_config( - { - "provider": "openmemory_mcp", - "openmemory_mcp": { - "server_url": mcp_url, - "client_name": client_name, - "user_id": user_id, - "timeout": int(timeout), - }, - } + if choice == "1": + # Stock gpt-5-nano entry ships in defaults.yml; re-sync a stale + # customized copy in config.yml so it doesn't shadow the default. + if existing_model: + self.config_manager.sync_models_from_defaults(["fallback-llm"]) + api_key = self.prompt_with_existing_masked( + prompt_text="OpenAI API key for the fallback (leave empty to keep existing)", + env_key="OPENAI_API_KEY", + placeholders=["your_openai_api_key_here", "your-openai-key-here"], + is_password=True, + default="", ) + if api_key: + self.config["OPENAI_API_KEY"] = api_key + else: + self.console.print( + "[yellow][WARNING][/yellow] No OPENAI_API_KEY — the fallback " + "will not work until one is set in .env" + ) + self.config_manager.update_config_defaults({"fallback_llm": "fallback-llm"}) self.console.print( - "[green][SUCCESS][/green] OpenMemory MCP configured in config.yml and .env" + "[green][SUCCESS][/green] Fallback LLM: OpenAI gpt-5-nano " + "(defaults.fallback_llm: fallback-llm)" ) + return + + # Custom OpenAI-compatible fallback endpoint + base_url = self.prompt_value( + "Fallback LLM API Base URL", + existing_model.get("model_url", "https://api.openai.com/v1"), + ) + if not base_url: self.console.print( - "[yellow][WARNING][/yellow] Remember to start OpenMemory: cd ../../extras/openmemory-mcp && docker compose up -d" + "[yellow][WARNING][/yellow] No base URL - fallback disabled" ) + self.config_manager.update_config_defaults({"fallback_llm": ""}) + return + + api_key = self.prompt_with_existing_masked( + prompt_text="Fallback LLM API Key (leave empty if not required)", + env_key="FALLBACK_LLM_API_KEY", + placeholders=["your_fallback_llm_api_key_here"], + is_password=True, + default="", + ) + if api_key: + self.config["FALLBACK_LLM_API_KEY"] = api_key + + model_name = self.prompt_value( + "Fallback LLM model name (e.g., gpt-5-nano, llama-3.1-8b-instant)", + existing_model.get("model_name", "gpt-5-nano"), + ) + if not model_name: + self.console.print( + "[yellow][WARNING][/yellow] No model name - fallback disabled" + ) + self.config_manager.update_config_defaults({"fallback_llm": ""}) + return + + fallback_model = { + "name": "fallback-llm", + "description": "Fallback LLM used when the primary LLM is unreachable", + "model_type": "llm", + "model_provider": "openai", + "api_family": "openai", + "model_name": model_name, + "model_url": base_url, + "api_key": "${oc.env:FALLBACK_LLM_API_KEY,''}", + "model_params": {"temperature": 0.2, "max_tokens": 4000}, + "model_output": "json", + } + self.config_manager.add_or_update_model(fallback_model) + self.config_manager.update_config_defaults({"fallback_llm": "fallback-llm"}) + self.console.print( + "[green][SUCCESS][/green] Custom fallback LLM configured " + "(defaults.fallback_llm: fallback-llm)" + ) + + def setup_memory(self): + """Configure memory provider - updates config.yml. + + Chronicle's agentic Markdown vault is currently the only memory provider, + so there is no provider choice to make — we just ensure config.yml/.env + record it. + """ + self.config_manager.update_memory_config({"provider": "chronicle"}) + self.console.print( + "[green][SUCCESS][/green] Memory: Chronicle agentic vault (config.yml + .env)" + ) def setup_optional_services(self): """Configure optional services""" @@ -871,8 +1269,30 @@ def setup_optional_services(self): f"[green]✅[/green] Parakeet ASR: {self.args.parakeet_asr_url} (configured via wizard)" ) + # Speaker / TTS source = "configure from the Tailnet later": leave the URL + # empty so the backend discovers chronicle-speaker / chronicle-tts at runtime. + speaker_discover = getattr(self.args, "speaker_discover", False) + if speaker_discover: + self.config["SPEAKER_SERVICE_URL"] = "" + self.console.print( + "[blue][INFO][/blue] SPEAKER_SERVICE_URL left empty — backend will " + "discover chronicle-speaker on the Tailnet at runtime" + ) + + if getattr(self.args, "tts_discover", False): + self.config["CHRONICLE_TTS_URL"] = "" + self.console.print( + "[blue][INFO][/blue] CHRONICLE_TTS_URL left empty — backend will " + "discover chronicle-tts on the Tailnet at runtime" + ) + elif getattr(self.args, "tts_url", None): + self.config["CHRONICLE_TTS_URL"] = self.args.tts_url + self.console.print( + f"[green]✅[/green] TTS: {self.args.tts_url} (configured via wizard)" + ) + # Only show interactive section if not all configured via args - if not has_speaker_arg: + if not has_speaker_arg and not speaker_discover: try: enable_speaker = Confirm.ask( "Enable Speaker Recognition?", default=False @@ -901,101 +1321,6 @@ def setup_optional_services(self): f"[green][SUCCESS][/green] Tailscale auth key configured (Docker integration enabled)" ) - def setup_neo4j(self): - """Configure Neo4j credentials (always required - used by Knowledge Graph)""" - neo4j_password = getattr(self.args, "neo4j_password", None) - - if neo4j_password: - self.console.print( - f"[green]✅[/green] Neo4j: password configured via wizard" - ) - else: - # Interactive prompt (standalone init.py run) - self.console.print() - self.console.print("[bold cyan]Neo4j Configuration[/bold cyan]") - self.console.print( - "Neo4j is used for Knowledge Graph (entity/relationship extraction)" - ) - self.console.print() - neo4j_password = self.prompt_with_existing_masked( - "Neo4j password (min 8 chars)", - env_key="NEO4J_PASSWORD", - placeholders=["", "your-neo4j-password"], - is_password=True, - default="neo4jpassword", - ) - - self.config["NEO4J_HOST"] = "neo4j" - self.config["NEO4J_USER"] = "neo4j" - self.config["NEO4J_PASSWORD"] = neo4j_password - self.console.print("[green][SUCCESS][/green] Neo4j credentials configured") - - def setup_obsidian(self): - """Configure Obsidian integration (optional feature flag only - Neo4j credentials handled by setup_neo4j)""" - has_enable = hasattr(self.args, "enable_obsidian") and self.args.enable_obsidian - has_disable = hasattr(self.args, "no_obsidian") and self.args.no_obsidian - - if has_enable: - enable_obsidian = True - self.console.print( - f"[green]✅[/green] Obsidian: enabled (configured via wizard)" - ) - elif has_disable: - enable_obsidian = False - self.console.print( - f"[blue][INFO][/blue] Obsidian: disabled (configured via wizard)" - ) - else: - # Standalone init.py run — read existing config as default - full_config = self.config_manager.get_full_config() - existing_enabled = ( - full_config.get("memory", {}).get("obsidian", {}).get("enabled", False) - ) - - self.console.print() - self.console.print("[bold cyan]Obsidian Integration (Optional)[/bold cyan]") - self.console.print( - "Enable graph-based knowledge management for Obsidian vault notes" - ) - self.console.print() - - try: - enable_obsidian = Confirm.ask( - "Enable Obsidian integration?", default=existing_enabled - ) - except EOFError: - self.console.print( - f"Using default: {'Yes' if existing_enabled else 'No'}" - ) - enable_obsidian = existing_enabled - - if enable_obsidian: - self.config_manager.update_memory_config( - {"obsidian": {"enabled": True, "neo4j_host": "neo4j", "timeout": 30}} - ) - self.console.print("[green][SUCCESS][/green] Obsidian integration enabled") - else: - self.config_manager.update_memory_config( - {"obsidian": {"enabled": False, "neo4j_host": "neo4j", "timeout": 30}} - ) - self.console.print("[blue][INFO][/blue] Obsidian integration disabled") - - def setup_knowledge_graph(self): - """Configure Knowledge Graph (Neo4j-based entity/relationship extraction - always enabled)""" - self.config_manager.update_memory_config( - { - "knowledge_graph": { - "enabled": True, - "neo4j_host": "neo4j", - "timeout": 30, - } - } - ) - self.console.print("[green][SUCCESS][/green] Knowledge Graph enabled") - self.console.print( - "[blue][INFO][/blue] Entities and relationships will be extracted from conversations" - ) - def setup_langfuse(self): """Configure LangFuse observability and prompt management""" self.console.print() @@ -1176,17 +1501,19 @@ def setup_https(self): if enable_https: script_dir = Path(__file__).parent - # Check for centralized certs (generated by wizard.py) - certs_dir = script_dir / ".." / ".." / "certs" - cert_file = certs_dir / "server.crt" - if not cert_file.exists(): - self.console.print( - "[yellow][WARNING][/yellow] No certificates found in certs/ directory" - ) - self.console.print( - "[yellow][WARNING][/yellow] Run ./wizard.sh to generate certificates, " - "or: cd certs && ./generate-ssl.sh
" - ) + # Decide how the TLS cert is managed (same logic the wizard uses). + cert_mode = decide_cert_mode(server_ip) + + if cert_mode == "static": + # Host-issued cert file (e.g. Docker Desktop on macOS, where Caddy can't + # reach the tailscaled socket). Warn if it's missing; the wizard normally + # generates it and the services.py startup hook keeps it fresh on restart. + cert_file = script_dir / ".." / ".." / "certs" / "server.crt" + if not cert_file.exists(): + self.console.print( + "[yellow][WARNING][/yellow] No certificate found in certs/; " + "run ./wizard.sh, or it will be generated on first start." + ) # Generate Caddyfile from template self.console.print( @@ -1221,6 +1548,16 @@ def setup_https(self): "TAILSCALE_IP", server_ip ) + # Static mode serves a host-issued cert file; caddy mode lets + # Caddy obtain/renew the cert itself (no tls directive). + if cert_mode == "static": + caddyfile_content = caddyfile_content.replace( + f"localhost {server_ip} {{", + f"localhost {server_ip} {{\n" + " tls /certs/server.crt /certs/server.key", + 1, + ) + with open(caddyfile_path, "w") as f: f.write(caddyfile_content) @@ -1229,6 +1566,20 @@ def setup_https(self): ) self.config["HTTPS_ENABLED"] = "true" self.config["SERVER_IP"] = server_ip + self.config["HTTPS_CERT_MODE"] = cert_mode + + # Caddy-managed certs on a Tailscale address need the tailscaled + # socket mounted in; write/remove the compose override to match. + self._write_caddy_socket_override( + script_dir, cert_mode, server_ip + ) + + # Configure webui-dev for same-origin API calls through Caddy + self.config["VITE_BACKEND_URL"] = "" + self.config["VITE_HMR_PORT"] = "443" + self.config["VITE_ALLOWED_HOSTS"] = ( + f"localhost 127.0.0.1 {server_ip}" + ) except Exception as e: self.console.print( @@ -1247,6 +1598,29 @@ def setup_https(self): else: self.config["HTTPS_ENABLED"] = "false" + def _write_caddy_socket_override(self, service_dir, cert_mode, server_address): + """Write or remove the compose override that mounts the tailscaled socket into + Caddy. Needed only for Caddy-managed certs on a *.ts.net address; removed + otherwise so a re-run can't leave a stale mount behind.""" + override_path = service_dir / "docker-compose.override.yml" + socket = tailscale_socket_path() + if cert_mode == "caddy" and server_address.endswith(".ts.net") and socket: + override_path.write_text( + "# Generated by init.py for HTTPS_CERT_MODE=caddy on a Tailscale address.\n" + "# Mounts the host tailscaled socket so Caddy fetches and auto-renews the\n" + "# Tailscale TLS certificate itself (no host cert file, no renewal cron).\n" + "services:\n" + " caddy:\n" + " volumes:\n" + f" - {socket}:/var/run/tailscale/tailscaled.sock\n" + ) + self.console.print( + "[green][SUCCESS][/green] Caddy will auto-manage the Tailscale " + "certificate (tailscaled socket mounted)" + ) + elif override_path.exists(): + override_path.unlink() + def generate_env_file(self): """Generate .env file from template and update with configuration. @@ -1283,7 +1657,7 @@ def generate_env_file(self): merged = {**preserved_values, **self.config} for key, value in merged.items(): - if value: # Only set non-empty values + if value is not None: # Only set values that were explicitly configured set_key(env_path_str, key, value) # Ensure secure permissions @@ -1341,23 +1715,16 @@ def show_summary(self): llm_default = config_yml.get("defaults", {}).get("llm", "not set") embedding_default = config_yml.get("defaults", {}).get("embedding", "not set") self.console.print(f"✅ LLM: {llm_default} (config.yml)") + if llm_default == "gemma4-llm" and stt_default == "stt-gemma4": + self.console.print( + " [dim](unified: STT and LLM share the same Gemma 4 model)[/dim]" + ) self.console.print(f"✅ Embedding: {embedding_default} (config.yml)") # Show memory provider from config.yml memory_provider = config_yml.get("memory", {}).get("provider", "chronicle") self.console.print(f"✅ Memory Provider: {memory_provider} (config.yml)") - # Show Obsidian/Neo4j status (read from config.yml) - obsidian_config = config_yml.get("memory", {}).get("obsidian", {}) - if obsidian_config.get("enabled", False): - neo4j_host = obsidian_config.get("neo4j_host", "not set") - self.console.print(f"✅ Obsidian/Neo4j: Enabled ({neo4j_host})") - - # Show Knowledge Graph status (always enabled) - kg_config = config_yml.get("memory", {}).get("knowledge_graph", {}) - neo4j_host = kg_config.get("neo4j_host", "neo4j") - self.console.print(f"✅ Knowledge Graph: Enabled ({neo4j_host})") - # Auto-determine URLs based on HTTPS configuration if self.config.get("HTTPS_ENABLED") == "true": server_ip = self.config.get("SERVER_IP", "localhost") @@ -1400,13 +1767,6 @@ def show_next_steps(self): f" [cyan]curl http://localhost:{backend_port}/health[/cyan]" ) - if self.config.get("MEMORY_PROVIDER") == "openmemory_mcp": - self.console.print() - self.console.print("4. Start OpenMemory MCP:") - self.console.print( - " [cyan]cd ../../extras/openmemory-mcp && docker compose up -d[/cyan]" - ) - if self.config.get("TRANSCRIPTION_PROVIDER") == "offline": self.console.print() self.console.print("5. Start Parakeet ASR:") @@ -1436,12 +1796,12 @@ def run(self): self.setup_authentication() self.setup_transcription() self.setup_streaming_provider() + self.setup_live_segmentation() self.setup_llm() + self.setup_fast_llm() + self.setup_fallback_llm() self.setup_memory() self.setup_optional_services() - self.setup_neo4j() - self.setup_obsidian() - self.setup_knowledge_graph() self.setup_langfuse() self.setup_network() self.setup_https() @@ -1485,30 +1845,68 @@ def main(): "--speaker-service-url", help="Speaker Recognition service URL (default: prompt user)", ) + parser.add_argument( + "--speaker-discover", + action="store_true", + help="Leave SPEAKER_SERVICE_URL empty so the backend discovers " + "chronicle-speaker on the Tailnet at runtime.", + ) + parser.add_argument( + "--tts-url", + help="Text-to-speech endpoint URL (own/remote/Tailnet-picked) → CHRONICLE_TTS_URL.", + ) + parser.add_argument( + "--tts-discover", + action="store_true", + help="Leave CHRONICLE_TTS_URL empty so the backend discovers chronicle-tts " + "on the Tailnet at runtime.", + ) parser.add_argument( "--parakeet-asr-url", help="Parakeet ASR service URL (default: prompt user)" ) parser.add_argument( "--transcription-provider", - choices=["deepgram", "parakeet", "vibevoice", "qwen3-asr", "smallest", "none"], + choices=[ + "deepgram", + "parakeet", + "vibevoice", + "qwen3-asr", + "smallest", + "gemma4", + "af-next", + "none", + ], help="Transcription provider (default: prompt user)", ) parser.add_argument( - "--enable-https", + "--asr-url", + help="Offline ASR endpoint URL (own/remote/Tailnet-picked). Written to the " + "selected provider's *_ASR_URL env var.", + ) + parser.add_argument( + "--asr-discover", action="store_true", - help="Enable HTTPS configuration (default: prompt user)", + help="Leave the ASR URL empty so the backend discovers chronicle-asr on the " + "Tailnet at runtime ('configure from the Tailnet later').", ) parser.add_argument( - "--server-ip", - help="Server IP/domain for SSL certificate (default: prompt user)", + "--llm-base-url", + help="Pin the llama.cpp LLM endpoint (own/remote/Tailnet-picked) via LLM_BASE_URL.", ) parser.add_argument( - "--enable-obsidian", + "--llm-discover", action="store_true", - help="Enable Obsidian/Neo4j integration (default: prompt user)", + help="Leave LLM_BASE_URL empty so the backend discovers chronicle-llm on the " + "Tailnet at runtime.", ) parser.add_argument( - "--neo4j-password", help="Neo4j password (default: prompt user)" + "--enable-https", + action="store_true", + help="Enable HTTPS configuration (default: prompt user)", + ) + parser.add_argument( + "--server-ip", + help="Server IP/domain for SSL certificate (default: prompt user)", ) parser.add_argument( "--ts-authkey", @@ -1526,25 +1924,26 @@ def main(): "--langfuse-host", help="LangFuse host URL (default: http://langfuse-web:3000 for local)", ) + parser.add_argument( + "--langfuse-public-url", + help="Browser-accessible LangFuse URL for dashboard deep-links " + "(e.g. http://my-host:3002). Stored in config.yml as observability.langfuse.public_url", + ) parser.add_argument( "--streaming-provider", choices=["deepgram", "smallest", "qwen3-asr"], help="Streaming provider when different from batch (enables batch re-transcription)", ) parser.add_argument( - "--llm-provider", - choices=["openai", "ollama", "llamacpp", "custom", "none"], - help="LLM provider for memory extraction (default: prompt user)", - ) - parser.add_argument( - "--memory-provider", - choices=["chronicle", "openmemory_mcp"], - help="Memory storage backend (default: prompt user)", + "--live-segmentation", + choices=["streaming_stt", "windowed_batch"], + help="Live transcription path: streaming_stt (default) or windowed_batch " + "(batch-transcribe fixed windows when no streaming ASR)", ) parser.add_argument( - "--no-obsidian", - action="store_true", - help="Explicitly disable Obsidian integration (complementary to --enable-obsidian)", + "--llm-provider", + choices=["openai", "ollama", "llamacpp", "custom", "gemma4-unified", "none"], + help="LLM provider for memory extraction (default: prompt user)", ) args = parser.parse_args() diff --git a/backends/advanced/pyproject.toml b/backends/advanced/pyproject.toml index 56b5c62a..31116040 100644 --- a/backends/advanced/pyproject.toml +++ b/backends/advanced/pyproject.toml @@ -8,9 +8,6 @@ dependencies = [ "easy-audio-interfaces>=0.7.1", # we need to add local-audio for scripts/local-audio.py | If we don't need that, we can remove this, and then remove portaudio19-dev from Dockerfile "fastapi>=0.115.12", "fastmcp>=0.5.0", # MCP server for conversation access - "mem0ai", # Using main branch with PR #3250 AsyncMemory fix - "langchain_neo4j", - "neo4j>=5.0.0,<6.0.0", "motor>=3.7.1", "ollama>=0.4.8", "friend-lite-sdk", @@ -38,6 +35,8 @@ dependencies = [ "websockets>=12.0", "croniter>=1.3.0", "rich>=13.0.0", + "minidisc-python>=0.1.0", + "ten-vad @ git+https://github.com/TEN-framework/ten-vad.git@22a3bcd4509d0faaa8eef4881e8af5f39c178950", ] [project.optional-dependencies] @@ -52,6 +51,10 @@ galileo = [ "galileo>=1.0", "opentelemetry-exporter-otlp>=1.20", ] +benchmark = [ + "huggingface-hub>=0.23", # loader.py downloads LongMemEval via hf_hub_download + "ijson>=3.2.0", # streaming JSON parse for the large LongMemEval files +] [build-system] requires = ["setuptools>=61.0", "wheel"] @@ -63,9 +66,6 @@ where = ["src"] [tool.isort] profile = "black" -[tool.uv.sources] -mem0ai = { git = "https://github.com/AnkushMalaker/mem0.git", rev = "main" } - [tool.poetry.dependencies] robotframework = "^6.1.1" @@ -98,6 +98,7 @@ markers = [ "auth: marks tests that test authentication", "database: marks tests that require database access", ] +asyncio_mode = "auto" filterwarnings = [ "error", "ignore::UserWarning", diff --git a/backends/advanced/run-test.sh b/backends/advanced/run-test.sh index 61fd7d55..e7710b00 100755 --- a/backends/advanced/run-test.sh +++ b/backends/advanced/run-test.sh @@ -211,7 +211,7 @@ print_info "Using environment variables from .env file for test configuration" # Clean test environment print_info "Cleaning test environment..." -rm -rf ./test_audio_chunks/ ./test_data/ ./test_debug_dir/ ./mongo_data_test/ ./qdrant_data_test/ ./test_neo4j/ 2>/dev/null || true +rm -rf ./test_audio_chunks/ ./test_data/ ./test_debug_dir/ ./mongo_data_test/ ./test_falkordb/ 2>/dev/null || true # If cleanup fails due to permissions, try with docker if [ -d "./data/test_audio_chunks/" ] || [ -d "./data/test_data/" ] || [ -d "./data/test_debug_dir/" ]; then diff --git a/backends/advanced/src/advanced_omi_backend/app_config.py b/backends/advanced/src/advanced_omi_backend/app_config.py index d2f50d8e..5cfd0668 100644 --- a/backends/advanced/src/advanced_omi_backend/app_config.py +++ b/backends/advanced/src/advanced_omi_backend/app_config.py @@ -62,8 +62,6 @@ def __init__(self): ) # External Services Configuration - self.qdrant_base_url = os.getenv("QDRANT_BASE_URL", "qdrant") - self.qdrant_port = os.getenv("QDRANT_PORT", "6333") # Memory provider from registry _reg = get_models_registry() _mem = _reg.memory if _reg else {} @@ -112,8 +110,3 @@ def get_mongo_collections(): "users": app_config.users_col, "speakers": app_config.speakers_col, } - - -def get_redis_config(): - """Get Redis configuration.""" - return {"url": app_config.redis_url, "encoding": "utf-8", "decode_responses": False} diff --git a/backends/advanced/src/advanced_omi_backend/app_factory.py b/backends/advanced/src/advanced_omi_backend/app_factory.py index c1d56fed..1256999e 100644 --- a/backends/advanced/src/advanced_omi_backend/app_factory.py +++ b/backends/advanced/src/advanced_omi_backend/app_factory.py @@ -7,11 +7,12 @@ import asyncio import logging +import os import time from contextlib import asynccontextmanager from pathlib import Path -import redis.asyncio as redis +from beanie import init_beanie from fastapi import FastAPI from fastapi.staticfiles import StaticFiles @@ -26,6 +27,13 @@ ) from advanced_omi_backend.client_manager import get_client_manager from advanced_omi_backend.middleware.app_middleware import setup_middleware +from advanced_omi_backend.models.annotation import Annotation +from advanced_omi_backend.models.audio_chunk import AudioChunkDocument +from advanced_omi_backend.models.conversation import Conversation +from advanced_omi_backend.models.memory_audit import MemoryAuditEntry +from advanced_omi_backend.models.system_event import SystemEvent +from advanced_omi_backend.models.waveform import WaveformData +from advanced_omi_backend.redis_factory import create_async_redis from advanced_omi_backend.routers.api_router import router as api_router from advanced_omi_backend.routers.modules.health_routes import router as health_router from advanced_omi_backend.routers.modules.websocket_routes import ( @@ -48,77 +56,6 @@ application_logger = logging.getLogger("audio_processing") -async def initialize_openmemory_user() -> None: - """Initialize and register OpenMemory user if using OpenMemory MCP provider. - - This function: - - Checks if OpenMemory MCP is configured as the memory provider - - Registers the configured user with OpenMemory server - - Creates a test memory and deletes it to trigger user creation - - Logs success or warning if OpenMemory is not reachable - """ - from advanced_omi_backend.services.memory.config import ( - MemoryProvider, - build_memory_config_from_env, - ) - - memory_provider_config = build_memory_config_from_env() - - if memory_provider_config.memory_provider != MemoryProvider.OPENMEMORY_MCP: - return - - try: - from advanced_omi_backend.services.memory.providers.mcp_client import MCPClient - - # Get configured user_id and server_url - openmemory_config = memory_provider_config.openmemory_config - user_id = ( - openmemory_config.get("user_id", "openmemory") - if openmemory_config - else "openmemory" - ) - server_url = ( - openmemory_config.get("server_url", "http://host.docker.internal:8765") - if openmemory_config - else "http://host.docker.internal:8765" - ) - client_name = ( - openmemory_config.get("client_name", "chronicle") - if openmemory_config - else "chronicle" - ) - - application_logger.info( - f"Registering OpenMemory user: {user_id} at {server_url}" - ) - - # Make a lightweight registration call (create and delete dummy memory) - async with MCPClient( - server_url=server_url, client_name=client_name, user_id=user_id - ) as client: - # Test connection first - is_connected = await client.test_connection() - if is_connected: - # Create and immediately delete a dummy memory to trigger user creation - memory_ids = await client.add_memories( - "Chronicle initialization - user registration test" - ) - if memory_ids: - # Delete the test memory - await client.delete_memory(memory_ids[0]) - application_logger.info(f"✅ Registered OpenMemory user: {user_id}") - else: - application_logger.warning( - f"⚠️ OpenMemory MCP not reachable at {server_url}" - ) - application_logger.info( - "User will be auto-created on first memory operation" - ) - except Exception as e: - application_logger.warning(f"⚠️ Could not register OpenMemory user: {e}") - application_logger.info("User will be auto-created on first memory operation") - - @asynccontextmanager async def lifespan(app: FastAPI): """Manage application lifespan events.""" @@ -133,14 +70,6 @@ async def lifespan(app: FastAPI): # Initialize Beanie for all document models try: - from beanie import init_beanie - - from advanced_omi_backend.models.annotation import Annotation - from advanced_omi_backend.models.audio_chunk import AudioChunkDocument - from advanced_omi_backend.models.conversation import Conversation - from advanced_omi_backend.models.user import User - from advanced_omi_backend.models.waveform import WaveformData - await init_beanie( database=config.db, document_models=[ @@ -149,6 +78,8 @@ async def lifespan(app: FastAPI): AudioChunkDocument, WaveformData, Annotation, + MemoryAuditEntry, + SystemEvent, ], ) application_logger.info("Beanie initialized for all document models") @@ -252,9 +183,7 @@ async def _init_audio_stream_service(): async def _init_redis_audio_producer(): try: - app.state.redis_audio_stream = await redis.from_url( - config.redis_url, encoding="utf-8", decode_responses=False - ) + app.state.redis_audio_stream = create_async_redis(decode_responses=False) from advanced_omi_backend.services.audio_stream import AudioStreamProducer app.state.audio_stream_producer = AudioStreamProducer( @@ -268,7 +197,7 @@ async def _init_redis_audio_producer(): initialize_redis_for_client_manager, ) - initialize_redis_for_client_manager(config.redis_url) + initialize_redis_for_client_manager() except Exception as e: application_logger.error( f"Failed to initialize Redis client for audio streaming: {e}", @@ -321,11 +250,11 @@ async def _deferred_prompt_seed(): "Memory service will be initialized on first use (lazy loading)" ) - async def _init_openmemory(): - await initialize_openmemory_user() - async def _init_cron_scheduler(): try: + from advanced_omi_backend.controllers.data_audit_controller import ( + run_auto_clean_cron, + ) from advanced_omi_backend.cron_scheduler import ( get_scheduler, register_cron_job, @@ -347,6 +276,7 @@ async def _init_cron_scheduler(): register_cron_job("asr_jargon_extraction", run_asr_jargon_extraction_job) register_cron_job("prompt_optimization", run_prompt_optimization_job) register_cron_job("annotation_suggestions", surface_error_suggestions) + register_cron_job("auto_clean", run_auto_clean_cron) scheduler = get_scheduler() await scheduler.start() @@ -358,53 +288,114 @@ async def _init_plugins(): try: from advanced_omi_backend.services.plugin_service import ( init_plugin_router, + initialize_plugins, + run_plugin_recovery, set_plugin_router, ) plugin_router = init_plugin_router() if plugin_router: - for plugin_id, plugin in plugin_router.plugins.items(): - if plugin.enabled: - try: - await plugin.initialize() - plugin_router.mark_plugin_initialized(plugin_id) - application_logger.info(f"Plugin '{plugin_id}' initialized") - except Exception as e: - plugin_router.mark_plugin_failed(plugin_id, str(e)) - application_logger.error( - f"Failed to initialize plugin '{plugin_id}': {e}", - exc_info=True, - ) + await initialize_plugins(plugin_router) health = plugin_router.get_health_summary() application_logger.info( f"Plugins initialized: {health['initialized']}/{health['total']} active" + + (f", {health['degraded']} degraded" if health["degraded"] else "") + (f", {health['failed']} failed" if health["failed"] else "") ) app.state.plugin_router = plugin_router set_plugin_router(plugin_router) + # Background recovery: retries degraded/failed plugins with backoff + # (e.g. Home Assistant on a server that's off at boot) and demotes + # initialized plugins whose health_check starts failing. + app.state.plugin_recovery_task = asyncio.create_task( + run_plugin_recovery(plugin_router) + ) else: application_logger.info("No plugins configured") app.state.plugin_router = None + app.state.plugin_recovery_task = None except Exception as e: application_logger.error( f"Failed to initialize plugin system: {e}", exc_info=True ) app.state.plugin_router = None + app.state.plugin_recovery_task = None await asyncio.gather( - _init_openmemory(), _init_cron_scheduler(), _init_plugins(), ) application_logger.info( - f"Phase 4 (OpenMemory/Cron/Plugins) completed in {time.monotonic() - phase_start:.2f}s" + f"Phase 4 (Cron/Plugins) completed in {time.monotonic() - phase_start:.2f}s" ) + # Inbound vault edits (human edits in Obsidian, delivered by Syncthing) are + # recorded into the memory audit ledger by a background listener. No-ops when + # vault sync isn't configured. + try: + from advanced_omi_backend.services.memory.syncthing_audit import ( + start_syncthing_audit_listener, + ) + + app.state.syncthing_audit_task = start_syncthing_audit_listener() + except Exception as e: + application_logger.warning(f"Syncthing memory-audit listener not started: {e}") + app.state.syncthing_audit_task = None + + # Backstop reaper: one periodic loop that force-cleans stale clients (zombie + # "connected" devices), orphaned audio streams the idle-timeout path missed, and + # orphaned deferred RQ jobs whose dependency was deleted (never promotable). + try: + from advanced_omi_backend.services.reaper import run_reaper + + app.state.reaper_task = asyncio.create_task(run_reaper()) + except Exception as e: + application_logger.warning(f"Reaper not started: {e}") + app.state.reaper_task = None + + # Observability: drain the system-event ingest list (filled by RQ workers and the + # catch-all log handler) into Mongo + SSE, and poll service health to record + # crash-loop / down / recovered transitions. + try: + from advanced_omi_backend.services.observability import run_event_ingest_drain + from advanced_omi_backend.services.observability.health_poller import ( + run_health_poller, + ) + + app.state.system_event_drain_task = asyncio.create_task( + run_event_ingest_drain() + ) + app.state.health_poller_task = asyncio.create_task(run_health_poller(app)) + except Exception as e: + application_logger.warning(f"Observability tasks not started: {e}") + app.state.system_event_drain_task = None + app.state.health_poller_task = None + + # One-shot startup reconcile: recompute processing_status from facts once, so any + # drift left before this version (or by a failure callback that itself died) is + # healed at boot. Steady-state recovery is now event-driven — the post-conversation + # chain uses Retry + Dependency(allow_failure=True) + an on_failure callback, so a + # crashed/abandoned job recovers and surfaces a system event on its own without a + # periodic poll. This boot sweep + the admin endpoint + # (/api/admin/conversations/reconcile-status) are the remaining backstops. Run as a + # background task so the (full-collection) scan doesn't block startup. + try: + from advanced_omi_backend.services.status_reconciler import ( + reconcile_conversation_statuses, + ) + + app.state.status_reconciler_task = asyncio.create_task( + reconcile_conversation_statuses() + ) + except Exception as e: + application_logger.warning(f"Startup status reconcile not started: {e}") + app.state.status_reconciler_task = None + total_startup = time.monotonic() - startup_start application_logger.info( f"Application ready in {total_startup:.2f}s - using application-level processing architecture." @@ -429,6 +420,46 @@ async def _init_plugins(): except Exception as e: application_logger.error(f"Error cleaning up client {client_id}: {e}") + # Stop the Syncthing memory-audit listener + try: + audit_task = getattr(app.state, "syncthing_audit_task", None) + if audit_task is not None: + audit_task.cancel() + application_logger.info("Syncthing memory-audit listener stopped") + except Exception as e: + application_logger.error(f"Error stopping memory-audit listener: {e}") + + # Stop the backstop reaper + try: + reaper_task = getattr(app.state, "reaper_task", None) + if reaper_task is not None: + reaper_task.cancel() + application_logger.info("Reaper stopped") + except Exception as e: + application_logger.error(f"Error stopping reaper: {e}") + + # Stop the plugin recovery loop + try: + recovery_task = getattr(app.state, "plugin_recovery_task", None) + if recovery_task is not None: + recovery_task.cancel() + application_logger.info("Plugin recovery loop stopped") + except Exception as e: + application_logger.error(f"Error stopping plugin recovery loop: {e}") + + # Stop the observability tasks (event drain + health poller) + for _attr, _label in ( + ("system_event_drain_task", "System-event drain"), + ("health_poller_task", "Health poller"), + ): + try: + _task = getattr(app.state, _attr, None) + if _task is not None: + _task.cancel() + application_logger.info(f"{_label} stopped") + except Exception as e: + application_logger.error(f"Error stopping {_label}: {e}") + # Shutdown BackgroundTaskManager try: task_mgr = get_task_manager() @@ -500,7 +531,11 @@ def create_app() -> FastAPI: app = FastAPI(lifespan=lifespan) # Set up middleware (CORS, exception handlers) - setup_middleware(app) + setup_middleware( + app, + disable_request_logging=os.getenv("DISABLE_REQUEST_LOGGING", "").lower() + == "true", + ) # Include all routers app.include_router(api_router) diff --git a/backends/advanced/src/advanced_omi_backend/auth.py b/backends/advanced/src/advanced_omi_backend/auth.py index 3bd6ea88..c365c01c 100644 --- a/backends/advanced/src/advanced_omi_backend/auth.py +++ b/backends/advanced/src/advanced_omi_backend/auth.py @@ -3,7 +3,7 @@ import logging import os import re -from datetime import datetime, timedelta +from datetime import datetime, timedelta, timezone from typing import Literal, Optional, overload import jwt @@ -134,8 +134,8 @@ def generate_jwt_for_user(user_id: str, user_email: str) -> str: "email": user_email, "iss": "chronicle", # Issuer "aud": "chronicle", # Audience - "exp": datetime.utcnow() + timedelta(seconds=JWT_LIFETIME_SECONDS), - "iat": datetime.utcnow(), # Issued at + "exp": datetime.now(timezone.utc) + timedelta(seconds=JWT_LIFETIME_SECONDS), + "iat": datetime.now(timezone.utc), # Issued at } # Sign the token with the same secret key @@ -253,10 +253,34 @@ async def create_admin_user_if_needed(): logger.error(f"Failed to create admin user: {e}", exc_info=True) -async def websocket_auth(websocket, token: Optional[str] = None) -> Optional[User]: +def _check_token_expired(token_str: str) -> bool: + """Check if a JWT token is expired without full verification. + + Decodes without signature verification to inspect the exp claim. + Returns True if the token is expired, False otherwise. + """ + try: + payload = jwt.decode( + token_str, options={"verify_signature": False, "verify_exp": False} + ) + exp = payload.get("exp") + if exp is not None: + return datetime.now(timezone.utc).timestamp() > exp + except Exception: + pass + return False + + +async def websocket_auth( + websocket, token: Optional[str] = None +) -> tuple[Optional[User], str]: """ WebSocket authentication that supports both cookie and token-based auth. - Returns None if authentication fails (allowing graceful handling). + + Returns: + tuple of (User or None, failure_reason string). + failure_reason is empty string on success, or one of: + "token_expired", "user_not_found", "token_invalid", "no_credentials" """ strategy = get_jwt_strategy() @@ -274,16 +298,25 @@ async def websocket_auth(websocket, token: Optional[str] = None) -> Optional[Use logger.info( f"WebSocket auth successful for user {user.user_id} using query token." ) - return user + return user, "" else: - logger.warning( - f"Token validated but user inactive or not found: user={user}" - ) + # read_token returned None — figure out why + if _check_token_expired(token): + logger.warning("WebSocket auth failed: JWT token is expired") + return None, "token_expired" + else: + logger.warning( + f"WebSocket auth failed: user inactive or not found (user={user})" + ) + return None, "user_not_found" except Exception as e: logger.error( f"WebSocket auth with query token failed: {type(e).__name__}: {e}", exc_info=True, ) + if _check_token_expired(token): + return None, "token_expired" + return None, "token_invalid" # Try cookie authentication logger.debug("Attempting WebSocket auth with cookie.") @@ -299,17 +332,21 @@ async def websocket_auth(websocket, token: Optional[str] = None) -> Optional[Use if cookie_header: match = re.search(r"fastapiusersauth=([^;]+)", cookie_header) if match: + cookie_token = match.group(1) user_db_gen = get_user_db() user_db = await user_db_gen.__anext__() user_manager = UserManager(user_db) - user = await strategy.read_token(match.group(1), user_manager) + user = await strategy.read_token(cookie_token, user_manager) if user and user.is_active: logger.info( f"WebSocket auth successful for user {user.user_id} using cookie." ) - return user + return user, "" + elif _check_token_expired(cookie_token): + logger.warning("WebSocket auth failed: cookie JWT token is expired") + return None, "token_expired" except Exception as e: logger.warning(f"WebSocket auth with cookie failed: {e}") - logger.warning("WebSocket authentication failed.") - return None + logger.warning("WebSocket authentication failed: no valid credentials provided.") + return None, "no_credentials" diff --git a/backends/advanced/src/advanced_omi_backend/chat_service.py b/backends/advanced/src/advanced_omi_backend/chat_service.py index 015acaa9..6b882e42 100644 --- a/backends/advanced/src/advanced_omi_backend/chat_service.py +++ b/backends/advanced/src/advanced_omi_backend/chat_service.py @@ -12,31 +12,26 @@ import json import logging import time -from datetime import datetime +from datetime import datetime, timezone from typing import AsyncGenerator, Dict, List, Optional, Tuple from uuid import uuid4 -from bson import ObjectId from motor.motor_asyncio import AsyncIOMotorCollection from advanced_omi_backend.database import get_database from advanced_omi_backend.llm_client import async_chat_with_tools, get_llm_client -from advanced_omi_backend.model_registry import get_models_registry +from advanced_omi_backend.models.user import get_user_by_id from advanced_omi_backend.observability.otel_setup import ( get_tracer, is_otel_enabled, set_otel_session, set_trace_io, ) +from advanced_omi_backend.plugins.events import PluginEvent from advanced_omi_backend.prompt_registry import get_prompt_registry -from advanced_omi_backend.services.knowledge_graph.kb import KnowledgeBaseManager from advanced_omi_backend.services.memory import get_memory_service from advanced_omi_backend.services.memory.base import MemoryEntry -from advanced_omi_backend.services.obsidian_service import ( - ObsidianSearchError, - get_obsidian_service, -) -from advanced_omi_backend.users import User +from advanced_omi_backend.services.plugin_service import dispatch_plugin_event logger = logging.getLogger(__name__) @@ -71,6 +66,34 @@ } +def _format_memory_tool_result(memories: List["MemoryEntry"]) -> Dict: + """Shape the search_memories result for the chat LLM. + + The memory agent has already run an agentic search over the vault and + synthesized an answer (returned as the entry with kind "vault_search_answer"). + Feed the chat LLM that answer plus the note paths it cited — NOT the raw + note bodies, which are noisy markdown scaffolding (frontmatter, wikilinks, + section headers) that read as nonsense when echoed into a reply. + """ + answer_text: Optional[str] = None + sources: List[str] = [] + for m in memories: + if (m.metadata or {}).get("kind") == "vault_search_answer": + answer_text = m.content + elif m.content: + sources.append(m.id) + + if answer_text: + return {"answer": answer_text, "sources": sources} + + # No synthesized answer (nothing found, or a non-agent provider) — fall back + # to returning whatever note excerpts we have so the LLM still sees context. + return { + "answer": None, + "notes": [{"path": m.id, "excerpt": m.content} for m in memories if m.content], + } + + class ChatMessage: """Represents a chat message.""" @@ -81,16 +104,16 @@ def __init__( user_id: str, role: str, # 'user' or 'assistant' content: str, - timestamp: datetime = None, - memories_used: List[str] = None, - metadata: Dict = None, + timestamp: Optional[datetime] = None, + memories_used: Optional[List[str]] = None, + metadata: Optional[Dict] = None, ): self.message_id = message_id self.session_id = session_id self.user_id = user_id self.role = role self.content = content - self.timestamp = timestamp or datetime.utcnow() + self.timestamp = timestamp or datetime.now(timezone.utc) self.memories_used = memories_used or [] self.metadata = metadata or {} @@ -129,16 +152,16 @@ def __init__( self, session_id: str, user_id: str, - title: str = None, - created_at: datetime = None, - updated_at: datetime = None, - metadata: Dict = None, + title: Optional[str] = None, + created_at: Optional[datetime] = None, + updated_at: Optional[datetime] = None, + metadata: Optional[Dict] = None, ): self.session_id = session_id self.user_id = user_id self.title = title or "New Chat" - self.created_at = created_at or datetime.utcnow() - self.updated_at = updated_at or datetime.utcnow() + self.created_at = created_at or datetime.now(timezone.utc) + self.updated_at = updated_at or datetime.now(timezone.utc) self.metadata = metadata or {} def to_dict(self) -> Dict: @@ -175,45 +198,6 @@ def __init__(self): self.llm_client = None self.memory_service = None self._initialized = False - self._kb = KnowledgeBaseManager() - - async def _get_system_prompt(self) -> str: - """ - Get system prompt from config with fallback to prompt registry default. - - Returns: - str: System prompt for chat interactions - """ - try: - reg = get_models_registry() - if reg and hasattr(reg, "chat"): - chat_config = reg.chat - prompt = chat_config.get("system_prompt") - if prompt: - logger.info( - f"✅ Loaded chat system prompt from config (length: {len(prompt)} chars)" - ) - logger.debug(f"System prompt: {prompt[:100]}...") - return prompt - except Exception as e: - logger.warning(f"Failed to load chat system prompt from config: {e}") - - # Fallback to prompt registry - try: - registry = get_prompt_registry() - prompt = await registry.get_prompt("chat.system") - logger.info("Using chat system prompt from prompt registry") - return prompt - except Exception as e: - logger.warning(f"Failed to load chat system prompt from registry: {e}") - - # Final fallback - logger.info("Using hardcoded default chat system prompt") - return """You are a helpful AI assistant with access to the user's personal memories and conversation history. - -Use the provided memories and conversation context to give personalized, contextual responses. If memories are relevant, reference them naturally in your response. Be conversational and helpful. - -If no relevant memories are available, respond normally based on the conversation context.""" async def initialize(self): """Initialize the chat service with database connections.""" @@ -248,7 +232,9 @@ async def initialize(self): logger.error(f"Failed to initialize chat service: {e}") raise - async def create_session(self, user_id: str, title: str = None) -> ChatSession: + async def create_session( + self, user_id: str, title: Optional[str] = None + ) -> ChatSession: """Create a new chat session.""" if not self._initialized: await self.initialize() @@ -382,87 +368,6 @@ async def get_relevant_memories( logger.error(f"Failed to retrieve memories for user {user_id}: {e}") return [] - async def format_conversation_context( - self, - session_id: str, - user_id: str, - current_message: str, - include_obsidian_memory: bool = False, - memory_limit: Optional[int] = None, - ) -> Tuple[str, List[str]]: - """Format conversation context with memory integration.""" - # Get recent conversation history - messages = await self.get_session_messages( - session_id, user_id, MAX_CONVERSATION_HISTORY - ) - - # Get relevant memories - memories = await self.get_relevant_memories( - current_message, user_id, limit=memory_limit - ) - memory_ids = [memory.id for memory in memories if memory.id] - - # Build context string - context_parts = [] - - # Add basic memory (user's MEMORY.md) if available - basic_memory = self._kb.get_basic_memory(user_id) - if basic_memory: - context_parts.append("# User Knowledge Base:") - context_parts.append(basic_memory) - context_parts.append("") - - # Add memory context if available - if memories: - context_parts.append("# Relevant Personal Memories:") - for i, memory in enumerate(memories, 1): - memory_text = memory.content - if memory_text: - context_parts.append(f"{i}. {memory_text}") - context_parts.append("") - - # Add Obsidian context if requested - if include_obsidian_memory: - try: - obsidian_service = get_obsidian_service() - obsidian_result = await obsidian_service.search_obsidian( - current_message - ) - obsidian_context = obsidian_result["results"] - if obsidian_context: - context_parts.append("# Relevant Obsidian Notes:") - for entry in obsidian_context: - context_parts.append(entry) - context_parts.append("") - logger.info( - f"Added {len(obsidian_context)} Obsidian notes to context" - ) - except ObsidianSearchError as exc: - logger.error( - "Failed to get Obsidian context (%s stage): %s", - exc.stage, - exc, - ) - raise - except Exception as e: - logger.error(f"Failed to get Obsidian context: {e}") - raise e - - # Add conversation history - if messages: - context_parts.append("# Recent Conversation:") - for msg in messages[-MAX_CONVERSATION_HISTORY:]: - role_label = "You" if msg.role == "user" else "Assistant" - context_parts.append(f"{role_label}: {msg.content}") - context_parts.append("") - - # Add current message - context_parts.append("# Current Message:") - context_parts.append(f"You: {current_message}") - - context = "\n".join(context_parts) - return context, memory_ids - async def _get_tool_mode_system_prompt(self) -> str: """Get system prompt for tool-based memory mode.""" try: @@ -474,14 +379,18 @@ async def _get_tool_mode_system_prompt(self) -> str: pass return ( - "You are a helpful AI assistant. You have access to a tool called " - "`search_memories` that searches the user's personal memory database.\n\n" - "Use the tool when the user's question might benefit from personal context " - "(e.g., preferences, past events, people they know, things they've said before). " - "Do NOT use the tool for general knowledge questions, greetings, or simple tasks " - "like math.\n\n" - "When memories are returned, weave them naturally into your response without " - "listing them mechanically." + "You are Chronicle, a helpful assistant with access to the user's personal " + "memory: an Obsidian-style vault of notes about the people, topics, places, " + "and conversations in their life.\n\n" + "You have one tool, `search_memories`. It runs an agentic search over that " + "vault and returns a synthesized `answer` plus the `sources` it drew from.\n\n" + "Search whenever the question touches anything personal (people the user " + "knows, past conversations, their preferences, plans, things they've " + "mentioned). Do NOT search for general knowledge, math, or casual chit-chat.\n\n" + "Treat the returned `answer` as your retrieved knowledge and weave it " + "naturally into your reply — never dump raw paths or list memories " + "mechanically. If the search returns no answer, say you don't have that in " + "memory rather than guessing; never invent personal facts." ) async def _generate_response_tool_mode( @@ -509,11 +418,6 @@ async def _generate_response_tool_mode( # Build messages list with proper message objects system_prompt = await self._get_tool_mode_system_prompt() - # Inject basic memory into system prompt - basic_memory = self._kb.get_basic_memory(user_id) - if basic_memory: - system_prompt += f"\n\n# User Knowledge Base:\n{basic_memory}" - messages = [{"role": "system", "content": system_prompt}] # Add conversation history @@ -564,16 +468,12 @@ async def _generate_response_tool_mode( memory_ids = [m.id for m in memories if m.id] all_memory_ids.extend(memory_ids) - result = [ - {"content": m.content, "id": m.id} - for m in memories - if m.content - ] + tool_result = _format_memory_tool_result(memories) messages.append( { "role": "tool", "tool_call_id": tool_call.id, - "content": json.dumps(result, default=str), + "content": json.dumps(tool_result, default=str), } ) else: @@ -661,11 +561,14 @@ async def generate_response_stream( session_id: str, user_id: str, message_content: str, - include_obsidian_memory: bool = False, memory_limit: Optional[int] = None, - memory_mode: str = "always", ) -> AsyncGenerator[Dict, None]: - """Generate streaming response with memory context.""" + """Generate a streaming chat response. + + Memory is always agentic: the chat LLM calls the ``search_memories`` + tool when a question needs personal context, and that tool runs the + agentic vault search. There is no upfront RAG injection. + """ set_otel_session(session_id) tracer = get_tracer() if is_otel_enabled() else None @@ -687,122 +590,13 @@ async def generate_response_stream( with span_ctx: set_trace_io(input={"message": message_content}) - if memory_mode == "tool": - async for event in self._generate_response_tool_mode( - session_id=session_id, - user_id=user_id, - message_content=message_content, - memory_limit=memory_limit, - ): - yield event - return - - if not self._initialized: - await self.initialize() - - try: - # Save user message - user_message = ChatMessage( - message_id=str(uuid4()), - session_id=session_id, - user_id=user_id, - role="user", - content=message_content, - ) - await self.add_message(user_message) - - if memory_mode == "off": - # No memory search — just conversation history - messages = await self.get_session_messages( - session_id, user_id, MAX_CONVERSATION_HISTORY - ) - context_parts = [] - if messages: - context_parts.append("# Recent Conversation:") - for msg in messages[-MAX_CONVERSATION_HISTORY:]: - role_label = "You" if msg.role == "user" else "Assistant" - context_parts.append(f"{role_label}: {msg.content}") - context_parts.append("") - context_parts.append("# Current Message:") - context_parts.append(f"You: {message_content}") - context = "\n".join(context_parts) - memory_ids = [] - else: - # Format context with memories (always mode) - context, memory_ids = await self.format_conversation_context( - session_id, - user_id, - message_content, - include_obsidian_memory=include_obsidian_memory, - memory_limit=memory_limit, - ) - - # Send memory context used - yield { - "type": "memory_context", - "data": {"memory_ids": memory_ids, "memory_count": len(memory_ids)}, - "timestamp": time.time(), - } - - # Get system prompt from config - system_prompt = await self._get_system_prompt() - - # Prepare full prompt - full_prompt = f"{system_prompt}\n\n{context}" - - # Generate streaming response - logger.info( - f"Generating response for session {session_id} with {len(memory_ids)} memories" - ) - - # Resolve chat operation temperature from config - chat_temp = None - registry = get_models_registry() - if registry: - chat_op = registry.get_llm_operation("chat") - chat_temp = chat_op.temperature - - response_content = self.llm_client.generate( - prompt=full_prompt, - temperature=chat_temp, - ) - - yield { - "type": "token", - "data": response_content.strip(), - "timestamp": time.time(), - } - - # Save assistant message - assistant_message = ChatMessage( - message_id=str(uuid4()), - session_id=session_id, - user_id=user_id, - role="assistant", - content=response_content.strip(), - memories_used=memory_ids, - ) - await self.add_message(assistant_message) - - set_trace_io(output={"response": response_content.strip()}) - - # Send completion signal - yield { - "type": "complete", - "data": { - "message_id": assistant_message.message_id, - "memories_used": memory_ids, - }, - "timestamp": time.time(), - } - - except Exception as e: - logger.error(f"Error generating response for session {session_id}: {e}") - yield { - "type": "error", - "data": {"error": str(e)}, - "timestamp": time.time(), - } + async for event in self._generate_response_tool_mode( + session_id=session_id, + user_id=user_id, + message_content=message_content, + memory_limit=memory_limit, + ): + yield event async def update_session_title( self, session_id: str, user_id: str, title: str @@ -814,7 +608,7 @@ async def update_session_title( try: result = await self.sessions_collection.update_one( {"session_id": session_id, "user_id": user_id}, - {"$set": {"title": title, "updated_at": datetime.utcnow()}}, + {"$set": {"title": title, "updated_at": datetime.now(timezone.utc)}}, ) return result.modified_count > 0 except Exception as e: @@ -887,31 +681,68 @@ async def extract_memories_from_session( ) return True, [], 0 + # Resolve speaker labels from the user's profile so extracted memories + # are attributed to the actual person instead of a generic "User". + user = await get_user_by_id(user_id) + user_label = user.display_name if user and user.display_name else "User" + assistant_label = ( + user.assistant_name if user and user.assistant_name else "Assistant" + ) + # Format messages as a transcript transcript_parts = [] for message in messages: - role = "User" if message.role == "user" else "Assistant" + role = user_label if message.role == "user" else assistant_label transcript_parts.append(f"{role}: {message.content}") transcript = "\n".join(transcript_parts) # Get user email for memory service user_email = session.get("user_email", f"user_{user_id}") + source_id = f"chat_{session_id}" - # Extract memories using the memory service success, memory_ids = await self.memory_service.add_memory( transcript=transcript, client_id="chat_interface", - source_id=f"chat_{session_id}", + source_id=source_id, user_id=user_id, user_email=user_email, - allow_update=True, # Allow deduplication and updates + allow_update=True, ) if success: logger.info( f"✅ Extracted {len(memory_ids)} memories from chat session {session_id}" ) + memory_count = len(memory_ids or []) + + # Plugin dispatch — non-fatal, mirrors memory_jobs.py:398-422 + try: + memory_provider = getattr( + self.memory_service, "provider_identifier", "unknown" + ) + await dispatch_plugin_event( + event=PluginEvent.MEMORY_PROCESSED, + user_id=user_id, + data={ + "memories": memory_ids or [], + "conversation": { + "conversation_id": source_id, + "client_id": "chat_interface", + "user_id": user_id, + "user_email": user_email, + }, + "memory_count": memory_count, + "conversation_id": source_id, + }, + metadata={"memory_provider": memory_provider}, + description=f"chat={session_id[:12]}, memories={memory_count}", + ) + except Exception as e: + logger.warning( + f"⚠️ Error triggering memory-level plugins for chat {session_id}: {e}" + ) + return True, memory_ids, len(memory_ids) else: logger.error( diff --git a/backends/advanced/src/advanced_omi_backend/client.py b/backends/advanced/src/advanced_omi_backend/client.py index 30eed2bc..2f23b3da 100644 --- a/backends/advanced/src/advanced_omi_backend/client.py +++ b/backends/advanced/src/advanced_omi_backend/client.py @@ -1,175 +1,75 @@ -"""Simplified ClientState that only manages state, no processing. +"""Lightweight per-connection ClientState. -This module provides a lightweight ClientState class that tracks conversation -state, timestamps, and speech segments. All processing is handled at the -application level by the ProcessorManager. +Holds the in-memory state tied to a single live WebSocket connection: identity, +user info, session markers, and the streaming/batch bookkeeping the websocket +handlers maintain while a recording is in flight. Speech detection and +conversation lifecycle live in the Redis-streams / RQ-job pipeline, not here. """ -import asyncio import logging -import os import time -from pathlib import Path -from typing import Dict, List, Optional, Tuple - -from wyoming.audio import AudioChunk +from dataclasses import dataclass, field +from typing import Dict, List, Optional # Get loggers audio_logger = logging.getLogger("audio_processing") -# Configuration constants -NEW_CONVERSATION_TIMEOUT_MINUTES = float( - os.getenv("NEW_CONVERSATION_TIMEOUT_MINUTES", "1.5") -) - +@dataclass class ClientState: - """Manages conversation state for a single client connection.""" - - def __init__( - self, - client_id: str, - chunk_dir: Path, - user_id: str, - user_email: Optional[str] = None, - ): - self.client_id = client_id - self.connected = True - self.chunk_dir = chunk_dir - - # Store user data for memory processing - self.user_id = user_id - self.user_email = user_email - - # Conversation state tracking - self.last_transcript_time: Optional[float] = None - self.conversation_start_time: float = time.time() - self.current_audio_uuid: Optional[str] = None - - # Speech segment tracking for audio cropping - self.speech_segments: Dict[str, List[Tuple[float, float]]] = {} - self.current_speech_start: Dict[str, Optional[float]] = {} - - # NOTE: Removed in-memory transcript storage for single source of truth - # Transcripts are stored only in MongoDB via TranscriptionManager - - # Markers (e.g., button events) collected during the session - self.markers: List[dict] = [] - - # Track if conversation has been closed - self.conversation_closed: bool = False - - # Audio configuration - sample rate for this client's audio stream - self.sample_rate: Optional[int] = None - - # Debug tracking - self.transaction_id: Optional[str] = None - - audio_logger.info(f"Created client state for {client_id}") - - def update_audio_received(self, chunk: AudioChunk): - """Update state when audio is received.""" - # Check if we should start a new conversation - if self.should_start_new_conversation(): - asyncio.create_task(self.start_new_conversation()) - - def set_current_audio_uuid(self, audio_uuid: str): - """Set the current audio UUID when processor creates a new file.""" - self.current_audio_uuid = audio_uuid - self.conversation_closed = False # Reset for new audio file - - def record_speech_start(self, audio_uuid: str, timestamp: float): - """Record the start of a speech segment.""" - self.current_speech_start[audio_uuid] = timestamp - audio_logger.info(f"Recorded speech start for {audio_uuid}: {timestamp}") - - def record_speech_end(self, audio_uuid: str, timestamp: float): - """Record the end of a speech segment.""" - if ( - audio_uuid in self.current_speech_start - and self.current_speech_start[audio_uuid] is not None - ): - start_time = self.current_speech_start[audio_uuid] - if start_time is not None: - if audio_uuid not in self.speech_segments: - self.speech_segments[audio_uuid] = [] - self.speech_segments[audio_uuid].append((start_time, timestamp)) - self.current_speech_start[audio_uuid] = None - duration = timestamp - start_time - audio_logger.info( - f"Recorded speech segment for {audio_uuid}: {start_time:.3f} -> {timestamp:.3f} " - f"(duration: {duration:.3f}s)" - ) - else: - audio_logger.warning( - f"Speech end recorded for {audio_uuid} but no start time found" - ) - - def update_transcript_received(self): - """Update timestamp when transcript is received (for timeout detection).""" - self.last_transcript_time = time.time() - - def add_marker(self, marker: dict) -> None: - """Add a marker (e.g., button event) to the current session.""" - self.markers.append(marker) + """Connection-scoped state for a single client connection.""" - def should_start_new_conversation(self) -> bool: - """Check if we should start a new conversation based on timeout.""" - if self.last_transcript_time is None: - return False + client_id: str + user_id: str + user_email: Optional[str] = None - current_time = time.time() - time_since_last_transcript = current_time - self.last_transcript_time - timeout_seconds = NEW_CONVERSATION_TIMEOUT_MINUTES * 60 + # Liveness flag, flipped by disconnect(). + connected: bool = True - return time_since_last_transcript > timeout_seconds + # Wall-clock time of the last inbound WebSocket message, stamped via touch(). + # Drives the idle read-timeout reaper and the Network page's "last seen" / honest + # connected status. Defaults to creation time so a freshly-created client is fresh. + last_activity: float = field(default_factory=time.time) - async def close_current_conversation(self): - """Clean up in-memory speech segments for the current conversation.""" - if self.conversation_closed: - audio_logger.debug( - f"🔒 Conversation already closed for client {self.client_id}, skipping" - ) - return + # Markers (e.g., button events) collected during the session, + # drained onto the conversation when it is persisted. + markers: List[dict] = field(default_factory=list) - self.conversation_closed = True + # Recording mode for the active audio session ("batch" or "streaming"). + recording_mode: str = "batch" - if not self.current_audio_uuid: - return + # Streaming-mode session id, set when a streaming session is initialized and + # reset to None when finalized. Doubles as the "session active" flag. + stream_session_id: Optional[str] = None + # NOTE: reserved — nothing populates this yet, so the streaming-finalize + # buffer flush currently falls back to default 16kHz/mono/16-bit. + stream_audio_format: Dict = field(default_factory=dict) - audio_logger.info(f"🔒 Closing conversation state for client {self.client_id}") + # Batch-mode accumulator: audio is buffered here until a 30-minute roll or + # an audio-stop, then flushed into a conversation. + batch_started: bool = False + batch_audio_chunks: List[bytes] = field(default_factory=list) + batch_audio_format: Dict = field(default_factory=dict) + batch_audio_bytes: int = 0 + batch_chunks_processed: int = 0 - if self.current_audio_uuid in self.speech_segments: - del self.speech_segments[self.current_audio_uuid] - if self.current_audio_uuid in self.current_speech_start: - del self.current_speech_start[self.current_audio_uuid] + def __post_init__(self) -> None: + audio_logger.info(f"Created client state for {self.client_id}") - async def start_new_conversation(self): - """Start a new conversation by closing current and resetting state.""" - await self.close_current_conversation() - - # Reset conversation state - self.current_audio_uuid = None - self.conversation_start_time = time.time() - self.last_transcript_time = None - self.conversation_closed = False - self.markers = [] + def add_marker(self, marker: dict) -> None: + """Add a marker (e.g., button event) to the current session.""" + self.markers.append(marker) - audio_logger.info(f"Client {self.client_id}: Started new conversation") + def touch(self) -> None: + """Record inbound activity. Called on every received WebSocket message so the + idle-timeout reaper and the Network page can tell a live client from a zombie. + """ + self.last_activity = time.time() - async def disconnect(self): + async def disconnect(self) -> None: """Clean disconnect of client state.""" if not self.connected: return self.connected = False - audio_logger.info(f"Disconnecting client {self.client_id}") - - # Close current conversation - await self.close_current_conversation() - - # Clean up state - self.speech_segments.clear() - self.current_speech_start.clear() - - audio_logger.info(f"Client {self.client_id} disconnected and cleaned up") + audio_logger.info(f"Client {self.client_id} disconnected") diff --git a/backends/advanced/src/advanced_omi_backend/client_manager.py b/backends/advanced/src/advanced_omi_backend/client_manager.py index 2e90a4de..a71bf137 100644 --- a/backends/advanced/src/advanced_omi_backend/client_manager.py +++ b/backends/advanced/src/advanced_omi_backend/client_manager.py @@ -12,8 +12,10 @@ import redis.asyncio as redis +from advanced_omi_backend.client import ClientState +from advanced_omi_backend.redis_factory import create_async_redis + if TYPE_CHECKING: - from advanced_omi_backend.client import ClientState from advanced_omi_backend.users import User logger = logging.getLogger(__name__) @@ -99,7 +101,7 @@ def get_client_count(self) -> int: return len(self._active_clients) def create_client( - self, client_id: str, chunk_dir, user_id: str, user_email: Optional[str] = None + self, client_id: str, user_id: str, user_email: Optional[str] = None ) -> "ClientState": """ Atomically create and register a new client. @@ -109,7 +111,6 @@ def create_client( Args: client_id: Unique client identifier - chunk_dir: Directory for audio chunks user_id: User ID who owns this client user_email: Optional user email @@ -122,11 +123,8 @@ def create_client( if client_id in self._active_clients: raise ValueError(f"Client {client_id} already exists") - # Import here to avoid circular imports - from advanced_omi_backend.client import ClientState - # Create client state - client_state = ClientState(client_id, chunk_dir, user_id, user_email) + client_state = ClientState(client_id, user_id, user_email) # Atomically add to internal storage and register mapping self._active_clients[client_id] = client_state @@ -135,32 +133,6 @@ def create_client( logger.info(f"✅ Created and registered client {client_id} for user {user_id}") return client_state - def remove_client(self, client_id: str) -> bool: - """ - Atomically remove and deregister a client. - - This method ensures that client removal and deregistration happen atomically. - - Args: - client_id: Client identifier to remove - - Returns: - True if client was removed, False if client didn't exist - """ - if client_id not in self._active_clients: - logger.warning(f"Attempted to remove non-existent client {client_id}") - return False - - # Get client state for cleanup - client_state = self._active_clients[client_id] - - # Atomically remove from storage and deregister mapping - del self._active_clients[client_id] - unregister_client_user_mapping(client_id) - - logger.info(f"✅ Removed and deregistered client {client_id}") - return True - async def remove_client_with_cleanup(self, client_id: str) -> bool: """ Atomically remove client with full cleanup. @@ -197,20 +169,6 @@ def get_all_client_ids(self) -> list[str]: """ return list(self._active_clients.keys()) - def add_existing_client(self, client_id: str, client_state: "ClientState"): - """ - Add an existing client state (for migration purposes). - - Args: - client_id: Client identifier - client_state: Existing ClientState object - """ - if client_id in self._active_clients: - logger.warning(f"Overwriting existing client {client_id}") - - self._active_clients[client_id] = client_state - logger.info(f"Added existing client {client_id} to ClientManager") - def get_client_info_summary(self) -> list: """ Get summary information about all active clients. @@ -220,17 +178,10 @@ def get_client_info_summary(self) -> list: """ client_info = [] for client_id, client_state in self._active_clients.items(): - current_audio_uuid = client_state.current_audio_uuid client_data = { "client_id": client_id, - "connected": getattr(client_state, "connected", True), - "current_audio_uuid": current_audio_uuid, - "last_transcript_time": client_state.last_transcript_time, - "conversation_start_time": client_state.conversation_start_time, - "has_active_conversation": current_audio_uuid is not None, - "conversation_transcripts_count": len( - getattr(client_state, "conversation_transcripts", []) - ), + "connected": client_state.connected, + "user_email": client_state.user_email, } client_info.append(client_data) @@ -440,16 +391,11 @@ def get_user_clients_active(user_id: str) -> list[str]: return user_clients -def initialize_redis_for_client_manager(redis_url: str): - """ - Initialize Redis client for cross-container client→user mapping. - - Args: - redis_url: Redis connection URL - """ +def initialize_redis_for_client_manager(): + """Initialize the shared Redis client for cross-container client→user mapping.""" global _redis_client - _redis_client = redis.from_url(redis_url, decode_responses=True) - logger.info(f"✅ ClientManager Redis initialized: {redis_url}") + _redis_client = create_async_redis(decode_responses=True) + logger.info("✅ ClientManager Redis initialized") async def get_client_owner_async(client_id: str) -> Optional[str]: @@ -510,17 +456,27 @@ async def some_endpoint(client_manager: ClientManager = Depends(get_client_manag def generate_client_id(user: "User", device_name: Optional[str] = None) -> str: """ - Generate a unique client_id in the format: user_id_suffix-device_suffix[-counter] + Generate a STABLE client_id in the format: user_id_suffix-device_suffix - This function checks both the database (user.registered_clients) and active - connections to ensure no conflicts with existing or currently connected clients. + The client_id is deterministic for a given (user, device_name): the same device + reconnecting always maps to the same id. This is the device's stable identity — + a reconnect reuses it (and evict-on-reconnect collapses any lingering connection + onto the new one) instead of minting a fresh id every time. + + NOTE: there is deliberately NO uniqueness counter. The old `-N` counter, checked + against the ever-growing `user.registered_clients` history, turned every reconnect + of one device into a brand-new id (havpe, havpe-2, … havpe-286) and made the + registry and the Network page balloon. Device names are expected to be unique per + physical device (set per relay/app); two distinct devices sharing a name should be + renamed, not silently disambiguated into divergent identities. Args: user: The User object device_name: Optional device name (e.g., 'havpe', 'phone', 'tablet') Returns: - client_id in format: user_id_suffix-device_suffix or user_id_suffix-device_suffix-N for duplicates + client_id as user_id_suffix-device_suffix (or user_id_suffix- when no + device name is supplied). """ # Use last 6 characters of MongoDB ObjectId as user identifier user_id_suffix = str(user.id)[-6:] @@ -530,31 +486,8 @@ def generate_client_id(user: "User", device_name: Optional[str] = None) -> str: sanitized_device = "".join( c for c in device_name.lower() if c.isalnum() or c == "-" )[:10] - base_client_id = f"{user_id_suffix}-{sanitized_device}" - - # Check for existing client IDs in database - existing_client_ids = user.get_client_ids() + return f"{user_id_suffix}-{sanitized_device}" - # Also check active clients to prevent conflicts with currently connected clients - client_manager = get_client_manager() - active_client_ids = set() - if client_manager.is_initialized(): - active_client_ids = set(client_manager.get_all_clients().keys()) - - # Combine both sets of existing IDs - all_existing_ids = set(existing_client_ids) | active_client_ids - - # If base client_id doesn't exist anywhere, use it - if base_client_id not in all_existing_ids: - return base_client_id - - # If it exists, find the next available counter - counter = 2 - while f"{base_client_id}-{counter}" in all_existing_ids: - counter += 1 - - return f"{base_client_id}-{counter}" - else: - # Generate UUID-based suffix if no device name provided - uuid_suffix = str(uuid.uuid4())[:8] # Use first 8 chars of UUID for readability - return f"{user_id_suffix}-{uuid_suffix}" + # No device name → fall back to a UUID suffix (still unique, just not stable). + uuid_suffix = str(uuid.uuid4())[:8] + return f"{user_id_suffix}-{uuid_suffix}" diff --git a/backends/advanced/src/advanced_omi_backend/config.py b/backends/advanced/src/advanced_omi_backend/config.py index 40f62cdf..5a0fb265 100644 --- a/backends/advanced/src/advanced_omi_backend/config.py +++ b/backends/advanced/src/advanced_omi_backend/config.py @@ -7,7 +7,7 @@ import logging import os -from dataclasses import dataclass +from dataclasses import asdict, dataclass from pathlib import Path from typing import Optional @@ -27,6 +27,16 @@ DATA_DIR = Path(os.getenv("DATA_DIR", "/app/data")) CHUNK_DIR = Path("./audio_chunks") # Mounted to ./data/audio_chunks by Docker +# Liveness: close a WebSocket that sends nothing for this many seconds. A streaming +# device emits chunks every ~0.25s, so this only ever trips on a genuinely dead peer +# (including a relay holding the socket open after its device vanished). Doubles as the +# freshness window used to decide whether a client counts as "connected" on the Network +# page. Default 5 min — conservative enough that an idle-but-alive client (armed device, +# app holding a control socket) isn't churned, while still bounding a zombie to minutes +# instead of forever. A falsely-reaped client simply auto-reconnects. Lower it if you +# want tighter detection and your clients stream or ping continuously. +WS_IDLE_TIMEOUT_SECS = float(os.getenv("WS_IDLE_TIMEOUT_SECS", "300")) + # ============================================================================ # Configuration Functions (OmegaConf-based) @@ -127,8 +137,6 @@ def save_cleanup_settings(settings: CleanupSettings) -> bool: Returns: True if saved successfully, False otherwise """ - from dataclasses import asdict - return save_config_section("backend.cleanup", asdict(settings)) @@ -212,6 +220,65 @@ def get_streaming_fallback_timeout() -> int: return int(timeout) +def get_live_segmentation() -> str: + """ + Get the live-transcription mode (top-level ``defaults.live_segmentation``). + + - ``"streaming_stt"`` (default): a real streaming ASR produces live transcripts. + - ``"windowed_batch"``: pseudo-streaming via fixed-duration batch windows. + - ``"off"``: no live transcription; the final transcript is produced by batch + transcription only when the session ends. + + Returns: + One of "streaming_stt", "windowed_batch", "off". + """ + cfg = load_config() + defaults_settings = ( + OmegaConf.to_container(cfg.get("defaults", {}), resolve=True) if cfg else {} + ) or {} + return defaults_settings.get("live_segmentation", "streaming_stt") + + +# ============================================================================ +# Wake-Word Command Source (OmegaConf-based) +# ============================================================================ + +# Valid values for backend.wakeword.command_source. +WAKEWORD_COMMAND_SOURCES = ("batch", "streaming", "batch_then_streaming") + + +def get_wakeword_command_source() -> str: + """How the acoustic wake-word command text is obtained. + + The standalone wakeword-service captures the post-wake-word turn and the + dispatcher turns it into a command string. This controls how: + + - ``"batch"``: batch-transcribe the captured command audio via the configured + batch STT provider. Highest quality, but the command silently fails if that + service is down (the streaming live transcript is unaffected, so it looks + like only commands break). + - ``"streaming"``: trust the live streaming transcript for the capture window + and skip batch ASR entirely. Useful when the streaming provider is as good + as (or better than) the batch one, or to avoid running a second ASR. + - ``"batch_then_streaming"`` (default): batch ASR, but fall back to the + streaming transcript — with a WARNING log and a degraded ``asr_status`` — + when batch is unreachable or returns an empty command. + + Lives at ``backend.wakeword.command_source`` in config.yml. + """ + cfg = get_backend_config("wakeword") + settings = OmegaConf.to_container(cfg, resolve=True) if cfg else {} + source = (settings or {}).get("command_source", "batch_then_streaming") + if source not in WAKEWORD_COMMAND_SOURCES: + logger.warning( + "Invalid backend.wakeword.command_source=%r; falling back to " + "'batch_then_streaming'", + source, + ) + return "batch_then_streaming" + return source + + # ============================================================================ # Miscellaneous Settings (OmegaConf-based) # ============================================================================ @@ -222,7 +289,7 @@ def get_misc_settings() -> dict: Get miscellaneous configuration settings using OmegaConf. Returns: - Dict with always_persist_enabled and use_provider_segments + Dict with miscellaneous settings (persistence, timeouts, segmentation mode) """ # Get audio settings for always_persist_enabled audio_cfg = get_backend_config("audio") @@ -230,7 +297,7 @@ def get_misc_settings() -> dict: OmegaConf.to_container(audio_cfg, resolve=True) if audio_cfg else {} ) - # Get transcription settings for use_provider_segments + # Get transcription settings for timeouts and batch re-transcription transcription_cfg = get_backend_config("transcription") transcription_settings = ( OmegaConf.to_container(transcription_cfg, resolve=True) @@ -244,11 +311,17 @@ def get_misc_settings() -> dict: OmegaConf.to_container(speaker_cfg, resolve=True) if speaker_cfg else {} ) + # Live-transcription mode lives in the top-level `defaults` block, not under + # `backend`. "windowed_batch" is the pseudo-streaming-via-batch preview; + # "off" disables the live preview entirely (final transcript still produced + # by batch transcription when the conversation ends). + cfg = load_config() + defaults_settings = ( + OmegaConf.to_container(cfg.get("defaults", {}), resolve=True) if cfg else {} + ) or {} + return { "always_persist_enabled": audio_settings.get("always_persist_enabled", False), - "use_provider_segments": transcription_settings.get( - "use_provider_segments", False - ), "per_segment_speaker_id": speaker_settings.get("per_segment_speaker_id", False), "streaming_fallback_timeout_seconds": int( transcription_settings.get( @@ -259,6 +332,9 @@ def get_misc_settings() -> dict: "always_batch_retranscribe": transcription_settings.get( "always_batch_retranscribe", False ), + "live_segmentation": defaults_settings.get( + "live_segmentation", "streaming_stt" + ), } @@ -267,7 +343,7 @@ def save_misc_settings(settings: dict) -> bool: Save miscellaneous settings to config.yml using OmegaConf. Args: - settings: Dict with always_persist_enabled and/or use_provider_segments + settings: Dict with miscellaneous settings to save Returns: True if saved successfully, False otherwise @@ -280,14 +356,6 @@ def save_misc_settings(settings: dict) -> bool: if not save_config_section("backend.audio", audio_settings): success = False - # Save transcription settings if use_provider_segments is provided - if "use_provider_segments" in settings: - transcription_settings = { - "use_provider_segments": settings["use_provider_segments"] - } - if not save_config_section("backend.transcription", transcription_settings): - success = False - # Save speaker recognition settings if per_segment_speaker_id is provided if "per_segment_speaker_id" in settings: speaker_settings = { @@ -314,4 +382,13 @@ def save_misc_settings(settings: dict) -> bool: if not save_config_section("backend.transcription", batch_settings): success = False + # Save live_segmentation if provided (top-level `defaults` block). Note: this + # selects which live worker the orchestrator runs, so it only takes effect + # after the workers container is restarted. + if "live_segmentation" in settings: + if not save_config_section( + "defaults", {"live_segmentation": settings["live_segmentation"]} + ): + success = False + return success diff --git a/backends/advanced/src/advanced_omi_backend/config_loader.py b/backends/advanced/src/advanced_omi_backend/config_loader.py index bd1f9de4..d18ba549 100644 --- a/backends/advanced/src/advanced_omi_backend/config_loader.py +++ b/backends/advanced/src/advanced_omi_backend/config_loader.py @@ -205,3 +205,58 @@ def save_config_section(section_path: str, values: dict) -> bool: except Exception as e: logger.error(f"Error saving config section '{section_path}': {e}") return False + + +def _load_raw_config() -> DictConfig: + """Load the raw (unmerged, unresolved) config.yml. + + Unlike ``load_config()`` this does NOT merge defaults.yml or resolve + ``${oc.env:...}`` interpolations — it returns exactly what is persisted in + config.yml. Callers that edit model definitions or want to display the + *reference* form of a secret (e.g. ``${oc.env:OPENAI_API_KEY}``) rather than + the resolved value must read through here. + """ + config_path = get_config_dir() / "config.yml" + if config_path.exists(): + return OmegaConf.load(config_path) + return OmegaConf.create({}) + + +def get_raw_models() -> list: + """Return the raw ``models`` list from config.yml as plain dicts. + + Values are NOT resolved, so ``${oc.env:...}`` references are preserved. This + is the list to mutate-and-write-back when adding/editing/deleting a model; + note it excludes default-only models that live solely in defaults.yml. + """ + raw = _load_raw_config() + models = raw.get("models", []) or [] + return OmegaConf.to_container(models, resolve=False) + + +def save_models_list(models: list) -> bool: + """Persist the full ``models`` list to config.yml (replace semantics). + + OmegaConf merge replaces lists wholesale, so model add/edit/delete must pass + the COMPLETE intended list here (read it via ``get_raw_models()`` first, + mutate, then save). After writing, the config cache is invalidated; callers + must also ``load_models_config(force_reload=True)`` to refresh the model + registry (kept out of here to avoid a config_loader→model_registry import). + """ + try: + config_path = get_config_dir() / "config.yml" + + existing_config = OmegaConf.create({}) + if config_path.exists(): + existing_config = OmegaConf.load(config_path) + + existing_config["models"] = OmegaConf.create(models) + OmegaConf.save(existing_config, config_path) + + reload_config() + logger.info(f"Saved {len(models)} models to {config_path}") + return True + + except Exception as e: + logger.error(f"Error saving models list: {e}") + return False diff --git a/backends/advanced/src/advanced_omi_backend/constants.py b/backends/advanced/src/advanced_omi_backend/constants.py index 9ff6a489..99b44cbc 100644 --- a/backends/advanced/src/advanced_omi_backend/constants.py +++ b/backends/advanced/src/advanced_omi_backend/constants.py @@ -1,3 +1,43 @@ +import re + OMI_SAMPLE_RATE = 16_000 # Hz OMI_CHANNELS = 1 OMI_SAMPLE_WIDTH = 2 # bytes (16‑bit) + +# Reserved diarization label for segments triaged as background/noise (TV, media, +# ambient) rather than a real person. Used by the Data Audit speaker-triage flow: +# applying it sets the segment's speaker to this label AND reclassifies it to a +# non-speech (event) segment, and the enroll step skips it (you never enroll a +# noise voiceprint). Kept in sync with the frontend constant of the same name. +NOISE_LABEL = "Background/Noise" + +# Display label for a diarized speaker that was NOT matched to an enrolled +# voiceprint. Speaker recognition assigns "Unknown Speaker 1", "Unknown Speaker 2", +# ... per conversation (identified speakers keep their real name; the remaining +# diarization labels become Unknown Speaker N, in order of appearance). These are +# cosmetic display labels only — they must NEVER be enrolled as real speakers, +# otherwise distinct people get merged into one bogus "Unknown Speaker" voiceprint. +# Kept in sync with the frontend constant of the same name. +UNKNOWN_SPEAKER_PREFIX = "Unknown Speaker" + +# Matches the placeholder unknown labels: "Unknown", "Unknown 1", +# "Unknown Speaker", "Unknown Speaker 2", "unknown_speaker_3" (case-insensitive). +_UNKNOWN_SPEAKER_RE = re.compile( + r"^\s*unknown(?:[ _]speaker)?(?:[ _]*\d+)?\s*$", re.IGNORECASE +) + + +def is_unknown_speaker_label(name: str | None) -> bool: + """True for placeholder unknown-speaker display labels (never enrollable).""" + return bool(name) and bool(_UNKNOWN_SPEAKER_RE.match(name)) + + +def is_non_enrollable_speaker(name: str | None) -> bool: + """True when ``name`` must not be enrolled as a real voiceprint. + + Covers empty/whitespace names, the reserved NOISE_LABEL, and any placeholder + "Unknown Speaker N" label. These are correction labels, not real people. + """ + if not name or not name.strip(): + return True + return name == NOISE_LABEL or is_unknown_speaker_label(name) diff --git a/backends/advanced/src/advanced_omi_backend/controllers/client_controller.py b/backends/advanced/src/advanced_omi_backend/controllers/client_controller.py index d603e7ea..3cb1e55d 100644 --- a/backends/advanced/src/advanced_omi_backend/controllers/client_controller.py +++ b/backends/advanced/src/advanced_omi_backend/controllers/client_controller.py @@ -3,15 +3,108 @@ """ import logging +import time from fastapi.responses import JSONResponse -from advanced_omi_backend.client_manager import ClientManager, get_user_clients_active -from advanced_omi_backend.users import User +from advanced_omi_backend.client_manager import ( + ClientManager, + get_client_manager, + get_user_clients_active, +) +from advanced_omi_backend.config import WS_IDLE_TIMEOUT_SECS +from advanced_omi_backend.users import ( + User, + forget_client_for_user, + get_user_by_client_id, + rename_client_for_user, +) logger = logging.getLogger(__name__) +def _device_view(entry: dict, client_manager: ClientManager, now: float) -> dict: + """Shape one registry device joined with its live connection state. + + `connected` and `last_seen` come from the in-memory ClientState when the device is + live (authoritative), falling back to the persisted registry timestamp when offline. + """ + client_id = entry["client_id"] + state = client_manager.get_client(client_id) + if state is not None: + last_seen = max(0.0, now - state.last_activity) + connected = last_seen < WS_IDLE_TIMEOUT_SECS + has_active = bool(state.stream_session_id) or state.batch_started + else: + last = entry.get("last_seen") + last_seen = max(0.0, now - last.timestamp()) if last is not None else None + connected = False + has_active = False + return { + "client_id": client_id, + "device_name": entry.get("device_name"), + "name": entry.get("name") or entry.get("device_name") or client_id, + "connected": connected, + "last_seen": round(last_seen, 1) if last_seen is not None else None, + "has_active_conversation": has_active, + } + + +async def list_devices(user: User, client_manager: ClientManager) -> dict: + """List remembered devices joined with live status. Admins see every user's devices; + regular users see only their own.""" + now = time.time() + if user.is_superuser: + users = await User.find_all().to_list() + else: + users = [user] + devices = [] + for u in users: + owner_email = u.email + for entry in u.registered_clients.values(): + view = _device_view(entry, client_manager, now) + view["user_email"] = owner_email + devices.append(view) + devices.sort(key=lambda d: (not d["connected"], d["last_seen"] or 1e18)) + return {"devices": devices, "total_count": len(devices)} + + +async def rename_device(user: User, client_id: str, name: str): + """Set a device's friendly display name (caller's own device, or any for admins).""" + name = (name or "").strip() + if not name: + return JSONResponse( + status_code=400, content={"error": "name must not be empty"} + ) + + owner = user if client_id in user.registered_clients else None + if owner is None and user.is_superuser: + owner = await get_user_by_client_id(client_id) + if owner is None: + return JSONResponse(status_code=404, content={"error": "Device not found"}) + + if await rename_client_for_user(owner, client_id, name): + return {"client_id": client_id, "name": name} + return JSONResponse(status_code=404, content={"error": "Device not found"}) + + +async def forget_device(user: User, client_id: str, client_manager: ClientManager): + """Remove a device from the registry. A currently-connected device is also evicted + so it doesn't immediately re-appear from its live ClientState.""" + owner = user if client_id in user.registered_clients else None + if owner is None and user.is_superuser: + owner = await get_user_by_client_id(client_id) + if owner is None: + return JSONResponse(status_code=404, content={"error": "Device not found"}) + + if client_manager.has_client(client_id): + await client_manager.remove_client_with_cleanup(client_id) + + if await forget_client_for_user(owner, client_id): + return {"client_id": client_id, "forgotten": True} + return JSONResponse(status_code=404, content={"error": "Device not found"}) + + async def get_active_clients(user: User, client_manager: ClientManager): """Get information about active clients. Users see only their own clients, admins see all.""" try: diff --git a/backends/advanced/src/advanced_omi_backend/controllers/conversation_controller.py b/backends/advanced/src/advanced_omi_backend/controllers/conversation_controller.py index eaa6afa9..4bc660b0 100644 --- a/backends/advanced/src/advanced_omi_backend/controllers/conversation_controller.py +++ b/backends/advanced/src/advanced_omi_backend/controllers/conversation_controller.py @@ -3,13 +3,11 @@ """ import logging -import os import time import uuid -from datetime import datetime +from datetime import datetime, timezone from pathlib import Path -import redis.asyncio as aioredis from fastapi.responses import JSONResponse from pymongo.errors import OperationFailure @@ -20,19 +18,30 @@ from advanced_omi_backend.config_loader import get_service_config from advanced_omi_backend.controllers.queue_controller import ( JOB_RESULT_TTL, + conversation_edit_chain_in_flight, default_queue, memory_queue, + post_conv_enqueue_kwargs, start_post_conversation_jobs, transcription_queue, ) -from advanced_omi_backend.controllers.session_controller import ( - request_conversation_close, -) from advanced_omi_backend.models.audio_chunk import AudioChunkDocument from advanced_omi_backend.models.conversation import Conversation from advanced_omi_backend.models.job import JobPriority +from advanced_omi_backend.models.memory_audit import MemoryAuditEntry +from advanced_omi_backend.models.waveform import WaveformData from advanced_omi_backend.plugins.events import ConversationCloseReason, PluginEvent +from advanced_omi_backend.redis_factory import create_async_redis +from advanced_omi_backend.services.audio_stream.session_store import SessionStore from advanced_omi_backend.services.memory import get_memory_service +from advanced_omi_backend.services.memory.audit import ( + MemoryCause, + UpdateStrategy, + actor_for, + source_kind_for, + source_label_for, +) +from advanced_omi_backend.services.plugin_service import get_plugin_router from advanced_omi_backend.users import User from advanced_omi_backend.workers.conversation_jobs import generate_title_summary_job from advanced_omi_backend.workers.memory_jobs import ( @@ -40,6 +49,7 @@ process_memory_job, ) from advanced_omi_backend.workers.speaker_jobs import recognise_speakers_job +from advanced_omi_backend.workers.transcription_jobs import transcribe_full_audio_job logger = logging.getLogger(__name__) audio_logger = logging.getLogger("audio_processing") @@ -91,7 +101,7 @@ async def close_current_conversation(client_id: str, user: User): status_code=404, ) - session_id = getattr(client_state, "stream_session_id", None) + session_id = client_state.stream_session_id if not session_id: return JSONResponse( content={"error": "No active session"}, @@ -99,11 +109,10 @@ async def close_current_conversation(client_id: str, user: User): ) # Signal the conversation job to close and trigger post-processing - redis_url = os.getenv("REDIS_URL", "redis://localhost:6379/0") - r = aioredis.from_url(redis_url) + r = create_async_redis() try: - success = await request_conversation_close( - r, session_id, reason=ConversationCloseReason.USER_REQUESTED.value + success = await SessionStore(r).request_close( + session_id, ConversationCloseReason.USER_REQUESTED.value ) finally: await r.aclose() @@ -151,6 +160,7 @@ async def get_conversation(conversation_id: str, user: User): conversation.deleted_at.isoformat() if conversation.deleted_at else None ), "processing_status": conversation.processing_status, + "failure_stage": conversation.failure_stage, "always_persist": conversation.always_persist, "end_reason": ( conversation.end_reason.value if conversation.end_reason else None @@ -167,14 +177,9 @@ async def get_conversation(conversation_id: str, user: User): "transcript": conversation.transcript, "segments": [s.model_dump() for s in conversation.segments], "segment_count": conversation.segment_count, - "memory_count": conversation.memory_count, - "has_memory": conversation.has_memory, "active_transcript_version": conversation.active_transcript_version, - "active_memory_version": conversation.active_memory_version, "transcript_version_count": conversation.transcript_version_count, - "memory_version_count": conversation.memory_version_count, "active_transcript_version_number": conversation.active_transcript_version_number, - "active_memory_version_number": conversation.active_memory_version_number, "starred": conversation.starred, "starred_at": ( conversation.starred_at.isoformat() if conversation.starred_at else None @@ -229,20 +234,18 @@ def _conversation_to_list_dict(conv: Conversation) -> dict: "deleted": conv.deleted, "deletion_reason": conv.deletion_reason, "deleted_at": conv.deleted_at.isoformat() if conv.deleted_at else None, + "audio_archived": conv.audio_archived, + "archive_reason": conv.archive_reason, "processing_status": conv.processing_status, + "failure_stage": conv.failure_stage, "always_persist": conv.always_persist, "title": conv.title, "summary": conv.summary, "detailed_summary": conv.detailed_summary, "active_transcript_version": conv.active_transcript_version, - "active_memory_version": conv.active_memory_version, "segment_count": conv.segment_count, - "has_memory": conv.has_memory, - "memory_count": conv.memory_count, "transcript_version_count": conv.transcript_version_count, - "memory_version_count": conv.memory_version_count, "active_transcript_version_number": conv.active_transcript_version_number, - "active_memory_version_number": conv.active_memory_version_number, "starred": conv.starred, "starred_at": conv.starred_at.isoformat() if conv.starred_at else None, } @@ -251,41 +254,36 @@ def _conversation_to_list_dict(conv: Conversation) -> dict: def _raw_doc_to_list_dict(doc: dict) -> dict: """Convert a raw pymongo document (projected) to a list-view dict. - Computes segment_count, memory_count etc. from the lightweight projected - version arrays without loading full transcript/word data. + Computes segment_count and the active version number from the lightweight + projected version arrays without loading full transcript/word data. """ active_tv = doc.get("active_transcript_version") - active_mv = doc.get("active_memory_version") - # Compute segment_count from projected transcript_versions + # Compute segment_count + the unique speaker list from the active version's segments + # (segments are already projected for the count — deriving speakers here is free and + # lets the list show "who's in this conversation" at a glance without expanding). segment_count = 0 + speakers: list[str] = [] transcript_versions = doc.get("transcript_versions") or [] for tv in transcript_versions: if tv.get("version_id") == active_tv: - segment_count = len(tv.get("segments", [])) + segs = tv.get("segments", []) + segment_count = len(segs) + seen: set[str] = set() + for seg in segs: + sp = (seg.get("speaker") or "").strip() + if sp and sp not in seen: + seen.add(sp) + speakers.append(sp) break - # Compute memory_count from projected memory_versions - memory_count = 0 - memory_versions = doc.get("memory_versions") or [] - for mv in memory_versions: - if mv.get("version_id") == active_mv: - memory_count = mv.get("memory_count", 0) - break - - # Compute active version numbers (1-based) + # Compute active version number (1-based) active_transcript_version_number = None for i, tv in enumerate(transcript_versions): if tv.get("version_id") == active_tv: active_transcript_version_number = i + 1 break - active_memory_version_number = None - for i, mv in enumerate(memory_versions): - if mv.get("version_id") == active_mv: - active_memory_version_number = i + 1 - break - created_at = doc.get("created_at") deleted_at = doc.get("deleted_at") starred_at = doc.get("starred_at") @@ -302,20 +300,19 @@ def _raw_doc_to_list_dict(doc: dict) -> dict: "deleted": doc.get("deleted", False), "deletion_reason": doc.get("deletion_reason"), "deleted_at": deleted_at.isoformat() if deleted_at else None, + "audio_archived": doc.get("audio_archived", False), + "archive_reason": doc.get("archive_reason"), "processing_status": doc.get("processing_status"), + "failure_stage": doc.get("failure_stage"), "always_persist": doc.get("always_persist", False), "title": doc.get("title"), "summary": doc.get("summary"), "detailed_summary": doc.get("detailed_summary"), "active_transcript_version": active_tv, - "active_memory_version": active_mv, "segment_count": segment_count, - "has_memory": len(memory_versions) > 0, - "memory_count": memory_count, + "speakers": speakers, "transcript_version_count": len(transcript_versions), - "memory_version_count": len(memory_versions), "active_transcript_version_number": active_transcript_version_number, - "active_memory_version_number": active_memory_version_number, "starred": doc.get("starred", False), "starred_at": starred_at.isoformat() if starred_at else None, } @@ -333,7 +330,10 @@ def _raw_doc_to_list_dict(doc: dict) -> dict: "deleted": 1, "deletion_reason": 1, "deleted_at": 1, + "audio_archived": 1, + "archive_reason": 1, "processing_status": 1, + "failure_stage": 1, "always_persist": 1, "title": 1, "summary": 1, @@ -341,12 +341,9 @@ def _raw_doc_to_list_dict(doc: dict) -> dict: "starred": 1, "starred_at": 1, "active_transcript_version": 1, - "active_memory_version": 1, # Lightweight version metadata (exclude transcript, words, segment text) "transcript_versions.version_id": 1, "transcript_versions.segments": 1, - "memory_versions.version_id": 1, - "memory_versions.memory_count": 1, } @@ -385,13 +382,13 @@ async def get_conversations( conditions.append({"deleted": False}) if include_unprocessed: - # Orphan type 1: always_persist stuck in pending/failed (not deleted) + # Orphan type 1: always_persist that ended up failed (not deleted). + # "active" = still in-flight (don't flag); stale-active crashes are + # reconciled to "failed" by the reconciler, so they show up here too. conditions.append( { "always_persist": True, - "processing_status": { - "$in": ["pending_transcription", "transcription_failed"] - }, + "processing_status": Conversation.ConversationStatus.FAILED.value, "deleted": False, } ) @@ -437,7 +434,7 @@ async def get_conversations( is_orphan_type1 = ( doc.get("always_persist") and doc.get("processing_status") - in ("pending_transcription", "transcription_failed") + == Conversation.ConversationStatus.FAILED.value and not doc.get("deleted") ) is_orphan_type2 = ( @@ -562,7 +559,7 @@ async def _soft_delete_conversation( where a retry will complete the operation. """ conversation_id = conversation.conversation_id - deleted_at = datetime.utcnow() + deleted_at = datetime.now(timezone.utc) # 1. Soft delete audio chunks FIRST (safe failure mode: orphaned-deleted chunks) result = await AudioChunkDocument.find( @@ -696,6 +693,112 @@ async def delete_conversation( ) +async def archive_conversation_audio_doc( + conversation: Conversation, reason: str = "manual_cleanup" +) -> int: + """Archive a conversation document's audio (no permission check). + + Hard-deletes the audio chunks and marks the conversation archived + soft- + deleted, keeping duration as the stub metadata. Returns the number of audio + chunks deleted. Idempotent: a re-run on an already-archived conversation + backfills the soft-delete flags and deletes 0 chunks. + + This is the shared core used by both the user-facing endpoint (after a + permission check) and the system auto-clean sweep. Archiving is a + specialization of soft-delete: ``deleted=True`` hides it from the normal + list and surfaces it in the Archive tab; ``audio_archived``/``archive_reason`` + record that the audio bytes were permanently purged. ``deletion_reason + ="audio_archived"`` is intentionally distinct from the no-speech/orphan + reasons so it isn't treated as a reprocessable orphan. + """ + conversation_id = conversation.conversation_id + archived_at = datetime.now(timezone.utc) + + if conversation.audio_archived: + # Idempotent backfill of soft-delete flags for items archived before + # archiving was unified with soft-delete. + if not conversation.deleted: + conversation.deleted = True + conversation.deletion_reason = "audio_archived" + conversation.deleted_at = conversation.audio_archived_at or archived_at + await conversation.save() + return 0 + + # 1. Hard delete audio chunks FIRST (no rollback for hard deletes; if the + # metadata write fails afterwards a re-run completes — chunks are gone). + result = await AudioChunkDocument.find( + AudioChunkDocument.conversation_id == conversation_id + ).delete() + deleted_chunks = result.deleted_count + + # 2. Mark the conversation as archived (+ soft-deleted), keeping duration. + conversation.audio_archived = True + conversation.audio_archived_at = archived_at + conversation.archive_reason = reason + conversation.audio_chunks_count = 0 + conversation.audio_compression_ratio = None + conversation.vad_analysis = None # derived from chunks that no longer exist + conversation.deleted = True + conversation.deletion_reason = "audio_archived" + conversation.deleted_at = archived_at + await conversation.save() + + # Drop any cached waveform — it points at audio that no longer exists. + try: + await WaveformData.find( + WaveformData.conversation_id == conversation_id + ).delete() + except Exception as e: + logger.warning(f"Failed to delete waveform for {conversation_id}: {e}") + + logger.info( + f"Archived audio for conversation {conversation_id} " + f"(reason={reason}, deleted {deleted_chunks} chunks)" + ) + return deleted_chunks + + +async def archive_conversation_audio( + conversation_id: str, user: User, reason: str = "manual_cleanup" +) -> JSONResponse: + """Archive a conversation's audio: permanently delete the audio bytes from + MongoDB while keeping the conversation document as a lightweight metadata + stub (date, duration, reason). + + Used by the Data Audit feature to reclaim storage for speech-free or + bad-speaker recordings. Unlike soft delete, this is irreversible for the + audio — the transcript/segment metadata is retained. + """ + conversation, error = await _get_conversation_or_error(conversation_id, user) + if error: + return error + + already_archived = conversation.audio_archived + deleted_chunks = await archive_conversation_audio_doc(conversation, reason) + + if already_archived: + return JSONResponse( + status_code=200, + content={ + "message": "Conversation audio already archived", + "conversation_id": conversation_id, + "already_archived": True, + "deleted_chunks": 0, + }, + ) + + return JSONResponse( + status_code=200, + content={ + "message": f"Successfully archived audio for conversation '{conversation_id}'", + "conversation_id": conversation_id, + "archive_reason": reason, + "deleted_chunks": deleted_chunks, + "duration_seconds": conversation.audio_total_duration, + }, + ) + + async def restore_conversation(conversation_id: str, user: User) -> JSONResponse: """ Restore a soft-deleted conversation. @@ -768,7 +871,7 @@ async def restore_conversation(conversation_id: str, user: User) -> JSONResponse async def _restore_if_deleted_and_prepare( conversation: Conversation, conversation_id: str, - processing_status: str | None = "reprocessing", + processing_status: str | None = Conversation.ConversationStatus.ACTIVE.value, ) -> None: """Restore soft-deleted conversation/chunks and optionally set processing_status.""" changed = False @@ -805,10 +908,6 @@ def _enqueue_transcript_reprocessing( Returns (version_id, transcript_job, post_jobs dict). """ - from advanced_omi_backend.workers.transcription_jobs import ( - transcribe_full_audio_job, - ) - version_id = str(uuid.uuid4()) transcript_job = transcription_queue.enqueue( @@ -829,6 +928,7 @@ def _enqueue_transcript_reprocessing( transcript_version_id=version_id, depends_on_job=transcript_job, end_reason=end_reason, + memory_cause=MemoryCause.TRANSCRIPT_REPROCESS, ) return version_id, transcript_job, post_jobs @@ -854,12 +954,7 @@ def _resolve_transcript_version(conversation: Conversation, version_id: str) -> ) resolved_id = active_id - version_obj = None - for v in conversation.transcript_versions: - if v.version_id == resolved_id: - version_obj = v - break - + version_obj = conversation.get_transcript_version(resolved_id) if not version_obj: return ( JSONResponse( @@ -886,16 +981,22 @@ def _enqueue_speaker_reprocessing_chain( recognise_speakers_job, conversation_id, version_id, + "", # transcript_text: read from source version + None, # words: read from source version + source_version_id, # create-on-success: read from this version, create version_id job_timeout=1200, result_ttl=JOB_RESULT_TTL, job_id=f"reprocess_speaker_{conversation_id[:12]}", description=f"Re-diarize speakers for {conversation_id[:8]}", - meta={ - "conversation_id": conversation_id, - "version_id": version_id, - "source_version_id": source_version_id, - "trigger": "reprocess", - }, + **post_conv_enqueue_kwargs( + "speaker", + { + "conversation_id": conversation_id, + "version_id": version_id, + "source_version_id": source_version_id, + "trigger": "reprocess", + }, + ), ) logger.info( f"Enqueued speaker reprocessing job {speaker_job.id} for version {version_id}" @@ -904,12 +1005,19 @@ def _enqueue_speaker_reprocessing_chain( memory_job = memory_queue.enqueue( process_memory_job, conversation_id, - depends_on=speaker_job, job_timeout=1800, result_ttl=JOB_RESULT_TTL, job_id=f"memory_{conversation_id[:12]}", description=f"Extract memories for {conversation_id[:8]}", - meta={"conversation_id": conversation_id, "trigger": "reprocess_after_speaker"}, + **post_conv_enqueue_kwargs( + "memory", + { + "conversation_id": conversation_id, + "cause": MemoryCause.SPEAKER_REPROCESS.value, + "strategy": UpdateStrategy.SPEAKER_DIFF.value, + }, + depends_on=speaker_job, + ), ) logger.info( f"Chained memory job {memory_job.id} after speaker job {speaker_job.id}" @@ -920,10 +1028,13 @@ def _enqueue_speaker_reprocessing_chain( conversation_id, job_timeout=300, result_ttl=JOB_RESULT_TTL, - depends_on=memory_job, job_id=f"title_summary_{conversation_id[:12]}", description=f"Regenerate title/summary for {conversation_id[:8]}", - meta={"conversation_id": conversation_id, "trigger": "reprocess_after_speaker"}, + **post_conv_enqueue_kwargs( + "title_summary", + {"conversation_id": conversation_id}, + depends_on=memory_job, + ), ) logger.info( f"Chained title/summary job {title_summary_job.id} after memory job {memory_job.id}" @@ -945,7 +1056,9 @@ async def toggle_star(conversation_id: str, user: User): # Toggle conversation.starred = not conversation.starred - conversation.starred_at = datetime.utcnow() if conversation.starred else None + conversation.starred_at = ( + datetime.now(timezone.utc) if conversation.starred else None + ) await conversation.save() logger.info( @@ -955,8 +1068,6 @@ async def toggle_star(conversation_id: str, user: User): # Dispatch plugin event (fire-and-forget) try: - from advanced_omi_backend.services.plugin_service import get_plugin_router - plugin_router = get_plugin_router() if plugin_router: await plugin_router.dispatch_event( @@ -1018,8 +1129,10 @@ async def reprocess_orphan(conversation_id: str, user: User): conversation.deletion_reason = None conversation.deleted_at = None - # Set processing status and update title - conversation.processing_status = "reprocessing" + # Back to in-flight; the finalizer reconciles the terminal status when the + # reprocess chain completes. + conversation.processing_status = Conversation.ConversationStatus.ACTIVE.value + conversation.failure_stage = None conversation.title = "Reprocessing..." conversation.summary = None conversation.detailed_summary = None @@ -1143,6 +1256,8 @@ async def reprocess_memory( job = enqueue_memory_processing( conversation_id=conversation_id, priority=JobPriority.NORMAL, + cause=MemoryCause.MEMORY_REPLAY, + strategy=UpdateStrategy.FULL, ) logger.info( @@ -1184,7 +1299,36 @@ async def reprocess_speakers( if error: return error - await _restore_if_deleted_and_prepare(conversation_model, conversation_id) + # Re-diarization operates on an existing transcript (text is copied to the + # new version), so the conversation stays "completed" throughout. Don't flip + # it to "active": the speaker-reprocess chain (speaker->memory->title_summary) + # has no finalizer, so an "active" flip would never settle back. Restore from + # soft-delete but keep the fact-derived status, then settle it explicitly. + await _restore_if_deleted_and_prepare( + conversation_model, conversation_id, processing_status=None + ) + if conversation_model.apply_status(settled=True): + await conversation_model.save() + + # Single-flight: reject if a reprocess chain is already running for this + # conversation. Overlapping chains (e.g. rapid repeat clicks) race on the + # conversation's full-document save() and a stale chain can clobber the newer + # speaker write — leaving an orphan version with empty speaker metadata and + # unchanged labels. Bail before creating a new version so we don't pile up + # dead versions either. + in_flight = conversation_edit_chain_in_flight(conversation_id) + if in_flight: + logger.info( + f"Reprocess already in flight for {conversation_id[:8]} " + f"(job {in_flight}); skipping duplicate request" + ) + return JSONResponse( + status_code=409, + content={ + "error": "Speaker reprocessing is already in progress for this conversation.", + "in_flight_job_id": in_flight, + }, + ) # 2-3. Resolve source transcript version ID and find version object error, source_version_id, source_version = _resolve_transcript_version( @@ -1237,51 +1381,14 @@ async def reprocess_speakers( }, ) - # 6. Create NEW transcript version (copy text/words, segments for provider-diarized) + # 6. Pre-allocate the new version id but DON'T create it yet. The speaker job + # reads from the source version and creates this version only once it has a + # usable result — mirroring transcript reprocess (transcribe_full_audio_job). + # A failed/empty reprocess therefore leaves NO new version behind and surfaces + # an error, instead of a degraded no-op version with unchanged labels. new_version_id = str(uuid.uuid4()) - # For provider-diarized transcripts, copy segments so the speaker job can - # identify speakers per-segment. For word-based transcripts, leave segments - # empty so pyannote can re-diarize. - new_metadata = { - "reprocessing_type": "speaker_diarization", - "source_version_id": source_version_id, - "trigger": "manual_reprocess", - "provider_capabilities": provider_capabilities, - } - use_segments = provider_has_diarization or not has_words - if use_segments: - new_segments = source_version.segments # COPY provider segments - if not has_words and not provider_has_diarization: - new_metadata["segments_only"] = True - else: - new_segments = [] # Empty - will be populated by speaker job - - new_version = conversation_model.add_transcript_version( - version_id=new_version_id, - transcript=source_version.transcript, # COPY transcript text - words=source_version.words, # COPY word timings - segments=new_segments, - provider=source_version.provider, - model=source_version.model, - processing_time_seconds=None, # Will be updated by job - metadata=new_metadata, - set_as_active=True, # Set new version as active - ) - - # Carry over diarization_source so speaker job knows to use segment identification - if provider_has_diarization or (not has_words and has_segments): - new_version.diarization_source = "provider" - - # Save conversation with new version - await conversation_model.save() - - logger.info( - f"Created new transcript version {new_version_id} from source {source_version_id} " - f"for conversation {conversation_id}" - ) - - # 7-8. Enqueue speaker → memory → title/summary chain + # 7-8. Enqueue speaker → memory → title/summary chain (create-on-success). job_ids = _enqueue_speaker_reprocessing_chain( conversation_id, new_version_id, @@ -1350,44 +1457,12 @@ async def activate_transcript_version( ) -async def activate_memory_version(conversation_id: str, version_id: str, user: User): - """Activate a specific memory version. Users can only modify their own conversations.""" - try: - conversation_model, error = await _get_conversation_or_error( - conversation_id, user - ) - if error: - return error - - # Activate the memory version using Beanie model method - success = conversation_model.set_active_memory_version(version_id) - if not success: - return JSONResponse( - status_code=400, content={"error": "Failed to activate memory version"} - ) - - await conversation_model.save() - - logger.info( - f"Activated memory version {version_id} for conversation {conversation_id} by user {user.user_id}" - ) - - return JSONResponse( - content={ - "message": f"Memory version {version_id} activated successfully", - "active_memory_version": version_id, - } - ) - - except Exception as e: - logger.error(f"Error activating memory version: {e}") - return JSONResponse( - status_code=500, content={"error": "Error activating memory version"} - ) - - async def get_conversation_version_history(conversation_id: str, user: User): - """Get version history for a conversation. Users can only access their own conversations.""" + """Get transcript version history for a conversation. Users can only access their own conversations. + + Memory is no longer versioned (the vault is the system of record); see the + memory audit ledger (``get_conversation_memory_audit``) for memory change history. + """ try: conversation_model, error = await _get_conversation_or_error( conversation_id, user @@ -1404,19 +1479,10 @@ async def get_conversation_version_history(conversation_id: str, user: User): version_dict["created_at"] = version_dict["created_at"].isoformat() transcript_versions.append(version_dict) - memory_versions = [] - for v in conversation_model.memory_versions: - version_dict = v.model_dump() - if version_dict.get("created_at"): - version_dict["created_at"] = version_dict["created_at"].isoformat() - memory_versions.append(version_dict) - history = { "conversation_id": conversation_id, "active_transcript_version": conversation_model.active_transcript_version, - "active_memory_version": conversation_model.active_memory_version, "transcript_versions": transcript_versions, - "memory_versions": memory_versions, } return JSONResponse(content=history) @@ -1426,3 +1492,77 @@ async def get_conversation_version_history(conversation_id: str, user: User): return JSONResponse( status_code=500, content={"error": "Error fetching version history"} ) + + +async def get_conversation_memory_audit( + conversation_id: str, user: User, limit: int = 100 +): + """Get the memory vault change history (audit ledger) for a conversation. + + Replaces the old per-conversation "memory versions": memory is a vault that is + overwritten in place, so instead we return the recorded changes (which notes + were created/updated/deleted, when, and what triggered each). + """ + try: + _, error = await _get_conversation_or_error(conversation_id, user) + if error: + return error + + entries = ( + await MemoryAuditEntry.find( + MemoryAuditEntry.conversation_id == conversation_id + ) + .sort(-MemoryAuditEntry.created_at) + .limit(limit) + .to_list() + ) + + return JSONResponse( + content={ + "conversation_id": conversation_id, + "count": len(entries), + "entries": [_memory_audit_to_dict(e) for e in entries], + } + ) + + except Exception as e: + logger.error(f"Error fetching memory audit for {conversation_id}: {e}") + return JSONResponse( + status_code=500, content={"error": "Error fetching memory audit"} + ) + + +def _memory_audit_to_dict(entry) -> dict: + """Serialize a MemoryAuditEntry for API responses. + + Provenance is exposed both raw (``cause``/``strategy``) and pre-classified + (``source_kind``/``source_label``/``actor``) so the WebUI renders an honest + label without re-deriving the taxonomy from magic strings. + """ + return { + "id": str(entry.id), + "user_id": entry.user_id, + "conversation_id": entry.conversation_id, + "operation": entry.operation, + "note_path": entry.note_path, + "cause": entry.cause, + "strategy": entry.strategy, + "source_kind": source_kind_for(entry.cause, entry.agent_mode, entry.operation), + "source_label": source_label_for( + entry.cause, entry.agent_mode, entry.operation + ), + "actor": actor_for(entry.cause, entry.agent_mode, entry.operation), + "provider": entry.provider, + "agent_mode": entry.agent_mode, + "before_hash": entry.before_hash, + "after_hash": entry.after_hash, + "after_bytes": entry.after_bytes, + "summary": entry.summary, + "extra": entry.extra, + "created_at": entry.created_at.isoformat() if entry.created_at else None, + # Whether a before→after diff can be fetched for this entry. True when the + # post-change content was retained, or for deletes (the prior recorded + # change supplies the removed content). False for legacy entries written + # before content was retained and for note-less delete_all operations. + "has_diff": entry.after_text is not None or entry.operation == "delete", + } diff --git a/backends/advanced/src/advanced_omi_backend/controllers/data_audit_controller.py b/backends/advanced/src/advanced_omi_backend/controllers/data_audit_controller.py new file mode 100644 index 00000000..e4c66d90 --- /dev/null +++ b/backends/advanced/src/advanced_omi_backend/controllers/data_audit_controller.py @@ -0,0 +1,1608 @@ +""" +Data-audit controller. + +Backs the Data Audit dashboard page: surfaces per-conversation VAD speech +metrics + latest speaker labels, filters by a compound predicate (speech +fraction AND speaker include/exclude), enqueues batch audio analysis, +archives (hard-deletes) audio, and splits/merges conversations. +""" + +import json +import logging +import shutil +import statistics +import uuid +from datetime import datetime, timezone +from typing import Dict, List, Optional, Tuple + +from fastapi.responses import FileResponse, JSONResponse + +from advanced_omi_backend.config import get_diarization_settings +from advanced_omi_backend.controllers.conversation_controller import ( + archive_conversation_audio, +) +from advanced_omi_backend.controllers.queue_controller import ( + JOB_RESULT_TTL, + default_queue, + start_post_conversation_jobs, +) +from advanced_omi_backend.models.annotation import Annotation, AnnotationType +from advanced_omi_backend.models.audio_chunk import AudioChunkDocument +from advanced_omi_backend.models.conversation import Conversation, create_conversation +from advanced_omi_backend.services.memory import get_memory_service +from advanced_omi_backend.speaker_recognition_client import SpeakerRecognitionClient +from advanced_omi_backend.users import User +from advanced_omi_backend.utils.annotation_export import ( + EXPORTS_DIR, + META_NAME, + ZIP_NAME, + export_dir, + new_export_id, + validate_export_id, +) +from advanced_omi_backend.utils.audio_chunk_utils import ( + audio_cache_duration_matches, + reconstruct_audio_segment, +) +from advanced_omi_backend.utils.transcript_slicing import ( + build_transcript_text, + shift_segments, + shift_words, + slice_segments, + slice_words, +) +from advanced_omi_backend.utils.vad_analysis import ( + detect_silence_gaps, + frame_speech_intervals, + intersect_intervals, + merge_speech_regions, + speech_fraction_from_histogram, +) +from advanced_omi_backend.workers.data_audit_jobs import ( + analyze_audio_batch_job, + auto_clean_job, + export_annotation_dataset_job, + get_sensitivity_policy, + screen_conversations_job, +) + +logger = logging.getLogger(__name__) + +# Upper bound on how many conversations a single scan inspects in memory. +# Speaker/silence predicates are applied in Python, so we cap the working set +# and report when it was hit rather than silently truncating. +MAX_SCAN = 2000 + +# Projection: lightweight metadata + cached VAD analysis + speaker labels +# from the active transcript version's segments (no transcript text / words). +_SCAN_PROJECTION = { + "conversation_id": 1, + "user_id": 1, + "client_id": 1, + "title": 1, + "created_at": 1, + "audio_total_duration": 1, + "audio_chunks_count": 1, + "audio_archived": 1, + "audio_archived_at": 1, + "archive_reason": 1, + "processing_status": 1, + "failure_stage": 1, + "vad_analysis": 1, + "derived_from": 1, + "active_transcript_version": 1, + "transcript_versions.version_id": 1, + "transcript_versions.segments.speaker": 1, + "transcript_versions.segments.identified_as": 1, + "transcript_versions.segments.confidence": 1, + "transcript_versions.segments.segment_type": 1, +} + + +def _audit_segments(doc: dict) -> list: + """Segments to audit for this conversation. + + Prefers the active transcript version's segments. Falls back to the version + with the most identified segments when the active pointer is missing or + points at an empty version — a known reprocess failure mode leaves a + dangling ``active_transcript_version`` (a UUID absent from the versions + dict), which would otherwise hide a conversation's segments entirely. + """ + active = doc.get("active_transcript_version") + best: list = [] + best_score = -1 + for tv in doc.get("transcript_versions") or []: + segs = tv.get("segments") or [] + if tv.get("version_id") == active and segs: + return segs + ident = sum(1 for s in segs if s.get("identified_as")) + score = ident * 100000 + len(segs) + if score > best_score: + best_score = score + best = segs + return best + + +def _speakers_for_doc(doc: dict) -> List[str]: + """Distinct speaker labels from the audited transcript version's segments. + + Prefers ``identified_as`` (recognized name) over the raw ``speaker`` label. + """ + speakers: set = set() + for seg in _audit_segments(doc): + label = seg.get("identified_as") or seg.get("speaker") + if label: + speakers.add(label) + return sorted(speakers) + + +def _unknown_speech_count(doc: dict) -> int: + """Speech segments in the audited version not matched to an enrolled speaker. + + The reliable "needs triage" signal: a speech segment with no ``identified_as`` + is either an unenrolled person or background noise. Drives the table's + "N to review" hint so you can see which files still need triage. + """ + count = 0 + for seg in _audit_segments(doc): + if (seg.get("segment_type") or "speech") != "speech": + continue + if not seg.get("identified_as"): + count += 1 + return count + + +def _marginal_identified_count(doc: dict, threshold: float, margin: float) -> int: + """Speech segments identified as an enrolled speaker but at a confidence + below ``threshold + margin`` — weak matches that likely shouldn't carry a + name (e.g. background noise force-labeled as the nearest speaker). This is + the cheap, stored-data review signal: confidence is already on each segment, + so no audio re-embedding is needed. + """ + cutoff = threshold + margin + count = 0 + for seg in _audit_segments(doc): + if (seg.get("segment_type") or "speech") != "speech": + continue + if not seg.get("identified_as"): + continue + conf = seg.get("confidence") + if conf is not None and conf < cutoff: + count += 1 + return count + + +def _vad_stale(va: Optional[dict], duration: float) -> bool: + """True if a cached ``vad_analysis`` no longer describes the conversation's + current audio. The analysis is derived from the chunk set; if the chunks + changed in place its implied duration (frame_count * frame_hop) drifts from + ``audio_total_duration`` and the speech metrics are stale — the Analyze + button should re-run it. Returns False when there is no cached analysis + (that's "unanalyzed", surfaced separately).""" + if not va: + return False + cached = (va.get("frame_count") or 0) * (va.get("frame_hop_ms") or 0) / 1000.0 + return not audio_cache_duration_matches(cached, duration) + + +async def list_for_audit( + user: User, + speech_threshold: float = 0.5, + min_speech_fraction: float = 0.0, + max_speech_fraction: float = 1.0, + min_duration: float = 0.0, + max_duration: float = 0.0, + created_after: Optional[datetime] = None, + created_before: Optional[datetime] = None, + include_speakers: Optional[List[str]] = None, + exclude_speakers: Optional[List[str]] = None, + archived_only: bool = False, + hide_failed: bool = False, + hide_reviewed: bool = False, + limit: int = 100, + offset: int = 0, +): + """List conversations with VAD speech metrics + speakers, filtered by the + compound predicate. Returns derived ``speech_fraction`` at the requested + probability threshold so the UI threshold control is just a re-fetch. + + Speaker filtering is per-speaker tri-state: a conversation is kept only if + it contains at least one ``include_speakers`` (when any are set) AND none of + the ``exclude_speakers``. Speech bounds exclude unanalyzed conversations; + ``max_speech_fraction=1`` / ``min_speech_fraction=0`` / ``max_duration=0`` + disable the respective bound. + """ + try: + base: dict = {} if user.is_superuser else {"user_id": str(user.user_id)} + + if archived_only: + base["audio_archived"] = True + else: + # audio_archived is a newer field; absent == not archived + base["audio_archived"] = {"$ne": True} + base["deleted"] = {"$ne": True} + base["audio_chunks_count"] = {"$gt": 0} + # Exclude conversations flagged with corrupt audio metadata — they're + # surfaced on the System Errors page instead (a None match also covers + # the field being absent on normal conversations). + base["audio_integrity_error"] = None + # Opt-in: drop conversations the pipeline marked failed (keeps null + # legacy status and in-progress 'active' rows visible — the latter + # are chipped as 'Processing…' so a stuck one is still apparent). + if hide_failed: + base["processing_status"] = { + "$ne": Conversation.ConversationStatus.FAILED.value + } + + # Date range goes into the Mongo query (not the Python predicate) so + # it narrows the MAX_SCAN working set instead of competing with it. + if created_after or created_before: + created: dict = {} + if created_after: + created["$gte"] = created_after + if created_before: + created["$lte"] = created_before + base["created_at"] = created + + collection = Conversation.get_pymongo_collection() + cursor = ( + collection.find(base, _SCAN_PROJECTION) + .sort("created_at", -1) + .limit(MAX_SCAN) + ) + raw_docs = await cursor.to_list(length=MAX_SCAN) + scan_capped = len(raw_docs) >= MAX_SCAN + + # Match threshold the pipeline used + a small comfort margin: an + # identification within this band of the cutoff is a weak/suspect match + # (the "low-confidence" review signal), computed from stored confidence. + similarity_threshold = float( + get_diarization_settings().get("similarity_threshold", 0.5) + ) + marginal_margin = 0.05 + + include_set = set(include_speakers or []) + exclude_set = set(exclude_speakers or []) + matched: List[dict] = [] + # Speakers present anywhere in the scanned working set (before the + # compound predicate), so the filter UI offers exactly the labels that + # exist in this view — and isn't narrowed by its own selection. + available_speakers: set = set() + + for doc in raw_docs: + duration = doc.get("audio_total_duration") or 0.0 + va = doc.get("vad_analysis") + doc_speakers = _speakers_for_doc(doc) + available_speakers.update(doc_speakers) + unknown_count = _unknown_speech_count(doc) + + speech_fraction = None + if va: + speech_fraction = speech_fraction_from_histogram( + histogram=va.get("histogram") or [], + frame_count=va.get("frame_count") or 0, + histogram_bin_width=va.get("histogram_bin_width") or 0.05, + threshold=speech_threshold, + ) + + # --- Compound filter (skip filters for the archived audit view) --- + if not archived_only: + if duration < min_duration: + continue + if 0.0 < max_duration < duration: + continue + if max_speech_fraction < 1.0 or min_speech_fraction > 0.0: + # Unanalyzed conversations can't satisfy a speech filter + if speech_fraction is None: + continue + if speech_fraction > max_speech_fraction: + continue + if speech_fraction < min_speech_fraction: + continue + if include_set and not include_set.intersection(doc_speakers): + continue + if exclude_set and exclude_set.intersection(doc_speakers): + continue + # Opt-in: drop conversations with nothing left to triage (every + # speech segment already has an identified_as). + if hide_reviewed and unknown_count == 0: + continue + + created_at = doc.get("created_at") + archived_at = doc.get("audio_archived_at") + derived_from = doc.get("derived_from") + matched.append( + { + "conversation_id": doc.get("conversation_id"), + "title": doc.get("title"), + "client_id": doc.get("client_id"), + "created_at": created_at.isoformat() if created_at else None, + "duration_seconds": duration, + "speakers": doc_speakers, + "unknown_speech_segments": unknown_count, + "marginal_identified_segments": _marginal_identified_count( + doc, similarity_threshold, marginal_margin + ), + "processing_status": doc.get("processing_status"), + "failure_stage": doc.get("failure_stage"), + "analyzed": va is not None, + "speech_fraction": ( + round(speech_fraction, 4) + if speech_fraction is not None + else None + ), + "derived_operation": ( + derived_from.get("operation") if derived_from else None + ), + "audio_archived": doc.get("audio_archived", False), + "audio_archived_at": ( + archived_at.isoformat() if archived_at else None + ), + "archive_reason": doc.get("archive_reason"), + } + ) + + total = len(matched) + page = matched[offset : offset + limit] + + # How many conversations the Analyze button would actually process: + # the user's own live audio without cached VAD analysis (the batch job + # is scoped to the requesting user, so the count matches even for + # superusers viewing everyone's rows). Lets the UI disable the button + # when there is nothing left to analyze. + # Conversations the Analyze button should process: those with no VAD + # analysis OR whose cached analysis went stale (audio changed in place + # since it was computed). Staleness needs the frame-count/duration + # comparison, so count it in Python over the projected set rather than a + # Mongo predicate. Folding stale into this count means a stale cache + # surfaces as a non-zero Analyze count and the existing button re-runs + # it — no separate UI affordance for what is a rare contingency. + analyze_docs = await collection.find( + { + "user_id": str(user.user_id), + "audio_archived": {"$ne": True}, + "deleted": {"$ne": True}, + "audio_chunks_count": {"$gt": 0}, + "audio_integrity_error": None, + }, + { + "vad_analysis.frame_count": 1, + "vad_analysis.frame_hop_ms": 1, + "audio_total_duration": 1, + }, + ).to_list(length=MAX_SCAN) + unanalyzed_count = sum( + 1 + for d in analyze_docs + if d.get("vad_analysis") is None + or _vad_stale(d.get("vad_analysis"), d.get("audio_total_duration") or 0.0) + ) + + return { + "conversations": page, + "total": total, + "limit": limit, + "offset": offset, + "scan_capped": scan_capped, + "speech_threshold": speech_threshold, + "similarity_threshold": similarity_threshold, + "marginal_margin": marginal_margin, + "unanalyzed_count": unanalyzed_count, + "speakers": sorted(available_speakers), + } + + except Exception as e: + logger.exception(f"Error listing conversations for cleaning: {e}") + return JSONResponse( + status_code=500, content={"error": "Error listing conversations"} + ) + + +def _recommend_threshold(values: List[float]) -> Optional[float]: + """Otsu-style split: the cutoff in [0.36, 0.60] maximizing between-class + variance of the confidence distribution. With the bimodal noise/real + structure this lands in the valley between the two humps — a data-driven + similarity-threshold suggestion. Needs enough points to be meaningful. + """ + if len(values) < 30: + return None + best_t: Optional[float] = None + best_var = -1.0 + t = 0.36 + while t <= 0.60001: + below = [v for v in values if v < t] + above = [v for v in values if v >= t] + if below and above: + wb = len(below) / len(values) + wa = len(above) / len(values) + var = wb * wa * (statistics.mean(above) - statistics.mean(below)) ** 2 + if var > best_var: + best_var = var + best_t = round(t, 2) + t += 0.01 + return best_t + + +async def speaker_confidence_overview(user: User): + """Per-speaker identification-confidence statistics across the corpus. + + Reads stored per-segment confidence (no audio re-embedding) and reports the + global distribution histogram, the marginal-match fraction, per-speaker + baselines (mean/median/min/max + %marginal + survival at the live + threshold), survival counts at candidate thresholds, and a data-driven + recommended threshold. This is the strategic view: it surfaces which + enrolled speakers are "noise magnets" (matches clustered at the floor) and + what threshold cleanly separates real matches from noise. + """ + try: + threshold = float(get_diarization_settings().get("similarity_threshold", 0.5)) + margin = 0.05 + + base: dict = {"deleted": {"$ne": True}} + if not user.is_superuser: + base["user_id"] = str(user.user_id) + + collection = Conversation.get_pymongo_collection() + cursor = collection.find(base, _SCAN_PROJECTION).limit(MAX_SCAN) + docs = await cursor.to_list(length=MAX_SCAN) + + per_speaker: Dict[str, List[float]] = {} + per_speaker_convs: Dict[str, set] = {} + all_conf: List[float] = [] + convs_with_ids = 0 + + for doc in docs: + cid = doc.get("conversation_id") + had = False + for seg in _audit_segments(doc): + if (seg.get("segment_type") or "speech") != "speech": + continue + name = seg.get("identified_as") + conf = seg.get("confidence") + if not name or conf is None: + continue + had = True + all_conf.append(conf) + per_speaker.setdefault(name, []).append(conf) + per_speaker_convs.setdefault(name, set()).add(cid) + if had: + convs_with_ids += 1 + + cutoff = threshold + margin + # Histogram: 0.30..1.00 in 0.05 bins (14 bins). + bin_width = 0.05 + hist_start = 0.30 + n_bins = 14 + counts = [0] * n_bins + for v in all_conf: + idx = int((v - hist_start) / bin_width) + idx = max(0, min(n_bins - 1, idx)) + counts[idx] += 1 + + total = len(all_conf) + marginal = sum(1 for v in all_conf if v < cutoff) + survival = [ + { + "threshold": t, + "keep": sum(1 for v in all_conf if v >= t), + "drop": sum(1 for v in all_conf if v < t), + } + for t in (0.40, 0.45, 0.50, 0.55) + ] + + speakers = [] + for name, vals in per_speaker.items(): + vals_sorted = sorted(vals) + n = len(vals_sorted) + speakers.append( + { + "name": name, + "nseg": n, + "nconv": len(per_speaker_convs[name]), + "mean": round(statistics.mean(vals_sorted), 3), + "median": round(statistics.median(vals_sorted), 3), + "min": round(vals_sorted[0], 3), + "max": round(vals_sorted[-1], 3), + "marginal_pct": round( + 100.0 * sum(1 for v in vals_sorted if v < cutoff) / n, 1 + ), + "keep_pct": round( + 100.0 * sum(1 for v in vals_sorted if v >= threshold) / n, 1 + ), + } + ) + speakers.sort(key=lambda s: (-s["marginal_pct"], -s["nseg"])) + + return { + "threshold": threshold, + "margin": margin, + "total_identified": total, + "conversations_with_ids": convs_with_ids, + "conversations_scanned": len(docs), + "scan_capped": len(docs) >= MAX_SCAN, + "marginal_count": marginal, + "marginal_fraction": round(marginal / total, 4) if total else 0.0, + "histogram": { + "start": hist_start, + "bin_width": bin_width, + "counts": counts, + }, + "survival": survival, + "recommended_threshold": _recommend_threshold(all_conf), + "speakers": speakers, + } + except Exception as e: + logger.exception(f"Error computing speaker confidence overview: {e}") + return JSONResponse( + status_code=500, + content={"error": "Error computing speaker confidence overview"}, + ) + + +async def enqueue_analysis( + user: User, conversation_ids: Optional[List[str]] = None, force: bool = False +): + """Enqueue the batch VAD-analysis job for the user's conversations.""" + try: + # Pass as kwargs so the job's user_id is recorded in job.kwargs — the + # queue status endpoint authorizes non-admins by job.kwargs["user_id"]. + job = default_queue.enqueue( + analyze_audio_batch_job, + user_id=str(user.user_id), + conversation_ids=conversation_ids, + force=force, + job_timeout=3600, + result_ttl=JOB_RESULT_TTL, + description="Analyze conversation audio (VAD)", + ) + logger.info(f"Enqueued VAD analysis job {job.id} for user {user.user_id}") + return {"job_id": job.id, "status": "queued"} + + except Exception as e: + logger.exception(f"Error enqueueing VAD analysis: {e}") + return JSONResponse( + status_code=500, content={"error": "Error enqueueing analysis"} + ) + + +async def run_auto_clean_cron() -> dict: + """Cron entrypoint for auto-clean. + + Runs in the FastAPI process (invoked by the cron scheduler). It does NOT do + the work itself — it enqueues the ``auto_clean_job`` RQ job so the sweep is + visible on the Queue/Jobs page (and runs in the worker). Returns the + enqueued job id, which the cron-jobs page records as the run result. + """ + job = default_queue.enqueue( + auto_clean_job, + job_timeout=3600, + result_ttl=JOB_RESULT_TTL, + description="Auto-clean: archive speech-free conversations", + ) + logger.info(f"Auto-clean cron enqueued job {job.id}") + return {"enqueued_job_id": job.id, "queue": "default"} + + +async def archive_audio_many(user: User, conversation_ids: List[str], reason: str): + """Archive (hard-delete) audio for multiple conversations.""" + results = [] + for cid in conversation_ids: + response = await archive_conversation_audio(cid, user, reason) + try: + body = json.loads(bytes(response.body).decode()) + except Exception: + body = {} + results.append( + { + "conversation_id": cid, + "status_code": response.status_code, + "ok": response.status_code == 200, + "deleted_chunks": body.get("deleted_chunks"), + "error": body.get("error"), + } + ) + + archived = sum(1 for r in results if r["ok"]) + return {"archived": archived, "total": len(conversation_ids), "results": results} + + +# --------------------------------------------------------------------------- +# Split / merge +# --------------------------------------------------------------------------- + +# Projection for chunk timeline reads — never pull audio_data. +_CHUNK_META_PROJECTION = { + "chunk_index": 1, + "start_time": 1, + "end_time": 1, + "duration": 1, + "vad.max_score": 1, +} + + +async def _load_operable_conversation( + user: User, conversation_id: str +) -> Tuple[Optional[Conversation], Optional[JSONResponse]]: + """Fetch a conversation and validate it can be split/merged. + + Returns (conversation, None) or (None, error_response). + """ + conversation = await Conversation.find_one( + Conversation.conversation_id == conversation_id + ) + if not conversation: + return None, JSONResponse( + status_code=404, content={"error": "Conversation not found"} + ) + if not user.is_superuser and conversation.user_id != str(user.user_id): + return None, JSONResponse( + status_code=403, content={"error": "Access forbidden"} + ) + if conversation.deleted: + return None, JSONResponse( + status_code=409, content={"error": "Conversation is deleted"} + ) + if conversation.audio_archived: + return None, JSONResponse( + status_code=409, content={"error": "Conversation audio is archived"} + ) + if not conversation.audio_chunks_count: + return None, JSONResponse( + status_code=409, content={"error": "Conversation has no audio"} + ) + if conversation.derived_into: + return None, JSONResponse( + status_code=409, + content={"error": "Conversation was already split/merged"}, + ) + return conversation, None + + +async def _chunk_timeline(conversation_id: str) -> List[dict]: + """Chunk metadata (no audio bytes) sorted by chunk_index.""" + collection = AudioChunkDocument.get_pymongo_collection() + cursor = collection.find( + {"conversation_id": conversation_id}, _CHUNK_META_PROJECTION + ).sort("chunk_index", 1) + return await cursor.to_list(length=None) + + +def _active_transcript_version( + conversation: Conversation, +) -> Optional["Conversation.TranscriptVersion"]: + for version in conversation.transcript_versions: + if version.version_id == conversation.active_transcript_version: + return version + return None + + +async def _delete_source_memories(user_id: str, conversation_id: str) -> None: + """Best-effort removal of memories sourced from a replaced conversation.""" + try: + memory_service = get_memory_service() + deleted = await memory_service.delete_memories_by_source( + user_id, conversation_id + ) + if deleted: + logger.info( + f"Deleted {deleted} memories sourced from {conversation_id[:12]}" + ) + except Exception as e: + logger.warning( + f"Memory cleanup failed for {conversation_id[:12]} (non-fatal): {e}" + ) + + +async def get_silence_gaps( + user: User, + conversation_id: str, + speech_threshold: float = 0.5, + min_gap_seconds: float = 900.0, +): + """Silence gaps (candidate split points) from cached chunk VAD scores.""" + conversation, error = await _load_operable_conversation(user, conversation_id) + if error: + return error + + chunks = await _chunk_timeline(conversation_id) + if not chunks: + return JSONResponse( + status_code=409, content={"error": "Conversation has no audio chunks"} + ) + + # Only "needs analysis" when *no* chunk is scored. detect_silence_gaps + # already treats individual unscored chunks as speech (so it never suggests a + # split through unscored audio), which keeps a partially-scored conversation + # — e.g. one with leftover unscored chunks from the reconnect-duplicate bug — + # usable for splitting instead of falsely blocked. + needs_analysis = all((c.get("vad") or {}).get("max_score") is None for c in chunks) + duration = float(chunks[-1]["end_time"]) + + gaps = ( + [] + if needs_analysis + else detect_silence_gaps( + chunks, + speech_threshold=speech_threshold, + min_gap_seconds=min_gap_seconds, + ) + ) + + return { + "analyzed": not needs_analysis, + "needs_analysis": needs_analysis, + "duration_seconds": round(duration, 2), + "chunk_duration_seconds": float(chunks[0].get("duration") or 10.0), + "speech_threshold": speech_threshold, + "min_gap_seconds": min_gap_seconds, + "gaps": gaps, + } + + +async def get_speech_regions( + user: User, conversation_id: str, speakers: Optional[List[str]] = None +): + """Merged speech intervals for speech-skip playback. + + Without ``speakers``: served from the cached ``vad_analysis.speech_regions`` + when present; otherwise derived from the chunk-level frame scores (no audio + decode) and cached back onto the conversation. + + With ``speakers``: the raw frame-level speech intervals are intersected + with the selected speakers' transcript segments *before* merging, so the + regions cover only time where the VAD heard voice while one of those + speakers was tagged. Speaker labels match ``identified_as`` (recognized + name) falling back to the raw ``speaker`` label, same as the audit + listing. Filtered results are never cached (they depend on the selection). + """ + conversation = await Conversation.find_one( + Conversation.conversation_id == conversation_id + ) + if not conversation: + return JSONResponse( + status_code=404, content={"error": "Conversation not found"} + ) + if not user.is_superuser and conversation.user_id != str(user.user_id): + return JSONResponse(status_code=403, content={"error": "Access forbidden"}) + if conversation.audio_archived or not conversation.audio_chunks_count: + return JSONResponse( + status_code=409, content={"error": "Conversation has no audio"} + ) + + wanted = {s for s in (speakers or []) if s} + duration = conversation.audio_total_duration or 0.0 + va = conversation.vad_analysis + + # Trust the cached summary only if it still describes the current chunk set. + # vad_analysis is derived from the chunks' frame scores; if the chunks changed + # in place (e.g. the reconnect-duplicate dedup) the cache's implied duration + # (frame_count * frame_hop) no longer matches audio_total_duration — fall back + # to deriving from current chunks rather than serving stale regions. + va_fresh = va is not None and audio_cache_duration_matches( + va.frame_count * va.frame_hop_ms / 1000.0, duration + ) + + if not wanted and va_fresh and va.speech_regions is not None: + regions = va.speech_regions + else: + # Derive from chunk frame scores with a streaming cursor (score + # vectors are ~5KB per chunk; never materialize them all at once). + collection = AudioChunkDocument.get_pymongo_collection() + cursor = collection.find( + {"conversation_id": conversation_id}, + {"start_time": 1, "end_time": 1, "vad.scores": 1, "vad.frame_hop_ms": 1}, + ).sort("chunk_index", 1) + + # Derive regions from the chunks that have scores; chunks missing them + # contribute no speech intervals (treated as non-speech for speech-only + # playback — full-audio mode still plays everything). Only report + # needs_analysis when *no* chunk is scored, i.e. the conversation was + # genuinely never analyzed. A partially-scored conversation — e.g. one + # damaged by the reconnect-duplicate bug, where some overlapping chunks + # never got scored — still gets a usable speech preview instead of + # falsely reading as "needs analysis" while its cached summary says it is + # analyzed (which is the contradiction the audit listing would show). + raw_intervals: List[List[float]] = [] + scored_any = False + last_end = 0.0 + async for chunk in cursor: + vad = chunk.get("vad") + last_end = max(last_end, float(chunk["end_time"])) + if not vad or vad.get("scores") is None: + continue + scored_any = True + raw_intervals.extend( + frame_speech_intervals( + vad["scores"], + float(vad["frame_hop_ms"]) / 1000.0, + float(chunk["start_time"]), + ) + ) + + if not scored_any: + return { + "analyzed": False, + "needs_analysis": True, + "duration_seconds": round(duration, 2), + "speech_seconds": 0, + "regions": [], + } + + duration = duration or last_end + if wanted: + version = _active_transcript_version(conversation) + tagged = [ + [segment.start, segment.end] + for segment in (version.segments if version else []) + if segment.segment_type == Conversation.SegmentType.SPEECH + and (segment.identified_as or segment.speaker) in wanted + ] + raw_intervals = intersect_intervals(raw_intervals, tagged) + regions = merge_speech_regions(raw_intervals, duration) + if not wanted and va is not None: + va.speech_regions = regions + await conversation.save() + + speech_seconds = sum(end - start for start, end in regions) + return { + "analyzed": True, + "needs_analysis": False, + "duration_seconds": round(duration, 2), + "speech_seconds": round(speech_seconds, 2), + "speakers": sorted(wanted), + "regions": [{"start": start, "end": end} for start, end in regions], + } + + +async def get_segments(user: User, conversation_id: str): + """Active-version transcript segments for the speaker-triage panel. + + Returns every segment (the frontend filters to speech / needs-review) with + the speaker-recognition fields the panel needs — including ``confidence``, + which the listing projection strips. ``index`` is the position in the active + version's segment list (so it matches the ``segment_index`` an annotation + stores), and ``segment_start_time`` is sent so the frontend records the + drift-stable time key on each annotation it creates. + """ + conversation = await Conversation.find_one( + Conversation.conversation_id == conversation_id + ) + if not conversation: + return JSONResponse( + status_code=404, content={"error": "Conversation not found"} + ) + if not user.is_superuser and conversation.user_id != str(user.user_id): + return JSONResponse(status_code=403, content={"error": "Access forbidden"}) + + version = _active_transcript_version(conversation) + segments = [] + for index, seg in enumerate(version.segments if version else []): + segments.append( + { + "index": index, + "start": round(seg.start, 3), + "end": round(seg.end, 3), + "segment_start_time": seg.start, + "text": seg.text, + "speaker": seg.speaker, + "identified_as": seg.identified_as, + "confidence": seg.confidence, + "segment_type": seg.segment_type, + } + ) + + return { + "conversation_id": conversation_id, + "duration_seconds": round(conversation.audio_total_duration or 0.0, 2), + "audio_available": bool( + conversation.audio_chunks_count and not conversation.audio_archived + ), + "segments": segments, + } + + +async def identify_segment_clip( + user: User, conversation_id: str, start: float, end: float +): + """Live speaker suggestion for one segment. + + Reconstructs the segment's audio and asks the speaker service for its + closest enrolled match. The service returns the best name + cosine even + below the match threshold, which is the only real signal available on an + unknown segment (stored confidence is 0.0 there). + """ + _conversation, error = await _load_operable_conversation(user, conversation_id) + if error: + return error + if end <= start: + return JSONResponse( + status_code=400, content={"error": "end must be greater than start"} + ) + + speaker_client = SpeakerRecognitionClient() + if not speaker_client.enabled: + return JSONResponse( + status_code=503, content={"error": "Speaker recognition is not enabled"} + ) + + try: + wav_bytes = await reconstruct_audio_segment(conversation_id, start, end) + except Exception as e: + logger.warning(f"Segment audio reconstruction failed for identify: {e}") + return JSONResponse( + status_code=409, content={"error": "Could not reconstruct segment audio"} + ) + + # Pass a near-zero threshold so the service always returns the *closest* + # enrolled speaker + its true cosine, even for a below-threshold (unknown) + # segment — that name+score is the whole point of a triage suggestion. The + # caller surfaces the cosine (color-coded) so a weak match reads as weak; + # `found` reflects whether it would clear the real operating threshold. + suggest = await speaker_client.identify_segment( + wav_bytes, user_id="1", similarity_threshold=0.0 + ) + confidence = suggest.get("confidence") + threshold = get_diarization_settings().get("similarity_threshold", 0.5) + return { + "found": confidence is not None and confidence >= threshold, + "speaker_id": suggest.get("speaker_id"), + "speaker_name": suggest.get("speaker_name"), + "confidence": confidence, + "threshold": threshold, + "status": suggest.get("status"), + } + + +async def get_triage_pending(user: User): + """Count of unapplied speaker-triage decisions (pending diarization + annotations) and how many conversations they span — drives the toolbar's + 'Apply all' control.""" + base = { + "annotation_type": AnnotationType.DIARIZATION, + "processed": False, + } + if not user.is_superuser: + base["user_id"] = user.user_id + pending = await Annotation.find(base).to_list() + conversation_ids = {a.conversation_id for a in pending if a.conversation_id} + return { + "pending_count": len(pending), + "conversation_count": len(conversation_ids), + } + + +async def apply_triage(user: User): + """Bulk-apply all pending speaker-triage decisions across every conversation. + + Each triage decision was persisted as a diarization annotation. This applies them + (new transcript version + chained memory reprocess) in one pass over every + conversation the user triaged. It does NOT enroll voiceprints — that's a deliberate + action reserved for the finetuning / Enrollment pages. Noise decisions ride along: + apply reclassifies them to non-speech. + """ + # Lazy import: circular dependency — the routers.modules package __init__ + # imports data_audit_routes, which imports back into this controller, so a + # top-level import here would re-enter this module mid-import. + from advanced_omi_backend.routers.modules.annotation_routes import ( + apply_diarization_annotations, + ) + + base = { + "annotation_type": AnnotationType.DIARIZATION, + "processed": False, + } + if not user.is_superuser: + base["user_id"] = user.user_id + pending = await Annotation.find(base).to_list() + + conversation_ids = sorted({a.conversation_id for a in pending if a.conversation_id}) + if not conversation_ids: + return {"applied_count": 0, "conversation_count": 0, "enrolled": None} + + applied_count = 0 + apply_errors: List[str] = [] + for cid in conversation_ids: + try: + await apply_diarization_annotations(cid, current_user=user) + applied_count += 1 + except Exception as e: + logger.warning(f"Triage apply failed for {cid[:8]}: {e}") + apply_errors.append(cid) + + # NOTE: triage only ANNOTATES (fixes the conversation transcript + memory). It does + # NOT enroll/train voiceprints — voiceprint training is a deliberate action done only + # from the finetuning page and the speaker-recognition Enrollment page, so noisy + # conversational corrections (e.g. a one-word "yeah") never drift someone's voiceprint. + return { + "applied_count": applied_count, + "conversation_count": len(conversation_ids), + "apply_errors": apply_errors, + "enrolled": None, + } + + +def _snap_split_points( + split_points: List[float], chunks: List[dict] +) -> Tuple[Optional[List[int]], Optional[str]]: + """Snap time points to chunk boundaries. + + Returns (sorted chunk indices that begin each new child, None) or + (None, error message). Each child must contain at least one chunk, so + snapped indices must be unique and within (0, n_chunks). + """ + n_chunks = len(chunks) + duration = float(chunks[-1]["end_time"]) + + indices = [] + for point in sorted(set(split_points)): + if not (0 < point < duration): + return None, f"Split point {point}s is outside (0, {duration:.0f}s)" + # The chunk containing the point begins the next child. + snapped = None + for chunk in chunks: + if chunk["start_time"] <= point < chunk["end_time"]: + snapped = int(chunk["chunk_index"]) + break + if snapped is None: + snapped = n_chunks - 1 + if snapped == 0: + return None, f"Split point {point}s leaves an empty first part" + indices.append(snapped) + + if len(set(indices)) != len(indices): + return None, "Split points collapse onto the same chunk boundary" + return indices, None + + +async def split_conversation( + user: User, conversation_id: str, split_points: List[float] +): + """Split a conversation into children at the given time points. + + Chunk documents are reassigned to the children (no audio re-encode); the + parent's active transcript is sliced by time range; the parent is + soft-deleted with lineage metadata; memory + title jobs run per child. + Crash-safe ordering without transactions: children are created first, the + parent is mutated last. + """ + conversation, error = await _load_operable_conversation(user, conversation_id) + if error: + return error + + try: + chunks = await _chunk_timeline(conversation_id) + if not chunks: + return JSONResponse( + status_code=409, content={"error": "Conversation has no audio chunks"} + ) + + boundary_indices, message = _snap_split_points(split_points, chunks) + if message: + return JSONResponse(status_code=422, content={"error": message}) + + # Build [start_index, end_index) chunk ranges for each child. + edges = [0] + boundary_indices + [len(chunks)] + chunk_by_index = {int(c["chunk_index"]): c for c in chunks} + parent_version = _active_transcript_version(conversation) + now = datetime.now(timezone.utc) + + children: List[Conversation] = [] + child_specs: List[dict] = [] + for a, b in zip(edges[:-1], edges[1:]): + t0 = float(chunk_by_index[a]["start_time"]) + t1 = float(chunk_by_index[b - 1]["end_time"]) + + child = create_conversation( + user_id=conversation.user_id, + client_id=conversation.client_id, + ) + child.derived_from = Conversation.DerivedFrom( + operation="split", + source_conversation_ids=[conversation_id], + time_range=[t0, t1], + performed_at=now, + performed_by=str(user.user_id), + ) + child.end_reason = conversation.end_reason + child.audio_chunks_count = b - a + child.audio_total_duration = round( + sum( + float(chunk_by_index[i].get("duration") or 0.0) for i in range(a, b) + ), + 2, + ) + child.audio_compression_ratio = conversation.audio_compression_ratio + + version_id = None + if parent_version: + segments = slice_segments(parent_version.segments or [], t0, t1) + words = slice_words(parent_version.words or [], t0, t1) + if segments or words: + version_id = str(uuid.uuid4()) + child.add_transcript_version( + version_id=version_id, + transcript=build_transcript_text(segments), + words=words, + segments=segments, + provider=parent_version.provider, + model=parent_version.model, + metadata={ + "derived": "split", + "source_conversation_id": conversation_id, + "source_version_id": parent_version.version_id, + "time_range": [t0, t1], + }, + set_as_active=True, + ) + + part = len(children) + 1 + total = len(edges) - 1 + base_title = conversation.title or conversation_id[:8] + child.title = f"Part {part}/{total} — {base_title}" + + await child.insert() + children.append(child) + child_specs.append( + {"a": a, "b": b, "t0": t0, "t1": t1, "version_id": version_id} + ) + + # Re-validate just before mutating chunks (no lock; admin tool). + current = await Conversation.find_one( + Conversation.conversation_id == conversation_id + ) + if current is None or current.deleted or current.derived_into: + for child in children: + await child.delete() + return JSONResponse( + status_code=409, + content={"error": "Conversation changed during split; aborted"}, + ) + + # Move chunks to the children: re-id, re-index, shift times. + collection = AudioChunkDocument.get_pymongo_collection() + for child, spec in zip(children, child_specs): + await collection.update_many( + { + "conversation_id": conversation_id, + "chunk_index": {"$gte": spec["a"], "$lt": spec["b"]}, + }, + [ + { + "$set": { + "conversation_id": child.conversation_id, + "chunk_index": {"$subtract": ["$chunk_index", spec["a"]]}, + "start_time": {"$subtract": ["$start_time", spec["t0"]]}, + "end_time": {"$subtract": ["$end_time", spec["t0"]]}, + } + } + ], + ) + + # Soft-delete the parent (chunks were moved, not deleted). + conversation.deleted = True + conversation.deletion_reason = "split" + conversation.deleted_at = now + conversation.derived_into = [c.conversation_id for c in children] + conversation.audio_chunks_count = 0 + await conversation.save() + + await _delete_source_memories(conversation.user_id, conversation_id) + + results = [] + for child, spec in zip(children, child_specs): + jobs = None + if spec["version_id"]: + jobs = start_post_conversation_jobs( + child.conversation_id, + conversation.user_id, + transcript_version_id=spec["version_id"], + client_id=conversation.client_id, + end_reason="split", + skip_speaker_recognition=True, + ) + results.append( + { + "conversation_id": child.conversation_id, + "start_seconds": spec["t0"], + "end_seconds": spec["t1"], + "duration_seconds": child.audio_total_duration, + "chunk_count": child.audio_chunks_count, + "has_transcript": spec["version_id"] is not None, + "jobs": jobs, + } + ) + + logger.info( + f"Split conversation {conversation_id[:12]} into {len(children)} parts" + ) + return { + "parent_conversation_id": conversation_id, + "children": results, + } + + except Exception as e: + logger.exception(f"Error splitting conversation {conversation_id}: {e}") + return JSONResponse( + status_code=500, content={"error": "Error splitting conversation"} + ) + + +async def merge_conversations(user: User, conversation_ids: List[str]): + """Merge adjacent conversations into a new conversation. + + Creates a fresh conversation (symmetric with split: sources stay intact + and individually recoverable), reassigns chunks with cumulative time + offsets, concatenates transcripts with a seam note where the wall-clock + gap between recordings is elided, soft-deletes the sources, and enqueues + memory + title jobs. Crash-safe ordering: the merged conversation is + created before any source is mutated. + """ + if len(set(conversation_ids)) != len(conversation_ids): + return JSONResponse( + status_code=422, content={"error": "Duplicate conversation ids"} + ) + + try: + sources: List[Conversation] = [] + for cid in conversation_ids: + conversation, error = await _load_operable_conversation(user, cid) + if error: + return error + sources.append(conversation) + + client_ids = {s.client_id for s in sources} + if len(client_ids) != 1: + return JSONResponse( + status_code=422, + content={"error": "Conversations belong to different devices"}, + ) + user_ids = {s.user_id for s in sources} + if len(user_ids) != 1: + return JSONResponse( + status_code=422, + content={"error": "Conversations belong to different users"}, + ) + + sources.sort(key=lambda s: s.created_at) + first, last = sources[0], sources[-1] + + # Adjacency: no other live conversation of this device may sit between + # the earliest and latest selected (server-authoritative check). + conv_collection = Conversation.get_pymongo_collection() + between = await conv_collection.count_documents( + { + "client_id": first.client_id, + "deleted": {"$ne": True}, + "conversation_id": {"$nin": conversation_ids}, + "created_at": {"$gt": first.created_at, "$lt": last.created_at}, + } + ) + if between: + return JSONResponse( + status_code=422, + content={ + "error": "Conversations are not adjacent: " + f"{between} other conversation(s) lie between them" + }, + ) + + # Uniform audio format across all sources' chunks. + chunk_collection = AudioChunkDocument.get_pymongo_collection() + chunk_filter = {"conversation_id": {"$in": conversation_ids}} + sample_rates = await chunk_collection.distinct("sample_rate", chunk_filter) + channel_counts = await chunk_collection.distinct("channels", chunk_filter) + if len(sample_rates) > 1 or len(channel_counts) > 1: + return JSONResponse( + status_code=422, + content={"error": "Conversations have mixed audio formats"}, + ) + + # Precise per-source duration/count from the chunks themselves. + stats = { + row["_id"]: row + for row in await chunk_collection.aggregate( + [ + {"$match": chunk_filter}, + { + "$group": { + "_id": "$conversation_id", + "duration": {"$sum": "$duration"}, + "count": {"$sum": 1}, + } + }, + ] + ).to_list(length=None) + } + missing = [s.conversation_id for s in sources if s.conversation_id not in stats] + if missing: + return JSONResponse( + status_code=409, + content={"error": f"No audio chunks for {missing[0]}"}, + ) + + now = datetime.now(timezone.utc) + merged = create_conversation(user_id=first.user_id, client_id=first.client_id) + merged.created_at = first.created_at + merged.derived_from = Conversation.DerivedFrom( + operation="merge", + source_conversation_ids=[s.conversation_id for s in sources], + performed_at=now, + performed_by=str(user.user_id), + ) + merged.end_reason = Conversation.EndReason.MERGE + merged.audio_chunks_count = sum( + int(stats[s.conversation_id]["count"]) for s in sources + ) + merged.audio_total_duration = round( + sum(float(stats[s.conversation_id]["duration"]) for s in sources), 2 + ) + merged.audio_compression_ratio = first.audio_compression_ratio + + # Concatenate transcripts with cumulative offsets + seam notes. + merged_segments: List[Conversation.SpeakerSegment] = [] + merged_words: List[Conversation.Word] = [] + provider = None + model = None + offset = 0.0 + prev = None + for source in sources: + version = _active_transcript_version(source) + if prev is not None: + gap_seconds = max( + 0.0, + (source.created_at - prev.created_at).total_seconds() + - float(stats[prev.conversation_id]["duration"]), + ) + merged_segments.append( + Conversation.SpeakerSegment( + start=round(offset, 3), + end=round(offset, 3), + text=( + f"[merged: {max(1, round(gap_seconds / 60))} min gap " + "between recordings elided]" + ), + speaker="system", + segment_type=Conversation.SegmentType.NOTE, + ) + ) + if version: + merged_segments.extend(shift_segments(version.segments or [], offset)) + merged_words.extend(shift_words(version.words or [], offset)) + provider = provider or version.provider + model = model or version.model + offset += float(stats[source.conversation_id]["duration"]) + prev = source + + version_id = None + has_content = any( + seg.segment_type == Conversation.SegmentType.SPEECH + for seg in merged_segments + ) or bool(merged_words) + if has_content: + version_id = str(uuid.uuid4()) + merged.add_transcript_version( + version_id=version_id, + transcript=build_transcript_text(merged_segments), + words=merged_words, + segments=merged_segments, + provider=provider, + model=model, + metadata={ + "derived": "merge", + "source_conversation_ids": [s.conversation_id for s in sources], + }, + set_as_active=True, + ) + + merged.title = first.title or f"Merged conversation ({len(sources)} parts)" + await merged.insert() + + # Move chunks: per source, offset index/time and re-id. + offset = 0.0 + index_base = 0 + for source in sources: + await chunk_collection.update_many( + {"conversation_id": source.conversation_id}, + [ + { + "$set": { + "conversation_id": merged.conversation_id, + "chunk_index": {"$add": ["$chunk_index", index_base]}, + "start_time": {"$add": ["$start_time", offset]}, + "end_time": {"$add": ["$end_time", offset]}, + } + } + ], + ) + offset += float(stats[source.conversation_id]["duration"]) + index_base += int(stats[source.conversation_id]["count"]) + + # Soft-delete sources with lineage (chunks were moved, not deleted). + for source in sources: + source.deleted = True + source.deletion_reason = "merged" + source.deleted_at = now + source.derived_into = [merged.conversation_id] + source.audio_chunks_count = 0 + await source.save() + await _delete_source_memories(source.user_id, source.conversation_id) + + jobs = None + if version_id: + jobs = start_post_conversation_jobs( + merged.conversation_id, + merged.user_id, + transcript_version_id=version_id, + client_id=merged.client_id, + end_reason="merge", + skip_speaker_recognition=True, + ) + + logger.info( + f"Merged {len(sources)} conversations into {merged.conversation_id[:12]}" + ) + return { + "merged_conversation_id": merged.conversation_id, + "source_conversation_ids": [s.conversation_id for s in sources], + "duration_seconds": merged.audio_total_duration, + "chunk_count": merged.audio_chunks_count, + "has_transcript": version_id is not None, + "jobs": jobs, + } + + except Exception as e: + logger.exception(f"Error merging conversations {conversation_ids}: {e}") + return JSONResponse( + status_code=500, content={"error": "Error merging conversations"} + ) + + +# --------------------------------------------------------------------------- +# Annotation dataset export +# --------------------------------------------------------------------------- + + +async def start_screening( + user: User, + conversation_ids: List[str], + policy: Optional[str] = None, +): + """Enqueue the privacy-screen job for selected conversations. + + The job applies the shareability ``policy`` (or the configured default) to + each conversation's transcript and returns the flagged segments for the + user to review before exporting. + """ + try: + job = default_queue.enqueue( + screen_conversations_job, + user_id=str(user.user_id), + conversation_ids=conversation_ids, + policy=policy, + job_timeout=1800, + result_ttl=JOB_RESULT_TTL, + description=f"Privacy-screen {len(conversation_ids)} conversations", + ) + logger.info( + f"Enqueued sensitivity screen (job {job.id}) for user {user.user_id}" + ) + return {"job_id": job.id, "status": "queued"} + except Exception as e: + logger.exception(f"Error enqueueing sensitivity screen: {e}") + return JSONResponse( + status_code=500, content={"error": "Error enqueueing screen"} + ) + + +async def get_default_sensitivity_policy(): + """Return the configured default shareability policy (UI prefill).""" + return {"policy": get_sensitivity_policy()} + + +async def start_export( + user: User, + conversation_ids: List[str], + mode: str = "clips", + pad_seconds: float = 1.0, + speech_threshold: float = 0.5, + merge_gap_seconds: float = 3.0, + excluded_ranges: Optional[Dict[str, List[List[float]]]] = None, + sensitivity_policy: Optional[str] = None, +): + """Enqueue the annotation-dataset export job for selected conversations. + + ``excluded_ranges`` maps conversation_id → withheld ``[start, end]`` ranges + confirmed from the privacy screen; those are carved out of the export. + """ + try: + export_id = new_export_id() + job = default_queue.enqueue( + export_annotation_dataset_job, + user_id=str(user.user_id), + export_id=export_id, + conversation_ids=conversation_ids, + mode=mode, + pad_seconds=pad_seconds, + speech_threshold=speech_threshold, + merge_gap_seconds=merge_gap_seconds, + excluded_ranges=excluded_ranges, + sensitivity_policy=sensitivity_policy, + job_timeout=3600, + result_ttl=JOB_RESULT_TTL, + description=f"Export annotation dataset ({len(conversation_ids)} conversations, {mode})", + ) + logger.info( + f"Enqueued annotation export {export_id} (job {job.id}) " + f"for user {user.user_id}" + ) + return {"job_id": job.id, "export_id": export_id, "status": "queued"} + except Exception as e: + logger.exception(f"Error enqueueing annotation export: {e}") + return JSONResponse( + status_code=500, content={"error": "Error enqueueing export"} + ) + + +def _read_export_meta(user: User, export_id: str): + """Load an export's metadata, enforcing id validity + ownership. + + Returns (meta_dict, None) or (None, error_response). + """ + if not validate_export_id(export_id): + return None, JSONResponse( + status_code=422, content={"error": "Invalid export id"} + ) + meta_path = export_dir(export_id) / META_NAME + if not meta_path.is_file(): + return None, JSONResponse( + status_code=404, content={"error": "Export not found"} + ) + meta = json.loads(meta_path.read_text()) + if not user.is_superuser and meta.get("created_by") != str(user.user_id): + return None, JSONResponse( + status_code=403, content={"error": "Access forbidden"} + ) + return meta, None + + +async def list_exports(user: User): + """List completed exports (superusers see all, others their own).""" + exports = [] + if EXPORTS_DIR.is_dir(): + for meta_path in EXPORTS_DIR.glob(f"*/{META_NAME}"): + try: + meta = json.loads(meta_path.read_text()) + except Exception: + logger.warning(f"Unreadable export metadata: {meta_path}") + continue + if not user.is_superuser and meta.get("created_by") != str(user.user_id): + continue + meta["zip_ready"] = (meta_path.parent / ZIP_NAME).is_file() + exports.append(meta) + exports.sort(key=lambda m: m.get("created_at") or "", reverse=True) + return {"exports": exports} + + +async def download_export(user: User, export_id: str): + """Stream an export's dataset.zip as an attachment.""" + meta, error = _read_export_meta(user, export_id) + if error: + return error + zip_path = export_dir(export_id) / ZIP_NAME + if not zip_path.is_file(): + return JSONResponse( + status_code=404, content={"error": "Export zip not found (job failed?)"} + ) + return FileResponse( + zip_path, media_type="application/zip", filename=f"{export_id}.zip" + ) + + +async def delete_export(user: User, export_id: str): + """Delete an export directory (zip + metadata).""" + meta, error = _read_export_meta(user, export_id) + if error: + return error + shutil.rmtree(export_dir(export_id)) + logger.info(f"Deleted annotation export {export_id}") + return {"deleted": True, "export_id": export_id} diff --git a/backends/advanced/src/advanced_omi_backend/controllers/drift_controller.py b/backends/advanced/src/advanced_omi_backend/controllers/drift_controller.py new file mode 100644 index 00000000..d803c1d5 --- /dev/null +++ b/backends/advanced/src/advanced_omi_backend/controllers/drift_controller.py @@ -0,0 +1,185 @@ +"""Identify drift conversations — whose speaker labels would change under the current gallery. + +After cleaning voiceprints (Enrollment Health), past conversations may carry stale +speaker identifications ("drift"). This re-identifies each conversation's STORED per-cluster +centroids against the live gallery (pure vector math — no GPU, no re-diarization) and reports +how many segment labels would flip, so you reprocess only the ones that drifted. + +Centroids are stored in ``TranscriptVersion.metadata["cluster_centroids"]`` keyed by the +segment's display label (the speaker name or "Unknown Speaker N"), so they map 1:1 to a +version's segments via ``segment.speaker``. Conversations recorded before centroid storage +need the one-time :func:`backfill_cluster_embeddings` pass. +""" + +import logging +from collections import Counter +from typing import Optional + +from advanced_omi_backend.config import get_diarization_settings +from advanced_omi_backend.models.conversation import Conversation +from advanced_omi_backend.speaker_recognition_client import SpeakerRecognitionClient +from advanced_omi_backend.utils.audio_chunk_utils import reconstruct_audio_segment + +logger = logging.getLogger(__name__) + +# Single-admin assumption, mirroring speaker_recognition_client. +SPEAKER_USER_ID = 1 + + +def _speech_segments(version) -> list: + return [ + s + for s in (version.segments or []) + if getattr(s, "segment_type", "speech") == "speech" and s.speaker + ] + + +def _threshold() -> Optional[float]: + try: + return get_diarization_settings().get("similarity_threshold") + except Exception: + return None + + +async def find_drift_conversations() -> dict: + """Rank non-deleted conversations by how many speaker labels would change now. + + Cheap: one ``/v1/reidentify-clusters`` call per conversation (no audio, no GPU). + Conversations without stored centroids are counted under ``no_centroid_data`` (run + the backfill to cover them). + """ + threshold = _threshold() + client = SpeakerRecognitionClient() + convs = await Conversation.find({"deleted": {"$ne": True}}).to_list() + + drifted = [] + scanned = 0 + no_data = 0 + for conv in convs: + version = conv.active_transcript + if not version or not version.segments: + continue + scanned += 1 + centroids = (version.metadata or {}).get("cluster_centroids") + if not centroids: + no_data += 1 + continue + + resp = await client.reidentify_clusters( + centroids, user_id=SPEAKER_USER_ID, similarity_threshold=threshold + ) + if resp.get("error"): + logger.warning( + "reidentify failed for %s: %s", + conv.conversation_id[:8], + resp.get("error"), + ) + continue + assignments = resp.get("assignments", {}) + + changed = [] + speech_count = 0 + for s in _speech_segments(version): + speech_count += 1 + old = s.identified_as or None + new_a = assignments.get(s.speaker) + new = (new_a["name"] if new_a else None) or None + if old != new: + changed.append((old, new)) + + if changed: + trans = Counter(changed) + drifted.append( + { + "conversation_id": conv.conversation_id, + "title": conv.title or "(untitled)", + "speech_segments": speech_count, + "drifted_segments": len(changed), + "transitions": [ + {"from": f, "to": t, "count": n} + for (f, t), n in trans.most_common() + ], + "processed_at": ( + version.created_at.isoformat() if version.created_at else None + ), + } + ) + + drifted.sort(key=lambda c: c["drifted_segments"], reverse=True) + return { + "drifted": drifted, + "total_drifted": len(drifted), + "conversations_scanned": scanned, + "no_centroid_data": no_data, + "similarity_threshold": threshold, + } + + +async def backfill_cluster_embeddings( + limit: Optional[int] = None, only_missing: bool = True +) -> dict: + """One-time: embed per-cluster centroids for conversations that lack them. + + Reconstructs each conversation's audio and pools one centroid per existing diarized + speaker (no re-diarization) via ``/v1/embed-clusters``, storing the result keyed by + the segments' display labels. GPU-bound (runs the embedder); intended to be invoked + from a script inside the backend container. + """ + client = SpeakerRecognitionClient() + convs = await Conversation.find({"deleted": {"$ne": True}}).to_list() + + done = skipped = failed = 0 + for conv in convs: + version = conv.active_transcript + if not version or not version.segments: + skipped += 1 + continue + if only_missing and (version.metadata or {}).get("cluster_centroids"): + skipped += 1 + continue + + speech = _speech_segments(version) + diar = [ + {"speaker": s.speaker, "start": float(s.start), "end": float(s.end)} + for s in speech + ] + if not diar: + skipped += 1 + continue + + max_end = max(d["end"] for d in diar) + try: + audio = await reconstruct_audio_segment( + conv.conversation_id, 0.0, max_end + 1.0 + ) + except Exception as e: + logger.warning( + "audio reconstruct failed for %s: %s", conv.conversation_id[:8], e + ) + failed += 1 + continue + + resp = await client.embed_clusters(audio, diar) + if resp.get("error") or not resp.get("clusters"): + logger.warning( + "embed_clusters failed for %s: %s", + conv.conversation_id[:8], + resp.get("error") or resp.get("message"), + ) + failed += 1 + continue + + if not version.metadata: + version.metadata = {} + version.metadata["cluster_centroids"] = resp["clusters"] + await conv.save() + done += 1 + logger.info( + "backfilled cluster centroids for %s (%d clusters)", + conv.conversation_id[:8], + len(resp["clusters"]), + ) + if limit and done >= limit: + break + + return {"backfilled": done, "skipped": skipped, "failed": failed} diff --git a/backends/advanced/src/advanced_omi_backend/controllers/memory_controller.py b/backends/advanced/src/advanced_omi_backend/controllers/memory_controller.py index 40c1ac51..b4356ef6 100644 --- a/backends/advanced/src/advanced_omi_backend/controllers/memory_controller.py +++ b/backends/advanced/src/advanced_omi_backend/controllers/memory_controller.py @@ -3,12 +3,18 @@ """ import asyncio +import difflib import logging from typing import Optional +from beanie import PydanticObjectId from fastapi.responses import JSONResponse +from advanced_omi_backend.controllers.conversation_controller import ( + _memory_audit_to_dict, +) from advanced_omi_backend.models.conversation import Conversation +from advanced_omi_backend.models.memory_audit import MemoryAuditEntry from advanced_omi_backend.services.memory import get_memory_service from advanced_omi_backend.services.memory.base import MemoryEntry from advanced_omi_backend.users import User @@ -24,6 +30,131 @@ def _resolve_target_user(user: User, user_id: Optional[str] = None) -> str: return user.user_id +async def get_memory_audit( + user: User, + limit: int, + user_id: Optional[str] = None, + conversation_id: Optional[str] = None, +): + """Return the memory vault change ledger for a user (newest first). + + Records which notes were created/updated/deleted, when, and what triggered + each change (memory extraction, speaker reprocess, or an inbound Obsidian edit). + """ + try: + target_user_id = _resolve_target_user(user, user_id) + + query = MemoryAuditEntry.find(MemoryAuditEntry.user_id == target_user_id) + if conversation_id: + query = query.find(MemoryAuditEntry.conversation_id == conversation_id) + + entries = await query.sort(-MemoryAuditEntry.created_at).limit(limit).to_list() + + return { + "user_id": target_user_id, + "count": len(entries), + "entries": [_memory_audit_to_dict(e) for e in entries], + } + + except Exception as e: + logger.error(f"Error fetching memory audit: {e}", exc_info=True) + return JSONResponse( + status_code=500, + content={"message": f"Error fetching memory audit: {str(e)}"}, + ) + + +async def get_memory_audit_diff(user: User, entry_id: str): + """Return the before→after diff for a single memory-audit entry. + + The ledger stores the post-change note content (``after_text``); the "before" + is reconstructed from the most recent prior recorded change to the same note. + The diff therefore reflects the net change since the last recorded state, + whether the previous writer was the AI or an inbound Obsidian (human) edit. + """ + try: + try: + oid = PydanticObjectId(entry_id) + except Exception: + return JSONResponse( + status_code=400, content={"message": "Invalid audit entry id"} + ) + + entry = await MemoryAuditEntry.get(oid) + if entry is None: + return JSONResponse( + status_code=404, content={"message": "Audit entry not found"} + ) + + # Ownership: users may only diff their own ledger; admins may diff any. + if entry.user_id != user.user_id and not user.is_superuser: + return JSONResponse( + status_code=403, content={"message": "Not authorized for this entry"} + ) + + after_text = entry.after_text + + # Reconstruct the prior state from the previous recorded change to this note. + before_text: Optional[str] = None + if entry.note_path: + prior = ( + await MemoryAuditEntry.find( + MemoryAuditEntry.user_id == entry.user_id, + MemoryAuditEntry.note_path == entry.note_path, + MemoryAuditEntry.created_at < entry.created_at, + ) + .sort(-MemoryAuditEntry.created_at) + .limit(1) + .to_list() + ) + if prior: + before_text = prior[0].after_text + + base = { + "id": str(entry.id), + "note_path": entry.note_path, + "operation": entry.operation, + "cause": entry.cause, + "created_at": entry.created_at.isoformat() if entry.created_at else None, + } + + # Nothing recorded on either side (legacy entry or note-less delete_all). + if after_text is None and before_text is None: + return { + **base, + "before_text": None, + "after_text": None, + "diff": "", + "diff_available": False, + "reason": "No note content was recorded for this change.", + } + + diff = "\n".join( + difflib.unified_diff( + (before_text or "").splitlines(), + (after_text or "").splitlines(), + fromfile=f"a/{entry.note_path or 'note'}", + tofile=f"b/{entry.note_path or 'note'}", + lineterm="", + ) + ) + + return { + **base, + "before_text": before_text, + "after_text": after_text, + "diff": diff, + "diff_available": True, + } + + except Exception as e: + logger.error(f"Error building memory audit diff: {e}", exc_info=True) + return JSONResponse( + status_code=500, + content={"message": f"Error building memory audit diff: {str(e)}"}, + ) + + async def get_memories(user: User, limit: int, user_id: Optional[str] = None): """Get memories. Users see only their own memories, admins can see all or filter by user.""" try: diff --git a/backends/advanced/src/advanced_omi_backend/controllers/queue_controller.py b/backends/advanced/src/advanced_omi_backend/controllers/queue_controller.py index 1d18f1da..385c7d5e 100644 --- a/backends/advanced/src/advanced_omi_backend/controllers/queue_controller.py +++ b/backends/advanced/src/advanced_omi_backend/controllers/queue_controller.py @@ -10,23 +10,77 @@ import logging import os +import time import uuid -from datetime import datetime +from datetime import datetime, timezone from typing import Any, Dict, Optional -import redis -from rq import Queue, Worker -from rq.job import Job, JobStatus +from fastapi.responses import JSONResponse +from rq import Queue, Retry, Worker +from rq.exceptions import NoSuchJobError +from rq.job import Dependency, Job, JobStatus from rq.registry import DeferredJobRegistry, ScheduledJobRegistry +from advanced_omi_backend.config import get_misc_settings from advanced_omi_backend.config_loader import get_service_config +from advanced_omi_backend.redis_factory import create_sync_redis +from advanced_omi_backend.services.memory.audit import MemoryCause, UpdateStrategy from advanced_omi_backend.services.sse_publisher import publish_sse_event logger = logging.getLogger(__name__) -# Redis connection configuration -REDIS_URL = os.getenv("REDIS_URL", "redis://localhost:6379/0") -redis_conn = redis.from_url(REDIS_URL) +# Shared sync Redis connection for RQ (decode_responses=False, as RQ requires). +redis_conn = create_sync_redis() + + +def _as_allow_failure_dependency(depends_on): + """Wrap a job (or list of jobs) in a ``Dependency`` with ``allow_failure=True``. + + A plain ``depends_on`` leaves a dependent ``deferred`` forever when an upstream + job ends up FAILED (e.g. abandoned + retries exhausted). ``allow_failure=True`` + makes RQ *promote* the dependent on upstream failure instead — so the chain + always drains to the finalizer, which reconciles the real status. Returns + ``None`` for an empty/all-None input (enqueue with no dependency). + """ + if depends_on is None: + return None + if isinstance(depends_on, Dependency): + return depends_on + jobs = list(depends_on) if isinstance(depends_on, (list, tuple)) else [depends_on] + jobs = [j for j in jobs if j is not None] + if not jobs: + return None + return Dependency(jobs=jobs, allow_failure=True) + + +def post_conv_enqueue_kwargs(stage: str, meta: dict, depends_on=None) -> dict: + """Shared enqueue kwargs for every post-conversation chain job. + + Centralises the three things each chain job needs so the three enqueue sites + (this module, conversation_controller reprocess, enqueue_memory_processing) + can't drift: + + - ``retry``: bounded immediate re-enqueue, so a transient crash / worker death + doesn't permanently strand the job (``interval=0`` needs no rq-scheduler). + - ``on_failure``: emits a visible ``system_event`` + diagnostic breadcrumb when + the job fails or is abandoned (instead of failing silently). + - ``meta.failure_stage``: tells that callback which stage this job is. + - ``depends_on`` wrapped with ``allow_failure=True`` (see helper above). + """ + # Lazy import: job_callbacks lives in the `workers` package, whose __init__ + # imports back from this module — importing it at module top would create a + # circular import. By call time (enqueue) all modules are fully loaded. + from advanced_omi_backend.workers.job_callbacks import on_chain_job_failure + + kwargs: dict = { + "retry": Retry(max=2, interval=0), + "on_failure": on_chain_job_failure, + "meta": {**meta, "failure_stage": stage}, + } + dep = _as_allow_failure_dependency(depends_on) + if dep is not None: + kwargs["depends_on"] = dep + return kwargs def get_job_status_from_rq(job: Job) -> str: @@ -146,16 +200,16 @@ def get_job_stats() -> Dict[str, Any]: "failed_jobs": failed_jobs, "canceled_jobs": canceled_jobs, "deferred_jobs": deferred_jobs, - "timestamp": datetime.utcnow().isoformat(), + "timestamp": datetime.now(timezone.utc).isoformat(), } def get_jobs( limit: int = 20, offset: int = 0, - queue_name: str = None, - job_type: str = None, - client_id: str = None, + queue_name: Optional[str] = None, + job_type: Optional[str] = None, + client_id: Optional[str] = None, ) -> Dict[str, Any]: """ Get jobs from a specific queue or all queues with optional filtering. @@ -377,6 +431,194 @@ def is_job_complete(job): return True +# Job statuses that mean a speech-detection job is still live, so re-enqueuing +# would create a duplicate detector for the same session. +_LIVE_JOB_STATUSES = {"queued", "started", "deferred", "scheduled"} + + +def _job_is_live(job_id: str) -> bool: + """True if the given job exists in Redis and hasn't terminated.""" + try: + job = Job.fetch(job_id, connection=redis_conn) + return job.get_status(refresh=True) in _LIVE_JOB_STATUSES + except NoSuchJobError: + return False + except Exception: + return False + + +def _speech_detection_job_is_live(job_id: str) -> bool: + """True if the given speech-detection job exists and hasn't terminated.""" + return _job_is_live(job_id) + + +def enqueue_audio_persistence( + session_id: str, + user_id: str, + client_id: str, + *, + always_persist: bool, +) -> str: + """Single-flight enqueue of the per-session audio-persistence job. + + The persistence job is SESSION-scoped — one consumer for the whole + ``audio:stream:{client_id}``, surviving WebSocket reconnects. A reconnect + re-runs ``start_streaming_jobs``; if a persistence job is already live we must + NOT enqueue a second one. Two persistence jobs share the same Redis consumer + name (``persistence-{session_id[:8]}``), so each new stream message is + delivered to only one of them — the audio gets split between the jobs, and the + speech-detected conversations created after the reconnect find no chunks under + their id and get deleted as ``audio_chunks_not_ready`` while their transcripts + are stranded on a different conversation. + + The job id is deterministic per session, so liveness is checked by fetching it + directly; a short Redis mutex collapses a simultaneous reconnect burst into one + winner. Returns the live or newly-enqueued job id. + """ + # Lazy import: circular dependency with the `workers` package (its __init__ + # imports back from this module). + from advanced_omi_backend.workers.audio_jobs import audio_streaming_persistence_job + + job_id = f"audio-persist_{session_id}" + lock_key = f"audio_persistence_enqueue_lock:{session_id}" + + if _job_is_live(job_id): + logger.info( + f"⏭️ Audio persistence already live for session {session_id[:12]} " + f"({job_id}) — skipping duplicate enqueue (reconnect)" + ) + return job_id + + if not redis_conn.set(lock_key, "1", nx=True, ex=15): + logger.info( + f"⏭️ Concurrent audio-persistence enqueue in progress for session " + f"{session_id[:12]} — skipping" + ) + return job_id + try: + # Re-check liveness under the mutex (another caller may have just enqueued). + if _job_is_live(job_id): + return job_id + + audio_job = audio_queue.enqueue( + audio_streaming_persistence_job, + session_id, + user_id, + client_id, + always_persist, + job_timeout=86400, # 24 hours for all-day sessions + ttl=None, # No pre-run expiry (job can wait indefinitely in queue) + result_ttl=JOB_RESULT_TTL, # Cleanup AFTER completion + failure_ttl=86400, # Cleanup failed jobs after 24h + job_id=job_id, + description=f"Audio persistence for session {session_id}", + meta={"client_id": client_id, "session_level": True}, + ) + logger.info( + f"📥 RQ: Enqueued audio persistence job {audio_job.id} on audio queue " + f"(session {session_id[:12]})" + ) + return audio_job.id + finally: + redis_conn.delete(lock_key) + + +def enqueue_speech_detection( + session_id: str, + user_id: str, + client_id: str, + *, + reason: str = "restart", + replaces_current: bool = False, +) -> Optional[str]: + """Single-flight enqueue of the per-session speech-detection job. + + At most ONE speech-detection job may be live per session. Re-enqueuing while + one is already listening (a WebSocket reconnect, or several conversation-end + handlers firing at once) previously spawned a swarm of duplicate detectors + that raced to mark the actively-recording placeholder conversation as + ``transcription_failed``. This guards every restart path against that. + + The current live job (if any) is tracked in ``speech_detection_job:{session_id}``; + a short Redis mutex collapses a simultaneous burst of callers into one winner. + + Args: + replaces_current: set by the off-mode rotation path, where the caller IS + the current tracked job and is deliberately handing off to a successor + (so the liveness check would otherwise see itself and skip). + + Returns the live or newly-enqueued job id, or None if it could not enqueue. + """ + # Lazy import: circular dependency with the `workers` package (its __init__ + # imports back from this module). + from advanced_omi_backend.workers.transcription_jobs import ( + stream_speech_detection_job, + ) + + job_key = f"speech_detection_job:{session_id}" + lock_key = f"speech_detection_enqueue_lock:{session_id}" + + def _tracked_id() -> Optional[str]: + val = redis_conn.get(job_key) + if isinstance(val, bytes): + return val.decode() + if isinstance(val, str): + return val + return None + + if not replaces_current: + existing_id = _tracked_id() + if existing_id and _speech_detection_job_is_live(existing_id): + logger.info( + f"⏭️ Speech detection already live for session {session_id[:12]} " + f"({existing_id}) — skipping duplicate enqueue (reason={reason})" + ) + return existing_id + + # Collapse a concurrent-enqueue burst (several end handlers firing together) + # into a single winner. The loser skips; the winner records the new job in + # job_key so any later caller sees it live and also skips. + if not redis_conn.set(lock_key, "1", nx=True, ex=15): + logger.info( + f"⏭️ Concurrent speech-detection enqueue in progress for session " + f"{session_id[:12]} — skipping (reason={reason})" + ) + return _tracked_id() + try: + if not replaces_current: + # Re-check liveness under the mutex (another caller may have just enqueued). + existing_id = _tracked_id() + if existing_id and _speech_detection_job_is_live(existing_id): + logger.info( + f"⏭️ Speech detection became live for session {session_id[:12]} " + f"while acquiring lock — skipping (reason={reason})" + ) + return existing_id + + speech_job = transcription_queue.enqueue( + stream_speech_detection_job, + session_id, + user_id, + client_id, + job_timeout=86400, # 24 hours for all-day sessions + ttl=None, # No pre-run expiry (job can wait indefinitely in queue) + result_ttl=JOB_RESULT_TTL, # Cleanup AFTER completion + failure_ttl=86400, # Cleanup failed jobs after 24h + job_id=f"speech-detect_{session_id}_{uuid.uuid4().hex[:8]}", + description="Listening for speech...", + meta={"client_id": client_id, "session_level": True}, + ) + # Track the live job for both single-flight and WebSocket cleanup. + redis_conn.set(job_key, speech_job.id, ex=86400) + logger.info( + f"📥 RQ: Enqueued speech detection job {speech_job.id} for session " + f"{session_id[:12]} (reason={reason})" + ) + return speech_job.id + finally: + redis_conn.delete(lock_key) + + def start_streaming_jobs( session_id: str, user_id: str, client_id: str ) -> Dict[str, str]: @@ -399,77 +641,41 @@ def start_streaming_jobs( - user_email is fetched from the database when needed. - always_persist setting is read from global config at enqueue time and passed to worker. """ - from advanced_omi_backend.config import get_misc_settings + # Lazy import: circular dependency with the `workers` package (its __init__ + # imports back from this module). from advanced_omi_backend.workers.audio_jobs import audio_streaming_persistence_job - from advanced_omi_backend.workers.transcription_jobs import ( - stream_speech_detection_job, - ) # Read always_persist from global config NOW (backend process has fresh config) misc_settings = get_misc_settings() always_persist = misc_settings.get("always_persist_enabled", False) - # Enqueue speech detection job - speech_job = transcription_queue.enqueue( - stream_speech_detection_job, - session_id, - user_id, - client_id, - job_timeout=86400, # 24 hours for all-day sessions - ttl=None, # No pre-run expiry (job can wait indefinitely in queue) - result_ttl=JOB_RESULT_TTL, # Cleanup AFTER completion - failure_ttl=86400, # Cleanup failed jobs after 24h - job_id=f"speech-detect_{session_id}", - description=f"Listening for speech...", - meta={"client_id": client_id, "session_level": True}, - ) - # Log job enqueue with TTL information for debugging - actual_ttl = redis_conn.ttl(f"rq:job:{speech_job.id}") - logger.info(f"📥 RQ: Enqueued speech detection job {speech_job.id}") - logger.info( - f"🔍 Job enqueue details: ID={speech_job.id}, " - f"job_timeout={speech_job.timeout}, result_ttl={speech_job.result_ttl}, " - f"failure_ttl={speech_job.failure_ttl}, redis_key_ttl={actual_ttl}, " - f"queue_length={transcription_queue.count}, client_id={client_id}" - ) + # In "off" live-segmentation mode there is no live transcript to gate on, so batch + # transcription is the ONLY path to a transcript — and batch reads audio from + # MongoDB. always_persist=False would mean nothing is persisted (no speech signal + # to trigger it), so the audio would only ever exist in the soon-trimmed Redis + # stream and never get transcribed. Force persistence so off mode always has audio + # for batch; no-speech sessions are still hidden via post-batch soft-delete. + live_segmentation = misc_settings.get("live_segmentation", "streaming_stt") + if live_segmentation == "off" and not always_persist: + logger.info( + "🔒 live_segmentation=off → forcing always_persist=True so batch " + "transcription has persisted audio to read" + ) + always_persist = True - # Store job ID for cleanup (keyed by client_id for easy WebSocket cleanup) - try: - redis_conn.set( - f"speech_detection_job:{client_id}", speech_job.id, ex=86400 - ) # 24 hour TTL - logger.info(f"📌 Stored speech detection job ID for client {client_id}") - except Exception as e: - logger.warning(f"⚠️ Failed to store job ID for {client_id}: {e}") - - # Enqueue audio persistence job on dedicated audio queue - # NOTE: This job handles file rotation for multiple conversations automatically - # Runs for entire session, not tied to individual conversations - audio_job = audio_queue.enqueue( - audio_streaming_persistence_job, - session_id, - user_id, - client_id, - always_persist, - job_timeout=86400, # 24 hours for all-day sessions - ttl=None, # No pre-run expiry (job can wait indefinitely in queue) - result_ttl=JOB_RESULT_TTL, # Cleanup AFTER completion - failure_ttl=86400, # Cleanup failed jobs after 24h - job_id=f"audio-persist_{session_id}", - description=f"Audio persistence for session {session_id}", - meta={ - "client_id": client_id, - "session_level": True, - }, # Mark as session-level job + # Enqueue speech detection job (single-flight: skips if one is already live + # for this session, e.g. on a WebSocket reconnect mid-session). + speech_job_id = ( + enqueue_speech_detection(session_id, user_id, client_id, reason="session_start") + or "" ) - # Log job enqueue with TTL information for debugging - actual_ttl = redis_conn.ttl(f"rq:job:{audio_job.id}") - logger.info(f"📥 RQ: Enqueued audio persistence job {audio_job.id} on audio queue") - logger.info( - f"🔍 Job enqueue details: ID={audio_job.id}, " - f"job_timeout={audio_job.timeout}, result_ttl={audio_job.result_ttl}, " - f"failure_ttl={audio_job.failure_ttl}, redis_key_ttl={actual_ttl}, " - f"queue_length={audio_queue.count}, client_id={client_id}" + + # Enqueue audio persistence job on dedicated audio queue (single-flight: a + # reconnect mid-session reuses the live job instead of starting a second + # consumer that would split the audio stream). This job handles file rotation + # for multiple conversations and runs for the entire session. + audio_job_id = enqueue_audio_persistence( + session_id, user_id, client_id, always_persist=always_persist ) # Notify frontend that streaming jobs are queued @@ -483,7 +689,99 @@ def start_streaming_jobs( }, ) - return {"speech_detection": speech_job.id, "audio_persistence": audio_job.id} + return {"speech_detection": speech_job_id, "audio_persistence": audio_job_id} + + +def _clear_post_conversation_chain(conversation_id: str) -> list: + """Delete any existing post-conversation chain jobs for a conversation. + + The post-conversation jobs (speaker → memory → title/summary → event) use + deterministic job_ids keyed on the conversation. When the chain is + re-triggered (e.g. a transcript reprocess) while a previous chain is still + ``deferred``, re-enqueuing the same job_id with a *new* ``depends_on`` makes + RQ **accumulate** dependencies on the existing deferred job rather than + replacing it. If one upstream dependency then finishes and is evicted from + Redis before the others resolve, RQ never promotes the dependents and the + whole chain stays ``deferred`` forever (orphaned). + + Deleting the stale chain first guarantees each re-enqueue starts fresh with + a single, correct dependency. Jobs that are currently ``started`` are left + alone so we never yank work out from under a running worker. + + Returns the list of job_ids that were actually deleted (for logging). + """ + suffix = conversation_id[:12] + job_ids = [ + f"speaker_{suffix}", + f"memory_{suffix}", + f"title_summary_{suffix}", + f"event_complete_{suffix}", + ] + cleared = [] + for job_id in job_ids: + try: + job = Job.fetch(job_id, connection=redis_conn) + except NoSuchJobError: + continue + if get_job_status_from_rq(job) == JobStatus.STARTED.value: + logger.warning( + f"⚠️ Not clearing post-conversation job {job_id} for " + f"{conversation_id[:8]}: currently running" + ) + continue + try: + job.delete(remove_from_queue=True) + cleared.append(job_id) + except Exception as e: + logger.error(f"Failed to delete stale chain job {job_id}: {e}") + if cleared: + logger.info( + f"🧹 Cleared {len(cleared)} stale post-conversation job(s) for " + f"{conversation_id[:8]}: {cleared}" + ) + return cleared + + +# Statuses that mean a job is still occupying the chain (not yet terminal). +_IN_FLIGHT_JOB_STATUSES = frozenset( + { + JobStatus.QUEUED.value, + JobStatus.STARTED.value, + JobStatus.DEFERRED.value, + JobStatus.SCHEDULED.value, + } +) + + +def conversation_edit_chain_in_flight(conversation_id: str) -> Optional[str]: + """Return an in-flight edit-chain job_id for this conversation, else None. + + Several endpoints edit a conversation by creating a new transcript version and + enqueuing follow-up work under deterministic job_ids keyed on the conversation: + + - ``reprocess_speakers`` → reprocess_speaker → memory → title_summary + - annotation apply (``/diarization/{id}/apply``, ``/{id}/apply``) → memory + + Firing any of these again while a previous one is still running spawns overlapping + work that races on the conversation's full-document ``save()`` — a stale writer can + clobber a newer version's segments/metadata (e.g. lost speaker labels, an orphaned + stale ``active`` version). Callers use this as a single-flight guard: if an edit + chain is already live, don't create a new transcript version or enqueue more work. + """ + suffix = conversation_id[:12] + job_ids = [ + f"reprocess_speaker_{suffix}", + f"memory_{suffix}", + f"title_summary_{suffix}", + ] + for job_id in job_ids: + try: + job = Job.fetch(job_id, connection=redis_conn) + except NoSuchJobError: + continue + if get_job_status_from_rq(job) in _IN_FLIGHT_JOB_STATUSES: + return job_id + return None def start_post_conversation_jobs( @@ -493,6 +791,9 @@ def start_post_conversation_jobs( depends_on_job=None, client_id: Optional[str] = None, end_reason: str = "file_upload", + skip_speaker_recognition: bool = False, + memory_cause: MemoryCause = MemoryCause.AUTO_EXTRACTION, + memory_strategy: UpdateStrategy = UpdateStrategy.FULL, ) -> Dict[str, str]: """ Start post-conversation processing jobs after conversation is created. @@ -513,10 +814,14 @@ def start_post_conversation_jobs( depends_on_job: Optional job dependency for first job (e.g., transcription for file uploads) client_id: Client ID for UI tracking end_reason: Reason conversation ended (e.g., 'file_upload', 'websocket_disconnect', 'user_stopped') + skip_speaker_recognition: Skip the speaker step even when enabled — used + by split/merge, whose transcripts already carry speaker labels Returns: Dict with job IDs for speaker_recognition, memory, title_summary, event_dispatch """ + # Lazy import: circular dependency with the `workers` package (its __init__ + # imports back from this module). from advanced_omi_backend.workers.conversation_jobs import ( dispatch_conversation_complete_event_job, generate_title_summary_job, @@ -524,6 +829,11 @@ def start_post_conversation_jobs( from advanced_omi_backend.workers.memory_jobs import process_memory_job from advanced_omi_backend.workers.speaker_jobs import recognise_speakers_job + # Re-triggering the chain (e.g. transcript reprocess) must not stack new + # dependencies onto a previously-deferred chain — that orphans it forever. + # Clear any stale chain jobs first so each enqueue below starts fresh. + _clear_post_conversation_chain(conversation_id) + version_id = transcript_version_id or str(uuid.uuid4()) # Build job metadata (include client_id if provided for UI tracking) @@ -543,6 +853,12 @@ def start_post_conversation_jobs( ) speaker_job = None + if speaker_enabled and skip_speaker_recognition: + logger.info( + f"⏭️ Speaker recognition skipped by caller for conversation {conversation_id[:8]}" + ) + speaker_enabled = False + if speaker_enabled: speaker_job_id = f"speaker_{conversation_id[:12]}" logger.info( @@ -555,10 +871,11 @@ def start_post_conversation_jobs( version_id, job_timeout=1200, # 20 minutes result_ttl=JOB_RESULT_TTL, - depends_on=speaker_dependency, job_id=speaker_job_id, description=f"Speaker recognition for conversation {conversation_id[:8]}", - meta=job_meta, + **post_conv_enqueue_kwargs( + "speaker", job_meta, depends_on=speaker_dependency + ), ) speaker_dependency = speaker_job # Chain for next jobs if depends_on_job: @@ -589,15 +906,23 @@ def start_post_conversation_jobs( f"🔍 DEBUG: Creating memory job with job_id={memory_job_id}, conversation_id={conversation_id[:12]}" ) + # Memory job carries provenance (cause/strategy) on top of the shared meta. + memory_meta = { + **job_meta, + "cause": memory_cause.value, + "strategy": memory_strategy.value, + } memory_job = memory_queue.enqueue( process_memory_job, conversation_id, job_timeout=900, # 15 minutes result_ttl=JOB_RESULT_TTL, - depends_on=speaker_dependency, # Either speaker_job or upstream dependency job_id=memory_job_id, description=f"Memory extraction for conversation {conversation_id[:8]}", - meta=job_meta, + # Either speaker_job or upstream dependency + **post_conv_enqueue_kwargs( + "memory", memory_meta, depends_on=speaker_dependency + ), ) if speaker_job: logger.info( @@ -630,10 +955,11 @@ def start_post_conversation_jobs( conversation_id, job_timeout=300, # 5 minutes result_ttl=JOB_RESULT_TTL, - depends_on=title_dependency, job_id=title_job_id, description=f"Generate title and summary for conversation {conversation_id[:8]}", - meta=job_meta, + **post_conv_enqueue_kwargs( + "title_summary", job_meta, depends_on=title_dependency + ), ) if memory_job: logger.info( @@ -676,12 +1002,13 @@ def start_post_conversation_jobs( end_reason, # Use the end_reason parameter (defaults to 'file_upload' for backward compatibility) job_timeout=120, # 2 minutes result_ttl=JOB_RESULT_TTL, - depends_on=( - event_dependencies if event_dependencies else None - ), # Wait for jobs that were enqueued job_id=event_job_id, description=f"Dispatch conversation complete event ({end_reason}) for {conversation_id[:8]}", - meta=job_meta, + # Wait for whichever upstream jobs were enqueued; allow_failure so this + # finalizer still runs (and reconciles status) even if one failed. + **post_conv_enqueue_kwargs( + "event_complete", job_meta, depends_on=event_dependencies or None + ), ) # Log event dispatch dependencies @@ -781,10 +1108,6 @@ def get_queue_health() -> Dict[str, Any]: # needs tidying but works for now async def cleanup_stuck_stream_workers(request): """Clean up stuck Redis Stream consumers and pending messages from all active streams.""" - import time - - from fastapi.responses import JSONResponse - try: # Get Redis client from request.app.state (initialized during startup) redis_client = request.app.state.redis_audio_stream diff --git a/backends/advanced/src/advanced_omi_backend/controllers/session_controller.py b/backends/advanced/src/advanced_omi_backend/controllers/session_controller.py index 39a1c788..fdcd0275 100644 --- a/backends/advanced/src/advanced_omi_backend/controllers/session_controller.py +++ b/backends/advanced/src/advanced_omi_backend/controllers/session_controller.py @@ -9,254 +9,54 @@ import logging import time -from typing import Dict, List, Literal, Optional from fastapi.responses import JSONResponse +from advanced_omi_backend.controllers.queue_controller import ( + all_jobs_complete_for_client, + default_queue, + memory_queue, + transcription_queue, +) +from advanced_omi_backend.services.audio_stream.session_store import ( + SessionStatus, + SessionStore, + SessionView, +) + logger = logging.getLogger(__name__) -async def mark_session_complete( - redis_client, - session_id: str, - reason: Literal[ - "websocket_disconnect", - "user_stopped", - "inactivity_timeout", - "max_duration", - "all_jobs_complete", - ], -) -> None: - """ - Single source of truth for marking sessions as complete. - - This function ensures that both 'status' and 'completion_reason' are ALWAYS - set together atomically, preventing race conditions where workers check status - before completion_reason is set. - - Args: - redis_client: Redis async client - session_id: Session UUID - reason: Why the session is completing (enforced by type system) - - Usage: - # WebSocket disconnect - await mark_session_complete(redis, session_id, "websocket_disconnect") - - # User manually stopped - await mark_session_complete(redis, session_id, "user_stopped") - - # Inactivity timeout - await mark_session_complete(redis, session_id, "inactivity_timeout") - - # Max duration reached - await mark_session_complete(redis, session_id, "max_duration") - - # All jobs finished - await mark_session_complete(redis, session_id, "all_jobs_complete") - """ - session_key = f"audio:session:{session_id}" - mark_time = time.time() - mapping = { - "status": "finished", - "completed_at": str(mark_time), - "completion_reason": reason, +def _session_info_dict(view: SessionView, conversation_count: int) -> dict: + """Shape a SessionView into the session-info response dict used by the API.""" + now = time.time() + return { + "session_id": view.session_id, + "user_id": view.user_id, + "client_id": view.client_id, + "provider": view.provider, + "mode": view.mode, + "status": view.status.value if view.status else "", + "websocket_connected": view.websocket_connected, + "completion_reason": view.completion_reason, + "chunks_published": view.chunks_published, + "started_at": view.started_at, + "last_chunk_at": view.last_chunk_at, + "age_seconds": now - view.started_at, + "idle_seconds": now - view.last_chunk_at, + "conversation_count": conversation_count, + # Speech detection events + "last_event": view.last_event, + "speech_detected_at": view.speech_detected_at, + "speaker_check_status": ( + view.speaker_check_status.value if view.speaker_check_status else "" + ), + "identified_speakers": ",".join(view.identified_speakers), } - if reason == "websocket_disconnect": - mapping["websocket_connected"] = "false" - await redis_client.hset( - session_key, - mapping=mapping, - ) - logger.info( - f"✅ Session {session_id[:12]} marked finished: {reason} [TIME: {mark_time:.3f}]" - ) - - -async def request_conversation_close( - redis_client, - session_id: str, - reason: str = "user_requested", -) -> bool: - """ - Request closing the current conversation without killing the session. - - Unlike mark_session_complete() which finalizes the entire session, - this signals open_conversation_job to close just the current conversation - and trigger post-processing. The session stays active for new conversations. - - Sets 'conversation_close_requested' field on the session hash. - The open_conversation_job checks this field every poll iteration. - - Args: - redis_client: Redis async client - session_id: Session UUID - reason: Why the conversation is being closed - - Returns: - True if the close request was set, False if session not found - """ - session_key = f"audio:session:{session_id}" - if not await redis_client.exists(session_key): - return False - await redis_client.hset(session_key, "conversation_close_requested", reason) - logger.info( - f"🔒 Conversation close requested for session {session_id[:12]}: {reason}" - ) - return True - - -async def get_session_info(redis_client, session_id: str) -> Optional[Dict]: - """ - Get detailed information about a specific session. - - Args: - redis_client: Redis async client - session_id: Session UUID - - Returns: - Dict with session information or None if not found - """ - try: - session_key = f"audio:session:{session_id}" - session_data = await redis_client.hgetall(session_key) - - if not session_data: - return None - - # Get conversation count for this session - conversation_count_key = f"session:conversation_count:{session_id}" - conversation_count_bytes = await redis_client.get(conversation_count_key) - conversation_count = ( - int(conversation_count_bytes.decode()) if conversation_count_bytes else 0 - ) - - started_at = float(session_data.get(b"started_at", b"0")) - last_chunk_at = float(session_data.get(b"last_chunk_at", b"0")) - - return { - "session_id": session_id, - "user_id": session_data.get(b"user_id", b"").decode(), - "client_id": session_data.get(b"client_id", b"").decode(), - "provider": session_data.get(b"provider", b"").decode(), - "mode": session_data.get(b"mode", b"").decode(), - "status": session_data.get(b"status", b"").decode(), - "websocket_connected": session_data.get( - b"websocket_connected", b"false" - ).decode() - == "true", - "completion_reason": session_data.get(b"completion_reason", b"").decode(), - "chunks_published": int(session_data.get(b"chunks_published", b"0")), - "started_at": started_at, - "last_chunk_at": last_chunk_at, - "age_seconds": time.time() - started_at, - "idle_seconds": time.time() - last_chunk_at, - "conversation_count": conversation_count, - # Speech detection events - "last_event": session_data.get(b"last_event", b"").decode(), - "speech_detected_at": session_data.get(b"speech_detected_at", b"").decode(), - "speaker_check_status": session_data.get( - b"speaker_check_status", b"" - ).decode(), - "identified_speakers": session_data.get( - b"identified_speakers", b"" - ).decode(), - } - - except Exception as e: - logger.error(f"Error getting session info for {session_id}: {e}") - return None - - -async def get_all_sessions(redis_client, limit: int = 100) -> List[Dict]: - """ - Get information about all active sessions. - - Args: - redis_client: Redis async client - limit: Maximum number of sessions to return - - Returns: - List of session info dictionaries - """ - try: - # Get all session keys - session_keys = [] - cursor = b"0" - while cursor and len(session_keys) < limit: - cursor, keys = await redis_client.scan( - cursor, match="audio:session:*", count=limit - ) - session_keys.extend(keys[: limit - len(session_keys)]) - - # Get info for each session - sessions = [] - for key in session_keys: - session_id = key.decode().replace("audio:session:", "") - session_info = await get_session_info(redis_client, session_id) - if session_info: - sessions.append(session_info) - - return sessions - - except Exception as e: - logger.error(f"Error getting all sessions: {e}") - return [] - - -async def get_session_conversation_count(redis_client, session_id: str) -> int: - """ - Get the conversation count for a specific session. - - Args: - redis_client: Redis async client - session_id: Session UUID - - Returns: - Number of conversations created in this session - """ - try: - conversation_count_key = f"session:conversation_count:{session_id}" - conversation_count_bytes = await redis_client.get(conversation_count_key) - return int(conversation_count_bytes.decode()) if conversation_count_bytes else 0 - except Exception as e: - logger.error(f"Error getting conversation count for session {session_id}: {e}") - return 0 - - -async def increment_session_conversation_count(redis_client, session_id: str) -> int: - """ - Increment and return the conversation count for a session. - - Args: - redis_client: Redis async client - session_id: Session UUID - - Returns: - New conversation count - """ - try: - conversation_count_key = f"session:conversation_count:{session_id}" - count = await redis_client.incr(conversation_count_key) - await redis_client.expire(conversation_count_key, 3600) # 1 hour TTL - logger.info(f"📊 Conversation count for session {session_id}: {count}") - return count - except Exception as e: - logger.error( - f"Error incrementing conversation count for session {session_id}: {e}" - ) - return 0 async def get_streaming_status(request): """Get status of active streaming sessions and Redis Streams health.""" - from advanced_omi_backend.controllers.queue_controller import ( - all_jobs_complete_for_client, - default_queue, - memory_queue, - transcription_queue, - ) - try: # Get Redis client from request.app.state (initialized during startup) redis_client = request.app.state.redis_audio_stream @@ -268,23 +68,17 @@ async def get_streaming_status(request): ) # Get all sessions (both active and completed) - session_keys = await redis_client.keys("audio:session:*") + store = SessionStore(redis_client) active_sessions = [] completed_sessions_from_redis = [] - for key in session_keys: - session_id = key.decode().split(":")[-1] - - # Use session_controller to get complete session info including conversation_count - session_obj = await get_session_info(redis_client, session_id) - if not session_obj: - continue - - status = session_obj.get("status", "") + async for view in store.iter_views(): + conversation_count = await store.get_conversation_count(view.session_id) + session_obj = _session_info_dict(view, conversation_count) # Separate active and completed sessions # Check if all jobs are complete (including failed jobs) - all_jobs_done = all_jobs_complete_for_client(session_obj.get("client_id")) + all_jobs_done = all_jobs_complete_for_client(view.client_id) # Session is completed ONLY when: # 1. Status was already set to "finished" by an authoritative source @@ -295,40 +89,13 @@ async def get_streaming_status(request): # (after open_conversation_job finishes, before speech detection restarts), # all jobs are briefly terminal. Writing "finished" during this gap kills # the session permanently. - if status == "finished" and all_jobs_done: - # Get additional session data for completed sessions - session_key = f"audio:session:{session_id}" - session_data = await redis_client.hgetall(session_key) - + if view.status == SessionStatus.FINISHED and all_jobs_done: completed_sessions_from_redis.append( { - "session_id": session_id, - "client_id": session_obj.get("client_id", ""), - "conversation_id": ( - session_data.get(b"conversation_id", b"").decode() - if session_data and b"conversation_id" in session_data - else None - ), - "has_conversation": bool( - session_data and session_data.get(b"conversation_id", b"") - ), - "action": ( - session_data.get(b"action", b"finished").decode() - if session_data and b"action" in session_data - else "finished" - ), - "reason": ( - session_data.get(b"reason", b"").decode() - if session_data and b"reason" in session_data - else "" - ), - "completed_at": session_obj.get("last_chunk_at", 0), - "audio_file": ( - session_data.get(b"audio_file", b"").decode() - if session_data and b"audio_file" in session_data - else "" - ), - "conversation_count": session_obj.get("conversation_count", 0), + "session_id": view.session_id, + "client_id": view.client_id, + "completed_at": view.completed_at or view.last_chunk_at, + "conversation_count": conversation_count, } ) else: @@ -594,10 +361,6 @@ async def get_streaming_status(request): async def cleanup_old_sessions(request, max_age_seconds: int = 3600): """Clean up old session tracking metadata and old audio streams from Redis.""" - import time - - from fastapi.responses import JSONResponse - try: # Get Redis client from request.app.state (initialized during startup) redis_client = request.app.state.redis_audio_stream @@ -608,38 +371,30 @@ async def cleanup_old_sessions(request, max_age_seconds: int = 3600): content={"error": "Redis client for audio streaming not initialized"}, ) - # Get all session keys - session_keys = await redis_client.keys("audio:session:*") + # Clean up old session hashes + store = SessionStore(redis_client) cleaned_sessions = 0 old_sessions = [] current_time = time.time() - for key in session_keys: - session_data = await redis_client.hgetall(key) - if not session_data: - continue - - session_id = key.decode().split(":")[-1] - started_at = float(session_data.get(b"started_at", b"0")) - status = session_data.get(b"status", b"").decode() - - age_seconds = current_time - started_at + async for view in store.iter_views(): + age_seconds = current_time - view.started_at # Clean up sessions older than max_age or stuck in "finalizing" should_clean = age_seconds > max_age_seconds or ( - status == "finalizing" and age_seconds > 300 + view.status == SessionStatus.FINALIZING and age_seconds > 300 ) # Finalizing for more than 5 minutes if should_clean: old_sessions.append( { - "session_id": session_id, + "session_id": view.session_id, "age_seconds": age_seconds, - "status": status, + "status": view.status.value if view.status else "", } ) - await redis_client.delete(key) + await store.delete(view.session_id) cleaned_sessions += 1 # Also clean up old audio streams (per-client streams that are inactive) diff --git a/backends/advanced/src/advanced_omi_backend/controllers/system_controller.py b/backends/advanced/src/advanced_omi_backend/controllers/system_controller.py index dc51d061..e3e31270 100644 --- a/backends/advanced/src/advanced_omi_backend/controllers/system_controller.py +++ b/backends/advanced/src/advanced_omi_backend/controllers/system_controller.py @@ -4,6 +4,7 @@ import asyncio import inspect +import json import logging import os import re @@ -15,31 +16,165 @@ from io import StringIO from pathlib import Path from typing import Optional +from urllib.parse import urlparse +import httpx from dotenv import set_key as dotenv_set_key from fastapi import HTTPException from ruamel.yaml import YAML +from advanced_omi_backend.client_manager import get_client_manager +from advanced_omi_backend.config import CleanupSettings, get_cleanup_settings from advanced_omi_backend.config import ( get_diarization_settings as load_diarization_settings, ) from advanced_omi_backend.config import get_misc_settings as load_misc_settings -from advanced_omi_backend.config import save_diarization_settings, save_misc_settings -from advanced_omi_backend.config_loader import get_plugins_yml_path, save_config_section +from advanced_omi_backend.config import ( + save_cleanup_settings, + save_diarization_settings, + save_misc_settings, +) +from advanced_omi_backend.config_loader import ( + get_backend_config, + get_plugins_yml_path, + get_raw_models, + load_config, + save_config_section, + save_models_list, +) +from advanced_omi_backend.controllers import client_controller from advanced_omi_backend.model_registry import ( + ModelDef, _find_config_path, get_models_registry, load_models_config, ) from advanced_omi_backend.models.user import User +from advanced_omi_backend.observability.otel_setup import is_langfuse_enabled +from advanced_omi_backend.openai_factory import create_openai_client +from advanced_omi_backend.services.memory import get_memory_service +from advanced_omi_backend.services.plugin_service import ( + _get_plugins_dir, + discover_plugins, + expand_env_vars, + get_plugin_metadata, + load_plugin_env, + reload_plugins, + save_plugin_env, + signal_worker_restart, +) +from advanced_omi_backend.speaker_recognition_client import SpeakerRecognitionClient logger = logging.getLogger(__name__) audio_logger = logging.getLogger("audio_processing") + +async def get_network_discovery(app, current_user=None): + """Return Tailscale status and discovered minidisc services. + + The *app* parameter is the FastAPI application instance (kept for API + compatibility but no longer used — the node agent handles advertising). + """ + + result = { + "tailscale_available": False, + "advertising": [], + "discovered_services": [], + } + + try: + # Lazy import: optional external module (edge/discovery, resolved via a + # sys.path arrangement) that may be absent; guarded by the ImportError below. + from discovery import is_tailscale_available, list_all_services + except ImportError: + result["error"] = "discovery module not available" + return result + + result["tailscale_available"] = is_tailscale_available() + + # Read advertised services written by the node agent (edge/service_manager.py). + # The file is at config/advertised-services.json (volume-mounted from repo root). + _advertised_path = Path("/app/config/advertised-services.json") + if _advertised_path.exists(): + try: + result["advertising"] = json.loads(_advertised_path.read_text()) + except (json.JSONDecodeError, OSError) as exc: + logger.warning("Could not read advertised-services.json: %s", exc) + + if not result["tailscale_available"]: + return result + + # Discover all chronicle-* services on the Tailnet via list_all_services() + loop = asyncio.get_running_loop() + all_services = await loop.run_in_executor(None, list_all_services) + + async def _health_check(svc: dict): + name = svc["name"] + address = svc.get("address", "") + port = svc.get("port", 0) + labels = svc.get("labels", {}) + host = labels.get("host", address) + + url = f"http://{address}:{port}" if address and port else None + reachable = False + if url: + try: + async with httpx.AsyncClient(timeout=3.0) as client: + resp = await client.get(f"{url}/health") + reachable = resp.status_code < 500 + except Exception: + pass + return { + "name": name, + "url": url, + "reachable": reachable, + "labels": labels, + "host": host, + } + + if all_services: + discovered = await asyncio.gather(*[_health_check(svc) for svc in all_services]) + result["discovered_services"] = list(discovered) + else: + result["discovered_services"] = [] + + # Connected WebSocket clients (phones, relays, etc.) + # Devices are the user's *remembered* devices (the registry) joined with live + # connection state — so a known device shows whether it's online now or when it was + # last seen, with its editable friendly name. "connected" is derived from real + # activity (the live ClientState's last_activity), never a persisted flag. + mgr = get_client_manager() + if current_user is not None: + devices = (await client_controller.list_devices(current_user, mgr))["devices"] + else: + devices = [] + result["connected_devices"] = devices + + return result + + _yaml = YAML() _yaml.preserve_quotes = True +def _is_self_hosted_model(model) -> bool: + """Whether a model entry points at a self-hosted service (no API key needed). + + Cloud providers (Deepgram, OpenAI, smallest.ai, ...) live on public domains; + self-hosted services are reached via localhost, docker hostnames, private/ + tailnet IPs, or tailnet DNS names. + """ + host = urlparse(str(getattr(model, "model_url", "") or "")).hostname or "" + if not host: + return False + if host in ("localhost", "host.docker.internal"): + return True + if re.match(r"^(127\.|10\.|172\.|192\.168\.|100\.)", host): + return True + # Tailnet DNS names, or bare docker-compose service names (no dots) + return host.endswith(".ts.net") or "." not in host + + async def get_config_diagnostics(): """ Get comprehensive configuration diagnostics. @@ -57,8 +192,6 @@ async def get_config_diagnostics(): # Test OmegaConf configuration loading try: - from advanced_omi_backend.config_loader import load_config - # Capture warnings during config load with warnings.catch_warnings(record=True) as w: warnings.simplefilter("always") @@ -101,8 +234,6 @@ async def get_config_diagnostics(): # Test model registry try: - from advanced_omi_backend.model_registry import get_models_registry - with warnings.catch_warnings(record=True) as w: warnings.simplefilter("always") registry = get_models_registry() @@ -143,6 +274,13 @@ async def get_config_diagnostics(): "message": f"Configured: {stt.name} ({stt.model_provider}) - API key present", } ) + elif _is_self_hosted_model(stt): + diagnostics["info"].append( + { + "component": "STT (Batch)", + "message": f"Configured: {stt.name} ({stt.model_provider}) - local service, no API key required", + } + ) else: diagnostics["warnings"].append( { @@ -172,6 +310,13 @@ async def get_config_diagnostics(): "message": f"Configured: {stt_stream.name} ({stt_stream.model_provider}) - API key present", } ) + elif _is_self_hosted_model(stt_stream): + diagnostics["info"].append( + { + "component": "STT (Streaming)", + "message": f"Configured: {stt_stream.name} ({stt_stream.model_provider}) - local service, no API key required", + } + ) else: diagnostics["warnings"].append( { @@ -200,6 +345,13 @@ async def get_config_diagnostics(): "message": f"Configured: {llm.name} ({llm.model_provider}) - API key present", } ) + elif _is_self_hosted_model(llm): + diagnostics["info"].append( + { + "component": "LLM", + "message": f"Configured: {llm.name} ({llm.model_provider}) - local service, no API key required", + } + ) else: diagnostics["warnings"].append( { @@ -322,14 +474,10 @@ async def get_observability_config(): Returns non-secret data only (enabled status and browser URL). """ - from advanced_omi_backend.observability.otel_setup import is_langfuse_enabled - enabled = is_langfuse_enabled() session_base_url = None if enabled: - from advanced_omi_backend.config_loader import load_config - cfg = load_config() public_url = ( cfg.get("observability", {}).get("langfuse", {}).get("public_url", "") @@ -391,10 +539,10 @@ async def save_diarization_settings_controller(settings: dict): detail=f"Invalid value for {key}: must be integer 1-20", ) elif key == "diarization_source": - if not isinstance(value, str) or value not in ["pyannote", "deepgram"]: + if not isinstance(value, str) or value not in ["pyannote", "provider"]: raise HTTPException( status_code=400, - detail=f"Invalid value for {key}: must be 'pyannote' or 'deepgram'", + detail=f"Invalid value for {key}: must be 'pyannote' or 'provider'", ) else: if not isinstance(value, (int, float)) or value < 0: @@ -437,6 +585,98 @@ async def save_diarization_settings_controller(settings: dict): raise e +# --------------------------------------------------------------------------- +# ASR context / hint-mechanism settings +# +# Each STT provider consumes recognition hints in exactly one way (see +# ModelDef.capabilities): "keyword_boosting" (acoustic hot-word boost, never +# echoed) or "context_prompt" (LLM context that informs but must not be echoed). +# context_prompt providers (e.g. Gemma 4) are NOT given the wake-word boost list; +# instead the user authors a free-form context string, stored per-model under +# backend.asr.context. in config.yml. +# --------------------------------------------------------------------------- + + +def _asr_hint_type(capabilities) -> str: + caps = set(capabilities or []) + if "context_prompt" in caps: + return "context_prompt" + if "keyword_boosting" in caps: + return "keyword_boosting" + return "none" + + +def _asr_model_info(model) -> Optional[dict]: + """Summarise an STT model's hint mechanism + resolved context for the UI.""" + if not model: + return None + asr_cfg = get_backend_config("asr") or {} + ctx_map = asr_cfg.get("context", {}) or {} + override = ctx_map.get(model.name) + inline = getattr(model, "asr_context", None) + context = override if override is not None else (inline or "") + return { + "name": model.name, + "provider": model.model_provider, + "description": model.description, + "capabilities": list(model.capabilities or []), + "hint_type": _asr_hint_type(model.capabilities), + "context": context or "", + } + + +async def get_asr_context_config(): + """Return the active batch + streaming STT provider hint mechanisms.""" + registry = get_models_registry() + if not registry: + raise HTTPException(status_code=503, detail="Model registry unavailable") + return { + "batch": _asr_model_info(registry.get_default("stt")), + "stream": _asr_model_info(registry.get_default("stt_stream")), + "status": "success", + } + + +async def save_asr_context_controller(payload: dict): + """Persist a context string for a context_prompt STT provider.""" + model_name = (payload.get("model_name") or "").strip() + context = payload.get("context", "") + if not model_name: + raise HTTPException(status_code=400, detail="model_name is required") + if not isinstance(context, str): + raise HTTPException(status_code=400, detail="context must be a string") + + registry = get_models_registry() + model = registry.get_by_name(model_name) if registry else None + if not model: + raise HTTPException(status_code=404, detail=f"Unknown model '{model_name}'") + if "context_prompt" not in set(model.capabilities or []): + raise HTTPException( + status_code=400, + detail=( + f"Model '{model_name}' does not use a context prompt; ASR context " + "only applies to context_prompt providers." + ), + ) + + if not save_config_section("backend.asr.context", {model_name: context.strip()}): + raise HTTPException(status_code=500, detail="Failed to save ASR context") + + # Refresh the in-process registry and signal workers so the new context is + # picked up on the next transcription (same pattern as a provider switch). + load_models_config(force_reload=True) + try: + signal_worker_restart() + except Exception as e: + logger.warning(f"Could not signal worker restart after ASR context save: {e}") + + return { + "status": "success", + "model_name": model_name, + "context": context.strip(), + } + + async def get_misc_settings(): """Get current miscellaneous settings.""" try: @@ -454,7 +694,6 @@ async def save_misc_settings_controller(settings: dict): # Validate settings boolean_keys = { "always_persist_enabled", - "use_provider_segments", "per_segment_speaker_id", "always_batch_retranscribe", } @@ -462,7 +701,13 @@ async def save_misc_settings_controller(settings: dict): "streaming_fallback_timeout_seconds", "max_conversation_duration_seconds", } - valid_keys = boolean_keys | integer_keys + # Live-transcription mode selector (top-level defaults.live_segmentation). + # "windowed_batch" = pseudo-streaming via batch preview; "off" disables the + # live preview; "streaming_stt" uses a real streaming ASR provider. + enum_keys = { + "live_segmentation": {"streaming_stt", "windowed_batch", "off"}, + } + valid_keys = boolean_keys | integer_keys | set(enum_keys) # Filter to only valid keys filtered_settings = {} @@ -477,6 +722,13 @@ async def save_misc_settings_controller(settings: dict): status_code=400, detail=f"Invalid value for {key}: must be boolean", ) + elif key in enum_keys: + if value not in enum_keys[key]: + allowed = ", ".join(sorted(enum_keys[key])) + raise HTTPException( + status_code=400, + detail=f"Invalid value for {key}: must be one of {allowed}", + ) elif key == "streaming_fallback_timeout_seconds": if not isinstance(value, int) or value < 60 or value > 7200: raise HTTPException( @@ -534,8 +786,6 @@ async def get_cleanup_settings_controller(user: User) -> dict: Returns: Dict with cleanup settings """ - from advanced_omi_backend.config import get_cleanup_settings - return get_cleanup_settings() @@ -556,8 +806,6 @@ async def save_cleanup_settings_controller( Raises: ValueError: If validation fails """ - from advanced_omi_backend.config import CleanupSettings, save_cleanup_settings - # Validation if not isinstance(auto_cleanup_enabled, bool): raise ValueError("auto_cleanup_enabled must be a boolean") @@ -640,13 +888,64 @@ async def update_speaker_configuration(user: User, primary_speakers: list[dict]) raise e -async def get_enrolled_speakers(user: User): - """Get enrolled speakers from speaker recognition service.""" +async def get_wakeword_speaker_gate(user: User): + """Get current user's wake-word speaker gate configuration.""" try: - from advanced_omi_backend.speaker_recognition_client import ( - SpeakerRecognitionClient, + return { + "enabled": user.wakeword_gate_enabled, + "speakers": user.wakeword_allowed_speakers, + "user_id": user.user_id, + "status": "success", + } + except Exception: + logger.exception( + f"Error getting wake-word speaker gate for user {user.user_id}" ) + raise + + +async def update_wakeword_speaker_gate(user: User, enabled: bool, speakers: list[dict]): + """Update current user's wake-word speaker gate configuration. + + When ``enabled`` and at least one speaker is selected, an acoustic wake word + only dispatches a command if one of these speakers is recognized in the + captured turn (see the wake-word dispatcher's speaker gate). + """ + try: + # Keep only the fields we rely on for matching, mirroring primary_speakers. + for speaker in speakers: + if not isinstance(speaker, dict): + raise ValueError("Each speaker must be a dictionary") + if "speaker_id" not in speaker or "name" not in speaker: + raise ValueError("Each speaker needs 'speaker_id' and 'name'") + cleaned = [{"speaker_id": s["speaker_id"], "name": s["name"]} for s in speakers] + user.wakeword_gate_enabled = bool(enabled) + user.wakeword_allowed_speakers = cleaned + await user.save() + + logger.info( + f"Updated wake-word speaker gate for user {user.user_id}: " + f"enabled={enabled}, speakers={len(cleaned)}" + ) + + return { + "message": "Wake-word speaker gate updated successfully", + "enabled": user.wakeword_gate_enabled, + "speakers": cleaned, + "count": len(cleaned), + "status": "success", + } + except Exception: + logger.exception( + f"Error updating wake-word speaker gate for user {user.user_id}" + ) + raise + + +async def get_enrolled_speakers(user: User): + """Get enrolled speakers from speaker recognition service.""" + try: # Initialize speaker recognition client speaker_client = SpeakerRecognitionClient() @@ -676,10 +975,6 @@ async def get_enrolled_speakers(user: User): async def get_speaker_service_status(): """Check speaker recognition service health status.""" try: - from advanced_omi_backend.speaker_recognition_client import ( - SpeakerRecognitionClient, - ) - # Initialize speaker recognition client speaker_client = SpeakerRecognitionClient() @@ -826,8 +1121,6 @@ async def reload_memory_config(): async def delete_all_user_memories(user: User): """Delete all memories for the current user.""" try: - from advanced_omi_backend.services.memory import get_memory_service - memory_service = get_memory_service() # Delete all memories for the user @@ -858,8 +1151,8 @@ async def get_memory_provider(): if current_provider in ("friend-lite", "friend_lite"): current_provider = "chronicle" - # Get available providers - available_providers = ["chronicle", "openmemory_mcp"] + # Chronicle (agentic vault) is currently the only provider. + available_providers = ["chronicle"] return { "current_provider": current_provider, @@ -875,9 +1168,9 @@ async def get_memory_provider(): async def set_memory_provider(provider: str): """Set memory provider and update .env file.""" try: - # Validate provider + # Validate provider. Chronicle (agentic vault) is currently the only provider. provider = provider.lower().strip() - valid_providers = ["chronicle", "openmemory_mcp"] + valid_providers = ["chronicle"] if provider not in valid_providers: raise ValueError( @@ -1034,8 +1327,6 @@ async def save_llm_operations(operations: dict): async def test_llm_model(model_name: Optional[str]): """Test an LLM model connection with a trivial prompt.""" try: - from advanced_omi_backend.openai_factory import create_openai_client - registry = get_models_registry() if not registry: raise RuntimeError("Model registry not loaded") @@ -1061,7 +1352,7 @@ async def test_llm_model(model_name: Optional[str]): client = create_openai_client( api_key=model_def.api_key or "", - base_url=model_def.model_url, + base_url=model_def.resolved_url(), is_async=True, ) @@ -1091,108 +1382,320 @@ async def test_llm_model(model_name: Optional[str]): } -# Chat Configuration Management Functions +# Model Registry Management Functions +# Default-pointer keys exposed for editing → the model_type a chosen model must +# have. ``llm`` and ``fast_llm`` both point at LLMs. ``live_segmentation`` is a +# mode string (owned by misc-settings), not a model, so it's intentionally absent. +_DEFAULT_KEY_TO_MODEL_TYPE = { + "llm": "llm", + "fast_llm": "llm", + "fallback_llm": "llm", + "embedding": "embedding", + "stt": "stt", + "stt_stream": "stt_stream", + "tts": "tts", +} -async def get_chat_config_yaml() -> str: - """Get chat system prompt as plain text.""" - try: - config_path = _find_config_path() +# Model types editable from the registry UI (must be routable by the pipeline). +_EDITABLE_MODEL_TYPES = ["llm", "embedding", "stt", "stt_stream", "tts"] - default_prompt = """You are a helpful AI assistant with access to the user's personal memories and conversation history. +# Sentinel sent to the browser instead of an inline secret, and recognised on the +# way back in as "keep the stored value". +_API_KEY_MASK = "••••••••" -Use the provided memories and conversation context to give personalized, contextual responses. If memories are relevant, reference them naturally in your response. Be conversational and helpful. +# Inline api_key values that aren't real secrets (placeholders) — shown verbatim. +_NON_SECRET_API_KEYS = {"", "no-key", "dummy", "none", "null"} -If no relevant memories are available, respond normally based on the conversation context.""" - if not os.path.exists(config_path): - return default_prompt +def _is_env_ref(value) -> bool: + """True if the value is an OmegaConf interpolation like ``${oc.env:VAR}``.""" + return isinstance(value, str) and value.strip().startswith("${") - with open(config_path, "r") as f: - full_config = _yaml.load(f) or {} - chat_config = full_config.get("chat", {}) - system_prompt = chat_config.get("system_prompt", default_prompt) +def _mask_api_key(raw_value): + """Mask an inline secret for display; pass through refs/placeholders/None.""" + if raw_value is None: + return "" + if _is_env_ref(raw_value): + return raw_value + if isinstance(raw_value, str) and raw_value.strip().lower() in _NON_SECRET_API_KEYS: + return raw_value + return _API_KEY_MASK - # Return just the prompt text, not the YAML structure - return system_prompt - except Exception as e: - logger.error(f"Error loading chat config: {e}") - raise +def _model_view(model_def: ModelDef, raw_by_name: dict, default_names: set) -> dict: + """Build the UI-facing model dict, zipping registry (resolved/derived) with + raw config.yml (source + unmasked secret reference detection).""" + raw = raw_by_name.get(model_def.name) + raw_api_key = raw.get("api_key") if raw else model_def.api_key + return { + "name": model_def.name, + "model_type": model_def.model_type, + "model_provider": model_def.model_provider, + "model_name": model_def.model_name, + "model_url": model_def.model_url, + "api_family": model_def.api_family, + "api_key": _mask_api_key(raw_api_key), + "api_key_is_set": bool(raw_api_key) + and not ( + isinstance(raw_api_key, str) + and raw_api_key.strip().lower() in _NON_SECRET_API_KEYS + ), + "api_key_is_ref": _is_env_ref(raw_api_key), + "description": model_def.description, + "model_params": dict(model_def.model_params or {}), + "capabilities": list(model_def.capabilities or []), + "embedding_dimensions": model_def.embedding_dimensions, + "model_output": model_def.model_output, + "thinking": model_def.thinking, + # 'config' = defined in config.yml (editable/deletable); + # 'default' = built-in template from defaults.yml (read-only baseline). + "source": "config" if model_def.name in raw_by_name else "default", + "is_default": model_def.name in default_names, + } -async def save_chat_config_yaml(prompt_text: str) -> dict: - """Save chat system prompt from plain text.""" - try: - config_path = _find_config_path() +async def get_models(): + """Return all registry models grouped by type plus the active defaults. + + Inline API keys are masked; ``${oc.env:...}`` references are shown verbatim so + the operator can see which env var backs a model without leaking the secret. + """ + registry = get_models_registry() + if not registry: + raise RuntimeError("Model registry not loaded") + + raw_by_name = { + m.get("name"): m + for m in get_raw_models() + if isinstance(m, dict) and m.get("name") + } + default_names = set(registry.defaults.values()) - # Validate plain text prompt - if not prompt_text or not isinstance(prompt_text, str): - raise ValueError("Prompt must be a non-empty string") + grouped = {t: [] for t in _EDITABLE_MODEL_TYPES} + for model_def in registry.models.values(): + if model_def.model_type in grouped: + grouped[model_def.model_type].append( + _model_view(model_def, raw_by_name, default_names) + ) + for t in grouped: + grouped[t].sort(key=lambda v: v["name"]) + + defaults = { + key: registry.defaults.get(key) + for key in ( + "llm", + "fast_llm", + "fallback_llm", + "embedding", + "stt", + "stt_stream", + "tts", + "live_segmentation", + ) + } - prompt_text = prompt_text.strip() - if len(prompt_text) < 10: - raise ValueError("Prompt too short (minimum 10 characters)") - if len(prompt_text) > 10000: - raise ValueError("Prompt too long (maximum 10000 characters)") + return {"defaults": defaults, "models": grouped, "status": "success"} + + +async def set_active_defaults(body: dict): + """Repoint one or more active-model defaults (llm/fast_llm/embedding/stt/ + stt_stream/tts). Validates the target exists and its model_type matches the + key, then hot-reloads the registry and signals workers.""" + registry = get_models_registry() + if not registry: + raise RuntimeError("Model registry not loaded") + + updates: dict = {} + for key, model_name in (body or {}).items(): + if key not in _DEFAULT_KEY_TO_MODEL_TYPE: + raise HTTPException(status_code=400, detail=f"Unknown default key '{key}'") + if not model_name: + continue + model_def = registry.get_by_name(model_name) + if not model_def: + raise HTTPException( + status_code=400, detail=f"Model '{model_name}' not found in registry" + ) + expected = _DEFAULT_KEY_TO_MODEL_TYPE[key] + if model_def.model_type != expected: + raise HTTPException( + status_code=400, + detail=( + f"Model '{model_name}' is a {model_def.model_type} model; " + f"default '{key}' requires a {expected} model" + ), + ) + updates[key] = model_name - # Create chat config dict - chat_config = {"system_prompt": prompt_text} + if not updates: + raise HTTPException(status_code=400, detail="No valid defaults to update") - # Load full config - if os.path.exists(config_path): - with open(config_path, "r") as f: - full_config = _yaml.load(f) or {} - else: - full_config = {} + if not save_config_section("defaults", updates): + return {"status": "error", "message": "Failed to save defaults"} - # Backup existing config - if os.path.exists(config_path): - backup_path = str(config_path) + ".backup" - shutil.copy2(config_path, backup_path) - logger.info(f"Created config backup at {backup_path}") + load_models_config(force_reload=True) + signal_worker_restart() + logger.info("Updated active defaults: %s", updates) + return { + "status": "success", + "defaults": updates, + "requires_worker_restart": True, + } - # Update chat section - full_config["chat"] = chat_config - # Save - with open(config_path, "w") as f: - _yaml.dump(full_config, f) +async def upsert_model(body: dict): + """Add or update a single model definition in config.yml. - # Reload config in memory (hot-reload) - load_models_config(force_reload=True) + Validates the def via the ModelDef schema. An incoming api_key equal to the + mask sentinel preserves the stored secret. Editing a default-only (defaults.yml) + model creates a config.yml override. Hot-reloads the registry afterwards. + """ + if not isinstance(body, dict) or not body.get("name"): + raise HTTPException(status_code=400, detail="Model 'name' is required") + + model_type = body.get("model_type") + if model_type not in _EDITABLE_MODEL_TYPES: + raise HTTPException( + status_code=400, + detail=f"model_type must be one of {_EDITABLE_MODEL_TYPES}", + ) - logger.info("Chat configuration updated successfully") + raw_models = get_raw_models() + existing = next((m for m in raw_models if m.get("name") == body["name"]), None) - return {"success": True, "message": "Chat configuration updated successfully"} + # Preserve the stored secret when the form sends back the mask sentinel. + incoming_key = body.get("api_key") + if incoming_key == _API_KEY_MASK: + body["api_key"] = existing.get("api_key") if existing else None + # Validate shape via the single source of truth (raises on bad def). + try: + ModelDef(**body) except Exception as e: - logger.error(f"Error saving chat config: {e}") - raise + raise HTTPException(status_code=400, detail=f"Invalid model definition: {e}") + # Drop None/empty optional keys so we don't litter config.yml with nulls. + clean = {k: v for k, v in body.items() if v is not None} -async def validate_chat_config_yaml(prompt_text: str) -> dict: - """Validate chat system prompt plain text.""" - try: - # Validate plain text prompt - if not isinstance(prompt_text, str): - return {"valid": False, "error": "Prompt must be a string"} - - prompt_text = prompt_text.strip() - if len(prompt_text) < 10: - return {"valid": False, "error": "Prompt too short (minimum 10 characters)"} - if len(prompt_text) > 10000: + new_models = [] + replaced = False + for m in raw_models: + if m.get("name") == body["name"]: + new_models.append(clean) + replaced = True + else: + new_models.append(m) + if not replaced: + new_models.append(clean) + + if not save_models_list(new_models): + return {"status": "error", "message": "Failed to save model"} + + load_models_config(force_reload=True) + signal_worker_restart() + logger.info("%s model '%s'", "Updated" if replaced else "Added", body["name"]) + + registry = get_models_registry() + raw_by_name = { + m.get("name"): m + for m in get_raw_models() + if isinstance(m, dict) and m.get("name") + } + model_def = registry.get_by_name(body["name"]) if registry else None + view = ( + _model_view(model_def, raw_by_name, set(registry.defaults.values())) + if model_def + else None + ) + return {"status": "success", "model": view, "requires_worker_restart": True} + + +async def delete_model(name: str): + """Delete a config.yml model. Refuses if it's an active default or a built-in + (defaults.yml-only) template.""" + registry = get_models_registry() + if not registry: + raise RuntimeError("Model registry not loaded") + + for key, default_name in registry.defaults.items(): + if default_name == name: + raise HTTPException( + status_code=409, + detail=f"Model '{name}' is the active '{key}'; repoint that default first", + ) + + raw_models = get_raw_models() + if not any(m.get("name") == name for m in raw_models): + raise HTTPException( + status_code=409, + detail=f"Model '{name}' is a built-in template (defaults.yml) and cannot be deleted", + ) + + new_models = [m for m in raw_models if m.get("name") != name] + if not save_models_list(new_models): + return {"status": "error", "message": "Failed to delete model"} + + load_models_config(force_reload=True) + signal_worker_restart() + logger.info("Deleted model '%s'", name) + return {"status": "success", "deleted": name} + + +async def test_model(model_name: Optional[str]): + """Connectivity test for a registry model. LLMs do a trivial chat round-trip; + embedding models do a 1-token embeddings call; STT/TTS have no automated test.""" + registry = get_models_registry() + if not registry: + raise RuntimeError("Model registry not loaded") + + if not model_name: + return await test_llm_model(None) + + model_def = registry.get_by_name(model_name) + if not model_def: + return { + "success": False, + "model_name": model_name, + "error": f"Model '{model_name}' not found", + "status": "error", + } + + if model_def.model_type == "llm": + return await test_llm_model(model_name) + + if model_def.model_type == "embedding": + try: + client = create_openai_client( + api_key=model_def.api_key or "", + base_url=model_def.resolved_url(), + is_async=True, + ) + start = time.time() + await client.embeddings.create(model=model_def.model_name, input="ping") + latency_ms = int((time.time() - start) * 1000) return { - "valid": False, - "error": "Prompt too long (maximum 10000 characters)", + "success": True, + "model_name": model_def.name, + "model_provider": model_def.model_provider, + "latency_ms": latency_ms, + "status": "success", + } + except Exception as e: + return { + "success": False, + "model_name": model_name, + "error": str(e), + "status": "error", } - return {"valid": True, "message": "Configuration is valid"} - - except Exception as e: - logger.error(f"Error validating chat config: {e}") - return {"valid": False, "error": f"Validation error: {str(e)}"} + return { + "success": False, + "model_name": model_name, + "error": f"No automated test for {model_def.model_type} models", + "status": "unsupported", + } # Plugin Configuration Management Functions @@ -1212,7 +1715,7 @@ async def get_plugins_config_yaml() -> str: # access_level: transcript # trigger: # type: wake_word - # wake_word: vivi + # wake_word: hermes # ha_url: http://localhost:8123 # ha_token: YOUR_TOKEN_HERE """ @@ -1364,11 +1867,6 @@ async def _reload_and_signal(app=None) -> tuple[dict, bool]: Returns: (reload_result, worker_signal_sent) tuple. """ - from advanced_omi_backend.services.plugin_service import ( - reload_plugins, - signal_worker_restart, - ) - reload_result = await reload_plugins(app=app) worker_signal_sent = False @@ -1387,8 +1885,6 @@ async def restart_workers() -> dict: Workers finish their current job before restarting. Uses the existing plugin-reload worker restart mechanism. """ - from advanced_omi_backend.services.plugin_service import signal_worker_restart - try: signal_worker_restart() logger.info("Worker restart signaled via Redis") @@ -1460,11 +1956,6 @@ async def get_plugins_metadata() -> dict: Dict with plugins list containing metadata for each plugin """ try: - from advanced_omi_backend.services.plugin_service import ( - discover_plugins, - get_plugin_metadata, - ) - # Discover all available plugins discovered_plugins = discover_plugins() @@ -1517,11 +2008,6 @@ async def update_plugin_config_structured(plugin_id: str, config: dict) -> dict: Success message with list of updated files """ try: - from advanced_omi_backend.services.plugin_service import ( - _get_plugins_dir, - discover_plugins, - ) - # Validate plugin exists discovered_plugins = discover_plugins() if plugin_id not in discovered_plugins: @@ -1598,8 +2084,6 @@ async def update_plugin_config_structured(plugin_id: str, config: dict) -> dict: # 3. Update per-plugin .env (only changed env vars) if "env_vars" in config and config["env_vars"]: - from advanced_omi_backend.services.plugin_service import save_plugin_env - # Filter out masked values (unchanged secrets) changed_vars = { k: v for k, v in config["env_vars"].items() if v != "••••••••••••" @@ -1656,12 +2140,6 @@ async def test_plugin_connection(plugin_id: str, config: dict) -> dict: Test result with success status and details """ try: - from advanced_omi_backend.services.plugin_service import ( - discover_plugins, - expand_env_vars, - load_plugin_env, - ) - # Validate plugin exists discovered_plugins = discover_plugins() if plugin_id not in discovered_plugins: @@ -1747,11 +2225,6 @@ async def create_plugin( Returns: Success dict with plugin_id and created_files list """ - from advanced_omi_backend.services.plugin_service import ( - _get_plugins_dir, - discover_plugins, - ) - # Validate name if not plugin_name.replace("_", "").isalnum(): return { @@ -1915,8 +2388,6 @@ async def write_plugin_code( Returns: Success dict with updated_files list """ - from advanced_omi_backend.services.plugin_service import _get_plugins_dir - plugins_dir = _get_plugins_dir() plugin_dir = plugins_dir / plugin_id @@ -1969,8 +2440,6 @@ async def delete_plugin(plugin_id: str, remove_files: bool = False) -> dict: Returns: Success dict """ - from advanced_omi_backend.services.plugin_service import _get_plugins_dir - plugins_yml_path = get_plugins_yml_path() # Check plugins.yml @@ -2021,3 +2490,210 @@ async def delete_plugin(plugin_id: str, remove_files: bool = False) -> dict: "removed_from_yml": removed_from_yml, "files_removed": files_removed, } + + +# ── External Service Management (host service-manager agent proxy) ────────── +# The service manager agent (edge/service_manager.py) runs natively on the +# host and wraps services.py — the backend just proxies admin requests to it. + +_SERVICE_MANAGER_TIMEOUT = 30.0 + + +def _service_manager_config() -> tuple[str, str]: + url = (os.getenv("SERVICE_MANAGER_URL") or "").rstrip("/") + token = os.getenv("SERVICE_MANAGER_TOKEN") or "" + return url, token + + +async def _service_manager_request( + method: str, path: str, json_body: dict | None = None, *, params: dict | None = None +): + """Proxy a request to the LOCAL service manager agent. Raises on failure. + + The backend always talks to its local agent — cross-node merge + control + forwarding happens inside the agent (a host process with a real Tailnet + identity), because a container can't present a Tailnet source IP for the peer + agents' tailnet-trust to accept. + """ + url, token = _service_manager_config() + if not url or not token: + raise HTTPException( + status_code=503, + detail="Service manager not configured (SERVICE_MANAGER_URL / SERVICE_MANAGER_TOKEN)", + ) + try: + async with httpx.AsyncClient(timeout=_SERVICE_MANAGER_TIMEOUT) as client: + resp = await client.request( + method, + f"{url}{path}", + json=json_body, + params=params, + headers={"Authorization": f"Bearer {token}"}, + ) + except httpx.HTTPError as e: + raise HTTPException( + status_code=502, detail=f"Service manager unreachable at {url}: {e}" + ) + if resp.status_code >= 400: + try: + detail = resp.json().get("detail", resp.text) + except ValueError: + detail = resp.text + raise HTTPException(status_code=resp.status_code, detail=detail) + return resp.json() + + +async def _local_node_host() -> str | None: + """This node's hostname per the local agent — used to tell whether a provider + switch targets the local pipeline (so we repoint hub config.yml) or a remote node. + """ + try: + node = await _service_manager_request("GET", "/node") + except HTTPException: + return None + return node.get("host") + + +async def get_external_services(): + """List host-managed services across the cluster with health and provider info. + + The local agent merges this node's services with peer nodes' (each tagged with a + ``node`` host + ``remote`` flag) so the WebUI can group and route control calls. + Returns available=False (instead of an error) when the local agent is not + configured or unreachable. + """ + url, token = _service_manager_config() + if not url or not token: + return {"available": False, "reason": "not_configured"} + try: + data = await _service_manager_request("GET", "/services") + except HTTPException as e: + if e.status_code in (502, 503): + return {"available": False, "reason": "unreachable", "detail": e.detail} + raise + return {"available": True, **data} + + +async def external_service_action(name: str, action: str, body: dict): + """Start/stop/restart a host-managed service. ``body["node"]`` selects the owning + node; the local agent forwards to that node when it isn't the local one.""" + result = await _service_manager_request("POST", f"/services/{name}/{action}", body) + # Tag the operation with its node so the WebUI polls the right agent. + if isinstance(result.get("operation"), dict): + result["operation"]["node"] = body.get("node") + return result + + +# ASR_PROVIDER key (extras/asr-services/.env, drives which container runs) → +# STT model name in the model registry (drives which model entry the pipeline +# calls). A provider switch must update BOTH, or transcription keeps using the +# old model entry while a different container serves the port. +_ASR_PROVIDER_TO_STT_MODEL = { + "vibevoice": "stt-vibevoice", + "vibevoice-strixhalo": "stt-vibevoice", + "faster-whisper": "stt-faster-whisper", + "transformers": "stt-transformers", + "nemo": "stt-nemo", + "nemo-strixhalo": "stt-nemo", + "parakeet": "stt-parakeet-batch", + "qwen3-asr": "stt-qwen3-asr", + "gemma4": "stt-gemma4", + "nemotron": "stt-nemotron-batch", +} + +# Streaming ASR provider → stt_stream model name. Mirror of the streaming options +# in services.py (STREAMING_ASR_PROVIDER_OPTIONS); a streaming switch repoints +# defaults.stt_stream so live transcription uses the newly selected provider. +_STREAMING_ASR_PROVIDER_TO_STT_STREAM_MODEL = { + "nemotron": "stt-nemotron-stream", + "smallest": "stt-smallest-stream", + "deepgram": "stt-deepgram-stream", + "qwen3-asr": "stt-qwen3-asr-stream", +} + + +async def set_external_service_provider(name: str, body: dict): + """Switch the active provider (ASR/TTS) for a host-managed service. + + For ASR this also repoints config.yml at the matching model registry entry + and hot-reloads the registry (+ signals workers), so the pipeline actually + uses the newly selected provider. The batch lane drives defaults.stt; the + streaming lane (body["lane"] == "streaming") drives defaults.stt_stream. + + ``body["node"]`` selects the owning node (the local agent forwards to a remote + node when set). For a *remote* node we skip the hub-side config.yml/registry + repoint (that's a local-pipeline concern — the hub operator points defaults at the + remote provider separately). + """ + node = body.get("node") + result = await _service_manager_request("POST", f"/services/{name}/provider", body) + if isinstance(result.get("operation"), dict): + result["operation"]["node"] = node + + is_local = not node or node == await _local_node_host() + if name == "asr-services" and is_local: + streaming = body.get("lane") == "streaming" + default_key = "stt_stream" if streaming else "stt" + model_map = ( + _STREAMING_ASR_PROVIDER_TO_STT_STREAM_MODEL + if streaming + else _ASR_PROVIDER_TO_STT_MODEL + ) + stt_model = model_map.get(body.get("provider", "")) + if stt_model: + save_config_section("defaults", {default_key: stt_model}) + load_models_config(force_reload=True) + signal_worker_restart() + logger.info( + "ASR %s provider switched to %s — defaults.%s set to %s, " + "registry reloaded, workers signaled", + "streaming" if streaming else "batch", + body.get("provider"), + default_key, + stt_model, + ) + result["stt_model"] = stt_model + else: + logger.warning( + "No STT model mapping for ASR provider %r (lane=%s) — defaults.%s unchanged", + body.get("provider"), + body.get("lane", "batch"), + default_key, + ) + + return result + + +async def get_external_service_operation(operation_id: str, node: str | None = None): + """Poll a long-running service operation. ``node`` is forwarded to the local agent, + which routes the poll to the remote node that owns the operation.""" + params = {"node": node} if node else None + result = await _service_manager_request( + "GET", f"/operations/{operation_id}", params=params + ) + if isinstance(result, dict): + result["node"] = node + return result + + +async def get_remote_control_status(): + """Status of the host's Claude remote-control session (for the System page). + + Returns available=False (instead of raising) when the agent is not configured + or unreachable, so the WebUI can hide/disable the card. + """ + url, token = _service_manager_config() + if not url or not token: + return {"available": False, "reason": "not_configured"} + try: + data = await _service_manager_request("GET", "/remote-control") + except HTTPException as e: + if e.status_code in (502, 503): + return {"available": False, "reason": "unreachable", "detail": e.detail} + raise + return {"available": True, **data} + + +async def remote_control_action(action: str): + """Start/stop/restart the host's Claude remote-control session via the agent.""" + return await _service_manager_request("POST", f"/remote-control/{action}") diff --git a/backends/advanced/src/advanced_omi_backend/controllers/system_events_controller.py b/backends/advanced/src/advanced_omi_backend/controllers/system_events_controller.py new file mode 100644 index 00000000..4b061a89 --- /dev/null +++ b/backends/advanced/src/advanced_omi_backend/controllers/system_events_controller.py @@ -0,0 +1,256 @@ +"""Controller for the system-event ledger (admin "System Errors" page). + +Read/manage side of the store written by +:mod:`advanced_omi_backend.services.observability`. All callers are admin-gated at +the route layer. +""" + +import logging +from datetime import datetime, timedelta, timezone +from typing import Any, Optional + +from beanie import PydanticObjectId +from fastapi import HTTPException + +from advanced_omi_backend.models.system_event import SystemEvent +from advanced_omi_backend.services.observability.system_events import record_event + +logger = logging.getLogger(__name__) + + +def _system_event_to_dict(doc) -> dict[str, Any]: + return { + "id": str(doc.id), + "severity": doc.severity, + "category": doc.category, + "source": doc.source, + "title": doc.title, + "detail": doc.detail, + "traceback": doc.traceback, + "user_id": doc.user_id, + "client_id": doc.client_id, + "conversation_id": doc.conversation_id, + "count": doc.occurrences, + "acked": doc.acked, + "metadata": doc.metadata, + "created_at": doc.created_at.isoformat() if doc.created_at else None, + "last_seen_at": doc.last_seen_at.isoformat() if doc.last_seen_at else None, + } + + +# Bounds for externally-submitted event fields, so a misbehaving service can't +# push a multi-megabyte traceback into the ledger. (source/title are additionally +# clamped by the recorder's _build to 200/500 chars.) +_MAX_DETAIL = 4000 +_MAX_TRACEBACK = 20000 +_VALID_SEVERITIES = ("info", "warning", "error", "critical") + + +async def ingest_external_event( + *, + severity: str, + category: Optional[str], + source: str, + title: str, + detail: Optional[str], + traceback: Optional[str], + client_id: Optional[str], + conversation_id: Optional[str], + metadata: Optional[dict], +) -> dict[str, Any]: + """Record an event submitted over HTTP by another service (token-gated route). + + Treats the payload as untrusted input: severity is constrained to the known + set, free-text fields are size-clamped, and the source is namespaced with a + ``service:`` prefix so a remote service can't masquerade as a backend logger. + """ + severity = severity if severity in _VALID_SEVERITIES else "error" + # External submissions are, by definition, service-originated unless they say + # otherwise; default the category accordingly. + category = (category or "service")[:50] + # Namespace the source so it's unambiguous in the feed and can't collide with + # an internal logger name. The shared token authenticates "a trusted node", + # not a specific identity, so we don't trust the raw name beyond labelling. + source = f"service:{(source or 'unknown').strip()[:180]}" + title = (title or "(no title)").strip() + if detail and len(detail) > _MAX_DETAIL: + detail = detail[:_MAX_DETAIL] + "… (truncated)" + if traceback and len(traceback) > _MAX_TRACEBACK: + traceback = traceback[:_MAX_TRACEBACK] + "\n… (truncated)" + + await record_event( + severity=severity, + category=category, + source=source, + title=title, + detail=detail, + traceback=traceback, + client_id=client_id, + conversation_id=conversation_id, + metadata=metadata or {}, + ) + return {"status": "recorded"} + + +async def list_system_events( + *, + severity: Optional[str] = None, + category: Optional[str] = None, + source: Optional[str] = None, + client_id: Optional[str] = None, + user_id: Optional[str] = None, + acked: Optional[bool] = None, + since_hours: Optional[float] = None, + limit: int = 100, + offset: int = 0, +) -> dict[str, Any]: + """Paginated, newest-first list of events with optional facet filters.""" + conditions = [] + if severity: + conditions.append(SystemEvent.severity == severity) + if category: + conditions.append(SystemEvent.category == category) + if source: + conditions.append(SystemEvent.source == source) + if client_id: + conditions.append(SystemEvent.client_id == client_id) + if user_id: + conditions.append(SystemEvent.user_id == user_id) + if acked is not None: + conditions.append(SystemEvent.acked == acked) + if since_hours: + since = datetime.now(timezone.utc) - timedelta(hours=since_hours) + conditions.append(SystemEvent.created_at >= since) + + limit = max(1, min(limit, 500)) + offset = max(0, offset) + + total = await SystemEvent.find(*conditions).count() + docs = ( + await SystemEvent.find(*conditions) + .sort("-created_at") + .skip(offset) + .limit(limit) + .to_list() + ) + return { + "events": [_system_event_to_dict(d) for d in docs], + "total": total, + "limit": limit, + "offset": offset, + } + + +async def get_system_events_summary(*, window_hours: float = 24) -> dict[str, Any]: + """Counts by severity/category/source over a time window (for the strip + badge).""" + since = datetime.now(timezone.utc) - timedelta(hours=window_hours) + pipeline = [ + {"$match": {"created_at": {"$gte": since}}}, + { + "$facet": { + "by_severity": [{"$group": {"_id": "$severity", "count": {"$sum": 1}}}], + "by_category": [{"$group": {"_id": "$category", "count": {"$sum": 1}}}], + "by_source": [ + {"$group": {"_id": "$source", "count": {"$sum": 1}}}, + {"$sort": {"count": -1}}, + {"$limit": 15}, + ], + "total": [{"$count": "n"}], + "unacked": [ + {"$match": {"acked": False}}, + {"$count": "n"}, + ], + } + }, + ] + cursor = SystemEvent.get_pymongo_collection().aggregate(pipeline) + rows = await cursor.to_list(length=1) + facet = rows[0] if rows else {} + + def _kv(items: list) -> dict[str, int]: + return {i["_id"]: i["count"] for i in items if i.get("_id") is not None} + + def _n(items: list) -> int: + return items[0]["n"] if items else 0 + + return { + "window_hours": window_hours, + "total": _n(facet.get("total", [])), + "unacked": _n(facet.get("unacked", [])), + "by_severity": _kv(facet.get("by_severity", [])), + "by_category": _kv(facet.get("by_category", [])), + "by_source": _kv(facet.get("by_source", [])), + } + + +async def ack_system_event(event_id: str) -> dict[str, Any]: + try: + oid = PydanticObjectId(event_id) + except Exception: + raise HTTPException(status_code=400, detail="Invalid event id") + doc = await SystemEvent.get(oid) + if doc is None: + raise HTTPException(status_code=404, detail="Event not found") + doc.acked = True + await doc.save() + return {"success": True, "id": event_id} + + +async def ack_system_events_by_ids(event_ids: list[str]) -> dict[str, Any]: + """Acknowledge a specific set of events by id (skips any already acked).""" + oids = [] + for eid in event_ids: + try: + oids.append(PydanticObjectId(eid)) + except Exception: + raise HTTPException(status_code=400, detail=f"Invalid event id: {eid}") + if not oids: + return {"success": True, "acked": 0} + + result = await SystemEvent.find( + {"_id": {"$in": oids}, "acked": False}, + ).update({"$set": {"acked": True}}) + return {"success": True, "acked": getattr(result, "modified_count", None)} + + +async def ack_system_events( + *, + severity: Optional[str] = None, + category: Optional[str] = None, + source: Optional[str] = None, + client_id: Optional[str] = None, + user_id: Optional[str] = None, + since_hours: Optional[float] = None, +) -> dict[str, Any]: + """Acknowledge all currently-unacked events matching the given filters. + + Filters mirror :func:`list_system_events` so the UI can acknowledge exactly the + set the operator is currently looking at. Always scoped to ``acked == False``. + """ + conditions = [SystemEvent.acked == False] # noqa: E712 + if severity: + conditions.append(SystemEvent.severity == severity) + if category: + conditions.append(SystemEvent.category == category) + if source: + conditions.append(SystemEvent.source == source) + if client_id: + conditions.append(SystemEvent.client_id == client_id) + if user_id: + conditions.append(SystemEvent.user_id == user_id) + if since_hours: + since = datetime.now(timezone.utc) - timedelta(hours=since_hours) + conditions.append(SystemEvent.created_at >= since) + + result = await SystemEvent.find(*conditions).update({"$set": {"acked": True}}) + return {"success": True, "acked": getattr(result, "modified_count", None)} + + +async def clear_system_events(*, acked_only: bool = False) -> dict[str, Any]: + if acked_only: + result = await SystemEvent.find( + SystemEvent.acked == True + ).delete() # noqa: E712 + else: + result = await SystemEvent.find_all().delete() + return {"success": True, "deleted": getattr(result, "deleted_count", None)} diff --git a/backends/advanced/src/advanced_omi_backend/controllers/user_controller.py b/backends/advanced/src/advanced_omi_backend/controllers/user_controller.py index a0c10e4f..8c5a0dcc 100644 --- a/backends/advanced/src/advanced_omi_backend/controllers/user_controller.py +++ b/backends/advanced/src/advanced_omi_backend/controllers/user_controller.py @@ -2,17 +2,17 @@ User controller for handling user-related business logic. """ -import asyncio import logging +import traceback from bson import ObjectId from fastapi import HTTPException from fastapi.responses import JSONResponse from advanced_omi_backend.auth import ADMIN_EMAIL, UserManager, get_user_db -from advanced_omi_backend.client_manager import get_user_clients_all -from advanced_omi_backend.database import db, users_col +from advanced_omi_backend.database import users_col from advanced_omi_backend.models.conversation import Conversation +from advanced_omi_backend.models.user import UserRead from advanced_omi_backend.services.memory import get_memory_service from advanced_omi_backend.users import User, UserCreate, UserUpdate @@ -58,8 +58,6 @@ async def create_user(user_data: UserCreate): user = await user_manager.create(user_data) # Return the full user object (serialized via UserRead schema) - from advanced_omi_backend.models.user import UserRead - user_read = UserRead.model_validate(user) return JSONResponse( @@ -68,8 +66,6 @@ async def create_user(user_data: UserCreate): ) except Exception as e: - import traceback - error_details = traceback.format_exc() logger.error(f"Error creating user: {e}") logger.error(f"Full traceback: {error_details}") @@ -114,8 +110,6 @@ async def update_user(user_id: str, user_data: UserUpdate): updated_user = await user_manager.update(user_data, user_obj) # Return the full user object (serialized via UserRead schema) - from advanced_omi_backend.models.user import UserRead - user_read = UserRead.model_validate(updated_user) return JSONResponse( @@ -124,8 +118,6 @@ async def update_user(user_id: str, user_data: UserUpdate): ) except Exception as e: - import traceback - error_details = traceback.format_exc() logger.error(f"Error updating user: {e}") logger.error(f"Full traceback: {error_details}") @@ -190,9 +182,7 @@ async def delete_user( # Delete all memories for this user using the memory service try: memory_service = get_memory_service() - memory_count = await asyncio.get_running_loop().run_in_executor( - None, memory_service.delete_all_user_memories, user_id - ) + memory_count = await memory_service.delete_all_user_memories(user_id) deleted_data["memories_deleted"] = memory_count except Exception as mem_error: logger.error(f"Error deleting memories for user {user_id}: {mem_error}") diff --git a/backends/advanced/src/advanced_omi_backend/controllers/websocket_controller.py b/backends/advanced/src/advanced_omi_backend/controllers/websocket_controller.py index 80ecb5f1..b8ebc44a 100644 --- a/backends/advanced/src/advanced_omi_backend/controllers/websocket_controller.py +++ b/backends/advanced/src/advanced_omi_backend/controllers/websocket_controller.py @@ -10,28 +10,57 @@ import logging import os import time +import traceback import uuid from functools import partial from typing import Optional -import redis.asyncio as redis from fastapi import Query, WebSocket, WebSocketDisconnect from friend_lite.decoder import OmiOpusDecoder from starlette.websockets import WebSocketState from advanced_omi_backend.auth import websocket_auth -from advanced_omi_backend.client_manager import generate_client_id, get_client_manager +from advanced_omi_backend.client_manager import ( + generate_client_id, + get_client_manager, + track_client_user_relationship_async, +) +from advanced_omi_backend.config import WS_IDLE_TIMEOUT_SECS from advanced_omi_backend.constants import ( OMI_CHANNELS, OMI_SAMPLE_RATE, OMI_SAMPLE_WIDTH, ) -from advanced_omi_backend.controllers.session_controller import mark_session_complete -from advanced_omi_backend.services.audio_stream import AudioStreamProducer +from advanced_omi_backend.controllers.queue_controller import ( + JOB_RESULT_TTL, + start_post_conversation_jobs, + start_streaming_jobs, + transcription_queue, +) +from advanced_omi_backend.model_registry import get_models_registry +from advanced_omi_backend.models.conversation import create_conversation +from advanced_omi_backend.plugins.events import BUTTON_STATE_TO_EVENT, ButtonState +from advanced_omi_backend.redis_factory import create_async_redis from advanced_omi_backend.services.audio_stream.producer import ( get_audio_stream_producer, ) +from advanced_omi_backend.services.audio_stream.session_store import ( + SessionStatus, + SessionStore, +) +from advanced_omi_backend.services.device_audio import ( + is_opus_streaming_client, + stop_play_audio, + stream_play_audio_as_opus, +) +from advanced_omi_backend.services.observability import record_event_sync +from advanced_omi_backend.services.plugin_service import get_plugin_router from advanced_omi_backend.services.sse_publisher import publish_sse_event +from advanced_omi_backend.services.transcription import is_transcription_available +from advanced_omi_backend.services.wakeword.followup import handle_dial_followup +from advanced_omi_backend.users import register_client_to_user, touch_client_last_seen +from advanced_omi_backend.utils.audio_chunk_utils import convert_audio_to_chunks +from advanced_omi_backend.workers.transcription_jobs import transcribe_full_audio_job # Thread pool executors for audio decoding _DEC_IO_EXECUTOR = concurrent.futures.ThreadPoolExecutor( @@ -46,6 +75,19 @@ # Track pending WebSocket connections to prevent race conditions pending_connections: set[str] = set() +# Per-client_id locks serializing connection setup. A reconnecting device (same +# client_id) must evict its stale connection and create a fresh ClientState as one +# atomic step, so two concurrent connections can't interleave and orphan state. +_client_setup_locks: dict[str, asyncio.Lock] = {} + + +def _get_client_setup_lock(client_id: str) -> asyncio.Lock: + lock = _client_setup_locks.get(client_id) + if lock is None: + lock = asyncio.Lock() + _client_setup_locks[client_id] = lock + return lock + async def subscribe_to_interim_results(websocket: WebSocket, session_id: str) -> None: """ @@ -63,11 +105,9 @@ async def subscribe_to_interim_results(websocket: WebSocket, session_id: str) -> This task runs continuously until the WebSocket disconnects or the task is cancelled. Results are published to Redis Pub/Sub channel: transcription:interim:{session_id} """ - redis_url = os.getenv("REDIS_URL", "redis://localhost:6379/0") - try: # Create Redis client for Pub/Sub - redis_client = await redis.from_url(redis_url, decode_responses=True) + redis_client = create_async_redis(decode_responses=True) # Create Pub/Sub instance pubsub = redis_client.pubsub() @@ -90,6 +130,12 @@ async def subscribe_to_interim_results(websocket: WebSocket, session_id: str) -> try: result_data = json.loads(message["data"]) + # Stop if the socket closed: a result can race the close, and + # send_json after close raises the ASGI "websocket.send after + # websocket.close" error. Guard instead of catching post-hoc. + if websocket.client_state != WebSocketState.CONNECTED: + break + # Forward to client WebSocket await websocket.send_json( {"type": "interim_transcript", "data": result_data} @@ -145,6 +191,145 @@ async def subscribe_to_interim_results(websocket: WebSocket, session_id: str) -> ) +async def subscribe_to_device_downlink(websocket: WebSocket, client_id: str) -> None: + """Forward backend→device control messages from Redis Pub/Sub to the device WebSocket. + + Any backend component (wake-word service, plugins) can push a message to a + specific device by publishing to ``device:downlink:{client_id}``. Each message + is a Wyoming-style control frame (e.g. ``{"type": "play-audio", "data": {...}}``) + which the HAVPE relay's ``handle_backend_messages`` dispatches to the device. + + Runs as a background task for the lifetime of the WebSocket connection. + """ + channel = f"device:downlink:{client_id}" + redis_client = None + pubsub = None + opus_stream = is_opus_streaming_client(client_id) + + try: + redis_client = create_async_redis(decode_responses=True) + pubsub = redis_client.pubsub() + await pubsub.subscribe(channel) + logger.info( + f"🔊 Subscribed to device downlink channel: {channel}" + f"{' (opus streaming)' if opus_stream else ''}" + ) + + while True: + try: + message = await pubsub.get_message( + ignore_subscribe_messages=True, timeout=1.0 + ) + if not message or message["type"] != "message": + continue + + try: + payload = json.loads(message["data"]) + except json.JSONDecodeError as e: + logger.error(f"Invalid device downlink message on {channel}: {e}") + continue + + msg_type = payload.get("type") + if not msg_type: + logger.warning(f"Device downlink message missing 'type': {payload}") + continue + + try: + # Skip if the device socket already closed (a downlink can race the + # disconnect); sending after close raises the ASGI websocket.send error. + if websocket.client_state != WebSocketState.CONNECTED: + break + if msg_type == "stop-audio": + # Barge-in: stop whatever TTS is playing on the device. For + # Opus clients this cancels the in-flight stream + flushes the + # device; for others there's nothing streaming to cancel, so + # just forward the control frame best-effort. + if opus_stream: + await stop_play_audio(websocket, client_id) + else: + await websocket.send_json(payload) + elif opus_stream and msg_type == "play-audio": + # RAM-limited devices can't take a big base64 WAV frame; + # transcode + stream it as small Opus packets instead. + streamed = await stream_play_audio_as_opus( + websocket, payload.get("data") or {}, client_id + ) + if not streamed: + await websocket.send_json(payload) # fallback + else: + await websocket.send_json(payload) + data = payload.get("data") + # Summarize so large fields (e.g. base64 TTS audio) don't flood logs. + summary = ( + { + k: ( + f"<{len(v)} chars>" + if isinstance(v, str) and len(v) > 80 + else v + ) + for k, v in data.items() + } + if isinstance(data, dict) + else data + ) + logger.info( + f"📤 Forwarded '{msg_type}' to device {client_id}: {summary}" + ) + except Exception as send_error: + logger.warning( + f"Failed to send downlink to device {client_id}: {send_error}" + ) + break + + except asyncio.TimeoutError: + continue + except asyncio.CancelledError: + logger.info(f"Device downlink subscriber cancelled for {client_id}") + break + except Exception as e: + logger.error( + f"Error in device downlink subscriber for {client_id}: {e}", + exc_info=True, + ) + break + + except Exception as e: + logger.error( + f"Failed to initialize device downlink subscriber for {client_id}: {e}", + exc_info=True, + ) + finally: + if pubsub is not None: + try: + await pubsub.unsubscribe(channel) + await pubsub.close() + except Exception as cleanup_error: + logger.error(f"Error cleaning up downlink subscriber: {cleanup_error}") + if redis_client is not None: + try: + await redis_client.aclose() + except Exception: + pass + + +async def receive_with_idle_timeout(ws: WebSocket) -> dict: + """`ws.receive()` with a liveness deadline. + + A live device streams audio every ~0.25s, so if nothing arrives for + WS_IDLE_TIMEOUT_SECS the peer is dead (or a relay is holding the socket open + after its device vanished). We raise WebSocketDisconnect so the handler's + `finally` runs the same full cleanup as a clean close, reaping the zombie. + """ + try: + return dict(await asyncio.wait_for(ws.receive(), timeout=WS_IDLE_TIMEOUT_SECS)) + except asyncio.TimeoutError: + logger.warning( + f"⏰ WebSocket idle for {WS_IDLE_TIMEOUT_SECS:.0f}s with no data — " + f"treating peer as disconnected" + ) + raise WebSocketDisconnect(code=1001, reason="idle timeout") + + async def parse_wyoming_protocol(ws: WebSocket) -> tuple[dict, Optional[bytes]]: """Parse Wyoming protocol: JSON header line followed by optional binary payload. @@ -153,7 +338,7 @@ async def parse_wyoming_protocol(ws: WebSocket) -> tuple[dict, Optional[bytes]]: """ # Read data from WebSocket logger.debug(f"parse_wyoming_protocol: About to call ws.receive()") - message = await ws.receive() + message = await receive_with_idle_timeout(ws) logger.debug( f"parse_wyoming_protocol: Received message with keys: {message.keys() if message else 'None'}" ) @@ -161,7 +346,7 @@ async def parse_wyoming_protocol(ws: WebSocket) -> tuple[dict, Optional[bytes]]: # Handle WebSocket close frame if "type" in message and message["type"] == "websocket.disconnect": # This is a normal WebSocket close event - code = message.get("code", 1000) + code = message.get("code") reason = message.get("reason", "") logger.info( f"📴 WebSocket disconnect received in parse_wyoming_protocol. Code: {code}, Reason: {reason}" @@ -183,7 +368,7 @@ async def parse_wyoming_protocol(ws: WebSocket) -> tuple[dict, Optional[bytes]]: payload = None payload_length = header.get("payload_length") if payload_length is not None and payload_length > 0: - payload_msg = await ws.receive() + payload_msg = await receive_with_idle_timeout(ws) if "bytes" in payload_msg: payload = payload_msg["bytes"] else: @@ -202,30 +387,33 @@ async def parse_wyoming_protocol(ws: WebSocket) -> tuple[dict, Optional[bytes]]: async def create_client_state(client_id: str, user, device_name: Optional[str] = None): - """Create and register a new client state.""" + """Create and register a new client state. + + If a connection with the same client_id already exists (a reconnecting device + whose previous connection is still lingering — e.g. a relay-held zombie), the + stale connection is evicted first so the newest connection always wins. The + per-client lock makes evict+create atomic against concurrent reconnects. + """ # Get client manager client_manager = get_client_manager() - # Directory where WAV chunks are written - from pathlib import Path + async with _get_client_setup_lock(client_id): + # Newest-wins: tear down any stale connection holding this client_id. This + # finalizes its sessions and removes it so create_client below won't raise. + if client_manager.has_client(client_id): + logger.warning( + f"♻️ Client {client_id} reconnecting while a previous connection is " + f"still registered — evicting the stale connection (newest wins)" + ) + await cleanup_client_state(client_id) - CHUNK_DIR = Path( - "./audio_chunks" - ) # This will be mounted to ./data/audio_chunks by Docker - - # Use ClientManager for atomic client creation and registration - client_state = client_manager.create_client( - client_id, CHUNK_DIR, user.user_id, user.email - ) + # Use ClientManager for atomic client creation and registration + client_state = client_manager.create_client(client_id, user.user_id, user.email) # Also track in persistent mapping (for database queries + cross-container Redis) - from advanced_omi_backend.client_manager import track_client_user_relationship_async - await track_client_user_relationship_async(client_id, user.user_id) # Register client in user model (persistent) - from advanced_omi_backend.users import register_client_to_user - await register_client_to_user(user, client_id, device_name) return client_state @@ -245,8 +433,6 @@ async def cleanup_client_state(client_id: str): # Note: Previously we cancelled the speech detection job here, but this prevented # conversations from being created when WebSocket disconnects mid-recording. # The speech detection job now monitors session status and completes naturally. - import redis.asyncio as redis - logger.info( f"🔄 Letting speech detection job complete naturally for client {client_id} (if running)" ) @@ -254,73 +440,56 @@ async def cleanup_client_state(client_id: str): # Mark all active sessions for this client as complete AND delete Redis streams try: # Get async Redis client - redis_url = os.getenv("REDIS_URL", "redis://localhost:6379/0") - async_redis = redis.from_url(redis_url, decode_responses=False) + async_redis = create_async_redis(decode_responses=False) # Get audio stream producer for finalization - from advanced_omi_backend.services.audio_stream.producer import ( - get_audio_stream_producer, - ) - audio_stream_producer = get_audio_stream_producer() - # Find all session keys for this client and mark them complete - pattern = f"audio:session:*" - cursor = 0 + # Find all sessions for this client and mark them complete + store = SessionStore(async_redis) sessions_closed = 0 + session_ids: list[str] = [] + + async for view in store.iter_views(): + is_match = view.client_id == client_id + if not is_match and view.session_id.startswith(client_id): + # Fallback: session hash may have lost client_id (e.g., partial resurrection). + # Session IDs are formatted as "{client_id}-{uuid}", so check prefix. + is_match = True + logger.warning( + f"⚠️ Session {view.session_id[:12]} missing client_id field — " + f"matched via session_id prefix fallback" + ) + if not is_match: + continue - while True: - cursor, keys = await async_redis.scan(cursor, match=pattern, count=100) - - for key in keys: - # Check if this session belongs to this client - client_id_bytes = await async_redis.hget(key, "client_id") - is_match = client_id_bytes and client_id_bytes.decode() == client_id - if not is_match: - # Fallback: session hash may have lost client_id (e.g., partial resurrection). - # Session IDs are formatted as "{client_id}-{uuid}", so check prefix. - session_id_from_key = key.decode().replace("audio:session:", "") - if session_id_from_key.startswith(client_id): - is_match = True - logger.warning( - f"⚠️ Session {session_id_from_key[:12]} missing client_id field — " - f"matched via session_id prefix fallback" - ) - if is_match: - session_id = key.decode().replace("audio:session:", "") - - # Check session status - status_bytes = await async_redis.hget(key, "status") - status = status_bytes.decode() if status_bytes else None - - # If session is still active, finalize it first (sets status + completion_reason atomically) - if status in ["active", None]: - logger.info( - f"📊 Finalizing active session {session_id[:12]} due to WebSocket disconnect" - ) - await audio_stream_producer.finalize_session( - session_id, completion_reason="websocket_disconnect" - ) + session_id = view.session_id + session_ids.append(session_id) - # Mark session as complete (WebSocket disconnected) - await mark_session_complete( - async_redis, session_id, "websocket_disconnect" - ) - sessions_closed += 1 + # If session is still active, finalize it first (sets status + completion_reason atomically) + if view.status in (SessionStatus.ACTIVE, None): + logger.info( + f"📊 Finalizing active session {session_id[:12]} due to WebSocket disconnect" + ) + await audio_stream_producer.finalize_session( + session_id, completion_reason="websocket_disconnect" + ) - # Notify frontend that session ended - publish_sse_event( - user_id, - "session.ended", - { - "session_id": session_id, - "client_id": client_id, - "reason": "websocket_disconnect", - }, - ) + # Mark session as complete (WebSocket disconnected) + await store.mark_complete(session_id, "websocket_disconnect") + sessions_closed += 1 - if cursor == 0: - break + # Notify frontend that session ended + if view.user_id: + publish_sse_event( + view.user_id, + "session.ended", + { + "session_id": session_id, + "client_id": client_id, + "reason": "websocket_disconnect", + }, + ) if sessions_closed > 0: logger.info( @@ -360,6 +529,36 @@ async def cleanup_client_state(client_id: str): else: logger.debug(f"No Redis stream found for client {client_id}") + # Hygiene: bound per-session keys that otherwise leak when a session ends + # without a conversation ever opening (no speech) or via graceless disconnect. + # We set TTLs rather than hard-delete so the speech-detection job keeps its + # grace window to read final results and open a conversation from buffered audio. + for session_id in session_ids: + for key, ttl in ( + ( + f"transcription:results:{session_id}", + 300, + ), # backstop for no-speech sessions + ( + f"conversation:current:{session_id}", + 3600, + ), # backstop for always_persist + ): + try: + if await async_redis.exists(key): + await async_redis.expire(key, ttl) + except Exception as ttl_error: + logger.debug(f"Could not set TTL on {key}: {ttl_error}") + + # The speech_detection_job pointer is a stale 24h key once the session ends; + # shorten it so it can't mislead later lookups (the job itself exits naturally). + try: + await async_redis.expire(f"speech_detection_job:{client_id}", 3600) + except Exception as ttl_error: + logger.debug( + f"Could not shorten speech_detection_job TTL for {client_id}: {ttl_error}" + ) + await async_redis.close() except Exception as session_error: @@ -376,6 +575,13 @@ async def cleanup_client_state(client_id: str): else: logger.warning(f"Client {client_id} was not found for cleanup") + # Stamp the device's last_seen in the registry so the Network page shows an + # accurate "last seen" once it's offline (the live ClientState is now gone). + try: + await touch_client_last_seen(client_id) + except Exception as e: + logger.debug(f"Could not stamp last_seen for {client_id}: {e}") + # Shared helper functions for WebSocket handlers async def _setup_websocket_connection( @@ -402,28 +608,44 @@ async def _setup_websocket_connection( await ws.accept() # Authenticate user after accepting connection - user = await websocket_auth(ws, token) + user, auth_failure_reason = await websocket_auth(ws, token) if not user: + # Build specific error message based on failure reason + if auth_failure_reason == "token_expired": + error_type = "token_expired" + message = "Your session has expired. Please log in again." + close_reason = "Token expired" + elif auth_failure_reason == "user_not_found": + error_type = "user_not_found" + message = "User account not found or inactive. Please log in again." + close_reason = "User not found" + else: + error_type = "authentication_failed" + message = "Authentication failed. Please log in again and ensure your token is valid." + close_reason = "Authentication failed" + # Send error message to client before closing try: error_msg = ( json.dumps( { "type": "error", - "error": "authentication_failed", - "message": "Authentication failed. Please log in again and ensure your token is valid.", + "error": error_type, + "message": message, "code": 1008, } ) + "\n" ) await ws.send_text(error_msg) - application_logger.info("Sent authentication error message to client") + application_logger.info( + f"Sent auth error to client: {error_type} ({close_reason})" + ) except Exception as send_error: application_logger.warning(f"Failed to send error message: {send_error}") # Close connection with appropriate code - await ws.close(code=1008, reason="Authentication failed") + await ws.close(code=1008, reason=close_reason) return None, None, None # Generate proper client_id using user and device_name @@ -438,7 +660,15 @@ async def _setup_websocket_connection( # Send ready message to confirm connection is established try: ready_msg = ( - json.dumps({"type": "ready", "message": "WebSocket connection established"}) + json.dumps( + { + "type": "ready", + "message": "WebSocket connection established", + # The resolved client_id lets the client scope per-client signals + # (wake-word SSE feedback) to its own device — see webui useSSE. + "client_id": client_id, + } + ) + "\n" ) await ws.send_text(ready_msg) @@ -480,7 +710,7 @@ async def _initialize_streaming_session( f"🔴 BACKEND: _initialize_streaming_session called for {client_id}" ) - if hasattr(client_state, "stream_session_id"): + if client_state.stream_session_id is not None: application_logger.debug(f"Session already initialized for {client_id}") return None @@ -492,8 +722,6 @@ async def _initialize_streaming_session( ) # Determine transcription provider from config.yml - from advanced_omi_backend.model_registry import get_models_registry - registry = get_models_registry() if not registry: raise ValueError( @@ -527,19 +755,11 @@ async def _initialize_streaming_session( ) # Store audio format in Redis session (not in ClientState) - import json - - from advanced_omi_backend.services.audio_stream.producer import ( - get_audio_stream_producer, + await audio_stream_producer.store.set_audio_format( + client_state.stream_session_id, audio_format ) - session_key = f"audio:session:{client_state.stream_session_id}" - redis_client = audio_stream_producer.redis_client - await redis_client.hset(session_key, "audio_format", json.dumps(audio_format)) - # Enqueue streaming jobs (speech detection + audio persistence) - from advanced_omi_backend.controllers.queue_controller import start_streaming_jobs - job_ids = start_streaming_jobs( session_id=client_state.stream_session_id, user_id=user_id, client_id=client_id ) @@ -590,7 +810,7 @@ async def _finalize_streaming_session( user_email: User email client_id: Client ID """ - if not hasattr(client_state, "stream_session_id"): + if client_state.stream_session_id is None: application_logger.debug(f"No active session to finalize for {client_id}") return @@ -598,7 +818,7 @@ async def _finalize_streaming_session( try: # Flush any remaining buffered audio - audio_format = getattr(client_state, "stream_audio_format", {}) + audio_format = client_state.stream_audio_format await audio_stream_producer.flush_session_buffer( session_id=session_id, sample_rate=audio_format.get("rate", 16000), @@ -616,9 +836,8 @@ async def _finalize_streaming_session( # Store markers in Redis so open_conversation_job can persist them if client_state.markers: - session_key = f"audio:session:{session_id}" - await audio_stream_producer.redis_client.hset( - session_key, "markers", json.dumps(client_state.markers) + await audio_stream_producer.store.set_markers( + session_id, client_state.markers ) client_state.markers.clear() @@ -643,8 +862,7 @@ async def _finalize_streaming_session( # Clear session state from ClientState (only stream_session_id is stored there now) # All other session metadata lives in Redis (single source of truth) - if hasattr(client_state, "stream_session_id"): - delattr(client_state, "stream_session_id") + client_state.stream_session_id = None except Exception as finalize_error: application_logger.error( @@ -675,7 +893,7 @@ async def _publish_audio_to_stream( channels: Number of channels sample_width: Bytes per sample """ - if not hasattr(client_state, "stream_session_id"): + if client_state.stream_session_id is None: application_logger.warning( f"⚠️ Received audio chunk before session initialized for {client_id}" ) @@ -778,7 +996,7 @@ async def _handle_streaming_mode_audio( """ # Initialize session if needed subscriber_task = None - if not hasattr(client_state, "stream_session_id"): + if client_state.stream_session_id is None: subscriber_task = await _initialize_streaming_session( client_state, audio_stream_producer, @@ -816,12 +1034,10 @@ async def _handle_batch_mode_audio( audio_format: Audio format dict client_id: Client ID """ - # Initialize batch accumulator if needed - if not hasattr(client_state, "batch_audio_chunks"): - client_state.batch_audio_chunks = [] + # Capture the audio format on the first chunk of the connection's first batch + if not client_state.batch_started: + client_state.batch_started = True client_state.batch_audio_format = audio_format - client_state.batch_audio_bytes = 0 # Track total bytes - client_state.batch_chunks_processed = 0 # Track how many batches processed application_logger.info(f"📦 Started batch audio accumulation for {client_id}") # Accumulate audio @@ -894,7 +1110,7 @@ async def _handle_audio_chunk( Returns: Interim results subscriber task if websocket provided and streaming mode, None otherwise """ - recording_mode = getattr(client_state, "recording_mode", "batch") + recording_mode = client_state.recording_mode if recording_mode == "streaming": return await _handle_streaming_mode_audio( @@ -932,8 +1148,6 @@ async def _handle_audio_session_start( Returns: (audio_streaming_flag, recording_mode) """ - from advanced_omi_backend.services.transcription import is_transcription_available - recording_mode = audio_format.get("mode", "batch") application_logger.info( @@ -1023,7 +1237,7 @@ async def _handle_audio_session_stop( Returns: False to switch back to control mode """ - recording_mode = getattr(client_state, "recording_mode", "batch") + recording_mode = client_state.recording_mode application_logger.info( f"🛑 Audio session stopped for {client_id} (mode: {recording_mode})" ) @@ -1057,15 +1271,14 @@ async def _handle_button_event( user_id: User ID client_id: Client ID """ - from advanced_omi_backend.plugins.events import BUTTON_STATE_TO_EVENT, ButtonState - from advanced_omi_backend.services.plugin_service import get_plugin_router - timestamp = time.time() - audio_uuid = client_state.current_audio_uuid + # The live conversation id is assigned later in the RQ pipeline and is not + # tracked on ClientState; markers carry the session id instead. + session_id = client_state.stream_session_id application_logger.info( f"🔘 Button event from {client_id}: {button_state} " - f"(audio_uuid={audio_uuid})" + f"(session_id={session_id})" ) # Store marker on client state for later persistence to conversation @@ -1073,7 +1286,8 @@ async def _handle_button_event( "type": "button_event", "state": button_state, "timestamp": timestamp, - "audio_uuid": audio_uuid, + "audio_uuid": None, # assigned later in the pipeline, not on ClientState + "session_id": session_id, "client_id": client_id, } client_state.add_marker(marker) @@ -1099,13 +1313,50 @@ async def _handle_button_event( data={ "state": button_state_enum.value, "timestamp": timestamp, - "audio_uuid": audio_uuid, - "session_id": getattr(client_state, "stream_session_id", None), + "audio_uuid": None, # assigned later in the pipeline, not on ClientState + "session_id": session_id, "client_id": client_id, }, ) +async def _handle_dial_event( + client_state, + direction: str, + user_id: str, + client_id: str, +) -> None: + """Handle a rotary-dial event (CW/CCW) from the device. + + During an open wake follow-up window, a detent is a *physical* follow-up that + nudges the just-controlled lights (warmer/cooler or brighter/dimmer). Outside a + window it's a no-op here — the device may still use the dial locally (e.g. for + volume). Best-effort: never breaks the audio loop. + """ + session_id = client_state.stream_session_id + router = get_plugin_router() + if not router or not session_id: + return + + redis_client = create_async_redis(decode_responses=True) + try: + result = await handle_dial_followup( + redis_client, + router, + user_id=user_id, + session_id=session_id, + client_id=client_id, + direction=direction, + ) + application_logger.info( + f"🎛️ Dial event from {client_id}: {direction} (session={session_id}) -> {result}" + ) + except Exception as e: # noqa: BLE001 - dial feedback must never break the loop + application_logger.warning(f"Dial event handling failed for {client_id}: {e}") + finally: + await redis_client.aclose() + + async def _create_batch_conversation_and_enqueue( client_state, user_id: str, @@ -1131,18 +1382,8 @@ async def _create_batch_conversation_and_enqueue( Returns: conversation_id on success, None on failure. """ - from advanced_omi_backend.controllers.queue_controller import ( - JOB_RESULT_TTL, - transcription_queue, - ) - from advanced_omi_backend.models.conversation import create_conversation - from advanced_omi_backend.utils.audio_chunk_utils import convert_audio_to_chunks - from advanced_omi_backend.workers.transcription_jobs import ( - transcribe_full_audio_job, - ) - complete_audio = b"".join(client_state.batch_audio_chunks) - audio_format = getattr(client_state, "batch_audio_format", {}) + audio_format = client_state.batch_audio_format sample_rate = audio_format.get("rate", 16000) sample_width = audio_format.get("width", 2) channels = audio_format.get("channels", 1) @@ -1202,10 +1443,6 @@ async def _create_batch_conversation_and_enqueue( # Optionally chain post-conversation jobs if enqueue_post_jobs: - from advanced_omi_backend.controllers.queue_controller import ( - start_post_conversation_jobs, - ) - job_ids = start_post_conversation_jobs( conversation_id=conversation_id, user_id=None, @@ -1226,10 +1463,7 @@ async def _process_rolling_batch( client_state, user_id: str, user_email: str, client_id: str, batch_number: int ) -> None: """Process accumulated batch audio as a rolling segment.""" - if ( - not hasattr(client_state, "batch_audio_chunks") - or not client_state.batch_audio_chunks - ): + if not client_state.batch_audio_chunks: application_logger.warning(f"⚠️ No audio chunks to process for rolling batch") return @@ -1252,10 +1486,7 @@ async def _process_batch_audio_complete( client_state, user_id: str, user_email: str, client_id: str ) -> None: """Process completed batch audio: create conversation, enqueue full job chain.""" - if ( - not hasattr(client_state, "batch_audio_chunks") - or not client_state.batch_audio_chunks - ): + if not client_state.batch_audio_chunks: application_logger.warning( f"⚠️ Batch mode: No audio chunks accumulated for {client_id}" ) @@ -1339,6 +1570,7 @@ async def _websocket_session(ws, token, device_name, connection_type): client_id = None interim_holder = [None] # mutable so inner loop can update + downlink_task = None try: client_id, client_state, user = await _setup_websocket_connection( @@ -1348,13 +1580,13 @@ async def _websocket_session(ws, token, device_name, connection_type): yield None return - # Store user context on client state up front (shared by all handlers) - client_state.user_id = user.user_id - client_state.user_email = user.email - client_state.client_id = client_id - + # user_id / user_email / client_id are already set on client_state by + # create_client_state() during connection setup. audio_stream_producer = get_audio_stream_producer() + # Forward backend→device control messages (tones, TTS) for this device. + downlink_task = asyncio.create_task(subscribe_to_device_downlink(ws, client_id)) + yield (client_id, client_state, user, audio_stream_producer, interim_holder) except WebSocketDisconnect: @@ -1366,7 +1598,29 @@ async def _websocket_session(ws, token, device_name, connection_type): f"❌ {connection_type} WebSocket error for client {client_id}: {e}", exc_info=True, ) + # Surface error-disconnects as a first-class client event (the catch-all log + # handler also captures the line above, but without client_id / category). + record_event_sync( + severity="error", + category="client", + source=client_id or "unknown", + title=f"{connection_type} client disconnected on error", + detail=str(e), + traceback=traceback.format_exc(), + client_id=client_id, + metadata={"connection_type": connection_type}, + ) finally: + if downlink_task and not downlink_task.done(): + downlink_task.cancel() + try: + await downlink_task + except asyncio.CancelledError: + pass + except Exception as task_error: + application_logger.error( + f"Error cancelling downlink task for {client_id}: {task_error}" + ) await _cleanup_websocket_connection( client_id, pending_client_id, interim_holder[0] ) @@ -1393,6 +1647,7 @@ async def handle_omi_websocket( while True: # Parse Wyoming protocol header, payload = await parse_wyoming_protocol(ws) + client_state.touch() # liveness: inbound activity keeps this client fresh if header["type"] == "audio-start": application_logger.info( @@ -1458,6 +1713,18 @@ async def handle_omi_websocket( packet_count = 0 total_bytes = 0 + elif header["type"] == "ping": + # App-level heartbeat. The mobile client (useAudioStreamer) pings every + # 25s and closes the socket as a half-open "zombie" if it gets no pong + # within 2 heartbeats (~50s). The OMI/opus path previously had no pong + # reply (only the PCM handler did), so opus clients dropped + reconnected + # every ~50s — churning the streaming-transcription provider connection + # and stranding real speech as "transcription service did not respond". + application_logger.debug( + f"🏓 Received ping from OMI client {client_id}" + ) + await ws.send_json({"type": "pong"}) + elif header["type"] == "button-event": button_data = header.get("data", {}) button_state = button_data.get("state", "unknown") @@ -1465,6 +1732,13 @@ async def handle_omi_websocket( client_state, button_state, user.user_id, client_id ) + elif header["type"] == "dial-event": + dial_data = header.get("data", {}) + direction = dial_data.get("direction", "") + await _handle_dial_event( + client_state, direction, user.user_id, client_id + ) + else: application_logger.debug( f"Ignoring Wyoming event type '{header['type']}' for OMI client {client_id}" @@ -1495,6 +1769,7 @@ async def handle_pcm_websocket( f"📨 About to receive control message for {client_id}" ) header, payload = await parse_wyoming_protocol(ws) + client_state.touch() # liveness: inbound activity keeps this client fresh application_logger.debug( f"✅ Received message type: {header.get('type')} for {client_id}" ) @@ -1536,6 +1811,8 @@ async def handle_pcm_websocket( elif header["type"] == "ping": application_logger.debug(f"🏓 Received ping from {client_id}") + # Reply so the client can detect a half-open (zombie) socket. + await ws.send_json({"type": "pong"}) continue elif header["type"] == "button-event": @@ -1546,6 +1823,14 @@ async def handle_pcm_websocket( ) continue + elif header["type"] == "dial-event": + dial_data = header.get("data", {}) + direction = dial_data.get("direction", "") + await _handle_dial_event( + client_state, direction, user.user_id, client_id + ) + continue + else: application_logger.debug( f"Ignoring Wyoming control event type '{header['type']}' for {client_id}" @@ -1559,13 +1844,14 @@ async def handle_pcm_websocket( ) try: - message = await ws.receive() + message = await receive_with_idle_timeout(ws) + client_state.touch() # liveness: inbound activity keeps this client fresh if ( "type" in message and message["type"] == "websocket.disconnect" ): - code = message.get("code", 1000) + code = message.get("code") reason = message.get("reason", "") application_logger.info( f"🔌 WebSocket disconnect during audio streaming for {client_id}. Code: {code}, Reason: {reason}" @@ -1590,6 +1876,8 @@ async def handle_pcm_websocket( application_logger.debug( f"🏓 Received ping during streaming from {client_id}" ) + # Reply so the client can detect a half-open (zombie) socket. + await ws.send_json({"type": "pong"}) continue elif control_header.get("type") == "audio-start": application_logger.info( @@ -1601,7 +1889,9 @@ async def handle_pcm_websocket( "payload_length" ) if payload_length and payload_length > 0: - payload_msg = await ws.receive() + payload_msg = await receive_with_idle_timeout( + ws + ) if "bytes" in payload_msg: audio_data = payload_msg["bytes"] packet_count += 1 @@ -1645,6 +1935,16 @@ async def handle_pcm_websocket( client_id, ) continue + elif control_header.get("type") == "dial-event": + dial_data = control_header.get("data", {}) + direction = dial_data.get("direction", "") + await _handle_dial_event( + client_state, + direction, + user.user_id, + client_id, + ) + continue else: application_logger.warning( f"Unknown control message during streaming: {control_header.get('type')}" @@ -1686,11 +1986,28 @@ async def handle_pcm_websocket( ) continue + except WebSocketDisconnect as disconnect: + # Expected: clean close, or an idle/zombie socket reaped by + # receive_with_idle_timeout. Not an error — exit so `finally` + # runs the normal cleanup. (WebSocketDisconnect subclasses + # Exception, so it must be caught before the generic handler.) + application_logger.info( + f"🔌 WebSocket disconnect during audio streaming for " + f"{client_id}. Code: {disconnect.code}, Reason: {disconnect.reason}" + ) + break except Exception as streaming_error: application_logger.error( - f"Error in audio streaming mode: {streaming_error}" + f"Error in audio streaming mode for {client_id}: " + f"{type(streaming_error).__name__}: {streaming_error}", + exc_info=True, ) - if "disconnect" in str(streaming_error).lower(): + error_text = f"{type(streaming_error).__name__} {streaming_error}".lower() + if ( + "disconnect" in error_text + or "closed" in error_text + or "receive" in error_text + ): break continue diff --git a/backends/advanced/src/advanced_omi_backend/cron_scheduler.py b/backends/advanced/src/advanced_omi_backend/cron_scheduler.py index 3e3f6b8b..950374b3 100644 --- a/backends/advanced/src/advanced_omi_backend/cron_scheduler.py +++ b/backends/advanced/src/advanced_omi_backend/cron_scheduler.py @@ -21,6 +21,7 @@ from croniter import croniter from advanced_omi_backend.config_loader import load_config, save_config_section +from advanced_omi_backend.redis_factory import create_async_redis logger = logging.getLogger(__name__) @@ -80,10 +81,7 @@ def __init__(self) -> None: async def start(self) -> None: """Load config, restore state from Redis, and start the scheduler loop.""" - import os - - redis_url = os.getenv("REDIS_URL", "redis://localhost:6379/0") - self._redis = aioredis.from_url(redis_url, decode_responses=True) + self._redis = create_async_redis(decode_responses=True) self._load_jobs_from_config() await self._restore_state() diff --git a/backends/advanced/src/advanced_omi_backend/heartbeat.py b/backends/advanced/src/advanced_omi_backend/heartbeat.py new file mode 100644 index 00000000..6b026ad2 --- /dev/null +++ b/backends/advanced/src/advanced_omi_backend/heartbeat.py @@ -0,0 +1,36 @@ +"""Liveness heartbeats for the custom stream-consumer workers. + +RQ workers register and heartbeat with RQ itself (observable via ``Worker.all()``), +so a dead/wedged RQ worker drops out of the registration count. The custom +stream-consumer workers (``streaming-stt``, ``windowed-batch``, +``wakeword-dispatch``) are plain processes, where "process is alive" does NOT +prove the main loop is still turning. Each beats once per main-loop iteration; +the workers-container healthcheck (``worker_healthcheck.py``) flags a stale +heartbeat so a wedged-but-alive consumer stops reporting healthy. +""" + +import time + +HEARTBEAT_KEY_PREFIX = "worker:heartbeat:" + +# Keep the key well beyond the staleness window: a restarted worker refreshes it, +# and a permanently-removed worker's key eventually expires on its own — but a +# wedge (loop stops beating) is detectable long before that. +HEARTBEAT_TTL_SECONDS = 3600 + + +async def beat(redis_client, worker_name: str) -> None: + """Record that ``worker_name``'s main loop just iterated. Best-effort. + + A heartbeat write must never break the work loop, so all errors are + swallowed — a transient Redis blip simply means a missed beat, and the + staleness window is generous enough to tolerate that. + """ + try: + await redis_client.set( + f"{HEARTBEAT_KEY_PREFIX}{worker_name}", + str(time.time()), + ex=HEARTBEAT_TTL_SECONDS, + ) + except Exception: # noqa: BLE001 - heartbeat is best-effort + pass diff --git a/backends/advanced/src/advanced_omi_backend/llm_client.py b/backends/advanced/src/advanced_omi_backend/llm_client.py index 78346a32..e13e5377 100644 --- a/backends/advanced/src/advanced_omi_backend/llm_client.py +++ b/backends/advanced/src/advanced_omi_backend/llm_client.py @@ -10,8 +10,13 @@ from abc import ABC, abstractmethod from typing import Any, Dict, Optional +import openai + from advanced_omi_backend.model_registry import get_models_registry -from advanced_omi_backend.openai_factory import create_openai_client +from advanced_omi_backend.openai_factory import ( + create_openai_client, + model_supports_temperature, +) from advanced_omi_backend.services.memory.config import ( load_config_yml as _load_root_config, ) @@ -92,11 +97,13 @@ def generate( model_name = model or self.model temp = temperature if temperature is not None else self.temperature - response = self.client.chat.completions.create( - model=model_name, - messages=[{"role": "user", "content": prompt}], - temperature=temp, - ) + params: dict[str, Any] = { + "model": model_name, + "messages": [{"role": "user", "content": prompt}], + } + if model_supports_temperature(model_name): + params["temperature"] = temp + response = self.client.chat.completions.create(**params) return response.choices[0].message.content.strip() except Exception as e: self.logger.error(f"Error generating completion: {e}") @@ -111,19 +118,20 @@ def chat_with_tools( ): """Chat completion with tool/function calling support. Returns raw response object.""" model_name = model or self.model - params = { + params: dict[str, Any] = { "model": model_name, "messages": messages, - "temperature": temperature if temperature is not None else self.temperature, } + if model_supports_temperature(model_name): + params["temperature"] = ( + temperature if temperature is not None else self.temperature + ) if tools: params["tools"] = tools return self.client.chat.completions.create(**params) async def health_check(self) -> Dict: """Check OpenAI-compatible service health by calling models.list().""" - import openai as _openai - if not self.api_key or not self.base_url or not self.model: return { "status": "⚠️ Configuration incomplete", @@ -143,7 +151,7 @@ async def health_check(self) -> Dict: "base_url": self.base_url, "default_model": self.model, } - except _openai.AuthenticationError: + except openai.AuthenticationError: return { "status": "❌ Auth Failed — check API key", "healthy": False, @@ -157,7 +165,7 @@ async def health_check(self) -> Dict: "base_url": self.base_url, "default_model": self.model, } - except _openai.APIConnectionError: + except openai.APIConnectionError: return { "status": "❌ Connection Failed — service unreachable", "healthy": False, @@ -195,7 +203,7 @@ def create_client() -> LLMClient: params = llm_def.model_params or {} return OpenAILLMClient( api_key=llm_def.api_key, - base_url=llm_def.model_url, + base_url=llm_def.resolved_url(), model=llm_def.model_name, temperature=params.get("temperature", 0.1), ) @@ -226,12 +234,88 @@ def reset_llm_client(): _llm_client = None +# Transport-level failures that warrant one retry against defaults.fallback_llm. +# APITimeoutError subclasses APIConnectionError; InternalServerError covers 5xx. +# Auth/4xx errors are config problems the fallback would not fix. +_FALLBACK_EXCEPTIONS = ( + openai.APIConnectionError, + openai.InternalServerError, + asyncio.TimeoutError, +) + + +def _get_fallback_model_def(): + """ModelDef named by defaults.fallback_llm, or None when unset/invalid or + identical to defaults.llm (retrying the same model is pointless).""" + registry = get_models_registry() + if not registry: + return None + fb_name = registry.defaults.get("fallback_llm") + if not fb_name or fb_name == registry.defaults.get("llm"): + return None + fb = registry.get_by_name(fb_name) + if not fb or fb.model_type != "llm": + return None + return fb + + +def _create_fallback_client() -> Optional[OpenAILLMClient]: + """Build a one-off client for the fallback LLM (singleton-path retries).""" + fb = _get_fallback_model_def() + if not fb: + return None + try: + params = fb.model_params or {} + return OpenAILLMClient( + api_key=fb.api_key, + base_url=fb.resolved_url(), + model=fb.model_name, + temperature=params.get("temperature", 0.1), + ) + except Exception as e: # noqa: BLE001 - fallback must never mask the original error + logger.error(f"Failed to create fallback LLM client: {e}") + return None + + +async def _generate_with_op( + op, + prompt: str, + model: str | None, + temperature: float | None, + operation: str, +) -> str: + """One generation attempt against a ResolvedLLMOperation.""" + client = op.get_client(is_async=True) + api_params = op.to_api_params() + if temperature is not None: + api_params["temperature"] = temperature + if model is not None: + api_params["model"] = model + if not model_supports_temperature(api_params.get("model")): + api_params.pop("temperature", None) + api_params["messages"] = [{"role": "user", "content": prompt}] + response = await client.chat.completions.create(**api_params) + choice = response.choices[0] + content = (choice.message.content or "").strip() + if not content: + # Reasoning models can consume the whole completion budget on + # reasoning and return empty content with finish_reason=length. + raise RuntimeError( + f"LLM returned empty content for operation {operation!r} " + f"(model={api_params.get('model')}, " + f"finish_reason={choice.finish_reason}) — if 'length', " + f"raise the operation's max_tokens" + ) + return content + + # Async wrapper for blocking LLM operations async def async_generate( prompt: str, model: str | None = None, temperature: float | None = None, operation: str | None = None, + default_model_type: str = "llm", ) -> str: """Async wrapper for LLM text generation. @@ -240,29 +324,58 @@ async def async_generate( The resolved config determines model, temperature, max_tokens, etc. Explicit ``model``/``temperature`` kwargs still override the resolved values. + If the primary model is unreachable (connection failure, timeout, 5xx) and + ``defaults.fallback_llm`` names a different model, the call is retried once + against the fallback. + Tracing is handled automatically by the OTEL instrumentor; use ``set_otel_session()`` at job boundaries to group calls by session. """ if operation: registry = get_models_registry() if registry: - op = registry.get_llm_operation(operation) - client = op.get_client(is_async=True) - api_params = op.to_api_params() - if temperature is not None: - api_params["temperature"] = temperature - if model is not None: - api_params["model"] = model - api_params["messages"] = [{"role": "user", "content": prompt}] - response = await client.chat.completions.create(**api_params) - return response.choices[0].message.content.strip() + op = registry.get_llm_operation( + operation, default_model_type=default_model_type + ) + try: + return await _generate_with_op( + op, prompt, model, temperature, operation + ) + except _FALLBACK_EXCEPTIONS as e: + fb_op = registry.get_fallback_llm_operation( + operation, primary=op, default_model_type=default_model_type + ) + if fb_op is None: + raise + logger.warning( + f"Primary LLM {op.model_name!r} failed for operation " + f"{operation!r} ({e}); retrying with fallback LLM " + f"{fb_op.model_name!r}" + ) + # No explicit model override on the retry — it would repoint + # the fallback endpoint back at the (dead) primary model. + return await _generate_with_op( + fb_op, prompt, None, temperature, operation + ) # Fallback: use singleton client client = get_llm_client() loop = asyncio.get_running_loop() - return await loop.run_in_executor( - None, lambda: client.generate(prompt, model, temperature) - ) + try: + return await loop.run_in_executor( + None, lambda: client.generate(prompt, model, temperature) + ) + except _FALLBACK_EXCEPTIONS as e: + fb_client = _create_fallback_client() + if fb_client is None: + raise + logger.warning( + f"Primary LLM failed ({e}); retrying with fallback LLM " + f"{fb_client.model!r}" + ) + return await loop.run_in_executor( + None, lambda: fb_client.generate(prompt, None, temperature) + ) async def async_chat_with_tools( @@ -275,32 +388,114 @@ async def async_chat_with_tools( """Async wrapper for chat completion with tool calling. When ``operation`` is provided, parameters are resolved from config. + Unreachable-primary calls retry once against ``defaults.fallback_llm`` + (see :func:`async_generate`). Tracing is handled automatically by the OTEL instrumentor. """ + + async def _chat_once(op, model_override): + client = op.get_client(is_async=True) + api_params = op.to_api_params() + if temperature is not None: + api_params["temperature"] = temperature + if model_override is not None: + api_params["model"] = model_override + api_params["messages"] = messages + if tools: + api_params["tools"] = tools + return await client.chat.completions.create(**api_params) + if operation: registry = get_models_registry() if registry: op = registry.get_llm_operation(operation) - client = op.get_client(is_async=True) - api_params = op.to_api_params() - if temperature is not None: - api_params["temperature"] = temperature - if model is not None: - api_params["model"] = model - api_params["messages"] = messages - if tools: - api_params["tools"] = tools - return await client.chat.completions.create(**api_params) + try: + return await _chat_once(op, model) + except _FALLBACK_EXCEPTIONS as e: + fb_op = registry.get_fallback_llm_operation(operation, primary=op) + if fb_op is None: + raise + logger.warning( + f"Primary LLM {op.model_name!r} failed for operation " + f"{operation!r} ({e}); retrying with fallback LLM " + f"{fb_op.model_name!r}" + ) + return await _chat_once(fb_op, None) # Fallback: use singleton client client = get_llm_client() loop = asyncio.get_running_loop() - return await loop.run_in_executor( - None, lambda: client.chat_with_tools(messages, tools, model, temperature) - ) + try: + return await loop.run_in_executor( + None, lambda: client.chat_with_tools(messages, tools, model, temperature) + ) + except _FALLBACK_EXCEPTIONS as e: + fb_client = _create_fallback_client() + if fb_client is None: + raise + logger.warning( + f"Primary LLM failed ({e}); retrying with fallback LLM " + f"{fb_client.model!r}" + ) + return await loop.run_in_executor( + None, lambda: fb_client.chat_with_tools(messages, tools, None, temperature) + ) async def async_health_check() -> Dict: """Async LLM health check.""" client = get_llm_client() return await client.health_check() + + +async def _async_health_check_named_default( + defaults_key: str, label: str +) -> Optional[Dict]: + """Health check for a SEPARATE LLM named by ``defaults[defaults_key]``. + + Returns None when the key is unset or points at the same model as + ``defaults.llm`` (that model is already covered by :func:`async_health_check`). + """ + registry = get_models_registry() + if not registry: + return None + name = registry.defaults.get(defaults_key) + if not name or name == registry.defaults.get("llm"): + return None + model_def = registry.get_by_name(name) + if not model_def: + return None + + url = model_def.resolved_url() + result = {"base_url": url, "default_model": model_def.model_name} + if not model_def.api_key or not url or not model_def.model_name: + return {**result, "status": "⚠️ Configuration incomplete", "healthy": False} + try: + client = create_openai_client( + api_key=model_def.api_key, base_url=url, is_async=True + ) + await asyncio.wait_for(client.models.list(), timeout=5.0) + return {**result, "status": "✅ Connected", "healthy": True} + except openai.AuthenticationError: + return {**result, "status": "❌ Auth Failed — check API key", "healthy": False} + except asyncio.TimeoutError: + return {**result, "status": "❌ Connection Timeout", "healthy": False} + except openai.APIConnectionError: + return { + **result, + "status": "❌ Connection Failed — service unreachable", + "healthy": False, + } + except Exception as e: # noqa: BLE001 + logger.error(f"{label} health check failed: {e}") + return {**result, "status": f"❌ Error: {e}", "healthy": False} + + +async def async_health_check_fast() -> Optional[Dict]: + """Health check for a SEPARATE fast LLM, or None if none is configured.""" + return await _async_health_check_named_default("fast_llm", "Fast LLM") + + +async def async_health_check_fallback() -> Optional[Dict]: + """Health check for a SEPARATE fallback LLM, or None if none is configured.""" + return await _async_health_check_named_default("fallback_llm", "Fallback LLM") diff --git a/backends/advanced/src/advanced_omi_backend/main.py b/backends/advanced/src/advanced_omi_backend/main.py index 5b0f3f05..2bde95f5 100644 --- a/backends/advanced/src/advanced_omi_backend/main.py +++ b/backends/advanced/src/advanced_omi_backend/main.py @@ -25,6 +25,13 @@ logging.basicConfig(level=logging.INFO) logger = logging.getLogger("advanced-backend") +# Catch-all: record every ERROR/CRITICAL log as a system event (best-effort). +from advanced_omi_backend.services.observability.log_handler import ( # noqa: E402 + install_system_event_log_handler, +) + +install_system_event_log_handler() + # Create FastAPI application using the app factory pattern app = create_app() diff --git a/backends/advanced/src/advanced_omi_backend/middleware/app_middleware.py b/backends/advanced/src/advanced_omi_backend/middleware/app_middleware.py index 693ce0da..e9256a88 100644 --- a/backends/advanced/src/advanced_omi_backend/middleware/app_middleware.py +++ b/backends/advanced/src/advanced_omi_backend/middleware/app_middleware.py @@ -11,9 +11,10 @@ from fastapi import FastAPI, HTTPException, Request from fastapi.middleware.cors import CORSMiddleware -from fastapi.responses import JSONResponse, StreamingResponse +from fastapi.responses import JSONResponse from pymongo.errors import ConnectionFailure, PyMongoError from starlette.middleware.base import BaseHTTPMiddleware +from starlette.responses import Response from advanced_omi_backend.app_config import get_app_config @@ -44,11 +45,12 @@ class RequestLoggingMiddleware(BaseHTTPMiddleware): """ Middleware to log API requests and JSON responses. - Excludes: - - Authentication endpoints (login, logout) - - WebSocket connections - - Binary file responses (audio, images) - - Streaming responses + Only small ``application/json`` bodies are buffered for pretty-printing. + Everything else (file downloads, audio, SSE, huge JSON) gets a status + line only — buffering a large body here happens ON the event loop and + has frozen the whole backend before (163MB zip → minutes at 100% CPU). + Note: ``call_next`` always returns a streaming wrapper, so response + *type* can't be used to detect file responses — only headers can. """ # Paths to exclude from logging @@ -66,13 +68,8 @@ class RequestLoggingMiddleware(BaseHTTPMiddleware): "/api/events/stream", # SSE endpoint — BaseHTTPMiddleware breaks streaming } - # Binary content types to exclude - BINARY_CONTENT_TYPES = { - "audio/", - "image/", - "video/", - "application/octet-stream", - } + # Bodies larger than this are never buffered/logged, even JSON. + MAX_LOGGED_BODY_BYTES = 256 * 1024 def should_log_request(self, path: str) -> bool: """Determine if request should be logged.""" @@ -91,16 +88,18 @@ def should_log_request(self, path: str) -> bool: return True - def should_log_response_body(self, content_type: str) -> bool: - """Determine if response body should be logged.""" - if not content_type: - return True - - # Exclude binary content types - for binary_type in self.BINARY_CONTENT_TYPES: - if content_type.startswith(binary_type): - return False - + def should_log_response_body( + self, content_type: str, content_length: Optional[str] + ) -> bool: + """Buffer-and-log only small JSON bodies.""" + if not content_type.startswith("application/json"): + return False + if content_length: + try: + if int(content_length) > self.MAX_LOGGED_BODY_BYTES: + return False + except ValueError: + pass return True async def dispatch(self, request: Request, call_next): @@ -123,44 +122,46 @@ async def dispatch(self, request: Request, call_next): # Calculate duration duration_ms = (time.time() - start_time) * 1000 - # Check if we should log response body - content_type = response.headers.get("content-type", "") - should_log_body = self.should_log_response_body(content_type) - - # Skip body logging for streaming responses - if isinstance(response, StreamingResponse): - request_logger.info( - f"← {request.method} {path} - {response.status_code} " - f"(streaming response) - {duration_ms:.2f}ms" - ) - return response + should_log_body = self.should_log_response_body( + response.headers.get("content-type", ""), + response.headers.get("content-length"), + ) - # For non-streaming responses, try to extract and log JSON body if should_log_body and response.status_code != 204: # No content try: - # Read response body - response_body = b"" + # Read response body (bytearray: linear-time accumulation) + buffer = bytearray() async for chunk in response.body_iterator: - response_body += chunk - - # Try to parse as JSON for pretty printing - try: - json_body = json.loads(response_body) - formatted_json = json.dumps(json_body, indent=2) - request_logger.info( - f"← {request.method} {path} - {response.status_code} - {duration_ms:.2f}ms\n" - f"Response body:\n{formatted_json}" - ) - except (json.JSONDecodeError, UnicodeDecodeError): - # Not JSON or not UTF-8, just log the status + buffer.extend(chunk) + if len(buffer) > self.MAX_LOGGED_BODY_BYTES: + # Mis-declared content-length; stop pretty-printing but + # keep consuming so the response can be re-emitted. + async for chunk in response.body_iterator: + buffer.extend(chunk) + break + response_body = bytes(buffer) + + if len(response_body) > self.MAX_LOGGED_BODY_BYTES: request_logger.info( f"← {request.method} {path} - {response.status_code} - {duration_ms:.2f}ms " - f"(non-JSON response)" + f"(json body too large to log: {len(response_body)} bytes)" ) + else: + # Try to parse as JSON for pretty printing + try: + json_body = json.loads(response_body) + formatted_json = json.dumps(json_body, indent=2) + request_logger.info( + f"← {request.method} {path} - {response.status_code} - {duration_ms:.2f}ms\n" + f"Response body:\n{formatted_json}" + ) + except (json.JSONDecodeError, UnicodeDecodeError): + request_logger.info( + f"← {request.method} {path} - {response.status_code} - {duration_ms:.2f}ms " + f"(non-JSON response)" + ) # Recreate response with the body we consumed - from starlette.responses import Response - return Response( content=response_body, status_code=response.status_code, @@ -175,7 +176,7 @@ async def dispatch(self, request: Request, call_next): ) return response else: - # Just log status for responses without body + # Status line only — body passes through untouched (streaming). request_logger.info( f"← {request.method} {path} - {response.status_code} - {duration_ms:.2f}ms" ) @@ -230,11 +231,14 @@ async def http_exception_handler(request: Request, exc: HTTPException): return JSONResponse(status_code=exc.status_code, content={"detail": exc.detail}) -def setup_middleware(app: FastAPI) -> None: +def setup_middleware(app: FastAPI, *, disable_request_logging: bool = False) -> None: """Set up all middleware for the FastAPI application.""" # Add request logging middleware - app.add_middleware(RequestLoggingMiddleware) - logger.info("📝 Request logging middleware enabled") + if not disable_request_logging: + app.add_middleware(RequestLoggingMiddleware) + logger.info("📝 Request logging middleware enabled") + else: + logger.info("📝 Request logging middleware DISABLED (profiling)") setup_cors_middleware(app) setup_exception_handlers(app) diff --git a/backends/advanced/src/advanced_omi_backend/model_registry.py b/backends/advanced/src/advanced_omi_backend/model_registry.py index e7a6ddf5..c5f5af86 100644 --- a/backends/advanced/src/advanced_omi_backend/model_registry.py +++ b/backends/advanced/src/advanced_omi_backend/model_registry.py @@ -10,6 +10,7 @@ from __future__ import annotations import logging +import time from pathlib import Path from typing import Any, Dict, List, Optional @@ -25,7 +26,45 @@ # Import config merging for defaults.yml + config.yml integration # OmegaConf handles environment variable resolution (${VAR:-default} syntax) -from advanced_omi_backend.config import get_config +from advanced_omi_backend.config import get_config, get_config_yml_path +from advanced_omi_backend.openai_factory import create_openai_client, is_reasoning_model + +logger = logging.getLogger(__name__) + +# Tailnet service-URL discovery cache. A model whose model_url is empty but which +# carries a `discovery_service` (e.g. chronicle-asr / chronicle-llm) resolves its URL +# live from minidisc — so a remote ASR/LLM node coming online is picked up without +# re-config. Cached briefly so the hot transcription/LLM paths don't do a minidisc +# lookup on every call. +_DISCOVERY_TTL_SECS = 30.0 +_discovery_cache: Dict[str, tuple[float, str]] = {} + + +def _with_scheme(url: str) -> str: + """Prepend http:// to a scheme-less host:port (env vars often store bare host:port). + Leaves '' and already-schemed URLs (http/https/ws/wss) untouched.""" + if url and "://" not in url: + return "http://" + url + return url + + +def _discover_service_url(service_name: str) -> str: + """Minidisc URL for a chronicle-* service, cached for _DISCOVERY_TTL_SECS. '' if none.""" + now = time.monotonic() + cached = _discovery_cache.get(service_name) + if cached and now - cached[0] < _DISCOVERY_TTL_SECS: + return cached[1] + url = "" + try: + from discovery import resolve_service_url + + url = resolve_service_url(None, service_name, default="") or "" + except Exception: # noqa: BLE001 - discovery is best-effort + url = "" + _discovery_cache[service_name] = (now, url) + if url: + logger.debug("Discovered %s at %s (tailnet)", service_name, url) + return url class ModelDef(BaseModel): @@ -66,6 +105,16 @@ class ModelDef(BaseModel): model_output: Optional[str] = Field( default=None, description="Output format: json, text, vector, etc." ) + thinking: bool = Field( + default=False, + description=( + "Local thinking/reasoning model whose extended thinking is toggled via the " + "chat template (chat_template_kwargs.enable_thinking), NOT the OpenAI " + "top-level `reasoning_effort` (which llama.cpp silently ignores). When set, " + "an operation's reasoning_effort is translated to enable_thinking for this " + "model (e.g. gemma/qwen served by llama.cpp)." + ), + ) embedding_dimensions: Optional[int] = Field( default=None, ge=1, description="Embedding vector dimensions" ) @@ -74,7 +123,48 @@ class ModelDef(BaseModel): ) capabilities: List[str] = Field( default_factory=list, - description="Provider capabilities: word_timestamps, segments, diarization (for STT providers)", + description=( + "Provider capabilities. Output capabilities: word_timestamps, segments, " + "diarization. ASR hint mechanism (mutually exclusive): " + "'keyword_boosting' — accepts a hot-word list as an acoustic recognition " + "hint that biases decoding without leaking into the transcript " + "(Deepgram keyterm, VibeVoice prompt, Parakeet); 'context_prompt' — an " + "LLM-backbone ASR that takes free-form context as prompt text (Gemma 4). " + "context_prompt providers are given the user-authored asr_context only, " + "never the wake-word boost list (which an LLM would echo into output)." + ), + ) + asr_context: Optional[str] = Field( + default=None, + description=( + "Free-form context string for 'context_prompt' STT providers (e.g. a " + "domain/topic description). Informs an LLM-backbone ASR without being " + "transcribed. User overrides are stored under backend.asr.context.." + ), + ) + discovery_service: Optional[str] = Field( + default=None, + description=( + "minidisc service name (e.g. 'chronicle-asr', 'chronicle-llm'). When set " + "and model_url is empty, the base URL is resolved live from the Tailnet, so " + "a remote ASR/LLM node coming online is used without re-config. An explicit " + "model_url (env/config) always takes precedence over discovery." + ), + ) + discovery_default: Optional[str] = Field( + default=None, + description=( + "Fallback base URL used when discovery_service is set but nothing is " + "advertised yet (e.g. a host-local default)." + ), + ) + discovery_path: Optional[str] = Field( + default=None, + description=( + "Path appended to a *discovered* bare host:port (e.g. '/v1' for an " + "OpenAI-compatible LLM). Not applied to an explicit model_url or " + "discovery_default (those are expected to already be complete)." + ), ) @field_validator("model_name", mode="before") @@ -104,6 +194,25 @@ def sanitize_api_key(cls, v: Any) -> Optional[str]: return v return v + def resolved_url(self) -> str: + """Effective base URL, resolving Tailnet discovery when not explicitly set. + + Order: explicit model_url (env/config) → minidisc discovery_service (with + discovery_path appended to the bare host:port) → discovery_default. An + advertised remote node is picked up live (cached), so a 'configure from the + Tailnet later' setup needs no re-config when the node comes online. URLs + without a scheme get http:// prepended. Returns '' when nothing is available. + """ + if self.model_url: + return _with_scheme(self.model_url) + if self.discovery_service: + discovered = _discover_service_url(self.discovery_service) + if discovered: + if self.discovery_path: + discovered = discovered.rstrip("/") + self.discovery_path + return _with_scheme(discovered) + return _with_scheme(self.discovery_default or "") + @model_validator(mode="after") def validate_model(self) -> ModelDef: """Cross-field validation.""" @@ -135,6 +244,9 @@ class LLMOperationConfig(BaseModel): temperature: Optional[float] = None max_tokens: Optional[int] = None response_format: Optional[str] = None # "json" → {"type": "json_object"} + reasoning_effort: Optional[str] = ( + None # "minimal"|"low"|... — reasoning models only + ) class ResolvedLLMOperation(BaseModel): @@ -150,6 +262,7 @@ class ResolvedLLMOperation(BaseModel): temperature: float max_tokens: Optional[int] = None response_format: Optional[Dict[str, Any]] = None # {"type": "json_object"} or None + reasoning_effort: Optional[str] = None # OpenAI reasoning models OR thinking models @property def model_name(self) -> str: @@ -161,21 +274,56 @@ def api_key(self) -> Optional[str]: @property def base_url(self) -> str: - return self.model_def.model_url + return self.model_def.resolved_url() def to_api_params(self) -> Dict[str, Any]: """Returns kwargs for client.chat.completions.create(). - Works for OpenAI, Ollama, Groq — all OpenAI-compatible. + Works for OpenAI, Ollama, Groq — all OpenAI-compatible. For OpenAI + reasoning-class models (gpt-5*, o1/o3/o4), `temperature` is omitted, + `max_tokens` is renamed to `max_completion_tokens`, and `reasoning_effort` + is forwarded as a top-level param — matching OpenAI's stricter API surface. + + Local *thinking* models (``model_def.thinking``, e.g. gemma/qwen served by + llama.cpp) silently IGNORE the OpenAI top-level `reasoning_effort`, so an + operation's reasoning_effort is instead translated to the chat-template switch + the server actually honors (``chat_template_kwargs.enable_thinking``), sent via + the OpenAI SDK's ``extra_body``. Extended thinking is bounded server-side by + llama.cpp's ``--reasoning-budget``. """ - params: Dict[str, Any] = { - "model": self.model_def.model_name, - "temperature": self.temperature, - } + model_name = self.model_def.model_name + openai_reasoning = is_reasoning_model(model_name) + + params: Dict[str, Any] = {"model": model_name} + if not openai_reasoning: + params["temperature"] = self.temperature if self.max_tokens is not None: - params["max_tokens"] = self.max_tokens + key = "max_completion_tokens" if openai_reasoning else "max_tokens" + params[key] = self.max_tokens if self.response_format is not None: params["response_format"] = self.response_format + + if self.reasoning_effort: + if openai_reasoning: + effort = self.reasoning_effort.strip().lower() + # "none" is only accepted from gpt-5.1 on; earlier reasoning + # models (gpt-5-nano, o3, …) reject it — "minimal" is their floor. + if effort in ("none", "off", "0") and not model_name.lower().startswith( + "gpt-5.1" + ): + effort = "minimal" + params["reasoning_effort"] = effort + elif self.model_def.thinking: + # "none"/"minimal"/"off"/"0" → thinking off; any other level → on. + enable = self.reasoning_effort.strip().lower() not in ( + "none", + "minimal", + "off", + "0", + ) + params["extra_body"] = { + "chat_template_kwargs": {"enable_thinking": enable} + } return params def get_client(self, is_async: bool = False): @@ -183,11 +331,9 @@ def get_client(self, is_async: bool = False): Uses create_openai_client which handles Langfuse tracing. """ - from advanced_omi_backend.openai_factory import create_openai_client - return create_openai_client( api_key=self.model_def.api_key or "", - base_url=self.model_def.model_url, + base_url=self.model_def.resolved_url(), is_async=is_async, ) @@ -216,10 +362,6 @@ class AppModels(BaseModel): speaker_recognition: Dict[str, Any] = Field( default_factory=dict, description="Speaker recognition service configuration" ) - chat: Dict[str, Any] = Field( - default_factory=dict, - description="Chat service configuration including system prompt", - ) llm_operations: Dict[str, LLMOperationConfig] = Field( default_factory=dict, description="Per-operation LLM configuration (temperature, model override, etc.)", @@ -279,17 +421,29 @@ def list_model_types(self) -> List[str]: """ return sorted(set(m.model_type for m in self.models.values())) - def get_llm_operation(self, name: str) -> ResolvedLLMOperation: + def get_llm_operation( + self, + name: str, + *, + default_model_type: str = "llm", + model_override: Optional[str] = None, + ) -> ResolvedLLMOperation: """Resolve a named LLM operation to a self-contained config. Resolution: 1. Look up llm_operations[name] (empty LLMOperationConfig if missing) - 2. Resolve model_def: op.model → get_by_name, else defaults.llm + 2. Resolve model_def: model_override → get_by_name, else op.model → + get_by_name, else defaults[default_model_type] (falling back to + defaults.llm — so e.g. an unset fast_llm reuses the main LLM) 3. Merge parameters: operation > model_def.model_params > safe fallback 4. Return ResolvedLLMOperation ready for use Args: name: Operation name (e.g. "memory_extraction", "chat") + default_model_type: defaults key to use when the operation pins no model + (e.g. "fast_llm"); falls back to "llm" when that default is unset. + model_override: pin a specific model by name, overriding both the + operation's model and the defaults (used for fallback retries). Returns: ResolvedLLMOperation with model_def, temperature, max_tokens, response_format @@ -300,7 +454,14 @@ def get_llm_operation(self, name: str) -> ResolvedLLMOperation: op_config = self.llm_operations.get(name, LLMOperationConfig()) # Resolve model definition - if op_config.model: + if model_override: + model_def = self.get_by_name(model_override) + if not model_def: + raise RuntimeError( + f"LLM operation '{name}' requested override model " + f"'{model_override}' which is not defined in the models list" + ) + elif op_config.model: model_def = self.get_by_name(op_config.model) if not model_def: raise RuntimeError( @@ -308,7 +469,9 @@ def get_llm_operation(self, name: str) -> ResolvedLLMOperation: f"which is not defined in the models list" ) else: - model_def = self.get_default("llm") + model_def = self.get_default(default_model_type) + if not model_def and default_model_type != "llm": + model_def = self.get_default("llm") if not model_def: raise RuntimeError( f"No model specified for operation '{name}' and no default LLM defined" @@ -327,6 +490,11 @@ def get_llm_operation(self, name: str) -> ResolvedLLMOperation: if op_config.max_tokens is not None else model_params.get("max_tokens") ) + reasoning_effort = ( + op_config.reasoning_effort + if op_config.reasoning_effort is not None + else model_params.get("reasoning_effort") + ) # Convert "json" shorthand to OpenAI format response_format = None @@ -338,6 +506,34 @@ def get_llm_operation(self, name: str) -> ResolvedLLMOperation: temperature=float(temperature), max_tokens=int(max_tokens) if max_tokens is not None else None, response_format=response_format, + reasoning_effort=reasoning_effort, + ) + + def get_fallback_llm_operation( + self, + name: str, + *, + primary: ResolvedLLMOperation, + default_model_type: str = "llm", + ) -> Optional[ResolvedLLMOperation]: + """Resolve operation ``name`` against ``defaults.fallback_llm``. + + Returns None when no fallback is configured, when the fallback entry is + missing or not an LLM, or when it is the same model the primary attempt + already used (retrying it would be pointless). + """ + fb_name = self.defaults.get("fallback_llm") + if not fb_name or fb_name == primary.model_def.name: + return None + fb_def = self.get_by_name(fb_name) + if not fb_def or fb_def.model_type != "llm": + logger.warning( + "defaults.fallback_llm=%r does not name an LLM model — ignoring", + fb_name, + ) + return None + return self.get_llm_operation( + name, default_model_type=default_model_type, model_override=fb_name ) @@ -355,8 +551,6 @@ def _find_config_path() -> Path: Returns: Path to config.yml """ - from advanced_omi_backend.config import get_config_yml_path - return get_config_yml_path() @@ -393,7 +587,6 @@ def load_models_config(force_reload: bool = False) -> Optional[AppModels]: model_list = raw.get("models", []) or [] memory_settings = raw.get("memory", {}) or {} speaker_recognition_cfg = raw.get("speaker_recognition", {}) or {} - chat_settings = raw.get("chat", {}) or {} llm_ops_raw = raw.get("llm_operations", {}) or {} # Parse and validate models using Pydantic @@ -422,7 +615,6 @@ def load_models_config(force_reload: bool = False) -> Optional[AppModels]: models=models, memory=memory_settings, speaker_recognition=speaker_recognition_cfg, - chat=chat_settings, llm_operations=llm_operations, ) return _REGISTRY diff --git a/backends/advanced/src/advanced_omi_backend/models/annotation.py b/backends/advanced/src/advanced_omi_backend/models/annotation.py index 99974532..b8737e02 100644 --- a/backends/advanced/src/advanced_omi_backend/models/annotation.py +++ b/backends/advanced/src/advanced_omi_backend/models/annotation.py @@ -20,9 +20,10 @@ class AnnotationType(str, Enum): MEMORY = "memory" TRANSCRIPT = "transcript" DIARIZATION = "diarization" # Speaker identification corrections - ENTITY = "entity" # Knowledge graph entity corrections (name/details edits) TITLE = "title" # Conversation title corrections INSERT = "insert" # Insert new segment between existing segments + TIMING = "timing" # Adjust an existing segment's start/end (waveform region edit) + DELETION = "deletion" # Remove an existing segment from the transcript SPEECH_SUGGESTION_CORRECTION = "speech_suggestion_correction" # User-refined model suggestion (training signal triple) @@ -77,12 +78,6 @@ class Annotation(Document): corrected_speaker: Optional[str] = None # Speaker label after correction segment_start_time: Optional[float] = None # Time offset for reference - # For ENTITY annotations: - # Dual purpose: feeds both the jargon pipeline (entity name corrections = domain vocabulary - # the ASR should know) and the entity extraction pipeline (corrections improve future accuracy). - entity_id: Optional[str] = None # Neo4j entity ID - entity_field: Optional[str] = None # Which field was changed ("name" or "details") - # For SPEECH_SUGGESTION_CORRECTION annotations: model_suggested_text: Optional[str] = ( None # What AI originally suggested before user edited @@ -93,6 +88,12 @@ class Annotation(Document): insert_text: Optional[str] = None # e.g., "[laughter]" or "wife laughed" insert_segment_type: Optional[str] = None # "event", "note", or "speech" insert_speaker: Optional[str] = None # Speaker label for "speech" type inserts + insert_start: Optional[float] = None # Explicit span start (waveform-drawn region) + insert_end: Optional[float] = None # Explicit span end; None → zero-duration marker + + # For TIMING annotations (adjust an existing segment's span on the waveform): + new_start: Optional[float] = None + new_end: Optional[float] = None # Processed tracking (applies to ALL annotation types) processed: bool = Field( @@ -103,6 +104,14 @@ class Annotation(Document): None # What processed it (manual, cron, apply, training, etc.) ) + # Fine-tuning failure tracking (diarization annotations sent to speaker training) + # When training an annotation fails (corrupt segment times, missing audio, + # speaker-service error) it is NOT silently dropped: the attempt count and last + # error are recorded so the Fine-tuning page can surface the stuck annotation and + # let an admin retry or discard it (instead of it re-failing on every run). + training_attempts: int = 0 # Number of failed training attempts (0 = never failed) + training_error: Optional[str] = None # Reason for the last training failure + # Timestamps (Python 3.12+ compatible) created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc)) updated_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc)) @@ -117,7 +126,6 @@ class Settings: "status", # Filter by status (pending/accepted/rejected) "memory_id", # Lookup annotations for specific memory "conversation_id", # Lookup annotations for specific conversation - "entity_id", # Lookup annotations for specific entity "processed", # Query unprocessed annotations ] @@ -133,10 +141,6 @@ def is_diarization_annotation(self) -> bool: """Check if this is a diarization annotation.""" return self.annotation_type == AnnotationType.DIARIZATION - def is_entity_annotation(self) -> bool: - """Check if this is an entity annotation.""" - return self.annotation_type == AnnotationType.ENTITY - def is_title_annotation(self) -> bool: """Check if this is a title annotation.""" return self.annotation_type == AnnotationType.TITLE @@ -192,19 +196,6 @@ class DiarizationAnnotationCreate(BaseModel): status: AnnotationStatus = AnnotationStatus.ACCEPTED -class EntityAnnotationCreate(BaseModel): - """Create entity annotation request. - - Dual purpose: feeds both the jargon pipeline (entity name corrections = domain vocabulary - the ASR should know) and the entity extraction pipeline (corrections improve future accuracy). - """ - - entity_id: str - entity_field: str # "name" or "details" - original_text: str - corrected_text: str - - class TitleAnnotationCreate(AnnotationCreateBase): """Create title annotation request.""" @@ -221,6 +212,26 @@ class InsertAnnotationCreate(BaseModel): insert_text: str insert_segment_type: str # "event", "note", or "speech" insert_speaker: Optional[str] = None # Speaker label for "speech" type inserts + insert_start: Optional[float] = None # Explicit span start (waveform-drawn region) + insert_end: Optional[float] = None # Explicit span end; None → zero-duration marker + + +class TimingAnnotationCreate(BaseModel): + """Adjust an existing segment's start/end times (waveform region edit).""" + + conversation_id: str + segment_index: int + new_start: float + new_end: float + status: AnnotationStatus = AnnotationStatus.ACCEPTED + + +class DeletionAnnotationCreate(BaseModel): + """Remove an existing segment from the transcript (waveform/segment editor).""" + + conversation_id: str + segment_index: int + status: AnnotationStatus = AnnotationStatus.ACCEPTED class AnnotationUpdate(BaseModel): @@ -248,16 +259,20 @@ class AnnotationResponse(BaseModel): original_speaker: Optional[str] = None corrected_speaker: Optional[str] = None segment_start_time: Optional[float] = None - entity_id: Optional[str] = None - entity_field: Optional[str] = None model_suggested_text: Optional[str] = None insert_after_index: Optional[int] = None insert_text: Optional[str] = None insert_segment_type: Optional[str] = None insert_speaker: Optional[str] = None + insert_start: Optional[float] = None + insert_end: Optional[float] = None + new_start: Optional[float] = None + new_end: Optional[float] = None processed: bool = False processed_at: Optional[datetime] = None processed_by: Optional[str] = None + training_attempts: int = 0 + training_error: Optional[str] = None status: AnnotationStatus source: AnnotationSource created_at: datetime diff --git a/backends/advanced/src/advanced_omi_backend/models/audio_chunk.py b/backends/advanced/src/advanced_omi_backend/models/audio_chunk.py index d8ed0125..09ece84c 100644 --- a/backends/advanced/src/advanced_omi_backend/models/audio_chunk.py +++ b/backends/advanced/src/advanced_omi_backend/models/audio_chunk.py @@ -6,12 +6,32 @@ from a conversation. """ -from datetime import datetime -from typing import Optional +from datetime import datetime, timezone +from typing import List, Optional from beanie import Document, Indexed from bson import Binary -from pydantic import ConfigDict, Field, field_serializer +from pydantic import BaseModel, ConfigDict, Field, field_serializer + + +class VADResult(BaseModel): + """Voice-activity scores for one audio chunk. + + Self-contained: provider/hop/threshold are stored with the scores because + chunks (and their VAD data) survive conversation split/merge operations, + while the conversation-level ``VadAnalysis`` summary does not travel. + """ + + provider: str = Field(description="VAD provider that produced the scores") + frame_hop_ms: float = Field(description="Milliseconds of audio per score frame") + scores: List[float] = Field( + description="Per-frame speech probabilities for this chunk (one per frame hop)" + ) + max_score: float = Field( + description="Maximum speech probability in this chunk (voice-present score)" + ) + threshold: float = Field(description="Decision threshold used to derive has_speech") + has_speech: bool = Field(description="Whether max_score reached the threshold") class AudioChunkDocument(Document): @@ -74,13 +94,14 @@ class AudioChunkDocument(Document): ) # Optional analysis - has_speech: Optional[bool] = Field( - default=None, description="Voice Activity Detection result (if available)" + vad: Optional[VADResult] = Field( + default=None, description="Voice-activity scores (set by data-audit analysis)" ) # Metadata created_at: datetime = Field( - default_factory=datetime.utcnow, description="Chunk creation timestamp" + default_factory=lambda: datetime.now(timezone.utc), + description="Chunk creation timestamp", ) # Soft delete fields diff --git a/backends/advanced/src/advanced_omi_backend/models/conversation.py b/backends/advanced/src/advanced_omi_backend/models/conversation.py index 62d0286e..9cbc87a1 100644 --- a/backends/advanced/src/advanced_omi_backend/models/conversation.py +++ b/backends/advanced/src/advanced_omi_backend/models/conversation.py @@ -1,17 +1,17 @@ """ Conversation models for Chronicle backend. -This module contains Beanie Document and Pydantic models for conversations, -transcript versions, and memory versions. +This module contains Beanie Document and Pydantic models for conversations +and transcript versions. """ import uuid -from datetime import datetime +from datetime import datetime, timezone from enum import Enum -from typing import Any, Dict, List, Optional, Union +from typing import Any, Dict, List, Optional from beanie import Document, Indexed -from pydantic import BaseModel, Field, computed_field, field_validator, model_validator +from pydantic import BaseModel, Field, computed_field, model_validator from pymongo import IndexModel @@ -20,13 +20,6 @@ class Conversation(Document): # Nested Enums - Note: TranscriptProvider accepts any string value for flexibility - class MemoryProvider(str, Enum): - """Supported memory providers.""" - - CHRONICLE = "chronicle" - OPENMEMORY_MCP = "openmemory_mcp" - FRIEND_LITE = "friend_lite" # Legacy value - class ConversationStatus(str, Enum): """Conversation processing status.""" @@ -49,6 +42,8 @@ class EndReason(str, Enum): "close_requested" # External close signal (API, plugin, button) ) ERROR = "error" # Processing error forced conversation end + SPLIT = "split" # Created by splitting a longer conversation + MERGE = "merge" # Created by merging adjacent conversations UNKNOWN = "unknown" # Unknown or legacy reason # Nested Models @@ -123,27 +118,60 @@ class TranscriptVersion(BaseModel): default_factory=dict, description="Additional provider-specific metadata" ) - class MemoryVersion(BaseModel): - """Version of memory extraction with processing metadata.""" + class VadAnalysis(BaseModel): + """Cached VAD summary for a conversation's audio (data-audit feature). - version_id: str = Field(description="Unique version identifier") - memory_count: int = Field(description="Number of memories extracted") - transcript_version_id: str = Field( - description="Which transcript version was used" + Frame-level speech probabilities live on the audio chunk documents + (``AudioChunkDocument.vad_scores``); this stores a threshold-independent + histogram of those probabilities so the UI can derive the speech + fraction for any chosen threshold without touching the chunks. + """ + + provider: str = Field(description="VAD provider that produced the scores") + frame_hop_ms: float = Field( + description="Milliseconds of audio per VAD frame/score" ) - provider: "Conversation.MemoryProvider" = Field( - description="Memory provider used" + frame_count: int = Field(description="Number of frames scored") + histogram_bin_width: float = Field( + description="Width of each probability histogram bin (bins span [0, 1])" ) - model: Optional[str] = Field( - None, description="Model used (e.g., gpt-4o-mini, llama3)" + histogram: List[int] = Field( + default_factory=list, + description="Frame counts per speech-probability bin (low→high)", ) - created_at: datetime = Field(description="When this version was created") - processing_time_seconds: Optional[float] = Field( - None, description="Time taken to process" + chunk_duration_seconds: float = Field( + description="Nominal audio chunk duration used during analysis" ) - metadata: Dict[str, Any] = Field( - default_factory=dict, description="Additional provider-specific metadata" + speech_regions: Optional[List[List[float]]] = Field( + None, + description="Merged [start, end] speech intervals in seconds, for speech-skip playback", + ) + analyzed_at: datetime = Field(description="When this analysis was computed") + + def speech_fraction(self, threshold: float = 0.5) -> float: + """Fraction of frames with speech probability >= ``threshold`` (0.0-1.0).""" + if self.frame_count <= 0 or not self.histogram: + return 0.0 + speech = 0 + for i, count in enumerate(self.histogram): + bin_lower = i * self.histogram_bin_width + if bin_lower >= threshold: + speech += count + return speech / self.frame_count + + class DerivedFrom(BaseModel): + """Lineage record for conversations produced by split/merge operations.""" + + operation: str = Field(description="'split' or 'merge'") + source_conversation_ids: List[str] = Field( + description="Conversations this one was derived from" + ) + time_range: Optional[List[float]] = Field( + None, + description="For split children: [start, end] seconds in the parent timeline", ) + performed_at: datetime = Field(description="When the operation ran") + performed_by: str = Field(description="User who performed the operation") # Core identifiers conversation_id: Indexed(str, unique=True) = Field( @@ -174,6 +202,41 @@ class MemoryVersion(BaseModel): description="Compression ratio (compressed_size / original_size), typically ~0.047 for Opus", ) + # Cached VAD speech analysis (data-audit feature) + vad_analysis: Optional["Conversation.VadAnalysis"] = Field( + None, + description="Cached VAD speech-probability summary computed from audio chunks", + ) + audio_integrity_error: Optional[str] = Field( + None, + description="Set when the conversation's audio is internally inconsistent " + "(e.g. reconnect-duplication: stored duration/chunk-count disagree with the " + "actual chunks). Such conversations are excluded from the data-audit list and " + "surfaced on the System Errors page instead of being repeatedly re-analyzed.", + ) + + # Split/merge lineage (data-audit feature) + derived_from: Optional["Conversation.DerivedFrom"] = Field( + None, description="Set on conversations created by a split/merge operation" + ) + derived_into: List[str] = Field( + default_factory=list, + description="Conversation IDs this conversation was split/merged into (set on soft-deleted sources)", + ) + + # Audio archival (data-audit feature): audio bytes hard-deleted, metadata kept + audio_archived: bool = Field( + False, + description="Whether the audio bytes were permanently deleted while keeping this metadata stub", + ) + audio_archived_at: Optional[datetime] = Field( + None, description="When the audio was archived (hard-deleted)" + ) + archive_reason: Optional[str] = Field( + None, + description="Why audio was archived (near_silent, bad_speaker, manual_cleanup, etc.)", + ) + # Markers (e.g., button events) captured during the session markers: List[Dict[str, Any]] = Field( default_factory=list, @@ -182,7 +245,8 @@ class MemoryVersion(BaseModel): # Creation metadata created_at: Indexed(datetime) = Field( - default_factory=datetime.utcnow, description="When the conversation was created" + default_factory=lambda: datetime.now(timezone.utc), + description="When the conversation was created", ) # Processing status tracking @@ -198,10 +262,20 @@ class MemoryVersion(BaseModel): None, description="When the conversation was marked as deleted" ) - # Always persist audio flag and processing status + # Always persist audio flag and processing status. + # Canonical values are the ConversationStatus enum: "active" | "completed" | + # "failed". This field is OWNED by apply_status() — derived from facts (does an + # active transcript exist?) at terminal points and by the reconciler. Individual + # jobs must NOT hand-stamp it; they do their work and let the finalizer reconcile. processing_status: Optional[str] = Field( None, - description="Processing status: pending_transcription, transcription_failed, completed", + description="Processing status (ConversationStatus): active, completed, failed", + ) + # When processing_status == "failed", which pipeline stage failed + # (e.g. "transcription", "summarization"). Replaces the old overloaded + # "transcription_failed" string that conflated distinct failure causes. + failure_stage: Optional[str] = Field( + None, description="Stage that failed when processing_status == 'failed'" ) always_persist: bool = Field( default=False, @@ -238,20 +312,16 @@ class MemoryVersion(BaseModel): transcript_versions: List["Conversation.TranscriptVersion"] = Field( default_factory=list, description="All transcript processing attempts" ) - memory_versions: List["Conversation.MemoryVersion"] = Field( - default_factory=list, description="All memory extraction attempts" - ) - # Active version pointers + # Active version pointer active_transcript_version: Optional[str] = Field( None, description="Version ID of currently active transcript" ) - active_memory_version: Optional[str] = Field( - None, description="Version ID of currently active memory extraction" - ) - # Legacy fields removed - use transcript_versions[active_transcript_version] and memory_versions[active_memory_version] - # Frontend should access: conversation.active_transcript.segments, conversation.active_transcript.transcript + # Legacy fields removed - use transcript_versions[active_transcript_version]. + # Frontend should access: conversation.active_transcript.segments, conversation.active_transcript.transcript. + # Memory is no longer versioned (the vault is the system of record); changes are + # recorded in the memory_audit ledger (see models/memory_audit.py). @model_validator(mode="before") @classmethod @@ -291,29 +361,22 @@ def clean_legacy_data(cls, data: Any) -> Any: return data - @computed_field - @property - def active_transcript(self) -> Optional["Conversation.TranscriptVersion"]: - """Get the currently active transcript version.""" - if not self.active_transcript_version: - return None - + def get_transcript_version( + self, version_id: str + ) -> Optional["Conversation.TranscriptVersion"]: + """Find a transcript version by id, or None if it doesn't exist.""" for version in self.transcript_versions: - if version.version_id == self.active_transcript_version: + if version.version_id == version_id: return version return None @computed_field @property - def active_memory(self) -> Optional["Conversation.MemoryVersion"]: - """Get the currently active memory version.""" - if not self.active_memory_version: + def active_transcript(self) -> Optional["Conversation.TranscriptVersion"]: + """Get the currently active transcript version.""" + if not self.active_transcript_version: return None - - for version in self.memory_versions: - if version.version_id == self.active_memory_version: - return version - return None + return self.get_transcript_version(self.active_transcript_version) # Convenience properties that return data from active transcript version @computed_field @@ -334,30 +397,12 @@ def segment_count(self) -> int: """Get segment count from active transcript version.""" return len(self.segments) if self.segments else 0 - @computed_field - @property - def memory_count(self) -> int: - """Get memory count from active memory version.""" - return self.active_memory.memory_count if self.active_memory else 0 - - @computed_field - @property - def has_memory(self) -> bool: - """Check if conversation has any memory versions.""" - return len(self.memory_versions) > 0 - @computed_field @property def transcript_version_count(self) -> int: """Get count of transcript versions.""" return len(self.transcript_versions) - @computed_field - @property - def memory_version_count(self) -> int: - """Get count of memory versions.""" - return len(self.memory_versions) - @computed_field @property def active_transcript_version_number(self) -> Optional[int]: @@ -369,24 +414,15 @@ def active_transcript_version_number(self) -> Optional[int]: return i + 1 return None - @computed_field - @property - def active_memory_version_number(self) -> Optional[int]: - """Get 1-based version number of the active memory version.""" - if not self.active_memory_version: - return None - for i, version in enumerate(self.memory_versions): - if version.version_id == self.active_memory_version: - return i + 1 - return None - def add_transcript_version( self, version_id: str, transcript: str, words: Optional[List["Conversation.Word"]] = None, segments: Optional[List["Conversation.SpeakerSegment"]] = None, - provider: str = None, # Provider name from config.yml (deepgram, parakeet, etc.) + provider: Optional[ + str + ] = None, # Provider name from config.yml (deepgram, parakeet, etc.) model: Optional[str] = None, processing_time_seconds: Optional[float] = None, metadata: Optional[Dict[str, Any]] = None, @@ -412,36 +448,6 @@ def add_transcript_version( return new_version - def add_memory_version( - self, - version_id: str, - memory_count: int, - transcript_version_id: str, - provider: "Conversation.MemoryProvider", - model: Optional[str] = None, - processing_time_seconds: Optional[float] = None, - metadata: Optional[Dict[str, Any]] = None, - set_as_active: bool = True, - ) -> "Conversation.MemoryVersion": - """Add a new memory version and optionally set it as active.""" - new_version = Conversation.MemoryVersion( - version_id=version_id, - memory_count=memory_count, - transcript_version_id=transcript_version_id, - provider=provider, - model=model, - created_at=datetime.now(), - processing_time_seconds=processing_time_seconds, - metadata=metadata or {}, - ) - - self.memory_versions.append(new_version) - - if set_as_active: - self.active_memory_version = version_id - - return new_version - def set_active_transcript_version(self, version_id: str) -> bool: """Set a specific transcript version as active.""" for version in self.transcript_versions: @@ -450,13 +456,42 @@ def set_active_transcript_version(self, version_id: str) -> bool: return True return False - def set_active_memory_version(self, version_id: str) -> bool: - """Set a specific memory version as active.""" - for version in self.memory_versions: - if version.version_id == version_id: - self.active_memory_version = version_id - return True - return False + @property + def has_meaningful_transcript(self) -> bool: + """True when the active transcript version has non-empty text.""" + av = self.active_transcript + return bool(av and (av.transcript or "").strip()) + + def apply_status( + self, *, settled: bool, failure_stage: str = "transcription" + ) -> bool: + """Derive and set processing_status from facts. The SINGLE owner of the field. + + The transcript is the conversation's core deliverable, so its presence is the + source of truth for success — independent of which jobs ran or what order they + ran in. This makes ``failed`` non-absorbing (a later recovery flips it to + ``completed``) and removes the need for jobs to hand-stamp the status. + + - has a meaningful transcript -> COMPLETED + - ``settled`` and no transcript -> FAILED (failure_stage) + - otherwise (still in flight) -> ACTIVE + + ``settled`` means the caller knows the pipeline has reached a terminal point + (the finalizer job, a fallback dead-end, or the reconciler's staleness check), + so "no transcript" is final rather than "not yet". Returns True if anything + changed. + """ + prev = (self.processing_status, self.failure_stage) + if self.has_meaningful_transcript: + self.processing_status = self.ConversationStatus.COMPLETED.value + self.failure_stage = None + elif settled: + self.processing_status = self.ConversationStatus.FAILED.value + self.failure_stage = failure_stage + else: + self.processing_status = self.ConversationStatus.ACTIVE.value + self.failure_stage = None + return (self.processing_status, self.failure_stage) != prev class Settings: name = "conversations" @@ -528,8 +563,6 @@ def create_conversation( "summary": summary, "transcript_versions": [], "active_transcript_version": None, - "memory_versions": [], - "active_memory_version": None, "external_source_id": external_source_id, "external_source_type": external_source_type, } diff --git a/backends/advanced/src/advanced_omi_backend/models/job.py b/backends/advanced/src/advanced_omi_backend/models/job.py index 91870643..c494423d 100644 --- a/backends/advanced/src/advanced_omi_backend/models/job.py +++ b/backends/advanced/src/advanced_omi_backend/models/job.py @@ -9,6 +9,7 @@ import asyncio import logging +import os import time from abc import ABC, abstractmethod from datetime import datetime, timezone @@ -20,6 +21,7 @@ from advanced_omi_backend.prompt_defaults import register_all_defaults from advanced_omi_backend.prompt_registry import get_prompt_registry +from advanced_omi_backend.redis_factory import create_async_redis logger = logging.getLogger(__name__) @@ -35,14 +37,16 @@ async def _ensure_beanie_initialized(): if _beanie_initialized: return try: - import os - + # Lazy import: Beanie + Mongo drivers + document models are only pulled in + # when an RQ worker process first needs them, so importing this module + # (e.g. for BaseRQJob/async_job) doesn't drag in the DB stack. from beanie import init_beanie from motor.motor_asyncio import AsyncIOMotorClient from pymongo.errors import ConfigurationError from advanced_omi_backend.models.audio_chunk import AudioChunkDocument from advanced_omi_backend.models.conversation import Conversation + from advanced_omi_backend.models.memory_audit import MemoryAuditEntry from advanced_omi_backend.models.user import User from advanced_omi_backend.models.waveform import WaveformData @@ -61,7 +65,13 @@ async def _ensure_beanie_initialized(): # Initialize Beanie await init_beanie( database=database, - document_models=[User, Conversation, AudioChunkDocument, WaveformData], + document_models=[ + User, + Conversation, + AudioChunkDocument, + WaveformData, + MemoryAuditEntry, + ], ) _beanie_initialized = True @@ -273,11 +283,7 @@ async def process(): # Create Redis client if requested if redis: - from advanced_omi_backend.controllers.queue_controller import ( - REDIS_URL, - ) - - redis_client = redis_async.from_url(REDIS_URL) + redis_client = create_async_redis() kwargs["redis_client"] = redis_client logger.debug(f"Redis client created") diff --git a/backends/advanced/src/advanced_omi_backend/models/memory_audit.py b/backends/advanced/src/advanced_omi_backend/models/memory_audit.py new file mode 100644 index 00000000..0f3163a4 --- /dev/null +++ b/backends/advanced/src/advanced_omi_backend/models/memory_audit.py @@ -0,0 +1,101 @@ +"""Memory vault audit ledger. + +An append-only record of every change made to a user's memory vault +(``data/conversation_docs/{user_id}/``). This is the audit trail that replaced +the old per-conversation "memory versions": memory is now a filesystem vault that +is overwritten in place, so instead of versioning the data we record *which notes +changed, when, and what triggered the change*. + +Entries are written by the chronicle memory provider (the only provider that owns +a vault) from the memory RQ worker, and read back through the API for display. +Each entry retains the post-change note content (``after_text``) so the ledger can +show an exact before→after diff: the "before" is reconstructed from the previous +recorded change to the same note. There is no restore — the ledger records history. +""" + +from datetime import datetime, timezone +from typing import Any, Dict, Optional + +from beanie import Document, Indexed +from pydantic import Field +from pymongo import IndexModel + + +class MemoryAuditEntry(Document): + """One recorded change to a user's memory vault.""" + + user_id: Indexed(str) = Field(description="Owner of the vault that changed") + conversation_id: Optional[str] = Field( + None, description="Conversation whose processing caused the change (if any)" + ) + + # What happened + operation: str = Field(description="create | update | delete | rename | delete_all") + note_path: Optional[str] = Field( + None, + description="Vault-relative path of the changed note (e.g. 'People/Alice.md')", + ) + cause: Optional[str] = Field( + None, + description="Why the memory changed (provenance), one of MemoryCause: " + "auto_extraction, memory_replay, transcript_reprocess, speaker_reprocess, " + "annotation_apply, obsidian_sync, delete_all. See services/memory/audit.py.", + ) + strategy: Optional[str] = Field( + None, + description="How the provider updated the vault (UpdateStrategy): " + "full re-extraction or speaker_diff. Control-flow detail, not a label.", + ) + provider: str = Field( + default="chronicle", description="Memory provider that owns the vault" + ) + agent_mode: bool = Field( + default=False, + description="Whether the vault-first memory agent produced this change", + ) + + # What changed: hashes/sizes for integrity, plus the post-change content so a + # before→after diff can be reconstructed from the note's recorded history. + before_hash: Optional[str] = Field( + None, + description="SHA-256 of the note before the change (None if newly created)", + ) + after_hash: Optional[str] = Field( + None, description="SHA-256 of the note after the change (None if deleted)" + ) + after_bytes: Optional[int] = Field( + None, description="Size of the note after the change, in bytes" + ) + after_text: Optional[str] = Field( + None, + description="Full note content after the change (None if deleted). Used to " + "reconstruct diffs; the 'before' is the prior recorded change to this note.", + ) + summary: Optional[str] = Field( + None, + description="Human-readable note about the change (line delta, agent summary, etc.)", + ) + extra: Dict[str, Any] = Field( + default_factory=dict, description="Additional operation-specific metadata" + ) + + created_at: Indexed(datetime) = Field( + default_factory=lambda: datetime.now(timezone.utc), + description="When the change was recorded", + ) + + class Settings: + name = "memory_audit" + indexes = [ + "user_id", + "conversation_id", + "created_at", + IndexModel( + [("conversation_id", 1), ("created_at", -1)], + name="memory_audit_conversation_recent", + ), + IndexModel( + [("user_id", 1), ("created_at", -1)], + name="memory_audit_user_recent", + ), + ] diff --git a/backends/advanced/src/advanced_omi_backend/models/system_event.py b/backends/advanced/src/advanced_omi_backend/models/system_event.py new file mode 100644 index 00000000..1b09891c --- /dev/null +++ b/backends/advanced/src/advanced_omi_backend/models/system_event.py @@ -0,0 +1,106 @@ +"""System event / error ledger. + +A central, append-only store of operational and application failures across the +backend and its services: captured backend exceptions (every ``ERROR``/``CRITICAL`` +log via :mod:`advanced_omi_backend.services.observability.log_handler`), semantic +failures tapped at high-value sites (WebSocket error-disconnects, streaming-ASR +terminal failures, failed/soft-failed jobs, plugin init failures), +service-health transitions (a sidecar entering/leaving a crash loop) detected by +the health poller, and errors pushed by sidecar services themselves over the +token-gated ``POST /api/admin/system-events/ingest`` endpoint (recorded with a +``service:`` source). + +It backs the admin-only "System Errors" page. Entries auto-expire after +``RETENTION_DAYS`` via a MongoDB TTL index on ``created_at`` — this is a rolling +window of "what broke recently", not a permanent audit trail. + +Modeled on :class:`advanced_omi_backend.models.memory_audit.MemoryAuditEntry`. +""" + +from datetime import datetime, timezone +from typing import Any, Dict, Optional + +from beanie import Document +from pydantic import Field +from pymongo import IndexModel + +# How long events live before MongoDB's TTL monitor removes them. +RETENTION_DAYS = 30 +_RETENTION_SECONDS = RETENTION_DAYS * 24 * 3600 + +# Canonical severities and categories. Stored as plain strings (not enums) so the +# catch-all log handler can record arbitrary backend errors without the schema +# fighting it; the WebUI filters on these values. +SEVERITIES = ("info", "warning", "error", "critical") +CATEGORIES = ( + "service", # a service/container health transition (e.g. crash loop) + "client", # a device/WebSocket connection event + "pipeline", # audio/transcription pipeline failure + "job", # an RQ job failure (hard or soft) + "plugin", # plugin init/dispatch failure + "config", # configuration diagnostics issue + "api", # request-handling error + "log", # generic captured ERROR/CRITICAL log (catch-all net) +) + + +class SystemEvent(Document): + """One recorded operational/application error or health transition.""" + + severity: str = Field(description="info | warning | error | critical") + category: str = Field(description="One of CATEGORIES; free-form fallback allowed") + source: str = Field( + description="Where it came from: service name, logger name, or client_id" + ) + title: str = Field(description="Short one-line summary") + detail: Optional[str] = Field(None, description="Longer message / context") + traceback: Optional[str] = Field(None, description="Formatted traceback, if any") + + # Identity context (any may be absent depending on the source). + user_id: Optional[str] = Field(None) + client_id: Optional[str] = Field(None) + conversation_id: Optional[str] = Field(None) + + # De-duplication: events with the same fingerprint that recur within a short + # window collapse onto one row (``count`` incremented, ``last_seen_at`` bumped) + # so a crash loop logging the same error every second doesn't flood the feed. + fingerprint: str = Field( + description="Stable hash of severity+category+source+title" + ) + occurrences: int = Field( + default=1, description="How many times this event has recurred (dedup count)" + ) + last_seen_at: datetime = Field( + default_factory=lambda: datetime.now(timezone.utc), + description="When this event most recently recurred", + ) + + acked: bool = Field(default=False, description="Whether an admin acknowledged it") + metadata: Dict[str, Any] = Field(default_factory=dict) + + created_at: datetime = Field( + default_factory=lambda: datetime.now(timezone.utc), + description="When the event was first recorded", + ) + + class Settings: + name = "system_events" + indexes = [ + "fingerprint", + "client_id", + # Facet filters with newest-first sort. + IndexModel( + [("severity", 1), ("created_at", -1)], name="system_events_severity" + ), + IndexModel( + [("category", 1), ("created_at", -1)], name="system_events_category" + ), + # TTL index for 30-day retention. A single-field index is direction-agnostic + # for sorting, so this {created_at: 1} index also serves the plain + # newest-first listing — no separate descending index needed. + IndexModel( + [("created_at", 1)], + name="system_events_ttl", + expireAfterSeconds=_RETENTION_SECONDS, + ), + ] diff --git a/backends/advanced/src/advanced_omi_backend/models/user.py b/backends/advanced/src/advanced_omi_backend/models/user.py index 77e0cc38..a89bc481 100644 --- a/backends/advanced/src/advanced_omi_backend/models/user.py +++ b/backends/advanced/src/advanced_omi_backend/models/user.py @@ -16,6 +16,7 @@ class UserCreate(BaseUserCreate): """Schema for creating new users.""" display_name: Optional[str] = None + assistant_name: Optional[str] = None notification_email: Optional[EmailStr] = None is_superuser: Optional[bool] = False @@ -24,15 +25,19 @@ class UserRead(BaseUser[PydanticObjectId]): """Schema for reading user data.""" display_name: Optional[str] = None + assistant_name: Optional[str] = None notification_email: Optional[EmailStr] = None registered_clients: dict[str, dict] = Field(default_factory=dict) primary_speakers: list[dict] = Field(default_factory=list) + wakeword_gate_enabled: bool = False + wakeword_allowed_speakers: list[dict] = Field(default_factory=list) class UserUpdate(BaseUserUpdate): """Schema for updating user data.""" display_name: Optional[str] = None + assistant_name: Optional[str] = None notification_email: Optional[EmailStr] = None is_superuser: Optional[bool] = None @@ -41,6 +46,8 @@ def create_update_dict(self): update_dict = super().create_update_dict() if self.display_name is not None: update_dict["display_name"] = self.display_name + if self.assistant_name is not None: + update_dict["assistant_name"] = self.assistant_name if self.notification_email is not None: update_dict["notification_email"] = self.notification_email return update_dict @@ -50,6 +57,8 @@ def create_update_dict_superuser(self): update_dict = super().create_update_dict_superuser() if self.display_name is not None: update_dict["display_name"] = self.display_name + if self.assistant_name is not None: + update_dict["assistant_name"] = self.assistant_name if self.notification_email is not None: update_dict["notification_email"] = self.notification_email return update_dict @@ -65,11 +74,19 @@ class User(BeanieBaseUser, Document): ) display_name: Optional[str] = None + # Name used to label the assistant's turns when extracting memories from chat + assistant_name: Optional[str] = None notification_email: Optional[EmailStr] = None # Client tracking for audio devices registered_clients: dict[str, dict] = Field(default_factory=dict) # Speaker processing filter configuration primary_speakers: list[dict] = Field(default_factory=list) + # Wake-word speaker gate: when enabled, an acoustic wake word only dispatches a + # command if one of ``wakeword_allowed_speakers`` is recognized in the captured + # turn (enforced by the wake-word dispatcher via the speaker-recognition + # service). Each allowed speaker is {speaker_id, name}. Empty list = inert. + wakeword_gate_enabled: bool = False + wakeword_allowed_speakers: list[dict] = Field(default_factory=list) class Settings: name = "users" # Collection name in MongoDB - standardized from "fastapi_users" @@ -83,26 +100,51 @@ def user_id(self) -> str: def register_client( self, client_id: str, device_name: Optional[str] = None ) -> None: - """Register a new client for this user.""" - # Check if client already exists - if client_id in self.registered_clients: - # Update existing client - logger.info(f"Updating existing client {client_id} for user {self.user_id}") - self.registered_clients[client_id]["last_seen"] = datetime.now(UTC) - self.registered_clients[client_id]["device_name"] = ( - device_name or self.registered_clients[client_id].get("device_name") - ) + """Register (auto-add) or refresh a device for this user. + + Devices are remembered across disconnects/restarts. The friendly ``name`` is + user-editable (defaults to the device name) and is NEVER overwritten here on a + reconnect — only ``last_seen`` and the technical ``device_name`` are refreshed. + Liveness is not stored: whether a device is connected right now is derived from + the in-memory ClientState, not a persisted flag. + """ + existing = self.registered_clients.get(client_id) + if existing: + existing["last_seen"] = datetime.now(UTC) + if device_name: + existing["device_name"] = device_name + # Backfill a friendly name for devices registered before naming existed. + existing.setdefault("name", device_name or client_id) return - # Add new client self.registered_clients[client_id] = { "client_id": client_id, "device_name": device_name, + "name": device_name or client_id, # editable display label "first_seen": datetime.now(UTC), "last_seen": datetime.now(UTC), - "is_active": True, } + def touch_client(self, client_id: str) -> bool: + """Stamp a device's last_seen (e.g. on disconnect). Returns False if unknown.""" + device = self.registered_clients.get(client_id) + if device is None: + return False + device["last_seen"] = datetime.now(UTC) + return True + + def set_client_name(self, client_id: str, name: str) -> bool: + """Set a device's friendly display name. Returns False if unknown.""" + device = self.registered_clients.get(client_id) + if device is None: + return False + device["name"] = name + return True + + def forget_client(self, client_id: str) -> bool: + """Remove a device from the registry. Returns False if unknown.""" + return self.registered_clients.pop(client_id, None) is not None + def get_client_ids(self) -> list[str]: """Get all client IDs registered to this user.""" return list(self.registered_clients.keys()) @@ -138,3 +180,27 @@ async def register_client_to_user( """Register a client to a user and save to database.""" user.register_client(client_id, device_name) await user.save() + + +async def touch_client_last_seen(client_id: str) -> None: + """Stamp a device's last_seen in the registry (e.g. on disconnect). No-op if the + client_id isn't owned by any user.""" + user = await get_user_by_client_id(client_id) + if user and user.touch_client(client_id): + await user.save() + + +async def rename_client_for_user(user: User, client_id: str, name: str) -> bool: + """Set a device's friendly name and persist. Returns False if unknown.""" + if user.set_client_name(client_id, name): + await user.save() + return True + return False + + +async def forget_client_for_user(user: User, client_id: str) -> bool: + """Remove a device from the registry and persist. Returns False if unknown.""" + if user.forget_client(client_id): + await user.save() + return True + return False diff --git a/backends/advanced/src/advanced_omi_backend/models/waveform.py b/backends/advanced/src/advanced_omi_backend/models/waveform.py index ccf1158c..7b02d1e3 100644 --- a/backends/advanced/src/advanced_omi_backend/models/waveform.py +++ b/backends/advanced/src/advanced_omi_backend/models/waveform.py @@ -6,7 +6,7 @@ without real-time decoding. """ -from datetime import datetime +from datetime import datetime, timezone from typing import List, Optional from beanie import Document, Indexed @@ -32,7 +32,8 @@ class WaveformData(Document): # Metadata duration_seconds: float = Field(description="Total audio duration in seconds") created_at: datetime = Field( - default_factory=datetime.utcnow, description="When this waveform was generated" + default_factory=lambda: datetime.now(timezone.utc), + description="When this waveform was generated", ) processing_time_seconds: Optional[float] = Field( None, description="Time taken to generate waveform" diff --git a/backends/advanced/src/advanced_omi_backend/observability/otel_setup.py b/backends/advanced/src/advanced_omi_backend/observability/otel_setup.py index fdd11c67..4b7f3077 100644 --- a/backends/advanced/src/advanced_omi_backend/observability/otel_setup.py +++ b/backends/advanced/src/advanced_omi_backend/observability/otel_setup.py @@ -21,6 +21,10 @@ logger = logging.getLogger(__name__) +# NOTE: All opentelemetry / openinference imports in this module are done lazily +# inside the functions below (guarded by try/except ImportError) so the backend +# runs fine when the optional OTel dependencies are not installed. + # Surface silent Galileo SDK errors (it catches exceptions internally) logging.getLogger("galileo.utils.catch_log").setLevel(logging.WARNING) diff --git a/backends/advanced/src/advanced_omi_backend/openai_factory.py b/backends/advanced/src/advanced_omi_backend/openai_factory.py index b10f72f0..9de4ec00 100644 --- a/backends/advanced/src/advanced_omi_backend/openai_factory.py +++ b/backends/advanced/src/advanced_omi_backend/openai_factory.py @@ -4,6 +4,9 @@ modules that need an OpenAI client should use this factory instead of creating clients directly. +Clients are cached by (api_key, base_url, is_async) to avoid repeated +SSL context creation (~400ms per instantiation). + Tracing is handled by the OTEL instrumentor (see observability/otel_setup.py), which auto-instruments all OpenAI calls at startup. No per-client wrapping needed. """ @@ -14,9 +17,32 @@ logger = logging.getLogger(__name__) +_client_cache: dict[tuple[str, str, bool], openai.OpenAI | openai.AsyncOpenAI] = {} + + +def is_reasoning_model(model: str | None) -> bool: + """Return True for OpenAI reasoning-class models (o1/o3/o4/gpt-5 family). + + These models have stricter API surface than standard chat models — they + reject non-default `temperature` and require `max_completion_tokens` + instead of `max_tokens`. Callers should adapt API params accordingly. + """ + if not model: + return False + m = model.lower() + return m.startswith(("o1", "o3", "o4")) or m.startswith("gpt-5") + + +def model_supports_temperature(model: str | None) -> bool: + """Return False for models that reject non-default temperature.""" + return not is_reasoning_model(model) + def create_openai_client(api_key: str, base_url: str, is_async: bool = False): - """Create an OpenAI client. + """Get or create a cached OpenAI client. + + Clients are cached by (api_key, base_url, is_async). If the API key or + base URL changes (e.g. config reload), a new client is created automatically. Args: api_key: OpenAI API key @@ -26,7 +52,18 @@ def create_openai_client(api_key: str, base_url: str, is_async: bool = False): Returns: OpenAI or AsyncOpenAI client instance """ + cache_key = (api_key, base_url, is_async) + client = _client_cache.get(cache_key) + if client is not None: + return client + if is_async: - return openai.AsyncOpenAI(api_key=api_key, base_url=base_url) + client = openai.AsyncOpenAI(api_key=api_key, base_url=base_url) else: - return openai.OpenAI(api_key=api_key, base_url=base_url) + client = openai.OpenAI(api_key=api_key, base_url=base_url) + + _client_cache[cache_key] = client + logger.info( + f"Created {'async' if is_async else 'sync'} OpenAI client for {base_url}" + ) + return client diff --git a/backends/advanced/src/advanced_omi_backend/plugins/__init__.py b/backends/advanced/src/advanced_omi_backend/plugins/__init__.py index 35b0a2b0..f0a4fc68 100644 --- a/backends/advanced/src/advanced_omi_backend/plugins/__init__.py +++ b/backends/advanced/src/advanced_omi_backend/plugins/__init__.py @@ -14,7 +14,7 @@ - conditional: Execute based on custom condition (future) """ -from .base import BasePlugin, PluginContext, PluginResult +from .base import BasePlugin, PluginConnectivityError, PluginContext, PluginResult from .events import ButtonActionType, ButtonState, ConversationCloseReason, PluginEvent from .router import PluginRouter from .services import PluginServices @@ -22,6 +22,7 @@ __all__ = [ "BasePlugin", "ButtonActionType", + "PluginConnectivityError", "ButtonState", "ConversationCloseReason", "PluginContext", diff --git a/backends/advanced/src/advanced_omi_backend/plugins/base.py b/backends/advanced/src/advanced_omi_backend/plugins/base.py index 92e926f6..24b99e31 100644 --- a/backends/advanced/src/advanced_omi_backend/plugins/base.py +++ b/backends/advanced/src/advanced_omi_backend/plugins/base.py @@ -12,6 +12,17 @@ from typing import Any, Dict, List, Optional +class PluginConnectivityError(Exception): + """An external dependency (e.g. a Home Assistant server) is unreachable. + + Raise this from initialize() for transient network conditions. The plugin + system marks the plugin DEGRADED (not FAILED) and retries initialize() in + the background with backoff, instead of logging a full traceback and giving + up until process restart. Reserve plain exceptions for real config/setup + errors (missing token, bad config) that a retry cannot fix. + """ + + @dataclass class PluginContext: """Context passed to plugin execution""" @@ -66,6 +77,12 @@ def __init__(self, config: Dict[str, Any]): self.enabled = config.get("enabled", False) self.events = config.get("events", []) self.condition = config.get("condition", {"type": "always"}) + # Lower runs earlier. Plugins form an ordered chain of responsibility per + # event: a plugin that handles a command returns should_continue=False to + # stop the chain; returning None (or should_continue=True) passes it to + # the next plugin. This is the configurable routing hierarchy (a future + # drag-to-reorder UI would just edit this value in config/plugins.yml). + self.priority = config.get("priority", 100) def register_prompts(self, registry) -> None: """Register plugin prompts with the prompt registry. @@ -192,6 +209,27 @@ async def on_button_event(self, context: PluginContext) -> Optional[PluginResult """ pass + async def on_wake_word_detected( + self, context: PluginContext + ) -> Optional[PluginResult]: + """ + Called when the standalone wakeword-service detects the acoustic wake word + and captures the command turn. + + Context data contains: + - command: str - The captured command text (resolved from existing + transcription; may be empty if transcription lagged) + - client_id: str - Client identifier + - session_id: str - Audio session id + - conversation_id: str or None - Current conversation id (if any) + - score: float - Acoustic detection score + - reason: str - End-of-turn reason ("smart_turn" | "max_duration") + + Returns: + PluginResult with success status, optional message, and should_continue flag + """ + pass + async def on_plugin_action(self, context: PluginContext) -> Optional[PluginResult]: """ Called when another plugin dispatches an action to this plugin via PluginServices.call_plugin(). diff --git a/backends/advanced/src/advanced_omi_backend/plugins/events.py b/backends/advanced/src/advanced_omi_backend/plugins/events.py index 7a732a04..0cc1f97f 100644 --- a/backends/advanced/src/advanced_omi_backend/plugins/events.py +++ b/backends/advanced/src/advanced_omi_backend/plugins/events.py @@ -44,6 +44,10 @@ def __new__(cls, value: str, description: str = ""): "conversation.starred", "Fires when a conversation is starred or unstarred", ) + WAKE_WORD_DETECTED = ( + "wake_word.detected", + "Acoustic wake word detected by the standalone wakeword-service", + ) # Button events (from OMI device) BUTTON_SINGLE_PRESS = ("button.single_press", "OMI device button single press") @@ -72,11 +76,12 @@ class ButtonState(str, Enum): class ButtonActionType(str, Enum): - """Types of actions a button press can trigger (from test_button_actions plugin config).""" + """Types of actions a button press can trigger (see the button_control plugin).""" CLOSE_CONVERSATION = "close_conversation" STAR_CONVERSATION = "star_conversation" CALL_PLUGIN = "call_plugin" + STOP_PLAYBACK = "stop_playback" class ConversationCloseReason(str, Enum): diff --git a/backends/advanced/src/advanced_omi_backend/plugins/router.py b/backends/advanced/src/advanced_omi_backend/plugins/router.py index d1c64fc3..05ebb085 100644 --- a/backends/advanced/src/advanced_omi_backend/plugins/router.py +++ b/backends/advanced/src/advanced_omi_backend/plugins/router.py @@ -7,13 +7,14 @@ import asyncio import json import logging -import os import re import string import time -from typing import Any, Dict, List, NamedTuple, Optional +from typing import Any, Awaitable, Callable, Dict, List, NamedTuple, Optional -import redis +from advanced_omi_backend.redis_factory import create_sync_redis +from advanced_omi_backend.services.observability import record_event_sync +from advanced_omi_backend.services.sse_publisher import publish_sse_event from .base import BasePlugin, PluginContext, PluginResult from .events import PluginEvent @@ -30,9 +31,9 @@ def normalize_text_for_wake_word(text: str) -> str: - Strip leading/trailing whitespace Example: - "Hey, Vivi!" -> "hey vivi" - "HEY VIVI" -> "hey vivi" - "Hey-Vivi" -> "hey vivi" + "Hey, Hermes!" -> "hey hermes" + "HEY HERMES" -> "hey hermes" + "Hey-Hermes" -> "hey hermes" """ # Lowercase text = text.lower() @@ -53,16 +54,16 @@ def extract_command_around_keyword(transcript: str, keyword: str) -> str: Handles punctuation and spacing around the keyword gracefully. Example: - transcript: "Turn off the lights, Vivi" - keyword: "vivi" + transcript: "Turn off the lights, Hermes" + keyword: "hermes" -> "Turn off the lights" - transcript: "Vivi, turn off the lights in the hall" - keyword: "vivi" + transcript: "Hermes, turn off the lights in the hall" + keyword: "hermes" -> "turn off the lights in the hall" - transcript: "Turn off the hall lights, Vivi, please" - keyword: "vivi" + transcript: "Turn off the hall lights, Hermes, please" + keyword: "hermes" -> "Turn off the hall lights, please" Args: @@ -97,8 +98,8 @@ def extract_command_after_wake_word(transcript: str, wake_word: str) -> str: Handles punctuation and spacing variations by creating a flexible regex pattern. Example: - transcript: "Hey, Vivi, turn off lights" - wake_word: "hey vivi" + transcript: "Hey, Hermes, turn off lights" + wake_word: "hey hermes" -> extracts: "turn off lights" Args: @@ -115,7 +116,7 @@ def extract_command_after_wake_word(transcript: str, wake_word: str) -> str: return transcript.strip() # Create regex pattern that allows punctuation/whitespace between parts - # Example: "hey" + "vivi" -> r"hey[\s,.\-!?]*vivi[\s,.\-!?]*" + # Example: "hey" + "hermes" -> r"hey[\s,.\-!?]*hermes[\s,.\-!?]*" # The pattern matches the wake word parts with optional punctuation/whitespace between and after pattern_parts = [re.escape(part) for part in wake_word_parts] # Allow optional punctuation/whitespace between parts @@ -151,6 +152,7 @@ class PluginHealth: # Possible status values REGISTERED = "registered" # Registered but not yet initialized INITIALIZED = "initialized" # Successfully initialized + DEGRADED = "degraded" # External dependency unreachable; retried in background FAILED = "failed" # initialize() raised an exception def __init__(self, plugin_id: str): @@ -182,9 +184,8 @@ def __init__(self): self._services = None # Sync Redis for event logging (works from both FastAPI and RQ workers) - redis_url = os.getenv("REDIS_URL", "redis://localhost:6379/0") try: - self._event_redis = redis.from_url(redis_url, decode_responses=True) + self._event_redis = create_sync_redis(decode_responses=True) except Exception: logger.warning("Could not connect to Redis for event logging") self._event_redis = None @@ -209,7 +210,9 @@ def register_plugin(self, plugin_id: str, plugin: BasePlugin): def mark_plugin_initialized(self, plugin_id: str) -> None: """Mark a plugin as successfully initialized.""" if plugin_id in self.plugin_health: - self.plugin_health[plugin_id].status = PluginHealth.INITIALIZED + health = self.plugin_health[plugin_id] + health.status = PluginHealth.INITIALIZED + health.error = None def mark_plugin_failed(self, plugin_id: str, error: str) -> None: """Mark a plugin as failed during initialization.""" @@ -217,6 +220,37 @@ def mark_plugin_failed(self, plugin_id: str, error: str) -> None: health = self.plugin_health[plugin_id] health.status = PluginHealth.FAILED health.error = error + record_event_sync( + severity="error", + category="plugin", + source=plugin_id, + title=f"Plugin '{plugin_id}' failed to initialize", + detail=error, + metadata={"plugin_id": plugin_id}, + ) + + def mark_plugin_degraded(self, plugin_id: str, error: str) -> None: + """Mark a plugin as degraded: its external dependency is unreachable. + + Unlike FAILED, degraded plugins are retried in the background (see + plugin_service.run_plugin_recovery). The system event is recorded only on + the transition into DEGRADED, so retry ticks don't spam the event log. + """ + health = self.plugin_health.get(plugin_id) + if health is None: + return + transition = health.status != PluginHealth.DEGRADED + health.status = PluginHealth.DEGRADED + health.error = error + if transition: + record_event_sync( + severity="warning", + category="plugin", + source=plugin_id, + title=f"Plugin '{plugin_id}' degraded: dependency unreachable", + detail=error, + metadata={"plugin_id": plugin_id}, + ) def get_health_summary(self) -> Dict[str, Any]: """Get health summary for all registered plugins.""" @@ -225,13 +259,19 @@ def get_health_summary(self) -> Dict[str, Any]: return { "total": len(plugins), "initialized": statuses.count(PluginHealth.INITIALIZED), + "degraded": statuses.count(PluginHealth.DEGRADED), "failed": statuses.count(PluginHealth.FAILED), "registered": statuses.count(PluginHealth.REGISTERED), "plugins": plugins, } async def dispatch_event( - self, event: str, user_id: str, data: Dict, metadata: Optional[Dict] = None + self, + event: str, + user_id: str, + data: Dict, + metadata: Optional[Dict] = None, + on_plugin_done: Optional[Callable[..., Awaitable[None]]] = None, ) -> List[PluginResult]: """ Dispatch event to all subscribed plugins. @@ -241,6 +281,13 @@ async def dispatch_event( user_id: User ID for context data: Event-specific data metadata: Optional metadata + on_plugin_done: Optional async observer called after each plugin in the + chain runs, as ``await on_plugin_done(plugin_id, duration_ms, + result, is_last)``. ``result`` is the PluginResult or ``None`` (a + decline). Lets callers trace per-plugin latency and react to a + handoff (e.g. the voice path plays a "thinking" tone when the fast + handler declines and a slower one takes over). Best-effort: an + observer exception is logged and never breaks dispatch. Returns: List of plugin results @@ -251,8 +298,12 @@ async def dispatch_event( results = [] executed = [] # Track per-plugin outcomes for event log - # Get plugins subscribed to this event - plugin_ids = self._plugins_by_event.get(event, []) + # Get plugins subscribed to this event, ordered by priority (lower first) + # so they form a deterministic chain of responsibility. + plugin_ids = sorted( + self._plugins_by_event.get(event, []), + key=lambda pid: getattr(self.plugins[pid], "priority", 100), + ) if not plugin_ids: logger.info(f"🔌 ROUTER: No plugins subscribed to event '{event}'") @@ -261,7 +312,8 @@ async def dispatch_event( f"🔌 ROUTER: Found {len(plugin_ids)} subscribed plugin(s): {plugin_ids}" ) - for plugin_id in plugin_ids: + last_index = len(plugin_ids) - 1 + for index, plugin_id in enumerate(plugin_ids): plugin = self.plugins[plugin_id] if not plugin.enabled: @@ -289,7 +341,28 @@ async def dispatch_event( services=self._services, ) + started = time.perf_counter() result = await self._execute_plugin(plugin, event, context) + duration_ms = (time.perf_counter() - started) * 1000.0 + + # Notify the observer for every plugin that actually ran — including + # declines (result is None), so callers can time a "miss" and react + # to the handoff to the next handler in the chain. + if on_plugin_done is not None: + try: + await on_plugin_done( + plugin_id, + duration_ms, + result, + is_last=(index == last_index), + ) + except ( + Exception + ): # noqa: BLE001 - observer must never break dispatch + logger.debug( + f"on_plugin_done observer failed for '{plugin_id}'", + exc_info=True, + ) if result: status_icon = "✓" if result.success else "✗" @@ -303,6 +376,10 @@ async def dispatch_event( "plugin_id": plugin_id, "success": result.success, "message": result.message, + # Carry the plugin's structured output (reply, command, + # skip reason, etc.) into the event log so the Events + # page can show *why* a plugin succeeded/failed. + "data": result.data, } ) @@ -363,11 +440,32 @@ async def _should_execute( if condition_type == "always": return self._PASS - # Button and starred events bypass transcript-based conditions (no transcript to match) + # Acoustic wake word: the standalone wakeword-service is the ONLY source of + # WAKE_WORD_DETECTED. Fire ONLY on that event, gated by the configured score + # threshold, and never on transcript/button events — so a plugin can be + # gated EXCLUSIVELY on the acoustic wake word. The command text arrives + # pre-resolved in data["command"] (set by the wake-word dispatcher). + if condition_type == "acoustic_wake_word": + if event == PluginEvent.WAKE_WORD_DETECTED: + threshold = float(plugin.condition.get("threshold", 0.0) or 0.0) + raw_score = data.get("score") + score = float(raw_score) if raw_score is not None else 1.0 + if score >= threshold: + return self._PASS + logger.debug( + f"Acoustic wake below threshold ({score:.3f} < {threshold:.3f}); skipping" + ) + return self._SKIP + + # Button, starred, and acoustic wake-word events bypass transcript-based + # conditions (wake_word/keyword_anywhere) — they have no transcript to + # match against. The acoustic detector already arms on the wake word, and + # the command text arrives pre-resolved in data["command"]. if event and event in ( PluginEvent.BUTTON_SINGLE_PRESS, PluginEvent.BUTTON_DOUBLE_PRESS, PluginEvent.CONVERSATION_STARRED, + PluginEvent.WAKE_WORD_DETECTED, ): return self._PASS @@ -449,6 +547,8 @@ async def _execute_plugin( return await plugin.on_memory_processed(context) elif event == PluginEvent.CONVERSATION_STARRED: return await plugin.on_conversation_starred(context) + elif event == PluginEvent.WAKE_WORD_DETECTED: + return await plugin.on_wake_word_detected(context) elif event in ( PluginEvent.BUTTON_SINGLE_PRESS, PluginEvent.BUTTON_DOUBLE_PRESS, @@ -489,8 +589,6 @@ def _log_event( pipe.execute() # Publish SSE event for queue page live updates - from advanced_omi_backend.services.sse_publisher import publish_sse_event - if user_id: publish_sse_event( user_id, diff --git a/backends/advanced/src/advanced_omi_backend/plugins/services.py b/backends/advanced/src/advanced_omi_backend/plugins/services.py index 5dc09a98..25c5ae09 100644 --- a/backends/advanced/src/advanced_omi_backend/plugins/services.py +++ b/backends/advanced/src/advanced_omi_backend/plugins/services.py @@ -5,10 +5,14 @@ (e.g., close a conversation) or with other plugins (e.g., call Home Assistant to toggle lights). """ +import json import logging from typing import TYPE_CHECKING, Optional -import redis.asyncio as aioredis +from advanced_omi_backend.models.conversation import Conversation +from advanced_omi_backend.redis_factory import create_async_redis +from advanced_omi_backend.services.audio_stream.session_store import SessionStore +from advanced_omi_backend.users import User from .base import PluginContext, PluginResult from .events import ConversationCloseReason, PluginEvent @@ -22,9 +26,9 @@ class PluginServices: """Typed interface for plugin-to-system and plugin-to-plugin communication.""" - def __init__(self, router: "PluginRouter", redis_url: str): + def __init__(self, router: "PluginRouter"): self._router = router - self._async_redis = aioredis.from_url(redis_url, decode_responses=True) + self._async_redis = create_async_redis(decode_responses=True) async def cleanup(self): """Close the shared async Redis connection pool.""" @@ -66,12 +70,8 @@ async def close_conversation( ) return False - from advanced_omi_backend.controllers.session_controller import ( - request_conversation_close, - ) - - return await request_conversation_close( - self._async_redis, session_id, reason=reason.value + return await SessionStore(self._async_redis).request_close( + session_id, reason.value ) async def star_conversation(self, session_id: str) -> bool: @@ -85,9 +85,9 @@ async def star_conversation(self, session_id: str) -> bool: Returns: True if the star toggle was successful """ + # Lazy import: circular dependency (conversation_controller imports + # plugin_service, which imports this module) from advanced_omi_backend.controllers.conversation_controller import toggle_star - from advanced_omi_backend.models.conversation import Conversation - from advanced_omi_backend.users import User # Look up current conversation_id from Redis conversation_id = await self._async_redis.get( @@ -115,6 +115,30 @@ async def star_conversation(self, session_id: str) -> bool: # toggle_star returns a dict on success, JSONResponse on error return isinstance(result, dict) and "starred" in result + async def stop_playback(self, client_id: str) -> bool: + """Stop any TTS currently playing on a device (barge-in). + + Publishes a ``stop-audio`` control frame to the device's downlink channel. + The WebSocket handler that owns the device connection picks it up and, for + Opus-streaming clients, cancels the in-flight stream and tells the device to + flush (see ``device_audio.stop_play_audio``). Decoupled via Redis so this + works from any process (the button handler runs in the backend, but wake + handlers run in the workers). + + Args: + client_id: The device/client whose playback should stop. + + Returns: + True if the stop request was published. + """ + if not client_id: + logger.warning("stop_playback called with no client_id") + return False + message = json.dumps({"type": "stop-audio", "data": {}}) + await self._async_redis.publish(f"device:downlink:{client_id}", message) + logger.info(f"⏹ Requested stop-audio for {client_id}") + return True + async def call_plugin( self, plugin_id: str, diff --git a/backends/advanced/src/advanced_omi_backend/prompt_defaults.py b/backends/advanced/src/advanced_omi_backend/prompt_defaults.py index 37dfcd24..11ae9005 100644 --- a/backends/advanced/src/advanced_omi_backend/prompt_defaults.py +++ b/backends/advanced/src/advanced_omi_backend/prompt_defaults.py @@ -315,84 +315,35 @@ def register_all_defaults(registry: PromptRegistry) -> None: ) # ------------------------------------------------------------------ - # memory.temporal_extraction + # chat.system.tool_mode # ------------------------------------------------------------------ registry.register_default( - "memory.temporal_extraction", + "chat.system.tool_mode", template="""\ -You are an expert at extracting temporal and entity information from memory facts. - -Your task is to analyze a memory fact and extract structured information in JSON format: -1. **Entity Types**: Determine if the memory is about events, people, places, promises, or relationships -2. **Temporal Information**: Extract and resolve any time references to actual ISO 8601 timestamps -3. **Named Entities**: List all people, places, and things mentioned -4. **Representation**: Choose a single emoji that captures the essence of the memory - -You must return a valid JSON object with the following structure. - -**Current Date Context:** -- Today's date: {{current_date}} -- Current time: {{current_time}} -- Day of week: {{day_of_week}} - -**Time Resolution Guidelines:** - -Relative Time References: -- "tomorrow" -> Add 1 day to current date -- "next week" -> Add 7 days to current date -- "in X days/weeks/months" -> Add X time units to current date -- "yesterday" -> Subtract 1 day from current date - -Time of Day: -- "4pm" or "16:00" -> Use current date with that time -- "tomorrow at 4pm" -> Use tomorrow's date at 16:00 -- "morning" -> 09:00 on the referenced day -- "afternoon" -> 14:00 on the referenced day -- "evening" -> 18:00 on the referenced day -- "night" -> 21:00 on the referenced day - -Duration Estimation (when only start time is mentioned): -- Events like "wedding", "meeting", "party" -> Default 2 hours duration -- "lunch", "dinner", "breakfast" -> Default 1 hour duration -- "class", "workshop" -> Default 1.5 hours duration -- "appointment", "call" -> Default 30 minutes duration - -**Entity Type Guidelines:** - -- **isEvent**: True for scheduled activities, appointments, meetings, parties, ceremonies, classes, etc. -- **isPerson**: True when the primary focus is on a person (e.g., "Met John", "Sarah is my friend") -- **isPlace**: True when the primary focus is a location (e.g., "Botanical Gardens is beautiful", "Favorite restaurant is...") -- **isPromise**: True for commitments, promises, or agreements (e.g., "I'll call you tomorrow", "We agreed to meet") -- **isRelationship**: True for statements about relationships (e.g., "John is my brother", "We're getting married") - -**Instructions:** -- Return structured data following the TemporalEntity schema -- Convert all temporal references to ISO 8601 format -- Be conservative: if there's no temporal information, leave timeRanges empty -- Multiple tags can be true (e.g., isEvent and isPerson both true for "meeting with John") -- Extract all meaningful entities (people, places, things) mentioned in the fact -- Choose an emoji that best represents the core meaning of the memory -""", - name="Temporal Extraction", - description="Extracts temporal and entity information from memory facts with date resolution.", - category="memory", - variables=["current_date", "current_time", "day_of_week"], - is_dynamic=True, - ) - - # ------------------------------------------------------------------ - # chat.system - # ------------------------------------------------------------------ - registry.register_default( - "chat.system", - template="""\ -You are a helpful AI assistant with access to the user's personal memories and conversation history. - -Use the provided memories and conversation context to give personalized, contextual responses. If memories are relevant, reference them naturally in your response. Be conversational and helpful. - -If no relevant memories are available, respond normally based on the conversation context.""", - name="Chat System Prompt", - description="Default system prompt for the chat assistant.", +You are Chronicle, a helpful assistant with access to the user's personal memory: an +Obsidian-style vault of notes about the people, topics, places, and conversations in +their life. + +You have one tool, `search_memories`. It runs an agentic search over that vault and +returns a synthesized `answer` plus the `sources` (note paths) it drew from. + +When to search: +- Search whenever the question touches anything personal — people the user knows, past + conversations, their preferences, plans, things they've mentioned, or any "what do you + know about X / who is X / when did I…" question. +- Do NOT search for general knowledge, definitions, math, coding, or casual chit-chat + ("hi", "thanks"). Just answer those directly. +- You may search more than once if the first query was too narrow or returned nothing + useful. + +Using results: +- Treat the returned `answer` as your retrieved knowledge and weave it naturally into + your reply. Mention which note it came from only when it genuinely helps; never dump + raw paths or list memories mechanically. +- If the search returns no answer (nothing in the vault), say you don't have that in + memory rather than guessing. Never invent personal facts about the user.""", + name="Chat System Prompt (Tool Mode)", + description="System prompt for the chat assistant. The assistant decides when to call the search_memories tool, which runs the agentic vault search.", category="chat", ) @@ -425,106 +376,38 @@ def register_all_defaults(registry: PromptRegistry) -> None: registry.register_default( "conversation.detailed_summary", template="""\ -Generate a comprehensive, detailed summary of this conversation transcript. +Summarize this conversation transcript, accurately and in proportion to how much was actually said. {{memory_section}}INSTRUCTIONS: -Your task is to create a high-quality, detailed summary of a conversation transcription that captures the full information and context of what was discussed. This is NOT a brief summary - provide comprehensive coverage. +Write a clear summary of the conversation. Match the length to the content: a long, substantive conversation deserves a thorough multi-paragraph summary; a short or trivial exchange (a single question, a quick command, a few words) deserves only a sentence or two. Do not pad. Rules: - We know it's a conversation, so no need to say "This conversation involved..." -- Provide complete coverage of all topics, points, and important details discussed -- Correct obvious transcription errors and remove filler words (um, uh, like, you know) -- Organize information logically by topic or chronologically as appropriate -- Use clear, well-structured paragraphs or bullet points, but make the length relative to the amound of content. -- Maintain the meaning and intent of what was said, but improve clarity and coherence -- Include relevant context, decisions made, action items mentioned, and conclusions reached -{{speaker_instruction}}- Write in a natural, flowing narrative style -- Only include word-for-word quotes if it's more efficiency than rephrasing -- Focus on substantive content - what was actually discussed and decided - -Think of this as creating a high-quality information set that someone could use to understand everything important that happened in this conversation without reading the full transcript. - -DETAILED SUMMARY:""", +- Ground everything strictly in the transcript. Do NOT invent or infer details, decisions, action items, plans, names, dates, or next steps that were not actually said. +- Only mention decisions, action items, or conclusions if they were explicitly stated in the transcript. +- Correct obvious transcription errors and remove filler words (um, uh, like, you know), but never change the meaning. +- Organize information logically by topic or chronologically as appropriate. +- Use clear, well-structured prose or bullet points; keep the length proportional to the amount of content. +- If the transcript is trivial or says very little, summarize it in a sentence or two rather than elaborating. +{{speaker_instruction}}- Write in a natural, flowing style. +- Only include word-for-word quotes when a quote is clearer than rephrasing. + +Any "CONTEXT ABOUT THE USER" shown above is background only — use it to interpret what was said, never as material to add. Do not summarize the context; only summarize the transcript. + +SUMMARY:""", name="Conversation Detailed Summary", - description="Generates a comprehensive multi-paragraph summary of a conversation.", + description="Generates a proportional-length summary of a conversation, grounded strictly in the transcript.", category="conversation", variables=["speaker_instruction", "memory_section"], is_dynamic=True, ) - # ------------------------------------------------------------------ - # knowledge_graph.entity_extraction - # ------------------------------------------------------------------ - registry.register_default( - "knowledge_graph.entity_extraction", - template="""\ -You are an entity extraction system. Extract entities, relationships, and promises from conversation transcripts. - -ENTITY TYPES: -- person: Named individuals (not generic roles) -- organization: Companies, institutions, groups -- place: Locations, addresses, venues -- event: Meetings, appointments, activities with time -- thing: Products, objects, concepts mentioned - -RELATIONSHIP TYPES: -- works_at: Employment relationship -- lives_in: Residence -- knows: Personal connection -- attended: Participated in event -- located_at: Place within place -- part_of: Membership or inclusion -- related_to: General association - -EXTRACTION RULES: -1. Only extract NAMED entities (not "my friend" but "John") -2. Use "speaker" as the subject when the user mentions themselves -3. Extract temporal info for events (dates, times) -4. Capture promises/commitments with deadlines -5. Skip filler words, small talk, and vague references -6. Normalize names (capitalize properly) -7. Assign appropriate emoji icons to entities - -Return a JSON object with this structure: -{ - "entities": [ - { - "name": "Entity Name", - "type": "person|organization|place|event|thing", - "details": "Brief description or context", - "icon": "Appropriate emoji", - "when": "Time reference for events (optional)" - } - ], - "relationships": [ - { - "subject": "Entity name or 'speaker'", - "relation": "works_at|lives_in|knows|attended|located_at|part_of|related_to", - "object": "Target entity name" - } - ], - "promises": [ - { - "action": "What was promised", - "to": "Person promised to (optional)", - "deadline": "When it should be done (optional)" - } - ] -} - -If no entities, relationships, or promises are found, return empty arrays. -Only return valid JSON, no additional text.""", - name="Entity Extraction", - description="Extracts entities, relationships, and promises from conversation transcripts.", - category="knowledge_graph", - ) - # ------------------------------------------------------------------ # asr.hot_words # ------------------------------------------------------------------ registry.register_default( "asr.hot_words", - template="vivi, chronicle, omi", + template="hermes, chronicle, omi", name="ASR Hot Words", description="Comma-separated hot words for speech recognition. " "For Deepgram: boosts keyword recognition via keyterm. " @@ -590,7 +473,7 @@ def register_all_defaults(registry: PromptRegistry) -> None: - **hourly_recap**: button events + email sending - **email_summarizer**: conversation.complete events - **homeassistant**: wake word condition + cross-plugin calls - - **test_button_actions**: button action routing + - **button_control**: button press → action routing (stop playback, close/star conversation) ## Rules - Describe proposed changes before applying; the system handles user confirmation @@ -716,42 +599,106 @@ def register_all_defaults(registry: PromptRegistry) -> None: ) # ------------------------------------------------------------------ - # memory.consolidate_basic_memory + # memory.generate_conversation_doc # ------------------------------------------------------------------ registry.register_default( - "memory.consolidate_basic_memory", + "memory.generate_conversation_doc", template="""\ -You are building a personal knowledge base document for an AI assistant. - -You will receive two inputs: -1. **Existing MEMORY.md** — the current knowledge base (may be empty on first run) -2. **Extracted memories** — individual facts extracted from the user's conversations - -Your job is to produce an updated MEMORY.md that merges the new facts into the \ -existing document. The result should be a well-organized markdown document that \ -an AI can use as context about this user. - -## Guidelines - -- **Organize by topic**: Group facts under clear headings (e.g., ## People, \ -## Work, ## Preferences, ## Health, ## Plans, ## Locations) -- **Merge, don't duplicate**: If a new fact updates or contradicts an existing \ -entry, replace the old one -- **Be concise**: Each fact should be 1-2 lines. No filler, no prose -- **Preserve existing structure**: Keep the heading hierarchy from the existing \ -MEMORY.md if present; add new sections as needed -- **Use bullet points**: Facts under each heading should be bulleted -- **Include attribution where useful**: "John (coworker)" not just "John" \ -if context helps -- **Drop noise**: Skip facts that are too vague or ephemeral to be useful \ -long-term (e.g., "had a meeting today" without specifics) -- **Date-stamp where relevant**: If a fact has a clear date, include it \ -(e.g., "Starting new role at Acme Corp (March 2026)") - -## Output - -Return ONLY the updated markdown document. No preamble, no explanation.""", - name="Consolidate Basic Memory", - description="Merges extracted memory facts into a structured MEMORY.md knowledge base document.", +You are generating a structured conversation document from a transcript. + +Given a transcript with speaker labels, produce a markdown document with this EXACT structure: + +--- +conversation_id: {{conversation_id}} +date: {{date}} +speakers: [{{speakers}}] +duration_minutes: {{duration}} +--- + +## {Title - descriptive, 3-8 words} + +### Summary +{2-3 sentence summary of what was discussed} + +### Key Facts +{Bulleted list. CRITICAL — preserve every concrete detail VERBATIM. For each fact in the transcript, capture all WH-details that appear: + - WHO: people, friends, colleagues, family, by name. + - WHAT: titles (books / plays / movies / songs / games / podcasts / playlists), + products, brands, dishes, model numbers, job titles, occupations. + - WHERE: stores, retailers (e.g. "Target", "Trader Joe's"), restaurants, + cities, parks, venues, addresses, websites. + - WHEN: full date and/or time. If the transcript prefix has one, use it verbatim. + - HOW MUCH / HOW MANY: prices, distances, durations, quantities, speeds, + personal-best times — keep them exact. + +Distinguish current vs. previous state explicitly when the user says so +("previous job was X", "used to be Y", "no longer Z"). + +Examples of BAD vs. GOOD facts: + BAD: Attended a play at the local community theater. + GOOD: Attended The Glass Menagerie at the local community theater on 2023-05-26. + BAD: Bought a new tennis racket recently. + GOOD: Bought a new tennis racket from the sports store downtown. + BAD: Made a Spotify playlist. + GOOD: Created a Spotify playlist named "Summer Vibes". + BAD: Previous job was in marketing. + GOOD: Previous occupation: marketing specialist at a small startup. + BAD: Upgraded internet plan. + GOOD: New internet plan: 500 Mbps. + BAD: Improved 5K time. + GOOD: Personal best in the charity 5K: 25:50 (previous PB was 27:12).} + +### People +{Bulleted list in format: - Name (role/relationship, context)} +Include ALL named individuals — speakers, people mentioned, people referenced. +If a speaker is identified by name (e.g., "John" not "Speaker 0"), they MUST appear here. +Do not include unnamed roles or generic labels like "Speaker 0". + +### Action Items +{Bulleted list in format: - [ ] Action item description} +Use [x] for items already completed in the conversation. + +Rules: +- Every ### section MUST be present, even if empty (use "- None" for empty sections) +- People section: ONLY named individuals, format MUST be "- Name (description)" +- Key Facts: keep every WH-detail verbatim — never paraphrase a name, title, + store, date, or number into a generic word like "a play" / "a store" / "recently" +- Action Items: use checkbox format [ ] or [x] +- Do NOT add any sections beyond the four listed above +""", + name="Conversation Document Generator", + description="Generates a structured markdown conversation document from a transcript with frontmatter, summary, key facts, people, and action items.", + category="memory", + variables=["conversation_id", "date", "speakers", "duration"], + is_dynamic=True, + ) + + # ------------------------------------------------------------------ + # memory.agent_system (Chronicle memory agent — vault-editing system prompt) + # ------------------------------------------------------------------ + # Canonical text lives with the agent so the two never drift; lazy import + # avoids any import-order coupling (the agent module is heavy). + from advanced_omi_backend.services.memory.agent.memory_agent import ( + DEFAULT_AGENT_SYSTEM_PROMPT, + SEARCH_SYSTEM_PROMPT, + ) + + registry.register_default( + "memory.agent_system", + template=DEFAULT_AGENT_SYSTEM_PROMPT, + name="Memory Agent System Prompt", + description="Vault-aware system prompt for the tool-calling memory agent. " + "Supports a {{vault_summary}} slot for learned, per-user vault conventions.", + category="memory", + variables=["vault_summary"], + ) + + registry.register_default( + "memory.search_system", + template=SEARCH_SYSTEM_PROMPT, + name="Memory Search Agent System Prompt", + description="Vault-aware system prompt for the read-only retrieval agent " + "(grep/glob/read). Supports a {{vault_summary}} slot.", category="memory", + variables=["vault_summary"], ) diff --git a/backends/advanced/src/advanced_omi_backend/redis_factory.py b/backends/advanced/src/advanced_omi_backend/redis_factory.py new file mode 100644 index 00000000..836814f2 --- /dev/null +++ b/backends/advanced/src/advanced_omi_backend/redis_factory.py @@ -0,0 +1,58 @@ +"""Central Redis client factory. + +The one place that knows ``REDIS_URL`` and how to construct a correctly-configured +Redis client. Call sites must not sprinkle ``os.getenv("REDIS_URL")`` + +``from_url(...)`` with ad-hoc ``decode_responses`` settings — that inconsistency is +what forces downstream code (e.g. ``SessionStore``) to defensively tolerate both +bytes and str values. + +Two axes: + +- **async vs sync.** FastAPI handlers, services, and the ``@async_job`` RQ wrapper + use async clients (``redis.asyncio``). RQ's own queue/worker connection and the + sync SSE/plugin-event-log publishers use sync clients (``redis``). +- **decode_responses.** ``True`` yields ``str`` values — convenient for string and + JSON keys. ``False`` yields ``bytes`` — required by RQ and by the binary audio + streams. Choose per use-case; default ``False`` matches RQ and the audio path. + +Ownership: ``create_*`` returns a *fresh* client the caller owns and MUST close +(``await client.aclose()`` / ``client.close()``). This is the right choice for RQ +jobs (each runs in its own short-lived event loop), per-request handlers, and +per-connection pub/sub. Long-lived singletons (client manager, SSE publisher, +plugin router) create one client via ``create_*`` and hold it for the process +lifetime — there is intentionally no shared cache here, because an async client is +bound to the event loop that first uses it and caching one across RQ's per-job +loops would break. + +This module is also the natural home for future cross-cutting concerns: connection +pool tuning, ``health_check_interval``, socket keepalive, and retry policy. +""" + +import os + +import redis +import redis.asyncio as aioredis + +REDIS_URL = os.getenv("REDIS_URL", "redis://localhost:6379/0") + + +def create_async_redis(*, decode_responses: bool = False) -> aioredis.Redis: + """Create a fresh async Redis client. The caller owns it and must ``aclose()``. + + Use for RQ jobs, per-request handlers, per-connection pub/sub, and the + ``asyncio.run`` entrypoints of standalone async workers. + """ + return aioredis.from_url( + REDIS_URL, encoding="utf-8", decode_responses=decode_responses + ) + + +def create_sync_redis(*, decode_responses: bool = False) -> redis.Redis: + """Create a fresh sync Redis client. The caller owns it and must ``close()``. + + Use for RQ queue/worker connections (``decode_responses=False``) and the sync + SSE / plugin-event-log publishers. + """ + return redis.from_url( + REDIS_URL, encoding="utf-8", decode_responses=decode_responses + ) diff --git a/backends/advanced/src/advanced_omi_backend/redis_keys.py b/backends/advanced/src/advanced_omi_backend/redis_keys.py new file mode 100644 index 00000000..30908ecc --- /dev/null +++ b/backends/advanced/src/advanced_omi_backend/redis_keys.py @@ -0,0 +1,58 @@ +"""Single source of truth for Redis key names used across the backend. + +Redis keys were historically built as inline f-strings at each call site, which is +a misnaming hazard: a typo in one of a dozen sites silently points at a different +key. This module centralizes the key *names* as typed builder functions so a key +can be constructed in exactly one place. + +It is intentionally dependency-light — stdlib only, no imports of ``workers``, +``services``, ``models``, or ``controllers`` — so it can be imported from anywhere +(including ``utils.conversation_utils``, which the RQ workers import, and +``SessionStore``) without risk of an import cycle. + +**Scope (Stage 1):** the session-scoped pointer/lock keys and the +``speech_detection_job`` family. Other key families (``transcription:*``, +``audio:stream:*``, ``sse:*``, …) are still built inline and may migrate here later. + +**Invariant:** for WebSocket audio streams ``session_id == client_id``. The +session-scoped builders below take ``session_id``; a couple of historical call +sites pass ``client_id`` for the same value — routing both through one builder is +exactly what keeps them from silently diverging. +""" + +# --- session-scoped (keyed by session_id == client_id for WebSocket streams) --- + + +def audio_session(session_id: str) -> str: + """Hash holding the cross-process session state (owned by ``SessionStore``).""" + return f"audio:session:{session_id}" + + +def session_signal(session_id: str) -> str: + """Pub/sub channel for session lifecycle signals.""" + return f"session:signal:{session_id}" + + +def session_conversation_count(session_id: str) -> str: + """Counter of conversations opened during the session.""" + return f"session:conversation_count:{session_id}" + + +def conversation_current(session_id: str) -> str: + """Pointer to the session's current conversation (drives WAV rotation).""" + return f"conversation:current:{session_id}" + + +def conversation_create_lock(session_id: str) -> str: + """Per-session lock serializing first-conversation creation across workers.""" + return f"conversation:create_lock:{session_id}" + + +def speech_detection_job(session_id: str) -> str: + """Pointer to the live speech-detection job id for the session.""" + return f"speech_detection_job:{session_id}" + + +def speech_detection_enqueue_lock(session_id: str) -> str: + """Single-flight lock guarding speech-detection job enqueue bursts.""" + return f"speech_detection_enqueue_lock:{session_id}" diff --git a/backends/advanced/src/advanced_omi_backend/routers/api_router.py b/backends/advanced/src/advanced_omi_backend/routers/api_router.py index 99bc8c9e..16811b5e 100644 --- a/backends/advanced/src/advanced_omi_backend/routers/api_router.py +++ b/backends/advanced/src/advanced_omi_backend/routers/api_router.py @@ -17,14 +17,16 @@ chat_router, client_router, conversation_router, + data_audit_router, finetuning_router, - knowledge_graph_router, memory_router, - obsidian_router, queue_router, sse_router, + system_events_router, system_router, user_router, + vault_sync_router, + wakeword_router, ) from .modules.health_routes import router as health_router @@ -42,13 +44,15 @@ router.include_router(chat_router) router.include_router(client_router) router.include_router(conversation_router) +router.include_router(data_audit_router) router.include_router(finetuning_router) -router.include_router(knowledge_graph_router) router.include_router(memory_router) -router.include_router(obsidian_router) router.include_router(sse_router) +router.include_router(system_events_router) router.include_router(system_router) router.include_router(queue_router) +router.include_router(vault_sync_router) +router.include_router(wakeword_router) router.include_router( health_router ) # Also include under /api for frontend compatibility diff --git a/backends/advanced/src/advanced_omi_backend/routers/modules/__init__.py b/backends/advanced/src/advanced_omi_backend/routers/modules/__init__.py index 522ae73d..c93daa58 100644 --- a/backends/advanced/src/advanced_omi_backend/routers/modules/__init__.py +++ b/backends/advanced/src/advanced_omi_backend/routers/modules/__init__.py @@ -15,7 +15,6 @@ - health_routes: Health check endpoints - websocket_routes: WebSocket connection handling - admin_routes: Admin-only system management endpoints -- knowledge_graph_routes: Knowledge graph entities, relationships, and promises - sse_routes: Server-Sent Events for real-time UI updates """ @@ -25,15 +24,17 @@ from .chat_routes import router as chat_router from .client_routes import router as client_router from .conversation_routes import router as conversation_router +from .data_audit_routes import router as data_audit_router from .finetuning_routes import router as finetuning_router from .health_routes import router as health_router -from .knowledge_graph_routes import router as knowledge_graph_router from .memory_routes import router as memory_router -from .obsidian_routes import router as obsidian_router from .queue_routes import router as queue_router from .sse_routes import router as sse_router +from .system_events_routes import router as system_events_router from .system_routes import router as system_router from .user_routes import router as user_router +from .vault_sync_routes import router as vault_sync_router +from .wakeword_routes import router as wakeword_router from .websocket_routes import router as websocket_router __all__ = [ @@ -43,14 +44,16 @@ "chat_router", "client_router", "conversation_router", + "data_audit_router", "finetuning_router", "health_router", - "knowledge_graph_router", "memory_router", - "obsidian_router", "queue_router", "sse_router", + "system_events_router", "system_router", "user_router", + "vault_sync_router", + "wakeword_router", "websocket_router", ] diff --git a/backends/advanced/src/advanced_omi_backend/routers/modules/admin_routes.py b/backends/advanced/src/advanced_omi_backend/routers/modules/admin_routes.py index 2b6f0dd9..f6ebc20b 100644 --- a/backends/advanced/src/advanced_omi_backend/routers/modules/admin_routes.py +++ b/backends/advanced/src/advanced_omi_backend/routers/modules/admin_routes.py @@ -5,13 +5,18 @@ """ import logging +from datetime import datetime, timedelta, timezone from typing import Optional from fastapi import APIRouter, Depends, HTTPException, Query from fastapi.responses import JSONResponse from advanced_omi_backend.auth import current_active_user +from advanced_omi_backend.config import get_cleanup_settings +from advanced_omi_backend.controllers.queue_controller import get_queue +from advanced_omi_backend.models.conversation import Conversation from advanced_omi_backend.users import User +from advanced_omi_backend.workers.cleanup_jobs import purge_old_deleted_conversations logger = logging.getLogger(__name__) @@ -28,8 +33,6 @@ def require_admin(current_user: User = Depends(current_active_user)) -> User: @router.get("/cleanup/settings") async def get_cleanup_settings_admin(admin: User = Depends(require_admin)): """Get current cleanup settings (admin only).""" - from advanced_omi_backend.config import get_cleanup_settings - settings = get_cleanup_settings() return { **settings, @@ -47,11 +50,6 @@ async def trigger_cleanup( ): """Manually trigger cleanup of soft-deleted conversations (admin only).""" try: - from advanced_omi_backend.controllers.queue_controller import get_queue - from advanced_omi_backend.workers.cleanup_jobs import ( - purge_old_deleted_conversations, - ) - # Enqueue cleanup job queue = get_queue("default") job = queue.enqueue( @@ -92,17 +90,12 @@ async def preview_cleanup( ): """Preview what would be deleted by cleanup (admin only).""" try: - from datetime import datetime, timedelta - - from advanced_omi_backend.config import get_cleanup_settings - from advanced_omi_backend.models.conversation import Conversation - # Use provided retention or default from config if retention_days is None: settings_dict = get_cleanup_settings() retention_days = settings_dict["retention_days"] - cutoff_date = datetime.utcnow() - timedelta(days=retention_days) + cutoff_date = datetime.now(timezone.utc) - timedelta(days=retention_days) # Count conversations that would be deleted count = await Conversation.find( diff --git a/backends/advanced/src/advanced_omi_backend/routers/modules/annotation_routes.py b/backends/advanced/src/advanced_omi_backend/routers/modules/annotation_routes.py index 43ffa212..4ddba293 100644 --- a/backends/advanced/src/advanced_omi_backend/routers/modules/annotation_routes.py +++ b/backends/advanced/src/advanced_omi_backend/routers/modules/annotation_routes.py @@ -6,6 +6,7 @@ """ import logging +import uuid from datetime import datetime, timezone from typing import List @@ -13,6 +14,10 @@ from fastapi.responses import JSONResponse from advanced_omi_backend.auth import current_active_user +from advanced_omi_backend.constants import NOISE_LABEL +from advanced_omi_backend.controllers.queue_controller import ( + conversation_edit_chain_in_flight, +) from advanced_omi_backend.models.annotation import ( Annotation, AnnotationResponse, @@ -20,23 +25,41 @@ AnnotationStatus, AnnotationType, AnnotationUpdate, + DeletionAnnotationCreate, DiarizationAnnotationCreate, - EntityAnnotationCreate, InsertAnnotationCreate, MemoryAnnotationCreate, + TimingAnnotationCreate, TitleAnnotationCreate, TranscriptAnnotationCreate, ) from advanced_omi_backend.models.conversation import Conversation -from advanced_omi_backend.services.knowledge_graph import get_knowledge_graph_service +from advanced_omi_backend.models.job import JobPriority from advanced_omi_backend.services.memory import get_memory_service +from advanced_omi_backend.services.memory.audit import MemoryCause, UpdateStrategy from advanced_omi_backend.users import User +from advanced_omi_backend.workers.memory_jobs import enqueue_memory_processing logger = logging.getLogger(__name__) router = APIRouter(prefix="/annotations", tags=["annotations"]) +def _apply_diarization_label(segment, corrected_speaker: str) -> None: + """Apply a diarization correction to a copied segment in place. + + The reserved NOISE_LABEL means "this is background/noise, not a person": + relabel it, drop any prior identification, and reclassify it to a non-speech + (event) segment so it falls out of speech∩speaker overlap and speech-only + playback. Any other label is a normal speaker correction. + """ + segment.speaker = corrected_speaker + if corrected_speaker == NOISE_LABEL: + segment.identified_as = None + segment.confidence = None + segment.segment_type = Conversation.SegmentType.EVENT.value + + @router.get("/suggestions") async def get_pending_suggestions( current_user: User = Depends(current_active_user), @@ -393,27 +416,6 @@ async def update_annotation_status( except Exception as e: logger.error(f"Error applying transcript suggestion: {e}") # Don't fail the status update if segment update fails - elif annotation.is_entity_annotation(): - # Update entity in Neo4j - try: - kg_service = get_knowledge_graph_service() - update_kwargs = {} - if annotation.entity_field == "name": - update_kwargs["name"] = annotation.corrected_text - elif annotation.entity_field == "details": - update_kwargs["details"] = annotation.corrected_text - if update_kwargs: - await kg_service.update_entity( - entity_id=annotation.entity_id, - user_id=annotation.user_id, - **update_kwargs, - ) - logger.info( - f"Applied entity suggestion to entity {annotation.entity_id}" - ) - except Exception as e: - logger.error(f"Error applying entity suggestion: {e}") - # Don't fail the status update if entity update fails elif annotation.is_title_annotation(): # Update conversation title try: @@ -606,6 +608,8 @@ async def create_insert_annotation( insert_text=annotation_data.insert_text, insert_segment_type=annotation_data.insert_segment_type, insert_speaker=annotation_data.insert_speaker, + insert_start=annotation_data.insert_start, + insert_end=annotation_data.insert_end, status=AnnotationStatus.PENDING, processed=False, ) @@ -650,115 +654,6 @@ async def get_insert_annotations( ) -# === Entity Annotation Routes === - - -@router.post("/entity", response_model=AnnotationResponse) -async def create_entity_annotation( - annotation_data: EntityAnnotationCreate, - current_user: User = Depends(current_active_user), -): - """ - Create annotation for entity edit (name or details correction). - - - Validates user owns the entity - - Creates annotation record for jargon/finetuning pipeline - - Applies correction to Neo4j immediately - - Marked as processed=False for downstream cron consumption - - Dual purpose: entity name corrections feed both the jargon pipeline - (domain vocabulary for ASR) and the entity extraction pipeline - (improving future extraction accuracy). - """ - try: - # Validate entity_field - if annotation_data.entity_field not in ("name", "details"): - raise HTTPException( - status_code=400, - detail="entity_field must be 'name' or 'details'", - ) - - # Verify entity exists and belongs to user - kg_service = get_knowledge_graph_service() - entity = await kg_service.get_entity( - entity_id=annotation_data.entity_id, - user_id=current_user.user_id, - ) - if not entity: - raise HTTPException(status_code=404, detail="Entity not found") - - # Create annotation - annotation = Annotation( - annotation_type=AnnotationType.ENTITY, - user_id=current_user.user_id, - entity_id=annotation_data.entity_id, - entity_field=annotation_data.entity_field, - original_text=annotation_data.original_text, - corrected_text=annotation_data.corrected_text, - status=AnnotationStatus.ACCEPTED, - processed=False, # Unprocessed — jargon/finetuning cron will consume later - ) - await annotation.save() - logger.info( - f"Created entity annotation {annotation.id} for entity {annotation_data.entity_id} " - f"field={annotation_data.entity_field}" - ) - - # Apply correction to Neo4j immediately - try: - update_kwargs = {} - if annotation_data.entity_field == "name": - update_kwargs["name"] = annotation_data.corrected_text - elif annotation_data.entity_field == "details": - update_kwargs["details"] = annotation_data.corrected_text - - await kg_service.update_entity( - entity_id=annotation_data.entity_id, - user_id=current_user.user_id, - **update_kwargs, - ) - logger.info( - f"Applied entity correction to Neo4j for entity {annotation_data.entity_id}" - ) - except Exception as e: - logger.error(f"Error applying entity correction to Neo4j: {e}") - # Annotation is saved but Neo4j update failed — log but don't fail the request - - return AnnotationResponse.model_validate(annotation) - - except HTTPException: - raise - except Exception as e: - logger.error(f"Error creating entity annotation: {e}", exc_info=True) - raise HTTPException( - status_code=500, - detail=f"Failed to create entity annotation: {str(e)}", - ) - - -@router.get("/entity/{entity_id}", response_model=List[AnnotationResponse]) -async def get_entity_annotations( - entity_id: str, - current_user: User = Depends(current_active_user), -): - """Get all annotations for an entity.""" - try: - annotations = await Annotation.find( - Annotation.annotation_type == AnnotationType.ENTITY, - Annotation.entity_id == entity_id, - Annotation.user_id == current_user.user_id, - ).to_list() - - return [AnnotationResponse.model_validate(a) for a in annotations] - - except Exception as e: - logger.error(f"Error fetching entity annotations: {e}", exc_info=True) - raise HTTPException( - status_code=500, - detail=f"Failed to fetch entity annotations: {str(e)}", - ) - - # === Title Annotation Routes === @@ -909,6 +804,147 @@ async def create_diarization_annotation( ) +@router.post("/timing", response_model=AnnotationResponse) +async def create_timing_annotation( + annotation_data: TimingAnnotationCreate, + current_user: User = Depends(current_active_user), +): + """ + Create a TIMING annotation that adjusts an existing segment's start/end. + + Used by the waveform region editor (move/resize a segment's span). Not applied + immediately — staged as a pending correction and committed by ``/apply``, which + re-derives a new transcript version. Validates ownership, segment index and that + end > start. + """ + try: + conversation = await Conversation.find_one( + Conversation.conversation_id == annotation_data.conversation_id, + Conversation.user_id == current_user.user_id, + ) + if not conversation: + raise HTTPException(status_code=404, detail="Conversation not found") + + active_transcript = conversation.active_transcript + if not active_transcript or annotation_data.segment_index >= len( + active_transcript.segments + ): + raise HTTPException(status_code=400, detail="Invalid segment index") + + if annotation_data.new_end <= annotation_data.new_start: + raise HTTPException( + status_code=400, detail="new_end must be greater than new_start" + ) + + annotation = Annotation( + annotation_type=AnnotationType.TIMING, + user_id=current_user.user_id, + conversation_id=annotation_data.conversation_id, + segment_index=annotation_data.segment_index, + new_start=annotation_data.new_start, + new_end=annotation_data.new_end, + status=annotation_data.status, + processed=False, + ) + await annotation.save() + logger.info( + f"Created timing annotation {annotation.id} for conversation " + f"{annotation_data.conversation_id} segment {annotation_data.segment_index} " + f"→ [{annotation_data.new_start:.2f}, {annotation_data.new_end:.2f}]" + ) + + return AnnotationResponse.model_validate(annotation) + + except HTTPException: + raise + except Exception as e: + logger.error(f"Error creating timing annotation: {e}", exc_info=True) + raise HTTPException( + status_code=500, + detail=f"Failed to create timing annotation: {str(e)}", + ) + + +@router.get("/timing/{conversation_id}", response_model=List[AnnotationResponse]) +async def get_timing_annotations( + conversation_id: str, + current_user: User = Depends(current_active_user), +): + """Get all TIMING annotations for a conversation.""" + annotations = await Annotation.find( + Annotation.conversation_id == conversation_id, + Annotation.user_id == current_user.user_id, + Annotation.annotation_type == AnnotationType.TIMING, + ).to_list() + return [AnnotationResponse.model_validate(a) for a in annotations] + + +@router.post("/deletion", response_model=AnnotationResponse) +async def create_deletion_annotation( + annotation_data: DeletionAnnotationCreate, + current_user: User = Depends(current_active_user), +): + """ + Create a DELETION annotation that removes an existing segment. + + Staged as a pending correction (not applied immediately) and committed by + ``/apply``, which re-derives a new transcript version with the segment dropped. + Validates ownership and segment index. + """ + try: + conversation = await Conversation.find_one( + Conversation.conversation_id == annotation_data.conversation_id, + Conversation.user_id == current_user.user_id, + ) + if not conversation: + raise HTTPException(status_code=404, detail="Conversation not found") + + active_transcript = conversation.active_transcript + if not active_transcript or annotation_data.segment_index >= len( + active_transcript.segments + ): + raise HTTPException(status_code=400, detail="Invalid segment index") + + annotation = Annotation( + annotation_type=AnnotationType.DELETION, + user_id=current_user.user_id, + conversation_id=annotation_data.conversation_id, + segment_index=annotation_data.segment_index, + status=annotation_data.status, + processed=False, + ) + await annotation.save() + logger.info( + f"Created deletion annotation {annotation.id} for conversation " + f"{annotation_data.conversation_id} segment {annotation_data.segment_index}" + ) + + return AnnotationResponse.model_validate(annotation) + + except HTTPException: + raise + except Exception as e: + logger.error(f"Error creating deletion annotation: {e}", exc_info=True) + raise HTTPException( + status_code=500, + detail=f"Failed to create deletion annotation: {str(e)}", + ) + + +@router.get("/deletion/{conversation_id}", response_model=List[AnnotationResponse]) +async def get_deletion_annotations( + conversation_id: str, + current_user: User = Depends(current_active_user), +): + """Get all DELETION annotations for a conversation.""" + annotations = await Annotation.find( + Annotation.conversation_id == conversation_id, + Annotation.user_id == current_user.user_id, + Annotation.annotation_type == AnnotationType.DELETION, + ).to_list() + return [AnnotationResponse.model_validate(a) for a in annotations] + + @router.get("/diarization/{conversation_id}", response_model=List[AnnotationResponse]) async def get_diarization_annotations( conversation_id: str, @@ -971,14 +1007,26 @@ async def apply_diarization_annotations( } ) + # Single-flight: don't stack a new edit on top of an in-flight one. Apply + # creates a new transcript version and enqueues memory under deterministic + # job_ids; overlapping with another apply/reprocess races on the + # conversation's full-document save() and can clobber it. + in_flight = conversation_edit_chain_in_flight(conversation_id) + if in_flight: + return JSONResponse( + status_code=409, + content={ + "error": "This conversation is still processing a previous edit. Try again in a moment.", + "in_flight_job_id": in_flight, + }, + ) + # Get active transcript version active_transcript = conversation.active_transcript if not active_transcript: raise HTTPException(status_code=404, detail="No active transcript found") # Create NEW transcript version with corrected speakers - import uuid - new_version_id = str(uuid.uuid4()) # Copy segments and apply corrections (most recent annotation wins) @@ -997,14 +1045,20 @@ async def apply_diarization_annotations( if annotation_for_segment: # Apply correction corrected_segment = segment.model_copy() - corrected_segment.speaker = annotation_for_segment.corrected_speaker + _apply_diarization_label( + corrected_segment, annotation_for_segment.corrected_speaker + ) corrected_segments.append(corrected_segment) else: # No correction, keep original corrected_segments.append(segment.model_copy()) - # Add new version - conversation.add_transcript_version( + # Add new version — carry over provider_capabilities so downstream + # processing knows the provider's diarization/word_timestamp support. + source_capabilities = active_transcript.metadata.get( + "provider_capabilities", {} + ) + new_version = conversation.add_transcript_version( version_id=new_version_id, transcript=active_transcript.transcript, # Same transcript text words=active_transcript.words, # Same word timings @@ -1017,9 +1071,12 @@ async def apply_diarization_annotations( "source_version_id": active_transcript.version_id, "trigger": "manual_annotation_apply", "applied_annotation_count": len(annotations), + "provider_capabilities": source_capabilities, }, set_as_active=True, ) + if active_transcript.diarization_source: + new_version.diarization_source = active_transcript.diarization_source await conversation.save() logger.info( @@ -1033,13 +1090,14 @@ async def apply_diarization_annotations( annotation.processed_by = "apply" await annotation.save() - # Chain memory reprocessing - from advanced_omi_backend.models.job import JobPriority - from advanced_omi_backend.workers.memory_jobs import enqueue_memory_processing - + # Chain memory reprocessing. Diarization-only edits change speaker + # attribution, so use the same speaker-diff strategy as a speaker + # reprocess (it falls back to a full re-extraction if no diff applies). enqueue_memory_processing( conversation_id=conversation_id, priority=JobPriority.NORMAL, + cause=MemoryCause.ANNOTATION_APPLY, + strategy=UpdateStrategy.SPEAKER_DIFF, ) return JSONResponse( @@ -1112,6 +1170,27 @@ async def apply_all_annotations( insert_annotations = [ a for a in annotations if a.annotation_type == AnnotationType.INSERT ] + timing_annotations = [ + a for a in annotations if a.annotation_type == AnnotationType.TIMING + ] + deletion_annotations = [ + a for a in annotations if a.annotation_type == AnnotationType.DELETION + ] + deleted_indices = { + a.segment_index for a in deletion_annotations if a.segment_index is not None + } + + # Single-flight: don't stack a new edit on top of an in-flight one (see + # apply_diarization_annotations — same deterministic-job-id save race). + in_flight = conversation_edit_chain_in_flight(conversation_id) + if in_flight: + return JSONResponse( + status_code=409, + content={ + "error": "This conversation is still processing a previous edit. Try again in a moment.", + "in_flight_job_id": in_flight, + }, + ) # Get active transcript active_transcript = conversation.active_transcript @@ -1119,15 +1198,19 @@ async def apply_all_annotations( raise HTTPException(status_code=404, detail="No active transcript found") # Create new version with ALL corrections applied - import uuid - new_version_id = str(uuid.uuid4()) corrected_segments = [] + # Segments marked for deletion are dropped AFTER inserts are positioned, so + # insert_after_index (which references original indices) stays valid. Track by + # object identity since indices shift once inserts are spliced in. + deleted_obj_ids: set[int] = set() # For diarization/transcript: if multiple annotations exist for same segment, # pick the most recently updated one for segment_idx, segment in enumerate(active_transcript.segments): corrected_segment = segment.model_copy() + if segment_idx in deleted_indices: + deleted_obj_ids.add(id(corrected_segment)) # Apply diarization correction (most recent wins) diar_for_segment = sorted( @@ -1136,7 +1219,9 @@ async def apply_all_annotations( reverse=True, ) if diar_for_segment: - corrected_segment.speaker = diar_for_segment[0].corrected_speaker + _apply_diarization_label( + corrected_segment, diar_for_segment[0].corrected_speaker + ) # Apply transcript correction (most recent wins) transcript_for_segment = sorted( @@ -1147,6 +1232,16 @@ async def apply_all_annotations( if transcript_for_segment: corrected_segment.text = transcript_for_segment[0].corrected_text + # Apply timing correction (most recent wins) — waveform region move/resize + timing_for_segment = sorted( + [a for a in timing_annotations if a.segment_index == segment_idx], + key=lambda a: a.updated_at, + reverse=True, + ) + if timing_for_segment and timing_for_segment[0].new_end is not None: + corrected_segment.start = timing_for_segment[0].new_start + corrected_segment.end = timing_for_segment[0].new_end + corrected_segments.append(corrected_segment) # Apply inserts from highest index to lowest (stable indexing) @@ -1160,25 +1255,48 @@ async def apply_all_annotations( idx = ins.insert_after_index # -1 = before first insert_pos = idx + 1 # Convert to list insertion position - # Calculate timing from surrounding segments - if insert_pos > 0 and insert_pos <= len(corrected_segments): - boundary_time = corrected_segments[insert_pos - 1].end - elif insert_pos == 0 and corrected_segments: - boundary_time = corrected_segments[0].start + # Timing: prefer an explicit waveform-drawn span; otherwise fall back to + # a zero-duration marker at the neighbouring boundary (legacy inserts). + if ins.insert_start is not None and ins.insert_end is not None: + seg_start = ins.insert_start + seg_end = ins.insert_end else: - boundary_time = 0.0 + if insert_pos > 0 and insert_pos <= len(corrected_segments): + boundary_time = corrected_segments[insert_pos - 1].end + elif insert_pos == 0 and corrected_segments: + boundary_time = corrected_segments[0].start + else: + boundary_time = 0.0 + seg_start = boundary_time + seg_end = boundary_time new_segment = Conversation.SpeakerSegment( - start=boundary_time, - end=boundary_time, + start=seg_start, + end=seg_end, text=ins.insert_text or "", speaker=ins.insert_speaker or "", segment_type=ins.insert_segment_type or "event", ) corrected_segments.insert(insert_pos, new_segment) - # Add new version - conversation.add_transcript_version( + # Drop segments marked for deletion (now that inserts have been positioned + # against the original indices). + if deleted_obj_ids: + corrected_segments = [ + s for s in corrected_segments if id(s) not in deleted_obj_ids + ] + + # Re-order by start time so moved/inserted segments read in chronological order. + # Stable sort keeps the original list order for equal starts — important for the + # intentional same-time overlapping segments (two speakers at once). + corrected_segments.sort(key=lambda s: s.start) + + # Add new version — carry over provider_capabilities so downstream + # processing knows the provider's diarization/word_timestamp support. + source_capabilities = active_transcript.metadata.get( + "provider_capabilities", {} + ) + new_version = conversation.add_transcript_version( version_id=new_version_id, transcript=active_transcript.transcript, words=active_transcript.words, # Preserved (may be misaligned for text edits) @@ -1192,16 +1310,23 @@ async def apply_all_annotations( "diarization_count": len(diarization_annotations), "transcript_count": len(transcript_annotations), "insert_count": len(insert_annotations), + "timing_count": len(timing_annotations), + "deletion_count": len(deletion_annotations), + "provider_capabilities": source_capabilities, }, set_as_active=True, ) + if active_transcript.diarization_source: + new_version.diarization_source = active_transcript.diarization_source await conversation.save() logger.info( f"Applied {len(annotations)} annotations " f"(diarization: {len(diarization_annotations)}, " f"transcript: {len(transcript_annotations)}, " - f"insert: {len(insert_annotations)})" + f"insert: {len(insert_annotations)}, " + f"timing: {len(timing_annotations)}, " + f"deletion: {len(deletion_annotations)})" ) # Mark all annotations as processed @@ -1212,26 +1337,30 @@ async def apply_all_annotations( annotation.status = AnnotationStatus.ACCEPTED await annotation.save() - # Trigger memory reprocessing (once for all changes) - from advanced_omi_backend.models.job import JobPriority - from advanced_omi_backend.workers.memory_jobs import enqueue_memory_processing - + # Trigger memory reprocessing (once for all changes). Combined apply may + # change transcript text as well as speakers, so re-extract in full. enqueue_memory_processing( conversation_id=conversation_id, priority=JobPriority.NORMAL, + cause=MemoryCause.ANNOTATION_APPLY, + strategy=UpdateStrategy.FULL, ) return JSONResponse( content={ "message": ( f"Applied {len(diarization_annotations)} diarization, " - f"{len(transcript_annotations)} transcript, and " - f"{len(insert_annotations)} insert annotations" + f"{len(transcript_annotations)} transcript, " + f"{len(insert_annotations)} insert, " + f"{len(timing_annotations)} timing, and " + f"{len(deletion_annotations)} deletion annotations" ), "version_id": new_version_id, "diarization_count": len(diarization_annotations), "transcript_count": len(transcript_annotations), "insert_count": len(insert_annotations), + "timing_count": len(timing_annotations), + "deletion_count": len(deletion_annotations), "status": "success", } ) diff --git a/backends/advanced/src/advanced_omi_backend/routers/modules/audio_routes.py b/backends/advanced/src/advanced_omi_backend/routers/modules/audio_routes.py index 81065a8e..3ce4328b 100644 --- a/backends/advanced/src/advanced_omi_backend/routers/modules/audio_routes.py +++ b/backends/advanced/src/advanced_omi_backend/routers/modules/audio_routes.py @@ -6,7 +6,9 @@ """ import io +import logging import re +import wave from typing import Optional from fastapi import APIRouter, Depends, File, HTTPException, Query, Request, UploadFile @@ -24,6 +26,9 @@ from advanced_omi_backend.utils.audio_chunk_utils import ( build_wav_from_pcm, concatenate_chunks_to_pcm, + get_opus_for_conversation, + get_trimmed_opus_for_time_range, + reconstruct_audio_segment, reconstruct_wav_from_conversation, retrieve_audio_chunks, ) @@ -70,6 +75,7 @@ async def upload_audio_from_drive_folder( async def get_conversation_audio( conversation_id: str, request: Request, + format: str = Query(default="opus", description="Audio format: opus or wav"), token: Optional[str] = Query( default=None, description="JWT token for audio element access" ), @@ -78,27 +84,17 @@ async def get_conversation_audio( """ Serve complete audio file for a conversation from MongoDB chunks. - Reconstructs audio by: - 1. Retrieving all Opus-compressed chunks from MongoDB - 2. Decoding each chunk to PCM - 3. Concatenating PCM data - 4. Building complete WAV file with headers - - Supports both header-based auth (Authorization: Bearer) and query param token - for