From 80ec3b01d23f5718b728213a10e1a959e7ba1aa4 Mon Sep 17 00:00:00 2001 From: Pavel Donin Date: Mon, 9 Nov 2026 15:31:57 +0100 Subject: [PATCH 001/104] Remove v3 code and documents --- CONTEXT.md | 44 - backend/.python-version | 1 - backend/pyproject.toml | 39 - backend/src/voca/__init__.py | 1 - backend/src/voca/application/__init__.py | 0 backend/src/voca/application/ports.py | 63 - backend/src/voca/application/sessions.py | 54 - backend/src/voca/bootstrap/__init__.py | 0 backend/src/voca/bootstrap/app.py | 48 - backend/src/voca/bootstrap/settings.py | 51 - backend/src/voca/domain/__init__.py | 0 backend/src/voca/domain/session.py | 11 - backend/src/voca/infrastructure/__init__.py | 0 .../infrastructure/observability/__init__.py | 0 .../observability/diagnostics.py | 158 --- .../infrastructure/observability/logging.py | 90 -- backend/src/voca/infrastructure/sqlite.py | 147 -- .../src/voca/infrastructure/sqlite_schema.py | 40 - backend/src/voca/presentation/__init__.py | 0 backend/src/voca/presentation/control.py | 266 ---- backend/src/voca/presentation/http.py | 75 - backend/src/voca/presentation/responses.py | 66 - backend/src/voca/presentation/schemas.py | 63 - backend/tests/test_app_lifecycle.py | 112 -- backend/tests/test_diagnostics.py | 120 -- backend/tests/test_session_flow.py | 264 ---- backend/uv.lock | 408 ------ docs/adr/0001-rewrite-v3.md | 63 - ...0002-conversation-lifecycle-and-roadmap.md | 48 - docs/adr/0003-code-and-test-foundation.md | 39 - docs/architecture.md | 335 ----- docs/code-architecture.md | 41 - docs/diagnostics.md | 20 - docs/index.md | 59 - docs/research/minicpm5-2b.md | 33 - docs/roadmap.md | 104 -- docs/testing.md | 20 - frontend/biome.json | 16 - frontend/index.html | 12 - frontend/package.json | 31 - frontend/pnpm-lock.yaml | 1240 ----------------- frontend/src/app/App.tsx | 5 - frontend/src/app/styles.css | 53 - frontend/src/main.tsx | 16 - .../session-console/api/control-connection.ts | 68 - .../src/pages/session-console/api/http.ts | 17 - .../src/pages/session-console/api/protocol.ts | 103 -- frontend/src/pages/session-console/index.ts | 1 - .../pages/session-console/lib/identifiers.ts | 28 - .../pages/session-console/model/projection.ts | 33 - .../session-console/model/session-storage.ts | 69 - .../model/use-session-console.ts | 148 -- .../session-console/ui/SessionConsole.tsx | 75 - frontend/tests/import-boundaries.test.mjs | 81 -- frontend/tests/session-projection.test.mjs | 87 -- frontend/tsconfig.json | 21 - frontend/vite.config.ts | 17 - 57 files changed, 5004 deletions(-) delete mode 100644 CONTEXT.md delete mode 100644 backend/.python-version delete mode 100644 backend/pyproject.toml delete mode 100644 backend/src/voca/__init__.py delete mode 100644 backend/src/voca/application/__init__.py delete mode 100644 backend/src/voca/application/ports.py delete mode 100644 backend/src/voca/application/sessions.py delete mode 100644 backend/src/voca/bootstrap/__init__.py delete mode 100644 backend/src/voca/bootstrap/app.py delete mode 100644 backend/src/voca/bootstrap/settings.py delete mode 100644 backend/src/voca/domain/__init__.py delete mode 100644 backend/src/voca/domain/session.py delete mode 100644 backend/src/voca/infrastructure/__init__.py delete mode 100644 backend/src/voca/infrastructure/observability/__init__.py delete mode 100644 backend/src/voca/infrastructure/observability/diagnostics.py delete mode 100644 backend/src/voca/infrastructure/observability/logging.py delete mode 100644 backend/src/voca/infrastructure/sqlite.py delete mode 100644 backend/src/voca/infrastructure/sqlite_schema.py delete mode 100644 backend/src/voca/presentation/__init__.py delete mode 100644 backend/src/voca/presentation/control.py delete mode 100644 backend/src/voca/presentation/http.py delete mode 100644 backend/src/voca/presentation/responses.py delete mode 100644 backend/src/voca/presentation/schemas.py delete mode 100644 backend/tests/test_app_lifecycle.py delete mode 100644 backend/tests/test_diagnostics.py delete mode 100644 backend/tests/test_session_flow.py delete mode 100644 backend/uv.lock delete mode 100644 docs/adr/0001-rewrite-v3.md delete mode 100644 docs/adr/0002-conversation-lifecycle-and-roadmap.md delete mode 100644 docs/adr/0003-code-and-test-foundation.md delete mode 100644 docs/architecture.md delete mode 100644 docs/code-architecture.md delete mode 100644 docs/diagnostics.md delete mode 100644 docs/index.md delete mode 100644 docs/research/minicpm5-2b.md delete mode 100644 docs/roadmap.md delete mode 100644 docs/testing.md delete mode 100644 frontend/biome.json delete mode 100644 frontend/index.html delete mode 100644 frontend/package.json delete mode 100644 frontend/pnpm-lock.yaml delete mode 100644 frontend/src/app/App.tsx delete mode 100644 frontend/src/app/styles.css delete mode 100644 frontend/src/main.tsx delete mode 100644 frontend/src/pages/session-console/api/control-connection.ts delete mode 100644 frontend/src/pages/session-console/api/http.ts delete mode 100644 frontend/src/pages/session-console/api/protocol.ts delete mode 100644 frontend/src/pages/session-console/index.ts delete mode 100644 frontend/src/pages/session-console/lib/identifiers.ts delete mode 100644 frontend/src/pages/session-console/model/projection.ts delete mode 100644 frontend/src/pages/session-console/model/session-storage.ts delete mode 100644 frontend/src/pages/session-console/model/use-session-console.ts delete mode 100644 frontend/src/pages/session-console/ui/SessionConsole.tsx delete mode 100644 frontend/tests/import-boundaries.test.mjs delete mode 100644 frontend/tests/session-projection.test.mjs delete mode 100644 frontend/tsconfig.json delete mode 100644 frontend/vite.config.ts diff --git a/CONTEXT.md b/CONTEXT.md deleted file mode 100644 index da3a13e..0000000 --- a/CONTEXT.md +++ /dev/null @@ -1,44 +0,0 @@ ---- -tags: - - voice-agent - - v3 - - context - - glossary ---- - -# VOCA v3 — Domain Glossary - -Термины будущего голосового runtime и реализуемой VOCA-13 session-основы; её `backend/`/`frontend/` границы описаны в [code architecture](docs/code-architecture.md). Точный текущий subset описан в [code architecture](docs/code-architecture.md); будущие контракты — в [architecture](docs/architecture.md), порядок — в [roadmap](docs/roadmap.md). - -- **Session** — в VOCA-13 durable SQLite запись с уникальным `session_id` и cursor; будущая реализация расширит её историей разговора, turns, инструкциями, tool calls и tasks. Session живёт дольше соединения или включённого микрофона. -- **Connection** — работоспособность WebRTC и control WS: `connected` или `disconnected`; потеря любого канала инвалидирует активный ответ. -- **Conversation** — режим приёма речи внутри session: `off` или `listening`. Кнопка переключает режим без удержания; `listening` сохраняется и пока агент отвечает. Reconnect восстанавливает данные, не включает микрофон. -- **Conversation runtime** — единственный владелец активного turn, context и очереди событий внутри одного Python/FastAPI backend. -- **Voice turn** — захват пользовательской реплики и ответ; состояния `capturing`, `transcribing`, `generating`, `speaking`, затем `completed`, `interrupted` или `failed`. `speaking` может перекрываться с LLM/TTS generation. -- **Result turn** — отдельный turn для пользовательской реплики о результате task; ждёт паузы включённой исходной session, уступает пользовательской речи. Это не чтение сырого tool/RPC payload. -- **Generation** — идентичность исполнения ответа (`generation_id`) для cancellation и отсечения поздних callbacks/audio. Инвалидация поколения не отменяет связанные durable tasks. -- **Barge-in** — обнаружение начала новой речи, отменяющее текущий voice/result ответ немедленно на speech onset, не только после паузы. Новый capture не ждёт старый inference. -- **TTS cancel** — отмена synthesis stream вместе с invalidation текущего ответа и очисткой backend audio queue. Отмена запроса не доказывает освобождение GPU и сама не очищает browser. -- **Playback flush** — прекращение уже запланированного/буферизованного старого звука на клиенте, включая downstream WebRTC/playback boundary; `playback.clear` / `playback.cleared` коррелируются со старым generation. Реализация FastRTC должна пройти H1, готовый interrupt callback не предполагается. -- **Playback facts** — отдельно generated text, `not_started`, `started`, подтверждённое `finished` либо неизвестная слышимость. Прерванный ответ не считается полностью услышанным. -- **AEC** — acoustic echo cancellation, запрашиваемый у браузера механизм; наличие флага не гарантирует отсутствие эха и ложных turns. -- **VAD / turn detection** — определение начала и окончания речи; базовый режим кнопка + VAD. Semantic turn detection — последующий проверяемый кандидат для различения пауз внутри реплики и её конца. -- **Semantic activation** — отложенный always-on эксперимент: определить, адресована ли речь ассистенту. До проверки false/missed activation и latency не заменяет кнопку. -- **LLM loop** — custom context → LLM → разделённые content/reasoning/tool calls → валидация/dispatch → tool result/context цикл; не модельный Pythonic/XML parser. -- **Normalized tool call** — `id`, `function.name`, полная JSON `function.arguments`; serving layer llama.cpp нормализует модельную форму, loop проверяет имя/schema/доступность и дедуплицирует до dispatch. -- **Промежуточная реплика** — отдельный speakable assistant content рядом с действием («Исследую проект»). Reasoning, arguments/results, RPC events и служебные markers не являются репликой для TTS. -- **Provider** — adapter с контрактом входа/выхода, языков/форматов/streaming, окончания/cancel/timeout/error. Совместимый URL не доказывает совместимость модели или языка. -- **STT provider** — audio segment + language hint → final transcript + language; старт — локальный Whisper server с OpenAI-compatible transcription API, unified audio.cpp только эксперимент. -- **LLM provider / proxy** — локальный LiteLLM → llama.cpp/LFM2.5; separate content/reasoning/tool streams. MiniCPM — кандидат сравнения. Автоматического cloud fallback нет. -- **TTSProvider** — пользовательский текст фрагментами → audio chunks с encoding/rate/channels, старт — audio.cpp. Русский checkpoint выбирается по качеству/ресурсам, не по одному списку поддерживаемых языков. -- **Delegation adapter** — mapping `delegate(agent, task)` к протоколу подключённого агента; сначала только Pi. Hermes и OpenClaw будут независимыми adapters, не общим готовым gateway. -- **Pi instance** — выбранные пользователем executable/configuration/окружение и рабочий каталог; отдельная новая сессия наследует tools/MCP и ограничения именно этого экземпляра, не управляет его текущей беседой. Полномочия: **«Чтение, исследование и использование тулов/mcp подключенных к конкретно этому Pi инстансу»**. -- **Task** — durable поручение с `task_id`, исходной session/tool call и выбранным instance: `queued` → `running` → `succeeded`/`failed`; `unknown` — неизвестный outcome после потери процесса/restart, без автоматического повторного поручения. Task ID сохраняется до dispatch и возвращается loop сразу. -- **Pi settled** — RPC `agent_settled`, завершение всех автоматических continuations/retries; после него проверяется terminal outcome. Первый `agent_end` или prompt accepted ещё не success. -- **Result delivery** — terminal result и его постановка в conversation queue сохраняются атомарно, context insertion дедуплицируется по task ID. При выключенном разговоре ждёт включения исходной session; прерванный result turn не переозвучивается автоматически. Физическое exactly-once прослушивание не обещается. -- **Session store** — SQLite как источник истины для sessions/messages/turns/snapshots/tool calls/tasks/deliveries/events. JSONL — экспорт, не второй источник истины. -- **Instruction snapshot / reload** — неизменяемый набор SOUL.md → user.md → agent.md в начале turn; reload действует со следующего. При ошибке остаётся last valid set с видимой ошибкой; без начального valid set start запрещён. -- **Control channel** — отдельный WebSocket для управления, states, transcript, task/result metadata и playback acknowledgements; не audio и не TTS text. -- **Event cursor** — server `event_id`, уникальный упорядоченный cursor внутри session; resume передаёт последний применённый ID, authoritative snapshot восстанавливает данные без повторного UI append/dispatch. Client command/ack IDs не двигают server cursor. -- **Observability** — полная доступная связанная trace voice → task → result с IDs, timing, полезным redacted содержимым и errors. Обязателен локальный self-hosted LangSmith с этапа 1; LiteLLM callback покрывает лишь LLM участок. Credentials удаляются до экспорта; license egress продукта не равен cloud trace fallback. -- **Gate** — проверка с заранее заданным сценарием/критерием и решением при провале. 500 мс — latency-цель отдельно от функциональной приёмки; неизвестные license/resource/browser flush не выдаются за пройденные gates. diff --git a/backend/.python-version b/backend/.python-version deleted file mode 100644 index 4de75f8..0000000 --- a/backend/.python-version +++ /dev/null @@ -1 +0,0 @@ -3.13.15 diff --git a/backend/pyproject.toml b/backend/pyproject.toml deleted file mode 100644 index 021c57f..0000000 --- a/backend/pyproject.toml +++ /dev/null @@ -1,39 +0,0 @@ -[build-system] -requires = ["hatchling>=1.27,<2"] -build-backend = "hatchling.build" - -[project] -name = "voca" -version = "0.1.0" -description = "Local VOCA session foundation" -requires-python = ">=3.13,<3.14" -dependencies = [ - "fastapi>=0.115,<1", - "pydantic>=2.9,<3", - "starlette>=0.40,<1", - "uvicorn>=0.32,<1", - "websockets>=13,<16", -] - -[dependency-groups] -dev = [ - "httpx>=0.28,<1", - "mypy>=1.13,<2", - "pytest>=8.3,<10", - "ruff>=0.8,<1", -] - -[tool.hatch.build.targets.wheel] -packages = ["src/voca"] - -[tool.pytest.ini_options] -testpaths = ["tests"] -addopts = ["--import-mode=importlib"] - -[tool.ruff] -target-version = "py313" - -[tool.mypy] -python_version = "3.13" -strict = true -files = ["src/voca"] diff --git a/backend/src/voca/__init__.py b/backend/src/voca/__init__.py deleted file mode 100644 index 15349a3..0000000 --- a/backend/src/voca/__init__.py +++ /dev/null @@ -1 +0,0 @@ -from voca.bootstrap.app import create_app as create_app diff --git a/backend/src/voca/application/__init__.py b/backend/src/voca/application/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/backend/src/voca/application/ports.py b/backend/src/voca/application/ports.py deleted file mode 100644 index 863c848..0000000 --- a/backend/src/voca/application/ports.py +++ /dev/null @@ -1,63 +0,0 @@ -from __future__ import annotations - -from contextlib import AbstractContextManager -from typing import Protocol - -from voca.domain.session import Session - - -class SessionNotFound(Exception): - pass - - -class InvalidCursor(Exception): - pass - - -class StorageBusy(Exception): - pass - - -class StorageUnavailable(Exception): - pass - - -class StoreClosed(Exception): - pass - - -class SessionStore(Protocol): - def create(self, session: Session, operation_id: str | None = None) -> Session: ... - - def get(self, session_id: str) -> Session: ... - - def advance_cursor( - self, session_id: str, operation_id: str | None = None - ) -> Session: ... - - -class Diagnostics(Protocol): - def operation( - self, - component: str, - event: str, - *, - operation_id: str | None = None, - session_id: str | None = None, - command_id: str | None = None, - ) -> AbstractContextManager[str]: ... - - def log_event( - self, - component: str, - event: str, - outcome: str, - *, - operation_id: str | None = None, - level: str | None = None, - duration_ms: int | None = None, - reason: str | None = None, - session_id: str | None = None, - command_id: str | None = None, - server_event_id: str | None = None, - ) -> None: ... diff --git a/backend/src/voca/application/sessions.py b/backend/src/voca/application/sessions.py deleted file mode 100644 index e4d05a4..0000000 --- a/backend/src/voca/application/sessions.py +++ /dev/null @@ -1,54 +0,0 @@ -from __future__ import annotations - -from dataclasses import dataclass -from datetime import UTC, datetime -from uuid import uuid4 - -from voca.application.ports import Diagnostics, InvalidCursor, SessionStore -from voca.domain.session import Session - -MAX_CURSOR = 9223372036854775807 - - -@dataclass(frozen=True) -class ResumeResult: - session: Session - cursor_valid: bool - - -class SessionService: - def __init__(self, store: SessionStore, diagnostics: Diagnostics) -> None: - self._store = store - self._diagnostics = diagnostics - - def create_session(self, operation_id: str | None = None) -> Session: - session = Session(str(uuid4()), datetime.now(UTC), 0) - with self._diagnostics.operation( - "session", "session.create", operation_id=operation_id - ): - return self._store.create(session, operation_id) - - def get_session(self, session_id: str) -> Session: - return self._store.get(session_id) - - def resume_session( - self, - session_id: str, - previous_cursor: int, - operation_id: str | None = None, - command_id: str | None = None, - ) -> ResumeResult: - if previous_cursor < 0 or previous_cursor > MAX_CURSOR: - raise InvalidCursor - with self._diagnostics.operation( - "session", - "session.resume", - operation_id=operation_id, - session_id=session_id, - command_id=command_id, - ): - session = self._store.advance_cursor(session_id, operation_id) - return ResumeResult(session, previous_cursor <= session.event_cursor - 1) - - def error_event(self, session_id: str, operation_id: str | None = None) -> Session: - return self._store.advance_cursor(session_id, operation_id) diff --git a/backend/src/voca/bootstrap/__init__.py b/backend/src/voca/bootstrap/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/backend/src/voca/bootstrap/app.py b/backend/src/voca/bootstrap/app.py deleted file mode 100644 index e47bdb7..0000000 --- a/backend/src/voca/bootstrap/app.py +++ /dev/null @@ -1,48 +0,0 @@ -from __future__ import annotations - -from collections.abc import AsyncIterator -from contextlib import asynccontextmanager - -from fastapi import FastAPI - -from voca.application.sessions import SessionService -from voca.bootstrap.settings import Settings, load_settings -from voca.infrastructure.observability.logging import configure_logging -from voca.infrastructure.sqlite import SQLiteSessionStore -from voca.presentation.control import create_control_router -from voca.presentation.http import create_http_router, internal_error - - -def create_app(settings: Settings | None = None) -> FastAPI: - configuration = settings or load_settings() - - @asynccontextmanager - async def lifespan(app: FastAPI) -> AsyncIterator[None]: - diagnostics = configure_logging( - configuration.log_dir, configuration.log_level, configuration.log_levels - ) - diagnostics.log_event("app", "app.startup", "started") - store: SQLiteSessionStore | None = None - try: - store = SQLiteSessionStore.open(configuration.db_path, diagnostics) - app.state.diagnostics = diagnostics - app.state.store = store - app.state.service = SessionService(store, diagnostics) - diagnostics.log_event("app", "app.startup", "succeeded") - yield - except BaseException: - diagnostics.log_event( - "app", "app.startup", "failed", reason="startup_failed" - ) - raise - finally: - if store is not None: - store.close() - diagnostics.log_event("app", "app.shutdown", "succeeded") - diagnostics.close() - - app = FastAPI(lifespan=lifespan) - app.add_exception_handler(Exception, internal_error) - app.include_router(create_http_router()) - app.include_router(create_control_router()) - return app diff --git a/backend/src/voca/bootstrap/settings.py b/backend/src/voca/bootstrap/settings.py deleted file mode 100644 index 93196ac..0000000 --- a/backend/src/voca/bootstrap/settings.py +++ /dev/null @@ -1,51 +0,0 @@ -from __future__ import annotations - -import json -import os -from dataclasses import dataclass, field -from pathlib import Path -from typing import Final - -COMPONENTS: Final = frozenset({"app", "control", "session", "storage"}) -LEVELS: Final = frozenset({"DEBUG", "INFO", "WARNING", "ERROR"}) - - -class ConfigurationError(ValueError): - pass - - -@dataclass(frozen=True) -class Settings: - db_path: Path = Path(".local/voca.sqlite3") - log_dir: Path = Path(".local/logs") - log_level: str = "INFO" - log_levels: dict[str, str] = field(default_factory=dict) - - -def load_settings(environ: dict[str, str] | None = None) -> Settings: - values = os.environ if environ is None else environ - log_level = values.get("VOCA_LOG_LEVEL", "INFO").upper() - if log_level not in LEVELS: - raise ConfigurationError("Invalid logging configuration") - try: - parsed = json.loads(values.get("VOCA_LOG_LEVELS", "{}")) - except json.JSONDecodeError as error: - raise ConfigurationError("Invalid logging configuration") from error - if not isinstance(parsed, dict): - raise ConfigurationError("Invalid logging configuration") - levels: dict[str, str] = {} - for component, level in parsed.items(): - if ( - not isinstance(component, str) - or not isinstance(level, str) - or component not in COMPONENTS - or level.upper() not in LEVELS - ): - raise ConfigurationError("Invalid logging configuration") - levels[component] = level.upper() - return Settings( - Path(values.get("VOCA_DB_PATH", ".local/voca.sqlite3")), - Path(values.get("VOCA_LOG_DIR", ".local/logs")), - log_level, - levels, - ) diff --git a/backend/src/voca/domain/__init__.py b/backend/src/voca/domain/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/backend/src/voca/domain/session.py b/backend/src/voca/domain/session.py deleted file mode 100644 index df92a7f..0000000 --- a/backend/src/voca/domain/session.py +++ /dev/null @@ -1,11 +0,0 @@ -from __future__ import annotations - -from dataclasses import dataclass -from datetime import datetime - - -@dataclass(frozen=True) -class Session: - session_id: str - created_at: datetime - event_cursor: int diff --git a/backend/src/voca/infrastructure/__init__.py b/backend/src/voca/infrastructure/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/backend/src/voca/infrastructure/observability/__init__.py b/backend/src/voca/infrastructure/observability/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/backend/src/voca/infrastructure/observability/diagnostics.py b/backend/src/voca/infrastructure/observability/diagnostics.py deleted file mode 100644 index b6001d8..0000000 --- a/backend/src/voca/infrastructure/observability/diagnostics.py +++ /dev/null @@ -1,158 +0,0 @@ -from __future__ import annotations - -import asyncio -import logging -import time -import uuid -from collections.abc import Iterator -from contextlib import contextmanager - -from voca.infrastructure.observability.logging import LEVELS - - -class Diagnostics: - def __init__(self, logger: logging.Logger, handler: logging.Handler) -> None: - self._logger = logger - self._handler = handler - - def close(self) -> None: - self._logger.removeHandler(self._handler) - self._handler.flush() - self._handler.close() - - def log_event( - self, - component: str, - event: str, - outcome: str, - *, - operation_id: str | None = None, - level: str | None = None, - duration_ms: int | None = None, - reason: str | None = None, - session_id: str | None = None, - command_id: str | None = None, - server_event_id: str | None = None, - ) -> None: - payload: dict[str, object] = { - "timestamp": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()), - "level": level or self.level_for(outcome), - "event": event, - "operation_id": operation_id or str(uuid.uuid4()), - "component": component, - "outcome": outcome, - } - for key, value in { - "duration_ms": duration_ms, - "reason": reason, - "session_id": session_id, - "command_id": command_id, - "server_event_id": server_event_id, - }.items(): - if value is not None: - payload[key] = value - self._logger.log( - LEVELS.get(str(payload["level"]), logging.INFO), - "event", - extra={"payload": payload, "component": component}, - ) - - @contextmanager - def operation( - self, - component: str, - event: str, - *, - operation_id: str | None = None, - session_id: str | None = None, - command_id: str | None = None, - ) -> Iterator[str]: - correlation = operation_id or str(uuid.uuid4()) - started = time.monotonic() - self.log_event( - component, - event, - "started", - operation_id=correlation, - session_id=session_id, - command_id=command_id, - ) - try: - yield correlation - except asyncio.CancelledError: - self.terminal( - component, - event, - "cancelled", - correlation, - started, - "operation_cancelled", - session_id, - command_id, - ) - raise - except TimeoutError: - self.terminal( - component, - event, - "timeout", - correlation, - started, - "operation_timeout", - session_id, - command_id, - ) - raise - except BaseException: - self.terminal( - component, - event, - "failed", - correlation, - started, - "operation_failed", - session_id, - command_id, - ) - raise - else: - self.terminal( - component, - event, - "succeeded", - correlation, - started, - None, - session_id, - command_id, - ) - - def terminal( - self, - component: str, - event: str, - outcome: str, - operation_id: str, - started: float, - reason: str | None, - session_id: str | None, - command_id: str | None, - ) -> None: - self.log_event( - component, - event, - outcome, - operation_id=operation_id, - duration_ms=int((time.monotonic() - started) * 1000), - reason=reason, - session_id=session_id, - command_id=command_id, - ) - - @staticmethod - def level_for(outcome: str) -> str: - if outcome == "failed": - return "ERROR" - if outcome in {"cancelled", "timeout"}: - return "WARNING" - return "INFO" diff --git a/backend/src/voca/infrastructure/observability/logging.py b/backend/src/voca/infrastructure/observability/logging.py deleted file mode 100644 index 3539958..0000000 --- a/backend/src/voca/infrastructure/observability/logging.py +++ /dev/null @@ -1,90 +0,0 @@ -from __future__ import annotations - -import json -import logging -import sys -from logging.handlers import RotatingFileHandler -from pathlib import Path -from typing import TYPE_CHECKING, Final - -if TYPE_CHECKING: - from voca.infrastructure.observability.diagnostics import Diagnostics - -LEVELS: Final = { - "DEBUG": logging.DEBUG, - "INFO": logging.INFO, - "WARNING": logging.WARNING, - "ERROR": logging.ERROR, -} -MAX_RECORD_BYTES: Final = 2048 - - -class SafeRotatingFileHandler(RotatingFileHandler): - def __init__(self, path: Path) -> None: - super().__init__(path, maxBytes=262144, backupCount=3, encoding="utf-8") - self.disabled = False - self.failure_reported = False - - def emit(self, record: logging.LogRecord) -> None: - if not self.disabled: - super().emit(record) - - def handleError(self, record: logging.LogRecord) -> None: - del record - self.disabled = True - if not self.failure_reported: - self.failure_reported = True - try: - sys.stderr.write("diagnostics/write_failed\n") - except OSError: - pass - - -class JsonFormatter(logging.Formatter): - def format(self, record: logging.LogRecord) -> str: - encoded = json.dumps( - getattr(record, "payload", {}), ensure_ascii=True, separators=(",", ":") - ) - if len(encoded.encode()) > MAX_RECORD_BYTES: - return '{"event":"diagnostics.record_dropped","outcome":"failed"}' - return encoded - - -class ComponentFilter(logging.Filter): - def __init__(self, default_level: str, component_levels: dict[str, str]) -> None: - super().__init__() - self._default_level = LEVELS[default_level] - self._component_levels = { - key: LEVELS[value] for key, value in component_levels.items() - } - - def filter(self, record: logging.LogRecord) -> bool: - return record.levelno >= self._component_levels.get( - getattr(record, "component", "app"), self._default_level - ) - - -def configure_logging( - log_dir: Path, log_level: str, log_levels: dict[str, str] -) -> Diagnostics: - from voca.infrastructure.observability.diagnostics import Diagnostics - - logger = logging.getLogger("voca") - logger.setLevel(logging.DEBUG) - logger.propagate = False - for existing in list(logger.handlers): - logger.removeHandler(existing) - existing.close() - try: - log_dir.mkdir(parents=True, exist_ok=True) - handler: logging.Handler = SafeRotatingFileHandler(log_dir / "voca.jsonl") - except OSError: - try: - sys.stderr.write("diagnostics/write_failed\n") - except OSError: - pass - handler = logging.NullHandler() - handler.setFormatter(JsonFormatter()) - handler.addFilter(ComponentFilter(log_level, log_levels)) - logger.addHandler(handler) - return Diagnostics(logger, handler) diff --git a/backend/src/voca/infrastructure/sqlite.py b/backend/src/voca/infrastructure/sqlite.py deleted file mode 100644 index d972f4f..0000000 --- a/backend/src/voca/infrastructure/sqlite.py +++ /dev/null @@ -1,147 +0,0 @@ -from __future__ import annotations - -import sqlite3 -import threading -from datetime import datetime -from pathlib import Path - -from voca.application.ports import ( - Diagnostics, - SessionNotFound, - StorageBusy, - StorageUnavailable, - StoreClosed, -) -from voca.domain.session import Session -from voca.infrastructure.sqlite_schema import initialize_schema - - -class SQLiteSessionStore: - def __init__( - self, connection: sqlite3.Connection, diagnostics: Diagnostics - ) -> None: - self._connection = connection - self._diagnostics = diagnostics - self._lock = threading.RLock() - self._closed = False - - @classmethod - def open(cls, path: Path, diagnostics: Diagnostics) -> SQLiteSessionStore: - if str(path) == ":memory:" or str(path).startswith("file:"): - raise StorageUnavailable - connection: sqlite3.Connection | None = None - try: - path.parent.mkdir(parents=True, exist_ok=True) - connection = sqlite3.connect(path, check_same_thread=False) - connection.execute("PRAGMA foreign_keys=ON") - connection.execute("PRAGMA busy_timeout=1000") - connection.execute("PRAGMA synchronous=FULL") - initialize_schema(connection) - except StorageUnavailable: - if connection is not None: - connection.close() - raise - except sqlite3.Error as error: - if connection is not None: - connection.close() - raise cls.storage_error(error) from error - store = cls(connection, diagnostics) - diagnostics.log_event("storage", "storage.schema", "succeeded") - diagnostics.log_event("storage", "storage.open", "succeeded") - return store - - def create(self, session: Session, operation_id: str | None = None) -> Session: - with ( - self._diagnostics.operation( - "storage", - "storage.session_insert", - operation_id=operation_id, - session_id=session.session_id, - ), - self._lock, - ): - self.ensure_open() - try: - self._connection.execute("BEGIN IMMEDIATE") - self._connection.execute( - "INSERT INTO sessions(session_id, created_at, event_cursor) VALUES (?, ?, ?)", - ( - session.session_id, - session.created_at.isoformat(), - session.event_cursor, - ), - ) - self._connection.commit() - except sqlite3.Error as error: - self._connection.rollback() - raise self.storage_error(error) from error - return session - - def get(self, session_id: str) -> Session: - with self._lock: - self.ensure_open() - try: - row = self._connection.execute( - "SELECT session_id, created_at, event_cursor FROM sessions WHERE session_id = ?", - (session_id,), - ).fetchone() - except sqlite3.Error as error: - raise self.storage_error(error) from error - if row is None: - raise SessionNotFound - return self.session_from_row(row) - - def advance_cursor( - self, session_id: str, operation_id: str | None = None - ) -> Session: - with ( - self._diagnostics.operation( - "storage", - "storage.cursor_advance", - operation_id=operation_id, - session_id=session_id, - ), - self._lock, - ): - self.ensure_open() - try: - self._connection.execute("BEGIN IMMEDIATE") - row = self._connection.execute( - "UPDATE sessions SET event_cursor = event_cursor + 1 WHERE session_id = ? RETURNING session_id, created_at, event_cursor", - (session_id,), - ).fetchone() - if row is None: - self._connection.rollback() - raise SessionNotFound - self._connection.commit() - except SessionNotFound: - raise - except sqlite3.Error as error: - self._connection.rollback() - raise self.storage_error(error) from error - return self.session_from_row(row) - - def close(self) -> None: - with self._lock: - if self._closed: - return - self._connection.close() - self._closed = True - self._diagnostics.log_event("storage", "storage.close", "succeeded") - - def ensure_open(self) -> None: - if self._closed: - raise StoreClosed - - @staticmethod - def session_from_row(row: tuple[str, str, int]) -> Session: - return Session(row[0], datetime.fromisoformat(row[1]), row[2]) - - @staticmethod - def storage_error(error: sqlite3.Error) -> StorageBusy | StorageUnavailable: - if ( - isinstance(error, sqlite3.OperationalError) - and "locked" in str(error).lower() - ): - return StorageBusy() - return StorageUnavailable() diff --git a/backend/src/voca/infrastructure/sqlite_schema.py b/backend/src/voca/infrastructure/sqlite_schema.py deleted file mode 100644 index bda6d81..0000000 --- a/backend/src/voca/infrastructure/sqlite_schema.py +++ /dev/null @@ -1,40 +0,0 @@ -from __future__ import annotations - -import sqlite3 - -from voca.application.ports import StorageUnavailable - -SCHEMA = "CREATE TABLE sessions(session_id TEXT PRIMARY KEY, created_at TEXT NOT NULL, event_cursor INTEGER NOT NULL CHECK(event_cursor >= 0))" - - -def initialize_schema(connection: sqlite3.Connection) -> None: - try: - version = connection.execute("PRAGMA user_version").fetchone()[0] - if version == 0: - connection.execute("BEGIN IMMEDIATE") - try: - connection.execute(SCHEMA) - connection.execute("PRAGMA user_version=1") - connection.commit() - except sqlite3.Error: - connection.rollback() - raise - elif version != 1 or not has_expected_schema(connection): - raise StorageUnavailable - except sqlite3.Error as error: - raise StorageUnavailable from error - - -def has_expected_schema(connection: sqlite3.Connection) -> bool: - row = connection.execute( - "SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'sessions'" - ).fetchone() - return ( - row is not None - and isinstance(row[0], str) - and normalize_schema(row[0]) == normalize_schema(SCHEMA) - ) - - -def normalize_schema(schema: str) -> str: - return "".join(schema.upper().split()) diff --git a/backend/src/voca/presentation/__init__.py b/backend/src/voca/presentation/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/backend/src/voca/presentation/control.py b/backend/src/voca/presentation/control.py deleted file mode 100644 index 6a018e5..0000000 --- a/backend/src/voca/presentation/control.py +++ /dev/null @@ -1,266 +0,0 @@ -from __future__ import annotations - -import asyncio -import json - -from fastapi import APIRouter, WebSocket, WebSocketDisconnect -from pydantic import ValidationError -from starlette.concurrency import run_in_threadpool - -from voca.application.ports import ( - Diagnostics, - SessionNotFound, - StorageBusy, - StorageUnavailable, - StoreClosed, -) -from voca.application.sessions import SessionService -from voca.domain.session import Session -from voca.presentation.responses import ( - close_with_deadline, - error_payload, - send_snapshot, - send_with_deadline, -) -from voca.presentation.schemas import ( - Command, - ResumeCommand, - decode_cursor, - is_canonical_uuid, -) - -MAX_FRAME_BYTES = 8192 - - -def create_control_router() -> APIRouter: - router = APIRouter() - - @router.websocket("/api/sessions/{session_id}/control") - async def control_socket(socket: WebSocket, session_id: str) -> None: - service: SessionService = socket.app.state.service - diagnostics: Diagnostics = socket.app.state.diagnostics - await socket.accept() - if not is_canonical_uuid(session_id): - await safe_close(socket, diagnostics, None, 4400, "invalid_session_id") - return - try: - await run_in_threadpool(service.get_session, session_id) - except SessionNotFound: - await safe_close(socket, diagnostics, session_id, 4404, "session_not_found") - return - except (StorageBusy, StorageUnavailable, StoreClosed): - await safe_close( - socket, diagnostics, session_id, 1011, "storage_unavailable" - ) - return - diagnostics.log_event( - "control", "control.connect", "succeeded", session_id=session_id - ) - try: - while True: - message = await socket.receive() - if message["type"] == "websocket.disconnect": - raise WebSocketDisconnect(message.get("code", 1000)) - binary = message.get("bytes") - if isinstance(binary, bytes) and len(binary) > MAX_FRAME_BYTES: - await safe_close( - socket, diagnostics, session_id, 1009, "frame_too_large" - ) - return - if binary is not None: - await send_session_error( - socket, - service, - diagnostics, - session_id, - "invalid_command", - "Invalid command", - ) - continue - raw = message.get("text") - if isinstance(raw, str) and len(raw.encode()) > MAX_FRAME_BYTES: - await safe_close( - socket, diagnostics, session_id, 1009, "frame_too_large" - ) - return - if not isinstance(raw, str): - await send_session_error( - socket, - service, - diagnostics, - session_id, - "invalid_command", - "Invalid command", - ) - continue - await handle_command(socket, service, diagnostics, session_id, raw) - except WebSocketDisconnect: - diagnostics.log_event( - "control", "control.disconnect", "succeeded", session_id=session_id - ) - except asyncio.CancelledError: - await safe_close(socket, diagnostics, session_id, 1011, "cancelled") - raise - except (StorageBusy, StorageUnavailable, StoreClosed): - await safe_close( - socket, diagnostics, session_id, 1011, "storage_unavailable" - ) - except (RuntimeError, ValueError, OSError, TimeoutError): - diagnostics.log_event( - "app", - "control.disconnect", - "failed", - reason="internal_error", - session_id=session_id, - ) - await safe_close(socket, diagnostics, session_id, 1011, "internal_error") - - return router - - -async def handle_command( - socket: WebSocket, - service: SessionService, - diagnostics: Diagnostics, - session_id: str, - raw: str, -) -> None: - try: - parsed = json.loads(raw) - if not isinstance(parsed, dict): - raise TypeError - base = ( - ResumeCommand if parsed.get("type") == "session.resume" else Command - ).model_validate(parsed) - except (TypeError, ValueError, ValidationError, json.JSONDecodeError): - await send_session_error( - socket, - service, - diagnostics, - session_id, - "invalid_command", - "Invalid command", - ) - return - if base.session_id != session_id: - await send_session_error( - socket, - service, - diagnostics, - session_id, - "session_mismatch", - "Session does not match", - ) - return - if base.type != "session.resume": - await send_session_error( - socket, - service, - diagnostics, - session_id, - "unsupported_command", - "Unsupported command", - base.event_id, - ) - return - if not isinstance(base, ResumeCommand): - await send_session_error( - socket, - service, - diagnostics, - session_id, - "invalid_command", - "Invalid command", - base.event_id, - ) - return - previous_cursor = decode_cursor(base.last_event_id) - if previous_cursor is None: - await send_session_error( - socket, - service, - diagnostics, - session_id, - "invalid_command", - "Invalid command", - base.event_id, - ) - return - with diagnostics.operation( - "control", "control.receive", session_id=session_id, command_id=base.event_id - ) as operation_id: - result = await run_in_threadpool( - service.resume_session, - session_id, - previous_cursor, - operation_id, - base.event_id, - ) - if not result.cursor_valid: - await send_session_error( - socket, - service, - diagnostics, - session_id, - "invalid_cursor", - "Invalid cursor", - base.event_id, - result.session, - ) - return - await send_snapshot( - socket.send_json, diagnostics, result.session, base.event_id, operation_id - ) - - -async def send_session_error( - socket: WebSocket, - service: SessionService, - diagnostics: Diagnostics, - session_id: str, - code: str, - message: str, - command_id: str | None = None, - session: Session | None = None, -) -> None: - diagnostics.log_event( - "control", - "control.command_rejected", - "failed", - level="WARNING", - reason=code, - session_id=session_id, - command_id=command_id, - ) - if session is None: - current: Session = await run_in_threadpool(service.error_event, session_id) - else: - current = session - await send_with_deadline(socket.send_json, error_payload(current, code, message)) - diagnostics.log_event( - "control", - "control.error_send", - "succeeded", - session_id=session_id, - command_id=command_id, - server_event_id=f"e{current.event_cursor}", - ) - - -async def safe_close( - socket: WebSocket, - diagnostics: Diagnostics, - session_id: str | None, - code: int, - reason: str, -) -> None: - try: - await close_with_deadline(socket.close, code=code, reason=reason) - except TimeoutError: - diagnostics.log_event( - "control", - "control.close", - "timeout", - reason="close_timeout", - session_id=session_id, - ) diff --git a/backend/src/voca/presentation/http.py b/backend/src/voca/presentation/http.py deleted file mode 100644 index 747c73d..0000000 --- a/backend/src/voca/presentation/http.py +++ /dev/null @@ -1,75 +0,0 @@ -from __future__ import annotations - -import json - -from fastapi import APIRouter, Request -from fastapi.responses import JSONResponse -from pydantic import ValidationError -from starlette.concurrency import run_in_threadpool - -from voca.application.ports import ( - Diagnostics, - StorageBusy, - StorageUnavailable, - StoreClosed, -) -from voca.application.sessions import SessionService -from voca.presentation.schemas import EmptyBody - - -def create_http_router() -> APIRouter: - router = APIRouter() - - @router.post("/api/sessions", status_code=201) - async def create_session(request: Request) -> JSONResponse: - service: SessionService = request.app.state.service - diagnostics: Diagnostics = request.app.state.diagnostics - if ( - request.headers.get("content-type", "").split(";", 1)[0].lower() - != "application/json" - ): - return error_response( - "control", "unsupported_media_type", "Unsupported media type", 415 - ) - try: - payload = await request.json() - EmptyBody.model_validate(payload) - except (UnicodeDecodeError, json.JSONDecodeError, ValidationError): - return error_response("control", "invalid_request", "Invalid request", 400) - try: - with diagnostics.operation( - "control", "http.session_create" - ) as operation_id: - session = await run_in_threadpool(service.create_session, operation_id) - except StorageBusy: - return error_response("storage", "storage_busy", "Storage is busy", 503) - except (StorageUnavailable, StoreClosed): - return error_response( - "storage", "storage_unavailable", "Storage is unavailable", 503 - ) - return JSONResponse( - {"session_id": session.session_id, "last_event_id": None}, - status_code=201, - headers={"Cache-Control": "no-store"}, - ) - - return router - - -def error_response( - component: str, code: str, message: str, status_code: int -) -> JSONResponse: - return JSONResponse( - {"error": {"component": component, "code": code, "message": message}}, - status_code=status_code, - ) - - -async def internal_error(request: Request, error: Exception) -> JSONResponse: - del error - diagnostics = getattr(request.app.state, "diagnostics", None) - if diagnostics is not None: - diagnostics.log_event( - "app", "http.session_create", "failed", reason="internal_error" - ) - return error_response("app", "internal_error", "Internal error", 500) diff --git a/backend/src/voca/presentation/responses.py b/backend/src/voca/presentation/responses.py deleted file mode 100644 index 3cefdbe..0000000 --- a/backend/src/voca/presentation/responses.py +++ /dev/null @@ -1,66 +0,0 @@ -from __future__ import annotations - -import asyncio -from collections.abc import Awaitable, Callable -from typing import Any - -from voca.application.ports import Diagnostics -from voca.domain.session import Session - -IO_TIMEOUT_SECONDS = 5 - - -def snapshot_payload(session: Session) -> dict[str, Any]: - return { - "type": "session.snapshot", - "session_id": session.session_id, - "event_id": f"e{session.event_cursor}", - "connection": "disconnected", - "conversation": "off", - "active_turn": None, - "messages": [], - "tasks": [], - "deliveries": [], - } - - -def error_payload(session: Session, code: str, message: str) -> dict[str, Any]: - return { - "type": "error", - "session_id": session.session_id, - "event_id": f"e{session.event_cursor}", - "component": "control", - "code": code, - "message": message, - } - - -async def send_snapshot( - socket_sender: Callable[[dict[str, Any]], Awaitable[None]], - diagnostics: Diagnostics, - session: Session, - command_id: str, - operation_id: str | None = None, -) -> None: - with diagnostics.operation( - "control", - "control.snapshot_send", - operation_id=operation_id, - session_id=session.session_id, - command_id=command_id, - ): - await send_with_deadline(socket_sender, snapshot_payload(session)) - - -async def send_with_deadline( - sender: Callable[[dict[str, Any]], Awaitable[None]], payload: dict[str, Any] -) -> None: - async with asyncio.timeout(IO_TIMEOUT_SECONDS): - await sender(payload) - - -async def close_with_deadline( - closer: Callable[..., Awaitable[None]], *, code: int, reason: str -) -> None: - async with asyncio.timeout(IO_TIMEOUT_SECONDS): - await closer(code=code, reason=reason) diff --git a/backend/src/voca/presentation/schemas.py b/backend/src/voca/presentation/schemas.py deleted file mode 100644 index 1bd93ca..0000000 --- a/backend/src/voca/presentation/schemas.py +++ /dev/null @@ -1,63 +0,0 @@ -from __future__ import annotations - -from uuid import UUID - -from pydantic import BaseModel, ConfigDict, field_validator - -MAX_CURSOR = 9223372036854775807 - - -class EmptyBody(BaseModel): - model_config = ConfigDict(extra="forbid", strict=True) - - -class Command(BaseModel): - model_config = ConfigDict(extra="forbid", strict=True) - - type: str - session_id: str - event_id: str - - @field_validator("session_id", "event_id") - @classmethod - def canonical_uuid(cls, value: str) -> str: - if not is_canonical_uuid(value): - raise ValueError("invalid identifier") - return value - - -class ResumeCommand(Command): - last_event_id: str | None - - @field_validator("last_event_id") - @classmethod - def canonical_cursor(cls, value: str | None) -> str | None: - if value is not None and decode_cursor(value) is None: - raise ValueError("invalid cursor") - return value - - -def decode_cursor(value: str | None) -> int | None: - if value is None: - return 0 - if not is_canonical_cursor(value): - return None - cursor = int(value[1:]) - return cursor if cursor <= MAX_CURSOR else None - - -def is_canonical_cursor(value: str) -> bool: - return ( - len(value) > 1 - and value.startswith("e") - and value[1:].isascii() - and value[1:].isdecimal() - and value[1] != "0" - ) - - -def is_canonical_uuid(value: str) -> bool: - try: - return str(UUID(value)) == value - except (ValueError, TypeError): - return False diff --git a/backend/tests/test_app_lifecycle.py b/backend/tests/test_app_lifecycle.py deleted file mode 100644 index d09d860..0000000 --- a/backend/tests/test_app_lifecycle.py +++ /dev/null @@ -1,112 +0,0 @@ -from __future__ import annotations - -import ast -import sqlite3 -from pathlib import Path - -import pytest -from fastapi.testclient import TestClient - -from voca.application.ports import StorageUnavailable, StoreClosed -from voca.bootstrap.app import create_app -from voca.bootstrap.settings import Settings - - -def test_app_lifecycle(tmp_path: Path) -> None: - settings = Settings(db_path=tmp_path / "voca.sqlite3", log_dir=tmp_path / "logs") - app = create_app(settings) - assert not settings.db_path.exists() - with TestClient(app) as client: - assert settings.db_path.exists() - assert client.post("/api/sessions", json={}).status_code == 201 - store = app.state.store - with pytest.raises(StoreClosed): - store.get("any") - with TestClient(create_app(settings)) as client: - assert client.post("/api/sessions", json={}).status_code == 201 - logs = (settings.log_dir / "voca.jsonl").read_text().splitlines() - assert sum("app.startup" in line for line in logs) == 4 - - -def test_unusable_database(tmp_path: Path) -> None: - database_directory = tmp_path / "database-directory" - database_directory.mkdir() - settings = Settings(db_path=database_directory, log_dir=tmp_path / "logs") - with pytest.raises(StorageUnavailable), TestClient(create_app(settings)): - pass - incompatible = tmp_path / "incompatible.sqlite3" - with sqlite3.connect(incompatible) as database: - database.execute("PRAGMA user_version=2") - with ( - pytest.raises(StorageUnavailable), - TestClient( - create_app(Settings(db_path=incompatible, log_dir=tmp_path / "other-logs")) - ), - ): - pass - incompatible_shape = tmp_path / "incompatible-shape.sqlite3" - with sqlite3.connect(incompatible_shape) as database: - database.execute( - "CREATE TABLE sessions(session_id TEXT PRIMARY KEY, created_at TEXT, event_cursor TEXT)" - ) - database.execute("PRAGMA user_version=1") - with ( - pytest.raises(StorageUnavailable), - TestClient( - create_app( - Settings(db_path=incompatible_shape, log_dir=tmp_path / "shape-logs") - ) - ), - ): - pass - - -def resolved_import(node: ast.ImportFrom, path: Path, source_root: Path) -> str: - if node.level == 0: - return node.module or "" - package = list(path.relative_to(source_root).parent.parts) - parent = package[: len(package) - (node.level - 1)] - return ".".join(["voca", *parent, *(node.module or "").split(".")]).rstrip(".") - - -def test_import_boundaries() -> None: - source_root = Path(__file__).parents[1] / "src" / "voca" - for path in source_root.rglob("*.py"): - tree = ast.parse(path.read_text()) - imports = [ - alias.name - for node in ast.walk(tree) - if isinstance(node, ast.Import) - for alias in node.names - ] + [ - resolved_import(node, path, source_root) - for node in ast.walk(tree) - if isinstance(node, ast.ImportFrom) - ] - relative = path.relative_to(source_root).as_posix() - if relative.startswith(("domain/", "application/")): - assert not any( - name.startswith( - ( - "fastapi", - "pydantic", - "starlette", - "sqlite3", - "logging", - "voca.presentation", - "voca.infrastructure", - "voca.bootstrap", - ) - ) - for name in imports - ) - if relative.startswith("presentation/"): - assert not any( - name.startswith(("voca.infrastructure", "voca.bootstrap")) - for name in imports - ) - if relative.startswith("infrastructure/"): - assert not any( - name.startswith(("voca.presentation", "voca.bootstrap")) - for name in imports - ) diff --git a/backend/tests/test_diagnostics.py b/backend/tests/test_diagnostics.py deleted file mode 100644 index 2424d1f..0000000 --- a/backend/tests/test_diagnostics.py +++ /dev/null @@ -1,120 +0,0 @@ -from __future__ import annotations - -import asyncio -import json -import sqlite3 -from pathlib import Path - -import pytest -from fastapi.testclient import TestClient - -from voca.bootstrap.app import create_app -from voca.bootstrap.settings import Settings -from voca.infrastructure.observability.logging import configure_logging - - -def entries(log_dir: Path) -> list[dict[str, object]]: - return [ - json.loads(line) - for path in log_dir.glob("voca.jsonl*") - for line in path.read_text().splitlines() - ] - - -def test_json_fields_filtering_and_rotation(tmp_path: Path) -> None: - log_dir = tmp_path / "logs" - diagnostics = configure_logging(log_dir, "INFO", {"storage": "ERROR"}) - with diagnostics.operation("session", "session.resume", session_id="session"): - pass - diagnostics.log_event( - "storage", "storage.cursor_advance", "succeeded", session_id="session" - ) - for _ in range(3000): - diagnostics.log_event( - "session", "session.resume", "succeeded", session_id="session" - ) - diagnostics.close() - recorded = entries(log_dir) - assert recorded - assert all( - {"timestamp", "level", "event", "operation_id", "component", "outcome"} - <= entry.keys() - for entry in recorded - ) - assert any( - entry["event"] == "session.resume" and entry.get("duration_ms", -1) >= 0 - for entry in recorded - ) - assert not any(entry["event"] == "storage.cursor_advance" for entry in recorded) - files = list(log_dir.glob("voca.jsonl*")) - assert len(files) <= 4 - assert all(path.stat().st_size <= 262144 + 2048 for path in files) - - -def test_timeout_is_logged_as_timeout(tmp_path: Path) -> None: - log_dir = tmp_path / "logs" - diagnostics = configure_logging(log_dir, "INFO", {}) - with ( - pytest.raises(TimeoutError), - diagnostics.operation("control", "control.snapshot_send"), - ): - raise TimeoutError - diagnostics.close() - timeout_entries = [ - entry for entry in entries(log_dir) if entry["outcome"] == "timeout" - ] - assert len(timeout_entries) == 1 - assert timeout_entries[0]["event"] == "control.snapshot_send" - assert timeout_entries[0]["reason"] == "operation_timeout" - - -def test_cancellation_is_logged_and_reraised(tmp_path: Path) -> None: - log_dir = tmp_path / "logs" - diagnostics = configure_logging(log_dir, "INFO", {}) - - async def cancelled() -> None: - with diagnostics.operation("control", "control.snapshot_send"): - raise asyncio.CancelledError - - with pytest.raises(asyncio.CancelledError): - asyncio.run(cancelled()) - diagnostics.close() - cancelled_entries = [ - entry for entry in entries(log_dir) if entry["outcome"] == "cancelled" - ] - assert len(cancelled_entries) == 1 - assert cancelled_entries[0]["event"] == "control.snapshot_send" - - -def test_failure_warning_is_safe_and_once( - tmp_path: Path, capsys: pytest.CaptureFixture[str] -) -> None: - unavailable_log_dir = tmp_path / "unavailable-log-dir" - unavailable_log_dir.write_text("not a directory") - settings = Settings(db_path=tmp_path / "voca.sqlite3", log_dir=unavailable_log_dir) - marker = "VOCA_SYNTHETIC_SECRET" - with TestClient(create_app(settings)) as client: - created = client.post("/api/sessions", json={}) - assert created.status_code == 201 - session_id = created.json()["session_id"] - with client.websocket_connect(f"/api/sessions/{session_id}/control") as socket: - socket.send_json( - { - "type": "session.resume", - "session_id": session_id, - "event_id": "f1d37af1-2ed6-4bb2-a7a2-5d0777d1db4c", - "last_event_id": None, - } - ) - assert socket.receive_json()["type"] == "session.snapshot" - invalid = client.post( - "/api/sessions", - content=f'{{"marker":"{marker}"}}', - headers={"content-type": "application/json"}, - ) - assert invalid.status_code == 400 - with sqlite3.connect(settings.db_path) as database: - assert database.execute("SELECT COUNT(*) FROM sessions").fetchone() == (1,) - captured = capsys.readouterr() - assert captured.err == "diagnostics/write_failed\n" - assert marker not in captured.err diff --git a/backend/tests/test_session_flow.py b/backend/tests/test_session_flow.py deleted file mode 100644 index 4038ddd..0000000 --- a/backend/tests/test_session_flow.py +++ /dev/null @@ -1,264 +0,0 @@ -from __future__ import annotations - -import asyncio -import json -import sqlite3 -from pathlib import Path -from uuid import uuid4 - -import pytest -from fastapi.testclient import TestClient -from starlette.websockets import WebSocketDisconnect - -from voca.bootstrap.app import create_app -from voca.bootstrap.settings import Settings, load_settings - - -def settings_for(tmp_path: Path) -> Settings: - return Settings(db_path=tmp_path / "voca.sqlite3", log_dir=tmp_path / "logs") - - -def create_session(client: TestClient) -> str: - response = client.post("/api/sessions", json={}) - assert response.status_code == 201 - assert response.headers["cache-control"] == "no-store" - assert response.json()["last_event_id"] is None - return response.json()["session_id"] - - -def resume( - session_id: str, last_event_id: str | None = None, command_id: str | None = None -) -> dict[str, object]: - return { - "type": "session.resume", - "session_id": session_id, - "event_id": command_id or str(uuid4()), - "last_event_id": last_event_id, - } - - -def test_session_flow(tmp_path: Path) -> None: - settings = settings_for(tmp_path) - with TestClient(create_app(settings)) as client: - session_id = create_session(client) - command_id = str(uuid4()) - with client.websocket_connect(f"/api/sessions/{session_id}/control") as socket: - socket.send_json(resume(session_id, command_id=command_id)) - first = socket.receive_json() - assert first["type"] == "session.snapshot" - assert first["event_id"] == "e1" - assert first["connection"] == "disconnected" - assert first["conversation"] == "off" - assert first["messages"] == [] - assert first["tasks"] == [] - socket.send_json(resume(session_id, "e1", command_id)) - second = socket.receive_json() - assert second["event_id"] == "e2" - with client.websocket_connect(f"/api/sessions/{session_id}/control") as socket: - socket.send_json(resume(session_id, "e2")) - reconnected = socket.receive_json() - assert reconnected["event_id"] == "e3" - with ( - TestClient(create_app(settings)) as restarted, - restarted.websocket_connect(f"/api/sessions/{session_id}/control") as socket, - ): - socket.send_json(resume(session_id, "e3")) - restored = socket.receive_json() - assert restored["session_id"] == session_id - assert restored["event_id"] == "e4" - assert restored["messages"] == [] - with sqlite3.connect(settings.db_path) as database: - rows = database.execute( - "SELECT session_id, event_cursor FROM sessions" - ).fetchall() - assert rows == [(session_id, 4)] - entries = [ - json.loads(line) - for line in (settings.log_dir / "voca.jsonl").read_text().splitlines() - ] - assert any(entry.get("session_id") == session_id for entry in entries) - assert any( - "duration_ms" in entry and entry["duration_ms"] >= 0 for entry in entries - ) - resume_events = [ - entry - for entry in entries - if entry.get("session_id") == session_id - and entry["event"] - in { - "control.receive", - "session.resume", - "storage.cursor_advance", - "control.snapshot_send", - } - ] - operation_ids = {entry["operation_id"] for entry in resume_events} - assert any( - sum(entry["operation_id"] == operation_id for entry in resume_events) == 8 - for operation_id in operation_ids - ) - - -@pytest.mark.parametrize( - "selected_path", [".local/voca.sqlite3", "custom/voca.sqlite3"] -) -def test_absolute_paths_survive_cwd_change( - tmp_path: Path, monkeypatch: pytest.MonkeyPatch, selected_path: str -) -> None: - repo = tmp_path / "repo" - backend = repo / "backend" - backend.mkdir(parents=True) - monkeypatch.chdir(repo) - settings = load_settings( - { - "VOCA_DB_PATH": str((repo / selected_path).resolve()), - "VOCA_LOG_DIR": str((repo / ".local/logs").resolve()), - } - ) - with TestClient(create_app(settings)) as client: - session_id = create_session(client) - with client.websocket_connect(f"/api/sessions/{session_id}/control") as socket: - socket.send_json(resume(session_id)) - assert socket.receive_json()["event_id"] == "e1" - monkeypatch.chdir(backend) - with ( - TestClient(create_app(settings)) as client, - client.websocket_connect(f"/api/sessions/{session_id}/control") as socket, - ): - socket.send_json(resume(session_id, "e1")) - assert socket.receive_json()["event_id"] == "e2" - with sqlite3.connect(settings.db_path) as database: - assert database.execute( - "SELECT session_id, event_cursor FROM sessions" - ).fetchall() == [(session_id, 2)] - assert not (backend / ".local").exists() - - -@pytest.mark.parametrize( - "case,code", - [ - ("malformed", "invalid_command"), - ("unknown", "unsupported_command"), - ("start", "unsupported_command"), - ("stop", "unsupported_command"), - ("mismatch", "session_mismatch"), - ("future", "invalid_cursor"), - ("unicode_cursor", "invalid_command"), - ], -) -def test_control_errors(tmp_path: Path, case: str, code: str) -> None: - settings = settings_for(tmp_path) - with TestClient(create_app(settings)) as client: - session_id = create_session(client) - command_id = str(uuid4()) - if case == "malformed": - rendered = "{" - else: - command: dict[str, object] = { - "type": "unknown", - "session_id": session_id, - "event_id": command_id, - } - if case in {"start", "stop"}: - command["type"] = f"conversation.{case}" - if case == "mismatch": - command = { - "type": "session.resume", - "session_id": str(uuid4()), - "event_id": command_id, - "last_event_id": None, - } - if case == "future": - command = { - "type": "session.resume", - "session_id": session_id, - "event_id": command_id, - "last_event_id": "e999", - } - if case == "unicode_cursor": - command = { - "type": "session.resume", - "session_id": session_id, - "event_id": command_id, - "last_event_id": "e١", - } - rendered = json.dumps(command) - with client.websocket_connect(f"/api/sessions/{session_id}/control") as socket: - socket.send_text(rendered) - error = socket.receive_json() - assert error["type"] == "error" - assert error["code"] == code - assert error["session_id"] == session_id - socket.send_json(resume(session_id)) - assert socket.receive_json()["type"] == "session.snapshot" - with client.websocket_connect(f"/api/sessions/{session_id}/control") as socket: - socket.send_bytes(b"invalid") - assert socket.receive_json()["code"] == "invalid_command" - with ( - pytest.raises(WebSocketDisconnect) as oversized, - client.websocket_connect(f"/api/sessions/{session_id}/control") as socket, - ): - socket.send_bytes(b"x" * 8193) - socket.receive_json() - assert oversized.value.code == 1009 - with TestClient(create_app(settings)) as client: - with ( - pytest.raises(WebSocketDisconnect) as unknown, - client.websocket_connect(f"/api/sessions/{uuid4()}/control") as socket, - ): - socket.receive_json() - assert unknown.value.code == 4404 - with ( - pytest.raises(WebSocketDisconnect) as malformed, - client.websocket_connect("/api/sessions/not-a-uuid/control") as socket, - ): - socket.receive_json() - assert malformed.value.code == 4400 - diagnostics = [ - json.loads(line) - for line in (settings.log_dir / "voca.jsonl").read_text().splitlines() - ] - assert any( - entry["event"] == "control.command_rejected" and entry["level"] == "WARNING" - for entry in diagnostics - ) - - -def test_transport_deadlines(monkeypatch: pytest.MonkeyPatch) -> None: - from voca.presentation import responses - - async def blocked(_: object = None, **kwargs: object) -> None: - del kwargs - await asyncio.Event().wait() - - monkeypatch.setattr(responses, "IO_TIMEOUT_SECONDS", 0) - with pytest.raises(TimeoutError): - asyncio.run(responses.send_with_deadline(blocked, {})) - with pytest.raises(TimeoutError): - asyncio.run(responses.close_with_deadline(blocked, code=1011, reason="timeout")) - - -def test_bootstrap_errors(tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None: - settings = settings_for(tmp_path) - marker = "VOCA_SYNTHETIC_SECRET" - with TestClient(create_app(settings)) as client: - invalid = client.post( - f"/api/sessions?token={marker}", - content=f'{{"value":"{marker}"}}', - headers={"content-type": "application/json", "authorization": marker}, - ) - assert invalid.status_code == 400 - assert invalid.json()["error"]["code"] == "invalid_request" - unsupported = client.post( - "/api/sessions", content=marker, headers={"content-type": "text/plain"} - ) - assert unsupported.status_code == 415 - malformed_utf8 = client.post( - "/api/sessions", - content=b"\xff", - headers={"content-type": "application/json"}, - ) - assert malformed_utf8.status_code == 400 - logs = "".join(path.read_text() for path in settings.log_dir.glob("voca.jsonl*")) - assert marker not in logs - assert marker not in capsys.readouterr().err diff --git a/backend/uv.lock b/backend/uv.lock deleted file mode 100644 index be8fe57..0000000 --- a/backend/uv.lock +++ /dev/null @@ -1,408 +0,0 @@ -version = 1 -revision = 3 -requires-python = "==3.13.*" - -[[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.15.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "idna" }, - { name = "typing-extensions" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/a9/d2/f4d173e22df740bc37b1db102b386ba719b66e95b0f0d751f556b387e6d2/anyio-4.15.1.tar.gz", hash = "sha256:9f28306018cbd6d329e64a36d58256edff76dd996fe423bc957326e578b82a94", size = 276966, upload-time = "2026-09-05T10:42:39.44Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/12/b8/4bd346e22b28902df4d651910f5242c28d84e4a5c2435ca5c3f797ed7e2e/anyio-4.15.1-py3-none-any.whl", hash = "sha256:6152fdbbf9a77fdec97731721bebf7c4c44f7c29b424b0065826173efc7ed101", size = 132079, upload-time = "2026-09-05T10:42:37.923Z" }, -] - -[[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 = "click" -version = "8.5.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/c7/0e/7fa0ef50764b67090eca4114772a2abf8b6148198475e54c660b97caeee6/click-8.5.0.tar.gz", hash = "sha256:ba0d2089de75ea0310e2dde03160e6ca10009947fb95a182f9b54021bb272e34", size = 382235, upload-time = "2026-08-26T13:33:14.56Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/58/50/6c0d534c5f134586a8e1ba4e330569e32f057e33372ae556463212fb4cd3/click-8.5.0-py3-none-any.whl", hash = "sha256:255bc9599cf7748b4b1a446ccc735421bd08a2ae529a8b88597d3de5664ee360", size = 125251, upload-time = "2026-08-26T13:33:12.928Z" }, -] - -[[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 = "fastapi" -version = "0.141.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "annotated-doc" }, - { name = "pydantic" }, - { name = "starlette" }, - { name = "typing-extensions" }, - { name = "typing-inspection" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/8a/02/91e3416a8fdd715abb903a952a6bec7cdd8d14eed55d415fc8595524c319/fastapi-0.141.1.tar.gz", hash = "sha256:e8822fc40db1e1858054d7a949a888695bc9bdce70139178e33bd2871a453ca1", size = 425799, upload-time = "2026-07-29T17:18:05.568Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/cb/03/10388a42375ee7e4ac9b94eb2c5c569c8b5795e377e701c9ac3ad63de890/fastapi-0.141.1-py3-none-any.whl", hash = "sha256:bfb91aa2d334c61cb35ba9a116fc123b3d3df31640b801cf57a7a78ec3f603b3", size = 131954, upload-time = "2026-07-29T17:18:04.364Z" }, -] - -[[package]] -name = "h11" -version = "0.16.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, -] - -[[package]] -name = "httpcore" -version = "1.0.9" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "certifi" }, - { name = "h11" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, -] - -[[package]] -name = "httpx" -version = "0.28.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "anyio" }, - { name = "certifi" }, - { name = "httpcore" }, - { name = "idna" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, -] - -[[package]] -name = "idna" -version = "3.19" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/5f/f7/abb373e5757eaec4b922b92f97ec8d6d7e057cf06778247604fbc4e7c3f3/idna-3.19.tar.gz", hash = "sha256:5e0811a4383b21dc5838069f801c4fb62113b7447663d2530d2bd6e77b49bf15", size = 215237, upload-time = "2026-08-18T05:14:24.27Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/57/b0/0e52c878c53f245edd3a11020f20979b3f490f245af532c7cae3027754b5/idna-3.19-py3-none-any.whl", hash = "sha256:815e7be7a7806d54abb586dc943addc79e8b2ee16915059658cbeff4b1b43bf4", size = 68550, upload-time = "2026-08-18T05:14:22.343Z" }, -] - -[[package]] -name = "iniconfig" -version = "2.3.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, -] - -[[package]] -name = "librt" -version = "0.15.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/36/9b/356320fbae2ac8467e21c5e73e1389c80468e4998c62cc7d3536cc51b614/librt-0.15.0.tar.gz", hash = "sha256:4e66cbe84437497d951b799d3e1551291b6fb3d643820a7014b3655d57a59162", size = 214338, upload-time = "2026-08-07T10:49:42.663Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/e7/42/467b53a601b406ccd7b97c1fd54b59cb34f9185ad5ce7e9d5c3c4e8961c8/librt-0.15.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:db13ca398005abcbe538deda87b686d9bd08b7001cf40c4c06b444960ae10a26", size = 151029, upload-time = "2026-08-07T10:47:19.312Z" }, - { url = "https://files.pythonhosted.org/packages/3e/e6/36c2299b7a94b84fdd01220d8a777a71be5be0925bb0dbdf71c0a06a34d9/librt-0.15.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:aa1f1995789dca3698bc550aaceb09a51bd5df0a057ff84ff15296cd1975b801", size = 155194, upload-time = "2026-08-07T10:47:20.398Z" }, - { url = "https://files.pythonhosted.org/packages/c9/b6/ed5071f9325845e670bd36012757419767fbf56af77ed483077b9e4db541/librt-0.15.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:55456ea87d8df21808446d03817be2f65e20391c1c615d9187440dff28cd08dc", size = 502568, upload-time = "2026-08-07T10:47:21.652Z" }, - { url = "https://files.pythonhosted.org/packages/7f/81/6450c67c3615d87704bcbc21323fafc69c799b06a044c447529f725d4b01/librt-0.15.0-cp313-cp313-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:5a86a5a08c2235316bdb359d5dbb6ce0abfca7fac06363103e2c5af571d92f95", size = 496153, upload-time = "2026-08-07T10:47:22.925Z" }, - { url = "https://files.pythonhosted.org/packages/e1/d6/5f52b722bc75076954b3bfd49be15ea362df4d580c6fb315d0f617100d30/librt-0.15.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e56b6a368529bed262da40ce13f8fef590db0479819cca84f16a1f01ac356d0b", size = 513336, upload-time = "2026-08-07T10:47:24.213Z" }, - { url = "https://files.pythonhosted.org/packages/8d/e2/c08fd1d36ce63ea5a12b85c5d37f4550b5f86a692167e41e5a74222607ae/librt-0.15.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:234d8d394721fa0d786af15ebf1f3fb7f3ed82fd1cd0cde45c2f247b5d4281d2", size = 531661, upload-time = "2026-08-07T10:47:25.507Z" }, - { url = "https://files.pythonhosted.org/packages/3f/d8/d9482fcbeb177b9eb87bb3899eeb3b42be690313c652f9e146b1d0681fb2/librt-0.15.0-cp313-cp313-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d8363d7accb0286ac3a0e633f396e93800dafb8150494505daf9515bbda591f3", size = 524487, upload-time = "2026-08-07T10:47:26.79Z" }, - { url = "https://files.pythonhosted.org/packages/10/cc/075171517b41f861753034fbb151b42cfc83bcc853849f24f5e66fd60ccf/librt-0.15.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:0f0ee3644d951f31055ad07d77d92520e84505dd7a432cc4cd501dd70ee06785", size = 543201, upload-time = "2026-08-07T10:47:27.999Z" }, - { url = "https://files.pythonhosted.org/packages/b0/03/42c2330f37eeb475b6affeedd06518f60035f323af3a839335e3fc9fef2d/librt-0.15.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:2cfd1a81a648806e6a7717be4cc4d1bb392fa229752bf8444ba365e381e984d6", size = 546467, upload-time = "2026-08-07T10:47:29.396Z" }, - { url = "https://files.pythonhosted.org/packages/57/1e/1ad4c5638f7e64d8560328bd25c54b409a661bdb6ff254b38ff90744288d/librt-0.15.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:a6cd22c9da0d866558e46a041f1cc0c2bbb26b61b137b2347fa834c332e1d101", size = 555139, upload-time = "2026-08-07T10:47:30.815Z" }, - { url = "https://files.pythonhosted.org/packages/49/41/39fa7d15db1204cd1cbe6514680fbdc243adf754a0885061308f43afc013/librt-0.15.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:6d5225ef8801e4ea5e482fa9b5dfb891dd9ef6f6d870f1f25d449ca2c70ac218", size = 536050, upload-time = "2026-08-07T10:47:32.222Z" }, - { url = "https://files.pythonhosted.org/packages/1e/88/c6dcf0dd8e26dc0c9a499a2abab8646c86dcaf9ecea9524cb46d3686331a/librt-0.15.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6d28a05796b99f749bf8794f17ba9ba1612d0076b802e9cfc62c554634e9ce3b", size = 573700, upload-time = "2026-08-07T10:47:33.527Z" }, - { url = "https://files.pythonhosted.org/packages/1b/9b/ab54c71a7918a7c34fa5327fb61390a77446a07a146fbfb1165250a61035/librt-0.15.0-cp313-cp313-pyemscripten_2025_0_wasm32.whl", hash = "sha256:2067ff438048cead9d223ca5675bae2a25e520a7c3e6c1498bf9c6892d22caab", size = 82194, upload-time = "2026-08-07T10:47:34.835Z" }, - { url = "https://files.pythonhosted.org/packages/8d/b2/4f9a243bb892395f3becb80789ade13771701091f9f07ab8230247953ba8/librt-0.15.0-cp313-cp313-win32.whl", hash = "sha256:1cd3b721f24c206398b9e26da3c3a9c011e6e89d06f318ba8ebefc30f1003890", size = 106231, upload-time = "2026-08-07T10:47:36.251Z" }, - { url = "https://files.pythonhosted.org/packages/bf/af/64aff4885a40b93132382f2c314647d722574605416504379184ef3045ea/librt-0.15.0-cp313-cp313-win_amd64.whl", hash = "sha256:f395a4a9a03ac062dbe9a9f82e0c720502e590a38feee6a757bc82e9c63afbd8", size = 126996, upload-time = "2026-08-07T10:47:37.453Z" }, - { url = "https://files.pythonhosted.org/packages/27/83/335bccf6c7cb9028cb0b54aead27d9ece3f01f83bc6baa2abace5da655c1/librt-0.15.0-cp313-cp313-win_arm64.whl", hash = "sha256:0a15cb554761247d84a3ec0cbdf4078d70725384f0e4662c0fa3b26266eb60ad", size = 112188, upload-time = "2026-08-07T10:47:38.729Z" }, -] - -[[package]] -name = "mypy" -version = "1.20.2" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "librt", marker = "platform_python_implementation != 'PyPy'" }, - { name = "mypy-extensions" }, - { name = "pathspec" }, - { name = "typing-extensions" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/04/af/e3d4b3e9ec91a0ff9aabfdb38692952acf49bbb899c2e4c29acb3a6da3ae/mypy-1.20.2.tar.gz", hash = "sha256:e8222c26daaafd9e8626dec58ae36029f82585890589576f769a650dd20fd665", size = 3817349, upload-time = "2026-04-21T17:12:28.473Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/5b/c4/b93812d3a192c9bcf5df405bd2f30277cd0e48106a14d1023c7f6ed6e39b/mypy-1.20.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:edfbfca868cdd6bd8d974a60f8a3682f5565d3f5c99b327640cedd24c4264026", size = 14524670, upload-time = "2026-04-21T17:10:30.737Z" }, - { url = "https://files.pythonhosted.org/packages/f3/47/42c122501bff18eaf1e8f457f5c017933452d8acdc52918a9f59f6812955/mypy-1.20.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e2877a02380adfcdbc69071a0f74d6e9dbbf593c0dc9d174e1f223ffd5281943", size = 13336218, upload-time = "2026-04-21T17:08:44.069Z" }, - { url = "https://files.pythonhosted.org/packages/92/8f/75bbc92f41725fbd585fb17b440b1119b576105df1013622983e18640a93/mypy-1.20.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7488448de6007cd5177c6cea0517ac33b4c0f5ee9b5e9f2be51ce75511a85517", size = 13724906, upload-time = "2026-04-21T17:08:01.02Z" }, - { url = "https://files.pythonhosted.org/packages/a1/32/4c49da27a606167391ff0c39aa955707a00edc500572e562f7c36c08a71f/mypy-1.20.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bb9c2fa06887e21d6a3a868762acb82aec34e2c6fd0174064f27c93ede68ad15", size = 14726046, upload-time = "2026-04-21T17:11:22.354Z" }, - { url = "https://files.pythonhosted.org/packages/7f/fc/4e354a1bd70216359deb0c9c54847ee6b32ef78dfb09f5131ff99b494078/mypy-1.20.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9d56a78b646f2e3daa865bc70cd5ec5a46c50045801ca8ff17a0c43abc97e3ee", size = 14955587, upload-time = "2026-04-21T17:12:16.033Z" }, - { url = "https://files.pythonhosted.org/packages/62/b2/c0f2056e9eb8f08c62cafd9715e4584b89132bdc832fcf85d27d07b5f3e5/mypy-1.20.2-cp313-cp313-win_amd64.whl", hash = "sha256:2a4102b03bb7481d9a91a6da8d174740c9c8c4401024684b9ca3b7cc5e49852f", size = 10922681, upload-time = "2026-04-21T17:06:35.842Z" }, - { url = "https://files.pythonhosted.org/packages/e5/14/065e333721f05de8ef683d0aa804c23026bcc287446b61cac657b902ccac/mypy-1.20.2-cp313-cp313-win_arm64.whl", hash = "sha256:a95a9248b0c6fd933a442c03c3b113c3b61320086b88e2c444676d3fd1ca3330", size = 9830560, upload-time = "2026-04-21T17:07:51.023Z" }, - { url = "https://files.pythonhosted.org/packages/28/9a/f23c163e25b11074188251b0b5a0342625fc1cdb6af604757174fa9acc9b/mypy-1.20.2-py3-none-any.whl", hash = "sha256:a94c5a76ab46c5e6257c7972b6c8cff0574201ca7dc05647e33e795d78680563", size = 2637314, upload-time = "2026-04-21T17:05:54.5Z" }, -] - -[[package]] -name = "mypy-extensions" -version = "1.1.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/a2/6e/371856a3fb9d31ca8dac321cda606860fa4548858c0cc45d9d1d4ca2628b/mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558", size = 6343, upload-time = "2025-04-22T14:54:24.164Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963, upload-time = "2025-04-22T14:54:22.983Z" }, -] - -[[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 = "pathspec" -version = "1.1.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/5a/82/42f767fc1c1143d6fd36efb827202a2d997a375e160a71eb2888a925aac1/pathspec-1.1.1.tar.gz", hash = "sha256:17db5ecd524104a120e173814c90367a96a98d07c45b2e10c2f3919fff91bf5a", size = 135180, upload-time = "2026-04-27T01:46:08.907Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/f1/d9/7fb5aa316bc299258e68c73ba3bddbc499654a07f151cba08f6153988714/pathspec-1.1.1-py3-none-any.whl", hash = "sha256:a00ce642f577bf7f473932318056212bc4f8bfdf53128c78bbd5af0b9b20b189", size = 57328, upload-time = "2026-04-27T01:46:07.06Z" }, -] - -[[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 = "pydantic" -version = "2.13.5" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "annotated-types" }, - { name = "pydantic-core" }, - { name = "typing-extensions" }, - { name = "typing-inspection" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/53/ef/fc4f868f4e2cee79f863883abffceff107875f569b848507319842d2a681/pydantic-2.13.5.tar.gz", hash = "sha256:51a9c5f7b2f8e636f04c6cada605d9b6a3bf1348fdf945a3d8869b19bba0ee08", size = 845750, upload-time = "2026-08-28T14:04:00.916Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/eb/47/c95ffc2009878c7aac0c5e08528022dcb885933252a88b5f170058014464/pydantic-2.13.5-py3-none-any.whl", hash = "sha256:346a034f080da3755d8e9cb5e00e8b07de1d39e4f6e2c87d8ab7cafa0b269a73", size = 472589, upload-time = "2026-08-28T14:03:59.136Z" }, -] - -[[package]] -name = "pydantic-core" -version = "2.46.5" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "typing-extensions" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/af/f9/8a06bea35ef8daf588f707784c973a7046e0034c8d8cfb08828eeffb8b75/pydantic_core-2.46.5.tar.gz", hash = "sha256:10416c15b8839ecc4ef4d0885da76da6fd0f67333a0eb8aff6d93c4b8f2910fc", size = 472262, upload-time = "2026-08-28T10:01:31.677Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/f5/37/5abe39a8372a61d3dc3c1338fc504281c01b32fdb3169cd7187153b56d3e/pydantic_core-2.46.5-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:b7ca9034437b6022f941f4857459562ee00a560b97e7cce8a0ec5a74fc6766e0", size = 2075885, upload-time = "2026-08-28T09:58:47.856Z" }, - { url = "https://files.pythonhosted.org/packages/21/43/6323b1f8b217780454c61304bcd2b38ae4762f50754414124603ccc90bb2/pydantic_core-2.46.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f332f0e72a5a0400141f830744e141bf9f97917878dbe968669e8a7fefea78ff", size = 1922768, upload-time = "2026-08-28T09:58:49.58Z" }, - { url = "https://files.pythonhosted.org/packages/0f/a3/c05ca796e1197618a774b01e596aeedfefc2f7d8c01ae3054e910b120e8a/pydantic_core-2.46.5-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:193375f3548919d3f0b60936ca113ada3e38f264f91b9b8e0508efaad57be931", size = 1951241, upload-time = "2026-08-28T09:58:51.511Z" }, - { url = "https://files.pythonhosted.org/packages/68/32/33bc39ac705c52cffc908e8389f9754fdb208aea5c69cceddf4eb3ce99af/pydantic_core-2.46.5-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:79bdfa52f843137045b2d081cc05c120ba6665d29b7559c2c47690906f39279f", size = 2031975, upload-time = "2026-08-28T09:58:53.166Z" }, - { url = "https://files.pythonhosted.org/packages/b0/70/2333e885c0f6a67bc105c5916965dac9b57f2718ee20d81d1a06a4ebdc13/pydantic_core-2.46.5-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:24922243639cbdac66c75fcb6fd6495a9cb52b213d62f9a0d16f0310b1ff8038", size = 2208542, upload-time = "2026-08-28T09:58:55.017Z" }, - { url = "https://files.pythonhosted.org/packages/f7/ea/296debfb4264207bbda5936133892e027c0a58875ad53ebd512fba8ec3a2/pydantic_core-2.46.5-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c76fe65e607be28c7fd4d56fc3c42b1583aa058ce3408b7ad0fd540171d31f9f", size = 2264692, upload-time = "2026-08-28T09:58:56.767Z" }, - { url = "https://files.pythonhosted.org/packages/d3/f2/9e4de77a6271e07a76d2d58b11c091a979c191ed2939bf80067568b369d2/pydantic_core-2.46.5-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6f7b393a8b3da82f5c1fc0751e6d01ac6c55b93c18226a60bdfba4a724efafd1", size = 2066633, upload-time = "2026-08-28T09:58:58.531Z" }, - { url = "https://files.pythonhosted.org/packages/8d/db/f9e9d0c97445987b2084823d5c240de88087338f04fc2cfaa2df186b8049/pydantic_core-2.46.5-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:7ac031912d54f3d83ef3b3eb98dfabc1608802e2202263d25957eeed40b94761", size = 2105235, upload-time = "2026-08-28T09:59:00.421Z" }, - { url = "https://files.pythonhosted.org/packages/07/c5/79169b047b3b2c3e99e04bc76372af9637e0bf6db638274fa927df96369e/pydantic_core-2.46.5-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:837b396ca3d7b74091ca623f6cbd8351bd42d670a79c2683e79fb089f06a2de5", size = 2157367, upload-time = "2026-08-28T09:59:02.442Z" }, - { url = "https://files.pythonhosted.org/packages/26/b5/ba6057afb7c291bd449f51b867f95aef2072941c4ce4e5c31d6ffd132d3b/pydantic_core-2.46.5-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:5ee239d575f80b08eca11f6e20f90c4c695de7825c67eefe6091fbf20dda648e", size = 2158420, upload-time = "2026-08-28T09:59:04.2Z" }, - { url = "https://files.pythonhosted.org/packages/6e/28/2057abecaafdc22912afa819603a51f0a62d40643b7c4871c51721fea9be/pydantic_core-2.46.5-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:e80675d75ae2cd14372cb65cad5400d9347a3d3f6c13000183f22dfd027283ed", size = 2309588, upload-time = "2026-08-28T09:59:06.048Z" }, - { url = "https://files.pythonhosted.org/packages/71/9d/881156dc404e27479c4246128d73538464cab4a239bec61995e227644c30/pydantic_core-2.46.5-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:9c4b71f10dd532fb7a5cbc8f58707779e64f03a258c2bf8bfbaecfcd9970b519", size = 2341866, upload-time = "2026-08-28T09:59:08.539Z" }, - { url = "https://files.pythonhosted.org/packages/5a/38/d66f443a259f84d13babdceae568e572b0ed26da17ca5d0a649ebb110a67/pydantic_core-2.46.5-cp313-cp313-win32.whl", hash = "sha256:97bf8de4d541598c94a59344eeb988a94c08ff76b5723c41f6567ec18c7892ea", size = 1938580, upload-time = "2026-08-28T09:59:10.402Z" }, - { url = "https://files.pythonhosted.org/packages/2c/1e/1d5371213f4cc9a7ed70c0bfcc7911de22311ee99a662a56077d7292d2ac/pydantic_core-2.46.5-cp313-cp313-win_amd64.whl", hash = "sha256:15f4a94963c95accac15b7b657bb177d3ad82bb90b0d0526d9a9b85079925db5", size = 2041980, upload-time = "2026-08-28T09:59:12.396Z" }, - { url = "https://files.pythonhosted.org/packages/5a/48/4222d90b1c67568bace4dec6dca6271449c66de3595d72b6d098f5fde597/pydantic_core-2.46.5-cp313-cp313-win_arm64.whl", hash = "sha256:d22a945598fb91236b4dd793a6e42e4f3dd7740bb5aace5ebd7d4c08d13bb575", size = 1997213, upload-time = "2026-08-28T09:59:14.245Z" }, -] - -[[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 = "pytest" -version = "9.1.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "colorama", marker = "sys_platform == 'win32'" }, - { name = "iniconfig" }, - { name = "packaging" }, - { name = "pluggy" }, - { name = "pygments" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" }, -] - -[[package]] -name = "ruff" -version = "0.16.7" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/82/bb/5a449b9162e49b139d72f61672bd3ac1d790221f796d3304e2241fff4c58/ruff-0.16.7.tar.gz", hash = "sha256:5f71d004ac1263b22fa39462ac5ae618a4b77d58981af2cc79bf79a29c12b1a6", size = 4924184, upload-time = "2026-09-10T18:04:06.336Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/e3/b2/c80aeeb7f9e469c0d63a85d2f1ab6e1ebfbe10ea7a8d2438b7e09e3ff09e/ruff-0.16.7-py3-none-linux_armv6l.whl", hash = "sha256:727307773e7c7f9181d3ed3a2484186e56c1fa1874255911c74585eb2c7c19f9", size = 10048917, upload-time = "2026-09-10T18:03:30.28Z" }, - { url = "https://files.pythonhosted.org/packages/7b/96/20bb7bcae008004df52afcb7ac83432d4a467f2c17b672fe46d26be231c5/ruff-0.16.7-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:9d61c258deabf58f34c67bd4bb4d939c7f2e6b5f0e59c1cdd1cf771b11cde929", size = 10242929, upload-time = "2026-09-10T18:03:32.706Z" }, - { url = "https://files.pythonhosted.org/packages/90/b2/f184b0d5abec02db69cfd7e49b688ae0237554528ca777136c613bf36bee/ruff-0.16.7-py3-none-macosx_11_0_arm64.whl", hash = "sha256:7ab81118df8945e0193d0240712aa4496573595b75185c3636ed825592a0f728", size = 9847245, upload-time = "2026-09-10T18:03:34.509Z" }, - { url = "https://files.pythonhosted.org/packages/eb/2d/db1633a641866ed801e34cc6b60ef236c5e16f9b2124ab1d49cc24a5fe4f/ruff-0.16.7-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4c196c968874fc8019da8e7163de7a1a370f111e2309b4b7dfea0fce950198d0", size = 9961780, upload-time = "2026-09-10T18:03:36.618Z" }, - { url = "https://files.pythonhosted.org/packages/4d/98/edea21e1a3e38dbbc3bf6bb068b863b3b06184cf8533a4c7dbbe208a89d5/ruff-0.16.7-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ac8c3bd0a7e10ad31e6ce51e7a99f3cb772e69aecdd6b9ea7e99b362f62a62c0", size = 9866337, upload-time = "2026-09-10T18:03:38.805Z" }, - { url = "https://files.pythonhosted.org/packages/0b/11/a15e60d4c87b214646f116ca9d204475bf993ee1047459bc9a360fd4d6d1/ruff-0.16.7-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:398d3988edde000b5c75dc1b3f584708da9bc990de069c18909142580fec1af9", size = 10562512, upload-time = "2026-09-10T18:03:40.71Z" }, - { url = "https://files.pythonhosted.org/packages/29/42/eaff4c9b6d0c7cdf56df313a17e89ae854f5bbc0b0c8f9cce19be0ab7a8f/ruff-0.16.7-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ce05b62b770a8217c4646a9c4139fca00efe8fe5d71f87df2b243ff20d4584d1", size = 11302938, upload-time = "2026-09-10T18:03:42.607Z" }, - { url = "https://files.pythonhosted.org/packages/5d/43/c75aa59a4ec181fe2ec06cab30e198c1c6d107229a9f008ae3a7c16cabd8/ruff-0.16.7-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:af1b576fddb9d9ef2ececfb5fadcd6a624b25070ed85e3cfcfe449fc3ff6a7b9", size = 10840857, upload-time = "2026-09-10T18:03:44.604Z" }, - { url = "https://files.pythonhosted.org/packages/21/33/81f3da371942ea031105ba679d8d6e28ec1660ccd690a45f42d381161356/ruff-0.16.7-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9ce7f8f22df67c93ed96c717f9128eadb797144ac2bad475cf536f31d6100c55", size = 10370001, upload-time = "2026-09-10T18:03:46.706Z" }, - { url = "https://files.pythonhosted.org/packages/fa/0b/6345fb4dbf6dd0ed1cfe5d18391dc9c3f59cc81622a7b0a65b84b3e730ba/ruff-0.16.7-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:06d0e93d04f392996435ebd600c153f65b47d73fbec2415aa99c5ee5756b3a5f", size = 10548735, upload-time = "2026-09-10T18:03:48.658Z" }, - { url = "https://files.pythonhosted.org/packages/3f/4d/c5576adf511f92a328e5569dda190ecdd430da51f1a649f3a4a2fd73e21e/ruff-0.16.7-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:142151a5e7b93c1b11111337142f89dd2fbfee92161225c99a97222f22e32656", size = 10108496, upload-time = "2026-09-10T18:03:50.563Z" }, - { url = "https://files.pythonhosted.org/packages/ff/8c/667d83c16199a17a56adc6b0bd4c3beb5b767a2babcd16a56f76f9be7fd6/ruff-0.16.7-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:e6651f97a342d8b35d54d8991544ca22169b86dc54111cb604666940c431b750", size = 9860136, upload-time = "2026-09-10T18:03:52.621Z" }, - { url = "https://files.pythonhosted.org/packages/99/75/78d401106731999a1dd20cc5a6961e37e1eb9397a3b589f73f3a5ce146a3/ruff-0.16.7-py3-none-musllinux_1_2_i686.whl", hash = "sha256:ef140c6eb935fa9a84c9c607dfb2cb1b85843c192e79265b0c54f35f557ea8e5", size = 10286290, upload-time = "2026-09-10T18:03:55.207Z" }, - { url = "https://files.pythonhosted.org/packages/68/49/56f9c3a8b755df93a0ad318b2147bf4ef5dae9a7e5ec61c460109c67957f/ruff-0.16.7-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:53e39506a730fadeee0d998ed5946f30671f0db240c6c7c73bdabbe33604bb6f", size = 10745048, upload-time = "2026-09-10T18:03:57.299Z" }, - { url = "https://files.pythonhosted.org/packages/5f/ea/7f9b938a63ece4bec677ad7f9f7fa02df3383db1949ed93a382441c09a87/ruff-0.16.7-py3-none-win32.whl", hash = "sha256:2ea3470fcebcbc5df2fb0c6f3b90333fa9084c534e0111c038fa4a6ab9f1c4b7", size = 10059082, upload-time = "2026-09-10T18:03:59.632Z" }, - { url = "https://files.pythonhosted.org/packages/39/11/480a6973a927aa653e1cead6a6416008640e03a99d05b34c0434b8c6c366/ruff-0.16.7-py3-none-win_amd64.whl", hash = "sha256:7ac26aca826e9e21d0f1cb25b54ac660760a9fdd094d3e4df9848232be98cfc6", size = 10593368, upload-time = "2026-09-10T18:04:01.999Z" }, - { url = "https://files.pythonhosted.org/packages/8b/4b/51327018d056f0dad2c2238f26d1fb0f53707a9d91b75dea6d1b3039f136/ruff-0.16.7-py3-none-win_arm64.whl", hash = "sha256:aab7f39e2c9df6c596216070f98eef1207b94f8516cca20c808826974971855b", size = 10412401, upload-time = "2026-09-10T18:04:04.098Z" }, -] - -[[package]] -name = "starlette" -version = "0.52.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "anyio" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/c4/68/79977123bb7be889ad680d79a40f339082c1978b5cfcf62c2d8d196873ac/starlette-0.52.1.tar.gz", hash = "sha256:834edd1b0a23167694292e94f597773bc3f89f362be6effee198165a35d62933", size = 2653702, upload-time = "2026-01-18T13:34:11.062Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/81/0d/13d1d239a25cbfb19e740db83143e95c772a1fe10202dda4b76792b114dd/starlette-0.52.1-py3-none-any.whl", hash = "sha256:0029d43eb3d273bc4f83a08720b4912ea4b071087a3b48db01b7c839f7954d74", size = 74272, upload-time = "2026-01-18T13:34:09.188Z" }, -] - -[[package]] -name = "typing-extensions" -version = "4.16.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5", size = 113555, upload-time = "2026-07-02T08:40:05.92Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" }, -] - -[[package]] -name = "typing-inspection" -version = "0.4.4" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "typing-extensions" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/a3/26/b09b8010994eccc3c09092e6b34058f36a460eea2d4c3e8b910c695975a0/typing_inspection-0.4.4.tar.gz", hash = "sha256:547274fa6b0a561ccf549cc9524b999a578e737d015d8709d021f9d0d13bea47", size = 76928, upload-time = "2026-08-12T12:37:25.997Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/67/81/4add07e5172b7ac40d8ed5ff580409a7801a4fe26d529bdd915401dabfbe/typing_inspection-0.4.4-py3-none-any.whl", hash = "sha256:65b8397ba37ccbce054456aaccddfc91e6e3083c92824df348d96ca832f3f147", size = 14750, upload-time = "2026-08-12T12:37:24.648Z" }, -] - -[[package]] -name = "uvicorn" -version = "0.52.4" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "click" }, - { name = "h11" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/f2/0f/3f86e61397dd33bf2ccf28188c40db6a740658aeebbbf6e7dbc101a1f487/uvicorn-0.52.4.tar.gz", hash = "sha256:73acfee47a0b133c5de13d219492d62d8a31e935f4fe6e41a232451a15379f86", size = 100627, upload-time = "2026-08-19T06:27:41.821Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/f1/79/4a20b54ab0491485ccd8c077db2d39187c7f12b3e15485d38a7be37c81b4/uvicorn-0.52.4-py3-none-any.whl", hash = "sha256:f86e41a149d7d05a9969337e3946a9c171c06a5d42680896daaba624aeac8da1", size = 79871, upload-time = "2026-08-19T06:27:40.36Z" }, -] - -[[package]] -name = "voca" -version = "0.1.0" -source = { editable = "." } -dependencies = [ - { name = "fastapi" }, - { name = "pydantic" }, - { name = "starlette" }, - { name = "uvicorn" }, - { name = "websockets" }, -] - -[package.dev-dependencies] -dev = [ - { name = "httpx" }, - { name = "mypy" }, - { name = "pytest" }, - { name = "ruff" }, -] - -[package.metadata] -requires-dist = [ - { name = "fastapi", specifier = ">=0.115,<1" }, - { name = "pydantic", specifier = ">=2.9,<3" }, - { name = "starlette", specifier = ">=0.40,<1" }, - { name = "uvicorn", specifier = ">=0.32,<1" }, - { name = "websockets", specifier = ">=13,<16" }, -] - -[package.metadata.requires-dev] -dev = [ - { name = "httpx", specifier = ">=0.28,<1" }, - { name = "mypy", specifier = ">=1.13,<2" }, - { name = "pytest", specifier = ">=8.3,<10" }, - { name = "ruff", specifier = ">=0.8,<1" }, -] - -[[package]] -name = "websockets" -version = "15.0.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/21/e6/26d09fab466b7ca9c7737474c52be4f76a40301b08362eb2dbc19dcc16c1/websockets-15.0.1.tar.gz", hash = "sha256:82544de02076bafba038ce055ee6412d68da13ab47f0c60cab827346de828dee", size = 177016, upload-time = "2025-03-05T20:03:41.606Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/cb/9f/51f0cf64471a9d2b4d0fc6c534f323b664e7095640c34562f5182e5a7195/websockets-15.0.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ee443ef070bb3b6ed74514f5efaa37a252af57c90eb33b956d35c8e9c10a1931", size = 175440, upload-time = "2025-03-05T20:02:36.695Z" }, - { url = "https://files.pythonhosted.org/packages/8a/05/aa116ec9943c718905997412c5989f7ed671bc0188ee2ba89520e8765d7b/websockets-15.0.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5a939de6b7b4e18ca683218320fc67ea886038265fd1ed30173f5ce3f8e85675", size = 173098, upload-time = "2025-03-05T20:02:37.985Z" }, - { url = "https://files.pythonhosted.org/packages/ff/0b/33cef55ff24f2d92924923c99926dcce78e7bd922d649467f0eda8368923/websockets-15.0.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:746ee8dba912cd6fc889a8147168991d50ed70447bf18bcda7039f7d2e3d9151", size = 173329, upload-time = "2025-03-05T20:02:39.298Z" }, - { url = "https://files.pythonhosted.org/packages/31/1d/063b25dcc01faa8fada1469bdf769de3768b7044eac9d41f734fd7b6ad6d/websockets-15.0.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:595b6c3969023ecf9041b2936ac3827e4623bfa3ccf007575f04c5a6aa318c22", size = 183111, upload-time = "2025-03-05T20:02:40.595Z" }, - { url = "https://files.pythonhosted.org/packages/93/53/9a87ee494a51bf63e4ec9241c1ccc4f7c2f45fff85d5bde2ff74fcb68b9e/websockets-15.0.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3c714d2fc58b5ca3e285461a4cc0c9a66bd0e24c5da9911e30158286c9b5be7f", size = 182054, upload-time = "2025-03-05T20:02:41.926Z" }, - { url = "https://files.pythonhosted.org/packages/ff/b2/83a6ddf56cdcbad4e3d841fcc55d6ba7d19aeb89c50f24dd7e859ec0805f/websockets-15.0.1-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0f3c1e2ab208db911594ae5b4f79addeb3501604a165019dd221c0bdcabe4db8", size = 182496, upload-time = "2025-03-05T20:02:43.304Z" }, - { url = "https://files.pythonhosted.org/packages/98/41/e7038944ed0abf34c45aa4635ba28136f06052e08fc2168520bb8b25149f/websockets-15.0.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:229cf1d3ca6c1804400b0a9790dc66528e08a6a1feec0d5040e8b9eb14422375", size = 182829, upload-time = "2025-03-05T20:02:48.812Z" }, - { url = "https://files.pythonhosted.org/packages/e0/17/de15b6158680c7623c6ef0db361da965ab25d813ae54fcfeae2e5b9ef910/websockets-15.0.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:756c56e867a90fb00177d530dca4b097dd753cde348448a1012ed6c5131f8b7d", size = 182217, upload-time = "2025-03-05T20:02:50.14Z" }, - { url = "https://files.pythonhosted.org/packages/33/2b/1f168cb6041853eef0362fb9554c3824367c5560cbdaad89ac40f8c2edfc/websockets-15.0.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:558d023b3df0bffe50a04e710bc87742de35060580a293c2a984299ed83bc4e4", size = 182195, upload-time = "2025-03-05T20:02:51.561Z" }, - { url = "https://files.pythonhosted.org/packages/86/eb/20b6cdf273913d0ad05a6a14aed4b9a85591c18a987a3d47f20fa13dcc47/websockets-15.0.1-cp313-cp313-win32.whl", hash = "sha256:ba9e56e8ceeeedb2e080147ba85ffcd5cd0711b89576b83784d8605a7df455fa", size = 176393, upload-time = "2025-03-05T20:02:53.814Z" }, - { url = "https://files.pythonhosted.org/packages/1b/6c/c65773d6cab416a64d191d6ee8a8b1c68a09970ea6909d16965d26bfed1e/websockets-15.0.1-cp313-cp313-win_amd64.whl", hash = "sha256:e09473f095a819042ecb2ab9465aee615bd9c2028e4ef7d933600a8401c79561", size = 176837, upload-time = "2025-03-05T20:02:55.237Z" }, - { url = "https://files.pythonhosted.org/packages/fa/a8/5b41e0da817d64113292ab1f8247140aac61cbf6cfd085d6a0fa77f4984f/websockets-15.0.1-py3-none-any.whl", hash = "sha256:f7a866fbc1e97b5c617ee4116daaa09b722101d4a3c170c787450ba409f9736f", size = 169743, upload-time = "2025-03-05T20:03:39.41Z" }, -] diff --git a/docs/adr/0001-rewrite-v3.md b/docs/adr/0001-rewrite-v3.md deleted file mode 100644 index eb7ce6d..0000000 --- a/docs/adr/0001-rewrite-v3.md +++ /dev/null @@ -1,63 +0,0 @@ ---- -tags: - - voice-agent - - v3 - - adr -status: accepted ---- - -# ADR-0001: Voice Agent v3 — clean rewrite - -## Status - -Accepted (2026-09-02) - -> **Историческое решение rewrite.** Формулировки и порядок фаз ниже сохранены как свидетельство grill 2026-09-02, не как текущая спецификация реализации. Уточнения lifecycle, Pi-first, полномочий, локального LangSmith и порядка этапов: [ADR-0002 — VOCA-2](0002-conversation-lifecycle-and-roadmap.md); действующие контракты — в [архитектуре](../architecture.md) и [roadmap](../roadmap.md). - -## Context - -Voice Agent v2 (9 слайсов, 12 ADRs, full architecture doc) работает плохо: код плохо организован, over-engineering (куча контрактов, версионирования, бюрократии), стек не гибкий (зафиксированные модели, нет tool calling), куча бесполезных тестов, баги. Паша принял решение создать v3 с нуля. - -Требования к v3: -- Тонкий кастомизируемый пайплайн -- Максимально близко к реалтайм общению -- Лёгкая замена моделей (LLM/STT/TTS) — эксперименты -- S2S по модели STT → LLM with tooling → TTS -- LFM-2.5 как основная LLM (быстрая) -- Делегирование задач агентам (Hermes/Pi/OpenClaw) через custom gateway -- Barge-in, wake word (позже), echo cancellation -- Сессии с историей и контекстом общения + тулколинга -- SOUL.md/user.md/agent.md инструкции -- UI новый с нуля (после бэкенда) - -## Decision - -Создать v3 как clean rewrite. Ключевые решения: - -1. **FastRTC** (не LiveKit) — тонкий Python WebRTC transport. Причины: не нужен Go SFU для 1-пользователя, не диктует архитектуру (custom LLM loop), MIT лицензия, AEC через браузерный WebRTC. Принятые минусы: barge-in TTS cancel руками, нет session model (свой SQLite), control events через отдельный WebSocket. - -2. **LiteLLM proxy** для LLM — tracing через LangSmith, отдельные тесты LLM, доступ к модели другим агентам. Смена модели = конфиг. - -3. **audio.cpp** для TTS (TTSProvider абстракция). Возможно STT/VAD тоже — hypothesis на реализацию. STT fallback: Whisper как сервер с OpenAI-compatible API. - -4. **Custom LLM loop** в Python пайплайне — не framework. Гибрид: text = говорить, tool call = действовать, промежуточные реплики возможны. - -5. **Единый delegate tool** — `delegate(agent, task)`, LLM выбирает агента параметром, маршрутизация в реализации tool. Async streaming статусов. - -6. **SQLite** для сессий (паттерн Hermes state.db) + JSONL экспорт. - -7. **Фазы**: MVP pipeline → agent capabilities → realtime polish → UI → R&D (always-on activation, fine-tuning). - -8. **Deployment**: прямой запуск, systemd, monorepo. - -## Consequences - -- Положительные: тонкий пайплайн, полный контроль над loop, лёгкая замена моделей, независимость от LiveKit ecosystem -- Отрицательные: barge-in TTS cancel — ручной код; FastRTC молодой (0.0.x), API может меняться; нет production war stories у FastRTC -- R&D области: always-on semantic activation, fine-tuning LFM-2.5, audio.cpp для всего аудио - -## References - -- Grill session 2026-09-02 (index.md) -- FastRTC: https://github.com/gradio-app/fastrtc (MIT, 4.6k stars) -- LiteLLM, LangSmith, audio.cpp (0xShug0), LFM-2.5 \ No newline at end of file diff --git a/docs/adr/0002-conversation-lifecycle-and-roadmap.md b/docs/adr/0002-conversation-lifecycle-and-roadmap.md deleted file mode 100644 index cc467a1..0000000 --- a/docs/adr/0002-conversation-lifecycle-and-roadmap.md +++ /dev/null @@ -1,48 +0,0 @@ ---- -tags: - - voice-agent - - v3 - - adr -status: accepted ---- - -# ADR-0002: Conversation lifecycle and gated roadmap - -## Status - -Accepted as the documentary contract in [VOCA-2](https://github.com/yokeloop/voca/issues/2) and merged [PR #3](https://github.com/yokeloop/voca/pull/3), 2026-09-10. This ADR records the planned v3 contract, not runtime or experimentally verified voice behavior. [ADR-0001](0001-rewrite-v3.md) remains the historical rewrite decision of 2026-09-02; this document refines its lifecycle, deployment and stage order. - -## Context - -The initial documents listed technologies but did not define who owns a turn, when a response is cancelled, what happens to delegated work after a disconnect, or what evidence permits the next stage. They also treated a FastRTC interrupt callback, model replacement by URL and local resource fit as if these were already established. - -The target remains a thin, single-user local voice assistant. VOCA-2 supplies a verifiable architecture and roadmap without adding application code, installing products or reporting unperformed measurements. - -## Decision - -1. **One business backend.** A Python/FastAPI conversation runtime owns the active turn, context and event queue. Audio transport, provider and delegation adapters and SQLite stay inside that boundary. Inference servers, LiteLLM and self-hosted LangSmith are supporting processes, not additional business backends. -2. **Toggle, not hold.** A button enables/disables a conversation; VAD segments speech while enabled. Minimal browser UI, WebRTC/FastRTC audio and separate control WebSocket belong to the first voice stage. Browser AEC is requested, not presumed effective. -3. **Explicit lifecycle and playback.** Session/connection/conversation/turn/generation are distinct. Speech onset invalidates the old generation, cancels STT/LLM/TTS, clears backend and browser playback and immediately captures the new utterance. Late callbacks/audio are discarded. FastRTC client flush must be demonstrated early; closing a generator is insufficient. -4. **Tasks outlive voice responses.** Barge-in, stop and disconnect never abort Pi work. Reconnect restores data with the microphone off and never replays old audio. Generated text, started playback and confirmed completion are stored separately; interrupted text is not declared fully heard. -5. **Pi first, with the selected instance.** `delegate(agent, task)` initially allows only `pi`. The user chooses the instance and cwd before dispatch; a new separate Pi session inherits its configuration/tools/MCP/restrictions, not an existing conversation or a blank environment. Authority remains exactly: **«Чтение, исследование и использование тулов/mcp подключенных к конкретно этому Pi инстансу»**. No general tools/MCP prohibition, arbitrary write permission or new approval layer is introduced. RPC completion follows `agent_settled`, not initial prompt acceptance or the first `agent_end`. -6. **Durable results.** SQLite is the source of truth; JSONL is export. Persist task ID before dispatch, deduplicate originating tool calls, commit terminal result and queue entry atomically. Insert result once into the original session context and schedule a separate result turn in a speaking pause, yielding to the user. Results persist while disconnected/off. Interrupted result speech is not automatically repeated. Unknown outcome after restart never authorizes automatic re-dispatch; storage deduplication does not promise physical exactly-once listening. -7. **Local provider chain.** Start with Whisper server transcription → LiteLLM → local llama.cpp/LFM2.5 → audio.cpp/TTSProvider. No automatic cloud fallback. Serving-layer Pythonic/XML normalization, followed by loop validation, replaces model-specific parsing in the loop. Only user-facing content reaches TTS; reasoning/tool/RPC payloads do not. Provider language/streaming/cancel compatibility must be verified, not inferred from a URL. -8. **Mandatory local observability.** Self-hosted LangSmith on the same host starts with the first voice stage. Application tracing and LiteLLM use explicit local endpoints; no SaaS default or opt-in substitution. Full available voice/task/result traces retain useful content after credential redaction. Export failure is visible and uses a bounded local queue without blocking the microphone. Official entitlement, artifact access, single-host profile, license egress/offline license, RAM/CPU/storage prerequisites remain launch gates; direct application launch does not imply container-free LangSmith. -9. **Vertical stage order.** Voice conversation → context/instructions → Pi delegation → separate additional adapters, richer UI and gated semantic/model experiments. SOUL → user → agent snapshot is fixed at turn start; managed reload applies to the next turn, preserving last valid instructions on failure. Reliable interruption is not deferred to polish. MiniCPM/unified audio.cpp/always-on/fine-tuning are not prerequisites. - -## Consequences and gates - -- [Architecture](../architecture.md) is the normative contract for provider messages, state transitions, control correlation and task delivery; [glossary](../../CONTEXT.md) supplies consistent names. -- [Roadmap](../roadmap.md) ties each user outcome to dependencies, acceptance and an evidence artifact. There are no dates or speculative future tickets. -- H1 browser interruption, H2 Russian voice/shared memory and H7 local tracing are early functional gates. H5 normalized tool scenarios gate Pi. H4/H6 and [MiniCPM research](../research/minicpm5-2b.md) gate only their optional candidates. -- Approximately 500 ms speech-end → first-audible is a target to measure, not a guarantee or an existing measurement. Missing that target records a bottleneck without automatically rejecting a functionally accepted voice stage. -- Official LangSmith guidance recommends at least 16 vCPU/64 GB RAM; the target host has roughly 32 GB shared with OS/inference and RTX 4070 12 GB VRAM. Entitlement/image access and a viable supported local profile are unproven. Documentation can be accepted with these explicit blockers; the dependent voice stage cannot be declared ready by substituting another tracing product. -- Provider/RPC documentation establishes source behavior only. No runtime, inference, browser or Pi experiment was executed by VOCA-2, and no real credential was needed for source verification. - -## References - -- [VOCA-2](https://github.com/yokeloop/voca/issues/2) -- [Current architecture and source verification](../architecture.md) -- [Roadmap](../roadmap.md) -- [Historical grill and current index](../index.md) -- [Historical ADR-0001](0001-rewrite-v3.md) diff --git a/docs/adr/0003-code-and-test-foundation.md b/docs/adr/0003-code-and-test-foundation.md deleted file mode 100644 index 3e5350a..0000000 --- a/docs/adr/0003-code-and-test-foundation.md +++ /dev/null @@ -1,39 +0,0 @@ ---- -tags: - - voice-agent - - v3 - - adr -status: agreed-pending-runtime-review ---- - -# ADR-0003: Code and test foundation - -## Status - -The VOCA-13 decision is agreed; its executable result is pending review. It does not state that this PR is accepted or merged. - -## Context - -VOCA-2 defined future voice contracts but deliberately shipped no runtime. The next work needs a small executable boundary that proves source-package installation, safe local diagnostics, durable session identity and snapshot recovery without implementing synthetic voice, tasks or providers. - -## Decision - -Build one Python/FastAPI application with explicit composition, a narrow core session service, a single locked SQLite connection, strict HTTP/control WebSocket wire models and a small React diagnostic panel. Use real SQLite/TestClient workflows for persistence and recovery, focused diagnostics tests, and a manual browser walkthrough for the browser entry. - -The service/store boundary exists because current use cases need durable sessions and an adapter, not as a generic repository or Unit-of-Work framework. Lifespan owns store and logging resources. The browser owns only its socket and last applied cursor. Snapshot recovery has empty histories because no turns/messages/tasks exist yet. - -## Consequences - -Rejected approaches are: synthetic turns or scripted LLM behavior; framework/DI/plugin scaffolding; ORM, async storage, generic repository layers and future empty trees; duplicated tests across unit/API/browser; a browser-test framework before a browser risk justifies it. The cost is that VOCA-4 must add durable command dedup before it introduces side effects, and voice/hardware acceptance remains future work. - -## Rework clarification — 2026-09-11 - -The initial layout did not pass acceptance because backend/frontend responsibilities were mixed and the core session, control and panel modules accumulated unrelated work. The chosen correction is an explicit `backend/` and `frontend/`, Simple Clean dependency direction for Python, and one pages-first FSD slice for the panel. This costs more explicit module boundaries and import checks, but avoids a giant hook or transport module. DI/CQRS/plugin frameworks, empty FSD layers, generic repositories and a new browser test platform were rejected as unnecessary for the current responsibility set. The status remains `agreed-pending-runtime-review`: this clarification is neither acceptance nor merge. - -## References - -- [VOCA-13](https://github.com/yokeloop/voca/issues/13) -- [Code architecture](../code-architecture.md) -- [Testing](../testing.md) -- [Diagnostics](../diagnostics.md) -- [ADR-0002](0002-conversation-lifecycle-and-roadmap.md) diff --git a/docs/architecture.md b/docs/architecture.md deleted file mode 100644 index 5f8a16a..0000000 --- a/docs/architecture.md +++ /dev/null @@ -1,335 +0,0 @@ ---- -tags: - - voice-agent - - v3 - - architecture - - spec ---- - -# VOCA v3 — Architecture - -> Нормативная спецификация будущего голосового пути. [VOCA-13](https://github.com/yokeloop/voca/issues/13) реализует только предшествующую session/control основу; её точный subset описан в [code architecture](code-architecture.md). Voice runtime, inference и hardware/browser измерения не выполнялись. - -Навигация: [code architecture](code-architecture.md), [testing](testing.md), [diagnostics](diagnostics.md), [roadmap](roadmap.md), [glossary](../CONTEXT.md), [ADR-0002](adr/0002-conversation-lifecycle-and-roadmap.md), [ADR-0003](adr/0003-code-and-test-foundation.md), [MiniCPM research](research/minicpm5-2b.md), [исторический grill](index.md). - -## System Overview - -**Решено:** один Python/FastAPI backend, один пользователь и одна активная голосовая беседа; локальные inference endpoints без автоматического cloud fallback. **Подтверждено источником** означает только наличие описанного API/поведения в указанной версии, не работоспособность VOCA. **Не измерено на целевом хосте:** качество русского, cancellation/browser flush, latency, совместная память и размещение LangSmith. - -```text -Browser: microphone + AEC request + playback + minimal UI - | WebRTC audio | WebSocket control/status - v v -One Python/FastAPI backend - Audio transport (FastRTC, VAD) <--> Conversation runtime - | sole active turn/context/event queue owner - | custom LLM loop - +--> STT adapter --> local Whisper server - +--> LLM adapter --> local LiteLLM --> llama.cpp / LFM2.5 - +--> TTSProvider --> local audio.cpp - | | - WebRTC audio <-- backend audio queue <---------------------+ - +--> delegation adapter --> selected Pi instance - +--> SQLite store - +--> redacted trace queue --> local self-hosted LangSmith -Local LiteLLM ------------------------------------------------> same local LangSmith -``` - -Inference servers, LiteLLM и LangSmith — обслуживающие процессы, не дополнительные бизнес-бэкенды. SQLite хранит состояние VOCA; базы LangSmith обслуживают tracing, не runtime. Отдельный брокер, workflow engine и общий агентский gateway не вводятся. - -## Components - -### Audio transport and activation - -FastRTC — стартовый WebRTC transport. Browser запрашивает `getUserMedia({audio:{echoCancellation:true}})`; это запрос AEC, не гарантия отсутствия эха. VAD разделяет реплики. Кнопка включает/выключает разговор, удерживать её не нужно. Минимальный UI входит в первый этап: переключатель, transcript/assistant text, состояние соединения и разговора, видимая ошибка. Control/status идут по отдельному WebSocket, не внутри текста LLM и не через аудио. - -Transport передаёт runtime аудиофрагменты с format/sample rate, начало/конец речи и ошибки соединения. Runtime определяет turn и поколение ответа, владеет очередями и отменой; transport исполняет его команды. Реальный browser flush FastRTC ещё должен пройти раннюю проверку. - -### Provider contracts - -Все вызовы коррелируются с `session_id`, `turn_id`, `generation_id`. Адаптер объявляет capabilities: языки, форматы/частоты, streaming, cancellation и особенности модели; неподдерживаемая возможность — явная ошибка до dispatch. URL-совместимость не означает совместимость языка, модели или streaming. Никакой автоматической облачной подмены при ошибке. - -| Граница / владелец адаптации | Вход | Выход в runtime | Завершение, отмена и ошибка | -|---|---|---|---| -| STT adapter → Whisper server, OpenAI-compatible transcription API | Завершённый аудиосегмент, format/sample rate, language hint `ru` | Итоговый transcript и язык (распознанный либо явно заданный), metadata запроса | Один final либо timeout/error, пустой результат не выдумывается. Cancel закрывает запрос; поздний final старого поколения отбрасывается | -| LLM adapter → LiteLLM → локальный llama.cpp / LFM2.5 | Instruction snapshot, история, сообщения, schemas доступных tools | Раздельные потоки assistant content, reasoning, нормализованные `tool_calls`; finish reason | EOF с валидным finish либо timeout/error; cancel upstream и отсечение всех поздних deltas | -| TTSProvider → audio.cpp | Только пользовательский текст фрагментами, язык и выбранный voice/model | Последовательные audio chunks с encoding, sample rate, channels и признаком конца | Конец текста/аудио, cancel стрима, timeout/error; runtime очищает очередь, отсекает поздние chunks | -| Audio transport → Browser playback | Audio chunks активного поколения, согласованный playback boundary | Первый playback и подтверждение завершения/очистки | Сбой транспорта инвалидирует ответ; backend EOF не означает, что пользователь всё услышал | - -Стартовый STT — Whisper server с OpenAI-compatible transcription API (не обещание, что голый whisper.cpp `/inference` уже совместим). Стартовый LLM — LFM2.5 в llama.cpp через LiteLLM. Стартовый TTS — audio.cpp через `TTSProvider`; пригодный русский checkpoint ещё не выбран по измерениям. Unified STT/TTS audio.cpp — отдельный эксперимент, не prerequisite. MiniCPM5-2B — кандидат сравнения, не выбранная замена. - -Каждый адаптер до интеграции фиксирует конечные request/idle timeout и поведение при cancel, включая случай, когда сервер физически продолжает inference. Runtime никогда не ждёт освобождения старого inference для захвата следующей речи. Cancel запроса не выдаётся за подтверждённое освобождение GPU. - -### Custom LLM loop - -Runtime собирает context → вызывает LLM → принимает типизированные deltas → произносит только speakable assistant content → проверяет complete tool calls → dispatch → добавляет tool result в context → продолжает при необходимости. Для `delegate` немедленный tool result — durable task ID, не ожидание внешнего агента. - -Контракт tool call: `{"id":"call-1","function":{"name":"delegate","arguments":"{\"agent\":\"pi\",\"task\":\"Исследуй структуру проекта\"}"}}`. `function.arguments` — полная JSON-строка, разбираемая в объект по schema. Loop проверяет уникальный ID, известное имя, типы/обязательные поля и доступного агента **до** dispatch. Неполный, невалидный или неизвестный вызов не исполняется: фиксируется tool validation error. Повтор ID возвращает прежний результат dispatch. - -Pythonic LFM и XML MiniCPM разбирает serving layer llama.cpp; loop не получает новый модельный парсер. Нормализация должна быть доказана для выбранных версий server/template/model. Reasoning, tool arguments/results, RPC events и служебные маркеры никогда не направляются в TTS. Assistant content рядом с tool call допускается только как отдельная пользовательская промежуточная реплика, например «Исследую проект»; это не озвучивание сырого stream. Правила speakable content задаёт `agent.md`, а типизированная граница TTS удерживает изоляцию независимо от промпта. - -## Conversation lifecycle - -Connection (`connected`/`disconnected`), conversation (`off`/`listening`) и turn — разные оси. `connected` требует работоспособности WebRTC и control WS; потеря любого канала означает `disconnected`. `listening` означает включённый разговор, в том числе пока агент отвечает. Один активный turn: `capturing` → `transcribing` → `generating` → `speaking` → `completed`, либо `interrupted`/`failed`. LLM и TTS могут работать одновременно: `speaking` не означает конец генерации. Для ответа без звука допустим `generating` → `completed` с `playback=not_started`. - -| Переход / trigger | Владелец и ресурсы | Наблюдаемый результат | -|---|---|---| -| `disconnected/off` → `connected/off`: кнопка, доступ к микрофону и оба транспорта готовы | Browser получает media permission, устанавливает каналы, до подтверждения start не посылает речь | UI соединён; отправляет `conversation.start` | -| `off` → `listening`: start и валидные инструкции | Runtime разрешает capture; browser включает track | `conversation.state(listening)` | -| Permission denied / невалидный начальный набор инструкций | Browser освобождает track; runtime не начинает разговор | `off`, видимый `error(microphone/permission_denied)` или `error(instructions/invalid)` | -| Нет активного turn → `capturing`: VAD speech onset | Runtime создаёт turn/generation, фиксирует instruction snapshot, transport буферизует новую речь | `turn.state(capturing)` | -| `capturing` → `transcribing`: VAD speech end | Runtime завершает входной сегмент, STT adapter получает аудио | `turn.state(transcribing)` | -| `transcribing` → `generating`: STT final | Runtime сохраняет transcript, собирает context, открывает LLM stream | `transcript.final`, `turn.state(generating)` | -| `generating` → `speaking`: speakable text → TTS chunks → первый слышимый звук | Runtime ставит chunks в audio queue; browser подтверждает начало playback | Assistant text в UI, `playback.started`, `turn.state(speaking)` | -| `speaking` → `completed`: LLM и TTS закончены, playback подтверждён | Runtime закрывает streams, фиксирует terminal turn; capture остаётся доступен | `playback.finished`, `turn.state(completed)`; conversation `listening` | -| Активный turn → `interrupted`: новый speech onset | Runtime инвалидирует старое поколение, выполняет cancellation/flush ниже; сразу создаёт новый `capturing` | `turn.state(interrupted)`, `playback.clear`, затем `turn.state(capturing)` | -| `listening` → `off`: stop кнопкой | Browser сразу прекращает capture/playback; runtime прерывает активный turn, отменяет STT/LLM/TTS и очищает очереди | `conversation.state(off)`, при активном ответе `turn.state(interrupted)` и `playback.clear` | -| `connected` → `disconnected`: потеря WebRTC/WS | Browser локально останавливает звук/микрофон; runtime инвалидирует ответ, закрывает streams, оставляет tasks | UI disconnected/off; terminal `interrupted` и состояние сохраняются для snapshot | -| `disconnected` → `connected/off`: reconnect + resume | Runtime восстанавливает данные; browser создаёт пустой playback, не включает capture | `session.snapshot`, `conversation.state(off)`; включение только новой кнопкой | -| Активный turn → `failed`: ошибка provider | Runtime отменяет остаток ответа, инвалидирует generation, очищает звук | `error`, `turn.state(failed)`, `playback.clear`; conversation остаётся `listening` при живом transport | - -Сквозной happy path: нажать включение → permission/каналы → start/listening → произнести фразу → VAD end → STT final → transcript в SQLite/UI → context со snapshot → speakable assistant content → TTS fragments → transport → первый слышимый звук/`playback.started` → generation EOF + playback finished → completed → следующая реплика без удержания кнопки. - -### Cancellation and playback boundary - -Barge-in начинается на **speech onset**, не после паузы. Runtime сначала инвалидирует старый `generation_id`, затем отменяет текущие STT/LLM/TTS запросы, чистит backend audio queue и отправляет `playback.clear` со старым поколением. Новая речь захватывается немедленно, без ожидания cancel acknowledgement. Все callbacks/deltas/audio старого поколения игнорируются даже после нового turn; задачи Pi не отменяются. - -Browser должен остановить уже запланированный playback и отбросить старое аудио, включая данные в WebRTC/jitter/playback buffers, а не только очистить Python generator. `playback.cleared` подтверждает именно этот boundary. Одного control-сообщения недостаточно: требуется доказанная привязка media к generation/границе и flush downstream, либо пересоздание соответствующего output transport без потери нового input. Как это реализовать в FastRTC, **ещё не доказано**; это ранний gate, не существующий interrupt callback. До подтверждённой чистой границы новый output не выпускается; input продолжает приниматься. Timeout flush — `error(transport/flush_timeout)` и остановка output, не возобновление старого звука. Запоздалый clear/ack одного поколения не очищает и не завершает другое. - -Stop, disconnect и provider failure используют тот же boundary. Если WS уже потерян, browser сам очищает playback; runtime сохраняет invalidation, а reconnect создаёт новое пустое output-соединение. Никакое старое audio не replay-ится из истории. - -### Provider failure - -| Компонент / ошибка, включая timeout | Что сохраняется и чего не происходит | Завершение и восстановление | -|---|---|---| -| STT | Только факт ошибки и metadata сегмента; нет выдуманного transcript и вызова LLM | `failed`, error с component/code; cancel/flush, новая реплика после восстановления | -| LLM | Transcript и полученный assistant text с отметкой неполноты; неисполненные partial tools отбрасываются | `failed`; cancel TTS/flush, поздние deltas и звук отсекаются | -| TTS | Доступный assistant text остаётся в UI, без отметки «услышан» | `failed`; прекращение звука/flush и отмена остатка ответа, следующая реплика доступна | - -Ошибки не выключают исправный микрофон и не включают cloud fallback. Если транспорт тоже упал, применяется `disconnected/off`. Завершённые tool dispatch/tasks сохраняются при любой ошибке голосового ответа. - -### Instructions and history - -`SOUL.md`, `user.md`, `agent.md` собираются **в этом порядке**. Runtime фиксирует неизменяемый snapshot в начале каждого turn, включая result turn; reload влияет только на следующий turn. Ошибка чтения/валидации показывает `error(instructions/reload_failed)` и оставляет последнюю валидную версию. Без начального валидного набора start отклоняется. Начальная загрузка нужна этапу 1; управляемый reload и пользовательская приёмка истории — этапу 2. - -История различает сгенерированный assistant text, начало playback и подтверждённое завершение. Прерванный ответ сохраняется с `interrupted`, `playback=started` либо `not_started`, но не как целиком услышанный. Потерянный ack даёт неизвестную слышимость, не выдуманный `finished`. Context получает эти отметки. Backend EOF или выдача первого TTS chunk не доказывают ни начало, ни завершение слышимости. - -## Control contract - -This is the normative future protocol. VOCA-13 implements a deliberately smaller subset: HTTP session bootstrap and `session.resume` snapshot/error wire events with durable `eN` cursors; it implements neither voice commands nor future envelopes. The exact current behavior and idempotency limit are in [code architecture](code-architecture.md#implemented-control-subset). - -Это спецификация сообщений, не существующий API. Везде обязательны `type`, `session_id`, `event_id`. Server `event_id` — монотонный durable cursor внутри session; client `event_id` — уникальный command/ack ID, не двигающий server cursor. Turn-сообщения требуют `turn_id`, `generation_id`; task-сообщения — `task_id` и исходный turn. Browser дедуплицирует server events по ID, применяет их по порядку и хранит последний применённый server cursor. Повтор client command ID не повторяет действие; stop идемпотентен. Для initial connection session ID выдаётся backend при установке сессии до start. - -`session.resume.last_event_id` — последний подтверждённый применением в UI server event ID, `null` при первом входе. Resume не включает разговор. Backend выдаёт авторитетный snapshot с cursor, состояниями, историей, task cards и delivery status, затем события строго после cursor. Потерянный/слишком старый cursor восстанавливается snapshot, а не повторным dispatch. Повтор события с тем же ID не создаёт вторую UI-запись. Snapshot также позволяет восстановиться после restart без replay аудио. - -В примерах `s1/t1/g1/k1` — условные IDs. Поля в payload обязательны; `null` разрешён только там, где показан. Для session-level error `turn_id`/`generation_id` отсутствуют; для turn error обязательны. - -| Сообщение | Направление | Пример payload | -|---|---|---| -| `conversation.start` | Browser → runtime | `{"type":"conversation.start","session_id":"s1","event_id":"c1"}` | -| `conversation.stop` | Browser → runtime | `{"type":"conversation.stop","session_id":"s1","event_id":"c2"}` | -| `session.resume` | Browser → runtime | `{"type":"session.resume","session_id":"s1","event_id":"c3","last_event_id":"e8"}` | -| `session.snapshot` | Runtime → browser | `{"type":"session.snapshot","session_id":"s1","event_id":"e9","connection":"connected","conversation":"off","active_turn":null,"messages":[],"tasks":[],"deliveries":[]}` | -| `conversation.state` | Runtime → browser | `{"type":"conversation.state","session_id":"s1","event_id":"e10","state":"listening"}` | -| `turn.state` | Runtime → browser | `{"type":"turn.state","session_id":"s1","event_id":"e11","turn_id":"t1","generation_id":"g1","state":"generating"}` | -| `transcript.final` | Runtime → browser | `{"type":"transcript.final","session_id":"s1","event_id":"e12","turn_id":"t1","generation_id":"g1","language":"ru","text":"Привет"}` | -| `playback.clear` | Runtime → browser | `{"type":"playback.clear","session_id":"s1","event_id":"e13","turn_id":"t1","generation_id":"g1","reason":"barge_in"}` | -| `playback.cleared` | Browser → runtime | `{"type":"playback.cleared","session_id":"s1","event_id":"c4","turn_id":"t1","generation_id":"g1","clear_event_id":"e13"}` | -| `task.update` | Runtime → browser | `{"type":"task.update","session_id":"s1","event_id":"e14","turn_id":"t1","generation_id":"g1","task_id":"k1","state":"running","result":null}` | -| `error` | Runtime → browser (или локально в UI при потере канала) | `{"type":"error","session_id":"s1","event_id":"e15","turn_id":"t1","generation_id":"g1","component":"tts","code":"timeout","message":"Синтез недоступен"}` | - -Минимальные дополнительные сообщения для наблюдаемой текстовой истории и playback acknowledgement: - -| Сообщение | Направление | Пример payload | -|---|---|---| -| `assistant.text` | Runtime → browser | `{"type":"assistant.text","session_id":"s1","event_id":"e16","turn_id":"t1","generation_id":"g1","text":"Привет!","final":true}` | -| `playback.started` | Browser → runtime | `{"type":"playback.started","session_id":"s1","event_id":"c5","turn_id":"t1","generation_id":"g1"}` | -| `playback.finished` | Browser → runtime | `{"type":"playback.finished","session_id":"s1","event_id":"c6","turn_id":"t1","generation_id":"g1"}` | - -`assistant.text` передаёт актуальный накопленный пользовательский текст этого turn (не append delta); `final=false` — промежуточный снимок. Snapshot messages используют тот же turn/generation/text и terminal/playback status. `playback.finished` допустим только после end-of-audio marker и фактического опустошения browser playback, не временной паузы между chunks. ACK-и становятся durable playback facts и отражаются в turn state/snapshot. Local permission/connection error показывается даже без доставки серверу и не притворяется server cursor. - -## Delegation - -Внешний инструмент — `delegate(agent, task)`. В первом реализуемом наборе `agent` допускает **только `pi`**; enum строится из подключённых адаптеров, а не из списка желаемых продуктов. Пользователь до поручения выбирает конкретный экземпляр Pi и рабочий каталог. Модель задаёт только задачу; текст задания не меняет instance, cwd, конфигурацию или полномочия. - -Полномочия: **«Чтение, исследование и использование тулов/mcp подключенных к конкретно этому Pi инстансу»**. - -Первое поручение открывает **отдельную новую сессию** выбранного Pi в выбранном каталоге, не управляет его существующей беседой. Адаптер сохраняет выбранный executable/version, config root (`PI_CODING_AGENT_DIR`), пользовательское окружение запуска по ссылке, project resources и уже установленные этим экземпляром ограничения. Наследуются его tools/MCP/extensions/settings; секреты не копируются в task payload или trace. Нельзя запускать пустой Pi с потерянным окружением, подставлять конфигурацию VOCA/другого экземпляра, добавлять общий запрет tools/MCP либо разрешать произвольные изменения. VOCA не добавляет approvals и не обходит существующие ограничения. - -### Pi RPC contract and sources - -Проверено документально **2026-09-10**, установленный официальный пакет `@earendil-works/pi-coding-agent` **0.85.1**: полностью прочитаны README, `docs/rpc.md` (включая Types, Error Handling и Extension UI Protocol), `docs/settings.md`, `docs/environment-variables.md`, `docs/packages.md`; просмотрен связанный `examples/rpc-extension-ui.ts`. Официальные ссылки: [RPC](https://github.com/badlogic/pi-mono/blob/main/packages/coding-agent/docs/rpc.md), [settings](https://github.com/badlogic/pi-mono/blob/main/packages/coding-agent/docs/settings.md), [environment](https://github.com/badlogic/pi-mono/blob/main/packages/coding-agent/docs/environment-variables.md), [packages](https://github.com/badlogic/pi-mono/blob/main/packages/coding-agent/docs/packages.md). Ссылки main изменяемы; утверждения привязаны к прочитанному пакету 0.85.1. При реализации сверить документацию версии **выбранного** экземпляра заново. Никакой пользовательский Pi для проверки не запускался. - -- Запуск выбранного Pi в `--mode rpc`, stdin/stdout — JSONL с разделителем **LF**; Unicode separators внутри JSON не разделяют records, CR перед LF допустим. stderr — отдельный канал диагностики после redaction. -- Новая сессия, без `--continue`, `--resume`, `--fork` и выбора чужого session file. `get_state` возвращает `sessionId`/`sessionFile`; адаптер сохраняет их вместе с идентификатором запуска/процесса до prompt. PID сам по себе не устойчивый ID после restart. -- RPC не показывает project trust prompt; `ask`/`never` без сохранённого доверия игнорируют project resources. Поэтому совпадение конфигурации/доверия с выбранным экземпляром — preflight gate. Если это не обеспечено, видимая ошибка до запуска поручения; нельзя «исправить» это автоматическим `--approve` или `--no-tools`. -- Отправка: `{"id":"k1:prompt","type":"prompt","message":"Исследуй структуру проекта"}`. Адаптер отображает request ID → `task_id=k1`. `response(success=true)` означает **принятие**, не окончание. `success=false` до принятия — known failed; после принятия ошибки приходят events/messages. -- Agent events обычно не содержат request ID. Отдельный процесс/сессия с одним outstanding поручением даёт однозначное process/session → task соответствие. `message_update` содержит раздельные text/thinking/toolcall deltas; `message_end.message` — авторитетный результат сборки. `tool_execution_start/update/end` коррелируются через `toolCallId`, `partialResult` — накопленный snapshot, не append delta. Доступные MCP события видны как события tools/extension, а не гарантированная внутренняя трасса MCP-сервера. -- `agent_end` завершает низкоуровневый run, после него возможны retry, compaction и queued continuations. Окончание поручения определяется **`agent_settled`**, после которого анализируются окончательные messages/error/stopReason. `agent_settled` сам по себе не означает success: `error`/`aborted` → failed, валидный финальный результат → succeeded; неоднозначное завершение без результата — видимая ошибка протокола, не пустой success. Промежуточный tool error может быть обработан агентом и не равен провалу всей задачи. -- `extension_error`, auto-retry status и tool errors сохраняются/показываются. Штатный `extension_ui_request` передаётся пользователю и ответ возвращается по его ID, если этого требует существующая конфигурация. Это отображение уже существующего взаимодействия Pi, не новый слой approvals; никакого автоматического «да». TUI-only extension без RPC-эквивалента — явный blocker совместимости выбранного instance. -- RPC `abort` отменяет операцию Pi и ждёт idle; voice barge-in/stop/disconnect **не посылают abort** и не завершают процесс. Отдельная отмена поручения не входит в первый voice-контракт. - -Hermes и OpenClaw — будущие отдельные адаптеры, а не взаимозаменяемый готовый protocol: Hermes runs/events SSE/stop/approval и OpenClaw WS agent/runId/agent.wait требуют собственных mappings и проверок. - -## Session Store - -SQLite — единственный источник истины VOCA; JSONL — экспорт, не синхронная вторая база. Не SQL-миграция, а обязательные связи: - -| Запись | Связи / уникальности | -|---|---| -| sessions | Уникальный `session_id`, текущий server event cursor | -| turns | Уникальный `turn_id`, FK session, instruction snapshot, generation и terminal state; result turn с FK task | -| instruction snapshots | Уникальный snapshot ID/version, порядок SOUL → user → agent, валидное содержимое; turn ссылается на неизменяемую версию | -| messages | FK session/turn, role/text, generated/interrupted/error и playback facts отдельно; task result уникален по task ID | -| tool calls | FK session/turn, уникальность `(session_id, tool_call_id)`, нормализованные arguments, dispatch outcome/task ID | -| tasks | Уникальный task ID, FK originating session/tool call, уникальность originating tool call; выбранный instance/cwd, process/session identity, state, result/error | -| result delivery / events | Уникальная запись результата и постановки в conversation queue по task ID; event ID уникален в session; context insertion и result turn claim отмечаются durable | - -До внешнего dispatch одна транзакция сохраняет tool call, `task_id`, `queued` и немедленный tool result с этим ID. Перед возможной отправкой prompt сохраняется launch intent/`running`: crash в неопределённом окне не разрешает повторный prompt. Повтор того же tool call возвращает прежний task ID. Только очередь, о которой известно, что launch intent ещё не было, допускает первый запуск. - -Терминальный task result и запись о его постановке в conversation queue сохраняются **атомарно**. Consumer добавляет результат в историю/context ровно один раз по task ID и отмечает это в той же транзакции; повтор terminal event — no-op, конфликтующий terminal result — видимая protocol error, не вторая запись. Runtime восстанавливает pending deliveries из SQLite после restart. Result turn claim сохраняется до начала речи; при потере процесса claimed turn не проигрывается автоматически повторно. Нельзя потерять durable result из-за выключенного микрофона или browser disconnect. - -## Task lifecycle - -| Переход | Trigger / владелец | Durable результат и UI | -|---|---|---| -| → `queued` | Валидный delegate, runtime transaction | Task ID возвращён loop немедленно, `task.update(queued)` | -| `queued` → `running` | Adapter фиксирует launch intent, запускает отдельный Pi и prompt | Process/session mapping, `task.update(running)`; микрофон не блокируется | -| `running` → `succeeded` | `agent_settled` + валидный окончательный результат | Result + delivery атомарно, `task.update(succeeded, result)` | -| `queued/running` → `failed` | Известный отказ запуска/принятия или подтверждённая terminal ошибка | Error + terminal delivery атомарно, `task.update(failed)` | -| `running` → `unknown` | Потеря процесса/канала, restart с неопределённым outcome | UI сообщает неизвестный исход, task ID сохраняется; **нет автоматического повтора** | -| `unknown` → `succeeded/failed` | Надёжное восстановление исхода той же сессии, не новый dispatch | Обычная дедуплицированная terminal transaction; иначе остаётся unknown | - -Task card обновляется сразу после durable result, если браузер подключён, иначе при snapshot. Результат один раз входит в context **исходной сессии**. Runtime ставит отдельный result turn в свою очередь, не вклинивая речь в пользовательский turn. Пользовательский speech onset имеет приоритет. Если разговор выключен/браузер отключён, озвучивание pending result ждёт следующего включения этой сессии, не другой активной сессии. Если result turn уже начал выполняться и был прерван barge-in/stop/disconnect, автоматического переозвучивания нет: результат остаётся в истории/card и доступен по запросу. Exactly-once относится к записи и постановке, **не** к физическому прослушиванию при потере связи. - -### Task walkthroughs - -1. **Поручение → продолжение разговора → late result.** `delegate(pi, research)` → SQLite k1 queued → немедленный tool result k1 → Pi running. Следующий голосовой turn идёт независимо. Settled/result → atomic result+delivery → task card → context insertion → после пользовательского turn отдельная краткая пользовательская реплика result turn → TTS. Сырой RPC/tool result в TTS не идёт. -2. **Barge-in во время поручения.** Voice g1 инвалидирован, audio flushed, g2 capturing; k1 остаётся running в своём процессе. Result приходит независимо, ждёт свободного conversation slot. Barge-in result turn прекращает звук и не ставит его назад в очередь. -3. **Результат без браузера → reconnect.** Conversation off, k1 завершается, result+pending delivery сохранены. Resume даёт snapshot/card/history без микрофона и старого аудио. Новая кнопка разрешает ещё не начатый result turn в паузе, с приоритетом речи пользователя. -4. **Restart backend при неизвестном исходе.** Незавершённый launch intent/running → unknown, показываем k1 и выбранную Pi session. Не запускаем новый процесс с тем же поручением. Только подтверждение исхода прежней сессии может закрыть unknown; иначе нужно решение пользователя, а не автоматический retry. -5. **Дубликаты.** Второй `tool_call_id` с тем же исходным ID возвращает k1; повтор `agent_settled`/terminal result не создаёт новую delivery/message/result turn. После restart pending delivery продолжает потребление, а не новый Pi dispatch; уже claimed/interrupted playback не возобновляется. - -## Observability and self-hosted LangSmith - -LangSmith обязателен **с первого голосового вертикального этапа**, self-hosted **на той же машине**, не opt-in и не SaaS. Приложение и LiteLLM явно получают локальный endpoint; отсутствие/невалидность endpoint — видимая configuration error, а не использование SDK SaaS default. Trace exporter не создаётся с неявным cloud URL. Это не равнозначно отсутствию license egress самого продукта. - -### Official requirements and installation blockers - -Проверка источников: **2026-09-10**, документальная, без установки, токенов и покупки лицензии. В таблице каждое утверждение ограничено указанным источником; изменяемые страницы не являются зафиксированной версией будущей установки. - -| Предмет | Официальный источник (проверен 2026-09-10) | Подтверждено / предел подтверждения | -|---|---|---| -| License / entitlement | [Self-hosted LangSmith](https://docs.langchain.com/langsmith/self-hosted), [pricing](https://www.langchain.com/pricing) как ссылка из руководства | Self-host — add-on Enterprise; trial/license key через sales. Наличие entitlement у пользователя **не подтверждено**, публичные docs не дают лицензию | -| Доступные artifacts | [Официальный Helm repository](https://github.com/langchain-ai/helm), [chart](https://github.com/langchain-ai/helm/blob/03818bf6b3caf842869ad1ccda1dd6dc0e6e2620/charts/langsmith/Chart.yaml) | Chart/config доступны публично; на прочитанном commit chart `0.17.0-rc.25`, app `0.17.20rc1` — snapshot источника, **не выбор production версии**. Pull образов и доступ по лицензии на хосте не проверялись; будущая установка фиксирует stable release и image digests | -| Single-host способ | [Kubernetes guide](https://docs.langchain.com/langsmith/kubernetes), [старый Docker URL](https://docs.langchain.com/langsmith/docker) | Подтверждён Helm/Kubernetes; Minikube/Kind тестируются **для development**, поэтому локальный single-host dev путь документирован. Docker URL сейчас отдаёт общий self-host guide с переходом к Kubernetes, не отдельную поддерживаемую Compose-инструкцию. Production single-host и native/systemd LangSmith **не подтверждены** | -| Сервисы | [Состав установки](https://docs.langchain.com/langsmith/self-hosted) | Frontend, backend API, platform backend, queue, Playground, ACE; PostgreSQL (operational data), ClickHouse (traces/feedback), Redis (queue/cache). Blob storage опционален, рекомендован для production. VOCA SQLite их не заменяет | -| Версии инфраструктуры | [Dependency versions](https://docs.langchain.com/langsmith/self-host-dependency-versions) | PostgreSQL ≥14, Redis ≥6.2 (либо Valkey ≥8), ClickHouse не ниже версии выбранного chart; Helm ≥3, поддерживаемый upstream Kubernetes. Это требования продукта, не установленный набор VOCA | -| CPU/RAM | [Kubernetes prerequisites](https://docs.langchain.com/langsmith/kubernetes#kubernetes-cluster-requirements) | Рекомендуются ≥16 vCPU, 64 GB memory available. Default ClickHouse требует node с ≥4 vCPU / 16 GB **allocatable**. Это опубликованная рекомендация и default request, не доказанный минимум для одного пользователя | -| Storage | [Kubernetes prerequisites](https://docs.langchain.com/langsmith/kubernetes#kubernetes-cluster-requirements), [chart values](https://github.com/langchain-ai/helm/blob/03818bf6b3caf842869ad1ccda1dd6dc0e6e2620/charts/langsmith/values.yaml) | Для in-cluster DB требуются PV/dynamic provisioner; рекомендованы SSD, 7000 IOPS / 1000 MiB/s, расширение томов и backups. Универсальная достаточная ёмкость для VOCA не опубликована: зависит от payload/retention/rate; свободное место и throughput хоста не измерены | -| Network/license | [Egress](https://docs.langchain.com/langsmith/self-host-egress) | Для ≥0.9.0 без offline license обязателен HTTPS к `beacon.langchain.com`: проверка лицензии и usage billing. Offline/air-gapped license согласуется с sales, не получается выключением флага. Operational telemetry с 0.11 и usage telemetry включены по умолчанию, отдельно отключаемы; это не отключает billing | -| Локальный API | [Self-host usage](https://docs.langchain.com/langsmith/self-host-usage) | UI `http(s)://`, API `http(s):///api/v1`; `LANGSMITH_ENDPOINT` либо SDK `api_url` задаются явно. Конкретные URL/auth VOCA ещё не настроены и в документ не копируются | - -Целевой i5 (6 cores/12 threads) и около 32 GB RAM не соответствуют опубликованной рекомендации 16 vCPU/64 GB **даже до** ОС/inference. Hyperthreads не выдаются за свободные vCPU Kubernetes. RTX 4070 12 GB отдельно разделяется между STT/LLM/TTS. Нельзя объявлять этот host достаточным по уменьшенным на глаз requests или наличию GPU. - -**Блокеры начала зависимой реализации этапа 1:** (1) подтвердить Enterprise self-host/trial entitlement и доступ к образам выбранного release; (2) согласовать документированный single-host dev профиль Minikube/Kind либо получить подтверждение другого поддерживаемого профиля, затем доказать совместное размещение в 32 GB RAM с inference; (3) выделить persistent storage/retention budget и подтвердить его ресурсно; (4) выбрать разрешённый license egress или получить offline entitlement. До этого этап не объявляется готовым. Документация VOCA-2 завершима с этими блокерами; LangSmith не заменяется продуктом/облаком. Ничего из перечисленного не установлено этим тикетом. - -При будущем развёртывании отключаются необязательные operational/usage exports; VOCA traces остаются локальными. Не включать Engine, отправляющий содержимое во внешнюю LangSmith Intelligence, или cloud inference в Playground/других дополнительных функциях. Сетевые/license требования не скрывать под словом «локально». - -### Trace map - -Один voice turn — root run с `session_id`, `turn_id`, `generation_id`, trace/run IDs; task сохраняет исходные IDs и `task_id`, отдельный долгоживущий task run связан с originating trace, result turn ссылается на task run и исходную session. После завершения голосового trace связь с late result не теряется. - -| Участок | Содержимое / наблюдаемая граница | -|---|---| -| Browser/transport/VAD | Capture start/end, speech onset/end, AEC request/settings, timestamps и clock domain, transport delay; browser audio boundary и ACK | -| STT | Input segment metadata и доступный локальный audio reference, language, redacted transcript, provider/model/version, timings/errors | -| Context | Порядок и snapshot version инструкций, redacted assembled messages/history и отметки interrupted/playback | -| LLM/LiteLLM | Redacted input/output, assistant content **отдельно** от reasoning, нормализованные tool calls, finish reason и first speakable text timing | -| Dispatch/Pi | Validated call, task ID, selected instance identity без credentials, prompt acceptance, доступные RPC/tool/MCP progress/errors/result, settled и durable delivery | -| TTS/playback | Пользовательский TTS input, output metadata (encoding/rate/chunks), first audio/first audible, queue depth, end marker, playback started/finished/cleared | -| Runtime | Queue enqueue/dequeue/claim, invalidation/cancel request/ack, отброшенные late events, disconnect/reconnect/snapshot, component errors | - -LiteLLM callback покрывает LLM, **не всю трассу**. Приложение инструментирует остальные участки и связывает их IDs. Внутренности Pi/MCP, отсутствующие в их публичных events, отмечаются unavailable, а не придумываются. Для межпроцессных времён сохраняются clock domain/offset и погрешность; first audible фиксируется клиентом, не серверным timestamp выдачи PCM. - -### Content, redaction and failure policy - -Полезные inputs/outputs сохраняются для исследования, но это чувствительные тексты/результаты даже после redaction. Credentials удаляются **до** записи в экспортную очередь/trace: Authorization/Cookie headers, API keys, tokens, секреты в tool payloads/errors и свободном тексте. Не дампить окружение, auth configs, полные header maps. Идентификаторы/версии вместо содержимого конфигураций авторизации. Неизвестный неочищаемый payload не экспортируется; видимый redaction failure обозначает пропуск, а не молчаливую утечку. - -Будущий redaction gate: синтетический маркер `VOCA_SYNTHETIC_SECRET` в заголовках, nested arguments/results и error strings; в локальной trace/очереди/повторной доставке маркера нет, полезный безопасный текст и IDs остаются. Реальные секреты для проверки не используются. - -Недоступный LangSmith вызывает видимый `error(tracing/unavailable)` и запись в **ограниченную локальную очередь** повторной доставки (ограничения числа/байтов/возраста фиксируются до испытаний, с проверкой переполнения). Network export асинхронен и не блокирует микрофон. При переполнении/исчерпании retries фиксируются dropped count, затронутые event/run IDs и видимое tracing degraded; восстановление не скрывает потерянные spans. Повторная отправка использует прежние run/event IDs. Никакого SaaS fallback. Приложение может функционально продолжать разговор, но **неполная трасса не проходит gate наблюдаемости**. - -## Hypotheses and verification protocols - -Ниже — **будущие** испытания, не результаты VOCA-2. Каждый запуск записывает дату, host/driver/browser, версии/commits server и adapters, checkpoint/hash/quantization, prompt/snapshot/corpus revision, generation settings, timeout и очередь, cold/warm условия. Артефакт — таблица по каждой пробе + redacted trace IDs и необходимые audio/JSON samples; агрегат без исходных проб недостаточен. До испытаний фиксируются входы и критерии, после провала они не ослабляются. - -### H1 — Browser barge-in - -**Факт/источник:** проверено 2026-09-10: FastRTC [`f9395ea2`, v0.0.34](https://github.com/gradio-app/fastrtc/blob/f9395ea2/pyproject.toml); [`ReplyOnPause.__init__`](https://github.com/gradio-app/fastrtc/blob/f9395ea2/backend/fastrtc/reply_on_pause.py#L114-L130) не имеет отдельного interrupt callback; [`receive`](https://github.com/gradio-app/fastrtc/blob/f9395ea2/backend/fastrtc/reply_on_pause.py#L277-L297) закрывает generator/очищает очередь после `pause_detected`. Default `audio_chunk_duration=0.6` — окно обработки, не измеренная latency VOCA. [Issue #341](https://github.com/gradio-app/fastrtc/issues/341) описывает **Twilio**, версию 0.0.26, а не доказанный browser flush. - -**Не измерено:** speech-onset cancellation, WebRTC/browser buffers и echo в реальном VOCA. **Установка/вход:** минимальный browser UI, текущий локальный стек и tracing; длинный streaming ответ прервать короткой и длинной русской фразой в начале/середине/конце playback, повторить перебивание трижды подряд; проверить наушники и динамики/AEC, stop/reconnect и поздние callbacks. После этапа 3 повторить с работающим Pi поручением. - -**Метрики/артефакт:** speech onset → generation invalidation → cancel request/ack → последний слышимый старый сэмпл, отметки backend/browser queue и clear ACK, сохранённый новый transcript; timeline + loopback/аудиозапись и trace. **Успех:** старый звук реально прекращается без возобновления после clear, новая фраза не теряется, новый capture не ждёт старый inference, echo не порождает ложные turns; Pi task продолжает работать. **Решение:** успех допускает голосовой этап; провал блокирует его до устранения transport/cancellation разрыва. Закрытие Python generator без client boundary недостаточно; устойчивое перебивание не откладывается до polish. - -### H2 — Russian voice and shared memory - -**Факт/источник:** локальный Whisper server — выбранный стартовый STT, но checkpoint/server version ещё не зафиксированы и качество не доказано. [audio.cpp README `3174e6b2`](https://github.com/0xShug0/audio.cpp/blob/3174e6b2/README.md#supported-models) перечисляет языки/streaming по моделям, в частности русский у ряда TTS; это сведения upstream, не прослушивание VOCA. LangSmith budget — [официальные требования выше](#official-requirements-and-installation-blockers). - -**Не измерено:** русский STT/TTS и ресурсы при одновременной резидентности. **Установка/вход:** одинаковый заранее сохранённый корпус не менее 20 русских реплик (имена «Паша», «Белград», числа/даты «двадцать три», «10 сентября», короткие/длинные паузы, вопросы, промежуточное «Исследую проект»), каждый трижды cold и warm. STT/LLM/TTS остаются одновременно резидентными; LangSmith и ОС работают рядом. TTS отдельно получает эти классы текста; в LLM-ответах есть reasoning/tool payload, который не должен попасть в звук. - -**Метрики/артефакт:** transcript/reference, WER и ошибки имён/чисел/смысла; слепое прослушивание на разборчивость и правильность русских слов, пропуски/артефакты; first TTS audio/first audible, peak VRAM/RAM, CPU, OOM и очередь по пробам. **Успех:** на приёмочном корпусе сохранены смысл, имена и числа, все фразы разборчивы, нет служебного текста в TTS, OOM или бесконечного роста очередей. **Решение:** успех допускает этап 1; иначе остановить голосовой gate, локализовать STT/checkpoint/TTS/resource проблему и повторить тот же корпус. Нельзя объявить английский demo подтверждением русского или lazy load освобождением VRAM. - -### H3 — Latency - -**Факт/источник:** около **500 мс** — цель проекта из [исторического grill](index.md), не опубликованная гарантия FastRTC и не измерение. **Не измерено:** end-of-speech → first audible на этом хосте. - -**Установка/вход:** корпус H2, те же версии и совместная нагрузка; отдельно cold start и warm, short/long context, ответ с промежуточной speakable репликой и tool call. **Метрики:** VAD/end-of-turn delay, STT duration, LLM first **speakable text** (не первый reasoning token), TTS first chunk, transport/browser playback. Артефакт содержит p50/p95 end-of-speech → first audible и каждого сегмента, число проб и clock uncertainty; параллельные интервалы не суммируются как последовательные. - -**Критерий:** warm p50 около 500 мс или меньше — достижение центральной цели; p95 и cold публикуются отдельно, без сокрытия хвостов и без заявления о гарантии каждого ответа. **Решение:** успех подтверждает цель только для записанных условий; превышение означает «цель не достигнута», trace локализует bottleneck. Это не обязательная смена всего стека и **не отменяет функционально принятый этап 1**, если H1/H2/наблюдаемость проходят. До измерений цель остаётся непроверенной. - -### H4 — Unified audio.cpp (optional) - -**Факт/источник:** [server README `3174e6b2`](https://github.com/0xShug0/audio.cpp/blob/3174e6b2/app/server/README.md) описывает `/v1/audio/transcriptions`, `/v1/audio/speech`; PCM chunked/SSE для **streaming-capable** моделей в streaming mode. Transcription SSE загруженного файла не равно live input. [Lazy loading](https://github.com/0xShug0/audio.cpp/blob/3174e6b2/README.md#server) сохраняет загруженную модель до выхода, если residency отдельно не ограничена; сам lazy load не освобождает VRAM. - -**Не измерено:** unified STT/TTS на русском, cancellation и shared residency. **Установка/вход:** после базовой цепочки заменить только STT adapter в отдельной конфигурации опыта, повторить H1/H2/H3 с зафиксированными ASR/TTS моделями audio.cpp, чередовать запросы и отмены, наблюдать VRAM до/после lazy load. **Метрики/артефакт:** transcript/voice quality, streaming EOF/errors/cancel timeline, cold/warm first audio, peak memory и OOM, сравнение с Whisper baseline. - -**Успех:** не хуже baseline по приёмочному русскому корпусу, проходит flush/error/resource checks. **Решение:** успех лишь допускает отдельное решение о unified adapter; провал оставляет Whisper server. Эксперимент не prerequisite этапов 1–3. - -### H5 — LFM/MiniCPM and tool calling - -**Факт/источник:** проверено 2026-09-10: llama.cpp `c32d1dabe819002ca8aa3a885aca4057f5968e3d`, [`common/chat.cpp:1135–1144`](https://github.com/ggml-org/llama.cpp/blob/c32d1dabe819002ca8aa3a885aca4057f5968e3d/common/chat.cpp#L1135-L1144) выбирает LFM parser, [1186–1191](https://github.com/ggml-org/llama.cpp/blob/c32d1dabe819002ca8aa3a885aca4057f5968e3d/common/chat.cpp#L1186-L1191) — MiniCPM5 XML parser. Реализации [LFM Python-style](https://github.com/ggml-org/llama.cpp/blob/c32d1dabe819002ca8aa3a885aca4057f5968e3d/common/parsers/lfm2.cpp) и [MiniCPM XML](https://github.com/ggml-org/llama.cpp/blob/c32d1dabe819002ca8aa3a885aca4057f5968e3d/common/parsers/minicpm5.cpp) подтверждают serving-layer parsing. Это не проверка end-to-end deltas через LiteLLM. [Research с официальным GGUF](research/minicpm5-2b.md) отделяет model card от VOCA measurements. - -**Не измерено:** tool correctness, русский, reasoning isolation, latency/VRAM обеих моделей. **Установка/вход:** один корпус H2, одинаковые SOUL/user/agent snapshots и schema `delegate`, один llama.cpp/LiteLLM, сопоставимая quantization и одинаковые limits; отдельно документировать неизбежные различия templates/thinking. Сценарии: обычный вопрос без delegate; явное исследование → `delegate(pi, task)`; промежуточная реплика + call; неизвестный агент; malformed/truncated arguments; duplicate ID; reasoning рядом с text. Провести каждый трижды, сохранить как server deltas, так и normalized выход adapter (redacted). Реальное Pi поручение — только в приёмке этапа 3. - -**Метрики/артефакт:** валидность `id/function.name/function.arguments`, правильное действие/отказ, аргументы и количество dispatch, speakable текст без reasoning, first audible p50/p95, peak VRAM/RAM. **Успех:** все обязательные сценарии дают верное действие/отказ, invalid/duplicate не вызывают второй dispatch, reasoning/service text не озвучивается, H2 resource/voice проходит. **Решение:** gate LFM проходит → допускается Pi-этап; провал блокирует Pi до исправления server/template/provider integration или качества модели, **не порождает парсер в loop**. MiniCPM при успехе допускается к выбору по сравнительным артефактам, не автоматически становится baseline; при провале остаётся кандидатом. Рекламный benchmark ничего не переключает. - -### H6 — Deferred semantic activation, turn detection and fine-tuning - -**Источник/статус:** гипотезы grill 2026-09-02 и [ADR-0001](adr/0001-rewrite-v3.md), не внешний benchmark, checkpoint/реализации пока нет. Число **200–400 мс** для VAD → лёгкий STT → semantic gate — прежняя оценка, **не измерено**; необходимость fine-tuning не установлена. - -| Кандидат | Будущий сценарий и артефакт | Критерий допуска / решение | -|---|---|---| -| Always-on semantic activation | После принятой Pi-делегации: размеченный набор прямых обращений, фонового разговора, TV/эхо и тишины, один и тот же набор для baseline; confusion matrix false/missed activation, onset → decision p50/p95 и сохранность начала фразы | До опыта фиксируются допустимые false/missed rates и latency budget пользователем. Успех допускает opt-in эксперимент; провал оставляет кнопку + VAD. 200–400 мс не считается достигнутым без измерений | -| Semantic turn detection | Русские паузы внутри фразы, перечисления/числа, заминки и законченные фразы из размеченного корпуса; сравнение с VAD: premature cut, пропущенный конец и delay p50/p95, запись/trace | Не терять слова/смысл, не ухудшить ошибки относительно VAD, показать улучшение end-of-turn delay на том же корпусе. Успех допускает отдельное включение; провал оставляет VAD | -| Fine-tuning | Сначала зафиксировать воспроизводимые провалы исходной LFM с prompts на H2/H5; отдельный held-out корпус, baseline vs tuned checkpoint, ошибки tools/русского/instruction following, H3 и память | Только улучшение целевых ошибок без регрессии обязательных H2/H5 и resource gates допускает tuned model. Нет доказанных baseline failures — fine-tuning не нужен; провал опыта оставляет текущую модель | - -H6 относится к этапу 4; не блокирует этапы 1–3. Для любого кандидата сначала определяется версия, набор входов и критерии; до этого эксперимент не допускается к продукту. - -### H7 — Tracing and LangSmith gate - -**Факт/источник:** [официальные требования и неизвестные предпосылки](#official-requirements-and-installation-blockers); сам факт документации не доказывает установку. **Будущая проверка:** после entitlement/profile/resource preflight выполнить один полный voice turn и failure/barge-in, затем на этапе 3 late Pi result при отключённом браузере. Сопоставить trace map с runtime events, выключить endpoint и исчерпать ограниченную очередь, восстановить его; добавить только синтетический secret marker. - -**Артефакт/метрики:** локальные run IDs, карта span coverage, export attempts/dropped counts, queue bounds, redaction поиск, одновременная RAM/CPU/storage нагрузка H2. **Успех:** весь доступный путь коррелируется, секретного маркера нет, outage/overflow видны, микрофон не блокируется export, нет внешних trace/inference endpoints. **Решение:** успех допускает наблюдаемость соответствующего этапа; отсутствующая предпосылка, неполная trace или hidden loss блокирует его приёмку. Не переключать продукт и не откладывать tracing на opt-in. - -## Roadmap - -Вертикальный порядок и пользовательская приёмка: [голосовой разговор → контекст/инструкции → Pi → остальные возможности](roadmap.md). Gates H1/H2/H7 проверяются в начале голосового этапа, H5 перед Pi; H3 — отдельная измеряемая цель, H4/H6 — необязательные эксперименты. Старый порядок с interrupt после MVP не действует. - -## Deployment - -Приложение предполагает прямой запуск на хосте; systemd возможен при будущей реализации. Это не обещание «всё без контейнеров»: официальный способ установки self-hosted LangSmith проверяется отдельно. Целевой бюджет: i5-10600KF, RTX 4070 **12 GB VRAM**, около **32 GB RAM** на ОС, runtime, одновременно резидентные STT/LLM/TTS и LangSmith вместе. Достаточность бюджета не установлена. - -## Out of scope - -VOCA-2 не создаёт runtime, UI, migrations, configs, services или тестовую инфраструктуру; не запускает эксперименты и не устанавливает модели/LangSmith. Ранние этапы v3 исключают multi-user, telephony, kiosk и proactivity. Cloud inference/fallback не входит в выбранную цепочку. Режим always-on, дополнительные агенты и замена моделей не prerequisite базового разговора. diff --git a/docs/code-architecture.md b/docs/code-architecture.md deleted file mode 100644 index 7f9d28f..0000000 --- a/docs/code-architecture.md +++ /dev/null @@ -1,41 +0,0 @@ -# Code architecture - -VOCA-13 is a persisted-session foundation, not a voice runtime. The repository explicitly separates `backend/` and `frontend/`. The [Clean Architecture dependency rule](https://blog.cleancoder.com/uncle-bob/2012/08/13/the-clean-architecture.html) here means source dependencies point inward and boundaries use simple data; it does not require a framework or a fixed number of classes. - -## Backend - -```text -bootstrap/app -> presentation/http, presentation/control -bootstrap/app -> application/sessions -> application/ports, domain/session -bootstrap/app -> infrastructure/sqlite -> application/ports, domain/session -bootstrap/app -> infrastructure/observability -``` - -`backend/src/voca/domain/session.py` is the immutable session entity. `application/ports.py` contains the session-store and narrow diagnostics protocols; `application/sessions.py` owns create/get/resume/error use cases and their session-operation scopes. The presentation layer validates wire values once in `schemas.py`, maps HTTP in `http.py`, binds sequential WebSocket commands in `control.py`, and keeps payload/timeout helpers in `responses.py`. It never reads a store directly. - -`infrastructure/sqlite.py` owns one connection, `RLock`, `BEGIN IMMEDIATE`, rollback, FULL synchronous mode and error translation; `sqlite_schema.py` owns version-1 schema validation. Observability configuration is separate from concrete diagnostics. Lifespan opens/closes concrete resources; `create_app` opens none. - -HTTP is `POST /api/sessions` with strict `{}`, `201`, `Cache-Control: no-store`, and safe 400/415/503/500 mappings. WS validates canonical UUID/envelopes and gives snapshot-only recovery. `session.resume` decodes `eN` once, allocates one durable response cursor, and rejects a future cursor with that same allocated event; no replay, ledger or retry exists. Sends and closes share a five-second bound; cancellation does bounded cleanup then propagates. - -## Frontend - -`frontend/src/app/App.tsx` renders only the public API of `pages/session-console`. The page owns one real slice: `api` contains HTTP/protocol/the sole WebSocket owner, `model` contains storage, projection and React orchestration, `ui` contains JSX, and `lib` contains identifiers. This is [pages-first FSD](https://feature-sliced.design/docs/guides/migration/from-custom): no empty shared/entities/features/widgets/processes layers, no wildcard barrel, and external code enters only `pages/session-console/index.ts` ([layers](https://feature-sliced.design/docs/reference/layers), [slices and segments](https://feature-sliced.design/docs/reference/slices-segments), [public API](https://feature-sliced.design/docs/reference/public-api)). - -The connection object fences replaced sockets before closing them. Requested restore cursor is never the projection watermark. A projection stores its session, last **applied** event cursor, snapshot and error; it compares cursors as `BigInt`, ignores foreign/duplicate/older events, and preserves a snapshot when an error arrives. A valid future requested cursor may yield an applied `invalid_cursor` event; a later null restore starts a fresh projection. Storage retains only `{session_id,last_event_id}`, safely clears invalid entries, and does not block a current session when unavailable. - -## Queue map - -| Issue | Future placement and preserved proof | -|---|---| -| VOCA-4 | domain/application conversation and instructions; infrastructure audio/providers/tracing; presentation control/audio; frontend page-local media. Runtime/cancellation, LangSmith and browser gates remain separate. | -| VOCA-5 | domain/application history/instructions; infrastructure SQLite/JSONL; page projection. Snapshot and playback facts remain durable. | -| VOCA-6 | domain/application delegation rules/use cases; infrastructure Pi/SQLite; presentation control; page task projection. Atomic delivery and independent task cancellation remain required. | -| VOCA-7 | infrastructure Hermes adapter only; its SSE/approval/terminal mapping preserves task invariants. | -| VOCA-8 | infrastructure OpenClaw adapter only; its WS/runId/wait recovery remains distinct. | -| VOCA-9 | `frontend/src` page-local control/task projection; entities/features appear only when a real responsibility justifies them. | -| VOCA-10 | domain/application conversation end-of-turn use cases and provider boundary; H6 corpus remains the evidence. | -| VOCA-11 | application activation before capture, presentation/browser boundary; false/missed/latency gate remains separate. | -| VOCA-12 | infrastructure additional STT adapter/config; Whisper baseline and H1/H2/H3 checks remain required. | -| VOCA-1 | application provider use cases and infrastructure LLM adapter/config; normalized calls and H1/H2/H7/H3 evidence remain required. | - -These are placement plans, not files created by this PR. diff --git a/docs/diagnostics.md b/docs/diagnostics.md deleted file mode 100644 index d53304e..0000000 --- a/docs/diagnostics.md +++ /dev/null @@ -1,20 +0,0 @@ -# Local diagnostics - -VOCA-13 emits bounded local JSONL metadata, not LangSmith tracing. `bootstrap/settings.py` reads database/log paths and levels. `infrastructure/observability/logging.py` configures the concrete handler; `diagnostics.py` provides operation scopes. Application use cases own their own session scopes, while presentation owns transport scopes. This keeps instrumentation independent of logging factories. - -Records use an allowlist: timestamp, level, static event/component/outcome, operation/session/command/server-event IDs, duration and safe reason. No raw frames, request headers, paths, environment, SQL parameters, exception rendering or payloads are collected. INFO records normal work; WARNING covers rejected commands, timeout and cancellation; ERROR covers failures. A handler rotates at 262144 bytes with three backups, limits a record to 2048 bytes, and emits exactly one safe degraded warning on write failure before dropping later records. SQLite continues when logs are degraded. - -The future voice/task/tracing map remains in [architecture](architecture.md): local logs, SQLite facts and future LangSmith traces are distinct. Cancellation and timeout remain terminal outcomes with explicit operation/session correlation; local diagnostics neither satisfy nor weaken the future LangSmith gate. - -## Future diagnostic map - -| Area | Safe metadata and distinct responsibility | -|---|---| -| capture, transport, activation and end-of-turn | start/end/error/cancel/timeout, session/turn/generation IDs and clock domain; no audio or transcript payload | -| STT, instruction/context, LLM and tool validation | lifecycle, IDs, provider outcome and safe reason; never raw prompt, content or credentials | -| TTS, backend queue, browser playback and clear acknowledgement | lifecycle, generation, queue depth/drop reason and browser clock domain; server output does not claim audible playback | -| task dispatch, progress, settled, unknown and recovery | task/session/turn IDs and durable state transitions; task cancellation remains separate from voice cancellation | -| delivery enqueue, claim and context insertion | task/session IDs and atomic outcome; no result content in this local log | -| trace enqueue, export, retry, drop and recovery | trace IDs, queue depth/drop reason and exporter outcome; LangSmith data has its own redaction and entitlement gate | - -Late-generation discard records metadata, never audio or text. Browser timing stays in its own clock domain; missing playback acknowledgement does not prove heard audio, and cancellation request/ack does not prove physical inference stopped. diff --git a/docs/index.md b/docs/index.md deleted file mode 100644 index 5677c0c..0000000 --- a/docs/index.md +++ /dev/null @@ -1,59 +0,0 @@ ---- -tags: - - voice-agent - - v3 - - grill - - architecture - - index ---- - -# VOCA v3 — Documentation and Grill Provenance - -> **Historical date:** 2026-09-02 -> **Mode:** Deep-architecture grill (rewrite from scratch) -> **Predecessor:** `[[voice-agent-v2-architecture-and-tts-adapters]]` — историческая Obsidian-ссылка из внешней базы, **не файл этого репозитория**. - -## Current specification - -[VOCA-13](https://github.com/yokeloop/voca/issues/13) adds a small working session/control foundation split into `backend/` and `frontend/`; its actual module map is in [Code architecture](code-architecture.md). The voice stack remains planned: VOCA-2 supplies its contracts and roadmap, while VOCA-13 does not claim runtime inference or experiments. - -| Документ | Что читать | -|---|---| -| [README](../README.md) | Краткий статус и стартовый стек | -| [Code architecture](code-architecture.md) | Implemented session/control modules, owners and dependency direction | -| [Testing](testing.md) | Risk matrix and repeatable foundation checks | -| [Diagnostics](diagnostics.md) | Safe local JSONL policy and future diagnostic map | -| [Architecture](architecture.md) | Future voice/provider boundaries, lifecycle/control/Pi/delivery, LangSmith preflight, hypotheses H1–H7 | -| [Roadmap](roadmap.md) | Разговор → контекст/инструкции → Pi → остальные возможности; gates и приёмка | -| [Glossary](../CONTEXT.md) | Единые определения conversation/session, turn/generation, task/playback | -| [ADR-0002](adr/0002-conversation-lifecycle-and-roadmap.md) | Accepted documentary VOCA-2 decisions | -| [ADR-0003](adr/0003-code-and-test-foundation.md) | Agreed foundation decision, executable result pending review | -| [MiniCPM research](research/minicpm5-2b.md) | Официальный GGUF, пределы benchmark evidence и будущий протокол сравнения | -| [ADR-0001](adr/0001-rewrite-v3.md) | Историческое решение rewrite от 2026-09-02 | - -Актуальная архитектура: один Python/FastAPI runtime, browser WebRTC/FastRTC плюс control WS, локальный Whisper server → LiteLLM/llama.cpp/LFM2.5 → audio.cpp/TTSProvider. Кнопка включает разговор без удержания, VAD разделяет реплики. Minimal UI, реальный browser flush и self-hosted LangSmith на той же машине входят уже в первый голосовой этап. Следом история/reload, затем Pi-first с отдельной сессией выбранного экземпляра, durable tasks и результатом в исходную session. Stop/barge-in не отменяют поручения, reconnect не включает микрофон, unknown не повторяет dispatch. MiniCPM и unified audio.cpp не prerequisite. - -Развёртывание LangSmith и совместная память **не доказаны**: Enterprise entitlement/image access и single-host/resource условия требуют проверки; официальные рекомендации выше целевого бюджета 32 GB RAM. Около 500 мс — цель end-of-speech → first audible, не гарантия. Все источники/границы подтверждения — в архитектуре. - -## Historical grill summary — not the current implementation contract - -На grill 2026-09-02 инженер решил начать clean rewrite voice-agent-v2: прежняя архитектура считалась плохо организованной, перегруженной и недостаточно гибкой. Цель сохранилась: тонкий кастомизируемый голосовой ассистент, близкий к realtime, с делегацией более сильным агентам. - -Grill выбрал Python/custom loop, FastRTC вместо LiveKit, LiteLLM, Whisper-compatible STT, audio.cpp/TTSProvider, SQLite/JSONL и SOUL/user/agent. Обсуждались делегация Hermes/Pi/OpenClaw, systemd, monorepo и новый UI вместо переноса v2. Исторический список не доказывал совместимость, русский голос, cancellation, realtime или достаточность памяти. - -Уточнения, которые **заменяют старые текущие рекомендации**, а не переписывают дату решения: - -| Историческая формулировка / пробел | Действующий контракт | -|---|---| -| «Push-to-talk» и UI после backend | Переключатель без удержания, VAD и минимальный UI в этапе 1: [conversation lifecycle](architecture.md#conversation-lifecycle) | -| TTS cancel «поверх interrupt callback», interrupt в polish | Callback/browser flush не доказан, speech-onset invalidation и client boundary — ранний [H1](architecture.md#h1--browser-barge-in) | -| «LFM формат парсится в loop», «text = говорить» | Serving layer нормализует tools; loop валидирует; только speakable content в TTS: [loop](architecture.md#custom-llm-loop) | -| «Смена STT = URL», «смена LLM = конфиг» | Язык, streaming, cancel и модельные formats проверяются по [provider contracts](architecture.md#provider-contracts) | -| Единый gateway на три агента | Сначала выбранный Pi со своими tools/MCP/ограничениями; отдельные последующие adapters: [delegation](architecture.md#delegation) | -| SQLite history без outcome/restart контракта | Durable task/delivery, unknown и dedup: [task lifecycle](architecture.md#task-lifecycle) | -| Прямой запуск всех компонентов | Приложение отдельно от официальных требований LangSmith: [observability](architecture.md#observability-and-self-hosted-langsmith) | -| Фазы MVP → capabilities → polish → UI → R&D | Действует [roadmap](roadmap.md); fine-tuning/always-on только по будущим проверкам | - -## Deferred provenance - -Multi-user, kiosk, telephony и proactivity не входят в ранний VOCA. `[[grill-proactive-agent-results]]` — ещё одна историческая внешняя Obsidian-ссылка, не локальный документ. Always-on и прежняя оценка 200–400 мс, semantic turn detection и необходимость fine-tuning не доказаны: правила допуска находятся в [H6](architecture.md#h6--deferred-semantic-activation-turn-detection-and-fine-tuning). diff --git a/docs/research/minicpm5-2b.md b/docs/research/minicpm5-2b.md deleted file mode 100644 index e92d834..0000000 --- a/docs/research/minicpm5-2b.md +++ /dev/null @@ -1,33 +0,0 @@ -# MiniCPM5-2B — кандидат сравнения для VOCA - -> Исходная заметка сохранена 2026-09-07 по [Reddit](https://www.reddit.com/r/LocalLLaMA/comments/1w9skjz/minicpm52b_release_day/), [OpenBMB](https://github.com/OpenBMB/MiniCPM) и model card. Проверка источников уточнена 2026-09-10 в VOCA-2. Модели не скачивались, inference не запускался; измерений VOCA нет. - -## Evidence and limits - -| Источник / revision | Подтверждено документально | Не подтверждено | -|---|---|---| -| [Официальная model card](https://huggingface.co/openbmb/MiniCPM5-2B/blob/abe115e887989b14f05e64a3b260648329324c3f/README.md), revision `abe115e887989b14f05e64a3b260648329324c3f`, проверена 2026-09-10 | OpenBMB MiniCPM5-2B, dense Transformer / `LlamaForCausalLM`, Apache-2.0, заявленные English/Chinese, 2,516,756,480 parameters (1,981,982,720 non-embedding), context 131,072 | Качество русского, рабочий размер context/VRAM и latency на RTX 4070 | -| [Официальный GGUF](https://huggingface.co/openbmb/MiniCPM5-2B-GGUF/tree/c451f4d674096e6e6f1cc5d0abb6794bda638304), revision `c451f4d674096e6e6f1cc5d0abb6794bda638304`, проверен 2026-09-10 | Репозиторий издателя содержит F16, Q4_K_M и Q8_0; основная card прямо ссылается на него как GGUF для llama.cpp/Ollama/LM Studio | Наличие файла не равно проверенной streaming/tool совместимости конкретного server/template, тем более качеству VOCA | -| [llama.cpp chat routing](https://github.com/ggml-org/llama.cpp/blob/c32d1dabe819002ca8aa3a885aca4057f5968e3d/common/chat.cpp#L1186-L1191), проверен 2026-09-10 | MiniCPM5 template распознаётся и направляется в [XML parser](https://github.com/ggml-org/llama.cpp/blob/c32d1dabe819002ca8aa3a885aca4057f5968e3d/common/parsers/minicpm5.cpp); reasoning и content имеют отдельные границы | End-to-end нормализованные `tool_calls` через LiteLLM ещё не проверены | - -Нельзя делать вывод о совместимости лишь из `LlamaForCausalLM` или смены URL. Официальные serving recipes (vLLM/SGLang/Transformers/DSpark) — альтернативы из model card, **не выбранный VOCA stack**. Старт остаётся локальный llama.cpp с LFM2.5 через LiteLLM; новый модельный парсер в loop не вводится. - -## Manufacturer benchmarks are not VOCA measurements - -В исходной заметке были сохранены числа производителя: Average 53.9 против LFM2.5 33.2; BFCL v4 66.6 против 61.1; SWE-bench Verified 46.4 против 6.0; IFEval 86.7 против 93.4. Это заявления OpenBMB о своём сравнении, не воспроизведение VOCA и не независимая проверка. Они не отвечают на вопросы русского голоса, streaming/cancellation, реальной делегации и первого слышимого ответа. Reddit-оценка Artificial Analysis также не является артефактом VOCA. - -Training recipe (UltraData → SFT → RL/OPD) и открытые dataset listings описывают происхождение модели, но не доказывают нужду в fine-tuning VOCA. Решение о замене не принимается по рекламному среднему benchmark. - -## Future comparison protocol - -Нормативный протокол: [H5 — модели и tool calling](../architecture.md#h5--lfmminicpm-and-tool-calling), вместе с [H2 — русский и память](../architecture.md#h2--russian-voice-and-shared-memory) и [H3 — latency](../architecture.md#h3--latency). - -1. Зафиксировать host/driver, llama.cpp/LiteLLM/adapters versions, model revisions/hash/quantization, context size, sampling/thinking/template настройки. Начать с сопоставимой Q4_K_M и одинаковых limits, отличия templates/thinking перечислить, не скрывать. -2. Подать обеим моделям один сохранённый русский корпус, одинаковые SOUL → user → agent snapshots, историю и `delegate(agent, task)` schema. Обычный вопрос, явное поручение исследования, промежуточная реплика + tool, неизвестный агент, incomplete/malformed аргументы и duplicate tool ID — обязательны; каждый сценарий повторить трижды cold/warm. -3. Сохранить redacted provider deltas и normalized adapter output: `id`, `function.name`, полную JSON `function.arguments`, finish reason, раздельные assistant content/reasoning. Валидировать tool name/args **до** dispatch. Не исполнять partial calls; повтор ID не запускает второе поручение. На этапе сравнения фиксировать ожидаемое действие; реальный Pi запускается только в приёмке его этапа. -4. Проверить русский ответ и инструкции, speakable промежуточную реплику. В TTS не должны попадать reasoning, arguments/results, XML/Pythonic markers или RPC events. Tokens/sec и первый reasoning token не заменяют first audible. -5. На неизменных STT/TTS и локальном LangSmith измерить first speakable text, first TTS chunk, first audible p50/p95, cold/warm и peak VRAM/RAM/OOM при совместной резидентности. Артефакт — пробы, audio/transcripts, normalized calls, trace IDs и сравнительная таблица, не только итоговый рейтинг. - -**Критерий:** все обязательные действия/отказы верны, malformed/duplicate не dispatch-ятся, русский разборчив и сохраняет смысл/имена/числа, служебный текст изолирован, нет OOM. Latency публикуется независимо от функциональной приёмки; около 500 мс — цель, не гарантия. - -**Решение по результату:** LFM остаётся baseline. Успешный MiniCPM допускается к отдельному выбору по русскому/tool/resource/latency артефакту, но не заменяет LFM автоматически. Провал MiniCPM оставляет кандидата в research. Провал обязательных LFM tool-сценариев блокирует Pi-этап до исправления совместимости/качества, а не ведёт к новому парсеру в loop. Само сравнение MiniCPM не prerequisite первых трёх [этапов roadmap](../roadmap.md). diff --git a/docs/roadmap.md b/docs/roadmap.md deleted file mode 100644 index de99efc..0000000 --- a/docs/roadmap.md +++ /dev/null @@ -1,104 +0,0 @@ -# VOCA v3 — Roadmap - -> VOCA-13 adds a working session/control foundation before the voice roadmap. It does not execute voice experiments or prove their gates. Contracts — in [architecture](architecture.md), the implemented boundary — in [code architecture](code-architecture.md), decisions — in [ADR-0002](adr/0002-conversation-lifecycle-and-roadmap.md) and [ADR-0003](adr/0003-code-and-test-foundation.md), definitions — in [CONTEXT](../CONTEXT.md). - -Порядок: **голосовой разговор → контекст/инструкции → Pi-делегация → остальные возможности**. Каждый этап оставляет проверяемый пользовательский результат; ранняя разведка — входная работа первого голосового этапа, не отдельный инфраструктурный проект без пользовательского выхода. - -## Foundation — VOCA-13 - -**Outcome:** the `frontend/` diagnostic browser panel creates one durable session in the `backend/` SQLite application over HTTP, obtains an empty snapshot through control WS, and resumes the same session after reconnect or backend restart. Its own [tests](testing.md) and [diagnostics policy](diagnostics.md) are the proof. This is not a voice stage: it adds no microphone, providers, LangSmith, models, GPU or hardware gate result. - -## Gates - -| Gate | Протокол / контракт | Что останавливает | -|---|---|---| -| Предпосылки LangSmith | [Official requirements](architecture.md#official-requirements-and-installation-blockers), [H7](architecture.md#h7--tracing-and-langsmith-gate) | Нет подтверждённых entitlement/artifact access, поддерживаемого single-host профиля, storage/resource budget или license egress/offline license — блокер зависимого наращивания этапа 1; LangSmith не подменяется | -| Browser interrupt | [H1](architecture.md#h1--browser-barge-in), [playback boundary](architecture.md#cancellation-and-playback-boundary) | Старый звук не убирается из browser, возобновляется, новая речь теряется — функциональный блокер этапа 1 | -| Русский / совместная память | [H2](architecture.md#h2--russian-voice-and-shared-memory) | Неразборчивый русский, потеря смысла/имён/чисел, OOM, служебный текст в TTS — функциональный блокер этапа 1 и последующих изменений providers | -| Полная доступная трасса | [Trace map](architecture.md#trace-map), [H7](architecture.md#h7--tracing-and-langsmith-gate) | Неполная корреляция, credentials, hidden loss, cloud export — блокер приёмки этапа 1; расширяется на Pi в этапе 3 | -| История и snapshot | [Instructions/history](architecture.md#instructions-and-history), [control](architecture.md#control-contract) | Reload меняет текущий turn, потеря истории или interrupted text выдан за услышанный — блокер этапа 2 | -| Нормализованный tool calling | [H5](architecture.md#h5--lfmminicpm-and-tool-calling) | Неправильные действия/аргументы, partial dispatch, reasoning в TTS — блокер Pi-этапа. Проверяется baseline LFM, MiniCPM не обязателен | -| Pi и durable delivery | [Delegation](architecture.md#delegation), [Task lifecycle](architecture.md#task-lifecycle) | Не тот instance/cwd/config, утрата ограничений, voice abort задачи, повтор после unknown или duplicate result — блокер этапа 3 | -| Realtime цель | [H3](architecture.md#h3--latency) | Около 500 мс end-of-speech → first audible — измеряемая цель; превышение фиксирует bottleneck и недостигнутую цель, **не отменяет функциональную приёмку** | -| Отложенные кандидаты | [H4](architecture.md#h4--unified-audiocpp-optional), [H6](architecture.md#h6--deferred-semantic-activation-turn-detection-and-fine-tuning), [model research](research/minicpm5-2b.md) | Неуспех не допускает конкретный эксперимент в продукт, но не блокирует первые три этапа | - -## Этап 1 — голосовой разговор - -**Результат пользователю:** включить разговор кнопкой, сказать несколько русских реплик и услышать ответы; перебивание действительно останавливает старый звук. - -**Вход / ранняя работа:** в самом начале подтвердить предпосылки LangSmith и проверить H1/H2/H7 на минимальном сквозном пути, до наращивания зависимых функций. На момент VOCA-2 лицензия, доступ к images и совместное размещение LangSmith не доказаны: рекомендации 16 vCPU/64 GB выше бюджета хоста около 32 GB RAM. Документированный Minikube/Kind dev путь не является обещанием достаточности ресурсов или native/systemd установки. - -**Реализуется:** один Python/FastAPI backend с runtime-владельцем turn/context/event queue; browser UI (переключатель без удержания, transcript/assistant text, connection/conversation/error), FastRTC WebRTC audio, control WS, VAD и запрос AEC; локальный Whisper server → LiteLLM → llama.cpp/LFM2.5 → audio.cpp через TTSProvider. SQLite фиксирует sessions/turns/messages и базовый instruction snapshot. Self-hosted LangSmith на той же машине, явный локальный endpoint в приложении и LiteLLM, redaction и ограниченная export queue. Cancellation/flush/late-generation fencing реализуются здесь, не в polish. - -**Не входит:** произвольные инструменты и делегация, развитый UI, управляемое редактирование/reload инструкций, semantic/always-on, замена LFM и unified audio.cpp STT. - -**Приёмка:** - -- Кнопка → permission/connected/listening → несколько реплик → transcript и слышимый ответ → следующая реплика без удержания. -- Permission denied оставляет `off` с ошибкой. Echo на динамиках проверен, включённый AEC flag не заменяет проверку. -- Barge-in во время LLM/TTS streaming очищает backend/browser звук, не ждёт окончания старого inference, сохраняет новую речь; три последовательных перебивания без возобновления старого звука. -- Stop выключает capture/звук; disconnect инвалидирует generation; reconnect показывает snapshot/history и `off`, микрофон включается только кнопкой, старый звук не возвращается. -- Поочерёдный STT/LLM/TTS failure/timeout виден с компонентом; нет выдуманного transcript/cloud fallback; assistant text переживает TTS failure, новая реплика доступна после восстановления. -- Transcript, полная доступная trace и redaction проверены; tracing outage/overflow видны и не блокируют микрофон. H1/H2/H7 зелёные; H3 публикуется отдельно, без выдуманного достижения latency. - -**Артефакт выхода:** walkthrough запись минимального UI/аудио, corpus/reference/transcripts, cancellation timeline с последним старым слышимым сэмплом, local LangSmith run IDs/span map, redaction/outage evidence и таблица cold/warm p50/p95/VRAM/RAM. До закрытия функциональных gates этап не принят. - -## Этап 2 — контекст и инструкции - -**Результат пользователю:** продолжать исходную сессию между подключениями и менять инструкции без изменения выполняющегося ответа. - -**Вход:** принятый голосовой этап с H1/H2/H7; базовые persisted turns/snapshots уже существуют. - -**Реализуется:** восстановление и использование истории в context, JSONL export из SQLite, управляемый instruction snapshot/reload в порядке SOUL → user → agent, last-valid retention и видимые reload errors. Не дублировать источник истины в JSONL. Отдельно хранить generated text, playback started и playback finished/unknown. - -**Не входит:** вызов Pi, остальные агенты, arbitrary tooling и semantic activation. - -**Приёмка:** - -- Назвать факт, отключиться и вернуться: следующая реплика учитывает историю, resume сам не включает микрофон. -- Прервать длинный ответ и спросить о нём: история/context не выдают весь generated text за услышанный. -- Изменить инструкции во время turn: старый snapshot остаётся неизменным, новая инструкция действует со следующего turn. -- Ошибка reload видна, последняя валидная версия продолжает работать; отсутствие начального valid set блокирует start, а не подставляет пустой prompt. -- JSONL export соответствует SQLite messages/turns и playback status; голосовые сценарии этапа 1 не регрессируют. - -**Артефакт выхода:** transcript и export с идентификаторами turns/snapshots, trace двух соседних turns до/после reload, reconnect/error walkthrough и сравнение persisted playback facts с UI. - -## Этап 3 — Pi-делегация - -**Результат пользователю:** поручить исследование выбранному Pi, продолжить говорить и получить поздний результат в исходной сессии. - -**Вход:** этап 2, H5 на LFM и preflight выбранного Pi instance/cwd/config/trust/RPC version. При несовпадении конфигурации задача не запускается пустым Pi и не обходится новым approvals слоем. - -**Реализуется:** `delegate(agent, task)` с единственным доступным `pi`, выбор экземпляра/каталога пользователем до поручения, отдельная новая Pi-сессия с его окружением/tools/MCP/ограничениями; JSONL RPC prompt/progress/`agent_settled`, durable tasks и task cards, async result delivery, дедупликация tool ID/terminal events, unknown recovery, trace связи voice → task → result turn. - -Полномочия: **«Чтение, исследование и использование тулов/mcp подключенных к конкретно этому Pi инстансу»**. Текст задания не расширяет их и не переопределяет instance/cwd. Существующая беседа Pi не управляется. - -**Не входит:** Hermes/OpenClaw, общий agent gateway, произвольное разрешение изменений, новый approval mechanism, автоматический retry неизвестного поручения. - -**Приёмка:** - -- Выбрать instance/cwd → попросить исследовать проект → task ID сразу виден, отдельная Pi session использует выбранную конфигурацию, prompt acceptance не принят за success. -- Продолжить голосом и перебить ответ: текущий voice generation отменяется, Pi task остаётся running; `agent_end` не завершает задачу до `agent_settled` и проверки terminal outcome. -- Поздний результат сохраняется и обновляет card, один раз входит в context, отдельный result turn звучит в паузе с приоритетом пользовательской речи. Сырой RPC/tool текст не читается TTS. -- Result при выключенном браузере переживает reconnect: card/history восстановлены, микрофон off; ещё не начатое озвучивание ждёт включения. Прерванный result turn не переозвучивается сам. -- Повтор tool/terminal event не запускает второй Pi и не создаёт второй результат. Restart после launch intent без известного outcome показывает `unknown`, не повторяет поручение. -- Доступные Pi/tool/MCP события и late result связаны trace/run/task IDs; физическое exactly-once прослушивание не обещается. H1/H2/H7 повторены на расширенном пути. - -**Артефакт выхода:** redacted RPC event transcript, SQLite task/delivery/message evidence с IDs, task UI/reconnect/restart/duplicate walkthrough, linked local traces и audio cancellation timeline. Несовпадение полномочий, потеря result или дубли — блокер. - -## Этап 4 — остальные возможности - -**Результат пользователю:** дополнительные агенты и способы взаимодействия на уже принятом базовом разговоре и Pi, без их регрессии. Ни один подэтап не prerequisite первых трёх. - -Последовательные подэтапы, каждый со своим отдельным артефактом приёмки: - -| Порядок | Вход / что реализуется | Граница / не входит | Наблюдаемая приёмка и артефакт | -|---|---|---|---| -| 4.1 Hermes adapter | Принятый этап 3; сверить конкретную версию runs/events SSE/stop/approval, реализовать mapping к task lifecycle | Не общий protocol для всех агентов, не изменение Pi permissions | Поручение возвращает status/result без остановки микрофона; error/reconnect/duplicate не ломают delivery. Event transcript, task UI и trace | -| 4.2 OpenClaw adapter | После принятого 4.1; сверить WS agent/runId/agent.wait, независимый adapter | Не перенос Hermes SSE semantics по аналогии | Status и late result во время разговора, неизвестный outcome/дубли обработаны; event transcript и trace | -| 4.3 Развитый UI | Принятые adapters, тот же control contract; panels/controls/visuals с нуля | Не новая state machine и не перенос v2 | Все connection/conversation/turn/task состояния и ошибки видимы; walkthrough этапов 1–3 без регрессий, UI запись | -| 4.4 Semantic turn detection | H6 на принятом базовом VAD | Не always-on и не обещанная замена VAD | Нет потерь слов/смысла, не хуже baseline по cut/end errors, улучшение delay; размеченный корпус, comparison/trace; провал оставляет VAD | -| 4.5 Always-on semantic activation | H6, заранее согласованные false/missed rates и latency budget | Не обязательное включение без кнопки | Прямые обращения/фон/эхо/тишина: confusion matrix и p50/p95, сохранность начала речи; провал оставляет кнопку + VAD, 200–400 мс не считаются измеренными | -| 4.6 Модели и audio.cpp experiments | H2/H3/H4/H5, baseline артефакты; сравнение MiniCPM и optional unified STT/TTS по отдельности | Не замена по benchmark/URL, не обязательный fine-tuning | Русский/tool/streaming/cancel/resource checks проходят; сравнительные пробы/trace и решение по конкретному кандидату; провал сохраняет LFM/Whisper | - -Fine-tuning допускается только после воспроизводимых провалов исходных сценариев и held-out проверки H6; не объявляется необходимым заранее. Каждый принятый эксперимент повторяет базовые voice/task/error сценарии. Неуспех оставляет предыдущую работающую конфигурацию и видимый результат исследования, а не меняет критерии приёмки. diff --git a/docs/testing.md b/docs/testing.md deleted file mode 100644 index 99434a4..0000000 --- a/docs/testing.md +++ /dev/null @@ -1,20 +0,0 @@ -# Testing - -VOCA-13 uses the cheapest sufficient check: real SQLite/lifespan tests for persistence and lifecycle, focused diagnostics tests, pure frontend projection/protocol tests, and short AST/TypeScript-parser import-boundary checks. There is no coverage target, mock infrastructure, browser harness or duplicated layer suite. Browser/reload/reconnect walkthrough is `/review` evidence, not inferred from these checks. - -From the repository root after the locked bootstrap: - -```sh -(cd backend && uv run --offline --no-sync ruff format --check src tests) -(cd backend && uv run --offline --no-sync ruff check src tests) -(cd backend && uv run --offline --no-sync mypy src/voca) -(cd backend && uv run --offline --no-sync pytest -q) -pnpm --dir frontend format:check -pnpm --dir frontend lint -pnpm --dir frontend typecheck -pnpm --dir frontend test -pnpm --dir frontend build -git diff --check -``` - -The Python suite proves real schema/close/restart/error and bounded transport helpers. The Node built-in test runner imports pure TypeScript through Node strip-types and checks cursor precision, projection semantics and source boundaries. It adds no test dependency. The suite requires no GPU, credentials, models, services or browser after bootstrap. diff --git a/frontend/biome.json b/frontend/biome.json deleted file mode 100644 index 83c56e2..0000000 --- a/frontend/biome.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "$schema": "https://biomejs.dev/schemas/2.2.4/schema.json", - "files": { - "includes": ["**", "!dist", "!node_modules"] - }, - "formatter": { - "enabled": true, - "indentStyle": "space" - }, - "linter": { - "enabled": true, - "rules": { - "recommended": true - } - } -} diff --git a/frontend/index.html b/frontend/index.html deleted file mode 100644 index 7dbbee9..0000000 --- a/frontend/index.html +++ /dev/null @@ -1,12 +0,0 @@ - - - - - - VOCA control - - -
- - - diff --git a/frontend/package.json b/frontend/package.json deleted file mode 100644 index 1420e45..0000000 --- a/frontend/package.json +++ /dev/null @@ -1,31 +0,0 @@ -{ - "name": "voca-control-panel", - "private": true, - "version": "0.1.0", - "type": "module", - "packageManager": "pnpm@10.34.5", - "engines": { - "node": ">=22.12.0" - }, - "scripts": { - "dev": "vite", - "build": "tsc --noEmit && vite build", - "typecheck": "tsc --noEmit", - "lint": "biome lint .", - "format:check": "biome format .", - "format": "biome format --write .", - "test": "node --experimental-strip-types --test tests/*.test.mjs" - }, - "dependencies": { - "react": "19.1.1", - "react-dom": "19.1.1" - }, - "devDependencies": { - "@biomejs/biome": "2.2.4", - "@types/react": "19.1.12", - "@types/react-dom": "19.1.9", - "@vitejs/plugin-react": "5.0.2", - "typescript": "5.8.3", - "vite": "7.1.7" - } -} diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml deleted file mode 100644 index bf18c7c..0000000 --- a/frontend/pnpm-lock.yaml +++ /dev/null @@ -1,1240 +0,0 @@ -lockfileVersion: '9.0' - -settings: - autoInstallPeers: true - excludeLinksFromLockfile: false - -importers: - - .: - dependencies: - react: - specifier: 19.1.1 - version: 19.1.1 - react-dom: - specifier: 19.1.1 - version: 19.1.1(react@19.1.1) - devDependencies: - '@biomejs/biome': - specifier: 2.2.4 - version: 2.2.4 - '@types/react': - specifier: 19.1.12 - version: 19.1.12 - '@types/react-dom': - specifier: 19.1.9 - version: 19.1.9(@types/react@19.1.12) - '@vitejs/plugin-react': - specifier: 5.0.2 - version: 5.0.2(vite@7.1.7) - typescript: - specifier: 5.8.3 - version: 5.8.3 - vite: - specifier: 7.1.7 - version: 7.1.7 - -packages: - - '@babel/code-frame@7.29.7': - resolution: {integrity: sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==} - engines: {node: '>=6.9.0'} - - '@babel/compat-data@7.29.7': - resolution: {integrity: sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==} - engines: {node: '>=6.9.0'} - - '@babel/core@7.29.7': - resolution: {integrity: sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==} - engines: {node: '>=6.9.0'} - - '@babel/generator@7.29.8': - resolution: {integrity: sha512-gZbepsdh3WDtgZKWL+vTPh71LSBrm/Y4/QDZBVCcYfmeTEEuoOYwlSy+G1StfJg+/Zy550u/3TATbm7qDbbMtg==} - engines: {node: '>=6.9.0'} - - '@babel/helper-compilation-targets@7.29.7': - resolution: {integrity: sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==} - engines: {node: '>=6.9.0'} - - '@babel/helper-globals@7.29.7': - resolution: {integrity: sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==} - engines: {node: '>=6.9.0'} - - '@babel/helper-module-imports@7.29.7': - resolution: {integrity: sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==} - engines: {node: '>=6.9.0'} - - '@babel/helper-module-transforms@7.29.7': - resolution: {integrity: sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0 - - '@babel/helper-plugin-utils@7.29.7': - resolution: {integrity: sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==} - engines: {node: '>=6.9.0'} - - '@babel/helper-string-parser@7.29.7': - resolution: {integrity: sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==} - engines: {node: '>=6.9.0'} - - '@babel/helper-validator-identifier@7.29.7': - resolution: {integrity: sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==} - engines: {node: '>=6.9.0'} - - '@babel/helper-validator-option@7.29.7': - resolution: {integrity: sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==} - engines: {node: '>=6.9.0'} - - '@babel/helpers@7.29.7': - resolution: {integrity: sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==} - engines: {node: '>=6.9.0'} - - '@babel/parser@7.29.8': - resolution: {integrity: sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA==} - engines: {node: '>=6.0.0'} - hasBin: true - - '@babel/plugin-transform-react-jsx-self@7.29.7': - resolution: {integrity: sha512-TL0hMc9xzy86VD31nUiwzd5otRAcyEPcsegCxolO0PvcXuH1v0kECe/UIznYFihpkvU5wg/jk4v0TTEFfm53fw==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0-0 - - '@babel/plugin-transform-react-jsx-source@7.29.7': - resolution: {integrity: sha512-06IyK09H3wi4cGbhDBwp5gUGo0IKtnYa8tyTiephirPCK6fbobVGiXMMI5zLQ4aKEYP3wZ3ArU44o+8KMrSG/Q==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0-0 - - '@babel/template@7.29.7': - resolution: {integrity: sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==} - engines: {node: '>=6.9.0'} - - '@babel/traverse@7.29.8': - resolution: {integrity: sha512-I5z7H3bf/41ktsNVLtpN0wAa336HkqIHQ5BuPLEhTkt1jVSyZpeNKIzTgEWmlxjdg81R0IgUCcaE+Ok3NvrfZg==} - engines: {node: '>=6.9.0'} - - '@babel/types@7.29.8': - resolution: {integrity: sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==} - engines: {node: '>=6.9.0'} - - '@biomejs/biome@2.2.4': - resolution: {integrity: sha512-TBHU5bUy/Ok6m8c0y3pZiuO/BZoY/OcGxoLlrfQof5s8ISVwbVBdFINPQZyFfKwil8XibYWb7JMwnT8wT4WVPg==} - engines: {node: '>=14.21.3'} - hasBin: true - - '@biomejs/cli-darwin-arm64@2.2.4': - resolution: {integrity: sha512-RJe2uiyaloN4hne4d2+qVj3d3gFJFbmrr5PYtkkjei1O9c+BjGXgpUPVbi8Pl8syumhzJjFsSIYkcLt2VlVLMA==} - engines: {node: '>=14.21.3'} - cpu: [arm64] - os: [darwin] - - '@biomejs/cli-darwin-x64@2.2.4': - resolution: {integrity: sha512-cFsdB4ePanVWfTnPVaUX+yr8qV8ifxjBKMkZwN7gKb20qXPxd/PmwqUH8mY5wnM9+U0QwM76CxFyBRJhC9tQwg==} - engines: {node: '>=14.21.3'} - cpu: [x64] - os: [darwin] - - '@biomejs/cli-linux-arm64-musl@2.2.4': - resolution: {integrity: sha512-7TNPkMQEWfjvJDaZRSkDCPT/2r5ESFPKx+TEev+I2BXDGIjfCZk2+b88FOhnJNHtksbOZv8ZWnxrA5gyTYhSsQ==} - engines: {node: '>=14.21.3'} - cpu: [arm64] - os: [linux] - libc: [musl] - - '@biomejs/cli-linux-arm64@2.2.4': - resolution: {integrity: sha512-M/Iz48p4NAzMXOuH+tsn5BvG/Jb07KOMTdSVwJpicmhN309BeEyRyQX+n1XDF0JVSlu28+hiTQ2L4rZPvu7nMw==} - engines: {node: '>=14.21.3'} - cpu: [arm64] - os: [linux] - libc: [glibc] - - '@biomejs/cli-linux-x64-musl@2.2.4': - resolution: {integrity: sha512-m41nFDS0ksXK2gwXL6W6yZTYPMH0LughqbsxInSKetoH6morVj43szqKx79Iudkp8WRT5SxSh7qVb8KCUiewGg==} - engines: {node: '>=14.21.3'} - cpu: [x64] - os: [linux] - libc: [musl] - - '@biomejs/cli-linux-x64@2.2.4': - resolution: {integrity: sha512-orr3nnf2Dpb2ssl6aihQtvcKtLySLta4E2UcXdp7+RTa7mfJjBgIsbS0B9GC8gVu0hjOu021aU8b3/I1tn+pVQ==} - engines: {node: '>=14.21.3'} - cpu: [x64] - os: [linux] - libc: [glibc] - - '@biomejs/cli-win32-arm64@2.2.4': - resolution: {integrity: sha512-NXnfTeKHDFUWfxAefa57DiGmu9VyKi0cDqFpdI+1hJWQjGJhJutHPX0b5m+eXvTKOaf+brU+P0JrQAZMb5yYaQ==} - engines: {node: '>=14.21.3'} - cpu: [arm64] - os: [win32] - - '@biomejs/cli-win32-x64@2.2.4': - resolution: {integrity: sha512-3Y4V4zVRarVh/B/eSHczR4LYoSVyv3Dfuvm3cWs5w/HScccS0+Wt/lHOcDTRYeHjQmMYVC3rIRWqyN2EI52+zg==} - engines: {node: '>=14.21.3'} - cpu: [x64] - os: [win32] - - '@esbuild/aix-ppc64@0.25.12': - resolution: {integrity: sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==} - engines: {node: '>=18'} - cpu: [ppc64] - os: [aix] - - '@esbuild/android-arm64@0.25.12': - resolution: {integrity: sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==} - engines: {node: '>=18'} - cpu: [arm64] - os: [android] - - '@esbuild/android-arm@0.25.12': - resolution: {integrity: sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==} - engines: {node: '>=18'} - cpu: [arm] - os: [android] - - '@esbuild/android-x64@0.25.12': - resolution: {integrity: sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==} - engines: {node: '>=18'} - cpu: [x64] - os: [android] - - '@esbuild/darwin-arm64@0.25.12': - resolution: {integrity: sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==} - engines: {node: '>=18'} - cpu: [arm64] - os: [darwin] - - '@esbuild/darwin-x64@0.25.12': - resolution: {integrity: sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==} - engines: {node: '>=18'} - cpu: [x64] - os: [darwin] - - '@esbuild/freebsd-arm64@0.25.12': - resolution: {integrity: sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==} - engines: {node: '>=18'} - cpu: [arm64] - os: [freebsd] - - '@esbuild/freebsd-x64@0.25.12': - resolution: {integrity: sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==} - engines: {node: '>=18'} - cpu: [x64] - os: [freebsd] - - '@esbuild/linux-arm64@0.25.12': - resolution: {integrity: sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==} - engines: {node: '>=18'} - cpu: [arm64] - os: [linux] - - '@esbuild/linux-arm@0.25.12': - resolution: {integrity: sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==} - engines: {node: '>=18'} - cpu: [arm] - os: [linux] - - '@esbuild/linux-ia32@0.25.12': - resolution: {integrity: sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==} - engines: {node: '>=18'} - cpu: [ia32] - os: [linux] - - '@esbuild/linux-loong64@0.25.12': - resolution: {integrity: sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==} - engines: {node: '>=18'} - cpu: [loong64] - os: [linux] - - '@esbuild/linux-mips64el@0.25.12': - resolution: {integrity: sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==} - engines: {node: '>=18'} - cpu: [mips64el] - os: [linux] - - '@esbuild/linux-ppc64@0.25.12': - resolution: {integrity: sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==} - engines: {node: '>=18'} - cpu: [ppc64] - os: [linux] - - '@esbuild/linux-riscv64@0.25.12': - resolution: {integrity: sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==} - engines: {node: '>=18'} - cpu: [riscv64] - os: [linux] - - '@esbuild/linux-s390x@0.25.12': - resolution: {integrity: sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==} - engines: {node: '>=18'} - cpu: [s390x] - os: [linux] - - '@esbuild/linux-x64@0.25.12': - resolution: {integrity: sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==} - engines: {node: '>=18'} - cpu: [x64] - os: [linux] - - '@esbuild/netbsd-arm64@0.25.12': - resolution: {integrity: sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==} - engines: {node: '>=18'} - cpu: [arm64] - os: [netbsd] - - '@esbuild/netbsd-x64@0.25.12': - resolution: {integrity: sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==} - engines: {node: '>=18'} - cpu: [x64] - os: [netbsd] - - '@esbuild/openbsd-arm64@0.25.12': - resolution: {integrity: sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==} - engines: {node: '>=18'} - cpu: [arm64] - os: [openbsd] - - '@esbuild/openbsd-x64@0.25.12': - resolution: {integrity: sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==} - engines: {node: '>=18'} - cpu: [x64] - os: [openbsd] - - '@esbuild/openharmony-arm64@0.25.12': - resolution: {integrity: sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==} - engines: {node: '>=18'} - cpu: [arm64] - os: [openharmony] - - '@esbuild/sunos-x64@0.25.12': - resolution: {integrity: sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==} - engines: {node: '>=18'} - cpu: [x64] - os: [sunos] - - '@esbuild/win32-arm64@0.25.12': - resolution: {integrity: sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==} - engines: {node: '>=18'} - cpu: [arm64] - os: [win32] - - '@esbuild/win32-ia32@0.25.12': - resolution: {integrity: sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==} - engines: {node: '>=18'} - cpu: [ia32] - os: [win32] - - '@esbuild/win32-x64@0.25.12': - resolution: {integrity: sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==} - engines: {node: '>=18'} - cpu: [x64] - os: [win32] - - '@jridgewell/gen-mapping@0.3.13': - resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} - - '@jridgewell/remapping@2.3.5': - resolution: {integrity: sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==} - - '@jridgewell/resolve-uri@3.1.2': - resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} - engines: {node: '>=6.0.0'} - - '@jridgewell/sourcemap-codec@1.6.0': - resolution: {integrity: sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==} - - '@jridgewell/trace-mapping@0.3.31': - resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} - - '@napi-rs/lzma-linux-x64-gnu@1.5.1': - resolution: {integrity: sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==} - engines: {node: ^22.20 || ^24.12 || >=25} - cpu: [x64] - os: [linux] - libc: [glibc] - - '@rolldown/pluginutils@1.0.0-beta.34': - resolution: {integrity: sha512-LyAREkZHP5pMom7c24meKmJCdhf2hEyvam2q0unr3or9ydwDL+DJ8chTF6Av/RFPb3rH8UFBdMzO5MxTZW97oA==} - - '@rollup/rollup-android-arm-eabi@4.63.1': - resolution: {integrity: sha512-UZ8sUxPTiHWYX9QNdJedb1kDZSpS1t/VPWBWGSgqHNi9w3Cu6IXvu2mzbhiTiPvtrqgTQJ+zqiAq2iPIPilpaQ==} - cpu: [arm] - os: [android] - - '@rollup/rollup-android-arm64@4.63.1': - resolution: {integrity: sha512-cQ4nFQABN5cDvDpbvJ7bMStCpnaVxynZrRMfUJYgxcIk9Sh54FIO1vtfkg0B69REjER77ioZ/ov+eAApx/KmLQ==} - cpu: [arm64] - os: [android] - - '@rollup/rollup-darwin-arm64@4.63.1': - resolution: {integrity: sha512-FQNqd1lRy/0QhDk3xeRIkSBiCpXCiDnZO3YLVdcDKN1UBiKToNftCzcXYNLshmPDUMlu2TdeS8tGcsU6f3YF1Q==} - cpu: [arm64] - os: [darwin] - - '@rollup/rollup-darwin-x64@4.63.1': - resolution: {integrity: sha512-pvD16V939D3CloK0+qikpGaxiPrDUXTe7Y5cWOMkMSy7m1cawa8EGy/kXYi/G/cKAC4HDAbSnzCIk1WmsoOKXg==} - cpu: [x64] - os: [darwin] - - '@rollup/rollup-freebsd-arm64@4.63.1': - resolution: {integrity: sha512-pcFGeL2345VwdTnJhA6zLbew+YgWB0qBG2+dMtXjCicf6+rm6kO6cOoh5VnTe0ZMrMRgRyuHmCJxZWrIdzYuOw==} - cpu: [arm64] - os: [freebsd] - - '@rollup/rollup-freebsd-x64@4.63.1': - resolution: {integrity: sha512-mRJlqSRulVzcKq/LKA6ICSIc3K/l4fzlVn/gePn2nXIHy8seRi5z/eeRE0d/XMBxcMldiXtQTSpRj0tkkC3g8Q==} - cpu: [x64] - os: [freebsd] - - '@rollup/rollup-linux-arm-gnueabihf@4.63.1': - resolution: {integrity: sha512-YDUNvVM85TI3g/1OpnqKP1h4NeW/j64DfWMf+G3M809xNk1bJSnpFp4sh83NpmVE5DXnkh8ULor4LTVZKoYLHw==} - cpu: [arm] - os: [linux] - libc: [glibc] - - '@rollup/rollup-linux-arm-musleabihf@4.63.1': - resolution: {integrity: sha512-7Mcn71p9ZuQFAj+h+dhQXy/yeLePRS2yKRnmW1DijA9thKO5qap0GNOIQK4yQ6iP3SU0Mrb/yWo8h8vgRba8lw==} - cpu: [arm] - os: [linux] - libc: [musl] - - '@rollup/rollup-linux-arm64-gnu@4.63.1': - resolution: {integrity: sha512-4YiLQTX6U4CSl0L9cluep9A9W6UmTfqBDc2/CH6wlu54pl4E7Jn3cOD8oxzvBDEGk/JMKgJ47C8g+radF7mwvg==} - cpu: [arm64] - os: [linux] - libc: [glibc] - - '@rollup/rollup-linux-arm64-musl@4.63.1': - resolution: {integrity: sha512-2ra8F7w8OquwZN9z2/fKFnli69wa8PLwaVzRMIPGb13ByMJwC28Fbp8YcVGoUhlYMTt7j5j9bNgpysrN2UM+vw==} - cpu: [arm64] - os: [linux] - libc: [musl] - - '@rollup/rollup-linux-loong64-gnu@4.63.1': - resolution: {integrity: sha512-Sy20ncyhjmBP0Ml+UvQbimjlk6VFgjW5uNP+qqwHB00mTE8Bl2C1TuHTlRwK2YoXeZbee5lP2XevBWVkAQAtSQ==} - cpu: [loong64] - os: [linux] - libc: [glibc] - - '@rollup/rollup-linux-loong64-musl@4.63.1': - resolution: {integrity: sha512-noITLp8oNjYliPnGWmLyelIHwULGqbHloQHGw1rtxbWhTuWooRpnZarZQJ1y9EUC4szuCusCc+HEpUtxpIwYvA==} - cpu: [loong64] - os: [linux] - libc: [musl] - - '@rollup/rollup-linux-ppc64-gnu@4.63.1': - resolution: {integrity: sha512-hlxxXd+F1mWiAcaFR7Sv9ZQT6m6UfI8+Vy/kFJzztq2pDMU/0wZ9sish0iszNZvsQDo8Gc0i5yuFEOz5dDf6fA==} - cpu: [ppc64] - os: [linux] - libc: [glibc] - - '@rollup/rollup-linux-ppc64-musl@4.63.1': - resolution: {integrity: sha512-EF7OpqQTQ/BvGqLzUi4rEHuagCV9MugAUXSHemwPW5vxZ75RR+jxO/2j95Ph2dalMpFHSVECjRoioHZgA9zOYA==} - cpu: [ppc64] - os: [linux] - libc: [musl] - - '@rollup/rollup-linux-riscv64-gnu@4.63.1': - resolution: {integrity: sha512-wQO3JesW9PRkwlabQ27y7sPfVOOTLRG73I4F2UYHG5PXun3J9U3y+b7ezVKSYbsvSKGQ1k1cq8Qlun4C9kLt3w==} - cpu: [riscv64] - os: [linux] - libc: [glibc] - - '@rollup/rollup-linux-riscv64-musl@4.63.1': - resolution: {integrity: sha512-ouAGwhO6wHRXdnOVCOsB0tRFkA7nhNB2Nwax6oECXN0YiN8EYUTBAOudADOB1PI+yDL61TeNx/u7MVCzksNbkQ==} - cpu: [riscv64] - os: [linux] - libc: [musl] - - '@rollup/rollup-linux-s390x-gnu@4.63.1': - resolution: {integrity: sha512-q2R38Sn+1J8RxhfJ+T54wSWmyKXWec+9jgDfqO2AtArEqHO5R2aeayp5H5OYLr5UYDVGsVaZPEFUooMhYCdz5A==} - cpu: [s390x] - os: [linux] - libc: [glibc] - - '@rollup/rollup-linux-x64-gnu@4.63.1': - resolution: {integrity: sha512-gfI5T24WLLuFfSKw7Go/zDXjAAV0fny0swTaDv+WjK7vqcw4cRhFfdsyKL1n+ukI+ooBxn3bVQnyrn06WpI50w==} - cpu: [x64] - os: [linux] - libc: [glibc] - - '@rollup/rollup-linux-x64-musl@4.63.1': - resolution: {integrity: sha512-4h6XqthmB4Hspji84wvgk+ElodTsGj+dbZqHJHHtKxj4mYq0ANSEEPX9ys3moJueqsRjwpaJYH7874Itwnj2ow==} - cpu: [x64] - os: [linux] - libc: [musl] - - '@rollup/rollup-openbsd-x64@4.63.1': - resolution: {integrity: sha512-dlfCOa87o1VAYegLQ9EKilx2JCeRofiyPGhTCmqnuXZ6bMPiycO1rq1+sKoulAp7pGLIsTIw+1x5R+zgh5LhhA==} - cpu: [x64] - os: [openbsd] - - '@rollup/rollup-openharmony-arm64@4.63.1': - resolution: {integrity: sha512-cjkLbOlfcm3QGhMM1J5zaZjsw1GggbN6rw9UTSSRrPrR1KkcXnN7Uq9rPw34xImQ9VOY9GN+6u2Zj80B9ptkcw==} - cpu: [arm64] - os: [openharmony] - - '@rollup/rollup-win32-arm64-msvc@4.63.1': - resolution: {integrity: sha512-Li1KdUnWGE4N3e1F/B4RTB1ms+nG4WBgjByO46pkeBVX/2UBsY53xf5vK9WygVmnH3RwncIST7lkSdLSY6P9lg==} - cpu: [arm64] - os: [win32] - - '@rollup/rollup-win32-ia32-msvc@4.63.1': - resolution: {integrity: sha512-t4ZYOSoLTgwhuFMrmTMLx/+i1DQVK7HYqMc6kY46EApwi8X0nIVphzdNoThU3xt6n+N5urG1/gxBdCaKDLavfg==} - cpu: [ia32] - os: [win32] - - '@rollup/rollup-win32-x64-gnu@4.63.1': - resolution: {integrity: sha512-RgroPfMmKlD1RzSDxvwgcPiy2HNQKoYV7OmwIXDsk73uKW5t6B/V8KIy27SMv/FNXFo/oSBtWc9J0X7t91ezZg==} - cpu: [x64] - os: [win32] - - '@rollup/rollup-win32-x64-msvc@4.63.1': - resolution: {integrity: sha512-at8QVep6S3h5Y6gSbdGU06bRY5WJkf6WUduM9YtvYMbYhB1MOFfUgc6kehitQXzOtMSaT70q7f9ydPhpqu821w==} - cpu: [x64] - os: [win32] - - '@types/babel__core@7.20.5': - resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==} - - '@types/babel__generator@7.27.0': - resolution: {integrity: sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==} - - '@types/babel__template@7.4.4': - resolution: {integrity: sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==} - - '@types/babel__traverse@7.28.0': - resolution: {integrity: sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==} - - '@types/estree@1.0.9': - resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} - - '@types/react-dom@19.1.9': - resolution: {integrity: sha512-qXRuZaOsAdXKFyOhRBg6Lqqc0yay13vN7KrIg4L7N4aaHN68ma9OK3NE1BoDFgFOTfM7zg+3/8+2n8rLUH3OKQ==} - peerDependencies: - '@types/react': ^19.0.0 - - '@types/react@19.1.12': - resolution: {integrity: sha512-cMoR+FoAf/Jyq6+Df2/Z41jISvGZZ2eTlnsaJRptmZ76Caldwy1odD4xTr/gNV9VLj0AWgg/nmkevIyUfIIq5w==} - - '@vitejs/plugin-react@5.0.2': - resolution: {integrity: sha512-tmyFgixPZCx2+e6VO9TNITWcCQl8+Nl/E8YbAyPVv85QCc7/A3JrdfG2A8gIzvVhWuzMOVrFW1aReaNxrI6tbw==} - engines: {node: ^20.19.0 || >=22.12.0} - peerDependencies: - vite: ^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 - - baseline-browser-mapping@2.11.22: - resolution: {integrity: sha512-pWc4w51fBFd7mav43/zKRC+RI6f4yfzQoVlfvE8dECePyfkn1bzLp01Fj0QACcyCZyFhiEMyD2qScfKRWgWibA==} - engines: {node: '>=6.0.0'} - hasBin: true - - browserslist@4.28.9: - resolution: {integrity: sha512-EWazOblFYUvlGZcfGhPUPmYh3nikUxBVb+y9MJun5f3hBi812X+8MSQTujLBtgK3cf51fJWbWfOjyeO954d+Eg==} - engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} - hasBin: true - - caniuse-lite@1.0.30001810: - resolution: {integrity: sha512-TITQPUkaz+aVk5GL6NhOdwk1aEaNTSDPsGFWrTuhKGtjTF70jL/Oht2W4c6rXUe5fu7Ie19VIahAXHIIiWWNeg==} - - convert-source-map@2.0.0: - resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} - - csstype@3.2.3: - resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} - - debug@4.4.3: - resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} - engines: {node: '>=6.0'} - peerDependencies: - supports-color: '*' - peerDependenciesMeta: - supports-color: - optional: true - - electron-to-chromium@1.5.426: - resolution: {integrity: sha512-2Gcq6inCQs/AqfHP3f5ftCzk+pqeW2VlA1LgmPqEj2hfBO8HiZRspNYkD4HzFBe4u6lGqca4BspFr6Ix4Q400g==} - - esbuild@0.25.12: - resolution: {integrity: sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==} - engines: {node: '>=18'} - hasBin: true - - escalade@3.2.0: - resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} - engines: {node: '>=6'} - - fdir@6.5.0: - resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} - engines: {node: '>=12.0.0'} - peerDependencies: - picomatch: ^3 || ^4 - peerDependenciesMeta: - picomatch: - optional: true - - fsevents@2.3.3: - resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} - engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} - os: [darwin] - - gensync@1.0.0-beta.2: - resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==} - engines: {node: '>=6.9.0'} - - js-tokens@4.0.0: - resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} - - jsesc@3.1.0: - resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==} - engines: {node: '>=6'} - hasBin: true - - json5@2.2.3: - resolution: {integrity: sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==} - engines: {node: '>=6'} - hasBin: true - - lru-cache@5.1.1: - resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} - - ms@2.1.3: - resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} - - nanoid@3.3.19: - resolution: {integrity: sha512-Y2tUNy4ouw6tq5oDSKeQYGOyhkUBhNOcGV/02KC+6kd9eDGqdZd++mjMiIDilrBYvjEnCYvVtsuHCuP+okSfug==} - engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} - hasBin: true - - node-releases@2.0.55: - resolution: {integrity: sha512-mIrE/Cw9y+9Au6dS5vDKDhQza9YvG6w+ZrS6X+ZzA7yFW/soAeaups4Qzn1bL6g5FVy8WtP79+0j82oPIbqRjQ==} - engines: {node: '>=18'} - - picocolors@1.1.1: - resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} - - picomatch@4.0.7: - resolution: {integrity: sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==} - engines: {node: '>=12'} - - postcss@8.5.28: - resolution: {integrity: sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==} - engines: {node: ^10 || ^12 || >=14} - - react-dom@19.1.1: - resolution: {integrity: sha512-Dlq/5LAZgF0Gaz6yiqZCf6VCcZs1ghAJyrsu84Q/GT0gV+mCxbfmKNoGRKBYMJ8IEdGPqu49YWXD02GCknEDkw==} - peerDependencies: - react: ^19.1.1 - - react-refresh@0.17.0: - resolution: {integrity: sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==} - engines: {node: '>=0.10.0'} - - react@19.1.1: - resolution: {integrity: sha512-w8nqGImo45dmMIfljjMwOGtbmC/mk4CMYhWIicdSflH91J9TyCyczcPFXJzrZ/ZXcgGRFeP6BU0BEJTw6tZdfQ==} - engines: {node: '>=0.10.0'} - - rollup@4.63.1: - resolution: {integrity: sha512-3Df9jsstwhccuEfmAMi9l8XUh/GOkVObmFTU7CCVBysEbcOZLl84jCtaAZMcPiMz2EGKsATzQcU+Xr3n/wU6cg==} - engines: {node: '>=18.0.0', npm: '>=8.0.0'} - hasBin: true - - scheduler@0.26.0: - resolution: {integrity: sha512-NlHwttCI/l5gCPR3D1nNXtWABUmBwvZpEQiD4IXSbIDq8BzLIK/7Ir5gTFSGZDUu37K5cMNp0hFtzO38sC7gWA==} - - semver@6.3.1: - resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} - hasBin: true - - source-map-js@1.2.1: - resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} - engines: {node: '>=0.10.0'} - - tinyglobby@0.2.17: - resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} - engines: {node: '>=12.0.0'} - - typescript@5.8.3: - resolution: {integrity: sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==} - engines: {node: '>=14.17'} - hasBin: true - - update-browserslist-db@1.3.3: - resolution: {integrity: sha512-pJ2sYawQS0R/WI928Gj5GlPhTGzbMelq0+4INtSYNDV9ErKJcX6xjGWkoG/VnB3dpUm00zALaqkrUD77pO5TDQ==} - hasBin: true - peerDependencies: - browserslist: '>= 4.21.0' - - vite@7.1.7: - resolution: {integrity: sha512-VbA8ScMvAISJNJVbRDTJdCwqQoAareR/wutevKanhR2/1EkoXVZVkkORaYm/tNVCjP/UDTKtcw3bAkwOUdedmA==} - engines: {node: ^20.19.0 || >=22.12.0} - hasBin: true - peerDependencies: - '@types/node': ^20.19.0 || >=22.12.0 - jiti: '>=1.21.0' - less: ^4.0.0 - lightningcss: ^1.21.0 - sass: ^1.70.0 - sass-embedded: ^1.70.0 - stylus: '>=0.54.8' - sugarss: ^5.0.0 - terser: ^5.16.0 - tsx: ^4.8.1 - yaml: ^2.4.2 - peerDependenciesMeta: - '@types/node': - optional: true - jiti: - optional: true - less: - optional: true - lightningcss: - optional: true - sass: - optional: true - sass-embedded: - optional: true - stylus: - optional: true - sugarss: - optional: true - terser: - optional: true - tsx: - optional: true - yaml: - optional: true - - yallist@3.1.1: - resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} - -snapshots: - - '@babel/code-frame@7.29.7': - dependencies: - '@babel/helper-validator-identifier': 7.29.7 - js-tokens: 4.0.0 - picocolors: 1.1.1 - - '@babel/compat-data@7.29.7': {} - - '@babel/core@7.29.7': - dependencies: - '@babel/code-frame': 7.29.7 - '@babel/generator': 7.29.8 - '@babel/helper-compilation-targets': 7.29.7 - '@babel/helper-module-transforms': 7.29.7(@babel/core@7.29.7) - '@babel/helpers': 7.29.7 - '@babel/parser': 7.29.8 - '@babel/template': 7.29.7 - '@babel/traverse': 7.29.8 - '@babel/types': 7.29.8 - '@jridgewell/remapping': 2.3.5 - convert-source-map: 2.0.0 - debug: 4.4.3 - gensync: 1.0.0-beta.2 - json5: 2.2.3 - semver: 6.3.1 - transitivePeerDependencies: - - supports-color - - '@babel/generator@7.29.8': - dependencies: - '@babel/parser': 7.29.8 - '@babel/types': 7.29.8 - '@jridgewell/gen-mapping': 0.3.13 - '@jridgewell/trace-mapping': 0.3.31 - jsesc: 3.1.0 - - '@babel/helper-compilation-targets@7.29.7': - dependencies: - '@babel/compat-data': 7.29.7 - '@babel/helper-validator-option': 7.29.7 - browserslist: 4.28.9 - lru-cache: 5.1.1 - semver: 6.3.1 - - '@babel/helper-globals@7.29.7': {} - - '@babel/helper-module-imports@7.29.7': - dependencies: - '@babel/traverse': 7.29.8 - '@babel/types': 7.29.8 - transitivePeerDependencies: - - supports-color - - '@babel/helper-module-transforms@7.29.7(@babel/core@7.29.7)': - dependencies: - '@babel/core': 7.29.7 - '@babel/helper-module-imports': 7.29.7 - '@babel/helper-validator-identifier': 7.29.7 - '@babel/traverse': 7.29.8 - transitivePeerDependencies: - - supports-color - - '@babel/helper-plugin-utils@7.29.7': {} - - '@babel/helper-string-parser@7.29.7': {} - - '@babel/helper-validator-identifier@7.29.7': {} - - '@babel/helper-validator-option@7.29.7': {} - - '@babel/helpers@7.29.7': - dependencies: - '@babel/template': 7.29.7 - '@babel/types': 7.29.8 - - '@babel/parser@7.29.8': - dependencies: - '@babel/types': 7.29.8 - - '@babel/plugin-transform-react-jsx-self@7.29.7(@babel/core@7.29.7)': - dependencies: - '@babel/core': 7.29.7 - '@babel/helper-plugin-utils': 7.29.7 - - '@babel/plugin-transform-react-jsx-source@7.29.7(@babel/core@7.29.7)': - dependencies: - '@babel/core': 7.29.7 - '@babel/helper-plugin-utils': 7.29.7 - - '@babel/template@7.29.7': - dependencies: - '@babel/code-frame': 7.29.7 - '@babel/parser': 7.29.8 - '@babel/types': 7.29.8 - - '@babel/traverse@7.29.8': - 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.4.3 - transitivePeerDependencies: - - supports-color - - '@babel/types@7.29.8': - dependencies: - '@babel/helper-string-parser': 7.29.7 - '@babel/helper-validator-identifier': 7.29.7 - - '@biomejs/biome@2.2.4': - optionalDependencies: - '@biomejs/cli-darwin-arm64': 2.2.4 - '@biomejs/cli-darwin-x64': 2.2.4 - '@biomejs/cli-linux-arm64': 2.2.4 - '@biomejs/cli-linux-arm64-musl': 2.2.4 - '@biomejs/cli-linux-x64': 2.2.4 - '@biomejs/cli-linux-x64-musl': 2.2.4 - '@biomejs/cli-win32-arm64': 2.2.4 - '@biomejs/cli-win32-x64': 2.2.4 - - '@biomejs/cli-darwin-arm64@2.2.4': - optional: true - - '@biomejs/cli-darwin-x64@2.2.4': - optional: true - - '@biomejs/cli-linux-arm64-musl@2.2.4': - optional: true - - '@biomejs/cli-linux-arm64@2.2.4': - optional: true - - '@biomejs/cli-linux-x64-musl@2.2.4': - optional: true - - '@biomejs/cli-linux-x64@2.2.4': - optional: true - - '@biomejs/cli-win32-arm64@2.2.4': - optional: true - - '@biomejs/cli-win32-x64@2.2.4': - optional: true - - '@esbuild/aix-ppc64@0.25.12': - optional: true - - '@esbuild/android-arm64@0.25.12': - optional: true - - '@esbuild/android-arm@0.25.12': - optional: true - - '@esbuild/android-x64@0.25.12': - optional: true - - '@esbuild/darwin-arm64@0.25.12': - optional: true - - '@esbuild/darwin-x64@0.25.12': - optional: true - - '@esbuild/freebsd-arm64@0.25.12': - optional: true - - '@esbuild/freebsd-x64@0.25.12': - optional: true - - '@esbuild/linux-arm64@0.25.12': - optional: true - - '@esbuild/linux-arm@0.25.12': - optional: true - - '@esbuild/linux-ia32@0.25.12': - optional: true - - '@esbuild/linux-loong64@0.25.12': - optional: true - - '@esbuild/linux-mips64el@0.25.12': - optional: true - - '@esbuild/linux-ppc64@0.25.12': - optional: true - - '@esbuild/linux-riscv64@0.25.12': - optional: true - - '@esbuild/linux-s390x@0.25.12': - optional: true - - '@esbuild/linux-x64@0.25.12': - optional: true - - '@esbuild/netbsd-arm64@0.25.12': - optional: true - - '@esbuild/netbsd-x64@0.25.12': - optional: true - - '@esbuild/openbsd-arm64@0.25.12': - optional: true - - '@esbuild/openbsd-x64@0.25.12': - optional: true - - '@esbuild/openharmony-arm64@0.25.12': - optional: true - - '@esbuild/sunos-x64@0.25.12': - optional: true - - '@esbuild/win32-arm64@0.25.12': - optional: true - - '@esbuild/win32-ia32@0.25.12': - optional: true - - '@esbuild/win32-x64@0.25.12': - optional: true - - '@jridgewell/gen-mapping@0.3.13': - dependencies: - '@jridgewell/sourcemap-codec': 1.6.0 - '@jridgewell/trace-mapping': 0.3.31 - - '@jridgewell/remapping@2.3.5': - dependencies: - '@jridgewell/gen-mapping': 0.3.13 - '@jridgewell/trace-mapping': 0.3.31 - - '@jridgewell/resolve-uri@3.1.2': {} - - '@jridgewell/sourcemap-codec@1.6.0': {} - - '@jridgewell/trace-mapping@0.3.31': - dependencies: - '@jridgewell/resolve-uri': 3.1.2 - '@jridgewell/sourcemap-codec': 1.6.0 - - '@napi-rs/lzma-linux-x64-gnu@1.5.1': - optional: true - - '@rolldown/pluginutils@1.0.0-beta.34': {} - - '@rollup/rollup-android-arm-eabi@4.63.1': - optional: true - - '@rollup/rollup-android-arm64@4.63.1': - optional: true - - '@rollup/rollup-darwin-arm64@4.63.1': - optional: true - - '@rollup/rollup-darwin-x64@4.63.1': - optional: true - - '@rollup/rollup-freebsd-arm64@4.63.1': - optional: true - - '@rollup/rollup-freebsd-x64@4.63.1': - optional: true - - '@rollup/rollup-linux-arm-gnueabihf@4.63.1': - optional: true - - '@rollup/rollup-linux-arm-musleabihf@4.63.1': - optional: true - - '@rollup/rollup-linux-arm64-gnu@4.63.1': - optional: true - - '@rollup/rollup-linux-arm64-musl@4.63.1': - optional: true - - '@rollup/rollup-linux-loong64-gnu@4.63.1': - optional: true - - '@rollup/rollup-linux-loong64-musl@4.63.1': - optional: true - - '@rollup/rollup-linux-ppc64-gnu@4.63.1': - optional: true - - '@rollup/rollup-linux-ppc64-musl@4.63.1': - optional: true - - '@rollup/rollup-linux-riscv64-gnu@4.63.1': - optional: true - - '@rollup/rollup-linux-riscv64-musl@4.63.1': - optional: true - - '@rollup/rollup-linux-s390x-gnu@4.63.1': - optional: true - - '@rollup/rollup-linux-x64-gnu@4.63.1': - optional: true - - '@rollup/rollup-linux-x64-musl@4.63.1': - optional: true - - '@rollup/rollup-openbsd-x64@4.63.1': - optional: true - - '@rollup/rollup-openharmony-arm64@4.63.1': - optional: true - - '@rollup/rollup-win32-arm64-msvc@4.63.1': - optional: true - - '@rollup/rollup-win32-ia32-msvc@4.63.1': - optional: true - - '@rollup/rollup-win32-x64-gnu@4.63.1': - optional: true - - '@rollup/rollup-win32-x64-msvc@4.63.1': - optional: true - - '@types/babel__core@7.20.5': - dependencies: - '@babel/parser': 7.29.8 - '@babel/types': 7.29.8 - '@types/babel__generator': 7.27.0 - '@types/babel__template': 7.4.4 - '@types/babel__traverse': 7.28.0 - - '@types/babel__generator@7.27.0': - dependencies: - '@babel/types': 7.29.8 - - '@types/babel__template@7.4.4': - dependencies: - '@babel/parser': 7.29.8 - '@babel/types': 7.29.8 - - '@types/babel__traverse@7.28.0': - dependencies: - '@babel/types': 7.29.8 - - '@types/estree@1.0.9': {} - - '@types/react-dom@19.1.9(@types/react@19.1.12)': - dependencies: - '@types/react': 19.1.12 - - '@types/react@19.1.12': - dependencies: - csstype: 3.2.3 - - '@vitejs/plugin-react@5.0.2(vite@7.1.7)': - dependencies: - '@babel/core': 7.29.7 - '@babel/plugin-transform-react-jsx-self': 7.29.7(@babel/core@7.29.7) - '@babel/plugin-transform-react-jsx-source': 7.29.7(@babel/core@7.29.7) - '@rolldown/pluginutils': 1.0.0-beta.34 - '@types/babel__core': 7.20.5 - react-refresh: 0.17.0 - vite: 7.1.7 - transitivePeerDependencies: - - supports-color - - baseline-browser-mapping@2.11.22: {} - - browserslist@4.28.9: - dependencies: - baseline-browser-mapping: 2.11.22 - caniuse-lite: 1.0.30001810 - electron-to-chromium: 1.5.426 - node-releases: 2.0.55 - update-browserslist-db: 1.3.3(browserslist@4.28.9) - - caniuse-lite@1.0.30001810: {} - - convert-source-map@2.0.0: {} - - csstype@3.2.3: {} - - debug@4.4.3: - dependencies: - ms: 2.1.3 - - electron-to-chromium@1.5.426: {} - - esbuild@0.25.12: - optionalDependencies: - '@esbuild/aix-ppc64': 0.25.12 - '@esbuild/android-arm': 0.25.12 - '@esbuild/android-arm64': 0.25.12 - '@esbuild/android-x64': 0.25.12 - '@esbuild/darwin-arm64': 0.25.12 - '@esbuild/darwin-x64': 0.25.12 - '@esbuild/freebsd-arm64': 0.25.12 - '@esbuild/freebsd-x64': 0.25.12 - '@esbuild/linux-arm': 0.25.12 - '@esbuild/linux-arm64': 0.25.12 - '@esbuild/linux-ia32': 0.25.12 - '@esbuild/linux-loong64': 0.25.12 - '@esbuild/linux-mips64el': 0.25.12 - '@esbuild/linux-ppc64': 0.25.12 - '@esbuild/linux-riscv64': 0.25.12 - '@esbuild/linux-s390x': 0.25.12 - '@esbuild/linux-x64': 0.25.12 - '@esbuild/netbsd-arm64': 0.25.12 - '@esbuild/netbsd-x64': 0.25.12 - '@esbuild/openbsd-arm64': 0.25.12 - '@esbuild/openbsd-x64': 0.25.12 - '@esbuild/openharmony-arm64': 0.25.12 - '@esbuild/sunos-x64': 0.25.12 - '@esbuild/win32-arm64': 0.25.12 - '@esbuild/win32-ia32': 0.25.12 - '@esbuild/win32-x64': 0.25.12 - - escalade@3.2.0: {} - - fdir@6.5.0(picomatch@4.0.7): - optionalDependencies: - picomatch: 4.0.7 - - fsevents@2.3.3: - optional: true - - gensync@1.0.0-beta.2: {} - - js-tokens@4.0.0: {} - - jsesc@3.1.0: {} - - json5@2.2.3: {} - - lru-cache@5.1.1: - dependencies: - yallist: 3.1.1 - - ms@2.1.3: {} - - nanoid@3.3.19: {} - - node-releases@2.0.55: {} - - picocolors@1.1.1: {} - - picomatch@4.0.7: {} - - postcss@8.5.28: - dependencies: - nanoid: 3.3.19 - picocolors: 1.1.1 - source-map-js: 1.2.1 - - react-dom@19.1.1(react@19.1.1): - dependencies: - react: 19.1.1 - scheduler: 0.26.0 - - react-refresh@0.17.0: {} - - react@19.1.1: {} - - rollup@4.63.1: - dependencies: - '@types/estree': 1.0.9 - optionalDependencies: - '@napi-rs/lzma-linux-x64-gnu': 1.5.1 - '@rollup/rollup-android-arm-eabi': 4.63.1 - '@rollup/rollup-android-arm64': 4.63.1 - '@rollup/rollup-darwin-arm64': 4.63.1 - '@rollup/rollup-darwin-x64': 4.63.1 - '@rollup/rollup-freebsd-arm64': 4.63.1 - '@rollup/rollup-freebsd-x64': 4.63.1 - '@rollup/rollup-linux-arm-gnueabihf': 4.63.1 - '@rollup/rollup-linux-arm-musleabihf': 4.63.1 - '@rollup/rollup-linux-arm64-gnu': 4.63.1 - '@rollup/rollup-linux-arm64-musl': 4.63.1 - '@rollup/rollup-linux-loong64-gnu': 4.63.1 - '@rollup/rollup-linux-loong64-musl': 4.63.1 - '@rollup/rollup-linux-ppc64-gnu': 4.63.1 - '@rollup/rollup-linux-ppc64-musl': 4.63.1 - '@rollup/rollup-linux-riscv64-gnu': 4.63.1 - '@rollup/rollup-linux-riscv64-musl': 4.63.1 - '@rollup/rollup-linux-s390x-gnu': 4.63.1 - '@rollup/rollup-linux-x64-gnu': 4.63.1 - '@rollup/rollup-linux-x64-musl': 4.63.1 - '@rollup/rollup-openbsd-x64': 4.63.1 - '@rollup/rollup-openharmony-arm64': 4.63.1 - '@rollup/rollup-win32-arm64-msvc': 4.63.1 - '@rollup/rollup-win32-ia32-msvc': 4.63.1 - '@rollup/rollup-win32-x64-gnu': 4.63.1 - '@rollup/rollup-win32-x64-msvc': 4.63.1 - fsevents: 2.3.3 - - scheduler@0.26.0: {} - - semver@6.3.1: {} - - source-map-js@1.2.1: {} - - tinyglobby@0.2.17: - dependencies: - fdir: 6.5.0(picomatch@4.0.7) - picomatch: 4.0.7 - - typescript@5.8.3: {} - - update-browserslist-db@1.3.3(browserslist@4.28.9): - dependencies: - browserslist: 4.28.9 - escalade: 3.2.0 - picocolors: 1.1.1 - - vite@7.1.7: - dependencies: - esbuild: 0.25.12 - fdir: 6.5.0(picomatch@4.0.7) - picomatch: 4.0.7 - postcss: 8.5.28 - rollup: 4.63.1 - tinyglobby: 0.2.17 - optionalDependencies: - fsevents: 2.3.3 - - yallist@3.1.1: {} diff --git a/frontend/src/app/App.tsx b/frontend/src/app/App.tsx deleted file mode 100644 index f188dcf..0000000 --- a/frontend/src/app/App.tsx +++ /dev/null @@ -1,5 +0,0 @@ -import { SessionConsole } from "../pages/session-console"; - -export function App() { - return ; -} diff --git a/frontend/src/app/styles.css b/frontend/src/app/styles.css deleted file mode 100644 index 6492c15..0000000 --- a/frontend/src/app/styles.css +++ /dev/null @@ -1,53 +0,0 @@ -:root { - color: #1d2433; - font-family: system-ui, sans-serif; - background: #f4f6fa; -} - -body { - margin: 0; -} - -main { - max-width: 48rem; - margin: 2rem auto; - padding: 1.5rem; - background: #fff; - border-radius: 0.5rem; -} - -section { - display: flex; - gap: 0.75rem; - flex-wrap: wrap; - margin: 1rem 0; -} - -label { - display: grid; - gap: 0.25rem; -} - -input { - min-width: 18rem; -} - -dl { - display: grid; - grid-template-columns: max-content 1fr; - gap: 0.4rem 1rem; -} - -dt { - font-weight: 700; -} - -pre { - overflow: auto; - padding: 1rem; - background: #eef1f6; -} - -[role="alert"] { - color: #a21c28; -} diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx deleted file mode 100644 index c17d213..0000000 --- a/frontend/src/main.tsx +++ /dev/null @@ -1,16 +0,0 @@ -import { StrictMode } from "react"; -import { createRoot } from "react-dom/client"; -import { App } from "./app/App"; -import "./app/styles.css"; - -const root = document.getElementById("root"); - -if (root === null) { - throw new Error("Missing root element"); -} - -createRoot(root).render( - - - , -); diff --git a/frontend/src/pages/session-console/api/control-connection.ts b/frontend/src/pages/session-console/api/control-connection.ts deleted file mode 100644 index 6ba0a74..0000000 --- a/frontend/src/pages/session-console/api/control-connection.ts +++ /dev/null @@ -1,68 +0,0 @@ -import { - parseServerEvent, - serializeResumeCommand, - serializeStartCommand, - type ServerEvent, -} from "./protocol"; - -type Callbacks = { - onOpen: () => void; - onClosed: (reason: string) => void; - onEvent: (event: ServerEvent) => void; - onError: (message: string) => void; -}; - -export function createControlConnection(callbacks: Callbacks): { - connect(sessionId: string, lastEventId: string | null): void; - close(): void; - checkStart(): boolean; -} { - let socket: WebSocket | null = null; - let owner = 0; - - function connect(sessionId: string, lastEventId: string | null): void { - close(); - const currentOwner = ++owner; - const protocol = window.location.protocol === "https:" ? "wss:" : "ws:"; - const next = new WebSocket( - `${protocol}//${window.location.host}/api/sessions/${sessionId}/control`, - ); - socket = next; - next.addEventListener("open", () => { - if (currentOwner !== owner) return; - callbacks.onOpen(); - next.send(serializeResumeCommand(sessionId, lastEventId)); - }); - next.addEventListener("message", (message) => { - if (currentOwner !== owner) return; - const event = parseServerEvent(message.data, sessionId); - if (event === null) callbacks.onError("Control sent an invalid event."); - else callbacks.onEvent(event); - }); - next.addEventListener("close", (event) => { - if (currentOwner === owner) - callbacks.onClosed(event.reason || `Control closed (${event.code}).`); - }); - next.addEventListener("error", () => { - if (currentOwner === owner) - callbacks.onError("Control transport failed."); - }); - } - - function close(): void { - owner += 1; - const previous = socket; - socket = null; - previous?.close(); - } - - function checkStart(): boolean { - if (socket?.readyState !== WebSocket.OPEN) return false; - socket.send( - serializeStartCommand(new URL(socket.url).pathname.split("/")[3]), - ); - return true; - } - - return { connect, close, checkStart }; -} diff --git a/frontend/src/pages/session-console/api/http.ts b/frontend/src/pages/session-console/api/http.ts deleted file mode 100644 index eab239e..0000000 --- a/frontend/src/pages/session-console/api/http.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { isSessionBootstrap } from "./protocol"; - -export async function createSession(): Promise<{ - sessionId: string; - lastEventId: null; -}> { - const response = await fetch("/api/sessions", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: "{}", - }); - const payload: unknown = await response.json().catch(() => null); - if (!response.ok || !isSessionBootstrap(payload)) { - throw new Error("Session creation failed; its outcome may be unknown."); - } - return { sessionId: payload.session_id, lastEventId: null }; -} diff --git a/frontend/src/pages/session-console/api/protocol.ts b/frontend/src/pages/session-console/api/protocol.ts deleted file mode 100644 index a97c057..0000000 --- a/frontend/src/pages/session-console/api/protocol.ts +++ /dev/null @@ -1,103 +0,0 @@ -import { isCursor, isRecord, isSessionId } from "../lib/identifiers.ts"; - -export type Snapshot = { - type: "session.snapshot"; - session_id: string; - event_id: string; - connection: "disconnected"; - conversation: "off"; - active_turn: null; - messages: []; - tasks: []; - deliveries: []; -}; - -export type ControlError = { - type: "error"; - session_id: string; - event_id: string; - component: string; - code: string; - message: string; -}; - -export type ServerEvent = Snapshot | ControlError; - -export function parseServerEvent( - value: unknown, - expectedSessionId: string, -): ServerEvent | null { - if (typeof value !== "string") { - return null; - } - try { - const payload: unknown = JSON.parse(value); - if ( - !isRecord(payload) || - payload.session_id !== expectedSessionId || - !isCursor(payload.event_id) - ) { - return null; - } - if (isSnapshot(payload) || isControlError(payload)) { - return payload; - } - } catch { - return null; - } - return null; -} - -export function isSessionBootstrap( - value: unknown, -): value is { session_id: string; last_event_id: null } { - return ( - isRecord(value) && - isSessionId(value.session_id) && - value.last_event_id === null - ); -} - -export function serializeResumeCommand( - sessionId: string, - lastEventId: string | null, -): string { - return JSON.stringify({ - type: "session.resume", - session_id: sessionId, - event_id: crypto.randomUUID(), - last_event_id: lastEventId, - }); -} - -export function serializeStartCommand(sessionId: string): string { - return JSON.stringify({ - type: "conversation.start", - session_id: sessionId, - event_id: crypto.randomUUID(), - }); -} - -function isSnapshot(value: Record): value is Snapshot { - return ( - value.type === "session.snapshot" && - value.connection === "disconnected" && - value.conversation === "off" && - value.active_turn === null && - Array.isArray(value.messages) && - value.messages.length === 0 && - Array.isArray(value.tasks) && - value.tasks.length === 0 && - Array.isArray(value.deliveries) && - value.deliveries.length === 0 - ); -} - -function isControlError(value: Record): value is ControlError { - return ( - value.type === "error" && - typeof value.component === "string" && - typeof value.code === "string" && - typeof value.message === "string" - ); -} diff --git a/frontend/src/pages/session-console/index.ts b/frontend/src/pages/session-console/index.ts deleted file mode 100644 index ca99b43..0000000 --- a/frontend/src/pages/session-console/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { SessionConsole } from "./ui/SessionConsole"; diff --git a/frontend/src/pages/session-console/lib/identifiers.ts b/frontend/src/pages/session-console/lib/identifiers.ts deleted file mode 100644 index 0ff20a3..0000000 --- a/frontend/src/pages/session-console/lib/identifiers.ts +++ /dev/null @@ -1,28 +0,0 @@ -export const MAX_CURSOR = 9223372036854775807n; - -const sessionPattern = - /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/; -const cursorPattern = /^e[1-9][0-9]*$/; - -export function isRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null; -} - -export function isSessionId(value: unknown): value is string { - return typeof value === "string" && sessionPattern.test(value); -} - -export function isCursor(value: unknown): value is string { - if (typeof value !== "string" || !cursorPattern.test(value)) { - return false; - } - try { - return cursorValue(value) <= MAX_CURSOR; - } catch { - return false; - } -} - -export function cursorValue(value: string): bigint { - return BigInt(value.slice(1)); -} diff --git a/frontend/src/pages/session-console/model/projection.ts b/frontend/src/pages/session-console/model/projection.ts deleted file mode 100644 index 58c65b6..0000000 --- a/frontend/src/pages/session-console/model/projection.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { cursorValue } from "../lib/identifiers.ts"; -import type { ControlError, ServerEvent, Snapshot } from "../api/protocol.ts"; - -export type SessionProjection = { - sessionId: string; - lastEventId: string | null; - snapshot: Snapshot | null; - error: ControlError | null; -}; - -export function resetProjection(sessionId: string): SessionProjection { - return { sessionId, lastEventId: null, snapshot: null, error: null }; -} - -export function applyServerEvent( - state: SessionProjection, - event: ServerEvent, -): SessionProjection { - if ( - event.session_id !== state.sessionId || - (state.lastEventId !== null && - cursorValue(event.event_id) <= cursorValue(state.lastEventId)) - ) - return state; - if (event.type === "session.snapshot") - return { - ...state, - lastEventId: event.event_id, - snapshot: event, - error: null, - }; - return { ...state, lastEventId: event.event_id, error: event }; -} diff --git a/frontend/src/pages/session-console/model/session-storage.ts b/frontend/src/pages/session-console/model/session-storage.ts deleted file mode 100644 index 5578f4c..0000000 --- a/frontend/src/pages/session-console/model/session-storage.ts +++ /dev/null @@ -1,69 +0,0 @@ -import { isCursor, isRecord, isSessionId } from "../lib/identifiers"; - -const storageKey = "voca.session"; -export type StoredSession = { sessionId: string; lastEventId: string | null }; - -export function loadSession(): { - session: StoredSession | null; - error: string | null; -} { - try { - const raw = localStorage.getItem(storageKey); - if (raw === null) return { session: null, error: null }; - let value: unknown; - try { - value = JSON.parse(raw); - } catch { - try { - localStorage.removeItem(storageKey); - } catch { - return { - session: null, - error: "Session persistence between reloads is unavailable.", - }; - } - return { - session: null, - error: "Stored session was invalid and was cleared.", - }; - } - if ( - !isRecord(value) || - !isSessionId(value.session_id) || - (value.last_event_id !== null && !isCursor(value.last_event_id)) - ) { - localStorage.removeItem(storageKey); - return { - session: null, - error: "Stored session was invalid and was cleared.", - }; - } - return { - session: { - sessionId: value.session_id, - lastEventId: value.last_event_id, - }, - error: null, - }; - } catch { - return { - session: null, - error: "Session persistence between reloads is unavailable.", - }; - } -} - -export function persistSession(session: StoredSession): string | null { - try { - localStorage.setItem( - storageKey, - JSON.stringify({ - session_id: session.sessionId, - last_event_id: session.lastEventId, - }), - ); - return null; - } catch { - return "Session persistence between reloads is unavailable."; - } -} diff --git a/frontend/src/pages/session-console/model/use-session-console.ts b/frontend/src/pages/session-console/model/use-session-console.ts deleted file mode 100644 index aa39273..0000000 --- a/frontend/src/pages/session-console/model/use-session-console.ts +++ /dev/null @@ -1,148 +0,0 @@ -import { useEffect, useRef, useState } from "react"; - -import { createControlConnection } from "../api/control-connection"; -import { createSession } from "../api/http"; -import { isCursor, isSessionId } from "../lib/identifiers"; -import { - applyServerEvent, - resetProjection, - type SessionProjection, -} from "./projection"; -import { - loadSession, - persistSession, - type StoredSession, -} from "./session-storage"; - -export function useSessionConsole() { - const [session, setSession] = useState(null); - const [projection, setProjection] = useState(null); - const [sessionInput, setSessionInput] = useState(""); - const [cursorInput, setCursorInput] = useState(""); - const [transport, setTransport] = useState("closed"); - const [error, setError] = useState(null); - const [creating, setCreating] = useState(false); - const connection = useRef | null>( - null, - ); - const mounted = useRef(false); - - useEffect(() => { - mounted.current = true; - connection.current = createControlConnection({ - onOpen: () => setTransport("open"), - onClosed: (reason) => { - setTransport("closed"); - setError(reason); - }, - onError: setError, - onEvent: (event) => { - setProjection((previous) => { - if (previous === null) return previous; - const next = applyServerEvent(previous, event); - if (next !== previous) { - const persistenceError = persistSession({ - sessionId: next.sessionId, - lastEventId: next.lastEventId, - }); - if (persistenceError !== null) setError(persistenceError); - setSession({ - sessionId: next.sessionId, - lastEventId: next.lastEventId, - }); - setSessionInput(next.sessionId); - setCursorInput(next.lastEventId ?? ""); - } - return next; - }); - }, - }); - const restored = loadSession(); - if (restored.error !== null) setError(restored.error); - if (restored.session !== null) { - setSession(restored.session); - setSessionInput(restored.session.sessionId); - setCursorInput(restored.session.lastEventId ?? ""); - setProjection(resetProjection(restored.session.sessionId)); - setTransport("connecting"); - connection.current.connect( - restored.session.sessionId, - restored.session.lastEventId, - ); - } - return () => { - mounted.current = false; - connection.current?.close(); - }; - }, []); - - async function handleCreate(): Promise { - setCreating(true); - setError(null); - try { - const created = await createSession(); - if (!mounted.current) return; - const next = { sessionId: created.sessionId, lastEventId: null }; - setSession(next); - setProjection(resetProjection(next.sessionId)); - setSessionInput(next.sessionId); - setCursorInput(""); - const persistenceError = persistSession(next); - if (persistenceError !== null) setError(persistenceError); - setTransport("connecting"); - connection.current?.connect(next.sessionId, null); - } catch (reason) { - if (mounted.current) - setError( - reason instanceof Error ? reason.message : "Session creation failed.", - ); - } finally { - if (mounted.current) setCreating(false); - } - } - - function reconnect(): void { - if (projection === null) return; - setTransport("connecting"); - connection.current?.connect(projection.sessionId, projection.lastEventId); - } - - function restore(): void { - const sessionId = sessionInput.trim(); - const cursor = cursorInput.trim() || null; - if (!isSessionId(sessionId)) { - setError("Enter a canonical session ID before restoring."); - return; - } - if (cursor !== null && !isCursor(cursor)) { - setError("Enter a canonical cursor before restoring."); - return; - } - setSession({ sessionId, lastEventId: null }); - setProjection(resetProjection(sessionId)); - setError(null); - setTransport("connecting"); - connection.current?.connect(sessionId, cursor); - } - - function checkStart(): void { - if (!connection.current?.checkStart()) - setError("Open control before checking start."); - } - - return { - session, - projection, - sessionInput, - cursorInput, - transport, - error, - creating, - setSessionInput, - setCursorInput, - handleCreate, - reconnect, - restore, - checkStart, - }; -} diff --git a/frontend/src/pages/session-console/ui/SessionConsole.tsx b/frontend/src/pages/session-console/ui/SessionConsole.tsx deleted file mode 100644 index 21a9a13..0000000 --- a/frontend/src/pages/session-console/ui/SessionConsole.tsx +++ /dev/null @@ -1,75 +0,0 @@ -import { useSessionConsole } from "../model/use-session-console"; - -export function SessionConsole() { - const model = useSessionConsole(); - return ( -
-

VOCA diagnostic session

-

Voice is not implemented yet.

-
- - - -
-
- - - -
-
-
Control transport
-
{model.transport}
-
Voice connection
-
disconnected
-
Conversation
-
off
-
Session ID
-
{model.session?.sessionId ?? "none"}
-
Cursor
-
{model.projection?.lastEventId ?? "null"}
-
- {model.error !== null &&

{model.error}

} -
-

Last snapshot

-
-          {model.projection?.snapshot === null || model.projection === null
-            ? "No snapshot received."
-            : JSON.stringify(model.projection.snapshot, null, 2)}
-        
-
-
- ); -} diff --git a/frontend/tests/import-boundaries.test.mjs b/frontend/tests/import-boundaries.test.mjs deleted file mode 100644 index 9b68c74..0000000 --- a/frontend/tests/import-boundaries.test.mjs +++ /dev/null @@ -1,81 +0,0 @@ -import test from "node:test"; -import assert from "node:assert/strict"; -import fs from "node:fs"; -import path from "node:path"; -import ts from "typescript"; - -const root = path.resolve("src"); - -function imports(file) { - const source = ts.createSourceFile( - file, - fs.readFileSync(file, "utf8"), - ts.ScriptTarget.Latest, - true, - ); - return source.statements.flatMap((statement) => { - if ( - (ts.isImportDeclaration(statement) || - ts.isExportDeclaration(statement)) && - statement.moduleSpecifier && - ts.isStringLiteral(statement.moduleSpecifier) - ) - return [statement.moduleSpecifier.text]; - return []; - }); -} - -function files(directory) { - return fs - .readdirSync(directory, { withFileTypes: true }) - .flatMap((entry) => - entry.isDirectory() - ? files(path.join(directory, entry.name)) - : [path.join(directory, entry.name)], - ); -} - -test("app enters the page through its public API and page API stays independent", () => { - const app = path.join(root, "app/App.tsx"); - assert.deepEqual(imports(app), ["../pages/session-console"]); - for (const file of files(path.join(root, "pages/session-console/api"))) { - assert.equal( - imports(file).some( - (specifier) => - specifier.includes("/model/") || - specifier.includes("/ui/") || - specifier.includes("/app/"), - ), - false, - file, - ); - } - for (const file of files(root)) { - if ( - !file.includes(`${path.sep}pages${path.sep}session-console${path.sep}`) && - file !== app - ) { - assert.equal( - imports(file).some( - (specifier) => - specifier.includes("pages/session-console/") && - !specifier.endsWith("session-console"), - ), - false, - file, - ); - } - } - const ui = fs.readFileSync( - path.join(root, "pages/session-console/ui/SessionConsole.tsx"), - "utf8", - ); - assert.equal(/\b(fetch|WebSocket|localStorage)\b/.test(ui), false); - const owners = files(root).filter((file) => - fs.readFileSync(file, "utf8").includes("new WebSocket"), - ); - assert.deepEqual( - owners.map((file) => path.relative(root, file)), - ["pages/session-console/api/control-connection.ts"], - ); -}); diff --git a/frontend/tests/session-projection.test.mjs b/frontend/tests/session-projection.test.mjs deleted file mode 100644 index ec4a5ed..0000000 --- a/frontend/tests/session-projection.test.mjs +++ /dev/null @@ -1,87 +0,0 @@ -import test from "node:test"; -import assert from "node:assert/strict"; - -import { isCursor } from "../src/pages/session-console/lib/identifiers.ts"; -import { parseServerEvent } from "../src/pages/session-console/api/protocol.ts"; -import { - applyServerEvent, - resetProjection, -} from "../src/pages/session-console/model/projection.ts"; - -const sessionId = "f1d37af1-2ed6-4bb2-a7a2-5d0777d1db4c"; - -function event(type, eventId, extra = {}) { - return JSON.stringify({ - type, - session_id: sessionId, - event_id: eventId, - ...(type === "session.snapshot" - ? { - connection: "disconnected", - conversation: "off", - active_turn: null, - messages: [], - tasks: [], - deliveries: [], - } - : { - component: "control", - code: "invalid_cursor", - message: "Invalid cursor", - }), - ...extra, - }); -} - -test("projection applies ordered snapshots and errors without losing precision", () => { - let projection = resetProjection(sessionId); - const snapshot = parseServerEvent(event("session.snapshot", "e9"), sessionId); - assert.ok(snapshot); - projection = applyServerEvent(projection, snapshot); - const error = parseServerEvent(event("error", "e10"), sessionId); - assert.ok(error); - projection = applyServerEvent(projection, error); - assert.equal(projection.lastEventId, "e10"); - assert.equal(projection.error?.code, "invalid_cursor"); - assert.ok(projection.snapshot); - const large = parseServerEvent( - event("session.snapshot", "e9007199254740992"), - sessionId, - ); - assert.ok(large); - projection = applyServerEvent(projection, large); - assert.equal(projection.lastEventId, "e9007199254740992"); - assert.equal(applyServerEvent(projection, error), projection); - const foreign = parseServerEvent( - event("session.snapshot", "e11", { - session_id: "a1d37af1-2ed6-4bb2-a7a2-5d0777d1db4c", - }), - sessionId, - ); - assert.equal(foreign, null); -}); - -test("requested future cursor is not a projection watermark", () => { - let projection = resetProjection(sessionId); - const error = parseServerEvent(event("error", "e1"), sessionId); - assert.ok(error); - projection = applyServerEvent(projection, error); - assert.equal(projection.lastEventId, "e1"); - projection = resetProjection(sessionId); - const snapshot = parseServerEvent(event("session.snapshot", "e2"), sessionId); - assert.ok(snapshot); - assert.equal(applyServerEvent(projection, snapshot).lastEventId, "e2"); -}); - -test("cursor validation rejects malformed and out-of-range wire values", () => { - assert.equal(isCursor("e١"), false); - assert.equal(isCursor("e0"), false); - assert.equal(isCursor("e9223372036854775808"), false); - assert.equal( - parseServerEvent( - event("session.snapshot", "e9223372036854775808"), - sessionId, - ), - null, - ); -}); diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json deleted file mode 100644 index 13ca2e7..0000000 --- a/frontend/tsconfig.json +++ /dev/null @@ -1,21 +0,0 @@ -{ - "compilerOptions": { - "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", - "resolveJsonModule": true, - "isolatedModules": true, - "noEmit": true, - "jsx": "react-jsx", - "allowImportingTsExtensions": true - }, - "include": ["src"] -} diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts deleted file mode 100644 index 386d33c..0000000 --- a/frontend/vite.config.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { defineConfig } from "vite"; -import react from "@vitejs/plugin-react"; - -export default defineConfig({ - plugins: [react()], - server: { - host: "127.0.0.1", - strictPort: true, - proxy: { - "/api": { - target: "http://127.0.0.1:8000", - changeOrigin: true, - ws: true, - }, - }, - }, -}); From acaa8309c3f8534cca8b62a7e37bde804bd24b70 Mon Sep 17 00:00:00 2001 From: Pavel Donin Date: Mon, 9 Nov 2026 15:32:40 +0100 Subject: [PATCH 002/104] Add the voca agent package with the web entry --- agent/.python-version | 1 + agent/pyproject.toml | 39 +++ agent/src/voca/__init__.py | 1 + agent/src/voca/__main__.py | 25 ++ agent/src/voca/app/__init__.py | 0 agent/src/voca/app/web.py | 55 +++ agent/src/voca/config.py | 11 + agent/uv.lock | 600 +++++++++++++++++++++++++++++++++ 8 files changed, 732 insertions(+) create mode 100644 agent/.python-version create mode 100644 agent/pyproject.toml create mode 100644 agent/src/voca/__init__.py create mode 100644 agent/src/voca/__main__.py create mode 100644 agent/src/voca/app/__init__.py create mode 100644 agent/src/voca/app/web.py create mode 100644 agent/src/voca/config.py create mode 100644 agent/uv.lock diff --git a/agent/.python-version b/agent/.python-version new file mode 100644 index 0000000..d540408 --- /dev/null +++ b/agent/.python-version @@ -0,0 +1 @@ +3.13.16 diff --git a/agent/pyproject.toml b/agent/pyproject.toml new file mode 100644 index 0000000..a33b883 --- /dev/null +++ b/agent/pyproject.toml @@ -0,0 +1,39 @@ +[build-system] +requires = ["hatchling>=1.33,<2"] +build-backend = "hatchling.build" + +[project] +name = "voca" +version = "0.1.0" +description = "VOCA voice assistant: web entry and stand tooling" +requires-python = ">=3.13,<3.14" +dependencies = [ + "aiohttp>=3.14.4,<4", + "livekit-api>=1.2.2,<2", + "pydantic-settings>=2.16,<3", +] + +[project.scripts] +voca = "voca.__main__:main" + +[dependency-groups] +dev = [ + "mypy>=2.4,<3", + "pytest>=9.2,<10", + "ruff>=0.17,<1", +] + +[tool.hatch.build.targets.wheel] +packages = ["src/voca"] + +[tool.pytest.ini_options] +testpaths = ["tests"] +addopts = ["--import-mode=importlib"] + +[tool.ruff] +target-version = "py313" + +[tool.mypy] +python_version = "3.13" +strict = true +files = ["src/voca"] diff --git a/agent/src/voca/__init__.py b/agent/src/voca/__init__.py new file mode 100644 index 0000000..6de7b94 --- /dev/null +++ b/agent/src/voca/__init__.py @@ -0,0 +1 @@ +"""VOCA voice assistant.""" diff --git a/agent/src/voca/__main__.py b/agent/src/voca/__main__.py new file mode 100644 index 0000000..e531fe8 --- /dev/null +++ b/agent/src/voca/__main__.py @@ -0,0 +1,25 @@ +import argparse +import sys + +from aiohttp import web + +from voca.app.web import create_app, default_dist_dir +from voca.config import Settings + + +def main() -> None: + parser = argparse.ArgumentParser(prog="voca") + commands = parser.add_subparsers(dest="command", required=True) + commands.add_parser("web", help="serve the page and the token endpoint") + parser.parse_args() + settings = Settings() + try: + app = create_app(settings, default_dist_dir()) + except FileNotFoundError as error: + print(f"voca web: {error}", file=sys.stderr) + sys.exit(2) + web.run_app(app, host=settings.web_host, port=settings.web_port) + + +if __name__ == "__main__": + main() diff --git a/agent/src/voca/app/__init__.py b/agent/src/voca/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/agent/src/voca/app/web.py b/agent/src/voca/app/web.py new file mode 100644 index 0000000..14ba255 --- /dev/null +++ b/agent/src/voca/app/web.py @@ -0,0 +1,55 @@ +import re +from pathlib import Path + +from aiohttp import web +from livekit import api + +from voca.config import Settings + +ROOM = "voca" +DEVICE_RE = re.compile(r"^[a-z0-9-]{1,32}$") + + +def default_dist_dir() -> Path: + return Path(__file__).resolve().parents[4] / "web" / "dist" + + +def create_app(settings: Settings, dist_dir: Path) -> web.Application: + index = dist_dir / "index.html" + if not index.is_file(): + raise FileNotFoundError( + f"{dist_dir}/index.html not found; build the page: pnpm --dir web build" + ) + + async def healthz(request: web.Request) -> web.Response: + return web.json_response({"status": "ok"}) + + async def token(request: web.Request) -> web.Response: + try: + body = await request.json() + except ValueError: + body = None + device = body.get("device") if isinstance(body, dict) else None + if not isinstance(device, str) or not DEVICE_RE.match(device): + return web.json_response( + {"error": "device must match ^[a-z0-9-]{1,32}$"}, status=400 + ) + jwt = ( + api.AccessToken(settings.livekit_api_key, settings.livekit_api_secret) + .with_identity(f"user-{device}") + .with_grants(api.VideoGrants(room_join=True, room=ROOM)) + .to_jwt() + ) + return web.json_response({"token": jwt, "url": settings.public_livekit_url}) + + async def page(request: web.Request) -> web.FileResponse: + return web.FileResponse(index) + + app = web.Application() + app.router.add_get("/healthz", healthz) + app.router.add_post("/api/token", token) + app.router.add_get("/", page) + assets = dist_dir / "assets" + if assets.is_dir(): + app.router.add_static("/assets", assets) + return app diff --git a/agent/src/voca/config.py b/agent/src/voca/config.py new file mode 100644 index 0000000..b534033 --- /dev/null +++ b/agent/src/voca/config.py @@ -0,0 +1,11 @@ +from pydantic_settings import BaseSettings, SettingsConfigDict + + +class Settings(BaseSettings): + model_config = SettingsConfigDict(env_prefix="VOCA_") + + web_host: str = "127.0.0.1" + web_port: int = 8083 + public_livekit_url: str = "ws://127.0.0.1:7880" + livekit_api_key: str = "devkey" + livekit_api_secret: str = "secret" diff --git a/agent/uv.lock b/agent/uv.lock new file mode 100644 index 0000000..d1abe0c --- /dev/null +++ b/agent/uv.lock @@ -0,0 +1,600 @@ +version = 1 +revision = 5 +requires-python = "==3.13.*" + +[[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.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "aiohappyeyeballs" }, + { name = "aiosignal" }, + { name = "attrs" }, + { name = "frozenlist" }, + { name = "multidict" }, + { name = "propcache" }, + { name = "yarl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/6c/4c/bdccd81e9ee225b69c60e7766c9a5b05364f118f4d383713b89a682d772d/aiohttp-3.14.5.tar.gz", hash = "sha256:5558a7f5a05af9ecf744af91e5baefc436f93c9333e656c27ec253f9a6bbe178", size = 8078052, upload-time = "2026-10-11T01:05:12.408Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d3/e1/2841e020ebb7aefae5513586193e011e06313d9a6bdbd296622afbbce204/aiohttp-3.14.5-cp313-cp313-android_24_arm64_v8a.whl", hash = "sha256:9ad7e6aa38c20da1be697874349c4c273c8a03b7887169665081706398d0439a", size = 538932, upload-time = "2026-10-11T01:00:54.3Z" }, + { url = "https://files.pythonhosted.org/packages/f7/a6/7fb8ea8fe96bcc7b7c7a36d10f021d99d01a8dc8a4b3f0ddacecfad9a80e/aiohttp-3.14.5-cp313-cp313-android_24_x86_64.whl", hash = "sha256:f59c7673465908cbe506117176156c127f29f917677afceada34957179221d91", size = 553329, upload-time = "2026-10-11T01:00:56.442Z" }, + { url = "https://files.pythonhosted.org/packages/de/64/d056e3c27647dc25af1a592cf356245382ea7c808171b9dac7677afedfc8/aiohttp-3.14.5-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:1b5416552740edf07234cc9437d0706f2acb67b93c198670b1a68e1b2b587dec", size = 521367, upload-time = "2026-10-11T01:00:58.345Z" }, + { url = "https://files.pythonhosted.org/packages/3e/e4/95226147e11d4db916fd1d495dcf85af8e3816e38333e42718241196e848/aiohttp-3.14.5-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:43351bdb5e4c3cb7d1772368e988534e869a74db7778079a83782c11c69535c7", size = 528322, upload-time = "2026-10-11T01:01:00.211Z" }, + { url = "https://files.pythonhosted.org/packages/17/cd/1d3c9192cafdb51cad62b2d3ded96cff9cc8af51893210aadd448a325389/aiohttp-3.14.5-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:c8c4478bef6d57fcfda15dae461ea3c9f06aa7b257c58df3f2300174ccbb185a", size = 539874, upload-time = "2026-10-11T01:01:02.06Z" }, + { url = "https://files.pythonhosted.org/packages/f6/0c/dfa33aecc7d4d1dc75e05248f5eac5a0edf4d09e7b44d93ab62529b0c1db/aiohttp-3.14.5-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:c2c30484dd1417ef98b51021ffa2cc0d7f3c78918adaaaab7e70817335ab3e02", size = 813836, upload-time = "2026-10-11T01:01:04.01Z" }, + { url = "https://files.pythonhosted.org/packages/33/17/4a63738052d20567d55529d6daa1b9480d906fd52930fbcf6d3fbed618f0/aiohttp-3.14.5-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:dab9ac5a67c8d1f070c00fa8fccb7cbd1b8dcc1a8d6b42f37540df9b3d4cc603", size = 548070, upload-time = "2026-10-11T01:01:06.035Z" }, + { url = "https://files.pythonhosted.org/packages/15/e5/b57e58695a757fd4c02497c033fced96a69c631b13866c43d336530c9670/aiohttp-3.14.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e1cc2bfaee8c214f06080a7c7d5772419b8a1108e8e5349236189811823fb02a", size = 545786, upload-time = "2026-10-11T01:01:07.817Z" }, + { url = "https://files.pythonhosted.org/packages/9a/68/8c2c67a3aedf46e00f3c42f04fbc6983de80d4ed5786151e33681ba45883/aiohttp-3.14.5-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:74efb69332b85675b1eabd760a8cfc2e2cf42c60607c66f88014c1bdfb40942d", size = 1886492, upload-time = "2026-10-11T01:01:09.834Z" }, + { url = "https://files.pythonhosted.org/packages/ab/4b/74aab5e8d28c62e8f795b4fe8f38cf5586fd264a9a27bd2141ef6490333d/aiohttp-3.14.5-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:42b5e616946dbaf505e2bff18c9af2cd4ef9e7ef300ee58a6e951a5b7cf147ae", size = 1870057, upload-time = "2026-10-11T01:01:12.045Z" }, + { url = "https://files.pythonhosted.org/packages/a8/f7/eafc3b1988302b1815d9fd4a21071be5c360d616c0a430d02fd92dc97688/aiohttp-3.14.5-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f9033b43f511f27547c557dcaba0177649e10a3725336ccd2cce0fdc1dc4850d", size = 1925119, upload-time = "2026-10-11T01:01:14.09Z" }, + { url = "https://files.pythonhosted.org/packages/a1/04/78d8f294f74dd570f3898ff20402349fce524176045df98ba727d6846a68/aiohttp-3.14.5-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:15a310d3c71398e3d7bfc93a1a73fbe664315cd9e9016b8efc1cff85eeab7155", size = 2053137, upload-time = "2026-10-11T01:01:16.344Z" }, + { url = "https://files.pythonhosted.org/packages/32/51/395d225ef36f5a50d8e548dcd3141bfdbcd31fb6eed859022c573d2c4d66/aiohttp-3.14.5-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1ffa3a523a36d8628f98c06492ae16a31a23d14c0b4ec721757b477319f656d6", size = 1911038, upload-time = "2026-10-11T01:01:18.653Z" }, + { url = "https://files.pythonhosted.org/packages/ff/a4/2aec1aa06d82e8a244843b5dae31d78061e5e76744270a86dd0ee051c889/aiohttp-3.14.5-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:5fb6a6e919bfb703227bc1ce6579281b84b1a2ba57deb9794dfdbec7dcd1e40c", size = 1702205, upload-time = "2026-10-11T01:01:20.904Z" }, + { url = "https://files.pythonhosted.org/packages/16/27/6051bfde7b6f418f70edd60d655fa426abb3355fa0981764739d87ecf160/aiohttp-3.14.5-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:43e1b7994a8b038125f722bff07492ef501110722c2727c408995d9fb864c421", size = 1848588, upload-time = "2026-10-11T01:01:22.918Z" }, + { url = "https://files.pythonhosted.org/packages/9e/44/55efc06fc26c4e6e1c095f231b4c222bf2d86d64a8eebbc24b2bb5958ea8/aiohttp-3.14.5-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:5f3e96071686755d9cd3600c3880183eb94b012178e92746d68101800f0ed8a3", size = 1887540, upload-time = "2026-10-11T01:01:25.278Z" }, + { url = "https://files.pythonhosted.org/packages/48/dc/1502bfdc2a65760d386ac6a00090b0addaa8a3c9c60b3f8127fad3a9afb2/aiohttp-3.14.5-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:cd88b01f3d37b7a2a34f91d98f14720206f1ea3d540843fab2d649dd5fb91fec", size = 1895030, upload-time = "2026-10-11T01:01:27.316Z" }, + { url = "https://files.pythonhosted.org/packages/93/7e/44174bb6288264418c9eec07a5e35180969c0d5a796c7af544db3cb8a33a/aiohttp-3.14.5-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:f2ed8b64dc0c651c0f5a9c926777719770021251b8f336d97c4b80b660836ce1", size = 1693244, upload-time = "2026-10-11T01:01:29.39Z" }, + { url = "https://files.pythonhosted.org/packages/47/dd/b507d64e50db23888582fff08eda13998b12f9dea70c072edaac19218380/aiohttp-3.14.5-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:a9918e58faf62ba2c7147927d06057aec78f42475aff5048047ec47e7265a600", size = 1926308, upload-time = "2026-10-11T01:01:31.634Z" }, + { url = "https://files.pythonhosted.org/packages/98/4b/5b51b4f63e3f2793151f4aea49c48fe1e00baeb7cec9c7a206de499f8de0/aiohttp-3.14.5-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:42f320d4a5b00b9af0bddcfec5407dc6f2d9816f006b2f79ebbaa31f16895df3", size = 1888406, upload-time = "2026-10-11T01:01:33.715Z" }, + { url = "https://files.pythonhosted.org/packages/ff/13/d5e818a5eaba9f822016727299f41c0e1075799f82a6433ec508a93ab867/aiohttp-3.14.5-cp313-cp313-win32.whl", hash = "sha256:3ae800a20947e2c2e53088047d021e6bf7d51560cc49f6a0737a1f79d2e3a13c", size = 479970, upload-time = "2026-10-11T01:01:35.677Z" }, + { url = "https://files.pythonhosted.org/packages/8d/d0/8eca2c65aa467320990d78fb2005f38ed3588944280c39ff9deb6423fef1/aiohttp-3.14.5-cp313-cp313-win_amd64.whl", hash = "sha256:d05e94cdfe0d15d0206f970722d2554780ce562787b21b218b275447f8751319", size = 505985, upload-time = "2026-10-11T01:01:37.574Z" }, + { url = "https://files.pythonhosted.org/packages/7a/f8/4cdd65305d2fca14b886bea9ed2abb1fe726287872524e56e3d26692b47d/aiohttp-3.14.5-cp313-cp313-win_arm64.whl", hash = "sha256:f001b571ead90ca1770f1e616db255351a1703317f20374c361ef22f12c06d09", size = 487629, upload-time = "2026-10-11T01:01:39.477Z" }, + { url = "https://files.pythonhosted.org/packages/68/30/173960c42b05a6c59f7558e4b12a4b0d9ba376cf6aa9bde7f9e08a30ca8d/aiohttp-3.14.5-py3-none-any.whl", hash = "sha256:efc21a454892828368b11c2c780de0ff8bc991f73f6b99c6b66e56205470929b", size = 279517, upload-time = "2026-10-11T01:05:08.523Z" }, +] + +[[package]] +name = "aiosignal" +version = "1.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "frozenlist" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/61/62/06741b579156360248d1ec624842ad0edf697050bbaf7c3e46394e106ad1/aiosignal-1.4.0.tar.gz", hash = "sha256:f47eecd9468083c2029cc99945502cb7708b082c232f9aca65da147157b251c7", size = 25007, upload-time = "2025-07-03T22:54:43.528Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fb/76/641ae371508676492379f16e2fa48f4e2c11741bd63c48be4b12a6b09cba/aiosignal-1.4.0-py3-none-any.whl", hash = "sha256:053243f8b92b990551949e63930a839ff0cf0b0ebbe0597b0f3fb19e1a0fe82e", size = 7490, upload-time = "2025-07-03T22:54:42.156Z" }, +] + +[[package]] +name = "annotated-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 = "ast-serialize" +version = "0.13.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/21/d5/5dc1ee8a80c3b20a0b63c61b43ba6bc53517faecd1def47bcd752d22268e/ast_serialize-0.13.1.tar.gz", hash = "sha256:401cc84e8888d4d76f2c8d296fa006ed1a5ff993b6aff90dd777c03973efd025", size = 955137, upload-time = "2026-10-17T00:04:17.183Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2b/e2/b0c140fe50fa39dd249e382f7733af6f816c90021fe7959a0b026f86237f/ast_serialize-0.13.1-cp315-abi3.abi3t-macosx_10_12_x86_64.whl", hash = "sha256:23d768369b89d551077dc173da6892fad00443338417523b6732f4e8ba4a7ed5", size = 1236160, upload-time = "2026-10-17T00:03:21.945Z" }, + { url = "https://files.pythonhosted.org/packages/23/c4/a8a2ca21e3943555b48221f8fba546877a7ad39efaf6505506524967d7c4/ast_serialize-0.13.1-cp315-abi3.abi3t-macosx_11_0_arm64.whl", hash = "sha256:68f8829d114dab6c9db4a8bbb8d0e342958faee1b5e15a29022678df639960f8", size = 1222214, upload-time = "2026-10-17T00:03:23.462Z" }, + { url = "https://files.pythonhosted.org/packages/15/19/2f58f2ba64e4799b39de85c140aef09a03aa6ea53be75dd292a9d9b9d8ae/ast_serialize-0.13.1-cp315-abi3.abi3t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4e385b17d5bde892a6184003a9aff3518387908b73f8f5ea539313c07c8c0ede", size = 1282979, upload-time = "2026-10-17T00:03:24.695Z" }, + { url = "https://files.pythonhosted.org/packages/b1/af/ca6087fe84210c1ccd43e3182917bb70649fa1e69745a90c1d59ba08fea8/ast_serialize-0.13.1-cp315-abi3.abi3t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:02686b281ec9b8e9614f6f0db27e12e4cac515f336c5d05b678bc9eb73ff2af5", size = 1288596, upload-time = "2026-10-17T00:03:26.04Z" }, + { url = "https://files.pythonhosted.org/packages/b0/2c/4ef964c763329a0347125e745ec0451ba4fbaceec9efe2ac729b7cd55f17/ast_serialize-0.13.1-cp315-abi3.abi3t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a8ab2c2585b2e22e48cd4aff2136fce4eab4ced569b85f150ef9a5a5d6f34d49", size = 1545540, upload-time = "2026-10-17T00:03:27.52Z" }, + { url = "https://files.pythonhosted.org/packages/bb/85/aa961dbb75813cfa94de0ce9a16a9510c226b95f604c405421e727f44bb0/ast_serialize-0.13.1-cp315-abi3.abi3t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c652d9f5d52d8b81eff3ff2c95c9dd0f281de45f6ad5d3fa95527550c396454a", size = 1309374, upload-time = "2026-10-17T00:03:29.091Z" }, + { url = "https://files.pythonhosted.org/packages/b4/e6/f2b67c327d9f9533e41791804403ccc2dc457821195a2e51f0b587a90b90/ast_serialize-0.13.1-cp315-abi3.abi3t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:121e1d08d58b75c15491cc3003609cbad71ac2b0016043c36d30a6b2032b6605", size = 1303466, upload-time = "2026-10-17T00:03:30.451Z" }, + { url = "https://files.pythonhosted.org/packages/73/1d/33f18b81d1bb1f5d682943ea92fda8130a6593627f12f6201668d40824fd/ast_serialize-0.13.1-cp315-abi3.abi3t-manylinux_2_31_riscv64.whl", hash = "sha256:30cf89b6a6844542d116c758725d13de5b97b9410a51ee577715f761270d0c1c", size = 1300576, upload-time = "2026-10-17T00:03:32.102Z" }, + { url = "https://files.pythonhosted.org/packages/25/cb/64914144925f1a4abdd9217e717b122255c38fc5ebe4342f92052f3bacbc/ast_serialize-0.13.1-cp315-abi3.abi3t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:732aaae9e498c973fc63fd7f308009b57c4afa6ff59d0b674d894e57d0bca01a", size = 1355960, upload-time = "2026-10-17T00:03:33.676Z" }, + { url = "https://files.pythonhosted.org/packages/ee/80/bd29379fab2bce19adc0bf9177002b11b23dfe8d8fecf409998b458b4624/ast_serialize-0.13.1-cp315-abi3.abi3t-musllinux_1_2_aarch64.whl", hash = "sha256:d9c1a920b2aa0c1e016bed7721689717034ca0dba64ac3a3ff243b003d4e8dd8", size = 1459779, upload-time = "2026-10-17T00:03:35.09Z" }, + { url = "https://files.pythonhosted.org/packages/83/89/012dbe733de05ffdf8e40ca0f9fa5d417cb16ec93a74463c57c001914198/ast_serialize-0.13.1-cp315-abi3.abi3t-musllinux_1_2_armv7l.whl", hash = "sha256:847cad4c22953823f5e76d0b126c6f9da670cfa45b1df41b9324e22189d77611", size = 1563399, upload-time = "2026-10-17T00:03:37.285Z" }, + { url = "https://files.pythonhosted.org/packages/69/37/ee4bcb0358bee38744eb0f18560446572d095fe3bf6b63d6f739d08a839c/ast_serialize-0.13.1-cp315-abi3.abi3t-musllinux_1_2_i686.whl", hash = "sha256:aca07de3ca8e3d9f2b716c3c31f4f384fe4bd2d03e09912417cff7a3e08036f7", size = 1558808, upload-time = "2026-10-17T00:03:38.711Z" }, + { url = "https://files.pythonhosted.org/packages/54/1c/ff44c5355251d61ef22934c4430bb31a61de4781d25a992154aac9b8c82a/ast_serialize-0.13.1-cp315-abi3.abi3t-musllinux_1_2_ppc64le.whl", hash = "sha256:5db12d843d931b9d1f1519ce66b9cbfe72d8563b75c1204d255322254d1964dd", size = 1666324, upload-time = "2026-10-17T00:03:40.376Z" }, + { url = "https://files.pythonhosted.org/packages/c8/bb/6da4bfc8aa3403e6447607131bf3463acaf954d990c6c8b6787c1902ddfe/ast_serialize-0.13.1-cp315-abi3.abi3t-musllinux_1_2_riscv64.whl", hash = "sha256:fb8021f4dc3296de18737e3b8b34994411da725374d74a0b9eb32a738f2a23d0", size = 1474089, upload-time = "2026-10-17T00:03:41.715Z" }, + { url = "https://files.pythonhosted.org/packages/21/97/897e33bef2eefd880e6350e375cc66bc5bcd00a139be480fd655f5b3f095/ast_serialize-0.13.1-cp315-abi3.abi3t-musllinux_1_2_x86_64.whl", hash = "sha256:0d764913374ba21df8cd618045b371ed104ce55c6276de7c59e554a4f5ba26fd", size = 1501542, upload-time = "2026-10-17T00:03:43.08Z" }, + { url = "https://files.pythonhosted.org/packages/8d/4c/821211ac83870c27e4412381b7c7838336456b40575bf840e4680b2018d9/ast_serialize-0.13.1-cp315-abi3.abi3t-win32.whl", hash = "sha256:2bad302ae2427454ef9dab71bd5c3ad67188860c39d36daee38f72031edb39c4", size = 1126118, upload-time = "2026-10-17T00:03:44.616Z" }, + { url = "https://files.pythonhosted.org/packages/1f/86/2b2d5e6c4fa352874a80618e742ffd32d249dee6c119f4233b51afc6b8f4/ast_serialize-0.13.1-cp315-abi3.abi3t-win_amd64.whl", hash = "sha256:032b09d86c785c3984e975378b7861f2a68ac44e22ba7b07b93a536b2524eeb8", size = 1163766, upload-time = "2026-10-17T00:03:45.986Z" }, + { url = "https://files.pythonhosted.org/packages/77/a3/d610cfcdabaa44fd62a54ca1fc57a7dba120643798b089a14914973562de/ast_serialize-0.13.1-cp315-abi3.abi3t-win_arm64.whl", hash = "sha256:929eda0c419e622ab49df3a4c45f81c57dcc10874bbad55d0470a4a03324dac3", size = 1134043, upload-time = "2026-10-17T00:03:48.453Z" }, + { url = "https://files.pythonhosted.org/packages/81/92/d705be88ff452b9ba0088b554a0cbd308fd01879da092b33fd2ad7d1aa60/ast_serialize-0.13.1-cp39-abi3-macosx_10_12_x86_64.whl", hash = "sha256:4bcf1d5f4eefecaa325a34ecf079b2741189e7d0821523f92cb343a389a67489", size = 1242416, upload-time = "2026-10-17T00:03:51.521Z" }, + { url = "https://files.pythonhosted.org/packages/60/c2/98370845a7f35cb8a25782e78284b48e01bf3128339881d48d613f304f8f/ast_serialize-0.13.1-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:1b158f1c9e3a0d386913924c57995d6df78224868a4d0f215bdd41587c97b34a", size = 1229729, upload-time = "2026-10-17T00:03:52.825Z" }, + { url = "https://files.pythonhosted.org/packages/d8/8b/2d7ffed76addd9d3fcf37fb171400942735c8912264c763561a85407f6a8/ast_serialize-0.13.1-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:77a6201b69ce42e919f2fd9a559138d5410b5d8afff7c3a6b1f500b0481ff7d8", size = 1292255, upload-time = "2026-10-17T00:03:54.148Z" }, + { url = "https://files.pythonhosted.org/packages/6b/2a/bb9e0e719d89cc950fadfcfcc9cb4bc7640419c4228c852f031ab8abf440/ast_serialize-0.13.1-cp39-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:6eb0be64c9b8f1ed26637eef345abe75ea90351c92c85a8aec7838b3af971011", size = 1298501, upload-time = "2026-10-17T00:03:55.559Z" }, + { url = "https://files.pythonhosted.org/packages/7a/32/13ce07dce48226f5bd9cfd8f708ec101a8be05aa430cda4918ce31ce8c82/ast_serialize-0.13.1-cp39-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7f2427bddfca208e84de700044fadf9aef2769a70bae740a1bdabb89ee9e1117", size = 1553758, upload-time = "2026-10-17T00:03:56.952Z" }, + { url = "https://files.pythonhosted.org/packages/e2/fb/9b51d186db0c20ca28cbaaefd50f03b573c89327b9a2627820cbce22196b/ast_serialize-0.13.1-cp39-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:88df960e67184a71874e5b2ee014f87d1254a0dbc1f903c7bd7b1ab7733ecaef", size = 1318258, upload-time = "2026-10-17T00:03:58.418Z" }, + { url = "https://files.pythonhosted.org/packages/c5/fa/da311559dd3dae9af5825cf21660775a929b970f72e3786af3f2cd667944/ast_serialize-0.13.1-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9f99786e0d62082a5b121b3437f3167562133711df22128de1e9886f57f0b226", size = 1313260, upload-time = "2026-10-17T00:04:00.194Z" }, + { url = "https://files.pythonhosted.org/packages/2b/11/97a4b9e5b4776d72f47fcd9a09a497bbf68ec8c1ae074307a2c51e410d85/ast_serialize-0.13.1-cp39-abi3-manylinux_2_31_riscv64.whl", hash = "sha256:a01b515b25a03184eff4acef25b981ae007916379682e908fc8785b342a0bf3e", size = 1309992, upload-time = "2026-10-17T00:04:01.575Z" }, + { url = "https://files.pythonhosted.org/packages/2e/c4/62f85121a80cd451ef04f550cf0f7ea0ad9ff46cc4e9a5b9ae439b6527b1/ast_serialize-0.13.1-cp39-abi3-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:29422d7bcd9e7f39411571c36cec45ce8a7a9b3ad4b2e7aa64497c15a9e397f6", size = 1362715, upload-time = "2026-10-17T00:04:02.881Z" }, + { url = "https://files.pythonhosted.org/packages/c1/fa/c5f4a545f72a7681e3295bab6e3600ee0862ec4391bed961fe9f735b67e0/ast_serialize-0.13.1-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:6ffb0f548a7dc126067fdfb7b2325ab5dfcdeecc29faf5e0f0461c96d6ebfa3d", size = 1469931, upload-time = "2026-10-17T00:04:04.181Z" }, + { url = "https://files.pythonhosted.org/packages/38/a9/65aed4f8fa7a001a7cfdc03e68e844517ab041ba6fb614b7d78a1d6ac056/ast_serialize-0.13.1-cp39-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:2876444a5f87a7c76f371af3c07c2fda563a08bc8af52568df812e8aba4bf568", size = 1570945, upload-time = "2026-10-17T00:04:05.778Z" }, + { url = "https://files.pythonhosted.org/packages/fb/8c/44b2da991cede79bfbf52089f33a5f0168945466b3d9cd38b5dcf08e7130/ast_serialize-0.13.1-cp39-abi3-musllinux_1_2_i686.whl", hash = "sha256:885a62ec6481ce58ab24c6357360d7a82023b62dd7e5e7ceccf59e9662a3272b", size = 1569978, upload-time = "2026-10-17T00:04:07.347Z" }, + { url = "https://files.pythonhosted.org/packages/46/e4/109b2a5beca700237767d5add920c2f8e1ba9bd5799b0faf143d0e9cf4ef/ast_serialize-0.13.1-cp39-abi3-musllinux_1_2_ppc64le.whl", hash = "sha256:2015fd0165e463aac3b1aadc6a1fa1e5820c14ca950821f4fe02f8a3f6a388d6", size = 1676082, upload-time = "2026-10-17T00:04:08.7Z" }, + { url = "https://files.pythonhosted.org/packages/73/e0/7df70f6dbb11b5762da5416318f08316a1c51348e0d69ec79ca5f98f836a/ast_serialize-0.13.1-cp39-abi3-musllinux_1_2_riscv64.whl", hash = "sha256:eb41e1906502ac80d24ff343f45008552a3f5b5158607959fe6a07dbfc6eb037", size = 1483207, upload-time = "2026-10-17T00:04:10.162Z" }, + { url = "https://files.pythonhosted.org/packages/1f/bc/523aa1c08bd69b5654621beb3dc1a9035715a4a63762fb8d219d6fb3eb81/ast_serialize-0.13.1-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:ca9acc67f796aa2554fa3037dccc022a4b08b38b2ad153edfac9c9bb7cb1f3c6", size = 1512140, upload-time = "2026-10-17T00:04:11.589Z" }, + { url = "https://files.pythonhosted.org/packages/66/83/6607060659998f00afdea0d8375c1e6e2b7d515ab905090d9a5a02f48851/ast_serialize-0.13.1-cp39-abi3-win32.whl", hash = "sha256:4f98da562e5901d69c7dd30885594c5438f859edf354e9ac7517d188fb026c3a", size = 1131889, upload-time = "2026-10-17T00:04:12.953Z" }, + { url = "https://files.pythonhosted.org/packages/9f/6f/bafd6490fe7b355627fabe95f4417e3d02da93102c8552550eb9a0774e1e/ast_serialize-0.13.1-cp39-abi3-win_amd64.whl", hash = "sha256:ddf7cbc9c8abf70102c4a088efe154bb86bfb825b0a10e556d2b15972cf99bd7", size = 1171153, upload-time = "2026-10-17T00:04:14.362Z" }, + { url = "https://files.pythonhosted.org/packages/11/25/4235f4b375a67a37ac595bf478a5407a744eaf7b64113c1c3a5432b8d954/ast_serialize-0.13.1-cp39-abi3-win_arm64.whl", hash = "sha256:48f11d96c9ec71119612f8e79a8c55c12f2b136716d68347433c561d27a25f43", size = 1139035, upload-time = "2026-10-17T00:04:15.767Z" }, +] + +[[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 = "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 = "frozenlist" +version = "1.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2d/f5/c831fac6cc817d26fd54c7eaccd04ef7e0288806943f7cc5bbf69f3ac1f0/frozenlist-1.8.0.tar.gz", hash = "sha256:3ede829ed8d842f6cd48fc7081d7a41001a56f1f38603f9d49bf3020d59a31ad", size = 45875, upload-time = "2025-10-06T05:38:17.865Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2d/40/0832c31a37d60f60ed79e9dfb5a92e1e2af4f40a16a29abcc7992af9edff/frozenlist-1.8.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:8d92f1a84bb12d9e56f818b3a746f3efba93c1b63c8387a73dde655e1e42282a", size = 85717, upload-time = "2025-10-06T05:36:27.341Z" }, + { url = "https://files.pythonhosted.org/packages/30/ba/b0b3de23f40bc55a7057bd38434e25c34fa48e17f20ee273bbde5e0650f3/frozenlist-1.8.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:96153e77a591c8adc2ee805756c61f59fef4cf4073a9275ee86fe8cba41241f7", size = 49651, upload-time = "2025-10-06T05:36:28.855Z" }, + { url = "https://files.pythonhosted.org/packages/0c/ab/6e5080ee374f875296c4243c381bbdef97a9ac39c6e3ce1d5f7d42cb78d6/frozenlist-1.8.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f21f00a91358803399890ab167098c131ec2ddd5f8f5fd5fe9c9f2c6fcd91e40", size = 49417, upload-time = "2025-10-06T05:36:29.877Z" }, + { url = "https://files.pythonhosted.org/packages/d5/4e/e4691508f9477ce67da2015d8c00acd751e6287739123113a9fca6f1604e/frozenlist-1.8.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:fb30f9626572a76dfe4293c7194a09fb1fe93ba94c7d4f720dfae3b646b45027", size = 234391, upload-time = "2025-10-06T05:36:31.301Z" }, + { url = "https://files.pythonhosted.org/packages/40/76/c202df58e3acdf12969a7895fd6f3bc016c642e6726aa63bd3025e0fc71c/frozenlist-1.8.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:eaa352d7047a31d87dafcacbabe89df0aa506abb5b1b85a2fb91bc3faa02d822", size = 233048, upload-time = "2025-10-06T05:36:32.531Z" }, + { url = "https://files.pythonhosted.org/packages/f9/c0/8746afb90f17b73ca5979c7a3958116e105ff796e718575175319b5bb4ce/frozenlist-1.8.0-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:03ae967b4e297f58f8c774c7eabcce57fe3c2434817d4385c50661845a058121", size = 226549, upload-time = "2025-10-06T05:36:33.706Z" }, + { url = "https://files.pythonhosted.org/packages/7e/eb/4c7eefc718ff72f9b6c4893291abaae5fbc0c82226a32dcd8ef4f7a5dbef/frozenlist-1.8.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f6292f1de555ffcc675941d65fffffb0a5bcd992905015f85d0592201793e0e5", size = 239833, upload-time = "2025-10-06T05:36:34.947Z" }, + { url = "https://files.pythonhosted.org/packages/c2/4e/e5c02187cf704224f8b21bee886f3d713ca379535f16893233b9d672ea71/frozenlist-1.8.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:29548f9b5b5e3460ce7378144c3010363d8035cea44bc0bf02d57f5a685e084e", size = 245363, upload-time = "2025-10-06T05:36:36.534Z" }, + { url = "https://files.pythonhosted.org/packages/1f/96/cb85ec608464472e82ad37a17f844889c36100eed57bea094518bf270692/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ec3cc8c5d4084591b4237c0a272cc4f50a5b03396a47d9caaf76f5d7b38a4f11", size = 229314, upload-time = "2025-10-06T05:36:38.582Z" }, + { url = "https://files.pythonhosted.org/packages/5d/6f/4ae69c550e4cee66b57887daeebe006fe985917c01d0fff9caab9883f6d0/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:517279f58009d0b1f2e7c1b130b377a349405da3f7621ed6bfae50b10adf20c1", size = 243365, upload-time = "2025-10-06T05:36:40.152Z" }, + { url = "https://files.pythonhosted.org/packages/7a/58/afd56de246cf11780a40a2c28dc7cbabbf06337cc8ddb1c780a2d97e88d8/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:db1e72ede2d0d7ccb213f218df6a078a9c09a7de257c2fe8fcef16d5925230b1", size = 237763, upload-time = "2025-10-06T05:36:41.355Z" }, + { url = "https://files.pythonhosted.org/packages/cb/36/cdfaf6ed42e2644740d4a10452d8e97fa1c062e2a8006e4b09f1b5fd7d63/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:b4dec9482a65c54a5044486847b8a66bf10c9cb4926d42927ec4e8fd5db7fed8", size = 240110, upload-time = "2025-10-06T05:36:42.716Z" }, + { url = "https://files.pythonhosted.org/packages/03/a8/9ea226fbefad669f11b52e864c55f0bd57d3c8d7eb07e9f2e9a0b39502e1/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:21900c48ae04d13d416f0e1e0c4d81f7931f73a9dfa0b7a8746fb2fe7dd970ed", size = 233717, upload-time = "2025-10-06T05:36:44.251Z" }, + { url = "https://files.pythonhosted.org/packages/1e/0b/1b5531611e83ba7d13ccc9988967ea1b51186af64c42b7a7af465dcc9568/frozenlist-1.8.0-cp313-cp313-win32.whl", hash = "sha256:8b7b94a067d1c504ee0b16def57ad5738701e4ba10cec90529f13fa03c833496", size = 39628, upload-time = "2025-10-06T05:36:45.423Z" }, + { url = "https://files.pythonhosted.org/packages/d8/cf/174c91dbc9cc49bc7b7aab74d8b734e974d1faa8f191c74af9b7e80848e6/frozenlist-1.8.0-cp313-cp313-win_amd64.whl", hash = "sha256:878be833caa6a3821caf85eb39c5ba92d28e85df26d57afb06b35b2efd937231", size = 43882, upload-time = "2025-10-06T05:36:46.796Z" }, + { url = "https://files.pythonhosted.org/packages/c1/17/502cd212cbfa96eb1388614fe39a3fc9ab87dbbe042b66f97acb57474834/frozenlist-1.8.0-cp313-cp313-win_arm64.whl", hash = "sha256:44389d135b3ff43ba8cc89ff7f51f5a0bb6b63d829c8300f79a2fe4fe61bcc62", size = 39676, upload-time = "2025-10-06T05:36:47.8Z" }, + { url = "https://files.pythonhosted.org/packages/d2/5c/3bbfaa920dfab09e76946a5d2833a7cbdf7b9b4a91c714666ac4855b88b4/frozenlist-1.8.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:e25ac20a2ef37e91c1b39938b591457666a0fa835c7783c3a8f33ea42870db94", size = 89235, upload-time = "2025-10-06T05:36:48.78Z" }, + { url = "https://files.pythonhosted.org/packages/d2/d6/f03961ef72166cec1687e84e8925838442b615bd0b8854b54923ce5b7b8a/frozenlist-1.8.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:07cdca25a91a4386d2e76ad992916a85038a9b97561bf7a3fd12d5d9ce31870c", size = 50742, upload-time = "2025-10-06T05:36:49.837Z" }, + { url = "https://files.pythonhosted.org/packages/1e/bb/a6d12b7ba4c3337667d0e421f7181c82dda448ce4e7ad7ecd249a16fa806/frozenlist-1.8.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:4e0c11f2cc6717e0a741f84a527c52616140741cd812a50422f83dc31749fb52", size = 51725, upload-time = "2025-10-06T05:36:50.851Z" }, + { url = "https://files.pythonhosted.org/packages/bc/71/d1fed0ffe2c2ccd70b43714c6cab0f4188f09f8a67a7914a6b46ee30f274/frozenlist-1.8.0-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b3210649ee28062ea6099cfda39e147fa1bc039583c8ee4481cb7811e2448c51", size = 284533, upload-time = "2025-10-06T05:36:51.898Z" }, + { url = "https://files.pythonhosted.org/packages/c9/1f/fb1685a7b009d89f9bf78a42d94461bc06581f6e718c39344754a5d9bada/frozenlist-1.8.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:581ef5194c48035a7de2aefc72ac6539823bb71508189e5de01d60c9dcd5fa65", size = 292506, upload-time = "2025-10-06T05:36:53.101Z" }, + { url = "https://files.pythonhosted.org/packages/e6/3b/b991fe1612703f7e0d05c0cf734c1b77aaf7c7d321df4572e8d36e7048c8/frozenlist-1.8.0-cp313-cp313t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3ef2d026f16a2b1866e1d86fc4e1291e1ed8a387b2c333809419a2f8b3a77b82", size = 274161, upload-time = "2025-10-06T05:36:54.309Z" }, + { url = "https://files.pythonhosted.org/packages/ca/ec/c5c618767bcdf66e88945ec0157d7f6c4a1322f1473392319b7a2501ded7/frozenlist-1.8.0-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5500ef82073f599ac84d888e3a8c1f77ac831183244bfd7f11eaa0289fb30714", size = 294676, upload-time = "2025-10-06T05:36:55.566Z" }, + { url = "https://files.pythonhosted.org/packages/7c/ce/3934758637d8f8a88d11f0585d6495ef54b2044ed6ec84492a91fa3b27aa/frozenlist-1.8.0-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:50066c3997d0091c411a66e710f4e11752251e6d2d73d70d8d5d4c76442a199d", size = 300638, upload-time = "2025-10-06T05:36:56.758Z" }, + { url = "https://files.pythonhosted.org/packages/fc/4f/a7e4d0d467298f42de4b41cbc7ddaf19d3cfeabaf9ff97c20c6c7ee409f9/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:5c1c8e78426e59b3f8005e9b19f6ff46e5845895adbde20ece9218319eca6506", size = 283067, upload-time = "2025-10-06T05:36:57.965Z" }, + { url = "https://files.pythonhosted.org/packages/dc/48/c7b163063d55a83772b268e6d1affb960771b0e203b632cfe09522d67ea5/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:eefdba20de0d938cec6a89bd4d70f346a03108a19b9df4248d3cf0d88f1b0f51", size = 292101, upload-time = "2025-10-06T05:36:59.237Z" }, + { url = "https://files.pythonhosted.org/packages/9f/d0/2366d3c4ecdc2fd391e0afa6e11500bfba0ea772764d631bbf82f0136c9d/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:cf253e0e1c3ceb4aaff6df637ce033ff6535fb8c70a764a8f46aafd3d6ab798e", size = 289901, upload-time = "2025-10-06T05:37:00.811Z" }, + { url = "https://files.pythonhosted.org/packages/b8/94/daff920e82c1b70e3618a2ac39fbc01ae3e2ff6124e80739ce5d71c9b920/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:032efa2674356903cd0261c4317a561a6850f3ac864a63fc1583147fb05a79b0", size = 289395, upload-time = "2025-10-06T05:37:02.115Z" }, + { url = "https://files.pythonhosted.org/packages/e3/20/bba307ab4235a09fdcd3cc5508dbabd17c4634a1af4b96e0f69bfe551ebd/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:6da155091429aeba16851ecb10a9104a108bcd32f6c1642867eadaee401c1c41", size = 283659, upload-time = "2025-10-06T05:37:03.711Z" }, + { url = "https://files.pythonhosted.org/packages/fd/00/04ca1c3a7a124b6de4f8a9a17cc2fcad138b4608e7a3fc5877804b8715d7/frozenlist-1.8.0-cp313-cp313t-win32.whl", hash = "sha256:0f96534f8bfebc1a394209427d0f8a63d343c9779cda6fc25e8e121b5fd8555b", size = 43492, upload-time = "2025-10-06T05:37:04.915Z" }, + { url = "https://files.pythonhosted.org/packages/59/5e/c69f733a86a94ab10f68e496dc6b7e8bc078ebb415281d5698313e3af3a1/frozenlist-1.8.0-cp313-cp313t-win_amd64.whl", hash = "sha256:5d63a068f978fc69421fb0e6eb91a9603187527c86b7cd3f534a5b77a592b888", size = 48034, upload-time = "2025-10-06T05:37:06.343Z" }, + { url = "https://files.pythonhosted.org/packages/16/6c/be9d79775d8abe79b05fa6d23da99ad6e7763a1d080fbae7290b286093fd/frozenlist-1.8.0-cp313-cp313t-win_arm64.whl", hash = "sha256:bf0a7e10b077bf5fb9380ad3ae8ce20ef919a6ad93b4552896419ac7e1d8e042", size = 41749, upload-time = "2025-10-06T05:37:07.431Z" }, + { url = "https://files.pythonhosted.org/packages/9a/9a/e35b4a917281c0b8419d4207f4334c8e8c5dbf4f3f5f9ada73958d937dcc/frozenlist-1.8.0-py3-none-any.whl", hash = "sha256:0c18a16eab41e82c295618a77502e17b195883241c563b00f0aa5106fc4eaa0d", size = 13409, upload-time = "2025-10-06T05:38:16.721Z" }, +] + +[[package]] +name = "idna" +version = "3.20" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f5/08/8eea9d4b8302028f3abb2c0813953f7aec26d33b7a8960ed760e65ff29fa/idna-3.20.tar.gz", hash = "sha256:a7db850025b95ded1eae8a46181a1a6c56c92c96f0e2b005d9ff8dc0210cab44", size = 216463, upload-time = "2026-09-17T14:11:04.752Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/58/a2/bb081bab032533a855d44de1d56f8e8426114ff1ba5d1f07a438a0a654f8/idna-3.20-py3-none-any.whl", hash = "sha256:ab7ae7122974553370f0bdb919e1a960b2cd1bc1ef0276416d896db81c14582c", size = 69583, upload-time = "2026-09-17T14:11:03.168Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.3.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/01/e1/2069291243c926a2ff1cd706c7f3eeb9b62144bf60f77c9fb9ff2fb26bd3/iniconfig-2.3.1.tar.gz", hash = "sha256:67f4b9c50da0dedf52af349e7749a80a9057a5031199791b906c3bb3ae878960", size = 21209, upload-time = "2026-10-06T22:48:38.076Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/56/43/4ca9e49d27a1fcf6bece6f6aec0ea46bb9112489b93d4b688fb415457bdb/iniconfig-2.3.1-py3-none-any.whl", hash = "sha256:9121e2c1fdb355232495be3194c8dfe87ccc2d5dee45947b78e68f499790d7a7", size = 7552, upload-time = "2026-10-06T22:48:36.959Z" }, +] + +[[package]] +name = "librt" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/04/f5/9dc696772d241814bacac7880bac32f2930b5a6ebc1f85317b83161a011c/librt-0.16.0.tar.gz", hash = "sha256:ac38d6d8d66bf3d744148dbbc0b8e193e195a51e364ed55e224631f5721891fc", size = 219839, upload-time = "2026-09-29T00:55:32.891Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/46/cd/ae5e0e9dba45d1399aa04a5395bcc0bead40d9fa06dc903634a7b4d7473d/librt-0.16.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:c5e6144e68b577f157519f2ba88ca20e3ed61c29b00e5cdfa76cd2d45acf059a", size = 151036, upload-time = "2026-09-29T00:53:06.284Z" }, + { url = "https://files.pythonhosted.org/packages/41/5a/48a16e323c5f9447a94cce7b59babf60fa04e62c3365ecf060c77ed8b320/librt-0.16.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:33f41443a1f4e1f099331b3d8120e409fbff84b9760bc1cc9ea496f37ddaa5cc", size = 155211, upload-time = "2026-09-29T00:53:07.72Z" }, + { url = "https://files.pythonhosted.org/packages/3f/29/0f59299eb4251a409b2e690ad4b7d9f8a676db829d7817ec961f32b44f7e/librt-0.16.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7e510b7770bee609617a3374a96548eb114cae048023e3f049ee449e7ff2db32", size = 502587, upload-time = "2026-09-29T00:53:09.191Z" }, + { url = "https://files.pythonhosted.org/packages/de/ba/d6fb4ef8d1537c396079d72289f16be7cd35a366e5065c51253fea2760b6/librt-0.16.0-cp313-cp313-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:efc49c462d4516b8a58b00b490078fa64689fd1fe66970cc190131d7afb8027e", size = 496146, upload-time = "2026-09-29T00:53:11.016Z" }, + { url = "https://files.pythonhosted.org/packages/52/fc/8c50dd4d7cc97c0ee8f252c8a3104980f234391cf1519b554e8b9de08b60/librt-0.16.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:92caf82ebef5e12d21c72242b70d1e92536f1711cf2a727a4c276de4b4469087", size = 513336, upload-time = "2026-09-29T00:53:12.655Z" }, + { url = "https://files.pythonhosted.org/packages/a9/59/16c409c56f708eda2db9a0553662845d45e3871c77d70a240dae3f3bdc56/librt-0.16.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:17bac7f7a16b328fff77e440287693eb017abde913595b5827ebccbc21ecd8a6", size = 531689, upload-time = "2026-09-29T00:53:14.39Z" }, + { url = "https://files.pythonhosted.org/packages/88/82/d34772a6c29d1446dcca6e64d74062efd508523ab351aa16625a4689d5cc/librt-0.16.0-cp313-cp313-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:5b976054553670829985ed767feb78fb6bcede0175327c4844dd5c281c1be659", size = 524537, upload-time = "2026-09-29T00:53:16.064Z" }, + { url = "https://files.pythonhosted.org/packages/77/8f/24c5631313746131ccee53bc91fdc8374f9cf25e0082a1fee9c93bb98acc/librt-0.16.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:0058f9d68721094105917254c72ac0569117bb7b13b9769cf45d26d89f9d21cd", size = 543231, upload-time = "2026-09-29T00:53:17.939Z" }, + { url = "https://files.pythonhosted.org/packages/f2/cb/5f8e0d41dbd8b499c2265e939c31acc9ba59845565bf99539ad1c06aebcf/librt-0.16.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:30b7beaf3f4487b7d8adef1f158b49067cb4d5a19fa7a3bf31a4e7a820e435c5", size = 546499, upload-time = "2026-09-29T00:53:19.666Z" }, + { url = "https://files.pythonhosted.org/packages/be/61/063052de441d1385f59cea4223f184bf9e5d125de1ae3239b490aa1e486e/librt-0.16.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:468df902df016a06eb0e40b0747dc8d14e47d7a38b18b63b1fb167d85cb94d63", size = 555261, upload-time = "2026-09-29T00:53:21.292Z" }, + { url = "https://files.pythonhosted.org/packages/14/11/a2ada0529372268d6401afa9d457a095b68cd7753532b6f7f33049a19b43/librt-0.16.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:aea7b1f2b125dad5de85f049136651bff256c883c65e6b9209b2da0a1ac3cdef", size = 536029, upload-time = "2026-09-29T00:53:23.07Z" }, + { url = "https://files.pythonhosted.org/packages/23/9d/5bb6d38853382986dca702fc7e06c8256d30f5fa0676d882773b744610dc/librt-0.16.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a8afb6557920860b7a3a596eb804cf37e09e7cf8a803db2478c202acc72d8c2e", size = 573748, upload-time = "2026-09-29T00:53:24.696Z" }, + { url = "https://files.pythonhosted.org/packages/99/f6/0025cde35ff7f607684dc775a2b2d732cce2561cec050a6ee1fb2e1fc6fe/librt-0.16.0-cp313-cp313-pyemscripten_2025_0_wasm32.whl", hash = "sha256:77c7a2b4fe2c1369e0d5aa1cade26740a7b14be32fbc9a5535d617d20065c39d", size = 79861, upload-time = "2026-09-29T00:53:26.089Z" }, + { url = "https://files.pythonhosted.org/packages/93/93/303b8592909bd583f83f02818ffbea3f7647ca1e22b1cd5465d04b8145fe/librt-0.16.0-cp313-cp313-win32.whl", hash = "sha256:02d89c813d5ff74b17df72d3a34819d132cd168e56b81bf755b809bd9e46b8c4", size = 106236, upload-time = "2026-09-29T00:53:27.43Z" }, + { url = "https://files.pythonhosted.org/packages/cf/24/80bbb463c60ed18e29cb26aba386ddb76609580e4e7160710e550587018c/librt-0.16.0-cp313-cp313-win_amd64.whl", hash = "sha256:14ed6ebe3e4f85f326d7920011ad30ff49ed9334e62cf88caef9ba973d9e3a92", size = 126180, upload-time = "2026-09-29T00:53:28.7Z" }, + { url = "https://files.pythonhosted.org/packages/43/80/b1a6fbdd7da825cdd55c71aa81eb6cfa82c513c360774152eacb50b4a771/librt-0.16.0-cp313-cp313-win_arm64.whl", hash = "sha256:83d4041a3d9b2fd053a8a4e1f22878b3e5833e2712956382d5c048d791454e91", size = 116557, upload-time = "2026-09-29T00:53:30.012Z" }, +] + +[[package]] +name = "livekit-api" +version = "1.2.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "aiohttp" }, + { name = "idna" }, + { name = "livekit-protocol" }, + { name = "multidict" }, + { name = "protobuf" }, + { name = "pyjwt" }, + { name = "types-protobuf" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/18/0c/a6ced29213cf99f868b8456157a9778294336d2aa53e83cc790b4ed2a364/livekit_api-1.2.2.tar.gz", hash = "sha256:689753198dfefe58b007581fcce09e1089b2ad80adb3f9445798e55abdc9a264", size = 23116, upload-time = "2026-10-16T03:34:58.584Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/50/39/d103c5314fb508832a5abc92b8b3394cb50f79cd07aac350bf5a6e7c9c3a/livekit_api-1.2.2-py3-none-any.whl", hash = "sha256:64f82ed90aaef2dc982dbc7f9dcda6f5dc958928ce77ef2b582459d8e2c87b6b", size = 28806, upload-time = "2026-10-16T03:34:57.178Z" }, +] + +[[package]] +name = "livekit-protocol" +version = "1.1.31" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "protobuf" }, + { name = "types-protobuf" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ab/18/9055bd2b0615cc477559b7d68853ac79ac09a4ee281081dd8eb02c35d47d/livekit_protocol-1.1.31.tar.gz", hash = "sha256:97fb2453b1050e21ec29e3933ef78df23349dcdc1b642857025d4784fd12547b", size = 134972, upload-time = "2026-11-05T00:33:00.107Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/7f/3bc2fee2d3f5a1d0dc812ab2212fe7dc84c732660138020e2e54a56f6908/livekit_protocol-1.1.31-py3-none-any.whl", hash = "sha256:b37c262515c541008c43227a9424151bbf3b93dbae7fb6b9c8e8521c76f5cc29", size = 162950, upload-time = "2026-11-05T00:32:58.384Z" }, +] + +[[package]] +name = "multidict" +version = "7.2.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7b/6b/29ef8e752f5854dcc267ded0652c475bec99b86f255957a0d4fcad657b18/multidict-7.2.1.tar.gz", hash = "sha256:e8af3e6f1bdbc31fd762b094133b478b89988eaebc63cbe82b1d9e3bbd15cede", size = 369994, upload-time = "2026-10-24T20:41:35.457Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/33/f7/453ab156d423def05ac3a4a268f52975378520bf919ded61c9a8bb432b7d/multidict-7.2.1-cp313-cp313-android_24_x86_64.whl", hash = "sha256:fba33e264cdd2ec43b762b9960d1648478eecfc1ce6e8159e7573510f0412c0a", size = 103189, upload-time = "2026-10-24T20:40:18.599Z" }, + { url = "https://files.pythonhosted.org/packages/dc/f1/db4171d67615081b007ceab10af476cf7d66a09291fe43c11f1608f08d4a/multidict-7.2.1-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:f2087ee23a18d1d618a135eedcafba7dfc40c6be4e8e52d7f29a1c8a2344cfcc", size = 101149, upload-time = "2026-10-24T20:40:20.693Z" }, + { url = "https://files.pythonhosted.org/packages/be/7f/adae49eae252c26f2b7a2fe89984533e0bdb0072f29023c1f2ad7588886e/multidict-7.2.1-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:f5812cfcf7daf3ace5fedf6e2ef8d4b342c92fb8ea3a87c1aed2b50453271093", size = 103294, upload-time = "2026-10-24T20:40:21.094Z" }, + { url = "https://files.pythonhosted.org/packages/48/45/9fac67be204fc5570593f4dfa2395c542bd1643ffd090c8566de0d1d7f8e/multidict-7.2.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:57ac987ec5d26d89bbc48c03e486f659e1367d425c465552ac2e4c4af8219def", size = 171995, upload-time = "2026-10-24T20:40:57.102Z" }, + { url = "https://files.pythonhosted.org/packages/91/5f/7912bfb95ab00ed6bd588884ca44adf29f7f56de871247a535135af5a2b4/multidict-7.2.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:714fe5f3eabd153b1b6911d0b5d31cfc2b808ae6b9e59a32fab17a4e6cdad5a6", size = 97470, upload-time = "2026-10-24T20:40:19.915Z" }, + { url = "https://files.pythonhosted.org/packages/06/18/29874e132d78ce182305e818a1553c4ddae238471ba55903a1ea38b5c4ee/multidict-7.2.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:4bebc9587cc29359e3b045518a7a09264fa882a7d165b0d143fdac8eeb7ea948", size = 103438, upload-time = "2026-10-24T20:40:21.892Z" }, + { url = "https://files.pythonhosted.org/packages/20/9c/0b181fac73aa9d0f24bc38ce24f56a9cb08c96109c3703d2f79ef15c2c2b/multidict-7.2.1-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:12bbab225ce7f69df75409ab8005b11b4e9309fae863821e019e81b32e53a2a8", size = 493852, upload-time = "2026-10-24T20:40:22.383Z" }, + { url = "https://files.pythonhosted.org/packages/4f/2e/d9ee8ccc91fcde4ad9a6e8275b1df67c0e079349fe33fd30530706dc2707/multidict-7.2.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:335f715f8c111654f29e4bf19086bdf0245579d2b4f4f59ca8dd9b3003b859c1", size = 506390, upload-time = "2026-10-24T20:40:58.494Z" }, + { url = "https://files.pythonhosted.org/packages/3c/f6/eca9192c0e598655258b2a3d14120894aecc338982670ca7ff4d4168b226/multidict-7.2.1-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:9df532dd12c59a7a4d381646fc1bf5ccb4bcdbd01c0887d07ec769fae965ea9a", size = 469470, upload-time = "2026-10-24T20:40:21.363Z" }, + { url = "https://files.pythonhosted.org/packages/cf/1b/a4cc6da2651966c725298812d965b9d70918544214a5638c670837034f24/multidict-7.2.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9c9f285f788acf2448a57c9b93d31103ac69bd03f92907e2ffbb23c4e2b42eb6", size = 523167, upload-time = "2026-10-24T20:40:23.155Z" }, + { url = "https://files.pythonhosted.org/packages/20/64/f3df5b32600c6dfcc1a949f15c8de2b89e44053f5c78bea05c6240e10af8/multidict-7.2.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:21d86f2aa58aa428fb9909d6b8587b75eea827287e3515b8506dca86b55e2c7d", size = 547031, upload-time = "2026-10-24T20:40:23.769Z" }, + { url = "https://files.pythonhosted.org/packages/8f/85/15918b8f73aeb770c534f22b7dac256dc70b6fc7aa0571856c82b17bf3c5/multidict-7.2.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cd69c99affd1b4debc1f9a53058ea6e3a9b34f2e733840bb45891666cd8ede0a", size = 516056, upload-time = "2026-10-24T20:40:59.951Z" }, + { url = "https://files.pythonhosted.org/packages/d6/eb/b74b3441b572218dd24068b67bf4f1f71934cfa5938f55c299425b1c41c0/multidict-7.2.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fc45ff19520bc5321dcd9c6dd7df6f35ddd894eed35e5be65d1828a9c3f5e7fb", size = 449703, upload-time = "2026-10-24T20:40:22.96Z" }, + { url = "https://files.pythonhosted.org/packages/47/81/ac9bb12377260913c0384bdb60b2f9097b1ba580579a2f9697b9e21709f3/multidict-7.2.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:cba29f12cd717f1cbbe220edf005666f8df0453347217748331a0c2f7522eca4", size = 492340, upload-time = "2026-10-24T20:40:24.498Z" }, + { url = "https://files.pythonhosted.org/packages/b1/e9/bac55e20457d3e23879b65e8c46dca5ea90675eee73ca70e3fdc4984d53c/multidict-7.2.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:4f31ddf9c00e0e7db848b0236c717acbe8a51954d0d31b4d908c538ab3f0f5bc", size = 468205, upload-time = "2026-10-24T20:40:25.143Z" }, + { url = "https://files.pythonhosted.org/packages/ff/ce/1dc1e0f8bc28f41edee6fd0e2116f1d7b077e8fc04f50617e65f3938cbaa/multidict-7.2.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:7cb1a6bb70edcc06ea65cb9a35654f268ee39d9fc3a52240e6ba074db4d9c99e", size = 493634, upload-time = "2026-10-24T20:41:01.377Z" }, + { url = "https://files.pythonhosted.org/packages/70/e2/7a4e9f4e35d522715f8987a17d3a098582d0e879cfde621defa586330310/multidict-7.2.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:a2a322c8a6ca82a5a67ae6542e9c6a13ded9045cfd171dcccc5c213ac4fe0eab", size = 509128, upload-time = "2026-10-24T20:40:24.735Z" }, + { url = "https://files.pythonhosted.org/packages/60/cd/d142dc374de3585c57e2635659d45c3179cf47ded415794b2e7ead663373/multidict-7.2.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:678ac791b5820d9346eb13c8515fe0c9383659335e5cfc544bf6f54714ca1939", size = 444795, upload-time = "2026-10-24T20:40:25.846Z" }, + { url = "https://files.pythonhosted.org/packages/6d/41/7121157290d436282cf2405cda15d4448921231f7291679f5a3f47669bbe/multidict-7.2.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:0c454b677214fa604e9c4a38952c685dc88a62c96f5576a7ce550f213d1dc318", size = 526200, upload-time = "2026-10-24T20:40:26.763Z" }, + { url = "https://files.pythonhosted.org/packages/6f/fd/05e6e78a34023574db41c057676aa1c26925533fe0b60de9fb5ed5e1a101/multidict-7.2.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:3ca60708f0ca9d994f46790c5a5c774771e2f1dcb75b12c70a5582f915d0e721", size = 507752, upload-time = "2026-10-24T20:41:02.846Z" }, + { url = "https://files.pythonhosted.org/packages/41/50/3bccfa9f3003303fda37a1f7131ab90dcf36ef7fa9af6611151f4b2e316c/multidict-7.2.1-cp313-cp313-win32.whl", hash = "sha256:fa949c81e3865c10f981dea72fd8d418b59edd13a047a0aee89ae64790113d53", size = 84246, upload-time = "2026-10-24T20:40:26.077Z" }, + { url = "https://files.pythonhosted.org/packages/74/6b/3f90a565e6eb94266d197d91cca5833b34ab89768a24f7f07b29bc186e09/multidict-7.2.1-cp313-cp313-win_amd64.whl", hash = "sha256:ce0f884408938fbd72e764031d0e23a8f5e438a7bf73641089c7a6465cb84456", size = 95078, upload-time = "2026-10-24T20:40:27.34Z" }, + { url = "https://files.pythonhosted.org/packages/09/29/3acaf67a5d0f0ecce997f1f57cd14c8c634d05e1672df23b29d4f072262e/multidict-7.2.1-cp313-cp313-win_arm64.whl", hash = "sha256:554ee1d2e71fb5b4059c0c4ee35d8cbc8af2a3147036d65a454c53fea07ff4eb", size = 92390, upload-time = "2026-10-24T20:40:28.055Z" }, + { url = "https://files.pythonhosted.org/packages/9d/96/1aaad0e5df33f1f5af21775c411349c0234a6edd581ea403bc81919a6eab/multidict-7.2.1-py3-none-any.whl", hash = "sha256:08ea94663d9e7c25d14d78222f7aee906c472d4837766bec62477c38f6e419dc", size = 28145, upload-time = "2026-10-24T20:40:59.159Z" }, +] + +[[package]] +name = "mypy" +version = "2.4.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "ast-serialize" }, + { name = "librt", marker = "platform_python_implementation != 'PyPy'" }, + { name = "mypy-extensions" }, + { name = "pathspec" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/4a/3c/78ecece71ee4b5445389a4ccd11627cfb9c69e4de554d11060171f376305/mypy-2.4.1.tar.gz", hash = "sha256:63b590965691e540a335fa74dadf31a7069142f6b72e234689bbd244edf55623", size = 4066728, upload-time = "2026-10-28T19:23:13.286Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6a/65/cd19bc06b67d2df4828220245fd98569ba40fac00c60ba48bef1817d8ac3/mypy-2.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:3f1613bfe6726169a5680ec3dad5fe1f6d96285c12aded51ad432eadedd39d45", size = 14214179, upload-time = "2026-10-28T19:22:27.49Z" }, + { url = "https://files.pythonhosted.org/packages/c9/db/4d54ca4f49a0113d0c956980272aefdc6eec5458f7a72b350785b9ef2249/mypy-2.4.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:680ba5d9f0e3fe0c746b3d14df15f2a718e4bc308a1145616a1ea691ce7c354b", size = 14376217, upload-time = "2026-10-28T19:21:08.019Z" }, + { url = "https://files.pythonhosted.org/packages/a2/45/089b4724b85f0f408941be5298f817ee89181c1ce031a73d60ebb150c2c7/mypy-2.4.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1fb70621535c22fd4b1a0450d37ed1d6c5eb39656a1cebfb189084c12de54ba7", size = 15467623, upload-time = "2026-10-28T19:21:21.555Z" }, + { url = "https://files.pythonhosted.org/packages/27/4c/15edbc64b0b1412bfe94c74192b3b56554ab642b85a31bb06cef6313101f/mypy-2.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4af5ad558033bf71a44a1be149ed61633522824f84c1b6bc2445d3fe2d2a5296", size = 15729867, upload-time = "2026-10-28T19:21:37.557Z" }, + { url = "https://files.pythonhosted.org/packages/76/5b/7d160fb685994b65b0ca86d352f3afb5424f96a9e6914719f7b861fb6701/mypy-2.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:acdd82cb3a2eb10720e87706a128d75785d005a8c47e36a4973221524f41fc56", size = 11390802, upload-time = "2026-10-28T19:22:18.081Z" }, + { url = "https://files.pythonhosted.org/packages/9a/ea/453d27f8d531f57c5f14aa73094eaa48e3fb77f680bdede2045cb64fa58b/mypy-2.4.1-cp313-cp313-win_arm64.whl", hash = "sha256:133886ef1a7a8cf673df55d865e41580c6c46aa9b8e31e1325dfe315c038f745", size = 10950427, upload-time = "2026-10-28T19:22:59.003Z" }, + { url = "https://files.pythonhosted.org/packages/21/5c/6e8010f8f2c4ad817364592d4b3883633417269b913fc9d5174fdeb7e565/mypy-2.4.1-py3-none-any.whl", hash = "sha256:1784648d6ec8c2b5465e3f26241b845a0b3cb4e56c993c64abe16989e7698999", size = 2801286, upload-time = "2026-10-28T19:21:19.516Z" }, +] + +[[package]] +name = "mypy-extensions" +version = "1.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d0/6f/43edf3be2748d0e40974a5ee046a8bb4c9961a4bdd3e7d5c6dcbf32d1af6/mypy_extensions-1.2.0.tar.gz", hash = "sha256:c6e275a07f1c2fc096b62940452d0eaa3f3af7de45d704787866240a7c6d5a51", size = 6566, upload-time = "2026-11-03T16:26:33.692Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b5/99/7b1adac7e97ce7bbda34792dc6e9b2f5cc5cffd1875883f78a818b2a2687/mypy_extensions-1.2.0-py3-none-any.whl", hash = "sha256:993e7a20aa5671eb41a7132cf555af30062da99729601601b9a8382b11cae768", size = 5114, upload-time = "2026-11-03T16:26:32.731Z" }, +] + +[[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 = "pathspec" +version = "1.1.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5a/82/42f767fc1c1143d6fd36efb827202a2d997a375e160a71eb2888a925aac1/pathspec-1.1.1.tar.gz", hash = "sha256:17db5ecd524104a120e173814c90367a96a98d07c45b2e10c2f3919fff91bf5a", size = 135180, upload-time = "2026-04-27T01:46:08.907Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f1/d9/7fb5aa316bc299258e68c73ba3bddbc499654a07f151cba08f6153988714/pathspec-1.1.1-py3-none-any.whl", hash = "sha256:a00ce642f577bf7f473932318056212bc4f8bfdf53128c78bbd5af0b9b20b189", size = 57328, upload-time = "2026-04-27T01:46:07.06Z" }, +] + +[[package]] +name = "pluggy" +version = "1.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/bf/db/7fc19e6f2dc92a966727031389fc2e08b558f0f25eb7403c1119ad4713cd/pluggy-1.7.0.tar.gz", hash = "sha256:d1eaa46ebb595891b860ab086b4d09c8588af65ebd4361b8e8f4bb8920b90ba8", size = 123304, upload-time = "2026-10-15T09:50:58.343Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/40/9e/2b38731e0fc536806f16490e1a12d7f0dc2a1235aa8cc07bcc75416a7daa/pluggy-1.7.0-py3-none-any.whl", hash = "sha256:7dd7b0d8832ba3cb632c306926ded123429211b83641b35dc5c41ad2d34f9bec", size = 27082, upload-time = "2026-10-15T09:50:56.808Z" }, +] + +[[package]] +name = "propcache" +version = "0.5.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b3/9a/9fbf4e4ec0c2d7f1c32519fff782ef467859b8faa9fbc5331a96f6395d43/propcache-0.5.4.tar.gz", hash = "sha256:ff6b113f50bc066a698db5d944d2c6dc7507168dd3341e255a8892fd0715a558", size = 61545, upload-time = "2026-09-16T00:17:14.386Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/78/4c/3b1365d58a667689e067e13d055fcd92bdf8d9a2fca3d9201b47ed5b3631/propcache-0.5.4-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:36c0d9db44b523ef93d03341b1c42d69ff01d673c053d1b1c6c3a363bcaa39ba", size = 85290, upload-time = "2026-09-16T00:14:52.342Z" }, + { url = "https://files.pythonhosted.org/packages/8f/61/5f9c29c3aa67c30238c4eadf95149b1d983a48f69b86b0cff927a7d6df13/propcache-0.5.4-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e1d52a05dc417279f7e5c7618c5dfbbc29923aaf9bc0a5c1802ddcebf54c61a0", size = 50027, upload-time = "2026-09-16T00:14:53.67Z" }, + { url = "https://files.pythonhosted.org/packages/25/7d/c1ab1ef09e9d4d835be5d58c0a32a1e1de8397abaa4e502a9d4141328cad/propcache-0.5.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:44149f46500a0a41b95b4d99c2e586a77319539730607b9892974a092788b111", size = 51425, upload-time = "2026-09-16T00:14:54.826Z" }, + { url = "https://files.pythonhosted.org/packages/73/36/0093091ebb270fcd1bc1f6e095f93b2e0ed7f1011c28837dc2dbe5f96b99/propcache-0.5.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dbab5f5ff6897c81f355d079010cdae85b02e5a0b518b5251523b8ad8ae9ac3c", size = 233595, upload-time = "2026-09-16T00:14:56.09Z" }, + { url = "https://files.pythonhosted.org/packages/ae/8f/0de9d4c8e05ce0be71b436919a216bd7fc5cc6e2691c0602295efb22b9ed/propcache-0.5.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c3e98c55bde2bcf7db3c70d1aed7ae9aa8aebbf19a250c66645cde44cdb8b867", size = 240318, upload-time = "2026-09-16T00:14:57.674Z" }, + { url = "https://files.pythonhosted.org/packages/7d/71/2b35e91455209b85ee98f7859583e0814fab57d3af0f2381aaee34c37304/propcache-0.5.4-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:db3ae52ccc150dbc84704e9d642743897f3e1c54742ff34cacb661e52e3818a9", size = 246649, upload-time = "2026-09-16T00:14:59.352Z" }, + { url = "https://files.pythonhosted.org/packages/ed/74/08e6c1faf26ee2732023a3828787ba535557122774f4a386b1f715cbd8e0/propcache-0.5.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f85915e00dcb1cd9f2f890ead064ed40a27df06f0db65be427b29482ae357572", size = 234316, upload-time = "2026-09-16T00:15:00.696Z" }, + { url = "https://files.pythonhosted.org/packages/5c/9a/08385733c9321c9bb78039d3ff31045e4fca962d9665023c4eb70f998819/propcache-0.5.4-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c2ba30a89035b57b73e00475de948521602f543d79ce01db10b04b36c4c76fc8", size = 204666, upload-time = "2026-09-16T00:15:02.019Z" }, + { url = "https://files.pythonhosted.org/packages/1d/f4/e87bc7629af9a14a752b218764a78742d73c2c563ac58315da6841f0cbe4/propcache-0.5.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ae58f361bd5dae942717c65d3413b478c70aea9c462599e7b9adad3731db3894", size = 225900, upload-time = "2026-09-16T00:15:03.394Z" }, + { url = "https://files.pythonhosted.org/packages/d9/6d/11014938d3fe9bea2ea2dcf930f26ed565bfb2f5be3c756362ea48c92636/propcache-0.5.4-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:96f7c5c15656040ddcbc51e56dc59b58aa25999d743c126abd425b9766ab43e9", size = 219988, upload-time = "2026-09-16T00:15:04.811Z" }, + { url = "https://files.pythonhosted.org/packages/dc/72/fbf17c589f92c0b3bbf6709a425661f8ef2ed0d46b38985a7d7b5a0f6b91/propcache-0.5.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:7cc528e760a8af06f2b13e9b9f362cd90c7c718ea61228a96dbd31ba16ed7f47", size = 233611, upload-time = "2026-09-16T00:15:06.498Z" }, + { url = "https://files.pythonhosted.org/packages/55/7e/dbd637572a279692e5518d117274a9331bf5faac59f191d30e82521a3ec7/propcache-0.5.4-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:425f8cc86ab5018b4b8d4a23bc8e74d964bd3d757c3702e301aa79be76c53f6c", size = 204333, upload-time = "2026-09-16T00:15:07.961Z" }, + { url = "https://files.pythonhosted.org/packages/ba/5a/f99c92068f1e0f5c886899ce0e4a619db376ca98c5279d93f95bd86906af/propcache-0.5.4-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:a5793c7698a53f56f4a1889a4737c7eeb1b7ad0842fa6b1abca22913ff79c8c1", size = 235177, upload-time = "2026-09-16T00:15:09.334Z" }, + { url = "https://files.pythonhosted.org/packages/ee/28/95456fabd2daf6be89049a13fbf03341756014d2959c83d12957d4c49694/propcache-0.5.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:c02c0e570c5c7e077b0181a9f3cdb7d4c3617d1cda6b5c95bd5d34022923d82c", size = 228982, upload-time = "2026-09-16T00:15:10.729Z" }, + { url = "https://files.pythonhosted.org/packages/b1/bb/df90f62c9cf7c93ea235f6f9405143bba802914607317266dd81fc8d737e/propcache-0.5.4-cp313-cp313-win32.whl", hash = "sha256:3e413d7a4a9b4866b7a761d6060d434b64d23cd35122eda3b026a0bbe8196b25", size = 42611, upload-time = "2026-09-16T00:15:12.111Z" }, + { url = "https://files.pythonhosted.org/packages/01/bc/e0a7b84af04ec02d73a48aa71f091e1e4a2107e3074b7ce12195b66901f4/propcache-0.5.4-cp313-cp313-win_amd64.whl", hash = "sha256:0c889f6fa84957bc7e8b4eab71fd16a0455068d5045e3aa40c733071d2b2fd77", size = 45342, upload-time = "2026-09-16T00:15:13.519Z" }, + { url = "https://files.pythonhosted.org/packages/9a/70/50b031cafe72a5c1878b903ee87303f71313345566bf3d6ec202e5ddc9ec/propcache-0.5.4-cp313-cp313-win_arm64.whl", hash = "sha256:69fc35c0779522da366c563e5faf203ffc1f8ff0021d5b1337fa4efa5be73177", size = 42408, upload-time = "2026-09-16T00:15:14.788Z" }, + { url = "https://files.pythonhosted.org/packages/f5/cd/785c64ed382f3f04201870267b02783f63b4678c2acfddc177a3ebcc2727/propcache-0.5.4-py3-none-any.whl", hash = "sha256:62c60aec739ed00124573cce1178138fd690c7676352d67a37328c1cf51d7468", size = 16338, upload-time = "2026-09-16T00:17:13.106Z" }, +] + +[[package]] +name = "protobuf" +version = "7.36.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d9/89/5b8517baa72f84a67b8a307ba953c91057af618bf40bf676f3c03551f8f0/protobuf-7.36.2.tar.gz", hash = "sha256:497d0463ff3316681da6c0b9e8d06cb465d61abce00b613ab42226175644d1bb", size = 512737, upload-time = "2026-09-17T20:07:59.326Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/32/72/98342feb672507c8f3a69e34b4fa8961f608edba5c1a48a6f47156d92cb5/protobuf-7.36.2-cp310-abi3-macosx_10_9_universal2.whl", hash = "sha256:cbc70b17ee27e28894c7fee8bb04be1abead49e936bc70eb60052531eee2079e", size = 456039, upload-time = "2026-09-17T20:07:51.542Z" }, + { url = "https://files.pythonhosted.org/packages/b6/ea/91fdf7c2b8bbd49cde056f00a9df6773532987e1c00fe2830b895af95c7e/protobuf-7.36.2-cp310-abi3-manylinux2014_aarch64.whl", hash = "sha256:e11e1f0180583a2af89db6a2ecd9e8dc40aa6d2988ca175bfd0e6d12ea72d74e", size = 344219, upload-time = "2026-09-17T20:07:52.914Z" }, + { url = "https://files.pythonhosted.org/packages/17/ab/5fd5f8ece73fad885c5a09aa849b32d70472f954ba3a92d3bb5974ea953b/protobuf-7.36.2-cp310-abi3-manylinux2014_s390x.whl", hash = "sha256:f4fee11ec330d238b34a05c9b675f693c20415d1c5bd7d5320cc2f8a798eb9cf", size = 357223, upload-time = "2026-09-17T20:07:53.985Z" }, + { url = "https://files.pythonhosted.org/packages/db/f3/3996583dd2906297a637af12114deddf7658af6e683fedb83be061983fb5/protobuf-7.36.2-cp310-abi3-manylinux2014_x86_64.whl", hash = "sha256:89f23aa53c24553a2416fd4fd1ec06f74fa42b14b546d8883128813f775bbfd2", size = 343223, upload-time = "2026-09-17T20:07:54.931Z" }, + { url = "https://files.pythonhosted.org/packages/fc/1b/dcc64f358fcb51811b58ae40b3d28f820725f116d86487cc20bd4b130701/protobuf-7.36.2-cp310-abi3-win32.whl", hash = "sha256:912c1221170e16c08d1f086762f563dd61ff83c18b5fa6652952dfaded66f728", size = 442998, upload-time = "2026-09-17T20:07:55.826Z" }, + { url = "https://files.pythonhosted.org/packages/8a/55/b77bda4e5e5f5971fb51b07663694690e9afdb9402136c16a522bd621cad/protobuf-7.36.2-cp310-abi3-win_amd64.whl", hash = "sha256:a300819d441e078a5608c0d3c709796bb548136058fda017ae51d425b44fd353", size = 456514, upload-time = "2026-09-17T20:07:57.188Z" }, + { url = "https://files.pythonhosted.org/packages/e4/04/d52c7016b04b6c5108f26691f9d33ec82a9b65d041f1a9c771137693d618/protobuf-7.36.2-py3-none-any.whl", hash = "sha256:bdb3a345d48db958e6ce1f18e508beb0cc981d64f24088427549c866cd039f1e", size = 179806, upload-time = "2026-09-17T20:07:58.211Z" }, +] + +[[package]] +name = "pydantic" +version = "2.14.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-types" }, + { name = "pydantic-core" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b4/fc/1203d011145c98ddce3cd2f505bb54375ef8d43223053df6bfcf3c746f46/pydantic-2.14.3.tar.gz", hash = "sha256:1054f237f4d4733e5a30e1e2333bd33a2004ab9ab1510bd053a9a31bb48e232d", size = 925636, upload-time = "2026-11-05T09:17:04.686Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/72/b8/b864503789ef5c58597d2a7b4adbee62c26440ab1e682581fc6838df8947/pydantic-2.14.3-py3-none-any.whl", hash = "sha256:3f7b347782ab7e4cf4c798496ae3a82787d9f21ec12676a9c564355eeef8daa8", size = 494071, upload-time = "2026-11-05T09:17:02.426Z" }, +] + +[[package]] +name = "pydantic-core" +version = "2.50.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9c/0f/34f9ce4ba701dd0b815033ef94d99b56f66200e578ee6052aa3ac26f064e/pydantic_core-2.50.4.tar.gz", hash = "sha256:aead3b74b68396bf0feab5d57e050b730ded7df55853c1e048a87e00aaf033c6", size = 522249, upload-time = "2026-11-05T09:15:26.774Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1b/21/39f8d97e7ea8e3083606ee9bb1d7f3ec70ded780bb6a872aad76c7eb08b2/pydantic_core-2.50.4-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:0f87ae9db5ba8d8de40dcb93982cf8dd544e6552a80d3345af51941cc2146b80", size = 2232841, upload-time = "2026-11-05T09:12:32.617Z" }, + { url = "https://files.pythonhosted.org/packages/90/a9/fc89a632331882c4cb91b949003418e6f5085ddcf2c9b875d499682bca91/pydantic_core-2.50.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:1239daaf063e62213ca1f34d96663900bd7510c42a786e843fd328af70e46445", size = 2108326, upload-time = "2026-11-05T09:12:34.419Z" }, + { url = "https://files.pythonhosted.org/packages/5d/6d/4f6d00e9e4ba2a7bfb206ae49cb32a3ea90c4d658e62f8775277d75e8291/pydantic_core-2.50.4-cp313-cp313-manylinux1_i686.manylinux_2_5_i686.whl", hash = "sha256:1afefe44ec042fbabfb4483ea258c04cf75ff4ff328d754f09b321fdaa232aca", size = 2256276, upload-time = "2026-11-05T09:12:36.212Z" }, + { url = "https://files.pythonhosted.org/packages/b3/b8/936cb0d85a4082e902c7397db0f05c4372eac08372c8bd2d0b43088fcf17/pydantic_core-2.50.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:a9ee990bf2cfbd81574c5d4f9f6c9d480a14b5b43d2c4f2ca13f4c6f48089394", size = 2125469, upload-time = "2026-11-05T09:12:37.913Z" }, + { url = "https://files.pythonhosted.org/packages/66/a1/f3b551ce233dcac73286b8b2ae45c23232a9f41c68663e2dcce4bb2dbcfa/pydantic_core-2.50.4-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.whl", hash = "sha256:ab2822bcdcefb54d1e8ca29f5e3ec3afe160fb794bb3a3a73b09d11ed2671104", size = 2196397, upload-time = "2026-11-05T09:12:39.725Z" }, + { url = "https://files.pythonhosted.org/packages/3d/93/a8a6e7e20826567a73e6f52416667698aa3de704804d7a49d25272246af4/pydantic_core-2.50.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:0001d6920e3a5d92758cda460d6dc9f918a78ad6d5d092f1c8b5395e6b775356", size = 2380442, upload-time = "2026-11-05T09:12:41.196Z" }, + { url = "https://files.pythonhosted.org/packages/aa/ba/921d90c9d8ddfa653a35a92f2bbdcd650a9493f5c43f39cdfe4c56659cfa/pydantic_core-2.50.4-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:ad92d255a165cde5f9882ff69699eaf5495e0a1f32e97566bbbb80f11e46781f", size = 2457747, upload-time = "2026-11-05T09:12:42.723Z" }, + { url = "https://files.pythonhosted.org/packages/2b/c0/1c8f2c8f1b03d3fe2fdd7962df8f5a744fe48c90b4c49615e4eb77d04141/pydantic_core-2.50.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fb2bb5b557080da398b866408117d98fe9731a12334afd0cdff221b458cdc5dc", size = 2260946, upload-time = "2026-11-05T09:12:44.185Z" }, + { url = "https://files.pythonhosted.org/packages/07/ab/5b78a78f0c0f11c8b54fac214ed49cbb4795c34881aa5e5a15715917936f/pydantic_core-2.50.4-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:92f8f63df729061bb14558ed740e7a222f6e2aa6c2e6a5bcfb4e706676562049", size = 2261921, upload-time = "2026-11-05T09:12:45.706Z" }, + { url = "https://files.pythonhosted.org/packages/b3/e8/dd5d154cf3401f836d8754fdc1b82ecadd37944e4355d855e5b54c7d8ae5/pydantic_core-2.50.4-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:2a60ec2a57ac3ef3767e526e9bf706ec75aa3d79c4ea134d9d4fcf7c62a297a7", size = 2298475, upload-time = "2026-11-05T09:12:47.183Z" }, + { url = "https://files.pythonhosted.org/packages/b7/49/af059d9c4483cba168b17e5f84f1090a3e4547a02fc7698ebc231d48298a/pydantic_core-2.50.4-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:a2f8716344bbbd10443391be0b1f2003b34949e3c4e6883005c2bc498ab6b64b", size = 2473226, upload-time = "2026-11-05T09:12:48.785Z" }, + { url = "https://files.pythonhosted.org/packages/71/ba/4e8cc3064e8bc2ea272cf80dfc57fa4e455f6835c1def21609a9b4f94135/pydantic_core-2.50.4-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:b300a30304c4c6aeab03ed06809819d9eceacdd58441d3c7dffe41edfd006091", size = 2498764, upload-time = "2026-11-05T09:12:50.316Z" }, + { url = "https://files.pythonhosted.org/packages/bf/96/0878499cfa88eb0b07762b488d3cc1b64cb156a4905db99a0d12096b2e17/pydantic_core-2.50.4-cp313-cp313-win32.whl", hash = "sha256:ef8a9ddb2e2a4eae4abb72dc0710eca6c2726779b0664bd10c3ce9fcfaed8c4e", size = 2119158, upload-time = "2026-11-05T09:12:51.834Z" }, + { url = "https://files.pythonhosted.org/packages/e7/e0/95b651213e79ffe0cbee855590bf5cfdf8e3a0d90a7fc73a080e63f7cc38/pydantic_core-2.50.4-cp313-cp313-win_amd64.whl", hash = "sha256:4c29b8f2d6417069bb1f257e6b9113f5d64dc2f554cc200ea90b4fe2c5cdb733", size = 2262887, upload-time = "2026-11-05T09:12:53.282Z" }, + { url = "https://files.pythonhosted.org/packages/08/d3/c3801dc75844a0b13d000bd575bf1b3b3a8aca9f63d19e1809f69c9c5121/pydantic_core-2.50.4-cp313-cp313-win_arm64.whl", hash = "sha256:a0c5210d038418a115e7cf8706bfa611bd14add2f5787274dba45aadca3155e0", size = 2167878, upload-time = "2026-11-05T09:12:54.677Z" }, + { url = "https://files.pythonhosted.org/packages/9e/3a/d405a50e02ea81cd95e93fc97f43d351eeaa1707c28aa6fb58a9f1c1efa9/pydantic_core-2.50.4-graalpy313-graalpy253_313_native-macosx_11_0_arm64.whl", hash = "sha256:71e03d2ea1a221d020cfc5b6cc8bbbedef23a06704c70fcf4d0afb42965b22d1", size = 2049324, upload-time = "2026-11-05T09:15:02.379Z" }, + { url = "https://files.pythonhosted.org/packages/8f/3b/03e7992115e17cae4131ecd71aadb8fd5e4a04cc2aead72c3c27e9b453d1/pydantic_core-2.50.4-graalpy313-graalpy253_313_native-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:09450778e3ab58d3f565899fcfbf6c82926e31af29e28e799d1d50941316e0cb", size = 2119802, upload-time = "2026-11-05T09:15:03.901Z" }, + { url = "https://files.pythonhosted.org/packages/e3/d5/b76fc647e473377ca6e693c21549ee3a4a7677e4962c28ae4659337c5114/pydantic_core-2.50.4-graalpy313-graalpy253_313_native-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:4eb15be588407148ef5937dc8d9256a71f6b115e6cddadb7aa20b6ee384680dc", size = 2280639, upload-time = "2026-11-05T09:15:05.374Z" }, +] + +[[package]] +name = "pydantic-settings" +version = "2.16.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pydantic" }, + { name = "python-dotenv" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d0/31/21d21e0e38a8f5f7a7f0cde22820117b6d17d6bc36f348129527f915d7f4/pydantic_settings-2.16.2.tar.gz", hash = "sha256:3dc08ffabed2bbd5dc2dd55ead7b78b4e4c5cd776aecd04310db5f1224caaf46", size = 309225, upload-time = "2026-10-28T11:14:28.066Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/39/88/4131c51ff87b7cd7f82a1194d0c0a2b7ca68c72d9da04c3b7e69ab91a79f/pydantic_settings-2.16.2-py3-none-any.whl", hash = "sha256:8d523e5c129b097686bde019bd6109bff53f4c8c3f63f1ddf9933ffa42e5ab4f", size = 81994, upload-time = "2026-10-28T11:14:26.716Z" }, +] + +[[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.15.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/43/ea/5194e52748b0da83d71e082d75496eaec6e58f419f5e184786ded517e6a9/pyjwt-2.15.1.tar.gz", hash = "sha256:4f259e80cdfb6b3fc18a7de51fd1ef9ec79652f25019bae68975ca2468a34df8", size = 121252, upload-time = "2026-09-28T18:40:42.598Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/50/ca/44de4e75f8aadc457f0634be3b542815078ded46dca30efb960edeecad6e/pyjwt-2.15.1-py3-none-any.whl", hash = "sha256:42d59d631f7768a1028a64c7ff581a9bf7519804daf91fc5b6c56e30eec5e193", size = 33860, upload-time = "2026-09-28T18:40:41.429Z" }, +] + +[[package]] +name = "pytest" +version = "9.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/79/42/bb4641652e58dedeac2ec51abc1f8cae4fee4ec63899a0999016229a0f2a/pytest-9.2.0.tar.gz", hash = "sha256:97c4f3b1f3c7414aa36465e09b8220f1d03627993bd51b1282e193753c80557b", size = 1797555, upload-time = "2026-10-27T21:30:20.728Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/51/39/1f5d8c8877a0fa327db959c19341d4be61660de1ab6b7391f9eb805bcd67/pytest-9.2.0-py3-none-any.whl", hash = "sha256:07acdcf88ec174f907dc01af70bf52d49b014bbab03faeeade31c96b18c2e588", size = 411776, upload-time = "2026-10-27T21:30:19.131Z" }, +] + +[[package]] +name = "python-dotenv" +version = "1.2.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/74/26/2fbeedb218a787a5eea551c7532cac4e009f83d689dd2faa0d0353473f86/python_dotenv-1.2.4.tar.gz", hash = "sha256:f0d53e69935a851c0dcc78f3ab7aaccd8cabef0b92382b576b824212902873c0", size = 60824, upload-time = "2026-10-01T05:36:10Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/60/d1/38f3a3405989a89ac18390803e70c6ad7c7760da4f9b83cbeca0c44a0c72/python_dotenv-1.2.4-py3-none-any.whl", hash = "sha256:42269a8a5b3fd54ffa6f3d84b18abed50064717576b4ecf03dc4a55d8aa04fdc", size = 23266, upload-time = "2026-10-01T05:36:08.633Z" }, +] + +[[package]] +name = "ruff" +version = "0.17.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/84/0e/3d154514b5bad104f7e1c13169c2d09aa9959fccdc06eb47610ccdfa83ed/ruff-0.17.4.tar.gz", hash = "sha256:f32d178b50e826e00f6c6c1be3f56333068acb9b67fe167143527fa9b81e890d", size = 5357750, upload-time = "2026-11-05T18:52:17.638Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e0/61/c83c78bbb6108f6c6270a106cf12b54175f4f42da088b9bb880f18e31289/ruff-0.17.4-py3-none-linux_armv6l.whl", hash = "sha256:2b08c4ff98dc234f4a3871a3105353b922fa875ea1073ca31001eb8c06bb4d89", size = 10252195, upload-time = "2026-11-05T18:51:36.594Z" }, + { url = "https://files.pythonhosted.org/packages/0a/12/dd158708378866e8b53e145a465dbc51a7cccb9fa197a74d67eaedde7234/ruff-0.17.4-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:13e781c3c5f663e7fba737eaaeb70dd943dd55171daf316afb25cd5268cfe0c7", size = 10266044, upload-time = "2026-11-05T18:51:39.502Z" }, + { url = "https://files.pythonhosted.org/packages/28/32/89a94a8eb11fd505a381ae6f04e190d038d9958695209dc1786ce5cfcda3/ruff-0.17.4-py3-none-macosx_11_0_arm64.whl", hash = "sha256:7ed2f416349944f908f9574215994602242b0836844eba48eec61473033afca0", size = 9432227, upload-time = "2026-11-05T18:51:41.968Z" }, + { url = "https://files.pythonhosted.org/packages/58/de/a392dc38b215798ae72e639d228b7f6c02d826a95d1c0ad7c4c0ee49ef5b/ruff-0.17.4-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8ea6e0017c785f7282092b11e46338e4ebed1b9e575ebf2e55a8a1eda968241a", size = 10136900, upload-time = "2026-11-05T18:51:44.562Z" }, + { url = "https://files.pythonhosted.org/packages/b7/8f/a880f60f42005f6ffdf58f77af34c7ee327000b5607f48a2f6a69d8b242c/ruff-0.17.4-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7e061dcc0933c7031a616ac8d66ac540309b2aa21ab558101a8f53e9ac7a1185", size = 10051245, upload-time = "2026-11-05T18:51:46.7Z" }, + { url = "https://files.pythonhosted.org/packages/09/55/98844264108af7d5ec27ca3a9cd5a989094ca657a14599c4b69a537a0e9a/ruff-0.17.4-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f99e95a1515161138be3cbd1b06c06e739d8feb38ade35564556999fffc3686e", size = 10704341, upload-time = "2026-11-05T18:51:49.228Z" }, + { url = "https://files.pythonhosted.org/packages/28/a2/5edf97659c3ac7c73f978003bd314936f671037fdac6b978e9de3e4b2b65/ruff-0.17.4-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5f28cf0dd37cb172fa5a8d2e92e6ada3a9dbd0609f094ed24039929eef0b7524", size = 11394465, upload-time = "2026-11-05T18:51:51.534Z" }, + { url = "https://files.pythonhosted.org/packages/f2/8f/51371ae856b2bef4d15a044a574695fbc0f6ae63aafa42a2a22428070fb6/ruff-0.17.4-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6b21c771b296888e8a35c6f48555f4b44e4483f61675d6ed51810a5203469ce6", size = 10988262, upload-time = "2026-11-05T18:51:53.91Z" }, + { url = "https://files.pythonhosted.org/packages/af/1b/62874f14c7873f27c7887d2b1ea41de990e14ba9518801ffe8eacb5d51a6/ruff-0.17.4-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2cd025509b2e15721b426fc70fd946d71f43b3db3b08e946dfec2d696d911690", size = 10572829, upload-time = "2026-11-05T18:51:56.129Z" }, + { url = "https://files.pythonhosted.org/packages/98/30/860b9dae67432b16b88558a9cb2d3194825fa62838f8b9cc49f5d9022095/ruff-0.17.4-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:49b77f7b3921669466882a4881168801bdb06ec2cd83fb936049016cf5fdece1", size = 10617545, upload-time = "2026-11-05T18:51:58.172Z" }, + { url = "https://files.pythonhosted.org/packages/0f/08/781a4b41a1c83fd6e30fea610d6502a6f94e49cf94693fd37bdcbc6bfe11/ruff-0.17.4-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:e3b84eb74f260d1c2dbd236fd9d4c011d4fdd0a52bdf8aea57a10e3a1b71fca5", size = 10111955, upload-time = "2026-11-05T18:52:00.59Z" }, + { url = "https://files.pythonhosted.org/packages/3c/e9/94b1c7b677120f30839254518924f73a8157e9bcfa200edcd5796fecdb99/ruff-0.17.4-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:20e43d7a08e712e33067ba887e6574546c79bd4831596ce8f16803ac7ff9ad90", size = 10058355, upload-time = "2026-11-05T18:52:03.61Z" }, + { url = "https://files.pythonhosted.org/packages/27/5f/a825910b9f309336dc33cb8c0774126662c1d2f765699b3e0a1bd538d01f/ruff-0.17.4-py3-none-musllinux_1_2_i686.whl", hash = "sha256:0d1f4848fd2ec0499be4046db9b7dfcc952666885f00233cc7033515282556a8", size = 10415932, upload-time = "2026-11-05T18:52:06.264Z" }, + { url = "https://files.pythonhosted.org/packages/8b/b8/0796468423413ea4df1a6e2442ae26cf49b8f7dda0eecf246e0d5d0dde54/ruff-0.17.4-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:6007e7f3a0e55b0fbdb947af69cf24c21b4928df6be8ef3e767c69b5ccccf496", size = 10826033, upload-time = "2026-11-05T18:52:08.675Z" }, + { url = "https://files.pythonhosted.org/packages/b9/ab/d8e407669ffe5014429baf780c2df47e001968a6e4c17f615e2e7b13a9ba/ruff-0.17.4-py3-none-win32.whl", hash = "sha256:3971a78cba76298b284c8321e6d40d93045ed348bec5c369d85099efc97fb7cf", size = 9931993, upload-time = "2026-11-05T18:52:11.169Z" }, + { url = "https://files.pythonhosted.org/packages/bd/15/42af8a72d8946e3c9efd2bbf6641e462b6832783b0d043d63ef5b280ba7f/ruff-0.17.4-py3-none-win_amd64.whl", hash = "sha256:67c5a894fc054cf41f75f7afa24815de5a74329d97634126c2a1015740ff4e8c", size = 10292922, upload-time = "2026-11-05T18:52:13.436Z" }, + { url = "https://files.pythonhosted.org/packages/68/9e/f1dbffcfb8ff25dadbc21f87a8b73c3848abe4abada9d4b08ccef8bec40c/ruff-0.17.4-py3-none-win_arm64.whl", hash = "sha256:181ae38a9a8fb94925bf7d0794327b48d7fd7f4b50cfd84c0e999cc09ef8d131", size = 9889396, upload-time = "2026-11-05T18:52:15.549Z" }, +] + +[[package]] +name = "types-protobuf" +version = "7.35.1.20260906" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e1/6c/e3e5b3e10bc328126a39637c138f9ebfd734bf14342b9f3540039b4ab995/types_protobuf-7.35.1.20260906.tar.gz", hash = "sha256:efd1a3862d4c967dad5512ef8d56b1530ac84f182c41735b94004756518c4998", size = 69895, upload-time = "2026-09-06T06:35:28.308Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/44/4e/f63e826c68f77ef875506d72f225918800346545ee99847bc28f3394f18d/types_protobuf-7.35.1.20260906-py3-none-any.whl", hash = "sha256:5155e48569e0dabff303fdf578db96cd31ea9a4a63b18018a4ceac6b0ae17462", size = 86419, upload-time = "2026-09-06T06:35:27.247Z" }, +] + +[[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.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ff/50/0195198eb1d75bab716bb0a813abea4cc394bfa17a92a189f021a34859af/typing_inspection-0.4.5.tar.gz", hash = "sha256:7f16c411e7930dc79596d42ab44672a3fe5f60eb34b781a1647e7872363120c7", size = 81194, upload-time = "2026-11-04T14:13:23.774Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/de/0c/ea2544850a7c135e15033a5c7a8e7211136184b7ae63e2239d557c2b3019/typing_inspection-0.4.5-py3-none-any.whl", hash = "sha256:bc210535442b39e1bf2805e0c7a17dfd6a4ace760c84c7f7d16245d23a6b1c39", size = 14874, upload-time = "2026-11-04T14:13:22.616Z" }, +] + +[[package]] +name = "voca" +version = "0.1.0" +source = { editable = "." } +dependencies = [ + { name = "aiohttp" }, + { name = "livekit-api" }, + { name = "pydantic-settings" }, +] + +[package.dev-dependencies] +dev = [ + { name = "mypy" }, + { name = "pytest" }, + { name = "ruff" }, +] + +[package.metadata] +requires-dist = [ + { name = "aiohttp", specifier = ">=3.14.4,<4" }, + { name = "livekit-api", specifier = ">=1.2.2,<2" }, + { name = "pydantic-settings", specifier = ">=2.16,<3" }, +] + +[package.metadata.requires-dev] +dev = [ + { name = "mypy", specifier = ">=2.4,<3" }, + { name = "pytest", specifier = ">=9.2,<10" }, + { name = "ruff", specifier = ">=0.17,<1" }, +] + +[[package]] +name = "yarl" +version = "1.25.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "idna" }, + { name = "multidict" }, + { name = "propcache" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/75/16/e8be8e2fb175bbf41a0680381a319f1199fae256588241a2ac8677eafb49/yarl-1.25.1.tar.gz", hash = "sha256:03dd38de09bc213e9a8b29761eec33ee1d5318dac0e49d8af36e4d27830e23a7", size = 246245, upload-time = "2026-09-15T19:35:02.264Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7b/ed/2f3129bbcc9a5c8ba12cc2b29d8060a3bab9c8043c456cfd4b5ca3188890/yarl-1.25.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:25868beca8b6765f8f7d0e11fe6dd7c66dd4b0793b9500286d20cc92352126a5", size = 143623, upload-time = "2026-09-15T19:31:37.966Z" }, + { url = "https://files.pythonhosted.org/packages/17/e1/f1bc3390fdca352826676b531d0712736f156919090206700421d46b2c37/yarl-1.25.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:10b2fd95332f0d716d5eee3c9fb2ce8eada19082de7fee83d32e37992fd75c26", size = 104011, upload-time = "2026-09-15T19:31:40.25Z" }, + { url = "https://files.pythonhosted.org/packages/a8/aa/50acc5c3e5da04172ae3c281c75405af4d2ca911e16120ab0563f4dffb66/yarl-1.25.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:0f12afda4eea8c8994a76d4df1875c765194f5fbe8a9d197929ea303caee29ec", size = 103677, upload-time = "2026-09-15T19:31:42.46Z" }, + { url = "https://files.pythonhosted.org/packages/30/d2/7d1e0ab9f8390e1fbcede5a6dbf70d23c96ad09b8c5567f3a514d1ddb0e2/yarl-1.25.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:14b79a30a93a3ce2e8832603fd0ab780ada281b0ba5110b519a634f2d7d7d1fc", size = 115392, upload-time = "2026-09-15T19:31:44.371Z" }, + { url = "https://files.pythonhosted.org/packages/71/e1/5ba1e3a2a22139213655e760919038e8ed7e2d4a99826d0bbddb3beb96e5/yarl-1.25.1-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:4bd6340d20ae2c7ca719b87b426e808e90743b676d05d4c26c4fb5ca71f41184", size = 107493, upload-time = "2026-09-15T19:31:46.273Z" }, + { url = "https://files.pythonhosted.org/packages/f5/53/780653d5e0f73831f467cf13548912e5eec97f21dc49fc8daf21da027df4/yarl-1.25.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:126a2533570c554719ca40a1288fdee1700b6bc82e7131aa69fa85252d92e651", size = 122537, upload-time = "2026-09-15T19:31:48.654Z" }, + { url = "https://files.pythonhosted.org/packages/03/92/d54fa70236c6036271c9c9c09fd978df5cbe3ef49ef6c46e9b833476d215/yarl-1.25.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a3faadac7d812ddac258feb57b9846b60c1b437c4f4b9ad42595c6f6fe4390df", size = 126170, upload-time = "2026-09-15T19:31:50.872Z" }, + { url = "https://files.pythonhosted.org/packages/0e/b7/a82a49bf88340b837ef6972b508a1604ae377b9e6904b46b10cf5f1cf925/yarl-1.25.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:be80550d9bfe83d9b62398a37081a90434e6df2d978ec345c3d2820de6beddab", size = 117012, upload-time = "2026-09-15T19:31:53.189Z" }, + { url = "https://files.pythonhosted.org/packages/ef/78/5d684b411e3f3602464ee9b538db48205038f8605872985f61efb809ced0/yarl-1.25.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e07595c7d6f4db270ceede356a1bd1c07a34f1c26f958d1ed0cd7b48e0d2bba3", size = 114950, upload-time = "2026-09-15T19:31:55.694Z" }, + { url = "https://files.pythonhosted.org/packages/2f/11/51d82b852c64f7fad0fc7a7ff3031517204887e874c722bbca839c0b23ac/yarl-1.25.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:eb96ed1ae6c7d072d60840c0434aef07a2df611812810807fbc54263a6053e9a", size = 115428, upload-time = "2026-09-15T19:31:57.966Z" }, + { url = "https://files.pythonhosted.org/packages/e4/49/9d1978049bf646b9ea918313926453c6901b71c92f097467777d47d36a88/yarl-1.25.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:3feb99222553a8cbedfa52c2f59dd84c3f50d5b582c728d522caf8d72769a54b", size = 108428, upload-time = "2026-09-15T19:32:00.048Z" }, + { url = "https://files.pythonhosted.org/packages/43/35/7b8f1ebb45d7ec3dda7d1909bf44f458de41ef91e2937f107733582a5166/yarl-1.25.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:a2ed0ba415ccdf08f14bf544cb78346d0f76086707ffee24921a2c84dbf1305a", size = 121961, upload-time = "2026-09-15T19:32:02.436Z" }, + { url = "https://files.pythonhosted.org/packages/63/d6/d8b689ab7ca26edeb85f6ff28812aac7a25376eefc1780e303a7bfbaceff/yarl-1.25.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:2b49375d22299b0a834c2bca72f39aaecc270d96fb24c30424899676f487b22a", size = 114961, upload-time = "2026-09-15T19:32:04.456Z" }, + { url = "https://files.pythonhosted.org/packages/cf/d5/1a1798ea4dc6b7ee3260010a27907ebc697c95dae99817d817ed446d24aa/yarl-1.25.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:ef74070ac553c59eb4f04258722066d6c6135b7baa03b2e9f2da65c096e96d98", size = 120036, upload-time = "2026-09-15T19:32:06.5Z" }, + { url = "https://files.pythonhosted.org/packages/91/8d/b1b35ed7903da6669b1d367cb2c09436acd4ff508029b4f39a0c0c2058fc/yarl-1.25.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:0a66db89ea473abeac4b70523cafd94db3772380e565f9d28af7a179b7af71fa", size = 117276, upload-time = "2026-09-15T19:32:09.401Z" }, + { url = "https://files.pythonhosted.org/packages/3a/8f/4db01cef62caff0d7a4593ed694fb8a41a27a11158cab80d290221f13e57/yarl-1.25.1-cp313-cp313-win_amd64.whl", hash = "sha256:1f51020b2eb8a003c84925638ec63c21a750a4bddd3a22ec8eac6a742dadf1b9", size = 101945, upload-time = "2026-09-15T19:32:11.545Z" }, + { url = "https://files.pythonhosted.org/packages/c0/5e/3ce00497c5c0babb74d4130c10c3828ccd215b4819d12020c42429f991ac/yarl-1.25.1-cp313-cp313-win_arm64.whl", hash = "sha256:b10dd0557ba422715b5206b3743192135a6022acca8baec51aa127d0a75db8fe", size = 97270, upload-time = "2026-09-15T19:32:14.127Z" }, + { url = "https://files.pythonhosted.org/packages/54/22/318c7980066769c6bcd9221ed2248294f5698811da099013098c670565ed/yarl-1.25.1-py3-none-any.whl", hash = "sha256:681c758b0490f9e96b78e5fa8e8dc6e648e9185bb6eaebe73183c33ea0c445f3", size = 63617, upload-time = "2026-09-15T19:34:59.616Z" }, +] From 353c90caf0cc6aacbe6bd63ed27cc11f27ed421a Mon Sep 17 00:00:00 2001 From: Pavel Donin Date: Mon, 9 Nov 2026 15:33:07 +0100 Subject: [PATCH 003/104] Test the web entry and the import boundaries --- agent/tests/test_import_boundaries.py | 48 +++++++++++++++++ agent/tests/test_web.py | 78 +++++++++++++++++++++++++++ 2 files changed, 126 insertions(+) create mode 100644 agent/tests/test_import_boundaries.py create mode 100644 agent/tests/test_web.py diff --git a/agent/tests/test_import_boundaries.py b/agent/tests/test_import_boundaries.py new file mode 100644 index 0000000..524ca40 --- /dev/null +++ b/agent/tests/test_import_boundaries.py @@ -0,0 +1,48 @@ +import ast +from pathlib import Path + +SRC = Path(__file__).resolve().parents[1] / "src" / "voca" +PURE_MODULES = ( + "delegation/model", + "delegation/ports", + "delegation/service", + "conversation/context", + "conversation/instructions", +) +FORBIDDEN = frozenset({"livekit", "httpx", "aiosqlite"}) + + +def forbidden_imports(source: str) -> list[str]: + names: set[str] = set() + for node in ast.walk(ast.parse(source)): + if isinstance(node, ast.Import): + names.update(alias.name.split(".")[0] for alias in node.names) + elif isinstance(node, ast.ImportFrom) and node.level == 0 and node.module: + names.add(node.module.split(".")[0]) + return sorted(names & FORBIDDEN) + + +def pure_files() -> list[Path]: + files: list[Path] = [] + for module in PURE_MODULES: + file = SRC / f"{module}.py" + if file.is_file(): + files.append(file) + directory = SRC / module + if directory.is_dir(): + files.extend(sorted(directory.rglob("*.py"))) + return files + + +def test_pure_modules_do_not_import_adapters() -> None: + for file in pure_files(): + assert forbidden_imports(file.read_text()) == [], file + + +def test_violation_is_caught() -> None: + source = "import livekit.rtc\nfrom httpx import AsyncClient\nimport json\nfrom . import local\n" + assert forbidden_imports(source) == ["httpx", "livekit"] + + +def test_clean_source_passes() -> None: + assert forbidden_imports("import json\nfrom pathlib import Path\n") == [] diff --git a/agent/tests/test_web.py b/agent/tests/test_web.py new file mode 100644 index 0000000..760b146 --- /dev/null +++ b/agent/tests/test_web.py @@ -0,0 +1,78 @@ +import asyncio +from collections.abc import Awaitable, Callable +from pathlib import Path + +import pytest +from aiohttp.test_utils import TestClient, TestServer +from livekit import api + +from voca.app.web import create_app +from voca.config import Settings + + +def make_dist(tmp_path: Path) -> Path: + (tmp_path / "index.html").write_text("VOCA") + return tmp_path + + +def run( + settings: Settings, + dist: Path, + scenario: Callable[[TestClient[object, object]], Awaitable[None]], +) -> None: + async def main() -> None: + async with TestClient(TestServer(create_app(settings, dist))) as client: + await scenario(client) + + asyncio.run(main()) + + +def test_healthz(tmp_path: Path) -> None: + async def scenario(client: TestClient[object, object]) -> None: + response = await client.get("/healthz") + assert response.status == 200 + assert await response.json() == {"status": "ok"} + + run(Settings(), make_dist(tmp_path), scenario) + + +def test_index(tmp_path: Path) -> None: + async def scenario(client: TestClient[object, object]) -> None: + response = await client.get("/") + assert response.status == 200 + assert "VOCA" in await response.text() + + run(Settings(), make_dist(tmp_path), scenario) + + +def test_token(tmp_path: Path) -> None: + settings = Settings( + public_livekit_url="wss://voca.example", + livekit_api_key="devkey", + livekit_api_secret="secret", + ) + + async def scenario(client: TestClient[object, object]) -> None: + response = await client.post("/api/token", json={"device": "phone"}) + assert response.status == 200 + body = await response.json() + assert body["url"] == "wss://voca.example" + claims = api.TokenVerifier("devkey", "secret").verify(body["token"]) + assert claims.identity == "user-phone" + assert claims.video.room == "voca" + assert claims.video.room_join is True + + run(settings, make_dist(tmp_path), scenario) + + +def test_token_rejects_bad_device(tmp_path: Path) -> None: + async def scenario(client: TestClient[object, object]) -> None: + response = await client.post("/api/token", json={"device": "Bad Name"}) + assert response.status == 400 + + run(Settings(), make_dist(tmp_path), scenario) + + +def test_missing_dist(tmp_path: Path) -> None: + with pytest.raises(FileNotFoundError, match="pnpm --dir web build"): + create_app(Settings(), tmp_path) From e6197aa3eee89376ada7d4402fb642b8f5cf6592 Mon Sep 17 00:00:00 2001 From: Pavel Donin Date: Mon, 9 Nov 2026 15:34:25 +0100 Subject: [PATCH 004/104] Add the empty voca web page --- web/biome.json | 16 + web/index.html | 12 + web/package.json | 26 ++ web/pnpm-lock.yaml | 830 ++++++++++++++++++++++++++++++++++++++++ web/pnpm-workspace.yaml | 18 + web/src/main.ts | 10 + web/tsconfig.json | 20 + web/vite.config.ts | 3 + 8 files changed, 935 insertions(+) create mode 100644 web/biome.json create mode 100644 web/index.html create mode 100644 web/package.json create mode 100644 web/pnpm-lock.yaml create mode 100644 web/pnpm-workspace.yaml create mode 100644 web/src/main.ts create mode 100644 web/tsconfig.json create mode 100644 web/vite.config.ts diff --git a/web/biome.json b/web/biome.json new file mode 100644 index 0000000..25cc6b5 --- /dev/null +++ b/web/biome.json @@ -0,0 +1,16 @@ +{ + "$schema": "https://biomejs.dev/schemas/2.6.6/schema.json", + "files": { + "includes": ["**", "!dist", "!node_modules"] + }, + "formatter": { + "enabled": true, + "indentStyle": "space" + }, + "linter": { + "enabled": true, + "rules": { + "recommended": true + } + } +} diff --git a/web/index.html b/web/index.html new file mode 100644 index 0000000..9a26eb5 --- /dev/null +++ b/web/index.html @@ -0,0 +1,12 @@ + + + + + + VOCA + + +
+ + + diff --git a/web/package.json b/web/package.json new file mode 100644 index 0000000..6cbf9ee --- /dev/null +++ b/web/package.json @@ -0,0 +1,26 @@ +{ + "name": "voca-web", + "private": true, + "version": "0.1.0", + "type": "module", + "packageManager": "pnpm@12.6.0", + "engines": { + "node": ">=22.12.0" + }, + "scripts": { + "dev": "vite", + "build": "tsc --noEmit && vite build", + "typecheck": "tsc --noEmit", + "lint": "biome lint .", + "format:check": "biome format .", + "format": "biome format --write ." + }, + "dependencies": { + "livekit-client": "2.23.0" + }, + "devDependencies": { + "@biomejs/biome": "2.6.6", + "typescript": "5.9.3", + "vite": "8.3.7" + } +} diff --git a/web/pnpm-lock.yaml b/web/pnpm-lock.yaml new file mode 100644 index 0000000..7728676 --- /dev/null +++ b/web/pnpm-lock.yaml @@ -0,0 +1,830 @@ +--- +lockfileVersion: '9.0' + +importers: + + .: + configDependencies: {} + packageManagerDependencies: + pnpm: + specifier: 12.6.0 + version: 12.6.0 + +packages: + + '@pnpm/exe.android-arm64@12.6.0': + resolution: {integrity: sha512-kviIHft9h02q+7N2In7tSL9T/HRUSGwYmU74YPtsWU5ee7vgqPyC0JT6RRX6miW+NxX1CvHgQxg69qq1qTLJYQ==} + cpu: [arm64] + os: [android] + + '@pnpm/exe.android-x64@12.6.0': + resolution: {integrity: sha512-CT8aJKLq2mtZFE71pr4E5Z2xHL8uGnRGr+NclDqsXlVF4SVcQ2QAs14mWi0C8gjT4hbmi+Q0lJRRIkMAWfjjng==} + cpu: [x64] + os: [android] + + '@pnpm/exe.darwin-arm64@12.6.0': + resolution: {integrity: sha512-rafpVkjzugKBMxSvGQd5wK1x5eOTkD/+lcJIFHeQhvd+BJ8kDisOOvwhQDrpGd4vd2Fx+hhW0P2Ptt638OlhFg==} + cpu: [arm64] + os: [darwin] + + '@pnpm/exe.darwin-x64@12.6.0': + resolution: {integrity: sha512-72Jpuv1m24gI8zUNcaylYpBWEhBzz1VPhnDIU2GYM2eRP+KsOqVLwV78i+JrihI39zY2NNXmaN4eAKX1inpaDQ==} + cpu: [x64] + os: [darwin] + + '@pnpm/exe.freebsd-x64@12.6.0': + resolution: {integrity: sha512-LON4QgNy1w/XF4uySZIyC/5hEB0oGdelXsY9tSKqstER+2j2X2oHz67skD11RRd/HWFRtMr64shQZ4I9ZoZ0zg==} + cpu: [x64] + os: [freebsd] + + '@pnpm/exe.linux-arm64-musl@12.6.0': + resolution: {integrity: sha512-BdDpX+DeaMUc5x6WNcE6FkmFy36NwrBbBvwsZU737Zt3u1fTY+iKFPchjEQNCM4rT6q6xvF2oN3ZwExGsJCkxQ==} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@pnpm/exe.linux-arm64@12.6.0': + resolution: {integrity: sha512-8h2sNoIhHDpHDYqZZCh9PXr6krqW10btb0GHVGuw710muDHlFAHMtUG2ogmaHge6zJG1xgY4Y7mwX5g/LWvrYg==} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@pnpm/exe.linux-ppc64@12.6.0': + resolution: {integrity: sha512-ld6xgcEhFCFrsVsJhbmdmR714PnsEspO/Ij8w0/D7OSiz2hYGu3fZd5tKRxOI5m6H6Gl0/RRFAPncTHbsgXVjQ==} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@pnpm/exe.linux-riscv64@12.6.0': + resolution: {integrity: sha512-HIbImydoC3J8NFt+8MXfhTCf5rO6NjCMyinSAHac0VmV8uCHw+kbQkyAzxV84rzz9ZOzmBOnj0b1N461UIYGsw==} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@pnpm/exe.linux-s390x@12.6.0': + resolution: {integrity: sha512-DjSqT7+BZ/lWcDHwNMuzsRVfyxUimh+D54Xw4tSWbdVVQhtWtNMUyI93LVnwCmsoEpar2gC3SFeAgywx37svKQ==} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@pnpm/exe.linux-x64-musl@12.6.0': + resolution: {integrity: sha512-31lKeGPmRE6xfV6I3VjaEGVFRei14pl/zgoFDK4w+UkbJqOuNl2Ht3O1GuqNIg3Gl14qTEb4kZRAYFHXifDx9A==} + cpu: [x64] + os: [linux] + libc: [musl] + + '@pnpm/exe.linux-x64@12.6.0': + resolution: {integrity: sha512-qFWBneHJAJ73W4whtbaFOL1M/7DBC6ILHXuxc7ZPtEhfPuT1zeZiGrmKHoMAfJA+mcm6xhOFljqVTUS+00Jabw==} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@pnpm/exe.win32-arm64@12.6.0': + resolution: {integrity: sha512-OhfefXEEykZlslSUhR8PPRe6MVPV3Oink/dVfIecE5velpc7j80cZYsHlaDrtRDcm3TPwQHkaRW1SJ1efX9oFg==} + cpu: [arm64] + os: [win32] + + '@pnpm/exe.win32-x64@12.6.0': + resolution: {integrity: sha512-L2tuyrD2+Imgxs3VK/ST/2L3xD6vDP0ktJQxM0TaGw7TbAQEMq+RMc8iK6Ew7Id579uzjJe8jSfJID2ZGsOHCA==} + cpu: [x64] + os: [win32] + + pnpm@12.6.0: + resolution: {integrity: sha512-PvaPlRyxEawgS0paFvCy3fDaVqluBBPoHYVdnwtV75JnFHCQKOHNAMQFwsX7e56OxNxGd3yAXQNzwvL/AP0g7A==} + engines: {node: '>=18.*'} + hasBin: true + +snapshots: + + '@pnpm/exe.android-arm64@12.6.0': + optional: true + + '@pnpm/exe.android-x64@12.6.0': + optional: true + + '@pnpm/exe.darwin-arm64@12.6.0': + optional: true + + '@pnpm/exe.darwin-x64@12.6.0': + optional: true + + '@pnpm/exe.freebsd-x64@12.6.0': + optional: true + + '@pnpm/exe.linux-arm64-musl@12.6.0': + optional: true + + '@pnpm/exe.linux-arm64@12.6.0': + optional: true + + '@pnpm/exe.linux-ppc64@12.6.0': + optional: true + + '@pnpm/exe.linux-riscv64@12.6.0': + optional: true + + '@pnpm/exe.linux-s390x@12.6.0': + optional: true + + '@pnpm/exe.linux-x64-musl@12.6.0': + optional: true + + '@pnpm/exe.linux-x64@12.6.0': + optional: true + + '@pnpm/exe.win32-arm64@12.6.0': + optional: true + + '@pnpm/exe.win32-x64@12.6.0': + optional: true + + pnpm@12.6.0: + optionalDependencies: + '@pnpm/exe.android-arm64': 12.6.0 + '@pnpm/exe.android-x64': 12.6.0 + '@pnpm/exe.darwin-arm64': 12.6.0 + '@pnpm/exe.darwin-x64': 12.6.0 + '@pnpm/exe.freebsd-x64': 12.6.0 + '@pnpm/exe.linux-arm64': 12.6.0 + '@pnpm/exe.linux-arm64-musl': 12.6.0 + '@pnpm/exe.linux-ppc64': 12.6.0 + '@pnpm/exe.linux-riscv64': 12.6.0 + '@pnpm/exe.linux-s390x': 12.6.0 + '@pnpm/exe.linux-x64': 12.6.0 + '@pnpm/exe.linux-x64-musl': 12.6.0 + '@pnpm/exe.win32-arm64': 12.6.0 + '@pnpm/exe.win32-x64': 12.6.0 + +--- +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + dependencies: + livekit-client: + specifier: 2.23.0 + version: 2.23.0(@types/dom-mediacapture-record@1.0.22) + devDependencies: + '@biomejs/biome': + specifier: 2.6.6 + version: 2.6.6 + typescript: + specifier: 5.9.3 + version: 5.9.3 + vite: + specifier: 8.3.7 + version: 8.3.7 + +packages: + + '@biomejs/biome@2.6.6': + resolution: {integrity: sha512-NCgVJRyrU1+hsW1ILNaRqNAb9fRL4n49bwyef+wjVJQ488/bXbp0BfnNqTTExkiNQT3ugkgYKR1vK8NxsjlNSQ==} + engines: {node: '>=14.21.3'} + hasBin: true + + '@biomejs/cli-android-arm64@2.6.6': + resolution: {integrity: sha512-bicnqNNxEX7pjkDQaptYvPRIWDfgXJLl/Ok7iZjC+F1AhfReor3FSS7SgPYnOvUy/jf++5U/KYN4T8OKH5rrNA==} + engines: {node: '>=14.21.3'} + cpu: [arm64] + os: [android] + + '@biomejs/cli-android-x64@2.6.6': + resolution: {integrity: sha512-N443kDSElF4kDMKjarsL+ymj/ewLA5R9VAdawA7fJ7pwgN3JVDZp0K+xhKBglH8EwsKi2+K8xC6WxXSkgziFfw==} + engines: {node: '>=14.21.3'} + cpu: [x64] + os: [android] + + '@biomejs/cli-darwin-arm64@2.6.6': + resolution: {integrity: sha512-/vSAqWx3by3p8w5ew+HVJDgguuaTK3U6Fb5gk/oDUH21iWOopm3gOlhT6tZfy5mTaM5R4CVc3uFWkxBtEDrKyw==} + engines: {node: '>=14.21.3'} + cpu: [arm64] + os: [darwin] + + '@biomejs/cli-darwin-x64@2.6.6': + resolution: {integrity: sha512-lLdOAJKbrAoDs+3GvTyVmMW345D9IhrIE3GgoL2eeHwO1boN4VhrSlALtFYsP0p+Jy4udUp4bTguij0qjKrH8w==} + engines: {node: '>=14.21.3'} + cpu: [x64] + os: [darwin] + + '@biomejs/cli-linux-arm64-musl@2.6.6': + resolution: {integrity: sha512-rw2Vsoi/WZGDyRjMXx9KZH9zsHe79LIzkdWHkLqsIkeKZm6qiGxtiIApxn7cmJSoEPaSvHaH5pRj1FhTcyL/XA==} + engines: {node: '>=14.21.3'} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@biomejs/cli-linux-arm64@2.6.6': + resolution: {integrity: sha512-UzJdUCg9dbEGpZOuZRyehEPrnwKjx9z4Um7sWLG5x6txu7UjeWdJZuBXVGlyAdD39w2Hx0nZeK1jKC4NFUTCNQ==} + engines: {node: '>=14.21.3'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@biomejs/cli-linux-x64-musl@2.6.6': + resolution: {integrity: sha512-DiKejs+GnFbOVQsBnukUpiB+T13tqUDFGwwjsTq51vatAsaflzFzVD0BOgjVUGRrqKgE0K0Xf+wNtkcmdN2chw==} + engines: {node: '>=14.21.3'} + cpu: [x64] + os: [linux] + libc: [musl] + + '@biomejs/cli-linux-x64@2.6.6': + resolution: {integrity: sha512-l8/aPXc1cz8VpUyd4hb5Pm5zx+X3voQBks4aLYbp5pC8KSchuHyvqDfY5xax39qCHomI/2xn6NKEot+zebG38g==} + engines: {node: '>=14.21.3'} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@biomejs/cli-win32-arm64@2.6.6': + resolution: {integrity: sha512-DjVXwQAQeHrO1JEZAEus54k40Tnh4glyU3VFLcDBKwoz0xnxCEnqw6TCMXoujcQMQynWd3AY8bLrbR1o0XNqow==} + engines: {node: '>=14.21.3'} + cpu: [arm64] + os: [win32] + + '@biomejs/cli-win32-x64@2.6.6': + resolution: {integrity: sha512-B7KMG26A4I9lAuxwBGMmiVcjMGAeJRoz+pzFt9brRvOAsIrgOu8ptJld5rUWui6tM1uiJwxeJhhDPuAiZtxXdw==} + engines: {node: '>=14.21.3'} + cpu: [x64] + os: [win32] + + '@bufbuild/protobuf@1.10.1': + resolution: {integrity: sha512-wJ8ReQbHxsAfXhrf9ixl0aYbZorRuOWpBNzm8pL8ftmSxQx/wnJD5Eg861NwJU/czy2VXFIebCeZnZrI9rktIQ==} + + '@livekit/mutex@1.1.1': + resolution: {integrity: sha512-EsshAucklmpuUAfkABPxJNhzj9v2sG7JuzFDL4ML1oJQSV14sqrpTYnsaOudMAw9yOaW53NU3QQTlUQoRs4czw==} + + '@livekit/protocol@1.52.1': + resolution: {integrity: sha512-X8DxKlNbzk6Lh+v630+mjdFHSotdAFwIyzxSRSUql2DbTxCnlpa7JsvFGc6Na3mXiex2OFh0iiQtWwOBP0rIYA==} + + '@oxc-project/types@0.156.0': + resolution: {integrity: sha512-1Duyg48TdJNdyhEB8Xn1L36bT9Pv6GUtlKjHxOys8xkaF/rQq3XJE+bAurSK/AgIz5mGFsb77cn9Pb20+WCScw==} + + '@rolldown/binding-android-arm-eabi@1.2.18': + resolution: {integrity: sha512-ubTG+4KlidvioxjOFdsdEa2F6JVGZb8m2ByJUqQ7fYzUeT1cEn194POcsQ10PKH4p//q0YQn4SB3YpSuzcA1Yg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [android] + + '@rolldown/binding-android-arm64@1.2.18': + resolution: {integrity: sha512-2Xa1t/JeFGTZn5TbzgPppcCftF8sCVnTNvrIvoSdcZhoFzAuVy1L3xxQUQ4sfWDtwVzg1u6Yi1R3SlJugRNV2Q==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [android] + + '@rolldown/binding-darwin-arm64@1.2.18': + resolution: {integrity: sha512-iUzw9HshVvrWci9gVXjxWS7/M/IcLvNo67vu2c/w9zKBz4hIvPRixdUpIW/iCfhldLaq9ER2hecsI9UY5z0/yA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [darwin] + + '@rolldown/binding-darwin-x64@1.2.18': + resolution: {integrity: sha512-XuDHFzbFEtZl0QGJi92+fmG67tfnp1iZ9VOeDkwasGyTq2VG3/5PnJnvAr+MTzFh/rNScJ3VhN+XpZyyGsN7Jw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [darwin] + + '@rolldown/binding-freebsd-x64@1.2.18': + resolution: {integrity: sha512-MM8opLfsLK15oEXDz6iBc0t3ahu+ew9jsAmha+ovZ33hSwYgqo7YmpRITqFTfW7xA71BLEQ3jJdTsaCmhB1c9w==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [freebsd] + + '@rolldown/binding-linux-arm-gnueabihf@1.2.18': + resolution: {integrity: sha512-J6EoCWiy5RnFQzHrKyHXXevZ9lukQfGpnchX/zG/k/WC2nc9O9XyS1JgA8kI9VxMFggMbCPjpv7LcikriXfehw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@rolldown/binding-linux-arm64-gnu@1.2.18': + resolution: {integrity: sha512-HIkFuLF7CdDeki+1aCZLLbLq0hk/PHntb+z4/YCTo47JbksTD6jJEELIz92tYROmDmvMrNqUMqBi71BkJLDR+A==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-arm64-musl@1.2.18': + resolution: {integrity: sha512-4oqWItLLYt7IceOnElCct8T2ek5p7PmD7LN04TRJRXGo5f3HtKTgGH6PhYRFK4C2ICkq/LMUU0BVPvvlAKhm8g==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@rolldown/binding-linux-ppc64-gnu@1.2.18': + resolution: {integrity: sha512-+AkRly5SrCEgnPhGRfub7pucASYEC2fTsxZg7VCcnozpA2+Wn/gCYy1t7LuJS67/xyJu/7noYBJncDUkbwgw2A==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-s390x-gnu@1.2.18': + resolution: {integrity: sha512-Ttg1r9vVykMnQrE/GwYO9icKtXiuiMfRe/w74xJkeoVuvhRG+WtT6LEMOf58Yl63yd77lhHzO8Y55R8CC3XALA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-x64-gnu@1.2.18': + resolution: {integrity: sha512-xX1Tt4o5jUMma0vJsXp5eRZA9nOuyigb2dKJMMzZRmzMxLYGE9PaKKSqHPzPvSQgM8ZOEacSTQwDQcwWfh0xmQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-x64-musl@1.2.18': + resolution: {integrity: sha512-4VVlGI5K2kociZlW3lSN8VjG3TW5yQIlAcfCdBXMgGjJxZll15Fa4ocRHXHvIVVnwlibCw/NQajJrNDiqqpiiw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [musl] + + '@rolldown/binding-openharmony-arm64@1.2.18': + resolution: {integrity: sha512-PApTsgH1jLYuyy3tSCz9De82Gq5qXwi7vImVZhEzTgX1C4150Cp5M2ZfL3pLvbCvbEHL0oJeC6pd6podtv8Y5w==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [openharmony] + + '@rolldown/binding-win32-arm64-msvc@1.2.18': + resolution: {integrity: sha512-J2XjWjx/SP+4x0H4r6dz3Vzb5WVkg7re40Do9K7HJ3sK2/xcmZrWwakXlSwaMvOkvruLJCD4CL/3wqsIR/thgg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [win32] + + '@rolldown/binding-win32-x64-msvc@1.2.18': + resolution: {integrity: sha512-XwRJxRQvkuEtS0hIIt29QrqPJ+JzMJSNYHJw4ae/mc6uEarfRG1ceyg4HmmWiBdL/YCb6g7N5BLbSvmRJRYZHw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [win32] + + '@rolldown/pluginutils@1.0.1': + resolution: {integrity: sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==} + + '@types/dom-mediacapture-record@1.0.22': + resolution: {integrity: sha512-mUMZLK3NvwRLcAAT9qmcK+9p7tpU2FHdDsntR3YI4+GY88XrgG4XiE7u1Q2LAN2/FZOz/tdMDC3GQCR4T8nFuw==} + + detect-libc@2.1.2: + resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} + engines: {node: '>=8'} + + events@3.3.0: + resolution: {integrity: sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==} + engines: {node: '>=0.8.x'} + + fdir@6.5.0: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + jose@6.2.15: + resolution: {integrity: sha512-qdF6ewcZARtef63fJ9AKKZPgxw+t7USH6HLXYcD4CrdmSvDp79rpJSPFEKc90QBeGS63u1KQtN2MZQHC0yDIzA==} + + lightningcss-android-arm64@1.33.0: + resolution: {integrity: sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [android] + + lightningcss-darwin-arm64@1.33.0: + resolution: {integrity: sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [darwin] + + lightningcss-darwin-x64@1.33.0: + resolution: {integrity: sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [darwin] + + lightningcss-freebsd-x64@1.33.0: + resolution: {integrity: sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [freebsd] + + lightningcss-linux-arm-gnueabihf@1.33.0: + resolution: {integrity: sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm] + os: [linux] + + lightningcss-linux-arm64-gnu@1.33.0: + resolution: {integrity: sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + lightningcss-linux-arm64-musl@1.33.0: + resolution: {integrity: sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + libc: [musl] + + lightningcss-linux-x64-gnu@1.33.0: + resolution: {integrity: sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + libc: [glibc] + + lightningcss-linux-x64-musl@1.33.0: + resolution: {integrity: sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + libc: [musl] + + lightningcss-win32-arm64-msvc@1.33.0: + resolution: {integrity: sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [win32] + + lightningcss-win32-x64-msvc@1.33.0: + resolution: {integrity: sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [win32] + + lightningcss@1.33.0: + resolution: {integrity: sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==} + engines: {node: '>= 12.0.0'} + + livekit-client@2.23.0: + resolution: {integrity: sha512-j9Kg86nJjBjNFUD+ukp2jwMCGa0k+YD5gU//RzAM+ZY3pbG631QCte7fQjwqWMmtBzhDJL9nKYuOLMv/9IbAxg==} + peerDependencies: + '@types/dom-mediacapture-record': ^1 + + loglevel@1.9.2: + resolution: {integrity: sha512-HgMmCqIJSAKqo68l0rS2AanEWfkxaZ5wNiEFb5ggm08lDs9Xl2KxBlX3PTcaD2chBM1gXAYf491/M2Rv8Jwayg==} + engines: {node: '>= 0.6.0'} + + machina@7.0.1: + resolution: {integrity: sha512-0Xkgs8NeBvO2hW6bjJT8ewLWrMfh1haMPstaghpASWjXkLjRhtDiaJG1Kb7mslaOHxZYxpH7PIjpmirRVYbMMA==} + engines: {node: '>=22.22'} + + nanoid@3.3.20: + resolution: {integrity: sha512-uKdg2G3GNCKQn9byYOpxbGqrT2fGO5KRt5J/8b3pok8rT6qxGWF6hxMyJiEYtAf+FVyYuD9hRaDqX5uPFYJ4ZQ==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + picomatch@4.0.7: + resolution: {integrity: sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==} + engines: {node: '>=12'} + + postcss@8.5.32: + resolution: {integrity: sha512-LJlKCCAUFKOuZxipUdYw1NzIITEsPcgyOG9LkvRJvuKj/bRzbuRY0ARXvL0pWGpsZvn8jku+LMep2eU0seHp8g==} + engines: {node: ^10 || ^12 || >=14} + + rolldown@1.2.18: + resolution: {integrity: sha512-C1uuT3UR60SShuCaPQFAAZyefJCWZlAKB02BV3BuPE2Rs4fevRQltEKoFO2tV5W9/eJhU5RieV6vJj3euulfNQ==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + + rxjs@7.8.2: + resolution: {integrity: sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==} + + sdp-transform@2.15.0: + resolution: {integrity: sha512-KrOH82c/W+GYQ0LHqtr3caRpM3ITglq3ljGUIb8LTki7ByacJZ9z+piSGiwZDsRyhQbYBOBJgr2k6X4BZXi3Kw==} + hasBin: true + + sdp@3.2.3: + resolution: {integrity: sha512-UYuY3JFaVa7Zc+rxxi8iblwD7mdloGxCzMEQIJPcCLiHKjjqMqLsruMkI+emgnYARkfHk4o7seEETABc4bwokg==} + + source-map-js@1.2.2: + resolution: {integrity: sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==} + engines: {node: '>=0.10.0'} + + tinyglobby@0.2.17: + resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} + engines: {node: '>=12.0.0'} + + tslib@2.8.1: + resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + + typed-emitter@2.1.0: + resolution: {integrity: sha512-g/KzbYKbH5C2vPkaXGu8DJlHrGKHLsM25Zg9WuC9pMGfuvT+X25tZQWo5fK1BjBm8+UrVE9LDCvaY0CQk+fXDA==} + + typescript@5.9.3: + resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} + engines: {node: '>=14.17'} + hasBin: true + + vite@8.3.7: + resolution: {integrity: sha512-oxh0dMxrayBr33ScJvvBJ2HBQ2jc1TBrXKR2NyL/MJ9WPuX19mN2DXWUmywKdyw5RFVQBo8hmtb2FMC0LHrnng==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + '@types/node': ^20.19.0 || >=22.12.0 + '@vitejs/devtools': ^0.7.1 || ^0.8.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 + + webrtc-adapter@9.0.6: + resolution: {integrity: sha512-CHbl2ZQbxx164IgWRgzJno4hWtM4tFbRam1QfI3Yxhs3w/DvqluVxVWeXs3oL5/fbGkSNLKo0Ty5MgUWceNhog==} + engines: {node: '>=6.0.0', npm: '>=3.10.0'} + +snapshots: + + '@biomejs/biome@2.6.6': + optionalDependencies: + '@biomejs/cli-android-arm64': 2.6.6 + '@biomejs/cli-android-x64': 2.6.6 + '@biomejs/cli-darwin-arm64': 2.6.6 + '@biomejs/cli-darwin-x64': 2.6.6 + '@biomejs/cli-linux-arm64': 2.6.6 + '@biomejs/cli-linux-arm64-musl': 2.6.6 + '@biomejs/cli-linux-x64': 2.6.6 + '@biomejs/cli-linux-x64-musl': 2.6.6 + '@biomejs/cli-win32-arm64': 2.6.6 + '@biomejs/cli-win32-x64': 2.6.6 + + '@biomejs/cli-android-arm64@2.6.6': + optional: true + + '@biomejs/cli-android-x64@2.6.6': + optional: true + + '@biomejs/cli-darwin-arm64@2.6.6': + optional: true + + '@biomejs/cli-darwin-x64@2.6.6': + optional: true + + '@biomejs/cli-linux-arm64-musl@2.6.6': + optional: true + + '@biomejs/cli-linux-arm64@2.6.6': + optional: true + + '@biomejs/cli-linux-x64-musl@2.6.6': + optional: true + + '@biomejs/cli-linux-x64@2.6.6': + optional: true + + '@biomejs/cli-win32-arm64@2.6.6': + optional: true + + '@biomejs/cli-win32-x64@2.6.6': + optional: true + + '@bufbuild/protobuf@1.10.1': {} + + '@livekit/mutex@1.1.1': {} + + '@livekit/protocol@1.52.1': + dependencies: + '@bufbuild/protobuf': 1.10.1 + + '@oxc-project/types@0.156.0': {} + + '@rolldown/binding-android-arm-eabi@1.2.18': + optional: true + + '@rolldown/binding-android-arm64@1.2.18': + optional: true + + '@rolldown/binding-darwin-arm64@1.2.18': + optional: true + + '@rolldown/binding-darwin-x64@1.2.18': + optional: true + + '@rolldown/binding-freebsd-x64@1.2.18': + optional: true + + '@rolldown/binding-linux-arm-gnueabihf@1.2.18': + optional: true + + '@rolldown/binding-linux-arm64-gnu@1.2.18': + optional: true + + '@rolldown/binding-linux-arm64-musl@1.2.18': + optional: true + + '@rolldown/binding-linux-ppc64-gnu@1.2.18': + optional: true + + '@rolldown/binding-linux-s390x-gnu@1.2.18': + optional: true + + '@rolldown/binding-linux-x64-gnu@1.2.18': + optional: true + + '@rolldown/binding-linux-x64-musl@1.2.18': + optional: true + + '@rolldown/binding-openharmony-arm64@1.2.18': + optional: true + + '@rolldown/binding-win32-arm64-msvc@1.2.18': + optional: true + + '@rolldown/binding-win32-x64-msvc@1.2.18': + optional: true + + '@rolldown/pluginutils@1.0.1': {} + + '@types/dom-mediacapture-record@1.0.22': {} + + detect-libc@2.1.2: {} + + events@3.3.0: {} + + fdir@6.5.0(picomatch@4.0.7): + optionalDependencies: + picomatch: 4.0.7 + + fsevents@2.3.3: + optional: true + + jose@6.2.15: {} + + lightningcss-android-arm64@1.33.0: + optional: true + + lightningcss-darwin-arm64@1.33.0: + optional: true + + lightningcss-darwin-x64@1.33.0: + optional: true + + lightningcss-freebsd-x64@1.33.0: + optional: true + + lightningcss-linux-arm-gnueabihf@1.33.0: + optional: true + + lightningcss-linux-arm64-gnu@1.33.0: + optional: true + + lightningcss-linux-arm64-musl@1.33.0: + optional: true + + lightningcss-linux-x64-gnu@1.33.0: + optional: true + + lightningcss-linux-x64-musl@1.33.0: + optional: true + + lightningcss-win32-arm64-msvc@1.33.0: + optional: true + + lightningcss-win32-x64-msvc@1.33.0: + optional: true + + lightningcss@1.33.0: + dependencies: + detect-libc: 2.1.2 + 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 + + livekit-client@2.23.0(@types/dom-mediacapture-record@1.0.22): + dependencies: + '@livekit/mutex': 1.1.1 + '@livekit/protocol': 1.52.1 + '@types/dom-mediacapture-record': 1.0.22 + events: 3.3.0 + jose: 6.2.15 + loglevel: 1.9.2 + machina: 7.0.1 + sdp-transform: 2.15.0 + tslib: 2.8.1 + typed-emitter: 2.1.0 + webrtc-adapter: 9.0.6 + + loglevel@1.9.2: {} + + machina@7.0.1: {} + + nanoid@3.3.20: {} + + picocolors@1.1.1: {} + + picomatch@4.0.7: {} + + postcss@8.5.32: + dependencies: + nanoid: 3.3.20 + picocolors: 1.1.1 + source-map-js: 1.2.2 + + rolldown@1.2.18: + dependencies: + '@oxc-project/types': 0.156.0 + '@rolldown/pluginutils': 1.0.1 + optionalDependencies: + '@rolldown/binding-android-arm-eabi': 1.2.18 + '@rolldown/binding-android-arm64': 1.2.18 + '@rolldown/binding-darwin-arm64': 1.2.18 + '@rolldown/binding-darwin-x64': 1.2.18 + '@rolldown/binding-freebsd-x64': 1.2.18 + '@rolldown/binding-linux-arm-gnueabihf': 1.2.18 + '@rolldown/binding-linux-arm64-gnu': 1.2.18 + '@rolldown/binding-linux-arm64-musl': 1.2.18 + '@rolldown/binding-linux-ppc64-gnu': 1.2.18 + '@rolldown/binding-linux-s390x-gnu': 1.2.18 + '@rolldown/binding-linux-x64-gnu': 1.2.18 + '@rolldown/binding-linux-x64-musl': 1.2.18 + '@rolldown/binding-openharmony-arm64': 1.2.18 + '@rolldown/binding-win32-arm64-msvc': 1.2.18 + '@rolldown/binding-win32-x64-msvc': 1.2.18 + + rxjs@7.8.2: + dependencies: + tslib: 2.8.1 + optional: true + + sdp-transform@2.15.0: {} + + sdp@3.2.3: {} + + source-map-js@1.2.2: {} + + tinyglobby@0.2.17: + dependencies: + fdir: 6.5.0(picomatch@4.0.7) + picomatch: 4.0.7 + + tslib@2.8.1: {} + + typed-emitter@2.1.0: + optionalDependencies: + rxjs: 7.8.2 + + typescript@5.9.3: {} + + vite@8.3.7: + dependencies: + lightningcss: 1.33.0 + picomatch: 4.0.7 + postcss: 8.5.32 + rolldown: 1.2.18 + tinyglobby: 0.2.17 + optionalDependencies: + fsevents: 2.3.3 + + webrtc-adapter@9.0.6: + dependencies: + sdp: 3.2.3 diff --git a/web/pnpm-workspace.yaml b/web/pnpm-workspace.yaml new file mode 100644 index 0000000..8b378de --- /dev/null +++ b/web/pnpm-workspace.yaml @@ -0,0 +1,18 @@ +minimumReleaseAgeExclude: + - "@rolldown/binding-android-arm-eabi@1.2.18" + - "@rolldown/binding-android-arm64@1.2.18" + - "@rolldown/binding-darwin-arm64@1.2.18" + - "@rolldown/binding-darwin-x64@1.2.18" + - "@rolldown/binding-freebsd-x64@1.2.18" + - "@rolldown/binding-linux-arm-gnueabihf@1.2.18" + - "@rolldown/binding-linux-arm64-gnu@1.2.18" + - "@rolldown/binding-linux-arm64-musl@1.2.18" + - "@rolldown/binding-linux-ppc64-gnu@1.2.18" + - "@rolldown/binding-linux-s390x-gnu@1.2.18" + - "@rolldown/binding-linux-x64-gnu@1.2.18" + - "@rolldown/binding-linux-x64-musl@1.2.18" + - "@rolldown/binding-openharmony-arm64@1.2.18" + - "@rolldown/binding-win32-arm64-msvc@1.2.18" + - "@rolldown/binding-win32-x64-msvc@1.2.18" + - rolldown@1.2.18 + - vite@8.3.7 diff --git a/web/src/main.ts b/web/src/main.ts new file mode 100644 index 0000000..c09a7e3 --- /dev/null +++ b/web/src/main.ts @@ -0,0 +1,10 @@ +import { version } from "livekit-client"; + +const app = document.querySelector("#app"); +if (app) { + const title = document.createElement("h1"); + title.textContent = "VOCA"; + const line = document.createElement("p"); + line.textContent = `livekit-client ${version}`; + app.replaceChildren(title, line); +} diff --git a/web/tsconfig.json b/web/tsconfig.json new file mode 100644 index 0000000..44b3057 --- /dev/null +++ b/web/tsconfig.json @@ -0,0 +1,20 @@ +{ + "compilerOptions": { + "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", + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "allowImportingTsExtensions": true + }, + "include": ["src"] +} diff --git a/web/vite.config.ts b/web/vite.config.ts new file mode 100644 index 0000000..b54533f --- /dev/null +++ b/web/vite.config.ts @@ -0,0 +1,3 @@ +import { defineConfig } from "vite"; + +export default defineConfig({ build: { outDir: "dist", emptyOutDir: true } }); From 346ee6263541474da766729af9c89d166769304f Mon Sep 17 00:00:00 2001 From: Pavel Donin Date: Mon, 9 Nov 2026 15:35:05 +0100 Subject: [PATCH 005/104] Add the stand environment and its example --- .env.example | 28 ++++++++++++++++++++++++++++ scripts/env.sh | 21 +++++++++++++++++++++ 2 files changed, 49 insertions(+) create mode 100644 .env.example create mode 100644 scripts/env.sh diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..e9dbed7 --- /dev/null +++ b/.env.example @@ -0,0 +1,28 @@ +# VOCA stand settings. This is a bash file: only scripts/*.sh read it, through +# scripts/env.sh. Each line keeps a value already set in the environment. +# devkey/secret are the built-in keys of `livekit-server --dev`; they cannot be +# changed without a livekit-server config. + +# Root of downloaded tools: /// (scripts/SHA256SUMS "apps"). +VOCA_APPS_DIR="${VOCA_APPS_DIR:-$HOME/Apps}" +# Root of downloaded models (scripts/SHA256SUMS "models"). +VOCA_MODELS_DIR="${VOCA_MODELS_DIR:-$HOME/models}" +# livekit-server release binary (scripts/fetch.sh puts it here). +VOCA_LIVEKIT_BIN="${VOCA_LIVEKIT_BIN:-$VOCA_APPS_DIR/livekit/1.13.11/livekit-server}" +# Address livekit-server announces for media (--node-ip). +VOCA_LIVEKIT_NODE_IP="${VOCA_LIVEKIT_NODE_IP:-127.0.0.1}" +# LiveKit API key that signs tokens (livekit-server --dev key). +VOCA_LIVEKIT_API_KEY="${VOCA_LIVEKIT_API_KEY:-devkey}" +# LiveKit API secret that signs tokens (livekit-server --dev secret). +VOCA_LIVEKIT_API_SECRET="${VOCA_LIVEKIT_API_SECRET:-secret}" +# Address voca web listens on. +VOCA_WEB_HOST="${VOCA_WEB_HOST:-127.0.0.1}" +# Port voca web listens on. +VOCA_WEB_PORT="${VOCA_WEB_PORT:-8083}" +# LiveKit URL the page receives with its token. +VOCA_PUBLIC_LIVEKIT_URL="${VOCA_PUBLIC_LIVEKIT_URL:-ws://127.0.0.1:7880}" + +# voca.priney.com (deployment.md): uncomment to serve the stand over the tailnet. +# VOCA_WEB_HOST=100.108.140.49 +# VOCA_LIVEKIT_NODE_IP=100.108.140.49 +# VOCA_PUBLIC_LIVEKIT_URL=wss://voca.priney.com diff --git a/scripts/env.sh b/scripts/env.sh new file mode 100644 index 0000000..d6d8ee2 --- /dev/null +++ b/scripts/env.sh @@ -0,0 +1,21 @@ +VOCA_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd -P)" +export VOCA_ROOT + +if [ -f "$VOCA_ROOT/.env" ]; then + set -a + . "$VOCA_ROOT/.env" + set +a +fi + +: "${VOCA_APPS_DIR:=$HOME/Apps}" +: "${VOCA_MODELS_DIR:=$HOME/models}" +: "${VOCA_LIVEKIT_BIN:=$VOCA_APPS_DIR/livekit/1.13.11/livekit-server}" +: "${VOCA_LIVEKIT_NODE_IP:=127.0.0.1}" +: "${VOCA_LIVEKIT_API_KEY:=devkey}" +: "${VOCA_LIVEKIT_API_SECRET:=secret}" +: "${VOCA_WEB_HOST:=127.0.0.1}" +: "${VOCA_WEB_PORT:=8083}" +: "${VOCA_PUBLIC_LIVEKIT_URL:=ws://127.0.0.1:7880}" +export VOCA_APPS_DIR VOCA_MODELS_DIR VOCA_LIVEKIT_BIN +export VOCA_LIVEKIT_NODE_IP VOCA_LIVEKIT_API_KEY VOCA_LIVEKIT_API_SECRET +export VOCA_WEB_HOST VOCA_WEB_PORT VOCA_PUBLIC_LIVEKIT_URL From b0f7d6c033b866d800f0f57cb4d23dcd7d3d2688 Mon Sep 17 00:00:00 2001 From: Pavel Donin Date: Mon, 9 Nov 2026 15:36:32 +0100 Subject: [PATCH 006/104] Fetch and verify livekit-server by SHA256SUMS --- scripts/SHA256SUMS | 5 +++ scripts/fetch.sh | 92 ++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 97 insertions(+) create mode 100644 scripts/SHA256SUMS create mode 100755 scripts/fetch.sh diff --git a/scripts/SHA256SUMS b/scripts/SHA256SUMS new file mode 100644 index 0000000..2ebe867 --- /dev/null +++ b/scripts/SHA256SUMS @@ -0,0 +1,5 @@ +# /// +# : apps -> $VOCA_APPS_DIR (~/Apps), models -> $VOCA_MODELS_DIR (~/models); relative, no "..". +# : https://... downloads the file; tar: extracts from in the same directory (listed above). +1e9ee90ca301e1842b2887fc77cb122759756a9c8aeb4334ee91de3325ec43fb apps/livekit/1.13.11/livekit_1.13.11_linux_amd64.tar.gz https://github.com/livekit/livekit/releases/download/v1.13.11/livekit_1.13.11_linux_amd64.tar.gz +85a58024d9b36542712514e0e926c0adb74f6182bcb19a664b9d9eb98ad01b52 apps/livekit/1.13.11/livekit-server tar:livekit_1.13.11_linux_amd64.tar.gz diff --git a/scripts/fetch.sh b/scripts/fetch.sh new file mode 100755 index 0000000..8f907ee --- /dev/null +++ b/scripts/fetch.sh @@ -0,0 +1,92 @@ +#!/usr/bin/env bash +set -euo pipefail +. "$(dirname "$0")/env.sh" + +check=0 +case "${1:-}" in + "") ;; + --check) check=1 ;; + *) + echo "usage: scripts/fetch.sh [--check]" >&2 + exit 2 + ;; +esac + +sums="$VOCA_ROOT/scripts/SHA256SUMS" +failed=0 +number=0 + +digest() { + sha256sum "$1" | cut -d' ' -f1 +} + +while read -r sum path source rest || [ -n "${sum:-}" ]; do + number=$((number + 1)) + case "${sum:-}" in "" | "#"*) continue ;; esac + if [ -z "${path:-}" ] || [ -z "${source:-}" ] || [ -n "${rest:-}" ]; then + echo "SHA256SUMS:$number: ожидается « »" >&2 + exit 2 + fi + case "$path" in + /*) echo "SHA256SUMS:$number: абсолютный путь $path" >&2; exit 2 ;; + esac + case "/$path/" in + */../*) echo "SHA256SUMS:$number: путь с .. $path" >&2; exit 2 ;; + esac + case "${path%%/*}" in + apps) base="$VOCA_APPS_DIR" ;; + models) base="$VOCA_MODELS_DIR" ;; + *) echo "SHA256SUMS:$number: неизвестный корень ${path%%/*} (apps|models)" >&2; exit 2 ;; + esac + dest="$base/${path#*/}" + dir="$(dirname "$dest")" + + if [ "$check" = 1 ]; then + if [ ! -f "$dest" ]; then + echo "MISSING $dest" + failed=1 + elif [ "$(digest "$dest")" != "$sum" ]; then + echo "MISMATCH $dest" + failed=1 + else + echo "OK $dest" + fi + continue + fi + + if [ -f "$dest" ]; then + if [ "$(digest "$dest")" != "$sum" ]; then + echo "SHA256SUMS:$number: sha256 $dest не совпадает: удалите файл и повторите" >&2 + exit 1 + fi + echo "ok $dest" + continue + fi + + mkdir -p "$dir" + case "$source" in + https://*) + curl -fL --retry 3 -o "$dest.part" "$source" + if [ "$(digest "$dest.part")" != "$sum" ]; then + rm -f "$dest.part" + echo "SHA256SUMS:$number: sha256 загрузки $source не совпадает" >&2 + exit 1 + fi + mv "$dest.part" "$dest" + ;; + tar:*) + tar -xzf "$dir/${source#tar:}" -C "$dir" "$(basename "$dest")" + if [ "$(digest "$dest")" != "$sum" ]; then + echo "SHA256SUMS:$number: sha256 $dest из ${source#tar:} не совпадает: удалите файл и повторите" >&2 + exit 1 + fi + ;; + *) + echo "SHA256SUMS:$number: неизвестный источник $source (https://…|tar:)" >&2 + exit 2 + ;; + esac + echo "fetched $dest" +done <"$sums" + +exit "$failed" From c32e5ce50e23d77edd6c518881bb7d03b328156a Mon Sep 17 00:00:00 2001 From: Pavel Donin Date: Mon, 9 Nov 2026 15:38:36 +0100 Subject: [PATCH 007/104] Start and stop the stand services --- scripts/down.sh | 33 ++++++++++++++++++ scripts/up.sh | 93 +++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 126 insertions(+) create mode 100755 scripts/down.sh create mode 100755 scripts/up.sh diff --git a/scripts/down.sh b/scripts/down.sh new file mode 100755 index 0000000..25edac8 --- /dev/null +++ b/scripts/down.sh @@ -0,0 +1,33 @@ +#!/usr/bin/env bash +set -euo pipefail +. "$(dirname "$0")/env.sh" + +if [ "$#" -eq 0 ]; then + set -- web llm audio livekit +fi + +run_dir="$VOCA_ROOT/.local/run" + +for name in "$@"; do + pid_file="$run_dir/$name.pid" + if [ ! -f "$pid_file" ]; then + echo "$name не запущен" + continue + fi + pid="$(cat "$pid_file")" + if ! kill -0 "$pid" 2>/dev/null; then + rm -f "$pid_file" + echo "$name не запущен" + continue + fi + kill -TERM "$pid" 2>/dev/null || true + for _ in $(seq 20); do + kill -0 "$pid" 2>/dev/null || break + sleep 0.5 + done + if kill -0 "$pid" 2>/dev/null; then + kill -KILL "$pid" 2>/dev/null || true + fi + rm -f "$pid_file" + echo "$name stopped (pid $pid)" +done diff --git a/scripts/up.sh b/scripts/up.sh new file mode 100755 index 0000000..5c6d631 --- /dev/null +++ b/scripts/up.sh @@ -0,0 +1,93 @@ +#!/usr/bin/env bash +set -euo pipefail +. "$(dirname "$0")/env.sh" + +if [ "$#" -eq 0 ]; then + set -- livekit audio llm web +fi + +run_dir="$VOCA_ROOT/.local/run" +log_dir="$VOCA_ROOT/.local/logs" + +fail() { + echo "$*" >&2 + exit 1 +} + +tcp_busy() { + [ -n "$(ss -Hltn "sport = :$1")" ] +} + +udp_busy() { + [ -n "$(ss -Hlun "sport = :$1")" ] +} + +for name in "$@"; do + case "$name" in + livekit | audio | llm | web) ;; + *) fail "неизвестный сервис $name: livekit audio llm web" ;; + esac + pid_file="$run_dir/$name.pid" + if [ -f "$pid_file" ] && kill -0 "$(cat "$pid_file")" 2>/dev/null; then + fail "$name уже запущен (pid $(cat "$pid_file"))" + fi + case "$name" in + livekit) + [ -x "$VOCA_LIVEKIT_BIN" ] || fail "VOCA_LIVEKIT_BIN=$VOCA_LIVEKIT_BIN не найден: scripts/fetch.sh" + for port in 7880 7881; do + if tcp_busy "$port"; then + fail "порт $port занят — фиксированный порт livekit-server --dev" + fi + done + if udp_busy 7882; then + fail "порт 7882 занят — фиксированный порт livekit-server --dev" + fi + ;; + web) + [ -f "$VOCA_ROOT/web/dist/index.html" ] || fail "нет web/dist/index.html: pnpm --dir web build" + [ -x "$VOCA_ROOT/agent/.venv/bin/voca" ] || fail "нет agent/.venv/bin/voca: (cd agent && uv sync --locked)" + if tcp_busy "$VOCA_WEB_PORT"; then + fail "порт $VOCA_WEB_PORT (VOCA_WEB_PORT) занят" + fi + ;; + audio) "$VOCA_ROOT/scripts/audiocpp.sh" --check || exit 1 ;; + llm) "$VOCA_ROOT/scripts/llama.sh" --check || exit 1 ;; + esac +done + +mkdir -p "$log_dir" "$run_dir" +started=() + +for name in "$@"; do + case "$name" in + livekit) + command=("$VOCA_LIVEKIT_BIN" --dev --bind 0.0.0.0 --node-ip "$VOCA_LIVEKIT_NODE_IP") + ready="http://127.0.0.1:7880/" + ;; + web) + command=("$VOCA_ROOT/agent/.venv/bin/voca" web) + ready="http://$VOCA_WEB_HOST:$VOCA_WEB_PORT/healthz" + ;; + esac + log="$log_dir/$name.log" + nohup "${command[@]}" >>"$log" 2>&1 "$run_dir/$name.pid" + started+=("$name") + up=0 + for _ in $(seq 40); do + if curl -fsS -o /dev/null "$ready" 2>/dev/null; then + up=1 + break + fi + kill -0 "$pid" 2>/dev/null || break + sleep 0.5 + done + if [ "$up" = 0 ]; then + echo "$name не ответил на $ready за 20 с; последние строки $log:" >&2 + tail -n 20 "$log" >&2 + "$VOCA_ROOT/scripts/down.sh" "${started[@]}" >&2 + exit 1 + fi + echo "$name up (pid $pid) $ready" +done From dafc8be76d35150c7b43d0df3ec2a244b39f36cb Mon Sep 17 00:00:00 2001 From: Pavel Donin Date: Mon, 9 Nov 2026 15:39:08 +0100 Subject: [PATCH 008/104] Add audio.cpp and llama-server placeholders for 0.2 and 0.3 --- scripts/audiocpp.sh | 8 ++++++++ scripts/llama.sh | 6 ++++++ scripts/server.json | 25 +++++++++++++++++++++++++ 3 files changed, 39 insertions(+) create mode 100755 scripts/audiocpp.sh create mode 100755 scripts/llama.sh create mode 100644 scripts/server.json diff --git a/scripts/audiocpp.sh b/scripts/audiocpp.sh new file mode 100755 index 0000000..2eb5468 --- /dev/null +++ b/scripts/audiocpp.sh @@ -0,0 +1,8 @@ +#!/usr/bin/env bash +set -euo pipefail +. "$(dirname "$0")/env.sh" + +mkdir -p "$VOCA_ROOT/.local" +envsubst '${VOCA_MODELS_DIR}' <"$VOCA_ROOT/scripts/server.json" >"$VOCA_ROOT/.local/server.json" +echo "запуск audiocpp_server добавляет VOCA-5 (0.2)" >&2 +exit 1 diff --git a/scripts/llama.sh b/scripts/llama.sh new file mode 100755 index 0000000..e4b7464 --- /dev/null +++ b/scripts/llama.sh @@ -0,0 +1,6 @@ +#!/usr/bin/env bash +set -euo pipefail +. "$(dirname "$0")/env.sh" + +echo "запуск llama-server добавляет VOCA-4 (0.3)" >&2 +exit 1 diff --git a/scripts/server.json b/scripts/server.json new file mode 100644 index 0000000..628bc86 --- /dev/null +++ b/scripts/server.json @@ -0,0 +1,25 @@ +{ + "host": "127.0.0.1", + "port": 8082, + "backend": "cpu", + "threads": 4, + "lazy_load": false, + "max_loaded_models": 2, + "ui": false, + "models": [ + { + "id": "asr", + "family": "parakeet_tdt", + "path": "${VOCA_MODELS_DIR}/audio-cpp/Parakeet-TDT-0.6B-v3-GGUF/parakeet-tdt-0.6b-v3.gguf", + "task": "asr", + "mode": "streaming" + }, + { + "id": "tts", + "family": "supertonic", + "path": "${VOCA_MODELS_DIR}/audio-cpp/Supertonic-3-GGUF/supertonic-3-q8_0.gguf", + "task": "tts", + "mode": "streaming" + } + ] +} From 31f70eeb1ab94217638d55c9d53ef662d032845f Mon Sep 17 00:00:00 2001 From: Pavel Donin Date: Mon, 9 Nov 2026 15:40:00 +0100 Subject: [PATCH 009/104] Describe the bench directory --- bench/README.md | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) create mode 100644 bench/README.md diff --git a/bench/README.md b/bench/README.md new file mode 100644 index 0000000..8a81d93 --- /dev/null +++ b/bench/README.md @@ -0,0 +1,18 @@ +# bench + +Stand scripts that measure the E0 pieces against their gates. They run in the +`agent/` environment, so they share its Python, dependencies and checks: + +```sh +uv run --project agent python bench/