diff --git a/.github/workflows/muse-glimmer-macos.yml b/.github/workflows/muse-glimmer-macos.yml new file mode 100644 index 0000000000..a16aa24af3 --- /dev/null +++ b/.github/workflows/muse-glimmer-macos.yml @@ -0,0 +1,190 @@ +# Copyright (c) Meta Platforms, Inc. and affiliates. +# All rights reserved. +# +# This source code is licensed under the BSD-style license found in the +# LICENSE file in the root directory of this source tree. + +name: Muse Glimmer macOS + +on: + pull_request: + paths: + - "muse_glimmer/macos/**" + - ".github/workflows/muse-glimmer-macos.yml" + push: + branches: [main] + paths: + - "muse_glimmer/macos/**" + - ".github/workflows/muse-glimmer-macos.yml" + +permissions: + contents: read + +jobs: + python: + runs-on: macos-14 + defaults: + run: + working-directory: muse_glimmer/macos + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 + - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 + with: + python-version: "3.13" + - uses: astral-sh/setup-uv@d0cc045d04ccac9d8b7881df0226f9e82c39688e + with: + enable-cache: true + cache-dependency-glob: muse_glimmer/macos/uv.lock + - name: Sync Python dependencies + run: uv sync --all-packages --all-groups --frozen + - name: Check Python lint + run: uv run --all-packages --all-groups ruff check . + - name: Check Python formatting + run: uv run --all-packages --all-groups ruff format --check . + - name: Run Python tests + run: uv run --all-packages --all-groups pytest + - name: Read release compatibility state + id: compatibility + run: | + uv run --all-packages --all-groups python - <<'PY' >> "$GITHUB_OUTPUT" + import json + import re + from pathlib import Path + + compatibility = json.loads( + Path("config/dependencies/compatibility.lock.json").read_text() + ) + ready = bool(compatibility["ready_for_release"]) + commit = compatibility["executorch"]["commit"] or "" + if ready and re.fullmatch(r"[0-9a-f]{40}", commit) is None: + raise SystemExit("release-ready compatibility requires a full commit SHA") + print(f"ready={str(ready).lower()}") + print(f"commit={commit}") + PY + - name: Check out release ExecuTorch revision + if: steps.compatibility.outputs.ready == 'true' + env: + EXECUTORCH_COMMIT: ${{ steps.compatibility.outputs.commit }} + run: | + test -n "$EXECUTORCH_COMMIT" + git clone --filter=blob:none --no-checkout \ + https://github.com/pytorch/executorch.git \ + "$RUNNER_TEMP/executorch" + git -C "$RUNNER_TEMP/executorch" checkout --detach "$EXECUTORCH_COMMIT" + echo "GLIMMER_EXECUTORCH_ROOT=$RUNNER_TEMP/executorch" >> "$GITHUB_ENV" + - name: Validate manifests + run: uv run --all-packages --all-groups python -m scripts.validate_manifests + - name: Check publication contents + run: uv run --all-packages --all-groups python -m scripts.publication_check + - name: Build Python packages + run: uv build --all-packages --out-dir "$RUNNER_TEMP/muse-glimmer-dist" + - name: Verify package LICENSE payloads + env: + DIST_DIR: ${{ runner.temp }}/muse-glimmer-dist + run: | + uv run --all-packages --all-groups python - <<'PY' + import os + import tarfile + import zipfile + from pathlib import Path + + dist = Path(os.environ["DIST_DIR"]) + expected = { + "livekit_plugins_executorch": Path( + "packages/livekit-plugins-executorch/LICENSE" + ).read_bytes(), + "muse_glimmer_token_service": Path("apps/token-service/LICENSE").read_bytes(), + "muse_glimmer_worker": Path("apps/worker/LICENSE").read_bytes(), + } + archives = list(dist.glob("*.whl")) + list(dist.glob("*.tar.gz")) + for package, license_payload in expected.items(): + package_archives = [archive for archive in archives if archive.name.startswith(package)] + if len(package_archives) != 2: + raise SystemExit( + f"expected wheel and sdist for {package}, found {len(package_archives)} archives" + ) + for archive in package_archives: + if archive.suffix == ".whl": + with zipfile.ZipFile(archive) as built: + names = built.namelist() + payloads = [ + built.read(name) + for name in names + if Path(name).name == "LICENSE" + ] + else: + with tarfile.open(archive, mode="r:gz") as built: + members = built.getmembers() + names = [member.name for member in members] + payloads = [ + built.extractfile(member).read() + for member in members + if member.isfile() and Path(member.name).name == "LICENSE" + ] + if license_payload not in payloads: + raise SystemExit(f"source LICENSE payload missing from {archive.name}") + if any(Path(name).name == "NOTICE" for name in names): + raise SystemExit(f"obsolete NOTICE payload found in {archive.name}") + print("Verified BSD LICENSE payloads in all Python package archives.") + PY + + web: + runs-on: macos-14 + defaults: + run: + working-directory: muse_glimmer/macos/apps/web + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 + with: + node-version: "22.12.0" + cache: npm + cache-dependency-path: muse_glimmer/macos/apps/web/package-lock.json + - name: Install web dependencies + run: npm ci + - name: Lint web app + run: npm run lint + - name: Type-check web app + run: npm run typecheck + - name: Test web app + run: npm test + - name: Build web app + run: npm run build + - name: Audit web dependencies + run: npm audit --audit-level=high + - name: Scan browser bundle for private runtime data + run: | + node --input-type=module <<'JS' + import { readdir, readFile } from "node:fs/promises"; + import { join } from "node:path"; + + const forbidden = new Map([ + ["LLM port", /127\.0\.0\.1:8000/], + ["model variant", /muse-glimmer-k-quant|17G|128K|dflash/i], + ["model runtime", /Parakeet|Supertonic|MUSE_GLIMMER_|PARAKEET_|SUPERTONIC_/], + ["private path", /\/Users\/|\.local\/artifacts|\.pte\b/], + ["credential name", /LIVEKIT_API_SECRET/], + ["cloud URL", /wss:\/\/|https:\/\/[^\s"']*livekit/i], + ]); + + async function* files(directory) { + for (const entry of await readdir(directory, { withFileTypes: true })) { + const path = join(directory, entry.name); + if (entry.isDirectory()) { + yield* files(path); + } else if (entry.isFile()) { + yield path; + } + } + } + + for await (const path of files("dist")) { + const contents = await readFile(path, "utf8"); + for (const [label, pattern] of forbidden) { + if (pattern.test(contents)) { + throw new Error(`browser bundle contains forbidden ${label}: ${path}`); + } + } + } + console.log("Browser bundle privacy scan passed."); + JS diff --git a/README.md b/README.md index 5ebde13396..cf280529fa 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,11 @@ Example apps and demos using PyTorch's [ExecuTorch](https://github.com/pytorch/executorch) framework. +## Full applications + +- [Muse Glimmer Voice Agent for macOS](muse_glimmer/macos/README.md): a fully local + Apple silicon voice app whose native setup and release remain development-gated + pending a compatible pinned ExecuTorch commit. + ## License + ExecuTorch is BSD licensed, as found in the LICENSE file. diff --git a/muse_glimmer/macos/.gitignore b/muse_glimmer/macos/.gitignore new file mode 100644 index 0000000000..0939211d29 --- /dev/null +++ b/muse_glimmer/macos/.gitignore @@ -0,0 +1,54 @@ +# Local configuration and credentials +.env +.env.* +!.env.example +*.key +*.keys +*.pem +*.token + +# Managed local state +.local/ + +# Python +.venv/ +__pycache__/ +*.py[cod] +*.egg-info/ +.pytest_cache/ +.mypy_cache/ +.ruff_cache/ +.coverage +htmlcov/ +dist/ +build/ + +# Node +node_modules/ +*.tsbuildinfo +coverage/ + +# Native/model artifacts +*.pte +*.pt +*.ptd +*.onnx +*.safetensors +*.gguf +*.bin +*.dylib +*.so +*.a +*.o +*.wav +*.pcm +*.metallib + +# Runtime data +*.log +*.pid +*.sock +recordings/ +reports/ +museglimmer-reports/ +.DS_Store diff --git a/muse_glimmer/macos/.node-version b/muse_glimmer/macos/.node-version new file mode 100644 index 0000000000..1d9b7831ba --- /dev/null +++ b/muse_glimmer/macos/.node-version @@ -0,0 +1 @@ +22.12.0 diff --git a/muse_glimmer/macos/.python-version b/muse_glimmer/macos/.python-version new file mode 100644 index 0000000000..24ee5b1be9 --- /dev/null +++ b/muse_glimmer/macos/.python-version @@ -0,0 +1 @@ +3.13 diff --git a/muse_glimmer/macos/CONTRIBUTING.md b/muse_glimmer/macos/CONTRIBUTING.md new file mode 100644 index 0000000000..6c61878e69 --- /dev/null +++ b/muse_glimmer/macos/CONTRIBUTING.md @@ -0,0 +1,16 @@ +# Contributing + +This subtree follows the repository contribution requirements in +[`../../CONTRIBUTING.md`](../../CONTRIBUTING.md), including its licensing and +Contributor License Agreement terms. The guidance below is specific to the +local-only, source-only macOS example. + +- Do not commit models, native binaries, credentials, recordings, logs, caches, + generated output, or another repository. +- Keep browser-visible data within the policy documented in + `docs/security-model.md`. +- Keep ASR, LLM, and TTS native components on one pinned ExecuTorch revision. +- Preserve MuseGlimmer reasoning configuration under + `chat_template_kwargs.reasoning_strength`; do not use `reasoning_effort`. +- Run `make check`, `make test`, and `make publication-check` before opening a + pull request. diff --git a/muse_glimmer/macos/LICENSE b/muse_glimmer/macos/LICENSE new file mode 100644 index 0000000000..5651f75604 --- /dev/null +++ b/muse_glimmer/macos/LICENSE @@ -0,0 +1,30 @@ +BSD License + +For "ExecuTorch" software + +Copyright (c) Meta Platforms, Inc. and affiliates. All rights reserved. + +Redistribution and use in source and binary forms, with or without modification, +are permitted provided that the following conditions are met: + + * Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. + + * Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + + * Neither the name Meta nor the names of its contributors may be used to + endorse or promote products derived from this software without specific + prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR +ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON +ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/muse_glimmer/macos/LICENSES/LIVEKIT-MODEL-LICENSE.txt b/muse_glimmer/macos/LICENSES/LIVEKIT-MODEL-LICENSE.txt new file mode 100644 index 0000000000..44bea48025 --- /dev/null +++ b/muse_glimmer/macos/LICENSES/LIVEKIT-MODEL-LICENSE.txt @@ -0,0 +1,113 @@ +LIVEKIT MODEL LICENSE AGREEMENT + +1. Introduction + + LiveKit Incorporated ("LiveKit") is making available its proprietary models for + use pursuant to the terms and conditions of this Agreement. As further + described below, you may use these LiveKit models freely but can only use them + together with the LiveKit Agents framework. You cannot use the LiveKit models + on a standalone basis or with any other frameworks. + + BY CLICKING "I ACCEPT," OR BY DOWNLOADING, INSTALLING, OR OTHERWISE ACCESSING + OR USING THE LIVEKIT MATERIALS, YOU AGREE THAT YOU HAVE READ AND UNDERSTOOD, + AND, AS A CONDITION TO YOUR USE OF THE LIVEKIT MATERIALS, YOU AGREE TO BE + BOUND BY, THE FOLLOWING TERMS AND CONDITIONS. + +2. Definitions + + "Agreement" means this LiveKit Model License Agreement. + + "Documentation" means the specifications, manuals, and documentation + accompanying any LiveKit Model and distributed by LiveKit. + + "Licensee" or "you" means the individual or entity agreeing to be bound by + this Agreement. + + "LiveKit Agents" means the proprietary LiveKit software framework for building + real-time multimodal AI applications with programmable backend participants. + + "LiveKit Materials" means, collectively, the LiveKit Models and Documentation. + + "LiveKit Model" means any of LiveKit's proprietary software models or + algorithms, including machine-learning software code, model weights, + inference-enabling software code, training-enabling software code, and + fine-tuning enabling software code. Any derivative works of a LiveKit Model, + whether developed by LiveKit, you, or any third party, will be deemed the + "LiveKit Model" for the purposes of this Agreement. + +3. License Rights + + Right to Use LiveKit Materials. Subject to the terms and conditions of this + Agreement, including the requirements of Section 3.b, LiveKit grants you a + nonexclusive, nontransferable, worldwide, royalty-free license under LiveKit's + intellectual property rights to use, reproduce, distribute, copy, and create + derivative works of the LiveKit Materials. + + Limitation on Use. As a condition to your use of the LiveKit Materials, you + agree: (i) not to use any LiveKit Models on a standalone basis or with any + frameworks other than LiveKit Agents; (ii) not to use any LiveKit Materials or + any output from, or results of using, LiveKit Models (including any derivative + works thereof) to improve or otherwise develop any other models that are not + LiveKit Models; or (iii) distribute or otherwise make available the LiveKit + Materials (including any derivative works thereof) except (x) pursuant to the + terms of this Agreement, and (y) you reproduce the above copyright notice. + +4. Intellectual Property + + The LiveKit Materials are owned by LiveKit and its licensors. Except for the + rights granted to you under this Agreement, all rights are reserved and no + other express or implied rights are granted. + + You will own any derivative works that you created from the LiveKit Materials, + subject to the terms of this Agreement. + +5. Disclaimer + + UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING, LIVEKIT PROVIDES + THE LIVEKIT MATERIALS, AND ANY OUTPUT OR RESULTS THEREFROM, ON AN "AS IS" + BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, EITHER EXPRESS OR IMPLIED, + INCLUDING, WITHOUT LIMITATION, ANY WARRANTIES OR CONDITIONS OF TITLE, + NON-INFRINGEMENT, MERCHANTABILITY, OR FITNESS FOR A PARTICULAR PURPOSE. YOU + ARE SOLELY RESPONSIBLE FOR DETERMINING THE APPROPRIATENESS OF USING OR + REDISTRIBUTING THE LIVEKIT MATERIALS AND ASSUME ANY RISKS ASSOCIATED WITH YOUR + USE OF THE LIVEKIT MATERIALS AND ANY OUTPUT AND RESULTS. + +6. Limitation of Liability + + IN NO EVENT AND UNDER NO LEGAL THEORY, WHETHER IN TORT (INCLUDING NEGLIGENCE), + CONTRACT, OR OTHERWISE, UNLESS REQUIRED BY APPLICABLE LAW (SUCH AS DELIBERATE + AND GROSSLY NEGLIGENT ACTS) OR AGREED TO IN WRITING, WILL LIVEKIT BE LIABLE TO + YOU FOR INDIRECT DAMAGES, INCLUDING ANY SPECIAL, INCIDENTAL, OR CONSEQUENTIAL + DAMAGES OF ANY CHARACTER ARISING AS A RESULT OF THIS AGREEMENT OR OUT OF THE + USE OR INABILITY TO USE THE LIVEKIT MATERIALS OR ANY OUTPUT OR RESULTS + THEREFROM (INCLUDING BUT NOT LIMITED TO DAMAGES FOR LOSS OF GOODWILL, WORK + STOPPAGE, COMPUTER FAILURE OR MALFUNCTION, OR ANY AND ALL OTHER COMMERCIAL + DAMAGES OR LOSSES), EVEN IF LIVEKIT HAS BEEN ADVISED OF THE POSSIBILITY OF + SUCH DAMAGES. + +7. Trademarks + + This Agreement does not grant permission to use the trade names, trademarks, + service marks, or product names of LiveKit, except as required for reasonable + and customary use in describing the origin of the LiveKit Materials. + +8. Term and Termination + + The term of this Agreement commences upon your acceptance of this Agreement + and continues in effect until you cease using the LiveKit Materials or it is + terminated by either party (on immediate written notice to the other party). + This Agreement will automatically terminate if you breach any of its terms. + Upon termination, you must immediately cease all use of the LiveKit Materials. + Sections 4, 5, 6, and 9 will survive termination. + +9. Governing Law and Venue + + This Agreement is subject to the laws of the State of California, without + regard to its conflict of laws principles. The UN Convention on Contracts for + the International Sale of Goods does not apply to this Agreement. The courts + located in San Francisco, California, have exclusive jurisdiction for any + dispute arising out of this Agreement. + ++ + + + + +Last Updated: November 25, 2024 diff --git a/muse_glimmer/macos/LICENSES/OFL-1.1.txt b/muse_glimmer/macos/LICENSES/OFL-1.1.txt new file mode 100644 index 0000000000..f1fd8ffc71 --- /dev/null +++ b/muse_glimmer/macos/LICENSES/OFL-1.1.txt @@ -0,0 +1,80 @@ +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The fonts, +including any derivative works, can be bundled, embedded, redistributed +and/or sold with any software provided that any reserved names are not used +by derivative works. The fonts and derivatives, however, cannot be released +under any other type of license. The requirement for fonts to remain under +this license does not apply to any document created using the fonts or their +derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright Holder(s) +under this license and clearly marked as such. This may include source files, +build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, or +substituting -- in part or in whole -- any of the components of the Original +Version, by changing formats or by porting the Font Software to a new +environment. + +"Author" refers to any designer, engineer, programmer, technical writer or +other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining a copy +of the Font Software, to use, study, copy, merge, embed, modify, redistribute, +and sell modified and unmodified copies of the Font Software, subject to the +following conditions: + +1) Neither the Font Software nor any of its individual components, in +Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy contains +the above copyright notice and this license. These can be included either as +stand-alone text files, human-readable headers or in the appropriate +machine-readable metadata fields within text or binary files as long as those +fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font Name(s) +unless explicit written permission is granted by the corresponding Copyright +Holder. This restriction only applies to the primary font name as presented +to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any Modified +Version, except to acknowledge the contribution(s) of the Copyright Holder(s) +and the Author(s) or with their explicit written permission. + +5) The Font Software, modified or unmodified, in part or in whole, must be +distributed entirely under this license, and must not be distributed under +any other license. The requirement for fonts to remain under this license +does not apply to any document created using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT OF COPYRIGHT, PATENT, +TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE COPYRIGHT HOLDER BE LIABLE FOR +ANY CLAIM, DAMAGES OR OTHER LIABILITY, INCLUDING ANY GENERAL, SPECIAL, +INDIRECT, INCIDENTAL, OR CONSEQUENTIAL DAMAGES, WHETHER IN AN ACTION OF +CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF THE USE OR INABILITY TO USE +THE FONT SOFTWARE OR FROM OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/muse_glimmer/macos/Makefile b/muse_glimmer/macos/Makefile new file mode 100644 index 0000000000..d971d68333 --- /dev/null +++ b/muse_glimmer/macos/Makefile @@ -0,0 +1,49 @@ +SHELL := /bin/bash +PYTHON := .venv/bin/python +BOOTSTRAP_PYTHON := uv run --no-project --python 3.13 python +STACK := $(PYTHON) -m scripts.dev_stack + +.PHONY: bootstrap prepare-artifacts dev up down restart status logs check test e2e publication-check + +bootstrap: + @$(BOOTSTRAP_PYTHON) -m scripts.bootstrap + +prepare-artifacts: + @$(PYTHON) -m scripts.prepare_artifacts + +dev: up + +up: + @$(STACK) up + +down: + @$(STACK) down + +restart: + @$(STACK) restart + +status: + @$(STACK) status + +logs: + @$(STACK) logs + +check: + @$(PYTHON) -m scripts.validate_manifests + @$(PYTHON) -m scripts.publication_check + @uv run --all-packages --all-groups ruff check . + @uv run --all-packages --all-groups ruff format --check . + @npm --prefix apps/web run lint + @npm --prefix apps/web run typecheck + @npm --prefix apps/web run format:check + +test: + @uv run --all-packages --all-groups pytest + @npm --prefix apps/web test + @npm --prefix apps/web run build + +e2e: + @uv run --all-packages --all-groups pytest -m e2e + +publication-check: + @python3 -m scripts.publication_check diff --git a/muse_glimmer/macos/PROVENANCE.md b/muse_glimmer/macos/PROVENANCE.md new file mode 100644 index 0000000000..649001a72d --- /dev/null +++ b/muse_glimmer/macos/PROVENANCE.md @@ -0,0 +1,41 @@ +# Provenance + +## Canonical source + +This macOS example is product-owned source in the canonical +[`meta-pytorch/executorch-examples`](https://github.com/meta-pytorch/executorch-examples) +repository under `muse_glimmer/macos`. Product-owned source is licensed under +BSD-3-Clause as described in `LICENSE`. + +The source snapshot used for this migration is +`914fb816fe9e0f6b7fc808fd843eb2e97df31dcf`. That snapshot records development +history; the canonical maintained source and ownership are in +`meta-pytorch/executorch-examples`. + +## Original source and API integrations + +The application, token service, worker, web UI, launchers, packaging, lifecycle +code, and LiveKit ExecuTorch adapters are original product source. They +integrate with public LiveKit APIs but do not copy LiveKit implementation +source. + +The worker and adapter integrations were developed against the public +[`livekit/agents`](https://github.com/livekit/agents) API at commit +`bc5f3df3a2bd1b3b8c5d1df742be57b063374991`. Package-specific source mappings +and integration details are recorded in: + +- `apps/worker/PROVENANCE.md` +- `packages/livekit-plugins-executorch/PROVENANCE.md` + +The token service integrates with the LiveKit API to issue short-lived tokens; +it does not include LiveKit implementation source. + +## Excluded components + +This source subtree does not include ExecuTorch source, native runners, model +weights, exported programs, tokenizers, voice styles, recordings, generated +output, or dependency source. Those components retain their independent +licenses and notices as documented in `THIRD_PARTY_NOTICES.md` and `LICENSES/`. + +Muse Glimmer, ExecuTorch, LiveKit, and other names may be trademarks of their +respective owners. The BSD-3-Clause license does not grant trademark rights. diff --git a/muse_glimmer/macos/README.md b/muse_glimmer/macos/README.md new file mode 100644 index 0000000000..35e367b7d1 --- /dev/null +++ b/muse_glimmer/macos/README.md @@ -0,0 +1,113 @@ +# Muse Glimmer Voice Agent + +A fully local voice agent for macOS on Apple silicon. The browser captures the +microphone, loopback-only LiveKit carries audio, and local ExecuTorch runtimes +perform Parakeet speech recognition, Muse Glimmer generation, and Supertonic +speech synthesis. + +```text +browser microphone + -> 127.0.0.1 LiveKit + -> Parakeet ASR + -> Muse Glimmer LLM + -> Supertonic TTS + -> browser speaker +``` + +No LiveKit Cloud account or cloud inference service is used. + +## Status + +The public source repository is ready for development, but the native +compatibility lock is intentionally gated. A release pin requires one +ExecuTorch commit containing both bounded MuseGlimmer worker cancellation and +persistent Supertonic JSONL mode. See `docs/upstream-pins.md`. + +## Supported platform + +- macOS on Apple silicon +- Python 3.13 +- Node.js 22 +- A compatible Xcode/CMake toolchain +- LiveKit Server 1.x + +Other platforms are not part of the first milestone. + +## First-time setup + +Run application commands from the subtree root: + +```bash +cd muse_glimmer/macos +``` + +Review the independent model and runtime licenses before providing artifacts. +Models and native binaries are stored only under ignored `.local/` paths. Source +checks and package builds are available now: + +```bash +make check +make test +``` + +Runtime provisioning remains intentionally unavailable while the compatibility +commit is null: + +```bash +make bootstrap +make prepare-artifacts +``` + +Once the release gate is resolved, `make bootstrap` validates the locked toolchain +and installs source dependencies. `make prepare-artifacts` validates the single +pinned ExecuTorch checkout and every model/native artifact, then writes an ignored +compatibility receipt. Neither operation runs during normal startup. + +## Daily development + +```bash +make dev +make status +make logs +make restart +make down +``` + +`make dev up` is also supported and starts the stack exactly once. Once +artifacts have been prepared, startup requires no external network access. + +The UI opens at `http://127.0.0.1:5173`. + +## Local security boundary + +- LiveKit signaling: `127.0.0.1:7880` +- LiveKit media: `127.0.0.1:7882/udp` +- Token service: `127.0.0.1:8787` +- Browser UI: `127.0.0.1:5173` +- MuseGlimmer server: backend-only `127.0.0.1:8000` +- Short-lived participant tokens grant microphone publication only. +- Runtime LiveKit credentials are generated locally per stack run. +- Browser code never receives model identifiers, artifact paths, the LLM + endpoint, native worker details, or server credentials. + +See `docs/security-model.md` for the local-process trust model. + +## Development checks + +From `muse_glimmer/macos`: + +```bash +make check +make test +make publication-check +``` + +The publication check rejects secrets, models, native binaries, recordings, +generated output, nested repositories, absolute workstation paths, internal +URLs, and AGPL avatar dependencies. + +## License + +Product-owned source is BSD-3-Clause. Models, exported programs, native +binaries, fonts, and third-party packages retain their independent licenses. +See `LICENSE`, `PROVENANCE.md`, `THIRD_PARTY_NOTICES.md`, and `LICENSES/`. diff --git a/muse_glimmer/macos/SECURITY.md b/muse_glimmer/macos/SECURITY.md new file mode 100644 index 0000000000..6ed66b5b21 --- /dev/null +++ b/muse_glimmer/macos/SECURITY.md @@ -0,0 +1,23 @@ +# Security Policy + +## Supported configuration + +The initial supported configuration is macOS on Apple silicon with every +service bound to loopback. Do not expose the web, token, LiveKit, LLM, or worker +ports to a LAN or the internet. + +## Reporting + +Do not include credentials, transcripts, audio, model paths, or runtime logs in +a public issue. Use the repository host's private security advisory mechanism. + +## Local trust boundary + +A process running as the same operating-system user can reach loopback services +and read files that user can access. The token service is not an authentication +boundary against local malware. Runtime credentials are ephemeral, mode 0600, +and removed by normal shutdown. + +The browser receives only a short-lived participant token and the fixed local +LiveKit URL. It must never receive model identifiers, artifact variants, local +paths, the LLM endpoint, native worker details, or cloud metadata. diff --git a/muse_glimmer/macos/THIRD_PARTY_NOTICES.md b/muse_glimmer/macos/THIRD_PARTY_NOTICES.md new file mode 100644 index 0000000000..1e47ad0e83 --- /dev/null +++ b/muse_glimmer/macos/THIRD_PARTY_NOTICES.md @@ -0,0 +1,31 @@ +# Third-Party Notices + +Product-owned source in this subtree is licensed under BSD-3-Clause. It +integrates with software and assets governed by independent terms; BSD-3-Clause +does not relicense models, exported programs, native binaries, fonts, or +third-party packages. + +## Runtime dependencies + +- **ExecuTorch**: BSD-3-Clause. Source and runtime artifacts are provisioned + separately. Release builds require the immutable revision recorded in + `config/dependencies/compatibility.lock.json`; that revision is intentionally + unset while upstream integration remains gated. +- **LiveKit Agents and LiveKit Server**: Apache-2.0. The temporary + `packages/livekit-plugins-executorch` package records its API baseline and + product-owned source provenance in `PROVENANCE.md`. +- **LiveKit Local Inference**: installed transitively by LiveKit Agents and + distributed under `Apache-2.0 AND LicenseRef-LiveKit-Model`. The model-license + terms restrict LiveKit model use to the LiveKit Agents framework; see + `LICENSES/LIVEKIT-MODEL-LICENSE.txt`. This repository does not redistribute + those model assets. +- **Supertonic**: consult the upstream source and model licenses before + downloading or exporting assets. Assets are never committed here. +- **Muse Glimmer and Parakeet models**: use is governed by their respective + model licenses. Model weights and exported programs are never committed here. +- **Inter**: Copyright 2016 The Inter Project Authors + (https://github.com/rsms/inter), SIL Open Font License 1.1, consumed through + `@fontsource/inter`. + +A release must run the repository's license and publication checks and update +this file when any dependency, model, or asset changes. diff --git a/muse_glimmer/macos/apps/muse-glimmer-server/README.md b/muse_glimmer/macos/apps/muse-glimmer-server/README.md new file mode 100644 index 0000000000..38d763e8fb --- /dev/null +++ b/muse_glimmer/macos/apps/muse-glimmer-server/README.md @@ -0,0 +1,9 @@ +# MuseGlimmer server launcher + +`launch.py` validates the prepared artifact receipt and directly executes the +OpenAI-compatible server from the single pinned ExecuTorch checkout. It is not +a proxy and does not reimplement the upstream API. + +The launcher forces `127.0.0.1:8000`, a 131072-token context limit, DFlash +artifact mode, no tool parser, and the prepared cancellation-capable native +worker. diff --git a/muse_glimmer/macos/apps/muse-glimmer-server/launch.py b/muse_glimmer/macos/apps/muse-glimmer-server/launch.py new file mode 100644 index 0000000000..5a38aaf996 --- /dev/null +++ b/muse_glimmer/macos/apps/muse-glimmer-server/launch.py @@ -0,0 +1,64 @@ +from __future__ import annotations + +import os +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(ROOT)) + +from scripts.repository import load_valid_receipt, relative_local_path # noqa: E402 + +MODEL_ID = "muse-glimmer-k-quant-17G-128K-text-dflash-metal" + + +def main() -> None: + receipt = load_valid_receipt() + artifacts = receipt["artifacts"] + checkout = Path(receipt["executorch_checkout"]).resolve() + worker = relative_local_path(artifacts["muse_glimmer_worker"]["path"]) + model = relative_local_path(artifacts["muse_glimmer_model"]["path"]) + tokenizer = relative_local_path(artifacts["muse_glimmer_tokenizer"]["path"]) + tokenizer_root = tokenizer.parent + + pythonpath = os.pathsep.join( + value for value in (str(checkout / "src"), os.environ.get("PYTHONPATH", "")) if value + ) + environment = { + "HOME": os.environ.get("HOME", ""), + "LANG": os.environ.get("LANG", "C.UTF-8"), + "PATH": os.environ.get("PATH", "/usr/bin:/bin"), + "PYTHONPATH": pythonpath, + "TMPDIR": os.environ.get("TMPDIR", "/tmp"), + } + command = [ + sys.executable, + "-m", + "executorch.examples.models.muse_glimmer.serving.serve", + "--model-path", + str(model), + "--tokenizer-path", + str(tokenizer), + "--hf-tokenizer", + str(tokenizer_root), + "--worker-bin", + str(worker), + "--model-id", + MODEL_ID, + "--artifact-mode", + "dflash", + "--max-context", + "131072", + "--tool-parser", + "none", + "--host", + "127.0.0.1", + "--port", + "8000", + ] + os.chdir(checkout) + os.execve(sys.executable, command, environment) + + +if __name__ == "__main__": + main() diff --git a/muse_glimmer/macos/apps/token-service/LICENSE b/muse_glimmer/macos/apps/token-service/LICENSE new file mode 100644 index 0000000000..5651f75604 --- /dev/null +++ b/muse_glimmer/macos/apps/token-service/LICENSE @@ -0,0 +1,30 @@ +BSD License + +For "ExecuTorch" software + +Copyright (c) Meta Platforms, Inc. and affiliates. All rights reserved. + +Redistribution and use in source and binary forms, with or without modification, +are permitted provided that the following conditions are met: + + * Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. + + * Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + + * Neither the name Meta nor the names of its contributors may be used to + endorse or promote products derived from this software without specific + prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR +ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON +ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/muse_glimmer/macos/apps/token-service/pyproject.toml b/muse_glimmer/macos/apps/token-service/pyproject.toml new file mode 100644 index 0000000000..4957a6b0ef --- /dev/null +++ b/muse_glimmer/macos/apps/token-service/pyproject.toml @@ -0,0 +1,38 @@ +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[project] +name = "muse-glimmer-token-service" +version = "0.1.0" +description = "Local-only LiveKit token issuer for the Muse Glimmer voice UI" +requires-python = ">=3.13,<3.14" +license = "BSD-3-Clause" +license-files = ["LICENSE"] +dependencies = [ + "fastapi>=0.116,<1", + "livekit-api>=1.2,<2", + "pydantic-settings>=2.10,<3", + "uvicorn[standard]>=0.35,<1", +] + +[dependency-groups] +dev = [ + "httpx>=0.28,<1", + "PyJWT>=2.10,<3", + "pytest>=8.4,<9", + "ruff>=0.12,<1", +] + +[project.scripts] +muse-glimmer-token-service = "muse_glimmer_token_service.__main__:main" + +[tool.hatch.build] +include = [ + "/LICENSE", + "/src", + "/tests", +] + +[tool.hatch.build.targets.wheel] +packages = ["src/muse_glimmer_token_service"] diff --git a/muse_glimmer/macos/apps/token-service/src/muse_glimmer_token_service/__init__.py b/muse_glimmer/macos/apps/token-service/src/muse_glimmer_token_service/__init__.py new file mode 100644 index 0000000000..2aed1ad4a3 --- /dev/null +++ b/muse_glimmer/macos/apps/token-service/src/muse_glimmer_token_service/__init__.py @@ -0,0 +1,5 @@ +"""Hardened local token issuer for Muse Glimmer.""" + +from .app import create_app + +__all__ = ["create_app"] diff --git a/muse_glimmer/macos/apps/token-service/src/muse_glimmer_token_service/__main__.py b/muse_glimmer/macos/apps/token-service/src/muse_glimmer_token_service/__main__.py new file mode 100644 index 0000000000..d6510f52df --- /dev/null +++ b/muse_glimmer/macos/apps/token-service/src/muse_glimmer_token_service/__main__.py @@ -0,0 +1,22 @@ +from __future__ import annotations + +import uvicorn + +HOST = "127.0.0.1" +PORT = 8787 +APP_FACTORY = "muse_glimmer_token_service.app:create_app" + + +def main() -> None: + uvicorn.run( + APP_FACTORY, + factory=True, + host=HOST, + port=PORT, + proxy_headers=False, + server_header=False, + ) + + +if __name__ == "__main__": + main() diff --git a/muse_glimmer/macos/apps/token-service/src/muse_glimmer_token_service/app.py b/muse_glimmer/macos/apps/token-service/src/muse_glimmer_token_service/app.py new file mode 100644 index 0000000000..d0feeecb7f --- /dev/null +++ b/muse_glimmer/macos/apps/token-service/src/muse_glimmer_token_service/app.py @@ -0,0 +1,110 @@ +from __future__ import annotations + +from ipaddress import ip_address +from typing import Any + +from fastapi import FastAPI, Header, HTTPException +from fastapi.middleware.cors import CORSMiddleware +from fastapi.middleware.trustedhost import TrustedHostMiddleware +from pydantic import BaseModel, ConfigDict +from starlette.responses import JSONResponse +from starlette.types import ASGIApp, Message, Receive, Scope, Send + +from .config import Settings, get_settings +from .tokens import issue_connection + + +class SecurityHeadersMiddleware: + def __init__(self, app: ASGIApp) -> None: + self.app = app + + async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None: + async def send_with_security_headers(message: Message) -> None: + if message["type"] == "http.response.start": + headers = list(message.get("headers", [])) + headers.extend( + [ + (b"cache-control", b"no-store"), + (b"pragma", b"no-cache"), + (b"referrer-policy", b"no-referrer"), + (b"x-content-type-options", b"nosniff"), + ] + ) + message["headers"] = headers + await send(message) + + await self.app(scope, receive, send_with_security_headers) + + +class LoopbackClientMiddleware: + def __init__(self, app: ASGIApp) -> None: + self.app = app + + async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None: + if scope["type"] == "http" and not _is_loopback_client(scope.get("client")): + response = JSONResponse({"detail": "Loopback clients only"}, status_code=403) + await response(scope, receive, send) + return + await self.app(scope, receive, send) + + +def _is_loopback_client(client: Any) -> bool: + if not isinstance(client, tuple) or not client: + return False + try: + return ip_address(client[0]).is_loopback + except ValueError: + return False + + +def _to_camel(value: str) -> str: + head, *tail = value.split("_") + return head + "".join(part.title() for part in tail) + + +class ConnectionResponse(BaseModel): + model_config = ConfigDict(alias_generator=_to_camel, populate_by_name=True) + + server_url: str + participant_token: str + room_name: str + participant_identity: str + + +def create_app(settings: Settings | None = None) -> FastAPI: + resolved_settings = settings or get_settings() + app = FastAPI( + title="Muse Glimmer local token service", + version="0.1.0", + docs_url=None, + redoc_url=None, + openapi_url=None, + ) + app.add_middleware(TrustedHostMiddleware, allowed_hosts=["127.0.0.1"]) + app.add_middleware( + CORSMiddleware, + allow_origins=list(resolved_settings.allowed_web_origins), + allow_credentials=False, + allow_methods=["POST"], + allow_headers=["Accept"], + ) + app.add_middleware(LoopbackClientMiddleware) + app.add_middleware(SecurityHeadersMiddleware) + + @app.get("/healthz") + async def healthz() -> dict[str, str]: + return {"status": "ok"} + + @app.post("/api/token", response_model=ConnectionResponse, response_model_by_alias=True) + async def token(origin: str | None = Header(default=None)) -> ConnectionResponse: + if origin is not None and origin not in resolved_settings.allowed_web_origins: + raise HTTPException(status_code=403, detail="Origin is not allowed") + details = issue_connection(resolved_settings) + return ConnectionResponse( + server_url=details.server_url, + participant_token=details.participant_token, + room_name=details.room_name, + participant_identity=details.participant_identity, + ) + + return app diff --git a/muse_glimmer/macos/apps/token-service/src/muse_glimmer_token_service/config.py b/muse_glimmer/macos/apps/token-service/src/muse_glimmer_token_service/config.py new file mode 100644 index 0000000000..9ee0e851a6 --- /dev/null +++ b/muse_glimmer/macos/apps/token-service/src/muse_glimmer_token_service/config.py @@ -0,0 +1,56 @@ +from __future__ import annotations + +from typing import ClassVar, Final + +from pydantic import Field, SecretStr, field_validator +from pydantic_settings import BaseSettings, SettingsConfigDict + +LIVEKIT_SERVER_URL: Final = "ws://127.0.0.1:7880" +ALLOWED_WEB_ORIGINS: Final = ("http://127.0.0.1:5173",) + + +class Settings(BaseSettings): + """Environment-only credentials and bounded token lifetime.""" + + model_config = SettingsConfigDict( + env_file=None, + case_sensitive=True, + extra="ignore", + frozen=True, + ) + + livekit_api_key: SecretStr = Field(validation_alias="LIVEKIT_API_KEY", min_length=1) + livekit_api_secret: SecretStr = Field(validation_alias="LIVEKIT_API_SECRET", min_length=1) + livekit_url: str = Field( + default=LIVEKIT_SERVER_URL, + validation_alias="LIVEKIT_URL", + ) + token_ttl_seconds: int = Field( + default=600, + validation_alias="TOKEN_TTL_SECONDS", + ge=60, + le=3600, + ) + allowed_web_origins: ClassVar[tuple[str, ...]] = ALLOWED_WEB_ORIGINS + + @field_validator("livekit_api_key", "livekit_api_secret", mode="before") + @classmethod + def strip_credential(cls, value: object) -> object: + if isinstance(value, SecretStr): + value = value.get_secret_value() + if isinstance(value, str): + value = value.strip() + if not value: + raise ValueError("LiveKit credentials must be non-empty") + return value + + @field_validator("livekit_url") + @classmethod + def require_local_livekit_url(cls, value: str) -> str: + if value != LIVEKIT_SERVER_URL: + raise ValueError(f"LIVEKIT_URL must be exactly {LIVEKIT_SERVER_URL}") + return value + + +def get_settings() -> Settings: + return Settings() # type: ignore[call-arg] diff --git a/muse_glimmer/macos/apps/token-service/src/muse_glimmer_token_service/tokens.py b/muse_glimmer/macos/apps/token-service/src/muse_glimmer_token_service/tokens.py new file mode 100644 index 0000000000..910c2eef40 --- /dev/null +++ b/muse_glimmer/macos/apps/token-service/src/muse_glimmer_token_service/tokens.py @@ -0,0 +1,55 @@ +from __future__ import annotations + +import uuid +from dataclasses import dataclass +from datetime import timedelta +from typing import Final + +from livekit import api + +from .config import Settings + +AGENT_NAME: Final = "assistant" + + +@dataclass(frozen=True, slots=True) +class ConnectionDetails: + server_url: str + participant_token: str + room_name: str + participant_identity: str + + +def issue_connection(settings: Settings) -> ConnectionDetails: + room_name = f"r_{uuid.uuid4().hex}" + participant_identity = f"p_{uuid.uuid4().hex}" + grants = api.VideoGrants( + room_join=True, + room=room_name, + can_publish=True, + can_subscribe=True, + can_publish_data=False, + can_publish_sources=["microphone"], + ) + room_config = api.RoomConfiguration( + agents=[api.RoomAgentDispatch(agent_name=AGENT_NAME)], + ) + participant_token = ( + api.AccessToken( + settings.livekit_api_key.get_secret_value(), + settings.livekit_api_secret.get_secret_value(), + ) + .with_identity(participant_identity) + .with_name("Local voice participant") + .with_ttl(timedelta(seconds=settings.token_ttl_seconds)) + .with_grants(grants) + .with_room_config(room_config) + .to_jwt() + ) + + return ConnectionDetails( + server_url=settings.livekit_url, + participant_token=participant_token, + room_name=room_name, + participant_identity=participant_identity, + ) diff --git a/muse_glimmer/macos/apps/token-service/tests/test_app.py b/muse_glimmer/macos/apps/token-service/tests/test_app.py new file mode 100644 index 0000000000..4019b158b3 --- /dev/null +++ b/muse_glimmer/macos/apps/token-service/tests/test_app.py @@ -0,0 +1,186 @@ +from __future__ import annotations + +import time + +import jwt +import pytest +from fastapi.testclient import TestClient +from muse_glimmer_token_service.app import create_app +from muse_glimmer_token_service.config import ALLOWED_WEB_ORIGINS, Settings +from muse_glimmer_token_service.tokens import AGENT_NAME +from pydantic import SecretStr + +_API_KEY = "test-key" +_SECRET = "test-secret-with-at-least-thirty-two-characters" + + +def _settings() -> Settings: + return Settings( + LIVEKIT_API_KEY=SecretStr(_API_KEY), + LIVEKIT_API_SECRET=SecretStr(_SECRET), + TOKEN_TTL_SECONDS=600, + ) + + +def _client(*, host: str = "127.0.0.1", client_host: str = "127.0.0.1") -> TestClient: + return TestClient( + create_app(_settings()), + base_url=f"http://{host}", + client=(client_host, 50000), + ) + + +def test_token_has_restricted_grants_fixed_dispatch_and_exact_response() -> None: + with _client() as client: + response = client.post("/api/token", headers={"Origin": ALLOWED_WEB_ORIGINS[0]}) + + assert response.status_code == 200 + assert response.headers["cache-control"] == "no-store" + assert response.headers["pragma"] == "no-cache" + assert response.headers["x-content-type-options"] == "nosniff" + assert response.headers["access-control-allow-origin"] == ALLOWED_WEB_ORIGINS[0] + body = response.json() + assert set(body) == { + "participantIdentity", + "participantToken", + "roomName", + "serverUrl", + } + assert body["serverUrl"] == "ws://127.0.0.1:7880" + assert body["roomName"].startswith("r_") + assert body["participantIdentity"].startswith("p_") + + assert jwt.get_unverified_header(body["participantToken"])["alg"] == "HS256" + claims = jwt.decode( + body["participantToken"], + _SECRET, + algorithms=["HS256"], + options={"verify_aud": False}, + ) + assert claims["iss"] == _API_KEY + assert claims["sub"] == body["participantIdentity"] + assert 590 <= claims["exp"] - int(time.time()) <= 600 + assert claims["video"] == { + "canPublish": True, + "canPublishData": False, + "canPublishSources": ["microphone"], + "canSubscribe": True, + "room": body["roomName"], + "roomJoin": True, + } + assert claims["roomConfig"] == {"agents": [{"agentName": AGENT_NAME}]} + assert "admin" not in claims + assert "recorder" not in claims + + +def test_each_request_gets_random_room_and_participant() -> None: + with _client() as client: + first = client.post("/api/token", headers={"Origin": ALLOWED_WEB_ORIGINS[0]}).json() + second = client.post("/api/token", headers={"Origin": ALLOWED_WEB_ORIGINS[0]}).json() + + assert first["roomName"] != second["roomName"] + assert first["participantIdentity"] != second["participantIdentity"] + + +def test_client_cannot_choose_room_or_agent() -> None: + with _client() as client: + response = client.post( + "/api/token", + headers={"Origin": ALLOWED_WEB_ORIGINS[0]}, + json={"roomName": "attacker-room", "agentName": "other-agent"}, + ) + + assert response.status_code == 200 + assert response.json()["roomName"] != "attacker-room" + + +@pytest.mark.parametrize("origin", ALLOWED_WEB_ORIGINS) +def test_exact_web_origins_are_allowed(origin: str) -> None: + with _client() as client: + response = client.post("/api/token", headers={"Origin": origin}) + + assert response.status_code == 200 + assert response.headers["access-control-allow-origin"] == origin + + +@pytest.mark.parametrize( + "origin", + [ + "http://localhost:5173", + "http://127.0.0.1:5173/", + "http://127.0.0.1:5174", + "https://127.0.0.1:5173", + "https://unapproved.example", + ], +) +def test_inexact_origin_is_rejected(origin: str) -> None: + headers = {"Origin": origin} + with _client() as client: + response = client.post("/api/token", headers=headers) + + assert response.status_code == 403 + assert "access-control-allow-origin" not in response.headers + assert _API_KEY not in response.text + assert _SECRET not in response.text + + +def test_missing_origin_is_allowed_for_loopback_native_clients() -> None: + with _client() as client: + response = client.post("/api/token") + + assert response.status_code == 200 + assert "access-control-allow-origin" not in response.headers + + +def test_cors_preflight_allows_only_expected_origin_and_method() -> None: + with _client() as client: + allowed = client.options( + "/api/token", + headers={ + "Origin": ALLOWED_WEB_ORIGINS[0], + "Access-Control-Request-Method": "POST", + "Access-Control-Request-Headers": "Accept", + }, + ) + disallowed = client.options( + "/api/token", + headers={ + "Origin": "http://localhost:5173", + "Access-Control-Request-Method": "POST", + }, + ) + + assert allowed.status_code == 200 + assert allowed.headers["access-control-allow-origin"] == ALLOWED_WEB_ORIGINS[0] + assert disallowed.status_code == 400 + assert "access-control-allow-origin" not in disallowed.headers + + +def test_untrusted_host_is_rejected() -> None: + with _client(host="attacker.example") as client: + response = client.get("/healthz") + + assert response.status_code == 400 + assert response.headers["cache-control"] == "no-store" + + +def test_non_loopback_client_is_rejected() -> None: + with _client(client_host="203.0.113.10") as client: + response = client.get("/healthz") + + assert response.status_code == 403 + assert response.json() == {"detail": "Loopback clients only"} + assert response.headers["cache-control"] == "no-store" + + +def test_health_and_disabled_documentation_expose_no_configuration() -> None: + with _client() as client: + health = client.get("/healthz") + docs = [client.get(path) for path in ("/docs", "/redoc", "/openapi.json")] + + assert health.status_code == 200 + assert health.json() == {"status": "ok"} + assert health.headers["cache-control"] == "no-store" + assert _API_KEY not in health.text + assert _SECRET not in health.text + assert all(response.status_code == 404 for response in docs) diff --git a/muse_glimmer/macos/apps/token-service/tests/test_cli.py b/muse_glimmer/macos/apps/token-service/tests/test_cli.py new file mode 100644 index 0000000000..69dfc986c4 --- /dev/null +++ b/muse_glimmer/macos/apps/token-service/tests/test_cli.py @@ -0,0 +1,28 @@ +from __future__ import annotations + +from typing import Any + +from muse_glimmer_token_service import __main__ + + +def test_launcher_uses_fixed_loopback_address(monkeypatch: Any) -> None: + invocation: dict[str, Any] = {} + + def fake_run(app: str, **kwargs: Any) -> None: + invocation["app"] = app + invocation.update(kwargs) + + monkeypatch.setattr(__main__.uvicorn, "run", fake_run) + monkeypatch.setenv("HOST", "0.0.0.0") + monkeypatch.setenv("PORT", "9999") + + __main__.main() + + assert invocation == { + "app": "muse_glimmer_token_service.app:create_app", + "factory": True, + "host": "127.0.0.1", + "port": 8787, + "proxy_headers": False, + "server_header": False, + } diff --git a/muse_glimmer/macos/apps/token-service/tests/test_config.py b/muse_glimmer/macos/apps/token-service/tests/test_config.py new file mode 100644 index 0000000000..57f74df32c --- /dev/null +++ b/muse_glimmer/macos/apps/token-service/tests/test_config.py @@ -0,0 +1,101 @@ +from __future__ import annotations + +from pathlib import Path + +import pytest +from muse_glimmer_token_service.config import ALLOWED_WEB_ORIGINS, Settings +from pydantic import SecretStr, ValidationError + +_SECRET = "test-secret-with-at-least-thirty-two-characters" + + +def _values(**overrides: object) -> dict[str, object]: + values: dict[str, object] = { + "LIVEKIT_API_KEY": "test-key", + "LIVEKIT_API_SECRET": _SECRET, + } + values.update(overrides) + return values + + +def test_settings_read_credentials_from_environment(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("LIVEKIT_API_KEY", " environment-key ") + monkeypatch.setenv("LIVEKIT_API_SECRET", f" {_SECRET} ") + + settings = Settings() # type: ignore[call-arg] + + assert settings.livekit_api_key.get_secret_value() == "environment-key" + assert settings.livekit_api_secret.get_secret_value() == _SECRET + assert settings.livekit_url == "ws://127.0.0.1:7880" + assert settings.allowed_web_origins == ("http://127.0.0.1:5173",) + + +def test_dotenv_file_is_never_loaded( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + monkeypatch.chdir(tmp_path) + monkeypatch.delenv("LIVEKIT_API_KEY", raising=False) + monkeypatch.delenv("LIVEKIT_API_SECRET", raising=False) + (tmp_path / ".env").write_text( + f"LIVEKIT_API_KEY=dotenv-key\nLIVEKIT_API_SECRET={_SECRET}\n", + encoding="utf-8", + ) + + with pytest.raises(ValidationError): + Settings() # type: ignore[call-arg] + + +@pytest.mark.parametrize("field", ["LIVEKIT_API_KEY", "LIVEKIT_API_SECRET"]) +def test_blank_credentials_are_rejected(field: str) -> None: + with pytest.raises(ValidationError): + Settings(**_values(**{field: " "})) # type: ignore[arg-type] + + +def test_credentials_are_redacted_from_settings_representation() -> None: + settings = Settings( + **_values( + LIVEKIT_API_KEY=SecretStr("test-key"), + LIVEKIT_API_SECRET=SecretStr(_SECRET), + ) + ) + + assert "test-key" not in repr(settings) + assert _SECRET not in repr(settings) + + +@pytest.mark.parametrize( + "url", + [ + "ws://localhost:7880", + "ws://127.0.0.1:7880/", + "ws://127.0.0.1:7880/path", + "ws://127.0.0.1:7880?query=yes", + "wss://127.0.0.1:7880", + "wss://example.livekit.cloud", + ], +) +def test_livekit_url_variations_are_rejected(url: str) -> None: + with pytest.raises(ValidationError, match="must be exactly"): + Settings(**_values(LIVEKIT_URL=url)) + + +@pytest.mark.parametrize("ttl", [60, 3600]) +def test_token_ttl_boundaries_are_allowed(ttl: int) -> None: + assert Settings(**_values(TOKEN_TTL_SECONDS=ttl)).token_ttl_seconds == ttl + + +@pytest.mark.parametrize("ttl", [59, 3601]) +def test_token_ttl_outside_bounds_is_rejected(ttl: int) -> None: + with pytest.raises(ValidationError): + Settings(**_values(TOKEN_TTL_SECONDS=ttl)) + + +def test_allowed_origins_cannot_be_overridden_by_environment( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setenv("ALLOWED_ORIGINS", "https://attacker.example") + + settings = Settings(**_values()) + + assert settings.allowed_web_origins == ALLOWED_WEB_ORIGINS diff --git a/muse_glimmer/macos/apps/web/.gitignore b/muse_glimmer/macos/apps/web/.gitignore new file mode 100644 index 0000000000..c4e5510013 --- /dev/null +++ b/muse_glimmer/macos/apps/web/.gitignore @@ -0,0 +1,4 @@ +dist/ +node_modules/ +*.tsbuildinfo +.DS_Store diff --git a/muse_glimmer/macos/apps/web/README.md b/muse_glimmer/macos/apps/web/README.md new file mode 100644 index 0000000000..d8b6da03be --- /dev/null +++ b/muse_glimmer/macos/apps/web/README.md @@ -0,0 +1,41 @@ +# Muse Glimmer local voice web app + +A redistributable React interface for a private Muse Glimmer voice conversation. The browser requests a short-lived token from the fixed local endpoint `http://127.0.0.1:8787/api/token` and accepts media connections only to `ws://127.0.0.1:7880`. + +## Requirements + +- Node.js 22.12 or newer +- A local token service on `127.0.0.1:8787` +- A local LiveKit server on `127.0.0.1:7880` +- A LiveKit voice agent registered with the public name `assistant` + +## Development + +```bash +npm ci +npm run dev +``` + +Open `http://127.0.0.1:5173`. The development server binds only to loopback. + +## Production + +```bash +npm ci +npm run build +npm run serve +``` + +The production server binds only to `127.0.0.1` on port `5173` by default. The supervisor uses the constrained command `npm run serve -- --host 127.0.0.1 --port 5173`; all other host values are rejected. The server delivers the built application with a Content Security Policy and defensive browser headers. Production source maps are disabled. + +## Quality checks + +```bash +npm run typecheck +npm test +npm run lint +npm run format:check +npm run build +``` + +The package contains no token secrets, generated distribution files, third-party avatar definitions, or image branding assets. diff --git a/muse_glimmer/macos/apps/web/eslint.config.js b/muse_glimmer/macos/apps/web/eslint.config.js new file mode 100644 index 0000000000..f2f5a3411e --- /dev/null +++ b/muse_glimmer/macos/apps/web/eslint.config.js @@ -0,0 +1,43 @@ +import js from "@eslint/js"; +import reactHooks from "eslint-plugin-react-hooks"; +import reactRefresh from "eslint-plugin-react-refresh"; +import globals from "globals"; +import tseslint from "typescript-eslint"; + +export default tseslint.config( + { ignores: ["dist", "node_modules"] }, + { + files: ["*.{js,mjs}"], + ...js.configs.recommended, + languageOptions: { + ecmaVersion: 2023, + globals: globals.node, + }, + }, + { + files: ["**/*.{ts,tsx}"], + extends: [ + js.configs.recommended, + ...tseslint.configs.recommendedTypeChecked, + ], + languageOptions: { + ecmaVersion: 2022, + globals: globals.browser, + parserOptions: { + project: ["./tsconfig.app.json", "./tsconfig.node.json"], + tsconfigRootDir: import.meta.dirname, + }, + }, + plugins: { + "react-hooks": reactHooks, + "react-refresh": reactRefresh, + }, + rules: { + ...reactHooks.configs.recommended.rules, + "react-refresh/only-export-components": [ + "warn", + { allowConstantExport: true }, + ], + }, + }, +); diff --git a/muse_glimmer/macos/apps/web/index.html b/muse_glimmer/macos/apps/web/index.html new file mode 100644 index 0000000000..fc278e2aa0 --- /dev/null +++ b/muse_glimmer/macos/apps/web/index.html @@ -0,0 +1,17 @@ + + + + + + + + Muse Glimmer | Local voice conversation + + +
+ + + diff --git a/muse_glimmer/macos/apps/web/package-lock.json b/muse_glimmer/macos/apps/web/package-lock.json new file mode 100644 index 0000000000..6473367c82 --- /dev/null +++ b/muse_glimmer/macos/apps/web/package-lock.json @@ -0,0 +1,4563 @@ +{ + "name": "@muse-glimmer/web", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@muse-glimmer/web", + "version": "0.1.0", + "dependencies": { + "@fontsource/inter": "5.3.0", + "@livekit/components-react": "2.9.24", + "livekit-client": "2.22.0", + "react": "19.2.4", + "react-dom": "19.2.4" + }, + "devDependencies": { + "@eslint/js": "9.34.0", + "@testing-library/jest-dom": "6.8.0", + "@testing-library/react": "16.3.0", + "@types/node": "24.3.0", + "@types/react": "19.1.12", + "@types/react-dom": "19.1.9", + "@vitejs/plugin-react": "6.0.2", + "eslint": "9.34.0", + "eslint-plugin-react-hooks": "7.0.0", + "eslint-plugin-react-refresh": "0.4.20", + "globals": "16.3.0", + "jsdom": "26.1.0", + "prettier": "3.6.2", + "typescript": "5.9.3", + "typescript-eslint": "8.41.0", + "vite": "8.2.2", + "vitest": "4.1.11" + }, + "engines": { + "node": ">=22.12.0" + } + }, + "node_modules/@adobe/css-tools": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.5.0.tgz", + "integrity": "sha512-6OzddxPio9UiWTCemp4N8cYLV2ZN1ncRnV1cVGtve7dhPOtRkleRyx32GQCYSwDYgaHU3USMm84tNsvKzRCa1Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@asamuzakjp/css-color": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-3.2.0.tgz", + "integrity": "sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@csstools/css-calc": "^2.1.3", + "@csstools/css-color-parser": "^3.0.9", + "@csstools/css-parser-algorithms": "^3.0.4", + "@csstools/css-tokenizer": "^3.0.3", + "lru-cache": "^10.4.3" + } + }, + "node_modules/@asamuzakjp/css-color/node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/@babel/code-frame": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.29.7", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz", + "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz", + "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helpers": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.8.tgz", + "integrity": "sha512-gZbepsdh3WDtgZKWL+vTPh71LSBrm/Y4/QDZBVCcYfmeTEEuoOYwlSy+G1StfJg+/Zy550u/3TATbm7qDbbMtg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.8", + "@babel/types": "^7.29.8", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz", + "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.29.7", + "@babel/helper-validator-option": "^7.29.7", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", + "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", + "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz", + "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7", + "@babel/traverse": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz", + "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz", + "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.8.tgz", + "integrity": "sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.8" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/runtime": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz", + "integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/template": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", + "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.8.tgz", + "integrity": "sha512-I5z7H3bf/41ktsNVLtpN0wAa336HkqIHQ5BuPLEhTkt1jVSyZpeNKIzTgEWmlxjdg81R0IgUCcaE+Ok3NvrfZg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.8", + "@babel/helper-globals": "^7.29.7", + "@babel/parser": "^7.29.8", + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.8", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.8.tgz", + "integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@bufbuild/protobuf": { + "version": "1.10.1", + "resolved": "https://registry.npmjs.org/@bufbuild/protobuf/-/protobuf-1.10.1.tgz", + "integrity": "sha512-wJ8ReQbHxsAfXhrf9ixl0aYbZorRuOWpBNzm8pL8ftmSxQx/wnJD5Eg861NwJU/czy2VXFIebCeZnZrI9rktIQ==", + "license": "(Apache-2.0 AND BSD-3-Clause)" + }, + "node_modules/@csstools/color-helpers": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-5.1.0.tgz", + "integrity": "sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=18" + } + }, + "node_modules/@csstools/css-calc": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-2.1.4.tgz", + "integrity": "sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-3.1.0.tgz", + "integrity": "sha512-nbtKwh3a6xNVIp/VRuXV64yTKnb1IjTAEEh3irzS+HkKjAOYLTGNb9pmVNntZ8iVBHcWDA2Dof0QtPgFI1BaTA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^5.1.0", + "@csstools/css-calc": "^2.1.4" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-3.0.5.tgz", + "integrity": "sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-tokenizer": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-3.0.4.tgz", + "integrity": "sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.10.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.10.1.tgz", + "integrity": "sha512-cuadcxVFE8sDK6iWJbs8Sn0av2Nrh2QSGQhVlBW9AaAHqHwjWsZHT8LJ4hFGPh7ASBV2deFdM7H/DPjulmh8rg==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/config-array": { + "version": "0.21.2", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.2.tgz", + "integrity": "sha512-nJl2KGTlrf9GjLimgIru+V/mzgSK0ABCDQRvxw5BjURL7WfH5uoWmizbH7QB6MmnMBd8cIC9uceWnezL1VZWWw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/object-schema": "^2.1.7", + "debug": "^4.3.1", + "minimatch": "^3.1.5" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/config-helpers": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.3.1.tgz", + "integrity": "sha512-xR93k9WhrDYpXHORXpxVL5oHj3Era7wo6k/Wd8/IsQNnZUTzkGS29lyn3nAT05v6ltUuTFVCCYDEGfy2Or/sPA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/core": { + "version": "0.15.2", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.15.2.tgz", + "integrity": "sha512-78Md3/Rrxh83gCxoUc0EiciuOHsIITzLy53m3d9UyiW8y9Dj2D29FeETqyKA+BRK76tnTp6RXWb3pCay8Oyomg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/eslintrc": { + "version": "3.3.6", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.6.tgz", + "integrity": "sha512-l2Ul9PrHsPCKcEY/ac7VgFj9D80C7S68sOKc618SyHDPK36s1XcFebXY0iTzUVn4Yq+YbwvSnDmCz9yxjX+QrA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^6.14.0", + "debug": "^4.3.2", + "espree": "^10.0.1", + "globals": "^14.0.0", + "ignore": "^5.2.0", + "import-fresh": "^3.2.1", + "js-yaml": "^4.3.0", + "minimatch": "^3.1.5", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint/eslintrc/node_modules/globals": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", + "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@eslint/js": { + "version": "9.34.0", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.34.0.tgz", + "integrity": "sha512-EoyvqQnBNsV1CWaEJ559rxXL4c8V92gxirbawSmVUOWXlsRxxQXl6LmCpdUblgxgSkDIqKnhzba2SjRTI/A5Rw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + } + }, + "node_modules/@eslint/object-schema": { + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.7.tgz", + "integrity": "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.3.5.tgz", + "integrity": "sha512-Z5kJ+wU3oA7MMIqVR9tyZRtjYPr4OC004Q4Rw7pgOKUOKkJfZ3O24nz3WYfGRpMDNmcOi3TwQOmgm7B7Tpii0w==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.15.2", + "levn": "^0.4.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@floating-ui/core": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.8.0.tgz", + "integrity": "sha512-0CIZ5itps/8x7BG8dEIhs53BvCUH2PCoogtakwRTut+Arm58sJooJ0AuZhLw2HJYIR5cMLNPBSS728sPho2khQ==", + "license": "MIT", + "dependencies": { + "@floating-ui/utils": "^0.2.12" + } + }, + "node_modules/@floating-ui/dom": { + "version": "1.7.6", + "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.6.tgz", + "integrity": "sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ==", + "license": "MIT", + "dependencies": { + "@floating-ui/core": "^1.7.5", + "@floating-ui/utils": "^0.2.11" + } + }, + "node_modules/@floating-ui/utils": { + "version": "0.2.12", + "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.12.tgz", + "integrity": "sha512-HpCo8tmWzLVad5s2d19EhAz5zqrrQ6s69qd6moPMQvkOuSwDT1YgRfWSVuc4ennqrgv3OHppiOGMQ7oC13yIww==", + "license": "MIT" + }, + "node_modules/@fontsource/inter": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/@fontsource/inter/-/inter-5.3.0.tgz", + "integrity": "sha512-RofMylZmjlJEfELXeNHFWBRcSs75rGU/6bV2S2jfnvv/3rPXPGe0LgUJTklcHZ9lM4OZmAVFhcJPnACfb91A3g==", + "license": "OFL-1.1", + "funding": { + "url": "https://github.com/sponsors/ayuhito" + } + }, + "node_modules/@humanfs/core": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", + "integrity": "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/types": "^0.15.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.8", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.8.tgz", + "integrity": "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/core": "^0.19.2", + "@humanfs/types": "^0.15.0", + "@humanwhocodes/retry": "^0.4.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/types": { + "version": "0.15.0", + "resolved": "https://registry.npmjs.org/@humanfs/types/-/types-0.15.0.tgz", + "integrity": "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/retry": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@livekit/components-core": { + "version": "0.12.15", + "resolved": "https://registry.npmjs.org/@livekit/components-core/-/components-core-0.12.15.tgz", + "integrity": "sha512-bDkK+jkPMgyT4a5lZmdcuc/hZ8fZUrXW+qQPNHVeSpXCBMzk5q9QWzx+HPtTsPJ8hnncWGfoT81RnXHBsy+Z8g==", + "license": "Apache-2.0", + "dependencies": { + "@floating-ui/dom": "1.7.6", + "loglevel": "1.9.1", + "rxjs": "7.8.2" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "livekit-client": "^2.20.1", + "tslib": "^2.6.2" + } + }, + "node_modules/@livekit/components-react": { + "version": "2.9.24", + "resolved": "https://registry.npmjs.org/@livekit/components-react/-/components-react-2.9.24.tgz", + "integrity": "sha512-qw5Oy1EfPg1f/xrKYrFielSrmtxY8BZ9mDt6299G5aikotQIztmldBfTNosrVS7VSNit7qeYfOFSyKaML20W6Q==", + "license": "Apache-2.0", + "dependencies": { + "@livekit/components-core": "0.12.15", + "clsx": "2.1.1", + "events": "^3.3.0", + "jose": "^6.0.12", + "usehooks-ts": "3.1.1" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@livekit/krisp-noise-filter": "^0.2.12 || ^0.3.0 || ^0.4.0", + "livekit-client": "^2.20.1", + "react": ">=18", + "react-dom": ">=18", + "tslib": "^2.6.2" + }, + "peerDependenciesMeta": { + "@livekit/krisp-noise-filter": { + "optional": true + } + } + }, + "node_modules/@livekit/mutex": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@livekit/mutex/-/mutex-1.1.1.tgz", + "integrity": "sha512-EsshAucklmpuUAfkABPxJNhzj9v2sG7JuzFDL4ML1oJQSV14sqrpTYnsaOudMAw9yOaW53NU3QQTlUQoRs4czw==", + "license": "Apache-2.0" + }, + "node_modules/@livekit/protocol": { + "version": "1.50.4", + "resolved": "https://registry.npmjs.org/@livekit/protocol/-/protocol-1.50.4.tgz", + "integrity": "sha512-L1uggNQAqyY21smQY8AllyOYbcv9Me9TaxwuLytL1R8ck9nbYPmQLNwEDi3pOFGAMa5F8I2nUi2Jc59W5awxlA==", + "license": "Apache-2.0", + "dependencies": { + "@bufbuild/protobuf": "^1.10.0" + } + }, + "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==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + }, + "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==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "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==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@oxc-project/types": { + "version": "0.146.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.146.0.tgz", + "integrity": "sha512-XC0QsnnhVe7sLIWmYmdPw7x5P0h4W8vUU3Nv1ySgWXtvCz8NizoAEpGXA0sOYoJQV2Rl13LgURAHQ5cI5ILCSA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, + "node_modules/@rolldown/binding-android-arm-eabi": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.5.tgz", + "integrity": "sha512-DLe/i+l8ynIBY7XEQ191TeZvCoowIGa18R+dIV30GW7DiOtp74i/xX8hs8GUjW5ARV7VZuie3d6AumSmCwbeRA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.5.tgz", + "integrity": "sha512-zXcwKlQApYAOELHd8PwKDFkagYF9Wy4e0RJ+0qnzl9Pjnpj75TEG8ufv40p2J7kCEfwZAsNiuzRIyNNMWT38ig==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.5.tgz", + "integrity": "sha512-dK4QakI42nzWgJT5sm4y4y/O//D4OxM75/cH28RLV+nzIN9AY+YsbuUVrUTjlLjXR6vpyxFbSsbmNuJ6BP9sww==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.5.tgz", + "integrity": "sha512-fqSALaUu1Wjd1nK2uW2kJDWdLCc8lx1IcY+MTY26Aurfdx19anlzhqXOgCFbBFQnlFDTn4TC1/7Nz4Bl2mLP3A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.5.tgz", + "integrity": "sha512-/vCnNxlkxs9tKxNDcyWUePpJ/PgTzxIaVhoM5SmG8UV+GR/IcPam4VYxi7GIMo7PSDuNqlJqvprqii9NqqVCMw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.5.tgz", + "integrity": "sha512-abk0NLA519LxRCszmbE0jYKuQ9YPocOXTiOXOo6Yr+YAT95VH+PtqYAjOJvGKt3viEd/x4qzabAlwd5bHOOARg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.5.tgz", + "integrity": "sha512-Y7eALiJ8lr0M2HH103Js+g7V34wf6snlpZLAsHI90uLhr3PVlNsbFVAXJC9d/V6BnPyKtpSwI+NcB/RLxsQxuA==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.5.tgz", + "integrity": "sha512-xMvZgnbZg4YVnR/AX2b3oOPDTFYJvUVaJg5FedA/LuvexAtXibZQej4cnTkw3rjsJ/ggUROB64TdtETiim+FYA==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.5.tgz", + "integrity": "sha512-GRjeqTUDHTo5GwntsLaAMcBahG3nlpjftXWZLN73HiYQlhwEowvarFgQnRnQZtIp4keXX7quXFbG38uPZBa2EA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.5.tgz", + "integrity": "sha512-vLNTR45F2Uwc8AufkNXPmB4VliaXs+FvcheEogIzOXzO4l+LzieXF5A/TWxLy5HtqpsRCHUfd0lPVrrdgXdLHQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.5.tgz", + "integrity": "sha512-Mgj59/HTuYeK9Gz2MA+mBWKnHsAgkBSec15ZMb1st3oIfFbX7gCjOae7GydHhzcyQi9Z/7M1QuN9bR3oFqF0jQ==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.5.tgz", + "integrity": "sha512-mY8AP0/ichsbhAxGnLa3d3+MwV0EfgrPND2bplI3Ym8T6R2pJ0N87bvrKVwNXmdy3jnr6eQBecdqx/HMknBmpA==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.5.tgz", + "integrity": "sha512-8SLssA2oweAxyRgDp789ACfRb/3P+zNRJpzZxSizxF9m8NUDQ4+3xjo8ttjhVGGw6Qxb70oZiEtIjaKikCO7Yw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.5.tgz", + "integrity": "sha512-vGbruD5zquhoc8D9SViXgN2FBJtNdTyQ4DtG+SWiEGlJiAzoKcZ2xp+xuXCffhubVdt0NJlTZqkeRuERy7g8Cw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.5.tgz", + "integrity": "sha512-e/SXpgISz+IoqVcSSI0rx/d/he8zqLex+/rCWpnHpmVfmPIUjag9H6P7zotf0gJHwPUhQxZ/mF8tr6acebT9yw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@testing-library/dom": { + "version": "10.4.1", + "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", + "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/code-frame": "^7.10.4", + "@babel/runtime": "^7.12.5", + "@types/aria-query": "^5.0.1", + "aria-query": "5.3.0", + "dom-accessibility-api": "^0.5.9", + "lz-string": "^1.5.0", + "picocolors": "1.1.1", + "pretty-format": "^27.0.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@testing-library/jest-dom": { + "version": "6.8.0", + "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-6.8.0.tgz", + "integrity": "sha512-WgXcWzVM6idy5JaftTVC8Vs83NKRmGJz4Hqs4oyOuO2J4r/y79vvKZsb+CaGyCSEbUPI6OsewfPd0G1A0/TUZQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@adobe/css-tools": "^4.4.0", + "aria-query": "^5.0.0", + "css.escape": "^1.5.1", + "dom-accessibility-api": "^0.6.3", + "picocolors": "^1.1.1", + "redent": "^3.0.0" + }, + "engines": { + "node": ">=14", + "npm": ">=6", + "yarn": ">=1" + } + }, + "node_modules/@testing-library/jest-dom/node_modules/dom-accessibility-api": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.6.3.tgz", + "integrity": "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@testing-library/react": { + "version": "16.3.0", + "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.0.tgz", + "integrity": "sha512-kFSyxiEDwv1WLl2fgsq6pPBbw5aWKrsY2/noi1Id0TK0UParSF62oFQFGHXIyaG4pp2tEub/Zlel+fjjZILDsw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.12.5" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@testing-library/dom": "^10.0.0", + "@types/react": "^18.0.0 || ^19.0.0", + "@types/react-dom": "^18.0.0 || ^19.0.0", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@types/aria-query": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", + "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/dom-mediacapture-record": { + "version": "1.0.22", + "resolved": "https://registry.npmjs.org/@types/dom-mediacapture-record/-/dom-mediacapture-record-1.0.22.tgz", + "integrity": "sha512-mUMZLK3NvwRLcAAT9qmcK+9p7tpU2FHdDsntR3YI4+GY88XrgG4XiE7u1Q2LAN2/FZOz/tdMDC3GQCR4T8nFuw==", + "license": "MIT", + "peer": true + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/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==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "24.3.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.3.0.tgz", + "integrity": "sha512-aPTXCrfwnDLj4VvXrm+UUCQjNEvJgNA8s5F1cvwQU+3KNltTOkBm1j30uNLyqqPNe7gE3KFzImYoZEfLhp4Yow==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~7.10.0" + } + }, + "node_modules/@types/react": { + "version": "19.1.12", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.1.12.tgz", + "integrity": "sha512-cMoR+FoAf/Jyq6+Df2/Z41jISvGZZ2eTlnsaJRptmZ76Caldwy1odD4xTr/gNV9VLj0AWgg/nmkevIyUfIIq5w==", + "dev": true, + "license": "MIT", + "dependencies": { + "csstype": "^3.0.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.1.9", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.1.9.tgz", + "integrity": "sha512-qXRuZaOsAdXKFyOhRBg6Lqqc0yay13vN7KrIg4L7N4aaHN68ma9OK3NE1BoDFgFOTfM7zg+3/8+2n8rLUH3OKQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^19.0.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "8.41.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.41.0.tgz", + "integrity": "sha512-8fz6oa6wEKZrhXWro/S3n2eRJqlRcIa6SlDh59FXJ5Wp5XRZ8B9ixpJDcjadHq47hMx0u+HW6SNa6LjJQ6NLtw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/regexpp": "^4.10.0", + "@typescript-eslint/scope-manager": "8.41.0", + "@typescript-eslint/type-utils": "8.41.0", + "@typescript-eslint/utils": "8.41.0", + "@typescript-eslint/visitor-keys": "8.41.0", + "graphemer": "^1.4.0", + "ignore": "^7.0.0", + "natural-compare": "^1.4.0", + "ts-api-utils": "^2.1.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^8.41.0", + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.6.tgz", + "integrity": "sha512-BAg6QkE8W+TuQLrrw0Ugr7HegXduRuuj8/ti2kSOc+jz1dmx8/WNcjr6XGnq5YpDWxFwwaavqD0+jIUOKelTsw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/@typescript-eslint/parser": { + "version": "8.41.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.41.0.tgz", + "integrity": "sha512-gTtSdWX9xiMPA/7MV9STjJOOYtWwIJIYxkQxnSV1U3xcE+mnJSH3f6zI0RYP+ew66WSlZ5ed+h0VCxsvdC1jJg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/scope-manager": "8.41.0", + "@typescript-eslint/types": "8.41.0", + "@typescript-eslint/typescript-estree": "8.41.0", + "@typescript-eslint/visitor-keys": "8.41.0", + "debug": "^4.3.4" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/project-service": { + "version": "8.41.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.41.0.tgz", + "integrity": "sha512-b8V9SdGBQzQdjJ/IO3eDifGpDBJfvrNTp2QD9P2BeqWTGrRibgfgIlBSw6z3b6R7dPzg752tOs4u/7yCLxksSQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/tsconfig-utils": "^8.41.0", + "@typescript-eslint/types": "^8.41.0", + "debug": "^4.3.4" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/scope-manager": { + "version": "8.41.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.41.0.tgz", + "integrity": "sha512-n6m05bXn/Cd6DZDGyrpXrELCPVaTnLdPToyhBoFkLIMznRUQUEQdSp96s/pcWSQdqOhrgR1mzJ+yItK7T+WPMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.41.0", + "@typescript-eslint/visitor-keys": "8.41.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.41.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.41.0.tgz", + "integrity": "sha512-TDhxYFPUYRFxFhuU5hTIJk+auzM/wKvWgoNYOPcOf6i4ReYlOoYN8q1dV5kOTjNQNJgzWN3TUUQMtlLOcUgdUw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/type-utils": { + "version": "8.41.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.41.0.tgz", + "integrity": "sha512-63qt1h91vg3KsjVVonFJWjgSK7pZHSQFKH6uwqxAH9bBrsyRhO6ONoKyXxyVBzG1lJnFAJcKAcxLS54N1ee1OQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.41.0", + "@typescript-eslint/typescript-estree": "8.41.0", + "@typescript-eslint/utils": "8.41.0", + "debug": "^4.3.4", + "ts-api-utils": "^2.1.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/types": { + "version": "8.41.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.41.0.tgz", + "integrity": "sha512-9EwxsWdVqh42afLbHP90n2VdHaWU/oWgbH2P0CfcNfdKL7CuKpwMQGjwev56vWu9cSKU7FWSu6r9zck6CVfnag==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/typescript-estree": { + "version": "8.41.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.41.0.tgz", + "integrity": "sha512-D43UwUYJmGhuwHfY7MtNKRZMmfd8+p/eNSfFe6tH5mbVDto+VQCayeAt35rOx3Cs6wxD16DQtIKw/YXxt5E0UQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/project-service": "8.41.0", + "@typescript-eslint/tsconfig-utils": "8.41.0", + "@typescript-eslint/types": "8.41.0", + "@typescript-eslint/visitor-keys": "8.41.0", + "debug": "^4.3.4", + "fast-glob": "^3.3.2", + "is-glob": "^4.0.3", + "minimatch": "^9.0.4", + "semver": "^7.6.0", + "ts-api-utils": "^2.1.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.4.tgz", + "integrity": "sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.2" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@typescript-eslint/utils": { + "version": "8.41.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.41.0.tgz", + "integrity": "sha512-udbCVstxZ5jiPIXrdH+BZWnPatjlYwJuJkDA4Tbo3WyYLh8NvB+h/bKeSZHDOFKfphsZYJQqaFtLeXEqurQn1A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.7.0", + "@typescript-eslint/scope-manager": "8.41.0", + "@typescript-eslint/types": "8.41.0", + "@typescript-eslint/typescript-estree": "8.41.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "8.41.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.41.0.tgz", + "integrity": "sha512-+GeGMebMCy0elMNg67LRNoVnUFPIm37iu5CmHESVx56/9Jsfdpsvbv605DQ81Pi/x11IdKUsS5nzgTYbCQU9fg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.41.0", + "eslint-visitor-keys": "^4.2.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.0.2.tgz", + "integrity": "sha512-DlSMqo4WhThw4vB8Mpn0Woe9J+Jfq1geJ61AKW0QEgLzGMNwtIMdxbDUzLxcun8W7NbJO0e2Jg/Nxm3cCSVzzg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rolldown/pluginutils": "^1.0.0" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0", + "babel-plugin-react-compiler": "^1.0.0", + "vite": "^8.0.0" + }, + "peerDependenciesMeta": { + "@rolldown/plugin-babel": { + "optional": true + }, + "babel-plugin-react-compiler": { + "optional": true + } + } + }, + "node_modules/@vitest/expect": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.11.tgz", + "integrity": "sha512-VX2x5vNJXET47KAFzwERI+KRMtTTCSWTfSMKsW7JsUsXV4psq++e3DvZpuTDOpHcxytiDs6p2nhVb2tVDiiUYw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.1.11", + "@vitest/utils": "4.1.11", + "chai": "^6.2.2", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.11.tgz", + "integrity": "sha512-2XJVD55d1o5AZous5CCGKS74g/riOj9odEt2bQpCVZeblHyHdnMeFl4jl0XjU21stf4mbjUkew2eXQZt65g5CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.1.11", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.11.tgz", + "integrity": "sha512-yiZzPbGTS9Sr/JpFl8zHrcIkAofNbFV6k21vIgQN/cY/oxZeXhJv5sc/MBJ5jFKWmWs+oJHw0UXLZjmf931+Vw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.11.tgz", + "integrity": "sha512-LztvUgdwMNJMIkj3hQnnxiC2Xy1zNxq928W/xhjCLaNCzqTZOudjwbQf6v9IntZGPw132i2Lq2rgTRZHD3JHNw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.1.11", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.11.tgz", + "integrity": "sha512-pN7ikn1ON7h8ee4gIAp4AzyK+zBtJPzVbqOgu5LCEh4VaJVbPQcgYQYJIMGQPXVeJJq1fnfazis7a5pFNPahog==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.11", + "@vitest/utils": "4.1.11", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.11.tgz", + "integrity": "sha512-apNa/prQy2qCeywhnixOHPRCgGNhvg7T4Dapfl1GahLp/R+uhBm5cPyFoNVyqsNd2h1nJxL6BqqdIjiABL60YA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.11.tgz", + "integrity": "sha512-zTCVGpyFsGWBhllOyKlTw/vnr6D9qxsfSDyfbyZmTyjHw5N/VuvzHpHoQjm2ZJzn4RJgx5w4r7V0er69CmLgPQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.11", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/acorn": { + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.18.0.tgz", + "integrity": "sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/ajv": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true, + "license": "Python-2.0" + }, + "node_modules/aria-query": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz", + "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "dequal": "^2.0.3" + } + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/baseline-browser-mapping": { + "version": "2.11.18", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.18.tgz", + "integrity": "sha512-1iEmLEYSiE1SeBoAfPo/Mnx3PzfzHUkDK61ASkCpuk3YXugYLH5DYK1SzqV55F8FMI6s0F+/tCP7Polz1QRjxw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/brace-expansion": { + "version": "1.1.18", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz", + "integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/browserslist": { + "version": "4.28.8", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.8.tgz", + "integrity": "sha512-V2NpofLblG64mfOtSgDhOJESZEGogzDMBv/q+W6oc4LXWP/q75eOXoOaaOu1EOadB9U4Bwx/e0yzbvwKH8zalA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.11.12", + "caniuse-lite": "^1.0.30001809", + "electron-to-chromium": "^1.5.402", + "node-releases": "^2.0.53", + "update-browserslist-db": "^1.3.0" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001809", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001809.tgz", + "integrity": "sha512-xxWVywk6a6Arlk+hymeycyn/VgqEfLDxupvhH/xiY5SJ/18kmi9o6MiO320DCUzypORHLtvh0I4i04tUhCNHNQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true, + "license": "MIT" + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/css.escape": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz", + "integrity": "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cssstyle": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-4.6.0.tgz", + "integrity": "sha512-2z+rWdzbbSZv6/rhtvzvqeZQHrBaqgogqt85sqFNbabZOuFbCVFb8kPeEtZjiKkbrm395irpNKiYeFeLiQnFPg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^3.2.0", + "rrweb-cssom": "^0.8.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/data-urls": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-5.0.0.tgz", + "integrity": "sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^14.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "dev": true, + "license": "MIT" + }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/dom-accessibility-api": { + "version": "0.5.16", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", + "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/electron-to-chromium": { + "version": "1.5.412", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.412.tgz", + "integrity": "sha512-z4rMe3esBzlzovKHj4gxJnsCGZRK5l4baUvm+gCGJBPE+gsyUMKsuU9tnEUtI1dOebXz1ytAPGjvXhmQ7rIPwA==", + "dev": true, + "license": "ISC" + }, + "node_modules/entities": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/es-module-lexer": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.2.tgz", + "integrity": "sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==", + "dev": true, + "license": "MIT" + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "9.34.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.34.0.tgz", + "integrity": "sha512-RNCHRX5EwdrESy3Jc9o8ie8Bog+PeYvvSR8sDGoZxNFTvZ4dlxUB3WzQ3bQMztFrSRODGrLLj8g6OFuGY/aiQg==", + "deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.2.0", + "@eslint-community/regexpp": "^4.12.1", + "@eslint/config-array": "^0.21.0", + "@eslint/config-helpers": "^0.3.1", + "@eslint/core": "^0.15.2", + "@eslint/eslintrc": "^3.3.1", + "@eslint/js": "9.34.0", + "@eslint/plugin-kit": "^0.3.5", + "@humanfs/node": "^0.16.6", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.2", + "@types/estree": "^1.0.6", + "@types/json-schema": "^7.0.15", + "ajv": "^6.12.4", + "chalk": "^4.0.0", + "cross-spawn": "^7.0.6", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^8.4.0", + "eslint-visitor-keys": "^4.2.1", + "espree": "^10.4.0", + "esquery": "^1.5.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^8.0.0", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "lodash.merge": "^4.6.2", + "minimatch": "^3.1.2", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } + } + }, + "node_modules/eslint-plugin-react-hooks": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-7.0.0.tgz", + "integrity": "sha512-fNXaOwvKwq2+pXiRpXc825Vd63+KM4DLL40Rtlycb8m7fYpp6efrTp1sa6ZbP/Ap58K2bEKFXRmhURE+CJAQWw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.24.4", + "@babel/parser": "^7.24.4", + "hermes-parser": "^0.25.1", + "zod": "^3.22.4 || ^4.0.0", + "zod-validation-error": "^3.0.3 || ^4.0.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0" + } + }, + "node_modules/eslint-plugin-react-refresh": { + "version": "0.4.20", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-refresh/-/eslint-plugin-react-refresh-0.4.20.tgz", + "integrity": "sha512-XpbHQ2q5gUF8BGOX4dHe+71qoirYMhApEPZ7sfhF/dNnOF1UXnCMGZf79SFTBO7Bz5YEIT4TMieSlJBWhP9WBA==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "eslint": ">=8.40" + } + }, + "node_modules/eslint-scope": { + "version": "8.4.0", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz", + "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/espree": { + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", + "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.15.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^4.2.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esquery": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", + "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/events": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", + "integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==", + "license": "MIT", + "engines": { + "node": ">=0.8.x" + } + }, + "node_modules/expect-type": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-glob": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", + "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.8" + }, + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/fast-glob/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fastq": { + "version": "1.20.1", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", + "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==", + "dev": true, + "license": "ISC", + "dependencies": { + "reusify": "^1.0.4" + } + }, + "node_modules/file-entry-cache": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", + "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^4.0.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat-cache": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", + "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.4" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/flatted": { + "version": "3.4.4", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.4.tgz", + "integrity": "sha512-5+ybhBZANEJxaH3X5evAFatUxLfEHSr7n6kYJ+1Qd0mUqr4eu9gIf6GDbWHf8RJijHrjjO8G+la14SlL2SeS1Q==", + "dev": true, + "license": "ISC" + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/globals": { + "version": "16.3.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-16.3.0.tgz", + "integrity": "sha512-bqWEnJ1Nt3neqx2q5SFfGS8r/ahumIakg3HcwtNlrVlwXIeNumWn/c7Pn/wKzGhf6SaW6H6uWXLqC30STCMchQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/graphemer": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/graphemer/-/graphemer-1.4.0.tgz", + "integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==", + "dev": true, + "license": "MIT" + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "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==", + "dev": true, + "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==", + "dev": true, + "license": "MIT", + "dependencies": { + "hermes-estree": "0.25.1" + } + }, + "node_modules/html-encoding-sniffer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-4.0.0.tgz", + "integrity": "sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-encoding": "^3.1.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/http-proxy-agent": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/import-fresh": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", + "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/indent-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", + "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/jose": { + "version": "6.2.10", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.10.tgz", + "integrity": "sha512-iiW7J9qRFlGxvCOIBDBDxFePQSn7ZMAnrYGhrrOo6siO/MIqwfyilLR27pkfDgUk+raLuzADS8A3S/KLBisc0g==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/js-yaml": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.1.tgz", + "integrity": "sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/jsdom": { + "version": "26.1.0", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-26.1.0.tgz", + "integrity": "sha512-Cvc9WUhxSMEo4McES3P7oK3QaXldCfNWp7pl2NNeiIFlCoLr3kfq9kb1fxftiwk1FLV7CvpvDfonxtzUDeSOPg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cssstyle": "^4.2.1", + "data-urls": "^5.0.0", + "decimal.js": "^10.5.0", + "html-encoding-sniffer": "^4.0.0", + "http-proxy-agent": "^7.0.2", + "https-proxy-agent": "^7.0.6", + "is-potential-custom-element-name": "^1.0.1", + "nwsapi": "^2.2.16", + "parse5": "^7.2.1", + "rrweb-cssom": "^0.8.0", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^5.1.1", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^7.0.0", + "whatwg-encoding": "^3.1.1", + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^14.1.1", + "ws": "^8.18.0", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "canvas": "^3.0.0" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/lightningcss": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz", + "integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.33.0", + "lightningcss-darwin-arm64": "1.33.0", + "lightningcss-darwin-x64": "1.33.0", + "lightningcss-freebsd-x64": "1.33.0", + "lightningcss-linux-arm-gnueabihf": "1.33.0", + "lightningcss-linux-arm64-gnu": "1.33.0", + "lightningcss-linux-arm64-musl": "1.33.0", + "lightningcss-linux-x64-gnu": "1.33.0", + "lightningcss-linux-x64-musl": "1.33.0", + "lightningcss-win32-arm64-msvc": "1.33.0", + "lightningcss-win32-x64-msvc": "1.33.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz", + "integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==", + "cpu": [ + "arm64" + ], + "dev": true, + "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.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz", + "integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz", + "integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz", + "integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz", + "integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz", + "integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz", + "integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz", + "integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz", + "integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz", + "integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz", + "integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/livekit-client": { + "version": "2.22.0", + "resolved": "https://registry.npmjs.org/livekit-client/-/livekit-client-2.22.0.tgz", + "integrity": "sha512-GLtYQfRh/RsvXaOX1x609bFZ17yyKmKWDqu9JmkMKn9vIFLi2GsapRv9gT8OJO/1R2dsitrkbGmloyUfxWQsaA==", + "license": "Apache-2.0", + "dependencies": { + "@livekit/mutex": "1.1.1", + "@livekit/protocol": "1.50.4", + "events": "^3.3.0", + "jose": "^6.1.0", + "loglevel": "^1.9.2", + "sdp-transform": "^2.15.0", + "tslib": "2.8.1", + "typed-emitter": "^2.1.0", + "webrtc-adapter": "9.0.6" + }, + "peerDependencies": { + "@types/dom-mediacapture-record": "^1" + } + }, + "node_modules/livekit-client/node_modules/loglevel": { + "version": "1.9.2", + "resolved": "https://registry.npmjs.org/loglevel/-/loglevel-1.9.2.tgz", + "integrity": "sha512-HgMmCqIJSAKqo68l0rS2AanEWfkxaZ5wNiEFb5ggm08lDs9Xl2KxBlX3PTcaD2chBM1gXAYf491/M2Rv8Jwayg==", + "license": "MIT", + "engines": { + "node": ">= 0.6.0" + }, + "funding": { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/loglevel" + } + }, + "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==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lodash.debounce": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/lodash.debounce/-/lodash.debounce-4.0.8.tgz", + "integrity": "sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==", + "license": "MIT" + }, + "node_modules/lodash.merge": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", + "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/loglevel": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/loglevel/-/loglevel-1.9.1.tgz", + "integrity": "sha512-hP3I3kCrDIMuRwAwHltphhDM1r8i55H33GgqjXbrisuJhF4kRhW1dNuxsRklp4bXl8DSdLaNLuiL4A/LWRfxvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6.0" + }, + "funding": { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/loglevel" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/lz-string": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", + "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", + "dev": true, + "license": "MIT", + "peer": true, + "bin": { + "lz-string": "bin/bin.js" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/merge2": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/min-indent": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz", + "integrity": "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.18", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz", + "integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, + "node_modules/node-releases": { + "version": "2.0.53", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.53.tgz", + "integrity": "sha512-D9UOmYG3UH1V+ENW56t5QXBwJw1YEY18ruVeus89Rw+SyIgjPkCO84bRzO3uNIYosJbNwiabWVn48o3uJLjxFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/nwsapi": { + "version": "2.2.24", + "resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.24.tgz", + "integrity": "sha512-7YRhZ3jS45LwmSCT4b2sVFHt/WuovaktDU07QrtOBY2PXskss5a9jfmR9jptyumwXST+rFjrmppMY1KT/yn35A==", + "dev": true, + "license": "MIT" + }, + "node_modules/obug": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.4.tgz", + "integrity": "sha512-4a+OsYv9UktOJKE+l1A4OufDgdRF9PifWj+tJnHURo/P+WOxpG4GzUFL9qCalmWauao6ogiG+QvnCovwPoyAWA==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT", + "engines": { + "node": ">=12.20.0" + } + }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "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==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parent-module": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "callsites": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/parse5": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz", + "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^6.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.26", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz", + "integrity": "sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.17", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/prettier": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.6.2.tgz", + "integrity": "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==", + "dev": true, + "license": "MIT", + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, + "node_modules/pretty-format": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", + "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "ansi-regex": "^5.0.1", + "ansi-styles": "^5.0.0", + "react-is": "^17.0.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/pretty-format/node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/queue-microtask": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/react": { + "version": "19.2.4", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz", + "integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.2.4", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz", + "integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.27.0" + }, + "peerDependencies": { + "react": "^19.2.4" + } + }, + "node_modules/react-is": { + "version": "17.0.2", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", + "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/redent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz", + "integrity": "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "indent-string": "^4.0.0", + "strip-indent": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/resolve-from": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/reusify": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", + "dev": true, + "license": "MIT", + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/rolldown": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.5.tgz", + "integrity": "sha512-VD2IE5PUG4Oj8zz2VGykiYd5wbnjdIiSsNQb8Qu5B+noEp+A78mu2iVvpp27g8es14Tk9rofNs5Tku9iQCS4fA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.146.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm-eabi": "1.2.5", + "@rolldown/binding-android-arm64": "1.2.5", + "@rolldown/binding-darwin-arm64": "1.2.5", + "@rolldown/binding-darwin-x64": "1.2.5", + "@rolldown/binding-freebsd-x64": "1.2.5", + "@rolldown/binding-linux-arm-gnueabihf": "1.2.5", + "@rolldown/binding-linux-arm64-gnu": "1.2.5", + "@rolldown/binding-linux-arm64-musl": "1.2.5", + "@rolldown/binding-linux-ppc64-gnu": "1.2.5", + "@rolldown/binding-linux-s390x-gnu": "1.2.5", + "@rolldown/binding-linux-x64-gnu": "1.2.5", + "@rolldown/binding-linux-x64-musl": "1.2.5", + "@rolldown/binding-openharmony-arm64": "1.2.5", + "@rolldown/binding-win32-arm64-msvc": "1.2.5", + "@rolldown/binding-win32-x64-msvc": "1.2.5" + } + }, + "node_modules/rrweb-cssom": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.8.0.tgz", + "integrity": "sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==", + "dev": true, + "license": "MIT" + }, + "node_modules/run-parallel": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", + "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "queue-microtask": "^1.2.2" + } + }, + "node_modules/rxjs": { + "version": "7.8.2", + "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz", + "integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.1.0" + } + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "dev": true, + "license": "MIT" + }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dev": true, + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, + "node_modules/scheduler": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "license": "MIT" + }, + "node_modules/sdp": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/sdp/-/sdp-3.2.2.tgz", + "integrity": "sha512-xZocWwfyp4hkbN4hLWxMjmv2Q8aNa9MhmOZ7L9aCZPT+dZsgRr6wZRrSYE3HTdyk/2pZKPSgqI7ns7Een1xMSA==", + "license": "MIT" + }, + "node_modules/sdp-transform": { + "version": "2.15.0", + "resolved": "https://registry.npmjs.org/sdp-transform/-/sdp-transform-2.15.0.tgz", + "integrity": "sha512-KrOH82c/W+GYQ0LHqtr3caRpM3ITglq3ljGUIb8LTki7ByacJZ9z+piSGiwZDsRyhQbYBOBJgr2k6X4BZXi3Kw==", + "license": "MIT", + "bin": { + "sdp-verify": "checker.js" + } + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.2.0.tgz", + "integrity": "sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==", + "dev": true, + "license": "MIT" + }, + "node_modules/strip-indent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz", + "integrity": "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "min-indent": "^1.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-json-comments": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.3.0.tgz", + "integrity": "sha512-QKAl9m8gWWGHV8jZcPeym6j+XULi6tOf1mT83WYJ4Lk2ytW/uwAWkrP0uFsdoYMdueVJ0qs26wZ+23xeB4ibNQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyglobby/node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/tinyglobby/node_modules/picomatch": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/tinyrainbow": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.1.tgz", + "integrity": "sha512-yau8yJdTt989Mm0Bd/236QnzEiPf2xLLTqUZRUJOo/3CB078LSwzei343DgtJVmfJKJE3TMINY1u42SQsP6mXw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tldts": { + "version": "6.1.86", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-6.1.86.tgz", + "integrity": "sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^6.1.86" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "6.1.86", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-6.1.86.tgz", + "integrity": "sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA==", + "dev": true, + "license": "MIT" + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/tough-cookie": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-5.1.2.tgz", + "integrity": "sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^6.1.32" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/tr46": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-5.1.1.tgz", + "integrity": "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/ts-api-utils": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", + "integrity": "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.12" + }, + "peerDependencies": { + "typescript": ">=4.8.4" + } + }, + "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-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/typed-emitter": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/typed-emitter/-/typed-emitter-2.1.0.tgz", + "integrity": "sha512-g/KzbYKbH5C2vPkaXGu8DJlHrGKHLsM25Zg9WuC9pMGfuvT+X25tZQWo5fK1BjBm8+UrVE9LDCvaY0CQk+fXDA==", + "license": "MIT", + "optionalDependencies": { + "rxjs": "*" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/typescript-eslint": { + "version": "8.41.0", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.41.0.tgz", + "integrity": "sha512-n66rzs5OBXW3SFSnZHr2T685q1i4ODm2nulFJhMZBotaTavsS8TrI3d7bDlRSs9yWo7HmyWrN9qDu14Qv7Y0Dw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/eslint-plugin": "8.41.0", + "@typescript-eslint/parser": "8.41.0", + "@typescript-eslint/typescript-estree": "8.41.0", + "@typescript-eslint/utils": "8.41.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "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==", + "dev": true, + "license": "MIT" + }, + "node_modules/update-browserslist-db": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.3.1.tgz", + "integrity": "sha512-ZZ61DsRsOnakl74HAmp3oSN4aXUmEWXf+i/yv0h7tIBfICc3VdrFErQKUUKPgu3AMsTUMbcongALEN4l6GSUrQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/usehooks-ts": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/usehooks-ts/-/usehooks-ts-3.1.1.tgz", + "integrity": "sha512-I4diPp9Cq6ieSUH2wu+fDAVQO43xwtulo+fKEidHUwZPnYImbtkTjzIJYcDcJqxgmX31GVqNFURodvcgHcW0pA==", + "license": "MIT", + "dependencies": { + "lodash.debounce": "^4.0.8" + }, + "engines": { + "node": ">=16.15.0" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17 || ^18 || ^19 || ^19.0.0-rc" + } + }, + "node_modules/vite": { + "version": "8.2.2", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.2.2.tgz", + "integrity": "sha512-cFKLV/PRgAUlIRm5WjMjJ86jrftzpqcgH+Us+DS8mI3CDNiH30Whrz8uHL3+MOLPAgqbMBAqWdAHAphOAM+z/Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "lightningcss": "^1.33.0", + "picomatch": "^4.0.5", + "postcss": "^8.5.26", + "rolldown": "~1.2.4", + "tinyglobby": "^0.2.17" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.4.0 || ^0.5.0", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vite/node_modules/picomatch": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/vitest": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.11.tgz", + "integrity": "sha512-fhACrNXUidIbGSBr5FlbuBkO7VWC1ZyLl0DO4CU2DrQoAPxX84Ysxs+HeGQpii5lZWV1Q4gBZTTu49mF+A6Edw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.1.11", + "@vitest/mocker": "4.1.11", + "@vitest/pretty-format": "4.1.11", + "@vitest/runner": "4.1.11", + "@vitest/snapshot": "4.1.11", + "@vitest/spy": "4.1.11", + "@vitest/utils": "4.1.11", + "es-module-lexer": "^2.0.0", + "expect-type": "^1.3.0", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^4.0.0-rc.1", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.1.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.1.11", + "@vitest/browser-preview": "4.1.11", + "@vitest/browser-webdriverio": "4.1.11", + "@vitest/coverage-istanbul": "4.1.11", + "@vitest/coverage-v8": "4.1.11", + "@vitest/ui": "4.1.11", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, + "node_modules/vitest/node_modules/picomatch": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/w3c-xmlserializer": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/webidl-conversions": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz", + "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/webrtc-adapter": { + "version": "9.0.6", + "resolved": "https://registry.npmjs.org/webrtc-adapter/-/webrtc-adapter-9.0.6.tgz", + "integrity": "sha512-CHbl2ZQbxx164IgWRgzJno4hWtM4tFbRam1QfI3Yxhs3w/DvqluVxVWeXs3oL5/fbGkSNLKo0Ty5MgUWceNhog==", + "license": "BSD-3-Clause", + "dependencies": { + "sdp": "^3.2.0" + }, + "engines": { + "node": ">=6.0.0", + "npm": ">=3.10.0" + } + }, + "node_modules/whatwg-encoding": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz", + "integrity": "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==", + "deprecated": "Use @exodus/bytes instead for a more spec-conformant and faster implementation", + "dev": true, + "license": "MIT", + "dependencies": { + "iconv-lite": "0.6.3" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/whatwg-mimetype": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz", + "integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/whatwg-url": { + "version": "14.2.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-14.2.0.tgz", + "integrity": "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tr46": "^5.1.0", + "webidl-conversions": "^7.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/ws": { + "version": "8.21.3", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.3.tgz", + "integrity": "sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true, + "license": "MIT" + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/zod": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", + "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/zod-validation-error": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/zod-validation-error/-/zod-validation-error-4.0.2.tgz", + "integrity": "sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "zod": "^3.25.0 || ^4.0.0" + } + } + } +} diff --git a/muse_glimmer/macos/apps/web/package.json b/muse_glimmer/macos/apps/web/package.json new file mode 100644 index 0000000000..6303ae896a --- /dev/null +++ b/muse_glimmer/macos/apps/web/package.json @@ -0,0 +1,46 @@ +{ + "name": "@muse-glimmer/web", + "version": "0.1.0", + "private": true, + "type": "module", + "engines": { + "node": ">=22.12.0" + }, + "scripts": { + "dev": "vite --host 127.0.0.1 --port 5173", + "build": "tsc -b && vite build", + "serve": "node ./server.mjs", + "preview": "npm run serve", + "typecheck": "tsc -b", + "test": "vitest run && node --test server.test.mjs", + "test:watch": "vitest", + "lint": "eslint . --max-warnings=0", + "format:check": "prettier --check ." + }, + "dependencies": { + "@fontsource/inter": "5.3.0", + "@livekit/components-react": "2.9.24", + "livekit-client": "2.22.0", + "react": "19.2.4", + "react-dom": "19.2.4" + }, + "devDependencies": { + "@eslint/js": "9.34.0", + "@testing-library/jest-dom": "6.8.0", + "@testing-library/react": "16.3.0", + "@types/node": "24.3.0", + "@types/react": "19.1.12", + "@types/react-dom": "19.1.9", + "@vitejs/plugin-react": "6.0.2", + "eslint": "9.34.0", + "eslint-plugin-react-hooks": "7.0.0", + "eslint-plugin-react-refresh": "0.4.20", + "globals": "16.3.0", + "jsdom": "26.1.0", + "prettier": "3.6.2", + "typescript": "5.9.3", + "typescript-eslint": "8.41.0", + "vite": "8.2.2", + "vitest": "4.1.11" + } +} diff --git a/muse_glimmer/macos/apps/web/server.mjs b/muse_glimmer/macos/apps/web/server.mjs new file mode 100644 index 0000000000..9a8c8c5d4e --- /dev/null +++ b/muse_glimmer/macos/apps/web/server.mjs @@ -0,0 +1,154 @@ +import { createReadStream, existsSync, statSync } from "node:fs"; +import { createServer } from "node:http"; +import { extname, join, normalize, resolve, sep } from "node:path"; +import { fileURLToPath } from "node:url"; + +const HOST = "127.0.0.1"; +const DEFAULT_PORT = 5173; +const APP_DIRECTORY = resolve( + fileURLToPath(new URL("./dist", import.meta.url)), +); +const CSP = [ + "default-src 'self'", + "base-uri 'none'", + "connect-src 'self' http://127.0.0.1:8787 ws://127.0.0.1:7880", + "font-src 'self'", + "form-action 'none'", + "frame-ancestors 'none'", + "img-src 'self' data:", + "media-src 'self' blob:", + "object-src 'none'", + "script-src 'self'", + "style-src 'self'", + "worker-src 'self' blob:", +].join("; "); + +const MIME_TYPES = new Map([ + [".css", "text/css; charset=utf-8"], + [".html", "text/html; charset=utf-8"], + [".js", "text/javascript; charset=utf-8"], + [".json", "application/json; charset=utf-8"], + [".svg", "image/svg+xml"], + [".woff2", "font/woff2"], +]); + +function applySecurityHeaders(response) { + response.setHeader("Cache-Control", "no-store"); + response.setHeader("Content-Security-Policy", CSP); + response.setHeader("Cross-Origin-Opener-Policy", "same-origin"); + response.setHeader( + "Permissions-Policy", + "camera=(), geolocation=(), microphone=(self)", + ); + response.setHeader("Referrer-Policy", "no-referrer"); + response.setHeader("X-Content-Type-Options", "nosniff"); + response.setHeader("X-Frame-Options", "DENY"); +} + +function resolveRequestPath(requestUrl) { + const pathname = decodeURIComponent( + new URL(requestUrl ?? "/", `http://${HOST}`).pathname, + ); + const normalizedPath = normalize(pathname).replace(/^[/\\]+/, ""); + const requestedPath = resolve(join(APP_DIRECTORY, normalizedPath)); + if ( + requestedPath !== APP_DIRECTORY && + !requestedPath.startsWith(`${APP_DIRECTORY}${sep}`) + ) { + return undefined; + } + if (existsSync(requestedPath) && statSync(requestedPath).isFile()) { + return requestedPath; + } + return join(APP_DIRECTORY, "index.html"); +} + +export function createAppServer() { + if (!existsSync(join(APP_DIRECTORY, "index.html"))) { + throw new Error("Production assets are missing. Run npm run build first."); + } + + return createServer((request, response) => { + applySecurityHeaders(response); + if (request.method !== "GET" && request.method !== "HEAD") { + response.writeHead(405, { Allow: "GET, HEAD" }); + response.end("Method Not Allowed"); + return; + } + + let filePath; + try { + filePath = resolveRequestPath(request.url); + } catch { + response.writeHead(400); + response.end("Bad Request"); + return; + } + if (!filePath) { + response.writeHead(403); + response.end("Forbidden"); + return; + } + + response.setHeader( + "Content-Type", + MIME_TYPES.get(extname(filePath)) ?? "application/octet-stream", + ); + response.writeHead(200); + if (request.method === "HEAD") { + response.end(); + return; + } + createReadStream(filePath).pipe(response); + }); +} + +export function parseServeOptions(args, environment = process.env) { + let host = HOST; + let port = environment.PORT ?? String(DEFAULT_PORT); + const provided = new Set(); + + for (let index = 0; index < args.length; index += 1) { + const argument = args[index]; + const [flag, inlineValue] = argument.split("=", 2); + if (flag !== "--host" && flag !== "--port") { + throw new Error(`Unknown serve argument: ${flag}`); + } + if (provided.has(flag)) { + throw new Error(`Duplicate serve argument: ${flag}`); + } + + const value = inlineValue ?? args[++index]; + if (!value || value.startsWith("--")) { + throw new Error(`${flag} requires a value.`); + } + provided.add(flag); + if (flag === "--host") { + host = value; + } else { + port = value; + } + } + + if (host !== HOST) { + throw new Error(`Host must be ${HOST}.`); + } + if (!/^\d+$/.test(port)) { + throw new Error("Port must be an integer between 1 and 65535."); + } + const portValue = Number.parseInt(port, 10); + if (portValue !== DEFAULT_PORT) { + throw new Error(`Port must be ${DEFAULT_PORT}.`); + } + + return { host, port: portValue }; +} + +const entryPoint = process.argv[1] ? resolve(process.argv[1]) : undefined; +if (entryPoint === fileURLToPath(import.meta.url)) { + const { host, port } = parseServeOptions(process.argv.slice(2)); + const server = createAppServer(); + server.listen(port, host, () => { + console.log(`Muse Glimmer web app listening at http://${host}:${port}`); + }); +} diff --git a/muse_glimmer/macos/apps/web/server.test.mjs b/muse_glimmer/macos/apps/web/server.test.mjs new file mode 100644 index 0000000000..402ef314bc --- /dev/null +++ b/muse_glimmer/macos/apps/web/server.test.mjs @@ -0,0 +1,62 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { parseServeOptions } from "./server.mjs"; + +test("defaults to the approved loopback endpoint", () => { + assert.deepEqual(parseServeOptions([], {}), { + host: "127.0.0.1", + port: 5173, + }); +}); + +test("accepts the supervisor serve contract", () => { + assert.deepEqual( + parseServeOptions(["--host", "127.0.0.1", "--port", "5173"], {}), + { + host: "127.0.0.1", + port: 5173, + }, + ); +}); + +test("accepts equals-style options", () => { + assert.deepEqual(parseServeOptions(["--host=127.0.0.1", "--port=5173"], {}), { + host: "127.0.0.1", + port: 5173, + }); +}); + +test("rejects non-loopback host overrides", () => { + assert.throws( + () => parseServeOptions(["--host", "0.0.0.0"], {}), + /Host must be 127\.0\.0\.1/, + ); + assert.throws( + () => parseServeOptions(["--host", "localhost"], {}), + /Host must be 127\.0\.0\.1/, + ); +}); + +test("rejects malformed ports, unknown flags, and duplicates", () => { + assert.throws( + () => parseServeOptions(["--port", "5173oops"], {}), + /Port must be an integer/, + ); + assert.throws( + () => parseServeOptions(["--port", "0"], {}), + /Port must be 5173/, + ); + assert.throws( + () => parseServeOptions(["--port", "4173"], {}), + /Port must be 5173/, + ); + assert.throws( + () => parseServeOptions(["--public"], {}), + /Unknown serve argument/, + ); + assert.throws( + () => parseServeOptions(["--host", "127.0.0.1", "--host", "127.0.0.1"], {}), + /Duplicate serve argument/, + ); +}); diff --git a/muse_glimmer/macos/apps/web/src/App.test.tsx b/muse_glimmer/macos/apps/web/src/App.test.tsx new file mode 100644 index 0000000000..62d8609361 --- /dev/null +++ b/muse_glimmer/macos/apps/web/src/App.test.tsx @@ -0,0 +1,91 @@ +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import type { ReactNode } from "react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + requestConnection: vi.fn(), + disconnectRoom: undefined as (() => void) | undefined, +})); + +vi.mock("./lib/tokenClient", () => ({ + requestConnection: mocks.requestConnection, +})); +vi.mock("./avatar/MuseAvatar", () => ({ MuseAvatar: () =>
Muse
})); +vi.mock("./components/RuntimeBadge", () => ({ + RuntimeBadge: () =>
ExecuTorch
, +})); +vi.mock("@livekit/components-react", () => ({ + LiveKitRoom: ({ + children, + onDisconnected, + }: { + children: ReactNode; + onDisconnected: () => void; + }) => { + mocks.disconnectRoom = onDisconnected; + return
{children}
; + }, +})); +vi.mock("./components/VoiceSession", () => ({ + VoiceSession: ({ + onEnding, + onEnded, + }: { + onEnding: () => void; + onEnded: () => void; + }) => ( +
+ + +
+ ), +})); + +import App from "./App"; + +beforeEach(() => { + mocks.disconnectRoom = undefined; + mocks.requestConnection.mockReset(); + mocks.requestConnection.mockResolvedValue({ + serverUrl: "ws://127.0.0.1:7880", + participantToken: "token", + roomName: "room", + participantIdentity: "participant", + }); +}); + +async function start() { + render(); + fireEvent.click(screen.getByRole("button", { name: /Start conversation/ })); + await screen.findByRole("button", { name: "Mark ending" }); +} + +describe("conversation disconnect handling", () => { + it("shows an error after an unexpected disconnect", async () => { + await start(); + + mocks.disconnectRoom?.(); + + expect(await screen.findByRole("alert")).toHaveTextContent( + "The local voice connection failed.", + ); + }); + + it("returns to idle after an intentional disconnect", async () => { + await start(); + fireEvent.click(screen.getByRole("button", { name: "Mark ending" })); + + mocks.disconnectRoom?.(); + + await waitFor(() => + expect( + screen.getByRole("button", { name: /Start conversation/ }), + ).toBeEnabled(), + ); + expect(screen.queryByRole("alert")).not.toBeInTheDocument(); + }); +}); diff --git a/muse_glimmer/macos/apps/web/src/App.tsx b/muse_glimmer/macos/apps/web/src/App.tsx new file mode 100644 index 0000000000..0ee9fe5665 --- /dev/null +++ b/muse_glimmer/macos/apps/web/src/App.tsx @@ -0,0 +1,155 @@ +import { LiveKitRoom } from "@livekit/components-react"; +import { useRef, useState } from "react"; + +import { MuseAvatar } from "./avatar/MuseAvatar"; +import { RuntimeBadge } from "./components/RuntimeBadge"; +import { VoiceSession } from "./components/VoiceSession"; +import { usePrefersReducedMotion } from "./hooks/usePrefersReducedMotion"; +import { + getAgentPresentation, + type SessionPhase, +} from "./lib/agentPresentation"; +import { requestConnection, type ConnectionDetails } from "./lib/tokenClient"; + +export default function App() { + const [phase, setPhase] = useState("idle"); + const [connection, setConnection] = useState(); + const [error, setError] = useState(); + const intentionalDisconnect = useRef(false); + const reducedMotion = usePrefersReducedMotion(); + + const startConversation = async () => { + intentionalDisconnect.current = false; + setPhase("requesting"); + setError(undefined); + try { + const details = await requestConnection(); + setConnection(details); + setPhase("active"); + } catch (caught) { + setConnection(undefined); + setPhase("error"); + setError( + caught instanceof Error + ? caught.message + : "The conversation could not start.", + ); + } + }; + + const finishConversation = () => { + setConnection(undefined); + setError(undefined); + setPhase("idle"); + }; + + const failConversation = () => { + setConnection(undefined); + setError("The local voice connection failed."); + setPhase("error"); + }; + + const handleDisconnected = () => { + if (intentionalDisconnect.current) { + finishConversation(); + } else { + failConversation(); + } + }; + + if (connection && phase === "active") { + return ( + { + setConnection(undefined); + setError( + "Chrome could not use the microphone. Check its site permission and try again.", + ); + setPhase("error"); + }} + data-lk-theme="default" + > + { + intentionalDisconnect.current = true; + }} + onEnded={finishConversation} + /> + + ); + } + + const presentation = getAgentPresentation(phase, false, false); + const isRequesting = phase === "requesting"; + + return ( +
+
+
+ +

Local Voice Agent

+

Talk with Muse Glimmer

+
+
+
+
+
+ {error ? ( +

+ {error} +

+ ) : ( +

+ Your microphone starts only after you choose to begin. +

+ )} + +
+
+ ); +} + +function ArrowIcon() { + return ( + + ); +} diff --git a/muse_glimmer/macos/apps/web/src/app.css b/muse_glimmer/macos/apps/web/src/app.css new file mode 100644 index 0000000000..20d55c6e9c --- /dev/null +++ b/muse_glimmer/macos/apps/web/src/app.css @@ -0,0 +1,836 @@ +:root { + color: #172b4d; + background: #f5f9ff; + font-family: "Inter", sans-serif; + font-synthesis: none; + text-rendering: optimizeLegibility; + --ink: #172b4d; + --slate: #24385b; + --meta-blue: #0668e1; + --meta-blue-deep: #0050b3; + --meta-blue-soft: #8cc8ff; + --paper: #f7fbff; + --paper-rich: #e7f3ff; + --error: #9e332f; +} + +* { + box-sizing: border-box; +} + +html, +body, +#root { + min-height: 100%; + margin: 0; +} + +body { + min-width: 320px; + min-height: 100dvh; + overflow-x: hidden; +} + +button { + font: inherit; +} + +button:focus-visible { + outline: 3px solid var(--meta-blue); + outline-offset: 4px; +} + +.voice-shell { + position: relative; + display: grid; + grid-template-rows: auto minmax(0, 1fr) auto; + min-height: 100dvh; + overflow: hidden; + color: var(--ink); + background: + linear-gradient(rgba(6, 104, 225, 0.055) 1px, transparent 1px), + linear-gradient(90deg, rgba(6, 104, 225, 0.055) 1px, transparent 1px), + linear-gradient(145deg, #fbfdff 0%, #eef6ff 55%, #dceeff 100%); + background-size: + 48px 48px, + 48px 48px, + auto; + isolation: isolate; +} + +.voice-shell::before { + position: absolute; + inset: 0; + z-index: -1; + background: linear-gradient( + 115deg, + rgba(255, 255, 255, 0.65), + transparent 38% 72%, + rgba(6, 104, 225, 0.08) + ); + content: ""; + pointer-events: none; +} + +.app-header { + z-index: 3; + display: grid; + grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr); + align-items: flex-start; + padding: max(28px, env(safe-area-inset-top)) clamp(24px, 5vw, 72px) 0; +} + +.header-title { + display: grid; + grid-column: 2; + justify-items: center; + text-align: center; +} + +.header-title .runtime-badge { + margin-bottom: 20px; +} + +.eyebrow { + margin: 0 0 8px; + color: var(--meta-blue); + font-size: 0.7rem; + font-weight: 700; + letter-spacing: 0; + text-transform: uppercase; +} + +.app-header h1 { + margin: 0; + font-size: 2rem; + font-weight: 600; + letter-spacing: 0; + line-height: 1.1; +} + +.runtime-badge { + display: inline-flex; + align-items: center; + gap: 11px; + min-height: 46px; + padding: 6px 16px 6px 8px; + color: #53627a; + border: 1px solid rgba(6, 104, 225, 0.18); + border-radius: 999px; + background: rgba(247, 251, 255, 0.86); + box-shadow: 0 10px 30px rgba(6, 75, 150, 0.08); + font-size: 0.84rem; + white-space: nowrap; + backdrop-filter: blur(12px); +} + +.runtime-chip { + position: relative; + display: grid; + width: 32px; + height: 32px; + place-items: center; + color: #fff; + border: 1px solid #032b62; + border-radius: 8px; + background: #061a35; + box-shadow: + inset 0 0 0 3px #fff, + inset 0 0 0 4px #82bbf6; + font-size: 0.55rem; + font-weight: 700; +} + +.runtime-chip::before, +.runtime-chip::after { + position: absolute; + inset: 5px -4px; + border-top: 1px solid #061a35; + border-bottom: 1px solid #061a35; + content: ""; +} + +.runtime-chip::after { + inset: -4px 5px; + border: 0; + border-right: 1px solid #061a35; + border-left: 1px solid #061a35; +} + +.runtime-badge strong { + color: var(--ink); + font-size: 0.92rem; + font-weight: 700; +} + +.header-status { + margin: 7px 0 0; + color: #52647d; + font-size: 0.78rem; + font-weight: 500; +} + +.presence { + position: relative; + z-index: 1; + display: grid; + align-content: center; + justify-items: center; + min-height: 0; + padding: 18px 24px; +} + +.presence-halo { + position: absolute; + top: 50%; + left: 50%; + width: min(52vw, 570px); + aspect-ratio: 1; + border: 1px solid rgba(62, 78, 101, 0.1); + border-radius: 50%; + background: repeating-radial-gradient( + circle, + rgba(255, 255, 255, 0.44) 0, + rgba(255, 255, 255, 0.44) 1px, + transparent 1px, + transparent 34px + ); + transform: translate(-50%, -53%); + transition: + scale 700ms ease, + opacity 700ms ease; +} + +.muse-avatar { + position: relative; + width: clamp(260px, 34vw, 480px); + aspect-ratio: 1; + filter: drop-shadow(0 32px 35px rgba(6, 75, 150, 0.13)); + transform: translateZ(0); + transition: + transform 500ms ease, + filter 500ms ease; +} + +.muse-avatar-art { + display: block; + width: 100%; + height: 100%; + overflow: visible; +} + +.muse-orbit { + fill: none; + stroke: rgba(6, 104, 225, 0.16); + stroke-dasharray: 3 9; + stroke-linecap: round; + stroke-width: 1.5; + transform-origin: center; + animation: orbit 32s linear infinite; +} + +.muse-orbit path { + opacity: 0.55; + stroke-dasharray: 1 12; +} + +.muse-character { + transform-origin: 180px 190px; + animation: breathe 4.8s ease-in-out infinite; +} + +.muse-ear { + fill: #0575e6; + stroke: rgba(255, 255, 255, 0.28); + stroke-width: 2; + transform-origin: center; + transition: transform 360ms ease; +} + +.muse-body { + stroke: rgba(255, 255, 255, 0.58); + stroke-width: 2; +} + +.muse-body-glow { + mix-blend-mode: screen; + pointer-events: none; +} + +.muse-face { + transform-origin: 180px 178px; + transition: transform 360ms ease; +} + +.muse-eye { + fill: #fff; + transform-box: fill-box; + transform-origin: center; + transition: transform 280ms ease; + animation: blink 6.4s ease-in-out infinite; +} + +.muse-mouth { + fill: none; + stroke: rgba(255, 255, 255, 0.8); + stroke-linecap: round; + stroke-width: 4; + transition: d 300ms ease; +} + +.muse-voice { + fill: none; + opacity: 0; + stroke: #fff; + stroke-linecap: round; + stroke-width: 5; + transition: opacity 240ms ease; +} + +.muse-voice path { + transform-box: fill-box; + transform-origin: center; +} + +.muse-avatar[data-animation="listening"] .muse-character { + animation: attentive 2.4s ease-in-out infinite; +} + +.muse-avatar[data-animation="listening"] .muse-ear-left { + transform: rotate(-7deg) translate(-2px, -3px); +} + +.muse-avatar[data-animation="listening"] .muse-ear-right { + transform: rotate(7deg) translate(2px, -3px); +} + +.muse-avatar[data-animation="listening"] .muse-eye { + transform: scaleY(1.08); +} + +.muse-avatar[data-animation="thinking"] .muse-character { + animation: ponder 3.2s ease-in-out infinite; +} + +.muse-avatar[data-animation="thinking"] .muse-face { + transform: translate(9px, -7px) rotate(2deg); +} + +.muse-avatar[data-animation="thinking"] .muse-eye-right { + transform: scaleY(0.7); +} + +.muse-avatar[data-animation="working"] .muse-character { + animation: working 1.5s ease-in-out infinite; +} + +.muse-avatar[data-animation="working"] .muse-orbit { + animation-duration: 8s; +} + +.muse-avatar[data-animation="happy"] .muse-character { + animation: speaking 800ms ease-in-out infinite alternate; +} + +.muse-avatar[data-animation="happy"] .muse-eye { + transform: scaleY(0.82); +} + +.muse-avatar[data-animation="happy"] .muse-voice { + opacity: 0.88; +} + +.muse-avatar[data-animation="happy"] .muse-voice path:nth-child(odd) { + animation: waveform 620ms ease-in-out infinite alternate; +} + +.muse-avatar[data-animation="happy"] .muse-voice path:nth-child(even) { + animation: waveform 780ms 120ms ease-in-out infinite alternate-reverse; +} + +.tone-active .presence-halo { + opacity: 0.82; + scale: 1.045; +} + +.tone-speaking .presence-halo { + opacity: 1; + scale: 1.09; +} + +.tone-speaking .muse-avatar { + filter: drop-shadow(0 34px 38px rgba(6, 104, 225, 0.26)); +} + +.tone-error .muse-avatar { + filter: grayscale(0.15) drop-shadow(0 30px 30px rgba(158, 51, 47, 0.12)); +} + +.state-caption { + display: flex; + align-items: center; + min-height: 30px; + margin-top: -10px; + color: #40536f; + font-size: 0.84rem; + font-weight: 600; +} + +.state-mark { + width: 7px; + height: 7px; + margin-right: 9px; + border-radius: 50%; + background: var(--meta-blue-soft); + box-shadow: 0 0 0 5px rgba(6, 104, 225, 0.13); +} + +.tone-speaking .state-mark { + background: var(--meta-blue); + box-shadow: 0 0 0 5px rgba(6, 104, 225, 0.16); +} + +.muted-note { + color: #52647d; + font-weight: 400; +} + +.conversation-dock { + z-index: 3; + display: grid; + justify-items: center; + padding: 0 clamp(20px, 5vw, 72px) + max(28px, calc(env(safe-area-inset-bottom) + 20px)); +} + +.transcript { + display: grid; + align-items: end; + width: min(720px, 100%); + min-height: 84px; + max-height: 152px; + margin-bottom: 18px; + overflow: hidden; + mask-image: linear-gradient(to bottom, transparent 0, black 24%, black 100%); +} + +.transcript-empty { + align-self: center; + margin: 0; + color: #52647d; + font-size: 1.05rem; + font-weight: 500; + text-align: center; +} + +.transcript-list { + display: grid; + gap: 7px; + margin: 0; + padding: 20px 0 0; + list-style: none; +} + +.transcript-line { + display: grid; + grid-template-columns: 48px minmax(0, 1fr); + gap: 12px; + margin: 0; + color: #40536f; + font-size: 0.9rem; + line-height: 1.45; +} + +.transcript-line--agent { + color: var(--ink); +} + +.transcript-speaker { + color: var(--meta-blue); + font-size: 0.67rem; + font-weight: 700; + letter-spacing: 0; + text-align: right; + text-transform: uppercase; +} + +.transcript-line--agent .transcript-speaker { + color: var(--meta-blue-deep); +} + +.transcript-interim { + color: #435672; + font-style: italic; +} + +.controls-wrap { + display: grid; + justify-items: center; +} + +.controls { + display: flex; + gap: 10px; + padding: 8px; + border: 1px solid rgba(6, 104, 225, 0.14); + border-radius: 999px; + background: rgba(247, 251, 255, 0.84); + box-shadow: 0 15px 45px rgba(6, 75, 150, 0.12); + backdrop-filter: blur(18px); +} + +.control-button { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 8px; + min-width: 98px; + min-height: 48px; + padding: 0 18px; + color: var(--ink); + border: 0; + border-radius: 999px; + background: transparent; + cursor: pointer; + transition: + color 180ms ease, + background 180ms ease, + transform 180ms ease; +} + +.control-button:hover:not(:disabled) { + background: rgba(62, 78, 101, 0.08); + transform: translateY(-1px); +} + +.control-button:disabled { + cursor: wait; + opacity: 0.55; +} + +.control-button svg, +.start-button svg { + width: 20px; + fill: none; + stroke: currentColor; + stroke-linecap: round; + stroke-linejoin: round; + stroke-width: 1.8; +} + +.control-button--mic.is-muted { + color: #7a3f3a; + background: rgba(226, 96, 85, 0.12); +} + +.control-button--end { + color: #fff; + background: var(--meta-blue); +} + +.control-button--end:hover:not(:disabled) { + background: var(--meta-blue-deep); +} + +.control-error, +.welcome-error { + margin: 0 0 12px; + color: var(--error); + font-size: 0.78rem; + text-align: center; +} + +.audio-unlock { + min-height: 42px; + margin-bottom: 10px; + padding: 0 18px; + color: var(--ink); + border: 1px solid rgba(62, 78, 101, 0.18); + border-radius: 999px; + background: var(--paper); + cursor: pointer; +} + +.welcome-presence { + align-content: center; + padding-bottom: 8px; +} + +.welcome-presence .muse-avatar { + width: clamp(270px, 36vw, 500px); +} + +.welcome-copy { + width: min(560px, 92vw); + margin-top: -18px; + text-align: center; +} + +.welcome-copy h2 { + display: grid; + gap: 6px; + margin: 0; + font-size: 2.2rem; + font-weight: 600; + letter-spacing: 0; + line-height: 1.1; +} + +.welcome-copy h2 span { + display: block; +} + +.welcome-copy p { + width: min(460px, 100%); + margin: 10px auto 0; + color: #485c77; + font-size: 0.91rem; + line-height: 1.55; +} + +.welcome-actions { + z-index: 3; + display: grid; + justify-items: center; + padding: 12px 24px max(36px, calc(env(safe-area-inset-bottom) + 22px)); +} + +.privacy-note { + margin: 0 0 13px; + color: #52647d; + font-size: 0.72rem; +} + +.start-button { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 10px; + min-height: 58px; + padding: 0 22px 0 26px; + color: #fff; + border: 0; + border-radius: 999px; + background: var(--meta-blue); + box-shadow: 0 18px 38px rgba(6, 104, 225, 0.24); + cursor: pointer; + font-weight: 600; + transition: + background 180ms ease, + transform 180ms ease, + box-shadow 180ms ease; +} + +.start-button:hover:not(:disabled) { + background: var(--meta-blue-deep); + box-shadow: 0 20px 42px rgba(6, 80, 179, 0.3); + transform: translateY(-2px); +} + +.start-button:disabled { + cursor: wait; + opacity: 0.72; +} + +@keyframes breathe { + 0%, + 100% { + transform: translateY(0) scale(1); + } + 50% { + transform: translateY(-4px) scale(1.008); + } +} + +@keyframes blink { + 0%, + 44%, + 48%, + 100% { + transform: scaleY(1); + } + 46% { + transform: scaleY(0.08); + } +} + +@keyframes orbit { + to { + transform: rotate(360deg); + } +} + +@keyframes attentive { + 0%, + 100% { + transform: translateY(0) rotate(-1deg); + } + 50% { + transform: translateY(-7px) rotate(1deg); + } +} + +@keyframes ponder { + 0%, + 100% { + transform: rotate(-2deg) translateY(0); + } + 50% { + transform: rotate(3deg) translateY(-5px); + } +} + +@keyframes working { + 0%, + 100% { + transform: scale(0.985); + } + 50% { + transform: scale(1.018); + } +} + +@keyframes speaking { + from { + transform: translateY(1px) scale(0.995); + } + to { + transform: translateY(-5px) scale(1.015); + } +} + +@keyframes waveform { + from { + transform: scaleY(0.45); + } + to { + transform: scaleY(1.15); + } +} + +@media (max-height: 740px) and (min-width: 681px) { + .muse-avatar, + .welcome-presence .muse-avatar { + width: min(38vh, 340px); + } + + .presence-halo { + width: min(48vh, 430px); + } + + .transcript { + min-height: 65px; + max-height: 95px; + } + + .welcome-copy h2 { + font-size: 1.8rem; + } +} + +@media (max-width: 680px) { + .voice-shell { + background-size: + 36px 36px, + 36px 36px, + auto; + } + + .app-header { + grid-template-columns: minmax(0, 1fr); + justify-items: center; + gap: 14px; + padding-right: 20px; + padding-left: 20px; + } + + .header-title { + grid-column: 1; + } + + .app-header h1 { + max-width: 100%; + font-size: 1.55rem; + line-height: 1.05; + } + + .runtime-badge { + gap: 7px; + min-height: 36px; + padding: 4px 11px 4px 5px; + font-size: 0.68rem; + } + + .runtime-chip { + width: 26px; + height: 26px; + border-radius: 7px; + font-size: 0.48rem; + } + + .runtime-badge strong { + font-size: 0.75rem; + } + + .presence { + padding: 8px 16px; + } + + .presence-halo { + width: min(94vw, 470px); + } + + .muse-avatar, + .welcome-presence .muse-avatar { + width: min(77vw, 380px); + } + + .welcome-copy { + margin-top: -8px; + } + + .welcome-copy h2 { + font-size: 1.7rem; + } + + .welcome-copy p { + font-size: 0.84rem; + } + + .conversation-dock { + padding-right: 16px; + padding-left: 16px; + } + + .transcript { + min-height: 76px; + max-height: 126px; + } + + .transcript-line { + grid-template-columns: 42px minmax(0, 1fr); + gap: 9px; + font-size: 0.82rem; + } + + .privacy-note { + max-width: 260px; + text-align: center; + } +} + +@media (prefers-reduced-motion: reduce) { + *, + *::before, + *::after { + scroll-behavior: auto !important; + transition-duration: 0.01ms !important; + animation-duration: 0.01ms !important; + animation-iteration-count: 1 !important; + } +} + +.muse-avatar[data-reduced-motion="true"] * { + animation: none !important; + transition: none !important; +} diff --git a/muse_glimmer/macos/apps/web/src/avatar/MuseAvatar.test.tsx b/muse_glimmer/macos/apps/web/src/avatar/MuseAvatar.test.tsx new file mode 100644 index 0000000000..f3ceeca405 --- /dev/null +++ b/muse_glimmer/macos/apps/web/src/avatar/MuseAvatar.test.tsx @@ -0,0 +1,34 @@ +import { render, screen } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; + +import { MuseAvatar } from "./MuseAvatar"; + +describe("Muse avatar", () => { + it("describes the current voice state to assistive technology", () => { + render(); + + expect( + screen.getByRole("img", { name: /Muse Glimmer/i }), + ).toHaveAccessibleDescription( + "An abstract blue voice companion, currently listening.", + ); + }); + + it("holds the visual animation at idle when reduced motion is requested", () => { + const { container } = render( + , + ); + + expect(container.firstElementChild).toHaveAttribute( + "data-animation", + "idle", + ); + expect(container.firstElementChild).toHaveAttribute( + "data-reduced-motion", + "true", + ); + expect(screen.getByRole("img")).toHaveAccessibleDescription( + "An abstract blue voice companion, currently speaking.", + ); + }); +}); diff --git a/muse_glimmer/macos/apps/web/src/avatar/MuseAvatar.tsx b/muse_glimmer/macos/apps/web/src/avatar/MuseAvatar.tsx new file mode 100644 index 0000000000..b2720d7c83 --- /dev/null +++ b/muse_glimmer/macos/apps/web/src/avatar/MuseAvatar.tsx @@ -0,0 +1,128 @@ +import { useId } from "react"; + +import type { MuseAnimation } from "../lib/agentPresentation"; + +interface MuseAvatarProps { + animation: MuseAnimation; + reducedMotion: boolean; +} + +const animationLabels: Record = { + idle: "ready", + listening: "listening", + thinking: "thinking", + working: "connecting", + happy: "speaking", +}; + +export function MuseAvatar({ animation, reducedMotion }: MuseAvatarProps) { + const titleId = useId(); + const descriptionId = useId(); + const visibleAnimation = reducedMotion ? "idle" : animation; + + return ( +
+ + Muse Glimmer + + An abstract blue voice companion, currently{" "} + {animationLabels[animation]}. + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ ); +} diff --git a/muse_glimmer/macos/apps/web/src/components/CompactTranscript.test.tsx b/muse_glimmer/macos/apps/web/src/components/CompactTranscript.test.tsx new file mode 100644 index 0000000000..fa7c7ec05f --- /dev/null +++ b/muse_glimmer/macos/apps/web/src/components/CompactTranscript.test.tsx @@ -0,0 +1,51 @@ +import { render, screen } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; + +import type { TranscriptEntry } from "../lib/transcript"; +import { CompactTranscript } from "./CompactTranscript"; + +function entry( + index: number, + speaker: "user" | "agent" = "user", +): TranscriptEntry { + return { + key: `${speaker}-${index}`, + segmentId: String(index), + participantIdentity: speaker, + speaker, + text: `Line ${index}`, + final: index % 2 === 0, + order: index, + }; +} + +describe("compact transcript", () => { + it("gives useful empty guidance for microphone state", () => { + const { rerender } = render( + , + ); + expect(screen.getByText("Say something to begin.")).toBeVisible(); + + rerender(); + expect( + screen.getByText("Unmute when you are ready to speak."), + ).toBeVisible(); + }); + + it("renders only the six newest entries with speaker labels and interim styling", () => { + render( + + entry(index, index % 2 ? "agent" : "user"), + )} + isMuted={false} + />, + ); + + expect(screen.queryByText("Line 1")).not.toBeInTheDocument(); + expect(screen.getByText("Line 2")).toBeVisible(); + expect(screen.getByText("Line 7")).toHaveClass("transcript-interim"); + expect(screen.getAllByText("Muse")).toHaveLength(3); + expect(screen.getAllByText("You")).toHaveLength(3); + }); +}); diff --git a/muse_glimmer/macos/apps/web/src/components/CompactTranscript.tsx b/muse_glimmer/macos/apps/web/src/components/CompactTranscript.tsx new file mode 100644 index 0000000000..6d620d1a5f --- /dev/null +++ b/muse_glimmer/macos/apps/web/src/components/CompactTranscript.tsx @@ -0,0 +1,45 @@ +import type { TranscriptEntry } from "../lib/transcript"; + +interface CompactTranscriptProps { + entries: TranscriptEntry[]; + isMuted: boolean; +} + +export function CompactTranscript({ + entries, + isMuted, +}: CompactTranscriptProps) { + const visibleEntries = entries.slice(-6); + + return ( +
+ {visibleEntries.length === 0 ? ( +

+ {isMuted + ? "Unmute when you are ready to speak." + : "Say something to begin."} +

+ ) : ( +
    + {visibleEntries.map((entry) => ( +
  1. + + {entry.speaker === "user" ? "You" : "Muse"} + + + {entry.text} + +
  2. + ))} +
+ )} +
+ ); +} diff --git a/muse_glimmer/macos/apps/web/src/components/RuntimeBadge.tsx b/muse_glimmer/macos/apps/web/src/components/RuntimeBadge.tsx new file mode 100644 index 0000000000..7c4cfc95a6 --- /dev/null +++ b/muse_glimmer/macos/apps/web/src/components/RuntimeBadge.tsx @@ -0,0 +1,12 @@ +export function RuntimeBadge() { + return ( +
+ + + Running on ExecuTorch + +
+ ); +} diff --git a/muse_glimmer/macos/apps/web/src/components/SessionControls.test.tsx b/muse_glimmer/macos/apps/web/src/components/SessionControls.test.tsx new file mode 100644 index 0000000000..264d45491a --- /dev/null +++ b/muse_glimmer/macos/apps/web/src/components/SessionControls.test.tsx @@ -0,0 +1,76 @@ +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +const disconnect = vi.fn(); +const setMicrophoneEnabled = vi.fn(); +let isMicrophoneEnabled = true; + +vi.mock("@livekit/components-react", () => ({ + useRoomContext: () => ({ disconnect }), + useLocalParticipant: () => ({ + isMicrophoneEnabled, + localParticipant: { setMicrophoneEnabled }, + }), +})); + +import { SessionControls } from "./SessionControls"; + +afterEach(() => { + disconnect.mockReset(); + setMicrophoneEnabled.mockReset(); + isMicrophoneEnabled = true; +}); + +describe("session controls", () => { + it("mutes an active microphone", async () => { + setMicrophoneEnabled.mockResolvedValue(undefined); + render(); + + fireEvent.click(screen.getByRole("button", { name: "Mute" })); + + await waitFor(() => + expect(setMicrophoneEnabled).toHaveBeenCalledWith(false), + ); + expect(disconnect).not.toHaveBeenCalled(); + }); + + it("shows a safe error when microphone control fails", async () => { + setMicrophoneEnabled.mockRejectedValue(new Error("device details")); + render(); + + fireEvent.click(screen.getByRole("button", { name: "Mute" })); + + expect(await screen.findByRole("alert")).toHaveTextContent( + "Microphone access was not available.", + ); + }); + + it("completes local teardown when disconnect rejects", async () => { + const onEnding = vi.fn(); + const onEnded = vi.fn(); + setMicrophoneEnabled.mockResolvedValue(undefined); + disconnect.mockRejectedValue(new Error("transport closed")); + render(); + + fireEvent.click(screen.getByRole("button", { name: "End" })); + + await waitFor(() => expect(onEnded).toHaveBeenCalledOnce()); + expect(onEnding).toHaveBeenCalledOnce(); + expect(disconnect).toHaveBeenCalledOnce(); + }); + + it("disables the microphone, disconnects, and reports a completed end action", async () => { + const onEnding = vi.fn(); + const onEnded = vi.fn(); + setMicrophoneEnabled.mockResolvedValue(undefined); + disconnect.mockResolvedValue(undefined); + render(); + + fireEvent.click(screen.getByRole("button", { name: "End" })); + + await waitFor(() => expect(onEnded).toHaveBeenCalledOnce()); + expect(onEnding).toHaveBeenCalledOnce(); + expect(setMicrophoneEnabled).toHaveBeenCalledWith(false); + expect(disconnect).toHaveBeenCalledOnce(); + }); +}); diff --git a/muse_glimmer/macos/apps/web/src/components/SessionControls.tsx b/muse_glimmer/macos/apps/web/src/components/SessionControls.tsx new file mode 100644 index 0000000000..c8bc11f9a3 --- /dev/null +++ b/muse_glimmer/macos/apps/web/src/components/SessionControls.tsx @@ -0,0 +1,94 @@ +import { useLocalParticipant, useRoomContext } from "@livekit/components-react"; +import { useState } from "react"; + +interface SessionControlsProps { + onEnding: () => void; + onEnded: () => void; +} + +export function SessionControls({ onEnding, onEnded }: SessionControlsProps) { + const room = useRoomContext(); + const { isMicrophoneEnabled, localParticipant } = useLocalParticipant(); + const [pendingAction, setPendingAction] = useState<"microphone" | "end">(); + const [controlError, setControlError] = useState(); + + const toggleMicrophone = async () => { + setPendingAction("microphone"); + setControlError(undefined); + try { + await localParticipant.setMicrophoneEnabled(!isMicrophoneEnabled); + } catch { + setControlError("Microphone access was not available."); + } finally { + setPendingAction(undefined); + } + }; + + const endConversation = async () => { + setPendingAction("end"); + onEnding(); + try { + await localParticipant.setMicrophoneEnabled(false); + } catch { + // Disconnect even when the browser has already removed the track. + } + try { + await room.disconnect(); + } catch { + // Local teardown still completes when the transport has already failed. + } finally { + onEnded(); + } + }; + + return ( +
+ {controlError ? ( +

+ {controlError} +

+ ) : null} +
+ + +
+
+ ); +} + +function MicrophoneIcon({ muted }: { muted: boolean }) { + return ( + + ); +} + +function EndIcon() { + return ( + + ); +} diff --git a/muse_glimmer/macos/apps/web/src/components/VoiceSession.tsx b/muse_glimmer/macos/apps/web/src/components/VoiceSession.tsx new file mode 100644 index 0000000000..48e291aa33 --- /dev/null +++ b/muse_glimmer/macos/apps/web/src/components/VoiceSession.tsx @@ -0,0 +1,84 @@ +import { + RoomAudioRenderer, + StartAudio, + useConnectionState, + useLocalParticipant, +} from "@livekit/components-react"; +import { ConnectionState } from "livekit-client"; + +import { MuseAvatar } from "../avatar/MuseAvatar"; +import { useNamedAgentState } from "../hooks/useNamedAgentState"; +import { usePrefersReducedMotion } from "../hooks/usePrefersReducedMotion"; +import { useTranscriptionSegments } from "../hooks/useTranscriptionSegments"; +import { getAgentPresentation } from "../lib/agentPresentation"; +import { CompactTranscript } from "./CompactTranscript"; +import { RuntimeBadge } from "./RuntimeBadge"; +import { SessionControls } from "./SessionControls"; + +interface VoiceSessionProps { + participantIdentity: string; + onEnding: () => void; + onEnded: () => void; +} + +export function VoiceSession({ + participantIdentity, + onEnding, + onEnded, +}: VoiceSessionProps) { + const connectionState = useConnectionState(); + const { isMicrophoneEnabled } = useLocalParticipant(); + const namedAgent = useNamedAgentState(); + const transcript = useTranscriptionSegments(participantIdentity); + const reducedMotion = usePrefersReducedMotion(); + const presentation = getAgentPresentation( + "active", + connectionState === ConnectionState.Connected, + namedAgent.hasNamedAgent, + namedAgent.agentState, + ); + + return ( +
+
+
+
+
+ + + +
+ +
+ ); +} + +function Header({ status }: { status: string }) { + return ( +
+
+ +

Local Voice Agent

+

Talk with Muse Glimmer

+ +
+
+ ); +} diff --git a/muse_glimmer/macos/apps/web/src/config.ts b/muse_glimmer/macos/apps/web/src/config.ts new file mode 100644 index 0000000000..be06ac849d --- /dev/null +++ b/muse_glimmer/macos/apps/web/src/config.ts @@ -0,0 +1,3 @@ +export const AGENT_NAME = "assistant"; +export const TOKEN_ENDPOINT = "http://127.0.0.1:8787/api/token"; +export const LIVEKIT_SERVER_URL = "ws://127.0.0.1:7880"; diff --git a/muse_glimmer/macos/apps/web/src/hooks/useNamedAgentState.ts b/muse_glimmer/macos/apps/web/src/hooks/useNamedAgentState.ts new file mode 100644 index 0000000000..b71c50bb9a --- /dev/null +++ b/muse_glimmer/macos/apps/web/src/hooks/useNamedAgentState.ts @@ -0,0 +1,24 @@ +import { useVoiceAssistant } from "@livekit/components-react"; + +import { AGENT_NAME } from "../config"; +import { normalizeAgentState, type AgentState } from "../lib/agentPresentation"; + +export interface NamedAgentState { + hasNamedAgent: boolean; + agentState?: AgentState; + agentIdentity?: string; +} + +export function useNamedAgentState(): NamedAgentState { + const assistant = useVoiceAssistant(); + const agentName = assistant.agent?.attributes["lk.agent.name"]; + const hasNamedAgent = agentName === AGENT_NAME; + + return { + hasNamedAgent, + agentState: hasNamedAgent + ? normalizeAgentState(assistant.state) + : undefined, + agentIdentity: hasNamedAgent ? assistant.agent?.identity : undefined, + }; +} diff --git a/muse_glimmer/macos/apps/web/src/hooks/usePrefersReducedMotion.ts b/muse_glimmer/macos/apps/web/src/hooks/usePrefersReducedMotion.ts new file mode 100644 index 0000000000..d039556f17 --- /dev/null +++ b/muse_glimmer/macos/apps/web/src/hooks/usePrefersReducedMotion.ts @@ -0,0 +1,18 @@ +import { useEffect, useState } from "react"; + +export function usePrefersReducedMotion(): boolean { + const [reducedMotion, setReducedMotion] = useState(() => + typeof window === "undefined" + ? false + : window.matchMedia("(prefers-reduced-motion: reduce)").matches, + ); + + useEffect(() => { + const media = window.matchMedia("(prefers-reduced-motion: reduce)"); + const update = () => setReducedMotion(media.matches); + media.addEventListener("change", update); + return () => media.removeEventListener("change", update); + }, []); + + return reducedMotion; +} diff --git a/muse_glimmer/macos/apps/web/src/hooks/useTranscriptionSegments.ts b/muse_glimmer/macos/apps/web/src/hooks/useTranscriptionSegments.ts new file mode 100644 index 0000000000..7bdb9c40f2 --- /dev/null +++ b/muse_glimmer/macos/apps/web/src/hooks/useTranscriptionSegments.ts @@ -0,0 +1,59 @@ +import { useRoomContext } from "@livekit/components-react"; +import { + RoomEvent, + type Participant, + type TranscriptionSegment, +} from "livekit-client"; +import { useEffect, useState } from "react"; + +import { AGENT_NAME } from "../config"; +import { + mergeTranscriptUpdates, + type TranscriptEntry, + type TranscriptUpdate, +} from "../lib/transcript"; + +export function useTranscriptionSegments( + localParticipantIdentity: string, +): TranscriptEntry[] { + const room = useRoomContext(); + const [entries, setEntries] = useState([]); + + useEffect(() => { + const handleTranscription = ( + segments: TranscriptionSegment[], + participant?: Participant, + ) => { + if (!participant) return; + + const speaker = + participant.identity === localParticipantIdentity + ? "user" + : participant.attributes["lk.agent.name"] === AGENT_NAME + ? "agent" + : undefined; + if (!speaker) return; + + const updates: TranscriptUpdate[] = segments + .filter((segment) => segment.text.trim().length > 0) + .map((segment) => ({ + segmentId: segment.id, + participantIdentity: participant.identity, + speaker, + text: segment.text, + final: segment.final, + })); + + if (updates.length > 0) { + setEntries((current) => mergeTranscriptUpdates(current, updates)); + } + }; + + room.on(RoomEvent.TranscriptionReceived, handleTranscription); + return () => { + room.off(RoomEvent.TranscriptionReceived, handleTranscription); + }; + }, [localParticipantIdentity, room]); + + return entries; +} diff --git a/muse_glimmer/macos/apps/web/src/lib/agentPresentation.test.ts b/muse_glimmer/macos/apps/web/src/lib/agentPresentation.test.ts new file mode 100644 index 0000000000..7cde56e3ff --- /dev/null +++ b/muse_glimmer/macos/apps/web/src/lib/agentPresentation.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it } from "vitest"; + +import { getAgentPresentation, normalizeAgentState } from "./agentPresentation"; + +describe("agent presentation", () => { + it.each([ + ["idle", "idle", "Ready when you are", "ready"], + ["listening", "listening", "Listening", "active"], + ["thinking", "thinking", "Thinking", "active"], + ["speaking", "happy", "Speaking", "speaking"], + ] as const)( + "maps %s to its visible voice state", + (state, animation, status, tone) => { + expect(getAgentPresentation("active", true, true, state)).toEqual({ + animation, + status, + tone, + }); + }, + ); + + it("prioritizes connection and named agent discovery", () => { + expect(getAgentPresentation("active", false, false)).toMatchObject({ + status: "Joining", + }); + expect(getAgentPresentation("active", true, false)).toMatchObject({ + status: "Waking up Muse", + }); + }); + + it("represents requesting and error phases independently of the room", () => { + expect(getAgentPresentation("requesting", false, false)).toMatchObject({ + animation: "working", + status: "Preparing your conversation", + }); + expect(getAgentPresentation("error", false, false)).toMatchObject({ + status: "Could not connect", + tone: "error", + }); + }); + + it("normalizes only known states", () => { + expect(normalizeAgentState("thinking")).toBe("thinking"); + expect(normalizeAgentState("disconnected")).toBeUndefined(); + expect(normalizeAgentState({ state: "idle" })).toBeUndefined(); + }); +}); diff --git a/muse_glimmer/macos/apps/web/src/lib/agentPresentation.ts b/muse_glimmer/macos/apps/web/src/lib/agentPresentation.ts new file mode 100644 index 0000000000..13f034bbee --- /dev/null +++ b/muse_glimmer/macos/apps/web/src/lib/agentPresentation.ts @@ -0,0 +1,80 @@ +export type AgentState = + | "initializing" + | "idle" + | "listening" + | "thinking" + | "speaking"; +export type SessionPhase = + | "idle" + | "requesting" + | "active" + | "ending" + | "error"; +export type MuseAnimation = + | "idle" + | "listening" + | "thinking" + | "working" + | "happy"; + +export interface AgentPresentation { + animation: MuseAnimation; + status: string; + tone: "ready" | "active" | "speaking" | "error"; +} + +export function normalizeAgentState(value: unknown): AgentState | undefined { + switch (value) { + case "initializing": + case "idle": + case "listening": + case "thinking": + case "speaking": + return value; + default: + return undefined; + } +} + +export function getAgentPresentation( + phase: SessionPhase, + isConnected: boolean, + hasNamedAgent: boolean, + agentState?: AgentState, +): AgentPresentation { + if (phase === "error") { + return { animation: "idle", status: "Could not connect", tone: "error" }; + } + if (phase === "requesting" || phase === "ending") { + return { + animation: "working", + status: + phase === "requesting" + ? "Preparing your conversation" + : "Ending conversation", + tone: "active", + }; + } + if (phase === "idle") { + return { animation: "idle", status: "Ready to talk", tone: "ready" }; + } + if (!isConnected) { + return { animation: "working", status: "Joining", tone: "active" }; + } + if (!hasNamedAgent || agentState === "initializing") { + return { animation: "working", status: "Waking up Muse", tone: "active" }; + } + + switch (agentState) { + case "idle": + return { animation: "idle", status: "Ready when you are", tone: "ready" }; + case "listening": + return { animation: "listening", status: "Listening", tone: "active" }; + case "thinking": + return { animation: "thinking", status: "Thinking", tone: "active" }; + case "speaking": + return { animation: "happy", status: "Speaking", tone: "speaking" }; + default: + return { animation: "working", status: "Working", tone: "active" }; + } +} diff --git a/muse_glimmer/macos/apps/web/src/lib/tokenClient.test.ts b/muse_glimmer/macos/apps/web/src/lib/tokenClient.test.ts new file mode 100644 index 0000000000..231caf0f1b --- /dev/null +++ b/muse_glimmer/macos/apps/web/src/lib/tokenClient.test.ts @@ -0,0 +1,97 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { LIVEKIT_SERVER_URL, TOKEN_ENDPOINT } from "../config"; +import { requestConnection } from "./tokenClient"; + +const validConnection = { + serverUrl: LIVEKIT_SERVER_URL, + participantToken: "local-token", + roomName: "glimmer-one", + participantIdentity: "web-one", +}; + +function mockResponse(body: unknown, status = 200) { + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue( + new Response(JSON.stringify(body), { + status, + headers: { "Content-Type": "application/json" }, + }), + ), + ); +} + +afterEach(() => vi.restoreAllMocks()); + +describe("token client", () => { + it("posts to the fixed local endpoint without credentials or referrer data", async () => { + mockResponse(validConnection); + + await expect(requestConnection()).resolves.toEqual(validConnection); + expect(fetch).toHaveBeenCalledOnce(); + expect(fetch).toHaveBeenCalledWith( + TOKEN_ENDPOINT, + expect.objectContaining({ + method: "POST", + cache: "no-store", + credentials: "omit", + referrerPolicy: "no-referrer", + headers: { Accept: "application/json" }, + }), + ); + }); + + it.each([ + "wss://127.0.0.1:7880", + "ws://localhost:7880", + "ws://127.0.0.1:7881", + "ws://192.168.1.5:7880", + ])("rejects the unapproved LiveKit URL %s", async (serverUrl) => { + mockResponse({ ...validConnection, serverUrl }); + + await expect(requestConnection()).rejects.toThrow("unapproved media URL"); + }); + + it("rejects unknown response fields", async () => { + mockResponse({ + ...validConnection, + debug: "should-not-cross-the-browser-boundary", + }); + + await expect(requestConnection()).rejects.toThrow("unexpected response"); + }); + + it("rejects missing or empty approved fields without exposing response values", async () => { + mockResponse({ ...validConnection, participantToken: " " }); + + await expect(requestConnection()).rejects.toThrow("incomplete response"); + }); + + it("uses a generic error when the service returns malformed JSON", async () => { + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue(new Response("{", { status: 200 })), + ); + + await expect(requestConnection()).rejects.toThrow("invalid response"); + }); + + it("uses a generic error when the local service is unavailable", async () => { + vi.stubGlobal( + "fetch", + vi.fn().mockRejectedValue(new TypeError("connection refused")), + ); + + await expect(requestConnection()).rejects.toThrow( + "local connection service is not available", + ); + }); + + it("preserves abort errors for cancellation handling", async () => { + const abortError = new DOMException("aborted", "AbortError"); + vi.stubGlobal("fetch", vi.fn().mockRejectedValue(abortError)); + + await expect(requestConnection()).rejects.toBe(abortError); + }); +}); diff --git a/muse_glimmer/macos/apps/web/src/lib/tokenClient.ts b/muse_glimmer/macos/apps/web/src/lib/tokenClient.ts new file mode 100644 index 0000000000..7f9cbe64dd --- /dev/null +++ b/muse_glimmer/macos/apps/web/src/lib/tokenClient.ts @@ -0,0 +1,95 @@ +import { LIVEKIT_SERVER_URL, TOKEN_ENDPOINT } from "../config"; + +export interface ConnectionDetails { + serverUrl: string; + participantToken: string; + roomName: string; + participantIdentity: string; +} + +const RESPONSE_FIELDS = [ + "participantIdentity", + "participantToken", + "roomName", + "serverUrl", +] as const; + +const isNonEmptyString = (value: unknown): value is string => + typeof value === "string" && value.trim().length > 0; + +export async function requestConnection( + signal?: AbortSignal, +): Promise { + let response: Response; + try { + response = await fetch(TOKEN_ENDPOINT, { + method: "POST", + cache: "no-store", + credentials: "omit", + headers: { Accept: "application/json" }, + referrerPolicy: "no-referrer", + signal, + }); + } catch (error) { + if (error instanceof DOMException && error.name === "AbortError") { + throw error; + } + throw new Error("The local connection service is not available."); + } + + if (!response.ok) { + throw new Error( + "The local connection service could not start a conversation.", + ); + } + + let body: unknown; + try { + body = await response.json(); + } catch { + throw new Error( + "The local connection service returned an invalid response.", + ); + } + + if (typeof body !== "object" || body === null || Array.isArray(body)) { + throw new Error( + "The local connection service returned an invalid response.", + ); + } + + const candidate = body as Record; + const responseFields = Object.keys(candidate).sort(); + if ( + responseFields.length !== RESPONSE_FIELDS.length || + responseFields.some((field, index) => field !== RESPONSE_FIELDS[index]) + ) { + throw new Error( + "The local connection service returned an unexpected response.", + ); + } + + if ( + !isNonEmptyString(candidate.serverUrl) || + !isNonEmptyString(candidate.participantToken) || + !isNonEmptyString(candidate.roomName) || + !isNonEmptyString(candidate.participantIdentity) + ) { + throw new Error( + "The local connection service returned an incomplete response.", + ); + } + + if (candidate.serverUrl !== LIVEKIT_SERVER_URL) { + throw new Error( + "The local connection service returned an unapproved media URL.", + ); + } + + return { + serverUrl: candidate.serverUrl, + participantToken: candidate.participantToken, + roomName: candidate.roomName, + participantIdentity: candidate.participantIdentity, + }; +} diff --git a/muse_glimmer/macos/apps/web/src/lib/transcript.test.ts b/muse_glimmer/macos/apps/web/src/lib/transcript.test.ts new file mode 100644 index 0000000000..2f9b033d04 --- /dev/null +++ b/muse_glimmer/macos/apps/web/src/lib/transcript.test.ts @@ -0,0 +1,79 @@ +import { describe, expect, it } from "vitest"; + +import { mergeTranscriptUpdates } from "./transcript"; + +describe("transcript reducer", () => { + it("replaces interim text and preserves its order when final", () => { + const interim = mergeTranscriptUpdates( + [], + [ + { + segmentId: "one", + participantIdentity: "web-1", + speaker: "user", + text: "what is the", + final: false, + }, + ], + ); + const final = mergeTranscriptUpdates(interim, [ + { + segmentId: "one", + participantIdentity: "web-1", + speaker: "user", + text: "What is the weather like?", + final: true, + }, + ]); + + expect(final).toHaveLength(1); + expect(final[0]).toMatchObject({ + text: "What is the weather like?", + final: true, + order: 0, + }); + }); + + it("keeps reused segment ids distinct by participant", () => { + const transcript = mergeTranscriptUpdates( + [], + [ + { + segmentId: "one", + participantIdentity: "web-1", + speaker: "user", + text: "Hello", + final: true, + }, + { + segmentId: "one", + participantIdentity: "agent-1", + speaker: "agent", + text: "Hi", + final: true, + }, + ], + ); + + expect(transcript.map((entry) => entry.key)).toEqual([ + "web-1:one", + "agent-1:one", + ]); + }); + + it("bounds history to the newest entries", () => { + const transcript = mergeTranscriptUpdates( + [], + Array.from({ length: 4 }, (_, index) => ({ + segmentId: String(index), + participantIdentity: "web-1", + speaker: "user" as const, + text: String(index), + final: true, + })), + 2, + ); + + expect(transcript.map((entry) => entry.text)).toEqual(["2", "3"]); + }); +}); diff --git a/muse_glimmer/macos/apps/web/src/lib/transcript.ts b/muse_glimmer/macos/apps/web/src/lib/transcript.ts new file mode 100644 index 0000000000..a127be1e10 --- /dev/null +++ b/muse_glimmer/macos/apps/web/src/lib/transcript.ts @@ -0,0 +1,47 @@ +export type TranscriptSpeaker = "user" | "agent"; + +export interface TranscriptEntry { + key: string; + segmentId: string; + participantIdentity: string; + speaker: TranscriptSpeaker; + text: string; + final: boolean; + order: number; +} + +export interface TranscriptUpdate { + segmentId: string; + participantIdentity: string; + speaker: TranscriptSpeaker; + text: string; + final: boolean; +} + +export function mergeTranscriptUpdates( + current: TranscriptEntry[], + updates: TranscriptUpdate[], + maxEntries = 50, +): TranscriptEntry[] { + const byKey = new Map(current.map((entry) => [entry.key, entry])); + let nextOrder = + current.reduce((maximum, entry) => Math.max(maximum, entry.order), -1) + 1; + + for (const update of updates) { + const key = `${update.participantIdentity}:${update.segmentId}`; + const existing = byKey.get(key); + byKey.set(key, { + key, + segmentId: update.segmentId, + participantIdentity: update.participantIdentity, + speaker: update.speaker, + text: update.text, + final: update.final, + order: existing?.order ?? nextOrder++, + }); + } + + return Array.from(byKey.values()) + .sort((left, right) => left.order - right.order) + .slice(-maxEntries); +} diff --git a/muse_glimmer/macos/apps/web/src/main.tsx b/muse_glimmer/macos/apps/web/src/main.tsx new file mode 100644 index 0000000000..7b8fd60e76 --- /dev/null +++ b/muse_glimmer/macos/apps/web/src/main.tsx @@ -0,0 +1,20 @@ +import "@fontsource/inter/latin-400.css"; +import "@fontsource/inter/latin-500.css"; +import "@fontsource/inter/latin-600.css"; +import "@fontsource/inter/latin-700.css"; +import { StrictMode } from "react"; +import { createRoot } from "react-dom/client"; + +import App from "./App"; +import "./app.css"; + +const root = document.getElementById("root"); +if (!root) { + throw new Error("The application root is missing."); +} + +createRoot(root).render( + + + , +); diff --git a/muse_glimmer/macos/apps/web/src/test/setup.ts b/muse_glimmer/macos/apps/web/src/test/setup.ts new file mode 100644 index 0000000000..55b2d40041 --- /dev/null +++ b/muse_glimmer/macos/apps/web/src/test/setup.ts @@ -0,0 +1,19 @@ +import "@testing-library/jest-dom/vitest"; +import { cleanup } from "@testing-library/react"; +import { afterEach } from "vitest"; + +afterEach(cleanup); + +Object.defineProperty(window, "matchMedia", { + writable: true, + value: (query: string) => ({ + matches: false, + media: query, + onchange: null, + addEventListener: () => undefined, + removeEventListener: () => undefined, + addListener: () => undefined, + removeListener: () => undefined, + dispatchEvent: () => false, + }), +}); diff --git a/muse_glimmer/macos/apps/web/src/vite-env.d.ts b/muse_glimmer/macos/apps/web/src/vite-env.d.ts new file mode 100644 index 0000000000..11f02fe2a0 --- /dev/null +++ b/muse_glimmer/macos/apps/web/src/vite-env.d.ts @@ -0,0 +1 @@ +/// diff --git a/muse_glimmer/macos/apps/web/tsconfig.app.json b/muse_glimmer/macos/apps/web/tsconfig.app.json new file mode 100644 index 0000000000..295a415220 --- /dev/null +++ b/muse_glimmer/macos/apps/web/tsconfig.app.json @@ -0,0 +1,21 @@ +{ + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", + "target": "ES2022", + "useDefineForClassFields": true, + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "allowJs": false, + "skipLibCheck": true, + "esModuleInterop": true, + "allowSyntheticDefaultImports": true, + "strict": true, + "forceConsistentCasingInFileNames": true, + "module": "ESNext", + "moduleResolution": "Bundler", + "isolatedModules": true, + "noEmit": true, + "jsx": "react-jsx", + "types": ["vite/client", "vitest/globals", "@testing-library/jest-dom"] + }, + "include": ["src"] +} diff --git a/muse_glimmer/macos/apps/web/tsconfig.json b/muse_glimmer/macos/apps/web/tsconfig.json new file mode 100644 index 0000000000..1ffef600d9 --- /dev/null +++ b/muse_glimmer/macos/apps/web/tsconfig.json @@ -0,0 +1,7 @@ +{ + "files": [], + "references": [ + { "path": "./tsconfig.app.json" }, + { "path": "./tsconfig.node.json" } + ] +} diff --git a/muse_glimmer/macos/apps/web/tsconfig.node.json b/muse_glimmer/macos/apps/web/tsconfig.node.json new file mode 100644 index 0000000000..bb717fdde5 --- /dev/null +++ b/muse_glimmer/macos/apps/web/tsconfig.node.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo", + "target": "ES2023", + "lib": ["ES2023"], + "module": "ESNext", + "moduleResolution": "Bundler", + "skipLibCheck": true, + "strict": true, + "noEmit": true, + "types": ["node"] + }, + "include": ["vite.config.ts"] +} diff --git a/muse_glimmer/macos/apps/web/vite.config.ts b/muse_glimmer/macos/apps/web/vite.config.ts new file mode 100644 index 0000000000..3eefb3afc0 --- /dev/null +++ b/muse_glimmer/macos/apps/web/vite.config.ts @@ -0,0 +1,20 @@ +import react from "@vitejs/plugin-react"; +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + plugins: [react()], + server: { + host: "127.0.0.1", + port: 5173, + strictPort: true, + }, + build: { + sourcemap: false, + target: "es2022", + }, + test: { + environment: "jsdom", + include: ["src/**/*.test.{ts,tsx}"], + setupFiles: "./src/test/setup.ts", + }, +}); diff --git a/muse_glimmer/macos/apps/worker/LICENSE b/muse_glimmer/macos/apps/worker/LICENSE new file mode 100644 index 0000000000..5651f75604 --- /dev/null +++ b/muse_glimmer/macos/apps/worker/LICENSE @@ -0,0 +1,30 @@ +BSD License + +For "ExecuTorch" software + +Copyright (c) Meta Platforms, Inc. and affiliates. All rights reserved. + +Redistribution and use in source and binary forms, with or without modification, +are permitted provided that the following conditions are met: + + * Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. + + * Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + + * Neither the name Meta nor the names of its contributors may be used to + endorse or promote products derived from this software without specific + prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR +ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON +ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/muse_glimmer/macos/apps/worker/PROVENANCE.md b/muse_glimmer/macos/apps/worker/PROVENANCE.md new file mode 100644 index 0000000000..0d3570189c --- /dev/null +++ b/muse_glimmer/macos/apps/worker/PROVENANCE.md @@ -0,0 +1,38 @@ +# Provenance + +## Ownership and source snapshots + +This package is original product source maintained in the canonical +[`meta-pytorch/executorch-examples`](https://github.com/meta-pytorch/executorch-examples) +repository under `muse_glimmer/macos/apps/worker` and licensed under +BSD-3-Clause. The source snapshot used for the macOS subtree migration is +`914fb816fe9e0f6b7fc808fd843eb2e97df31dcf`. + +The package uses the public +[`livekit/agents`](https://github.com/livekit/agents) APIs at commit +`bc5f3df3a2bd1b3b8c5d1df742be57b063374991`. The package did not exist in that +upstream commit, and no LiveKit implementation source was copied into it. + +## Source mapping + +The original product files were reorganized for this standalone package: + +- `agents/examples/voice_agents/glimmer_agent.py` -> `src/muse_glimmer_worker/agent.py` +- `agents/examples/voice_agents/glimmer_cli.py` -> `src/muse_glimmer_worker/cli.py` +- `agents/examples/voice_agents/glimmer_config.py` -> `src/muse_glimmer_worker/config.py` +- `agents/livekit-plugins/livekit-plugins-executorch/tests/test_glimmer_agent_privacy.py` + -> `tests/test_agent.py` +- `agents/livekit-plugins/livekit-plugins-executorch/tests/test_glimmer_cli.py` + -> `tests/test_cli.py` +- `agents/livekit-plugins/livekit-plugins-executorch/tests/test_glimmer_config.py` + -> `tests/test_config.py` + +Packaging and lifecycle code are original additions for this distribution. + +## Exclusions + +This source package does not include LiveKit or ExecuTorch implementation +source, native runners, model weights, exported programs, tokenizers, voice +styles, recordings, generated output, dependency source, or build and test +caches. Those components retain their independent upstream licenses and +notices. diff --git a/muse_glimmer/macos/apps/worker/README.md b/muse_glimmer/macos/apps/worker/README.md new file mode 100644 index 0000000000..7d4874cd1b --- /dev/null +++ b/muse_glimmer/macos/apps/worker/README.md @@ -0,0 +1,19 @@ +# Muse Glimmer worker + +An installable, local-only LiveKit worker for the Muse Glimmer voice application. +It uses local Parakeet and Supertonic ExecuTorch providers and an OpenAI-compatible +Muse Glimmer endpoint fixed at `http://127.0.0.1:8000/v1`. + +After installing the workspace, run the worker with: + +```bash +muse-glimmer-worker dev +``` + +The worker accepts only `ws://127.0.0.1:7880` for LiveKit and requires local artifact +paths through `PARAKEET_*` and `SUPERTONIC_*` environment variables. Credentials are +read from the environment and are never included in diagnostics. + +For deployment checks or a direct WAV pipeline, use `muse-glimmer-diagnostics doctor` +or `muse-glimmer-diagnostics pipeline INPUT.wav`. Diagnostic ZIPs omit the local +raw runtime log by default; do not attach local logs to public issues. diff --git a/muse_glimmer/macos/apps/worker/pyproject.toml b/muse_glimmer/macos/apps/worker/pyproject.toml new file mode 100644 index 0000000000..85f7200fa4 --- /dev/null +++ b/muse_glimmer/macos/apps/worker/pyproject.toml @@ -0,0 +1,66 @@ +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[project] +name = "muse-glimmer-worker" +version = "0.1.0" +description = "Local-only LiveKit voice worker for Muse Glimmer" +readme = "README.md" +requires-python = ">=3.13,<3.14" +license = "BSD-3-Clause" +license-files = ["LICENSE", "PROVENANCE.md"] +classifiers = [ + "License :: OSI Approved :: BSD License", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.13", +] +dependencies = [ + "livekit-agents[openai,silero]>=1.6.9,<2", + "livekit-plugins-executorch==0.1.0", +] + +[tool.uv.sources] +livekit-plugins-executorch = { workspace = true } + +[dependency-groups] +dev = [ + "pytest>=8.4,<9", + "pytest-asyncio>=0.25,<2", + "ruff>=0.12,<1", +] + +[project.scripts] +muse-glimmer-worker = "muse_glimmer_worker.agent:main" +muse-glimmer-diagnostics = "muse_glimmer_worker.cli:main" + +[tool.hatch.build] +include = [ + "/LICENSE", + "/PROVENANCE.md", + "/README.md", + "/src", +] + +[tool.hatch.build.targets.wheel] +packages = ["src/muse_glimmer_worker"] + +[tool.hatch.build.targets.sdist] +include = [ + "/LICENSE", + "/PROVENANCE.md", + "/README.md", + "/src", + "/tests", +] + +[tool.pytest.ini_options] +asyncio_mode = "auto" +asyncio_default_fixture_loop_scope = "function" + +[tool.ruff] +line-length = 100 +target-version = "py313" + +[tool.ruff.lint] +select = ["E", "F", "I", "UP", "B", "SIM"] diff --git a/muse_glimmer/macos/apps/worker/src/muse_glimmer_worker/__init__.py b/muse_glimmer/macos/apps/worker/src/muse_glimmer_worker/__init__.py new file mode 100644 index 0000000000..334a9891bf --- /dev/null +++ b/muse_glimmer/macos/apps/worker/src/muse_glimmer_worker/__init__.py @@ -0,0 +1,5 @@ +"""Local Muse Glimmer LiveKit worker.""" + +from .config import GlimmerConfig + +__all__ = ["GlimmerConfig"] diff --git a/muse_glimmer/macos/apps/worker/src/muse_glimmer_worker/__main__.py b/muse_glimmer/macos/apps/worker/src/muse_glimmer_worker/__main__.py new file mode 100644 index 0000000000..2fe7c4c2fb --- /dev/null +++ b/muse_glimmer/macos/apps/worker/src/muse_glimmer_worker/__main__.py @@ -0,0 +1,4 @@ +from .agent import main + +if __name__ == "__main__": + main() diff --git a/muse_glimmer/macos/apps/worker/src/muse_glimmer_worker/agent.py b/muse_glimmer/macos/apps/worker/src/muse_glimmer_worker/agent.py new file mode 100644 index 0000000000..a73d8b4bf5 --- /dev/null +++ b/muse_glimmer/macos/apps/worker/src/muse_glimmer_worker/agent.py @@ -0,0 +1,142 @@ +from __future__ import annotations + +import logging +import os + +from livekit.agents import ( + Agent, + AgentServer, + AgentSession, + AgentStateChangedEvent, + CloseEvent, + ErrorEvent, + JobContext, + JobProcess, + SessionUsageUpdatedEvent, + TurnHandlingOptions, + UserInputTranscribedEvent, + UserStateChangedEvent, + UserTranscriptionTimeoutEvent, + cli, +) +from livekit.plugins import silero + +from .config import GlimmerConfig, create_providers +from .lifecycle import ProviderCleanup + +logger = logging.getLogger("muse-glimmer-worker") +_VAD_KEY = "glimmer_vad" + + +class GlimmerAgent(Agent): + def __init__(self, *, instructions: str) -> None: + super().__init__(instructions=instructions) + + async def on_enter(self) -> None: + self.session.generate_reply( + instructions="Greet the user briefly and ask how you can help.", + allow_interruptions=True, + ) + + +def setup_process(proc: JobProcess) -> None: + proc.userdata[_VAD_KEY] = silero.VAD.load() + + +server = AgentServer(setup_fnc=setup_process, host="127.0.0.1") + + +@server.rtc_session(agent_name="assistant") +async def entrypoint(ctx: JobContext) -> None: + config = GlimmerConfig.from_env() + ctx.log_context_fields = {"room": ctx.room.name, "agent": config.agent_name} + providers = create_providers( + config, + voice_activity_detector=ctx.proc.userdata[_VAD_KEY], + ) + cleanup = ProviderCleanup( + llm=providers.llm, + parakeet=providers.parakeet, + supertonic=providers.supertonic, + logger=logger, + ) + ctx.add_shutdown_callback(cleanup.close) + + try: + await providers.parakeet.start() + session: AgentSession[None] = AgentSession( + stt=providers.session_stt, + llm=providers.llm, + tts=providers.session_tts, + turn_handling=TurnHandlingOptions( + interruption={ + "enabled": True, + "resume_false_interruption": True, + "false_interruption_timeout": 1.0, + }, + preemptive_generation={"enabled": False}, + ), + tts_text_transforms=["filter_markdown", "filter_emoji"], + ) + _attach_session_logging(session) + await session.start( + agent=GlimmerAgent(instructions=config.instructions), + room=ctx.room, + ) + except BaseException: + await cleanup.close() + raise + + +def _attach_session_logging(session: AgentSession[None]) -> None: + @session.on("user_state_changed") + def _on_user_state_changed(event: UserStateChangedEvent) -> None: + logger.info("USER: %s -> %s", event.old_state, event.new_state) + + @session.on("user_input_transcribed") + def _on_user_input_transcribed(event: UserInputTranscribedEvent) -> None: + if event.is_final: + logger.info("STT: final transcript received (%d characters)", len(event.transcript)) + logger.debug("STT: final=%s transcript=%r", event.is_final, event.transcript) + + @session.on("user_transcription_timeout") + def _on_user_transcription_timeout(event: UserTranscriptionTimeoutEvent) -> None: + logger.warning( + "STT: no transcript after %.2fs of VAD-detected speech", + event.speech_duration, + ) + + @session.on("agent_state_changed") + def _on_agent_state_changed(event: AgentStateChangedEvent) -> None: + logger.info("AGENT: %s -> %s", event.old_state, event.new_state) + + @session.on("error") + def _on_error(event: ErrorEvent) -> None: + logger.error("PIPELINE ERROR: %s", event.model_dump(mode="json")) + + @session.on("close") + def _on_close(event: CloseEvent) -> None: + logger.info("SESSION CLOSED: reason=%s error=%s", event.reason.value, event.error) + + last_usage: str | None = None + + @session.on("session_usage_updated") + def _on_usage_updated(event: SessionUsageUpdatedEvent) -> None: + nonlocal last_usage + snapshot = repr(event.usage) + if snapshot == last_usage: + return + last_usage = snapshot + logger.debug("Glimmer session usage changed: %s", event.usage) + + +def main() -> None: + config = GlimmerConfig.from_env() + os.environ["LIVEKIT_URL"] = config.livekit_url + os.environ["LIVEKIT_API_KEY"] = config.livekit_api_key + os.environ["LIVEKIT_API_SECRET"] = config.livekit_api_secret + cli.run_app(server) + + +if __name__ == "__main__": + main() diff --git a/muse_glimmer/macos/apps/worker/src/muse_glimmer_worker/cli.py b/muse_glimmer/macos/apps/worker/src/muse_glimmer_worker/cli.py new file mode 100644 index 0000000000..2aaa00c103 --- /dev/null +++ b/muse_glimmer/macos/apps/worker/src/muse_glimmer_worker/cli.py @@ -0,0 +1,782 @@ +from __future__ import annotations + +import argparse +import asyncio +import json +import logging +import os +import platform +import sys +import time +import traceback +import urllib.error +import urllib.request +import uuid +import wave +import zipfile +from datetime import UTC, datetime +from importlib import metadata +from pathlib import Path +from typing import Any, TextIO +from urllib.parse import urlsplit, urlunsplit + +from livekit import rtc +from livekit.agents import APIConnectOptions, llm +from livekit.agents.utils.audio import AudioByteStream + +from .agent import main as worker_main +from .config import REASONING_STRENGTH, GlimmerConfig, LocalProviders, create_local_providers +from .lifecycle import ProviderCleanup + +logger = logging.getLogger("museglimmer-cli") + +_APP_NAME = "MuseGlimmer-VoiceAgent" +_REPORT_SCHEMA_VERSION = 1 +_DEFAULT_TIMEOUT = 300.0 + + +class JsonlReporter: + def __init__(self, report_dir: Path) -> None: + self.report_dir = report_dir + self.events_path = report_dir / "events.jsonl" + self._stream: TextIO = self.events_path.open("w", encoding="utf-8") + + def emit(self, event: str, **fields: object) -> None: + payload = { + "timestamp": datetime.now(UTC).isoformat(), + "event": event, + **fields, + } + self._stream.write(json.dumps(payload, ensure_ascii=True, default=str) + "\n") + self._stream.flush() + message = str(fields.get("message", event)) + if event.endswith("failed"): + logger.error("%s: %s", event, message) + else: + logger.info("%s: %s", event, message) + + def close(self) -> None: + self._stream.close() + + +class StageTimer: + def __init__(self, reporter: JsonlReporter, stage: str, durations: dict[str, float]) -> None: + self._reporter = reporter + self._stage = stage + self._durations = durations + self._started = 0.0 + + def __enter__(self) -> StageTimer: + self._started = time.perf_counter() + self._reporter.emit("stage_started", stage=self._stage, message=self._stage) + return self + + def __exit__(self, exc_type: object, exc: object, exc_tb: object) -> None: + duration = time.perf_counter() - self._started + self._durations[self._stage] = duration + if exc is None: + self._reporter.emit( + "stage_completed", + stage=self._stage, + duration_seconds=round(duration, 6), + message=self._stage, + ) + else: + self._reporter.emit( + "stage_failed", + stage=self._stage, + duration_seconds=round(duration, 6), + error_type=type(exc).__name__, + message=str(exc), + ) + + +def _parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser( + prog="glimmer_cli.py", + description="Diagnose and exercise the MuseGlimmer-VoiceAgent pipeline.", + ) + parser.add_argument( + "--log-level", + choices=("DEBUG", "INFO", "WARNING", "ERROR"), + default="INFO", + ) + commands = parser.add_subparsers(dest="command", required=True) + + doctor = commands.add_parser( + "doctor", + help="Validate artifacts, native helper startup, and MuseGlimmer HTTP readiness.", + ) + doctor.add_argument("--report-dir", type=Path) + doctor.add_argument("--timeout", type=float, default=_DEFAULT_TIMEOUT) + + pipeline = commands.add_parser( + "pipeline", + help="Run a PCM WAV through Parakeet, MuseGlimmer, and Supertonic.", + ) + pipeline.add_argument("input_wav", type=Path) + pipeline.add_argument("--output-wav", type=Path) + pipeline.add_argument("--report-dir", type=Path) + pipeline.add_argument("--timeout", type=float, default=_DEFAULT_TIMEOUT) + pipeline.add_argument( + "--force", + action="store_true", + help="Replace an existing output WAV after a successful run.", + ) + pipeline.add_argument( + "--include-content", + action="store_true", + help="Include transcript and response text in the issue report.", + ) + + console = commands.add_parser( + "console", + help="Run the LiveKit microphone/speaker console with the same providers.", + ) + console.add_argument("--input-device") + console.add_argument("--output-device") + console.add_argument("--list-devices", action="store_true") + console.add_argument("--text", action="store_true") + console.add_argument("--record", action="store_true") + console.add_argument( + "--console-log-level", + choices=("trace", "debug", "info", "warn", "error", "critical"), + default="debug", + help="Log level passed to the LiveKit console process.", + ) + return parser + + +class RedactingFormatter(logging.Formatter): + def __init__(self, fmt: str) -> None: + super().__init__(fmt) + self.config: GlimmerConfig | None = None + + def format(self, record: logging.LogRecord) -> str: + return _redact_text(super().format(record), self.config) + + +def _attach_runtime_log(report_dir: Path) -> logging.FileHandler: + handler = logging.FileHandler(report_dir / "runtime.log", encoding="utf-8") + handler.setFormatter(RedactingFormatter("%(asctime)s %(levelname)s %(name)s %(message)s")) + logging.getLogger().addHandler(handler) + return handler + + +def _set_runtime_log_config(handler: logging.FileHandler, config: GlimmerConfig) -> None: + formatter = handler.formatter + if isinstance(formatter, RedactingFormatter): + formatter.config = config + + +def _detach_runtime_log(handler: logging.FileHandler) -> None: + logging.getLogger().removeHandler(handler) + handler.close() + + +def _prepare_report_dir(command: str, requested: Path | None) -> Path: + if requested is None: + stamp = datetime.now(UTC).strftime("%Y%m%dT%H%M%SZ") + requested = Path.cwd() / "museglimmer-reports" / f"{stamp}-{command}-{uuid.uuid4().hex[:8]}" + path = requested.expanduser().resolve() + if path.exists() and any(path.iterdir()): + raise ValueError(f"report directory must be empty: {path}") + path.mkdir(parents=True, exist_ok=True) + return path + + +def _package_version(distribution: str) -> str | None: + try: + return metadata.version(distribution) + except metadata.PackageNotFoundError: + return None + + +def _system_snapshot() -> dict[str, object]: + return { + "app": _APP_NAME, + "schema_version": _REPORT_SCHEMA_VERSION, + "timestamp": datetime.now(UTC).isoformat(), + "platform": platform.platform(), + "machine": platform.machine(), + "python": sys.version, + "packages": { + "livekit-agents": _package_version("livekit-agents"), + "livekit-plugins-executorch": _package_version("livekit-plugins-executorch"), + "livekit-plugins-openai": _package_version("livekit-plugins-openai"), + }, + } + + +def _artifact(path: Path | None) -> dict[str, object] | None: + if path is None: + return None + stat = path.stat() + return { + "path": f".../{path.name}", + "size_bytes": stat.st_size, + "modified_ns": stat.st_mtime_ns, + "executable": os.access(path, os.X_OK), + } + + +def _safe_url(value: str) -> str: + parsed = urlsplit(value) + host = parsed.hostname or "" + if parsed.port is not None: + host = f"{host}:{parsed.port}" + return urlunsplit((parsed.scheme, host, parsed.path, "", "")) + + +def _redacted_config(config: GlimmerConfig) -> dict[str, object]: + return { + "agent_name": config.agent_name, + "language": config.language, + "muse_glimmer": { + "base_url": _safe_url(config.muse_glimmer_base_url), + "model_id": config.muse_glimmer_model_id, + "temperature": config.muse_glimmer_temperature, + "max_tokens": config.muse_glimmer_max_tokens, + "reasoning_strength": REASONING_STRENGTH, + "api_key": "", + }, + "parakeet": { + "helper": _artifact(config.parakeet_helper_path), + "model": _artifact(config.parakeet_model_path), + "tokenizer": _artifact(config.parakeet_tokenizer_path), + "delegate_data": _artifact(config.parakeet_delegate_data_path), + }, + "supertonic": { + "runner": _artifact(config.supertonic_runner_path), + "pte": _artifact(config.supertonic_pte_path), + "asset_dir": _artifact(config.supertonic_asset_dir), + "voice_style": _artifact(config.supertonic_voice_style_path), + "speed": config.supertonic_speed, + "seed": config.supertonic_seed, + }, + } + + +def _read_pcm_wav(path: Path) -> tuple[list[rtc.AudioFrame], dict[str, object]]: + source = path.expanduser().resolve() + if not source.is_file(): + raise ValueError(f"input WAV does not exist: {source}") + with wave.open(str(source), "rb") as input_wav: + channels = input_wav.getnchannels() + sample_rate = input_wav.getframerate() + sample_width = input_wav.getsampwidth() + frame_count = input_wav.getnframes() + compression = input_wav.getcomptype() + payload = input_wav.readframes(frame_count) + if compression != "NONE" or sample_width != 2: + raise ValueError("input must be uncompressed signed PCM16 WAV") + if channels <= 0 or sample_rate <= 0 or frame_count <= 0: + raise ValueError("input WAV must contain non-empty audio with a valid format") + + byte_stream = AudioByteStream( + sample_rate=sample_rate, + num_channels=channels, + samples_per_channel=max(1, sample_rate // 10), + ) + frames = [*byte_stream.push(payload), *byte_stream.flush()] + return frames, { + "path": f".../{source.name}", + "sample_rate": sample_rate, + "channels": channels, + "sample_width_bytes": sample_width, + "frames": frame_count, + "duration_seconds": frame_count / sample_rate, + "size_bytes": source.stat().st_size, + } + + +async def _write_synthesized_wav( + stream: Any, output_path: Path, *, force: bool +) -> dict[str, object]: + target = output_path.expanduser().resolve() + if target.exists() and not force: + raise ValueError(f"output WAV already exists; pass --force to replace it: {target}") + target.parent.mkdir(parents=True, exist_ok=True) + partial = target.with_name(f".{target.name}.{uuid.uuid4().hex}.partial") + sample_rate: int | None = None + channels: int | None = None + sample_count = 0 + event_count = 0 + request_id: str | None = None + output_wav: wave.Wave_write | None = None + try: + async with stream: + async for event in stream: + frame = event.frame + if event.request_id != request_id: + if output_wav is not None: + output_wav.close() + partial.unlink(missing_ok=True) + request_id = event.request_id + sample_rate = frame.sample_rate + channels = frame.num_channels + sample_count = 0 + event_count = 0 + output_wav = wave.open(str(partial), "wb") # noqa: SIM115 + output_wav.setnchannels(channels) + output_wav.setsampwidth(2) + output_wav.setframerate(sample_rate) + elif frame.sample_rate != sample_rate or frame.num_channels != channels: + raise RuntimeError("TTS changed audio format during one synthesis attempt") + if output_wav is None: + raise RuntimeError("TTS stream did not initialize an output attempt") + output_wav.writeframesraw(frame.data.tobytes()) + sample_count += frame.samples_per_channel + event_count += 1 + if output_wav is not None: + output_wav.close() + output_wav = None + if sample_rate is None or channels is None or sample_count == 0: + raise RuntimeError("TTS returned no audio") + if target.exists() and not force: + raise ValueError( + f"output WAV appeared during synthesis; refusing to replace it: {target}" + ) + partial.replace(target) + except BaseException: + if output_wav is not None: + output_wav.close() + partial.unlink(missing_ok=True) + raise + return { + "path": f".../{target.name}", + "sample_rate": sample_rate, + "channels": channels, + "sample_width_bytes": 2, + "samples_per_channel": sample_count, + "duration_seconds": sample_count / sample_rate, + "events": event_count, + "request_id": request_id, + "size_bytes": target.stat().st_size, + } + + +async def _probe_synthesized_audio(stream: Any) -> dict[str, object]: + sample_count = 0 + event_count = 0 + request_id: str | None = None + async with stream: + async for event in stream: + frame = event.frame + if frame.sample_rate != 44100 or frame.num_channels != 1: + raise RuntimeError("Supertonic must return 44.1 kHz mono audio") + if request_id is None: + request_id = event.request_id + elif event.request_id != request_id: + raise RuntimeError("Supertonic changed request ID during the doctor probe") + sample_count += frame.samples_per_channel + event_count += 1 + if request_id is None or sample_count <= 0: + raise RuntimeError("Supertonic returned no audio during the doctor probe") + return { + "sample_rate": 44100, + "channels": 1, + "samples_per_channel": sample_count, + "duration_seconds": sample_count / 44100, + "events": event_count, + "request_id": request_id, + } + + +def _http_json(url: str, api_key: str, timeout: float) -> object: + headers = {"Accept": "application/json"} + if api_key: + headers["Authorization"] = f"Bearer {api_key}" + request = urllib.request.Request(url, headers=headers) + try: + with urllib.request.urlopen(request, timeout=timeout) as response: + payload = response.read() + except urllib.error.URLError as exc: + raise RuntimeError(f"HTTP readiness request failed for {_safe_url(url)}: {exc}") from exc + try: + return json.loads(payload) + except json.JSONDecodeError as exc: + raise RuntimeError(f"HTTP readiness response was not JSON: {_safe_url(url)}") from exc + + +def _provider_cleanup(providers: LocalProviders) -> ProviderCleanup: + return ProviderCleanup( + llm=providers.llm, + parakeet=providers.parakeet, + supertonic=providers.supertonic, + logger=logger, + ) + + +def _write_json(path: Path, payload: object) -> None: + temporary = path.with_name(f".{path.name}.{uuid.uuid4().hex}.tmp") + temporary.write_text( + json.dumps(payload, indent=2, sort_keys=True, ensure_ascii=True, default=str) + "\n", + encoding="utf-8", + ) + temporary.replace(path) + + +def _create_issue_bundle(report_dir: Path) -> Path: + bundle = report_dir / "issue-report.zip" + with zipfile.ZipFile(bundle, "w", compression=zipfile.ZIP_DEFLATED) as archive: + for name in ("report.json", "events.jsonl"): + path = report_dir / name + if path.exists(): + archive.write(path, arcname=name) + return bundle + + +def _attach_provider_reporting( + providers: LocalProviders, + reporter: JsonlReporter, + config: GlimmerConfig, +) -> None: + def metrics(stage: str, event: object) -> None: + payload = event.model_dump(mode="json") if hasattr(event, "model_dump") else str(event) + reporter.emit( + "provider_metrics", + stage=stage, + metrics=_redact_payload(payload, config), + message=stage, + ) + + def error(stage: str, event: object) -> None: + exception = getattr(event, "error", RuntimeError(str(event))) + reporter.emit( + "provider_error", + stage=stage, + recoverable=bool(getattr(event, "recoverable", False)), + error_type=type(exception).__name__, + message=_redact_text(str(exception), config), + ) + + for stage, provider in ( + ("stt", providers.parakeet), + ("llm", providers.llm), + ("tts", providers.supertonic), + ): + if not hasattr(provider, "on"): + continue + provider.on("metrics_collected", lambda event, stage=stage: metrics(stage, event)) + provider.on("error", lambda event, stage=stage: error(stage, event)) + + +def _redact_payload(value: object, config: GlimmerConfig | None) -> object: + if isinstance(value, str): + return _redact_text(value, config) + if isinstance(value, dict): + return { + _redact_text(str(key), config): _redact_payload(item, config) + for key, item in value.items() + } + if isinstance(value, (list, tuple)): + return [_redact_payload(item, config) for item in value] + if value is None or isinstance(value, (bool, int, float)): + return value + return _redact_text(str(value), config) + + +def _redact_text(value: str, config: GlimmerConfig | None) -> str: + replacements: dict[str, str] = {str(Path.home()): "~"} + for secret_name in ("LIVEKIT_API_SECRET", "MUSE_GLIMMER_API_KEY"): + secret = os.getenv(secret_name, "") + if secret: + replacements[secret] = "" + if config is not None: + replacements[config.muse_glimmer_api_key] = "" + for path in ( + config.parakeet_helper_path, + config.parakeet_model_path, + config.parakeet_tokenizer_path, + config.parakeet_delegate_data_path, + config.supertonic_runner_path, + config.supertonic_pte_path, + config.supertonic_asset_dir, + config.supertonic_voice_style_path, + ): + if path is not None: + replacements[str(path)] = f".../{path.name}" + for original, replacement in sorted( + replacements.items(), key=lambda item: len(item[0]), reverse=True + ): + if original: + value = value.replace(original, replacement) + return value + + +def _failure(exc: BaseException, config: GlimmerConfig | None) -> dict[str, object]: + return { + "type": type(exc).__name__, + "message": _redact_text(str(exc), config), + "traceback": _redact_text("".join(traceback.format_exception(exc)), config), + } + + +async def _run_doctor(args: argparse.Namespace) -> int: + report_dir = _prepare_report_dir("doctor", args.report_dir) + runtime_handler = _attach_runtime_log(report_dir) + reporter = JsonlReporter(report_dir) + durations: dict[str, float] = {} + cleanup: ProviderCleanup | None = None + config: GlimmerConfig | None = None + summary: dict[str, object] = { + "command": "doctor", + "status": "failed", + "system": _system_snapshot(), + "durations_seconds": durations, + } + exit_code = 1 + try: + with StageTimer(reporter, "configuration", durations): + config = GlimmerConfig.from_env() + _set_runtime_log_config(runtime_handler, config) + summary["configuration"] = _redacted_config(config) + + with StageTimer(reporter, "muse_glimmer_http", durations): + base = config.muse_glimmer_base_url.removesuffix("/v1") + health, models = await asyncio.gather( + asyncio.to_thread( + _http_json, f"{base}/health", config.muse_glimmer_api_key, args.timeout + ), + asyncio.to_thread( + _http_json, + f"{config.muse_glimmer_base_url}/models", + config.muse_glimmer_api_key, + args.timeout, + ), + ) + if not isinstance(health, dict) or health.get("status") != "ok": + raise RuntimeError(f"MuseGlimmer health check did not return status=ok: {health!r}") + if not isinstance(models, dict) or not isinstance(models.get("data"), list): + raise RuntimeError(f"MuseGlimmer models response is invalid: {models!r}") + model_ids = {item.get("id") for item in models["data"] if isinstance(item, dict)} + if config.muse_glimmer_model_id not in model_ids: + raise RuntimeError( + f"MuseGlimmer model is missing from /v1/models: {config.muse_glimmer_model_id}" + ) + summary["muse_glimmer_http"] = {"health": health, "models": models} + + providers = create_local_providers(config) + cleanup = _provider_cleanup(providers) + _attach_provider_reporting(providers, reporter, config) + connect_options = APIConnectOptions(max_retry=0, timeout=args.timeout) + + with StageTimer(reporter, "parakeet_startup", durations): + async with asyncio.timeout(args.timeout): + await providers.parakeet.start() + + with StageTimer(reporter, "supertonic_synthesis", durations): + async with asyncio.timeout(args.timeout): + first = await _probe_synthesized_audio( + providers.supertonic.synthesize( + "Hello from Glimmer.", conn_options=connect_options + ) + ) + process = providers.supertonic._process + first_pid = process.pid if process is not None else None + second = await _probe_synthesized_audio( + providers.supertonic.synthesize( + "The warm voice process is reusable.", conn_options=connect_options + ) + ) + if ( + first_pid is None + or providers.supertonic._process is None + or providers.supertonic._process.pid != first_pid + ): + raise RuntimeError("Supertonic did not reuse one warm process") + summary["supertonic_probe"] = { + "process_reused": True, + "utterances": [first, second], + } + + summary["status"] = "passed" + exit_code = 0 + reporter.emit("doctor_completed", message="all deployment checks passed") + except Exception as exc: + error = _failure(exc, config) + summary["error"] = error + reporter.emit("doctor_failed", error_type=type(exc).__name__, message=error["message"]) + finally: + if cleanup is not None: + await cleanup.close() + _write_json(report_dir / "report.json", summary) + reporter.close() + _detach_runtime_log(runtime_handler) + bundle = _create_issue_bundle(report_dir) + print( + json.dumps( + {"status": summary["status"], "report_dir": str(report_dir), "bundle": str(bundle)} + ) + ) + return exit_code + + +async def _run_pipeline(args: argparse.Namespace) -> int: + report_dir = _prepare_report_dir("pipeline", args.report_dir) + runtime_handler = _attach_runtime_log(report_dir) + reporter = JsonlReporter(report_dir) + durations: dict[str, float] = {} + cleanup: ProviderCleanup | None = None + config: GlimmerConfig | None = None + output_path = (args.output_wav or (report_dir / "response.wav")).expanduser().resolve() + summary: dict[str, object] = { + "command": "pipeline", + "status": "failed", + "system": _system_snapshot(), + "durations_seconds": durations, + "content_included": bool(args.include_content), + } + exit_code = 1 + transcript = "" + response_text = "" + try: + with StageTimer(reporter, "configuration", durations): + config = GlimmerConfig.from_env() + _set_runtime_log_config(runtime_handler, config) + summary["configuration"] = _redacted_config(config) + + with StageTimer(reporter, "input_wav", durations): + input_frames, input_metadata = _read_pcm_wav(args.input_wav) + summary["input_audio"] = input_metadata + + with StageTimer(reporter, "provider_startup", durations): + providers = create_local_providers(config) + cleanup = _provider_cleanup(providers) + _attach_provider_reporting(providers, reporter, config) + async with asyncio.timeout(args.timeout): + await providers.parakeet.start() + + connect_options = APIConnectOptions(max_retry=0, timeout=args.timeout) + with StageTimer(reporter, "stt", durations): + async with asyncio.timeout(args.timeout): + speech = await providers.parakeet.recognize( + input_frames, + language=config.language, + conn_options=connect_options, + ) + if not speech.alternatives: + raise RuntimeError("Parakeet returned no transcript alternatives") + transcript = speech.alternatives[0].text.strip() + if not transcript: + raise RuntimeError("Parakeet returned an empty transcript") + summary["transcript_chars"] = len(transcript) + + with StageTimer(reporter, "llm", durations): + chat_context = llm.ChatContext() + chat_context.add_message(role="system", content=config.instructions) + chat_context.add_message(role="user", content=transcript) + async with asyncio.timeout(args.timeout): + completion = await providers.llm.chat( + chat_ctx=chat_context, + conn_options=connect_options, + ).collect() + if completion.tool_calls: + raise RuntimeError("MuseGlimmer returned tool calls in the no-tools CLI pipeline") + response_text = completion.text.strip() + if not response_text: + raise RuntimeError("MuseGlimmer returned an empty response") + summary["response_chars"] = len(response_text) + if completion.usage is not None: + summary["llm_usage"] = completion.usage.model_dump(mode="json") + + with StageTimer(reporter, "tts", durations): + async with asyncio.timeout(args.timeout): + audio_metadata = await _write_synthesized_wav( + providers.supertonic.synthesize(response_text, conn_options=connect_options), + output_path, + force=args.force, + ) + summary["output_audio"] = audio_metadata + + if args.include_content: + summary["transcript"] = transcript + summary["response_text"] = response_text + summary["status"] = "passed" + exit_code = 0 + reporter.emit("pipeline_completed", message="end-to-end pipeline passed") + except Exception as exc: + error = _failure(exc, config) + summary["error"] = error + reporter.emit("pipeline_failed", error_type=type(exc).__name__, message=error["message"]) + finally: + if cleanup is not None: + await cleanup.close() + _write_json(report_dir / "report.json", summary) + reporter.close() + _detach_runtime_log(runtime_handler) + bundle = _create_issue_bundle(report_dir) + result: dict[str, object] = { + "status": summary["status"], + "report_dir": str(report_dir), + "bundle": str(bundle), + } + if exit_code == 0: + result.update( + { + "transcript": transcript, + "response": response_text, + "output_wav": str(output_path), + } + ) + print(json.dumps(result, indent=2, ensure_ascii=True)) + return exit_code + + +def _console_command(args: argparse.Namespace) -> list[str]: + command = [ + sys.executable, + "-m", + "muse_glimmer_worker", + "console", + "--log-level", + args.console_log_level, + ] + if args.input_device: + command.extend(("--input-device", args.input_device)) + if args.output_device: + command.extend(("--output-device", args.output_device)) + if args.list_devices: + command.append("--list-devices") + if args.text: + command.append("--text") + if args.record: + command.append("--record") + return command + + +def _exec_console(args: argparse.Namespace) -> int: + command = _console_command(args) + os.execv(sys.executable, command) + return 127 + + +def run_worker() -> None: + worker_main() + + +def main(argv: list[str] | None = None) -> int: + parser = _parser() + args = parser.parse_args(argv) + logging.basicConfig( + level=getattr(logging, args.log_level), + format="%(asctime)s %(levelname)s %(name)s %(message)s", + ) + if getattr(args, "timeout", 1.0) <= 0: + parser.error("--timeout must be positive") + if args.command == "console": + return _exec_console(args) + try: + if args.command == "doctor": + return asyncio.run(_run_doctor(args)) + if args.command == "pipeline": + return asyncio.run(_run_pipeline(args)) + except KeyboardInterrupt: + logger.error("interrupted") + return 130 + parser.error(f"unsupported command: {args.command}") + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/muse_glimmer/macos/apps/worker/src/muse_glimmer_worker/config.py b/muse_glimmer/macos/apps/worker/src/muse_glimmer_worker/config.py new file mode 100644 index 0000000000..e0fa90e367 --- /dev/null +++ b/muse_glimmer/macos/apps/worker/src/muse_glimmer_worker/config.py @@ -0,0 +1,222 @@ +from __future__ import annotations + +import math +import os +from dataclasses import dataclass +from pathlib import Path + +from livekit.agents import llm, stt, tts, vad +from livekit.plugins import executorch, openai + +LIVEKIT_URL = "ws://127.0.0.1:7880" +MUSE_GLIMMER_BASE_URL = "http://127.0.0.1:8000/v1" +REASONING_STRENGTH = "low" + + +@dataclass(frozen=True, slots=True) +class GlimmerConfig: + agent_name: str + instructions: str + language: str + livekit_url: str + livekit_api_key: str + livekit_api_secret: str + parakeet_helper_path: Path + parakeet_model_path: Path + parakeet_tokenizer_path: Path + parakeet_delegate_data_path: Path | None + muse_glimmer_base_url: str + muse_glimmer_model_id: str + muse_glimmer_api_key: str + muse_glimmer_temperature: float + muse_glimmer_max_tokens: int + supertonic_runner_path: Path + supertonic_pte_path: Path + supertonic_asset_dir: Path + supertonic_voice_style_path: Path + supertonic_speed: float + supertonic_seed: int + + @classmethod + def from_env(cls) -> GlimmerConfig: + return cls( + agent_name=_env("GLIMMER_AGENT_NAME", "assistant"), + instructions=_env( + "GLIMMER_AGENT_INSTRUCTIONS", + "You are Glimmer, a concise and friendly voice assistant. " + "Answer naturally for speech. Do not use markdown, emoji, or long lists.", + ), + language=_env("GLIMMER_LANGUAGE", "en"), + livekit_url=_exact_env("LIVEKIT_URL", LIVEKIT_URL), + livekit_api_key=_required_env("LIVEKIT_API_KEY"), + livekit_api_secret=_required_env("LIVEKIT_API_SECRET"), + parakeet_helper_path=_required_file("PARAKEET_HELPER_PATH", executable=True), + parakeet_model_path=_required_file("PARAKEET_MODEL_PATH"), + parakeet_tokenizer_path=_required_file("PARAKEET_TOKENIZER_PATH"), + parakeet_delegate_data_path=_optional_file("PARAKEET_DELEGATE_DATA_PATH"), + muse_glimmer_base_url=_exact_env("MUSE_GLIMMER_BASE_URL", MUSE_GLIMMER_BASE_URL), + muse_glimmer_model_id=_env( + "MUSE_GLIMMER_MODEL_ID", + "muse-glimmer-k-quant-17G-128K-text-dflash-metal", + ), + muse_glimmer_api_key=_env("MUSE_GLIMMER_API_KEY", "local"), + muse_glimmer_temperature=_finite_float("MUSE_GLIMMER_TEMPERATURE", 0.0), + muse_glimmer_max_tokens=_positive_int("MUSE_GLIMMER_MAX_TOKENS", 128), + supertonic_runner_path=_required_file("SUPERTONIC_RUNNER_PATH", executable=True), + supertonic_pte_path=_required_file("SUPERTONIC_PTE_PATH"), + supertonic_asset_dir=_required_directory("SUPERTONIC_ASSET_DIR"), + supertonic_voice_style_path=_required_file("SUPERTONIC_VOICE_STYLE_PATH"), + supertonic_speed=_positive_float("SUPERTONIC_SPEED", 1.05), + supertonic_seed=_non_negative_int("SUPERTONIC_SEED", 42), + ) + + +@dataclass(frozen=True, slots=True) +class LocalProviders: + parakeet: executorch.STT + llm: llm.LLM + supertonic: executorch.SupertonicTTS + + +@dataclass(frozen=True, slots=True) +class Providers: + parakeet: executorch.STT + session_stt: stt.STT + llm: llm.LLM + supertonic: executorch.SupertonicTTS + session_tts: tts.TTS + + +def create_local_providers(config: GlimmerConfig) -> LocalProviders: + parakeet = executorch.STT( + helper_path=config.parakeet_helper_path, + model_path=config.parakeet_model_path, + tokenizer_path=config.parakeet_tokenizer_path, + delegate_data_path=config.parakeet_delegate_data_path, + language=config.language, + ) + supertonic = executorch.SupertonicTTS( + runner_path=config.supertonic_runner_path, + pte_path=config.supertonic_pte_path, + asset_dir=config.supertonic_asset_dir, + voice_style_path=config.supertonic_voice_style_path, + language=config.language, + speed=config.supertonic_speed, + seed=config.supertonic_seed, + ) + muse_glimmer = openai.LLM( + model=config.muse_glimmer_model_id, + api_key=config.muse_glimmer_api_key, + base_url=config.muse_glimmer_base_url, + temperature=config.muse_glimmer_temperature, + max_completion_tokens=config.muse_glimmer_max_tokens, + extra_body={ + "chat_template_kwargs": { + "reasoning_strength": REASONING_STRENGTH, + }, + }, + ) + return LocalProviders(parakeet=parakeet, llm=muse_glimmer, supertonic=supertonic) + + +def create_providers(config: GlimmerConfig, *, voice_activity_detector: vad.VAD) -> Providers: + local = create_local_providers(config) + return Providers( + parakeet=local.parakeet, + session_stt=stt.StreamAdapter(stt=local.parakeet, vad=voice_activity_detector), + llm=local.llm, + supertonic=local.supertonic, + session_tts=local.supertonic, + ) + + +def _env(name: str, default: str) -> str: + value = os.getenv(name, default).strip() + if not value: + raise ValueError(f"{name} must be non-empty") + return value + + +def _required_env(name: str) -> str: + value = os.getenv(name, "").strip() + if not value: + raise ValueError(f"{name} must be set and non-empty") + return value + + +def _exact_env(name: str, expected: str) -> str: + value = _env(name, expected) + if value != expected: + raise ValueError(f"{name} must be exactly {expected}") + return value + + +def _optional_env(name: str) -> str | None: + value = os.getenv(name, "").strip() + return value or None + + +def _required_file(name: str, *, executable: bool = False) -> Path: + value = _optional_env(name) + if value is None: + raise ValueError(f"{name} must be set") + path = Path(value).expanduser().resolve() + if not path.is_file(): + raise ValueError(f"{name} must point to a file: {path}") + if executable and not os.access(path, os.X_OK): + raise ValueError(f"{name} must point to an executable file: {path}") + return path + + +def _required_directory(name: str) -> Path: + value = _optional_env(name) + if value is None: + raise ValueError(f"{name} must be set") + path = Path(value).expanduser().resolve() + if not path.is_dir(): + raise ValueError(f"{name} must point to a directory: {path}") + return path + + +def _optional_file(name: str) -> Path | None: + if _optional_env(name) is None: + return None + return _required_file(name) + + +def _finite_float(name: str, default: float) -> float: + raw = _env(name, str(default)) + try: + value = float(raw) + except ValueError as exc: + raise ValueError(f"{name} must be a number") from exc + if not math.isfinite(value): + raise ValueError(f"{name} must be finite") + return value + + +def _positive_float(name: str, default: float) -> float: + value = _finite_float(name, default) + if value <= 0.0: + raise ValueError(f"{name} must be positive") + return value + + +def _positive_int(name: str, default: int) -> int: + return _bounded_int(name, default, minimum=1) + + +def _non_negative_int(name: str, default: int) -> int: + return _bounded_int(name, default, minimum=0) + + +def _bounded_int(name: str, default: int, *, minimum: int) -> int: + raw = _env(name, str(default)) + try: + value = int(raw) + except ValueError as exc: + raise ValueError(f"{name} must be an integer") from exc + if value < minimum: + constraint = "positive" if minimum == 1 else "non-negative" + raise ValueError(f"{name} must be {constraint}") + return value diff --git a/muse_glimmer/macos/apps/worker/src/muse_glimmer_worker/lifecycle.py b/muse_glimmer/macos/apps/worker/src/muse_glimmer_worker/lifecycle.py new file mode 100644 index 0000000000..62588eae74 --- /dev/null +++ b/muse_glimmer/macos/apps/worker/src/muse_glimmer_worker/lifecycle.py @@ -0,0 +1,67 @@ +from __future__ import annotations + +import asyncio +import logging +from typing import Protocol + + +class AsyncCloseable(Protocol): + async def aclose(self) -> None: ... + + +class ProviderCleanup: + """Close every local provider once and let repeated callers await the result.""" + + def __init__( + self, + *, + llm: AsyncCloseable, + parakeet: AsyncCloseable, + supertonic: AsyncCloseable, + logger: logging.Logger, + ) -> None: + self._providers = ( + ("LLM", llm), + ("Parakeet", parakeet), + ("Supertonic", supertonic), + ) + self._logger = logger + self._lock = asyncio.Lock() + self._cleanup_task: asyncio.Task[None] | None = None + + @property + def closed(self) -> bool: + return self._cleanup_task is not None and self._cleanup_task.done() + + async def close(self) -> None: + async with self._lock: + if self._cleanup_task is None: + self._cleanup_task = asyncio.create_task( + self._close_providers(), name="muse-glimmer-provider-cleanup" + ) + cleanup_task = self._cleanup_task + + # Shutdown must finish even if its original caller is cancelled repeatedly. + while not cleanup_task.done(): + try: + await asyncio.shield(cleanup_task) + except asyncio.CancelledError: + continue + cleanup_task.result() + + async def _close_providers(self) -> None: + for name, _ in self._providers: + self._logger.info("Closing %s provider", name) + results = await asyncio.gather( + *(provider.aclose() for _, provider in self._providers), + return_exceptions=True, + ) + for (name, _), result in zip(self._providers, results, strict=True): + if isinstance(result, BaseException): + self._logger.error( + "Failed to close %s provider: %s", + name, + result, + ) + else: + self._logger.info("Closed %s provider", name) diff --git a/muse_glimmer/macos/apps/worker/tests/conftest.py b/muse_glimmer/macos/apps/worker/tests/conftest.py new file mode 100644 index 0000000000..3246574e56 --- /dev/null +++ b/muse_glimmer/macos/apps/worker/tests/conftest.py @@ -0,0 +1,10 @@ +from __future__ import annotations + +import sys +from pathlib import Path + +_WORKER_SRC = Path(__file__).parents[1] / "src" +_PLUGIN_ROOT = Path(__file__).parents[3] / "packages" / "livekit-plugins-executorch" +for path in (str(_WORKER_SRC), str(_PLUGIN_ROOT)): + if path not in sys.path: + sys.path.insert(0, path) diff --git a/muse_glimmer/macos/apps/worker/tests/test_agent.py b/muse_glimmer/macos/apps/worker/tests/test_agent.py new file mode 100644 index 0000000000..ecf2262562 --- /dev/null +++ b/muse_glimmer/macos/apps/worker/tests/test_agent.py @@ -0,0 +1,105 @@ +from __future__ import annotations + +from types import SimpleNamespace + +import pytest + +from muse_glimmer_worker import agent + + +class Closeable: + def __init__(self) -> None: + self.calls = 0 + + async def aclose(self) -> None: + self.calls += 1 + + +class FailingParakeet(Closeable): + async def start(self) -> None: + raise RuntimeError("startup failed") + + +class FakeContext: + def __init__(self) -> None: + self.room = SimpleNamespace(name="room") + self.proc = SimpleNamespace(userdata={agent._VAD_KEY: object()}) + self.log_context_fields = {} + self.shutdown_callback = None + + def add_shutdown_callback(self, callback) -> None: + self.shutdown_callback = callback + + +async def test_startup_failure_and_shutdown_callback_cleanup_once( + monkeypatch: pytest.MonkeyPatch, +) -> None: + parakeet = FailingParakeet() + model = Closeable() + supertonic = Closeable() + providers = SimpleNamespace( + parakeet=parakeet, + llm=model, + supertonic=supertonic, + session_stt=object(), + session_tts=object(), + ) + monkeypatch.setattr(agent.GlimmerConfig, "from_env", lambda: SimpleNamespace(agent_name="a")) + monkeypatch.setattr(agent, "create_providers", lambda *args, **kwargs: providers) + context = FakeContext() + + with pytest.raises(RuntimeError, match="startup failed"): + await agent.entrypoint(context) + assert context.shutdown_callback is not None + await context.shutdown_callback() + + assert (model.calls, parakeet.calls, supertonic.calls) == (1, 1, 1) + + +async def test_normal_shutdown_callback_cleanup_once( + monkeypatch: pytest.MonkeyPatch, +) -> None: + class StartedParakeet(Closeable): + async def start(self) -> None: + return None + + class FakeSession: + def __class_getitem__(cls, item): + return cls + + def __init__(self, **kwargs) -> None: + pass + + def on(self, event_name): + return lambda callback: callback + + async def start(self, **kwargs) -> None: + return None + + parakeet = StartedParakeet() + model = Closeable() + supertonic = Closeable() + providers = SimpleNamespace( + parakeet=parakeet, + llm=model, + supertonic=supertonic, + session_stt=object(), + session_tts=object(), + ) + config = SimpleNamespace(agent_name="a", instructions="Answer briefly.") + monkeypatch.setattr(agent.GlimmerConfig, "from_env", lambda: config) + monkeypatch.setattr(agent, "create_providers", lambda *args, **kwargs: providers) + monkeypatch.setattr(agent, "AgentSession", FakeSession) + context = FakeContext() + + await agent.entrypoint(context) + assert context.shutdown_callback is not None + await context.shutdown_callback() + await context.shutdown_callback() + + assert (model.calls, parakeet.calls, supertonic.calls) == (1, 1, 1) + + +def test_worker_is_loopback_only_with_neutral_agent_name() -> None: + assert agent.server._host == "127.0.0.1" + assert agent.server._agent_name == "assistant" diff --git a/muse_glimmer/macos/apps/worker/tests/test_cli.py b/muse_glimmer/macos/apps/worker/tests/test_cli.py new file mode 100644 index 0000000000..e89df262f9 --- /dev/null +++ b/muse_glimmer/macos/apps/worker/tests/test_cli.py @@ -0,0 +1,69 @@ +from __future__ import annotations + +import argparse +import sys +import zipfile +from pathlib import Path +from types import SimpleNamespace + +from muse_glimmer_worker import cli + + +def test_console_command_uses_installed_module() -> None: + args = argparse.Namespace( + input_device="Built-in Mic", + output_device="Built-in Output", + list_devices=True, + text=True, + record=False, + console_log_level="info", + ) + assert cli._console_command(args) == [ + sys.executable, + "-m", + "muse_glimmer_worker", + "console", + "--log-level", + "info", + "--input-device", + "Built-in Mic", + "--output-device", + "Built-in Output", + "--list-devices", + "--text", + ] + + +def test_issue_bundle_omits_runtime_log(tmp_path: Path) -> None: + for name in ("report.json", "events.jsonl", "runtime.log"): + (tmp_path / name).write_text(name) + + bundle = cli._create_issue_bundle(tmp_path) + + with zipfile.ZipFile(bundle) as archive: + assert set(archive.namelist()) == {"report.json", "events.jsonl"} + + +def test_provider_metrics_redact_nested_artifact_paths(tmp_path: Path) -> None: + model = tmp_path / "private" / "model.pte" + config = SimpleNamespace( + muse_glimmer_api_key="local-secret", + parakeet_helper_path=tmp_path / "bin" / "parakeet_helper", + parakeet_model_path=model, + parakeet_tokenizer_path=tmp_path / "private" / "tokenizer.model", + parakeet_delegate_data_path=None, + supertonic_runner_path=tmp_path / "bin" / "supertonic_runner", + supertonic_pte_path=tmp_path / "private" / "supertonic.pte", + supertonic_asset_dir=tmp_path / "private" / "assets", + supertonic_voice_style_path=tmp_path / "private" / "voice.json", + ) + + redacted = cli._redact_payload( + {"metadata": {"model_name": model}, "details": ["local-secret"]}, + config, + ) + + serialized = str(redacted) + assert str(tmp_path) not in serialized + assert "local-secret" not in serialized + assert ".../model.pte" in serialized diff --git a/muse_glimmer/macos/apps/worker/tests/test_config.py b/muse_glimmer/macos/apps/worker/tests/test_config.py new file mode 100644 index 0000000000..c248536a08 --- /dev/null +++ b/muse_glimmer/macos/apps/worker/tests/test_config.py @@ -0,0 +1,92 @@ +from __future__ import annotations + +import os +from pathlib import Path +from typing import Any + +import pytest + +from muse_glimmer_worker import config as config_module + + +def _set_required(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + monkeypatch.setenv("LIVEKIT_API_KEY", "local-key") + monkeypatch.setenv("LIVEKIT_API_SECRET", "local-secret") + for name in ( + "PARAKEET_HELPER_PATH", + "PARAKEET_MODEL_PATH", + "PARAKEET_TOKENIZER_PATH", + "SUPERTONIC_RUNNER_PATH", + "SUPERTONIC_PTE_PATH", + "SUPERTONIC_VOICE_STYLE_PATH", + ): + path = tmp_path / name.lower() + path.write_bytes(b"test") + if name.endswith(("HELPER_PATH", "RUNNER_PATH")): + path.chmod(0o755) + monkeypatch.setenv(name, str(path)) + assets = tmp_path / "supertonic-assets" + assets.mkdir() + monkeypatch.setenv("SUPERTONIC_ASSET_DIR", str(assets)) + + +def test_config_accepts_only_fixed_local_endpoints( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + _set_required(monkeypatch, tmp_path) + config = config_module.GlimmerConfig.from_env() + assert config.livekit_url == "ws://127.0.0.1:7880" + assert config.muse_glimmer_base_url == "http://127.0.0.1:8000/v1" + + monkeypatch.setenv("LIVEKIT_URL", "ws://localhost:7880") + with pytest.raises(ValueError, match="must be exactly"): + config_module.GlimmerConfig.from_env() + monkeypatch.setenv("LIVEKIT_URL", config_module.LIVEKIT_URL) + monkeypatch.setenv("MUSE_GLIMMER_BASE_URL", "https://example.com/v1") + with pytest.raises(ValueError, match="must be exactly"): + config_module.GlimmerConfig.from_env() + + +def test_config_requires_credentials(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + _set_required(monkeypatch, tmp_path) + monkeypatch.delenv("LIVEKIT_API_SECRET") + with pytest.raises(ValueError, match="LIVEKIT_API_SECRET"): + config_module.GlimmerConfig.from_env() + + +def test_llm_uses_reasoning_strength_low_without_reasoning_effort( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + _set_required(monkeypatch, tmp_path) + captured: dict[str, Any] = {} + + class FakeSTT: + def __init__(self, **kwargs: object) -> None: + captured["stt"] = kwargs + + class FakeTTS: + def __init__(self, **kwargs: object) -> None: + captured["tts"] = kwargs + + class FakeLLM: + def __init__(self, **kwargs: object) -> None: + captured["llm"] = kwargs + + monkeypatch.setattr(config_module.executorch, "STT", FakeSTT) + monkeypatch.setattr(config_module.executorch, "SupertonicTTS", FakeTTS) + monkeypatch.setattr(config_module.openai, "LLM", FakeLLM) + config_module.create_local_providers(config_module.GlimmerConfig.from_env()) + + llm_options = captured["llm"] + assert llm_options["extra_body"] == {"chat_template_kwargs": {"reasoning_strength": "low"}} + assert "reasoning_effort" not in llm_options + assert "reasoning_effort" not in repr(llm_options) + + +def test_environment_cannot_override_reasoning_strength( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + _set_required(monkeypatch, tmp_path) + monkeypatch.setenv("MUSE_GLIMMER_REASONING_STRENGTH", "high") + assert config_module.REASONING_STRENGTH == "low" + assert "MUSE_GLIMMER_REASONING_STRENGTH" in os.environ diff --git a/muse_glimmer/macos/apps/worker/tests/test_lifecycle.py b/muse_glimmer/macos/apps/worker/tests/test_lifecycle.py new file mode 100644 index 0000000000..62f63c840f --- /dev/null +++ b/muse_glimmer/macos/apps/worker/tests/test_lifecycle.py @@ -0,0 +1,71 @@ +from __future__ import annotations + +import asyncio +import logging + +from muse_glimmer_worker.lifecycle import ProviderCleanup + + +class Closeable: + def __init__(self, *, error: Exception | None = None) -> None: + self.calls = 0 + self.error = error + + async def aclose(self) -> None: + self.calls += 1 + await asyncio.sleep(0) + if self.error is not None: + raise self.error + + +async def test_cleanup_attempts_every_provider_exactly_once( + caplog, +) -> None: + model = Closeable(error=RuntimeError("llm close failed")) + parakeet = Closeable() + supertonic = Closeable() + cleanup = ProviderCleanup( + llm=model, + parakeet=parakeet, + supertonic=supertonic, + logger=logging.getLogger("cleanup-test"), + ) + + with caplog.at_level(logging.INFO): + await asyncio.gather(cleanup.close(), cleanup.close(), cleanup.close()) + + assert cleanup.closed + assert (model.calls, parakeet.calls, supertonic.calls) == (1, 1, 1) + assert "Closing LLM provider" in caplog.text + assert "Failed to close LLM provider: llm close failed" in caplog.text + assert "Closed Parakeet provider" in caplog.text + assert "Closed Supertonic provider" in caplog.text + + +async def test_cleanup_survives_caller_cancellation() -> None: + release = asyncio.Event() + + class BlockingCloseable(Closeable): + async def aclose(self) -> None: + self.calls += 1 + await release.wait() + + model = BlockingCloseable() + parakeet = BlockingCloseable() + supertonic = BlockingCloseable() + cleanup = ProviderCleanup( + llm=model, + parakeet=parakeet, + supertonic=supertonic, + logger=logging.getLogger("cleanup-cancellation-test"), + ) + first = asyncio.create_task(cleanup.close()) + await asyncio.sleep(0) + first.cancel() + release.set() + + await first + await cleanup.close() + + assert cleanup.closed + assert (model.calls, parakeet.calls, supertonic.calls) == (1, 1, 1) diff --git a/muse_glimmer/macos/artifacts/README.md b/muse_glimmer/macos/artifacts/README.md new file mode 100644 index 0000000000..eaedd12f6c --- /dev/null +++ b/muse_glimmer/macos/artifacts/README.md @@ -0,0 +1,12 @@ +# Local artifacts + +This directory contains manifests only. Models, tokenizers, voice styles, +exported programs, and native binaries live under ignored `.local/artifacts/`. + +Each artifact is governed by its own upstream license. The BSD-3-Clause +license for product-owned source does not apply to those artifacts. Run +`make prepare-artifacts` after reviewing the licenses and providing any +artifacts marked `user-provided` in `macos-arm64.lock.json`. + +Preparation verifies checksums and writes `.local/state/prepared.json`. Daily +startup consumes that receipt and never downloads, builds, or exports assets. diff --git a/muse_glimmer/macos/artifacts/macos-arm64.lock.json b/muse_glimmer/macos/artifacts/macos-arm64.lock.json new file mode 100644 index 0000000000..e4b7520f9b --- /dev/null +++ b/muse_glimmer/macos/artifacts/macos-arm64.lock.json @@ -0,0 +1,146 @@ +{ + "schema_version": 1, + "platform": "macos-arm64", + "artifacts": [ + { + "role": "parakeet_helper", + "kind": "file", + "executable": true, + "distribution": "build", + "source": "executorch", + "revision": null, + "license": "BSD-3-Clause", + "destination": ".local/artifacts/bin/parakeet_helper", + "sensitive": true, + "sha256": null, + "size_bytes": null, + "prepare": "Build from the single pinned ExecuTorch checkout." + }, + { + "role": "parakeet_model", + "kind": "file", + "executable": false, + "distribution": "user-provided", + "source": null, + "revision": null, + "license": "See upstream model terms", + "destination": ".local/artifacts/parakeet/model.pte", + "sensitive": true, + "sha256": null, + "size_bytes": null, + "prepare": "Place a licensed compatible artifact and record its checksum locally." + }, + { + "role": "parakeet_tokenizer", + "kind": "file", + "executable": false, + "distribution": "user-provided", + "source": null, + "revision": null, + "license": "See upstream model terms", + "destination": ".local/artifacts/parakeet/tokenizer.model", + "sensitive": true, + "sha256": null, + "size_bytes": null, + "prepare": "Place a licensed compatible tokenizer and record its checksum locally." + }, + { + "role": "muse_glimmer_worker", + "kind": "file", + "executable": true, + "distribution": "build", + "source": "executorch", + "revision": null, + "license": "BSD-3-Clause", + "destination": ".local/artifacts/bin/muse_glimmer_worker", + "sensitive": true, + "sha256": null, + "size_bytes": null, + "prepare": "Build from the single pinned ExecuTorch checkout." + }, + { + "role": "muse_glimmer_model", + "kind": "file", + "executable": false, + "distribution": "user-provided", + "source": null, + "revision": null, + "license": "See upstream model terms", + "destination": ".local/artifacts/muse-glimmer/model.pte", + "sensitive": true, + "sha256": null, + "size_bytes": null, + "prepare": "Place a licensed compatible artifact and record its checksum locally." + }, + { + "role": "muse_glimmer_tokenizer", + "kind": "file", + "executable": false, + "distribution": "user-provided", + "source": null, + "revision": null, + "license": "See upstream model terms", + "destination": ".local/artifacts/muse-glimmer/tokenizer.json", + "sensitive": true, + "sha256": null, + "size_bytes": null, + "prepare": "Place a licensed compatible tokenizer and record its checksum locally." + }, + { + "role": "supertonic_runner", + "kind": "file", + "executable": true, + "distribution": "build", + "source": "executorch", + "revision": null, + "license": "BSD-3-Clause", + "destination": ".local/artifacts/bin/supertonic_runner", + "sensitive": true, + "sha256": null, + "size_bytes": null, + "prepare": "Build from the single pinned ExecuTorch checkout." + }, + { + "role": "supertonic_model", + "kind": "file", + "executable": false, + "distribution": "build", + "source": "supertonic", + "revision": null, + "license": "See upstream model terms", + "destination": ".local/artifacts/supertonic/model.pte", + "sensitive": true, + "sha256": null, + "size_bytes": null, + "prepare": "Export from approved Supertonic assets during artifact preparation." + }, + { + "role": "supertonic_assets", + "kind": "directory", + "executable": false, + "distribution": "user-provided", + "source": null, + "revision": null, + "license": "See upstream asset terms", + "destination": ".local/artifacts/supertonic/assets", + "sensitive": true, + "sha256": null, + "size_bytes": null, + "prepare": "Place licensed assets and record their tree checksum locally." + }, + { + "role": "supertonic_voice_style", + "kind": "file", + "executable": false, + "distribution": "user-provided", + "source": null, + "revision": null, + "license": "See upstream asset terms", + "destination": ".local/artifacts/supertonic/voice-style.json", + "sensitive": true, + "sha256": null, + "size_bytes": null, + "prepare": "Place one licensed batch-1 voice style and record its checksum locally." + } + ] +} diff --git a/muse_glimmer/macos/artifacts/manifest.schema.json b/muse_glimmer/macos/artifacts/manifest.schema.json new file mode 100644 index 0000000000..52a336a8af --- /dev/null +++ b/muse_glimmer/macos/artifacts/manifest.schema.json @@ -0,0 +1,34 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://example.invalid/muse-glimmer-voice-agent/artifact-manifest.schema.json", + "title": "Muse Glimmer voice agent artifact manifest", + "type": "object", + "additionalProperties": false, + "required": ["schema_version", "platform", "artifacts"], + "properties": { + "schema_version": {"const": 1}, + "platform": {"const": "macos-arm64"}, + "artifacts": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "required": ["role", "kind", "executable", "distribution", "license", "destination", "sensitive", "sha256"], + "properties": { + "role": {"type": "string", "minLength": 1}, + "kind": {"enum": ["file", "directory"]}, + "executable": {"type": "boolean"}, + "distribution": {"enum": ["build", "download", "user-provided"]}, + "source": {"type": ["string", "null"]}, + "revision": {"type": ["string", "null"]}, + "license": {"type": "string", "minLength": 1}, + "destination": {"type": "string", "pattern": "^\\.local/artifacts/"}, + "sensitive": {"type": "boolean"}, + "sha256": {"type": ["string", "null"], "pattern": "^[0-9a-f]{64}$"}, + "size_bytes": {"type": ["integer", "null"], "minimum": 1}, + "prepare": {"type": ["string", "null"]} + } + } + } + } +} diff --git a/muse_glimmer/macos/config/dependencies/compatibility.lock.json b/muse_glimmer/macos/config/dependencies/compatibility.lock.json new file mode 100644 index 0000000000..580a5a3c37 --- /dev/null +++ b/muse_glimmer/macos/config/dependencies/compatibility.lock.json @@ -0,0 +1,39 @@ +{ + "schema_version": 1, + "status": "development-gated", + "platform": "macos-arm64", + "executorch": { + "repository": "https://github.com/pytorch/executorch.git", + "commit": null, + "required_capabilities": [ + "parakeet_persistent_helper", + "muse_glimmer_dflash_mlx", + "supports_cancel", + "supertonic_server_jsonl" + ], + "gates": { + "supertonic_runtime": { + "status": "landed", + "pull_request": "https://github.com/pytorch/executorch/pull/22063", + "commit": "81969a92dd2e5515fa23ccdf9d87346cf3ba2ba2" + }, + "supports_cancel": { + "status": "landed", + "pull_request": "https://github.com/pytorch/executorch/pull/22070", + "commit": "5bd86e50fcd986999e4c09b82de040a3ba224466" + }, + "supertonic_server_jsonl": { + "status": "pending", + "pull_request": "https://github.com/pytorch/executorch/pull/22208", + "commit": null, + "blocker": "Merge the protocol-v1 server with a ready frame that reports sample_rate 44100." + } + } + }, + "livekit": { + "agents_requirement": ">=1.6.9,<2", + "server_requirement": ">=1.9,<2" + }, + "ready_for_release": false, + "release_blocker": "Land the persistent Supertonic JSONL protocol and select one verified descendant ExecuTorch commit." +} diff --git a/muse_glimmer/macos/config/dependencies/toolchain.lock.json b/muse_glimmer/macos/config/dependencies/toolchain.lock.json new file mode 100644 index 0000000000..f7d19e4ed3 --- /dev/null +++ b/muse_glimmer/macos/config/dependencies/toolchain.lock.json @@ -0,0 +1,15 @@ +{ + "schema_version": 1, + "platform": { + "system": "Darwin", + "machine": "arm64" + }, + "tools": { + "python": ">=3.13,<3.14", + "node": ">=22.12.0,<23", + "npm": ">=10,<12", + "uv": ">=0.11,<1", + "cmake": ">=3.24,<5", + "livekit-server": ">=1.9,<2" + } +} diff --git a/muse_glimmer/macos/config/livekit/macos-arm64.yaml b/muse_glimmer/macos/config/livekit/macos-arm64.yaml new file mode 100644 index 0000000000..e2144f2938 --- /dev/null +++ b/muse_glimmer/macos/config/livekit/macos-arm64.yaml @@ -0,0 +1,22 @@ +port: 7880 +bind_addresses: + - 127.0.0.1 + +rtc: + node_ip: 127.0.0.1 + tcp_port: 0 + udp_port: 7882 + use_external_ip: false + enable_loopback_candidate: true + interfaces: + includes: + - lo0 + ips: + includes: + - 127.0.0.0/8 + +logging: + level: info + +room: + auto_create: true diff --git a/muse_glimmer/macos/docs/architecture.md b/muse_glimmer/macos/docs/architecture.md new file mode 100644 index 0000000000..50931b089b --- /dev/null +++ b/muse_glimmer/macos/docs/architecture.md @@ -0,0 +1,33 @@ +# Architecture + +## Runtime flow + +1. The React application requests a short-lived token from the local token + service only after the user selects **Start conversation**. +2. The browser connects to loopback LiveKit and publishes microphone audio. +3. The `assistant` worker receives audio and uses Silero VAD with the persistent + Parakeet ExecuTorch helper. +4. Final transcripts are sent to the loopback MuseGlimmer OpenAI-compatible + server. One native worker owns one loaded model and reusable sessions. +5. The worker passes visible response text to a persistent Supertonic JSONL + runner. The model is loaded and warmed once. +6. Generated PCM audio is published through LiveKit to the browser. + +## Process ownership + +The repository supervisor owns five process groups in dependency order: +MuseGlimmer, LiveKit, token service, production web server, and worker. It +stores identity-qualified state under `.local/run`, rolls startup failures back +in reverse order, and never kills a process based on PID alone. Shutdown owns the +recorded process groups even after a launcher exits, and worker status probes the +dynamic loopback health endpoint reported by LiveKit Agents. + +## Dependency boundary + +LiveKit and ExecuTorch are external dependencies. Their repositories are not +vendored. One compatibility lock pins all ExecuTorch Python and native pieces +together. Models, builds, and checkouts live under ignored `.local` paths. + +The temporary `packages/livekit-plugins-executorch` package is product-owned +until a compatible upstream package is released. Its provenance file records +the precise source and removal condition. diff --git a/muse_glimmer/macos/docs/artifacts.md b/muse_glimmer/macos/docs/artifacts.md new file mode 100644 index 0000000000..4a40e37986 --- /dev/null +++ b/muse_glimmer/macos/docs/artifacts.md @@ -0,0 +1,19 @@ +# Artifact Preparation + +`artifacts/macos-arm64.lock.json` is the source-of-truth inventory. Every entry +records its role, distribution method, independent license, ignored +`.local/artifacts` destination, and checksum when an approved immutable +artifact is available. + +Artifacts marked `user-provided` are not downloaded automatically. Obtain them +under their upstream terms, place them at the documented destination, and run: + +```bash +make prepare-artifacts +``` + +Preparation accepts only the one ExecuTorch commit in +`config/dependencies/compatibility.lock.json`, rejects a dirty or mismatched +checkout, validates all files, and writes `.local/state/prepared.json`. +Startup verifies that receipt and every checksum. It never installs, builds, +downloads, exports, or repairs artifacts. diff --git a/muse_glimmer/macos/docs/development.md b/muse_glimmer/macos/docs/development.md new file mode 100644 index 0000000000..c777ab12b3 --- /dev/null +++ b/muse_glimmer/macos/docs/development.md @@ -0,0 +1,21 @@ +# Development + +The repository separates source setup, artifact preparation, and daily runtime: + +```bash +make bootstrap +make prepare-artifacts +make dev +``` + +Use `make check` for static and publication checks, `make test` for unit tests +and the production web build, and `make e2e` for model-heavy macOS integration. + +Bootstrap records the locked dependency inputs, validated tool paths, and the +production web build digest. Daily startup rejects stale setup state and also +revalidates that the prepared ExecuTorch checkout remains clean at the exact +compatibility commit; it never installs or rebuilds. + +Do not place secrets in `.env` files. The supervisor creates ephemeral local +LiveKit credentials. Do not add cloud provider fallbacks or browser-configured +model endpoints. diff --git a/muse_glimmer/macos/docs/observability.md b/muse_glimmer/macos/docs/observability.md new file mode 100644 index 0000000000..32c50ddaf9 --- /dev/null +++ b/muse_glimmer/macos/docs/observability.md @@ -0,0 +1,19 @@ +# Local Observability + +Logs remain under ignored `.local/logs` and are not uploaded. The worker emits +structured records for: + +- User speech state transitions. +- Final ASR transcript availability and character count. +- VAD-detected speech that produced no final transcript. +- Agent state transitions. +- Pipeline/provider errors without browser-visible native detail. +- Session closure and reason. +- LLM prompt/completion token counts, time to first token, generation duration, + finish reason, and cancellation outcome. + +Transcript text is debug-only and must not be included in public issue reports +by default. Shareable diagnostic ZIPs contain the redacted report and structured +events only; raw `runtime.log` remains local and is never bundled automatically. +Native stderr is bounded and local. `make logs` follows all five managed service +logs. diff --git a/muse_glimmer/macos/docs/security-model.md b/muse_glimmer/macos/docs/security-model.md new file mode 100644 index 0000000000..cfdf59876c --- /dev/null +++ b/muse_glimmer/macos/docs/security-model.md @@ -0,0 +1,41 @@ +# Security Model + +## Guarantees + +The supported profile binds every network service to IPv4 loopback. LiveKit +advertises only loopback candidates and disables external-IP discovery. The +browser receives a short-lived room token restricted to microphone publication +and subscription; it cannot publish camera, screen, or data tracks. + +Runtime credentials are generated locally for each `up`, written mode 0600, +injected only into the LiveKit, token, and worker processes, and deleted by +normal shutdown. They are protocol credentials required by a local LiveKit +server, not cloud credentials. + +The browser may know only the public product name, `assistant` agent identity, +room/participant identities, the participant JWT, the fixed token endpoint, +the fixed LiveKit URL, state enums, and conversation transcripts. Model IDs, +quantization, artifact paths, LLM port 8000, native executable details, private +endpoints, and credentials stay server-side. + +## Trust boundary + +Loopback is a network boundary, not a same-user authentication boundary. A +process running as the same operating-system user can call the token endpoint +and may read files that user can access. Requests without an Origin are +accepted for local native clients. Browser origins and Host headers are still +restricted exactly to approved loopback values. + +## Enforcement + +- Configuration rejects non-loopback LiveKit and MuseGlimmer endpoints. +- The web client rejects token responses containing unknown fields or any + LiveKit URL other than `ws://127.0.0.1:7880`. +- A production Content Security Policy excludes the LLM endpoint. +- The post-start privacy audit checks listeners, managed connections, token + responses, runtime credential permissions, and browser bundle strings. +- The publication check prevents private artifacts and workstation paths from + entering source control. + +This design does not defend against malware executing as the same user, a +compromised browser, or an intentionally modified local build. diff --git a/muse_glimmer/macos/docs/upstream-pins.md b/muse_glimmer/macos/docs/upstream-pins.md new file mode 100644 index 0000000000..f1cc0fdc43 --- /dev/null +++ b/muse_glimmer/macos/docs/upstream-pins.md @@ -0,0 +1,33 @@ +# Upstream Compatibility Pins + +The application source can be developed in this repository, but the native +compatibility lock remains `development-gated` until one immutable ExecuTorch +commit contains every required interface. + +## Capability status + +- ExecuTorch PR [#22063](https://github.com/pytorch/executorch/pull/22063): + base Supertonic export and native runtime landed at + `81969a92dd2e5515fa23ccdf9d87346cf3ba2ba2`. +- ExecuTorch PR [#22070](https://github.com/pytorch/executorch/pull/22070): + bounded generic LLM worker cancellation and health propagation landed at + `5bd86e50fcd986999e4c09b82de040a3ba224466`. +- ExecuTorch PR [#22208](https://github.com/pytorch/executorch/pull/22208): + persistent Supertonic JSONL mode is pending review. Its protocol-v1 ready + frame reports `sample_rate: 44100` together with load and warmup timing so the + Python adapter and native runtime enforce one schema. + +The merged base Supertonic runtime alone is not a release pin. A temporary +public PR commit may be used for local development only when it is pinned by +full SHA, publicly accessible, license-compatible, and validated as a single +checkout. Branch names, dirty checkouts, and mixed Python/native revisions are +forbidden. + +Before setting `ready_for_release` to true: + +1. Land every required capability and select one descendant ExecuTorch commit. +2. Populate artifact sources, revisions, checksums, sizes, and exact licenses. +3. Run real generation, stream cancellation, and post-cancel generation. +4. Verify multiple Supertonic utterances reuse one warm process using the + documented protocol-v1 ready frame. +5. Pass a clean-machine macOS arm64 end-to-end run. diff --git a/muse_glimmer/macos/native/macos-arm64/README.md b/muse_glimmer/macos/native/macos-arm64/README.md new file mode 100644 index 0000000000..56ae71d61f --- /dev/null +++ b/muse_glimmer/macos/native/macos-arm64/README.md @@ -0,0 +1,9 @@ +# macOS Apple Silicon native targets + +Native binaries are built from the single ExecuTorch checkout pinned by +`config/dependencies/compatibility.lock.json`. They are installed under the +ignored `.local/artifacts/bin/` directory and are never committed. + +The supported milestone requires a MuseGlimmer worker that advertises +`supports_cancel` and a Supertonic runner with `--server_jsonl`. Startup fails +if either capability is unavailable. diff --git a/muse_glimmer/macos/native/macos-arm64/targets.json b/muse_glimmer/macos/native/macos-arm64/targets.json new file mode 100644 index 0000000000..a444ae55d2 --- /dev/null +++ b/muse_glimmer/macos/native/macos-arm64/targets.json @@ -0,0 +1,12 @@ +{ + "schema_version": 1, + "targets": { + "parakeet_helper": "parakeet_helper", + "muse_glimmer_worker": "muse_glimmer_worker", + "supertonic_runner": "supertonic_runner" + }, + "required_features": { + "muse_glimmer_worker": ["supports_cancel"], + "supertonic_runner": ["server_jsonl"] + } +} diff --git a/muse_glimmer/macos/packages/livekit-plugins-executorch/LICENSE b/muse_glimmer/macos/packages/livekit-plugins-executorch/LICENSE new file mode 100644 index 0000000000..5651f75604 --- /dev/null +++ b/muse_glimmer/macos/packages/livekit-plugins-executorch/LICENSE @@ -0,0 +1,30 @@ +BSD License + +For "ExecuTorch" software + +Copyright (c) Meta Platforms, Inc. and affiliates. All rights reserved. + +Redistribution and use in source and binary forms, with or without modification, +are permitted provided that the following conditions are met: + + * Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. + + * Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + + * Neither the name Meta nor the names of its contributors may be used to + endorse or promote products derived from this software without specific + prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR +ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON +ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/muse_glimmer/macos/packages/livekit-plugins-executorch/PROVENANCE.md b/muse_glimmer/macos/packages/livekit-plugins-executorch/PROVENANCE.md new file mode 100644 index 0000000000..4d85997227 --- /dev/null +++ b/muse_glimmer/macos/packages/livekit-plugins-executorch/PROVENANCE.md @@ -0,0 +1,43 @@ +# Provenance + +## Ownership and source snapshots + +This package is original product source maintained in the canonical +[`meta-pytorch/executorch-examples`](https://github.com/meta-pytorch/executorch-examples) +repository under `muse_glimmer/macos/packages/livekit-plugins-executorch` and +licensed under BSD-3-Clause. The source snapshot used for the macOS subtree +migration is `914fb816fe9e0f6b7fc808fd843eb2e97df31dcf`. + +The package implements adapters against the public +[`livekit/agents`](https://github.com/livekit/agents) plugin APIs at commit +`bc5f3df3a2bd1b3b8c5d1df742be57b063374991`. This ExecuTorch plugin subtree did +not exist in that upstream commit, and no LiveKit implementation source was +copied into it. + +## Original source + +The original product files were reorganized under this package: + +- `livekit/plugins/executorch/__init__.py` +- `livekit/plugins/executorch/_helper_process.py` +- `livekit/plugins/executorch/log.py` +- `livekit/plugins/executorch/py.typed` +- `livekit/plugins/executorch/stt.py` +- `livekit/plugins/executorch/supertonic_tts.py` +- `livekit/plugins/executorch/version.py` +- `tests/fake_helper.py` +- `tests/fake_supertonic_runner.py` +- `tests/test_helper_process.py` +- `tests/test_stt.py` +- `tests/test_supertonic_tts.py` + +The persistent Supertonic adapter and fake-runner tests are product-owned +implementations of the native runner's strict `--server_jsonl` protocol. + +## Exclusions + +This source package does not include LiveKit or ExecuTorch implementation +source, native runners, model weights, exported programs, tokenizers, voice +styles, recordings, generated output, dependency source, or build and test +caches. Those components retain their independent upstream licenses and +notices. diff --git a/muse_glimmer/macos/packages/livekit-plugins-executorch/README.md b/muse_glimmer/macos/packages/livekit-plugins-executorch/README.md new file mode 100644 index 0000000000..6774062896 --- /dev/null +++ b/muse_glimmer/macos/packages/livekit-plugins-executorch/README.md @@ -0,0 +1,19 @@ +# LiveKit ExecuTorch plugin + +Local, source-only adapters for LiveKit Agents: + +- `executorch.STT` runs batch Parakeet ASR through one persistent framed helper. +- `executorch.SupertonicTTS` owns one persistent `supertonic_runner --server_jsonl` + process and sends synthesis text only through stdin JSONL. + +Native binaries, model weights, tokenizers, voice styles, recordings, and generated +outputs are deliberately outside this package. Supply explicit local artifact paths +when constructing either provider. + +The adapters serialize requests because each native helper accepts one active request. +Timeout, cancellation, protocol failure, and explicit close all terminate and reap the +helper within configured bounds. + +Model weights and voice/style assets retain their upstream licenses. Supertonic 3 is +distributed under the OpenRAIL-M license described by its model card; review it before +redistributing model assets or generated output. diff --git a/muse_glimmer/macos/packages/livekit-plugins-executorch/livekit/plugins/executorch/__init__.py b/muse_glimmer/macos/packages/livekit-plugins-executorch/livekit/plugins/executorch/__init__.py new file mode 100644 index 0000000000..ba459016b5 --- /dev/null +++ b/muse_glimmer/macos/packages/livekit-plugins-executorch/livekit/plugins/executorch/__init__.py @@ -0,0 +1,18 @@ +"""Local ExecuTorch providers for LiveKit Agents.""" + +from livekit.agents import Plugin + +from .log import logger +from .stt import STT +from .supertonic_tts import SupertonicTTS +from .version import __version__ + +__all__ = ["STT", "SupertonicTTS", "__version__"] + + +class ExecuTorchPlugin(Plugin): + def __init__(self) -> None: + super().__init__(__name__, __version__, __package__, logger) + + +Plugin.register_plugin(ExecuTorchPlugin()) diff --git a/muse_glimmer/macos/packages/livekit-plugins-executorch/livekit/plugins/executorch/_helper_process.py b/muse_glimmer/macos/packages/livekit-plugins-executorch/livekit/plugins/executorch/_helper_process.py new file mode 100644 index 0000000000..26e11b8de9 --- /dev/null +++ b/muse_glimmer/macos/packages/livekit-plugins-executorch/livekit/plugins/executorch/_helper_process.py @@ -0,0 +1,265 @@ +from __future__ import annotations + +import asyncio +import contextlib +import json +from collections import deque +from collections.abc import Mapping, Sequence +from typing import Any + +from .log import logger + +_DEFAULT_MAX_HEADER_BYTES = 64 * 1024 +_DEFAULT_MAX_PAYLOAD_BYTES = 64 * 1024 * 1024 + + +class HelperProcessError(RuntimeError): + """Raised when a native helper exits or violates the framed protocol.""" + + +class HelperProtocolError(HelperProcessError): + """Raised when a helper sends invalid framing or message data.""" + + +class HelperProcess: + """Async lifecycle and JSON-plus-binary framing for one native helper.""" + + def __init__( + self, + executable: str, + argv: Sequence[str] = (), + *, + name: str, + ready_timeout: float = 120.0, + shutdown_timeout: float = 2.0, + terminate_timeout: float = 2.0, + max_header_bytes: int = _DEFAULT_MAX_HEADER_BYTES, + max_payload_bytes: int = _DEFAULT_MAX_PAYLOAD_BYTES, + ) -> None: + if not executable: + raise ValueError("helper executable must be non-empty") + if max_header_bytes <= 0 or max_payload_bytes <= 0: + raise ValueError("helper framing limits must be positive") + + self._executable = executable + self._argv = tuple(argv) + self._name = name + self._ready_timeout = ready_timeout + self._shutdown_timeout = shutdown_timeout + self._terminate_timeout = terminate_timeout + self._max_header_bytes = max_header_bytes + self._max_payload_bytes = max_payload_bytes + self._process: asyncio.subprocess.Process | None = None + self._write_lock = asyncio.Lock() + self._lifecycle_lock = asyncio.Lock() + self._stderr_task: asyncio.Task[None] | None = None + self._pending_read: asyncio.Task[tuple[dict[str, Any], bytes | None]] | None = None + self._ready_message: dict[str, Any] | None = None + self._stderr_tail: deque[str] = deque(maxlen=20) + + @property + def running(self) -> bool: + return self._process is not None and self._process.returncode is None + + @property + def stderr_tail(self) -> tuple[str, ...]: + return tuple(self._stderr_tail) + + async def start(self) -> dict[str, Any]: + async with self._lifecycle_lock: + if self.running: + if self._ready_message is None: + raise HelperProcessError(f"{self._name} helper has no cached ready message") + return dict(self._ready_message) + + await self._close_locked(graceful=False) + self._stderr_tail.clear() + try: + process = await asyncio.create_subprocess_exec( + self._executable, + *self._argv, + stdin=asyncio.subprocess.PIPE, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + limit=self._max_header_bytes + 1, + ) + except OSError as exc: + raise HelperProcessError( + f"failed to start {self._name} helper {self._executable!r}: {exc}" + ) from exc + + self._process = process + self._stderr_task = asyncio.create_task( + self._drain_stderr(process), name=f"{self._name}-stderr" + ) + try: + message, payload = await asyncio.wait_for( + self.read_message(), timeout=self._ready_timeout + ) + except (TimeoutError, HelperProcessError) as exc: + await self._close_locked(graceful=False) + context = self._format_stderr_context() + if isinstance(exc, asyncio.TimeoutError): + raise HelperProcessError( + f"{self._name} helper did not become ready within " + f"{self._ready_timeout:.1f}s{context}" + ) from None + raise HelperProcessError(f"{exc}{context}") from exc + + if payload is not None or message.get("type") != "ready" or message.get("version") != 1: + await self._close_locked(graceful=False) + raise HelperProtocolError( + f"{self._name} helper sent invalid ready message: {message!r}" + f"{self._format_stderr_context()}" + ) + self._ready_message = dict(message) + return dict(message) + + async def write_message( + self, message: Mapping[str, Any], payload: bytes | bytearray | memoryview | None = None + ) -> None: + process = self._require_running() + if process.stdin is None: + raise HelperProcessError(f"{self._name} helper stdin is unavailable") + + payload_bytes = bytes(payload) if payload is not None else b"" + if len(payload_bytes) > self._max_payload_bytes: + raise HelperProtocolError( + f"{self._name} helper payload exceeds {self._max_payload_bytes} bytes" + ) + try: + header = json.dumps(dict(message), separators=(",", ":"), allow_nan=False).encode() + except (TypeError, ValueError) as exc: + raise HelperProtocolError(f"helper message is not valid JSON: {exc}") from exc + if b"\n" in header or len(header) > self._max_header_bytes: + raise HelperProtocolError("helper message header exceeds framing limits") + + async with self._write_lock: + try: + process.stdin.write(header + b"\n") + if payload_bytes: + process.stdin.write(payload_bytes) + await process.stdin.drain() + except (BrokenPipeError, ConnectionResetError) as exc: + raise self._process_exit_error("failed to write helper request") from exc + + async def read_message(self) -> tuple[dict[str, Any], bytes | None]: + if self._pending_read is None: + self._pending_read = asyncio.create_task( + self._read_message_impl(), name=f"{self._name}-read" + ) + task = self._pending_read + try: + return await asyncio.shield(task) + finally: + if task.done() and self._pending_read is task: + self._pending_read = None + + async def restart(self) -> dict[str, Any]: + async with self._lifecycle_lock: + await self._close_locked(graceful=False) + return await self.start() + + async def aclose(self, *, graceful: bool = True) -> None: + async with self._lifecycle_lock: + await self._close_locked(graceful=graceful) + + async def _read_message_impl(self) -> tuple[dict[str, Any], bytes | None]: + process = self._require_running() + if process.stdout is None: + raise HelperProcessError(f"{self._name} helper stdout is unavailable") + try: + line = await process.stdout.readline() + except ValueError as exc: + raise HelperProtocolError( + f"{self._name} helper header exceeds {self._max_header_bytes} bytes" + ) from exc + if not line: + raise self._process_exit_error("unexpected EOF from helper") + if not line.endswith(b"\n") or len(line) - 1 > self._max_header_bytes: + raise HelperProtocolError( + f"{self._name} helper header exceeds {self._max_header_bytes} bytes" + ) + try: + parsed = json.loads(line) + except (UnicodeDecodeError, json.JSONDecodeError) as exc: + raise HelperProtocolError(f"{self._name} helper sent malformed JSON header") from exc + if not isinstance(parsed, dict): + raise HelperProtocolError(f"{self._name} helper header must be a JSON object") + + payload_size = parsed.get("payload_byte_count", 0) + if isinstance(payload_size, bool) or not isinstance(payload_size, int) or payload_size < 0: + raise HelperProtocolError("helper payload_byte_count must be a non-negative integer") + if payload_size > self._max_payload_bytes: + raise HelperProtocolError( + f"{self._name} helper payload exceeds {self._max_payload_bytes} bytes" + ) + if payload_size == 0: + return parsed, None + try: + payload = await process.stdout.readexactly(payload_size) + except asyncio.IncompleteReadError as exc: + raise HelperProcessError( + f"unexpected EOF reading {self._name} helper payload: " + f"expected {payload_size}, received {len(exc.partial)}" + ) from exc + return parsed, payload + + async def _drain_stderr(self, process: asyncio.subprocess.Process) -> None: + if process.stderr is None: + return + while line := await process.stderr.readline(): + text = line.decode(errors="replace").rstrip() + self._stderr_tail.append(text) + logger.debug("%s helper: %s", self._name, text) + + async def _close_locked(self, *, graceful: bool) -> None: + process = self._process + if process is None: + return + + if graceful and process.returncode is None: + with contextlib.suppress(HelperProcessError, HelperProtocolError): + await self.write_message({"type": "shutdown", "version": 1}) + if process.returncode is None and graceful: + with contextlib.suppress(TimeoutError): + await asyncio.wait_for(process.wait(), timeout=self._shutdown_timeout) + if process.returncode is None: + process.terminate() + try: + await asyncio.wait_for(process.wait(), timeout=self._terminate_timeout) + except TimeoutError: + process.kill() + await process.wait() + + if process.stdin is not None: + process.stdin.close() + with contextlib.suppress(BrokenPipeError, ConnectionResetError): + await process.stdin.wait_closed() + + if self._pending_read is not None: + self._pending_read.cancel() + with contextlib.suppress(asyncio.CancelledError, HelperProcessError): + await self._pending_read + self._pending_read = None + if self._stderr_task is not None: + with contextlib.suppress(asyncio.CancelledError): + await self._stderr_task + self._stderr_task = None + self._process = None + self._ready_message = None + + def _require_running(self) -> asyncio.subprocess.Process: + if not self.running or self._process is None: + raise self._process_exit_error("helper is not running") + return self._process + + def _process_exit_error(self, message: str) -> HelperProcessError: + returncode = self._process.returncode if self._process is not None else None + suffix = f" (exit code {returncode})" if returncode is not None else "" + return HelperProcessError(f"{self._name} {message}{suffix}{self._format_stderr_context()}") + + def _format_stderr_context(self) -> str: + if not self._stderr_tail: + return "" + return "; recent stderr: " + " | ".join(self._stderr_tail) diff --git a/muse_glimmer/macos/packages/livekit-plugins-executorch/livekit/plugins/executorch/log.py b/muse_glimmer/macos/packages/livekit-plugins-executorch/livekit/plugins/executorch/log.py new file mode 100644 index 0000000000..6b81deaf67 --- /dev/null +++ b/muse_glimmer/macos/packages/livekit-plugins-executorch/livekit/plugins/executorch/log.py @@ -0,0 +1,3 @@ +import logging + +logger = logging.getLogger("livekit.plugins.executorch") diff --git a/muse_glimmer/macos/packages/livekit-plugins-executorch/livekit/plugins/executorch/py.typed b/muse_glimmer/macos/packages/livekit-plugins-executorch/livekit/plugins/executorch/py.typed new file mode 100644 index 0000000000..e69de29bb2 diff --git a/muse_glimmer/macos/packages/livekit-plugins-executorch/livekit/plugins/executorch/stt.py b/muse_glimmer/macos/packages/livekit-plugins-executorch/livekit/plugins/executorch/stt.py new file mode 100644 index 0000000000..1c1083a80b --- /dev/null +++ b/muse_glimmer/macos/packages/livekit-plugins-executorch/livekit/plugins/executorch/stt.py @@ -0,0 +1,241 @@ +from __future__ import annotations + +import asyncio +import contextlib +import sys +from array import array +from collections.abc import Sequence +from pathlib import Path +from typing import Any + +from livekit.agents import ( + APIConnectionError, + APIConnectOptions, + APIError, + APITimeoutError, + LanguageCode, + stt, + utils, +) +from livekit.agents.types import NOT_GIVEN, NotGivenOr +from livekit.agents.utils import is_given + +from livekit import rtc + +from ._helper_process import HelperProcess, HelperProcessError, HelperProtocolError +from .log import logger + +_SAMPLE_RATE = 16000 + + +class STT(stt.STT): + """Batch Parakeet STT backed by a persistent ExecuTorch helper.""" + + def __init__( + self, + *, + helper_path: str | Path, + model_path: str | Path, + tokenizer_path: str | Path, + delegate_data_path: str | Path | None = None, + language: str = "en", + ready_timeout: float = 120.0, + _helper: HelperProcess | None = None, + ) -> None: + super().__init__( + capabilities=stt.STTCapabilities( + streaming=False, + interim_results=False, + offline_recognize=True, + ) + ) + self._model_path = str(model_path) + self._language = LanguageCode(language) + argv = [f"--model_path={model_path}", f"--tokenizer_path={tokenizer_path}"] + if delegate_data_path is not None: + argv.append(f"--data_path={delegate_data_path}") + self._helper = _helper or HelperProcess( + str(helper_path), argv, name="parakeet", ready_timeout=ready_timeout + ) + self._recognize_lock = asyncio.Lock() + self._prewarm_task: asyncio.Task[dict[str, Any]] | None = None + + @property + def model(self) -> str: + return self._model_path + + @property + def provider(self) -> str: + return "ExecuTorch" + + def prewarm(self) -> None: + if self._prewarm_task is None: + try: + loop = asyncio.get_running_loop() + except RuntimeError: + return + self._prewarm_task = loop.create_task(self._helper.start()) + + async def start(self) -> None: + """Start the Parakeet helper and wait for its ready message.""" + await self._ensure_ready() + + async def _ensure_ready(self) -> None: + if self._prewarm_task is not None: + task, self._prewarm_task = self._prewarm_task, None + await task + else: + await self._helper.start() + + async def _recognize_impl( + self, + buffer: utils.AudioBuffer, + *, + language: NotGivenOr[str] = NOT_GIVEN, + conn_options: APIConnectOptions, + ) -> stt.SpeechEvent: + async with self._recognize_lock: + request_id = utils.shortuuid("parakeet_") + payload = _audio_buffer_to_f32le(buffer) + message = { + "type": "transcribe", + "version": 1, + "request_id": request_id, + "audio": { + "encoding": "f32le", + "sample_rate": _SAMPLE_RATE, + "channel_count": 1, + "payload_byte_count": len(payload), + }, + "enable_runtime_profile": False, + } + try: + await self._ensure_ready() + response = await asyncio.wait_for( + self._request(message, payload, request_id), timeout=conn_options.timeout + ) + except asyncio.CancelledError: + await asyncio.shield(self._helper.aclose(graceful=False)) + raise + except TimeoutError: + await self._helper.aclose(graceful=False) + raise APITimeoutError("Parakeet transcription timed out") from None + except HelperProtocolError as exc: + await self._helper.aclose(graceful=False) + raise APIError(str(exc), retryable=False) from exc + except HelperProcessError as exc: + await self._helper.aclose(graceful=False) + raise APIConnectionError("Parakeet helper failed") from exc + + transcript_language = LanguageCode(language) if is_given(language) else self._language + return stt.SpeechEvent( + type=stt.SpeechEventType.FINAL_TRANSCRIPT, + request_id=request_id, + alternatives=[ + stt.SpeechData( + language=transcript_language, + text=response["text"], + metadata={ + "provider": self.provider, + "model": self.model, + "runtime": "parakeet_helper", + }, + ) + ], + ) + + async def _request( + self, message: dict[str, Any], payload: bytes, request_id: str + ) -> dict[str, Any]: + await self._helper.write_message(message, payload) + while True: + response, response_payload = await self._helper.read_message() + if response_payload is not None: + raise HelperProtocolError("Parakeet response must not contain a binary payload") + if response.get("version") != 1: + raise HelperProtocolError("Parakeet response has unsupported protocol version") + if response.get("request_id") != request_id: + raise HelperProtocolError("Parakeet response request_id does not match") + response_type = response.get("type") + if response_type == "status": + logger.debug("Parakeet status: %s", response.get("message", response.get("phase"))) + continue + if response_type == "result": + _required_string(response, "text") + return response + if response_type == "error": + details = response.get("details") + error_message = str(response.get("message", "Parakeet transcription failed")) + if details: + error_message = f"{error_message}: {details}" + raise APIError(error_message, body=response, retryable=False) + raise HelperProtocolError(f"unexpected Parakeet response type: {response_type!r}") + + async def aclose(self) -> None: + if self._prewarm_task is not None: + if not self._prewarm_task.done(): + self._prewarm_task.cancel() + with contextlib.suppress(asyncio.CancelledError, HelperProcessError): + await self._prewarm_task + self._prewarm_task = None + await self._helper.aclose() + + +def _required_string(message: dict[str, Any], key: str) -> str: + value = message.get(key) + if not isinstance(value, str): + raise HelperProtocolError(f"Parakeet response field {key!r} must be a string") + return value + + +def _audio_buffer_to_f32le(buffer: utils.AudioBuffer) -> bytes: + frame = rtc.combine_audio_frames(buffer) + if frame.samples_per_channel == 0: + return b"" + + mono_samples = _downmix_s16(frame) + mono_frame = rtc.AudioFrame( + data=_s16le_bytes(mono_samples), + sample_rate=frame.sample_rate, + num_channels=1, + samples_per_channel=len(mono_samples), + ) + if mono_frame.sample_rate != _SAMPLE_RATE: + resampler = rtc.AudioResampler( + input_rate=mono_frame.sample_rate, + output_rate=_SAMPLE_RATE, + num_channels=1, + quality=rtc.AudioResamplerQuality.HIGH, + ) + frames = [*resampler.push(mono_frame), *resampler.flush()] + mono_frame = rtc.combine_audio_frames(frames) + + samples = array("h") + samples.frombytes(mono_frame.data.tobytes()) + if sys.byteorder != "little": + samples.byteswap() + floats = array("f", (sample / 32768.0 for sample in samples)) + if sys.byteorder != "little": + floats.byteswap() + return floats.tobytes() + + +def _downmix_s16(frame: rtc.AudioFrame) -> Sequence[int]: + samples = array("h") + samples.frombytes(frame.data.tobytes()) + if sys.byteorder != "little": + samples.byteswap() + if frame.num_channels == 1: + return samples + channels = frame.num_channels + return [ + sum(samples[index : index + channels]) // channels + for index in range(0, len(samples), channels) + ] + + +def _s16le_bytes(samples: Sequence[int]) -> bytes: + output = array("h", samples) + if sys.byteorder != "little": + output.byteswap() + return output.tobytes() diff --git a/muse_glimmer/macos/packages/livekit-plugins-executorch/livekit/plugins/executorch/supertonic_tts.py b/muse_glimmer/macos/packages/livekit-plugins-executorch/livekit/plugins/executorch/supertonic_tts.py new file mode 100644 index 0000000000..9a9873eec8 --- /dev/null +++ b/muse_glimmer/macos/packages/livekit-plugins-executorch/livekit/plugins/executorch/supertonic_tts.py @@ -0,0 +1,487 @@ +from __future__ import annotations + +import asyncio +import contextlib +import json +import math +import os +import tempfile +import wave +from collections import deque +from dataclasses import dataclass +from pathlib import Path +from typing import Any + +from livekit.agents import ( + APIConnectionError, + APIConnectOptions, + APIError, + APITimeoutError, + tts, + utils, +) +from livekit.agents.types import DEFAULT_API_CONNECT_OPTIONS + +_SAMPLE_RATE = 44100 +_NUM_CHANNELS = 1 +_SAMPLE_WIDTH_BYTES = 2 +_MAX_JSON_LINE_BYTES = 64 * 1024 +_MAX_STDERR_LINES = 20 + + +class _ProtocolError(RuntimeError): + pass + + +@dataclass(frozen=True) +class _SupertonicOptions: + runner_path: str + pte_path: str + asset_dir: str + voice_style_path: str + language: str + speed: float + seed: int + + +class SupertonicTTS(tts.TTS): + """Batch TTS backed by one persistent Supertonic JSONL server process.""" + + def __init__( + self, + *, + runner_path: str | Path, + pte_path: str | Path, + asset_dir: str | Path, + voice_style_path: str | Path, + language: str = "en", + speed: float = 1.05, + seed: int = 42, + ready_timeout: float = 120.0, + shutdown_timeout: float = 2.0, + terminate_timeout: float = 2.0, + ) -> None: + runner = _required_file(runner_path, "runner_path", executable=True) + pte = _required_file(pte_path, "pte_path") + assets = _required_directory(asset_dir, "asset_dir") + voice_style = _required_file(voice_style_path, "voice_style_path") + if not language.strip(): + raise ValueError("language must be non-empty") + if not math.isfinite(speed) or speed <= 0.0: + raise ValueError("speed must be finite and positive") + if isinstance(seed, bool) or not isinstance(seed, int) or seed < 0: + raise ValueError("seed must be a non-negative integer") + for name, value in ( + ("ready_timeout", ready_timeout), + ("shutdown_timeout", shutdown_timeout), + ("terminate_timeout", terminate_timeout), + ): + if not math.isfinite(value) or value <= 0.0: + raise ValueError(f"{name} must be finite and positive") + + super().__init__( + capabilities=tts.TTSCapabilities(streaming=False), + sample_rate=_SAMPLE_RATE, + num_channels=_NUM_CHANNELS, + ) + self._opts = _SupertonicOptions( + runner_path=str(runner), + pte_path=str(pte), + asset_dir=str(assets), + voice_style_path=str(voice_style), + language=language.strip(), + speed=speed, + seed=seed, + ) + self._ready_timeout = ready_timeout + self._shutdown_timeout = shutdown_timeout + self._terminate_timeout = terminate_timeout + self._synthesis_lock = asyncio.Lock() + self._lifecycle_lock = asyncio.Lock() + self._process: asyncio.subprocess.Process | None = None + self._stderr_task: asyncio.Task[None] | None = None + self._stderr_tail: deque[str] = deque(maxlen=_MAX_STDERR_LINES) + self._request_active = False + self._next_request_id = 1 + self._closed = False + + @property + def model(self) -> str: + return self._opts.pte_path + + @property + def provider(self) -> str: + return "ExecuTorch Supertonic" + + @property + def running(self) -> bool: + return self._process is not None and self._process.returncode is None + + def synthesize( + self, text: str, *, conn_options: APIConnectOptions = DEFAULT_API_CONNECT_OPTIONS + ) -> ChunkedStream: + return ChunkedStream(tts=self, input_text=text, conn_options=conn_options) + + async def aclose(self) -> None: + self._closed = True + async with self._lifecycle_lock: + process = self._process + if process is None: + return + if self._request_active: + await self._stop_locked(process, graceful=False) + return + try: + await self._write_json(process, {"type": "shutdown"}) + response = await asyncio.wait_for( + self._read_json(process), timeout=self._shutdown_timeout + ) + if response != {"type": "stopped"}: + raise _ProtocolError(f"Supertonic sent invalid shutdown response: {response!r}") + if not await _wait_for_exit(process, self._shutdown_timeout): + raise TimeoutError + except (TimeoutError, BrokenPipeError, ConnectionResetError, _ProtocolError): + await self._stop_locked(process, graceful=False) + else: + await self._clear_process_locked(process) + + def _command(self) -> tuple[str, ...]: + return ( + self._opts.runner_path, + "--server_jsonl=true", + f"--pte={self._opts.pte_path}", + f"--asset_dir={self._opts.asset_dir}", + f"--voice_style={self._opts.voice_style_path}", + f"--language={self._opts.language}", + f"--speed={self._opts.speed}", + f"--seed={self._opts.seed}", + ) + + async def _ensure_started(self) -> asyncio.subprocess.Process: + async with self._lifecycle_lock: + if self._closed: + raise APIConnectionError("Supertonic TTS is closed", retryable=False) + if self.running and self._process is not None: + return self._process + if self._process is not None: + await self._stop_locked(self._process, graceful=False) + + self._stderr_tail.clear() + try: + process = await asyncio.create_subprocess_exec( + *self._command(), + stdin=asyncio.subprocess.PIPE, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + limit=_MAX_JSON_LINE_BYTES + 1, + ) + except OSError as exc: + raise APIConnectionError(f"failed to start Supertonic runner: {exc}") from exc + self._process = process + self._stderr_task = asyncio.create_task( + self._drain_stderr(process), name="supertonic-stderr" + ) + try: + ready = await asyncio.wait_for( + self._read_json(process), timeout=self._ready_timeout + ) + _validate_ready(ready) + except asyncio.CancelledError: + await asyncio.shield(self._stop_locked(process, graceful=False)) + raise + except TimeoutError: + await self._stop_locked(process, graceful=False) + raise APITimeoutError( + self._with_stderr( + f"Supertonic did not become ready within {self._ready_timeout:.1f}s" + ) + ) from None + except (OSError, _ProtocolError) as exc: + await self._stop_locked(process, graceful=False) + raise APIConnectionError(self._with_stderr(str(exc))) from exc + if self._closed: + await self._stop_locked(process, graceful=False) + raise APIConnectionError("Supertonic TTS is closed", retryable=False) + return process + + async def _request(self, text: str, output_path: Path, timeout: float) -> None: + process = await self._ensure_started() + request_id = self._next_request_id + self._next_request_id += 1 + request = { + "type": "synthesize", + "id": request_id, + "text": text, + "output": str(output_path), + } + async with self._lifecycle_lock: + if process is not self._process or process.returncode is not None or self._closed: + raise APIConnectionError("Supertonic runner is unavailable") + self._request_active = True + try: + await self._write_json(process, request) + except asyncio.CancelledError: + self._request_active = False + await asyncio.shield(self._stop_locked(process, graceful=False)) + raise + except _ProtocolError as exc: + self._request_active = False + raise APIError(str(exc), retryable=False) from exc + except (BrokenPipeError, ConnectionResetError, OSError) as exc: + self._request_active = False + await self._stop_locked(process, graceful=False) + raise APIConnectionError( + self._with_stderr("Supertonic request write failed") + ) from exc + + try: + response = await asyncio.wait_for(self._read_json(process), timeout=timeout) + _validate_response(response, request_id, output_path) + except TimeoutError: + await asyncio.shield(self._stop(process)) + raise APITimeoutError(self._with_stderr("Supertonic synthesis timed out")) from None + except asyncio.CancelledError: + await asyncio.shield(self._stop(process)) + raise + except _ProtocolError as exc: + await self._stop(process) + raise APIError(self._with_stderr(str(exc)), retryable=False) from exc + except (BrokenPipeError, ConnectionResetError, OSError) as exc: + await self._stop(process) + raise APIConnectionError(self._with_stderr("Supertonic runner failed")) from exc + finally: + async with self._lifecycle_lock: + self._request_active = False + + if response["type"] == "error": + raise APIError(str(response["message"]), body=response, retryable=False) + + async def _stop(self, process: asyncio.subprocess.Process) -> None: + async with self._lifecycle_lock: + await self._stop_locked(process, graceful=False) + + async def _stop_locked(self, process: asyncio.subprocess.Process, *, graceful: bool) -> None: + if process.returncode is None and graceful: + with contextlib.suppress(BrokenPipeError, ConnectionResetError, OSError): + await self._write_json(process, {"type": "shutdown"}) + await _wait_for_exit(process, self._shutdown_timeout) + if process.returncode is None: + with contextlib.suppress(ProcessLookupError): + process.terminate() + if not await _wait_for_exit(process, self._terminate_timeout): + with contextlib.suppress(ProcessLookupError): + process.kill() + if not await _wait_for_exit(process, self._terminate_timeout): + raise RuntimeError("Supertonic runner did not exit after SIGKILL") + await self._clear_process_locked(process) + + async def _clear_process_locked(self, process: asyncio.subprocess.Process) -> None: + stderr_task, self._stderr_task = self._stderr_task, None + if stderr_task is not None and not stderr_task.done(): + stderr_task.cancel() + if process.stdin is not None: + process.stdin.close() + if self._process is process: + self._process = None + await asyncio.sleep(0) + + async def _write_json( + self, process: asyncio.subprocess.Process, message: dict[str, object] + ) -> None: + if process.stdin is None: + raise OSError("Supertonic stdin is unavailable") + encoded = json.dumps(message, separators=(",", ":"), allow_nan=False).encode("utf-8") + if len(encoded) > _MAX_JSON_LINE_BYTES: + raise _ProtocolError("Supertonic request exceeds the JSONL size limit") + process.stdin.write(encoded + b"\n") + await process.stdin.drain() + + async def _read_json(self, process: asyncio.subprocess.Process) -> dict[str, Any]: + if process.stdout is None: + raise OSError("Supertonic stdout is unavailable") + try: + line = await process.stdout.readline() + except ValueError as exc: + raise _ProtocolError("Supertonic response exceeds the JSONL size limit") from exc + if not line: + returncode = await process.wait() + raise _ProtocolError(f"Supertonic runner exited unexpectedly with code {returncode}") + if not line.endswith(b"\n") or len(line) - 1 > _MAX_JSON_LINE_BYTES: + raise _ProtocolError("Supertonic response exceeds the JSONL size limit") + try: + response = json.loads( + line, + parse_constant=lambda value: (_ for _ in ()).throw( + ValueError(f"non-finite number {value}") + ), + ) + except (UnicodeDecodeError, json.JSONDecodeError, ValueError) as exc: + raise _ProtocolError("Supertonic sent invalid JSON") from exc + if not isinstance(response, dict): + raise _ProtocolError("Supertonic response must be a JSON object") + return response + + async def _drain_stderr(self, process: asyncio.subprocess.Process) -> None: + if process.stderr is None: + return + while line := await process.stderr.readline(): + self._stderr_tail.append(line.decode(errors="replace").rstrip()) + + def _with_stderr(self, message: str) -> str: + if not self._stderr_tail: + return message + return f"{message}; recent stderr: {' | '.join(self._stderr_tail)}" + + +class ChunkedStream(tts.ChunkedStream): + def __init__( + self, + *, + tts: SupertonicTTS, + input_text: str, + conn_options: APIConnectOptions, + ) -> None: + super().__init__(tts=tts, input_text=input_text, conn_options=conn_options) + self._tts: SupertonicTTS = tts + + async def _run(self, output_emitter: tts.AudioEmitter) -> None: + if not self._input_text: + raise APIError("Supertonic synthesis text must be non-empty", retryable=False) + async with self._tts._synthesis_lock: + if self._tts._closed: + raise APIConnectionError("Supertonic TTS is closed", retryable=False) + with tempfile.TemporaryDirectory(prefix="livekit-supertonic-") as temporary: + output_path = Path(temporary) / "speech.wav" + await self._tts._request(self._input_text, output_path, self._conn_options.timeout) + try: + payload = await asyncio.to_thread(_read_pcm_wav, output_path) + except (OSError, EOFError, wave.Error, ValueError) as exc: + raise APIError( + f"Supertonic produced invalid audio: {exc}", retryable=False + ) from exc + + output_emitter.initialize( + request_id=utils.shortuuid("supertonic_"), + sample_rate=_SAMPLE_RATE, + num_channels=_NUM_CHANNELS, + mime_type="audio/pcm", + frame_size_ms=50, + ) + output_emitter.push(payload) + output_emitter.flush() + + +async def _wait_for_exit(process: asyncio.subprocess.Process, timeout: float) -> bool: + if process.returncode is not None: + return True + wait_task = asyncio.create_task(process.wait()) + try: + await asyncio.wait_for(asyncio.shield(wait_task), timeout=timeout) + return True + except TimeoutError: + return process.returncode is not None + finally: + if not wait_task.done(): + wait_task.cancel() + + +def _validate_ready(response: dict[str, Any]) -> None: + expected = { + "type", + "protocol_version", + "sample_rate", + "load_seconds", + "warmup_seconds", + } + if set(response) != expected or response.get("type") != "ready": + raise _ProtocolError(f"Supertonic sent invalid ready response: {response!r}") + if response.get("protocol_version") != 1: + raise _ProtocolError("Supertonic uses an unsupported protocol version") + if response.get("sample_rate") != _SAMPLE_RATE: + raise _ProtocolError(f"Supertonic must use {_SAMPLE_RATE} Hz") + for field in ("load_seconds", "warmup_seconds"): + value = response.get(field) + if isinstance(value, bool) or not isinstance(value, (int, float)): + raise _ProtocolError(f"Supertonic ready field {field!r} must be numeric") + if not math.isfinite(value) or value < 0.0: + raise _ProtocolError( + f"Supertonic ready field {field!r} must be finite and non-negative" + ) + + +def _validate_response(response: dict[str, Any], request_id: int, output_path: Path) -> None: + response_type = response.get("type") + if response_type == "error": + if set(response) != {"type", "id", "message"}: + raise _ProtocolError("Supertonic error response has unexpected fields") + if response.get("id") != request_id or not isinstance(response.get("message"), str): + raise _ProtocolError("Supertonic error response is invalid") + return + expected = { + "type", + "id", + "output", + "samples", + "audio_seconds", + "synthesis_seconds", + "rtf", + } + if response_type != "result" or set(response) != expected: + raise _ProtocolError(f"Supertonic sent invalid synthesis response: {response!r}") + if response.get("id") != request_id: + raise _ProtocolError("Supertonic response id does not match the request") + if response.get("output") != str(output_path): + raise _ProtocolError("Supertonic response output does not match the request") + samples = response.get("samples") + if isinstance(samples, bool) or not isinstance(samples, int) or samples <= 0: + raise _ProtocolError("Supertonic response samples must be a positive integer") + for field in ("audio_seconds", "synthesis_seconds", "rtf"): + value = response.get(field) + if isinstance(value, bool) or not isinstance(value, (int, float)): + raise _ProtocolError(f"Supertonic response field {field!r} must be numeric") + if not math.isfinite(value) or value < 0.0: + raise _ProtocolError( + f"Supertonic response field {field!r} must be finite and non-negative" + ) + + +def _required_file(value: str | Path, name: str, *, executable: bool = False) -> Path: + path = Path(value).expanduser().resolve() + if not path.is_file(): + raise ValueError(f"{name} must point to a file: {path}") + if executable and not os.access(path, os.X_OK): + raise ValueError(f"{name} must point to an executable file: {path}") + return path + + +def _required_directory(value: str | Path, name: str) -> Path: + path = Path(value).expanduser().resolve() + if not path.is_dir(): + raise ValueError(f"{name} must point to a directory: {path}") + return path + + +def _read_pcm_wav(path: Path) -> bytes: + if not path.is_file(): + raise ValueError("output WAV is missing") + with wave.open(str(path), "rb") as output: + channels = output.getnchannels() + sample_rate = output.getframerate() + sample_width = output.getsampwidth() + compression = output.getcomptype() + frame_count = output.getnframes() + payload = output.readframes(frame_count) + if compression != "NONE": + raise ValueError("output WAV must be uncompressed PCM") + if channels != _NUM_CHANNELS: + raise ValueError("output WAV must be mono") + if sample_rate != _SAMPLE_RATE: + raise ValueError(f"output WAV must use {_SAMPLE_RATE} Hz") + if sample_width != _SAMPLE_WIDTH_BYTES: + raise ValueError("output WAV must use signed PCM16 samples") + expected_bytes = frame_count * channels * sample_width + if frame_count <= 0 or not payload: + raise ValueError("output WAV contains no audio") + if len(payload) != expected_bytes: + raise ValueError("output WAV is truncated") + return payload diff --git a/muse_glimmer/macos/packages/livekit-plugins-executorch/livekit/plugins/executorch/version.py b/muse_glimmer/macos/packages/livekit-plugins-executorch/livekit/plugins/executorch/version.py new file mode 100644 index 0000000000..3dc1f76bc6 --- /dev/null +++ b/muse_glimmer/macos/packages/livekit-plugins-executorch/livekit/plugins/executorch/version.py @@ -0,0 +1 @@ +__version__ = "0.1.0" diff --git a/muse_glimmer/macos/packages/livekit-plugins-executorch/pyproject.toml b/muse_glimmer/macos/packages/livekit-plugins-executorch/pyproject.toml new file mode 100644 index 0000000000..00f5fba11c --- /dev/null +++ b/muse_glimmer/macos/packages/livekit-plugins-executorch/pyproject.toml @@ -0,0 +1,62 @@ +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[project] +name = "livekit-plugins-executorch" +dynamic = ["version"] +description = "Local ExecuTorch Parakeet and Supertonic adapters for LiveKit Agents" +readme = "README.md" +requires-python = ">=3.13,<3.14" +license = "BSD-3-Clause" +license-files = ["LICENSE", "PROVENANCE.md"] +keywords = ["voice", "livekit", "executorch", "parakeet", "supertonic"] +classifiers = [ + "License :: OSI Approved :: BSD License", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.13", + "Topic :: Multimedia :: Sound/Audio", +] +dependencies = ["livekit-agents>=1.6.9,<2"] + +[dependency-groups] +dev = [ + "pytest>=8.4,<9", + "pytest-asyncio>=0.25,<2", + "ruff>=0.12,<1", +] + +[tool.hatch.version] +path = "livekit/plugins/executorch/version.py" + +[tool.hatch.build] +include = [ + "/LICENSE", + "/PROVENANCE.md", + "/README.md", + "/livekit", +] + +[tool.hatch.build.targets.wheel] +packages = ["livekit"] + +[tool.hatch.build.targets.sdist] +include = [ + "/LICENSE", + "/PROVENANCE.md", + "/README.md", + "/livekit", + "/tests", +] + +[tool.pytest.ini_options] +asyncio_mode = "auto" +asyncio_default_fixture_loop_scope = "function" +markers = ["unit: hermetic unit tests"] + +[tool.ruff] +line-length = 100 +target-version = "py313" + +[tool.ruff.lint] +select = ["E", "F", "I", "UP", "B", "SIM"] diff --git a/muse_glimmer/macos/packages/livekit-plugins-executorch/tests/fake_helper.py b/muse_glimmer/macos/packages/livekit-plugins-executorch/tests/fake_helper.py new file mode 100644 index 0000000000..f23d7b2187 --- /dev/null +++ b/muse_glimmer/macos/packages/livekit-plugins-executorch/tests/fake_helper.py @@ -0,0 +1,207 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import json +import os +import struct +import sys +import time + + +def send(message: dict[str, object], payload: bytes | None = None) -> None: + sys.stdout.buffer.write(json.dumps(message, separators=(",", ":")).encode() + b"\n") + if payload is not None: + sys.stdout.buffer.write(payload) + sys.stdout.buffer.flush() + + +def main() -> int: + mode = os.environ.get("FAKE_HELPER_MODE", "stt") + if mode == "timeout": + time.sleep(60) + return 0 + if mode == "stderr_crash": + print("model load exploded", file=sys.stderr, flush=True) + return 17 + if mode == "malformed_ready": + sys.stdout.buffer.write(b"not-json\n") + sys.stdout.buffer.flush() + return 0 + if mode == "oversized": + send({"type": "ready", "version": 1}) + send({"type": "audio_chunk", "version": 1, "payload_byte_count": 999999}) + return 0 + + if mode.startswith("tts"): + send( + { + "type": "ready", + "version": 1, + "sample_rate": 24000, + "channel_count": 1, + "encoding": "f32le", + } + ) + else: + send({"type": "ready", "version": 1}) + + active_request_id: str | None = None + for raw_line in sys.stdin.buffer: + request = json.loads(raw_line) + request_type = request.get("type") + if request_type == "shutdown": + if mode == "ignore_shutdown": + continue + return 0 + if request_type == "transcribe": + audio = request["audio"] + payload = sys.stdin.buffer.read(audio["payload_byte_count"]) + if mode == "eof": + return 3 + if mode == "stt_slow": + time.sleep(60) + continue + if audio != { + "encoding": "f32le", + "sample_rate": 16000, + "channel_count": 1, + "payload_byte_count": len(payload), + }: + send( + { + "type": "error", + "version": 1, + "request_id": request["request_id"], + "message": "invalid audio descriptor", + } + ) + continue + if mode == "stt_bad_result": + send( + { + "type": "result", + "version": 1, + "request_id": request["request_id"], + "text": 42, + } + ) + continue + if mode == "stt_error": + send( + { + "type": "error", + "version": 1, + "request_id": request["request_id"], + "message": "bad audio", + "details": "fake failure", + } + ) + continue + samples = struct.unpack(f"<{len(payload) // 4}f", payload) + send( + { + "type": "status", + "version": 1, + "request_id": request["request_id"], + "phase": "running_encoder", + "message": "Running encoder...", + } + ) + send( + { + "type": "result", + "version": 1, + "request_id": request["request_id"], + "text": ",".join(f"{sample:.3f}" for sample in samples[:4]), + "audio_descriptor": audio, + } + ) + continue + if request_type == "synthesize": + request_id = request["request_id"] + active_request_id = request_id + if ( + request.get("voice") != "voice.pt" + or request.get("temperature") != 0.25 + or request.get("max_new_tokens") != 321 + ): + send( + { + "type": "error", + "version": 1, + "request_id": request_id, + "message": "invalid synthesis options", + } + ) + continue + if mode == "tts_error": + send( + { + "type": "error", + "version": 1, + "request_id": request_id, + "message": "voice missing", + } + ) + continue + if mode == "tts_cancel_timeout": + time.sleep(60) + continue + if mode == "tts_slow": + time.sleep(60) + continue + if mode == "tts_wait_cancel": + continue + if mode == "tts_finish_cancel_race": + send( + { + "type": "result", + "version": 1, + "request_id": request_id, + "cancelled": False, + "sample_count": 0, + } + ) + active_request_id = None + continue + chunks = [(-1.5, -1.0, -0.5, 0.0), (0.5, 1.0, 1.5)] + for chunk in chunks: + if mode == "tts_progressive": + chunk = chunk * 300 + payload = struct.pack(f"<{len(chunk)}f", *chunk) + send( + { + "type": "audio_chunk", + "version": 1, + "request_id": request_id, + "payload_byte_count": len(payload), + }, + payload, + ) + send( + { + "type": "result", + "version": 1, + "request_id": request_id, + "cancelled": "no" if mode == "tts_bad_result" else False, + "sample_count": 7, + "request": request, + } + ) + continue + if request_type == "cancel" and request["request_id"] == active_request_id: + send( + { + "type": "result", + "version": 1, + "request_id": request["request_id"], + "cancelled": True, + "sample_count": 0, + } + ) + active_request_id = None + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/muse_glimmer/macos/packages/livekit-plugins-executorch/tests/fake_supertonic_runner.py b/muse_glimmer/macos/packages/livekit-plugins-executorch/tests/fake_supertonic_runner.py new file mode 100755 index 0000000000..35d5e17a3e --- /dev/null +++ b/muse_glimmer/macos/packages/livekit-plugins-executorch/tests/fake_supertonic_runner.py @@ -0,0 +1,124 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import json +import os +import signal +import struct +import sys +import time +import wave +from pathlib import Path + + +def _args() -> list[str]: + return sys.argv[1:] + + +def _send(message: dict[str, object]) -> None: + sys.stdout.write(json.dumps(message, separators=(",", ":")) + "\n") + sys.stdout.flush() + + +def _write_wav(path: Path, *, channels: int = 1, rate: int = 44100, width: int = 2) -> None: + samples = 4410 + if width == 2: + payload = struct.pack(f"<{samples * channels}h", *([1000] * samples * channels)) + else: + payload = b"\x80" * samples * channels + with wave.open(str(path), "wb") as output: + output.setnchannels(channels) + output.setsampwidth(width) + output.setframerate(rate) + output.writeframes(payload) + + +def main() -> int: + argv = _args() + capture_argv = os.getenv("FAKE_SUPERTONIC_ARGV_CAPTURE") + if capture_argv: + Path(capture_argv).write_text("\n".join(argv), encoding="utf-8") + if "--server_jsonl=true" not in argv: + print("server mode required", file=sys.stderr, flush=True) + return 2 + + mode = os.getenv("FAKE_SUPERTONIC_MODE", "success") + if mode == "ready_timeout": + time.sleep(60) + return 0 + if mode == "bad_ready": + _send({"type": "ready", "protocol_version": 2}) + return 3 + if mode == "stderr_crash": + print("model load exploded", file=sys.stderr, flush=True) + return 17 + + _send( + { + "type": "ready", + "protocol_version": 1, + "sample_rate": 44100, + "load_seconds": 0.01, + "warmup_seconds": 0.02, + } + ) + for line in sys.stdin: + request = json.loads(line) + capture_request = os.getenv("FAKE_SUPERTONIC_REQUEST_CAPTURE") + if capture_request and request.get("type") == "synthesize": + with Path(capture_request).open("a", encoding="utf-8") as output: + output.write(json.dumps(request, separators=(",", ":")) + "\n") + if request.get("type") == "shutdown": + if mode == "ignore_shutdown": + continue + _send({"type": "stopped"}) + return 0 + if request.get("type") != "synthesize": + _send({"type": "error", "id": request.get("id"), "message": "bad request"}) + continue + if mode == "sleep": + time.sleep(60) + continue + if mode == "ignore_terminate": + signal.signal(signal.SIGTERM, signal.SIG_IGN) + time.sleep(60) + continue + if mode == "error": + print("voice style is invalid", file=sys.stderr, flush=True) + _send( + { + "type": "error", + "id": request["id"], + "message": "voice style is invalid", + } + ) + continue + if mode == "wrong_id": + request["id"] += 1 + output_path = Path(request["output"]) + if mode == "malformed": + output_path.write_bytes(b"not a wav") + elif mode == "stereo": + _write_wav(output_path, channels=2) + elif mode == "wrong_rate": + _write_wav(output_path, rate=24000) + elif mode == "wrong_width": + _write_wav(output_path, width=1) + elif mode != "missing": + _write_wav(output_path) + _send( + { + "type": "result", + "id": request["id"], + "output": request["output"], + "samples": 4410, + "audio_seconds": 0.1, + "synthesis_seconds": 0.01, + "rtf": 0.1, + } + ) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/muse_glimmer/macos/packages/livekit-plugins-executorch/tests/test_helper_process.py b/muse_glimmer/macos/packages/livekit-plugins-executorch/tests/test_helper_process.py new file mode 100644 index 0000000000..977041e6f0 --- /dev/null +++ b/muse_glimmer/macos/packages/livekit-plugins-executorch/tests/test_helper_process.py @@ -0,0 +1,125 @@ +from __future__ import annotations + +import asyncio +import sys +from pathlib import Path + +import pytest + +from livekit.plugins.executorch._helper_process import ( + HelperProcess, + HelperProcessError, + HelperProtocolError, +) + +pytestmark = pytest.mark.unit + +_FAKE_HELPER = Path(__file__).with_name("fake_helper.py") + + +def helper(*, ready_timeout: float = 1.0, max_payload_bytes: int = 1024) -> HelperProcess: + return HelperProcess( + sys.executable, + [str(_FAKE_HELPER)], + name="fake", + ready_timeout=ready_timeout, + shutdown_timeout=0.05, + terminate_timeout=0.05, + max_payload_bytes=max_payload_bytes, + ) + + +async def test_ready_write_read_and_shutdown(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("FAKE_HELPER_MODE", "stt") + process = helper() + ready = await process.start() + assert ready == {"type": "ready", "version": 1} + + payload = b"\x00\x00\x00\x00" + await process.write_message( + { + "type": "transcribe", + "version": 1, + "request_id": "request-1", + "audio": { + "encoding": "f32le", + "sample_rate": 16000, + "channel_count": 1, + "payload_byte_count": len(payload), + }, + }, + payload, + ) + status, status_payload = await process.read_message() + result, result_payload = await process.read_message() + assert status["type"] == "status" + assert status_payload is None + assert result["text"] == "0.000" + assert result_payload is None + + await process.aclose() + assert not process.running + + +async def test_startup_timeout_reports_recent_stderr(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("FAKE_HELPER_MODE", "stderr_crash") + process = helper() + with pytest.raises(HelperProcessError, match="model load exploded"): + await process.start() + assert not process.running + + +async def test_startup_timeout_terminates_helper(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("FAKE_HELPER_MODE", "timeout") + process = helper(ready_timeout=0.02) + with pytest.raises(HelperProcessError, match="did not become ready"): + await process.start() + assert not process.running + + +async def test_shutdown_escalates_for_unresponsive_helper(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("FAKE_HELPER_MODE", "ignore_shutdown") + process = helper() + await process.start() + await asyncio.wait_for(process.aclose(), timeout=1.0) + assert not process.running + + +async def test_malformed_header_is_rejected(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("FAKE_HELPER_MODE", "malformed_ready") + process = helper() + with pytest.raises(HelperProcessError, match="malformed JSON"): + await process.start() + + +async def test_oversized_payload_is_rejected(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("FAKE_HELPER_MODE", "oversized") + process = helper(max_payload_bytes=32) + await process.start() + with pytest.raises(HelperProtocolError, match="payload exceeds"): + await process.read_message() + await process.aclose(graceful=False) + + +async def test_unexpected_eof_includes_exit_code(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("FAKE_HELPER_MODE", "eof") + process = helper() + await process.start() + payload = b"\x00\x00\x00\x00" + await process.write_message( + { + "type": "transcribe", + "version": 1, + "request_id": "request-1", + "audio": { + "encoding": "f32le", + "sample_rate": 16000, + "channel_count": 1, + "payload_byte_count": len(payload), + }, + }, + payload, + ) + with pytest.raises(HelperProcessError, match="unexpected EOF"): + await asyncio.wait_for(process.read_message(), 1.0) + await process.aclose(graceful=False) diff --git a/muse_glimmer/macos/packages/livekit-plugins-executorch/tests/test_stt.py b/muse_glimmer/macos/packages/livekit-plugins-executorch/tests/test_stt.py new file mode 100644 index 0000000000..44c7a089c2 --- /dev/null +++ b/muse_glimmer/macos/packages/livekit-plugins-executorch/tests/test_stt.py @@ -0,0 +1,144 @@ +from __future__ import annotations + +import asyncio +import struct +import sys +from pathlib import Path + +import pytest +from livekit.agents import APIConnectOptions, APIError, stt + +from livekit import rtc +from livekit.plugins.executorch import STT +from livekit.plugins.executorch._helper_process import HelperProcess +from livekit.plugins.executorch.stt import _audio_buffer_to_f32le + +pytestmark = pytest.mark.unit + +_FAKE_HELPER = Path(__file__).with_name("fake_helper.py") + + +def provider(*, mode: str = "stt") -> STT: + helper = HelperProcess( + sys.executable, + [str(_FAKE_HELPER)], + name="fake-parakeet", + ready_timeout=1.0, + shutdown_timeout=0.05, + terminate_timeout=0.05, + ) + return STT( + helper_path="unused", + model_path="parakeet.pte", + tokenizer_path="tokenizer.model", + _helper=helper, + ) + + +def frame(samples: tuple[int, ...], *, sample_rate: int, channels: int) -> rtc.AudioFrame: + return rtc.AudioFrame( + data=struct.pack(f"<{len(samples)}h", *samples), + sample_rate=sample_rate, + num_channels=channels, + samples_per_channel=len(samples) // channels, + ) + + +def test_audio_conversion_downmixes_and_scales_s16() -> None: + audio = frame((-32768, -32768, 16384, 16384, 32767, 32767), sample_rate=16000, channels=2) + converted = struct.unpack("<3f", _audio_buffer_to_f32le(audio)) + assert converted == pytest.approx((-1.0, 0.5, 32767 / 32768)) + + +def test_audio_conversion_resamples_to_16khz() -> None: + audio = frame(tuple([1000] * 480), sample_rate=48000, channels=1) + converted = _audio_buffer_to_f32le(audio) + assert len(converted) // 4 == pytest.approx(160, abs=2) + + +async def test_recognize_builds_final_transcript(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("FAKE_HELPER_MODE", "stt") + parakeet = provider() + event = await parakeet.recognize( + frame((-32768, 0, 16384, 32767), sample_rate=16000, channels=1), + language="en-US", + conn_options=APIConnectOptions(max_retry=0, timeout=1.0), + ) + assert event.type is stt.SpeechEventType.FINAL_TRANSCRIPT + assert event.request_id.startswith("parakeet_") + assert event.alternatives[0].text == "-1.000,0.000,0.500,1.000" + assert str(event.alternatives[0].language) == "en-US" + assert event.alternatives[0].metadata == { + "provider": "ExecuTorch", + "model": "parakeet.pte", + "runtime": "parakeet_helper", + } + await parakeet.aclose() + + +async def test_malformed_result_is_non_retryable(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("FAKE_HELPER_MODE", "stt_bad_result") + parakeet = provider() + with pytest.raises(APIError, match="field 'text' must be a string") as exc_info: + await parakeet.recognize( + frame((0,), sample_rate=16000, channels=1), + conn_options=APIConnectOptions(max_retry=0, timeout=1.0), + ) + assert not exc_info.value.retryable + assert not parakeet._helper.running + await parakeet.aclose() + + +async def test_helper_error_is_non_retryable(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("FAKE_HELPER_MODE", "stt_error") + parakeet = provider() + with pytest.raises(APIError, match="bad audio: fake failure") as exc_info: + await parakeet.recognize( + frame((0,), sample_rate=16000, channels=1), + conn_options=APIConnectOptions(max_retry=0, timeout=1.0), + ) + assert not exc_info.value.retryable + await parakeet.aclose() + + +async def test_recognition_calls_are_serialized(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("FAKE_HELPER_MODE", "stt") + parakeet = provider() + audio = frame((0,), sample_rate=16000, channels=1) + events = await asyncio.gather( + parakeet.recognize(audio, conn_options=APIConnectOptions(max_retry=0, timeout=1.0)), + parakeet.recognize(audio, conn_options=APIConnectOptions(max_retry=0, timeout=1.0)), + ) + assert len({event.request_id for event in events}) == 2 + assert all(event.alternatives[0].text == "0.000" for event in events) + await parakeet.aclose() + + +async def test_cancellation_closes_uncancellable_helper( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setenv("FAKE_HELPER_MODE", "stt_slow") + parakeet = provider() + task = asyncio.create_task( + parakeet.recognize( + frame((0,), sample_rate=16000, channels=1), + conn_options=APIConnectOptions(max_retry=0, timeout=60.0), + ) + ) + await asyncio.sleep(0.05) + assert parakeet._helper._process is not None + old_pid = parakeet._helper._process.pid + task.cancel() + with pytest.raises(asyncio.CancelledError): + await task + assert not parakeet._helper.running + + monkeypatch.setenv("FAKE_HELPER_MODE", "stt") + event = await parakeet.recognize( + frame((0,), sample_rate=16000, channels=1), + conn_options=APIConnectOptions(max_retry=0, timeout=1.0), + ) + assert parakeet._helper._process is not None + assert parakeet._helper._process.pid != old_pid + assert event.alternatives[0].text == "0.000" + await parakeet.aclose() diff --git a/muse_glimmer/macos/packages/livekit-plugins-executorch/tests/test_supertonic_tts.py b/muse_glimmer/macos/packages/livekit-plugins-executorch/tests/test_supertonic_tts.py new file mode 100644 index 0000000000..35043a0395 --- /dev/null +++ b/muse_glimmer/macos/packages/livekit-plugins-executorch/tests/test_supertonic_tts.py @@ -0,0 +1,282 @@ +from __future__ import annotations + +import asyncio +import json +import sys +from pathlib import Path + +import pytest +from livekit.agents import APIConnectOptions, APIError, APITimeoutError + +from livekit.plugins.executorch import SupertonicTTS + +pytestmark = pytest.mark.unit + +_FAKE_RUNNER = Path(__file__).with_name("fake_supertonic_runner.py") + + +@pytest.fixture(autouse=True) +def executable_runner() -> None: + _FAKE_RUNNER.chmod(0o755) + + +def provider( + tmp_path: Path, + *, + ready_timeout: float = 1.0, + shutdown_timeout: float = 0.05, + terminate_timeout: float = 0.05, +) -> SupertonicTTS: + pte = tmp_path / "supertonic.pte" + voice = tmp_path / "F1.json" + assets = tmp_path / "assets" + pte.write_bytes(b"pte") + voice.write_text("{}", encoding="utf-8") + assets.mkdir(exist_ok=True) + return SupertonicTTS( + runner_path=_FAKE_RUNNER, + pte_path=pte, + asset_dir=assets, + voice_style_path=voice, + language="en", + speed=1.05, + seed=42, + ready_timeout=ready_timeout, + shutdown_timeout=shutdown_timeout, + terminate_timeout=terminate_timeout, + ) + + +async def collect(supertonic: SupertonicTTS, text: str, *, timeout: float = 1.0): + stream = supertonic.synthesize( + text, + conn_options=APIConnectOptions(max_retry=0, timeout=timeout), + ) + return [event async for event in stream] + + +async def test_reuses_one_server_and_sends_text_only_over_jsonl( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + argv_capture = tmp_path / "argv.txt" + request_capture = tmp_path / "requests.jsonl" + monkeypatch.setenv("FAKE_SUPERTONIC_ARGV_CAPTURE", str(argv_capture)) + monkeypatch.setenv("FAKE_SUPERTONIC_REQUEST_CAPTURE", str(request_capture)) + text = "hello; touch /tmp/should-not-run && $(false)" + supertonic = provider(tmp_path) + + first = await collect(supertonic, text) + assert supertonic._process is not None + pid = supertonic._process.pid + second = await collect(supertonic, "second request") + + assert first and second + assert supertonic._process is not None and supertonic._process.pid == pid + argv = argv_capture.read_text(encoding="utf-8").splitlines() + assert "--server_jsonl=true" in argv + assert all(not argument.startswith("--text") for argument in argv) + assert text not in "\n".join(argv) + requests = [json.loads(line) for line in request_capture.read_text().splitlines()] + assert [request["text"] for request in requests] == [text, "second request"] + assert [request["id"] for request in requests] == [1, 2] + await supertonic.aclose() + assert not supertonic.running + + +async def test_oversized_request_does_not_poison_server(tmp_path: Path) -> None: + supertonic = provider(tmp_path) + + with pytest.raises(APIError, match="JSONL size limit") as exc_info: + await collect(supertonic, "x" * (64 * 1024)) + + assert not exc_info.value.retryable + assert not supertonic._request_active + assert await collect(supertonic, "small request") + await supertonic.aclose() + + +async def test_emits_44100_hz_pcm_without_wav_header(tmp_path: Path) -> None: + supertonic = provider(tmp_path) + events = await collect(supertonic, "hello") + payload = b"".join(event.frame.data.tobytes() for event in events) + + assert payload + assert not payload.startswith(b"RIFF") + assert events[0].request_id.startswith("supertonic_") + assert events[0].frame.sample_rate == 44100 + assert events[0].frame.num_channels == 1 + assert events[-1].is_final + await supertonic.aclose() + + +@pytest.mark.parametrize( + ("mode", "message"), + [ + ("missing", "missing"), + ("malformed", "invalid audio"), + ("stereo", "mono"), + ("wrong_rate", "44100 Hz"), + ("wrong_width", "PCM16"), + ], +) +async def test_rejects_invalid_wav( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, + mode: str, + message: str, +) -> None: + monkeypatch.setenv("FAKE_SUPERTONIC_MODE", mode) + supertonic = provider(tmp_path) + with pytest.raises(APIError, match=message) as exc_info: + await collect(supertonic, "hello") + assert not exc_info.value.retryable + await supertonic.aclose() + + +async def test_runner_error_includes_message( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + monkeypatch.setenv("FAKE_SUPERTONIC_MODE", "error") + supertonic = provider(tmp_path) + with pytest.raises(APIError, match="voice style is invalid") as exc_info: + await collect(supertonic, "hello") + assert not exc_info.value.retryable + await supertonic.aclose() + + +async def test_ready_timeout_kills_and_reaps_process( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + monkeypatch.setenv("FAKE_SUPERTONIC_MODE", "ready_timeout") + supertonic = provider(tmp_path, ready_timeout=0.02) + with pytest.raises(APITimeoutError): + await collect(supertonic, "hello") + assert not supertonic.running + await supertonic.aclose() + + +async def test_cancellation_during_startup_kills_and_reaps_process( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + monkeypatch.setenv("FAKE_SUPERTONIC_MODE", "ready_timeout") + supertonic = provider(tmp_path, ready_timeout=60.0) + stream = supertonic.synthesize( + "hello", conn_options=APIConnectOptions(max_retry=0, timeout=60.0) + ) + + await asyncio.sleep(0.05) + await stream.aclose() + + assert not supertonic.running + assert supertonic._process is None + await supertonic.aclose() + + +async def test_synthesis_timeout_kills_and_reaps_process( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + monkeypatch.setenv("FAKE_SUPERTONIC_MODE", "ignore_terminate") + supertonic = provider(tmp_path, terminate_timeout=0.01) + with pytest.raises(APITimeoutError): + await collect(supertonic, "hello", timeout=0.02) + assert not supertonic.running + await supertonic.aclose() + + +async def test_stream_cancellation_terminates_process( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + monkeypatch.setenv("FAKE_SUPERTONIC_MODE", "sleep") + supertonic = provider(tmp_path) + stream = supertonic.synthesize( + "hello", conn_options=APIConnectOptions(max_retry=0, timeout=60.0) + ) + + await asyncio.sleep(0.05) + await stream.aclose() + + assert not supertonic.running + await supertonic.aclose() + + +async def test_aclose_terminates_active_request( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + monkeypatch.setenv("FAKE_SUPERTONIC_MODE", "sleep") + supertonic = provider(tmp_path) + stream = supertonic.synthesize( + "hello", conn_options=APIConnectOptions(max_retry=0, timeout=60.0) + ) + task = asyncio.create_task(anext(stream)) + + await asyncio.sleep(0.05) + await supertonic.aclose() + with pytest.raises(APIError): + await task + await stream.aclose() + assert not supertonic.running + + +async def test_synthesis_calls_are_serialized_and_share_process( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + request_capture = tmp_path / "requests.jsonl" + monkeypatch.setenv("FAKE_SUPERTONIC_REQUEST_CAPTURE", str(request_capture)) + supertonic = provider(tmp_path) + + results = await asyncio.gather(collect(supertonic, "one"), collect(supertonic, "two")) + + assert all(result for result in results) + assert supertonic.running + assert len(request_capture.read_text().splitlines()) == 2 + await supertonic.aclose() + + +async def test_shutdown_escalates_for_unresponsive_server( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + monkeypatch.setenv("FAKE_SUPERTONIC_MODE", "ignore_shutdown") + supertonic = provider(tmp_path) + await collect(supertonic, "hello") + await asyncio.wait_for(supertonic.aclose(), timeout=1.0) + assert not supertonic.running + + +async def test_protocol_mismatch_terminates_server( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + monkeypatch.setenv("FAKE_SUPERTONIC_MODE", "wrong_id") + supertonic = provider(tmp_path) + with pytest.raises(APIError, match="response id"): + await collect(supertonic, "hello") + assert not supertonic.running + await supertonic.aclose() + + +def test_constructor_validates_paths_and_options(tmp_path: Path) -> None: + with pytest.raises(ValueError, match="runner_path"): + SupertonicTTS( + runner_path=tmp_path / "missing", + pte_path=tmp_path / "missing.pte", + asset_dir=tmp_path, + voice_style_path=tmp_path / "missing.json", + ) + + runner = tmp_path / "runner" + pte = tmp_path / "model.pte" + voice = tmp_path / "voice.json" + for path in (runner, pte, voice): + path.write_bytes(b"x") + runner.chmod(0o755) + with pytest.raises(ValueError, match="speed"): + SupertonicTTS( + runner_path=runner, + pte_path=pte, + asset_dir=tmp_path, + voice_style_path=voice, + speed=0.0, + ) + + +def test_runner_can_be_python_interpreter_for_fake_protocol(tmp_path: Path) -> None: + assert Path(sys.executable).is_file() diff --git a/muse_glimmer/macos/pyproject.toml b/muse_glimmer/macos/pyproject.toml new file mode 100644 index 0000000000..177de64c60 --- /dev/null +++ b/muse_glimmer/macos/pyproject.toml @@ -0,0 +1,41 @@ +[project] +name = "muse-glimmer-voice-agent-workspace" +version = "0.1.0" +description = "Fully local Muse Glimmer voice agent for macOS Apple silicon" +requires-python = ">=3.13,<3.14" +license = "BSD-3-Clause" + +[tool.uv.workspace] +members = [ + "apps/token-service", + "apps/worker", + "packages/livekit-plugins-executorch", +] + +[tool.uv] +package = false + +[dependency-groups] +dev = [ + "jsonschema>=4.25,<5", + "pytest>=8.4,<9", + "ruff>=0.12,<1", +] + +[tool.pytest.ini_options] +testpaths = ["tests", "apps", "packages"] +addopts = "--strict-markers --import-mode=importlib" +asyncio_mode = "auto" +asyncio_default_fixture_loop_scope = "function" +markers = [ + "unit: hermetic unit test", + "e2e: model-heavy macOS integration test", +] + +[tool.ruff] +line-length = 100 +target-version = "py313" +exclude = [".local"] + +[tool.ruff.lint] +select = ["E", "F", "I", "UP", "B", "SIM"] diff --git a/muse_glimmer/macos/scripts/__init__.py b/muse_glimmer/macos/scripts/__init__.py new file mode 100644 index 0000000000..c3e0ccb430 --- /dev/null +++ b/muse_glimmer/macos/scripts/__init__.py @@ -0,0 +1 @@ +"""Repository lifecycle and validation tools.""" diff --git a/muse_glimmer/macos/scripts/bootstrap.py b/muse_glimmer/macos/scripts/bootstrap.py new file mode 100644 index 0000000000..6e91b04e96 --- /dev/null +++ b/muse_glimmer/macos/scripts/bootstrap.py @@ -0,0 +1,146 @@ +from __future__ import annotations + +import argparse +import json +import os +import re +import shutil +import subprocess +import sys +from pathlib import Path + +from scripts.repository import ( + BOOTSTRAP_INPUTS, + BOOTSTRAP_RECEIPT, + ROOT, + TOOLCHAIN_LOCK, + WEB_DIST, + atomic_write_json, + digest_json, + digest_paths, + ensure_local_directories, + landed_gate_commits, + python_environment_fingerprint, + read_json, + require_supported_platform, + sha256_tree, + validate_executorch_checkout, +) + + +def _version(command: list[str]) -> str: + result = subprocess.run(command, check=True, capture_output=True, text=True) + return (result.stdout or result.stderr).strip().splitlines()[0] + + +_VERSION = re.compile(r"\d+(?:\.\d+){0,2}") + + +def _version_tuple(value: str) -> tuple[int, int, int]: + match = _VERSION.search(value) + if match is None: + raise RuntimeError(f"could not parse tool version: {value!r}") + parts = tuple(int(part) for part in match.group().split(".")) + return (parts + (0, 0, 0))[:3] + + +def _require_version(name: str, actual: str, requirement: str) -> None: + version = _version_tuple(actual) + for constraint in requirement.split(","): + constraint = constraint.strip() + if constraint.startswith(">=") and version < _version_tuple(constraint[2:]): + raise RuntimeError(f"{name} {actual!r} does not satisfy {requirement}") + if constraint.startswith("<") and version >= _version_tuple(constraint[1:]): + raise RuntimeError(f"{name} {actual!r} does not satisfy {requirement}") + + +def _require_tool(name: str) -> str: + executable = shutil.which(name) + if executable is None: + raise RuntimeError(f"required tool is missing: {name}") + return executable + + +def main() -> int: + parser = argparse.ArgumentParser( + description="Prepare source dependencies for local development" + ) + parser.add_argument( + "--skip-install", action="store_true", help="validate without installing packages" + ) + args = parser.parse_args() + + require_supported_platform() + ensure_local_directories() + compatibility = read_json(ROOT / "config/dependencies/compatibility.lock.json") + toolchain = read_json(ROOT / "config/dependencies/toolchain.lock.json") + tools = { + name: _require_tool(name) + for name in ("git", "uv", "node", "npm", "cmake", "livekit-server") + } + versions = { + "python": sys.version.split()[0], + **{name: _version([path, "--version"]) for name, path in tools.items()}, + } + for name, requirement in toolchain["tools"].items(): + _require_version(name, versions[name], requirement) + + commit = compatibility["executorch"].get("commit") + if not commit: + raise RuntimeError( + "the compatibility lock is gated until one ExecuTorch commit contains " + "supports_cancel and supertonic_server_jsonl" + ) + + checkout = ( + Path(os.environ.get("GLIMMER_EXECUTORCH_ROOT", ROOT / ".local/src/executorch")) + .expanduser() + .resolve() + ) + validate_executorch_checkout( + checkout, + commit, + required_ancestors=landed_gate_commits(compatibility), + ) + + if not args.skip_install: + subprocess.run( + [tools["uv"], "sync", "--all-packages", "--all-groups", "--frozen"], + cwd=ROOT, + check=True, + ) + subprocess.run([tools["npm"], "ci", "--prefix", "apps/web"], cwd=ROOT, check=True) + subprocess.run([tools["npm"], "run", "build", "--prefix", "apps/web"], cwd=ROOT, check=True) + + if not WEB_DIST.is_dir(): + raise RuntimeError("web application is not built; run bootstrap without --skip-install") + runtime_python = ROOT / ".venv/bin/python" + if not os.access(runtime_python, os.X_OK): + raise RuntimeError( + "Python workspace environment is missing; run bootstrap without --skip-install" + ) + runtime_python_version = _version([str(runtime_python), "--version"]).removeprefix("Python ") + _require_version("python", runtime_python_version, toolchain["tools"]["python"]) + + receipt = { + "schema_version": 1, + "toolchain_lock": digest_json(TOOLCHAIN_LOCK), + "bootstrap_inputs": digest_paths(BOOTSTRAP_INPUTS), + "web_dist": sha256_tree(WEB_DIST), + "python_environment": python_environment_fingerprint(runtime_python), + "tools": { + "python": {"path": str(runtime_python), "version": runtime_python_version}, + **{name: {"path": path, "version": versions[name]} for name, path in tools.items()}, + }, + } + atomic_write_json(BOOTSTRAP_RECEIPT, receipt) + print(json.dumps({"status": "ok", "tools": versions}, indent=2)) + return 0 + + +if __name__ == "__main__": + try: + raise SystemExit(main()) + except (OSError, RuntimeError, subprocess.CalledProcessError) as error: + print(f"bootstrap: {error}", file=sys.stderr) + raise SystemExit(1) from error diff --git a/muse_glimmer/macos/scripts/dev_stack.py b/muse_glimmer/macos/scripts/dev_stack.py new file mode 100644 index 0000000000..bf06806c8e --- /dev/null +++ b/muse_glimmer/macos/scripts/dev_stack.py @@ -0,0 +1,583 @@ +from __future__ import annotations + +import argparse +import contextlib +import fcntl +import hashlib +import json +import os +import re +import secrets +import signal +import socket +import subprocess +import sys +import time +import urllib.error +import urllib.request +from dataclasses import dataclass +from pathlib import Path +from typing import IO + +from scripts.repository import ( + BOOTSTRAP_INPUTS, + BOOTSTRAP_RECEIPT, + CREDENTIAL_FILE, + LOG_DIR, + ROOT, + RUN_DIR, + TOOLCHAIN_LOCK, + WEB_DIST, + atomic_write_json, + digest_json, + digest_paths, + ensure_local_directories, + load_valid_receipt, + python_environment_fingerprint, + relative_local_path, + sha256_tree, +) + +STATE_FILE = RUN_DIR / "stack.json" +LOCK_FILE = RUN_DIR / "stack.lock" +SERVICE_ORDER = ("llm", "livekit", "token", "web", "agent") +HTTP_ENDPOINTS = { + "llm": "http://127.0.0.1:8000/health", + "livekit": "http://127.0.0.1:7880", + "token": "http://127.0.0.1:8787/healthz", + "web": "http://127.0.0.1:5173", +} + + +@dataclass(frozen=True) +class Service: + name: str + command: list[str] + cwd: Path + environment: dict[str, str] + + +def _command_digest(command: list[str]) -> str: + return hashlib.sha256(b"\0".join(part.encode() for part in command)).hexdigest() + + +def _observed_command_digest(command: str) -> str: + return hashlib.sha256(command.encode()).hexdigest() + + +def _process_start(pid: int) -> str | None: + result = subprocess.run(["ps", "-p", str(pid), "-o", "lstart="], capture_output=True, text=True) + value = result.stdout.strip() + return value or None + + +def _process_command(pid: int) -> str | None: + result = subprocess.run( + ["ps", "-p", str(pid), "-o", "command="], capture_output=True, text=True + ) + value = result.stdout.strip() + return value or None + + +def _pid_alive(pid: int) -> bool: + try: + os.kill(pid, 0) + except (ProcessLookupError, PermissionError): + return False + return True + + +def _read_state() -> dict[str, object]: + if not STATE_FILE.is_file(): + return {"schema_version": 1, "services": {}} + with STATE_FILE.open(encoding="utf-8") as stream: + value = json.load(stream) + if not isinstance(value, dict) or not isinstance(value.get("services"), dict): + raise RuntimeError("managed stack state is invalid") + return value + + +def _service_matches(record: dict[str, object]) -> bool: + pid = record.get("pid") + if not isinstance(pid, int) or not _pid_alive(pid): + return False + if _process_start(pid) != record.get("start_time"): + return False + command = _process_command(pid) + expected = record.get("command_marker") + digest = record.get("observed_command_digest") + return ( + isinstance(expected, str) + and command is not None + and expected in command + and isinstance(digest, str) + and _observed_command_digest(command) == digest + ) + + +@contextlib.contextmanager +def _lifecycle_lock() -> IO[str]: + ensure_local_directories() + stream = LOCK_FILE.open("a+", encoding="utf-8") + try: + fcntl.flock(stream.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB) + except BlockingIOError as error: + stream.close() + raise RuntimeError("another stack lifecycle operation is in progress") from error + try: + yield stream + finally: + fcntl.flock(stream.fileno(), fcntl.LOCK_UN) + stream.close() + + +def _base_environment() -> dict[str, str]: + allowed = ("HOME", "LANG", "LC_ALL", "PATH", "TMPDIR", "SSL_CERT_FILE") + return {name: os.environ[name] for name in allowed if name in os.environ} + + +def _artifact(receipt: dict[str, object], role: str) -> str: + artifacts = receipt.get("artifacts") + if not isinstance(artifacts, dict) or role not in artifacts: + raise RuntimeError(f"prepared artifact is missing from receipt: {role}") + item = artifacts[role] + if not isinstance(item, dict) or not isinstance(item.get("path"), str): + raise RuntimeError(f"prepared artifact receipt is invalid: {role}") + return str(relative_local_path(item["path"])) + + +def _new_credentials() -> tuple[str, str]: + api_key = f"local_{secrets.token_hex(8)}" + api_secret = secrets.token_urlsafe(36) + temporary = CREDENTIAL_FILE.with_name( + f".{CREDENTIAL_FILE.name}.{os.getpid()}.{secrets.token_hex(4)}.tmp" + ) + descriptor = os.open(temporary, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600) + try: + with os.fdopen(descriptor, "w", encoding="utf-8") as stream: + stream.write(f"{api_key}: {api_secret}\n") + stream.flush() + os.fsync(stream.fileno()) + os.replace(temporary, CREDENTIAL_FILE) + except BaseException: + temporary.unlink(missing_ok=True) + raise + return api_key, api_secret + + +def _validated_tools() -> dict[str, str]: + if not BOOTSTRAP_RECEIPT.is_file(): + raise RuntimeError("source dependencies are not bootstrapped; run `make bootstrap`") + with BOOTSTRAP_RECEIPT.open(encoding="utf-8") as stream: + receipt = json.load(stream) + if not WEB_DIST.is_dir() or ( + receipt.get("toolchain_lock") != digest_json(TOOLCHAIN_LOCK) + or receipt.get("bootstrap_inputs") != digest_paths(BOOTSTRAP_INPUTS) + or receipt.get("web_dist") != sha256_tree(WEB_DIST) + ): + raise RuntimeError("bootstrap receipt is stale; run `make bootstrap`") + tools = receipt.get("tools") + required = {"python", "node", "livekit-server"} + if not isinstance(tools, dict) or not required <= set(tools): + raise RuntimeError("bootstrap receipt is invalid; run `make bootstrap`") + paths: dict[str, str] = {} + for name in required: + item = tools[name] + if not isinstance(item, dict) or not isinstance(item.get("path"), str): + raise RuntimeError("bootstrap receipt is invalid; run `make bootstrap`") + path = item["path"] + if not os.access(path, os.X_OK): + raise RuntimeError(f"bootstrapped tool is unavailable: {name}") + paths[name] = path + if receipt.get("python_environment") != python_environment_fingerprint(Path(paths["python"])): + raise RuntimeError("Python environment changed; run `make bootstrap`") + return paths + + +def _services(receipt: dict[str, object], api_key: str, api_secret: str) -> list[Service]: + base = _base_environment() + venv_bin = ROOT / ".venv/bin" + token_service = str(venv_bin / "muse-glimmer-token-service") + worker = str(venv_bin / "muse-glimmer-worker") + for executable in (token_service, worker): + if not os.access(executable, os.X_OK): + raise RuntimeError("Python workspace is not bootstrapped; run `make bootstrap`") + tools = _validated_tools() + python = tools["python"] + livekit_environment = { + **base, + "LIVEKIT_URL": "ws://127.0.0.1:7880", + "LIVEKIT_API_KEY": api_key, + "LIVEKIT_API_SECRET": api_secret, + } + worker_environment = { + **livekit_environment, + "GLIMMER_AGENT_NAME": "assistant", + "GLIMMER_LANGUAGE": "en", + "MUSE_GLIMMER_BASE_URL": "http://127.0.0.1:8000/v1", + "MUSE_GLIMMER_API_KEY": "local", + "MUSE_GLIMMER_REASONING_STRENGTH": "low", + "MUSE_GLIMMER_MAX_TOKENS": "128", + "PARAKEET_HELPER_PATH": _artifact(receipt, "parakeet_helper"), + "PARAKEET_MODEL_PATH": _artifact(receipt, "parakeet_model"), + "PARAKEET_TOKENIZER_PATH": _artifact(receipt, "parakeet_tokenizer"), + "SUPERTONIC_RUNNER_PATH": _artifact(receipt, "supertonic_runner"), + "SUPERTONIC_PTE_PATH": _artifact(receipt, "supertonic_model"), + "SUPERTONIC_ASSET_DIR": _artifact(receipt, "supertonic_assets"), + "SUPERTONIC_VOICE_STYLE_PATH": _artifact(receipt, "supertonic_voice_style"), + "SUPERTONIC_SPEED": "1.05", + "SUPERTONIC_SEED": "42", + } + return [ + Service( + "llm", + [python, "apps/muse-glimmer-server/launch.py"], + ROOT, + base, + ), + Service( + "livekit", + [ + tools["livekit-server"], + "--config", + str(ROOT / "config/livekit/macos-arm64.yaml"), + "--key-file", + str(CREDENTIAL_FILE), + ], + ROOT, + base, + ), + Service( + "token", + [token_service], + ROOT, + livekit_environment, + ), + Service( + "web", + [tools["node"], "server.mjs", "--host", "127.0.0.1", "--port", "5173"], + ROOT / "apps/web", + base, + ), + Service( + "agent", + [worker, "dev"], + ROOT, + worker_environment, + ), + ] + + +def _port_available(port: int, *, udp: bool = False) -> bool: + sock_type = socket.SOCK_DGRAM if udp else socket.SOCK_STREAM + with socket.socket(socket.AF_INET, sock_type) as probe: + try: + probe.bind(("127.0.0.1", port)) + except OSError: + return False + return True + + +def _assert_ports_available() -> None: + for port in (8000, 7880, 8787, 5173): + if not _port_available(port): + raise RuntimeError(f"TCP port {port} is already in use") + if not _port_available(7882, udp=True): + raise RuntimeError("UDP port 7882 is already in use") + + +def _process_group_alive(pgid: int) -> bool: + try: + os.killpg(pgid, 0) + return True + except ProcessLookupError: + return False + except PermissionError: + return True + + +def _wait_for_process_group(pgid: int, timeout: float) -> bool: + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + if not _process_group_alive(pgid): + return True + time.sleep(0.05) + return not _process_group_alive(pgid) + + +def _terminate_new_process_group(process: subprocess.Popen[bytes]) -> None: + pgid = process.pid + with contextlib.suppress(ProcessLookupError): + os.killpg(pgid, signal.SIGTERM) + if not _wait_for_process_group(pgid, 5): + with contextlib.suppress(ProcessLookupError): + os.killpg(pgid, signal.SIGKILL) + if not _wait_for_process_group(pgid, 5): + raise RuntimeError(f"process group {pgid} survived SIGKILL") + with contextlib.suppress(subprocess.TimeoutExpired): + process.wait(timeout=1) + + +def _start_service(service: Service) -> tuple[subprocess.Popen[bytes], dict[str, object]]: + log_path = LOG_DIR / f"{service.name}.log" + log_stream = log_path.open("wb") + try: + process = subprocess.Popen( + service.command, + cwd=service.cwd, + env=service.environment, + stdin=subprocess.DEVNULL, + stdout=log_stream, + stderr=subprocess.STDOUT, + start_new_session=True, + ) + finally: + log_stream.close() + time.sleep(0.2) + if process.poll() is not None: + _terminate_new_process_group(process) + raise RuntimeError(f"{service.name} exited during startup; see {log_path}") + start_time = _process_start(process.pid) + command = _process_command(process.pid) + if start_time is None or command is None: + _terminate_new_process_group(process) + raise RuntimeError(f"could not establish process identity for {service.name}") + marker = Path(service.command[0]).name + return process, { + "pid": process.pid, + "pgid": os.getpgid(process.pid), + "start_time": start_time, + "command_marker": marker, + "command_digest": _command_digest(service.command), + "observed_command_digest": _observed_command_digest(command), + "log": str(log_path.relative_to(ROOT)), + } + + +def _http_ready(url: str) -> bool: + try: + with urllib.request.urlopen(url, timeout=2) as response: + return response.status < 500 + except urllib.error.HTTPError as error: + return error.code < 500 + except (OSError, urllib.error.URLError): + return False + + +def _wait_for_http(name: str, record: dict[str, object], timeout: float = 120) -> None: + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + if _http_ready(HTTP_ENDPOINTS[name]): + return + if not _service_matches(record): + raise RuntimeError(f"{name} exited before readiness") + time.sleep(0.5) + raise RuntimeError(f"{name} timed out waiting for readiness") + + +def _wait_for_agent(record: dict[str, object], timeout: float = 60) -> str: + log_path = ROOT / str(record["log"]) + registration = re.compile(r'registered worker.*"agent_name"\s*:\s*"assistant"') + health_endpoint = re.compile(r"HTTP server listening on 127\.0\.0\.1:(\d+)") + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + text = log_path.read_text(encoding="utf-8", errors="replace") if log_path.exists() else "" + match = health_endpoint.search(text) + if registration.search(text) and match is not None: + url = f"http://127.0.0.1:{match.group(1)}/" + if _http_ready(url): + return url + if not _service_matches(record): + raise RuntimeError("agent exited before registration") + time.sleep(0.5) + raise RuntimeError("agent timed out waiting for assistant registration") + + +def _record_group_owned(record: dict[str, object]) -> bool: + pgid = record.get("pgid") + if not isinstance(pgid, int) or not _process_group_alive(pgid): + return False + if _service_matches(record): + return True + # A surviving child keeps the original PGID after its leader exits. If a + # process now occupies the leader PID, the PGID may have been recycled. + return not _pid_alive(pgid) + + +def _signal_service(record: dict[str, object], signum: int) -> None: + pgid = record.get("pgid") + if not isinstance(pgid, int) or not _record_group_owned(record): + return + with contextlib.suppress(ProcessLookupError): + os.killpg(pgid, signum) + + +def _stop_records(records: dict[str, object]) -> None: + for name in reversed(SERVICE_ORDER): + record = records.get(name) + if isinstance(record, dict): + _signal_service(record, signal.SIGTERM) + deadline = time.monotonic() + 10 + while time.monotonic() < deadline: + active = [ + record + for record in records.values() + if isinstance(record, dict) and _record_group_owned(record) + ] + if not active: + return + time.sleep(0.25) + for name in reversed(SERVICE_ORDER): + record = records.get(name) + if isinstance(record, dict): + _signal_service(record, signal.SIGKILL) + deadline = time.monotonic() + 5 + while True: + if not any( + isinstance(record, dict) and _record_group_owned(record) for record in records.values() + ): + return + if time.monotonic() >= deadline: + break + time.sleep(0.1) + raise RuntimeError("managed process group survived SIGKILL; state was retained") + + +def _write_state(records: dict[str, object]) -> None: + atomic_write_json( + STATE_FILE, + {"schema_version": 1, "updated_at": time.time(), "services": records}, + ) + + +def _run_probe(command: list[str], *, timeout: float = 180) -> None: + subprocess.run(command, cwd=ROOT, check=True, timeout=timeout) + + +def _up_locked() -> None: + state = _read_state() + existing = state["services"] + if any( + isinstance(record, dict) and _record_group_owned(record) for record in existing.values() + ): + raise RuntimeError( + "a managed stack or orphaned process group is still running; use `make down`" + ) + _assert_ports_available() + receipt = load_valid_receipt() + api_key, api_secret = _new_credentials() + records: dict[str, object] = {} + interrupted = False + + def handle_signal(_signum: int, _frame: object) -> None: + nonlocal interrupted + interrupted = True + raise KeyboardInterrupt + + previous = { + signum: signal.signal(signum, handle_signal) for signum in (signal.SIGINT, signal.SIGTERM) + } + try: + for service in _services(receipt, api_key, api_secret): + _, record = _start_service(service) + records[service.name] = record + _write_state(records) + if service.name == "llm": + _wait_for_http(service.name, record, 360) + _run_probe([sys.executable, "-m", "scripts.llm_readiness"]) + elif service.name == "agent": + record["health_url"] = _wait_for_agent(record) + _write_state(records) + elif service.name in HTTP_ENDPOINTS: + _wait_for_http(service.name, record, 120) + _run_probe([sys.executable, "-m", "scripts.privacy_audit"]) + except BaseException: + _stop_records(records) + STATE_FILE.unlink(missing_ok=True) + CREDENTIAL_FILE.unlink(missing_ok=True) + if interrupted: + print("startup interrupted; rolled back managed services", file=sys.stderr) + raise + finally: + for signum, handler in previous.items(): + signal.signal(signum, handler) + + +def _down_locked() -> None: + state = _read_state() + records = state["services"] + _stop_records(records) + STATE_FILE.unlink(missing_ok=True) + CREDENTIAL_FILE.unlink(missing_ok=True) + + +def up() -> int: + with _lifecycle_lock(): + _up_locked() + print("Muse Glimmer is ready at http://127.0.0.1:5173") + return 0 + + +def down() -> int: + with _lifecycle_lock(): + _down_locked() + print("Muse Glimmer stack is stopped.") + return 0 + + +def status() -> int: + state = _read_state() + records = state["services"] + healthy = True + for name in SERVICE_ORDER: + record = records.get(name) + process_ok = isinstance(record, dict) and _service_matches(record) + if name in HTTP_ENDPOINTS: + endpoint_ok = _http_ready(HTTP_ENDPOINTS[name]) + elif name == "agent" and isinstance(record, dict): + health_url = record.get("health_url") + endpoint_ok = isinstance(health_url, str) and _http_ready(health_url) + else: + endpoint_ok = False + service_ok = process_ok and endpoint_ok + healthy = healthy and service_ok + print(f"{name:<8} {'healthy' if service_ok else 'unavailable'}") + return 0 if healthy else 1 + + +def logs() -> int: + paths = [LOG_DIR / f"{name}.log" for name in SERVICE_ORDER] + existing = [path for path in paths if path.exists()] + if not existing: + raise RuntimeError("no managed logs exist") + return subprocess.run(["tail", "-n", "80", "-F", *map(str, existing)]).returncode + + +def restart() -> int: + with _lifecycle_lock(): + _down_locked() + _up_locked() + print("Muse Glimmer is ready at http://127.0.0.1:5173") + return 0 + + +def main() -> int: + parser = argparse.ArgumentParser(description="Manage the local Muse Glimmer voice stack") + parser.add_argument("operation", choices=("up", "down", "restart", "status", "logs")) + operation = parser.parse_args().operation + return {"up": up, "down": down, "restart": restart, "status": status, "logs": logs}[operation]() + + +if __name__ == "__main__": + try: + raise SystemExit(main()) + except KeyboardInterrupt: + raise SystemExit(130) from None + except ( + OSError, + RuntimeError, + subprocess.CalledProcessError, + subprocess.TimeoutExpired, + ) as error: + print(f"dev-stack: {error}", file=sys.stderr) + raise SystemExit(1) from error diff --git a/muse_glimmer/macos/scripts/llm_readiness.py b/muse_glimmer/macos/scripts/llm_readiness.py new file mode 100644 index 0000000000..764c5bd5d1 --- /dev/null +++ b/muse_glimmer/macos/scripts/llm_readiness.py @@ -0,0 +1,81 @@ +from __future__ import annotations + +import argparse +import http.client +import json +import time +import urllib.error +import urllib.request + +_BASE_URL = "http://127.0.0.1:8000" +_MODEL_ID = "muse-glimmer-k-quant-17G-128K-text-dflash-metal" + + +def _body(*, stream: bool, max_tokens: int) -> bytes: + return json.dumps( + { + "model": _MODEL_ID, + "messages": [{"role": "user", "content": "Reply with the word ready."}], + "stream": stream, + "max_tokens": max_tokens, + "temperature": 0, + "chat_template_kwargs": {"reasoning_strength": "low"}, + } + ).encode() + + +def _generation() -> None: + request = urllib.request.Request( + f"{_BASE_URL}/v1/chat/completions", + data=_body(stream=False, max_tokens=2), + headers={"Content-Type": "application/json", "Authorization": "Bearer local"}, + method="POST", + ) + with urllib.request.urlopen(request, timeout=90) as response: + payload = json.load(response) + if not payload.get("choices"): + raise RuntimeError("LLM readiness generation returned no choices") + + +def _disconnect_stream() -> None: + connection = http.client.HTTPConnection("127.0.0.1", 8000, timeout=90) + connection.request( + "POST", + "/v1/chat/completions", + body=_body(stream=True, max_tokens=128), + headers={"Content-Type": "application/json", "Authorization": "Bearer local"}, + ) + response = connection.getresponse() + if response.status != 200: + raise RuntimeError(f"LLM cancellation probe returned HTTP {response.status}") + deadline = time.monotonic() + 90 + observed_content = False + while time.monotonic() < deadline: + line = response.readline() + if not line: + break + if line.startswith(b"data:") and b"choices" in line: + observed_content = True + break + connection.close() + if not observed_content: + raise RuntimeError("LLM cancellation probe received no streamed output") + + +def main() -> int: + parser = argparse.ArgumentParser(description="Verify generation, cancellation, and reuse") + parser.add_argument("--settle-seconds", type=float, default=1.0) + args = parser.parse_args() + _generation() + _disconnect_stream() + time.sleep(args.settle_seconds) + _generation() + print("MuseGlimmer generation, cancellation, and reuse probe passed.") + return 0 + + +if __name__ == "__main__": + try: + raise SystemExit(main()) + except (OSError, RuntimeError, urllib.error.URLError) as error: + raise SystemExit(f"llm-readiness: {error}") from error diff --git a/muse_glimmer/macos/scripts/prepare_artifacts.py b/muse_glimmer/macos/scripts/prepare_artifacts.py new file mode 100644 index 0000000000..9701468947 --- /dev/null +++ b/muse_glimmer/macos/scripts/prepare_artifacts.py @@ -0,0 +1,100 @@ +from __future__ import annotations + +import json +import os +import subprocess +import sys +from datetime import UTC, datetime +from pathlib import Path + +from scripts.repository import ( + ARTIFACT_LOCK, + COMPATIBILITY_LOCK, + PREPARED_RECEIPT, + ROOT, + TOOLCHAIN_LOCK, + atomic_write_json, + digest_json, + ensure_local_directories, + landed_gate_commits, + read_json, + relative_local_path, + require_supported_platform, + sha256_file, + sha256_tree, + validate_executorch_checkout, +) + + +def main() -> int: + require_supported_platform() + ensure_local_directories() + compatibility = read_json(COMPATIBILITY_LOCK) + expected_commit = compatibility["executorch"].get("commit") + if not expected_commit: + raise RuntimeError( + "artifact preparation is gated: set one immutable ExecuTorch commit " + "containing supports_cancel and supertonic_server_jsonl" + ) + + checkout = ( + Path(os.environ.get("GLIMMER_EXECUTORCH_ROOT", ROOT / ".local/src/executorch")) + .expanduser() + .resolve() + ) + validate_executorch_checkout( + checkout, + expected_commit, + required_ancestors=landed_gate_commits(compatibility), + ) + + manifest = read_json(ARTIFACT_LOCK) + inventory: dict[str, dict[str, object]] = {} + missing: list[str] = [] + for item in manifest["artifacts"]: + path = relative_local_path(item["destination"]) + if not path.exists(): + missing.append(f"{item['role']}: {item['destination']} ({item['prepare']})") + continue + if item["kind"] == "file" and not path.is_file(): + raise RuntimeError(f"{item['role']} must be a regular file") + if item["kind"] == "directory" and not path.is_dir(): + raise RuntimeError(f"{item['role']} must be a directory") + if item["executable"] and not os.access(path, os.X_OK): + raise RuntimeError(f"{item['role']} must be executable") + checksum = sha256_tree(path) if path.is_dir() else sha256_file(path) + expected_checksum = item.get("sha256") + if expected_checksum and checksum != expected_checksum: + raise RuntimeError(f"checksum mismatch for {item['role']}") + inventory[item["role"]] = { + "path": item["destination"], + "sha256": checksum, + "size_bytes": path.stat().st_size if path.is_file() else None, + } + if missing: + details = "\n ".join(missing) + raise RuntimeError(f"required artifacts are missing:\n {details}") + + receipt = { + "schema_version": 1, + "prepared_at": datetime.now(UTC).isoformat(), + "executorch_commit": expected_commit, + "executorch_checkout": str(checkout), + "locks": { + "compatibility": digest_json(COMPATIBILITY_LOCK), + "artifacts": digest_json(ARTIFACT_LOCK), + "toolchain": digest_json(TOOLCHAIN_LOCK), + }, + "artifacts": inventory, + } + atomic_write_json(PREPARED_RECEIPT, receipt) + print(json.dumps({"status": "prepared", "receipt": str(PREPARED_RECEIPT)}, indent=2)) + return 0 + + +if __name__ == "__main__": + try: + raise SystemExit(main()) + except (OSError, RuntimeError, subprocess.CalledProcessError) as error: + print(f"prepare-artifacts: {error}", file=sys.stderr) + raise SystemExit(1) from error diff --git a/muse_glimmer/macos/scripts/privacy_audit.py b/muse_glimmer/macos/scripts/privacy_audit.py new file mode 100644 index 0000000000..08795baef0 --- /dev/null +++ b/muse_glimmer/macos/scripts/privacy_audit.py @@ -0,0 +1,134 @@ +from __future__ import annotations + +import json +import os +import re +import socket +import stat +import subprocess +import sys +import urllib.request + +from scripts.repository import CREDENTIAL_FILE, ROOT + +_TCP_PORTS = (8000, 7880, 8787, 5173) +_UDP_PORTS = (7882,) +_FORBIDDEN_BROWSER_PATTERNS = { + "LLM port": re.compile(r"127\.0\.0\.1:8000"), + "model variant": re.compile(r"muse-glimmer-k-quant|17G|128K|dflash", re.IGNORECASE), + "model runtime": re.compile(r"Parakeet|Supertonic|MUSE_GLIMMER_|PARAKEET_|SUPERTONIC_"), + "private path": re.compile(r"/" + r"Users/|\.local/artifacts|\.pte\b"), + "credential name": re.compile(r"LIVEKIT_API_SECRET"), + "cloud URL": re.compile(r"wss://|https://[^\s\"']*livekit", re.IGNORECASE), +} + + +def _socket_rows(kind: str, port: int) -> list[str]: + command = ["lsof", "-nP", f"-i{kind}:{port}"] + if kind == "TCP": + command.append("-sTCP:LISTEN") + result = subprocess.run(command, capture_output=True, text=True) + return result.stdout.splitlines()[1:] + + +def _assert_loopback_sockets() -> None: + for kind, ports in (("TCP", _TCP_PORTS), ("UDP", _UDP_PORTS)): + for port in ports: + rows = _socket_rows(kind, port) + if not rows: + raise RuntimeError(f"no {kind} listener found on required port {port}") + for row in rows: + if "127.0.0.1" not in row and "[::1]" not in row: + raise RuntimeError(f"{kind} port {port} is exposed beyond loopback: {row}") + + +def _assert_credentials() -> None: + if not CREDENTIAL_FILE.is_file(): + raise RuntimeError("runtime LiveKit credentials are missing") + mode = stat.S_IMODE(CREDENTIAL_FILE.stat().st_mode) + if mode != 0o600: + raise RuntimeError("runtime LiveKit credentials must have mode 0600") + + +def _assert_token_boundary() -> None: + request = urllib.request.Request("http://127.0.0.1:8787/api/token", method="POST") + with urllib.request.urlopen(request, timeout=5) as response: + payload = json.load(response) + if response.headers.get("Cache-Control") != "no-store": + raise RuntimeError("token response is cacheable") + if not isinstance(payload, dict): + raise RuntimeError("token response is not an object") + expected = {"serverUrl", "participantToken", "roomName", "participantIdentity"} + if set(payload) != expected: + raise RuntimeError("token response exposes unapproved fields") + if payload["serverUrl"] != "ws://127.0.0.1:7880": + raise RuntimeError("token response points beyond local LiveKit") + + +def _assert_browser_bundle() -> None: + bundle = ROOT / "apps/web/dist" + if not bundle.is_dir(): + raise RuntimeError("production web bundle is missing; run `npm run build`") + for path in bundle.rglob("*"): + if not path.is_file(): + continue + text = path.read_text(encoding="utf-8", errors="ignore") + for label, pattern in _FORBIDDEN_BROWSER_PATTERNS.items(): + if pattern.search(text): + raise RuntimeError(f"browser bundle contains forbidden {label}: {path.name}") + + +def _assert_no_external_connections() -> None: + result = subprocess.run( + ["lsof", "-nP", "-iTCP", "-sTCP:ESTABLISHED"], capture_output=True, text=True + ) + managed_pgids: set[int] = set() + state_path = ROOT / ".local/run/stack.json" + if state_path.is_file(): + state = json.loads(state_path.read_text(encoding="utf-8")) + managed_pgids = { + record["pgid"] + for record in state.get("services", {}).values() + if isinstance(record, dict) and isinstance(record.get("pgid"), int) + } + for row in result.stdout.splitlines()[1:]: + columns = row.split() + if len(columns) < 9: + continue + try: + process_group = os.getpgid(int(columns[1])) + except (ProcessLookupError, ValueError): + continue + if process_group not in managed_pgids: + continue + endpoint = columns[-1] + remote = endpoint.rsplit("->", 1)[-1] + host = remote.rsplit(":", 1)[0].strip("[]") + try: + address = socket.gethostbyname(host) + except OSError: + raise RuntimeError( + f"managed process has an unresolved connection: {endpoint}" + ) from None + if not address.startswith("127."): + raise RuntimeError(f"managed process has a non-loopback connection: {endpoint}") + + +def main() -> int: + if sys.platform != "darwin": + raise RuntimeError("privacy audit currently supports macOS only") + _assert_credentials() + _assert_loopback_sockets() + _assert_token_boundary() + _assert_browser_bundle() + _assert_no_external_connections() + print("Local privacy audit passed.") + return 0 + + +if __name__ == "__main__": + try: + raise SystemExit(main()) + except (OSError, RuntimeError) as error: + print(f"privacy-audit: {error}", file=sys.stderr) + raise SystemExit(1) from error diff --git a/muse_glimmer/macos/scripts/publication_check.py b/muse_glimmer/macos/scripts/publication_check.py new file mode 100644 index 0000000000..a919d387ce --- /dev/null +++ b/muse_glimmer/macos/scripts/publication_check.py @@ -0,0 +1,156 @@ +from __future__ import annotations + +import argparse +import os +import re +import subprocess +import sys +from pathlib import Path + +from scripts.repository import ROOT + +_DENIED_SUFFIXES = { + ".a", + ".bin", + ".dylib", + ".gguf", + ".metallib", + ".onnx", + ".o", + ".pcm", + ".pem", + ".pt", + ".ptd", + ".pte", + ".safetensors", + ".so", + ".wav", +} +_DENIED_PARTS = { + ".dev-stack", + ".local", + ".venv", + "__pycache__", + "dist", + "node_modules", + "recordings", + "reports", + "museglimmer-reports", +} +_DENIED_NAMES = {".env", ".env.cloud.disabled", "livekit.keys"} +_DENIED_CONTENT = { + "absolute user path": re.compile(b"/" + b"Users" + b"/"), + "internal URL": re.compile((b"internal" + b"fb\\.com|fburl\\.com"), re.IGNORECASE), + "AGPL avatar package": re.compile((b"@bible-strong/" + b"avatar"), re.IGNORECASE), + "private key": re.compile(b"BEGIN (?:RSA |EC |OPENSSH )?PRIVATE KEY"), +} +_MAX_FILE_BYTES = 5 * 1024 * 1024 + + +def _git_files(*arguments: str) -> list[Path]: + result = subprocess.run( + ["git", "ls-files", *arguments, "-z", "--", "."], + cwd=ROOT, + check=True, + capture_output=True, + ) + root = ROOT.resolve() + files: list[Path] = [] + for value in result.stdout.split(b"\0"): + if not value: + continue + relative = Path(os.fsdecode(value)) + if relative.is_absolute() or ".." in relative.parts: + raise RuntimeError(f"Git candidate escapes application root: {relative}") + files.append(root / relative) + return files + + +def _candidate_files() -> list[Path]: + return _git_files("--cached", "--others", "--exclude-standard") + + +def _check_path(path: Path) -> list[str]: + relative = path.relative_to(ROOT) + errors: list[str] = [] + if path.is_symlink(): + errors.append(f"symlink is not allowed: {relative}") + return errors + if any(part in _DENIED_PARTS for part in relative.parts): + errors.append(f"generated/private path is not allowed: {relative}") + if path.name in _DENIED_NAMES or (path.name.startswith(".env") and path.name != ".env.example"): + errors.append(f"environment or credential file is not allowed: {relative}") + if path.suffix.lower() in _DENIED_SUFFIXES: + errors.append(f"binary/model artifact is not allowed: {relative}") + if not path.is_file(): + return errors + size = path.stat().st_size + if size > _MAX_FILE_BYTES: + errors.append(f"file exceeds {_MAX_FILE_BYTES} bytes: {relative}") + return errors + payload = path.read_bytes() + for label, pattern in _DENIED_CONTENT.items(): + if pattern.search(payload): + errors.append(f"{label} found in {relative}") + assignment = re.compile( + rb"^(?:export\s+)?(?:LIVEKIT_API_SECRET|OPENAI_API_KEY|AWS_SECRET_ACCESS_KEY)\s*=\s*(.+)$" + ) + for raw_line in payload.splitlines(): + match = assignment.fullmatch(raw_line.rstrip()) + if match is None: + continue + value = match.group(1).strip().strip(b"\"'") + if value and not value.startswith((b"test-", b"<", b"${")): + errors.append(f"credential assignment found in {relative}") + break + return errors + + +def _nested_repositories() -> list[Path]: + nested = [] + for current, directories, files in os.walk(ROOT): + path = Path(current) + if path == ROOT: + directories[:] = [name for name in directories if name not in {".git", ".local"}] + continue + if ".git" in directories: + nested.append(path / ".git") + directories.remove(".git") + if ".git" in files: + nested.append(path / ".git") + directories[:] = [name for name in directories if name != ".local"] + return nested + + +def main() -> int: + parser = argparse.ArgumentParser(description="Reject files unsafe for public source control") + parser.add_argument("--tracked-only", action="store_true") + args = parser.parse_args() + files = _candidate_files() + if args.tracked_only: + allowed = set(_git_files("--cached")) + if not allowed: + print( + "publication-check: application subtree contains no tracked files", file=sys.stderr + ) + return 1 + files = [path for path in files if path in allowed] + errors = [error for path in files for error in _check_path(path)] + errors.extend( + f"nested repository is not allowed: {path.relative_to(ROOT)}" + for path in _nested_repositories() + ) + if errors: + for error in errors: + print(f"publication-check: {error}", file=sys.stderr) + return 1 + print(f"Publication check passed for {len(files)} files.") + return 0 + + +if __name__ == "__main__": + try: + raise SystemExit(main()) + except (OSError, RuntimeError, subprocess.CalledProcessError) as error: + print(f"publication-check: {error}", file=sys.stderr) + raise SystemExit(1) from error diff --git a/muse_glimmer/macos/scripts/repository.py b/muse_glimmer/macos/scripts/repository.py new file mode 100644 index 0000000000..02ce2f1bf8 --- /dev/null +++ b/muse_glimmer/macos/scripts/repository.py @@ -0,0 +1,273 @@ +from __future__ import annotations + +import hashlib +import importlib.metadata +import json +import os +import platform +import secrets +import subprocess +from collections.abc import Iterable +from pathlib import Path +from typing import Any + +ROOT = Path(__file__).resolve().parents[1] +LOCAL = ROOT / ".local" +RUN_DIR = LOCAL / "run" +LOG_DIR = LOCAL / "logs" +STATE_DIR = LOCAL / "state" +ARTIFACT_DIR = LOCAL / "artifacts" +SOURCE_DIR = LOCAL / "src" +PREPARED_RECEIPT = STATE_DIR / "prepared.json" +BOOTSTRAP_RECEIPT = STATE_DIR / "bootstrap.json" +CREDENTIAL_FILE = RUN_DIR / "livekit.keys" +COMPATIBILITY_LOCK = ROOT / "config" / "dependencies" / "compatibility.lock.json" +ARTIFACT_LOCK = ROOT / "artifacts" / "macos-arm64.lock.json" +TOOLCHAIN_LOCK = ROOT / "config" / "dependencies" / "toolchain.lock.json" +BOOTSTRAP_INPUTS = ( + ROOT / "pyproject.toml", + ROOT / "uv.lock", + ROOT / "apps/token-service/pyproject.toml", + ROOT / "apps/worker/pyproject.toml", + ROOT / "packages/livekit-plugins-executorch/pyproject.toml", + ROOT / "apps/web/package.json", + ROOT / "apps/web/package-lock.json", + ROOT / "apps/web/index.html", + ROOT / "apps/web/server.mjs", + ROOT / "apps/web/src", + ROOT / "apps/web/tsconfig.app.json", + ROOT / "apps/web/tsconfig.json", + ROOT / "apps/web/tsconfig.node.json", + ROOT / "apps/web/vite.config.ts", +) +WEB_DIST = ROOT / "apps/web/dist" + + +def read_json(path: Path) -> dict[str, Any]: + with path.open(encoding="utf-8") as stream: + value = json.load(stream) + if not isinstance(value, dict): + raise ValueError(f"expected a JSON object: {path}") + return value + + +def sha256_file(path: Path) -> str: + digest = hashlib.sha256() + with path.open("rb") as stream: + for chunk in iter(lambda: stream.read(1024 * 1024), b""): + digest.update(chunk) + return digest.hexdigest() + + +def sha256_tree(path: Path) -> str: + digest = hashlib.sha256() + for item in sorted(candidate for candidate in path.rglob("*") if candidate.is_file()): + digest.update(item.relative_to(path).as_posix().encode()) + digest.update(b"\0") + digest.update(sha256_file(item).encode()) + digest.update(b"\0") + return digest.hexdigest() + + +def digest_json(path: Path) -> str: + return sha256_file(path) + + +def digest_paths(paths: tuple[Path, ...]) -> str: + digest = hashlib.sha256() + for path in paths: + if not path.exists(): + raise RuntimeError(f"bootstrap input is missing: {path.relative_to(ROOT)}") + digest.update(path.relative_to(ROOT).as_posix().encode()) + digest.update(b"\0") + digest.update((sha256_tree(path) if path.is_dir() else sha256_file(path)).encode()) + digest.update(b"\0") + return digest.hexdigest() + + +def installed_environment_fingerprint( + distributions: Iterable[Any] | None = None, +) -> str: + digest = hashlib.sha256() + installed = distributions if distributions is not None else importlib.metadata.distributions() + ordered = sorted( + installed, + key=lambda distribution: ( + str(distribution.metadata.get("Name", "")).lower(), + distribution.version, + ), + ) + for distribution in ordered: + name = str(distribution.metadata.get("Name", "")).lower() + digest.update(name.encode()) + digest.update(b"\0") + digest.update(distribution.version.encode()) + digest.update(b"\0") + for relative in sorted(distribution.files or (), key=str): + relative_text = str(relative) + if relative_text.endswith(".pyc") or "__pycache__" in Path(relative_text).parts: + continue + digest.update(relative_text.encode()) + digest.update(b"\0") + path = Path(distribution.locate_file(relative)) + if path.is_file(): + stat = path.stat() + digest.update(f"{stat.st_size}:{stat.st_mtime_ns}".encode()) + if path.name in {"RECORD", "direct_url.json"}: + digest.update(sha256_file(path).encode()) + else: + digest.update(b"missing") + digest.update(b"\0") + return digest.hexdigest() + + +def python_environment_fingerprint(python: Path) -> str: + script = ( + "from scripts.repository import installed_environment_fingerprint; " + "print(installed_environment_fingerprint())" + ) + return subprocess.run( + [str(python), "-c", script], + cwd=ROOT, + check=True, + capture_output=True, + text=True, + ).stdout.strip() + + +def landed_gate_commits(compatibility: dict[str, Any]) -> tuple[str, ...]: + executorch = compatibility.get("executorch") + if not isinstance(executorch, dict): + raise RuntimeError("compatibility lock has no ExecuTorch configuration") + gates = executorch.get("gates") + if not isinstance(gates, dict): + raise RuntimeError("compatibility lock has no upstream gates") + commits: list[str] = [] + for name, gate in gates.items(): + if not isinstance(gate, dict): + raise RuntimeError(f"compatibility gate must be an object: {name}") + if gate.get("status") != "landed": + continue + commit = gate.get("commit") + if not isinstance(commit, str) or len(commit) != 40: + raise RuntimeError(f"landed compatibility gate requires a commit: {name}") + commits.append(commit) + return tuple(sorted(commits)) + + +def validate_executorch_checkout( + checkout: Path, expected_commit: str, *, required_ancestors: Iterable[str] = () +) -> None: + if not checkout.is_dir() or not (checkout / ".git").exists(): + raise RuntimeError(f"ExecuTorch checkout is missing: {checkout}") + actual = subprocess.run( + ["git", "-C", str(checkout), "rev-parse", "HEAD"], + check=True, + capture_output=True, + text=True, + ).stdout.strip() + if actual != expected_commit: + raise RuntimeError(f"ExecuTorch checkout is {actual}; expected {expected_commit}") + dirty = subprocess.run( + ["git", "-C", str(checkout), "status", "--porcelain"], + check=True, + capture_output=True, + text=True, + ).stdout + if dirty: + raise RuntimeError("ExecuTorch checkout must remain clean") + for ancestor in required_ancestors: + result = subprocess.run( + ["git", "-C", str(checkout), "merge-base", "--is-ancestor", ancestor, actual], + check=False, + capture_output=True, + text=True, + ) + if result.returncode != 0: + raise RuntimeError( + f"pinned ExecuTorch commit does not contain landed capability {ancestor}" + ) + + +def require_supported_platform() -> None: + if platform.system() != "Darwin" or platform.machine() != "arm64": + raise RuntimeError("Muse Glimmer currently supports macOS on Apple silicon only") + + +def ensure_local_directories() -> None: + for path in (RUN_DIR, LOG_DIR, STATE_DIR, ARTIFACT_DIR, SOURCE_DIR): + path.mkdir(parents=True, exist_ok=True) + + +def relative_local_path(value: str) -> Path: + path = (ROOT / value).resolve() + try: + path.relative_to(LOCAL.resolve()) + except ValueError as error: + raise ValueError(f"artifact destination must stay under .local: {value}") from error + return path + + +def atomic_write_json(path: Path, value: object, *, mode: int = 0o600) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + temporary = path.with_name(f".{path.name}.{os.getpid()}.{secrets.token_hex(4)}.tmp") + descriptor = os.open(temporary, os.O_WRONLY | os.O_CREAT | os.O_EXCL, mode) + try: + with os.fdopen(descriptor, "w", encoding="utf-8") as stream: + json.dump(value, stream, indent=2, sort_keys=True) + stream.write("\n") + stream.flush() + os.fsync(stream.fileno()) + os.replace(temporary, path) + except BaseException: + temporary.unlink(missing_ok=True) + raise + + +def load_valid_receipt() -> dict[str, Any]: + if not PREPARED_RECEIPT.is_file(): + raise RuntimeError("artifacts are not prepared; run `make prepare-artifacts`") + receipt = read_json(PREPARED_RECEIPT) + expected_locks = { + "compatibility": digest_json(COMPATIBILITY_LOCK), + "artifacts": digest_json(ARTIFACT_LOCK), + "toolchain": digest_json(TOOLCHAIN_LOCK), + } + if receipt.get("locks") != expected_locks: + raise RuntimeError("artifact receipt is stale; run `make prepare-artifacts`") + compatibility = read_json(COMPATIBILITY_LOCK) + commit = compatibility.get("executorch", {}).get("commit") + if not commit or receipt.get("executorch_commit") != commit: + raise RuntimeError("artifact receipt does not match the pinned ExecuTorch commit") + checkout_value = receipt.get("executorch_checkout") + if not isinstance(checkout_value, str): + raise RuntimeError("artifact receipt has no ExecuTorch checkout") + validate_executorch_checkout( + Path(checkout_value).expanduser().resolve(), + commit, + required_ancestors=landed_gate_commits(compatibility), + ) + recorded = receipt.get("artifacts") + if not isinstance(recorded, dict): + raise RuntimeError("artifact receipt has no artifact inventory") + manifest = read_json(ARTIFACT_LOCK) + requirements = {item["role"]: item for item in manifest["artifacts"]} + if set(recorded) != set(requirements): + raise RuntimeError("artifact receipt roles do not match the manifest") + for role, item in recorded.items(): + if not isinstance(item, dict) or not isinstance(item.get("path"), str): + raise RuntimeError(f"invalid artifact receipt entry: {role}") + path = relative_local_path(item["path"]) + if not path.exists(): + raise RuntimeError(f"prepared artifact is missing: {role}") + requirement = requirements[role] + if requirement["kind"] == "file" and not path.is_file(): + raise RuntimeError(f"prepared artifact must remain a regular file: {role}") + if requirement["kind"] == "directory" and not path.is_dir(): + raise RuntimeError(f"prepared artifact must remain a directory: {role}") + if requirement["executable"] and not os.access(path, os.X_OK): + raise RuntimeError(f"prepared artifact must remain executable: {role}") + actual = sha256_tree(path) if path.is_dir() else sha256_file(path) + if actual != item.get("sha256"): + raise RuntimeError(f"prepared artifact checksum changed: {role}") + return receipt diff --git a/muse_glimmer/macos/scripts/validate_manifests.py b/muse_glimmer/macos/scripts/validate_manifests.py new file mode 100644 index 0000000000..8a5fb3b4d6 --- /dev/null +++ b/muse_glimmer/macos/scripts/validate_manifests.py @@ -0,0 +1,215 @@ +from __future__ import annotations + +import json +import os +import re +import sys +from pathlib import Path + +try: + import jsonschema +except ModuleNotFoundError: # Core validation must also work before bootstrap. + jsonschema = None # type: ignore[assignment] + +from scripts.repository import ( + ARTIFACT_LOCK, + COMPATIBILITY_LOCK, + ROOT, + TOOLCHAIN_LOCK, + landed_gate_commits, + validate_executorch_checkout, +) + +_SHA256 = re.compile(r"^[0-9a-f]{64}$") +_GIT_COMMIT = re.compile(r"^[0-9a-f]{40}$") +_REQUIRED_CAPABILITIES = {"supports_cancel", "supertonic_server_jsonl"} +_REQUIRED_GATES = {"supertonic_runtime", "supports_cancel", "supertonic_server_jsonl"} +_GATE_STATUSES = {"landed", "pending", "unsubmitted"} +_EXECUTORCH_REPOSITORY = "https://github.com/pytorch/executorch.git" +_EXECUTORCH_ARTIFACT_ROLES = { + "parakeet_helper", + "muse_glimmer_worker", + "supertonic_runner", +} + + +def _load(path: Path) -> dict[str, object]: + with path.open(encoding="utf-8") as stream: + value = json.load(stream) + if not isinstance(value, dict): + raise RuntimeError(f"manifest must be a JSON object: {path}") + return value + + +def _validate_artifacts() -> None: + manifest = _load(ARTIFACT_LOCK) + if manifest.get("schema_version") != 1 or manifest.get("platform") != "macos-arm64": + raise RuntimeError("artifact manifest has an unsupported schema or platform") + artifacts = manifest.get("artifacts") + if not isinstance(artifacts, list) or not artifacts: + raise RuntimeError("artifact manifest must contain artifacts") + roles: set[str] = set() + required_keys = { + "role", + "kind", + "executable", + "distribution", + "license", + "destination", + "sensitive", + "sha256", + } + for item in artifacts: + if not isinstance(item, dict) or not required_keys <= set(item): + raise RuntimeError("artifact entries must contain all required keys") + role = item["role"] + if not isinstance(role, str) or not role or role in roles: + raise RuntimeError(f"artifact role is empty or duplicated: {role!r}") + roles.add(role) + if item["kind"] not in {"file", "directory"} or not isinstance(item["executable"], bool): + raise RuntimeError(f"invalid artifact shape for {role}") + if item["kind"] == "directory" and item["executable"]: + raise RuntimeError(f"artifact directories cannot be executable: {role}") + if item["distribution"] not in {"build", "download", "user-provided"}: + raise RuntimeError(f"invalid distribution for {role}") + destination = item["destination"] + if not isinstance(destination, str) or not destination.startswith(".local/artifacts/"): + raise RuntimeError(f"artifact destination escapes .local/artifacts: {role}") + checksum = item["sha256"] + if checksum is not None and ( + not isinstance(checksum, str) or not _SHA256.fullmatch(checksum) + ): + raise RuntimeError(f"invalid artifact checksum: {role}") + required_roles = { + "parakeet_helper", + "parakeet_model", + "parakeet_tokenizer", + "muse_glimmer_worker", + "muse_glimmer_model", + "muse_glimmer_tokenizer", + "supertonic_runner", + "supertonic_model", + "supertonic_assets", + "supertonic_voice_style", + } + if roles != required_roles: + raise RuntimeError(f"artifact roles differ from the runtime contract: {sorted(roles)}") + + +def _validate_compatibility( + compatibility: dict[str, object], *, release_checkout: Path | None = None +) -> None: + if compatibility.get("schema_version") != 1 or compatibility.get("platform") != "macos-arm64": + raise RuntimeError("compatibility lock has an unsupported schema or platform") + executorch = compatibility.get("executorch") + if not isinstance(executorch, dict): + raise RuntimeError("compatibility lock has no ExecuTorch configuration") + if executorch.get("repository") != _EXECUTORCH_REPOSITORY: + raise RuntimeError("compatibility lock must use the official ExecuTorch repository") + capabilities = executorch.get("required_capabilities") + if not isinstance(capabilities, list) or not set(capabilities) >= _REQUIRED_CAPABILITIES: + raise RuntimeError("compatibility lock omits required runtime capabilities") + gates = executorch.get("gates") + if not isinstance(gates, dict) or not set(gates) >= _REQUIRED_GATES: + raise RuntimeError("compatibility lock omits required upstream gates") + for name in _REQUIRED_GATES: + gate = gates[name] + if not isinstance(gate, dict): + raise RuntimeError(f"compatibility gate must be an object: {name}") + status = gate.get("status") + commit = gate.get("commit") + pull_request = gate.get("pull_request") + if status not in _GATE_STATUSES: + raise RuntimeError(f"compatibility gate has invalid status: {name}") + if pull_request is not None and ( + not isinstance(pull_request, str) + or not pull_request.startswith("https://github.com/pytorch/executorch/pull/") + ): + raise RuntimeError(f"compatibility gate has invalid pull request: {name}") + if status == "landed": + if not isinstance(commit, str) or not _GIT_COMMIT.fullmatch(commit): + raise RuntimeError(f"landed compatibility gate requires a commit: {name}") + elif commit is not None: + raise RuntimeError(f"unlanded compatibility gate cannot have a commit: {name}") + + final_commit = executorch.get("commit") + if final_commit is not None and ( + not isinstance(final_commit, str) or not _GIT_COMMIT.fullmatch(final_commit) + ): + raise RuntimeError("compatibility lock has an invalid final ExecuTorch commit") + if compatibility.get("ready_for_release"): + if final_commit is None: + raise RuntimeError("release-ready compatibility requires an immutable commit") + unlanded = sorted(name for name in _REQUIRED_GATES if gates[name]["status"] != "landed") + if unlanded: + raise RuntimeError(f"release-ready compatibility has unlanded gates: {unlanded}") + if release_checkout is None: + raise RuntimeError( + "release-ready compatibility requires checkout ancestry verification" + ) + validate_executorch_checkout( + release_checkout, + final_commit, + required_ancestors=landed_gate_commits(compatibility), + ) + + +def _validate_release_artifacts(artifacts: object, final_executorch_commit: object) -> None: + if not isinstance(artifacts, list) or not isinstance(final_executorch_commit, str): + raise RuntimeError("release-ready artifact validation requires an ExecuTorch commit") + for artifact in artifacts: + if not isinstance(artifact, dict): + raise RuntimeError("release-ready artifact entries must be objects") + role = artifact.get("role") + if not all(artifact.get(field) for field in ("source", "revision", "sha256")): + raise RuntimeError(f"release-ready artifact provenance is incomplete: {role}") + if artifact.get("size_bytes") is None or str(artifact.get("license", "")).startswith( + "See " + ): + raise RuntimeError(f"release-ready artifact metadata is incomplete: {role}") + source = artifact.get("source") + if role in _EXECUTORCH_ARTIFACT_ROLES and source != "executorch": + raise RuntimeError(f"ExecuTorch runtime artifact has invalid source: {role}") + if source == "executorch" and artifact.get("revision") != final_executorch_commit: + raise RuntimeError( + f"ExecuTorch-built artifact must match the final compatibility commit: {role}" + ) + + +def main() -> int: + _validate_artifacts() + schema = _load(ROOT / "artifacts/manifest.schema.json") + if schema.get("$schema") != "https://json-schema.org/draft/2020-12/schema": + raise RuntimeError("artifact schema must use JSON Schema 2020-12") + if jsonschema is not None: + jsonschema.Draft202012Validator.check_schema(schema) + jsonschema.Draft202012Validator(schema).validate(_load(ARTIFACT_LOCK)) + + compatibility = _load(COMPATIBILITY_LOCK) + release_checkout = None + if compatibility.get("ready_for_release"): + release_checkout = ( + Path(os.environ.get("GLIMMER_EXECUTORCH_ROOT", ROOT / ".local/src/executorch")) + .expanduser() + .resolve() + ) + _validate_compatibility(compatibility, release_checkout=release_checkout) + if compatibility.get("ready_for_release"): + _validate_release_artifacts( + _load(ARTIFACT_LOCK)["artifacts"], + compatibility["executorch"].get("commit"), + ) + + toolchain = _load(TOOLCHAIN_LOCK) + if toolchain.get("platform") != {"system": "Darwin", "machine": "arm64"}: + raise RuntimeError("toolchain platform must be macOS arm64") + print("Manifest validation passed.") + return 0 + + +if __name__ == "__main__": + try: + raise SystemExit(main()) + except (KeyError, RuntimeError, TypeError, ValueError) as error: + print(f"validate-manifests: {error}", file=sys.stderr) + raise SystemExit(1) from error diff --git a/muse_glimmer/macos/tests/conftest.py b/muse_glimmer/macos/tests/conftest.py new file mode 100644 index 0000000000..f96b4d8630 --- /dev/null +++ b/muse_glimmer/macos/tests/conftest.py @@ -0,0 +1,8 @@ +from __future__ import annotations + +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) diff --git a/muse_glimmer/macos/tests/test_bootstrap.py b/muse_glimmer/macos/tests/test_bootstrap.py new file mode 100644 index 0000000000..1118f3403b --- /dev/null +++ b/muse_glimmer/macos/tests/test_bootstrap.py @@ -0,0 +1,17 @@ +from __future__ import annotations + +import pytest + +from scripts.bootstrap import _require_version, _version_tuple + + +def test_version_parser_accepts_common_tool_output() -> None: + assert _version_tuple("Python 3.13.2") == (3, 13, 2) + assert _version_tuple("v22.12.0") == (22, 12, 0) + assert _version_tuple("livekit-server version 1.13.5") == (1, 13, 5) + + +def test_version_range_is_enforced() -> None: + _require_version("node", "v22.12.0", ">=22.12.0,<23") + with pytest.raises(RuntimeError, match="does not satisfy"): + _require_version("node", "v24.0.0", ">=22.12.0,<23") diff --git a/muse_glimmer/macos/tests/test_dev_stack.py b/muse_glimmer/macos/tests/test_dev_stack.py new file mode 100644 index 0000000000..3428ee10e5 --- /dev/null +++ b/muse_glimmer/macos/tests/test_dev_stack.py @@ -0,0 +1,327 @@ +from __future__ import annotations + +import json +import os +from pathlib import Path + +import pytest + +from scripts import dev_stack + + +@pytest.fixture +def receipt() -> dict[str, object]: + roles = { + "parakeet_helper": ".local/artifacts/bin/parakeet_helper", + "parakeet_model": ".local/artifacts/parakeet/model.pte", + "parakeet_tokenizer": ".local/artifacts/parakeet/tokenizer.model", + "supertonic_runner": ".local/artifacts/bin/supertonic_runner", + "supertonic_model": ".local/artifacts/supertonic/model.pte", + "supertonic_assets": ".local/artifacts/supertonic/assets", + "supertonic_voice_style": ".local/artifacts/supertonic/voice-style.json", + } + return {"artifacts": {role: {"path": path} for role, path in roles.items()}} + + +def test_validated_tools_rejects_missing_bootstrap_receipt( + tmp_path, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setattr(dev_stack, "BOOTSTRAP_RECEIPT", tmp_path / "missing.json") + with pytest.raises(RuntimeError, match="not bootstrapped"): + dev_stack._validated_tools() + + +def test_validated_tools_rejects_stale_bootstrap_inputs( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + receipt = tmp_path / "bootstrap.json" + receipt.write_text( + json.dumps( + { + "toolchain_lock": "lock", + "bootstrap_inputs": "stale", + "web_dist": "web", + "tools": {}, + } + ) + ) + web_dist = tmp_path / "dist" + web_dist.mkdir() + monkeypatch.setattr(dev_stack, "BOOTSTRAP_RECEIPT", receipt) + monkeypatch.setattr(dev_stack, "WEB_DIST", web_dist) + monkeypatch.setattr(dev_stack, "digest_json", lambda _path: "lock") + monkeypatch.setattr(dev_stack, "digest_paths", lambda _paths: "current") + monkeypatch.setattr(dev_stack, "sha256_tree", lambda _path: "web") + + with pytest.raises(RuntimeError, match="stale"): + dev_stack._validated_tools() + + +def test_validated_tools_rejects_changed_python_environment( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + python = tmp_path / "python" + node = tmp_path / "node" + livekit = tmp_path / "livekit-server" + for tool in (python, node, livekit): + tool.write_text("tool") + tool.chmod(0o755) + receipt = tmp_path / "bootstrap.json" + receipt.write_text( + json.dumps( + { + "toolchain_lock": "lock", + "bootstrap_inputs": "inputs", + "web_dist": "web", + "python_environment": "old", + "tools": { + "python": {"path": str(python)}, + "node": {"path": str(node)}, + "livekit-server": {"path": str(livekit)}, + }, + } + ) + ) + web_dist = tmp_path / "dist" + web_dist.mkdir() + monkeypatch.setattr(dev_stack, "BOOTSTRAP_RECEIPT", receipt) + monkeypatch.setattr(dev_stack, "WEB_DIST", web_dist) + monkeypatch.setattr(dev_stack, "digest_json", lambda _path: "lock") + monkeypatch.setattr(dev_stack, "digest_paths", lambda _paths: "inputs") + monkeypatch.setattr(dev_stack, "sha256_tree", lambda _path: "web") + monkeypatch.setattr(dev_stack, "python_environment_fingerprint", lambda _path: "new") + + with pytest.raises(RuntimeError, match="Python environment changed"): + dev_stack._validated_tools() + + +def test_credentials_are_private_before_secret_is_written( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + credential_file = tmp_path / "livekit.keys" + observed_modes: list[int] = [] + real_fdopen = os.fdopen + + def checked_fdopen(descriptor: int, *args, **kwargs): + observed_modes.append(os.fstat(descriptor).st_mode & 0o777) + return real_fdopen(descriptor, *args, **kwargs) + + monkeypatch.setattr(dev_stack, "CREDENTIAL_FILE", credential_file) + monkeypatch.setattr(dev_stack.os, "fdopen", checked_fdopen) + + api_key, api_secret = dev_stack._new_credentials() + + assert credential_file.read_text() == f"{api_key}: {api_secret}\n" + assert observed_modes == [0o600] + assert credential_file.stat().st_mode & 0o777 == 0o600 + + +def test_service_order_and_local_environment( + receipt: dict[str, object], monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setattr( + dev_stack, + "_validated_tools", + lambda: { + "python": "/test/.venv/bin/python", + "node": "/test/bin/node", + "livekit-server": "/test/bin/livekit-server", + }, + ) + monkeypatch.setattr(dev_stack.os, "access", lambda _path, _mode: True) + services = dev_stack._services(receipt, "test-key", "test-secret") + + assert [service.name for service in services] == list(dev_stack.SERVICE_ORDER) + worker = services[-1] + assert worker.environment["LIVEKIT_URL"] == "ws://127.0.0.1:7880" + assert worker.environment["MUSE_GLIMMER_BASE_URL"] == "http://127.0.0.1:8000/v1" + assert worker.environment["MUSE_GLIMMER_REASONING_STRENGTH"] == "low" + assert "OPENAI_API_KEY" not in worker.environment + + +def test_stop_records_uses_reverse_order_and_escalates(monkeypatch: pytest.MonkeyPatch) -> None: + signalled: list[tuple[str, int]] = [] + records = {name: {"name": name} for name in dev_stack.SERVICE_ORDER} + active = {name for name in dev_stack.SERVICE_ORDER} + + def matches(record: dict[str, object]) -> bool: + return str(record["name"]) in active + + def signal_record(record: dict[str, object], signum: int) -> None: + name = str(record["name"]) + signalled.append((name, signum)) + if signum == dev_stack.signal.SIGKILL: + active.discard(name) + + now = 0.0 + + def monotonic() -> float: + nonlocal now + now += 20.0 + return now + + monkeypatch.setattr(dev_stack, "_record_group_owned", matches) + monkeypatch.setattr(dev_stack, "_signal_service", signal_record) + monkeypatch.setattr(dev_stack.time, "monotonic", monotonic) + monkeypatch.setattr(dev_stack.time, "sleep", lambda _seconds: None) + + dev_stack._stop_records(records) + + expected_reverse = list(reversed(dev_stack.SERVICE_ORDER)) + assert [ + name for name, signum in signalled if signum == dev_stack.signal.SIGTERM + ] == expected_reverse + assert [ + name for name, signum in signalled if signum == dev_stack.signal.SIGKILL + ] == expected_reverse + + +def test_up_rejects_dead_leader_with_live_child_group( + monkeypatch: pytest.MonkeyPatch, +) -> None: + record = {"pid": 42, "pgid": 42} + monkeypatch.setattr( + dev_stack, + "_read_state", + lambda: {"schema_version": 1, "services": {"agent": record}}, + ) + monkeypatch.setattr(dev_stack, "_record_group_owned", lambda _record: True) + + with pytest.raises(RuntimeError, match="orphaned process group"): + dev_stack._up_locked() + + +def test_dead_leader_with_live_child_group_is_still_owned( + monkeypatch: pytest.MonkeyPatch, +) -> None: + record = {"pid": 42, "pgid": 42} + alive = True + signals: list[int] = [] + + monkeypatch.setattr(dev_stack, "_service_matches", lambda _record: False) + monkeypatch.setattr(dev_stack, "_pid_alive", lambda _pid: False) + monkeypatch.setattr(dev_stack, "_process_group_alive", lambda _pgid: alive) + + def killpg(_pgid: int, signum: int) -> None: + nonlocal alive + signals.append(signum) + alive = False + + monkeypatch.setattr(dev_stack.os, "killpg", killpg) + + assert dev_stack._record_group_owned(record) + dev_stack._stop_records({"agent": record}) + assert signals == [dev_stack.signal.SIGTERM] + + +def test_stop_waits_for_delayed_exit_after_sigkill( + monkeypatch: pytest.MonkeyPatch, +) -> None: + record = {"name": "agent"} + owned = iter((True, False)) + signals: list[int] = [] + times = iter((0.0, 11.0, 20.0, 20.0)) + + def monotonic() -> float: + return next(times) + + monkeypatch.setattr(dev_stack, "_record_group_owned", lambda _record: next(owned)) + monkeypatch.setattr( + dev_stack, + "_signal_service", + lambda _record, signum: signals.append(signum), + ) + monkeypatch.setattr(dev_stack.time, "monotonic", monotonic) + monkeypatch.setattr(dev_stack.time, "sleep", lambda _seconds: None) + + dev_stack._stop_records({"agent": record}) + + assert signals == [dev_stack.signal.SIGTERM, dev_stack.signal.SIGKILL] + + +def test_agent_readiness_returns_health_endpoint( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + log = tmp_path / "agent.log" + log.write_text( + 'HTTP server listening on 127.0.0.1:54321\nregistered worker {"agent_name": "assistant"}\n' + ) + monkeypatch.setattr(dev_stack, "ROOT", tmp_path) + monkeypatch.setattr(dev_stack, "_http_ready", lambda url: url.endswith(":54321/")) + monkeypatch.setattr(dev_stack, "_service_matches", lambda _record: True) + + assert dev_stack._wait_for_agent({"log": "agent.log"}) == "http://127.0.0.1:54321/" + + +def test_process_identity_requires_start_time_and_command(monkeypatch: pytest.MonkeyPatch) -> None: + command = ".venv/bin/muse-glimmer-worker dev" + record = { + "pid": 42, + "start_time": "Mon Aug 24 00:00:00 2026", + "command_marker": "muse-glimmer-worker", + "observed_command_digest": dev_stack._observed_command_digest(command), + } + monkeypatch.setattr(dev_stack, "_pid_alive", lambda _pid: True) + monkeypatch.setattr(dev_stack, "_process_start", lambda _pid: record["start_time"]) + monkeypatch.setattr(dev_stack, "_process_command", lambda _pid: command) + assert dev_stack._service_matches(record) + + monkeypatch.setattr(dev_stack, "_process_start", lambda _pid: "different process start") + assert not dev_stack._service_matches(record) + + +def test_new_process_group_termination_escalates(monkeypatch: pytest.MonkeyPatch) -> None: + signals: list[tuple[int, int]] = [] + + class Process: + pid = 42 + + def wait(self, timeout: float) -> int: + return 0 + + waits = iter((False, True)) + monkeypatch.setattr(dev_stack, "_wait_for_process_group", lambda _pgid, _timeout: next(waits)) + monkeypatch.setattr(dev_stack.os, "killpg", lambda pgid, signum: signals.append((pgid, signum))) + + dev_stack._terminate_new_process_group(Process()) # type: ignore[arg-type] + + assert signals == [ + (42, dev_stack.signal.SIGTERM), + (42, dev_stack.signal.SIGKILL), + ] + + +def test_restart_holds_one_lifecycle_lock(monkeypatch: pytest.MonkeyPatch) -> None: + events: list[str] = [] + + class Lock: + def __enter__(self): + events.append("locked") + + def __exit__(self, *_args): + events.append("unlocked") + + monkeypatch.setattr(dev_stack, "_lifecycle_lock", Lock) + monkeypatch.setattr(dev_stack, "_down_locked", lambda: events.append("down")) + monkeypatch.setattr(dev_stack, "_up_locked", lambda: events.append("up")) + + assert dev_stack.restart() == 0 + assert events == ["locked", "down", "up", "unlocked"] + + +def test_missing_artifact_role_fails( + receipt: dict[str, object], monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setattr( + dev_stack, + "_validated_tools", + lambda: { + "python": "/test/.venv/bin/python", + "node": "/test/bin/node", + "livekit-server": "/test/bin/livekit-server", + }, + ) + monkeypatch.setattr(dev_stack.os, "access", lambda _path, _mode: True) + del receipt["artifacts"]["supertonic_voice_style"] # type: ignore[index] + with pytest.raises(RuntimeError, match="supertonic_voice_style"): + dev_stack._services(receipt, "test-key", "test-secret") diff --git a/muse_glimmer/macos/tests/test_e2e_stack.py b/muse_glimmer/macos/tests/test_e2e_stack.py new file mode 100644 index 0000000000..bdd395953b --- /dev/null +++ b/muse_glimmer/macos/tests/test_e2e_stack.py @@ -0,0 +1,41 @@ +from __future__ import annotations + +import subprocess +import sys + +import pytest + +from scripts import dev_stack +from scripts.repository import ( + CREDENTIAL_FILE, + PREPARED_RECEIPT, + ROOT, + load_valid_receipt, +) + +pytestmark = pytest.mark.e2e + + +def test_running_prepared_stack_generation_cancellation_and_privacy() -> None: + if not PREPARED_RECEIPT.is_file(): + pytest.skip("local artifacts are not prepared") + if dev_stack.status() != 0: + pytest.skip("prepared Muse Glimmer stack is not running; run `make dev` first") + + api_key, api_secret = CREDENTIAL_FILE.read_text(encoding="utf-8").strip().split(": ", 1) + worker_environment = dev_stack._services(load_valid_receipt(), api_key, api_secret)[ + -1 + ].environment + subprocess.run( + [sys.executable, "-m", "scripts.llm_readiness"], cwd=ROOT, check=True, timeout=240 + ) + subprocess.run( + [str(ROOT / ".venv/bin/muse-glimmer-diagnostics"), "doctor"], + cwd=ROOT, + env=worker_environment, + check=True, + timeout=360, + ) + subprocess.run( + [sys.executable, "-m", "scripts.privacy_audit"], cwd=ROOT, check=True, timeout=60 + ) diff --git a/muse_glimmer/macos/tests/test_makefile.py b/muse_glimmer/macos/tests/test_makefile.py new file mode 100644 index 0000000000..f618b97101 --- /dev/null +++ b/muse_glimmer/macos/tests/test_makefile.py @@ -0,0 +1,18 @@ +from __future__ import annotations + +import subprocess + +from scripts.repository import ROOT + + +def test_dev_and_dev_up_execute_up_once() -> None: + dev = subprocess.run( + ["make", "-n", "dev"], cwd=ROOT, check=True, capture_output=True, text=True + ) + dev_up = subprocess.run( + ["make", "-n", "dev", "up"], cwd=ROOT, check=True, capture_output=True, text=True + ) + + command = ".venv/bin/python -m scripts.dev_stack up" + assert dev.stdout.count(command) == 1 + assert dev_up.stdout.count(command) == 1 diff --git a/muse_glimmer/macos/tests/test_privacy_audit.py b/muse_glimmer/macos/tests/test_privacy_audit.py new file mode 100644 index 0000000000..2a5ff04983 --- /dev/null +++ b/muse_glimmer/macos/tests/test_privacy_audit.py @@ -0,0 +1,32 @@ +from __future__ import annotations + +import json +from types import SimpleNamespace + +import pytest + +from scripts import privacy_audit + + +def test_external_connection_audit_includes_managed_process_group( + tmp_path, monkeypatch: pytest.MonkeyPatch +) -> None: + run_dir = tmp_path / ".local/run" + run_dir.mkdir(parents=True) + (run_dir / "stack.json").write_text( + json.dumps({"services": {"web": {"pid": 100, "pgid": 100}}}) + ) + output = ( + "COMMAND PID USER FD TYPE DEVICE SIZE/OFF NODE NAME\n" + "node 321 user 10u IPv4 1 0t0 TCP 127.0.0.1:5000->203.0.113.10:443\n" + ) + monkeypatch.setattr(privacy_audit, "ROOT", tmp_path) + monkeypatch.setattr( + privacy_audit.subprocess, + "run", + lambda *args, **kwargs: SimpleNamespace(stdout=output), + ) + monkeypatch.setattr(privacy_audit.os, "getpgid", lambda pid: 100 if pid == 321 else pid) + + with pytest.raises(RuntimeError, match="non-loopback connection"): + privacy_audit._assert_no_external_connections() diff --git a/muse_glimmer/macos/tests/test_publication_check.py b/muse_glimmer/macos/tests/test_publication_check.py new file mode 100644 index 0000000000..beb3ddca5b --- /dev/null +++ b/muse_glimmer/macos/tests/test_publication_check.py @@ -0,0 +1,91 @@ +from __future__ import annotations + +import subprocess +import sys +from pathlib import Path +from types import SimpleNamespace + +from scripts import publication_check + + +def test_rejects_model_and_environment_files(tmp_path: Path, monkeypatch) -> None: + monkeypatch.setattr(publication_check, "ROOT", tmp_path) + model = tmp_path / "model.pte" + model.write_bytes(b"artifact") + environment = tmp_path / ".env" + environment.write_text("LIVEKIT_API_SECRET=real-secret\n") + + assert publication_check._check_path(model) + assert publication_check._check_path(environment) + + +def test_allows_explicit_test_fixture(tmp_path: Path, monkeypatch) -> None: + monkeypatch.setattr(publication_check, "ROOT", tmp_path) + source = tmp_path / "test_config.py" + source.write_text('value = "LIVEKIT_API_SECRET=test-secret"\n') + + assert publication_check._check_path(source) == [] + + +def test_detects_nested_git_metadata_file(tmp_path: Path, monkeypatch) -> None: + monkeypatch.setattr(publication_check, "ROOT", tmp_path) + nested = tmp_path / "dependency" + nested.mkdir() + (nested / ".git").write_text("gitdir: ../.git/modules/dependency\n") + + assert publication_check._nested_repositories() == [nested / ".git"] + + +def test_tracked_only_rejects_repository_without_tracked_files(tmp_path: Path, monkeypatch) -> None: + monkeypatch.setattr(publication_check, "ROOT", tmp_path) + monkeypatch.setattr(sys, "argv", ["publication-check", "--tracked-only"]) + monkeypatch.setattr( + publication_check.subprocess, + "run", + lambda *args, **kwargs: SimpleNamespace(stdout=b""), + ) + + assert publication_check.main() == 1 + + +def test_rejects_literal_private_path(tmp_path: Path, monkeypatch) -> None: + monkeypatch.setattr(publication_check, "ROOT", tmp_path) + source = tmp_path / "config.py" + source.write_bytes(b"root = " + b'"/' + b"Users/example/models" + b'"') + + assert any("absolute user path" in error for error in publication_check._check_path(source)) + + +def test_candidate_files_are_scoped_to_nested_application(tmp_path: Path, monkeypatch) -> None: + application = tmp_path / "muse_glimmer" / "macos" + application.mkdir(parents=True) + subprocess.run(["git", "init", "-q"], cwd=tmp_path, check=True) + inside = application / "inside.py" + inside.write_text("safe = True\n") + outside = tmp_path / "sibling.env" + outside.write_text("LIVEKIT_API_SECRET=real-secret\n") + subprocess.run( + ["git", "add", "muse_glimmer/macos/inside.py", "sibling.env"], cwd=tmp_path, check=True + ) + untracked = application / "untracked.py" + untracked.write_text("safe = True\n") + monkeypatch.setattr(publication_check, "ROOT", application) + + assert set(publication_check._candidate_files()) == {inside.resolve(), untracked.resolve()} + assert publication_check._git_files("--cached") == [inside.resolve()] + + +def test_git_candidate_cannot_escape_application_root(tmp_path: Path, monkeypatch) -> None: + monkeypatch.setattr(publication_check, "ROOT", tmp_path) + monkeypatch.setattr( + publication_check.subprocess, + "run", + lambda *args, **kwargs: SimpleNamespace(stdout=b"../secret\0"), + ) + + try: + publication_check._candidate_files() + except RuntimeError as error: + assert "escapes application root" in str(error) + else: + raise AssertionError("escaping Git candidate was accepted") diff --git a/muse_glimmer/macos/tests/test_repository.py b/muse_glimmer/macos/tests/test_repository.py new file mode 100644 index 0000000000..0d7b6e9b5d --- /dev/null +++ b/muse_glimmer/macos/tests/test_repository.py @@ -0,0 +1,116 @@ +from __future__ import annotations + +import json +from pathlib import Path +from types import SimpleNamespace + +import pytest + +from scripts import repository + + +def test_relative_local_path_rejects_escape( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + local = tmp_path / ".local" + local.mkdir() + monkeypatch.setattr(repository, "ROOT", tmp_path) + monkeypatch.setattr(repository, "LOCAL", local) + + assert repository.relative_local_path(".local/artifacts/model") == local / "artifacts/model" + with pytest.raises(ValueError, match="must stay under .local"): + repository.relative_local_path("outside") + + +def test_atomic_write_json_uses_private_permissions(tmp_path: Path) -> None: + destination = tmp_path / "state.json" + repository.atomic_write_json(destination, {"status": "ok"}) + + assert json.loads(destination.read_text()) == {"status": "ok"} + assert destination.stat().st_mode & 0o777 == 0o600 + + +def test_installed_environment_fingerprint_detects_same_version_content_change( + tmp_path: Path, +) -> None: + package = tmp_path / "package.py" + package.write_text("value = 1\n") + + class Distribution: + metadata = {"Name": "example-package"} + version = "1.0.0" + files = (Path("package.py"),) + + def locate_file(self, relative: Path) -> Path: + return tmp_path / relative + + first = repository.installed_environment_fingerprint([Distribution()]) + package.write_text("value = 2\n") + second = repository.installed_environment_fingerprint([Distribution()]) + + assert first != second + + +def test_executorch_checkout_rejects_drift(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + checkout = tmp_path / "executorch" + (checkout / ".git").mkdir(parents=True) + responses = iter( + ( + SimpleNamespace(stdout="expected\n"), + SimpleNamespace(stdout="modified.py\n"), + ) + ) + monkeypatch.setattr(repository.subprocess, "run", lambda *args, **kwargs: next(responses)) + + with pytest.raises(RuntimeError, match="must remain clean"): + repository.validate_executorch_checkout(checkout, "expected") + + +def test_executorch_checkout_rejects_changed_commit( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + checkout = tmp_path / "executorch" + (checkout / ".git").mkdir(parents=True) + monkeypatch.setattr( + repository.subprocess, + "run", + lambda *args, **kwargs: SimpleNamespace(stdout="different\n"), + ) + + with pytest.raises(RuntimeError, match="expected expected"): + repository.validate_executorch_checkout(checkout, "expected") + + +def test_landed_gate_commits_returns_only_landed_capabilities() -> None: + compatibility = { + "executorch": { + "gates": { + "runtime": {"status": "landed", "commit": "a" * 40}, + "cancellation": {"status": "pending", "commit": None}, + } + } + } + + assert repository.landed_gate_commits(compatibility) == ("a" * 40,) + + +def test_executorch_checkout_rejects_missing_landed_capability( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + checkout = tmp_path / "executorch" + (checkout / ".git").mkdir(parents=True) + responses = iter( + ( + SimpleNamespace(stdout="expected\n"), + SimpleNamespace(stdout=""), + SimpleNamespace(returncode=1), + ) + ) + monkeypatch.setattr(repository.subprocess, "run", lambda *args, **kwargs: next(responses)) + + with pytest.raises(RuntimeError, match="does not contain landed capability"): + repository.validate_executorch_checkout( + checkout, + "expected", + required_ancestors=("a" * 40,), + ) diff --git a/muse_glimmer/macos/tests/test_validate_manifests.py b/muse_glimmer/macos/tests/test_validate_manifests.py new file mode 100644 index 0000000000..c257b63843 --- /dev/null +++ b/muse_glimmer/macos/tests/test_validate_manifests.py @@ -0,0 +1,150 @@ +from __future__ import annotations + +from copy import deepcopy + +import pytest + +from scripts import validate_manifests +from scripts.repository import landed_gate_commits + + +@pytest.fixture +def compatibility() -> dict[str, object]: + return { + "schema_version": 1, + "status": "development-gated", + "platform": "macos-arm64", + "executorch": { + "repository": "https://github.com/pytorch/executorch.git", + "commit": None, + "required_capabilities": [ + "parakeet_persistent_helper", + "muse_glimmer_dflash_mlx", + "supports_cancel", + "supertonic_server_jsonl", + ], + "gates": { + "supertonic_runtime": { + "status": "landed", + "pull_request": "https://github.com/pytorch/executorch/pull/22063", + "commit": "81969a92dd2e5515fa23ccdf9d87346cf3ba2ba2", + }, + "supports_cancel": { + "status": "landed", + "pull_request": "https://github.com/pytorch/executorch/pull/22070", + "commit": "5bd86e50fcd986999e4c09b82de040a3ba224466", + }, + "supertonic_server_jsonl": { + "status": "unsubmitted", + "pull_request": None, + "commit": None, + }, + }, + }, + "ready_for_release": False, + } + + +def _gates(compatibility: dict[str, object]) -> dict[str, dict[str, object]]: + executorch = compatibility["executorch"] + assert isinstance(executorch, dict) + gates = executorch["gates"] + assert isinstance(gates, dict) + return gates # type: ignore[return-value] + + +def test_accepts_development_gated_capabilities(compatibility: dict[str, object]) -> None: + validate_manifests._validate_compatibility(compatibility) + assert landed_gate_commits(compatibility) == ( + "5bd86e50fcd986999e4c09b82de040a3ba224466", + "81969a92dd2e5515fa23ccdf9d87346cf3ba2ba2", + ) + + +def test_landed_gate_requires_commit(compatibility: dict[str, object]) -> None: + value = deepcopy(compatibility) + _gates(value)["supertonic_runtime"]["commit"] = None + + with pytest.raises(RuntimeError, match="landed compatibility gate requires a commit"): + validate_manifests._validate_compatibility(value) + + +def test_unlanded_gate_rejects_commit(compatibility: dict[str, object]) -> None: + value = deepcopy(compatibility) + _gates(value)["supertonic_server_jsonl"]["commit"] = "a" * 40 + + with pytest.raises(RuntimeError, match="unlanded compatibility gate cannot have a commit"): + validate_manifests._validate_compatibility(value) + + +def test_release_ready_rejects_unlanded_gate(compatibility: dict[str, object]) -> None: + value = deepcopy(compatibility) + value["ready_for_release"] = True + executorch = value["executorch"] + assert isinstance(executorch, dict) + executorch["commit"] = "b" * 40 + + with pytest.raises(RuntimeError, match="unlanded gates"): + validate_manifests._validate_compatibility(value) + + +def test_release_ready_requires_checkout_ancestry_verification( + compatibility: dict[str, object], +) -> None: + value = deepcopy(compatibility) + value["ready_for_release"] = True + executorch = value["executorch"] + assert isinstance(executorch, dict) + executorch["commit"] = "b" * 40 + for gate in _gates(value).values(): + gate["status"] = "landed" + gate["commit"] = "a" * 40 + + with pytest.raises(RuntimeError, match="requires checkout ancestry verification"): + validate_manifests._validate_compatibility(value) + + +def test_release_ready_verifies_all_landed_gate_commits( + compatibility: dict[str, object], tmp_path, monkeypatch +) -> None: + value = deepcopy(compatibility) + value["ready_for_release"] = True + executorch = value["executorch"] + assert isinstance(executorch, dict) + executorch["commit"] = "b" * 40 + for index, gate in enumerate(_gates(value).values(), start=1): + gate["status"] = "landed" + gate["commit"] = str(index) * 40 + calls = [] + monkeypatch.setattr( + validate_manifests, + "validate_executorch_checkout", + lambda checkout, commit, *, required_ancestors: calls.append( + (checkout, commit, required_ancestors) + ), + ) + + validate_manifests._validate_compatibility(value, release_checkout=tmp_path) + + assert calls == [(tmp_path, "b" * 40, ("1" * 40, "2" * 40, "3" * 40))] + + +def test_executorch_artifact_revision_must_match_final_commit() -> None: + artifact = { + "role": "supertonic_runner", + "source": "executorch", + "revision": "a" * 40, + "sha256": "c" * 64, + "size_bytes": 1, + "license": "BSD-3-Clause", + } + + with pytest.raises(RuntimeError, match="must match the final compatibility commit"): + validate_manifests._validate_release_artifacts([artifact], "b" * 40) + + artifact["revision"] = "b" * 40 + validate_manifests._validate_release_artifacts([artifact], "b" * 40) + + artifact["source"] = "other" + with pytest.raises(RuntimeError, match="runtime artifact has invalid source"): + validate_manifests._validate_release_artifacts([artifact], "b" * 40) diff --git a/muse_glimmer/macos/uv.lock b/muse_glimmer/macos/uv.lock new file mode 100644 index 0000000000..0650e47d59 --- /dev/null +++ b/muse_glimmer/macos/uv.lock @@ -0,0 +1,1594 @@ +version = 1 +revision = 3 +requires-python = "==3.13.*" + +[manifest] +members = [ + "livekit-plugins-executorch", + "muse-glimmer-token-service", + "muse-glimmer-voice-agent-workspace", + "muse-glimmer-worker", +] + +[[package]] +name = "aiofiles" +version = "25.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/41/c3/534eac40372d8ee36ef40df62ec129bee4fdb5ad9706e58a29be53b2c970/aiofiles-25.1.0.tar.gz", hash = "sha256:a8d728f0a29de45dc521f18f07297428d56992a742f0cd2701ba86e44d23d5b2", size = 46354, upload-time = "2025-10-09T20:51:04.358Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bc/8a/340a1555ae33d7354dbca4faa54948d76d89a27ceef032c8c3bc661d003e/aiofiles-25.1.0-py3-none-any.whl", hash = "sha256:abe311e527c862958650f9438e859c1fa7568a141b22abcd015e120e86a85695", size = 14668, upload-time = "2025-10-09T20:51:03.174Z" }, +] + +[[package]] +name = "aiohappyeyeballs" +version = "2.7.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ce/f4/eec0465c2f67b2664688d0240b3212d5196fd89e741df67ddb81f8d35658/aiohappyeyeballs-2.7.1.tar.gz", hash = "sha256:065665c041c42a5938ed220bdcd7230f22527fbec085e1853d2402c8a3615d9d", size = 24757, upload-time = "2026-07-01T17:11:55.501Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/71/43/1947f06babed6b3f1d7f38b0c767f52df66bfb2bc10b468c4a7de9eceff2/aiohappyeyeballs-2.7.1-py3-none-any.whl", hash = "sha256:9243213661e29250eb41368e5daa826fc017156c3b8a11440826b2e3ed376472", size = 15038, upload-time = "2026-07-01T17:11:54.055Z" }, +] + +[[package]] +name = "aiohttp" +version = "3.14.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "aiohappyeyeballs" }, + { name = "aiosignal" }, + { name = "attrs" }, + { name = "frozenlist" }, + { name = "multidict" }, + { name = "propcache" }, + { name = "yarl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/58/d9/22ce5786ac0c1653ae8b6c23bded02c1686d11f0dbb45b31ce128e0df985/aiohttp-3.14.3.tar.gz", hash = "sha256:9491196535a88924a60afd5b5f434b5b203b6cc616250878dbdb223a8f7844bc", size = 7971213, upload-time = "2026-07-23T01:57:27.037Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/57/be/5afd201cc0ab139029aadb75392efe85a293403d9dd3a3226161c21ce00c/aiohttp-3.14.3-cp313-cp313-android_21_arm64_v8a.whl", hash = "sha256:2e9878ae68e4a5f1c0abe4dd497dbc3d51946f5837b56759e2a02e78fa90ef86", size = 506269, upload-time = "2026-07-23T01:54:49.075Z" }, + { url = "https://files.pythonhosted.org/packages/22/09/dec8189d62b45ade009f6792a2264b942a90cb88aeaf181239933cd72c3c/aiohttp-3.14.3-cp313-cp313-android_21_x86_64.whl", hash = "sha256:f3d2669fe7dec7fc359ecdb5984b29b50d85d5d00f8c1cb61de4f4a24ee42627", size = 515166, upload-time = "2026-07-23T01:54:51.894Z" }, + { url = "https://files.pythonhosted.org/packages/28/24/2854869d29ed8a8b19d74f9ec6629515f7e04d02dd329d9d179201e58e47/aiohttp-3.14.3-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:cc7cb243a68167172f48c1fd43cee91ec4b1d40cefd190edd43369d1a6bc9c82", size = 486263, upload-time = "2026-07-23T01:54:54.223Z" }, + { url = "https://files.pythonhosted.org/packages/d4/dd/57187c8be2a35aea65eaee3bd2c3dcbbcf0204f5106c89637e3610380cd1/aiohttp-3.14.3-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:78253b573e6ffab5028924fc98bc281aae05445969982a10864bc360dea2016c", size = 492299, upload-time = "2026-07-23T01:54:56.236Z" }, + { url = "https://files.pythonhosted.org/packages/b9/11/06ae6ed8f0d414edf4068861e233d8fe23ee699bfd4b3ceb8663db948a62/aiohttp-3.14.3-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:7041d52c3a7fa20c9e8c182b534704abb19502c8bdcbde7ab23bfda6f642394f", size = 502235, upload-time = "2026-07-23T01:54:58.377Z" }, + { url = "https://files.pythonhosted.org/packages/7e/a3/559639c34a345d2cf7c52dff6838119f2eaf29eb508227b5b83f573af813/aiohttp-3.14.3-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ac74facc01463f138b0da5580329cfcc82818dea5656e83ddcd11268fc12ff80", size = 750883, upload-time = "2026-07-23T01:55:00.65Z" }, + { url = "https://files.pythonhosted.org/packages/91/cd/41e131f13afd1e7b0172a9d9eda085ef90eb8439f41f0d279db81ed3ae60/aiohttp-3.14.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:d6218d92e450824e9b4881f44e8c09f1853b490f9a64130801024a4793b1b3b0", size = 508473, upload-time = "2026-07-23T01:55:02.945Z" }, + { url = "https://files.pythonhosted.org/packages/bc/6b/e7f13410d391c6e55b4c007a8de024355389d7d459e3d64c42b2d33617e5/aiohttp-3.14.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:11fb37ef075669eee52ab1928fbf6e1741fada40409fa309ebde9607a962aebf", size = 509190, upload-time = "2026-07-23T01:55:05.173Z" }, + { url = "https://files.pythonhosted.org/packages/97/21/6464573e53d69672cc1eada3e5c5cb2d2efa82701e8305a0f2047a576967/aiohttp-3.14.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:55bdcc472aafe2de4a253045cc128007a64f1e0264fb675791e132ea5edaa3bd", size = 1761478, upload-time = "2026-07-23T01:55:07.383Z" }, + { url = "https://files.pythonhosted.org/packages/1a/81/d217043a4c17fbce360905e3b2bdd20139ebc9a2de836d035d179c4da006/aiohttp-3.14.3-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c39846c3aad97a8530c89d7a3869a8f8e9e3762c6ac0504481e5c80948f7e807", size = 1735092, upload-time = "2026-07-23T01:55:09.803Z" }, + { url = "https://files.pythonhosted.org/packages/a1/66/e13a02d0eeb1a9a502402a977abb4e4abff9fe4051c26f80558c57a7c975/aiohttp-3.14.3-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5895ef58c4620afe02fa16044f023dc4dafec08158f9d08874a46a7dbc0341b8", size = 1800546, upload-time = "2026-07-23T01:55:12.012Z" }, + { url = "https://files.pythonhosted.org/packages/26/5e/57d42fca1d18cb5acc1cad945d017fabc5d6ae71d8a08ad66be8dc3ee544/aiohttp-3.14.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:fa9467a8113aa69d3d7c55a70ef0b7c636010a40993f3df9d9d0d73b3eb7ef24", size = 1895250, upload-time = "2026-07-23T01:55:14.357Z" }, + { url = "https://files.pythonhosted.org/packages/ca/1c/7da8d08e74d56f00070822f9638ff3f1c563f8ad87d1efa996c87bfc8644/aiohttp-3.14.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d7d2deec16eeedf55f2c7cf75b521ea3856a5177e123844f8fd0f114ce252cb5", size = 1789289, upload-time = "2026-07-23T01:55:16.668Z" }, + { url = "https://files.pythonhosted.org/packages/cd/0f/cf16bcf56896981c1a0319f5d5db9337994b5165730c48a8fa07e9b34be6/aiohttp-3.14.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:dd54d0e8717de95939766febac482ac0474d8ac3b048115f9f2b1d23a16e7db4", size = 1586706, upload-time = "2026-07-23T01:55:18.913Z" }, + { url = "https://files.pythonhosted.org/packages/fe/6f/76eac12a7f2480e1e304f842efdb07db33256b0d9165b866b6ef0806c202/aiohttp-3.14.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:df82f3787c940c94986b34222d59c9e38843fba85139f36e85255a82ad5355a9", size = 1724652, upload-time = "2026-07-23T01:55:21.296Z" }, + { url = "https://files.pythonhosted.org/packages/39/b6/19c8c592baeeb94b75f966547d40c02ac7590902306ec5863d5c027cf506/aiohttp-3.14.3-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:42a67efc36300d052fb4508a53e8b6901b9284b599ae63945c377569c5fcc1e1", size = 1756239, upload-time = "2026-07-23T01:55:23.705Z" }, + { url = "https://files.pythonhosted.org/packages/dc/c9/4e9383150296f97f873b680c4de8fb2cd88608fb9f48c79edcb111611abc/aiohttp-3.14.3-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:7a75aa63cbf9b21cfaf60dc2657e19df2c2867d91707d653fee171ffeedd1371", size = 1769161, upload-time = "2026-07-23T01:55:26.082Z" }, + { url = "https://files.pythonhosted.org/packages/aa/1e/147bdc6cc5de5f3ab011be8bf5d6e786633249f22c20bae06f85e45f5387/aiohttp-3.14.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:e92eb8acc45eb6a9f4935071a77edf5b85cc6f8dfad5cd99e97653c26593cdde", size = 1578759, upload-time = "2026-07-23T01:55:28.846Z" }, + { url = "https://files.pythonhosted.org/packages/fd/31/78388a9d6040ece2e11df62ea229a822cf5e52d238374b220ae9975b2623/aiohttp-3.14.3-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:b014a6ed7cf912e787149fdc529166d3ceabac23f26efeea3158c9aba2354e7e", size = 1792025, upload-time = "2026-07-23T01:55:31.457Z" }, + { url = "https://files.pythonhosted.org/packages/03/51/a3d29fdf2c25d796746af8ad6fe56a45d6256c38b0a8a2ed752e1160b3a2/aiohttp-3.14.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:3d4f72af88ac2474bb5bca640030320e3d38a0163a1d7533500e87be458eef71", size = 1768477, upload-time = "2026-07-23T01:55:33.87Z" }, + { url = "https://files.pythonhosted.org/packages/29/a6/442e18b5afeade534d877a2dc3c3e392aff8d49787890b0cf84790410267/aiohttp-3.14.3-cp313-cp313-win32.whl", hash = "sha256:5f08ec777f35ee70720233b8b9811d3bb5d728137f30ac91b7457709c3261ac0", size = 451069, upload-time = "2026-07-23T01:55:36.121Z" }, + { url = "https://files.pythonhosted.org/packages/9d/69/3d876ac02659f271cf7f6769f14a8e3de5b6e888ed8b5a7e998086a4cec8/aiohttp-3.14.3-cp313-cp313-win_amd64.whl", hash = "sha256:dff9461ec275f22135650d5ba4b4931a11f3958df7dfbb8db630000d4dee0883", size = 476518, upload-time = "2026-07-23T01:55:38.303Z" }, + { url = "https://files.pythonhosted.org/packages/b2/0e/50d6e6471cd31edce8b282bdec59375a3a69124d8a989a0b1313355cae52/aiohttp-3.14.3-cp313-cp313-win_arm64.whl", hash = "sha256:ddcac3c6b382e81f1dd0499199d4136b877beb4cb5ef770bbbfba56c4b8f55d2", size = 447676, upload-time = "2026-07-23T01:55:40.451Z" }, +] + +[[package]] +name = "aiosignal" +version = "1.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "frozenlist" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/61/62/06741b579156360248d1ec624842ad0edf697050bbaf7c3e46394e106ad1/aiosignal-1.4.0.tar.gz", hash = "sha256:f47eecd9468083c2029cc99945502cb7708b082c232f9aca65da147157b251c7", size = 25007, upload-time = "2025-07-03T22:54:43.528Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fb/76/641ae371508676492379f16e2fa48f4e2c11741bd63c48be4b12a6b09cba/aiosignal-1.4.0-py3-none-any.whl", hash = "sha256:053243f8b92b990551949e63930a839ff0cf0b0ebbe0597b0f3fb19e1a0fe82e", size = 7490, upload-time = "2025-07-03T22:54:42.156Z" }, +] + +[[package]] +name = "annotated-doc" +version = "0.0.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5a/8e/38aa427ed5402449e226975b649c5dc73ccadfefeb95e6aecb8f8ea4b6b6/annotated_doc-0.0.5.tar.gz", hash = "sha256:c7e58ce09192557605d8bbd92836d7e1d520ac9580096042c0bfd197efacf1bb", size = 10758, upload-time = "2026-07-28T13:50:58.129Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3e/30/e900b21425a860e195f32e37657aa1f7c7f2b1bfb26f03ca209b90933c06/annotated_doc-0.0.5-py3-none-any.whl", hash = "sha256:117bac03a25ede5df5440e855b32d556049ca169ead221505badf432fed4b101", size = 5302, upload-time = "2026-07-28T13:50:57.239Z" }, +] + +[[package]] +name = "annotated-types" +version = "0.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5f/56/a8120250d128bed162cd73c76d45f6ef9991f3e068f62a8ee060afa3104a/annotated_types-0.8.0.tar.gz", hash = "sha256:13b2beaad985e05e2d6407ee4c4f35590b11f8d693a258a561055cac8f64cab7", size = 15893, upload-time = "2026-07-23T20:16:13.995Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/99/91/8acff4f5e50511b911bbccb72b8628a49c68ce14148cd9f6431094859a90/annotated_types-0.8.0-py3-none-any.whl", hash = "sha256:f072f4d804ea359e4eaf198b1af7a8b0943881a87f31bb764f8bf219bb9419e0", size = 13427, upload-time = "2026-07-23T20:16:12.938Z" }, +] + +[[package]] +name = "anyio" +version = "4.14.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/61/cc/a381afa6efea9f496eff839d4a6a1aed3bfafc7b3ab4b0d1b243a12573dd/anyio-4.14.2.tar.gz", hash = "sha256:cfa139f3ed1a23ee8f88a145ddb5ac7605b8bbfd8592baacd7ce3d8bb4313c7f", size = 260176, upload-time = "2026-07-12T20:29:07.082Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/da/35/f2287558c17e29fafc8ef3daf819bb9834061cfa43bff8014f7df7f63bdc/anyio-4.14.2-py3-none-any.whl", hash = "sha256:9f505dda5ac9f0c8309b5e8bd445a8c2bf7246f3ce950121e45ea15bc41d1494", size = 125813, upload-time = "2026-07-12T20:29:05.763Z" }, +] + +[[package]] +name = "attrs" +version = "26.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9a/8e/82a0fe20a541c03148528be8cac2408564a6c9a0cc7e9171802bc1d26985/attrs-26.1.0.tar.gz", hash = "sha256:d03ceb89cb322a8fd706d4fb91940737b6642aa36998fe130a9bc96c985eff32", size = 952055, upload-time = "2026-03-19T14:22:25.026Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/64/b4/17d4b0b2a2dc85a6df63d1157e028ed19f90d4cd97c36717afef2bc2f395/attrs-26.1.0-py3-none-any.whl", hash = "sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309", size = 67548, upload-time = "2026-03-19T14:22:23.645Z" }, +] + +[[package]] +name = "av" +version = "18.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/8d/f4/f22114d30d3435e38c6af2b4870f37b864403dca6ae7af747a289ce0a18e/av-18.1.0.tar.gz", hash = "sha256:47bfc286e1bc9de7ab4681fc2b575cd2460a66919d31ffe1bd5aa54fae531a28", size = 4451061, upload-time = "2026-08-12T22:28:18.761Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/05/d4/d7cdc8bff143c17a6d35924375ae28dd692cacde38700a7d419fde54f44a/av-18.1.0-cp311-abi3-macosx_11_0_x86_64.whl", hash = "sha256:ae75d8bb6467895ed1f8572ededf7ffa49eac07f6e483222f5d7d62a41d12f04", size = 22546147, upload-time = "2026-08-12T22:27:11.851Z" }, + { url = "https://files.pythonhosted.org/packages/3f/c9/37a619297492256b77d5ed906e7d8166c10a26ed251dccf1ae03ab19bff6/av-18.1.0-cp311-abi3-macosx_14_0_arm64.whl", hash = "sha256:b30a4e8d934558e19602b68998a4d9ac9f250fa0dacef216f7e8e40153b13316", size = 18217603, upload-time = "2026-08-12T22:27:14.713Z" }, + { url = "https://files.pythonhosted.org/packages/d9/84/2464ffb64c08c5ce8b522c8e74594714414e3b0575267652c5c51c0574b9/av-18.1.0-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:6fc837cc51adf80331ac850779cd53b5d4c4460b0ebe9057a02a921c6736f19d", size = 33640142, upload-time = "2026-08-12T22:27:17.835Z" }, + { url = "https://files.pythonhosted.org/packages/27/3a/204dbfc3e08eb4cdc6e6ff57be02150bc44523ebdb50182d10025792ebd9/av-18.1.0-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:8a032e8d8ebc73dec079364b9b4a6837638a2d106e8472314e685ffbf163e700", size = 35786210, upload-time = "2026-08-12T22:27:20.984Z" }, + { url = "https://files.pythonhosted.org/packages/e1/99/b0d04ec553ff9a7e00455458dfa3a39c8a8f627b273056b4e5fe57d590de/av-18.1.0-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:3c8b1f8b46f99d52e2d8b0ed5d0cdadf172d24794d46e2077b16e44ed08e26ff", size = 39379798, upload-time = "2026-08-12T22:27:24.432Z" }, + { url = "https://files.pythonhosted.org/packages/56/b1/e00d4feae59160149df6126585e726fdc6300798fd40c5dd324879e81f68/av-18.1.0-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:ab5ac081bc9eaf54109120d4e56284674fecfbe520d9aa1707c7fa911ec5f4d2", size = 34690321, upload-time = "2026-08-12T22:27:27.769Z" }, + { url = "https://files.pythonhosted.org/packages/dc/94/836fa987e3084d11a21489f11357fb24843ef3aa8faf74ddddfc603d5062/av-18.1.0-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:191224788d87af06c31784a395bb73f14b72f33d7f4871ace0157de2abdc6276", size = 36859932, upload-time = "2026-08-12T22:27:31.403Z" }, + { url = "https://files.pythonhosted.org/packages/33/b4/76ba21e46704f632004276b85289a1582e95f5eff760436d6149875a1881/av-18.1.0-cp311-abi3-win_amd64.whl", hash = "sha256:ea1480b7a8d5405cb5f382b344731bf125fd2c1c6fae3964f6c48595628387ff", size = 27595679, upload-time = "2026-08-12T22:27:35.177Z" }, + { url = "https://files.pythonhosted.org/packages/4f/ad/a3135884c5753b09773176b97201ae602f67ad14206c395ff838d66bf9b0/av-18.1.0-cp311-abi3-win_arm64.whl", hash = "sha256:5509ec12aaa19fd6601de13cfa6f4cdad450da07982118510592875d970454d6", size = 20257584, upload-time = "2026-08-12T22:27:38.472Z" }, +] + +[[package]] +name = "certifi" +version = "2026.7.22" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a3/c2/24167ea9858356b47a87a50d39908bfdb72ceeefe0041586e704e5376b3a/certifi-2026.7.22.tar.gz", hash = "sha256:741e2c3b351ddf169a738da9f2c048608ff7f2c5cc02f1ebc6b118bb090d5d55", size = 138112, upload-time = "2026-07-22T03:35:12.644Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/a7/71ac2cff56fec219ed242bb11b8efb69fcc4bec75db06fb7bfe35de520e6/certifi-2026.7.22-py3-none-any.whl", hash = "sha256:62f22742b58a1a33014a2b6b706588a8d7e2a88ae7bd1a6ebe8c992928483775", size = 136983, upload-time = "2026-07-22T03:35:11.276Z" }, +] + +[[package]] +name = "cffi" +version = "2.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pycparser", marker = "implementation_name != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9e/ef/008a1939e372c06329a3fce4279c02f328488f3526744906eeec3da7ad5f/cffi-2.1.1.tar.gz", hash = "sha256:dd31f52ea1086513bb9df30f8fcee9b8918323ae067a3d5b78bc826a000712be", size = 530807, upload-time = "2026-08-03T21:21:18.939Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9d/f4/035513d4117049066b4779dc3b7c0c0fdad175fa13731c9f4003f1cd1478/cffi-2.1.1-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:b5bdfd1c873d4e093aabc0ca84c4ca6dbc4f752afb5c86f146d9742580c9da2e", size = 194248, upload-time = "2026-08-03T21:19:59.399Z" }, + { url = "https://files.pythonhosted.org/packages/76/af/2aeb4dbb5fc41a04161ae9ff1518de7cec08e164f44a8ce6a4cf7fd2cd1d/cffi-2.1.1-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:31348097ff5bbe827ccc41795d4dd099d9f0625e7def00ee653c137a490c2a6c", size = 196908, upload-time = "2026-08-03T21:20:00.746Z" }, + { url = "https://files.pythonhosted.org/packages/a7/46/2e5fdde8555706dd98139a910ca11be02809f3f605ce956f655d0214e100/cffi-2.1.1-cp313-cp313-macosx_10_15_x86_64.whl", hash = "sha256:9d2055050ea716bd38b7f7f1579c275386646b4894c155a3e2f3cd62ed41b7c6", size = 184805, upload-time = "2026-08-03T21:20:02.02Z" }, + { url = "https://files.pythonhosted.org/packages/55/41/4c7042f317b9217502988f0873af87e16ad606dc20f84e546e3e6ce9764c/cffi-2.1.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:19ee6127ee34de7d83ce3d371ebc5ed91addbdcc39f9ab15ce4eb35a4e534971", size = 184764, upload-time = "2026-08-03T21:20:03.141Z" }, + { url = "https://files.pythonhosted.org/packages/43/1f/1c3d90d91811c8f86ced9ed637956c54bfe5b79ca98fe976d7f8c8979f6b/cffi-2.1.1-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:6a8dddef476fab96d066d578fc88526767b836ab5ab21754e1d5bf3879c31c7c", size = 214722, upload-time = "2026-08-03T21:20:04.377Z" }, + { url = "https://files.pythonhosted.org/packages/37/6f/3b5ce4c3b2192d250f04908f2bfd91ef34552ec8f7716a5d4abdb8d67bb2/cffi-2.1.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f16c709686a78c727bbbf059f92b0bf41c6fc60deec706d2dc19f529175a6125", size = 222369, upload-time = "2026-08-03T21:20:05.544Z" }, + { url = "https://files.pythonhosted.org/packages/02/10/4b3c75dde3d9663c9e02ba05c2668b954f671d4bbe346413ca8c696b295a/cffi-2.1.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:fcd22650c908d7b7da162bbfaab594a1227a15d1643a98c68b122ac642fa2264", size = 210175, upload-time = "2026-08-03T21:20:06.75Z" }, + { url = "https://files.pythonhosted.org/packages/df/62/14f74b9543e605d17701dc797b815958b8bb70b7624ce1b832ddad48ed6c/cffi-2.1.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:aa9511c62d14da7aacc9b4bf51f3f697a621e83b2d6919008243c3aad168eea3", size = 208670, upload-time = "2026-08-03T21:20:08.04Z" }, + { url = "https://files.pythonhosted.org/packages/95/95/86342356ff5953b3fb06f7ef7c5bee212d45e770abc7218d451b9148313c/cffi-2.1.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:a931079504ecc49efed7744c476a5c343a92fabf66dec2db95edb1b2fdc770e2", size = 221824, upload-time = "2026-08-03T21:20:09.274Z" }, + { url = "https://files.pythonhosted.org/packages/eb/ff/7b3429ff53aafe931ed8a5fc69f481bbef7ba6de87ddcbb63d08f483f613/cffi-2.1.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a2d7755bef5a12ed488f4ef1f1b69ee9191d7396083b755a5d2295f6edb4768b", size = 225148, upload-time = "2026-08-03T21:20:10.7Z" }, + { url = "https://files.pythonhosted.org/packages/34/34/a95870b9221e09cf4f2ce3178b1a210abdfe63a1bd357da940418d7b8d15/cffi-2.1.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e0bcb7e0f677f543555d2adff3bf19c05f66cdb4796e5ff602442ab2fe3c4ef7", size = 223564, upload-time = "2026-08-03T21:20:12.165Z" }, + { url = "https://files.pythonhosted.org/packages/70/ea/839b50531021a647fb5e929f72cf97bc1ff702b5472166164b5b6e76b851/cffi-2.1.1-cp313-cp313-win32.whl", hash = "sha256:334644fbac4eff73d985a17a91226df55d0f394160c4cfb880e084c8f7161cac", size = 175263, upload-time = "2026-08-03T21:20:13.559Z" }, + { url = "https://files.pythonhosted.org/packages/60/a6/8b149b2c3f2e11aaa1618ef64500b45f50f22c57a977a4dff1aff1f91042/cffi-2.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:1aa5645c30469b09530c4ebca77ebf8f17618293c58f8549cb1a543a50236e7d", size = 185688, upload-time = "2026-08-03T21:20:14.69Z" }, + { url = "https://files.pythonhosted.org/packages/01/9a/11f687cb39d6a3504060d5242f04f48c735afb4d3d533958a20594890cb2/cffi-2.1.1-cp313-cp313-win_arm64.whl", hash = "sha256:63bbfd5ded17c4840ac07cd8f1c21ba9d9708141f840b324f422f41b207e3973", size = 180078, upload-time = "2026-08-03T21:20:15.917Z" }, +] + +[[package]] +name = "charset-normalizer" +version = "3.5.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e5/3f/143b048436775b0f76ac3eec145c019e8173ccc2885c8f20319b996d5e83/charset_normalizer-3.5.1.tar.gz", hash = "sha256:6117b84ea48435e5356dc737f5121485c30920ba43375fa7b434fd753df0eac3", size = 171764, upload-time = "2026-08-15T08:20:44.807Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bc/61/2cb6ad133dbbb449fa2d37ccae973232f4827e799af258d15e589a3d1e9e/charset_normalizer-3.5.1-cp313-cp313-android_24_arm64_v8a.whl", hash = "sha256:4f298bdadb8f0b9e5672877f647d1be9373ef5320c9e2f049795e26cad28b6a9", size = 211584, upload-time = "2026-08-15T08:17:33.597Z" }, + { url = "https://files.pythonhosted.org/packages/18/57/a305c968be1ca13f3dd1b32f445877e97addf55d80b65c7cb35fac82b777/charset_normalizer-3.5.1-cp313-cp313-android_24_x86_64.whl", hash = "sha256:88ca277405c2d3b71c4e1c2ee0e7966e807bcba86a69d11e19ba199d18ae4491", size = 223359, upload-time = "2026-08-15T08:17:35.022Z" }, + { url = "https://files.pythonhosted.org/packages/09/0a/d3646670292ce8d8f8cc11ac067d44885e697a5591f57a9221128da5e7b3/charset_normalizer-3.5.1-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:9362dd90aa7dab48c0054a21187791ccf05473f7dba5d92b8033ae62164675e7", size = 194464, upload-time = "2026-08-15T08:17:36.452Z" }, + { url = "https://files.pythonhosted.org/packages/de/93/d51ec556e01042fed6f993ea859311bc7917b466684182fbbceb6ca24762/charset_normalizer-3.5.1-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:977cdbd483a9cff38179bea4fd754289a6f2195c7abd414aba85410b3e66cc5e", size = 197676, upload-time = "2026-08-15T08:17:37.819Z" }, + { url = "https://files.pythonhosted.org/packages/a4/a0/562247944386f7d4ef94467e84876600cc1e0f1b93239aaa9213d2bc3cbd/charset_normalizer-3.5.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:e90251c0c7bdd54a100a0dce3c07b7e637278c93af29dbf78ebb89a58c4bac7d", size = 340473, upload-time = "2026-08-15T08:17:39.303Z" }, + { url = "https://files.pythonhosted.org/packages/31/e7/1d994be1b93d41e9502b8b0460eaa88a1dd8df335df415db87d6c3e91ab2/charset_normalizer-3.5.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:94d78ecec2605a8d0398b0f365d5f12a63248438516f5dac536a5eff7337df4a", size = 240156, upload-time = "2026-08-15T08:17:40.66Z" }, + { url = "https://files.pythonhosted.org/packages/09/53/27923ce5cc6cbccb832037b27dca98882d9c53e9b69e866bbbef4aae7fc8/charset_normalizer-3.5.1-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:d59b75732e9b6f27388e10c14b0259cc5f2e48c78627d185e6a177b58ad3cffe", size = 228246, upload-time = "2026-08-15T08:17:42.003Z" }, + { url = "https://files.pythonhosted.org/packages/ce/48/5a97e84d63af1d55c07439cb80e56d99a8efb4295700eb4e18c0d1615d2c/charset_normalizer-3.5.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:0d929fc574b4d6fd9e7c0f5c2ede8716a41911923aa7fa5fce38e0818aa4a1ac", size = 263660, upload-time = "2026-08-15T08:17:43.627Z" }, + { url = "https://files.pythonhosted.org/packages/7a/c2/071575791dcc88316c0a9a65ce38897a82e4cfe4a325f0f7fe1b1ac47bcf/charset_normalizer-3.5.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:394fea06235c8543390050ed5f529187074b029fb027213f6c46ac11ab5d950e", size = 260354, upload-time = "2026-08-15T08:17:45.094Z" }, + { url = "https://files.pythonhosted.org/packages/fb/af/63240b0c0248c075c2535a1f1bd992821d8251b9f173abc13329661d09e4/charset_normalizer-3.5.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:62b55f6722735a6c472f88361cde6640608773d9443cebdbb51abf436a1fcdd3", size = 250638, upload-time = "2026-08-15T08:17:46.496Z" }, + { url = "https://files.pythonhosted.org/packages/4d/66/70dfad64f15be09c15ccfee81330a7e515895dbe296dd23114e9a231268a/charset_normalizer-3.5.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fa48b1b63d639f9483e0633e092f5851e2348c352f1f9bb6c8182f87884ef876", size = 244583, upload-time = "2026-08-15T08:17:47.963Z" }, + { url = "https://files.pythonhosted.org/packages/c0/24/ef36367d38b9ddd4bccbf72888c342e8de1f5ae506fa0b2dcf970e2732a1/charset_normalizer-3.5.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:c71fb0d56c920c269cd3e2e3fe7c610e3f1fdb21a6ce60efa6430ff63676cea6", size = 242038, upload-time = "2026-08-15T08:17:49.481Z" }, + { url = "https://files.pythonhosted.org/packages/db/ab/55e683ba0fff2e43adafc10daa3001eac90fdaa419a97227d5a7067eedde/charset_normalizer-3.5.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:485a0d363cafefcd2538a73c7c838daa2035f09b2c9f9b5e3133f80c6aeb84c2", size = 233677, upload-time = "2026-08-15T08:17:50.845Z" }, + { url = "https://files.pythonhosted.org/packages/bd/67/0f40eaf8d1b6e7cf15e82382a2965efaca787fc1c2794b7021d37aaf5036/charset_normalizer-3.5.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:5c0ea61a470e070686aa30892fed79e297d2c8d0ab46b8bcdf027d38c51da591", size = 264491, upload-time = "2026-08-15T08:17:52.61Z" }, + { url = "https://files.pythonhosted.org/packages/5c/64/12b4c2a11ee8df4fcc518c78b0d93e3a92bd3d5253d1617ce74ff0e8c7ef/charset_normalizer-3.5.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:90b7481fb62fbe172c558bc6fd1c4c98d82004a54a7551f20e11ac9bf0b8708c", size = 245196, upload-time = "2026-08-15T08:17:54.023Z" }, + { url = "https://files.pythonhosted.org/packages/37/2e/651d910af6d0fba325eee1cda37ec5443462ed25360e666c144166eb6091/charset_normalizer-3.5.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:35fe081843b35aad20ffeccec3eeffbe637b15d14f3fb22cc1b59cd8ec17e93c", size = 261660, upload-time = "2026-08-15T08:17:55.491Z" }, + { url = "https://files.pythonhosted.org/packages/90/c6/b09e05e6db7f64338e0dc067c79577b1138da86c1e38369096851d96be88/charset_normalizer-3.5.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:fd0350afdc3aabd5576f60ea109228bd5538139713c7b094c5cd27c73a98bc6f", size = 252618, upload-time = "2026-08-15T08:17:57.025Z" }, + { url = "https://files.pythonhosted.org/packages/76/4e/362d4f9fdcdf5556fb2aa3ce7d4a58ebce03ed1ff03aa1d9aca8d02f13f3/charset_normalizer-3.5.1-cp313-cp313-pyemscripten_2025_0_wasm32.whl", hash = "sha256:9d9a0dc7cbe9bec24c3f767c9122c41fe5a1bc43f47cd099d00d393e09769de4", size = 140362, upload-time = "2026-08-15T08:17:58.425Z" }, + { url = "https://files.pythonhosted.org/packages/b4/d4/703be739b26acce318bd29eb3b25b7209e1b1f527f9eae3d1f1f01fdde2b/charset_normalizer-3.5.1-cp313-cp313-win32.whl", hash = "sha256:d63600d620ad0064c3a748b950ac5ea38a80190e5498532efefa4b7b3f1da1f3", size = 177755, upload-time = "2026-08-15T08:18:00.037Z" }, + { url = "https://files.pythonhosted.org/packages/8a/33/56d97ade41c8db611e727168c52ae46c9224c362ec28d4b65d7e9869e8da/charset_normalizer-3.5.1-cp313-cp313-win_amd64.whl", hash = "sha256:aea996a6aba25260827c9ea511d1addfde2da9eb686ac961838509086188b7e6", size = 199295, upload-time = "2026-08-15T08:18:01.506Z" }, + { url = "https://files.pythonhosted.org/packages/5b/75/5b20dd1e6573a01a08158fe104104fa2c8abf941745596954185726cd46c/charset_normalizer-3.5.1-cp313-cp313-win_arm64.whl", hash = "sha256:fd0a274c0e5f9a21565cd9d3dd749b61f96b7aa1e20a93aa1ba4029518f2e5c0", size = 179856, upload-time = "2026-08-15T08:18:02.929Z" }, + { url = "https://files.pythonhosted.org/packages/5b/97/fb4e82231aba271ffd775a1b4993b0defc4e3059f286ae41d9433409fe85/charset_normalizer-3.5.1-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:41876ee62a3dddf48ff1121ad8f0798032aa03f2fd35f21f34a4cab14f18d8d2", size = 331467, upload-time = "2026-08-15T08:19:50.959Z" }, + { url = "https://files.pythonhosted.org/packages/9f/2f/fe3f187327aac18e2d54e9d2b08e15d27bf9b642d9e51c219f130fc34d1a/charset_normalizer-3.5.1-cp37-abi3-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:a6dac12ff6b846103483683f60c5f8fee205121adc58ffd87e90a90a3af69e99", size = 253057, upload-time = "2026-08-15T08:19:52.654Z" }, + { url = "https://files.pythonhosted.org/packages/d7/c7/9e48cee5c161fe24da823b61bf381921d77cb994a0a4de148e95018c1984/charset_normalizer-3.5.1-cp37-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cee5dd7c6fb5dd52a0fe2a740f9bc6e3593f5f8b1788bde49de02086f30182b2", size = 240930, upload-time = "2026-08-15T08:19:54.163Z" }, + { url = "https://files.pythonhosted.org/packages/49/e0/716601f3cc69be7b198951150c75ead1ece33c3c8036ff6ffa46029659a0/charset_normalizer-3.5.1-cp37-abi3-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:343fb4f2821043bd87095f7b08a1a181febc8e36ac64212143bbfd0a0e1bc235", size = 230822, upload-time = "2026-08-15T08:19:55.807Z" }, + { url = "https://files.pythonhosted.org/packages/d3/05/71bfc5caa0abcc45aea1f6a4d50ac68e59605ddc7666fe8494f4cd229665/charset_normalizer-3.5.1-cp37-abi3-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ae4a097991662cd4fff0ddc74e0fe7874f82e00042fa0ea00855645ed0c79598", size = 260037, upload-time = "2026-08-15T08:19:57.312Z" }, + { url = "https://files.pythonhosted.org/packages/c3/92/de7e32ed05341e7a9c4c877c318418197b7f2d66a3b68d561bf2ac57ca3e/charset_normalizer-3.5.1-cp37-abi3-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4b599739b93b2cbeded49645ae3c8d1405c29ddfbceac1545c87a3f9580a9e96", size = 255097, upload-time = "2026-08-15T08:19:59.056Z" }, + { url = "https://files.pythonhosted.org/packages/f5/7b/ade0a122600319dfa0b1000ab0f9731c94a817904cf3c5de408c73a4ede7/charset_normalizer-3.5.1-cp37-abi3-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b39b69b347e5e47a3b5b8cfc005c68c1ba347474e3960236c4944a8ecd174962", size = 250166, upload-time = "2026-08-15T08:20:00.612Z" }, + { url = "https://files.pythonhosted.org/packages/75/9c/019fbb9f4834491a160951349b1a3714439376f66e5f7cf18b4f18f0c7aa/charset_normalizer-3.5.1-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:a2028475ba855475b8b4d3cfeb4994269c967aea8b9892dfba907f4263a863a3", size = 241821, upload-time = "2026-08-15T08:20:02.321Z" }, + { url = "https://files.pythonhosted.org/packages/2b/b8/11d4840bfc99330cc7fbcc2681ee5a044553a6e77655508d8f9b2bff7b34/charset_normalizer-3.5.1-cp37-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:36047af20e17097c3bb9476c2b7655f2f7aa51322c0ba58c07695bedf755a950", size = 232529, upload-time = "2026-08-15T08:20:04.008Z" }, + { url = "https://files.pythonhosted.org/packages/18/96/2b3a21492d9f65171ac75d872f5018260013d00bfa0ff70ec9f179148cbd/charset_normalizer-3.5.1-cp37-abi3-musllinux_1_2_ppc64le.whl", hash = "sha256:4c4fb141a727957c93edfe5c32a26ceb6b5f6461d67146e2d39f51e16170bea8", size = 260348, upload-time = "2026-08-15T08:20:05.877Z" }, + { url = "https://files.pythonhosted.org/packages/d6/aa/a69a2028e8bd052476c245460ab19d7de595de084dd968f2d75cd50c3e25/charset_normalizer-3.5.1-cp37-abi3-musllinux_1_2_riscv64.whl", hash = "sha256:2f293479cce755c75f1697e87c409b7ae4c555c7dfecb6e988ad13abba943031", size = 247234, upload-time = "2026-08-15T08:20:07.487Z" }, + { url = "https://files.pythonhosted.org/packages/35/8a/3d130aeabcaf3d2466af76b7b141c08d9e89c9016ab4b7cdd0f7dc2d1c62/charset_normalizer-3.5.1-cp37-abi3-musllinux_1_2_s390x.whl", hash = "sha256:3588e376b3ea2eea84976f67273d679f229e24c66dce7b82ae45aef04ff6e072", size = 256917, upload-time = "2026-08-15T08:20:09.142Z" }, + { url = "https://files.pythonhosted.org/packages/80/c2/a7379b840292d0c1ab9fbd17d1f3967aa81794dc95bc74be8999d7fedcf7/charset_normalizer-3.5.1-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:e199fb99720074809a7720f1c0b4d919eea8b87e88713e0f8f602f7bef543d9d", size = 254846, upload-time = "2026-08-15T08:20:10.727Z" }, + { url = "https://files.pythonhosted.org/packages/01/65/d43b714731bb2f40d4053dfa00ecfc1c5a301f8e3316c5db3a09af59fe94/charset_normalizer-3.5.1-cp37-abi3-win32.whl", hash = "sha256:dd732602a7009217f658d5863d12d79d373a4de0eebc111094bcdd3bb8e0a6cc", size = 174216, upload-time = "2026-08-15T08:20:12.334Z" }, + { url = "https://files.pythonhosted.org/packages/35/4f/b911ed898b26a09789eba9c9200c999aff6c61b4bafaf4838e56d1a1e1a3/charset_normalizer-3.5.1-cp37-abi3-win_amd64.whl", hash = "sha256:70055ff39b97c99e7ae40ea3e393fb62aa2e44dbd9b29f8d14f42fb0025c3959", size = 199764, upload-time = "2026-08-15T08:20:13.908Z" }, + { url = "https://files.pythonhosted.org/packages/f0/a7/920baf467bfd9bf689f3b318340f37aee4572a71f162bd8db51da55ba4fa/charset_normalizer-3.5.1-cp37-abi3-win_arm64.whl", hash = "sha256:87e4f41d375c0b9be2fb5251aee4b8a689169e134535aed81bf085c3b647451e", size = 287318, upload-time = "2026-08-15T08:20:15.551Z" }, + { url = "https://files.pythonhosted.org/packages/cc/61/d01fc49b8dea277640b55a9e15960dbca9fdc8c9fde18e572d39c59f4019/charset_normalizer-3.5.1-py3-none-any.whl", hash = "sha256:6df0ec430f9a831772c23ca5a224cba36517a58a84bb32c32bb59a9fa67c47f6", size = 68658, upload-time = "2026-08-15T08:20:43.306Z" }, +] + +[[package]] +name = "click" +version = "8.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/76/d4/81420972a676e8ffea40450d8c8c92943e7218a78fe9b64359836cc9876b/click-8.4.2.tar.gz", hash = "sha256:9a6cea6e60b17ebe0a44c5cc636d94f09bd66142c1cd7d8b4cd731c4917a15f6", size = 338000, upload-time = "2026-06-24T17:45:15.148Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fb/e2/79c688af8b210d232694e31e59da9f6ec747bae31c3f5946e4e9b98860d5/click-8.4.2-py3-none-any.whl", hash = "sha256:e6f9f66136c816745b9d65817da91d61d957fb16e02e4dcd0552553c5a197b76", size = 119243, upload-time = "2026-06-24T17:45:13.73Z" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "distro" +version = "1.9.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/fc/f8/98eea607f65de6527f8a2e8885fc8015d3e6f5775df186e443e0964a11c3/distro-1.9.0.tar.gz", hash = "sha256:2fa77c6fd8940f116ee1d6b94a2f90b13b5ea8d019b98bc8bafdcabcdd9bdbed", size = 60722, upload-time = "2023-12-24T09:54:32.31Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/12/b3/231ffd4ab1fc9d679809f356cebee130ac7daa00d6d6f3206dd4fd137e9e/distro-1.9.0-py3-none-any.whl", hash = "sha256:7bffd925d65168f85027d8da9af6bddab658135b840670a223589bc0c8ef02b2", size = 20277, upload-time = "2023-12-24T09:54:30.421Z" }, +] + +[[package]] +name = "docstring-parser" +version = "0.18.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e0/4d/f332313098c1de1b2d2ff91cf2674415cc7cddab2ca1b01ae29774bd5fdf/docstring_parser-0.18.0.tar.gz", hash = "sha256:292510982205c12b1248696f44959db3cdd1740237a968ea1e2e7a900eeb2015", size = 29341, upload-time = "2026-04-14T04:09:19.867Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a7/5f/ed01f9a3cdffbd5a008556fc7b2a08ddb1cc6ace7effa7340604b1d16699/docstring_parser-0.18.0-py3-none-any.whl", hash = "sha256:b3fcbed555c47d8479be0796ef7e19c2670d428d72e96da63f3a40122860374b", size = 22484, upload-time = "2026-04-14T04:09:18.638Z" }, +] + +[[package]] +name = "eval-type-backport" +version = "0.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1c/15/273a4baf8248d6d76220723c3caf039d283774b31a7c46ba686120145b76/eval_type_backport-0.4.0.tar.gz", hash = "sha256:8397d25e6524c2e67b9576bb0636be27dea2192017711220c534ec2de921e9b0", size = 10260, upload-time = "2026-06-02T13:22:06.059Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/50/a7/bb99bf5e6f78736ddb53480f2c3ff3702ffe2196a7c5e1661c03081d398e/eval_type_backport-0.4.0-py3-none-any.whl", hash = "sha256:ad5e2a8db71b6696a56eafb938b0f5a337d3217f256b8e158b469422b4772b20", size = 6432, upload-time = "2026-06-02T13:22:04.827Z" }, +] + +[[package]] +name = "fastapi" +version = "0.141.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-doc" }, + { name = "pydantic" }, + { name = "starlette" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/8a/02/91e3416a8fdd715abb903a952a6bec7cdd8d14eed55d415fc8595524c319/fastapi-0.141.1.tar.gz", hash = "sha256:e8822fc40db1e1858054d7a949a888695bc9bdce70139178e33bd2871a453ca1", size = 425799, upload-time = "2026-07-29T17:18:05.568Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/03/10388a42375ee7e4ac9b94eb2c5c569c8b5795e377e701c9ac3ad63de890/fastapi-0.141.1-py3-none-any.whl", hash = "sha256:bfb91aa2d334c61cb35ba9a116fc123b3d3df31640b801cf57a7a78ec3f603b3", size = 131954, upload-time = "2026-07-29T17:18:04.364Z" }, +] + +[[package]] +name = "flatbuffers" +version = "25.12.19" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e8/2d/d2a548598be01649e2d46231d151a6c56d10b964d94043a335ae56ea2d92/flatbuffers-25.12.19-py2.py3-none-any.whl", hash = "sha256:7634f50c427838bb021c2d66a3d1168e9d199b0607e6329399f04846d42e20b4", size = 26661, upload-time = "2025-12-19T23:16:13.622Z" }, +] + +[[package]] +name = "frozenlist" +version = "1.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2d/f5/c831fac6cc817d26fd54c7eaccd04ef7e0288806943f7cc5bbf69f3ac1f0/frozenlist-1.8.0.tar.gz", hash = "sha256:3ede829ed8d842f6cd48fc7081d7a41001a56f1f38603f9d49bf3020d59a31ad", size = 45875, upload-time = "2025-10-06T05:38:17.865Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2d/40/0832c31a37d60f60ed79e9dfb5a92e1e2af4f40a16a29abcc7992af9edff/frozenlist-1.8.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:8d92f1a84bb12d9e56f818b3a746f3efba93c1b63c8387a73dde655e1e42282a", size = 85717, upload-time = "2025-10-06T05:36:27.341Z" }, + { url = "https://files.pythonhosted.org/packages/30/ba/b0b3de23f40bc55a7057bd38434e25c34fa48e17f20ee273bbde5e0650f3/frozenlist-1.8.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:96153e77a591c8adc2ee805756c61f59fef4cf4073a9275ee86fe8cba41241f7", size = 49651, upload-time = "2025-10-06T05:36:28.855Z" }, + { url = "https://files.pythonhosted.org/packages/0c/ab/6e5080ee374f875296c4243c381bbdef97a9ac39c6e3ce1d5f7d42cb78d6/frozenlist-1.8.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f21f00a91358803399890ab167098c131ec2ddd5f8f5fd5fe9c9f2c6fcd91e40", size = 49417, upload-time = "2025-10-06T05:36:29.877Z" }, + { url = "https://files.pythonhosted.org/packages/d5/4e/e4691508f9477ce67da2015d8c00acd751e6287739123113a9fca6f1604e/frozenlist-1.8.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:fb30f9626572a76dfe4293c7194a09fb1fe93ba94c7d4f720dfae3b646b45027", size = 234391, upload-time = "2025-10-06T05:36:31.301Z" }, + { url = "https://files.pythonhosted.org/packages/40/76/c202df58e3acdf12969a7895fd6f3bc016c642e6726aa63bd3025e0fc71c/frozenlist-1.8.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:eaa352d7047a31d87dafcacbabe89df0aa506abb5b1b85a2fb91bc3faa02d822", size = 233048, upload-time = "2025-10-06T05:36:32.531Z" }, + { url = "https://files.pythonhosted.org/packages/f9/c0/8746afb90f17b73ca5979c7a3958116e105ff796e718575175319b5bb4ce/frozenlist-1.8.0-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:03ae967b4e297f58f8c774c7eabcce57fe3c2434817d4385c50661845a058121", size = 226549, upload-time = "2025-10-06T05:36:33.706Z" }, + { url = "https://files.pythonhosted.org/packages/7e/eb/4c7eefc718ff72f9b6c4893291abaae5fbc0c82226a32dcd8ef4f7a5dbef/frozenlist-1.8.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f6292f1de555ffcc675941d65fffffb0a5bcd992905015f85d0592201793e0e5", size = 239833, upload-time = "2025-10-06T05:36:34.947Z" }, + { url = "https://files.pythonhosted.org/packages/c2/4e/e5c02187cf704224f8b21bee886f3d713ca379535f16893233b9d672ea71/frozenlist-1.8.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:29548f9b5b5e3460ce7378144c3010363d8035cea44bc0bf02d57f5a685e084e", size = 245363, upload-time = "2025-10-06T05:36:36.534Z" }, + { url = "https://files.pythonhosted.org/packages/1f/96/cb85ec608464472e82ad37a17f844889c36100eed57bea094518bf270692/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ec3cc8c5d4084591b4237c0a272cc4f50a5b03396a47d9caaf76f5d7b38a4f11", size = 229314, upload-time = "2025-10-06T05:36:38.582Z" }, + { url = "https://files.pythonhosted.org/packages/5d/6f/4ae69c550e4cee66b57887daeebe006fe985917c01d0fff9caab9883f6d0/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:517279f58009d0b1f2e7c1b130b377a349405da3f7621ed6bfae50b10adf20c1", size = 243365, upload-time = "2025-10-06T05:36:40.152Z" }, + { url = "https://files.pythonhosted.org/packages/7a/58/afd56de246cf11780a40a2c28dc7cbabbf06337cc8ddb1c780a2d97e88d8/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:db1e72ede2d0d7ccb213f218df6a078a9c09a7de257c2fe8fcef16d5925230b1", size = 237763, upload-time = "2025-10-06T05:36:41.355Z" }, + { url = "https://files.pythonhosted.org/packages/cb/36/cdfaf6ed42e2644740d4a10452d8e97fa1c062e2a8006e4b09f1b5fd7d63/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:b4dec9482a65c54a5044486847b8a66bf10c9cb4926d42927ec4e8fd5db7fed8", size = 240110, upload-time = "2025-10-06T05:36:42.716Z" }, + { url = "https://files.pythonhosted.org/packages/03/a8/9ea226fbefad669f11b52e864c55f0bd57d3c8d7eb07e9f2e9a0b39502e1/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:21900c48ae04d13d416f0e1e0c4d81f7931f73a9dfa0b7a8746fb2fe7dd970ed", size = 233717, upload-time = "2025-10-06T05:36:44.251Z" }, + { url = "https://files.pythonhosted.org/packages/1e/0b/1b5531611e83ba7d13ccc9988967ea1b51186af64c42b7a7af465dcc9568/frozenlist-1.8.0-cp313-cp313-win32.whl", hash = "sha256:8b7b94a067d1c504ee0b16def57ad5738701e4ba10cec90529f13fa03c833496", size = 39628, upload-time = "2025-10-06T05:36:45.423Z" }, + { url = "https://files.pythonhosted.org/packages/d8/cf/174c91dbc9cc49bc7b7aab74d8b734e974d1faa8f191c74af9b7e80848e6/frozenlist-1.8.0-cp313-cp313-win_amd64.whl", hash = "sha256:878be833caa6a3821caf85eb39c5ba92d28e85df26d57afb06b35b2efd937231", size = 43882, upload-time = "2025-10-06T05:36:46.796Z" }, + { url = "https://files.pythonhosted.org/packages/c1/17/502cd212cbfa96eb1388614fe39a3fc9ab87dbbe042b66f97acb57474834/frozenlist-1.8.0-cp313-cp313-win_arm64.whl", hash = "sha256:44389d135b3ff43ba8cc89ff7f51f5a0bb6b63d829c8300f79a2fe4fe61bcc62", size = 39676, upload-time = "2025-10-06T05:36:47.8Z" }, + { url = "https://files.pythonhosted.org/packages/d2/5c/3bbfaa920dfab09e76946a5d2833a7cbdf7b9b4a91c714666ac4855b88b4/frozenlist-1.8.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:e25ac20a2ef37e91c1b39938b591457666a0fa835c7783c3a8f33ea42870db94", size = 89235, upload-time = "2025-10-06T05:36:48.78Z" }, + { url = "https://files.pythonhosted.org/packages/d2/d6/f03961ef72166cec1687e84e8925838442b615bd0b8854b54923ce5b7b8a/frozenlist-1.8.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:07cdca25a91a4386d2e76ad992916a85038a9b97561bf7a3fd12d5d9ce31870c", size = 50742, upload-time = "2025-10-06T05:36:49.837Z" }, + { url = "https://files.pythonhosted.org/packages/1e/bb/a6d12b7ba4c3337667d0e421f7181c82dda448ce4e7ad7ecd249a16fa806/frozenlist-1.8.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:4e0c11f2cc6717e0a741f84a527c52616140741cd812a50422f83dc31749fb52", size = 51725, upload-time = "2025-10-06T05:36:50.851Z" }, + { url = "https://files.pythonhosted.org/packages/bc/71/d1fed0ffe2c2ccd70b43714c6cab0f4188f09f8a67a7914a6b46ee30f274/frozenlist-1.8.0-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b3210649ee28062ea6099cfda39e147fa1bc039583c8ee4481cb7811e2448c51", size = 284533, upload-time = "2025-10-06T05:36:51.898Z" }, + { url = "https://files.pythonhosted.org/packages/c9/1f/fb1685a7b009d89f9bf78a42d94461bc06581f6e718c39344754a5d9bada/frozenlist-1.8.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:581ef5194c48035a7de2aefc72ac6539823bb71508189e5de01d60c9dcd5fa65", size = 292506, upload-time = "2025-10-06T05:36:53.101Z" }, + { url = "https://files.pythonhosted.org/packages/e6/3b/b991fe1612703f7e0d05c0cf734c1b77aaf7c7d321df4572e8d36e7048c8/frozenlist-1.8.0-cp313-cp313t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3ef2d026f16a2b1866e1d86fc4e1291e1ed8a387b2c333809419a2f8b3a77b82", size = 274161, upload-time = "2025-10-06T05:36:54.309Z" }, + { url = "https://files.pythonhosted.org/packages/ca/ec/c5c618767bcdf66e88945ec0157d7f6c4a1322f1473392319b7a2501ded7/frozenlist-1.8.0-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5500ef82073f599ac84d888e3a8c1f77ac831183244bfd7f11eaa0289fb30714", size = 294676, upload-time = "2025-10-06T05:36:55.566Z" }, + { url = "https://files.pythonhosted.org/packages/7c/ce/3934758637d8f8a88d11f0585d6495ef54b2044ed6ec84492a91fa3b27aa/frozenlist-1.8.0-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:50066c3997d0091c411a66e710f4e11752251e6d2d73d70d8d5d4c76442a199d", size = 300638, upload-time = "2025-10-06T05:36:56.758Z" }, + { url = "https://files.pythonhosted.org/packages/fc/4f/a7e4d0d467298f42de4b41cbc7ddaf19d3cfeabaf9ff97c20c6c7ee409f9/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:5c1c8e78426e59b3f8005e9b19f6ff46e5845895adbde20ece9218319eca6506", size = 283067, upload-time = "2025-10-06T05:36:57.965Z" }, + { url = "https://files.pythonhosted.org/packages/dc/48/c7b163063d55a83772b268e6d1affb960771b0e203b632cfe09522d67ea5/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:eefdba20de0d938cec6a89bd4d70f346a03108a19b9df4248d3cf0d88f1b0f51", size = 292101, upload-time = "2025-10-06T05:36:59.237Z" }, + { url = "https://files.pythonhosted.org/packages/9f/d0/2366d3c4ecdc2fd391e0afa6e11500bfba0ea772764d631bbf82f0136c9d/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:cf253e0e1c3ceb4aaff6df637ce033ff6535fb8c70a764a8f46aafd3d6ab798e", size = 289901, upload-time = "2025-10-06T05:37:00.811Z" }, + { url = "https://files.pythonhosted.org/packages/b8/94/daff920e82c1b70e3618a2ac39fbc01ae3e2ff6124e80739ce5d71c9b920/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:032efa2674356903cd0261c4317a561a6850f3ac864a63fc1583147fb05a79b0", size = 289395, upload-time = "2025-10-06T05:37:02.115Z" }, + { url = "https://files.pythonhosted.org/packages/e3/20/bba307ab4235a09fdcd3cc5508dbabd17c4634a1af4b96e0f69bfe551ebd/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:6da155091429aeba16851ecb10a9104a108bcd32f6c1642867eadaee401c1c41", size = 283659, upload-time = "2025-10-06T05:37:03.711Z" }, + { url = "https://files.pythonhosted.org/packages/fd/00/04ca1c3a7a124b6de4f8a9a17cc2fcad138b4608e7a3fc5877804b8715d7/frozenlist-1.8.0-cp313-cp313t-win32.whl", hash = "sha256:0f96534f8bfebc1a394209427d0f8a63d343c9779cda6fc25e8e121b5fd8555b", size = 43492, upload-time = "2025-10-06T05:37:04.915Z" }, + { url = "https://files.pythonhosted.org/packages/59/5e/c69f733a86a94ab10f68e496dc6b7e8bc078ebb415281d5698313e3af3a1/frozenlist-1.8.0-cp313-cp313t-win_amd64.whl", hash = "sha256:5d63a068f978fc69421fb0e6eb91a9603187527c86b7cd3f534a5b77a592b888", size = 48034, upload-time = "2025-10-06T05:37:06.343Z" }, + { url = "https://files.pythonhosted.org/packages/16/6c/be9d79775d8abe79b05fa6d23da99ad6e7763a1d080fbae7290b286093fd/frozenlist-1.8.0-cp313-cp313t-win_arm64.whl", hash = "sha256:bf0a7e10b077bf5fb9380ad3ae8ce20ef919a6ad93b4552896419ac7e1d8e042", size = 41749, upload-time = "2025-10-06T05:37:07.431Z" }, + { url = "https://files.pythonhosted.org/packages/9a/9a/e35b4a917281c0b8419d4207f4334c8e8c5dbf4f3f5f9ada73958d937dcc/frozenlist-1.8.0-py3-none-any.whl", hash = "sha256:0c18a16eab41e82c295618a77502e17b195883241c563b00f0aa5106fc4eaa0d", size = 13409, upload-time = "2025-10-06T05:38:16.721Z" }, +] + +[[package]] +name = "googleapis-common-protos" +version = "1.75.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "protobuf" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/72/73/74bcab964c9a7a61f2bb71e8179b0f13e6fa98f7ce00fd168aab291e4a2e/googleapis_common_protos-1.75.1.tar.gz", hash = "sha256:d3042c6c5a2d4e67113104d6b6818b59b6bd92a197f2a91508e801fe815cf071", size = 150967, upload-time = "2026-08-06T06:24:51.972Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9a/51/186c02b8549b69ccda44429cf6ff5081e4b61a602ddfe6a8020d1be31d1b/googleapis_common_protos-1.75.1-py3-none-any.whl", hash = "sha256:28a1934bcd33b9c9da66ac301a0a4227e3367f095a17d0375cb98f0a09d93b79", size = 300626, upload-time = "2026-08-06T06:23:46.696Z" }, +] + +[[package]] +name = "grpcio" +version = "1.83.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/0c/98/304898ac4e04e2d5e4e4c2eadc178b1f2a16d5f4bc2f91306c87d64680b9/grpcio-1.83.0.tar.gz", hash = "sha256:7674587248fbbb2ac6e4eecf83a8a0f3d91a928f941de571acfd3a2f007fbc24", size = 13428824, upload-time = "2026-07-23T15:20:37.759Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/56/eb/135daaa713f32d33b8f99b4153b3f8dc3b2a124996ac15581bf9ebdad3c3/grpcio-1.83.0-cp313-cp313-linux_armv7l.whl", hash = "sha256:6662f3b1e07cc7493d437351860dc867bddc6a93c83ecf33bbfdaf0c217ab2d0", size = 6304480, upload-time = "2026-07-23T15:19:53.962Z" }, + { url = "https://files.pythonhosted.org/packages/8f/a1/121806ce69f23138dabe06aa595b0e5f1ae051a37e4c1954eed7d692c800/grpcio-1.83.0-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:74fe6f9e8a35c7dbf32255ee154d15e3e5338a81ed39173d079d594d2e544cd1", size = 12154419, upload-time = "2026-07-23T15:19:56.3Z" }, + { url = "https://files.pythonhosted.org/packages/b0/e8/d0389e09cd6b4c4d3089b92967ae4e3ffd64795bd349bf2f85cd6656d3da/grpcio-1.83.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:10b3fa0475eb572c9a81a6fe37fa16a9c500c0c91cfc148cac15692b7e3c2867", size = 6873200, upload-time = "2026-07-23T15:19:58.701Z" }, + { url = "https://files.pythonhosted.org/packages/f8/51/f464c1d211fa50d5adbabe1b2e519948d99c13757052bfc9ea7afa28e284/grpcio-1.83.0-cp313-cp313-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:5f20a988480b0f28207f057f7f7ae1313393c3cef0adcfeae8248f9947eaf881", size = 7618811, upload-time = "2026-07-23T15:20:00.733Z" }, + { url = "https://files.pythonhosted.org/packages/e8/c0/539fe0832f2dd6500a28f5263071623fb34e8d4867aec632ccf81bd21156/grpcio-1.83.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7bd82671b39065ba18cd536e9cd45b27ff649053f81ddd2c6a966d595067080f", size = 7042310, upload-time = "2026-07-23T15:20:02.675Z" }, + { url = "https://files.pythonhosted.org/packages/8c/ca/ccf617d37ffa72567fa8e005ec7090c99da922799be2fb9847c8b21ca18c/grpcio-1.83.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:bc60215b5cb9fc8ca72942c498b551ac2305bd08f6ef8d4e3f0d21b64fbecd61", size = 7575412, upload-time = "2026-07-23T15:20:04.712Z" }, + { url = "https://files.pythonhosted.org/packages/eb/b9/fd8d5245f823a8e0fd35d90e20ea3aa4acd47f8d5318fa8df307df52dec6/grpcio-1.83.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:f1c3e5689d4b90987b1d72022bcfe866a9a3dc66197484cf856d96b6150e7f45", size = 8604248, upload-time = "2026-07-23T15:20:06.77Z" }, + { url = "https://files.pythonhosted.org/packages/14/1e/f37632fc11db72dfa4bba86c3a43e54358e53030df111ecae5e91a733ad6/grpcio-1.83.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a21cb4eeeba124443f399be2e8b624943cde864dcbe588cb42e5c483a52a906c", size = 7977458, upload-time = "2026-07-23T15:20:09.109Z" }, + { url = "https://files.pythonhosted.org/packages/93/b6/d70b69ae5c0cfc341b9ba474980e4ed99cbf05c0e4a14e9eee8cb73db0a5/grpcio-1.83.0-cp313-cp313-win32.whl", hash = "sha256:8fe04f1050a59f875601eb55d42b4f66946fe89817f967e34db1462ccd07dadf", size = 4393993, upload-time = "2026-07-23T15:20:11.017Z" }, + { url = "https://files.pythonhosted.org/packages/0f/13/45d4cccb555cf4c476226979bf3d2fd0b0254216f7564c3a053e35117efc/grpcio-1.83.0-cp313-cp313-win_amd64.whl", hash = "sha256:6e01ecd9d8ef280abe1365138a4dc318f9a5287f4cb1b41d07816f796653f735", size = 5159650, upload-time = "2026-07-23T15:20:12.979Z" }, +] + +[[package]] +name = "h11" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, +] + +[[package]] +name = "httpcore" +version = "1.0.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, +] + +[[package]] +name = "httptools" +version = "0.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/43/e5/d471fcb0e14523fe1c3f4ba58ca52480e7bd70ad7109a3846bc75892f7fb/httptools-0.8.0.tar.gz", hash = "sha256:6b2a32f18d97e16e90827d7a819ffa8dbd8cc245fc4e1fa9d1095b54ef4bd999", size = 271342, upload-time = "2026-05-25T22:17:48.841Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5e/e5/8cfcabc5546e8022f168be28bcdaa128a240a0befdd03b59d558b4f18bd6/httptools-0.8.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:614ceea8ea606848bece2338ac03b3ce5324bcb4be8dc7d377ed708012fa4db8", size = 205148, upload-time = "2026-05-25T22:17:16.333Z" }, + { url = "https://files.pythonhosted.org/packages/2a/0e/0fb14848c19a686c8062ff9067c1a48793e3224b47bc5b201535b6036fce/httptools-0.8.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2d689918c15a013c65ef52d9fd495d766893ab831a2c8d89f2ac5940a5df847c", size = 111368, upload-time = "2026-05-25T22:17:17.586Z" }, + { url = "https://files.pythonhosted.org/packages/2e/1b/46f1cecf06b9bbde8e4b8c88034ac7908989e5ff7a3a388ef38392949c1f/httptools-0.8.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:eb3028cca2fc0a6d720e52ef61d8ebb62fcbfeb1de56874546d858d3f25a26b7", size = 486447, upload-time = "2026-05-25T22:17:18.564Z" }, + { url = "https://files.pythonhosted.org/packages/77/00/258bfc0837221f81d9725c45f9b948a6a6b2994a147a4fb66e85100c668f/httptools-0.8.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:88bdd940f2b5d487b4d032c6afa5489a7dc4694410d43de3c38c4fb3af0dc45d", size = 482448, upload-time = "2026-05-25T22:17:19.912Z" }, + { url = "https://files.pythonhosted.org/packages/04/ab/d1cef3b5523f4d272a70f42a776c3169a2dddfe3a54de4b2ce4a36341528/httptools-0.8.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:6a43c9dd399758ccc0531acb0a3c4a6c299ee893ee9400e9c893b7bdcfae0681", size = 464460, upload-time = "2026-05-25T22:17:20.882Z" }, + { url = "https://files.pythonhosted.org/packages/ce/48/5d1d072442277bb2b3434e0e60690b8e8c23840ef7de8b6ea54040a536d3/httptools-0.8.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:0770728beb05094c809b98e814edff5fef69d26ad7d21185f2f6d5884a0ba683", size = 471312, upload-time = "2026-05-25T22:17:22.085Z" }, + { url = "https://files.pythonhosted.org/packages/0d/66/b96623b27e51a68199ef4efdda0613cced9233fe3062ac74e50749c5ad37/httptools-0.8.0-cp313-cp313-win_amd64.whl", hash = "sha256:7685df791fad561384bfb139e77fde27a1ffd93134e016f95a0db424ffbf77b1", size = 90117, upload-time = "2026-05-25T22:17:23.074Z" }, +] + +[[package]] +name = "httpx" +version = "0.28.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "certifi" }, + { name = "httpcore" }, + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, +] + +[[package]] +name = "idna" +version = "3.19" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5f/f7/abb373e5757eaec4b922b92f97ec8d6d7e057cf06778247604fbc4e7c3f3/idna-3.19.tar.gz", hash = "sha256:5e0811a4383b21dc5838069f801c4fb62113b7447663d2530d2bd6e77b49bf15", size = 215237, upload-time = "2026-08-18T05:14:24.27Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/57/b0/0e52c878c53f245edd3a11020f20979b3f490f245af532c7cae3027754b5/idna-3.19-py3-none-any.whl", hash = "sha256:815e7be7a7806d54abb586dc943addc79e8b2ee16915059658cbeff4b1b43bf4", size = 68550, upload-time = "2026-08-18T05:14:22.343Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + +[[package]] +name = "jiter" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1d/1f/10936e16d8860c70698a1aa939a46aa0224813b782bce4e000e637da0b2d/jiter-0.16.0.tar.gz", hash = "sha256:7b24c3492c5f4f84a37946ad9cf504910cf6a782d6a4e0689b6673c5894b4a1c", size = 176431, upload-time = "2026-06-29T13:05:13.657Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/91/c0/555fc60473d30d66894ba825e63615e3be7524fac23858356afa7a38906c/jiter-0.16.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:41977aa5654023948c2dae2a81cbf9c43343954bef1cd59a154dd15a4d84c195", size = 306203, upload-time = "2026-06-29T13:03:36.243Z" }, + { url = "https://files.pythonhosted.org/packages/d0/2b/c3eaf16f5d7c9bad66ea32f40a95bd169b29a91217fcc7f081375157e99c/jiter-0.16.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:d28bb3c26762358dadf3e5bf0bccd29ae987d65e6988d2e6f49829c76b003c09", size = 306489, upload-time = "2026-06-29T13:03:37.846Z" }, + { url = "https://files.pythonhosted.org/packages/96/3f/02fdfc6705cad96127d883af5c34e4867f554f29ec7705ec1a46156400a9/jiter-0.16.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0542a7189c26920778658fc8fcf2af8bae05bae9924577f71804acef37996536", size = 335453, upload-time = "2026-06-29T13:03:39.221Z" }, + { url = "https://files.pythonhosted.org/packages/b2/a6/e4bda5920d4b0d7c5dfb7174ce4a6b2e4d3e11c9162c452ef0eab4cdbdbd/jiter-0.16.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:8fb8de1e23a0cb2a7f53c335049c7b72b6db41aa6227cdcc0972a1de5cb39450", size = 361625, upload-time = "2026-06-29T13:03:40.597Z" }, + { url = "https://files.pythonhosted.org/packages/b7/97/4e6b59b2c6e55cbb3e183595f81ad65dcfb21c915fee5e19e335df21bc55/jiter-0.16.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b72d0b2990ca754a9102779ac98d8597b7cb31678958562214a007f909eab78e", size = 456958, upload-time = "2026-06-29T13:03:42.074Z" }, + { url = "https://files.pythonhosted.org/packages/15/e0/97e9557686d2f94f4b93786eccb7eed28e9228ad132ea8237f44727314a7/jiter-0.16.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d5f91b1c27fc22a57993d5a5cb8a627cb8ed4b10502716fac1ffbfe1d19d84e8", size = 372017, upload-time = "2026-06-29T13:03:43.658Z" }, + { url = "https://files.pythonhosted.org/packages/0f/94/db768b6938e0df35c86beeba3dfbbb025c9ee5c19e1aa271f2396e50864d/jiter-0.16.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c682bea068a90b764577bdb78a60a4c1d1606daf9cd4c893832a37c7cc9d9026", size = 343320, upload-time = "2026-06-29T13:03:45.226Z" }, + { url = "https://files.pythonhosted.org/packages/c1/d6/5a59d938244a30735fe62d9433fd325f9021ea29d89780ea4596ea93bc89/jiter-0.16.0-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:8d031aabecc4f1b6276adfb42e3aabb77c89d468bf616600e8d3a11328929053", size = 350520, upload-time = "2026-06-29T13:03:46.671Z" }, + { url = "https://files.pythonhosted.org/packages/67/f8/c4a857f49c9af125f6bbcac7e3eee7f7978ed89682833062e2dbf62576b1/jiter-0.16.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:eab2cd170150e70153de16896a1774e3a1dca80154c56b54d7a812c479a7165e", size = 387550, upload-time = "2026-06-29T13:03:48.361Z" }, + { url = "https://files.pythonhosted.org/packages/8b/d6/5fbc2f7d6b67b754caa61a993a2e626e815dec47ffc2f9e35f01adfebec7/jiter-0.16.0-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:6edb63a46e65a82c26800a868e49b2cac30dd5a4218b88d74bc2c848c8ad60bb", size = 515424, upload-time = "2026-06-29T13:03:49.881Z" }, + { url = "https://files.pythonhosted.org/packages/ed/54/284f0164b64a5fed915fea6ba7e9ba9b3d8d37c67d59cf2e3bb99d45cdfe/jiter-0.16.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:659039cc50b5addcc35fcc87ae2c1833b7c0a8e5326ef631a75e4478447bcf84", size = 546981, upload-time = "2026-06-29T13:03:51.363Z" }, + { url = "https://files.pythonhosted.org/packages/13/c5/2a467585a576594384e1d2c43e1224deaafc085f24e243529cf98beef8e1/jiter-0.16.0-cp313-cp313-win32.whl", hash = "sha256:c9c53be232c2e206ef9cdbad81a48bfa74c3d3f08bcf8124630a8a748aad993e", size = 202853, upload-time = "2026-06-29T13:03:53.015Z" }, + { url = "https://files.pythonhosted.org/packages/88/6a/de61d04b9eec69c71719968d2f716532a3bc121170c44a39e14979c6be81/jiter-0.16.0-cp313-cp313-win_amd64.whl", hash = "sha256:baad945ed47f163ad833314f8e3288c396118934f94e7bbb9e243ce4b341a4fd", size = 196160, upload-time = "2026-06-29T13:03:54.447Z" }, + { url = "https://files.pythonhosted.org/packages/19/4b/b390ed59bafb3f31d008d1218578f10327714484b334439947f7e5b11e7f/jiter-0.16.0-cp313-cp313-win_arm64.whl", hash = "sha256:3c1fd2dbe1b0af19e987f03fe66c5f5bd105a2229c1aff4ab14890b24f41d21a", size = 189862, upload-time = "2026-06-29T13:03:55.754Z" }, +] + +[[package]] +name = "json-repair" +version = "0.60.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5e/a6/d69888cb4ffde30e80db1e6c32caaadd2f984a80067d5ea72c2cb3f61c3f/json_repair-0.60.1.tar.gz", hash = "sha256:841661cdd2df507c9a4e189097f38ca6bc372e06d4b4e36d72e590f68176c290", size = 49451, upload-time = "2026-06-03T17:28:44.451Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/32/1f/2a2b5eea8ef5762a86ad3f8fddddaaba2c0d76dd44e644b9158900868bec/json_repair-0.60.1-py3-none-any.whl", hash = "sha256:ba6ff974f2a8bef2f7768144a7f03f870a816443f03da27a49cdd0ec31a78049", size = 48045, upload-time = "2026-06-03T17:28:43.038Z" }, +] + +[[package]] +name = "jsonschema" +version = "4.26.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "jsonschema-specifications" }, + { name = "referencing" }, + { name = "rpds-py" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b3/fc/e067678238fa451312d4c62bf6e6cf5ec56375422aee02f9cb5f909b3047/jsonschema-4.26.0.tar.gz", hash = "sha256:0c26707e2efad8aa1bfc5b7ce170f3fccc2e4918ff85989ba9ffa9facb2be326", size = 366583, upload-time = "2026-01-07T13:41:07.246Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/69/90/f63fb5873511e014207a475e2bb4e8b2e570d655b00ac19a9a0ca0a385ee/jsonschema-4.26.0-py3-none-any.whl", hash = "sha256:d489f15263b8d200f8387e64b4c3a75f06629559fb73deb8fdfb525f2dab50ce", size = 90630, upload-time = "2026-01-07T13:41:05.306Z" }, +] + +[[package]] +name = "jsonschema-specifications" +version = "2025.9.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "referencing" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/19/74/a633ee74eb36c44aa6d1095e7cc5569bebf04342ee146178e2d36600708b/jsonschema_specifications-2025.9.1.tar.gz", hash = "sha256:b540987f239e745613c7a9176f3edb72b832a4ac465cf02712288397832b5e8d", size = 32855, upload-time = "2025-09-08T01:34:59.186Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/41/45/1a4ed80516f02155c51f51e8cedb3c1902296743db0bbc66608a0db2814f/jsonschema_specifications-2025.9.1-py3-none-any.whl", hash = "sha256:98802fee3a11ee76ecaca44429fda8a41bff98b00a0f2838151b113f210cc6fe", size = 18437, upload-time = "2025-09-08T01:34:57.871Z" }, +] + +[[package]] +name = "livekit" +version = "1.1.14" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "aiofiles" }, + { name = "numpy" }, + { name = "protobuf" }, + { name = "types-protobuf" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ab/5d/bfaf1cc73f960b40294f604d334f05e628b0a07de3c47e475d760996a8d0/livekit-1.1.14.tar.gz", hash = "sha256:47428e10ecf20d7db4ee9fde4009bf96578c003b1ae6e1c5e7e4837a55902393", size = 375000, upload-time = "2026-07-31T14:05:14.425Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7a/ff/a2659522b3cf860b9b4453e1ec12d4b4c7e9cfd2b672f2cf925016d73492/livekit-1.1.14-py3-none-macosx_10_15_x86_64.whl", hash = "sha256:5f671b1752c93b878cb241b84fd3f72a31f857c3927755d672cfb7656a84778c", size = 10196322, upload-time = "2026-07-31T14:05:04.167Z" }, + { url = "https://files.pythonhosted.org/packages/82/a2/89f32d369cc78cb1a50b2a9e635c653f88d86ea4338ccdfa7b2d4ca0aecd/livekit-1.1.14-py3-none-macosx_11_0_arm64.whl", hash = "sha256:efa16b9036b0b592e5399fdb858c1f04ec8a32c385184c705f030952f72174e8", size = 9019745, upload-time = "2026-07-31T14:05:06.49Z" }, + { url = "https://files.pythonhosted.org/packages/8e/5b/dda7d660fa5d5b6e228dcfc6be3664a2442d1601481686052af2da642e5e/livekit-1.1.14-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:299146efefad5f67751cd15b8225bae759be0d7ad2f0b4ae1a22c15860d93cf9", size = 10042499, upload-time = "2026-07-31T14:05:08.563Z" }, + { url = "https://files.pythonhosted.org/packages/21/e3/d9255eeaf205f090d63d762e5254097b62af394bfaa90106f71f1fb6740e/livekit-1.1.14-py3-none-manylinux_2_28_x86_64.whl", hash = "sha256:80962c4a22ddbf0e0ebd3563fc090fce42df66b39b90de68b161b7db01970f68", size = 11445915, upload-time = "2026-07-31T14:05:10.628Z" }, + { url = "https://files.pythonhosted.org/packages/f7/0a/514fb230e7c7f13ae7e53b9e39a6dd9ea1aa9ff5be9e588d55301d159a1e/livekit-1.1.14-py3-none-win_amd64.whl", hash = "sha256:b8f8d38f131956297923e520bc4375bc9ebfa255cab7f125cb7755bfca71df24", size = 10766643, upload-time = "2026-07-31T14:05:12.716Z" }, +] + +[[package]] +name = "livekit-agents" +version = "1.7.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "aiofiles" }, + { name = "aiohttp" }, + { name = "av" }, + { name = "certifi" }, + { name = "click" }, + { name = "colorama" }, + { name = "docstring-parser" }, + { name = "eval-type-backport" }, + { name = "json-repair" }, + { name = "livekit" }, + { name = "livekit-api" }, + { name = "livekit-blingfire" }, + { name = "livekit-local-inference" }, + { name = "livekit-protocol" }, + { name = "nest-asyncio" }, + { name = "numpy" }, + { name = "openai" }, + { name = "opentelemetry-api" }, + { name = "opentelemetry-exporter-otlp" }, + { name = "opentelemetry-sdk" }, + { name = "prometheus-client" }, + { name = "protobuf" }, + { name = "psutil" }, + { name = "pydantic" }, + { name = "pyjwt" }, + { name = "pyyaml" }, + { name = "sounddevice" }, + { name = "typer" }, + { name = "types-protobuf" }, + { name = "typing-extensions" }, + { name = "watchfiles" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3c/25/242c1e0fcaef5486be838b8535401baa8ffea6cbe1be5c9a8ab0f367e1f7/livekit_agents-1.7.0.tar.gz", hash = "sha256:3cc8ec39ed0c63f09d94cb170d95284f4a2fd63ebe608cb5f40c5b6e08a7d52a", size = 2659034, upload-time = "2026-08-20T18:16:20.231Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/20/f83b5b89046514b80502b52f2a6ce70a8ce14d32902c499c385d2b1595d6/livekit_agents-1.7.0-py3-none-any.whl", hash = "sha256:416d73e7c9ae85d118b4feb9b9c5432d26c134abb537139bc44e0f9faa79ac11", size = 2773768, upload-time = "2026-08-20T18:16:17.744Z" }, +] + +[package.optional-dependencies] +codecs = [ + { name = "numpy" }, +] +images = [ + { name = "pillow" }, +] +openai = [ + { name = "livekit-plugins-openai" }, +] +silero = [ + { name = "livekit-plugins-silero" }, +] + +[[package]] +name = "livekit-api" +version = "1.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "aiohttp" }, + { name = "livekit-protocol" }, + { name = "protobuf" }, + { name = "pyjwt" }, + { name = "types-protobuf" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f3/19/36ff6712ec638a4b7dad4d8f03795952e401dc31db0b04cddec7892650da/livekit_api-1.2.0.tar.gz", hash = "sha256:a89817b3bca9584873786ff07209839308217537a42f95ecb2609aafaa109ddc", size = 20778, upload-time = "2026-07-11T23:20:54.781Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bf/e7/8926f16d4bc1b2e0ae46d4a507321bb899396d263a757f1adaabcd3b3867/livekit_api-1.2.0-py3-none-any.whl", hash = "sha256:307f8e5cfb0358c3ca091814ab768af55896022151bcd7f951954ccefa036a24", size = 26499, upload-time = "2026-07-11T23:20:53.736Z" }, +] + +[[package]] +name = "livekit-blingfire" +version = "1.1.0" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/81/50/46e410b935154a6bcf2d9494ee8e298b1a9c91ae33beaa78346703cf7681/livekit_blingfire-1.1.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:f5f6a40e498940f5b2e53d9753f5f7fb7f909e12a93a158844c9e3e99a5486b8", size = 154623, upload-time = "2025-12-16T00:48:17.641Z" }, + { url = "https://files.pythonhosted.org/packages/de/b4/f51c25bf104e51703dc66558ff9831a9769a9effa397956268902784a3d0/livekit_blingfire-1.1.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:945a672a224c9a686925e9af94c2660bacdbe190ccf693d6f17cea9359426c15", size = 148846, upload-time = "2025-12-16T00:48:18.591Z" }, + { url = "https://files.pythonhosted.org/packages/e0/d2/ad95d195ed6dccb6527ed3c1e753f211c3e9509050af5cddf007608bb104/livekit_blingfire-1.1.0-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8f3aac3207cdd88c62323e0b07c33a69aac79c544122a2ddfbecc6c721ca760c", size = 167886, upload-time = "2025-12-16T00:48:19.858Z" }, + { url = "https://files.pythonhosted.org/packages/c5/67/fc4af1bbbed319d8edc319051bce720b51fa544f5d2ebb3201240779f135/livekit_blingfire-1.1.0-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:839feefa2910f99d794d3f3d696f95193ee8188cc6688a8d712bade2cede7951", size = 175858, upload-time = "2025-12-16T00:48:21.144Z" }, + { url = "https://files.pythonhosted.org/packages/76/6c/9e14763826476925767b511531318a83f95f3bf9e4dbc7dc611400af6e9e/livekit_blingfire-1.1.0-cp313-cp313-win_amd64.whl", hash = "sha256:1409d4c297260b60a37bfe6ba21e4fb59dd53cd929632c0a78a28d41fe424302", size = 131048, upload-time = "2025-12-16T00:48:22.17Z" }, +] + +[[package]] +name = "livekit-local-inference" +version = "0.2.7" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a2/ff/01233367f526c67df021d5ee5ad0e7d229553ad7e90d5ae02d5afbdc7abb/livekit_local_inference-0.2.7-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5b23b2fca99fbf05d349b8c1c1e499d9154997214db15b6784a999783f63169a", size = 34839008, upload-time = "2026-08-18T09:46:50.374Z" }, + { url = "https://files.pythonhosted.org/packages/cc/45/9c70db9dc4581d9f2eecc04386bba3c8e74b6f438d2d6acb11aeaf66f96e/livekit_local_inference-0.2.7-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:866036cf42fce282404ecdad90bb2b814bc78aab245bc7be740e3cff528a36e8", size = 35096385, upload-time = "2026-08-18T09:46:53.359Z" }, + { url = "https://files.pythonhosted.org/packages/c3/7d/0a981a4c7504fc4323a277d04705799fa48fbc036d368e47d5780c737341/livekit_local_inference-0.2.7-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f304cca187033b257cc8baf67f8799f5f467fa5f5984cdc3948591eb2027761b", size = 34835081, upload-time = "2026-08-18T09:46:55.883Z" }, + { url = "https://files.pythonhosted.org/packages/7a/cc/858e2792eba28aa3baae1939488319f3bbef38c911cc0640f020bb0fe708/livekit_local_inference-0.2.7-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:454c451a4df153f5a9c8c7ba20e842dd5c77993103fd1c03194856d351be87dd", size = 34876656, upload-time = "2026-08-18T09:46:58.567Z" }, + { url = "https://files.pythonhosted.org/packages/2a/07/b85d8f18fd46f335a559f6d39f8acf07663510d1add19d1f31814ef7daf0/livekit_local_inference-0.2.7-cp313-cp313-win_amd64.whl", hash = "sha256:c16e86495346d8c349910ac8530de1e3532a6d1bac95ece0bc416c88f2a5c20f", size = 34877068, upload-time = "2026-08-18T09:47:01.317Z" }, +] + +[[package]] +name = "livekit-plugins-executorch" +source = { editable = "packages/livekit-plugins-executorch" } +dependencies = [ + { name = "livekit-agents" }, +] + +[package.dev-dependencies] +dev = [ + { name = "pytest" }, + { name = "pytest-asyncio" }, + { name = "ruff" }, +] + +[package.metadata] +requires-dist = [{ name = "livekit-agents", specifier = ">=1.6.9,<2" }] + +[package.metadata.requires-dev] +dev = [ + { name = "pytest", specifier = ">=8.4,<9" }, + { name = "pytest-asyncio", specifier = ">=0.25,<2" }, + { name = "ruff", specifier = ">=0.12,<1" }, +] + +[[package]] +name = "livekit-plugins-openai" +version = "1.7.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "livekit-agents", extra = ["codecs", "images"] }, + { name = "openai", extra = ["realtime"] }, +] +sdist = { url = "https://files.pythonhosted.org/packages/bf/d2/6a4f7f8b0cea084bbb750572b2722d8e9fccd651fe14b58de77ad1871371/livekit_plugins_openai-1.7.0.tar.gz", hash = "sha256:c388cd3e23c0ea1527d280c2c71b3d277de059395959eac4a84aecc2f5a56307", size = 54202, upload-time = "2026-08-20T18:17:48.833Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ad/99/7985693a4b0da0f431c51b10670f4a34fc0db6a4080f581ccf8657bb2899/livekit_plugins_openai-1.7.0-py3-none-any.whl", hash = "sha256:bde4c38eaacde216b3f9228d23237c1f74fe6235023c6497142c5fef4637e085", size = 59991, upload-time = "2026-08-20T18:17:47.627Z" }, +] + +[[package]] +name = "livekit-plugins-silero" +version = "1.7.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "livekit-agents" }, + { name = "numpy" }, + { name = "onnxruntime" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/87/34/388ba8cb1db86839228e88b799776d74db2286dbd2289ded744ec6b7d8dc/livekit_plugins_silero-1.7.0.tar.gz", hash = "sha256:f18aa1e980bc6892613a906ebfacd058160db3fb6db1dcd2a7b9d809dc9c0492", size = 1955425, upload-time = "2026-08-20T18:18:10.686Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2d/e1/5db37c5ca78bd011938f8d60ed6af984e61cd076a4535c56287cfb0d256b/livekit_plugins_silero-1.7.0-py3-none-any.whl", hash = "sha256:ce29626179d6a474b6b7fd6eb4dddb81e23a3a6a512e0013e8e3d07fda798e54", size = 3903143, upload-time = "2026-08-20T18:18:08.947Z" }, +] + +[[package]] +name = "livekit-protocol" +version = "1.1.24" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "protobuf" }, + { name = "types-protobuf" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d7/dc/65c106d689916e78e47d79d4f890f6e83b680442c67ed4909b44425084ea/livekit_protocol-1.1.24.tar.gz", hash = "sha256:b0a5699d3a4c4e42c3d37416dc3ed3c817c527c317c4cf3351d0bbe52887b9d4", size = 122624, upload-time = "2026-08-20T22:07:24.06Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/88/1f/e06cbbaea37bbe12cab1d169767f3fff9b385b019e6bdcd02fb9c94df63d/livekit_protocol-1.1.24-py3-none-any.whl", hash = "sha256:794463c4ed209fc884194470595052de652477d92b10d81bb5b0f9dcf53050f9", size = 149448, upload-time = "2026-08-20T22:07:22.699Z" }, +] + +[[package]] +name = "markdown-it-py" +version = "4.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mdurl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/ff/7841249c247aa650a76b9ee4bbaeae59370dc8bfd2f6c01f3630c35eb134/markdown_it_py-4.2.0.tar.gz", hash = "sha256:04a21681d6fbb623de53f6f364d352309d4094dd4194040a10fd51833e418d49", size = 82454, upload-time = "2026-05-07T12:08:28.36Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/81/4da04ced5a082363ecfa159c010d200ecbd959ae410c10c0264a38cac0f5/markdown_it_py-4.2.0-py3-none-any.whl", hash = "sha256:9f7ebbcd14fe59494226453aed97c1070d83f8d24b6fc3a3bcf9a38092641c4a", size = 91687, upload-time = "2026-05-07T12:08:27.182Z" }, +] + +[[package]] +name = "mdurl" +version = "0.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload-time = "2022-08-14T12:40:10.846Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" }, +] + +[[package]] +name = "multidict" +version = "6.7.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1a/c2/c2d94cbe6ac1753f3fc980da97b3d930efe1da3af3c9f5125354436c073d/multidict-6.7.1.tar.gz", hash = "sha256:ec6652a1bee61c53a3e5776b6049172c53b6aaba34f18c9ad04f82712bac623d", size = 102010, upload-time = "2026-01-26T02:46:45.979Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f2/22/929c141d6c0dba87d3e1d38fbdf1ba8baba86b7776469f2bc2d3227a1e67/multidict-6.7.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:2b41f5fed0ed563624f1c17630cb9941cf2309d4df00e494b551b5f3e3d67a23", size = 76174, upload-time = "2026-01-26T02:44:18.509Z" }, + { url = "https://files.pythonhosted.org/packages/c7/75/bc704ae15fee974f8fccd871305e254754167dce5f9e42d88a2def741a1d/multidict-6.7.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:84e61e3af5463c19b67ced91f6c634effb89ef8bfc5ca0267f954451ed4bb6a2", size = 45116, upload-time = "2026-01-26T02:44:19.745Z" }, + { url = "https://files.pythonhosted.org/packages/79/76/55cd7186f498ed080a18440c9013011eb548f77ae1b297206d030eb1180a/multidict-6.7.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:935434b9853c7c112eee7ac891bc4cb86455aa631269ae35442cb316790c1445", size = 43524, upload-time = "2026-01-26T02:44:21.571Z" }, + { url = "https://files.pythonhosted.org/packages/e9/3c/414842ef8d5a1628d68edee29ba0e5bcf235dbfb3ccd3ea303a7fe8c72ff/multidict-6.7.1-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:432feb25a1cb67fe82a9680b4d65fb542e4635cb3166cd9c01560651ad60f177", size = 249368, upload-time = "2026-01-26T02:44:22.803Z" }, + { url = "https://files.pythonhosted.org/packages/f6/32/befed7f74c458b4a525e60519fe8d87eef72bb1e99924fa2b0f9d97a221e/multidict-6.7.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e82d14e3c948952a1a85503817e038cba5905a3352de76b9a465075d072fba23", size = 256952, upload-time = "2026-01-26T02:44:24.306Z" }, + { url = "https://files.pythonhosted.org/packages/03/d6/c878a44ba877f366630c860fdf74bfb203c33778f12b6ac274936853c451/multidict-6.7.1-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:4cfb48c6ea66c83bcaaf7e4dfa7ec1b6bbcf751b7db85a328902796dfde4c060", size = 240317, upload-time = "2026-01-26T02:44:25.772Z" }, + { url = "https://files.pythonhosted.org/packages/68/49/57421b4d7ad2e9e60e25922b08ceb37e077b90444bde6ead629095327a6f/multidict-6.7.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1d540e51b7e8e170174555edecddbd5538105443754539193e3e1061864d444d", size = 267132, upload-time = "2026-01-26T02:44:27.648Z" }, + { url = "https://files.pythonhosted.org/packages/b7/fe/ec0edd52ddbcea2a2e89e174f0206444a61440b40f39704e64dc807a70bd/multidict-6.7.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:273d23f4b40f3dce4d6c8a821c741a86dec62cded82e1175ba3d99be128147ed", size = 268140, upload-time = "2026-01-26T02:44:29.588Z" }, + { url = "https://files.pythonhosted.org/packages/b0/73/6e1b01cbeb458807aa0831742232dbdd1fa92bfa33f52a3f176b4ff3dc11/multidict-6.7.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9d624335fd4fa1c08a53f8b4be7676ebde19cd092b3895c421045ca87895b429", size = 254277, upload-time = "2026-01-26T02:44:30.902Z" }, + { url = "https://files.pythonhosted.org/packages/6a/b2/5fb8c124d7561a4974c342bc8c778b471ebbeb3cc17df696f034a7e9afe7/multidict-6.7.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:12fad252f8b267cc75b66e8fc51b3079604e8d43a75428ffe193cd9e2195dfd6", size = 252291, upload-time = "2026-01-26T02:44:32.31Z" }, + { url = "https://files.pythonhosted.org/packages/5a/96/51d4e4e06bcce92577fcd488e22600bd38e4fd59c20cb49434d054903bd2/multidict-6.7.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:03ede2a6ffbe8ef936b92cb4529f27f42be7f56afcdab5ab739cd5f27fb1cbf9", size = 250156, upload-time = "2026-01-26T02:44:33.734Z" }, + { url = "https://files.pythonhosted.org/packages/db/6b/420e173eec5fba721a50e2a9f89eda89d9c98fded1124f8d5c675f7a0c0f/multidict-6.7.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:90efbcf47dbe33dcf643a1e400d67d59abeac5db07dc3f27d6bdeae497a2198c", size = 249742, upload-time = "2026-01-26T02:44:35.222Z" }, + { url = "https://files.pythonhosted.org/packages/44/a3/ec5b5bd98f306bc2aa297b8c6f11a46714a56b1e6ef5ebda50a4f5d7c5fb/multidict-6.7.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:5c4b9bfc148f5a91be9244d6264c53035c8a0dcd2f51f1c3c6e30e30ebaa1c84", size = 262221, upload-time = "2026-01-26T02:44:36.604Z" }, + { url = "https://files.pythonhosted.org/packages/cd/f7/e8c0d0da0cd1e28d10e624604e1a36bcc3353aaebdfdc3a43c72bc683a12/multidict-6.7.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:401c5a650f3add2472d1d288c26deebc540f99e2fb83e9525007a74cd2116f1d", size = 258664, upload-time = "2026-01-26T02:44:38.008Z" }, + { url = "https://files.pythonhosted.org/packages/52/da/151a44e8016dd33feed44f730bd856a66257c1ee7aed4f44b649fb7edeb3/multidict-6.7.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:97891f3b1b3ffbded884e2916cacf3c6fc87b66bb0dde46f7357404750559f33", size = 249490, upload-time = "2026-01-26T02:44:39.386Z" }, + { url = "https://files.pythonhosted.org/packages/87/af/a3b86bf9630b732897f6fc3f4c4714b90aa4361983ccbdcd6c0339b21b0c/multidict-6.7.1-cp313-cp313-win32.whl", hash = "sha256:e1c5988359516095535c4301af38d8a8838534158f649c05dd1050222321bcb3", size = 41695, upload-time = "2026-01-26T02:44:41.318Z" }, + { url = "https://files.pythonhosted.org/packages/b2/35/e994121b0e90e46134673422dd564623f93304614f5d11886b1b3e06f503/multidict-6.7.1-cp313-cp313-win_amd64.whl", hash = "sha256:960c83bf01a95b12b08fd54324a4eb1d5b52c88932b5cba5d6e712bb3ed12eb5", size = 45884, upload-time = "2026-01-26T02:44:42.488Z" }, + { url = "https://files.pythonhosted.org/packages/ca/61/42d3e5dbf661242a69c97ea363f2d7b46c567da8eadef8890022be6e2ab0/multidict-6.7.1-cp313-cp313-win_arm64.whl", hash = "sha256:563fe25c678aaba333d5399408f5ec3c383ca5b663e7f774dd179a520b8144df", size = 43122, upload-time = "2026-01-26T02:44:43.664Z" }, + { url = "https://files.pythonhosted.org/packages/6d/b3/e6b21c6c4f314bb956016b0b3ef2162590a529b84cb831c257519e7fde44/multidict-6.7.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:c76c4bec1538375dad9d452d246ca5368ad6e1c9039dadcf007ae59c70619ea1", size = 83175, upload-time = "2026-01-26T02:44:44.894Z" }, + { url = "https://files.pythonhosted.org/packages/fb/76/23ecd2abfe0957b234f6c960f4ade497f55f2c16aeb684d4ecdbf1c95791/multidict-6.7.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:57b46b24b5d5ebcc978da4ec23a819a9402b4228b8a90d9c656422b4bdd8a963", size = 48460, upload-time = "2026-01-26T02:44:46.106Z" }, + { url = "https://files.pythonhosted.org/packages/c4/57/a0ed92b23f3a042c36bc4227b72b97eca803f5f1801c1ab77c8a212d455e/multidict-6.7.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:e954b24433c768ce78ab7929e84ccf3422e46deb45a4dc9f93438f8217fa2d34", size = 46930, upload-time = "2026-01-26T02:44:47.278Z" }, + { url = "https://files.pythonhosted.org/packages/b5/66/02ec7ace29162e447f6382c495dc95826bf931d3818799bbef11e8f7df1a/multidict-6.7.1-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3bd231490fa7217cc832528e1cd8752a96f0125ddd2b5749390f7c3ec8721b65", size = 242582, upload-time = "2026-01-26T02:44:48.604Z" }, + { url = "https://files.pythonhosted.org/packages/58/18/64f5a795e7677670e872673aca234162514696274597b3708b2c0d276cce/multidict-6.7.1-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:253282d70d67885a15c8a7716f3a73edf2d635793ceda8173b9ecc21f2fb8292", size = 250031, upload-time = "2026-01-26T02:44:50.544Z" }, + { url = "https://files.pythonhosted.org/packages/c8/ed/e192291dbbe51a8290c5686f482084d31bcd9d09af24f63358c3d42fd284/multidict-6.7.1-cp313-cp313t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:0b4c48648d7649c9335cf1927a8b87fa692de3dcb15faa676c6a6f1f1aabda43", size = 228596, upload-time = "2026-01-26T02:44:51.951Z" }, + { url = "https://files.pythonhosted.org/packages/1e/7e/3562a15a60cf747397e7f2180b0a11dc0c38d9175a650e75fa1b4d325e15/multidict-6.7.1-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:98bc624954ec4d2c7cb074b8eefc2b5d0ce7d482e410df446414355d158fe4ca", size = 257492, upload-time = "2026-01-26T02:44:53.902Z" }, + { url = "https://files.pythonhosted.org/packages/24/02/7d0f9eae92b5249bb50ac1595b295f10e263dd0078ebb55115c31e0eaccd/multidict-6.7.1-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:1b99af4d9eec0b49927b4402bcbb58dea89d3e0db8806a4086117019939ad3dd", size = 255899, upload-time = "2026-01-26T02:44:55.316Z" }, + { url = "https://files.pythonhosted.org/packages/00/e3/9b60ed9e23e64c73a5cde95269ef1330678e9c6e34dd4eb6b431b85b5a10/multidict-6.7.1-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6aac4f16b472d5b7dc6f66a0d49dd57b0e0902090be16594dc9ebfd3d17c47e7", size = 247970, upload-time = "2026-01-26T02:44:56.783Z" }, + { url = "https://files.pythonhosted.org/packages/3e/06/538e58a63ed5cfb0bd4517e346b91da32fde409d839720f664e9a4ae4f9d/multidict-6.7.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:21f830fe223215dffd51f538e78c172ed7c7f60c9b96a2bf05c4848ad49921c3", size = 245060, upload-time = "2026-01-26T02:44:58.195Z" }, + { url = "https://files.pythonhosted.org/packages/b2/2f/d743a3045a97c895d401e9bd29aaa09b94f5cbdf1bd561609e5a6c431c70/multidict-6.7.1-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:f5dd81c45b05518b9aa4da4aa74e1c93d715efa234fd3e8a179df611cc85e5f4", size = 235888, upload-time = "2026-01-26T02:44:59.57Z" }, + { url = "https://files.pythonhosted.org/packages/38/83/5a325cac191ab28b63c52f14f1131f3b0a55ba3b9aa65a6d0bf2a9b921a0/multidict-6.7.1-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:eb304767bca2bb92fb9c5bd33cedc95baee5bb5f6c88e63706533a1c06ad08c8", size = 243554, upload-time = "2026-01-26T02:45:01.054Z" }, + { url = "https://files.pythonhosted.org/packages/20/1f/9d2327086bd15da2725ef6aae624208e2ef828ed99892b17f60c344e57ed/multidict-6.7.1-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:c9035dde0f916702850ef66460bc4239d89d08df4d02023a5926e7446724212c", size = 252341, upload-time = "2026-01-26T02:45:02.484Z" }, + { url = "https://files.pythonhosted.org/packages/e8/2c/2a1aa0280cf579d0f6eed8ee5211c4f1730bd7e06c636ba2ee6aafda302e/multidict-6.7.1-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:af959b9beeb66c822380f222f0e0a1889331597e81f1ded7f374f3ecb0fd6c52", size = 246391, upload-time = "2026-01-26T02:45:03.862Z" }, + { url = "https://files.pythonhosted.org/packages/e5/03/7ca022ffc36c5a3f6e03b179a5ceb829be9da5783e6fe395f347c0794680/multidict-6.7.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:41f2952231456154ee479651491e94118229844dd7226541788be783be2b5108", size = 243422, upload-time = "2026-01-26T02:45:05.296Z" }, + { url = "https://files.pythonhosted.org/packages/dc/1d/b31650eab6c5778aceed46ba735bd97f7c7d2f54b319fa916c0f96e7805b/multidict-6.7.1-cp313-cp313t-win32.whl", hash = "sha256:df9f19c28adcb40b6aae30bbaa1478c389efd50c28d541d76760199fc1037c32", size = 47770, upload-time = "2026-01-26T02:45:06.754Z" }, + { url = "https://files.pythonhosted.org/packages/ac/5b/2d2d1d522e51285bd61b1e20df8f47ae1a9d80839db0b24ea783b3832832/multidict-6.7.1-cp313-cp313t-win_amd64.whl", hash = "sha256:d54ecf9f301853f2c5e802da559604b3e95bb7a3b01a9c295c6ee591b9882de8", size = 53109, upload-time = "2026-01-26T02:45:08.044Z" }, + { url = "https://files.pythonhosted.org/packages/3d/a3/cc409ba012c83ca024a308516703cf339bdc4b696195644a7215a5164a24/multidict-6.7.1-cp313-cp313t-win_arm64.whl", hash = "sha256:5a37ca18e360377cfda1d62f5f382ff41f2b8c4ccb329ed974cc2e1643440118", size = 45573, upload-time = "2026-01-26T02:45:09.349Z" }, + { url = "https://files.pythonhosted.org/packages/81/08/7036c080d7117f28a4af526d794aab6a84463126db031b007717c1a6676e/multidict-6.7.1-py3-none-any.whl", hash = "sha256:55d97cc6dae627efa6a6e548885712d4864b81110ac76fa4e534c03819fa4a56", size = 12319, upload-time = "2026-01-26T02:46:44.004Z" }, +] + +[[package]] +name = "muse-glimmer-token-service" +version = "0.1.0" +source = { editable = "apps/token-service" } +dependencies = [ + { name = "fastapi" }, + { name = "livekit-api" }, + { name = "pydantic-settings" }, + { name = "uvicorn", extra = ["standard"] }, +] + +[package.dev-dependencies] +dev = [ + { name = "httpx" }, + { name = "pyjwt" }, + { name = "pytest" }, + { name = "ruff" }, +] + +[package.metadata] +requires-dist = [ + { name = "fastapi", specifier = ">=0.116,<1" }, + { name = "livekit-api", specifier = ">=1.2,<2" }, + { name = "pydantic-settings", specifier = ">=2.10,<3" }, + { name = "uvicorn", extras = ["standard"], specifier = ">=0.35,<1" }, +] + +[package.metadata.requires-dev] +dev = [ + { name = "httpx", specifier = ">=0.28,<1" }, + { name = "pyjwt", specifier = ">=2.10,<3" }, + { name = "pytest", specifier = ">=8.4,<9" }, + { name = "ruff", specifier = ">=0.12,<1" }, +] + +[[package]] +name = "muse-glimmer-voice-agent-workspace" +version = "0.1.0" +source = { virtual = "." } + +[package.dev-dependencies] +dev = [ + { name = "jsonschema" }, + { name = "pytest" }, + { name = "ruff" }, +] + +[package.metadata] + +[package.metadata.requires-dev] +dev = [ + { name = "jsonschema", specifier = ">=4.25,<5" }, + { name = "pytest", specifier = ">=8.4,<9" }, + { name = "ruff", specifier = ">=0.12,<1" }, +] + +[[package]] +name = "muse-glimmer-worker" +version = "0.1.0" +source = { editable = "apps/worker" } +dependencies = [ + { name = "livekit-agents", extra = ["openai", "silero"] }, + { name = "livekit-plugins-executorch" }, +] + +[package.dev-dependencies] +dev = [ + { name = "pytest" }, + { name = "pytest-asyncio" }, + { name = "ruff" }, +] + +[package.metadata] +requires-dist = [ + { name = "livekit-agents", extras = ["openai", "silero"], specifier = ">=1.6.9,<2" }, + { name = "livekit-plugins-executorch", editable = "packages/livekit-plugins-executorch" }, +] + +[package.metadata.requires-dev] +dev = [ + { name = "pytest", specifier = ">=8.4,<9" }, + { name = "pytest-asyncio", specifier = ">=0.25,<2" }, + { name = "ruff", specifier = ">=0.12,<1" }, +] + +[[package]] +name = "nest-asyncio" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/83/f8/51569ac65d696c8ecbee95938f89d4abf00f47d58d48f6fbabfe8f0baefe/nest_asyncio-1.6.0.tar.gz", hash = "sha256:6f172d5449aca15afd6c646851f4e31e02c598d553a667e38cafa997cfec55fe", size = 7418, upload-time = "2024-01-21T14:25:19.227Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/c4/c2971a3ba4c6103a3d10c4b0f24f461ddc027f0f09763220cf35ca1401b3/nest_asyncio-1.6.0-py3-none-any.whl", hash = "sha256:87af6efd6b5e897c81050477ef65c62e2b2f35d51703cae01aff2905b1852e1c", size = 5195, upload-time = "2024-01-21T14:25:17.223Z" }, +] + +[[package]] +name = "numpy" +version = "2.5.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9a/80/db0b4559e57ec36362bedbb05530a87fafbcb6067708c946967a41d449e7/numpy-2.5.2.tar.gz", hash = "sha256:d482d171c406ae88c5b19cad3b6a1c4c5209f886ab74bc44c2c865c23f52d860", size = 20773161, upload-time = "2026-08-09T13:48:27.962Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f5/d2/6b24738a0ef4557d189b150046cd07823c50e4273e8aebd651222e24306f/numpy-2.5.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8e4cb9a754c8a0c62eaa88273a5fba3391f4a610d1dee893c0755da31c083f15", size = 16886595, upload-time = "2026-08-09T13:45:27.323Z" }, + { url = "https://files.pythonhosted.org/packages/65/60/f2d208d366f263f39c6e69ed309290717aab41078b6d04c9be2a84fa2a07/numpy-2.5.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:52c808f96484f5571a5cc863775ce50247c17dfb3b0361f8ed6b4b0456f80080", size = 11896845, upload-time = "2026-08-09T13:45:31.638Z" }, + { url = "https://files.pythonhosted.org/packages/3c/79/81e0bf24f4d020a2b1d5cd297a9f60c3f24eeb116f9bba5870443f7b6a4a/numpy-2.5.2-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:29d81e97f668489cba8ebfd796b9bdd453525d35dd9e162e2daec94bf3fc7740", size = 5343880, upload-time = "2026-08-09T13:45:34.373Z" }, + { url = "https://files.pythonhosted.org/packages/ba/cc/e3141cf06d1a8a2c7e107543fe1269c1d1af760d4d683c0794a4ee1127c2/numpy-2.5.2-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:afb3f0632d6b2e3ba04dbce8d1e48d321b369138b73830b5ca371a0e8d479d56", size = 6682264, upload-time = "2026-08-09T13:45:36.7Z" }, + { url = "https://files.pythonhosted.org/packages/29/f1/2a64a307d92c5d98f5255a4014eb43bb6103ee477087b61ecae44a3aa9b9/numpy-2.5.2-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0aadf13b60048d501e05fa699efaf7734e2494f3498a4c2a5521d822640324f3", size = 15609566, upload-time = "2026-08-09T13:45:39.518Z" }, + { url = "https://files.pythonhosted.org/packages/7b/44/59a1eb68e773c4098d107ef34a0dbdeca501d72ffcfbff9a7707343921ce/numpy-2.5.2-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:29b86ff8a6cc556b47ec6b64b194815cc80e6bf5eedcc6cddfd65318cb0b4eee", size = 16709995, upload-time = "2026-08-09T13:45:43.661Z" }, + { url = "https://files.pythonhosted.org/packages/8a/4c/3e54d4ddbc359a1295f8b633e8106bcd4d7d4a206e82df051bdfb3058755/numpy-2.5.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:6950c4b7dd562453090548ba7f5da7e59f57f85663f15d5dcc60e249192f7e59", size = 16972511, upload-time = "2026-08-09T13:45:47.094Z" }, + { url = "https://files.pythonhosted.org/packages/f2/9f/02e371638ebf19b66d46231e4be52999e87f32d1961b113bc45656608b22/numpy-2.5.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:b9727f472d2f3888053b8a75ab0cb94745a9de224bb5846dbadc0092101bc71d", size = 18465609, upload-time = "2026-08-09T13:45:50.808Z" }, + { url = "https://files.pythonhosted.org/packages/eb/ae/ad6645abc7a3510fe48e8ea1ab4598166f500057ef4ebf38bfad4f1577de/numpy-2.5.2-cp313-cp313-win32.whl", hash = "sha256:4f9744f9fbdcea0bc552e8f19e1f141f811a3f9bc2be2cc6e86d982cab23e3f4", size = 6070204, upload-time = "2026-08-09T13:45:54.111Z" }, + { url = "https://files.pythonhosted.org/packages/15/20/f3489f86d81ea460b2bcdceaed094142ca6579f6be0ec527b781d39afe68/numpy-2.5.2-cp313-cp313-win_amd64.whl", hash = "sha256:85aaccb24182c25df891ad0ec333585967e115269d5f1b17f2c9ae005bc96657", size = 12460532, upload-time = "2026-08-09T13:45:57.167Z" }, + { url = "https://files.pythonhosted.org/packages/d5/21/35b31dde1b283b79de828b80f876afd8c94e28fe1e9c375f89e261cc4c0d/numpy-2.5.2-cp313-cp313-win_arm64.whl", hash = "sha256:bd68ece1553d2023c09a4226d9e41c586ad2d20594d1a456186c33513d2cb3f2", size = 10396725, upload-time = "2026-08-09T13:46:00.478Z" }, +] + +[[package]] +name = "onnxruntime" +version = "1.29.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "flatbuffers" }, + { name = "numpy" }, + { name = "packaging" }, + { name = "protobuf" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/41/f8/d375facf60edaf41f5732f9f689c98a800fcc52df5cf6ddfb406703eb5a1/onnxruntime-1.29.0-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:be0f8ed688cfb1d4d5765a137193b7bfab0c8ea214eed99260b380bb525a3a7f", size = 21429708, upload-time = "2026-08-17T22:54:01.44Z" }, + { url = "https://files.pythonhosted.org/packages/c9/17/b9ad04051a8c4f504852ce0e8e10f9a6b2f1a331eedcdcc503df776dd0ea/onnxruntime-1.29.0-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:d67673c5367727860922c5262d724472f1b5539fb7ccf4c81a638f9b71719803", size = 20816263, upload-time = "2026-08-17T22:54:04.088Z" }, + { url = "https://files.pythonhosted.org/packages/83/2c/d8eb945d2a372149df9705a8d5c8d7c6c46c987c5446dbcea9e1ea7f6556/onnxruntime-1.29.0-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:e2128f31f449e922c62dbe5d8b6b7b079f0bcaf2d56a102fa203cb6e5bb5ab19", size = 23136817, upload-time = "2026-08-17T22:54:06.714Z" }, + { url = "https://files.pythonhosted.org/packages/e1/3b/66b424c63fa92dfaa48d1719efaae66fc8c256b9426a832eda51d8dfe1e9/onnxruntime-1.29.0-cp313-cp313-win_amd64.whl", hash = "sha256:2945e1f82f81f27e88decea88c7861f45baea23818950d467bf3909aa303119e", size = 14001310, upload-time = "2026-08-17T22:54:09.13Z" }, + { url = "https://files.pythonhosted.org/packages/83/22/d6a700e3a6322fa3d56fbe7cee9ffc53f35e77ffcd6b7e97f4b7722a27ab/onnxruntime-1.29.0-cp313-cp313-win_arm64.whl", hash = "sha256:4b940b0d777590c7e20bf298f5c16af1ea6ad1b400a1c822a6be192f64f4d954", size = 13747112, upload-time = "2026-08-17T22:54:11.608Z" }, + { url = "https://files.pythonhosted.org/packages/4a/89/c4af146de3d60a32c89fea48d5d34bfd044faaf8957270043a03bd1b462b/onnxruntime-1.29.0-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:533f8370ce124304e5cb08ab961836cf755631e3dd77adc5f3bbdab70c2b7d99", size = 20826136, upload-time = "2026-08-17T22:54:14.315Z" }, + { url = "https://files.pythonhosted.org/packages/9d/f2/e6bbacd11dfe8d070613261a758795ea128b9fc9bea391a2a7da2e4c7a08/onnxruntime-1.29.0-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:c1ad3f437153fe77f9d01a08fbaac0beb030e09b8a80ace1603bcf69b6c95481", size = 23138951, upload-time = "2026-08-17T22:54:17.154Z" }, +] + +[[package]] +name = "openai" +version = "2.54.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "distro" }, + { name = "httpx" }, + { name = "jiter" }, + { name = "pydantic" }, + { name = "sniffio" }, + { name = "tqdm" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/50/9a/8c75e8c8a5b407a0586faeb2afac91674ff955c191ecc1d6d3b6669f6788/openai-2.54.0.tar.gz", hash = "sha256:e3e6f8bc1ba30ddf381ace1a14340eed381cb984a1a59bd0f34b5be3b5d49cfa", size = 1100285, upload-time = "2026-08-11T18:46:59.035Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/64/a8/bb76c7356de8ad57f59d5ff993d434df0607f07f08bcc9c9a5c275e399c0/openai-2.54.0-py3-none-any.whl", hash = "sha256:89089789197ccdb87f173a03145ed1598d00795220c93e96cf712b1cbf5e5f2b", size = 1660351, upload-time = "2026-08-11T18:46:56.684Z" }, +] + +[package.optional-dependencies] +realtime = [ + { name = "websockets" }, +] + +[[package]] +name = "opentelemetry-api" +version = "1.44.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ee/8b/aa9e2d8b8dfa7c946f7dec5d1f8f6ba8eca062f43509a06bdb5ce93d26c0/opentelemetry_api-1.44.0.tar.gz", hash = "sha256:67647e5e9566edcf421166fdf022b3537f818635daa852b289e34604dc6fb33a", size = 72406, upload-time = "2026-07-16T15:25:32.678Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ca/6f/a04e900f465ff3221ccc395522503e2d10e79fa21f2723c8e177aae1e0d1/opentelemetry_api-1.44.0-py3-none-any.whl", hash = "sha256:94b98c893a91b88657eaac1e3ba89618cdb85be6918196705354f34728b2cdef", size = 60018, upload-time = "2026-07-16T15:25:11.657Z" }, +] + +[[package]] +name = "opentelemetry-exporter-otlp" +version = "1.44.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "opentelemetry-exporter-otlp-proto-grpc" }, + { name = "opentelemetry-exporter-otlp-proto-http" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f2/45/7af37fe54e5d3e66e7dcd7ba8b8aeee73f202bfac909cc94b8c4e428f9ac/opentelemetry_exporter_otlp-1.44.0.tar.gz", hash = "sha256:af1cde7c33ea8ed624bf04ac49a885730fe44c1f1ad698656e592c38f70ce106", size = 6090, upload-time = "2026-07-16T15:25:34.585Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/18/c3/7b466a9463944e70b37b744072a0c1b88a425dade3fff0631adec66c9bcc/opentelemetry_exporter_otlp-1.44.0-py3-none-any.whl", hash = "sha256:4a498fa8d8fd8be9e8e2d175fe5524a3fe581ccffadd8509db86526a5fb97051", size = 6727, upload-time = "2026-07-16T15:25:14.445Z" }, +] + +[[package]] +name = "opentelemetry-exporter-otlp-proto-common" +version = "1.44.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "opentelemetry-proto" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/61/09/4d717852c1cf3f854b76c7110a5d00883bc3c99288b9b0dbcbeb9e306eb6/opentelemetry_exporter_otlp_proto_common-1.44.0.tar.gz", hash = "sha256:dc87a5a5bc58f149a56d1547e4691588fa12994cdc3bc039a694ccb3375862ac", size = 20202, upload-time = "2026-07-16T15:25:37.658Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5e/71/65fd9d54c10b860f87c045ccee1264cab7011268895d3528818a29c1172a/opentelemetry_exporter_otlp_proto_common-1.44.0-py3-none-any.whl", hash = "sha256:9a9fe61bba73d802904bc989f1d6b4a7b1ee40f06c40e98d6f85af65aaebb694", size = 17045, upload-time = "2026-07-16T15:25:18.201Z" }, +] + +[[package]] +name = "opentelemetry-exporter-otlp-proto-grpc" +version = "1.44.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "googleapis-common-protos" }, + { name = "grpcio" }, + { name = "opentelemetry-api" }, + { name = "opentelemetry-exporter-otlp-proto-common" }, + { name = "opentelemetry-proto" }, + { name = "opentelemetry-sdk" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/1f/47/80d9e9d468dc5de3af5096f5ccdb065fa4dd1470f74495cc53e59e397f47/opentelemetry_exporter_otlp_proto_grpc-1.44.0.tar.gz", hash = "sha256:40d1ae9e03fcc36de3cbac610cc99f35894938bff9cfd90fc4ec68bd85448463", size = 27225, upload-time = "2026-07-16T15:25:38.308Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/29/6ae42ba32b153ae0a44ae125f0caff2188bbe62d99c82d1768da30864e72/opentelemetry_exporter_otlp_proto_grpc-1.44.0-py3-none-any.whl", hash = "sha256:6a1a645ea182a2f59440c51fa8301d309f3324a8f9d65f8395584b064b67ee4e", size = 19624, upload-time = "2026-07-16T15:25:19.096Z" }, +] + +[[package]] +name = "opentelemetry-exporter-otlp-proto-http" +version = "1.44.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "googleapis-common-protos" }, + { name = "opentelemetry-api" }, + { name = "opentelemetry-exporter-otlp-proto-common" }, + { name = "opentelemetry-proto" }, + { name = "opentelemetry-sdk" }, + { name = "requests" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/1a/87/95e2a5aaa795b4e2260d74e16df2d5541deb2ea9de010bcd615f4dee2654/opentelemetry_exporter_otlp_proto_http-1.44.0.tar.gz", hash = "sha256:c633d7270ad6b57cd4cfbe8b0007a9e2e7c0cb50bd6c50fe2a7b245f721a09d8", size = 25806, upload-time = "2026-07-16T15:25:39.162Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cd/d0/fdeb1a98d8d3a6205f5f297c51b4a9bfe65126ab60339669bbe3dd54c2e2/opentelemetry_exporter_otlp_proto_http-1.44.0-py3-none-any.whl", hash = "sha256:838592fce774c1c8bb7b9a0a7facbfa82e17be5a8a4e94cef10cb84ae026bae3", size = 21850, upload-time = "2026-07-16T15:25:20.006Z" }, +] + +[[package]] +name = "opentelemetry-proto" +version = "1.44.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "protobuf" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/64/01/40ac4ae9a149263cc52c2cee200ddd80cb6d8db1a4610abf8eabce0fe771/opentelemetry_proto-1.44.0.tar.gz", hash = "sha256:c547a79c2f8c0c515d31509154682e5921c7cfd5ca67b70e1f9266e2c3e103f3", size = 46488, upload-time = "2026-07-16T15:25:45.34Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/7c/8be563d68e93bbefa5c8affb82ddcff91b3ad858ce49957ba7b16fd3e0ab/opentelemetry_proto-1.44.0-py3-none-any.whl", hash = "sha256:898b155a0e1557afd867478fb6158e8122a46329ca0bb8dc53cc55e98f017f56", size = 72483, upload-time = "2026-07-16T15:25:28.429Z" }, +] + +[[package]] +name = "opentelemetry-sdk" +version = "1.44.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "opentelemetry-api" }, + { name = "opentelemetry-semantic-conventions" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5d/77/a6592cbc7c8d9bcc9d6757a9df45e04a7c585e3e6e7a13456da522b21109/opentelemetry_sdk-1.44.0.tar.gz", hash = "sha256:cebe7f65dc12f26ead75c6064de12fd2a9052e5060c0272d402cfa203aae123b", size = 208624, upload-time = "2026-07-16T15:25:46.078Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e7/23/ff077e61886ee020a17ce9c8b6fa11c601c8d8345b09ea24f605445df62a/opentelemetry_sdk-1.44.0-py3-none-any.whl", hash = "sha256:df081c4c6bcfdb1211e3e86140376792643128a25f8d72d1d27675936e7e96ad", size = 137221, upload-time = "2026-07-16T15:25:29.534Z" }, +] + +[[package]] +name = "opentelemetry-semantic-conventions" +version = "0.65b0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "opentelemetry-api" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/8f/73/0cbdebcb4cf545fdd328da14f5137e37d0770c3f26185e478b0d15d94f50/opentelemetry_semantic_conventions-0.65b0.tar.gz", hash = "sha256:f9b2b81e9d5b64f11bc952075e7e9c7fb0aab075c7fd1c46d597f1b919852d60", size = 148774, upload-time = "2026-07-16T15:25:46.902Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a6/0e/49df70d9b81fb5cbae4bbf2a49d865b09bcbcbc4eb53f5851b1027738d78/opentelemetry_semantic_conventions-0.65b0-py3-none-any.whl", hash = "sha256:1cacde7b0ad306f84c5ef08c3dbe1bbaf20165bba6f8bff43b670e555a086bcb", size = 204645, upload-time = "2026-07-16T15:25:30.688Z" }, +] + +[[package]] +name = "packaging" +version = "26.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7d/fa/3944b40b07da9ce895c0e6303a5ab7d53da063554f534556b134a54d6093/packaging-26.3.tar.gz", hash = "sha256:94edc256424af38762eb31306eed28beb9f0efc50a8837492c9d6fd6004aed79", size = 313412, upload-time = "2026-08-04T18:15:28.737Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/63/34/ba1c580383c9eada3711951fef0795c80b829a078d72188184bcab9dd527/packaging-26.3-py3-none-any.whl", hash = "sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c", size = 129956, upload-time = "2026-08-04T18:15:27.159Z" }, +] + +[[package]] +name = "pillow" +version = "12.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1c/3d/bb7fca845737cf9d7dbde16ed1843984665ff2e0a518f5db43e77ec540b9/pillow-12.3.0.tar.gz", hash = "sha256:3b8182a766685eaa002637e28b4ec8d6b18819a0c71f579bf0dbaa5830297cce", size = 47025035, upload-time = "2026-07-01T11:56:38.965Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9d/ac/31fb64e1e7efb5a4b50cd3d92049ba89ac6e4d8d3bb6a74e15048ca3353e/pillow-12.3.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:21900ce7ba264168cd50defae43cd75d25c833ad4ad6e73ffc5596d12e25ac89", size = 4161684, upload-time = "2026-07-01T11:54:25.934Z" }, + { url = "https://files.pythonhosted.org/packages/87/b4/9805e23d2b4d77842b468513841fda254ee42f0289d25088340e4ff46e2d/pillow-12.3.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:4e8c2a84d977f50b9daed6eeaf3baef67d00d5d74d932288f02cb94518ee3ace", size = 4255487, upload-time = "2026-07-01T11:54:27.935Z" }, + { url = "https://files.pythonhosted.org/packages/df/39/ecf519435a200c693fe053a6ee4d835b41cf963a4dfc2551c4e637cb2a71/pillow-12.3.0-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:ae26d61dfa7a47befdc7572b521024e8745f3d809bd95ca9505a7bba9ef849ec", size = 3696433, upload-time = "2026-07-01T11:54:29.813Z" }, + { url = "https://files.pythonhosted.org/packages/42/92/2fc3ffad878ae8dd5469ec1bc8eb83b71f48e13efdf68f02709003982a32/pillow-12.3.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:7a743ff716f746fc19a9557f60dab1600d4613255f8a7aeb3cdde4db7eb15a66", size = 5345889, upload-time = "2026-07-01T11:54:31.97Z" }, + { url = "https://files.pythonhosted.org/packages/10/76/8803c13605b763d33d156c4678fc77f8443389c0c51c8aef707bb02015f4/pillow-12.3.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:d69141514cc30b774ceea5e3ed3a6635c8d8a96edf664689b890f4089111fb35", size = 4780109, upload-time = "2026-07-01T11:54:34.026Z" }, + { url = "https://files.pythonhosted.org/packages/1f/01/e18aff37cb0b4aac47ac90f016d347a49aca667ef97f190b06ac2aabc928/pillow-12.3.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f7401aebd7f581d7f83a439d87d474999317ee099218e5ad25d125290990ba65", size = 6263736, upload-time = "2026-07-01T11:54:36.131Z" }, + { url = "https://files.pythonhosted.org/packages/f7/62/de5bdd77d935331f4f802edc11e4d82950f642caad6cb2f949837b8560e2/pillow-12.3.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0847a763afefb695bc912d7c131e7e0632d4edc1d8698f58ddabec8e46b8b6d3", size = 6937129, upload-time = "2026-07-01T11:54:38.216Z" }, + { url = "https://files.pythonhosted.org/packages/70/4d/105627a13300c5e0df1d174230b32fd1273062c96f7745fd552b945d1e1d/pillow-12.3.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:571b9fcb07b97ef3a492028fb3d2dc0993ca23a06138b0315286566d29ef718a", size = 6339562, upload-time = "2026-07-01T11:54:40.354Z" }, + { url = "https://files.pythonhosted.org/packages/6b/1d/f13de01a553988ab895ba1c722e06cf3144d4f57656fd5b81b6d881f1179/pillow-12.3.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:756c768d0c9c2955feb7a56c37ea24aea2e369f8d36a88da270b6a9f19e62b5e", size = 7049439, upload-time = "2026-07-01T11:54:42.489Z" }, + { url = "https://files.pythonhosted.org/packages/c9/f9/066794cca041b969964f779ee5fa66a9498bbf34248ac39c5d7954e4198f/pillow-12.3.0-cp313-cp313-win32.whl", hash = "sha256:a876864214e136f0eb367788dbd7df045f4806801518e2cfe9e13229cfe06d8f", size = 6473287, upload-time = "2026-07-01T11:54:44.9Z" }, + { url = "https://files.pythonhosted.org/packages/a6/9b/7a58e61d62be561da3a356fe2384d4059a6345fc130e23ef1c36a5b81d24/pillow-12.3.0-cp313-cp313-win_amd64.whl", hash = "sha256:1cca606cd25738df4ed873d5ad46bbdb3d83b5cbca291f6b4ff13a4df6b0bbe8", size = 7239691, upload-time = "2026-07-01T11:54:47.141Z" }, + { url = "https://files.pythonhosted.org/packages/aa/b0/c4ed4f0ef8f8fa5ee8351537db6650bb8189f7e118842978dd6589065692/pillow-12.3.0-cp313-cp313-win_arm64.whl", hash = "sha256:b629de27fda84b42cde7edef0d85f13b958b47f6e9bbcbba9b673c562a89bd8b", size = 2568185, upload-time = "2026-07-01T11:54:49.137Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + +[[package]] +name = "prometheus-client" +version = "0.26.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/52/73/f1334c29c2af4cd9dba6c7817e61b611bd0215e2eb5565c6064a4de18802/prometheus_client-0.26.0.tar.gz", hash = "sha256:04a91bcf94e2cf74a44a1a874d651a2e853ed354b6e822f3b7487751465d5c2b", size = 92910, upload-time = "2026-07-24T19:36:41.893Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/eb/a3/b69efbf4143b5b9859b977770bbbabcc2796b702fa69dc40271e45cd5a56/prometheus_client-0.26.0-py3-none-any.whl", hash = "sha256:fa93d06737aa02bacd05794768508bb97d2fbee28cb3bca04eaae92f0ca953d6", size = 64494, upload-time = "2026-07-24T19:36:40.854Z" }, +] + +[[package]] +name = "propcache" +version = "0.5.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ec/44/c87281c333769159c50594f22610f77398a47ccbfbbf23074e744e86f87c/propcache-0.5.2.tar.gz", hash = "sha256:01c4fc7480cd0598bb4b57022df55b9ca296da7fc5a8760bd8451a7e63a7d427", size = 50208, upload-time = "2026-05-08T21:02:12.199Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c5/09/f049e45385503fe67db75a6b6186a7b9f0c3930366dc960522c312a825b1/propcache-0.5.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:099aaf4b4d1a02265b92a977edf00b5c4f63b3b17ac6de39b0d637c9cac0188a", size = 94457, upload-time = "2026-05-08T21:00:36.355Z" }, + { url = "https://files.pythonhosted.org/packages/6b/65/83d1d05655baf63113731bd5a1008435e14f8d1e5a06cbe4ec5b23ad7a31/propcache-0.5.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:68ce1c44c7a813a7f71ea04315a8c7b330b63db99d059a797a4651bb6f69f117", size = 53835, upload-time = "2026-05-08T21:00:38.072Z" }, + { url = "https://files.pythonhosted.org/packages/a9/12/a6ba6482bb5ea3260c000c9b20881c95fa11c6b30173715668259f844ed7/propcache-0.5.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:fc299c129490f55f254cd90be0deca4764e36e9a7c08b4aa588479a3bbed3098", size = 54545, upload-time = "2026-05-08T21:00:39.319Z" }, + { url = "https://files.pythonhosted.org/packages/a9/19/7fa086f5764c59ec8a8e157cd93aa8497acc00aba9dcdec56bfffb32602d/propcache-0.5.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a6ae2198be502c10f09b2516e7b5d019816924bc3183a43ce792a7bd6625e6f4", size = 59886, upload-time = "2026-05-08T21:00:40.621Z" }, + { url = "https://files.pythonhosted.org/packages/a1/e4/5d7663dc8235956c8f5281698a3af1d351d8820341ddd890f59d9a9127f2/propcache-0.5.2-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6041d31504dc1779d700e1edcfb08eea334b357620b06681a4eabb57a74e574e", size = 63261, upload-time = "2026-05-08T21:00:41.775Z" }, + { url = "https://files.pythonhosted.org/packages/4a/4a/15a03adee24d6350da4292caeac44c34c033d2afe5e87eb370f38854560f/propcache-0.5.2-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f7eabc04151c78a9f4d5bbb5f1faf571e4defeb4b585e0fe95b60ff2dbe4d3d7", size = 64184, upload-time = "2026-05-08T21:00:43.018Z" }, + { url = "https://files.pythonhosted.org/packages/8b/c6/979176efdaa3d239e36d503d5af63a0a773b36662ed8f52e5b6a6d9fd40e/propcache-0.5.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4db0ba63d693afd40d249bd93f842b5f144f8fcbb83de05660373bcf30517b1d", size = 61534, upload-time = "2026-05-08T21:00:44.507Z" }, + { url = "https://files.pythonhosted.org/packages/c8/22/63e8cd1bae4c2d2be6493b6b7d10566ddafad88137cfbc99964a1119853c/propcache-0.5.2-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1dbcf7675229b35d31abb6547d8ebc8c27a830ac3f9a794edff6254873ec7c0a", size = 61500, upload-time = "2026-05-08T21:00:45.796Z" }, + { url = "https://files.pythonhosted.org/packages/60/5a/28e5d9acbac1cc9ccb67045e8c1b943aa8d79fdf39c93bd73cacd68008ea/propcache-0.5.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d310c013aad2c72f1c3f2f8dd3279d460a858c551f97aeb8c63e4693cca7b4d2", size = 59994, upload-time = "2026-05-08T21:00:47.093Z" }, + { url = "https://files.pythonhosted.org/packages/f3/40/db650677f554a95b9c01a7c9d93d629e93a15562f5deb4573c9ee136fed2/propcache-0.5.2-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:06187263ddad280d05b4d8a8b3bb7d164cbebd469236544a42e6d9b28ac6a4fa", size = 56884, upload-time = "2026-05-08T21:00:48.376Z" }, + { url = "https://files.pythonhosted.org/packages/80/45/70b39b89516ff8b96bf732fa6fded8cef20f293cb1508690101c3c07ec51/propcache-0.5.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:3115559b8effafd63b142ea5ed53d63a16ea6469cbc63dce4ee194b42db5d853", size = 63464, upload-time = "2026-05-08T21:00:49.954Z" }, + { url = "https://files.pythonhosted.org/packages/f9/e2/fa59d3a89eac5534293124af4f1d0d0ada091ce4a0ab4610ce03fd2bdd8d/propcache-0.5.2-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:c60462af8e6dc30c35407c7237ea908d777b22862bbee27bc4699c0d8bcdc45a", size = 61588, upload-time = "2026-05-08T21:00:51.281Z" }, + { url = "https://files.pythonhosted.org/packages/0b/97/efb547a55c4bc7381cfb202d6a2239ac621045277bc1ea5dfd3a7f0516c0/propcache-0.5.2-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:40314bca9ac559716fe374094fc81c11dcc34b64fd6c585360f5775690505704", size = 64667, upload-time = "2026-05-08T21:00:52.602Z" }, + { url = "https://files.pythonhosted.org/packages/92/56/f5c7d9b4b7595d5127da38974d791b2153f3d1eae6c674af3583ace92ad3/propcache-0.5.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:cfa21e036ce1e1db2be04ba3b85d2df1bb1702fa01932d984c5464c665228ff4", size = 62463, upload-time = "2026-05-08T21:00:54.303Z" }, + { url = "https://files.pythonhosted.org/packages/bd/3b/484a3a65fc9f9f60c41dcd17b428bace5389544e2c680994534a20755066/propcache-0.5.2-cp313-cp313-win32.whl", hash = "sha256:f156a3529f38063b6dbaf356e15602a7f95f8055b1295a438433a6386f10463d", size = 38621, upload-time = "2026-05-08T21:00:55.808Z" }, + { url = "https://files.pythonhosted.org/packages/1c/fd/3f0f10dba4dabad3bf53102be007abf55481067952bde0fdddff439e7c61/propcache-0.5.2-cp313-cp313-win_amd64.whl", hash = "sha256:dfed59d0a5aeb01e242e66ff0300bc4a265a7c05f612d30016f0b60b1017d757", size = 41649, upload-time = "2026-05-08T21:00:57.061Z" }, + { url = "https://files.pythonhosted.org/packages/90/ec/6ce619cc32bb500a482f811f9cd509368b4e58e638d13f2c68f370d6b475/propcache-0.5.2-cp313-cp313-win_arm64.whl", hash = "sha256:ba338430e87ceb9c8f0cf754de38a9860560261e56c00376debd628698a7364f", size = 37636, upload-time = "2026-05-08T21:00:58.646Z" }, + { url = "https://files.pythonhosted.org/packages/1b/82/c1d268bbbf2ef981c5bf0fbbe746db617c66e3bcefe431a1aa8943fbe23a/propcache-0.5.2-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:a592f5f3da71c8691c788c13cb6734b6d17663d2e1cb8caddf0673d01ef8847d", size = 98872, upload-time = "2026-05-08T21:00:59.889Z" }, + { url = "https://files.pythonhosted.org/packages/f4/d4/52c871e73e864e6b34c0e2d58ac1ec5ccd149497ddc7ad2137ae98323a35/propcache-0.5.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:6a997d0489e9668a384fcfd5061b857aa5361de73191cac204d04b889cfbbafa", size = 56257, upload-time = "2026-05-08T21:01:01.195Z" }, + { url = "https://files.pythonhosted.org/packages/67/f0/9b90ca2a210b3d09bcfcd96ecd0f55545c091535abce2a45de2775cfd357/propcache-0.5.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:10734b5484ea113152ee25a91dccedf81631791805d2c9ccb054958e51842c94", size = 56696, upload-time = "2026-05-08T21:01:02.941Z" }, + { url = "https://files.pythonhosted.org/packages/9d/0e/6e9d4ba07c8e56e21ddec1e75f12148142b21ca83a51871babce095334f4/propcache-0.5.2-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cafca7e56c12bb02ae16d283742bef25a61122e9dab2b5b3f2ccbe589ce32164", size = 62378, upload-time = "2026-05-08T21:01:04.475Z" }, + { url = "https://files.pythonhosted.org/packages/65/19/c10badaa463dde8a27ce884f8ee2ec37e6035b7c9f5ff0c8f74f06f08dac/propcache-0.5.2-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f064f8d2b59177878b7615df1735cd8fe3462ed6be8c7b217d17a276489c2b7f", size = 65283, upload-time = "2026-05-08T21:01:05.959Z" }, + { url = "https://files.pythonhosted.org/packages/b0/b6/93bea99ca80e19cef6512a8580e5b7857bbe09422d9daa7fd4ef5723306c/propcache-0.5.2-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f78abfa8dfc32376fd1aacf597b2f2fbbe0ea751419aee718af5d4f82537ef8c", size = 66616, upload-time = "2026-05-08T21:01:07.228Z" }, + { url = "https://files.pythonhosted.org/packages/83/e4/5c7462e50625f051f37fb38b8224f7639f667184bbd34424ec83819bb1b7/propcache-0.5.2-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f7467da8a9822bf1a55336f877340c5bcbd3c482afc43a99771169f74a26dedc", size = 63773, upload-time = "2026-05-08T21:01:08.514Z" }, + { url = "https://files.pythonhosted.org/packages/ca/b6/99238894047b13c823be25027e736626cd414a52a5e30d2c3347c2733529/propcache-0.5.2-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a6ddc6ac9e25de626c1f129c1b467d7ecd33ce2237d3fd0c4e429feef0a7ee1f", size = 63664, upload-time = "2026-05-08T21:01:09.874Z" }, + { url = "https://files.pythonhosted.org/packages/85/1e/a3a1a63116a2b8edb415a8bb9a6f0c34bd03830b1e18e8ce2904e1dc1cf4/propcache-0.5.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:2f22cbbac9e26a8e864c0985ff1268d5d939d53d9d9411a9824279097e03a2cb", size = 62643, upload-time = "2026-05-08T21:01:11.132Z" }, + { url = "https://files.pythonhosted.org/packages/e4/03/893cf147de2fc6543c5eaa07ad833170e7e2a2385725bbebe8c0503723bb/propcache-0.5.2-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:fc76378c62a0f04d0cd82fbb1a2cd2d7e28fcb40d5873f28a6c44e388aaa2751", size = 59595, upload-time = "2026-05-08T21:01:12.387Z" }, + { url = "https://files.pythonhosted.org/packages/86/3b/04c1a2e12c57766568ba75ba72b3bf2042818d4c1425fab6fc07155c7cff/propcache-0.5.2-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:acd2c8edba48e31e58a363b8cf4e5c7db3b04b3f9e371f601df30d9b0d244836", size = 65711, upload-time = "2026-05-08T21:01:13.676Z" }, + { url = "https://files.pythonhosted.org/packages/1c/34/80f8d0099f8d6bacc4de1624c85672681c8cd1149ca2da0e38fd120b817f/propcache-0.5.2-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:452b5065457eb9991ec5eb38ff41d6cd4c991c9ac7c531c4d5849ae473a9a13f", size = 64247, upload-time = "2026-05-08T21:01:14.936Z" }, + { url = "https://files.pythonhosted.org/packages/f3/1a/8b08f3a5f1037e9e370c55883ceeeee0f6dd0416fb2d2d67b8bfc91f2a79/propcache-0.5.2-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:3430bb2bfe1331885c427745a751e774ee679fd4344f80b97bf879815fe8fa55", size = 67102, upload-time = "2026-05-08T21:01:16.281Z" }, + { url = "https://files.pythonhosted.org/packages/34/68/8bdb7bb7756d76e005490649d10e4a8369e610c74d619f71e1aedf889e9c/propcache-0.5.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:cef6cea3922890dd6c9654971001fa797b526c16ab5e1e46c05fd6f877be7568", size = 64964, upload-time = "2026-05-08T21:01:17.57Z" }, + { url = "https://files.pythonhosted.org/packages/0a/aa/50fb0b5d3968b61a510926ff8b8465f1d6e976b3ab74496d7a4b9fc42515/propcache-0.5.2-cp313-cp313t-win32.whl", hash = "sha256:72d61e16dd78228b58c5d47be830ff3da7e5f139abdf0aef9d86cde1c5cf2191", size = 42546, upload-time = "2026-05-08T21:01:18.946Z" }, + { url = "https://files.pythonhosted.org/packages/ae/4c/0ddbae64321bd4a95bcbfc19307238016b5b1fee645c84626c8d539e5b74/propcache-0.5.2-cp313-cp313t-win_amd64.whl", hash = "sha256:0958834041a0166d343b8d2cedcd8bcbaeb4fdbe0cf08320c5379f143c3be6e7", size = 46330, upload-time = "2026-05-08T21:01:20.162Z" }, + { url = "https://files.pythonhosted.org/packages/00/d9/9cddc8efb78d8af264c5ec9f6d10b62f57c515feda8d321595f56010fb23/propcache-0.5.2-cp313-cp313t-win_arm64.whl", hash = "sha256:6de8bd93ddde9b992cf2b2e0d796d501a19026b5b9fd87356d7d0779531a8d96", size = 40521, upload-time = "2026-05-08T21:01:21.399Z" }, + { url = "https://files.pythonhosted.org/packages/3a/ed/1cdcab6ba3d6ab7feca11fc14f0eeea80755bb53ef4e892079f31b10a25f/propcache-0.5.2-py3-none-any.whl", hash = "sha256:be1ddfcbb376e3de5d2e2db1d58d6d67463e6b4f9f040c000de8e300295465fe", size = 14036, upload-time = "2026-05-08T21:02:10.673Z" }, +] + +[[package]] +name = "protobuf" +version = "7.36.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a7/e7/0553e21d25ca4d9f573135775348a372c3ec34a93a71d5f297c3bac38341/protobuf-7.36.0.tar.gz", hash = "sha256:e8e09cb0d794c6687926fa558a8a6e72aa10edb997d5ca61da0765f12a3e00ea", size = 510034, upload-time = "2026-08-20T16:34:01.071Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8f/ae/58e3ca96cb2e118cc546b677359b3c6659f79a140935c08dec94c7998585/protobuf-7.36.0-cp310-abi3-macosx_10_9_universal2.whl", hash = "sha256:9103532dffd80c6fab7e50c65a31007680a06eb57537d437bb1b35812c138a37", size = 453256, upload-time = "2026-08-20T16:33:53.945Z" }, + { url = "https://files.pythonhosted.org/packages/f0/15/5162230af4912697f0fe406f6800f80760945babcff0e2c2fe6c84ef2d5d/protobuf-7.36.0-cp310-abi3-manylinux2014_aarch64.whl", hash = "sha256:bf94a5917c71058262de683669bc0a797a7669d3de71f0b36d058e3194f47b44", size = 341436, upload-time = "2026-08-20T16:33:55.134Z" }, + { url = "https://files.pythonhosted.org/packages/d7/09/1670b2bfc9a45e807e520c3e9be36524db9ccc7dc05ea17af7681cabdc61/protobuf-7.36.0-cp310-abi3-manylinux2014_s390x.whl", hash = "sha256:3297e60abdff301e5f74393d87f6cc59dacab5f024a89548a6e8de1d26576b16", size = 354440, upload-time = "2026-08-20T16:33:56.077Z" }, + { url = "https://files.pythonhosted.org/packages/c7/f8/bd5804695ba400e423c33fd4d9f58c28d86633d5ba1945c36ff3967d98cb/protobuf-7.36.0-cp310-abi3-manylinux2014_x86_64.whl", hash = "sha256:70f5ec8eb0da81a44360c0dc0beac99a0d78071d21956a7076bae8bd2051841b", size = 340439, upload-time = "2026-08-20T16:33:56.992Z" }, + { url = "https://files.pythonhosted.org/packages/ef/9f/acd02338235a3e7d03168c4303478347b7624fc8189ff4e7f0d2654bbe86/protobuf-7.36.0-cp310-abi3-win32.whl", hash = "sha256:7326fd717bdc419162a735938d89d4032332bcc3408804012b24ff3a37086071", size = 440216, upload-time = "2026-08-20T16:33:57.99Z" }, + { url = "https://files.pythonhosted.org/packages/0e/4e/12cb93270967a2affff5b3f720694700d4d87712a67afd05c8cb3f6fa52c/protobuf-7.36.0-cp310-abi3-win_amd64.whl", hash = "sha256:1781cc1de61249b750848029bca452c0a8b7e990080316b9bbc2518b2117b488", size = 453731, upload-time = "2026-08-20T16:33:58.951Z" }, + { url = "https://files.pythonhosted.org/packages/01/c3/629999e78d46c1115c11886d51c6bd68c17ce4a944f1ea3e153a91316a33/protobuf-7.36.0-py3-none-any.whl", hash = "sha256:53374d53fc29a67f7dbbf0ade47d7526a0f0137bf0f9c90e48d8a60790ef748c", size = 177024, upload-time = "2026-08-20T16:34:00.053Z" }, +] + +[[package]] +name = "psutil" +version = "7.2.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/aa/c6/d1ddf4abb55e93cebc4f2ed8b5d6dbad109ecb8d63748dd2b20ab5e57ebe/psutil-7.2.2.tar.gz", hash = "sha256:0746f5f8d406af344fd547f1c8daa5f5c33dbc293bb8d6a16d80b4bb88f59372", size = 493740, upload-time = "2026-01-28T18:14:54.428Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/51/08/510cbdb69c25a96f4ae523f733cdc963ae654904e8db864c07585ef99875/psutil-7.2.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:2edccc433cbfa046b980b0df0171cd25bcaeb3a68fe9022db0979e7aa74a826b", size = 130595, upload-time = "2026-01-28T18:14:57.293Z" }, + { url = "https://files.pythonhosted.org/packages/d6/f5/97baea3fe7a5a9af7436301f85490905379b1c6f2dd51fe3ecf24b4c5fbf/psutil-7.2.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:e78c8603dcd9a04c7364f1a3e670cea95d51ee865e4efb3556a3a63adef958ea", size = 131082, upload-time = "2026-01-28T18:14:59.732Z" }, + { url = "https://files.pythonhosted.org/packages/37/d6/246513fbf9fa174af531f28412297dd05241d97a75911ac8febefa1a53c6/psutil-7.2.2-cp313-cp313t-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1a571f2330c966c62aeda00dd24620425d4b0cc86881c89861fbc04549e5dc63", size = 181476, upload-time = "2026-01-28T18:15:01.884Z" }, + { url = "https://files.pythonhosted.org/packages/b8/b5/9182c9af3836cca61696dabe4fd1304e17bc56cb62f17439e1154f225dd3/psutil-7.2.2-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:917e891983ca3c1887b4ef36447b1e0873e70c933afc831c6b6da078ba474312", size = 184062, upload-time = "2026-01-28T18:15:04.436Z" }, + { url = "https://files.pythonhosted.org/packages/16/ba/0756dca669f5a9300d0cbcbfae9a4c30e446dfc7440ffe43ded5724bfd93/psutil-7.2.2-cp313-cp313t-win_amd64.whl", hash = "sha256:ab486563df44c17f5173621c7b198955bd6b613fb87c71c161f827d3fb149a9b", size = 139893, upload-time = "2026-01-28T18:15:06.378Z" }, + { url = "https://files.pythonhosted.org/packages/1c/61/8fa0e26f33623b49949346de05ec1ddaad02ed8ba64af45f40a147dbfa97/psutil-7.2.2-cp313-cp313t-win_arm64.whl", hash = "sha256:ae0aefdd8796a7737eccea863f80f81e468a1e4cf14d926bd9b6f5f2d5f90ca9", size = 135589, upload-time = "2026-01-28T18:15:08.03Z" }, + { url = "https://files.pythonhosted.org/packages/e7/36/5ee6e05c9bd427237b11b3937ad82bb8ad2752d72c6969314590dd0c2f6e/psutil-7.2.2-cp36-abi3-macosx_10_9_x86_64.whl", hash = "sha256:ed0cace939114f62738d808fdcecd4c869222507e266e574799e9c0faa17d486", size = 129090, upload-time = "2026-01-28T18:15:22.168Z" }, + { url = "https://files.pythonhosted.org/packages/80/c4/f5af4c1ca8c1eeb2e92ccca14ce8effdeec651d5ab6053c589b074eda6e1/psutil-7.2.2-cp36-abi3-macosx_11_0_arm64.whl", hash = "sha256:1a7b04c10f32cc88ab39cbf606e117fd74721c831c98a27dc04578deb0c16979", size = 129859, upload-time = "2026-01-28T18:15:23.795Z" }, + { url = "https://files.pythonhosted.org/packages/b5/70/5d8df3b09e25bce090399cf48e452d25c935ab72dad19406c77f4e828045/psutil-7.2.2-cp36-abi3-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:076a2d2f923fd4821644f5ba89f059523da90dc9014e85f8e45a5774ca5bc6f9", size = 155560, upload-time = "2026-01-28T18:15:25.976Z" }, + { url = "https://files.pythonhosted.org/packages/63/65/37648c0c158dc222aba51c089eb3bdfa238e621674dc42d48706e639204f/psutil-7.2.2-cp36-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b0726cecd84f9474419d67252add4ac0cd9811b04d61123054b9fb6f57df6e9e", size = 156997, upload-time = "2026-01-28T18:15:27.794Z" }, + { url = "https://files.pythonhosted.org/packages/8e/13/125093eadae863ce03c6ffdbae9929430d116a246ef69866dad94da3bfbc/psutil-7.2.2-cp36-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:fd04ef36b4a6d599bbdb225dd1d3f51e00105f6d48a28f006da7f9822f2606d8", size = 148972, upload-time = "2026-01-28T18:15:29.342Z" }, + { url = "https://files.pythonhosted.org/packages/04/78/0acd37ca84ce3ddffaa92ef0f571e073faa6d8ff1f0559ab1272188ea2be/psutil-7.2.2-cp36-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:b58fabe35e80b264a4e3bb23e6b96f9e45a3df7fb7eed419ac0e5947c61e47cc", size = 148266, upload-time = "2026-01-28T18:15:31.597Z" }, + { url = "https://files.pythonhosted.org/packages/b4/90/e2159492b5426be0c1fef7acba807a03511f97c5f86b3caeda6ad92351a7/psutil-7.2.2-cp37-abi3-win_amd64.whl", hash = "sha256:eb7e81434c8d223ec4a219b5fc1c47d0417b12be7ea866e24fb5ad6e84b3d988", size = 137737, upload-time = "2026-01-28T18:15:33.849Z" }, + { url = "https://files.pythonhosted.org/packages/8c/c7/7bb2e321574b10df20cbde462a94e2b71d05f9bbda251ef27d104668306a/psutil-7.2.2-cp37-abi3-win_arm64.whl", hash = "sha256:8c233660f575a5a89e6d4cb65d9f938126312bca76d8fe087b947b3a1aaac9ee", size = 134617, upload-time = "2026-01-28T18:15:36.514Z" }, +] + +[[package]] +name = "pycparser" +version = "3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1b/7d/92392ff7815c21062bea51aa7b87d45576f649f16458d78b7cf94b9ab2e6/pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29", size = 103492, upload-time = "2026-01-21T14:26:51.89Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" }, +] + +[[package]] +name = "pydantic" +version = "2.13.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-types" }, + { name = "pydantic-core" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/18/a5/b60d21ac674192f8ab0ba4e9fd860690f9b4a6e51ca5df118733b487d8d6/pydantic-2.13.4.tar.gz", hash = "sha256:c40756b57adaa8b1efeeced5c196f3f3b7c435f90e84ea7f443901bec8099ef6", size = 844775, upload-time = "2026-05-06T13:43:05.343Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fd/7b/122376b1fd3c62c1ed9dc80c931ace4844b3c55407b6fb2d199377c9736f/pydantic-2.13.4-py3-none-any.whl", hash = "sha256:45a282cde31d808236fd7ea9d919b128653c8b38b393d1c4ab335c62924d9aba", size = 472262, upload-time = "2026-05-06T13:43:02.641Z" }, +] + +[[package]] +name = "pydantic-core" +version = "2.46.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9d/56/921726b776ace8d8f5db44c4ef961006580d91dc52b803c489fafd1aa249/pydantic_core-2.46.4.tar.gz", hash = "sha256:62f875393d7f270851f20523dd2e29f082bcc82292d66db2b64ea71f64b6e1c1", size = 471464, upload-time = "2026-05-06T13:37:06.98Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/51/a2/5d30b469c5267a17b39dec53208222f76a8d351dfac4af661888c5aee77d/pydantic_core-2.46.4-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:5d5902252db0d3cedf8d4a1bc68f70eeb430f7e4c7104c8c476753519b423008", size = 2106306, upload-time = "2026-05-06T13:37:48.029Z" }, + { url = "https://files.pythonhosted.org/packages/c1/81/4fa520eaffa8bd7d1525e644cd6d39e7d60b1592bc5b516693c7340b50f1/pydantic_core-2.46.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:c94f0688e7b8d0a67abf40e57a7eaaecd17cc9586706a31b76c031f63df052b4", size = 1951906, upload-time = "2026-05-06T13:37:17.012Z" }, + { url = "https://files.pythonhosted.org/packages/03/d5/fd02da45b659668b05923b17ba3a0100a0a3d5541e3bd8fcc4ecb711309e/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f027324c56cd5406ca49c124b0db10e56c69064fec039acc571c29020cc87c76", size = 1976802, upload-time = "2026-05-06T13:37:35.113Z" }, + { url = "https://files.pythonhosted.org/packages/21/f2/95727e1368be3d3ed485eaab7adbd7dda408f33f7a36e8b48e0144002b91/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e739fee756ba1010f8bcccb534252e85a35fe45ae92c295a06059ce58b74ccd3", size = 2052446, upload-time = "2026-05-06T13:37:12.313Z" }, + { url = "https://files.pythonhosted.org/packages/9c/86/5d99feea3f77c7234b8718075b23db11532773c1a0dbd9b9490215dc2eeb/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9d56801be94b86a9da183e5f3766e6310752b99ff647e38b09a9500d88e46e76", size = 2232757, upload-time = "2026-05-06T13:39:01.149Z" }, + { url = "https://files.pythonhosted.org/packages/d2/3a/508ac615935ef7588cf6d9e9b91309fdc2da751af865e02a9098de88258c/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2412e734dcb48da14d4e4006b82b46b74f2518b8a26ee7e58c6844a6cd6d03c4", size = 2309275, upload-time = "2026-05-06T13:37:41.406Z" }, + { url = "https://files.pythonhosted.org/packages/07/f8/41db9de19d7987d6b04715a02b3b40aea467000275d9d758ffaa31af7d50/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9551187363ffc0de2a00b2e47c25aeaeb1020b69b668762966df15fc5659dd5a", size = 2094467, upload-time = "2026-05-06T13:39:18.847Z" }, + { url = "https://files.pythonhosted.org/packages/2c/e2/f35033184cb11d0052daf4416e8e10a502ea2ac006fc4f459aee872727d1/pydantic_core-2.46.4-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:0186750b482eefa11d7f435892b09c5c606193ef3375bcf94aa00ae6bfb66262", size = 2134417, upload-time = "2026-05-06T13:40:17.944Z" }, + { url = "https://files.pythonhosted.org/packages/7e/7b/6ceeb1cc90e193862f444ebe373d8fdf613f0a82572dde03fb10734c6c71/pydantic_core-2.46.4-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:5855698a4856556d86e8e6cd8434bc3ac0314ee8e12089ae0e143f64c6256e4e", size = 2179782, upload-time = "2026-05-06T13:40:32.618Z" }, + { url = "https://files.pythonhosted.org/packages/5a/f2/c8d7773ede6af08036423a00ae0ceffce266c3c52a096c435d68c896083f/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:cbaf13819775b7f769bf4a1f066cb6df7a28d4480081a589828ef190226881cd", size = 2188782, upload-time = "2026-05-06T13:36:51.018Z" }, + { url = "https://files.pythonhosted.org/packages/59/31/0c864784e31f09f05cdd87606f08923b9c9e7f6e51dd27f20f62f975ce9f/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:633147d34cf4550417f12e2b1a0383973bdf5cdfde212cb09e9a581cf10820be", size = 2328334, upload-time = "2026-05-06T13:40:37.764Z" }, + { url = "https://files.pythonhosted.org/packages/c2/eb/4f6c8a41efa30baa755590f4141abf3a8c370fab610915733e74134a7270/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:82cf5301172168103724d49a1444d3378cb20cdee30b116a1bd6031236298a5d", size = 2372986, upload-time = "2026-05-06T13:39:34.152Z" }, + { url = "https://files.pythonhosted.org/packages/5b/24/b375a480d53113860c299764bfe9f349a3dc9108b3adc0d7f0d786492ebf/pydantic_core-2.46.4-cp313-cp313-win32.whl", hash = "sha256:9fa8ae11da9e2b3126c6426f147e0fba88d96d65921799bb30c6abd1cb2c97fb", size = 1973693, upload-time = "2026-05-06T13:37:55.072Z" }, + { url = "https://files.pythonhosted.org/packages/7e/e8/cff247591966f2d22ec8c003cd7587e27b7ba7b81ab2fb888e3ab75dc285/pydantic_core-2.46.4-cp313-cp313-win_amd64.whl", hash = "sha256:6b3ace8194b0e5204818c92802dcdca7fc6d88aabbb799d7c795540d9cd6d292", size = 2071819, upload-time = "2026-05-06T13:38:49.139Z" }, + { url = "https://files.pythonhosted.org/packages/c6/1a/f4aee670d5670e9e148e0c82c7db98d780be566c6e6a97ee8035528ca0b3/pydantic_core-2.46.4-cp313-cp313-win_arm64.whl", hash = "sha256:184c081504d17f1c1066e430e117142b2c77d9448a97f7b65c6ac9fd9aee238d", size = 2027411, upload-time = "2026-05-06T13:40:45.796Z" }, +] + +[[package]] +name = "pydantic-settings" +version = "2.15.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pydantic" }, + { name = "python-dotenv" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/68/ca/31c57507b13119d7d3cfa1576dad2911a4861e3be07b579395f4e9d393f9/pydantic_settings-2.15.0.tar.gz", hash = "sha256:694b793e84f766ba76a90ebdefc01d0a9a045dab0382bee70393da93712ad117", size = 261253, upload-time = "2026-08-07T09:24:57.419Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/30/a4/2bffa9f8e804325a09867f0e9d30795c80ea9f8d62560bd1b6ad6220eb2f/pydantic_settings-2.15.0-py3-none-any.whl", hash = "sha256:0ba092c291c94baceb5eff768aa0d56400a457585bc0175925a5a5510303da42", size = 69413, upload-time = "2026-08-07T09:24:55.839Z" }, +] + +[[package]] +name = "pygments" +version = "2.21.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/49/2e/ced460408999b33da6b31b0021b0f37d329e202d4169aeb164493778f25b/pygments-2.21.0.tar.gz", hash = "sha256:610ca751c9bc2492b38eb9a38a7fbc93edbbb2d7182edaf34e66ae493dee5c8c", size = 5005329, upload-time = "2026-08-17T08:02:48.824Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/71/46/17f022dd3e953bf20a04a028a21ec746d942f8d2af30fa0f124fa0e6a684/pygments-2.21.0-py3-none-any.whl", hash = "sha256:2363c69b61c4a97c838da3b130dcd6468f4848992b21a82f2a63ec34377137d9", size = 1250147, upload-time = "2026-08-17T08:02:44.912Z" }, +] + +[[package]] +name = "pyjwt" +version = "2.13.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/3b/81/58d0ac84e1ef3a3843791d6954d94c0b33d526c75eeb1efbce9d0a4c4077/pyjwt-2.13.0.tar.gz", hash = "sha256:41571c89ca91598c79e8ef18a2d07367d4810fbbd6f637794879baf1b7703423", size = 107515, upload-time = "2026-05-21T19:54:36.618Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a3/5e/ecf12fdb62546d64385c158514e9b2b671f7832108ef2ecd2020ce0af2d1/pyjwt-2.13.0-py3-none-any.whl", hash = "sha256:66adcc2aff09b3f1bbd95fc1e1577df8ac8723c978552fd43304c8a290ac5728", size = 31274, upload-time = "2026-05-21T19:54:35.362Z" }, +] + +[[package]] +name = "pytest" +version = "8.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a3/5c/00a0e072241553e1a7496d638deababa67c5058571567b92a7eaa258397c/pytest-8.4.2.tar.gz", hash = "sha256:86c0d0b93306b961d58d62a4db4879f27fe25513d4b969df351abdddb3c30e01", size = 1519618, upload-time = "2025-09-04T14:34:22.711Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a8/a4/20da314d277121d6534b3a980b29035dcd51e6744bd79075a6ce8fa4eb8d/pytest-8.4.2-py3-none-any.whl", hash = "sha256:872f880de3fc3a5bdc88a11b39c9710c3497a547cfa9320bc3c5e62fbf272e79", size = 365750, upload-time = "2025-09-04T14:34:20.226Z" }, +] + +[[package]] +name = "pytest-asyncio" +version = "1.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/43/7c/d36d04db312ecf4298932ef77e6e4a9e8ad017906e24e34f0b0c361a2473/pytest_asyncio-1.4.0.tar.gz", hash = "sha256:c6c0d2259945122819f171a32ecea2c349ead889ee28176caaf492143424be42", size = 58514, upload-time = "2026-05-26T09:56:04.083Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/03/e2/08a497ef684b88559c9cc5f4ad53a37e7b99e727094a86d6ea32536d5d3c/pytest_asyncio-1.4.0-py3-none-any.whl", hash = "sha256:933ca923a23075a87fb7070c0ec272a6848489824d887c85c812670932835aa1", size = 16930, upload-time = "2026-05-26T09:56:02.576Z" }, +] + +[[package]] +name = "python-dotenv" +version = "1.2.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6a/53/ed9d74092561d4b01a2ef1349d52cdbc135e526c245f366b089cfca6de49/python_dotenv-1.2.3.tar.gz", hash = "sha256:a20a594dabeaa385725aa239d5244871c143ecb356add8a20fcf23773a6c3a35", size = 58945, upload-time = "2026-08-16T16:54:54.067Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0d/17/c5c6b53ddc18f297992099b3d9ec16c855c0ccc83263a21fe4d1c625ec6c/python_dotenv-1.2.3-py3-none-any.whl", hash = "sha256:904552145e8bfed22162c09dab1c2b9b54fefa7b23ba780f4f26ca0316b0f0d9", size = 22780, upload-time = "2026-08-16T16:54:52.473Z" }, +] + +[[package]] +name = "pyyaml" +version = "6.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" }, + { url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" }, + { url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" }, + { url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" }, + { url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" }, + { url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" }, + { url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" }, + { url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" }, + { url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" }, + { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" }, +] + +[[package]] +name = "referencing" +version = "0.37.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "rpds-py" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/22/f5/df4e9027acead3ecc63e50fe1e36aca1523e1719559c499951bb4b53188f/referencing-0.37.0.tar.gz", hash = "sha256:44aefc3142c5b842538163acb373e24cce6632bd54bdb01b21ad5863489f50d8", size = 78036, upload-time = "2025-10-13T15:30:48.871Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2c/58/ca301544e1fa93ed4f80d724bf5b194f6e4b945841c5bfd555878eea9fcb/referencing-0.37.0-py3-none-any.whl", hash = "sha256:381329a9f99628c9069361716891d34ad94af76e461dcb0335825aecc7692231", size = 26766, upload-time = "2025-10-13T15:30:47.625Z" }, +] + +[[package]] +name = "requests" +version = "2.34.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "charset-normalizer" }, + { name = "idna" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ac/c3/e2a2b89f2d3e2179abd6d00ebd70bff6273f37fb3e0cc209f48b39d00cbf/requests-2.34.2.tar.gz", hash = "sha256:f288924cae4e29463698d6d60bc6a4da69c89185ad1e0bcc4104f584e960b9ed", size = 142856, upload-time = "2026-05-14T19:25:27.735Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/f4/c67b0b3f1b9245e8d266f0f112c500d50e5b4e83cb6f3b71b6528104182a/requests-2.34.2-py3-none-any.whl", hash = "sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0", size = 73075, upload-time = "2026-05-14T19:25:26.443Z" }, +] + +[[package]] +name = "rich" +version = "15.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown-it-py" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c0/8f/0722ca900cc807c13a6a0c696dacf35430f72e0ec571c4275d2371fca3e9/rich-15.0.0.tar.gz", hash = "sha256:edd07a4824c6b40189fb7ac9bc4c52536e9780fbbfbddf6f1e2502c31b068c36", size = 230680, upload-time = "2026-04-12T08:24:00.75Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/82/3b/64d4899d73f91ba49a8c18a8ff3f0ea8f1c1d75481760df8c68ef5235bf5/rich-15.0.0-py3-none-any.whl", hash = "sha256:33bd4ef74232fb73fe9279a257718407f169c09b78a87ad3d296f548e27de0bb", size = 310654, upload-time = "2026-04-12T08:24:02.83Z" }, +] + +[[package]] +name = "rpds-py" +version = "2026.6.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/aa/2a/9618a122aeb2a169a28b03889a2995fe297588964333d4a7d67bdf46e147/rpds_py-2026.6.3.tar.gz", hash = "sha256:1cebd1337c242e4ec2293e541f712b2da849b29f48f0c293684b71c0632625d4", size = 64051, upload-time = "2026-06-30T07:17:53.009Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a4/9e/b818ee580026ec578138e961027a68820c40afeb1ec8f6819b54fb99e196/rpds_py-2026.6.3-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:3cfe765c1da0072636ca06628261e0ea05688e160d5c8a03e0217c3854037223", size = 343012, upload-time = "2026-06-30T07:15:36.005Z" }, + { url = "https://files.pythonhosted.org/packages/f3/6b/686d9dc4359a8f163cfbbf89ee0b4e586431de22fe8248edb63a8cf50d49/rpds_py-2026.6.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f4d78253f6996be4901669ad25319f842f740eccf4d58e3c7f3dd39e6dde1d8f", size = 338203, upload-time = "2026-06-30T07:15:37.462Z" }, + { url = "https://files.pythonhosted.org/packages/9e/9b/069aa329940f8207615e091f5eedbbd40e1e15eac68a0790fd05ccdf796c/rpds_py-2026.6.3-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:54f45a148e28767bf343d33a684693c70e451c6f4c0e9904709a723fafbdfc1f", size = 367984, upload-time = "2026-06-30T07:15:39.008Z" }, + { url = "https://files.pythonhosted.org/packages/14/db/34c203e4becff3703e4d3bc121842c00b8689197f398161203a880052f4e/rpds_py-2026.6.3-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:842e7b070435622248c7a2c44ae53fa1440e073cc3023bc919fed570884097a7", size = 374815, upload-time = "2026-06-30T07:15:40.253Z" }, + { url = "https://files.pythonhosted.org/packages/ee/7d/8071067d2cc453d916ad836e828c943f575e8a44612537759002a1e07381/rpds_py-2026.6.3-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8020133a74bd81b4572dd8e4be028a6b1ebcd70e6726edc3918008c08bee6ee6", size = 490545, upload-time = "2026-06-30T07:15:41.729Z" }, + { url = "https://files.pythonhosted.org/packages/a3/42/da06c5aa8f0484ff07f270787434204d9f4535e2f8c3b51ed402267e63c3/rpds_py-2026.6.3-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cdc7e35386f3847df728fbcb5e887e2d79c19e2fa1eba9e51b6621d23e3243af", size = 382828, upload-time = "2026-06-30T07:15:43.327Z" }, + { url = "https://files.pythonhosted.org/packages/57/d7/fe978efc2ae50abe48eb7464668ea99f53c010c60aeebb7b35ad27f23661/rpds_py-2026.6.3-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:acac386b453c2516111b50985d60ce46e7fadb5ea71ae7b25f4c946935bf27cf", size = 365678, upload-time = "2026-06-30T07:15:44.992Z" }, + { url = "https://files.pythonhosted.org/packages/69/9d/1d8922e1990b2a6eb532b6ff53d3e73d2b3bbffc84116c75826bee73dfc6/rpds_py-2026.6.3-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:425560c6fa0415f27261727bb20bd097568485e5eb0c121f1949417d1c516885", size = 377811, upload-time = "2026-06-30T07:15:46.523Z" }, + { url = "https://files.pythonhosted.org/packages/b1/3d/198dceafb4fb034a6a47347e1b0735d34e0bd4a50be4e898d408ee66cb14/rpds_py-2026.6.3-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a550fb4950a06dde3beb4721f5ad4b25bf4513784665b0a8522c792e2bd822a4", size = 395382, upload-time = "2026-06-30T07:15:47.955Z" }, + { url = "https://files.pythonhosted.org/packages/1f/f1/13968e49655d40b6b19d8b9140296bbc6f1d86b3f0f6c346cf9f1adddf4b/rpds_py-2026.6.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4f4bca01b63096f606e095734dd56e74e175f94cfbf24ff3d63281cec61f7bb7", size = 543832, upload-time = "2026-06-30T07:15:49.33Z" }, + { url = "https://files.pythonhosted.org/packages/ac/ab/289bcb1b90bd3e40a2900c561fa0e2087345ecbb094f0b870f2345142b7c/rpds_py-2026.6.3-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:ccffae9a092a00deb7efd545fe5e2c33c33b88e7c054337e9a74c179347d0b7d", size = 611011, upload-time = "2026-06-30T07:15:50.847Z" }, + { url = "https://files.pythonhosted.org/packages/1e/16/5043105e679436ccfbc8e5e0dd2d663ed18a8b8113515fd06a5e5d77c83e/rpds_py-2026.6.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1cf01971c4f2c5553b772a542e4aaf191789cd331bc2cd4ff0e6e65ba49e1e97", size = 572431, upload-time = "2026-06-30T07:15:52.394Z" }, + { url = "https://files.pythonhosted.org/packages/85/ed/adab103321c0a6565d5ae1c2998349bc3ee175b82ccc5ae8fc04cc413075/rpds_py-2026.6.3-cp313-cp313-win32.whl", hash = "sha256:8c3d1e9c15b9d51ca0391e13da1a25a0a4df3c58a37c9dc368e0736cf7f69df0", size = 201710, upload-time = "2026-06-30T07:15:53.894Z" }, + { url = "https://files.pythonhosted.org/packages/7b/ed/a03b09668e74e5dabbf2e211f6468e1820c0552f7b0500082da31841bf7b/rpds_py-2026.6.3-cp313-cp313-win_amd64.whl", hash = "sha256:9250a9a0a6fd4648b3f868da8d91a4c52b5811a62df58e753d50ae4454a36f80", size = 219454, upload-time = "2026-06-30T07:15:55.25Z" }, + { url = "https://files.pythonhosted.org/packages/27/17/b8642c12930b71bc2b25831f6708ccf0f75abcd11883932ec9ce54ba3a78/rpds_py-2026.6.3-cp313-cp313-win_arm64.whl", hash = "sha256:900a67df3fd1660b035a4761c4ce73c382ea6b35f90f9863c36c6fd8bf8b09bb", size = 215063, upload-time = "2026-06-30T07:15:56.573Z" }, +] + +[[package]] +name = "ruff" +version = "0.16.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/00/8f/d8074b1f25e003164087a8bfe79a0f1a3945135764dbb6aaab04103dcaf9/ruff-0.16.4.tar.gz", hash = "sha256:13171aa9d9af2240ee3504e639de73122c67e74036de5ba2e1d01422cd17e3dc", size = 4899731, upload-time = "2026-08-20T17:43:59.196Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ff/80/779895ef584e089d22f2c6df0d0e99a65ec2df0805f1fffd439415b8c1f0/ruff-0.16.4-py3-none-linux_armv6l.whl", hash = "sha256:df4075f71ddac40b9934af60c3ec8a53047dd5a5fdc43224e6e4e8e9a27cb6f7", size = 10006909, upload-time = "2026-08-20T17:43:16.888Z" }, + { url = "https://files.pythonhosted.org/packages/a9/e6/f553199b5e8927a05cb5c422d921fd0656b29ab976e91c44802107c6b0da/ruff-0.16.4-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:0c95538517af68004306b0fb3214ff2f2af67a65092aee77cd9eb86db6656604", size = 10240201, upload-time = "2026-08-20T17:43:19.337Z" }, + { url = "https://files.pythonhosted.org/packages/1c/70/4a6dc4bb34da4dee35e30f09bbd1bfbdd26f33b62fb9b8df31f08a199cd2/ruff-0.16.4-py3-none-macosx_11_0_arm64.whl", hash = "sha256:963f83df8e69e575b64d67dd447ebbc917db41a14bf38d4593a4183e7aaa8255", size = 9835122, upload-time = "2026-08-20T17:43:21.708Z" }, + { url = "https://files.pythonhosted.org/packages/24/12/c6e22d686372c15bcb7af99831f1a1be96df696491babf4f24e4f942c527/ruff-0.16.4-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:32a5057c7ff3f6e6480a48fccfb3a412a690f48a3d03ac5cf08177d6c2da3ade", size = 9977162, upload-time = "2026-08-20T17:43:24.236Z" }, + { url = "https://files.pythonhosted.org/packages/46/49/72b10ec912f5ab5854992eaf7aa7cd36729b6937d9dc4e0fb41b3bf428ec/ruff-0.16.4-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b3dce8d9b0c57c265b91885a66a567d8ea1372e8eb4e250fa8e5e3f579e99cff", size = 9829789, upload-time = "2026-08-20T17:43:26.966Z" }, + { url = "https://files.pythonhosted.org/packages/fa/80/0f30e32e7f6ee26edc39075502db9d368d788a44a79b55f763eb4ab03796/ruff-0.16.4-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7dc651db49283c69f8e72c834eec4fe5573e4c646856aebece0ce385dceb2a80", size = 10527949, upload-time = "2026-08-20T17:43:29.384Z" }, + { url = "https://files.pythonhosted.org/packages/52/3d/86e8ad3542169e56cac3859a343afdb9df2ad54d35a59ce1e67baee83421/ruff-0.16.4-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3817b87dbcabc92f13b05019257c5b89b5b4d51b5fb20f56fb5235ceb723cd07", size = 11333695, upload-time = "2026-08-20T17:43:31.872Z" }, + { url = "https://files.pythonhosted.org/packages/d0/16/481c29b380c20a0054a8261066665e1b3488e23636c49d0a43e75975b9bb/ruff-0.16.4-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e9fce1499134b2c8c68e5166f95705a5812062bb93aacc5f9873bb1a27084bc7", size = 10727741, upload-time = "2026-08-20T17:43:34.596Z" }, + { url = "https://files.pythonhosted.org/packages/5e/b6/56bc0b8cf45b54b28b3a5e6381c8945d51b5b18adf659454c32295209a31/ruff-0.16.4-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f2d812e482f5a7e02eee26cd73d2a37ebbdf47d795ea63ba1b89110ae93e9fb3", size = 10286522, upload-time = "2026-08-20T17:43:37.288Z" }, + { url = "https://files.pythonhosted.org/packages/e8/8b/b345b4fb110f2fbe2bd31eabd271e5e8b3b7e4ee6c0e02f2dc6be78db000/ruff-0.16.4-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:6baaf984aa7976edf93d3b627fe2d1d22ee94bbca05fa6f90fc76d73924e3454", size = 10584182, upload-time = "2026-08-20T17:43:39.984Z" }, + { url = "https://files.pythonhosted.org/packages/29/e5/827b34041c35f58774a9681a4213994c164fc987800f4dddabcf451da0bf/ruff-0.16.4-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:bdfcf0b28662eb890372d50f92c283bb94e67e7635ed93c7fd533970acff7b2b", size = 10134195, upload-time = "2026-08-20T17:43:42.351Z" }, + { url = "https://files.pythonhosted.org/packages/0f/10/d0bffcdd6729b87afc82ba0ef377173356a7dc8e972f5179968cf2fdf98c/ruff-0.16.4-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:b66b02cb9b04f537643cadf5768e5f98dc461890d530cb67113d71c8c76e605d", size = 9825821, upload-time = "2026-08-20T17:43:44.532Z" }, + { url = "https://files.pythonhosted.org/packages/f5/32/0db2a863b796ca62d83e92a07a3ccf00921b14db02059347576a2fda3d4b/ruff-0.16.4-py3-none-musllinux_1_2_i686.whl", hash = "sha256:8528bf9a4b291a60bf02ea453511e8ce6215bd2b982ee80405b66b008b6c30a0", size = 10267658, upload-time = "2026-08-20T17:43:46.989Z" }, + { url = "https://files.pythonhosted.org/packages/b2/a0/fbdeb59e48c6261f523e56c8f12e9c08fbe693786595cc7e3959207a9232/ruff-0.16.4-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:fbd85d2875fdd67e833213a651f613bbf25303abf6aa822a5121f4531195678d", size = 10697071, upload-time = "2026-08-20T17:43:49.891Z" }, + { url = "https://files.pythonhosted.org/packages/aa/28/0c6dd865859c6d17bc8ccc34cb72b0e02d6c7eb25e8a1e22b5bea681e2c0/ruff-0.16.4-py3-none-win32.whl", hash = "sha256:312769988007aaeb8e189b443ccdd03c0e6374489e053467be6d96518ebff76e", size = 10021687, upload-time = "2026-08-20T17:43:52.281Z" }, + { url = "https://files.pythonhosted.org/packages/a3/03/e724450f621698117f9aa6dd241c94d0274ae96781378dc86745ae29f0e7/ruff-0.16.4-py3-none-win_amd64.whl", hash = "sha256:05d9d27a18c4bcbefada602480ec9e01e0bc949d432e0ced5df77edac195919c", size = 10567657, upload-time = "2026-08-20T17:43:54.78Z" }, + { url = "https://files.pythonhosted.org/packages/0e/fe/da8b9e1347696bb22120b77280ec5ce25d500ca5cb39d5ad6e5c18de19c1/ruff-0.16.4-py3-none-win_arm64.whl", hash = "sha256:a3a61621c9b6f6a89573e938a080e648f1695baa3f58570a3a707bc51ff65a21", size = 10451579, upload-time = "2026-08-20T17:43:57.135Z" }, +] + +[[package]] +name = "shellingham" +version = "1.5.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/58/15/8b3609fd3830ef7b27b655beb4b4e9c62313a4e8da8c676e142cc210d58e/shellingham-1.5.4.tar.gz", hash = "sha256:8dbca0739d487e5bd35ab3ca4b36e11c4078f3a234bfce294b0a0291363404de", size = 10310, upload-time = "2023-10-24T04:13:40.426Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686", size = 9755, upload-time = "2023-10-24T04:13:38.866Z" }, +] + +[[package]] +name = "sniffio" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/87/a6771e1546d97e7e041b6ae58d80074f81b7d5121207425c964ddf5cfdbd/sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc", size = 20372, upload-time = "2024-02-25T23:20:04.057Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235, upload-time = "2024-02-25T23:20:01.196Z" }, +] + +[[package]] +name = "sounddevice" +version = "0.5.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ec/db/0c890e2d9aab9ba284021efc02e1d3aebfecab1b611762d7434602209bcf/sounddevice-0.5.6.tar.gz", hash = "sha256:8ec9fbfde2e32f020b167e348f3ab3bac6625a5f15af524d790108ac7147a410", size = 1120094, upload-time = "2026-08-17T07:55:05.048Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/72/1f/62eef605172bddc1017508469a12f75bc7c4194ece35c734f822795f53b1/sounddevice-0.5.6-py3-none-any.whl", hash = "sha256:de099612311ad81e55d31ccbd83f43ea6bf4d87b48f9b6ea55a1fbcde0eee4e0", size = 32793, upload-time = "2026-08-17T07:54:57.507Z" }, + { url = "https://files.pythonhosted.org/packages/b6/84/85e719d49cf98b2f406d9ac9c338892286c4448eb42ef0b2625ccf159616/sounddevice-0.5.6-py3-none-macosx_10_6_x86_64.macosx_10_6_universal2.whl", hash = "sha256:e3aef00ad8b1d1740eb66d9a7671eab88a4d2b8fa4ab33498d742e63b65c309c", size = 1009647, upload-time = "2026-08-17T07:54:58.814Z" }, + { url = "https://files.pythonhosted.org/packages/c5/6f/6292145099f72a153a710245f46ae43e5fb6c77bec1b6086cb76c12dc280/sounddevice-0.5.6-py3-none-win32.whl", hash = "sha256:b36b807eb02abd257198bf84b2af05e4fea199a9d2f0019014169c7136d45e9c", size = 1009627, upload-time = "2026-08-17T07:55:00.401Z" }, + { url = "https://files.pythonhosted.org/packages/8d/3e/cbc593c31a5f0d817b3fe97e64aa8461bd0f55cb07b67ce1b776296ae336/sounddevice-0.5.6-py3-none-win_amd64.whl", hash = "sha256:7f4162f514f007b0bf25a3ccfed3f1705bc2ec311888a90232729eec4f57a4f4", size = 1009630, upload-time = "2026-08-17T07:55:02.088Z" }, + { url = "https://files.pythonhosted.org/packages/60/a4/b0c21c9f215a6fd9606b8f8748c21212dc098e5d5a2d93068c50edcf19b4/sounddevice-0.5.6-py3-none-win_arm64.whl", hash = "sha256:c8ae19173e5f27f8c12d4b5eee2dbfe542cee125d591e663e0fb4dfb75246d45", size = 1009630, upload-time = "2026-08-17T07:55:03.689Z" }, +] + +[[package]] +name = "starlette" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b5/b4/205b0d5241d934e8add0c38aa924c4f9fb7330834ff11e5444db964ec3f9/starlette-1.6.0.tar.gz", hash = "sha256:d4e3ac5e546444960c710297a3c9fc3f7ebae1b7e963f3d36173b49da535be9b", size = 2716969, upload-time = "2026-08-08T18:27:57.512Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c8/cb/6a6a47d5b464bd08695d254f3da6e7986cc70c9fa5d778eda57538edfe56/starlette-1.6.0-py3-none-any.whl", hash = "sha256:a86dd39d14bb45f85a3d18525215a9ef0cfd1f192ac793220e72598c90335f0c", size = 75969, upload-time = "2026-08-08T18:27:56.196Z" }, +] + +[[package]] +name = "tqdm" +version = "4.70.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/21/3b/6c24bec5be5e743ffd99576daa5cc077722fc7d5bbc00bd133fa0c698dc6/tqdm-4.70.0.tar.gz", hash = "sha256:55b0b0dbd97462d06ebee91e4dac24ed4d4702be82b24f07e6c1d27e08cea220", size = 795438, upload-time = "2026-07-27T11:33:15.271Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f9/1c/01bfd571a64e7f270e6bab5e33777debe0edc56759233ce84f27dec92d14/tqdm-4.70.0-py3-none-any.whl", hash = "sha256:7f585706bfddbdebf89daac705b2dfcc16890130727d3197ca62c732b4310953", size = 80184, upload-time = "2026-07-27T11:33:13.167Z" }, +] + +[[package]] +name = "typer" +version = "0.27.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-doc" }, + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "rich" }, + { name = "shellingham" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ae/40/4a3db7990d1f62a53182aa96eaef57aeb2886a27f90a195bc66713565d31/typer-0.27.1.tar.gz", hash = "sha256:a79bef8469a79c45498e7b814ecf8d603cc7644e9acbd9e19cac0334240b18df", size = 203994, upload-time = "2026-08-03T14:41:03.438Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/43/89/9518bc0c3929bee36b3a4a8e3daddd6e03f92f9961c66d4983b837160543/typer-0.27.1-py3-none-any.whl", hash = "sha256:53150287edd11baeb4e4722c8e394fcdf8181c0ae89485cba8d25c778d5edd56", size = 122874, upload-time = "2026-08-03T14:41:04.391Z" }, +] + +[[package]] +name = "types-protobuf" +version = "7.35.1.20260824" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5b/9a/7725bf5ee3d1da5eebee6d6df2475efb87091b2dcbf85164b396e8bf6904/types_protobuf-7.35.1.20260824.tar.gz", hash = "sha256:9c40a3d4856b7d8e47a085dcf3ee82d06ce470fbad79488dfa49027ee15c619f", size = 69619, upload-time = "2026-08-24T02:53:16.045Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e5/ca/53324903b95d00ad80eaa732a0a195714ebac21b112b62b4e0652524c93e/types_protobuf-7.35.1.20260824-py3-none-any.whl", hash = "sha256:a05660361587d210a3e1fd50e68ea8f25286b52eb342c0f6bf97d83804b05bec", size = 86411, upload-time = "2026-08-24T02:53:14.982Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5", size = 113555, upload-time = "2026-07-02T08:40:05.92Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" }, +] + +[[package]] +name = "typing-inspection" +version = "0.4.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a3/26/b09b8010994eccc3c09092e6b34058f36a460eea2d4c3e8b910c695975a0/typing_inspection-0.4.4.tar.gz", hash = "sha256:547274fa6b0a561ccf549cc9524b999a578e737d015d8709d021f9d0d13bea47", size = 76928, upload-time = "2026-08-12T12:37:25.997Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/67/81/4add07e5172b7ac40d8ed5ff580409a7801a4fe26d529bdd915401dabfbe/typing_inspection-0.4.4-py3-none-any.whl", hash = "sha256:65b8397ba37ccbce054456aaccddfc91e6e3083c92824df348d96ca832f3f147", size = 14750, upload-time = "2026-08-12T12:37:24.648Z" }, +] + +[[package]] +name = "urllib3" +version = "2.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/53/0c/06f8b233b8fd13b9e5ee11424ef85419ba0d8ba0b3138bf360be2ff56953/urllib3-2.7.0.tar.gz", hash = "sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c", size = 433602, upload-time = "2026-05-07T16:13:18.596Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7f/3e/5db95bcf282c52709639744ca2a8b149baccf648e39c8cc87553df9eae0c/urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897", size = 131087, upload-time = "2026-05-07T16:13:17.151Z" }, +] + +[[package]] +name = "uvicorn" +version = "0.52.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f2/0f/3f86e61397dd33bf2ccf28188c40db6a740658aeebbbf6e7dbc101a1f487/uvicorn-0.52.4.tar.gz", hash = "sha256:73acfee47a0b133c5de13d219492d62d8a31e935f4fe6e41a232451a15379f86", size = 100627, upload-time = "2026-08-19T06:27:41.821Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f1/79/4a20b54ab0491485ccd8c077db2d39187c7f12b3e15485d38a7be37c81b4/uvicorn-0.52.4-py3-none-any.whl", hash = "sha256:f86e41a149d7d05a9969337e3946a9c171c06a5d42680896daaba624aeac8da1", size = 79871, upload-time = "2026-08-19T06:27:40.36Z" }, +] + +[package.optional-dependencies] +standard = [ + { name = "httptools" }, + { name = "python-dotenv" }, + { name = "pyyaml" }, + { name = "uvloop", marker = "platform_python_implementation != 'PyPy' and sys_platform != 'cygwin' and sys_platform != 'win32'" }, + { name = "watchfiles" }, + { name = "websockets" }, +] + +[[package]] +name = "uvloop" +version = "0.22.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/06/f0/18d39dbd1971d6d62c4629cc7fa67f74821b0dc1f5a77af43719de7936a7/uvloop-0.22.1.tar.gz", hash = "sha256:6c84bae345b9147082b17371e3dd5d42775bddce91f885499017f4607fdaf39f", size = 2443250, upload-time = "2025-10-16T22:17:19.342Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/89/8c/182a2a593195bfd39842ea68ebc084e20c850806117213f5a299dfc513d9/uvloop-0.22.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:561577354eb94200d75aca23fbde86ee11be36b00e52a4eaf8f50fb0c86b7705", size = 1358611, upload-time = "2025-10-16T22:16:36.833Z" }, + { url = "https://files.pythonhosted.org/packages/d2/14/e301ee96a6dc95224b6f1162cd3312f6d1217be3907b79173b06785f2fe7/uvloop-0.22.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:1cdf5192ab3e674ca26da2eada35b288d2fa49fdd0f357a19f0e7c4e7d5077c8", size = 751811, upload-time = "2025-10-16T22:16:38.275Z" }, + { url = "https://files.pythonhosted.org/packages/b7/02/654426ce265ac19e2980bfd9ea6590ca96a56f10c76e63801a2df01c0486/uvloop-0.22.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6e2ea3d6190a2968f4a14a23019d3b16870dd2190cd69c8180f7c632d21de68d", size = 4288562, upload-time = "2025-10-16T22:16:39.375Z" }, + { url = "https://files.pythonhosted.org/packages/15/c0/0be24758891ef825f2065cd5db8741aaddabe3e248ee6acc5e8a80f04005/uvloop-0.22.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0530a5fbad9c9e4ee3f2b33b148c6a64d47bbad8000ea63704fa8260f4cf728e", size = 4366890, upload-time = "2025-10-16T22:16:40.547Z" }, + { url = "https://files.pythonhosted.org/packages/d2/53/8369e5219a5855869bcee5f4d317f6da0e2c669aecf0ef7d371e3d084449/uvloop-0.22.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:bc5ef13bbc10b5335792360623cc378d52d7e62c2de64660616478c32cd0598e", size = 4119472, upload-time = "2025-10-16T22:16:41.694Z" }, + { url = "https://files.pythonhosted.org/packages/f8/ba/d69adbe699b768f6b29a5eec7b47dd610bd17a69de51b251126a801369ea/uvloop-0.22.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1f38ec5e3f18c8a10ded09742f7fb8de0108796eb673f30ce7762ce1b8550cad", size = 4239051, upload-time = "2025-10-16T22:16:43.224Z" }, +] + +[[package]] +name = "watchfiles" +version = "1.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/cd/41/5e1a4bb12aac5f1493fa1bdc11154eca3b258ca4eba65d39c473fe19d8e9/watchfiles-1.2.0.tar.gz", hash = "sha256:c995fba777f1ea992f090f9236e9284cf7a5d1a0130dd5a3d82c598cacd76838", size = 108252, upload-time = "2026-05-18T04:32:04.251Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/4d/70a7feced9f87e2ff26dba42667290f41694fc64646c67261fbb8cab5d5c/watchfiles-1.2.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:01ea8d66f0693b9b60a6541c8d10263091ca9a9060d242f3c1f3143f9aad2c98", size = 399730, upload-time = "2026-05-18T04:31:38.162Z" }, + { url = "https://files.pythonhosted.org/packages/31/3a/0da302f2307aee316922806ebd5726c542cbd787c938271cf14a074c7daf/watchfiles-1.2.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7ba0480b9a74af058f43b337e937a451e109295c420916d68ad24e3dc02f5e44", size = 392842, upload-time = "2026-05-18T04:30:27.051Z" }, + { url = "https://files.pythonhosted.org/packages/db/ef/d5bdb705c224dbc256aa0c1ec47bf4e61ec52558f2afb44a71a1fe4d7015/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4f34e26a19f91f710c08e0183429f0d1d15df734e6bc78c31e77b9ea9c433658", size = 452989, upload-time = "2026-05-18T04:31:11.945Z" }, + { url = "https://files.pythonhosted.org/packages/71/29/5495f2c1661949ef7a35e4d71111d129cfe7606414a26887a919d0a55406/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b4e77f6a55f858504069abd35d336a637555c09bca453dde1ee1e5ada8a6a1fb", size = 458978, upload-time = "2026-05-18T04:30:52.606Z" }, + { url = "https://files.pythonhosted.org/packages/d5/8c/7f9c07c433811c2fffd93e13fdfb7135de9aab5f2ae41be08960fa0047dc/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0cb4d80e212f116474a545c21c912b445f16bb0cef9e6a73a498164223e14e2f", size = 490248, upload-time = "2026-05-18T04:31:36.003Z" }, + { url = "https://files.pythonhosted.org/packages/3c/11/d93632febc52fbc21be90231bb7c17fd5387f46c9076fd40a5f9c2ae6910/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b974946a10af379d425e2eef5b62f5c6ebeaccf91d45eaad6f5b27ecd4f91aa0", size = 571847, upload-time = "2026-05-18T04:31:10.862Z" }, + { url = "https://files.pythonhosted.org/packages/55/b4/383173e73aabb07ad1d9c7aa859d95437ac46a6d6a1e11005facda0c9d19/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:86bc13c25a8d1fcd70b51d0ce7c9b65e90de5666fcbfd3e34957cc73ee19aeb5", size = 465974, upload-time = "2026-05-18T04:30:17.006Z" }, + { url = "https://files.pythonhosted.org/packages/a7/6c/89b1a230a78f57c52dd8893adb1f92f94411721b6ec12596c56d98c74356/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ca148d73dea36c9763aaa351e4d7a51780ec1584217c45276f4fe8239c768b71", size = 454782, upload-time = "2026-05-18T04:30:35.656Z" }, + { url = "https://files.pythonhosted.org/packages/24/62/1732118367cfff0a9fce3bf62ff4bfded09ef5df21d9d446b858b3f70a96/watchfiles-1.2.0-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:c525543d91961c6955b2636b308569e84a1d1c5f5f2932041ab9ef46422f43e3", size = 465182, upload-time = "2026-05-18T04:30:20.846Z" }, + { url = "https://files.pythonhosted.org/packages/28/96/716f7e5f51339bf22963f3345f9f27d7f3b30e2eadc597e257c881dd3c53/watchfiles-1.2.0-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:a204794696ffb8f9b10fba6f7cb5216d42f3b2b71860ccac6b6e42f5f10973b0", size = 629841, upload-time = "2026-05-18T04:31:05.397Z" }, + { url = "https://files.pythonhosted.org/packages/4c/fe/c40783950fd771ccf66ab3ec2722d188a9af1c7f96c6e811f36e40c6e03f/watchfiles-1.2.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:10d86db20695afe7997ac9e1717637d6714a8d0220458c33f3d2061f54cec427", size = 658028, upload-time = "2026-05-18T04:31:48.22Z" }, + { url = "https://files.pythonhosted.org/packages/71/72/4508db1856d1d87fcbb3b63f4839bab1b5682cb0e8d224d122263c09654a/watchfiles-1.2.0-cp313-cp313-win32.whl", hash = "sha256:eb283ee99e21ad6443c8cdb06ac5b34b1308c329cbdf03fa02b445363714c799", size = 275183, upload-time = "2026-05-18T04:30:59.57Z" }, + { url = "https://files.pythonhosted.org/packages/f9/36/14b76ca57652e5cc5fd1c11f32a261292c08a0d19a00351013c2549cbfb2/watchfiles-1.2.0-cp313-cp313-win_amd64.whl", hash = "sha256:a0f27f01bee51861392bb6b7c4fdb290b27d1eb194e9e28788d68102a0e898d9", size = 288059, upload-time = "2026-05-18T04:32:07.937Z" }, + { url = "https://files.pythonhosted.org/packages/1b/8d/0a85e395398d8d20fadfe5c5d32c726eee17a519e78fb356f2cf7531bffe/watchfiles-1.2.0-cp313-cp313-win_arm64.whl", hash = "sha256:3651aa7058595e9cfb75d35dd5ada2bf9f48a5b8a0f3562821d3e210c507e077", size = 280186, upload-time = "2026-05-18T04:31:54.484Z" }, + { url = "https://files.pythonhosted.org/packages/37/68/36db056f1fdcc5f07302f56e631774d6835bcd6fa3ace402304621d5f9e5/watchfiles-1.2.0-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:faea288b6f0ab1902ef08f4ca6de005dccf856c4e0c4f21b8c5fce02d90a1b08", size = 399031, upload-time = "2026-05-18T04:30:44.576Z" }, + { url = "https://files.pythonhosted.org/packages/c1/64/01a9d6f66a82a5c101ce939274106cc72759d62427e153f01edd2b9f87c2/watchfiles-1.2.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:01859b11fd9fbca670f4d5da00fbac282cfea9bd67a2125d8b2833a3b5617ea9", size = 391205, upload-time = "2026-05-18T04:30:25.413Z" }, + { url = "https://files.pythonhosted.org/packages/84/2c/0a44fe058cb4bb7b8ede6b6670698bbb7c0400740e378d00022189b7b31d/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fff610d7bb2256a317bb1e96f0d7862c7aa8076733ee5df0fd41bbe76a24a4f4", size = 451892, upload-time = "2026-05-18T04:32:14.005Z" }, + { url = "https://files.pythonhosted.org/packages/67/a1/351e0d56cd35e6488b5c8b4fb11a809a5bc923e8fe8fed9faf8920be0c89/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b141a4891c995a039cd89e9a49e62df1dc8a559a5d1a6e4c7106d16c12777a55", size = 458867, upload-time = "2026-05-18T04:31:22.279Z" }, + { url = "https://files.pythonhosted.org/packages/d5/7d/9d09605187f1b838998624049fcf8bf47b73c1a3b76901fcac1782f62277/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f22943b7770483f6ea0721c6b11d022947a98eb0acae14694de034f4d0d38925", size = 490217, upload-time = "2026-05-18T04:31:43.657Z" }, + { url = "https://files.pythonhosted.org/packages/60/5d/a17a16eccb182f04188cd308ec24b1a71a9b5c4e7098269cf35d9fa56d02/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1bc6195825b7dcd217968bb1f801a60fd4c16e8eeab5bedc7fe917d7d5995ab4", size = 571458, upload-time = "2026-05-18T04:32:11.875Z" }, + { url = "https://files.pythonhosted.org/packages/d3/3d/4dd457062083ab1938e5dfd45032eb425cee2ac817287ca8ff4356183e5d/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d4a4b147f5dca2a5d325a06a832fb43f345751adfbc63204aec30e0d9ca965a2", size = 464707, upload-time = "2026-05-18T04:30:43.492Z" }, + { url = "https://files.pythonhosted.org/packages/c6/71/ea8c57b128f5383de74d0c7d2d9c57ad7c9a65a930c451bd25d524b295b7/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4543579a9bdb0c9560039b4ffddbdb39545707659fbc430ce4c10f3f68d557f9", size = 454663, upload-time = "2026-05-18T04:30:16.061Z" }, + { url = "https://files.pythonhosted.org/packages/53/fd/2e812bf938406d7db351f0703ddd3fc6c061cf30d96153a77bc79a943a44/watchfiles-1.2.0-cp313-cp313t-manylinux_2_31_riscv64.whl", hash = "sha256:20aa0e708b920bde876a4aa82dc7dd6ebea228a63a67cda6632c2fc87b787efa", size = 463537, upload-time = "2026-05-18T04:31:44.9Z" }, + { url = "https://files.pythonhosted.org/packages/86/56/d17a7f1dd1bc3035f1072694a551301272f1739c2d8e319c927cb9e29b38/watchfiles-1.2.0-cp313-cp313t-musllinux_1_1_aarch64.whl", hash = "sha256:d413349d565dab74297f2a63e84a097936be69bf8f3b3801f27f380e32040f44", size = 629194, upload-time = "2026-05-18T04:31:14.141Z" }, + { url = "https://files.pythonhosted.org/packages/be/06/f1ff66bf5cae50aa4062779a0ecd0bbaf15e466195719074078947d9a17d/watchfiles-1.2.0-cp313-cp313t-musllinux_1_1_x86_64.whl", hash = "sha256:f28b2725eb8cce327b9b3ab02415c853011dc55c95832fe90de6bc56f5315f72", size = 656194, upload-time = "2026-05-18T04:31:47.14Z" }, +] + +[[package]] +name = "websockets" +version = "15.0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/21/e6/26d09fab466b7ca9c7737474c52be4f76a40301b08362eb2dbc19dcc16c1/websockets-15.0.1.tar.gz", hash = "sha256:82544de02076bafba038ce055ee6412d68da13ab47f0c60cab827346de828dee", size = 177016, upload-time = "2025-03-05T20:03:41.606Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/9f/51f0cf64471a9d2b4d0fc6c534f323b664e7095640c34562f5182e5a7195/websockets-15.0.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ee443ef070bb3b6ed74514f5efaa37a252af57c90eb33b956d35c8e9c10a1931", size = 175440, upload-time = "2025-03-05T20:02:36.695Z" }, + { url = "https://files.pythonhosted.org/packages/8a/05/aa116ec9943c718905997412c5989f7ed671bc0188ee2ba89520e8765d7b/websockets-15.0.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5a939de6b7b4e18ca683218320fc67ea886038265fd1ed30173f5ce3f8e85675", size = 173098, upload-time = "2025-03-05T20:02:37.985Z" }, + { url = "https://files.pythonhosted.org/packages/ff/0b/33cef55ff24f2d92924923c99926dcce78e7bd922d649467f0eda8368923/websockets-15.0.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:746ee8dba912cd6fc889a8147168991d50ed70447bf18bcda7039f7d2e3d9151", size = 173329, upload-time = "2025-03-05T20:02:39.298Z" }, + { url = "https://files.pythonhosted.org/packages/31/1d/063b25dcc01faa8fada1469bdf769de3768b7044eac9d41f734fd7b6ad6d/websockets-15.0.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:595b6c3969023ecf9041b2936ac3827e4623bfa3ccf007575f04c5a6aa318c22", size = 183111, upload-time = "2025-03-05T20:02:40.595Z" }, + { url = "https://files.pythonhosted.org/packages/93/53/9a87ee494a51bf63e4ec9241c1ccc4f7c2f45fff85d5bde2ff74fcb68b9e/websockets-15.0.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3c714d2fc58b5ca3e285461a4cc0c9a66bd0e24c5da9911e30158286c9b5be7f", size = 182054, upload-time = "2025-03-05T20:02:41.926Z" }, + { url = "https://files.pythonhosted.org/packages/ff/b2/83a6ddf56cdcbad4e3d841fcc55d6ba7d19aeb89c50f24dd7e859ec0805f/websockets-15.0.1-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0f3c1e2ab208db911594ae5b4f79addeb3501604a165019dd221c0bdcabe4db8", size = 182496, upload-time = "2025-03-05T20:02:43.304Z" }, + { url = "https://files.pythonhosted.org/packages/98/41/e7038944ed0abf34c45aa4635ba28136f06052e08fc2168520bb8b25149f/websockets-15.0.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:229cf1d3ca6c1804400b0a9790dc66528e08a6a1feec0d5040e8b9eb14422375", size = 182829, upload-time = "2025-03-05T20:02:48.812Z" }, + { url = "https://files.pythonhosted.org/packages/e0/17/de15b6158680c7623c6ef0db361da965ab25d813ae54fcfeae2e5b9ef910/websockets-15.0.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:756c56e867a90fb00177d530dca4b097dd753cde348448a1012ed6c5131f8b7d", size = 182217, upload-time = "2025-03-05T20:02:50.14Z" }, + { url = "https://files.pythonhosted.org/packages/33/2b/1f168cb6041853eef0362fb9554c3824367c5560cbdaad89ac40f8c2edfc/websockets-15.0.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:558d023b3df0bffe50a04e710bc87742de35060580a293c2a984299ed83bc4e4", size = 182195, upload-time = "2025-03-05T20:02:51.561Z" }, + { url = "https://files.pythonhosted.org/packages/86/eb/20b6cdf273913d0ad05a6a14aed4b9a85591c18a987a3d47f20fa13dcc47/websockets-15.0.1-cp313-cp313-win32.whl", hash = "sha256:ba9e56e8ceeeedb2e080147ba85ffcd5cd0711b89576b83784d8605a7df455fa", size = 176393, upload-time = "2025-03-05T20:02:53.814Z" }, + { url = "https://files.pythonhosted.org/packages/1b/6c/c65773d6cab416a64d191d6ee8a8b1c68a09970ea6909d16965d26bfed1e/websockets-15.0.1-cp313-cp313-win_amd64.whl", hash = "sha256:e09473f095a819042ecb2ab9465aee615bd9c2028e4ef7d933600a8401c79561", size = 176837, upload-time = "2025-03-05T20:02:55.237Z" }, + { url = "https://files.pythonhosted.org/packages/fa/a8/5b41e0da817d64113292ab1f8247140aac61cbf6cfd085d6a0fa77f4984f/websockets-15.0.1-py3-none-any.whl", hash = "sha256:f7a866fbc1e97b5c617ee4116daaa09b722101d4a3c170c787450ba409f9736f", size = 169743, upload-time = "2025-03-05T20:03:39.41Z" }, +] + +[[package]] +name = "yarl" +version = "1.24.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "idna" }, + { name = "multidict" }, + { name = "propcache" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/31/33/ebe9e3d1f86c7a0b51094c0a146392045ca1631d2664889539dec8088a33/yarl-1.24.5.tar.gz", hash = "sha256:e81b83143bee16329c23db3c1b2d82b29892fcbcb849186d2f6e98a5abe9a57f", size = 228679, upload-time = "2026-07-20T02:07:45.435Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e1/63/64ef361967cc983573149dc1515d531db5da8a4c92d22bb833d59e01b313/yarl-1.24.5-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:79af890482fc94648e8cde4c68620378f7fef60932710fa17a66abc039244da2", size = 135075, upload-time = "2026-07-20T02:05:59.671Z" }, + { url = "https://files.pythonhosted.org/packages/bb/89/55920fd853ce43e608adbc3962456f0d649d6bb15250dc2988321da0fe1c/yarl-1.24.5-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:46c2f213e23a04b93a392942d782eb9e413e6ef6bf7c8c53884e599a5c174dcb", size = 97225, upload-time = "2026-07-20T02:06:01.769Z" }, + { url = "https://files.pythonhosted.org/packages/15/f0/7688d3f2cfff7590df2af38ec46d969f4281a4dddb08a9ad2eafbcdddf98/yarl-1.24.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:92ab3e11448f2ff7bf53c5a26eff0edc086898ec8b21fb154b85839ce1d88075", size = 96751, upload-time = "2026-07-20T02:06:03.676Z" }, + { url = "https://files.pythonhosted.org/packages/05/1a/a851a0f94aaaf379dd4f901bfc80f634280bec51eb260b47363e2a4cd62e/yarl-1.24.5-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ebb0ec7f17803063d5aeb982f3b1bd2b2f4e4fae6751226cbd6ba1fcfe9e63ff", size = 107960, upload-time = "2026-07-20T02:06:05.699Z" }, + { url = "https://files.pythonhosted.org/packages/6c/a8/faea066c12f9c77ca0de90641f1655f9dd7b412477bf28c76d692f3aecff/yarl-1.24.5-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:82632daed195dcc8ea664e8556dc9bdbd671960fb3776bd92806ce05792c2448", size = 103500, upload-time = "2026-07-20T02:06:07.556Z" }, + { url = "https://files.pythonhosted.org/packages/fb/9c/1e67084c2a6e2f2db0e3be798328cb3be42c0119b621d25461479a224d21/yarl-1.24.5-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:53e549287ef628fecba270045c9701b0c564563a9b0577d24a4ec75b8ab8040f", size = 115780, upload-time = "2026-07-20T02:06:09.599Z" }, + { url = "https://files.pythonhosted.org/packages/58/86/1f94664e147474337e3359f52012cf3d02f825f694317b178bfba1078c62/yarl-1.24.5-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:fcd3b77e2f17bbe4ca56ec7bcb07992647d19d0b9c05d84886dcd6f9eb810afd", size = 115308, upload-time = "2026-07-20T02:06:11.352Z" }, + { url = "https://files.pythonhosted.org/packages/0a/43/8e55ae7538ba5f28ccb3c845c6dd4549cf7016d5992e5326512519107cdd/yarl-1.24.5-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d46b86567dd4e248c6c159fcbcdcce01e0a5c8a7cd2334a0fff759d0fa075b16", size = 110574, upload-time = "2026-07-20T02:06:13.129Z" }, + { url = "https://files.pythonhosted.org/packages/ce/ba/a889ec8765cedcf2ac44dcb02d6a21e4861399b243b263c5f2dde27ee740/yarl-1.24.5-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7f72c74aa99359e27a2ee8d6613fefa28b5f76a983c083074dfc2aaa4ab46213", size = 109914, upload-time = "2026-07-20T02:06:15.243Z" }, + { url = "https://files.pythonhosted.org/packages/9c/c3/e45f821af67b791c2dbbe4a9f4137a1d33f8d386654a05a0c3f47bdfa25d/yarl-1.24.5-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:3f45789ce415a7ec0820dc4f82925f9b5f7732070be1dec1f5f23ec381435a24", size = 107712, upload-time = "2026-07-20T02:06:17.443Z" }, + { url = "https://files.pythonhosted.org/packages/02/00/2ab0f42c9857fcb490bfaa6647b14540b53d241ab209f23220b958cc5832/yarl-1.24.5-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:6e73e7fe93f17a7b191f52ec9da9dd8c06a8fe735a1ecbd13b97d1c723bff385", size = 104251, upload-time = "2026-07-20T02:06:19.259Z" }, + { url = "https://files.pythonhosted.org/packages/7a/70/709d9a286e98af2c7fd8e4e6cada658b5c0e30d87dd7e2a63c2fb5767217/yarl-1.24.5-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:4a36f9becdd4c5c52a20c3e9484128b070b1dcfc8944c006f3a528295a359a9c", size = 115319, upload-time = "2026-07-20T02:06:21.207Z" }, + { url = "https://files.pythonhosted.org/packages/5c/6c/3eaa515142991fe84cfc483ff986492211f1978f90161ccefdbec919d09b/yarl-1.24.5-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:7bcbe0fcf850eae67b6b01749815a4f7161c560a844c769ad7b48fcd99f791c4", size = 109163, upload-time = "2026-07-20T02:06:23.006Z" }, + { url = "https://files.pythonhosted.org/packages/bb/64/711dafce66c323a3144d470547a71c5384c57623308ac8bb5e4b903ac148/yarl-1.24.5-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:24e861e9630e0daddcb9191fb187f60f034e17a4426f8101279f0c475cd74144", size = 115435, upload-time = "2026-07-20T02:06:24.923Z" }, + { url = "https://files.pythonhosted.org/packages/cf/f3/9b9d0e6d84bea851eb1ba99e4bdc755b86fd813e49ec86dfe42f26befdef/yarl-1.24.5-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9335a099ad87287c37fe5d1a982ff392fa5efe5d14b40a730b1ec1d6a41382b4", size = 110691, upload-time = "2026-07-20T02:06:26.973Z" }, + { url = "https://files.pythonhosted.org/packages/86/e4/62a06b7e87c4246ac76b7c2da136f972eb4a3a1fc94abb07e7022d6fdb0a/yarl-1.24.5-cp313-cp313-win_amd64.whl", hash = "sha256:2dbe06fc16bc91502bca713704022182e5729861ae00277c3a23354b40929740", size = 97454, upload-time = "2026-07-20T02:06:29.163Z" }, + { url = "https://files.pythonhosted.org/packages/9e/c9/5fc8025b318ab10db413b61056bd0d95c557a70e8df4210c7511f866329c/yarl-1.24.5-cp313-cp313-win_arm64.whl", hash = "sha256:6b8536851f9f65e7f00c7a1d49ba7f2be0ffe2c11555367fc9f50d9f842410a1", size = 92813, upload-time = "2026-07-20T02:06:31.113Z" }, + { url = "https://files.pythonhosted.org/packages/61/02/962c1cbfc401a30c1d034dc67ff395f64b52302c6d62de556c1fca99acc0/yarl-1.24.5-py3-none-any.whl", hash = "sha256:a33700d13d9b7d84fd10947b09ff69fb9a792e519c8cb9764a3ca70baa6c23a7", size = 58612, upload-time = "2026-07-20T02:07:43.461Z" }, +]