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/.gitignore b/.gitignore index 5d96ca062d..d6b8b09feb 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,7 @@ # System files .DS_Store *.png +!muse_glimmer/macos/apps/web/src/assets/et-logo.png # IDE / editor settings .claude/ 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..1287b263d2 --- /dev/null +++ b/muse_glimmer/macos/README.md @@ -0,0 +1,115 @@ +# 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 native compatibility pin is ExecuTorch +`20ad5ee43ff53804030899d621590af3daadda53`, which contains the landed +Supertonic runtime, bounded MuseGlimmer worker cancellation, and persistent +Supertonic JSONL mode. Release readiness remains false until final artifact +provenance and clean-machine macOS arm64 end-to-end validation are complete. +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 +``` + +With a clean ExecuTorch checkout at the locked commit, run: + +```bash +make bootstrap +make prepare-artifacts +``` + +`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. It does not download, build, export, or repair missing artifacts. +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..07b0a3358f --- /dev/null +++ b/muse_glimmer/macos/apps/muse-glimmer-server/launch.py @@ -0,0 +1,74 @@ +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 _server_environment(checkout: Path) -> dict[str, str]: + pythonpath = os.pathsep.join( + value for value in (str(checkout / "src"), os.environ.get("PYTHONPATH", "")) if value + ) + environment = { + "HF_HUB_DISABLE_TELEMETRY": "1", + "HF_HUB_OFFLINE": "1", + "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"), + "TRANSFORMERS_OFFLINE": "1", + } + if ssl_cert_file := os.environ.get("SSL_CERT_FILE"): + environment["SSL_CERT_FILE"] = ssl_cert_file + return environment + + +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 + + environment = _server_environment(checkout) + 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..b8b126cfbf --- /dev/null +++ b/muse_glimmer/macos/apps/web/src/App.tsx @@ -0,0 +1,154 @@ +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

+
+
+
+ +
+

+ One voice agent. + Entirely on-device. +

+

+ Local ASR hears you, an on-device LLM thinks, and local TTS speaks + back. No cloud required. +

+
+
+
+ {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..e27ca9317e --- /dev/null +++ b/muse_glimmer/macos/apps/web/src/app.css @@ -0,0 +1,768 @@ +: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(145deg, #fbfdff 0%, #eef6ff 55%, #dceeff 100%); + 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-logo { + display: block; + width: 32px; + height: 32px; + border: 1px solid rgba(6, 26, 53, 0.14); + border-radius: 8px; + background: #fff; + object-fit: contain; +} + +.runtime-badge strong { + color: var(--ink); + font-size: 0.92rem; + font-weight: 700; +} + +.presence { + position: relative; + z-index: 1; + display: grid; + align-content: center; + justify-items: center; + min-height: 0; + padding: 18px 24px; +} + +.presence-avatar { + position: relative; + display: grid; + place-items: center; + min-height: 0; + overflow: hidden; +} + +.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-character { + transform-origin: 0 8px; + animation: breathe 4.8s ease-in-out infinite; +} + +.muse-body { + fill: none; + stroke: rgba(255, 255, 255, 0.5); + stroke-width: 1.35; +} + +.muse-face { + transform-origin: 0 22px; + transition: transform 360ms ease; +} + +.muse-eye-cutout { + transform-box: fill-box; + transform-origin: center; + transition: transform 280ms ease; + animation: blink 6.4s ease-in-out infinite; +} + +.muse-voice { + fill: none; + opacity: 0; + stroke: rgba(255, 255, 255, 0.92); + stroke-linecap: round; + stroke-width: 3; + 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-eye-cutout { + transform: scale(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(5px, -4px) rotate(2deg); +} + +.muse-avatar[data-animation="thinking"] .muse-eye-right { + transform: scaleX(0.78); +} + +.muse-avatar[data-animation="working"] .muse-character { + animation: working 1.5s ease-in-out infinite; +} + +.muse-avatar[data-animation="happy"] .muse-character { + animation: speaking 800ms ease-in-out infinite alternate; +} + +.muse-avatar[data-animation="happy"] .muse-eye-cutout { + transform: scale(1.08); +} + +.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-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 { + position: relative; + z-index: 2; + display: flex; + align-items: center; + min-height: 30px; + margin: 0; + 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-shell { + grid-template-rows: auto auto auto; + align-content: safe center; + row-gap: 20px; + padding-block: max(20px, env(safe-area-inset-top)) + max(20px, env(safe-area-inset-bottom)); + overflow-x: hidden; + overflow-y: auto; +} + +.conversation-shell .app-header { + padding: 0 clamp(24px, 5vw, 72px); +} + +.conversation-shell .header-title .runtime-badge { + margin-bottom: 16px; +} + +.conversation-shell .presence { + grid-template-rows: auto 30px 152px; + align-content: start; + row-gap: 16px; + padding: 8px 24px 2px; +} + +.conversation-shell .muse-avatar { + align-self: center; + width: clamp(265px, 33vw, 450px); +} + +.conversation-dock { + z-index: 3; + display: grid; + align-content: end; + justify-items: center; + min-height: 101px; + padding: 16px clamp(20px, 5vw, 72px) 0; +} + +.transcript-row { + display: grid; + align-items: end; + justify-items: center; + width: 100%; + min-height: 0; + overflow: hidden; +} + +.transcript { + display: grid; + align-items: end; + width: min(720px, 100%); + max-height: 100%; + overflow-x: hidden; + overflow-y: auto; + overscroll-behavior: contain; + scrollbar-color: rgba(6, 104, 225, 0.3) transparent; + scrollbar-width: thin; + scroll-behavior: smooth; + mask-image: linear-gradient(to bottom, transparent 0, black 24%, black 100%); +} + +.transcript-list { + display: grid; + gap: 7px; + margin: 0; + padding: 20px 8px 2px 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-shell { + grid-template-rows: auto clamp(430px, 58dvh, 540px) auto; + align-content: center; + row-gap: 20px; +} + +.welcome-shell { + padding-block: max(20px, env(safe-area-inset-top)) + max(20px, env(safe-area-inset-bottom)); + overflow-y: auto; +} + +.welcome-shell .app-header { + padding: 0 clamp(24px, 5vw, 72px); +} + +.welcome-shell .header-title .runtime-badge { + margin-bottom: 16px; +} + +.welcome-presence { + align-content: center; + padding: 8px 24px 2px; +} + +.welcome-presence .muse-avatar { + width: clamp(265px, 33vw, 450px); +} + +.welcome-copy { + width: min(560px, 92vw); + margin-top: -20px; + text-align: center; +} + +.welcome-copy h2 { + display: grid; + gap: 9px; + 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: 18px auto 0; + color: #485c77; + font-size: 0.91rem; + line-height: 1.55; +} + +.welcome-actions { + z-index: 3; + display: grid; + align-content: end; + justify-items: center; + min-height: 101px; + padding: 16px 24px 0; +} + +.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 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); + } + + .transcript { + min-height: 65px; + max-height: 95px; + } + + .welcome-copy h2 { + font-size: 1.8rem; + } +} + +@media (max-width: 680px) { + .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-logo { + width: 26px; + height: 26px; + border-radius: 7px; + } + + .runtime-badge strong { + font-size: 0.75rem; + } + + .presence { + padding: 8px 16px; + } + + .muse-avatar, + .welcome-presence .muse-avatar, + .conversation-shell .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-shell .presence { + grid-template-rows: auto 30px 126px; + } + + .conversation-dock { + padding-right: 16px; + padding-left: 16px; + } + + .transcript { + max-height: 100%; + } + + .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/assets/et-logo.png b/muse_glimmer/macos/apps/web/src/assets/et-logo.png new file mode 100644 index 0000000000..b7995a5db7 Binary files /dev/null and b/muse_glimmer/macos/apps/web/src/assets/et-logo.png differ 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..e555ce371b --- /dev/null +++ b/muse_glimmer/macos/apps/web/src/avatar/MuseAvatar.test.tsx @@ -0,0 +1,49 @@ +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 showing the attentive expression, 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-face", "happy"); + expect(container.firstElementChild).toHaveAttribute( + "data-reduced-motion", + "true", + ); + expect(screen.getByRole("img")).toHaveAccessibleDescription( + "An abstract blue voice companion showing the happy expression, currently speaking.", + ); + }); + + it("renders an explicitly selected conversational face", () => { + const { container } = render( + , + ); + + expect(container.firstElementChild).toHaveAttribute( + "data-face", + "confused", + ); + expect(screen.getByRole("img")).toHaveAccessibleDescription( + "An abstract blue voice companion showing the confused expression, currently thinking.", + ); + }); +}); 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..fe08ed3ca1 --- /dev/null +++ b/muse_glimmer/macos/apps/web/src/avatar/MuseAvatar.tsx @@ -0,0 +1,129 @@ +import { useId } from "react"; + +import type { MuseAnimation } from "../lib/agentPresentation"; +import { avatarFaces, type MuseFace } from "./avatarFaces"; +import { defaultFaceForAnimation } from "./avatarExpression"; + +interface MuseAvatarProps { + animation: MuseAnimation; + reducedMotion: boolean; + face?: MuseFace; +} + +const animationLabels: Record = { + idle: "ready", + listening: "listening", + thinking: "thinking", + working: "connecting", + happy: "speaking", +}; + +const bodyPath = + "M91.36 0.1C92.66 3.09 93.68 6.26 94.38 9.44C95.07 12.62 95.45 15.94 95.51 19.19C95.57 22.45 95.31 25.77 94.73 28.97C94.15 32.18 93.24 35.39 92.05 38.41C90.86 41.44 89.35 44.41 87.59 47.15C85.83 49.89 83.76 52.5 81.51 54.84C79.25 57.18 76.71 59.34 74.04 61.19C71.37 63.05 68.46 64.67 65.48 65.96C62.5 67.25 59.33 68.27 56.16 68.96C52.98 69.65 49.07 68.9 46.42 70.07C43.78 71.25 42.46 74.18 40.26 76.02C38.07 77.86 35.71 79.58 33.25 81.1C30.8 82.63 28.19 83.99 25.52 85.15C22.85 86.31 20.06 87.28 17.23 88.04C14.41 88.79 11.49 89.35 8.59 89.68C5.68 90.02 2.71 90.14 -0.22 90.04C-3.14 89.94 -6.09 89.62 -8.96 89.1C-11.83 88.58 -14.69 87.83 -17.44 86.9C-20.19 85.96 -22.89 84.81 -25.45 83.5C-28.02 82.19 -30.48 80.63 -32.83 79.02C-35.18 77.42 -36.81 74.75 -39.56 73.89C-42.31 73.02 -46.11 74.14 -49.35 73.82C-52.6 73.5 -55.89 72.87 -59.04 71.96C-62.2 71.04 -65.33 69.82 -68.28 68.33C-71.23 66.84 -74.09 65.05 -76.72 63.04C-79.36 61.03 -81.85 58.74 -84.07 56.27C-86.29 53.8 -88.32 51.08 -90.05 48.24C-91.78 45.4 -93.28 42.34 -94.45 39.23C-95.63 36.12 -96.53 32.84 -97.11 29.57C-97.7 26.29 -97.98 22.91 -97.95 19.59C-97.92 16.27 -97.57 12.9 -96.93 9.65C-96.29 6.41 -95.34 3.17 -94.12 0.1C-92.9 -2.96 -91.38 -5.95 -89.63 -8.72C-87.89 -11.5 -85.85 -14.14 -83.65 -16.53C-81.45 -18.92 -78.37 -20.87 -76.43 -23.07C-74.49 -25.27 -72.87 -27.22 -72.02 -29.71C-71.17 -32.2 -71.8 -35.27 -71.33 -38C-70.86 -40.73 -70.14 -43.48 -69.2 -46.1C-68.26 -48.73 -67.07 -51.32 -65.68 -53.75C-64.29 -56.18 -62.66 -58.53 -60.86 -60.68C-59.06 -62.84 -57.04 -64.87 -54.89 -66.67C-52.74 -68.48 -50.39 -70.12 -47.96 -71.51C-45.52 -72.91 -42.93 -74.11 -40.3 -75.06C-37.66 -76 -34.9 -76.72 -32.16 -77.19C-29.41 -77.66 -26.58 -77.88 -23.81 -77.87C-21.04 -77.85 -18.24 -77.58 -15.53 -77.08C-12.83 -76.59 -10.14 -75.84 -7.59 -74.9C-5.03 -73.96 -2.54 -72.78 -0.22 -71.44C2.11 -70.1 4.23 -68.03 6.36 -66.86C8.5 -65.68 10.3 -64.4 12.58 -64.39C14.86 -64.37 17.48 -66.21 20.03 -66.79C22.57 -67.36 25.23 -67.72 27.86 -67.83C30.49 -67.94 33.18 -67.81 35.8 -67.44C38.43 -67.07 41.07 -66.46 43.59 -65.61C46.11 -64.77 48.6 -63.67 50.94 -62.38C53.27 -61.08 55.53 -59.55 57.59 -57.84C59.66 -56.14 61.6 -54.21 63.32 -52.16C65.03 -50.11 66.59 -47.85 67.91 -45.52C69.22 -43.19 70.34 -40.69 71.21 -38.16C72.07 -35.63 72.72 -32.98 73.11 -30.34C73.5 -27.7 72.37 -24.73 73.55 -22.33C74.74 -19.93 78.05 -18.24 80.22 -15.93C82.39 -13.62 84.72 -11.14 86.58 -8.46C88.43 -5.79 90.06 -2.88 91.36 0.1Z"; + +export function MuseAvatar({ + animation, + reducedMotion, + face, +}: MuseAvatarProps) { + const idPrefix = useId().replaceAll(":", ""); + const titleId = `${idPrefix}-title`; + const descriptionId = `${idPrefix}-description`; + const maskId = `${idPrefix}-mask`; + const fillId = `${idPrefix}-fill`; + const shadowId = `${idPrefix}-shadow`; + const visibleAnimation = reducedMotion ? "idle" : animation; + const visibleFace = face ?? defaultFaceForAnimation(animation); + const faceDefinition = avatarFaces[visibleFace]; + + return ( +
+ + Muse Glimmer + + An abstract blue voice companion showing the {visibleFace} expression, + currently {animationLabels[animation]}. + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ ); +} diff --git a/muse_glimmer/macos/apps/web/src/avatar/avatarExpression.test.ts b/muse_glimmer/macos/apps/web/src/avatar/avatarExpression.test.ts new file mode 100644 index 0000000000..861e1e5a06 --- /dev/null +++ b/muse_glimmer/macos/apps/web/src/avatar/avatarExpression.test.ts @@ -0,0 +1,68 @@ +import { describe, expect, it } from "vitest"; + +import type { TranscriptEntry } from "../lib/transcript"; +import { selectMuseFace } from "./avatarExpression"; + +function entry( + speaker: "user" | "agent", + text: string, + order = 0, +): TranscriptEntry { + return { + key: `${speaker}-${order}`, + segmentId: String(order), + participantIdentity: speaker, + speaker, + text, + final: true, + order, + }; +} + +describe("avatar expression selection", () => { + it("uses lifecycle faces while listening and connecting", () => { + const transcript = [entry("user", "Why is that scary?")]; + + expect(selectMuseFace("listening", transcript)).toBe("attentive"); + expect(selectMuseFace("working", transcript)).toBe("attentive"); + }); + + it.each([ + ["Why does this work?", "curious"], + ["I am confused by that", "confused"], + ["Are you sure this is really true?", "suspicious"], + ["Wow, no way!", "surprised"], + ["I feel scared and worried", "scared"], + ["This makes me angry", "angry"], + ["That is a private and embarrassing question", "shy"], + ["I am tired and sleepy", "sleepy"], + ["Whatever, this is boring", "unimpressed"], + ] as const)("maps a user question to %s-style expression", (text, face) => { + expect(selectMuseFace("thinking", [entry("user", text)])).toBe(face); + }); + + it.each([ + ["That is wonderful!", "excited"], + ["Haha, that was funny", "laughing"], + ["Congratulations, you did it", "proud"], + ["I am sorry that happened", "sad"], + ["Here is the answer.", "happy"], + ] as const)("maps an agent response to %s-style expression", (text, face) => { + expect(selectMuseFace("happy", [entry("agent", text)])).toBe(face); + }); + + it("uses neutral when there is no conversation yet", () => { + expect(selectMuseFace("idle", [])).toBe("neutral"); + }); + + it("uses the newest matching speaker entry", () => { + const transcript = [ + entry("user", "Why?", 0), + entry("agent", "I am not sure.", 1), + entry("user", "Are you sure?", 2), + ]; + + expect(selectMuseFace("thinking", transcript)).toBe("suspicious"); + expect(selectMuseFace("happy", transcript)).toBe("confused"); + }); +}); diff --git a/muse_glimmer/macos/apps/web/src/avatar/avatarExpression.ts b/muse_glimmer/macos/apps/web/src/avatar/avatarExpression.ts new file mode 100644 index 0000000000..63d69f6052 --- /dev/null +++ b/muse_glimmer/macos/apps/web/src/avatar/avatarExpression.ts @@ -0,0 +1,138 @@ +import type { MuseAnimation } from "../lib/agentPresentation"; +import type { TranscriptEntry } from "../lib/transcript"; +import type { MuseFace } from "./avatarFaces"; + +interface FaceRule { + face: MuseFace; + pattern: RegExp; +} + +const sharedRules: FaceRule[] = [ + { + face: "scared", + pattern: + /\b(afraid|danger|dangerous|fear|frighten|scared|terrified|worry|worried)\b/i, + }, + { + face: "sad", + pattern: /\b(cry|grief|hurt|lonely|miss you|sad|sorry|unhappy|upset)\b/i, + }, + { + face: "angry", + pattern: /\b(angry|annoyed|furious|hate|mad|outraged)\b/i, + }, + { + face: "shy", + pattern: /\b(awkward|embarrassed|private|secret|shy)\b/i, + }, + { + face: "sleepy", + pattern: /\b(exhausted|sleep|sleepy|tired|yawn)\b/i, + }, +]; + +const questionRules: FaceRule[] = [ + ...sharedRules, + { + face: "confused", + pattern: + /\b(confused|confusing|don't understand|doesn't make sense|what do you mean)\b/i, + }, + { + face: "suspicious", + pattern: /\b(are you sure|prove|really true|seriously|trust|verify)\b/i, + }, + { + face: "surprised", + pattern: /\b(amazing|no way|really|surprise|surprised|wow)\b/i, + }, + { + face: "unimpressed", + pattern: /\b(boring|whatever|who cares)\b/i, + }, + { + face: "curious", + pattern: /\?|\b(how|what|when|where|which|who|why)\b/i, + }, +]; + +const responseRules: FaceRule[] = [ + ...sharedRules, + { + face: "laughing", + pattern: /\b(ha(?:ha)+|funny|joke|lol)\b/i, + }, + { + face: "proud", + pattern: /\b(congratulations|proud|well done|you did it)\b/i, + }, + { + face: "excited", + pattern: + /!|\b(amazing|awesome|excellent|fantastic|great news|wonderful)\b/i, + }, + { + face: "confused", + pattern: /\b(I don't know|I(?:'m| am) not sure|unclear|uncertain)\b/i, + }, +]; + +function matchFace( + text: string, + rules: FaceRule[], + fallback: MuseFace, +): MuseFace { + return rules.find((rule) => rule.pattern.test(text))?.face ?? fallback; +} + +function latestEntry(entries: TranscriptEntry[], speaker: "user" | "agent") { + for (let index = entries.length - 1; index >= 0; index -= 1) { + const entry = entries[index]; + if (entry.speaker === speaker && entry.text.trim().length > 0) return entry; + } + return undefined; +} + +export function defaultFaceForAnimation(animation: MuseAnimation): MuseFace { + switch (animation) { + case "listening": + case "working": + return "attentive"; + case "thinking": + return "curious"; + case "happy": + return "happy"; + case "idle": + return "neutral"; + } +} + +export function selectMuseFace( + animation: MuseAnimation, + entries: TranscriptEntry[], +): MuseFace { + if (animation === "listening" || animation === "working") { + return defaultFaceForAnimation(animation); + } + + if (animation === "thinking") { + const question = latestEntry(entries, "user"); + return question + ? matchFace(question.text, questionRules, "curious") + : "curious"; + } + + const response = latestEntry(entries, "agent"); + if (animation === "happy") { + return response + ? matchFace(response.text, responseRules, "happy") + : "happy"; + } + + const latest = entries.at(-1); + if (!latest) return "neutral"; + if (latest.speaker === "agent") { + return matchFace(latest.text, responseRules, "happy"); + } + return matchFace(latest.text, questionRules, "attentive"); +} diff --git a/muse_glimmer/macos/apps/web/src/avatar/avatarFaces.ts b/muse_glimmer/macos/apps/web/src/avatar/avatarFaces.ts new file mode 100644 index 0000000000..41014d54b2 --- /dev/null +++ b/muse_glimmer/macos/apps/web/src/avatar/avatarFaces.ts @@ -0,0 +1,185 @@ +export type MuseFace = + | "neutral" + | "attentive" + | "surprised" + | "excited" + | "happy" + | "laughing" + | "angry" + | "sad" + | "scared" + | "suspicious" + | "confused" + | "curious" + | "proud" + | "shy" + | "unimpressed" + | "sleepy"; + +interface EyeDefinition { + path: string; + transform: string; +} + +export interface FaceDefinition { + left: EyeDefinition; + right: EyeDefinition; +} + +const vertical = + "M-10.5 -11.5A10.5 10.5 0 0 1 0 -22A10.5 10.5 0 0 1 10.5 -11.5V11.5A10.5 10.5 0 0 1 0 22A10.5 10.5 0 0 1 -10.5 11.5Z"; +const tall = + "M-20 -10A20 20 0 0 1 0 -30A20 20 0 0 1 20 -10V10A20 20 0 0 1 0 30A20 20 0 0 1 -20 10Z"; +const wide = + "M-17 0A7.5 7.5 0 0 1 -9.5 -7.5H9.5A7.5 7.5 0 0 1 17 0A7.5 7.5 0 0 1 9.5 7.5H-9.5A7.5 7.5 0 0 1 -17 0Z"; + +export const avatarFaces: Record = { + neutral: { + left: { + path: "M-9.3 -11.3A9.3 9.3 0 0 1 0 -20.6A9.3 9.3 0 0 1 9.3 -11.3V11.3A9.3 9.3 0 0 1 0 20.6A9.3 9.3 0 0 1 -9.3 11.3Z", + transform: "matrix(0.94,-0.2,0.23,0.97,-28.82,15.31)", + }, + right: { + path: "M-9.3 -11.3A9.3 9.3 0 0 1 0 -20.6A9.3 9.3 0 0 1 9.3 -11.3V11.3A9.3 9.3 0 0 1 0 20.6A9.3 9.3 0 0 1 -9.3 11.3Z", + transform: "matrix(0.94,-0.23,0.23,0.97,21.55,3.1)", + }, + }, + attentive: { + left: { + path: vertical, + transform: "matrix(0.96,-0.05,0.07,1,-28.22,7.99)", + }, + right: { + path: vertical, + transform: "matrix(0.96,-0.08,0.07,1,26.04,3.94)", + }, + }, + surprised: { + left: { + path: "M-22.5 -1A22.5 22.5 0 0 1 0 -23.5A22.5 22.5 0 0 1 22.5 -1V1A22.5 22.5 0 0 1 0 23.5A22.5 22.5 0 0 1 -22.5 1Z", + transform: "matrix(0.94,0.02,0,1,-33.78,6.33)", + }, + right: { + path: "M-22.5 -1A22.5 22.5 0 0 1 0 -23.5A22.5 22.5 0 0 1 22.5 -1V1A22.5 22.5 0 0 1 0 23.5A22.5 22.5 0 0 1 -22.5 1Z", + transform: "matrix(0.95,-0.02,0,1,30.28,6.23)", + }, + }, + excited: { + left: { + path: "M-20 -8A20 20 0 0 1 0 -28A20 20 0 0 1 20 -8V8A20 20 0 0 1 0 28A20 20 0 0 1 -20 8Z", + transform: "matrix(0.93,-0.15,0.16,0.99,-33.79,5.44)", + }, + right: { + path: "M-20 -8A20 20 0 0 1 0 -28A20 20 0 0 1 20 -8V8A20 20 0 0 1 0 28A20 20 0 0 1 -20 8Z", + transform: "matrix(0.93,0.15,-0.16,0.99,31.81,5.33)", + }, + }, + happy: { + left: { + path: "M-13.5 0A8.5 8.5 0 0 1 -5 -8.5H5A8.5 8.5 0 0 1 13.5 0A8.5 8.5 0 0 1 5 8.5H-5A8.5 8.5 0 0 1 -13.5 0Z", + transform: "matrix(0.92,0.21,-0.23,0.79,-30.34,6.21)", + }, + right: { + path: "M-13.5 0A8.5 8.5 0 0 1 -5 -8.5H5A8.5 8.5 0 0 1 13.5 0A8.5 8.5 0 0 1 5 8.5H-5A8.5 8.5 0 0 1 -13.5 0Z", + transform: "matrix(0.93,-0.21,0.23,0.79,27.46,6.1)", + }, + }, + laughing: { + left: { + path: "M-17 0A6.5 6.5 0 0 1 -10.5 -6.5H10.5A6.5 6.5 0 0 1 17 0A6.5 6.5 0 0 1 10.5 6.5H-10.5A6.5 6.5 0 0 1 -17 0Z", + transform: "matrix(0.89,0.36,-0.32,0.93,-32.03,6.19)", + }, + right: { + path: "M-17 0A6.5 6.5 0 0 1 -10.5 -6.5H10.5A6.5 6.5 0 0 1 17 0A6.5 6.5 0 0 1 10.5 6.5H-10.5A6.5 6.5 0 0 1 -17 0Z", + transform: "matrix(0.9,-0.36,0.33,0.93,28.92,6.09)", + }, + }, + angry: { + left: { path: wide, transform: "matrix(0.83,0.51,-0.48,0.86,-29.43,5.92)" }, + right: { path: wide, transform: "matrix(0.83,-0.51,0.48,0.86,28.32,5.81)" }, + }, + sad: { + left: { + path: "M-11 -9A11 11 0 0 1 0 -20A11 11 0 0 1 11 -9V9A11 11 0 0 1 0 20A11 11 0 0 1 -11 9Z", + transform: "matrix(0.85,-0.45,0.45,0.89,-28.19,6.57)", + }, + right: { + path: "M-11 -9A11 11 0 0 1 0 -20A11 11 0 0 1 11 -9V9A11 11 0 0 1 0 20A11 11 0 0 1 -11 9Z", + transform: "matrix(0.85,0.45,-0.45,0.89,26.51,6.45)", + }, + }, + scared: { + left: { path: tall, transform: "matrix(0.93,0.02,0,1,-35.52,5.24)" }, + right: { path: tall, transform: "matrix(0.94,-0.02,0,1,32.89,5.14)" }, + }, + suspicious: { + left: { + path: "M-10.5 -9.5A10.5 10.5 0 0 1 0 -20A10.5 10.5 0 0 1 10.5 -9.5V9.5A10.5 10.5 0 0 1 0 20A10.5 10.5 0 0 1 -10.5 9.5Z", + transform: "matrix(0.95,-0.08,0.11,0.99,-39.35,26.01)", + }, + right: { + path: "M-11 0A7.5 7.5 0 0 1 -3.5 -7.5H3.5A7.5 7.5 0 0 1 11 0A7.5 7.5 0 0 1 3.5 7.5H-3.5A7.5 7.5 0 0 1 -11 0Z", + transform: "matrix(0.96,-0.12,0.11,0.99,14.5,20.04)", + }, + }, + confused: { + left: { + path: "M-10 -12A10 10 0 0 1 0 -22A10 10 0 0 1 10 -12V12A10 10 0 0 1 0 22A10 10 0 0 1 -10 12Z", + transform: "matrix(0.94,-0.16,0.16,0.99,-23.44,4.99)", + }, + right: { + path: "M-14 0A8.5 8.5 0 0 1 -5.5 -8.5H5.5A8.5 8.5 0 0 1 14 0A8.5 8.5 0 0 1 5.5 8.5H-5.5A8.5 8.5 0 0 1 -14 0Z", + transform: "matrix(0.89,0.35,-0.36,0.93,31.66,12.91)", + }, + }, + curious: { + left: { + path: "M-12 -11A12 12 0 0 1 0 -23A12 12 0 0 1 12 -11V11A12 12 0 0 1 0 23A12 12 0 0 1 -12 11Z", + transform: "matrix(0.88,-0.36,0.39,0.92,-36.41,14.3)", + }, + right: { + path: "M-10 -9A10 10 0 0 1 0 -19A10 10 0 0 1 10 -9V9A10 10 0 0 1 0 19A10 10 0 0 1 -10 9Z", + transform: "matrix(0.88,-0.4,0.39,0.92,16.31,-0.54)", + }, + }, + proud: { + left: { + path: "M-15 0A7.5 7.5 0 0 1 -7.5 -7.5H7.5A7.5 7.5 0 0 1 15 0A7.5 7.5 0 0 1 7.5 7.5H-7.5A7.5 7.5 0 0 1 -15 0Z", + transform: "matrix(0.91,0.33,-0.29,0.94,-30.51,6.48)", + }, + right: { + path: "M-15 0A7.5 7.5 0 0 1 -7.5 -7.5H7.5A7.5 7.5 0 0 1 15 0A7.5 7.5 0 0 1 7.5 7.5H-7.5A7.5 7.5 0 0 1 -15 0Z", + transform: "matrix(0.91,-0.33,0.3,0.94,27.31,6.37)", + }, + }, + shy: { + left: { + path: "M-8.5 -6.5A8.5 8.5 0 0 1 0 -15A8.5 8.5 0 0 1 8.5 -6.5V6.5A8.5 8.5 0 0 1 0 15A8.5 8.5 0 0 1 -8.5 6.5Z", + transform: "matrix(0.96,-0.1,0.12,0.99,6.82,-8.99)", + }, + right: { + path: "M-8.5 -6.5A8.5 8.5 0 0 1 0 -15A8.5 8.5 0 0 1 8.5 -6.5V6.5A8.5 8.5 0 0 1 0 15A8.5 8.5 0 0 1 -8.5 6.5Z", + transform: "matrix(0.97,-0.13,0.12,0.99,54.48,-15.16)", + }, + }, + unimpressed: { + left: { + path: "M-15 0A6 6 0 0 1 -9 -6H9A6 6 0 0 1 15 0A6 6 0 0 1 9 6H-9A6 6 0 0 1 -15 0Z", + transform: "matrix(0.96,0.02,0,1,-25.89,8.12)", + }, + right: { + path: "M-15 0A6 6 0 0 1 -9 -6H9A6 6 0 0 1 15 0A6 6 0 0 1 9 6H-9A6 6 0 0 1 -15 0Z", + transform: "matrix(0.97,-0.02,0,1,28.77,8.02)", + }, + }, + sleepy: { + left: { + path: "M-10 -11A10 10 0 0 1 0 -21A10 10 0 0 1 10 -11V11A10 10 0 0 1 0 21A10 10 0 0 1 -10 11Z", + transform: "matrix(0.95,-0.02,0.05,0.45,-29.2,7.53)", + }, + right: { + path: "M-10 -11A10 10 0 0 1 0 -21A10 10 0 0 1 10 -11V11A10 10 0 0 1 0 21A10 10 0 0 1 -10 11Z", + transform: "matrix(0.96,-0.03,0.05,0.45,25.14,4.49)", + }, + }, +}; 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..dd76ad0a68 --- /dev/null +++ b/muse_glimmer/macos/apps/web/src/components/CompactTranscript.test.tsx @@ -0,0 +1,66 @@ +import { render, screen } from "@testing-library/react"; +import { describe, expect, it, vi } 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("stays hidden until the first transcript arrives", () => { + const { container } = render(); + + expect(container).toBeEmptyDOMElement(); + }); + + it("scrolls smoothly when the newest transcript text changes", () => { + const scrollTo = vi.fn(); + Object.defineProperty(HTMLElement.prototype, "scrollTo", { + configurable: true, + value: scrollTo, + }); + const initialEntry = entry(1, "agent"); + const { rerender } = render(); + scrollTo.mockClear(); + + rerender( + , + ); + + expect(scrollTo).toHaveBeenCalledWith({ + top: 0, + behavior: "smooth", + }); + }); + + it("renders only the six newest entries with speaker labels and interim styling", () => { + render( + + entry(index, index % 2 ? "agent" : "user"), + )} + />, + ); + + 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..57a71e0046 --- /dev/null +++ b/muse_glimmer/macos/apps/web/src/components/CompactTranscript.tsx @@ -0,0 +1,57 @@ +import { useEffect, useRef } from "react"; + +import type { TranscriptEntry } from "../lib/transcript"; + +interface CompactTranscriptProps { + entries: TranscriptEntry[]; +} + +export function CompactTranscript({ entries }: CompactTranscriptProps) { + const visibleEntries = entries.slice(-6); + const transcriptRef = useRef(null); + const latestEntry = visibleEntries.at(-1); + + useEffect(() => { + const transcript = transcriptRef.current; + if (!transcript) return; + + if (typeof transcript.scrollTo === "function") { + transcript.scrollTo({ + top: transcript.scrollHeight, + behavior: "smooth", + }); + } else { + transcript.scrollTop = transcript.scrollHeight; + } + }, [latestEntry?.key, latestEntry?.text, visibleEntries.length]); + + if (visibleEntries.length === 0) return null; + + return ( +
+
    + {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..fc9b24cad4 --- /dev/null +++ b/muse_glimmer/macos/apps/web/src/components/RuntimeBadge.tsx @@ -0,0 +1,12 @@ +import executorchLogo from "../assets/et-logo.png"; + +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..7ccd446fa5 --- /dev/null +++ b/muse_glimmer/macos/apps/web/src/components/VoiceSession.tsx @@ -0,0 +1,86 @@ +import { + RoomAudioRenderer, + StartAudio, + useConnectionState, + useLocalParticipant, +} from "@livekit/components-react"; +import { ConnectionState } from "livekit-client"; + +import { selectMuseFace } from "../avatar/avatarExpression"; +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, + ); + const face = selectMuseFace(presentation.animation, transcript); + + return ( +
+
+
+
+ +
+
+
+
+ +
+
+
+ + +
+ +
+ ); +} + +function Header() { + 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..a2575af6e2 --- /dev/null +++ b/muse_glimmer/macos/apps/worker/src/muse_glimmer_worker/__init__.py @@ -0,0 +1,10 @@ +"""Local Muse Glimmer LiveKit worker.""" + +import os + +# ONNX Runtime enables macOS telemetry at import time unless explicitly disabled. +os.environ["ORT_DISABLE_TELEMETRY"] = "1" + +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..ea1b7707d6 --- /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", 256), + 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..99c497f9e6 --- /dev/null +++ b/muse_glimmer/macos/apps/worker/tests/test_agent.py @@ -0,0 +1,110 @@ +from __future__ import annotations + +import os +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" + + +def test_onnx_runtime_telemetry_is_disabled_before_agent_import() -> None: + assert os.environ["ORT_DISABLE_TELEMETRY"] == "1" 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..4ee722f211 --- /dev/null +++ b/muse_glimmer/macos/apps/worker/tests/test_config.py @@ -0,0 +1,93 @@ +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" + assert config.muse_glimmer_max_tokens == 256 + + 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..3a18e19ada --- /dev/null +++ b/muse_glimmer/macos/artifacts/README.md @@ -0,0 +1,17 @@ +# Local artifacts + +This directory contains manifests only. Models, complete tokenizer bundles, +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; for +example, the pinned MLX-generated metallib is MIT-licensed. Run +`make prepare-artifacts` after reviewing the licenses and providing any +artifacts marked `user-provided` in `macos-arm64.lock.json`. + +Preparation verifies checksums and payload sizes, then writes +`.local/state/prepared.json`. Directory sizes are the sum of regular-file bytes. The +inventory includes the shared `mlx.metallib` that must be colocated with all +three native executables under `.local/artifacts/bin/`. 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..fbede960c3 --- /dev/null +++ b/muse_glimmer/macos/artifacts/macos-arm64.lock.json @@ -0,0 +1,188 @@ +{ + "schema_version": 1, + "platform": "macos-arm64", + "artifacts": [ + { + "role": "parakeet_helper", + "kind": "file", + "executable": true, + "distribution": "build", + "source": "executorch", + "revision": "20ad5ee43ff53804030899d621590af3daadda53", + "license": "BSD-3-Clause", + "destination": ".local/artifacts/bin/parakeet_helper", + "sensitive": true, + "sha256": "53a45df84fee869762449602767b9430caa409efb06a6661e621adb56c9f0b20", + "size_bytes": 13510864, + "prepare": "Build from the single pinned ExecuTorch checkout." + }, + { + "role": "parakeet_model", + "kind": "file", + "executable": false, + "distribution": "build", + "source": "https://huggingface.co/nvidia/parakeet-tdt-0.6b-v3", + "revision": "541d1f99c6b0c3cd0b11a95167540bb8edefd82b", + "license": "CC-BY-4.0", + "destination": ".local/artifacts/parakeet/model.pte", + "sensitive": true, + "sha256": "4690cfb245e3511f2822d5e716974ca7343c58c7e76ad95e81e79bcf19dcf4ae", + "size_bytes": 484430860, + "prepare": "Place a licensed compatible artifact and record its checksum locally." + }, + { + "role": "parakeet_tokenizer", + "kind": "file", + "executable": false, + "distribution": "build", + "source": "https://huggingface.co/nvidia/parakeet-tdt-0.6b-v3", + "revision": "541d1f99c6b0c3cd0b11a95167540bb8edefd82b", + "license": "CC-BY-4.0", + "destination": ".local/artifacts/parakeet/tokenizer.model", + "sensitive": true, + "sha256": "eacec2b0a77f336d4a2ca4a25a7047575d3c2b74de47e997f4c205126ed3135e", + "size_bytes": 360916, + "prepare": "Place a licensed compatible tokenizer and record its checksum locally." + }, + { + "role": "muse_glimmer_worker", + "kind": "file", + "executable": true, + "distribution": "build", + "source": "executorch", + "revision": "20ad5ee43ff53804030899d621590af3daadda53", + "license": "BSD-3-Clause", + "destination": ".local/artifacts/bin/muse_glimmer_worker", + "sensitive": true, + "sha256": "d6f7e97531835823912a5269bdee1eeebc2aa81eb940f6c2ea4eadd3b33a1b43", + "size_bytes": 12726128, + "prepare": "Build from the single pinned ExecuTorch checkout." + }, + { + "role": "muse_glimmer_model", + "kind": "file", + "executable": false, + "distribution": "download", + "source": "https://huggingface.co/meta-models/Muse-Glimmer-30B-ExecuTorch-PTE", + "revision": "fc6fa93cdefeddc93abd7abf883e99279af7ea51", + "license": "Apache-2.0", + "destination": ".local/artifacts/muse-glimmer/model.pte", + "sensitive": true, + "sha256": "6fbec8fc06f50e84c1a0e1fb1588bb825b1b5a3bbc314558faec2541a12f42e4", + "size_bytes": 19641819904, + "prepare": "Place a licensed compatible artifact and record its checksum locally." + }, + { + "role": "muse_glimmer_tokenizer", + "kind": "file", + "executable": false, + "distribution": "download", + "source": "https://huggingface.co/meta-models/Muse-Glimmer-30B-ExecuTorch-PTE", + "revision": "fc6fa93cdefeddc93abd7abf883e99279af7ea51", + "license": "Apache-2.0", + "destination": ".local/artifacts/muse-glimmer/tokenizer.json", + "sensitive": true, + "sha256": "c9dbee66967b58f31a7c27f723c3760da3526ccd0427578e8905b0abb0031c4d", + "size_bytes": 28129897, + "prepare": "Place a licensed compatible tokenizer and record its checksum locally." + }, + { + "role": "muse_glimmer_tokenizer_config", + "kind": "file", + "executable": false, + "distribution": "download", + "source": "https://huggingface.co/meta-models/Muse-Glimmer-30B-ExecuTorch-PTE", + "revision": "fc6fa93cdefeddc93abd7abf883e99279af7ea51", + "license": "Apache-2.0", + "destination": ".local/artifacts/muse-glimmer/tokenizer_config.json", + "sensitive": true, + "sha256": "781e6c74f571642c71202167b67d9255b28cc439bdda1582ff31346182f5a9c5", + "size_bytes": 79936, + "prepare": "Place the immutable tokenizer configuration beside tokenizer.json." + }, + { + "role": "muse_glimmer_chat_template", + "kind": "file", + "executable": false, + "distribution": "download", + "source": "https://huggingface.co/meta-models/Muse-Glimmer-30B-ExecuTorch-PTE", + "revision": "fc6fa93cdefeddc93abd7abf883e99279af7ea51", + "license": "Apache-2.0", + "destination": ".local/artifacts/muse-glimmer/chat_template.jinja", + "sensitive": true, + "sha256": "cfc67e5f349f37690dfd31ed1f18bc4442a9dd32fe39a648f993cb4eb3cae678", + "size_bytes": 9992, + "prepare": "Place the canonical immutable chat template beside tokenizer.json." + }, + { + "role": "supertonic_runner", + "kind": "file", + "executable": true, + "distribution": "build", + "source": "executorch", + "revision": "20ad5ee43ff53804030899d621590af3daadda53", + "license": "BSD-3-Clause", + "destination": ".local/artifacts/bin/supertonic_runner", + "sensitive": true, + "sha256": "d3629e6337103e4ffa6ce930e7fa58ede810f44648b19a253a249d38a0813adf", + "size_bytes": 5942144, + "prepare": "Build from the single pinned ExecuTorch checkout." + }, + { + "role": "mlx_metallib", + "kind": "file", + "executable": false, + "distribution": "build", + "source": "https://github.com/ml-explore/mlx.git", + "revision": "7a1d4f5c12ac82f4b4d0a6e71538d89ca0605247", + "license": "MIT", + "destination": ".local/artifacts/bin/mlx.metallib", + "sensitive": true, + "sha256": "713ddc7352f74d1bf4c50ea19fc65ea6f253d5779ed46b3108eac8a1c26d83ec", + "size_bytes": 1268028, + "prepare": "Copy the MLX metallib built beside the final-pinned native executables." + }, + { + "role": "supertonic_model", + "kind": "file", + "executable": false, + "distribution": "build", + "source": "https://huggingface.co/Supertone/supertonic-3", + "revision": "3cadd1ee6394adea1bd021217a0e650ede09a323", + "license": "BigScience Open RAIL-M", + "destination": ".local/artifacts/supertonic/model.pte", + "sensitive": true, + "sha256": "8aa17b185db9c4b18c8bebd49414337342a0499985e8218d6c30f618c8227432", + "size_bytes": 198747136, + "prepare": "Export from approved Supertonic assets during artifact preparation." + }, + { + "role": "supertonic_assets", + "kind": "directory", + "executable": false, + "distribution": "download", + "source": "https://huggingface.co/Supertone/supertonic-3", + "revision": "3cadd1ee6394adea1bd021217a0e650ede09a323", + "license": "BigScience Open RAIL-M", + "destination": ".local/artifacts/supertonic/assets", + "sensitive": true, + "sha256": "a5dec49a53ee1f69fd869e5298ccb7cdedc2ec13b91b3d0d1534f28ea0f0b23c", + "size_bytes": 398668429, + "prepare": "Place licensed assets and record their tree checksum locally." + }, + { + "role": "supertonic_voice_style", + "kind": "file", + "executable": false, + "distribution": "download", + "source": "https://huggingface.co/Supertone/supertonic-3", + "revision": "3cadd1ee6394adea1bd021217a0e650ede09a323", + "license": "BigScience Open RAIL-M", + "destination": ".local/artifacts/supertonic/voice-style.json", + "sensitive": true, + "sha256": "bbdec6ee00231c2c742ad05483df5334cab3b52fda3ba38e6a07059c4563dbc2", + "size_bytes": 292046, + "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..79c424a585 --- /dev/null +++ b/muse_glimmer/macos/config/dependencies/compatibility.lock.json @@ -0,0 +1,38 @@ +{ + "schema_version": 1, + "status": "validation-gated", + "platform": "macos-arm64", + "executorch": { + "repository": "https://github.com/pytorch/executorch.git", + "commit": "20ad5ee43ff53804030899d621590af3daadda53", + "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": "landed", + "pull_request": "https://github.com/pytorch/executorch/pull/22208", + "commit": "20ad5ee43ff53804030899d621590af3daadda53" + } + } + }, + "livekit": { + "agents_requirement": ">=1.6.9,<2", + "server_requirement": ">=1.9,<2" + }, + "ready_for_release": false, + "release_blocker": "Pass final clean-machine macOS arm64 end-to-end validation." +} 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..3fa9aa1d53 --- /dev/null +++ b/muse_glimmer/macos/docs/artifacts.md @@ -0,0 +1,23 @@ +# 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, immutable revision, checksum, and payload size. +Directory sizes are the sum of their regular-file bytes. + +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 ExecuTorch +`20ad5ee43ff53804030899d621590af3daadda53` selected in +`config/dependencies/compatibility.lock.json`, rejects a dirty or mismatched +checkout, validates all files, and writes `.local/state/prepared.json`. +The manifest also tracks the shared `mlx.metallib` beside the three native +executables because statically linked MLX discovers that file at runtime. Its +provenance is the pinned MIT-licensed MLX submodule used by the ExecuTorch build. +Startup verifies the 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..9c86f22e9d --- /dev/null +++ b/muse_glimmer/macos/docs/upstream-pins.md @@ -0,0 +1,33 @@ +# Upstream Compatibility Pins + +The native compatibility lock selects ExecuTorch +`20ad5ee43ff53804030899d621590af3daadda53`. This immutable `main` commit +contains every required upstream interface; release readiness remains gated on +final artifacts and clean-machine end-to-end validation. + +## 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 landed at + `20ad5ee43ff53804030899d621590af3daadda53`. 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 selected compatibility commit is a verified descendant of #22063 and +#22070 and contains the landed #22208 tree. Every ExecuTorch-built artifact +must be produced from this one clean checkout. Branch names, dirty checkouts, +and mixed Python/native revisions are forbidden. + +Before setting `ready_for_release` to true: + +1. Populate artifact sources, revisions, checksums, sizes, and exact licenses. +2. Run real generation, stream cancellation, and post-cancel generation. +3. Verify multiple Supertonic utterances reuse one warm process using the + documented protocol-v1 ready frame. +4. 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..8aad08f26a --- /dev/null +++ b/muse_glimmer/macos/pyproject.toml @@ -0,0 +1,48 @@ +[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" +dependencies = [ + "torch==2.13.0", + "transformers==5.0.0rc1", +] + +[tool.uv.workspace] +members = [ + "apps/token-service", + "apps/worker", + "packages/livekit-plugins-executorch", +] + +[tool.uv] +package = false +environments = [ + "sys_platform == 'darwin' and platform_machine == 'arm64'", +] + +[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..11af849f8b --- /dev/null +++ b/muse_glimmer/macos/scripts/dev_stack.py @@ -0,0 +1,584 @@ +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 _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 + pgid = record.get("pgid") + try: + actual_pgid = os.getpgid(pid) + except ProcessLookupError: + return False + if not isinstance(pgid, int) or actual_pgid != pgid: + return False + command = _process_command(pid) + expected = record.get("command_marker") + return isinstance(expected, str) and command is not None and expected in command + + +@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}, + "HF_HUB_DISABLE_TELEMETRY": "1", + "HF_HUB_OFFLINE": "1", + "TRANSFORMERS_OFFLINE": "1", + } + + +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": "256", + "ORT_DISABLE_TELEMETRY": "1", + "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), + "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..07ecb12bd1 --- /dev/null +++ b/muse_glimmer/macos/scripts/prepare_artifacts.py @@ -0,0 +1,105 @@ +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, + artifact_size, + 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']}") + size_bytes = artifact_size(path) + expected_size = item.get("size_bytes") + if expected_size is not None and size_bytes != expected_size: + raise RuntimeError(f"size mismatch for {item['role']}") + inventory[item["role"]] = { + "path": item["destination"], + "sha256": checksum, + "size_bytes": size_bytes, + } + 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..5cb5f53986 --- /dev/null +++ b/muse_glimmer/macos/scripts/privacy_audit.py @@ -0,0 +1,136 @@ +from __future__ import annotations + +import ipaddress +import json +import os +import re +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 = next((column for column in reversed(columns) if "->" in column), None) + if endpoint is None: + raise RuntimeError(f"managed process has an unparseable connection: {row}") + remote = endpoint.rsplit("->", 1)[-1] + host = remote.rsplit(":", 1)[0].strip("[]") + try: + address = ipaddress.ip_address(host) + except ValueError: + raise RuntimeError( + f"managed process has an unparseable connection: {endpoint}" + ) from None + if not address.is_loopback: + 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..1564949965 --- /dev/null +++ b/muse_glimmer/macos/scripts/repository.py @@ -0,0 +1,285 @@ +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 artifact_size(path: Path) -> int: + if path.is_file(): + return path.stat().st_size + return sum(item.stat().st_size for item in path.rglob("*") if item.is_file()) + + +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}") + actual_size = artifact_size(path) + expected_size = requirement.get("size_bytes") + if actual_size != item.get("size_bytes") or ( + expected_size is not None and actual_size != expected_size + ): + raise RuntimeError(f"prepared artifact size 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..eabc28838e --- /dev/null +++ b/muse_glimmer/macos/scripts/validate_manifests.py @@ -0,0 +1,226 @@ +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", +} +_MLX_REPOSITORY = "https://github.com/ml-explore/mlx.git" +_MLX_COMMIT = "7a1d4f5c12ac82f4b4d0a6e71538d89ca0605247" + + +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", + "muse_glimmer_tokenizer_config", + "muse_glimmer_chat_template", + "supertonic_runner", + "mlx_metallib", + "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}" + ) + if role == "mlx_metallib" and ( + source != _MLX_REPOSITORY + or artifact.get("revision") != _MLX_COMMIT + or artifact.get("license") != "MIT" + ): + raise RuntimeError("MLX metallib provenance must match the pinned MLX submodule") + + +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..78229420e4 --- /dev/null +++ b/muse_glimmer/macos/tests/test_dev_stack.py @@ -0,0 +1,369 @@ +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_base_environment_forces_model_libraries_offline( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setenv("HOME", "/tmp/test-home") + environment = dev_stack._base_environment() + + assert environment["HOME"] == "/tmp/test-home" + assert environment["HF_HUB_DISABLE_TELEMETRY"] == "1" + assert environment["HF_HUB_OFFLINE"] == "1" + assert environment["TRANSFORMERS_OFFLINE"] == "1" + + +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 worker.environment["MUSE_GLIMMER_MAX_TOKENS"] == "256" + assert worker.environment["ORT_DISABLE_TELEMETRY"] == "1" + 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_allows_same_pid_exec(monkeypatch: pytest.MonkeyPatch) -> None: + record = { + "pid": 42, + "pgid": 42, + "start_time": "Mon Aug 24 00:00:00 2026", + "command_marker": "python", + } + monkeypatch.setattr(dev_stack, "_pid_alive", lambda _pid: True) + monkeypatch.setattr(dev_stack, "_process_start", lambda _pid: record["start_time"]) + monkeypatch.setattr(dev_stack.os, "getpgid", lambda _pid: 42) + monkeypatch.setattr( + dev_stack, + "_process_command", + lambda _pid: ".venv/bin/python -m executorch.examples.models.muse_glimmer.serving.serve", + ) + + assert dev_stack._service_matches(record) + + +def test_process_identity_rejects_start_group_or_marker_drift( + monkeypatch: pytest.MonkeyPatch, +) -> None: + command = ".venv/bin/muse-glimmer-worker dev" + record = { + "pid": 42, + "pgid": 42, + "start_time": "Mon Aug 24 00:00:00 2026", + "command_marker": "muse-glimmer-worker", + } + monkeypatch.setattr(dev_stack, "_pid_alive", lambda _pid: True) + monkeypatch.setattr(dev_stack, "_process_start", lambda _pid: record["start_time"]) + monkeypatch.setattr(dev_stack.os, "getpgid", lambda _pid: 42) + 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) + monkeypatch.setattr(dev_stack, "_process_start", lambda _pid: record["start_time"]) + monkeypatch.setattr(dev_stack.os, "getpgid", lambda _pid: 99) + assert not dev_stack._service_matches(record) + monkeypatch.setattr(dev_stack.os, "getpgid", lambda _pid: 42) + monkeypatch.setattr(dev_stack, "_process_command", lambda _pid: "different-service") + 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_muse_server_launch.py b/muse_glimmer/macos/tests/test_muse_server_launch.py new file mode 100644 index 0000000000..6b2507c5e6 --- /dev/null +++ b/muse_glimmer/macos/tests/test_muse_server_launch.py @@ -0,0 +1,34 @@ +from __future__ import annotations + +import importlib.util +from pathlib import Path + +import pytest + + +@pytest.fixture +def launcher(): + path = Path(__file__).parents[1] / "apps/muse-glimmer-server/launch.py" + spec = importlib.util.spec_from_file_location("muse_glimmer_server_launch", path) + assert spec is not None and spec.loader is not None + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +def test_server_exec_environment_forces_offline_mode( + launcher, monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + monkeypatch.setenv("HF_HUB_DISABLE_TELEMETRY", "0") + monkeypatch.setenv("HF_HUB_OFFLINE", "0") + monkeypatch.setenv("TRANSFORMERS_OFFLINE", "0") + monkeypatch.setenv("PYTHONPATH", "/existing/pythonpath") + monkeypatch.setenv("SSL_CERT_FILE", "/private/cert.pem") + + environment = launcher._server_environment(tmp_path) + + assert environment["HF_HUB_DISABLE_TELEMETRY"] == "1" + assert environment["HF_HUB_OFFLINE"] == "1" + assert environment["TRANSFORMERS_OFFLINE"] == "1" + assert environment["PYTHONPATH"] == f"{tmp_path / 'src'}:/existing/pythonpath" + assert environment["SSL_CERT_FILE"] == "/private/cert.pem" 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..bc477de581 --- /dev/null +++ b/muse_glimmer/macos/tests/test_privacy_audit.py @@ -0,0 +1,94 @@ +from __future__ import annotations + +import json +from types import SimpleNamespace + +import pytest + +from scripts import privacy_audit + + +def _write_stack(tmp_path) -> 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}}}) + ) + + +def _set_connection_output(tmp_path, monkeypatch: pytest.MonkeyPatch, row: str) -> None: + _write_stack(tmp_path) + output = f"COMMAND PID USER FD TYPE DEVICE SIZE/OFF NODE NAME\n{row}\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) + + +def test_external_connection_audit_accepts_loopback_with_state_column( + tmp_path, monkeypatch: pytest.MonkeyPatch +) -> None: + _set_connection_output( + tmp_path, + monkeypatch, + "node 321 user 10u IPv4 1 0t0 TCP 127.0.0.1:5000->127.0.0.1:7880 (ESTABLISHED)", + ) + + privacy_audit._assert_no_external_connections() + + +def test_external_connection_audit_accepts_ipv6_loopback( + tmp_path, monkeypatch: pytest.MonkeyPatch +) -> None: + _set_connection_output( + tmp_path, + monkeypatch, + "node 321 user 10u IPv6 1 0t0 TCP [::1]:5000->[::1]:7880 (ESTABLISHED)", + ) + + privacy_audit._assert_no_external_connections() + + +def test_external_connection_audit_includes_managed_process_group( + tmp_path, monkeypatch: pytest.MonkeyPatch +) -> None: + _set_connection_output( + tmp_path, + monkeypatch, + "node 321 user 10u IPv4 1 0t0 TCP 127.0.0.1:5000->203.0.113.10:443 (ESTABLISHED)", + ) + + with pytest.raises(RuntimeError, match="non-loopback connection"): + privacy_audit._assert_no_external_connections() + + +def test_external_connection_audit_rejects_external_ipv6( + tmp_path, monkeypatch: pytest.MonkeyPatch +) -> None: + _set_connection_output( + tmp_path, + monkeypatch, + "node 321 user 10u IPv6 1 0t0 TCP [::1]:5000->[2001:db8::1]:443 (ESTABLISHED)", + ) + + with pytest.raises(RuntimeError, match="non-loopback connection"): + privacy_audit._assert_no_external_connections() + + +@pytest.mark.parametrize( + "row", + [ + "node 321 user 10u IPv4 1 0t0 TCP 127.0.0.1:5000 (ESTABLISHED)", + "node 321 user 10u IPv4 1 0t0 TCP 127.0.0.1:5000->not-an-address:443 (ESTABLISHED)", + ], +) +def test_external_connection_audit_rejects_unparseable_endpoint( + tmp_path, monkeypatch: pytest.MonkeyPatch, row: str +) -> None: + _set_connection_output(tmp_path, monkeypatch, row) + + with pytest.raises(RuntimeError, match="unparseable 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..cc4d2f24e1 --- /dev/null +++ b/muse_glimmer/macos/tests/test_repository.py @@ -0,0 +1,246 @@ +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_artifact_size_counts_regular_file_bytes(tmp_path: Path) -> None: + artifact = tmp_path / "artifact" + nested = artifact / "nested" + nested.mkdir(parents=True) + (artifact / "one.bin").write_bytes(b"123") + (nested / "two.bin").write_bytes(b"4567") + + assert repository.artifact_size(artifact / "one.bin") == 3 + assert repository.artifact_size(artifact) == 7 + + +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_load_valid_receipt_rejects_size_drift( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + local = tmp_path / ".local" + artifact = local / "artifacts" / "model.bin" + artifact.parent.mkdir(parents=True) + artifact.write_bytes(b"model") + state = local / "state" + state.mkdir() + compatibility_lock = tmp_path / "compatibility.json" + compatibility_lock.write_text(json.dumps({"executorch": {"commit": "expected", "gates": {}}})) + artifact_lock = tmp_path / "artifacts.json" + artifact_lock.write_text( + json.dumps( + { + "artifacts": [ + { + "role": "model", + "kind": "file", + "executable": False, + "destination": ".local/artifacts/model.bin", + "size_bytes": 5, + } + ] + } + ) + ) + toolchain_lock = tmp_path / "toolchain.json" + toolchain_lock.write_text("{}") + receipt = { + "executorch_commit": "expected", + "executorch_checkout": str(tmp_path / "executorch"), + "locks": {}, + "artifacts": { + "model": { + "path": ".local/artifacts/model.bin", + "sha256": repository.sha256_file(artifact), + "size_bytes": 4, + } + }, + } + prepared_receipt = state / "prepared.json" + prepared_receipt.write_text(json.dumps(receipt)) + monkeypatch.setattr(repository, "ROOT", tmp_path) + monkeypatch.setattr(repository, "LOCAL", local) + monkeypatch.setattr(repository, "PREPARED_RECEIPT", prepared_receipt) + monkeypatch.setattr(repository, "COMPATIBILITY_LOCK", compatibility_lock) + monkeypatch.setattr(repository, "ARTIFACT_LOCK", artifact_lock) + monkeypatch.setattr(repository, "TOOLCHAIN_LOCK", toolchain_lock) + receipt["locks"] = { + "compatibility": repository.digest_json(compatibility_lock), + "artifacts": repository.digest_json(artifact_lock), + "toolchain": repository.digest_json(toolchain_lock), + } + prepared_receipt.write_text(json.dumps(receipt)) + monkeypatch.setattr(repository, "validate_executorch_checkout", lambda *args, **kwargs: None) + + with pytest.raises(RuntimeError, match="prepared artifact size changed"): + repository.load_valid_receipt() + + +def test_load_valid_receipt_accepts_measured_size_when_manifest_size_is_pending( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + local = tmp_path / ".local" + artifact = local / "artifacts" / "model.bin" + artifact.parent.mkdir(parents=True) + artifact.write_bytes(b"model") + state = local / "state" + state.mkdir() + compatibility_lock = tmp_path / "compatibility.json" + compatibility_lock.write_text(json.dumps({"executorch": {"commit": "expected", "gates": {}}})) + artifact_lock = tmp_path / "artifacts.json" + artifact_lock.write_text( + json.dumps( + { + "artifacts": [ + { + "role": "model", + "kind": "file", + "executable": False, + "destination": ".local/artifacts/model.bin", + "size_bytes": None, + } + ] + } + ) + ) + toolchain_lock = tmp_path / "toolchain.json" + toolchain_lock.write_text("{}") + receipt = { + "executorch_commit": "expected", + "executorch_checkout": str(tmp_path / "executorch"), + "locks": { + "compatibility": repository.digest_json(compatibility_lock), + "artifacts": repository.digest_json(artifact_lock), + "toolchain": repository.digest_json(toolchain_lock), + }, + "artifacts": { + "model": { + "path": ".local/artifacts/model.bin", + "sha256": repository.sha256_file(artifact), + "size_bytes": 5, + } + }, + } + prepared_receipt = state / "prepared.json" + prepared_receipt.write_text(json.dumps(receipt)) + monkeypatch.setattr(repository, "ROOT", tmp_path) + monkeypatch.setattr(repository, "LOCAL", local) + monkeypatch.setattr(repository, "PREPARED_RECEIPT", prepared_receipt) + monkeypatch.setattr(repository, "COMPATIBILITY_LOCK", compatibility_lock) + monkeypatch.setattr(repository, "ARTIFACT_LOCK", artifact_lock) + monkeypatch.setattr(repository, "TOOLCHAIN_LOCK", toolchain_lock) + monkeypatch.setattr(repository, "validate_executorch_checkout", lambda *args, **kwargs: None) + + assert repository.load_valid_receipt()["artifacts"]["model"]["size_bytes"] == 5 + + +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..89d4c5486b --- /dev/null +++ b/muse_glimmer/macos/tests/test_validate_manifests.py @@ -0,0 +1,198 @@ +from __future__ import annotations + +import json +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": "artifact-gated", + "platform": "macos-arm64", + "executorch": { + "repository": "https://github.com/pytorch/executorch.git", + "commit": "20ad5ee43ff53804030899d621590af3daadda53", + "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": "landed", + "pull_request": "https://github.com/pytorch/executorch/pull/22208", + "commit": "20ad5ee43ff53804030899d621590af3daadda53", + }, + }, + }, + "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_artifact_gated_capabilities(compatibility: dict[str, object]) -> None: + validate_manifests._validate_compatibility(compatibility) + assert landed_gate_commits(compatibility) == ( + "20ad5ee43ff53804030899d621590af3daadda53", + "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) + gate = _gates(value)["supertonic_server_jsonl"] + gate["status"] = "pending" + gate["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 + gate = _gates(value)["supertonic_server_jsonl"] + gate["status"] = "pending" + gate["commit"] = None + + 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))] + + +@pytest.mark.parametrize( + "required_role", + [ + "mlx_metallib", + "muse_glimmer_tokenizer_config", + "muse_glimmer_chat_template", + ], +) +def test_artifact_contract_requires_runtime_sidecars( + required_role: str, tmp_path, monkeypatch +) -> None: + manifest = json.loads(validate_manifests.ARTIFACT_LOCK.read_text(encoding="utf-8")) + manifest["artifacts"] = [ + artifact for artifact in manifest["artifacts"] if artifact["role"] != required_role + ] + artifact_lock = tmp_path / "artifacts.json" + artifact_lock.write_text(json.dumps(manifest), encoding="utf-8") + monkeypatch.setattr(validate_manifests, "ARTIFACT_LOCK", artifact_lock) + + with pytest.raises(RuntimeError, match="artifact roles differ from the runtime contract"): + validate_manifests._validate_artifacts() + + +def test_mlx_metallib_requires_pinned_submodule_provenance() -> None: + artifact = { + "role": "mlx_metallib", + "source": "executorch", + "revision": "b" * 40, + "sha256": "c" * 64, + "size_bytes": 1, + "license": "BSD-3-Clause", + } + + with pytest.raises(RuntimeError, match="MLX metallib provenance"): + validate_manifests._validate_release_artifacts([artifact], "b" * 40) + + artifact.update( + source="https://github.com/ml-explore/mlx.git", + revision="7a1d4f5c12ac82f4b4d0a6e71538d89ca0605247", + license="MIT", + ) + validate_manifests._validate_release_artifacts([artifact], "b" * 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..009d9e6e25 --- /dev/null +++ b/muse_glimmer/macos/uv.lock @@ -0,0 +1,1444 @@ +version = 1 +revision = 3 +requires-python = "==3.13.*" +resolution-markers = [ + "platform_machine == 'arm64' and sys_platform == 'darwin'", +] +supported-markers = [ + "platform_machine == 'arm64' and sys_platform == 'darwin'", +] + +[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", marker = "platform_machine == 'arm64' and sys_platform == 'darwin'" }, + { name = "aiosignal", marker = "platform_machine == 'arm64' and sys_platform == 'darwin'" }, + { name = "attrs", marker = "platform_machine == 'arm64' and sys_platform == 'darwin'" }, + { name = "frozenlist", marker = "platform_machine == 'arm64' and sys_platform == 'darwin'" }, + { name = "multidict", marker = "platform_machine == 'arm64' and sys_platform == 'darwin'" }, + { name = "propcache", marker = "platform_machine == 'arm64' and sys_platform == 'darwin'" }, + { name = "yarl", marker = "platform_machine == 'arm64' and sys_platform == 'darwin'" }, +] +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/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/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/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" }, +] + +[[package]] +name = "aiosignal" +version = "1.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "frozenlist", marker = "platform_machine == 'arm64' and sys_platform == 'darwin'" }, +] +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", marker = "platform_machine == 'arm64' and sys_platform == 'darwin'" }, +] +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/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" }, +] + +[[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' and platform_machine == 'arm64' and sys_platform == 'darwin'" }, +] +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/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" }, +] + +[[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/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/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/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/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" } +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", marker = "platform_machine == 'arm64' and sys_platform == 'darwin'" }, + { name = "pydantic", marker = "platform_machine == 'arm64' and sys_platform == 'darwin'" }, + { name = "starlette", marker = "platform_machine == 'arm64' and sys_platform == 'darwin'" }, + { name = "typing-extensions", marker = "platform_machine == 'arm64' and sys_platform == 'darwin'" }, + { name = "typing-inspection", marker = "platform_machine == 'arm64' and sys_platform == 'darwin'" }, +] +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 = "filelock" +version = "3.32.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0a/a0/50c2c0ce5e74d7721bbb1b19a26ebd339aac5878553a6e35308c2f31f935/filelock-3.32.5.tar.gz", hash = "sha256:f6a6a28f743f9b95ce19db5abe0f376f75eb56517dff21e1a4751e2657d3e83d", size = 222838, upload-time = "2026-08-31T18:56:34.729Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/36/d2/b70a31e13d04456d28493f31d2aa087e99eeb2767ef0293b2625727ccb8c/filelock-3.32.5-py3-none-any.whl", hash = "sha256:142cd9fa77a872c5e78c62329a0d15278fadc686eb89e760017968961a4fd6b2", size = 100003, upload-time = "2026-08-31T18:56:33.078Z" }, +] + +[[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/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/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/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/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 = "fsspec" +version = "2026.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/00/78/f34251dadb8f3921264a1d9b8946f5e542014ee2614b285261b4e40e6775/fsspec-2026.7.0.tar.gz", hash = "sha256:c803c40f4cf860b49dea58ee3e1c33cb9c790520e233537e1340049f89b82a88", size = 317040, upload-time = "2026-07-28T16:34:51.052Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fd/3c/6a2bf344106328fd04963664a60b9bb6496fc25df8e962fcdc1367285fb9/fsspec-2026.7.0-py3-none-any.whl", hash = "sha256:b57ddbafedfaef7018c1ecab32aa200a9d7ca26b77965f64e48b70061249d279", size = 206583, upload-time = "2026-07-28T16:34:49.538Z" }, +] + +[[package]] +name = "googleapis-common-protos" +version = "1.75.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "protobuf", marker = "platform_machine == 'arm64' and sys_platform == 'darwin'" }, +] +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", marker = "platform_machine == 'arm64' and sys_platform == 'darwin'" }, +] +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/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" }, +] + +[[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 = "hf-xet" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1b/ab/522a2ab67f27971a9d48ca666d4fca85ef7d5282d142e31fd087e27b1bbe/hf_xet-1.6.0.tar.gz", hash = "sha256:2e58454a340b3556dfa4972d5451aff4fba8dd42a236600ba1a1d2b1514f0fef", size = 920527, upload-time = "2026-08-03T22:33:13.243Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4b/69/55b8dcf636142ae660fec1869fcac14c4da2e8412e14d6eee1523be77e9f/hf_xet-1.6.0-cp38-abi3-macosx_11_0_arm64.whl", hash = "sha256:f0906082d9932ae0c0057fa194041c22b4e2cdb46b2592ef3b91f020d62a081a", size = 3876287, upload-time = "2026-08-03T22:33:02.251Z" }, +] + +[[package]] +name = "httpcore" +version = "1.0.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi", marker = "platform_machine == 'arm64' and sys_platform == 'darwin'" }, + { name = "h11", marker = "platform_machine == 'arm64' and sys_platform == 'darwin'" }, +] +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" }, +] + +[[package]] +name = "httpx" +version = "0.28.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio", marker = "platform_machine == 'arm64' and sys_platform == 'darwin'" }, + { name = "certifi", marker = "platform_machine == 'arm64' and sys_platform == 'darwin'" }, + { name = "httpcore", marker = "platform_machine == 'arm64' and sys_platform == 'darwin'" }, + { name = "idna", marker = "platform_machine == 'arm64' and sys_platform == 'darwin'" }, +] +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 = "huggingface-hub" +version = "1.29.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click", marker = "platform_machine == 'arm64' and sys_platform == 'darwin'" }, + { name = "filelock", marker = "platform_machine == 'arm64' and sys_platform == 'darwin'" }, + { name = "fsspec", marker = "platform_machine == 'arm64' and sys_platform == 'darwin'" }, + { name = "hf-xet", marker = "platform_machine == 'arm64' and sys_platform == 'darwin'" }, + { name = "httpx", marker = "platform_machine == 'arm64' and sys_platform == 'darwin'" }, + { name = "packaging", marker = "platform_machine == 'arm64' and sys_platform == 'darwin'" }, + { name = "pyyaml", marker = "platform_machine == 'arm64' and sys_platform == 'darwin'" }, + { name = "tqdm", marker = "platform_machine == 'arm64' and sys_platform == 'darwin'" }, + { name = "typing-extensions", marker = "platform_machine == 'arm64' and sys_platform == 'darwin'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/64/35/42316e8f6908b6d21bc8df017cc6efba94fb5edbf99b64e28dd142325e20/huggingface_hub-1.29.0.tar.gz", hash = "sha256:6ebb385a581435325cf6d5c5b233d5d4bc91175834d99fd65dae14379b36e9ad", size = 963121, upload-time = "2026-08-27T12:18:37.432Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4e/a5/47c2ea9b228ccbcba8467e9a64823146e8ebbad29855e591d8f5eedcc9c7/huggingface_hub-1.29.0-py3-none-any.whl", hash = "sha256:b00f7782afc14db4bc6572763810a635bdfbab8623d957bfb553bd18e03852cd", size = 795768, upload-time = "2026-08-27T12:18:35.431Z" }, +] + +[[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 = "jinja2" +version = "3.1.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markupsafe", marker = "platform_machine == 'arm64' and sys_platform == 'darwin'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115, upload-time = "2025-03-05T20:05:02.478Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" }, +] + +[[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/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" }, +] + +[[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", marker = "platform_machine == 'arm64' and sys_platform == 'darwin'" }, + { name = "jsonschema-specifications", marker = "platform_machine == 'arm64' and sys_platform == 'darwin'" }, + { name = "referencing", marker = "platform_machine == 'arm64' and sys_platform == 'darwin'" }, + { name = "rpds-py", marker = "platform_machine == 'arm64' and sys_platform == 'darwin'" }, +] +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", marker = "platform_machine == 'arm64' and sys_platform == 'darwin'" }, +] +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", marker = "platform_machine == 'arm64' and sys_platform == 'darwin'" }, + { name = "numpy", marker = "platform_machine == 'arm64' and sys_platform == 'darwin'" }, + { name = "protobuf", marker = "platform_machine == 'arm64' and sys_platform == 'darwin'" }, + { name = "types-protobuf", marker = "platform_machine == 'arm64' and sys_platform == 'darwin'" }, +] +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/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" }, +] + +[[package]] +name = "livekit-agents" +version = "1.7.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "aiofiles", marker = "platform_machine == 'arm64' and sys_platform == 'darwin'" }, + { name = "aiohttp", marker = "platform_machine == 'arm64' and sys_platform == 'darwin'" }, + { name = "av", marker = "platform_machine == 'arm64' and sys_platform == 'darwin'" }, + { name = "certifi", marker = "platform_machine == 'arm64' and sys_platform == 'darwin'" }, + { name = "click", marker = "platform_machine == 'arm64' and sys_platform == 'darwin'" }, + { name = "colorama", marker = "platform_machine == 'arm64' and sys_platform == 'darwin'" }, + { name = "docstring-parser", marker = "platform_machine == 'arm64' and sys_platform == 'darwin'" }, + { name = "eval-type-backport", marker = "platform_machine == 'arm64' and sys_platform == 'darwin'" }, + { name = "json-repair", marker = "platform_machine == 'arm64' and sys_platform == 'darwin'" }, + { name = "livekit", marker = "platform_machine == 'arm64' and sys_platform == 'darwin'" }, + { name = "livekit-api", marker = "platform_machine == 'arm64' and sys_platform == 'darwin'" }, + { name = "livekit-blingfire", marker = "platform_machine == 'arm64' and sys_platform == 'darwin'" }, + { name = "livekit-local-inference", marker = "platform_machine == 'arm64' and sys_platform == 'darwin'" }, + { name = "livekit-protocol", marker = "platform_machine == 'arm64' and sys_platform == 'darwin'" }, + { name = "nest-asyncio", marker = "platform_machine == 'arm64' and sys_platform == 'darwin'" }, + { name = "numpy", marker = "platform_machine == 'arm64' and sys_platform == 'darwin'" }, + { name = "openai", marker = "platform_machine == 'arm64' and sys_platform == 'darwin'" }, + { name = "opentelemetry-api", marker = "platform_machine == 'arm64' and sys_platform == 'darwin'" }, + { name = "opentelemetry-exporter-otlp", marker = "platform_machine == 'arm64' and sys_platform == 'darwin'" }, + { name = "opentelemetry-sdk", marker = "platform_machine == 'arm64' and sys_platform == 'darwin'" }, + { name = "prometheus-client", marker = "platform_machine == 'arm64' and sys_platform == 'darwin'" }, + { name = "protobuf", marker = "platform_machine == 'arm64' and sys_platform == 'darwin'" }, + { name = "psutil", marker = "platform_machine == 'arm64' and sys_platform == 'darwin'" }, + { name = "pydantic", marker = "platform_machine == 'arm64' and sys_platform == 'darwin'" }, + { name = "pyjwt", marker = "platform_machine == 'arm64' and sys_platform == 'darwin'" }, + { name = "pyyaml", marker = "platform_machine == 'arm64' and sys_platform == 'darwin'" }, + { name = "sounddevice", marker = "platform_machine == 'arm64' and sys_platform == 'darwin'" }, + { name = "typer", marker = "platform_machine == 'arm64' and sys_platform == 'darwin'" }, + { name = "types-protobuf", marker = "platform_machine == 'arm64' and sys_platform == 'darwin'" }, + { name = "typing-extensions", marker = "platform_machine == 'arm64' and sys_platform == 'darwin'" }, + { name = "watchfiles", marker = "platform_machine == 'arm64' and sys_platform == 'darwin'" }, +] +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", marker = "platform_machine == 'arm64' and sys_platform == 'darwin'" }, +] +images = [ + { name = "pillow", marker = "platform_machine == 'arm64' and sys_platform == 'darwin'" }, +] +openai = [ + { name = "livekit-plugins-openai", marker = "platform_machine == 'arm64' and sys_platform == 'darwin'" }, +] +silero = [ + { name = "livekit-plugins-silero", marker = "platform_machine == 'arm64' and sys_platform == 'darwin'" }, +] + +[[package]] +name = "livekit-api" +version = "1.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "aiohttp", marker = "platform_machine == 'arm64' and sys_platform == 'darwin'" }, + { name = "livekit-protocol", marker = "platform_machine == 'arm64' and sys_platform == 'darwin'" }, + { name = "protobuf", marker = "platform_machine == 'arm64' and sys_platform == 'darwin'" }, + { name = "pyjwt", marker = "platform_machine == 'arm64' and sys_platform == 'darwin'" }, + { name = "types-protobuf", marker = "platform_machine == 'arm64' and sys_platform == 'darwin'" }, +] +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/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" }, +] + +[[package]] +name = "livekit-local-inference" +version = "0.2.7" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { 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" }, +] + +[[package]] +name = "livekit-plugins-executorch" +source = { editable = "packages/livekit-plugins-executorch" } +dependencies = [ + { name = "livekit-agents", marker = "platform_machine == 'arm64' and sys_platform == 'darwin'" }, +] + +[package.dev-dependencies] +dev = [ + { name = "pytest", marker = "platform_machine == 'arm64' and sys_platform == 'darwin'" }, + { name = "pytest-asyncio", marker = "platform_machine == 'arm64' and sys_platform == 'darwin'" }, + { name = "ruff", marker = "platform_machine == 'arm64' and sys_platform == 'darwin'" }, +] + +[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"], marker = "platform_machine == 'arm64' and sys_platform == 'darwin'" }, + { name = "openai", extra = ["realtime"], marker = "platform_machine == 'arm64' and sys_platform == 'darwin'" }, +] +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", marker = "platform_machine == 'arm64' and sys_platform == 'darwin'" }, + { name = "numpy", marker = "platform_machine == 'arm64' and sys_platform == 'darwin'" }, + { name = "onnxruntime", marker = "platform_machine == 'arm64' and sys_platform == 'darwin'" }, +] +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", marker = "platform_machine == 'arm64' and sys_platform == 'darwin'" }, + { name = "types-protobuf", marker = "platform_machine == 'arm64' and sys_platform == 'darwin'" }, +] +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", marker = "platform_machine == 'arm64' and sys_platform == 'darwin'" }, +] +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 = "markupsafe" +version = "3.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7e/99/7690b6d4034fffd95959cbe0c02de8deb3098cc577c67bb6a24fe5d7caa7/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698", size = 80313, upload-time = "2025-09-27T18:37:40.426Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9c/d9/5f7756922cdd676869eca1c4e3c0cd0df60ed30199ffd775e319089cb3ed/markupsafe-3.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:116bb52f642a37c115f517494ea5feb03889e04df47eeff5b130b1808ce7c219", size = 12029, upload-time = "2025-09-27T18:36:43.257Z" }, + { url = "https://files.pythonhosted.org/packages/99/9e/e412117548182ce2148bdeacdda3bb494260c0b0184360fe0d56389b523b/markupsafe-3.0.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3524b778fe5cfb3452a09d31e7b5adefeea8c5be1d43c4f810ba09f2ceb29d37", size = 12066, upload-time = "2025-09-27T18:36:55.714Z" }, +] + +[[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 = "mpmath" +version = "1.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e0/47/dd32fa426cc72114383ac549964eecb20ecfd886d1e5ccf5340b55b02f57/mpmath-1.3.0.tar.gz", hash = "sha256:7a28eb2a9774d00c7bc92411c19a89209d5da7c4c9a9e227be8330a23a25b91f", size = 508106, upload-time = "2023-03-07T16:47:11.061Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/43/e3/7d92a15f894aa0c9c4b49b8ee9ac9850d6e63b03c9c32c0367a13ae62209/mpmath-1.3.0-py3-none-any.whl", hash = "sha256:a0b2b9fe80bbcd81a6647ff13108738cfb482d481d826cc0e02f5b35e5c88d2c", size = 536198, upload-time = "2023-03-07T16:47:09.197Z" }, +] + +[[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/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/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/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/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", marker = "platform_machine == 'arm64' and sys_platform == 'darwin'" }, + { name = "livekit-api", marker = "platform_machine == 'arm64' and sys_platform == 'darwin'" }, + { name = "pydantic-settings", marker = "platform_machine == 'arm64' and sys_platform == 'darwin'" }, + { name = "uvicorn", extra = ["standard"], marker = "platform_machine == 'arm64' and sys_platform == 'darwin'" }, +] + +[package.dev-dependencies] +dev = [ + { name = "httpx", marker = "platform_machine == 'arm64' and sys_platform == 'darwin'" }, + { name = "pyjwt", marker = "platform_machine == 'arm64' and sys_platform == 'darwin'" }, + { name = "pytest", marker = "platform_machine == 'arm64' and sys_platform == 'darwin'" }, + { name = "ruff", marker = "platform_machine == 'arm64' and sys_platform == 'darwin'" }, +] + +[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 = "." } +dependencies = [ + { name = "torch", marker = "platform_machine == 'arm64' and sys_platform == 'darwin'" }, + { name = "transformers", marker = "platform_machine == 'arm64' and sys_platform == 'darwin'" }, +] + +[package.dev-dependencies] +dev = [ + { name = "jsonschema", marker = "platform_machine == 'arm64' and sys_platform == 'darwin'" }, + { name = "pytest", marker = "platform_machine == 'arm64' and sys_platform == 'darwin'" }, + { name = "ruff", marker = "platform_machine == 'arm64' and sys_platform == 'darwin'" }, +] + +[package.metadata] +requires-dist = [ + { name = "torch", specifier = "==2.13.0" }, + { name = "transformers", specifier = "==5.0.0rc1" }, +] + +[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"], marker = "platform_machine == 'arm64' and sys_platform == 'darwin'" }, + { name = "livekit-plugins-executorch", marker = "platform_machine == 'arm64' and sys_platform == 'darwin'" }, +] + +[package.dev-dependencies] +dev = [ + { name = "pytest", marker = "platform_machine == 'arm64' and sys_platform == 'darwin'" }, + { name = "pytest-asyncio", marker = "platform_machine == 'arm64' and sys_platform == 'darwin'" }, + { name = "ruff", marker = "platform_machine == 'arm64' and sys_platform == 'darwin'" }, +] + +[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 = "networkx" +version = "3.6.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6a/51/63fe664f3908c97be9d2e4f1158eb633317598cfa6e1fc14af5383f17512/networkx-3.6.1.tar.gz", hash = "sha256:26b7c357accc0c8cde558ad486283728b65b6a95d85ee1cd66bafab4c8168509", size = 2517025, upload-time = "2025-12-08T17:02:39.908Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9e/c9/b2622292ea83fbb4ec318f5b9ab867d0a28ab43c5717bb85b0a5f6b3b0a4/networkx-3.6.1-py3-none-any.whl", hash = "sha256:d47fbf302e7d9cbbb9e2555a0d267983d2aa476bac30e90dfbe5669bd57f3762", size = 2068504, upload-time = "2025-12-08T17:02:38.159Z" }, +] + +[[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/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" }, +] + +[[package]] +name = "onnxruntime" +version = "1.29.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "flatbuffers", marker = "platform_machine == 'arm64' and sys_platform == 'darwin'" }, + { name = "numpy", marker = "platform_machine == 'arm64' and sys_platform == 'darwin'" }, + { name = "packaging", marker = "platform_machine == 'arm64' and sys_platform == 'darwin'" }, + { name = "protobuf", marker = "platform_machine == 'arm64' and sys_platform == 'darwin'" }, +] +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" }, +] + +[[package]] +name = "openai" +version = "2.54.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio", marker = "platform_machine == 'arm64' and sys_platform == 'darwin'" }, + { name = "distro", marker = "platform_machine == 'arm64' and sys_platform == 'darwin'" }, + { name = "httpx", marker = "platform_machine == 'arm64' and sys_platform == 'darwin'" }, + { name = "jiter", marker = "platform_machine == 'arm64' and sys_platform == 'darwin'" }, + { name = "pydantic", marker = "platform_machine == 'arm64' and sys_platform == 'darwin'" }, + { name = "sniffio", marker = "platform_machine == 'arm64' and sys_platform == 'darwin'" }, + { name = "tqdm", marker = "platform_machine == 'arm64' and sys_platform == 'darwin'" }, + { name = "typing-extensions", marker = "platform_machine == 'arm64' and sys_platform == 'darwin'" }, +] +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", marker = "platform_machine == 'arm64' and sys_platform == 'darwin'" }, +] + +[[package]] +name = "opentelemetry-api" +version = "1.44.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions", marker = "platform_machine == 'arm64' and sys_platform == 'darwin'" }, +] +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", marker = "platform_machine == 'arm64' and sys_platform == 'darwin'" }, + { name = "opentelemetry-exporter-otlp-proto-http", marker = "platform_machine == 'arm64' and sys_platform == 'darwin'" }, +] +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", marker = "platform_machine == 'arm64' and sys_platform == 'darwin'" }, +] +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", marker = "platform_machine == 'arm64' and sys_platform == 'darwin'" }, + { name = "grpcio", marker = "platform_machine == 'arm64' and sys_platform == 'darwin'" }, + { name = "opentelemetry-api", marker = "platform_machine == 'arm64' and sys_platform == 'darwin'" }, + { name = "opentelemetry-exporter-otlp-proto-common", marker = "platform_machine == 'arm64' and sys_platform == 'darwin'" }, + { name = "opentelemetry-proto", marker = "platform_machine == 'arm64' and sys_platform == 'darwin'" }, + { name = "opentelemetry-sdk", marker = "platform_machine == 'arm64' and sys_platform == 'darwin'" }, + { name = "typing-extensions", marker = "platform_machine == 'arm64' and sys_platform == 'darwin'" }, +] +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", marker = "platform_machine == 'arm64' and sys_platform == 'darwin'" }, + { name = "opentelemetry-api", marker = "platform_machine == 'arm64' and sys_platform == 'darwin'" }, + { name = "opentelemetry-exporter-otlp-proto-common", marker = "platform_machine == 'arm64' and sys_platform == 'darwin'" }, + { name = "opentelemetry-proto", marker = "platform_machine == 'arm64' and sys_platform == 'darwin'" }, + { name = "opentelemetry-sdk", marker = "platform_machine == 'arm64' and sys_platform == 'darwin'" }, + { name = "requests", marker = "platform_machine == 'arm64' and sys_platform == 'darwin'" }, + { name = "typing-extensions", marker = "platform_machine == 'arm64' and sys_platform == 'darwin'" }, +] +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", marker = "platform_machine == 'arm64' and sys_platform == 'darwin'" }, +] +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", marker = "platform_machine == 'arm64' and sys_platform == 'darwin'" }, + { name = "opentelemetry-semantic-conventions", marker = "platform_machine == 'arm64' and sys_platform == 'darwin'" }, + { name = "typing-extensions", marker = "platform_machine == 'arm64' and sys_platform == 'darwin'" }, +] +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", marker = "platform_machine == 'arm64' and sys_platform == 'darwin'" }, + { name = "typing-extensions", marker = "platform_machine == 'arm64' and sys_platform == 'darwin'" }, +] +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/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" }, +] + +[[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/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/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/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/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/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/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/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" }, +] + +[[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", marker = "platform_machine == 'arm64' and sys_platform == 'darwin'" }, + { name = "pydantic-core", marker = "platform_machine == 'arm64' and sys_platform == 'darwin'" }, + { name = "typing-extensions", marker = "platform_machine == 'arm64' and sys_platform == 'darwin'" }, + { name = "typing-inspection", marker = "platform_machine == 'arm64' and sys_platform == 'darwin'" }, +] +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", marker = "platform_machine == 'arm64' and sys_platform == 'darwin'" }, +] +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/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" }, +] + +[[package]] +name = "pydantic-settings" +version = "2.15.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pydantic", marker = "platform_machine == 'arm64' and sys_platform == 'darwin'" }, + { name = "python-dotenv", marker = "platform_machine == 'arm64' and sys_platform == 'darwin'" }, + { name = "typing-inspection", marker = "platform_machine == 'arm64' and sys_platform == 'darwin'" }, +] +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 = "iniconfig", marker = "platform_machine == 'arm64' and sys_platform == 'darwin'" }, + { name = "packaging", marker = "platform_machine == 'arm64' and sys_platform == 'darwin'" }, + { name = "pluggy", marker = "platform_machine == 'arm64' and sys_platform == 'darwin'" }, + { name = "pygments", marker = "platform_machine == 'arm64' and sys_platform == 'darwin'" }, +] +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", marker = "platform_machine == 'arm64' and sys_platform == 'darwin'" }, +] +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/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" }, +] + +[[package]] +name = "referencing" +version = "0.37.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs", marker = "platform_machine == 'arm64' and sys_platform == 'darwin'" }, + { name = "rpds-py", marker = "platform_machine == 'arm64' and sys_platform == 'darwin'" }, +] +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 = "regex" +version = "2026.8.31" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/61/d8/9c23ec31d4973d7b41a99f45c7aa9aa65c7c4313d5c0463aafdb8fe05dd7/regex-2026.8.31.tar.gz", hash = "sha256:9350fd448a6442ae27853ab9d4b8d5a0bcb6d7774923a4fdfddd104c4458b35f", size = 416646, upload-time = "2026-08-30T21:53:47.227Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d9/6a/7d273b02dd8fd6be59db8446891067c100f6c80fb4b15e8aff05b268aed9/regex-2026.8.31-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:dacc364aa1c06cb3fffb1705ff313cb3622c94d8c248f29e57bac2acadd77bf7", size = 496453, upload-time = "2026-08-30T21:51:43.375Z" }, + { url = "https://files.pythonhosted.org/packages/d1/1b/b516e9dc4fb24f220f7db4c7be433de1bdffaba3f3666d160bfc76c4ee53/regex-2026.8.31-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:98381539ee2dd88794f3ce6e40166f59b93e6e3ee9cd27dea9f2dd6b857f3dbc", size = 291796, upload-time = "2026-08-30T21:51:46.821Z" }, + { url = "https://files.pythonhosted.org/packages/7d/09/478487f668c4d72dcbfb65eb7ad7d51a7102566f3430aad2feaeaea7111f/regex-2026.8.31-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:8d3e98b55372aa36b1e046a56a10f13cf0ef782ad6c86dbd64f3897c7e7a7a02", size = 501009, upload-time = "2026-08-30T21:52:10.817Z" }, + { url = "https://files.pythonhosted.org/packages/d8/03/8814a03181de9ace8d4bea55a5e2ea660859f6a5a10bd166be03398e9c70/regex-2026.8.31-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:69fbc60c1c34790037cfd350dd1600436fdfea9ca221761c614fc5e633c7cabd", size = 294479, upload-time = "2026-08-30T21:52:14.333Z" }, +] + +[[package]] +name = "requests" +version = "2.34.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi", marker = "platform_machine == 'arm64' and sys_platform == 'darwin'" }, + { name = "charset-normalizer", marker = "platform_machine == 'arm64' and sys_platform == 'darwin'" }, + { name = "idna", marker = "platform_machine == 'arm64' and sys_platform == 'darwin'" }, + { name = "urllib3", marker = "platform_machine == 'arm64' and sys_platform == 'darwin'" }, +] +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", marker = "platform_machine == 'arm64' and sys_platform == 'darwin'" }, + { name = "pygments", marker = "platform_machine == 'arm64' and sys_platform == 'darwin'" }, +] +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/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" }, +] + +[[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/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" }, +] + +[[package]] +name = "safetensors" +version = "0.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/45/06/f955dbbb1859e3bd23c8ac6141af5106e7ad5fedec4a3a6e3d60f94b7001/safetensors-0.8.0.tar.gz", hash = "sha256:fabaf3e0f18a6618d9b36560682562157f77c2b71fcffc7b432be2baed9d753d", size = 325846, upload-time = "2026-06-09T07:52:25.563Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f5/b1/fa7c600e7dceae12e9606c7578cbc9ff1e1ed55844883ee5c92205e86226/safetensors-0.8.0-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:c80201d22cbf405b80647a60ada77bba06c8fba2da2743ba1e89cdcc39a81f25", size = 484562, upload-time = "2026-06-09T07:52:17.518Z" }, +] + +[[package]] +name = "setuptools" +version = "84.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6d/44/f5da03a8ef95d369145c5bb53050e7877c9f3d312e128605fd9504829143/setuptools-84.0.0.tar.gz", hash = "sha256:f4695c21257f0d9b537ec2692c941d02ee143b7cc1276941349a546573b2ef73", size = 1168449, upload-time = "2026-08-08T18:27:58.365Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/95/9c/c510029fc6ef33a6275cd2c5d3cecd6613dfd6aa401d57c54f1c18852ccf/setuptools-84.0.0-py3-none-any.whl", hash = "sha256:51a52592b3b99e102b609654876bd65f19f999935166d1352678931132b0c670", size = 818216, upload-time = "2026-08-08T18:27:56.719Z" }, +] + +[[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", marker = "platform_machine == 'arm64' and sys_platform == 'darwin'" }, +] +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" }, +] + +[[package]] +name = "starlette" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio", marker = "platform_machine == 'arm64' and sys_platform == 'darwin'" }, +] +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 = "sympy" +version = "1.14.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mpmath", marker = "platform_machine == 'arm64' and sys_platform == 'darwin'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/83/d3/803453b36afefb7c2bb238361cd4ae6125a569b4db67cd9e79846ba2d68c/sympy-1.14.0.tar.gz", hash = "sha256:d3d3fe8df1e5a0b42f0e7bdf50541697dbe7d23746e894990c030e2b05e72517", size = 7793921, upload-time = "2025-04-27T18:05:01.611Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a2/09/77d55d46fd61b4a135c444fc97158ef34a095e5681d0a6c10b75bf356191/sympy-1.14.0-py3-none-any.whl", hash = "sha256:e091cc3e99d2141a0ba2847328f5479b05d94a6635cb96148ccb3f34671bd8f5", size = 6299353, upload-time = "2025-04-27T18:04:59.103Z" }, +] + +[[package]] +name = "tokenizers" +version = "0.22.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "huggingface-hub", marker = "platform_machine == 'arm64' and sys_platform == 'darwin'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/73/6f/f80cfef4a312e1fb34baf7d85c72d4411afde10978d4657f8cdd811d3ccc/tokenizers-0.22.2.tar.gz", hash = "sha256:473b83b915e547aa366d1eee11806deaf419e17be16310ac0a14077f1e28f917", size = 372115, upload-time = "2026-01-05T10:45:15.988Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2e/47/174dca0502ef88b28f1c9e06b73ce33500eedfac7a7692108aec220464e7/tokenizers-0.22.2-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:1e418a55456beedca4621dbab65a318981467a2b188e982a23e117f115ce5001", size = 2981472, upload-time = "2026-01-05T10:41:00.276Z" }, +] + +[[package]] +name = "torch" +version = "2.13.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "filelock", marker = "platform_machine == 'arm64' and sys_platform == 'darwin'" }, + { name = "fsspec", marker = "platform_machine == 'arm64' and sys_platform == 'darwin'" }, + { name = "jinja2", marker = "platform_machine == 'arm64' and sys_platform == 'darwin'" }, + { name = "networkx", marker = "platform_machine == 'arm64' and sys_platform == 'darwin'" }, + { name = "setuptools", marker = "platform_machine == 'arm64' and sys_platform == 'darwin'" }, + { name = "sympy", marker = "platform_machine == 'arm64' and sys_platform == 'darwin'" }, + { name = "typing-extensions", marker = "platform_machine == 'arm64' and sys_platform == 'darwin'" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/0d/fa/c1c10b7aff4a9a3e8956d4f0a5f468fa6db7abc3208805719076772b4833/torch-2.13.0-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:33449899ce5496c1b84b4853179d94fd102028ae1407314d9fb956bb79e70d09", size = 111213743, upload-time = "2026-07-08T16:03:28.579Z" }, +] + +[[package]] +name = "tqdm" +version = "4.70.0" +source = { registry = "https://pypi.org/simple" } +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 = "transformers" +version = "5.0.0rc1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "filelock", marker = "platform_machine == 'arm64' and sys_platform == 'darwin'" }, + { name = "huggingface-hub", marker = "platform_machine == 'arm64' and sys_platform == 'darwin'" }, + { name = "numpy", marker = "platform_machine == 'arm64' and sys_platform == 'darwin'" }, + { name = "packaging", marker = "platform_machine == 'arm64' and sys_platform == 'darwin'" }, + { name = "pyyaml", marker = "platform_machine == 'arm64' and sys_platform == 'darwin'" }, + { name = "regex", marker = "platform_machine == 'arm64' and sys_platform == 'darwin'" }, + { name = "requests", marker = "platform_machine == 'arm64' and sys_platform == 'darwin'" }, + { name = "safetensors", marker = "platform_machine == 'arm64' and sys_platform == 'darwin'" }, + { name = "tokenizers", marker = "platform_machine == 'arm64' and sys_platform == 'darwin'" }, + { name = "tqdm", marker = "platform_machine == 'arm64' and sys_platform == 'darwin'" }, + { name = "typer-slim", marker = "platform_machine == 'arm64' and sys_platform == 'darwin'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/2f/33/c4d7a86f5a60fda56e72f90911ce859044ecdac1dcea4cf904c1eb20ecf2/transformers-5.0.0rc1.tar.gz", hash = "sha256:1fdde557b96ef8ea277c45b8e0d558f1e167fe28a98593f4c4aec0277e335821", size = 8208085, upload-time = "2025-12-11T17:21:23.486Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fb/74/fd8aef40d2bf2a15c0e02a0d867ebbf488ccca79fcf45efa51ec8e40c004/transformers-5.0.0rc1-py3-none-any.whl", hash = "sha256:8b9604700769872cab4280dbcde201f557e93f72ee5a85c4592275ab4f15d330", size = 9873024, upload-time = "2025-12-11T17:21:20.348Z" }, +] + +[[package]] +name = "typer" +version = "0.27.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-doc", marker = "platform_machine == 'arm64' and sys_platform == 'darwin'" }, + { name = "rich", marker = "platform_machine == 'arm64' and sys_platform == 'darwin'" }, + { name = "shellingham", marker = "platform_machine == 'arm64' and sys_platform == 'darwin'" }, +] +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 = "typer-slim" +version = "0.24.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typer", marker = "platform_machine == 'arm64' and sys_platform == 'darwin'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a7/a7/e6aecc4b4eb59598829a3b5076a93aff291b4fdaa2ded25efc4e1f4d219c/typer_slim-0.24.0.tar.gz", hash = "sha256:f0ed36127183f52ae6ced2ecb2521789995992c521a46083bfcdbb652d22ad34", size = 4776, upload-time = "2026-02-16T22:08:51.2Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a7/24/5480c20380dfd18cf33d14784096dca45a24eae6102e91d49a718d3b6855/typer_slim-0.24.0-py3-none-any.whl", hash = "sha256:d5d7ee1ee2834d5020c7c616ed5e0d0f29b9a4b1dd283bdebae198ec09778d0e", size = 3394, upload-time = "2026-02-16T22:08:49.92Z" }, +] + +[[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", marker = "platform_machine == 'arm64' and sys_platform == 'darwin'" }, +] +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", marker = "platform_machine == 'arm64' and sys_platform == 'darwin'" }, + { name = "h11", marker = "platform_machine == 'arm64' and sys_platform == 'darwin'" }, +] +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", marker = "platform_machine == 'arm64' and sys_platform == 'darwin'" }, + { name = "python-dotenv", marker = "platform_machine == 'arm64' and sys_platform == 'darwin'" }, + { name = "pyyaml", marker = "platform_machine == 'arm64' and sys_platform == 'darwin'" }, + { name = "uvloop", marker = "platform_machine == 'arm64' and platform_python_implementation != 'PyPy' and sys_platform == 'darwin'" }, + { name = "watchfiles", marker = "platform_machine == 'arm64' and sys_platform == 'darwin'" }, + { name = "websockets", marker = "platform_machine == 'arm64' and sys_platform == 'darwin'" }, +] + +[[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" }, +] + +[[package]] +name = "watchfiles" +version = "1.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio", marker = "platform_machine == 'arm64' and sys_platform == 'darwin'" }, +] +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/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/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" }, +] + +[[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/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/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", marker = "platform_machine == 'arm64' and sys_platform == 'darwin'" }, + { name = "multidict", marker = "platform_machine == 'arm64' and sys_platform == 'darwin'" }, + { name = "propcache", marker = "platform_machine == 'arm64' and sys_platform == 'darwin'" }, +] +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/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/61/02/962c1cbfc401a30c1d034dc67ff395f64b52302c6d62de556c1fca99acc0/yarl-1.24.5-py3-none-any.whl", hash = "sha256:a33700d13d9b7d84fd10947b09ff69fb9a792e519c8cb9764a3ca70baa6c23a7", size = 58612, upload-time = "2026-07-20T02:07:43.461Z" }, +]