From b80e3c1710791c7b1eb3cbebf49b8a42b5264caa Mon Sep 17 00:00:00 2001 From: bogwi Date: Sun, 24 May 2026 16:35:46 +0900 Subject: [PATCH 01/16] Forward SHM transports to Rerun and unify Go2 replay IPC --- dimos/constants.py | 5 ++ dimos/core/coordination/module_coordinator.py | 37 +++++++++++- dimos/core/transport.py | 36 +++++++++++- .../go2/blueprints/basic/unitree_go2_basic.py | 38 ++++++++---- dimos/visualization/rerun/bridge.py | 58 ++++++++++++++++++- 5 files changed, 157 insertions(+), 17 deletions(-) diff --git a/dimos/constants.py b/dimos/constants.py index d849f4aaf3..5b83bce636 100644 --- a/dimos/constants.py +++ b/dimos/constants.py @@ -45,6 +45,11 @@ DEFAULT_CAPACITY_COLOR_IMAGE = 1920 * 1080 * 3 # Default depth image size: 1280x720 frame * 4 (float32 size) DEFAULT_CAPACITY_DEPTH_IMAGE = 1280 * 720 * 4 +# Fixed-capacity SHM channels must be sized before the first message arrives. +# These defaults cover current Go2 replay and navigation payloads while keeping +# large local streams off UDP multicast. +DEFAULT_CAPACITY_POINTCLOUD = 64 * 1024 * 1024 +DEFAULT_CAPACITY_OCCUPANCY_GRID = 16 * 1024 * 1024 # From https://github.com/lcm-proj/lcm.git LCM_MAX_CHANNEL_NAME_LENGTH = 63 diff --git a/dimos/core/coordination/module_coordinator.py b/dimos/core/coordination/module_coordinator.py index 60c41a6ba1..d0593b8e02 100644 --- a/dimos/core/coordination/module_coordinator.py +++ b/dimos/core/coordination/module_coordinator.py @@ -30,7 +30,14 @@ from dimos.core.global_config import GlobalConfig, global_config from dimos.core.module import ModuleBase, ModuleSpec from dimos.core.resource import Resource -from dimos.core.transport import LCMTransport, PubSubTransport, pLCMTransport +from dimos.core.transport import ( + JpegShmTransport, + LCMTransport, + PubSubTransport, + SHMTransport, + pLCMTransport, + pSHMTransport, +) from dimos.spec.utils import is_spec, spec_annotation_compliance, spec_structural_compliance from dimos.utils.generic import short_id from dimos.utils.logging_config import setup_logger @@ -279,6 +286,9 @@ def _connect_streams(self, blueprint: Blueprint) -> None: module=module.__name__, transport=transport.__class__.__name__, ) + # SHM streams are concrete transport objects, not LCM topics. Forward + # them to Rerun after stream wiring has resolved the transport registry. + _configure_rerun_bridge_visual_transports(self) @classmethod def build( @@ -584,6 +594,31 @@ def _get_transport_for(blueprint: Blueprint, name: str, stream_type: type) -> Pu return transport +def _configure_rerun_bridge_visual_transports(coordinator: ModuleCoordinator) -> None: + """Send resolved SHM transports to an active Rerun bridge. + + RerunBridgeModule subscribes to configured pubsubs directly. For SHM + streams, the coordinator forwards the concrete transport objects after + stream wiring has selected them. + """ + from dimos.visualization.rerun.bridge import RerunBridgeModule + + if RerunBridgeModule not in coordinator._deployed_modules: + return + + # LCM transports are already visible through RerunBridgeModule.config.pubsubs. + transports = [ + transport + for transport in coordinator._transport_registry.values() + if isinstance(transport, SHMTransport | pSHMTransport | JpegShmTransport) + ] + if not transports: + return + + bridge = coordinator.get_instance(RerunBridgeModule) + bridge.set_visual_transports(transports) + + def _verify_no_name_conflicts(blueprint: Blueprint) -> None: name_to_types: dict[Any, set[type]] = defaultdict(set) name_to_modules: dict[Any, list[tuple[type, type]]] = defaultdict(list) diff --git a/dimos/core/transport.py b/dimos/core/transport.py index 6435003758..de2ced9cbb 100644 --- a/dimos/core/transport.py +++ b/dimos/core/transport.py @@ -163,14 +163,23 @@ def stop(self) -> None: class pSHMTransport(PubSubTransport[T]): + """Pickled shared-memory transport for local Python object streams.""" + _started: bool = False def __init__(self, topic: str, **kwargs) -> None: # type: ignore[no-untyped-def] super().__init__(topic) + self._kwargs = kwargs self.shm = PickleSharedMemory(**kwargs) def __reduce__(self): # type: ignore[no-untyped-def] - return (pSHMTransport, (self.topic,)) + # Preserve sizing options such as default_capacity when the coordinator + # sends this transport to workers or to Rerun. + return (pSHMTransport, (self.topic,), self._kwargs) + + def __setstate__(self, state: dict[str, Any]) -> None: + self._kwargs = state + self.shm = PickleSharedMemory(**state) def broadcast(self, _, msg) -> None: # type: ignore[no-untyped-def] if not self._started: @@ -193,14 +202,23 @@ def stop(self) -> None: class SHMTransport(PubSubTransport[T]): + """Raw bytes shared-memory transport for local fixed-size payloads.""" + _started: bool = False def __init__(self, topic: str, **kwargs) -> None: # type: ignore[no-untyped-def] super().__init__(topic) + self._kwargs = kwargs self.shm = BytesSharedMemory(**kwargs) def __reduce__(self): # type: ignore[no-untyped-def] - return (SHMTransport, (self.topic,)) + # Preserve sizing options such as default_capacity when the coordinator + # sends this transport to workers or to Rerun. + return (SHMTransport, (self.topic,), self._kwargs) + + def __setstate__(self, state: dict[str, Any]) -> None: + self._kwargs = state + self.shm = BytesSharedMemory(**state) def broadcast(self, _, msg) -> None: # type: ignore[no-untyped-def] if not self._started: @@ -223,6 +241,8 @@ def stop(self) -> None: class JpegShmTransport(PubSubTransport[T]): + """JPEG-compressed shared-memory transport for local image streams.""" + _started: bool = False def __init__(self, topic: str, quality: int = 75, **kwargs) -> None: # type: ignore[no-untyped-def] @@ -233,9 +253,19 @@ def __init__(self, topic: str, quality: int = 75, **kwargs) -> None: # type: ig self.shm = JpegSharedMemory(quality=quality, **kwargs) self.quality = quality + self._kwargs = kwargs def __reduce__(self): # type: ignore[no-untyped-def] - return (JpegShmTransport, (self.topic, self.quality)) + # Preserve quality and sizing options when crossing worker boundaries. + return (JpegShmTransport, (self.topic, self.quality), self._kwargs) + + def __setstate__(self, state: dict[str, Any]) -> None: + from dimos.protocol.pubsub.impl.jpeg_shm import ( + JpegSharedMemory, + ) # deferred to avoid pulling in Image/cv2/rerun + + self._kwargs = state + self.shm = JpegSharedMemory(quality=self.quality, **state) def broadcast(self, _, msg) -> None: # type: ignore[no-untyped-def] if not self._started: diff --git a/dimos/robot/unitree/go2/blueprints/basic/unitree_go2_basic.py b/dimos/robot/unitree/go2/blueprints/basic/unitree_go2_basic.py index 96a291163d..ea315119c5 100644 --- a/dimos/robot/unitree/go2/blueprints/basic/unitree_go2_basic.py +++ b/dimos/robot/unitree/go2/blueprints/basic/unitree_go2_basic.py @@ -14,29 +14,47 @@ # See the License for the specific language governing permissions and # limitations under the License. -import platform from typing import Any -from dimos.constants import DEFAULT_CAPACITY_COLOR_IMAGE +from dimos.constants import ( + DEFAULT_CAPACITY_COLOR_IMAGE, + DEFAULT_CAPACITY_OCCUPANCY_GRID, + DEFAULT_CAPACITY_POINTCLOUD, +) from dimos.core.coordination.blueprints import autoconnect from dimos.core.global_config import global_config from dimos.core.transport import pSHMTransport +from dimos.msgs.nav_msgs.OccupancyGrid import OccupancyGrid from dimos.msgs.sensor_msgs.Image import Image +from dimos.msgs.sensor_msgs.PointCloud2 import PointCloud2 from dimos.robot.unitree.go2.connection import GO2Connection from dimos.visualization.vis_module import vis_module -# Mac has some issue with high bandwidth UDP, so we use pSHMTransport for color_image -# actually we can use pSHMTransport for all platforms, and for all streams -# TODO need a global transport toggle on blueprints/global config -_mac_transports: dict[tuple[str, type], pSHMTransport[Image]] = { +# Route large local replay and mapping streams through SHM on every platform. +# Small control/status streams continue to use the default LCM transport. +_local_high_bandwidth_transports: dict[tuple[str, type], pSHMTransport[Any]] = { ("color_image", Image): pSHMTransport( - "color_image", default_capacity=DEFAULT_CAPACITY_COLOR_IMAGE + "/color_image", default_capacity=DEFAULT_CAPACITY_COLOR_IMAGE + ), + ("lidar", PointCloud2): pSHMTransport("/lidar", default_capacity=DEFAULT_CAPACITY_POINTCLOUD), + ("pointcloud", PointCloud2): pSHMTransport( + "/pointcloud", default_capacity=DEFAULT_CAPACITY_POINTCLOUD + ), + ("global_map", PointCloud2): pSHMTransport( + "/global_map", default_capacity=DEFAULT_CAPACITY_POINTCLOUD + ), + ("merged_map", PointCloud2): pSHMTransport( + "/merged_map", default_capacity=DEFAULT_CAPACITY_POINTCLOUD + ), + ("global_costmap", OccupancyGrid): pSHMTransport( + "/global_costmap", default_capacity=DEFAULT_CAPACITY_OCCUPANCY_GRID + ), + ("navigation_costmap", OccupancyGrid): pSHMTransport( + "/navigation_costmap", default_capacity=DEFAULT_CAPACITY_OCCUPANCY_GRID ), } -_transports_base = ( - autoconnect() if platform.system() == "Linux" else autoconnect().transports(_mac_transports) -) +_transports_base = autoconnect().transports(_local_high_bandwidth_transports) def _convert_camera_info(camera_info: Any) -> Any: diff --git a/dimos/visualization/rerun/bridge.py b/dimos/visualization/rerun/bridge.py index 2f5fb1efa9..a60bccf8e1 100644 --- a/dimos/visualization/rerun/bridge.py +++ b/dimos/visualization/rerun/bridge.py @@ -43,6 +43,7 @@ from dimos.core.core import rpc from dimos.core.module import Module, ModuleConfig +from dimos.core.transport import PubSubTransport from dimos.protocol.pubsub.impl.lcmpubsub import LCM from dimos.protocol.pubsub.patterns import Glob, pattern_matches from dimos.protocol.pubsub.spec import SubscribeAllCapable @@ -164,7 +165,10 @@ def _default_blueprint() -> Blueprint: class Config(ModuleConfig): + # Pubsubs cover discoverable sources such as LCM. visual_transports is + # populated by the coordinator for concrete local streams such as SHM. pubsubs: list[SubscribeAllCapable[Any, Any]] = field(default_factory=lambda: [LCM()]) + visual_transports: list[PubSubTransport[Any]] = field(default_factory=list) visual_override: dict[Glob | str, Callable[[Any], Archetype] | None] = field( default_factory=dict @@ -186,10 +190,12 @@ class Config(ModuleConfig): class RerunBridgeModule(Module): - """Bridge that logs messages from pubsubs to Rerun. + """Bridge that logs transport messages to Rerun. - Spawns its own Rerun viewer and subscribes to all topics on each provided - pubsub. Any message that has a to_rerun() method is automatically logged. + Spawns its own Rerun viewer and subscribes to configured pubsubs and + explicit visual transports. Pubsubs cover discoverable transports such as + LCM; visual_transports covers concrete local transports such as SHM. + Any message that has a to_rerun() method is automatically logged. Example: from dimos.protocol.pubsub.impl.lcmpubsub import LCM @@ -215,6 +221,8 @@ def __init__(self, **kwargs: Any) -> None: self._last_log = {} self._override_cache: dict[str, Callable[[Any], RerunData | None]] = {} self._frame_attached: dict[str, str] = {} + self._subscribed_visual_transport_topics: set[str] = set() + self._started = False @property def host(self) -> str: @@ -265,13 +273,52 @@ def composed(msg: Any) -> RerunData | None: return composed def _get_entity_path(self, topic: Any) -> str: + """Map a transport topic to a Rerun entity path. + + LCM topics usually already include a leading slash and a type suffix. + SHM topics are plain strings. Normalize both forms so visual overrides + such as "world/color_image" match consistently. + """ if self.config.topic_to_entity: return self.config.topic_to_entity(topic) topic_str = getattr(topic, "name", None) or str(topic) topic_str = topic_str.split("#")[0] # strip LCM topic suffix + if not topic_str.startswith("/"): + topic_str = f"/{topic_str}" return f"{self.config.entity_prefix}{topic_str}" + @rpc + def set_visual_transports(self, transports: list[PubSubTransport[Any]]) -> None: + """Replace explicit visual transports and subscribe when running. + + The coordinator calls this after stream wiring and after loading + additional blueprints into an existing coordinator. + """ + self.config.visual_transports = transports + if self._started: + self._subscribe_visual_transports() + + def _subscribe_visual_transports(self) -> None: + """Attach to configured SHM streams once per topic.""" + for transport in self.config.visual_transports: + topic = str(getattr(transport, "topic", "")) + if not topic or topic in self._subscribed_visual_transport_topics: + continue + self._subscribed_visual_transport_topics.add(topic) + if hasattr(transport, "start"): + transport.start() + unsub = transport.subscribe( + # Capture the current topic so callbacks keep the correct + # entity path even as this loop advances to the next transport. + lambda msg, transport_topic=getattr(transport, "topic", topic): self._on_message( + msg, transport_topic + ) + ) + if unsub is not None: + self.register_disposable(Disposable(unsub)) + self.register_disposable(Disposable(transport.stop)) + def _on_message(self, msg: Any, topic: Any) -> None: """Handle incoming message - log to rerun.""" @@ -306,6 +353,7 @@ def _on_message(self, msg: Any, topic: Any) -> None: @rpc def start(self) -> None: super().start() + self._started = True logger.info("Rerun bridge starting") @@ -397,6 +445,8 @@ def start(self) -> None: unsub = pubsub.subscribe_all(self._on_message) self.register_disposable(Disposable(unsub)) + self._subscribe_visual_transports() + for pubsub in self.config.pubsubs: if hasattr(pubsub, "stop"): self.register_disposable(Disposable(pubsub.stop)) # type: ignore[union-attr] @@ -506,8 +556,10 @@ def log_blueprint_graph(self, dot_code: str, module_names: list[str]) -> None: @rpc def stop(self) -> None: + self._started = False self._override_cache.clear() self._frame_attached.clear() + self._subscribed_visual_transport_topics.clear() super().stop() From c48366d7954ba7d5e4f215dd51530f391ce74de5 Mon Sep 17 00:00:00 2001 From: bogwi Date: Mon, 25 May 2026 14:24:32 +0900 Subject: [PATCH 02/16] fix: mypy --- dimos/visualization/rerun/bridge.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/dimos/visualization/rerun/bridge.py b/dimos/visualization/rerun/bridge.py index a60bccf8e1..5aceba6404 100644 --- a/dimos/visualization/rerun/bridge.py +++ b/dimos/visualization/rerun/bridge.py @@ -308,12 +308,15 @@ def _subscribe_visual_transports(self) -> None: self._subscribed_visual_transport_topics.add(topic) if hasattr(transport, "start"): transport.start() + transport_topic = getattr(transport, "topic", topic) + + def on_visual_message(msg: Any, transport_topic: Any = transport_topic) -> None: + self._on_message(msg, transport_topic) + unsub = transport.subscribe( # Capture the current topic so callbacks keep the correct # entity path even as this loop advances to the next transport. - lambda msg, transport_topic=getattr(transport, "topic", topic): self._on_message( - msg, transport_topic - ) + on_visual_message ) if unsub is not None: self.register_disposable(Disposable(unsub)) From 928c08f5b264ef67bb0b053f8a4a34b3f2c4c9d0 Mon Sep 17 00:00:00 2001 From: bogwi Date: Mon, 25 May 2026 14:30:22 +0900 Subject: [PATCH 03/16] fix: Greptile P1 --- dimos/visualization/rerun/bridge.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/dimos/visualization/rerun/bridge.py b/dimos/visualization/rerun/bridge.py index 5aceba6404..eb00f23730 100644 --- a/dimos/visualization/rerun/bridge.py +++ b/dimos/visualization/rerun/bridge.py @@ -308,6 +308,8 @@ def _subscribe_visual_transports(self) -> None: self._subscribed_visual_transport_topics.add(topic) if hasattr(transport, "start"): transport.start() + # If subscribe raises, the bridge still owns cleanup for the transport it started. + self.register_disposable(Disposable(transport.stop)) transport_topic = getattr(transport, "topic", topic) def on_visual_message(msg: Any, transport_topic: Any = transport_topic) -> None: @@ -320,7 +322,6 @@ def on_visual_message(msg: Any, transport_topic: Any = transport_topic) -> None: ) if unsub is not None: self.register_disposable(Disposable(unsub)) - self.register_disposable(Disposable(transport.stop)) def _on_message(self, msg: Any, topic: Any) -> None: """Handle incoming message - log to rerun.""" From 49e2b6192c851820b6c7fa03d204256f341b4b85 Mon Sep 17 00:00:00 2001 From: Ernest Date: Wed, 27 May 2026 11:47:09 +0800 Subject: [PATCH 04/16] feat: add Go2 SeatGuide hardware flow --- bin/demo_seat_guide_hardware_acceptance | 744 ++++ bin/demo_seat_guide_hardware_bringup | 118 + bin/demo_seat_guide_replay_smoke | 39 + bin/demo_seat_guide_smoke | 140 + bin/demo_seat_guide_verify_acceptance_log | 189 + dimos/agents/mcp/mcp_client.py | 14 + dimos/agents/mcp/mcp_server.py | 40 +- dimos/agents/mcp/test_mcp_client.py | 7 + dimos/agents/mcp/test_mcp_server.py | 47 +- dimos/agents/skills/seat_guide.py | 1046 +++++ dimos/agents/skills/speak_skill.py | 32 +- dimos/agents/skills/test_seat_guide.py | 3681 +++++++++++++++++ dimos/agents/skills/test_speak_skill.py | 61 + dimos/agents/system_prompt.py | 15 + dimos/agents/web_human_input.py | 129 +- dimos/robot/all_blueprints.py | 5 + .../go2/blueprints/agentic/_common_agentic.py | 3 + .../agentic/unitree_go2_seat_guide.py | 29 + .../agentic/unitree_go2_seat_guide_agentic.py | 31 + docs/agents/seat_guide_modules.md | 400 ++ 20 files changed, 6740 insertions(+), 30 deletions(-) create mode 100755 bin/demo_seat_guide_hardware_acceptance create mode 100755 bin/demo_seat_guide_hardware_bringup create mode 100755 bin/demo_seat_guide_replay_smoke create mode 100755 bin/demo_seat_guide_smoke create mode 100755 bin/demo_seat_guide_verify_acceptance_log create mode 100644 dimos/agents/skills/seat_guide.py create mode 100644 dimos/agents/skills/test_seat_guide.py create mode 100644 dimos/agents/skills/test_speak_skill.py create mode 100644 dimos/robot/unitree/go2/blueprints/agentic/unitree_go2_seat_guide.py create mode 100644 dimos/robot/unitree/go2/blueprints/agentic/unitree_go2_seat_guide_agentic.py create mode 100644 docs/agents/seat_guide_modules.md diff --git a/bin/demo_seat_guide_hardware_acceptance b/bin/demo_seat_guide_hardware_acceptance new file mode 100755 index 0000000000..df3796e927 --- /dev/null +++ b/bin/demo_seat_guide_hardware_acceptance @@ -0,0 +1,744 @@ +#!/usr/bin/env bash +set -euo pipefail + +run_dimos() { + if command -v dimos >/dev/null 2>&1; then + dimos "$@" + else + uv run dimos "$@" + fi +} + +log_dir="${SEAT_GUIDE_ACCEPTANCE_LOG_DIR:-logs/seat_guide_acceptance}" +mkdir -p "${log_dir}" +log_file="${log_dir}/$(date +%Y%m%d-%H%M%S).log" +script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +acceptance_log_verifier="${script_dir}/demo_seat_guide_verify_acceptance_log" +web_input_url="http://localhost:5555" +active_stream_pid="" +active_stream_file="" + +cleanup_active_stream() { + if [[ -n "${active_stream_pid}" ]] && kill -0 "${active_stream_pid}" >/dev/null 2>&1; then + kill "${active_stream_pid}" >/dev/null 2>&1 || true + wait "${active_stream_pid}" >/dev/null 2>&1 || true + fi + if [[ -n "${active_stream_file}" ]]; then + rm -f "${active_stream_file}" + fi + active_stream_pid="" + active_stream_file="" +} + +trap cleanup_active_stream EXIT +trap 'cleanup_active_stream; exit 130' INT +trap 'cleanup_active_stream; exit 143' TERM + +log() { + printf '%s\n' "$*" | tee -a "${log_file}" +} + +run_and_log() { + log "+ dimos $*" + run_dimos "$@" 2>&1 | tee -a "${log_file}" +} + +run_output="" +run_capture() { + log "+ dimos $*" + if ! run_output="$(run_dimos "$@" 2>&1)"; then + printf '%s\n' "${run_output}" | tee -a "${log_file}" + return 1 + fi + printf '%s\n' "${run_output}" | tee -a "${log_file}" +} + +capture_dimos_log() { + local label="$1" + log "" + log "${label}" + log "+ dimos log -n 200" + run_dimos log -n 200 2>&1 | tee -a "${log_file}" || true +} + +verify_acceptance_log() { + log "" + log "Verifying hardware acceptance transcript..." + log "+ ${acceptance_log_verifier} ${log_file}" + local verifier_output + if ! verifier_output="$("${acceptance_log_verifier}" "${log_file}" 2>&1)"; then + printf '%s\n' "${verifier_output}" | tee -a "${log_file}" + log "Hardware acceptance no-go: transcript verifier failed." + log "Transcript saved to: ${log_file}" + exit 3 + fi + printf '%s\n' "${verifier_output}" | tee -a "${log_file}" +} + +extract_goal_sequence() { + sed -n 's/.*goal_sequence=\([0-9][0-9]*\).*/\1/p' <<<"$1" | tail -n 1 +} + +extract_web_input_url() { + sed -n 's/.*url=\(http[^; ]*\).*/\1/p' <<<"$1" | sed 's/\.$//' | tail -n 1 +} + +extract_run_id() { + sed -n 's/^ Run ID:[[:space:]]*//p' <<<"$1" | tail -n 1 +} + +seat_guide_goal_completed_after_sequence() { + local previous_goal_sequence="$1" + local nav_output="$2" + local current_goal_sequence + current_goal_sequence="$(extract_goal_sequence "${nav_output}")" + [[ -n "${current_goal_sequence}" ]] \ + && ((current_goal_sequence > previous_goal_sequence)) \ + && grep -Fq "goal_reached=true" <<<"${nav_output}" +} + +seat_guide_preflight_ready_for_hardware() { + local output="$1" + grep -Fq "SeatGuide preflight ready" <<<"${output}" \ + && grep -Fq "navigation=IDLE" <<<"${output}" \ + && grep -Fq "speaker=connected" <<<"${output}" +} + +web_input_ready_for_seat_guide() { + local output="$1" + grep -Fq "web=started" <<<"${output}" \ + && grep -Fq "thread=running" <<<"${output}" \ + && grep -Fq "seat_route=seat_guide_direct" <<<"${output}" \ + && grep -Fq "responses=connected" <<<"${output}" \ + && grep -Fq "voice_upload=connected" <<<"${output}" \ + && grep -Fq "stt=connected" <<<"${output}" \ + && grep -Fq "human_transport=connected" <<<"${output}" +} + +log_web_input_no_go_details() { + local output="$1" + if ! grep -Fq "web=started" <<<"${output}"; then + log " - WebInput server is not started. Check WebInput module startup before using browser text or microphone input." + fi + if ! grep -Fq "thread=running" <<<"${output}"; then + log " - WebInput server thread is not running. Inspect WebInput startup logs and port binding." + fi + if ! grep -Fq "seat_route=seat_guide_direct" <<<"${output}"; then + log " - WebInput is not directly wired to SeatGuide. Check blueprint injection of SeatGuideSkillContainer into WebInput." + fi + if ! grep -Fq "responses=connected" <<<"${output}"; then + log " - WebInput response stream is missing. Browser feedback cannot prove SeatGuide responses." + fi + if ! grep -Fq "voice_upload=connected" <<<"${output}"; then + log " - WebInput browser audio upload endpoint is not connected. Browser microphone recordings cannot reach the speech-to-text pipeline." + fi + if ! grep -Fq "stt=connected" <<<"${output}"; then + log " - WebInput speech-to-text pipeline is unavailable. Check Whisper dependencies, model loading, and audio pipeline initialization before browser microphone acceptance." + fi + if ! grep -Fq "human_transport=connected" <<<"${output}"; then + log " - WebInput human-input fallback transport is missing. Normal agent text fallback will not work." + fi +} + +camera_provider_ready_for_hardware() { + local output="$1" + grep -Fq "credential=present" <<<"${output}" \ + && grep -Eq "image=[0-9]+x[0-9]+" <<<"${output}" \ + && grep -Fq "image_fresh=true" <<<"${output}" \ + && grep -Fq "odom=(" <<<"${output}" \ + && grep -Fq "odom_fresh=true" <<<"${output}" \ + && grep -Fq "override=inactive" <<<"${output}" \ + && grep -Fq "configured_fallback_seats=0" <<<"${output}" \ + && grep -Fq "configured_fallback_people=0" <<<"${output}" +} + +log_camera_provider_no_go_details() { + local output="$1" + if grep -Fq "credential=missing" <<<"${output}"; then + log " - VLM credential is missing. Export ALIBABA_API_KEY in the environment used to start the DimOS daemon, then restart the SeatGuide stack." + fi + if grep -Fq "image=missing" <<<"${output}" || ! grep -Eq "image=[0-9]+x[0-9]+" <<<"${output}"; then + log " - Camera image is missing. Check the Go2 camera stream and confirm the robot is publishing color_image." + fi + if grep -Fq "image_fresh=false" <<<"${output}"; then + log " - Camera image is stale. Restart/fix the camera stream before using visual detections for a live goal." + fi + if grep -Fq "odom=missing" <<<"${output}"; then + log " - Odometry is missing. Check localization/odom before sending a map-frame navigation goal." + fi + if grep -Fq "odom_fresh=false" <<<"${output}"; then + log " - Odometry is stale. Restart/fix localization before sending a map-frame navigation goal." + fi + if grep -Fq "override=active" <<<"${output}"; then + log " - Runtime seat-scene override is active. Clear it before hardware acceptance so camera recognition is the source of truth." + fi + if ! grep -Fq "configured_fallback_seats=0" <<<"${output}" \ + || ! grep -Fq "configured_fallback_people=0" <<<"${output}"; then + log " - Configured fallback seats/people are non-zero. Use the SeatGuide hardware blueprint with camera-backed perception and no fallback layout for acceptance." + fi +} + +log_speech_no_go_details() { + local output="$1" + if ! grep -Fq "tts=ready" <<<"${output}"; then + log " - TTS is not ready. Export OPENAI_API_KEY in the environment used to start the DimOS daemon, then restart the SeatGuide stack." + fi + if ! grep -Fq "audio_output=connected" <<<"${output}"; then + log " - Audio output is not connected. Check the robot/local speaker device before relying on spoken feedback." + fi +} + +log_seat_guide_no_go_details() { + local output="$1" + if grep -Fq "navigation=FOLLOWING_PATH" <<<"${output}" \ + || grep -Fq "navigation=RECOVERY" <<<"${output}"; then + log " - Navigation is busy. Wait for the current goal to finish or cancel it, then rerun preflight before any live voice request." + fi + if grep -Fq "navigation=missing" <<<"${output}" \ + || grep -Fq "navigation=error(" <<<"${output}"; then + log " - Navigation is unavailable. Check the NavigationSkillContainer wiring and navigation logs before sending a SeatGuide goal." + fi + if grep -Fq "speaker=missing" <<<"${output}"; then + log " - SeatGuide speaker wiring is missing. Confirm SpeakSkill is in the SeatGuide blueprint and connected before official hardware acceptance." + fi + if grep -Fq "source=no_camera_image" <<<"${output}" \ + || grep -Fq "perception=no_camera_image" <<<"${output}"; then + log " - SeatGuide has no camera image. Check the Go2 camera stream and turn the robot toward the conference table." + fi + if grep -Fq "source=camera_no_odom" <<<"${output}" \ + || grep -Fq "perception=camera_no_odom" <<<"${output}"; then + log " - SeatGuide has camera frames but no odometry. Fix localization/odom before sending a map-frame navigation goal." + fi + if grep -Fq "source=stale_camera_image" <<<"${output}" \ + || grep -Fq "perception=stale_camera_image" <<<"${output}"; then + log " - SeatGuide camera frames are stale. Restore the live camera stream before sending a SeatGuide goal." + fi + if grep -Fq "source=stale_camera_odom" <<<"${output}" \ + || grep -Fq "perception=stale_camera_odom" <<<"${output}"; then + log " - SeatGuide odometry is stale. Restore localization before sending a map-frame goal." + fi + if grep -Fq "source=camera_no_seats_detected" <<<"${output}" \ + || grep -Fq "perception=camera_no_seats_detected" <<<"${output}" \ + || grep -Fq "no seats" <<<"${output}"; then + log " - SeatGuide cannot see chairs. Reposition the robot toward the long table or inspect VLM chair detections before live navigation." + fi + if grep -Fq "source=camera_detection_error" <<<"${output}" \ + || grep -Fq "perception=camera_detection_error" <<<"${output}"; then + log " - SeatGuide camera detection failed. Check ALIBABA_API_KEY, VLM model setup, and DimOS logs." + fi + if grep -Fq "is not live camera" <<<"${output}" \ + || grep -Fq "source=configured_fallback" <<<"${output}" \ + || grep -Fq "source=runtime_override" <<<"${output}"; then + log " - SeatGuide is using fallback/calibrated coordinates. That is useful for debugging but rejected for official hardware acceptance." + fi + if grep -Fq "no empty seat" <<<"${output}" \ + || grep -Fq "no empty seat available" <<<"${output}"; then + log " - SeatGuide sees seats but none are empty. Move a person/object away from a chair or adjust the occupancy radius/layout before live navigation." + fi +} + +require_hardware_run_registry() { + local status_text="$1" + local run_id + run_id="$(extract_run_id "${status_text}")" + if [[ -z "${run_id}" ]]; then + log "Hardware acceptance no-go: could not parse DimOS run ID from status." + log "Transcript saved to: ${log_file}" + exit 3 + fi + + local state_home="${XDG_STATE_HOME:-${HOME}/.local/state}" + local registry_path="${state_home}/dimos/runs/${run_id}.json" + if [[ ! -f "${registry_path}" ]]; then + log "Hardware acceptance no-go: DimOS run registry entry not found: ${registry_path}" + log "Transcript saved to: ${log_file}" + exit 3 + fi + + log "Hardware run registry: ${registry_path}" + if grep -Fq '"--replay' "${registry_path}" \ + || grep -Eq '"replay"[[:space:]]*:[[:space:]]*true' "${registry_path}"; then + log "Hardware acceptance no-go: running DimOS stack is replay mode." + log "Transcript saved to: ${log_file}" + exit 3 + fi + if grep -Fq '"--simulation' "${registry_path}" \ + || grep -Eq '"simulation"[[:space:]]*:[[:space:]]*true' "${registry_path}" \ + || grep -Eq '"simulation"[[:space:]]*:[[:space:]]*"[^"]+"' "${registry_path}"; then + log "Hardware acceptance no-go: running DimOS stack is simulation mode." + log "Transcript saved to: ${log_file}" + exit 3 + fi + if ! grep -Eq '"blueprint"[[:space:]]*:[[:space:]]*"unitree-go2-seat-guide(-agentic)?"' "${registry_path}"; then + log "Hardware acceptance no-go: running DimOS stack is not a SeatGuide Go2 blueprint." + log "Transcript saved to: ${log_file}" + exit 3 + fi + local blueprint + blueprint="$(sed -n 's/.*"blueprint"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' "${registry_path}" | tail -n 1)" + log "Hardware blueprint: ${blueprint}" + log "Hardware run mode: hardware." +} + +wait_for_navigation_goal_reached() { + local previous_goal_sequence="$1" + local attempts="${SEAT_GUIDE_GOAL_REACHED_ATTEMPTS:-60}" + local interval_s="${SEAT_GUIDE_GOAL_REACHED_INTERVAL_S:-2}" + + for ((attempt = 1; attempt <= attempts; attempt++)); do + log "Checking SeatGuide navigation completion (${attempt}/${attempts})..." + run_capture mcp call seat_guide_navigation_status + local nav_output="${run_output}" + if seat_guide_goal_completed_after_sequence "${previous_goal_sequence}" "${nav_output}"; then + log "SeatGuide navigation goal reached." + return 0 + fi + sleep "${interval_s}" + done + + log "Hardware acceptance no-go: SeatGuide navigation did not report goal_reached=true." + capture_dimos_log "Capturing DimOS log snapshot after navigation completion timeout..." + log "Transcript saved to: ${log_file}" + exit 3 +} + +wait_for_stream_text() { + local stream_file="$1" + local expected="$2" + local timeout_s="$3" + local start_offset="${4:-0}" + local elapsed=0 + + while true; do + if dd if="${stream_file}" bs=1 skip="${start_offset}" 2>/dev/null | grep -Fq "${expected}"; then + return 0 + fi + if ((elapsed >= timeout_s)); then + return 1 + fi + sleep 1 + elapsed=$((elapsed + 1)) + done +} + +check_webinput_preview_route() { + if ! command -v curl >/dev/null 2>&1; then + log "Hardware acceptance no-go: curl is required to verify the WebInput HTTP route." + log "Transcript saved to: ${log_file}" + exit 3 + fi + + local stream_file + stream_file="$(mktemp)" + active_stream_file="${stream_file}" + local stream_pid="" + stop_stream() { + if [[ -n "${stream_pid}" ]] && kill -0 "${stream_pid}" >/dev/null 2>&1; then + kill "${stream_pid}" >/dev/null 2>&1 || true + wait "${stream_pid}" >/dev/null 2>&1 || true + fi + active_stream_pid="" + } + + local response_timeout="${SEAT_GUIDE_WEBINPUT_TEXT_WAIT_S:-20}" + log "+ curl --no-buffer --max-time $((response_timeout + 5)) ${web_input_url}/text_stream/agent_responses" + curl --no-buffer --max-time "$((response_timeout + 5))" -s "${web_input_url}/text_stream/agent_responses" \ + >"${stream_file}" 2>&1 & + stream_pid="$!" + active_stream_pid="${stream_pid}" + sleep 0.5 + local stream_start_bytes + stream_start_bytes="$(wc -c <"${stream_file}" | tr -d ' ')" + + log "+ curl -X POST ${web_input_url}/submit_query --data-urlencode query=预检帮我找一个空位" + local post_output + if ! post_output="$(curl -sS -f -X POST \ + --data-urlencode "query=预检帮我找一个空位" \ + "${web_input_url}/submit_query" 2>&1)"; then + printf '%s\n' "${post_output}" | tee -a "${log_file}" + log "Hardware acceptance no-go: WebInput /submit_query request failed." + log "Transcript saved to: ${log_file}" + stop_stream + rm -f "${stream_file}" + active_stream_file="" + exit 3 + fi + printf '%s\n' "${post_output}" | tee -a "${log_file}" + + local stream_matched=0 + if wait_for_stream_text "${stream_file}" "SeatGuide preflight ready" "${response_timeout}" "${stream_start_bytes}"; then + stream_matched=1 + fi + stop_stream + + log "Captured WebInput agent_responses stream:" + cat "${stream_file}" | tee -a "${log_file}" || true + if [[ "${stream_matched}" != "1" ]]; then + log "Hardware acceptance no-go: WebInput text route did not publish a ready SeatGuide preview response." + capture_dimos_log "Capturing DimOS log snapshot after WebInput text route failure..." + log "Transcript saved to: ${log_file}" + rm -f "${stream_file}" + active_stream_file="" + exit 3 + fi + rm -f "${stream_file}" + active_stream_file="" +} + +check_webinput_voice_preview_route() { + if ! command -v curl >/dev/null 2>&1; then + log "Hardware acceptance no-go: curl is required to verify the WebInput voice route." + log "Transcript saved to: ${log_file}" + exit 3 + fi + + local stream_file + stream_file="$(mktemp)" + active_stream_file="${stream_file}" + local stream_pid="" + stop_stream() { + if [[ -n "${stream_pid}" ]] && kill -0 "${stream_pid}" >/dev/null 2>&1; then + kill "${stream_pid}" >/dev/null 2>&1 || true + wait "${stream_pid}" >/dev/null 2>&1 || true + fi + active_stream_pid="" + } + + local response_timeout="${SEAT_GUIDE_WEBINPUT_VOICE_PREVIEW_WAIT_S:-120}" + log "+ curl --no-buffer --max-time $((response_timeout + 5)) ${web_input_url}/text_stream/agent_responses" + curl --no-buffer --max-time "$((response_timeout + 5))" -s "${web_input_url}/text_stream/agent_responses" \ + >"${stream_file}" 2>&1 & + stream_pid="$!" + active_stream_pid="${stream_pid}" + + cat </dev/null 2>&1; then + log "Hardware acceptance no-go: curl is required to verify the live WebInput voice route." + log "Transcript saved to: ${log_file}" + exit 3 + fi + + local stream_file + stream_file="$(mktemp)" + active_stream_file="${stream_file}" + local stream_pid="" + stop_stream() { + if [[ -n "${stream_pid}" ]] && kill -0 "${stream_pid}" >/dev/null 2>&1; then + kill "${stream_pid}" >/dev/null 2>&1 || true + wait "${stream_pid}" >/dev/null 2>&1 || true + fi + active_stream_pid="" + } + + local response_timeout="${SEAT_GUIDE_WEBINPUT_VOICE_LIVE_WAIT_S:-150}" + log "+ curl --no-buffer --max-time $((response_timeout + 5)) ${web_input_url}/text_stream/agent_responses" + curl --no-buffer --max-time "$((response_timeout + 5))" -s "${web_input_url}/text_stream/agent_responses" \ + >"${stream_file}" 2>&1 & + stream_pid="$!" + active_stream_pid="${stream_pid}" + + cat <&2 + +No running DimOS stack found. +Start the real Go2 SeatGuide stack first, for example: + + dimos run unitree-go2-seat-guide-agentic --robot-ip 192.168.123.161 --daemon +EOF + log "Transcript saved to: ${log_file}" + exit 2 +fi +require_hardware_run_registry "${status_output}" + +log "" +log "Checking SeatGuide hardware acceptance tools..." +if ! tools="$(run_dimos mcp list-tools 2>&1)"; then + printf '%s\n' "${tools}" | tee -a "${log_file}" + log "Hardware acceptance no-go: MCP tools are unavailable." + log "Confirm the running blueprint is unitree-go2-seat-guide or unitree-go2-seat-guide-agentic and includes McpServer." + log "Transcript saved to: ${log_file}" + exit 3 +fi +printf '%s\n' "${tools}" >>"${log_file}" +require_tool "${tools}" "web_input_status" +require_tool "${tools}" "camera_seat_provider_status" +require_tool "${tools}" "seat_guide_status" +require_tool "${tools}" "seat_guide_readiness_report" +require_tool "${tools}" "seat_guide_preflight" +require_tool "${tools}" "seat_guide_navigation_status" +require_tool "${tools}" "preview_seat_request" +require_tool "${tools}" "preview_empty_seat_goal" +require_tool "${tools}" "handle_seat_request" +require_tool "${tools}" "speech_status" +require_tool "${tools}" "speak" + +log "" +log "Checking SeatGuide module wiring..." +run_capture mcp modules +modules_output="${run_output}" +require_output_contains "${modules_output}" "CameraSeatObservationProvider" "mcp modules" +require_output_contains "${modules_output}" "SeatGuideSkillContainer" "mcp modules" +require_output_contains "${modules_output}" "WebInput" "mcp modules" +require_output_contains "${modules_output}" "SpeakSkill" "mcp modules" + +log "" +log "Checking WebInput route..." +run_capture mcp call web_input_status +web_input_output="${run_output}" +if ! web_input_ready_for_seat_guide "${web_input_output}"; then + log "Hardware acceptance no-go: web_input_status was not ready for SeatGuide." + log_web_input_no_go_details "${web_input_output}" + log "Transcript saved to: ${log_file}" + exit 3 +fi +detected_web_input_url="$(extract_web_input_url "${web_input_output}")" +if [[ -n "${detected_web_input_url}" ]]; then + web_input_url="${detected_web_input_url}" +fi +log "Using WebInput URL: ${web_input_url}" + +log "" +log "Checking camera/VLM provider without running detection..." +run_capture mcp call camera_seat_provider_status +camera_output="${run_output}" +if ! camera_provider_ready_for_hardware "${camera_output}"; then + log "Hardware acceptance no-go: camera_seat_provider_status was not hardware ready." + log_camera_provider_no_go_details "${camera_output}" + log "Transcript saved to: ${log_file}" + exit 3 +fi + +log "" +log "Checking speech output readiness..." +run_capture mcp call speech_status +speech_output="${run_output}" +if ! grep -Fq "tts=ready" <<<"${speech_output}" \ + || ! grep -Fq "audio_output=connected" <<<"${speech_output}"; then + log "Hardware acceptance no-go: speech_status was not hardware ready." + log_speech_no_go_details "${speech_output}" + log "Transcript saved to: ${log_file}" + exit 3 +fi +run_capture mcp call speak --json-args '{"text": "SeatGuide audio check. I can guide you to an empty seat.", "blocking": true}' +audio_check_output="${run_output}" +require_output_contains "${audio_check_output}" "Spoke: SeatGuide audio check" "speak audio check" +cat <<'EOF' | tee -a "${log_file}" + +Audio output confirmation: + Type HEARD if you clearly heard: SeatGuide audio check. I can guide you to an empty seat. + Anything else is a no-go for official hardware acceptance. +EOF +read -r audio_confirmation +log "Operator audio confirmation: ${audio_confirmation}" +if [[ "${audio_confirmation}" != "HEARD" ]]; then + log "Hardware acceptance no-go: operator did not confirm audible SeatGuide speech output." + log "Transcript saved to: ${log_file}" + exit 3 +fi + +log "" +log "Checking current SeatGuide scene..." +run_capture mcp call seat_guide_status +scene_output="${run_output}" +if ! grep -Fq "SeatGuide scene source=camera:" <<<"${scene_output}"; then + log "Hardware acceptance no-go: seat_guide_status did not report live camera perception." + log_seat_guide_no_go_details "${scene_output}" + log "Transcript saved to: ${log_file}" + exit 3 +fi + +log "" +log "Running no-motion readiness report..." +run_capture mcp call seat_guide_readiness_report +readiness_output="${run_output}" +if ! seat_guide_preflight_ready_for_hardware "${readiness_output}"; then + log "Hardware acceptance no-go: seat_guide_readiness_report was not hardware ready." + log_seat_guide_no_go_details "${readiness_output}" + log "Transcript saved to: ${log_file}" + exit 3 +fi + +log "" +log "Running no-motion preflight..." +run_capture mcp call seat_guide_preflight +preflight_output="${run_output}" +if ! seat_guide_preflight_ready_for_hardware "${preflight_output}"; then + log "Hardware acceptance no-go: seat_guide_preflight was not hardware ready." + log_seat_guide_no_go_details "${preflight_output}" + log "Transcript saved to: ${log_file}" + exit 3 +fi + +log "" +log "Previewing spoken Chinese request without moving..." +run_capture mcp call preview_seat_request --json-args '{"text": "预检帮我找一个空位"}' +preview_request_output="${run_output}" +if ! seat_guide_preflight_ready_for_hardware "${preview_request_output}"; then + log "Hardware acceptance no-go: preview_seat_request was not hardware ready." + log_seat_guide_no_go_details "${preview_request_output}" + log "Transcript saved to: ${log_file}" + exit 3 +fi + +log "" +log "Previewing selected goal without moving..." +run_capture mcp call preview_empty_seat_goal +preview_goal_output="${run_output}" +require_output_contains "${preview_goal_output}" "SeatGuide preview source=camera:" "preview_empty_seat_goal" +require_output_contains "${preview_goal_output}" "selected" "preview_empty_seat_goal" +require_output_contains "${preview_goal_output}" "goal=(" "preview_empty_seat_goal" + +log "" +log "Verifying WebInput HTTP text route without moving..." +check_webinput_preview_route + +log "" +log "Verifying browser microphone/Whisper route without moving..." +check_webinput_voice_preview_route + +capture_dimos_log "Capturing DimOS log snapshot after no-motion checks..." + +log "" +log "Capturing SeatGuide goal sequence before live voice request..." +run_capture mcp call seat_guide_navigation_status +before_live_goal_sequence="$(extract_goal_sequence "${run_output}")" +if [[ -z "${before_live_goal_sequence}" ]]; then + log "Hardware acceptance no-go: could not parse SeatGuide goal_sequence before live request." + capture_dimos_log "Capturing DimOS log snapshot after goal_sequence parse failure..." + log "Transcript saved to: ${log_file}" + exit 3 +fi + +cat <<'EOF' | tee -a "${log_file}" + +No-motion checks completed. + +Before live navigation: + - Confirm the Go2 is physically clear to move. + - Automated gates passed for WebInput, browser voice, camera/VLM/odometry, speech, preflight, and goal preview. + +Type LIVE to start the real browser-microphone navigation request. Anything else aborts. +EOF + +read -r confirmation +log "Operator confirmation: ${confirmation}" +if [[ "${confirmation}" != "LIVE" ]]; then + log "Aborted before live navigation." + log "Transcript saved to: ${log_file}" + exit 0 +fi + +log "" +log "Sending live SeatGuide navigation request through browser microphone..." +check_webinput_voice_live_route +wait_for_navigation_goal_reached "${before_live_goal_sequence}" + +log "" +capture_dimos_log "Capturing DimOS log snapshot after live request..." +verify_acceptance_log + +log "" +log "Live request sent. Continue monitoring with: dimos log -f" +log "Transcript saved to: ${log_file}" diff --git a/bin/demo_seat_guide_hardware_bringup b/bin/demo_seat_guide_hardware_bringup new file mode 100755 index 0000000000..6b623ef9b0 --- /dev/null +++ b/bin/demo_seat_guide_hardware_bringup @@ -0,0 +1,118 @@ +#!/usr/bin/env bash +set -euo pipefail + +usage() { + cat <<'EOF' +Usage: bin/demo_seat_guide_hardware_bringup [--robot-ip ] [--skip-start] [--skip-smoke] + +Start the real Go2 SeatGuide stack, run no-motion readiness checks, then run +the browser-microphone hardware acceptance flow. + +Options: + --robot-ip Go2 robot IP. Default: 192.168.123.161 + --skip-start Use the currently running DimOS stack instead of starting one. + --skip-smoke Skip the no-motion smoke wrapper and go straight to hardware acceptance. + -h, --help Show this help. + +Required environment: + ALIBABA_API_KEY Qwen/VLM chair/person detection. + OPENAI_API_KEY TTS speech feedback. +EOF +} + +run_dimos() { + if command -v dimos >/dev/null 2>&1; then + dimos "$@" + else + uv run dimos "$@" + fi +} + +robot_ip="${SEAT_GUIDE_ROBOT_IP:-192.168.123.161}" +skip_start=0 +skip_smoke=0 + +while [[ $# -gt 0 ]]; do + case "$1" in + --robot-ip) + if [[ $# -lt 2 ]]; then + echo "Missing value for --robot-ip." >&2 + usage >&2 + exit 2 + fi + robot_ip="$2" + shift 2 + ;; + --skip-start) + skip_start=1 + shift + ;; + --skip-smoke) + skip_smoke=1 + shift + ;; + -h|--help) + usage + exit 0 + ;; + *) + echo "Unknown argument: $1" >&2 + usage >&2 + exit 2 + ;; + esac +done + +missing=0 +if [[ -z "${ALIBABA_API_KEY:-}" ]]; then + echo "SeatGuide bring-up no-go: ALIBABA_API_KEY is not set." >&2 + missing=1 +fi +if [[ -z "${OPENAI_API_KEY:-}" ]]; then + echo "SeatGuide bring-up no-go: OPENAI_API_KEY is not set." >&2 + missing=1 +fi +if [[ "${missing}" != "0" ]]; then + cat >&2 <<'EOF' +Set the required keys in the same terminal that will start DimOS, for example: + + export ALIBABA_API_KEY=... + export OPENAI_API_KEY=... +EOF + exit 2 +fi + +script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +if [[ "${skip_start}" != "1" ]]; then + echo "Starting real Go2 SeatGuide stack at robot IP ${robot_ip}..." + run_dimos run unitree-go2-seat-guide-agentic --robot-ip "${robot_ip}" --daemon +else + echo "Using the currently running DimOS stack." +fi + +echo +echo "Current DimOS status:" +run_dimos status + +if [[ "${skip_smoke}" != "1" ]]; then + echo + echo "Running SeatGuide no-motion smoke checks..." + "${script_dir}/demo_seat_guide_smoke" +else + echo + echo "Skipping no-motion smoke checks." +fi + +cat <<'EOF' + +SeatGuide hardware acceptance will now verify the real browser microphone path. +You will need to: + 1. Open the WebInput URL printed by the script. + 2. Allow microphone access. + 3. Type HEARD after the TTS audio check is audible. + 4. Type LIVE only when the Go2 is physically clear to move. + 5. Say the prompted Chinese phrases into the browser microphone. +EOF + +"${script_dir}/demo_seat_guide_hardware_acceptance" diff --git a/bin/demo_seat_guide_replay_smoke b/bin/demo_seat_guide_replay_smoke new file mode 100755 index 0000000000..ed67192a98 --- /dev/null +++ b/bin/demo_seat_guide_replay_smoke @@ -0,0 +1,39 @@ +#!/usr/bin/env bash +set -euo pipefail + +run_dimos() { + if command -v dimos >/dev/null 2>&1; then + dimos "$@" + else + uv run dimos "$@" + fi +} + +if [[ "$(uname -s)" == "Darwin" ]]; then + if ! netstat -nr | awk '/224\.0\.0(\.0)?\/4/ && /lo0/ { found = 1 } END { exit found ? 0 : 1 }'; then + cat >&2 <<'EOF' +SeatGuide replay smoke requires multicast route 224.0.0.0/4 on lo0. + +Run this once in an interactive terminal, then rerun this script: + + sudo route delete -net 224.0.0.0/4 || true + sudo route add -net 224.0.0.0/4 -interface lo0 +EOF + exit 2 + fi +fi + +cleanup() { + run_dimos stop >/dev/null 2>&1 || true +} +trap cleanup EXIT + +echo "Starting SeatGuide replay stack..." +run_dimos --replay run unitree-go2-seat-guide-agentic --daemon + +echo +echo "Running SeatGuide no-motion smoke against replay stack..." +"$(dirname "$0")/demo_seat_guide_smoke" + +echo +echo "SeatGuide replay smoke completed." diff --git a/bin/demo_seat_guide_smoke b/bin/demo_seat_guide_smoke new file mode 100755 index 0000000000..3bbc46b13e --- /dev/null +++ b/bin/demo_seat_guide_smoke @@ -0,0 +1,140 @@ +#!/usr/bin/env bash + +set -euo pipefail + +run_dimos() { + if command -v dimos >/dev/null 2>&1; then + dimos "$@" + else + uv run dimos "$@" + fi +} + +require_tool() { + local tools="$1" + local tool_name="$2" + if ! grep -q "\"${tool_name}\"" <<<"${tools}"; then + echo "SeatGuide smoke no-go: missing MCP tool '${tool_name}'." >&2 + echo "Confirm the running blueprint is unitree-go2-seat-guide or unitree-go2-seat-guide-agentic and includes SeatGuide, WebInput, camera provider, and SpeakSkill modules." >&2 + exit 3 + fi +} + +require_output_contains() { + local output="$1" + local expected="$2" + local label="$3" + if ! grep -Fq "${expected}" <<<"${output}"; then + echo "SeatGuide smoke no-go: ${label} did not contain '${expected}'." >&2 + return 1 + fi +} + +extract_run_id() { + sed -n 's/^ Run ID:[[:space:]]*//p' <<<"$1" | tail -n 1 +} + +require_seat_guide_run_registry() { + local status_text="$1" + local run_id + run_id="$(extract_run_id "${status_text}")" + if [[ -z "${run_id}" ]]; then + echo "SeatGuide smoke no-go: could not parse DimOS run ID from status." >&2 + exit 3 + fi + + local state_home="${XDG_STATE_HOME:-${HOME}/.local/state}" + local registry_path="${state_home}/dimos/runs/${run_id}.json" + if [[ ! -f "${registry_path}" ]]; then + echo "SeatGuide smoke no-go: DimOS run registry entry not found: ${registry_path}" >&2 + exit 3 + fi + + if ! grep -Eq '"blueprint"[[:space:]]*:[[:space:]]*"unitree-go2-seat-guide(-agentic)?"' "${registry_path}"; then + echo "SeatGuide smoke no-go: running DimOS stack is not a SeatGuide Go2 blueprint." >&2 + echo "Start unitree-go2-seat-guide-agentic for replay or real hardware acceptance." >&2 + exit 3 + fi +} + +echo "Checking DimOS run status..." +status_output="$(run_dimos status)" +printf '%s\n' "${status_output}" +if grep -q "No running DimOS instance" <<<"${status_output}"; then + cat >&2 <<'EOF' + +No running DimOS stack found. +Start a SeatGuide stack first, for example: + + dimos --replay run unitree-go2-seat-guide-agentic --daemon + +or, on real Go2 hardware: + + dimos run unitree-go2-seat-guide-agentic --robot-ip 192.168.123.161 --daemon +EOF + exit 2 +fi +require_seat_guide_run_registry "${status_output}" + +echo +echo "Checking SeatGuide MCP tools..." +if ! tools="$(run_dimos mcp list-tools 2>&1)"; then + printf '%s\n' "${tools}" >&2 + cat >&2 <<'EOF' +SeatGuide smoke no-go: MCP tools are unavailable. +Confirm the running blueprint is unitree-go2-seat-guide or unitree-go2-seat-guide-agentic and includes McpServer. +EOF + exit 3 +fi +require_tool "${tools}" "seat_guide_readiness_report" +require_tool "${tools}" "preview_seat_request" +require_tool "${tools}" "seat_guide_preflight" +require_tool "${tools}" "seat_guide_navigation_status" +require_tool "${tools}" "seat_guide_status" +require_tool "${tools}" "preview_empty_seat_goal" +require_tool "${tools}" "web_input_status" +require_tool "${tools}" "camera_seat_provider_status" +require_tool "${tools}" "speech_status" + +echo +echo "Checking WebInput voice/text route status..." +web_input_output="$(run_dimos mcp call web_input_status)" +printf '%s\n' "${web_input_output}" +require_output_contains "${web_input_output}" "web=started" "web_input_status" +require_output_contains "${web_input_output}" "thread=running" "web_input_status" +require_output_contains "${web_input_output}" "seat_route=seat_guide_direct" "web_input_status" +require_output_contains "${web_input_output}" "responses=connected" "web_input_status" +require_output_contains "${web_input_output}" "voice_upload=connected" "web_input_status" +require_output_contains "${web_input_output}" "stt=connected" "web_input_status" +require_output_contains "${web_input_output}" "human_transport=connected" "web_input_status" + +echo +echo "Checking camera SeatGuide perception provider status..." +run_dimos mcp call camera_seat_provider_status + +echo +echo "Checking current SeatGuide scene..." +run_dimos mcp call seat_guide_status + +echo +echo "Checking speech output readiness..." +run_dimos mcp call speech_status + +echo +echo "Running no-motion readiness report..." +run_dimos mcp call seat_guide_readiness_report + +echo +echo "Running no-motion voice-intent preview..." +run_dimos mcp call preview_seat_request --json-args '{"text": "预检帮我找一个空位"}' + +echo +echo "Previewing selected goal without moving..." +run_dimos mcp call preview_empty_seat_goal + +echo +echo "Checking navigation completion status reader..." +run_dimos mcp call seat_guide_navigation_status + +echo +echo "SeatGuide no-motion smoke completed." diff --git a/bin/demo_seat_guide_verify_acceptance_log b/bin/demo_seat_guide_verify_acceptance_log new file mode 100755 index 0000000000..9a6b6d465b --- /dev/null +++ b/bin/demo_seat_guide_verify_acceptance_log @@ -0,0 +1,189 @@ +#!/usr/bin/env bash + +set -euo pipefail + +log_file="${1:-}" +if [[ -z "${log_file}" ]]; then + echo "Usage: $0 " >&2 + exit 2 +fi + +if [[ ! -f "${log_file}" ]]; then + echo "Acceptance log not found: ${log_file}" >&2 + exit 2 +fi + +require_log_contains() { + local expected="$1" + local label="$2" + if ! grep -Fq "${expected}" "${log_file}"; then + echo "Acceptance log missing ${label}: ${expected}" >&2 + exit 3 + fi +} + +require_log_matches() { + local expected_regex="$1" + local label="$2" + if ! grep -Eq "${expected_regex}" "${log_file}"; then + echo "Acceptance log missing ${label}: ${expected_regex}" >&2 + exit 3 + fi +} + +require_log_not_contains() { + local forbidden="$1" + local label="$2" + if grep -Fq "${forbidden}" "${log_file}"; then + echo "Acceptance log contains forbidden ${label}: ${forbidden}" >&2 + exit 3 + fi +} + +require_log_count_at_least() { + local expected="$1" + local minimum_count="$2" + local label="$3" + local count + count="$(grep -Fc "${expected}" "${log_file}" || true)" + if ((count < minimum_count)); then + echo "Acceptance log has only ${count}/${minimum_count} ${label}: ${expected}" >&2 + exit 3 + fi +} + +require_log_line_matches() { + local expected_regex="$1" + local label="$2" + if ! grep -Eq "${expected_regex}" "${log_file}"; then + echo "Acceptance log missing ${label}: ${expected_regex}" >&2 + exit 3 + fi +} + +require_log_line_count_at_least() { + local expected_regex="$1" + local minimum_count="$2" + local label="$3" + local count + count="$(grep -Ec "${expected_regex}" "${log_file}" || true)" + if ((count < minimum_count)); then + echo "Acceptance log has only ${count}/${minimum_count} ${label}: ${expected_regex}" >&2 + exit 3 + fi +} + +require_log_order() { + local before="$1" + local after="$2" + local label="$3" + local before_line + local after_line + before_line="$(grep -nF "${before}" "${log_file}" | head -n 1 | cut -d: -f1 || true)" + after_line="$(grep -nF "${after}" "${log_file}" | awk -F: -v before_line="${before_line}" '$1 > before_line { print $1; exit }' || true)" + if [[ -z "${before_line}" || -z "${after_line}" ]]; then + echo "Acceptance log has invalid ${label}: expected '${before}' before '${after}'" >&2 + exit 3 + fi +} + +require_log_order_after() { + local anchor="$1" + local before="$2" + local after="$3" + local label="$4" + local anchor_line + local before_line + local after_line + anchor_line="$(grep -nF "${anchor}" "${log_file}" | head -n 1 | cut -d: -f1 || true)" + before_line="$(grep -nF "${before}" "${log_file}" | awk -F: -v anchor_line="${anchor_line}" '$1 > anchor_line { print $1; exit }' || true)" + after_line="$(grep -nF "${after}" "${log_file}" | awk -F: -v before_line="${before_line}" '$1 > before_line { print $1; exit }' || true)" + if [[ -z "${anchor_line}" || -z "${before_line}" || -z "${after_line}" ]]; then + echo "Acceptance log has invalid ${label}: expected '${before}' before '${after}' after '${anchor}'" >&2 + exit 3 + fi +} + +require_log_contains "Hardware run registry:" "hardware run registry" +require_log_contains "Hardware run mode: hardware." "hardware run mode" +require_log_matches "Hardware blueprint: unitree-go2-seat-guide(-agentic)?" "SeatGuide hardware blueprint" +require_log_contains "web=started" "WebInput web server readiness" +require_log_contains "thread=running" "WebInput thread readiness" +require_log_contains "seat_route=seat_guide_direct" "WebInput direct SeatGuide route" +require_log_contains "responses=connected" "WebInput response stream" +require_log_contains "voice_upload=connected" "WebInput browser audio upload route" +require_log_contains "stt=connected" "WebInput speech-to-text pipeline" +require_log_contains "human_transport=connected" "WebInput human transport" +require_log_contains "Using WebInput URL: http" "resolved WebInput URL" +require_log_contains "CameraSeatObservationProvider" "camera perception module" +require_log_contains "SeatGuideSkillContainer" "SeatGuide planner/navigation module" +require_log_contains "WebInput" "voice command intake module" +require_log_contains "SpeakSkill" "speech feedback module" +require_log_matches "image=[0-9]+x[0-9]+" "camera image readiness" +require_log_contains "image_fresh=true" "fresh camera image readiness" +require_log_contains "credential=present" "VLM credential readiness" +require_log_contains "odom=(" "odometry readiness" +require_log_contains "odom_fresh=true" "fresh odometry readiness" +require_log_contains "override=inactive" "camera runtime override disabled" +require_log_contains "configured_fallback_seats=0" "camera fallback seats disabled" +require_log_contains "configured_fallback_people=0" "camera fallback people disabled" +require_log_contains "tts=ready" "TTS readiness" +require_log_contains "audio_output=connected" "audio output readiness" +require_log_contains "SeatGuide audio check. I can guide you to an empty seat." "TTS audio check phrase" +require_log_contains "Spoke: SeatGuide audio check" "TTS audio check completion" +require_log_contains "Audio output confirmation:" "operator TTS audio confirmation prompt" +require_log_contains "Operator audio confirmation: HEARD" "operator heard TTS confirmation" +require_log_contains "SeatGuide scene source=camera:" "live camera perception" +require_log_contains "SeatGuide preflight ready" "no-motion preflight" +require_log_contains "speaker=connected" "SeatGuide speaker wiring" +require_log_contains "SeatGuide preview source=camera:" "camera-backed goal preview" +require_log_matches "empty=[0-9]+ occupied=[0-9]+" "SeatGuide occupancy counts" +require_log_contains "Captured WebInput agent_responses stream" "typed WebInput stream" +require_log_contains "Manual no-motion voice gate:" "browser microphone no-motion gate" +require_log_count_at_least "Press Enter here when ready." 2 "browser microphone readiness prompts" +require_log_contains "Click the microphone button and say: 预检帮我找一个空位" "browser microphone no-motion spoken phrase" +require_log_contains "Captured WebInput voice agent_responses stream" "no-motion voice stream" +require_log_count_at_least "WebInput received text" 3 "WebInput recognized text events" +require_log_line_count_at_least "WebInput received text.*预检帮我找一个空位" 2 "recognized no-motion SeatGuide phrases" +require_log_line_matches 'WebInput received text.*(text=帮我找一个空位|"text"[[:space:]]*:[[:space:]]*"帮我找一个空位")' "recognized live SeatGuide phrase" +require_log_contains "WebInput routing text to SeatGuide preview" "no-motion WebInput SeatGuide route" +require_log_line_matches "WebInput routing text to SeatGuide preview.*预检帮我找一个空位" "no-motion WebInput SeatGuide phrase route" +require_log_contains "Capturing DimOS log snapshot after no-motion checks" "no-motion DimOS log snapshot" +require_log_contains "No-motion checks completed." "no-motion completion marker" +require_log_order "Captured WebInput agent_responses stream" "Manual no-motion voice gate:" "typed preview before no-motion voice gate order" +require_log_order "Manual no-motion voice gate:" "Press Enter here when ready." "no-motion voice gate before readiness prompt order" +require_log_order_after "Manual no-motion voice gate:" "Press Enter here when ready." "Click the microphone button and say: 预检帮我找一个空位" "no-motion readiness before speech order" +require_log_order "Manual no-motion voice gate:" "Captured WebInput voice agent_responses stream" "no-motion voice gate before voice stream order" +require_log_order "Spoke: SeatGuide audio check" "Operator audio confirmation: HEARD" "TTS completion before operator audio confirmation order" +require_log_order "Operator audio confirmation: HEARD" "SeatGuide scene source=camera:" "operator audio confirmation before perception order" +require_log_order "WebInput routing text to SeatGuide preview" "Capturing DimOS log snapshot after no-motion checks" "no-motion preview before log snapshot order" +require_log_order "Capturing DimOS log snapshot after no-motion checks" "No-motion checks completed." "no-motion snapshot before completion order" +require_log_contains "Operator confirmation: LIVE" "operator live confirmation" +require_log_order "No-motion checks completed." "Operator confirmation: LIVE" "no-motion before live order" +require_log_contains "Live voice navigation gate:" "browser microphone live gate" +require_log_contains "Say: 帮我找一个空位" "browser microphone live spoken phrase" +require_log_contains "Captured live WebInput voice agent_responses stream" "live voice stream" +require_log_contains "WebInput routing text to SeatGuide live request" "live WebInput SeatGuide route" +require_log_line_matches 'WebInput routing text to SeatGuide live request.*(text=帮我找一个空位|"text"[[:space:]]*:[[:space:]]*"帮我找一个空位")' "live WebInput SeatGuide phrase route" +require_log_contains "Navigating to" "live SeatGuide navigation start" +require_log_contains "goal_sequence=" "SeatGuide goal sequence" +require_log_contains "Checking SeatGuide navigation completion" "navigation completion polling" +require_log_contains "goal_reached=true" "navigation completion" +require_log_contains "SeatGuide navigation goal reached" "acceptance completion marker" +require_log_contains "Capturing DimOS log snapshot after live request" "live DimOS log snapshot" +require_log_order "Operator confirmation: LIVE" "Live voice navigation gate:" "LIVE before browser microphone live gate order" +require_log_order "Live voice navigation gate:" "Press Enter here when ready." "live voice gate before readiness prompt order" +require_log_order_after "Live voice navigation gate:" "Press Enter here when ready." "Say: 帮我找一个空位" "live readiness before speech order" +require_log_order "Live voice navigation gate:" "WebInput routing text to SeatGuide live request" "live voice gate before live route order" +require_log_order "WebInput routing text to SeatGuide live request" "Navigating to" "live route before navigation order" +require_log_order "Navigating to" "goal_reached=true" "navigation before completion order" +require_log_order "Checking SeatGuide navigation completion" "goal_reached=true" "polling before completion order" +require_log_order "goal_reached=true" "SeatGuide navigation goal reached" "goal reached before completion marker order" +require_log_not_contains "+ dimos mcp call handle_seat_request" "direct MCP live SeatGuide call" +require_log_not_contains "+ dimos mcp call set_seat_scene" "fallback seat scene calibration" +require_log_not_contains "+ dimos mcp call clear_seat_scene_override" "fallback seat scene override clearing" +require_log_not_contains "require_live_perception=false" "fallback live-perception bypass" +require_log_not_contains '"require_live_perception": false' "fallback live-perception bypass" +require_log_not_contains '"require_live_perception":false' "fallback live-perception bypass" + +echo "SeatGuide acceptance log contains all required evidence: ${log_file}" diff --git a/dimos/agents/mcp/mcp_client.py b/dimos/agents/mcp/mcp_client.py index 75b532e9cc..95338c6fb0 100644 --- a/dimos/agents/mcp/mcp_client.py +++ b/dimos/agents/mcp/mcp_client.py @@ -13,6 +13,7 @@ # limitations under the License. from collections.abc import Callable +import os from queue import Empty, Queue from threading import Event, RLock, Thread import time @@ -41,6 +42,12 @@ logger = setup_logger() +def _requires_openai_api_key(model: Any) -> bool: + if not isinstance(model, str): + return False + return model.startswith("gpt-") or model.startswith("openai:") + + class McpClientConfig(ModuleConfig): system_prompt: str | None = SYSTEM_PROMPT model: str = "gpt-4o" @@ -217,6 +224,13 @@ def on_system_modules(self, _modules: list[RPCClient]) -> None: from dimos.agents.testing import MockModel model = MockModel(json_path=self.config.model_fixture) + elif _requires_openai_api_key(model) and not os.getenv("OPENAI_API_KEY"): + logger.warning( + "McpClient agent disabled because OPENAI_API_KEY is not set", + model=model, + n_tools=len(tools), + ) + return with self._lock: self._state_graph = create_agent( diff --git a/dimos/agents/mcp/mcp_server.py b/dimos/agents/mcp/mcp_server.py index dbd31f8d87..d0d626197f 100644 --- a/dimos/agents/mcp/mcp_server.py +++ b/dimos/agents/mcp/mcp_server.py @@ -19,7 +19,7 @@ import json import os import time -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING, Any, ClassVar from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware @@ -94,6 +94,13 @@ def _handle_tools_list(req_id: Any, skills: list[SkillInfo]) -> dict[str, Any]: return _jsonrpc_result(req_id, {"tools": tools}) +def _module_class_has_skills(module: RPCClient) -> bool: + return any( + callable(attr) and hasattr(attr, "__skill__") + for attr in (getattr(module.actor_class, name, None) for name in dir(module.actor_class)) + ) + + async def _handle_tools_call( req_id: Any, params: dict[str, Any], rpc_calls: dict[str, Any] ) -> dict[str, Any]: @@ -243,6 +250,8 @@ async def event_generator() -> AsyncGenerator[str, None]: class McpServer(Module): + dedicated_worker: ClassVar[bool] = True + _uvicorn_server: uvicorn.Server | None = None _serve_future: concurrent.futures.Future[None] | None = None _tool_stream_cleanup: Callable[[], None] | None = None @@ -279,15 +288,26 @@ def stop(self) -> None: def on_system_modules(self, modules: list[RPCClient]) -> None: # TODO: this is a bit hacky, also not thread-safe assert self.rpc is not None - app.state.skills = [ - skill_info for module in modules for skill_info in (module.get_skills() or []) - ] - app.state.rpc_calls = { - skill_info.func_name: RpcCall( - None, self.rpc, skill_info.func_name, skill_info.class_name, [] - ) - for skill_info in app.state.skills - } + skills: list[SkillInfo] = [] + rpc_calls: dict[str, Any] = {} + + for module in modules: + if module.remote_name == self.__class__.__name__: + module_skills = self.get_skills() + for skill_info in module_skills: + rpc_calls[skill_info.func_name] = getattr(self, skill_info.func_name) + else: + if not _module_class_has_skills(module): + continue + module_skills = module.get_skills() or [] + for skill_info in module_skills: + rpc_calls[skill_info.func_name] = RpcCall( + None, self.rpc, skill_info.func_name, skill_info.class_name, [] + ) + skills.extend(module_skills) + + app.state.skills = skills + app.state.rpc_calls = rpc_calls @skill def server_status(self) -> str: diff --git a/dimos/agents/mcp/test_mcp_client.py b/dimos/agents/mcp/test_mcp_client.py index 25d7e40add..2560153ee3 100644 --- a/dimos/agents/mcp/test_mcp_client.py +++ b/dimos/agents/mcp/test_mcp_client.py @@ -18,6 +18,7 @@ import pytest from dimos.agents.annotation import skill +from dimos.agents.mcp.mcp_client import _requires_openai_api_key from dimos.core.module import Module from dimos.msgs.sensor_msgs.Image import Image from dimos.utils.data import get_data @@ -199,3 +200,9 @@ def test_image(agent_setup): assert "cafe" in response assert "stadium" not in response assert "battleship" not in response + + +def test_requires_openai_api_key_for_gpt_models() -> None: + assert _requires_openai_api_key("gpt-4o") + assert _requires_openai_api_key("openai:gpt-4o") + assert not _requires_openai_api_key("ollama:llama3") diff --git a/dimos/agents/mcp/test_mcp_server.py b/dimos/agents/mcp/test_mcp_server.py index fd514b0643..973f5d467a 100644 --- a/dimos/agents/mcp/test_mcp_server.py +++ b/dimos/agents/mcp/test_mcp_server.py @@ -18,7 +18,7 @@ import json from unittest.mock import MagicMock -from dimos.agents.mcp.mcp_server import handle_request +from dimos.agents.mcp.mcp_server import McpServer, app, handle_request from dimos.core.module import SkillInfo @@ -68,6 +68,51 @@ def test_mcp_module_request_flow() -> None: rpc_calls["add"].assert_called_once_with(x=2, y=3) +def test_mcp_server_registers_own_skills_without_self_rpc(monkeypatch) -> None: + class OtherSkills: + def other(self) -> str: + """Other skill.""" + return "ok" + + OtherSkills.other.__skill__ = True # type: ignore[attr-defined] + + schema = json.dumps({"type": "object", "properties": {}}) + server_skill = SkillInfo( + class_name="McpServer", + func_name="server_status", + args_schema=schema, + ) + remote_skill = SkillInfo( + class_name="OtherSkills", + func_name="other", + args_schema=schema, + ) + + server = McpServer.__new__(McpServer) + server.rpc = MagicMock() + monkeypatch.setattr(server, "get_skills", MagicMock(return_value=[server_skill])) + + server_proxy = MagicMock() + server_proxy.remote_name = "McpServer" + server_proxy.get_skills.side_effect = AssertionError("self get_skills must be local") + + remote_proxy = MagicMock() + remote_proxy.remote_name = "OtherSkills" + remote_proxy.actor_class = OtherSkills + remote_proxy.get_skills.return_value = [remote_skill] + + try: + server.on_system_modules([server_proxy, remote_proxy]) + + assert {skill.func_name for skill in app.state.skills} == {"server_status", "other"} + server_proxy.get_skills.assert_not_called() + assert app.state.rpc_calls["server_status"] == server.server_status + assert app.state.rpc_calls["other"]._remote_name == "OtherSkills" + finally: + app.state.skills = [] + app.state.rpc_calls = {} + + def test_mcp_module_injects_progress_token_as_mcp_context() -> None: """When the client sends `_meta.progressToken`, the RPC call receives it as an `_mcp_context` kwarg so the `@skill` wrapper can stash it in the diff --git a/dimos/agents/skills/seat_guide.py b/dimos/agents/skills/seat_guide.py new file mode 100644 index 0000000000..17e39540e5 --- /dev/null +++ b/dimos/agents/skills/seat_guide.py @@ -0,0 +1,1046 @@ +# Copyright 2026 Dimensional Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from __future__ import annotations + +from dataclasses import dataclass +import math +import os +from threading import RLock +import time +from typing import Protocol + +from pydantic import Field +from reactivex.disposable import Disposable + +from dimos.agents.annotation import skill +from dimos.agents.skills.speak_skill_spec import SpeakSkillSpec +from dimos.core.core import rpc +from dimos.core.module import Module, ModuleConfig +from dimos.core.stream import In +from dimos.models.vl.base import VlModel +from dimos.models.vl.types import VlModelName +from dimos.msgs.geometry_msgs.PoseStamped import PoseStamped +from dimos.msgs.geometry_msgs.Quaternion import Quaternion +from dimos.msgs.geometry_msgs.Vector3 import Vector3 +from dimos.msgs.sensor_msgs.Image import Image +from dimos.navigation.navigation_spec import NavigationInterfaceSpec +from dimos.spec.utils import Spec +from dimos.utils.logging_config import setup_logger + +logger = setup_logger() + + +@dataclass(frozen=True) +class SeatObservation: + """A candidate chair pose in the room map frame.""" + + seat_id: str + x: float + y: float + yaw: float = 0.0 + + +@dataclass(frozen=True) +class PersonObservation: + """A detected person position in the room map frame.""" + + x: float + y: float + + +@dataclass(frozen=True) +class SeatGuideResult: + seat: SeatObservation + goal_x: float + goal_y: float + goal_yaw: float + spoken_summary: str + + +@dataclass(frozen=True) +class SeatSceneObservation: + seats: list[SeatObservation] + people: list[PersonObservation] + robot_x: float = 0.0 + robot_y: float = 0.0 + source: str = "unknown" + + +@dataclass(frozen=True) +class SeatGuideIntent: + should_find_seat: bool + normalized_text: str + + +class SeatObservationProviderSpec(Spec, Protocol): + def get_seat_scene(self) -> SeatSceneObservation: ... + + +class SeatGuideRequestSpec(Spec, Protocol): + def handle_seat_request(self, text: str) -> str: ... + def preview_seat_request(self, text: str) -> str: ... + + +class SeatGuidePlanner: + """Selects an empty conference room seat and computes the robot guide pose.""" + + def __init__( + self, + *, + occupied_radius_m: float = 0.75, + aisle_offset_m: float = 0.65, + ) -> None: + if occupied_radius_m <= 0: + raise ValueError("occupied_radius_m must be positive.") + if aisle_offset_m < 0: + raise ValueError("aisle_offset_m cannot be negative.") + self.occupied_radius_m = occupied_radius_m + self.aisle_offset_m = aisle_offset_m + + def find_empty_seat( + self, + seats: list[SeatObservation], + people: list[PersonObservation], + robot_x: float = 0.0, + robot_y: float = 0.0, + ) -> SeatGuideResult | None: + empty_seats = [seat for seat in seats if not self._is_occupied(seat, people)] + if not empty_seats: + return None + + selected = min( + empty_seats, + key=lambda seat: math.hypot(seat.x - robot_x, seat.y - robot_y), + ) + goal_x, goal_y = self._guide_pose_for(selected) + return SeatGuideResult( + seat=selected, + goal_x=goal_x, + goal_y=goal_y, + goal_yaw=selected.yaw, + spoken_summary=( + f"I found an empty seat {selected.seat_id}. " + "Please follow me to the chair beside the table." + ), + ) + + def _is_occupied(self, seat: SeatObservation, people: list[PersonObservation]) -> bool: + return any( + math.hypot(person.x - seat.x, person.y - seat.y) <= self.occupied_radius_m + for person in people + ) + + def occupancy_counts( + self, seats: list[SeatObservation], people: list[PersonObservation] + ) -> tuple[int, int]: + occupied = sum(1 for seat in seats if self._is_occupied(seat, people)) + return len(seats) - occupied, occupied + + def _guide_pose_for(self, seat: SeatObservation) -> tuple[float, float]: + offset_x = math.cos(seat.yaw) * self.aisle_offset_m + offset_y = math.sin(seat.yaw) * self.aisle_offset_m + return seat.x + offset_x, seat.y + offset_y + + +class SeatGuideSkillContainer(Module): + """Skill container for finding and guiding to an empty conference room seat.""" + + _navigation: NavigationInterfaceSpec + _seat_observation_provider: SeatObservationProviderSpec | None = None + _speaker: SpeakSkillSpec | None = None + _seat_guide_goal_sequence: int = 0 + _seat_guide_goal_reached_reset_required: bool = False + + @rpc + def start(self) -> None: + super().start() + + @rpc + def stop(self) -> None: + super().stop() + + @skill + def find_empty_seat( + self, + seats: list[float], + people: list[float], + robot_x: float = 0.0, + robot_y: float = 0.0, + ) -> str: + """Find an empty chair in a conference room and navigate next to it. + + This is the demo-critical SeatGuide skill for a controlled conference room. + Provide chair detections as a flat list of [x, y, yaw] triples in the map frame. + Provide person detections as a flat list of [x, y] pairs in the map frame. + A chair is considered occupied when a person is within 0.75 meters. + + Args: + seats: Flat chair pose list [x, y, yaw, x, y, yaw, ...]. + people: Flat person position list [x, y, x, y, ...]. + robot_x: Robot x position used to choose the nearest empty seat. + robot_y: Robot y position used to choose the nearest empty seat. + """ + seat_observations = _parse_seats(seats) + person_observations = _parse_people(people) + if not seat_observations: + message = ( + "I cannot see any seats yet. Please face the conference table or calibrate " + "the room layout." + ) + self._speak_feedback(message) + return message + + planner = SeatGuidePlanner() + result = planner.find_empty_seat( + seat_observations, + person_observations, + robot_x=robot_x, + robot_y=robot_y, + ) + if result is None: + message = "I could not find an empty seat in the conference room." + self._speak_feedback(message) + return message + + goal = PoseStamped( + frame_id="map", + position=Vector3(result.goal_x, result.goal_y, 0.0), + orientation=Quaternion.from_euler(Vector3(0.0, 0.0, result.goal_yaw)), + ) + navigation_text, navigation_ok = self._navigation_readiness_text() + if not navigation_ok: + message = ( + f"Found empty seat {result.seat.seat_id}, but navigation is not ready " + f"for a new goal: {navigation_text}." + ) + self._speak_feedback(message) + return message + + previous_goal_reached = self._navigation_goal_reached_or_false() + try: + goal_started = self._navigation.set_goal(goal) + except Exception as exc: + message = ( + f"Found empty seat {result.seat.seat_id}, but navigation raised an error: " + f"{exc}." + ) + self._speak_feedback(message) + return message + + if not goal_started: + message = f"Found empty seat {result.seat.seat_id}, but failed to start navigation." + self._speak_feedback(message) + return message + + self._seat_guide_goal_sequence = getattr(self, "_seat_guide_goal_sequence", 0) + 1 + self._seat_guide_goal_reached_reset_required = previous_goal_reached + self._speak_feedback(result.spoken_summary) + return f"{result.spoken_summary} Navigating to ({result.goal_x:.2f}, {result.goal_y:.2f})." + + @skill + def find_empty_seat_from_scene(self, require_live_perception: bool = True) -> str: + """Find an empty chair using the configured conference room observation provider. + + Use this for the SeatGuide demo when perception or a synthetic room provider + is already running as a module. The provider returns chair poses, person + positions, and robot position in the map frame. + + Args: + require_live_perception: When true, only a camera-backed scene can + trigger navigation. Set false only for explicit fallback calibration. + """ + if self._seat_observation_provider is None: + message = "No seat observation provider is connected." + self._speak_feedback(message) + return message + + scene = self._seat_observation_provider.get_seat_scene() + if require_live_perception and scene.source != "camera": + message = _describe_live_perception_required(scene) + self._speak_feedback(message) + return message + seats = _flatten_seats(scene.seats) + people = _flatten_people(scene.people) + return self.find_empty_seat( + seats=seats, + people=people, + robot_x=scene.robot_x, + robot_y=scene.robot_y, + ) + + @skill + def preview_empty_seat_goal(self) -> str: + """Preview the selected empty seat and navigation goal without moving. + + Use this during real Go2 bring-up after `seat_guide_status` and before + `handle_seat_request` to verify the selected chair and map-frame goal. + This never calls navigation. + """ + if self._seat_observation_provider is None: + return "No seat observation provider is connected." + + scene = self._seat_observation_provider.get_seat_scene() + return _describe_goal_preview(scene) + + @skill + def handle_seat_request(self, text: str, require_live_perception: bool = True) -> str: + """Handle a spoken or typed request to find an empty conference room seat. + + This is the Go2-free voice intake boundary for the SeatGuide demo. Pass + speech-to-text output or typed text here. If the text asks for an empty + seat, this delegates to the configured scene provider and navigation. + + Args: + text: Transcribed or typed user request. + require_live_perception: When true, only a camera-backed scene can + trigger navigation. Set false only for explicit fallback calibration. + """ + intent = parse_seat_guide_intent(text) + if not intent.should_find_seat: + message = "I did not hear a request to find an empty seat." + self._speak_feedback(message) + return message + + return self.find_empty_seat_from_scene( + require_live_perception=require_live_perception + ) + + @skill + def preview_seat_request(self, text: str) -> str: + """Preview a spoken or typed SeatGuide request without moving. + + Use this to validate the real microphone or typed WebInput path during + bring-up. If the text asks for an empty seat, this runs the same + no-motion preflight used before live hardware navigation. + + Args: + text: Transcribed or typed user request. + """ + intent = parse_seat_guide_intent(text) + if not intent.should_find_seat: + message = "I did not hear a request to find an empty seat." + self._speak_feedback(message) + return message + + message = self.seat_guide_preflight() + self._speak_feedback(message) + return message + + @skill + def seat_guide_preflight(self, require_live_perception: bool = True) -> str: + """Run a no-motion SeatGuide hardware preflight before sending a goal. + + Use this on the real Go2 before asking a person to follow the robot. It + checks navigation reachability at the interface level, the current seat + scene source, whether an empty seat can be selected, and whether speech + feedback is connected. This never calls navigation. + + Args: + require_live_perception: When true, only a camera-backed scene can + pass preflight. Set false only for explicit fallback calibration. + """ + if self._seat_observation_provider is None: + return ( + "SeatGuide preflight no-go: " + f"{self._navigation_readiness_text()[0]}; perception=missing; " + f"{self._speaker_readiness_text()}." + ) + + scene = self._seat_observation_provider.get_seat_scene() + return self._describe_preflight(scene, require_live_perception=require_live_perception) + + @skill + def seat_guide_status(self) -> str: + """Describe the current SeatGuide scene provider state without navigating. + + Use this during bring-up to confirm whether SeatGuide can see chairs and + people before asking the robot to guide a user to an empty seat. + """ + if self._seat_observation_provider is None: + return "SeatGuide status: no seat observation provider is connected." + + scene = self._seat_observation_provider.get_seat_scene() + return _describe_scene(scene) + + @skill + def seat_guide_readiness_report(self, require_live_perception: bool = True) -> str: + """Run all no-motion SeatGuide readiness checks in one report. + + Use this as the first hardware bring-up command. It combines scene + status, live-perception preflight, and selected-goal preview without + calling navigation. + + Args: + require_live_perception: When true, preflight only passes a + camera-backed scene. Set false only for explicit fallback calibration. + """ + if self._seat_observation_provider is None: + return "SeatGuide readiness report: no seat observation provider is connected." + + scene = self._seat_observation_provider.get_seat_scene() + status = _describe_scene(scene) + preflight = self._describe_preflight( + scene, require_live_perception=require_live_perception + ) + preview = _describe_goal_preview(scene) + return f"SeatGuide readiness report: {status} | {preflight} | {preview}" + + @skill + def seat_guide_navigation_status(self) -> str: + """Report whether the current SeatGuide navigation goal has completed. + + Use this after a live SeatGuide request to verify the robot did more + than accept a goal. It reads the navigation interface state and + `is_goal_reached()` without sending or canceling any goal. + """ + goal_sequence = getattr(self, "_seat_guide_goal_sequence", 0) + if not hasattr(self, "_navigation") or self._navigation is None: + return ( + "SeatGuide navigation status: navigation=missing; " + f"goal_reached=unknown; goal_sequence={goal_sequence}." + ) + try: + state = self._navigation.get_state() + raw_goal_reached = self._navigation.is_goal_reached() + except Exception as exc: + return ( + f"SeatGuide navigation status: navigation=error({exc}); " + f"goal_reached=unknown; goal_sequence={goal_sequence}." + ) + + reset_suffix = "" + goal_reached = raw_goal_reached + if ( + goal_sequence > 0 + and getattr(self, "_seat_guide_goal_reached_reset_required", False) + ): + if raw_goal_reached: + goal_reached = False + reset_suffix = "; completion_reset=waiting_for_false" + else: + self._seat_guide_goal_reached_reset_required = False + + return ( + f"SeatGuide navigation status: navigation={state.name}; " + f"goal_reached={'true' if goal_reached else 'false'}; " + f"goal_sequence={goal_sequence}{reset_suffix}." + ) + + def _navigation_goal_reached_or_false(self) -> bool: + try: + return self._navigation.is_goal_reached() + except Exception: + return False + + def _speak_feedback(self, text: str) -> None: + if self._speaker is None: + return + try: + self._speaker.speak(text, blocking=False) + except Exception: + logger.warning("SeatGuide speech feedback failed", exc_info=True) + + def _navigation_readiness_text(self) -> tuple[str, bool]: + if not hasattr(self, "_navigation") or self._navigation is None: + return "navigation=missing", False + try: + state = self._navigation.get_state() + return f"navigation={state.name}", state.name == "IDLE" + except Exception as exc: + return f"navigation=error({exc})", False + + def _speaker_readiness_text(self) -> str: + return "speaker=connected" if self._speaker is not None else "speaker=missing" + + def _describe_preflight( + self, + scene: SeatSceneObservation, + *, + require_live_perception: bool, + ) -> str: + navigation_text, navigation_ok = self._navigation_readiness_text() + speaker_text = self._speaker_readiness_text() + + if not scene.seats: + return ( + "SeatGuide preflight no-go: " + f"{navigation_text}; perception={scene.source} no seats; {speaker_text}." + ) + if require_live_perception and scene.source != "camera": + return ( + "SeatGuide preflight no-go: " + f"{navigation_text}; perception={scene.source} is not live camera; " + f"seats={len(scene.seats)} people={len(scene.people)}; {speaker_text}." + ) + + planner = SeatGuidePlanner() + empty_count, occupied_count = planner.occupancy_counts(scene.seats, scene.people) + result = planner.find_empty_seat( + scene.seats, + scene.people, + robot_x=scene.robot_x, + robot_y=scene.robot_y, + ) + if result is None: + return ( + "SeatGuide preflight no-go: " + f"{navigation_text}; perception={scene.source}; no empty seat; " + f"empty={empty_count} occupied={occupied_count}; {speaker_text}." + ) + + verdict = "ready" if navigation_ok else "no-go" + return ( + f"SeatGuide preflight {verdict}: {navigation_text}; " + f"perception={scene.source} seats={len(scene.seats)} people={len(scene.people)}; " + f"empty={empty_count} occupied={occupied_count}; " + f"selected={result.seat.seat_id}; " + f"goal=({result.goal_x:.2f}, {result.goal_y:.2f}, yaw={result.goal_yaw:.2f}); " + f"{speaker_text}." + ) + + +class SyntheticSeatSceneConfig(ModuleConfig): + seats: list[float] = Field( + default_factory=lambda: [0.0, -1.0, 0.0, 1.5, -1.0, 0.0, 3.0, -1.0, 0.0] + ) + people: list[float] = Field(default_factory=lambda: [0.1, -1.0, 1.6, -1.0]) + robot_x: float = -1.0 + robot_y: float = -1.0 + + +class SyntheticSeatObservationProvider(Module): + """Go2-free conference room observation provider for tests and demos.""" + + config: SyntheticSeatSceneConfig + _scene_override: SeatSceneObservation | None = None + _scene_lock: RLock = RLock() + + @rpc + def start(self) -> None: + super().start() + + @rpc + def stop(self) -> None: + super().stop() + + @rpc + def get_seat_scene(self) -> SeatSceneObservation: + with self._scene_lock: + if self._scene_override is not None: + return self._scene_override + + return _scene_from_flat_config(self.config) + + @skill + def set_seat_scene( + self, + seats: list[float], + people: list[float], + robot_x: float = 0.0, + robot_y: float = 0.0, + ) -> str: + """Configure the synthetic conference room scene at runtime. + + Use this during Go2 bring-up to align the fallback scene with the real + chair layout before calling `handle_seat_request`. Chair poses are flat + [x, y, yaw] triples in the map frame. Person positions are flat [x, y] + pairs in the map frame. + + Args: + seats: Flat chair pose list [x, y, yaw, x, y, yaw, ...]. + people: Flat person position list [x, y, x, y, ...]. + robot_x: Robot x position in the map frame. + robot_y: Robot y position in the map frame. + """ + scene = SeatSceneObservation( + seats=_parse_seats(seats), + people=_parse_people(people), + robot_x=robot_x, + robot_y=robot_y, + source="runtime_override", + ) + with self._scene_lock: + self._scene_override = scene + + people_word = "person" if len(scene.people) == 1 else "people" + return f"Configured {len(scene.seats)} seats and {len(scene.people)} {people_word}." + + @skill + def clear_seat_scene_override(self) -> str: + """Clear the runtime synthetic scene and return to configured defaults.""" + with self._scene_lock: + self._scene_override = None + return "Cleared synthetic seat scene override." + + +class CameraSeatSceneConfig(SyntheticSeatSceneConfig): + seats: list[float] = Field(default_factory=list) + people: list[float] = Field(default_factory=list) + detection_model: VlModelName = "qwen" + chair_distance_m: float = 2.0 + lateral_span_m: float = 3.0 + max_input_age_s: float = 5.0 + + +class CameraSeatObservationProvider(Module): + """Camera-backed conference room observation provider for SeatGuide.""" + + config: CameraSeatSceneConfig + color_image: In[Image] + odom: In[PoseStamped] + + _latest_image: Image | None = None + _latest_odom: PoseStamped | None = None + _vl_model: VlModel | None = None + _scene_override: SeatSceneObservation | None = None + _scene_lock: RLock = RLock() + + @rpc + def start(self) -> None: + super().start() + self.register_disposable(Disposable(self.color_image.subscribe(self._on_color_image))) + self.register_disposable(Disposable(self.odom.subscribe(self._on_odom))) + + @rpc + def stop(self) -> None: + super().stop() + + def _on_color_image(self, image: Image) -> None: + with self._scene_lock: + self._latest_image = image + + def _on_odom(self, odom: PoseStamped) -> None: + with self._scene_lock: + self._latest_odom = odom + + @rpc + def get_seat_scene(self) -> SeatSceneObservation: + with self._scene_lock: + if self._scene_override is not None: + return self._scene_override + latest_image = self._latest_image + latest_odom = self._latest_odom + + if latest_image is None: + return _scene_from_flat_config(self.config, source="no_camera_image") + if latest_odom is None: + return _scene_from_flat_config(self.config, source="camera_no_odom") + if _message_age_s(latest_image.ts) > self.config.max_input_age_s: + return _scene_from_flat_config(self.config, source="stale_camera_image") + if _message_age_s(latest_odom.ts) > self.config.max_input_age_s: + return _scene_from_flat_config(self.config, source="stale_camera_odom") + + try: + detected_scene = self._detect_scene_from_image(latest_image, latest_odom) + except Exception: + logger.warning( + "Failed to detect conference room seats from camera image", exc_info=True + ) + return _scene_from_flat_config(self.config, source="camera_detection_error") + + if not detected_scene.seats: + return _scene_from_flat_config(self.config, source="camera_no_seats_detected") + + return detected_scene + + @skill + def set_seat_scene( + self, + seats: list[float], + people: list[float], + robot_x: float = 0.0, + robot_y: float = 0.0, + ) -> str: + """Configure the fallback conference room scene at runtime. + + Use this during Go2 bring-up when visual detection is unavailable or + unreliable. Chair poses are flat [x, y, yaw] triples in the map frame. + Person positions are flat [x, y] pairs in the map frame. + + Args: + seats: Flat chair pose list [x, y, yaw, x, y, yaw, ...]. + people: Flat person position list [x, y, x, y, ...]. + robot_x: Robot x position in the map frame. + robot_y: Robot y position in the map frame. + """ + scene = SeatSceneObservation( + seats=_parse_seats(seats), + people=_parse_people(people), + robot_x=robot_x, + robot_y=robot_y, + source="runtime_override", + ) + with self._scene_lock: + self._scene_override = scene + + people_word = "person" if len(scene.people) == 1 else "people" + return f"Configured {len(scene.seats)} seats and {len(scene.people)} {people_word}." + + @skill + def clear_seat_scene_override(self) -> str: + """Clear the runtime fallback scene and return to camera detection/defaults.""" + with self._scene_lock: + self._scene_override = None + return "Cleared camera seat scene override." + + @skill + def camera_seat_provider_status(self) -> str: + """Report camera-backed SeatGuide perception readiness without running detection. + + Use this during Go2 bring-up before `seat_guide_status` to check whether + camera frames and odometry are arriving, whether the VLM credential path + is configured, and whether a runtime fallback override is active. + """ + with self._scene_lock: + override_active = self._scene_override is not None + latest_image = self._latest_image + latest_odom = self._latest_odom + image_text = ( + f"image={latest_image.width}x{latest_image.height}" + if latest_image is not None + else "image=missing" + ) + odom_text = ( + f"odom=({latest_odom.x:.2f}, {latest_odom.y:.2f}, " + f"yaw={latest_odom.yaw:.2f})" + if latest_odom is not None + else "odom=missing" + ) + image_fresh_text = _freshness_text( + latest_image.ts if latest_image is not None else None, + self.config.max_input_age_s, + ) + odom_fresh_text = _freshness_text( + latest_odom.ts if latest_odom is not None else None, + self.config.max_input_age_s, + ) + credential_text = ( + "credential=present" + if self.config.detection_model != "qwen" or os.getenv("ALIBABA_API_KEY") + else "credential=missing" + ) + return ( + "CameraSeatObservationProvider status: " + f"{image_text}; image_fresh={image_fresh_text}; " + f"{odom_text}; odom_fresh={odom_fresh_text}; " + f"detection_model={self.config.detection_model}; " + f"{credential_text}; override={'active' if override_active else 'inactive'}; " + f"configured_fallback_seats={len(_parse_seats(self.config.seats))}; " + f"configured_fallback_people={len(_parse_people(self.config.people))}." + ) + + def _detect_scene_from_image( + self, image: Image, odom: PoseStamped | None = None + ) -> SeatSceneObservation: + vl_model = self._get_vl_model() + chair_detections = vl_model.query_detections(image, "chair").detections + person_detections = vl_model.query_detections(image, "person").detections + robot_x, robot_y, robot_yaw = self._robot_pose_for_detection(odom) + seats: list[SeatObservation] = [] + people: list[PersonObservation] = [] + + for detection in chair_detections: + seats.append( + _bbox_to_seat_observation( + seat_id=f"seat_{len(seats) + 1}", + bbox=detection.bbox, + image_width=image.width, + robot_x=robot_x, + robot_y=robot_y, + robot_yaw=robot_yaw, + distance_m=self.config.chair_distance_m, + lateral_span_m=self.config.lateral_span_m, + ) + ) + for detection in person_detections: + people.append( + _bbox_to_person_observation( + bbox=detection.bbox, + image_width=image.width, + robot_x=robot_x, + robot_y=robot_y, + robot_yaw=robot_yaw, + distance_m=self.config.chair_distance_m, + lateral_span_m=self.config.lateral_span_m, + ) + ) + + return SeatSceneObservation( + seats=seats, + people=people, + robot_x=robot_x, + robot_y=robot_y, + source="camera", + ) + + def _robot_pose_for_detection( + self, odom: PoseStamped | None = None + ) -> tuple[float, float, float]: + if odom is None: + return self.config.robot_x, self.config.robot_y, 0.0 + return odom.x, odom.y, odom.yaw + + def _get_vl_model(self) -> VlModel: + if self._vl_model is not None: + return self._vl_model + if self.config.detection_model == "qwen" and not os.getenv("ALIBABA_API_KEY"): + raise ValueError( + "CameraSeatObservationProvider detection_model=qwen requires ALIBABA_API_KEY" + ) + + from dimos.models.vl.create import create + + self._vl_model = create(self.config.detection_model) + return self._vl_model + + +def _parse_seats(values: list[float]) -> list[SeatObservation]: + if len(values) % 3 != 0: + raise ValueError("seats must be a flat list of [x, y, yaw] triples.") + return [ + SeatObservation( + seat_id=f"seat_{index + 1}", + x=float(values[offset]), + y=float(values[offset + 1]), + yaw=float(values[offset + 2]), + ) + for index, offset in enumerate(range(0, len(values), 3)) + ] + + +def _parse_people(values: list[float]) -> list[PersonObservation]: + if len(values) % 2 != 0: + raise ValueError("people must be a flat list of [x, y] pairs.") + return [ + PersonObservation(x=float(values[offset]), y=float(values[offset + 1])) + for offset in range(0, len(values), 2) + ] + + +def _flatten_seats(seats: list[SeatObservation]) -> list[float]: + values: list[float] = [] + for seat in seats: + values.extend([seat.x, seat.y, seat.yaw]) + return values + + +def _flatten_people(people: list[PersonObservation]) -> list[float]: + values: list[float] = [] + for person in people: + values.extend([person.x, person.y]) + return values + + +def _scene_from_flat_config( + config: SyntheticSeatSceneConfig, + *, + source: str = "configured_fallback", +) -> SeatSceneObservation: + return SeatSceneObservation( + seats=_parse_seats(config.seats), + people=_parse_people(config.people), + robot_x=config.robot_x, + robot_y=config.robot_y, + source=source, + ) + + +def _describe_scene(scene: SeatSceneObservation) -> str: + if not scene.seats: + return ( + f"SeatGuide scene source={scene.source}: no seats visible or configured; " + f"{len(scene.people)} people detected." + ) + seats = ", ".join( + f"{seat.seat_id}=({seat.x:.2f}, {seat.y:.2f}, yaw={seat.yaw:.2f})" + for seat in scene.seats + ) + people = ", ".join(f"({person.x:.2f}, {person.y:.2f})" for person in scene.people) + people_text = people if people else "none" + return ( + f"SeatGuide scene source={scene.source}: {len(scene.seats)} seats [{seats}], " + f"{len(scene.people)} people [{people_text}], " + f"robot=({scene.robot_x:.2f}, {scene.robot_y:.2f})." + ) + + +def _describe_goal_preview(scene: SeatSceneObservation) -> str: + if not scene.seats: + return f"SeatGuide preview source={scene.source}: no seats visible or configured." + + planner = SeatGuidePlanner() + empty_count, occupied_count = planner.occupancy_counts(scene.seats, scene.people) + result = planner.find_empty_seat( + scene.seats, + scene.people, + robot_x=scene.robot_x, + robot_y=scene.robot_y, + ) + if result is None: + return ( + f"SeatGuide preview source={scene.source}: no empty seat available; " + f"empty={empty_count} occupied={occupied_count}." + ) + + return ( + f"SeatGuide preview source={scene.source}: selected {result.seat.seat_id} " + f"empty={empty_count} occupied={occupied_count} " + f"seat=({result.seat.x:.2f}, {result.seat.y:.2f}, yaw={result.seat.yaw:.2f}) " + f"goal=({result.goal_x:.2f}, {result.goal_y:.2f}, yaw={result.goal_yaw:.2f})." + ) + + +def _describe_live_perception_required(scene: SeatSceneObservation) -> str: + advice_by_source = { + "no_camera_image": "check camera stream wiring and face the conference table", + "camera_no_odom": "check localization/odometry before sending a map-frame goal", + "stale_camera_image": "camera frames are stale; restore the live camera stream", + "stale_camera_odom": "odometry is stale; restore localization before sending a goal", + "camera_no_seats_detected": "turn the robot toward the chairs or adjust the detector", + "camera_detection_error": "check VLM/API key setup and logs", + "configured_fallback": "use require_live_perception=false only for explicit fallback calibration", + "runtime_override": "use require_live_perception=false only for explicit fallback calibration", + } + advice = advice_by_source.get(scene.source, "run seat_guide_status and inspect perception") + return ( + "SeatGuide requires live camera perception before navigation; " + f"source={scene.source}; seats={len(scene.seats)}; people={len(scene.people)}; " + f"robot=({scene.robot_x:.2f}, {scene.robot_y:.2f}); next={advice}." + ) + + +def _message_age_s(ts: float) -> float: + return max(0.0, time.time() - ts) + + +def _freshness_text(ts: float | None, max_age_s: float) -> str: + if ts is None: + return "missing" + return "true" if _message_age_s(ts) <= max_age_s else "false" + + +def _bbox_center_x(bbox: tuple[float, float, float, float], image_width: int) -> float: + left = max(0.0, min(float(bbox[0]), float(bbox[2]), float(image_width))) + right = max(0.0, min(max(float(bbox[0]), float(bbox[2])), float(image_width))) + return (left + right) / 2.0 + + +def _bbox_to_lateral_offset( + bbox: tuple[float, float, float, float], image_width: int, lateral_span_m: float +) -> float: + if image_width <= 0: + return 0.0 + normalized_x = (_bbox_center_x(bbox, image_width) / image_width) - 0.5 + return normalized_x * lateral_span_m + + +def _bbox_to_seat_observation( + *, + seat_id: str, + bbox: tuple[float, float, float, float], + image_width: int, + robot_x: float, + robot_y: float, + robot_yaw: float, + distance_m: float, + lateral_span_m: float, +) -> SeatObservation: + x, y = _camera_relative_to_map( + forward_m=distance_m, + lateral_m=_bbox_to_lateral_offset(bbox, image_width, lateral_span_m), + robot_x=robot_x, + robot_y=robot_y, + robot_yaw=robot_yaw, + ) + return SeatObservation( + seat_id=seat_id, + x=x, + y=y, + yaw=robot_yaw, + ) + + +def _bbox_to_person_observation( + *, + bbox: tuple[float, float, float, float], + image_width: int, + robot_x: float, + robot_y: float, + robot_yaw: float, + distance_m: float, + lateral_span_m: float, +) -> PersonObservation: + x, y = _camera_relative_to_map( + forward_m=distance_m, + lateral_m=_bbox_to_lateral_offset(bbox, image_width, lateral_span_m), + robot_x=robot_x, + robot_y=robot_y, + robot_yaw=robot_yaw, + ) + return PersonObservation(x=x, y=y) + + +def _camera_relative_to_map( + *, + forward_m: float, + lateral_m: float, + robot_x: float, + robot_y: float, + robot_yaw: float, +) -> tuple[float, float]: + return ( + robot_x + math.cos(robot_yaw) * forward_m - math.sin(robot_yaw) * lateral_m, + robot_y + math.sin(robot_yaw) * forward_m + math.cos(robot_yaw) * lateral_m, + ) + + +def parse_seat_guide_intent(text: str) -> SeatGuideIntent: + normalized = " ".join(text.strip().lower().split()) + if not normalized: + return SeatGuideIntent(should_find_seat=False, normalized_text="") + + english_seat_words = ("seat", "chair", "place to sit", "empty place") + english_find_words = ("find", "look for", "take me", "guide me", "show me") + chinese_seat_words = ("座位", "椅子", "空位", "位置") + chinese_find_words = ("找", "带我", "帮我", "引导", "去") + + should_find_seat = ( + any(word in normalized for word in english_seat_words) + and any(word in normalized for word in english_find_words) + ) or ( + any(word in normalized for word in chinese_seat_words) + and any(word in normalized for word in chinese_find_words) + ) + return SeatGuideIntent(should_find_seat=should_find_seat, normalized_text=normalized) + + +def is_seat_guide_preview_request(text: str) -> bool: + normalized = text.casefold() + preview_words = ( + "preview", + "preflight", + "dry run", + "test", + "check", + "预检", + "测试", + "先看", + "检查", + "不要动", + "别动", + ) + return parse_seat_guide_intent(text).should_find_seat and any( + word in normalized for word in preview_words + ) diff --git a/dimos/agents/skills/speak_skill.py b/dimos/agents/skills/speak_skill.py index b46de157c4..9391287e72 100644 --- a/dimos/agents/skills/speak_skill.py +++ b/dimos/agents/skills/speak_skill.py @@ -12,6 +12,7 @@ # See the License for the specific language governing permissions and # limitations under the License. +import os import threading import time @@ -31,6 +32,7 @@ class SpeakSkill(Module): _tts_node: OpenAITTSNode | None = None _audio_output: SounddeviceAudioOutput | None = None + _speech_unavailable_reason: str | None = None _audio_lock: threading.Lock = threading.Lock() _bg_threads: list[threading.Thread] = [] _bg_threads_lock: threading.Lock = threading.Lock() @@ -38,6 +40,10 @@ class SpeakSkill(Module): @rpc def start(self) -> None: super().start() + if not os.getenv("OPENAI_API_KEY"): + self._speech_unavailable_reason = "OPENAI_API_KEY is not set" + logger.warning("SpeakSkill TTS disabled because OPENAI_API_KEY is not set") + return self._tts_node = OpenAITTSNode(speed=1.2, voice=Voice.ONYX) self._audio_output = SounddeviceAudioOutput(sample_rate=24000) self._audio_output.consume_audio(self._tts_node.emit_audio()) @@ -69,7 +75,8 @@ def speak(self, text: str, blocking: bool = True) -> str: speak("Hello, I am your robot assistant.") """ if self._tts_node is None: - return "Error: TTS not initialized" + reason = self._speech_unavailable_reason or "TTS not initialized" + return f"Speech unavailable: {reason}" if not blocking: thread = threading.Thread( @@ -82,6 +89,29 @@ def speak(self, text: str, blocking: bool = True) -> str: return self._speak_blocking(text) + @skill + def speech_status(self) -> str: + """Report text-to-speech readiness without speaking. + + Use this during hardware bring-up to confirm OpenAI TTS and the local + audio output are initialized before relying on spoken feedback. + """ + with self._bg_threads_lock: + active_background_threads = sum(1 for thread in self._bg_threads if thread.is_alive()) + if self._tts_node is None: + reason = self._speech_unavailable_reason or "TTS not initialized" + return ( + "SpeakSkill status: tts=unavailable; " + f"reason={reason}; audio_output=missing; " + f"background_speech_threads={active_background_threads}." + ) + audio_output = "connected" if self._audio_output is not None else "missing" + return ( + "SpeakSkill status: tts=ready; " + f"audio_output={audio_output}; " + f"background_speech_threads={active_background_threads}." + ) + def _speak_bg(self, text: str) -> None: try: self._speak_blocking(text) diff --git a/dimos/agents/skills/test_seat_guide.py b/dimos/agents/skills/test_seat_guide.py new file mode 100644 index 0000000000..9c3875191e --- /dev/null +++ b/dimos/agents/skills/test_seat_guide.py @@ -0,0 +1,3681 @@ +# Copyright 2026 Dimensional Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import asyncio +import json +import math +import os +from pathlib import Path +import subprocess +import sys +from threading import RLock +from types import SimpleNamespace +from typing import Any + +from fastapi.testclient import TestClient +import numpy as np +import pytest + +from dimos.agents.mcp.mcp_server import handle_request +import dimos.agents.skills.seat_guide as seat_guide_module +from dimos.agents.skills.seat_guide import ( + CameraSeatObservationProvider, + CameraSeatSceneConfig, + PersonObservation, + SeatGuidePlanner, + SeatGuideRequestSpec, + SeatGuideSkillContainer, + SeatObservation, + SeatSceneObservation, + SyntheticSeatObservationProvider, + SyntheticSeatSceneConfig, + _flatten_people, + _flatten_seats, + _parse_people, + _parse_seats, + is_seat_guide_preview_request, + parse_seat_guide_intent, +) +from dimos.agents.system_prompt import SYSTEM_PROMPT +import dimos.agents.web_human_input as web_human_input_module +from dimos.agents.web_human_input import WebInput, _create_whisper_node +from dimos.core.coordination.blueprints import autoconnect +from dimos.core.coordination.module_coordinator import ModuleCoordinator +from dimos.core.core import rpc +from dimos.core.module import Module +from dimos.msgs.geometry_msgs.PoseStamped import PoseStamped +from dimos.msgs.geometry_msgs.Quaternion import Quaternion +from dimos.msgs.geometry_msgs.Vector3 import Vector3 +from dimos.msgs.sensor_msgs.Image import Image +from dimos.navigation.base import NavigationState +from dimos.web.robot_web_interface import RobotWebInterface + +REPO_ROOT = Path(__file__).parents[3] +ACCEPTANCE_LOG_VERIFIER = REPO_ROOT / "bin" / "demo_seat_guide_verify_acceptance_log" +HARDWARE_ACCEPTANCE_SCRIPT = REPO_ROOT / "bin" / "demo_seat_guide_hardware_acceptance" +HARDWARE_BRINGUP_SCRIPT = REPO_ROOT / "bin" / "demo_seat_guide_hardware_bringup" +SMOKE_SCRIPT = REPO_ROOT / "bin" / "demo_seat_guide_smoke" +REPLAY_SMOKE_SCRIPT = REPO_ROOT / "bin" / "demo_seat_guide_replay_smoke" +SEAT_GUIDE_DOC = REPO_ROOT / "docs" / "agents" / "seat_guide_modules.md" +SEAT_GUIDE_SCRIPTS = [ + SMOKE_SCRIPT, + REPLAY_SMOKE_SCRIPT, + HARDWARE_BRINGUP_SCRIPT, + HARDWARE_ACCEPTANCE_SCRIPT, + ACCEPTANCE_LOG_VERIFIER, +] + + +class FakeNavigation: + def __init__( + self, + *, + accepts_goal: bool = True, + raises_on_goal: bool = False, + state: NavigationState = NavigationState.IDLE, + goal_reached: bool = False, + ) -> None: + self.accepts_goal = accepts_goal + self.raises_on_goal = raises_on_goal + self.state = state + self.goal_reached = goal_reached + self.goal: PoseStamped | None = None + + def set_goal(self, goal: PoseStamped) -> bool: + if self.raises_on_goal: + raise RuntimeError("planner unavailable") + self.goal = goal + return self.accepts_goal + + def get_state(self) -> NavigationState: + return self.state + + def is_goal_reached(self) -> bool: + return self.goal_reached + + def cancel_goal(self) -> bool: + return True + + +class FakeSeatObservationProvider: + def __init__(self, scene: SeatSceneObservation) -> None: + self.scene = scene + + def get_seat_scene(self) -> SeatSceneObservation: + return self.scene + + +class CountingSeatObservationProvider(FakeSeatObservationProvider): + def __init__(self, scene: SeatSceneObservation) -> None: + super().__init__(scene) + self.calls = 0 + + def get_seat_scene(self) -> SeatSceneObservation: + self.calls += 1 + return super().get_seat_scene() + + +class FakeSpeaker: + def __init__(self, *, raises: bool = False) -> None: + self.spoken: list[tuple[str, bool]] = [] + self.raises = raises + + def speak(self, text: str, blocking: bool = True) -> str: + if self.raises: + raise RuntimeError("audio device unavailable") + self.spoken.append((text, blocking)) + return f"Spoke: {text}" + + +class FakeSeatGuideRequest: + def __init__(self, *, raises: bool = False) -> None: + self.requests: list[str] = [] + self.preview_requests: list[str] = [] + self.raises = raises + + def handle_seat_request(self, text: str) -> str: + self.requests.append(text) + if self.raises: + raise RuntimeError("seat guide unavailable") + return "handled" + + def preview_seat_request(self, text: str) -> str: + self.preview_requests.append(text) + if self.raises: + raise RuntimeError("seat guide unavailable") + return "previewed" + + +class FakeHumanTransport: + def __init__(self) -> None: + self.published: list[str] = [] + + def publish(self, text: str) -> None: + self.published.append(text) + + +class FakeWebInterface: + port = 5555 + audio_subject = SimpleNamespace() + + +class FakeThread: + def __init__(self, *, alive: bool) -> None: + self.alive = alive + + def is_alive(self) -> bool: + return self.alive + + +class FakeAgentResponses: + def __init__(self) -> None: + self.published: list[str] = [] + + def on_next(self, text: str) -> None: + self.published.append(text) + + +class FakeLogger: + def __init__(self) -> None: + self.info_calls: list[tuple[str, dict[str, Any]]] = [] + + def info(self, event: str, **kwargs: Any) -> None: + self.info_calls.append((event, kwargs)) + + +class FakeVlModel: + def __init__(self, detections_by_query: dict[str, list[SimpleNamespace]]) -> None: + self._detections_by_query = detections_by_query + self.queries: list[str] = [] + + def query_detections(self, image: Image, query: str) -> SimpleNamespace: + self.queries.append(query) + return SimpleNamespace(detections=self._detections_by_query.get(query, [])) + + +class OdomMutatingFakeVlModel(FakeVlModel): + def __init__( + self, + detections_by_query: dict[str, list[SimpleNamespace]], + provider: CameraSeatObservationProvider, + ) -> None: + super().__init__(detections_by_query) + self._provider = provider + + def query_detections(self, image: Image, query: str) -> SimpleNamespace: + self._provider._on_odom( + PoseStamped( + frame_id="map", + position=Vector3(100.0, 200.0, 0.0), + orientation=Quaternion.from_euler(Vector3(0.0, 0.0, 1.0)), + ) + ) + return super().query_detections(image, query) + + +class RecordingNavigation(Module): + _last_goal: PoseStamped | None = None + + @rpc + def set_goal(self, goal: PoseStamped) -> bool: + self._last_goal = goal + return True + + @rpc + def get_state(self) -> NavigationState: + return NavigationState.IDLE + + @rpc + def is_goal_reached(self) -> bool: + return False + + @rpc + def cancel_goal(self) -> bool: + return True + + @rpc + def get_last_goal_xy(self) -> tuple[float, float] | None: + if self._last_goal is None: + return None + return self._last_goal.position.x, self._last_goal.position.y + + +class RecordingSpeaker(Module): + _spoken: list[str] + + def __init__(self, **kwargs: Any) -> None: + super().__init__(**kwargs) + self._spoken = [] + + @rpc + def speak(self, text: str, blocking: bool = True) -> str: + self._spoken.append(text) + return f"Spoke: {text}" + + @rpc + def get_spoken(self) -> list[str]: + return self._spoken + + +def test_planner_selects_nearest_empty_seat() -> None: + planner = SeatGuidePlanner(occupied_radius_m=0.75, aisle_offset_m=0.5) + seats = [ + SeatObservation("occupied_near", x=1.0, y=0.0, yaw=0.0), + SeatObservation("empty_far", x=5.0, y=0.0, yaw=0.0), + SeatObservation("empty_near", x=2.0, y=0.0, yaw=math.pi / 2), + ] + people = [PersonObservation(x=1.2, y=0.1)] + + result = planner.find_empty_seat(seats, people, robot_x=0.0, robot_y=0.0) + + assert result is not None + assert result.seat.seat_id == "empty_near" + assert result.goal_x == pytest.approx(2.0) + assert result.goal_y == pytest.approx(0.5) + assert result.goal_yaw == pytest.approx(math.pi / 2) + assert planner.occupancy_counts(seats, people) == (2, 1) + + +def test_planner_returns_none_when_all_seats_are_occupied() -> None: + planner = SeatGuidePlanner() + seats = [ + SeatObservation("left", x=0.0, y=0.0), + SeatObservation("right", x=1.0, y=0.0), + ] + people = [ + PersonObservation(x=0.1, y=0.0), + PersonObservation(x=1.1, y=0.0), + ] + + assert planner.find_empty_seat(seats, people) is None + + +def test_skill_sets_navigation_goal_for_empty_seat() -> None: + skill = SeatGuideSkillContainer.__new__(SeatGuideSkillContainer) + fake_navigation = FakeNavigation() + skill._navigation = fake_navigation + + message = skill.find_empty_seat( + seats=[0.0, 0.0, 0.0, 2.0, 0.0, math.pi], + people=[0.2, 0.1], + robot_x=0.0, + robot_y=0.0, + ) + + assert "seat_2" in message + assert fake_navigation.goal is not None + assert fake_navigation.goal.frame_id == "map" + assert fake_navigation.goal.position.x == pytest.approx(1.35) + assert fake_navigation.goal.position.y == pytest.approx(0.0) + assert "goal_sequence=1" in skill.seat_guide_navigation_status() + + +def test_navigation_status_ignores_stale_goal_reached_until_reset_seen() -> None: + skill = SeatGuideSkillContainer.__new__(SeatGuideSkillContainer) + fake_navigation = FakeNavigation(goal_reached=True) + skill._navigation = fake_navigation + + message = skill.find_empty_seat(seats=[1.0, 0.0, 0.0], people=[]) + + assert "Navigating to" in message + assert skill.seat_guide_navigation_status() == ( + "SeatGuide navigation status: navigation=IDLE; goal_reached=false; " + "goal_sequence=1; completion_reset=waiting_for_false." + ) + + fake_navigation.goal_reached = False + assert skill.seat_guide_navigation_status() == ( + "SeatGuide navigation status: navigation=IDLE; goal_reached=false; " + "goal_sequence=1." + ) + + fake_navigation.goal_reached = True + assert skill.seat_guide_navigation_status() == ( + "SeatGuide navigation status: navigation=IDLE; goal_reached=true; " + "goal_sequence=1." + ) + + +def test_skill_reports_navigation_failure() -> None: + skill = SeatGuideSkillContainer.__new__(SeatGuideSkillContainer) + skill._navigation = FakeNavigation(accepts_goal=False) + + message = skill.find_empty_seat(seats=[1.0, 0.0, 0.0], people=[]) + + assert message == "Found empty seat seat_1, but failed to start navigation." + assert "goal_sequence=0" in skill.seat_guide_navigation_status() + + +def test_skill_reports_navigation_exception() -> None: + skill = SeatGuideSkillContainer.__new__(SeatGuideSkillContainer) + fake_navigation = FakeNavigation(raises_on_goal=True) + skill._navigation = fake_navigation + + message = skill.find_empty_seat(seats=[1.0, 0.0, 0.0], people=[]) + + assert ( + message + == "Found empty seat seat_1, but navigation raised an error: planner unavailable." + ) + assert fake_navigation.goal is None + + +def test_skill_refuses_to_override_active_navigation_goal() -> None: + skill = SeatGuideSkillContainer.__new__(SeatGuideSkillContainer) + fake_navigation = FakeNavigation(state=NavigationState.FOLLOWING_PATH) + skill._navigation = fake_navigation + + message = skill.find_empty_seat(seats=[1.0, 0.0, 0.0], people=[]) + + assert message == ( + "Found empty seat seat_1, but navigation is not ready for a new goal: " + "navigation=FOLLOWING_PATH." + ) + assert fake_navigation.goal is None + + +def test_skill_continues_when_speech_feedback_raises() -> None: + skill = SeatGuideSkillContainer.__new__(SeatGuideSkillContainer) + fake_navigation = FakeNavigation() + skill._navigation = fake_navigation + skill._speaker = FakeSpeaker(raises=True) + + message = skill.find_empty_seat(seats=[1.0, 0.0, 0.0], people=[]) + + assert message == ( + "I found an empty seat seat_1. Please follow me to the chair beside the table. " + "Navigating to (1.65, 0.00)." + ) + assert fake_navigation.goal is not None + + +def test_skill_uses_connected_observation_provider() -> None: + skill = SeatGuideSkillContainer.__new__(SeatGuideSkillContainer) + fake_navigation = FakeNavigation() + skill._navigation = fake_navigation + skill._seat_observation_provider = FakeSeatObservationProvider( + SeatSceneObservation( + seats=[ + SeatObservation("occupied", x=0.0, y=0.0, yaw=0.0), + SeatObservation("empty", x=1.0, y=0.0, yaw=0.0), + ], + people=[PersonObservation(x=0.1, y=0.0)], + robot_x=0.0, + robot_y=0.0, + ) + ) + + message = skill.find_empty_seat_from_scene(require_live_perception=False) + + assert "seat_2" in message + assert fake_navigation.goal is not None + assert fake_navigation.goal.position.x == pytest.approx(1.65) + + +def test_skill_reports_missing_observation_provider() -> None: + skill = SeatGuideSkillContainer.__new__(SeatGuideSkillContainer) + skill._seat_observation_provider = None + + assert skill.find_empty_seat_from_scene() == "No seat observation provider is connected." + + +def test_skill_reports_no_visible_seats_separately() -> None: + skill = SeatGuideSkillContainer.__new__(SeatGuideSkillContainer) + fake_speaker = FakeSpeaker() + skill._navigation = FakeNavigation() + skill._speaker = fake_speaker + + message = skill.find_empty_seat(seats=[], people=[]) + + assert message == ( + "I cannot see any seats yet. Please face the conference table or calibrate " + "the room layout." + ) + assert fake_speaker.spoken == [(message, False)] + + +def test_handle_seat_request_delegates_to_scene_provider() -> None: + skill = SeatGuideSkillContainer.__new__(SeatGuideSkillContainer) + fake_navigation = FakeNavigation() + skill._navigation = fake_navigation + skill._seat_observation_provider = FakeSeatObservationProvider( + SeatSceneObservation( + seats=[ + SeatObservation("occupied", x=0.0, y=0.0, yaw=0.0), + SeatObservation("empty", x=1.0, y=0.0, yaw=0.0), + ], + people=[PersonObservation(x=0.1, y=0.0)], + source="camera", + ) + ) + + message = skill.handle_seat_request("Please help me find an empty seat") + + assert "seat_2" in message + assert fake_navigation.goal is not None + assert fake_navigation.goal.position.x == pytest.approx(1.65) + + +def test_handle_seat_request_speaks_feedback_when_speaker_is_connected() -> None: + skill = SeatGuideSkillContainer.__new__(SeatGuideSkillContainer) + fake_navigation = FakeNavigation() + fake_speaker = FakeSpeaker() + skill._navigation = fake_navigation + skill._speaker = fake_speaker + skill._seat_observation_provider = FakeSeatObservationProvider( + SeatSceneObservation( + seats=[ + SeatObservation("occupied", x=0.0, y=0.0, yaw=0.0), + SeatObservation("empty", x=1.0, y=0.0, yaw=0.0), + ], + people=[PersonObservation(x=0.1, y=0.0)], + source="camera", + ) + ) + + skill.handle_seat_request("Please help me find an empty seat") + + assert fake_speaker.spoken == [ + ("I found an empty seat seat_2. Please follow me to the chair beside the table.", False) + ] + + +def test_handle_seat_request_requires_live_camera_by_default() -> None: + skill = SeatGuideSkillContainer.__new__(SeatGuideSkillContainer) + fake_navigation = FakeNavigation() + fake_speaker = FakeSpeaker() + skill._navigation = fake_navigation + skill._speaker = fake_speaker + skill._seat_observation_provider = FakeSeatObservationProvider( + SeatSceneObservation( + seats=[SeatObservation("fallback", x=1.0, y=0.0, yaw=0.0)], + people=[], + source="configured_fallback", + ) + ) + + message = skill.handle_seat_request("Please help me find an empty seat") + + assert message == ( + "SeatGuide requires live camera perception before navigation; " + "source=configured_fallback; seats=1; people=0; robot=(0.00, 0.00); " + "next=use require_live_perception=false only for explicit fallback calibration." + ) + assert fake_navigation.goal is None + assert fake_speaker.spoken == [(message, False)] + + +def test_handle_seat_request_reports_camera_detection_error_next_step() -> None: + skill = SeatGuideSkillContainer.__new__(SeatGuideSkillContainer) + fake_navigation = FakeNavigation() + skill._navigation = fake_navigation + skill._seat_observation_provider = FakeSeatObservationProvider( + SeatSceneObservation( + seats=[SeatObservation("fallback", x=1.0, y=0.0, yaw=0.0)], + people=[PersonObservation(x=0.0, y=0.0)], + robot_x=-1.0, + robot_y=2.0, + source="camera_detection_error", + ) + ) + + message = skill.handle_seat_request("Please help me find an empty seat") + + assert message == ( + "SeatGuide requires live camera perception before navigation; " + "source=camera_detection_error; seats=1; people=1; robot=(-1.00, 2.00); " + "next=check VLM/API key setup and logs." + ) + assert fake_navigation.goal is None + + +def test_handle_seat_request_can_explicitly_allow_fallback_calibration() -> None: + skill = SeatGuideSkillContainer.__new__(SeatGuideSkillContainer) + fake_navigation = FakeNavigation() + skill._navigation = fake_navigation + skill._seat_observation_provider = FakeSeatObservationProvider( + SeatSceneObservation( + seats=[SeatObservation("fallback", x=1.0, y=0.0, yaw=0.0)], + people=[], + source="configured_fallback", + ) + ) + + message = skill.handle_seat_request( + "Please help me find an empty seat", + require_live_perception=False, + ) + + assert "seat_1" in message + assert fake_navigation.goal is not None + assert fake_navigation.goal.position.x == pytest.approx(1.65) + + +def test_handle_seat_request_rejects_unrelated_text() -> None: + skill = SeatGuideSkillContainer.__new__(SeatGuideSkillContainer) + + message = skill.handle_seat_request("what time is the meeting") + + assert message == "I did not hear a request to find an empty seat." + + +def test_preview_seat_request_runs_preflight_without_navigating() -> None: + skill = SeatGuideSkillContainer.__new__(SeatGuideSkillContainer) + fake_navigation = FakeNavigation() + fake_speaker = FakeSpeaker() + skill._navigation = fake_navigation + skill._speaker = fake_speaker + skill._seat_observation_provider = FakeSeatObservationProvider( + SeatSceneObservation( + seats=[SeatObservation("empty", x=2.0, y=0.0, yaw=0.0)], + people=[], + robot_x=0.0, + robot_y=0.0, + source="camera", + ) + ) + + message = skill.preview_seat_request("预检帮我找一个空位") + + assert "SeatGuide preflight ready" in message + assert fake_navigation.goal is None + assert fake_speaker.spoken == [(message, False)] + + +def test_seat_guide_status_describes_current_scene() -> None: + skill = SeatGuideSkillContainer.__new__(SeatGuideSkillContainer) + skill._seat_observation_provider = FakeSeatObservationProvider( + SeatSceneObservation( + seats=[SeatObservation("seat_1", x=1.0, y=2.0, yaw=0.5)], + people=[PersonObservation(x=1.1, y=2.0)], + robot_x=-1.0, + robot_y=0.0, + source="camera", + ) + ) + + message = skill.seat_guide_status() + + assert message == ( + "SeatGuide scene source=camera: 1 seats [seat_1=(1.00, 2.00, yaw=0.50)], " + "1 people [(1.10, 2.00)], robot=(-1.00, 0.00)." + ) + + +def test_seat_guide_status_reports_missing_provider() -> None: + skill = SeatGuideSkillContainer.__new__(SeatGuideSkillContainer) + skill._seat_observation_provider = None + + assert ( + skill.seat_guide_status() + == "SeatGuide status: no seat observation provider is connected." + ) + + +def test_preview_empty_seat_goal_does_not_navigate() -> None: + skill = SeatGuideSkillContainer.__new__(SeatGuideSkillContainer) + fake_navigation = FakeNavigation() + skill._navigation = fake_navigation + skill._seat_observation_provider = FakeSeatObservationProvider( + SeatSceneObservation( + seats=[ + SeatObservation("occupied", x=0.0, y=0.0, yaw=0.0), + SeatObservation("empty", x=2.0, y=0.0, yaw=0.0), + ], + people=[PersonObservation(x=0.1, y=0.0)], + robot_x=0.0, + robot_y=0.0, + source="camera", + ) + ) + + message = skill.preview_empty_seat_goal() + + assert message == ( + "SeatGuide preview source=camera: selected empty " + "empty=1 occupied=1 " + "seat=(2.00, 0.00, yaw=0.00) goal=(2.65, 0.00, yaw=0.00)." + ) + assert fake_navigation.goal is None + + +def test_preview_empty_seat_goal_reports_no_seats() -> None: + skill = SeatGuideSkillContainer.__new__(SeatGuideSkillContainer) + skill._seat_observation_provider = FakeSeatObservationProvider( + SeatSceneObservation(seats=[], people=[], source="no_camera_image") + ) + + assert ( + skill.preview_empty_seat_goal() + == "SeatGuide preview source=no_camera_image: no seats visible or configured." + ) + + +def test_preview_empty_seat_goal_reports_no_empty_seat() -> None: + skill = SeatGuideSkillContainer.__new__(SeatGuideSkillContainer) + skill._seat_observation_provider = FakeSeatObservationProvider( + SeatSceneObservation( + seats=[SeatObservation("occupied", x=0.0, y=0.0, yaw=0.0)], + people=[PersonObservation(x=0.1, y=0.0)], + source="camera", + ) + ) + + assert ( + skill.preview_empty_seat_goal() + == "SeatGuide preview source=camera: no empty seat available; empty=0 occupied=1." + ) + + +def test_seat_guide_preflight_reports_ready_without_navigating() -> None: + skill = SeatGuideSkillContainer.__new__(SeatGuideSkillContainer) + fake_navigation = FakeNavigation() + skill._navigation = fake_navigation + skill._speaker = FakeSpeaker() + skill._seat_observation_provider = FakeSeatObservationProvider( + SeatSceneObservation( + seats=[ + SeatObservation("occupied", x=0.0, y=0.0, yaw=0.0), + SeatObservation("empty", x=2.0, y=0.0, yaw=0.0), + ], + people=[PersonObservation(x=0.1, y=0.0)], + robot_x=0.0, + robot_y=0.0, + source="camera", + ) + ) + + message = skill.seat_guide_preflight() + + assert message == ( + "SeatGuide preflight ready: navigation=IDLE; perception=camera seats=2 people=1; " + "empty=1 occupied=1; selected=empty; " + "goal=(2.65, 0.00, yaw=0.00); speaker=connected." + ) + assert fake_navigation.goal is None + + +def test_seat_guide_preflight_reports_no_go_without_provider() -> None: + skill = SeatGuideSkillContainer.__new__(SeatGuideSkillContainer) + skill._navigation = FakeNavigation() + skill._seat_observation_provider = None + skill._speaker = None + + assert ( + skill.seat_guide_preflight() + == "SeatGuide preflight no-go: navigation=IDLE; perception=missing; speaker=missing." + ) + + +def test_seat_guide_preflight_reports_no_go_without_visible_seats() -> None: + skill = SeatGuideSkillContainer.__new__(SeatGuideSkillContainer) + skill._navigation = FakeNavigation() + skill._speaker = None + skill._seat_observation_provider = FakeSeatObservationProvider( + SeatSceneObservation(seats=[], people=[], source="camera_detection_error") + ) + + assert ( + skill.seat_guide_preflight() + == "SeatGuide preflight no-go: navigation=IDLE; perception=camera_detection_error " + "no seats; speaker=missing." + ) + + +def test_seat_guide_preflight_reports_no_empty_seat_counts() -> None: + skill = SeatGuideSkillContainer.__new__(SeatGuideSkillContainer) + skill._navigation = FakeNavigation() + skill._speaker = None + skill._seat_observation_provider = FakeSeatObservationProvider( + SeatSceneObservation( + seats=[ + SeatObservation("left", x=0.0, y=0.0, yaw=0.0), + SeatObservation("right", x=1.0, y=0.0, yaw=0.0), + ], + people=[ + PersonObservation(x=0.1, y=0.0), + PersonObservation(x=1.1, y=0.0), + ], + source="camera", + ) + ) + + assert skill.seat_guide_preflight() == ( + "SeatGuide preflight no-go: navigation=IDLE; perception=camera; " + "no empty seat; empty=0 occupied=2; speaker=missing." + ) + + +def test_seat_guide_preflight_reports_no_go_when_navigation_is_busy() -> None: + skill = SeatGuideSkillContainer.__new__(SeatGuideSkillContainer) + fake_navigation = FakeNavigation(state=NavigationState.FOLLOWING_PATH) + skill._navigation = fake_navigation + skill._speaker = None + skill._seat_observation_provider = FakeSeatObservationProvider( + SeatSceneObservation( + seats=[SeatObservation("empty", x=2.0, y=0.0, yaw=0.0)], + people=[], + source="camera", + ) + ) + + message = skill.seat_guide_preflight() + + assert message == ( + "SeatGuide preflight no-go: navigation=FOLLOWING_PATH; " + "perception=camera seats=1 people=0; empty=1 occupied=0; selected=empty; " + "goal=(2.65, 0.00, yaw=0.00); speaker=missing." + ) + assert fake_navigation.goal is None + + +def test_seat_guide_preflight_requires_live_camera_by_default() -> None: + skill = SeatGuideSkillContainer.__new__(SeatGuideSkillContainer) + fake_navigation = FakeNavigation() + skill._navigation = fake_navigation + skill._speaker = None + skill._seat_observation_provider = FakeSeatObservationProvider( + SeatSceneObservation( + seats=[SeatObservation("fallback", x=2.0, y=0.0, yaw=0.0)], + people=[], + source="configured_fallback", + ) + ) + + message = skill.seat_guide_preflight() + + assert message == ( + "SeatGuide preflight no-go: navigation=IDLE; perception=configured_fallback " + "is not live camera; seats=1 people=0; speaker=missing." + ) + assert fake_navigation.goal is None + + +def test_seat_guide_preflight_can_explicitly_allow_fallback_calibration() -> None: + skill = SeatGuideSkillContainer.__new__(SeatGuideSkillContainer) + fake_navigation = FakeNavigation() + skill._navigation = fake_navigation + skill._speaker = None + skill._seat_observation_provider = FakeSeatObservationProvider( + SeatSceneObservation( + seats=[SeatObservation("fallback", x=2.0, y=0.0, yaw=0.0)], + people=[], + source="configured_fallback", + ) + ) + + message = skill.seat_guide_preflight(require_live_perception=False) + + assert "SeatGuide preflight ready" in message + assert "perception=configured_fallback" in message + assert fake_navigation.goal is None + + +def test_seat_guide_readiness_report_combines_no_motion_checks() -> None: + skill = SeatGuideSkillContainer.__new__(SeatGuideSkillContainer) + fake_navigation = FakeNavigation() + skill._navigation = fake_navigation + skill._speaker = FakeSpeaker() + skill._seat_observation_provider = FakeSeatObservationProvider( + SeatSceneObservation( + seats=[ + SeatObservation("occupied", x=0.0, y=0.0, yaw=0.0), + SeatObservation("empty", x=2.0, y=0.0, yaw=0.0), + ], + people=[PersonObservation(x=0.1, y=0.0)], + robot_x=0.0, + robot_y=0.0, + source="camera", + ) + ) + + message = skill.seat_guide_readiness_report() + + assert message.startswith("SeatGuide readiness report: SeatGuide scene source=camera") + assert "SeatGuide preflight ready" in message + assert "empty=1 occupied=1" in message + assert "SeatGuide preview source=camera" in message + assert fake_navigation.goal is None + + +def test_seat_guide_readiness_report_keeps_fallback_no_go_by_default() -> None: + skill = SeatGuideSkillContainer.__new__(SeatGuideSkillContainer) + fake_navigation = FakeNavigation() + skill._navigation = fake_navigation + skill._speaker = None + skill._seat_observation_provider = FakeSeatObservationProvider( + SeatSceneObservation( + seats=[SeatObservation("fallback", x=2.0, y=0.0, yaw=0.0)], + people=[], + source="configured_fallback", + ) + ) + + message = skill.seat_guide_readiness_report() + + assert "SeatGuide preflight no-go" in message + assert "perception=configured_fallback is not live camera" in message + assert "SeatGuide preview source=configured_fallback" in message + assert fake_navigation.goal is None + + +def test_seat_guide_readiness_report_reads_scene_once() -> None: + skill = SeatGuideSkillContainer.__new__(SeatGuideSkillContainer) + provider = CountingSeatObservationProvider( + SeatSceneObservation( + seats=[SeatObservation("empty", x=2.0, y=0.0, yaw=0.0)], + people=[], + source="camera", + ) + ) + skill._navigation = FakeNavigation() + skill._speaker = None + skill._seat_observation_provider = provider + + skill.seat_guide_readiness_report() + + assert provider.calls == 1 + + +def test_seat_guide_navigation_status_reports_goal_reached() -> None: + skill = SeatGuideSkillContainer.__new__(SeatGuideSkillContainer) + skill._navigation = FakeNavigation(goal_reached=True) + + assert skill.seat_guide_navigation_status() == ( + "SeatGuide navigation status: navigation=IDLE; goal_reached=true; goal_sequence=0." + ) + + +def test_seat_guide_navigation_status_reports_busy_goal_not_reached() -> None: + skill = SeatGuideSkillContainer.__new__(SeatGuideSkillContainer) + skill._navigation = FakeNavigation( + state=NavigationState.FOLLOWING_PATH, + goal_reached=False, + ) + + assert skill.seat_guide_navigation_status() == ( + "SeatGuide navigation status: navigation=FOLLOWING_PATH; " + "goal_reached=false; goal_sequence=0." + ) + + +def test_seat_guide_navigation_status_reports_missing_navigation() -> None: + skill = SeatGuideSkillContainer.__new__(SeatGuideSkillContainer) + skill._navigation = None + + assert skill.seat_guide_navigation_status() == ( + "SeatGuide navigation status: navigation=missing; goal_reached=unknown; " + "goal_sequence=0." + ) + + +def test_web_input_routes_seat_voice_text_directly_to_seat_guide() -> None: + web_input = WebInput.__new__(WebInput) + fake_seat_guide = FakeSeatGuideRequest() + fake_transport = FakeHumanTransport() + fake_agent_responses = FakeAgentResponses() + web_input._seat_guide = fake_seat_guide + web_input._human_transport = fake_transport + web_input._agent_responses = fake_agent_responses + + web_input._route_text("帮我找一个空位") + + assert fake_seat_guide.requests == ["帮我找一个空位"] + assert fake_seat_guide.preview_requests == [] + assert fake_transport.published == [] + assert fake_agent_responses.published == ["handled"] + + +def test_web_input_logs_live_seat_guide_route_for_voice_bringup( + monkeypatch: pytest.MonkeyPatch, +) -> None: + web_input = WebInput.__new__(WebInput) + fake_seat_guide = FakeSeatGuideRequest() + fake_transport = FakeHumanTransport() + fake_agent_responses = FakeAgentResponses() + fake_logger = FakeLogger() + web_input._seat_guide = fake_seat_guide + web_input._human_transport = fake_transport + web_input._agent_responses = fake_agent_responses + monkeypatch.setattr(web_human_input_module, "logger", fake_logger) + + web_input._route_text("帮我找一个空位") + + assert fake_logger.info_calls == [ + ("WebInput received text", {"text": "帮我找一个空位"}), + ("WebInput routing text to SeatGuide live request", {"text": "帮我找一个空位"}), + ] + + +def test_web_input_routes_preview_seat_voice_text_without_navigation_request( + monkeypatch: pytest.MonkeyPatch, +) -> None: + web_input = WebInput.__new__(WebInput) + fake_seat_guide = FakeSeatGuideRequest() + fake_transport = FakeHumanTransport() + fake_agent_responses = FakeAgentResponses() + fake_logger = FakeLogger() + web_input._seat_guide = fake_seat_guide + web_input._human_transport = fake_transport + web_input._agent_responses = fake_agent_responses + monkeypatch.setattr(web_human_input_module, "logger", fake_logger) + + web_input._route_text("预检帮我找一个空位") + + assert fake_seat_guide.preview_requests == ["预检帮我找一个空位"] + assert fake_seat_guide.requests == [] + assert fake_transport.published == [] + assert fake_agent_responses.published == ["previewed"] + assert fake_logger.info_calls == [ + ("WebInput received text", {"text": "预检帮我找一个空位"}), + ( + "WebInput routing text to SeatGuide preview", + {"text": "预检帮我找一个空位"}, + ), + ] + + +def test_web_input_submit_query_http_route_reaches_seat_guide_preview() -> None: + web_input = WebInput.__new__(WebInput) + fake_seat_guide = FakeSeatGuideRequest() + fake_transport = FakeHumanTransport() + fake_agent_responses = FakeAgentResponses() + web_input._seat_guide = fake_seat_guide + web_input._human_transport = fake_transport + web_input._agent_responses = fake_agent_responses + + interface = RobotWebInterface(port=5555) + subscription = interface.query_stream.subscribe(web_input._route_text) + + try: + response = TestClient(interface.app).post( + "/submit_query", data={"query": "预检帮我找一个空位"} + ) + finally: + subscription.dispose() + + assert response.status_code == 200 + assert response.json() == {"success": True, "message": "Query received"} + assert fake_seat_guide.preview_requests == ["预检帮我找一个空位"] + assert fake_seat_guide.requests == [] + assert fake_transport.published == [] + assert fake_agent_responses.published == ["previewed"] + + +def test_web_input_upload_audio_http_route_emits_audio_event( + monkeypatch: pytest.MonkeyPatch, +) -> None: + audio_events: list[Any] = [] + audio_subject = web_human_input_module.rx.subject.Subject() + audio_subject.subscribe(audio_events.append) + + decoded_audio = np.array([0.1, -0.1, 0.0], dtype=np.float32) + monkeypatch.setattr( + "dimos.web.dimos_interface.api.server.FastAPIServer._decode_audio", + staticmethod(lambda raw: (decoded_audio, 16000)), + ) + + interface = RobotWebInterface(port=5555, audio_subject=audio_subject) + response = TestClient(interface.app).post( + "/upload_audio", + files={"file": ("seat-guide.webm", b"browser audio", "audio/webm")}, + ) + + assert response.status_code == 200 + assert response.json() == {"success": True} + assert len(audio_events) == 1 + event = audio_events[0] + assert event.sample_rate == 16000 + assert event.channels == 1 + assert np.array_equal(event.data, decoded_audio) + + +def test_web_input_upload_audio_requires_configured_voice_subject() -> None: + interface = RobotWebInterface(port=5555, audio_subject=None) + response = TestClient(interface.app).post( + "/upload_audio", + files={"file": ("seat-guide.webm", b"browser audio", "audio/webm")}, + ) + + assert response.status_code == 400 + assert response.json() == { + "success": False, + "message": "Voice input not configured", + } + + +def test_web_input_upload_audio_rejects_decode_failures( + monkeypatch: pytest.MonkeyPatch, +) -> None: + audio_events: list[Any] = [] + audio_subject = web_human_input_module.rx.subject.Subject() + audio_subject.subscribe(audio_events.append) + monkeypatch.setattr( + "dimos.web.dimos_interface.api.server.FastAPIServer._decode_audio", + staticmethod(lambda raw: (None, None)), + ) + + interface = RobotWebInterface(port=5555, audio_subject=audio_subject) + response = TestClient(interface.app).post( + "/upload_audio", + files={"file": ("seat-guide.webm", b"not audio", "audio/webm")}, + ) + + assert response.status_code == 400 + assert response.json() == {"success": False, "message": "Unable to decode audio"} + assert audio_events == [] + + +def test_web_input_falls_back_to_agent_path_when_seat_guide_route_fails() -> None: + web_input = WebInput.__new__(WebInput) + fake_seat_guide = FakeSeatGuideRequest(raises=True) + fake_transport = FakeHumanTransport() + fake_agent_responses = FakeAgentResponses() + web_input._seat_guide = fake_seat_guide + web_input._human_transport = fake_transport + web_input._agent_responses = fake_agent_responses + + web_input._route_text("帮我找一个空位") + + assert fake_seat_guide.requests == ["帮我找一个空位"] + assert fake_transport.published == ["帮我找一个空位"] + assert fake_agent_responses.published == [] + + +def test_web_input_keeps_unrelated_voice_text_on_agent_path( + monkeypatch: pytest.MonkeyPatch, +) -> None: + web_input = WebInput.__new__(WebInput) + fake_seat_guide = FakeSeatGuideRequest() + fake_transport = FakeHumanTransport() + fake_logger = FakeLogger() + web_input._seat_guide = fake_seat_guide + web_input._human_transport = fake_transport + monkeypatch.setattr(web_human_input_module, "logger", fake_logger) + + web_input._route_text("what time is the meeting") + + assert fake_seat_guide.requests == [] + assert fake_transport.published == ["what time is the meeting"] + assert fake_logger.info_calls == [ + ("WebInput received text", {"text": "what time is the meeting"}), + ("WebInput routing text to agent path", {"text": "what time is the meeting"}), + ] + + +def test_web_input_status_reports_not_started_state() -> None: + web_input = WebInput.__new__(WebInput) + web_input._web_interface = None + web_input._thread = None + web_input._seat_guide = None + web_input._agent_responses = None + web_input._stt_node = None + web_input._stt_error = None + web_input._human_transport = None + + assert web_input.web_input_status() == ( + "WebInput status: web=not_started; thread=not_running; " + "seat_route=agent_only; responses=missing; voice_upload=missing; " + "stt=missing; human_transport=missing; url=unavailable." + ) + + +def test_web_input_status_reports_seat_guide_direct_route() -> None: + web_input = WebInput.__new__(WebInput) + web_input._web_interface = FakeWebInterface() + web_input._thread = FakeThread(alive=True) + web_input._seat_guide = FakeSeatGuideRequest() + web_input._agent_responses = FakeAgentResponses() + web_input._stt_node = SimpleNamespace() + web_input._stt_error = None + web_input._human_transport = FakeHumanTransport() + + assert web_input.web_input_status() == ( + "WebInput status: web=started; thread=running; " + "seat_route=seat_guide_direct; responses=connected; " + "voice_upload=connected; stt=connected; human_transport=connected; " + "url=http://localhost:5555." + ) + + +def test_web_input_status_reports_stt_initialization_error() -> None: + web_input = WebInput.__new__(WebInput) + web_input._web_interface = FakeWebInterface() + web_input._thread = FakeThread(alive=True) + web_input._seat_guide = FakeSeatGuideRequest() + web_input._agent_responses = FakeAgentResponses() + web_input._stt_node = None + web_input._stt_error = "RuntimeError: whisper missing" + web_input._human_transport = FakeHumanTransport() + + assert web_input.web_input_status() == ( + "WebInput status: web=started; thread=running; " + "seat_route=seat_guide_direct; responses=connected; " + "voice_upload=connected; stt=error(RuntimeError: whisper missing); " + "human_transport=connected; url=http://localhost:5555." + ) + + +def test_web_input_status_reports_missing_browser_voice_upload() -> None: + web_input = WebInput.__new__(WebInput) + web_input._web_interface = SimpleNamespace(port=5555, audio_subject=None) + web_input._thread = FakeThread(alive=True) + web_input._seat_guide = FakeSeatGuideRequest() + web_input._agent_responses = FakeAgentResponses() + web_input._stt_node = SimpleNamespace() + web_input._stt_error = None + web_input._human_transport = FakeHumanTransport() + + assert web_input.web_input_status() == ( + "WebInput status: web=started; thread=running; " + "seat_route=seat_guide_direct; responses=connected; " + "voice_upload=missing; stt=connected; human_transport=connected; " + "url=http://localhost:5555." + ) + + +def test_web_input_whisper_auto_detects_language(monkeypatch: pytest.MonkeyPatch) -> None: + created_modelopts: list[dict[str, Any] | None] = [] + + class FakeWhisperNode: + def __init__( + self, + model: str = "base", + modelopts: dict[str, Any] | None = None, + ) -> None: + created_modelopts.append(modelopts) + + monkeypatch.setitem( + sys.modules, + "dimos.stream.audio.stt.node_whisper", + SimpleNamespace(WhisperNode=FakeWhisperNode), + ) + + _create_whisper_node() + + assert created_modelopts == [{"fp16": False}] + assert "language" not in created_modelopts[0] + + +def test_autoconnect_injects_scene_provider_and_navigation() -> None: + blueprint = autoconnect( + SeatGuideSkillContainer.blueprint(), + SyntheticSeatObservationProvider.blueprint( + seats=[0.0, 0.0, 0.0, 2.0, 0.0, 0.0], + people=[0.1, 0.0], + robot_x=0.0, + robot_y=0.0, + ), + RecordingNavigation.blueprint(), + ) + coordinator = ModuleCoordinator.build(blueprint, {"g": {"viewer": "none"}}) + + try: + seat_guide = coordinator.get_instance(SeatGuideSkillContainer) + navigation = coordinator.get_instance(RecordingNavigation) + + message = seat_guide.handle_seat_request( + "Please find me an empty seat", + require_live_perception=False, + ) + + assert "seat_2" in message + assert navigation.get_last_goal_xy() == pytest.approx((2.65, 0.0)) + finally: + coordinator.stop() + + +def test_autoconnect_uses_runtime_configured_synthetic_scene() -> None: + blueprint = autoconnect( + SeatGuideSkillContainer.blueprint(), + SyntheticSeatObservationProvider.blueprint( + seats=[0.0, 0.0, 0.0], + people=[], + robot_x=0.0, + robot_y=0.0, + ), + RecordingNavigation.blueprint(), + ) + coordinator = ModuleCoordinator.build(blueprint, {"g": {"viewer": "none"}}) + + try: + seat_guide = coordinator.get_instance(SeatGuideSkillContainer) + provider = coordinator.get_instance(SyntheticSeatObservationProvider) + navigation = coordinator.get_instance(RecordingNavigation) + + provider.set_seat_scene( + seats=[0.0, 0.0, 0.0, 4.0, 0.0, 0.0], + people=[0.1, 0.0], + robot_x=0.0, + robot_y=0.0, + ) + message = seat_guide.handle_seat_request( + "Please find me an empty seat", + require_live_perception=False, + ) + + assert "seat_2" in message + assert navigation.get_last_goal_xy() == pytest.approx((4.65, 0.0)) + finally: + coordinator.stop() + + +def test_autoconnect_injects_speaker_for_feedback() -> None: + blueprint = autoconnect( + SeatGuideSkillContainer.blueprint(), + SyntheticSeatObservationProvider.blueprint( + seats=[0.0, 0.0, 0.0, 2.0, 0.0, 0.0], + people=[0.1, 0.0], + robot_x=0.0, + robot_y=0.0, + ), + RecordingNavigation.blueprint(), + RecordingSpeaker.blueprint(), + ) + coordinator = ModuleCoordinator.build(blueprint, {"g": {"viewer": "none"}}) + + try: + seat_guide = coordinator.get_instance(SeatGuideSkillContainer) + speaker = coordinator.get_instance(RecordingSpeaker) + + seat_guide.handle_seat_request( + "Please find me an empty seat", + require_live_perception=False, + ) + + assert speaker.get_spoken() == [ + "I found an empty seat seat_2. Please follow me to the chair beside the table." + ] + finally: + coordinator.stop() + + +def test_seat_guide_exposes_agent_friendly_skills() -> None: + skill = SeatGuideSkillContainer() + try: + skill_infos = {info.func_name: json.loads(info.args_schema) for info in skill.get_skills()} + finally: + skill._close_module() + + assert "handle_seat_request" in skill_infos + assert "preview_seat_request" in skill_infos + assert "find_empty_seat_from_scene" in skill_infos + assert "seat_guide_preflight" in skill_infos + assert "seat_guide_readiness_report" in skill_infos + assert "seat_guide_navigation_status" in skill_infos + assert "preview_empty_seat_goal" in skill_infos + assert "seat_guide_status" in skill_infos + assert "camera_seat_provider_status" not in skill_infos + + request_schema = skill_infos["handle_seat_request"] + assert "spoken or typed request" in request_schema["description"] + assert request_schema["properties"] == { + "text": {"title": "Text", "type": "string"}, + "require_live_perception": { + "default": True, + "title": "Require Live Perception", + "type": "boolean", + }, + } + assert request_schema["required"] == ["text"] + + preview_request_schema = skill_infos["preview_seat_request"] + assert "without moving" in preview_request_schema["description"] + assert preview_request_schema["properties"] == {"text": {"title": "Text", "type": "string"}} + assert preview_request_schema["required"] == ["text"] + + scene_schema = skill_infos["find_empty_seat_from_scene"] + assert "observation provider" in scene_schema["description"] + assert scene_schema["properties"] == { + "require_live_perception": { + "default": True, + "title": "Require Live Perception", + "type": "boolean", + } + } + + preflight_schema = skill_infos["seat_guide_preflight"] + assert "no-motion" in preflight_schema["description"] + assert preflight_schema["properties"] == { + "require_live_perception": { + "default": True, + "title": "Require Live Perception", + "type": "boolean", + } + } + + readiness_schema = skill_infos["seat_guide_readiness_report"] + assert "readiness checks" in readiness_schema["description"] + assert readiness_schema["properties"] == { + "require_live_perception": { + "default": True, + "title": "Require Live Perception", + "type": "boolean", + } + } + + navigation_status_schema = skill_infos["seat_guide_navigation_status"] + assert "navigation goal has completed" in navigation_status_schema["description"] + assert navigation_status_schema.get("properties", {}) == {} + + preview_schema = skill_infos["preview_empty_seat_goal"] + assert "without moving" in preview_schema["description"] + assert preview_schema.get("properties", {}) == {} + + status_schema = skill_infos["seat_guide_status"] + assert "without navigating" in status_schema["description"] + assert status_schema.get("properties", {}) == {} + + +def test_web_input_exposes_bringup_status_skill() -> None: + web_input = WebInput() + try: + skill_infos = { + info.func_name: json.loads(info.args_schema) for info in web_input.get_skills() + } + finally: + web_input._close_module() + + assert "web_input_status" in skill_infos + status_schema = skill_infos["web_input_status"] + assert "voice and text routing readiness" in status_schema["description"] + assert status_schema.get("properties", {}) == {} + + +def test_camera_provider_exposes_bringup_status_skill() -> None: + provider = CameraSeatObservationProvider() + try: + skill_infos = { + info.func_name: json.loads(info.args_schema) for info in provider.get_skills() + } + finally: + provider._close_module() + + assert "camera_seat_provider_status" in skill_infos + status_schema = skill_infos["camera_seat_provider_status"] + assert "perception readiness" in status_schema["description"] + assert status_schema.get("properties", {}) == {} + + +def test_seat_guide_mcp_request_flow_without_go2() -> None: + skill = SeatGuideSkillContainer() + fake_navigation = FakeNavigation() + skill._navigation = fake_navigation + skill._seat_observation_provider = FakeSeatObservationProvider( + SeatSceneObservation( + seats=[ + SeatObservation("occupied", x=0.0, y=0.0, yaw=0.0), + SeatObservation("empty", x=2.0, y=0.0, yaw=0.0), + ], + people=[PersonObservation(x=0.1, y=0.0)], + robot_x=0.0, + robot_y=0.0, + source="configured_fallback", + ) + ) + + try: + skills = skill.get_skills() + rpc_calls = {info.func_name: getattr(skill, info.func_name) for info in skills} + + response = asyncio.run( + handle_request({"method": "tools/list", "id": 1}, skills, rpc_calls) + ) + tool_names = {tool["name"] for tool in response["result"]["tools"]} + assert { + "seat_guide_status", + "preview_seat_request", + "seat_guide_preflight", + "seat_guide_readiness_report", + "seat_guide_navigation_status", + "preview_empty_seat_goal", + "handle_seat_request", + } <= tool_names + + response = asyncio.run( + handle_request( + { + "method": "tools/call", + "id": 2, + "params": {"name": "seat_guide_status", "arguments": {}}, + }, + skills, + rpc_calls, + ) + ) + assert "source=configured_fallback" in response["result"]["content"][0]["text"] + + response = asyncio.run( + handle_request( + { + "method": "tools/call", + "id": 3, + "params": { + "name": "seat_guide_preflight", + "arguments": {"require_live_perception": False}, + }, + }, + skills, + rpc_calls, + ) + ) + assert "SeatGuide preflight ready" in response["result"]["content"][0]["text"] + assert fake_navigation.goal is None + + response = asyncio.run( + handle_request( + { + "method": "tools/call", + "id": 4, + "params": { + "name": "seat_guide_readiness_report", + "arguments": {"require_live_perception": False}, + }, + }, + skills, + rpc_calls, + ) + ) + assert "SeatGuide readiness report" in response["result"]["content"][0]["text"] + assert fake_navigation.goal is None + + response = asyncio.run( + handle_request( + { + "method": "tools/call", + "id": 5, + "params": {"name": "preview_empty_seat_goal", "arguments": {}}, + }, + skills, + rpc_calls, + ) + ) + assert "goal=(2.65, 0.00" in response["result"]["content"][0]["text"] + assert fake_navigation.goal is None + + response = asyncio.run( + handle_request( + { + "method": "tools/call", + "id": 6, + "params": { + "name": "preview_seat_request", + "arguments": {"text": "预检帮我找一个空位"}, + }, + }, + skills, + rpc_calls, + ) + ) + assert "is not live camera" in response["result"]["content"][0]["text"] + assert fake_navigation.goal is None + + response = asyncio.run( + handle_request( + { + "method": "tools/call", + "id": 7, + "params": { + "name": "handle_seat_request", + "arguments": { + "text": "帮我找一个空位", + "require_live_perception": False, + }, + }, + }, + skills, + rpc_calls, + ) + ) + assert "seat_2" in response["result"]["content"][0]["text"] + assert fake_navigation.goal is not None + assert fake_navigation.goal.position.x == pytest.approx(2.65) + + fake_navigation.goal_reached = True + response = asyncio.run( + handle_request( + { + "method": "tools/call", + "id": 8, + "params": {"name": "seat_guide_navigation_status", "arguments": {}}, + }, + skills, + rpc_calls, + ) + ) + assert "goal_reached=true" in response["result"]["content"][0]["text"] + finally: + skill._close_module() + + +def test_seat_guide_go2_blueprints_include_real_runtime_modules() -> None: + from dimos.robot.unitree.go2.blueprints.agentic.unitree_go2_seat_guide import ( + unitree_go2_seat_guide, + ) + from dimos.robot.unitree.go2.blueprints.agentic.unitree_go2_seat_guide_agentic import ( + unitree_go2_seat_guide_agentic, + ) + + agentic_modules = {atom.module.__name__ for atom in unitree_go2_seat_guide_agentic.blueprints} + direct_modules = {atom.module.__name__ for atom in unitree_go2_seat_guide.blueprints} + + assert { + "GO2Connection", + "SpatialMemory", + "McpServer", + "McpClient", + "CameraSeatObservationProvider", + "SeatGuideSkillContainer", + "WebInput", + "SpeakSkill", + } <= agentic_modules + assert { + "GO2Connection", + "SpatialMemory", + "McpServer", + "CameraSeatObservationProvider", + "SeatGuideSkillContainer", + "WebInput", + "SpeakSkill", + } <= direct_modules + assert "McpClient" not in direct_modules + + +def test_go2_seat_guide_blueprints_wire_web_input_to_seat_guide() -> None: + from dimos.robot.unitree.go2.blueprints.agentic.unitree_go2_seat_guide import ( + unitree_go2_seat_guide, + ) + from dimos.robot.unitree.go2.blueprints.agentic.unitree_go2_seat_guide_agentic import ( + unitree_go2_seat_guide_agentic, + ) + + for blueprint in (unitree_go2_seat_guide, unitree_go2_seat_guide_agentic): + web_input_atom = next( + atom for atom in blueprint.blueprints if atom.module is WebInput + ) + seat_guide_refs = [ + ref for ref in web_input_atom.module_refs if ref.name == "_seat_guide" + ] + + assert len(seat_guide_refs) == 1 + assert seat_guide_refs[0].spec is SeatGuideRequestSpec + assert seat_guide_refs[0].optional + + +def test_synthetic_observation_provider_returns_configured_scene() -> None: + provider = SyntheticSeatObservationProvider.__new__(SyntheticSeatObservationProvider) + provider.config = SyntheticSeatSceneConfig( + seats=[0.0, 1.0, 0.0, 2.0, 1.0, 0.5], + people=[0.1, 1.0], + robot_x=-1.0, + robot_y=1.0, + ) + + scene = provider.get_seat_scene() + + assert scene.seats == [ + SeatObservation("seat_1", 0.0, 1.0, 0.0), + SeatObservation("seat_2", 2.0, 1.0, 0.5), + ] + assert scene.people == [PersonObservation(0.1, 1.0)] + assert scene.robot_x == -1.0 + assert scene.robot_y == 1.0 + assert scene.source == "configured_fallback" + + +def test_synthetic_observation_provider_runtime_override() -> None: + provider = SyntheticSeatObservationProvider.__new__(SyntheticSeatObservationProvider) + provider.config = SyntheticSeatSceneConfig( + seats=[0.0, 0.0, 0.0], + people=[], + robot_x=0.0, + robot_y=0.0, + ) + provider._scene_override = None + + message = provider.set_seat_scene( + seats=[1.0, 2.0, 0.0, 3.0, 2.0, 0.0], + people=[1.1, 2.0], + robot_x=-1.0, + robot_y=2.0, + ) + scene = provider.get_seat_scene() + + assert message == "Configured 2 seats and 1 person." + assert scene.seats == [ + SeatObservation("seat_1", 1.0, 2.0, 0.0), + SeatObservation("seat_2", 3.0, 2.0, 0.0), + ] + assert scene.people == [PersonObservation(1.1, 2.0)] + assert scene.robot_x == -1.0 + assert scene.robot_y == 2.0 + assert scene.source == "runtime_override" + + assert provider.clear_seat_scene_override() == "Cleared synthetic seat scene override." + assert provider.get_seat_scene().seats == [SeatObservation("seat_1", 0.0, 0.0, 0.0)] + + +def test_camera_observation_provider_detects_scene_from_image() -> None: + provider = CameraSeatObservationProvider.__new__(CameraSeatObservationProvider) + provider.config = CameraSeatSceneConfig( + seats=[9.0, 9.0, 0.0], + people=[], + robot_x=0.0, + robot_y=0.0, + chair_distance_m=2.0, + lateral_span_m=4.0, + ) + provider._scene_override = None + provider._scene_lock = RLock() + provider._latest_image = Image.from_numpy(np.zeros((100, 100, 3), dtype=np.uint8)) + provider._latest_odom = PoseStamped( + frame_id="map", + position=Vector3(0.0, 0.0, 0.0), + orientation=Quaternion.from_euler(Vector3(0.0, 0.0, 0.0)), + ) + fake_vl_model = FakeVlModel( + { + "chair": [ + SimpleNamespace(name="chair", bbox=(10.0, 20.0, 30.0, 80.0)), + SimpleNamespace(name="chair", bbox=(70.0, 20.0, 90.0, 80.0)), + ], + "person": [ + SimpleNamespace(name="person", bbox=(12.0, 10.0, 32.0, 90.0)), + ], + } + ) + provider._vl_model = fake_vl_model + + scene = provider.get_seat_scene() + + assert fake_vl_model.queries == ["chair", "person"] + assert [seat.seat_id for seat in scene.seats] == ["seat_1", "seat_2"] + assert [seat.x for seat in scene.seats] == pytest.approx([2.0, 2.0]) + assert [seat.y for seat in scene.seats] == pytest.approx([-1.2, 1.2]) + assert [seat.yaw for seat in scene.seats] == pytest.approx([0.0, 0.0]) + assert [person.x for person in scene.people] == pytest.approx([2.0]) + assert [person.y for person in scene.people] == pytest.approx([-1.12]) + assert scene.source == "camera" + + +def test_camera_observation_provider_projects_detections_from_latest_odom() -> None: + provider = CameraSeatObservationProvider.__new__(CameraSeatObservationProvider) + provider.config = CameraSeatSceneConfig( + seats=[], + people=[], + robot_x=0.0, + robot_y=0.0, + chair_distance_m=2.0, + lateral_span_m=4.0, + ) + provider._scene_override = None + provider._scene_lock = RLock() + provider._latest_image = Image.from_numpy(np.zeros((100, 100, 3), dtype=np.uint8)) + provider._latest_odom = PoseStamped( + frame_id="map", + position=Vector3(10.0, 20.0, 0.0), + orientation=Quaternion.from_euler(Vector3(0.0, 0.0, math.pi / 2)), + ) + provider._vl_model = FakeVlModel( + { + "chair": [SimpleNamespace(name="chair", bbox=(40.0, 20.0, 60.0, 80.0))], + "person": [SimpleNamespace(name="person", bbox=(90.0, 20.0, 100.0, 80.0))], + } + ) + + scene = provider.get_seat_scene() + + assert scene.robot_x == pytest.approx(10.0) + assert scene.robot_y == pytest.approx(20.0) + assert len(scene.seats) == 1 + assert scene.seats[0].seat_id == "seat_1" + assert scene.seats[0].x == pytest.approx(10.0) + assert scene.seats[0].y == pytest.approx(22.0) + assert scene.seats[0].yaw == pytest.approx(math.pi / 2) + assert len(scene.people) == 1 + assert scene.people[0].x == pytest.approx(8.2) + assert scene.people[0].y == pytest.approx(22.0) + + +def test_camera_observation_provider_uses_one_odom_snapshot_per_detection() -> None: + provider = CameraSeatObservationProvider.__new__(CameraSeatObservationProvider) + provider.config = CameraSeatSceneConfig( + seats=[], + people=[], + robot_x=0.0, + robot_y=0.0, + chair_distance_m=2.0, + lateral_span_m=4.0, + ) + provider._scene_override = None + provider._scene_lock = RLock() + provider._latest_image = Image.from_numpy(np.zeros((100, 100, 3), dtype=np.uint8)) + provider._latest_odom = PoseStamped( + frame_id="map", + position=Vector3(10.0, 20.0, 0.0), + orientation=Quaternion.from_euler(Vector3(0.0, 0.0, 0.0)), + ) + provider._vl_model = OdomMutatingFakeVlModel( + { + "chair": [SimpleNamespace(name="chair", bbox=(40.0, 20.0, 60.0, 80.0))], + "person": [], + }, + provider, + ) + + scene = provider.get_seat_scene() + + assert scene.robot_x == pytest.approx(10.0) + assert scene.robot_y == pytest.approx(20.0) + assert scene.seats[0].x == pytest.approx(12.0) + assert scene.seats[0].y == pytest.approx(20.0) + assert provider._latest_odom.x == pytest.approx(100.0) + + +def test_camera_observation_provider_clamps_detection_bbox_to_image_width() -> None: + provider = CameraSeatObservationProvider.__new__(CameraSeatObservationProvider) + provider.config = CameraSeatSceneConfig( + seats=[], + people=[], + robot_x=0.0, + robot_y=0.0, + chair_distance_m=2.0, + lateral_span_m=4.0, + ) + provider._scene_override = None + provider._scene_lock = RLock() + provider._latest_image = Image.from_numpy(np.zeros((100, 100, 3), dtype=np.uint8)) + provider._latest_odom = PoseStamped( + frame_id="map", + position=Vector3(0.0, 0.0, 0.0), + orientation=Quaternion.from_euler(Vector3(0.0, 0.0, 0.0)), + ) + provider._vl_model = FakeVlModel( + { + "chair": [ + SimpleNamespace(name="chair", bbox=(-20.0, 20.0, 20.0, 80.0)), + SimpleNamespace(name="chair", bbox=(80.0, 20.0, 140.0, 80.0)), + SimpleNamespace(name="chair", bbox=(80.0, 20.0, 20.0, 80.0)), + ], + "person": [], + } + ) + + scene = provider.get_seat_scene() + + assert [seat.x for seat in scene.seats] == pytest.approx([2.0, 2.0, 2.0]) + assert [seat.y for seat in scene.seats] == pytest.approx([-1.6, 1.6, 0.0]) + + +def test_camera_observation_provider_falls_back_without_image() -> None: + provider = CameraSeatObservationProvider.__new__(CameraSeatObservationProvider) + provider.config = CameraSeatSceneConfig( + seats=[1.0, 2.0, 0.0], + people=[1.1, 2.0], + robot_x=-1.0, + robot_y=2.0, + ) + provider._scene_override = None + provider._scene_lock = RLock() + provider._latest_image = None + provider._latest_odom = None + + scene = provider.get_seat_scene() + + assert scene.seats == [SeatObservation("seat_1", 1.0, 2.0, 0.0)] + assert scene.people == [PersonObservation(1.1, 2.0)] + assert scene.robot_x == -1.0 + assert scene.robot_y == 2.0 + assert scene.source == "no_camera_image" + + +def test_camera_observation_provider_requires_odom_for_camera_source() -> None: + provider = CameraSeatObservationProvider.__new__(CameraSeatObservationProvider) + provider.config = CameraSeatSceneConfig( + seats=[1.0, 2.0, 0.0], + people=[1.1, 2.0], + robot_x=-1.0, + robot_y=2.0, + ) + provider._scene_override = None + provider._scene_lock = RLock() + provider._latest_image = Image.from_numpy(np.zeros((100, 100, 3), dtype=np.uint8)) + provider._latest_odom = None + provider._vl_model = FakeVlModel({"chair": [], "person": []}) + + scene = provider.get_seat_scene() + + assert scene.seats == [SeatObservation("seat_1", 1.0, 2.0, 0.0)] + assert scene.people == [PersonObservation(1.1, 2.0)] + assert scene.robot_x == -1.0 + assert scene.robot_y == 2.0 + assert scene.source == "camera_no_odom" + assert provider._vl_model.queries == [] + + +def test_camera_observation_provider_rejects_stale_image( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr(seat_guide_module.time, "time", lambda: 200.0) + provider = CameraSeatObservationProvider.__new__(CameraSeatObservationProvider) + provider.config = CameraSeatSceneConfig(max_input_age_s=5.0) + provider._scene_override = None + provider._scene_lock = RLock() + provider._latest_image = Image.from_numpy( + np.zeros((100, 100, 3), dtype=np.uint8), ts=190.0 + ) + provider._latest_odom = PoseStamped( + ts=199.0, + frame_id="map", + position=Vector3(0.0, 0.0, 0.0), + orientation=Quaternion.from_euler(Vector3(0.0, 0.0, 0.0)), + ) + provider._vl_model = FakeVlModel( + {"chair": [SimpleNamespace(bbox=(40, 20, 60, 80))], "person": []} + ) + + scene = provider.get_seat_scene() + + assert scene.source == "stale_camera_image" + assert provider._vl_model.queries == [] + + +def test_camera_observation_provider_rejects_stale_odom( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr(seat_guide_module.time, "time", lambda: 200.0) + provider = CameraSeatObservationProvider.__new__(CameraSeatObservationProvider) + provider.config = CameraSeatSceneConfig(max_input_age_s=5.0) + provider._scene_override = None + provider._scene_lock = RLock() + provider._latest_image = Image.from_numpy( + np.zeros((100, 100, 3), dtype=np.uint8), ts=199.0 + ) + provider._latest_odom = PoseStamped( + ts=190.0, + frame_id="map", + position=Vector3(0.0, 0.0, 0.0), + orientation=Quaternion.from_euler(Vector3(0.0, 0.0, 0.0)), + ) + provider._vl_model = FakeVlModel( + {"chair": [SimpleNamespace(bbox=(40, 20, 60, 80))], "person": []} + ) + + scene = provider.get_seat_scene() + + assert scene.source == "stale_camera_odom" + assert provider._vl_model.queries == [] + + +def test_camera_seat_provider_status_reports_missing_runtime_inputs( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.delenv("ALIBABA_API_KEY", raising=False) + provider = CameraSeatObservationProvider.__new__(CameraSeatObservationProvider) + provider.config = CameraSeatSceneConfig( + seats=[1.0, 2.0, 0.0], + people=[1.1, 2.0], + ) + provider._scene_override = None + provider._scene_lock = RLock() + provider._latest_image = None + provider._latest_odom = None + + assert provider.camera_seat_provider_status() == ( + "CameraSeatObservationProvider status: image=missing; image_fresh=missing; " + "odom=missing; odom_fresh=missing; detection_model=qwen; " + "credential=missing; override=inactive; " + "configured_fallback_seats=1; configured_fallback_people=1." + ) + + +def test_camera_seat_provider_status_reports_live_inputs_and_credentials( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setenv("ALIBABA_API_KEY", "test-key") + monkeypatch.setattr(seat_guide_module.time, "time", lambda: 101.0) + provider = CameraSeatObservationProvider.__new__(CameraSeatObservationProvider) + provider.config = CameraSeatSceneConfig(seats=[], people=[]) + provider._scene_override = None + provider._scene_lock = RLock() + provider._latest_image = Image.from_numpy( + np.zeros((120, 160, 3), dtype=np.uint8), ts=100.0 + ) + provider._latest_odom = PoseStamped( + ts=100.0, + frame_id="map", + position=Vector3(1.0, 2.0, 0.0), + orientation=Quaternion.from_euler(Vector3(0.0, 0.0, 0.5)), + ) + + assert provider.camera_seat_provider_status() == ( + "CameraSeatObservationProvider status: image=160x120; " + "image_fresh=true; odom=(1.00, 2.00, yaw=0.50); " + "odom_fresh=true; detection_model=qwen; " + "credential=present; override=inactive; configured_fallback_seats=0; " + "configured_fallback_people=0." + ) + + +def test_camera_seat_provider_status_reports_runtime_override() -> None: + provider = CameraSeatObservationProvider.__new__(CameraSeatObservationProvider) + provider.config = CameraSeatSceneConfig(seats=[], people=[]) + provider._scene_override = SeatSceneObservation( + seats=[SeatObservation("manual", 1.0, 0.0, 0.0)], + people=[], + source="runtime_override", + ) + provider._scene_lock = RLock() + provider._latest_image = None + provider._latest_odom = None + + assert "override=active" in provider.camera_seat_provider_status() + + +def test_camera_observation_provider_runtime_override_source() -> None: + provider = CameraSeatObservationProvider.__new__(CameraSeatObservationProvider) + provider.config = CameraSeatSceneConfig() + provider._scene_override = None + provider._scene_lock = RLock() + + provider.set_seat_scene( + seats=[1.0, 2.0, 0.0], + people=[1.1, 2.0], + robot_x=-1.0, + robot_y=2.0, + ) + scene = provider.get_seat_scene() + + assert scene.source == "runtime_override" + assert scene.seats == [SeatObservation("seat_1", 1.0, 2.0, 0.0)] + + +def test_camera_observation_provider_reports_empty_camera_detection_source() -> None: + provider = CameraSeatObservationProvider.__new__(CameraSeatObservationProvider) + provider.config = CameraSeatSceneConfig( + seats=[1.0, 2.0, 0.0], + people=[], + robot_x=-1.0, + robot_y=2.0, + ) + provider._scene_override = None + provider._scene_lock = RLock() + provider._latest_image = Image.from_numpy(np.zeros((100, 100, 3), dtype=np.uint8)) + provider._latest_odom = PoseStamped( + frame_id="map", + position=Vector3(0.0, 0.0, 0.0), + orientation=Quaternion.from_euler(Vector3(0.0, 0.0, 0.0)), + ) + provider._vl_model = FakeVlModel({"chair": [], "person": []}) + + scene = provider.get_seat_scene() + + assert scene.seats == [SeatObservation("seat_1", 1.0, 2.0, 0.0)] + assert scene.source == "camera_no_seats_detected" + + +def test_camera_observation_provider_reports_detection_error_source() -> None: + provider = CameraSeatObservationProvider.__new__(CameraSeatObservationProvider) + provider.config = CameraSeatSceneConfig( + seats=[1.0, 2.0, 0.0], + people=[], + robot_x=-1.0, + robot_y=2.0, + ) + provider._scene_override = None + provider._scene_lock = RLock() + provider._latest_image = Image.from_numpy(np.zeros((100, 100, 3), dtype=np.uint8)) + provider._latest_odom = PoseStamped( + frame_id="map", + position=Vector3(0.0, 0.0, 0.0), + orientation=Quaternion.from_euler(Vector3(0.0, 0.0, 0.0)), + ) + + def raise_detection_error( + image: Image, odom: PoseStamped | None = None + ) -> SeatSceneObservation: + raise RuntimeError("vlm unavailable") + + provider._detect_scene_from_image = raise_detection_error + + scene = provider.get_seat_scene() + + assert scene.seats == [SeatObservation("seat_1", 1.0, 2.0, 0.0)] + assert scene.source == "camera_detection_error" + + +def test_camera_observation_provider_reports_missing_qwen_key_as_detection_error( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.delenv("ALIBABA_API_KEY", raising=False) + provider = CameraSeatObservationProvider.__new__(CameraSeatObservationProvider) + provider.config = CameraSeatSceneConfig( + seats=[1.0, 2.0, 0.0], + people=[], + robot_x=-1.0, + robot_y=2.0, + detection_model="qwen", + ) + provider._scene_override = None + provider._scene_lock = RLock() + provider._latest_image = Image.from_numpy(np.zeros((100, 100, 3), dtype=np.uint8)) + provider._latest_odom = PoseStamped( + frame_id="map", + position=Vector3(0.0, 0.0, 0.0), + orientation=Quaternion.from_euler(Vector3(0.0, 0.0, 0.0)), + ) + provider._vl_model = None + + scene = provider.get_seat_scene() + + assert scene.seats == [SeatObservation("seat_1", 1.0, 2.0, 0.0)] + assert scene.source == "camera_detection_error" + + +def test_camera_observation_provider_does_not_fake_default_scene_without_image() -> None: + provider = CameraSeatObservationProvider.__new__(CameraSeatObservationProvider) + provider.config = CameraSeatSceneConfig() + provider._scene_override = None + provider._scene_lock = RLock() + provider._latest_image = None + + scene = provider.get_seat_scene() + + assert scene.seats == [] + assert scene.people == [] + assert scene.source == "no_camera_image" + + +def test_parse_flat_observation_lists() -> None: + assert _parse_seats([1.0, 2.0, 0.1, 3.0, 4.0, 0.2]) == [ + SeatObservation("seat_1", 1.0, 2.0, 0.1), + SeatObservation("seat_2", 3.0, 4.0, 0.2), + ] + assert _parse_people([1.0, 2.0, 3.0, 4.0]) == [ + PersonObservation(1.0, 2.0), + PersonObservation(3.0, 4.0), + ] + assert _flatten_seats([SeatObservation("ignored", 1.0, 2.0, 0.1)]) == [1.0, 2.0, 0.1] + assert _flatten_people([PersonObservation(1.0, 2.0)]) == [1.0, 2.0] + + +def test_parse_flat_observation_lists_reject_bad_lengths() -> None: + with pytest.raises(ValueError, match="triples"): + _parse_seats([1.0, 2.0]) + + with pytest.raises(ValueError, match="pairs"): + _parse_people([1.0]) + + +@pytest.mark.parametrize( + "text", + [ + "Please find me an empty seat", + "guide me to a chair", + "帮我找一个空位", + "带我去座位", + "我要找到一个位置", + "帮我找到附近的位置, 然后找到一个空位置", + ], +) +def test_parse_seat_guide_intent_accepts_seat_requests(text: str) -> None: + intent = parse_seat_guide_intent(text) + + assert intent.should_find_seat + assert intent.normalized_text + + +@pytest.mark.parametrize( + "text", + [ + "preview find me an empty seat", + "preflight guide me to a chair", + "预检帮我找一个空位", + "测试带我去座位", + "不要动, 先帮我找一个位置", + ], +) +def test_parse_seat_guide_preview_request_detects_no_motion_requests(text: str) -> None: + assert is_seat_guide_preview_request(text) + + +@pytest.mark.parametrize( + "text", + [ + "", + "start patrol", + "what is on the table", + "会议什么时候开始", + ], +) +def test_parse_seat_guide_intent_rejects_other_requests(text: str) -> None: + assert not parse_seat_guide_intent(text).should_find_seat + + +def _complete_acceptance_transcript() -> str: + return """Hardware run registry: /tmp/dimos/runs/hardware.json +Hardware run mode: hardware. +Hardware blueprint: unitree-go2-seat-guide-agentic +WebInput status: web=started; thread=running; seat_route=seat_guide_direct; responses=connected; voice_upload=connected; stt=connected; human_transport=connected; url=http://localhost:5555. +Using WebInput URL: http://localhost:5555 +{"modules": {"CameraSeatObservationProvider": ["camera_seat_provider_status"], "SeatGuideSkillContainer": ["seat_guide_status"], "WebInput": ["web_input_status"], "SpeakSkill": ["speech_status"]}} +image=160x120 +image_fresh=true +credential=present +odom=(1.00, 2.00, yaw=0.50) +odom_fresh=true +override=inactive +configured_fallback_seats=0 +configured_fallback_people=0 +tts=ready +audio_output=connected ++ dimos mcp call speak --json-args {"text": "SeatGuide audio check. I can guide you to an empty seat.", "blocking": true} +Spoke: SeatGuide audio check. I can guide you to an empty seat. +Audio output confirmation: +Operator audio confirmation: HEARD +SeatGuide scene source=camera: 2 seats [seat_1=(1.00, 2.00, yaw=0.00)], 0 people [none], robot=(1.00, 2.00). +SeatGuide preflight ready: navigation=IDLE; perception=camera seats=2 people=0; empty=2 occupied=0; selected=seat_1; goal=(1.65, 2.00, yaw=0.00); speaker=connected. +SeatGuide preview source=camera: selected seat_1 empty=2 occupied=0 seat=(1.00, 2.00, yaw=0.00) goal=(1.65, 2.00, yaw=0.00). +Captured WebInput agent_responses stream +Manual no-motion voice gate: +Press Enter here when ready. +Click the microphone button and say: 预检帮我找一个空位 +Captured WebInput voice agent_responses stream +WebInput received text text=预检帮我找一个空位 +WebInput received text text=预检帮我找一个空位 +WebInput received text text=帮我找一个空位 +WebInput routing text to SeatGuide preview text=预检帮我找一个空位 +Capturing DimOS log snapshot after no-motion checks +No-motion checks completed. +Operator confirmation: LIVE +Live voice navigation gate: +Press Enter here when ready. +Say: 帮我找一个空位 +Captured live WebInput voice agent_responses stream +WebInput routing text to SeatGuide live request text=帮我找一个空位 +Navigating to +goal_sequence=1 +Checking SeatGuide navigation completion +goal_reached=true +SeatGuide navigation goal reached +Capturing DimOS log snapshot after live request +""" + + +def test_acceptance_log_verifier_accepts_complete_hardware_transcript( + tmp_path: Path, +) -> None: + log_file = tmp_path / "acceptance.log" + log_file.write_text(_complete_acceptance_transcript()) + + result = subprocess.run( + ["bash", str(ACCEPTANCE_LOG_VERIFIER), str(log_file)], + check=False, + text=True, + capture_output=True, + ) + + assert result.returncode == 0 + assert "contains all required evidence" in result.stdout + + +def test_acceptance_log_verifier_accepts_earlier_stale_goal_reached_status( + tmp_path: Path, +) -> None: + log_file = tmp_path / "acceptance.log" + log_file.write_text( + _complete_acceptance_transcript().replace( + "Operator confirmation: LIVE\n", + "goal_reached=true\nOperator confirmation: LIVE\n", + 1, + ) + ) + + result = subprocess.run( + ["bash", str(ACCEPTANCE_LOG_VERIFIER), str(log_file)], + check=False, + text=True, + capture_output=True, + ) + + assert result.returncode == 0 + assert "contains all required evidence" in result.stdout + + +@pytest.mark.parametrize( + ("missing_text", "expected_error"), + [ + ("Hardware run mode: hardware.\n", "hardware run mode"), + ("Hardware run registry: /tmp/dimos/runs/hardware.json\n", "hardware run registry"), + ( + "Hardware blueprint: unitree-go2-seat-guide-agentic\n", + "SeatGuide hardware blueprint", + ), + ("voice_upload=connected; ", "WebInput browser audio upload route"), + ("stt=connected; ", "WebInput speech-to-text pipeline"), + ("image=160x120\n", "camera image readiness"), + ("image_fresh=true\n", "fresh camera image readiness"), + ("odom_fresh=true\n", "fresh odometry readiness"), + ("override=inactive\n", "camera runtime override disabled"), + ("configured_fallback_seats=0\n", "camera fallback seats disabled"), + ("configured_fallback_people=0\n", "camera fallback people disabled"), + ( + '"SeatGuideSkillContainer": ["seat_guide_status"], ', + "SeatGuide planner/navigation module", + ), + ( + "Capturing DimOS log snapshot after no-motion checks\n", + "no-motion DimOS log snapshot", + ), + ("No-motion checks completed.\n", "no-motion completion marker"), + ("Manual no-motion voice gate:\n", "browser microphone no-motion gate"), + ("Press Enter here when ready.\n", "browser microphone readiness prompts"), + ( + "Click the microphone button and say: 预检帮我找一个空位\n", + "browser microphone no-motion spoken phrase", + ), + ("Live voice navigation gate:\n", "browser microphone live gate"), + ("Say: 帮我找一个空位\n", "browser microphone live spoken phrase"), + ( + "WebInput routing text to SeatGuide live request text=帮我找一个空位\n", + "live WebInput SeatGuide route", + ), + ( + "WebInput received text text=预检帮我找一个空位\n", + "WebInput recognized text events", + ), + ( + "Capturing DimOS log snapshot after live request\n", + "live DimOS log snapshot", + ), + ("speaker=connected", "SeatGuide speaker wiring"), + ( + "Spoke: SeatGuide audio check. I can guide you to an empty seat.\n", + "TTS audio check completion", + ), + ("Operator audio confirmation: HEARD\n", "operator heard TTS confirmation"), + ("goal_reached=true\n", "navigation completion"), + ], +) +def test_acceptance_log_verifier_rejects_missing_required_evidence( + tmp_path: Path, + missing_text: str, + expected_error: str, +) -> None: + log_file = tmp_path / "acceptance.log" + log_file.write_text(_complete_acceptance_transcript().replace(missing_text, "", 1)) + + result = subprocess.run( + ["bash", str(ACCEPTANCE_LOG_VERIFIER), str(log_file)], + check=False, + text=True, + capture_output=True, + ) + + assert result.returncode == 3 + assert expected_error in result.stderr + + +def test_acceptance_log_verifier_rejects_missing_occupancy_counts( + tmp_path: Path, +) -> None: + log_file = tmp_path / "acceptance.log" + log_file.write_text( + _complete_acceptance_transcript() + .replace("empty=2 occupied=0; ", "") + .replace("empty=2 occupied=0 ", "") + ) + + result = subprocess.run( + ["bash", str(ACCEPTANCE_LOG_VERIFIER), str(log_file)], + check=False, + text=True, + capture_output=True, + ) + + assert result.returncode == 3 + assert "SeatGuide occupancy counts" in result.stderr + + +def test_acceptance_log_verifier_rejects_wrong_recognized_live_phrase( + tmp_path: Path, +) -> None: + log_file = tmp_path / "acceptance.log" + log_file.write_text( + _complete_acceptance_transcript().replace( + "WebInput received text text=帮我找一个空位\n", + "WebInput received text text=帮我找一个垃圾桶\n", + 1, + ) + ) + + result = subprocess.run( + ["bash", str(ACCEPTANCE_LOG_VERIFIER), str(log_file)], + check=False, + text=True, + capture_output=True, + ) + + assert result.returncode == 3 + assert "recognized live SeatGuide phrase" in result.stderr + + +def test_acceptance_log_verifier_rejects_wrong_routed_preview_phrase( + tmp_path: Path, +) -> None: + log_file = tmp_path / "acceptance.log" + log_file.write_text( + _complete_acceptance_transcript().replace( + "WebInput routing text to SeatGuide preview text=预检帮我找一个空位\n", + "WebInput routing text to SeatGuide preview text=预检帮我找一个垃圾桶\n", + 1, + ) + ) + + result = subprocess.run( + ["bash", str(ACCEPTANCE_LOG_VERIFIER), str(log_file)], + check=False, + text=True, + capture_output=True, + ) + + assert result.returncode == 3 + assert "no-motion WebInput SeatGuide phrase route" in result.stderr + + +def test_acceptance_log_verifier_rejects_live_before_no_motion_completion( + tmp_path: Path, +) -> None: + log_file = tmp_path / "acceptance.log" + log_file.write_text( + _complete_acceptance_transcript().replace( + "No-motion checks completed.\nOperator confirmation: LIVE\n", + "Operator confirmation: LIVE\nNo-motion checks completed.\n", + 1, + ) + ) + + result = subprocess.run( + ["bash", str(ACCEPTANCE_LOG_VERIFIER), str(log_file)], + check=False, + text=True, + capture_output=True, + ) + + assert result.returncode == 3 + assert "no-motion before live order" in result.stderr + + +def test_acceptance_log_verifier_rejects_no_motion_completion_before_snapshot( + tmp_path: Path, +) -> None: + log_file = tmp_path / "acceptance.log" + log_file.write_text( + _complete_acceptance_transcript().replace( + "Capturing DimOS log snapshot after no-motion checks\nNo-motion checks completed.\n", + "No-motion checks completed.\nCapturing DimOS log snapshot after no-motion checks\n", + 1, + ) + ) + + result = subprocess.run( + ["bash", str(ACCEPTANCE_LOG_VERIFIER), str(log_file)], + check=False, + text=True, + capture_output=True, + ) + + assert result.returncode == 3 + assert "no-motion snapshot before completion order" in result.stderr + + +def test_acceptance_log_verifier_rejects_no_motion_speech_before_readiness_prompt( + tmp_path: Path, +) -> None: + log_file = tmp_path / "acceptance.log" + log_file.write_text( + _complete_acceptance_transcript().replace( + "Press Enter here when ready.\nClick the microphone button and say: 预检帮我找一个空位\n", + "Click the microphone button and say: 预检帮我找一个空位\nPress Enter here when ready.\n", + 1, + ) + ) + + result = subprocess.run( + ["bash", str(ACCEPTANCE_LOG_VERIFIER), str(log_file)], + check=False, + text=True, + capture_output=True, + ) + + assert result.returncode == 3 + assert "no-motion readiness before speech order" in result.stderr + + +def test_acceptance_log_verifier_rejects_live_speech_before_live_readiness_prompt( + tmp_path: Path, +) -> None: + log_file = tmp_path / "acceptance.log" + log_file.write_text( + _complete_acceptance_transcript().replace( + "Press Enter here when ready.\nSay: 帮我找一个空位\n", + "Say: 帮我找一个空位\nPress Enter here when ready.\n", + 1, + ) + ) + + result = subprocess.run( + ["bash", str(ACCEPTANCE_LOG_VERIFIER), str(log_file)], + check=False, + text=True, + capture_output=True, + ) + + assert result.returncode == 3 + assert "live readiness before speech order" in result.stderr + + +def test_acceptance_log_verifier_rejects_navigation_before_live_route( + tmp_path: Path, +) -> None: + log_file = tmp_path / "acceptance.log" + log_file.write_text( + _complete_acceptance_transcript().replace( + "WebInput routing text to SeatGuide live request text=帮我找一个空位\nNavigating to\n", + "Navigating to\nWebInput routing text to SeatGuide live request text=帮我找一个空位\n", + 1, + ) + ) + + result = subprocess.run( + ["bash", str(ACCEPTANCE_LOG_VERIFIER), str(log_file)], + check=False, + text=True, + capture_output=True, + ) + + assert result.returncode == 3 + assert "live route before navigation order" in result.stderr + + +def test_acceptance_log_verifier_rejects_audio_confirmation_before_tts_completion( + tmp_path: Path, +) -> None: + log_file = tmp_path / "acceptance.log" + log_file.write_text( + _complete_acceptance_transcript().replace( + "Spoke: SeatGuide audio check. I can guide you to an empty seat.\n" + "Audio output confirmation:\n" + "Operator audio confirmation: HEARD\n", + "Audio output confirmation:\n" + "Operator audio confirmation: HEARD\n" + "Spoke: SeatGuide audio check. I can guide you to an empty seat.\n", + 1, + ) + ) + + result = subprocess.run( + ["bash", str(ACCEPTANCE_LOG_VERIFIER), str(log_file)], + check=False, + text=True, + capture_output=True, + ) + + assert result.returncode == 3 + assert "TTS completion before operator audio confirmation order" in result.stderr + + +def test_acceptance_log_verifier_rejects_goal_reached_before_polling( + tmp_path: Path, +) -> None: + log_file = tmp_path / "acceptance.log" + log_file.write_text( + _complete_acceptance_transcript().replace( + "Navigating to\ngoal_sequence=1\nChecking SeatGuide navigation completion\ngoal_reached=true\n", + "Navigating to\ngoal_reached=true\ngoal_sequence=1\nChecking SeatGuide navigation completion\n", + 1, + ) + ) + + result = subprocess.run( + ["bash", str(ACCEPTANCE_LOG_VERIFIER), str(log_file)], + check=False, + text=True, + capture_output=True, + ) + + assert result.returncode == 3 + assert "polling before completion order" in result.stderr + + +def test_acceptance_log_verifier_rejects_direct_mcp_live_request( + tmp_path: Path, +) -> None: + log_file = tmp_path / "acceptance.log" + log_file.write_text( + _complete_acceptance_transcript() + + '\n+ dimos mcp call handle_seat_request --json-args \'{"text": "帮我找一个空位"}\'\n' + ) + + result = subprocess.run( + ["bash", str(ACCEPTANCE_LOG_VERIFIER), str(log_file)], + check=False, + text=True, + capture_output=True, + ) + + assert result.returncode == 3 + assert "direct MCP live SeatGuide call" in result.stderr + + +@pytest.mark.parametrize( + ("forbidden_text", "expected_error"), + [ + ( + '+ dimos mcp call set_seat_scene --json-args \'{"seats": [0, 0, 0], "people": []}\'', + "fallback seat scene calibration", + ), + ( + "+ dimos mcp call clear_seat_scene_override", + "fallback seat scene override clearing", + ), + ( + 'dimos mcp call seat_guide_preflight --json-args \'{"require_live_perception": false}\'', + "fallback live-perception bypass", + ), + ( + 'dimos mcp call seat_guide_preflight --json-args \'{"require_live_perception":false}\'', + "fallback live-perception bypass", + ), + ( + "dimos mcp call seat_guide_preflight --arg require_live_perception=false", + "fallback live-perception bypass", + ), + ], +) +def test_acceptance_log_verifier_rejects_fallback_calibration_evidence( + tmp_path: Path, + forbidden_text: str, + expected_error: str, +) -> None: + log_file = tmp_path / "acceptance.log" + log_file.write_text(_complete_acceptance_transcript() + f"\n{forbidden_text}\n") + + result = subprocess.run( + ["bash", str(ACCEPTANCE_LOG_VERIFIER), str(log_file)], + check=False, + text=True, + capture_output=True, + ) + + assert result.returncode == 3 + assert expected_error in result.stderr + + +def _extract_bash_function(source: str, name: str) -> str: + lines = source.splitlines() + start = lines.index(f"{name}() {{") + body = [lines[start]] + for line in lines[start + 1 :]: + body.append(line) + if line == "}": + return "\n".join(body) + raise AssertionError(f"Could not extract bash function {name}") + + +def _run_hardware_registry_guard( + tmp_path: Path, + *, + run_id: str, + registry: dict[str, Any], +) -> subprocess.CompletedProcess[str]: + state_dir = tmp_path / "state" + registry_dir = state_dir / "dimos" / "runs" + registry_dir.mkdir(parents=True) + (registry_dir / f"{run_id}.json").write_text(json.dumps(registry)) + log_file = tmp_path / "acceptance.log" + + script_source = HARDWARE_ACCEPTANCE_SCRIPT.read_text() + wrapper = tmp_path / "guard.sh" + wrapper.write_text( + "\n".join( + [ + "set -euo pipefail", + _extract_bash_function(script_source, "log"), + _extract_bash_function(script_source, "extract_run_id"), + _extract_bash_function( + script_source, "require_hardware_run_registry" + ), + 'log_file="$1"', + 'XDG_STATE_HOME="$2"', + f"require_hardware_run_registry $' Run ID: {run_id}\\n'", + ] + ) + ) + + return subprocess.run( + ["bash", str(wrapper), str(log_file), str(state_dir)], + check=False, + text=True, + capture_output=True, + ) + + +def _run_smoke_registry_guard( + tmp_path: Path, + *, + run_id: str, + registry: dict[str, Any], +) -> subprocess.CompletedProcess[str]: + state_dir = tmp_path / "state" + registry_dir = state_dir / "dimos" / "runs" + registry_dir.mkdir(parents=True) + (registry_dir / f"{run_id}.json").write_text(json.dumps(registry)) + + script_source = SMOKE_SCRIPT.read_text() + wrapper = tmp_path / "smoke_guard.sh" + wrapper.write_text( + "\n".join( + [ + "set -euo pipefail", + _extract_bash_function(script_source, "extract_run_id"), + _extract_bash_function( + script_source, "require_seat_guide_run_registry" + ), + 'XDG_STATE_HOME="$1"', + f"require_seat_guide_run_registry $' Run ID: {run_id}\\n'", + ] + ) + ) + + return subprocess.run( + ["bash", str(wrapper), str(state_dir)], + check=False, + text=True, + capture_output=True, + ) + + +def _run_web_input_url_extract(tmp_path: Path, status_text: str) -> str: + script_source = HARDWARE_ACCEPTANCE_SCRIPT.read_text() + wrapper = tmp_path / "extract_url.sh" + wrapper.write_text( + "\n".join( + [ + "set -euo pipefail", + _extract_bash_function(script_source, "extract_web_input_url"), + 'extract_web_input_url "$1"', + ] + ) + ) + result = subprocess.run( + ["bash", str(wrapper), status_text], + check=True, + text=True, + capture_output=True, + ) + return result.stdout.strip() + + +def _run_goal_sequence_extract(tmp_path: Path, status_text: str) -> str: + script_source = HARDWARE_ACCEPTANCE_SCRIPT.read_text() + wrapper = tmp_path / "extract_goal_sequence.sh" + wrapper.write_text( + "\n".join( + [ + "set -euo pipefail", + _extract_bash_function(script_source, "extract_goal_sequence"), + 'extract_goal_sequence "$1"', + ] + ) + ) + result = subprocess.run( + ["bash", str(wrapper), status_text], + check=True, + text=True, + capture_output=True, + ) + return result.stdout.strip() + + +def _run_goal_completion_check( + tmp_path: Path, + *, + previous_sequence: int, + status_text: str, +) -> subprocess.CompletedProcess[str]: + script_source = HARDWARE_ACCEPTANCE_SCRIPT.read_text() + wrapper = tmp_path / "goal_completed.sh" + wrapper.write_text( + "\n".join( + [ + "set -euo pipefail", + _extract_bash_function(script_source, "extract_goal_sequence"), + _extract_bash_function( + script_source, "seat_guide_goal_completed_after_sequence" + ), + 'seat_guide_goal_completed_after_sequence "$1" "$2"', + ] + ) + ) + return subprocess.run( + ["bash", str(wrapper), str(previous_sequence), status_text], + check=False, + text=True, + capture_output=True, + ) + + +def _run_preflight_ready_check( + tmp_path: Path, + status_text: str, +) -> subprocess.CompletedProcess[str]: + script_source = HARDWARE_ACCEPTANCE_SCRIPT.read_text() + wrapper = tmp_path / "preflight_ready.sh" + wrapper.write_text( + "\n".join( + [ + "set -euo pipefail", + _extract_bash_function( + script_source, "seat_guide_preflight_ready_for_hardware" + ), + 'seat_guide_preflight_ready_for_hardware "$1"', + ] + ) + ) + return subprocess.run( + ["bash", str(wrapper), status_text], + check=False, + text=True, + capture_output=True, + ) + + +def _run_web_input_ready_check( + tmp_path: Path, + status_text: str, +) -> subprocess.CompletedProcess[str]: + script_source = HARDWARE_ACCEPTANCE_SCRIPT.read_text() + wrapper = tmp_path / "web_input_ready.sh" + wrapper.write_text( + "\n".join( + [ + "set -euo pipefail", + _extract_bash_function(script_source, "web_input_ready_for_seat_guide"), + 'web_input_ready_for_seat_guide "$1"', + ] + ) + ) + return subprocess.run( + ["bash", str(wrapper), status_text], + check=False, + text=True, + capture_output=True, + ) + + +def _run_web_input_no_go_details( + tmp_path: Path, + status_text: str, +) -> subprocess.CompletedProcess[str]: + script_source = HARDWARE_ACCEPTANCE_SCRIPT.read_text() + wrapper = tmp_path / "web_input_details.sh" + wrapper.write_text( + "\n".join( + [ + "set -euo pipefail", + _extract_bash_function(script_source, "log"), + _extract_bash_function(script_source, "log_web_input_no_go_details"), + 'log_file="$1"', + 'log_web_input_no_go_details "$2"', + ] + ) + ) + return subprocess.run( + ["bash", str(wrapper), str(tmp_path / "web_input.log"), status_text], + check=False, + text=True, + capture_output=True, + ) + + +def _run_camera_provider_ready_check( + tmp_path: Path, + status_text: str, +) -> subprocess.CompletedProcess[str]: + script_source = HARDWARE_ACCEPTANCE_SCRIPT.read_text() + wrapper = tmp_path / "camera_ready.sh" + wrapper.write_text( + "\n".join( + [ + "set -euo pipefail", + _extract_bash_function( + script_source, "camera_provider_ready_for_hardware" + ), + 'camera_provider_ready_for_hardware "$1"', + ] + ) + ) + return subprocess.run( + ["bash", str(wrapper), status_text], + check=False, + text=True, + capture_output=True, + ) + + +def _run_camera_provider_no_go_details( + tmp_path: Path, + status_text: str, +) -> subprocess.CompletedProcess[str]: + script_source = HARDWARE_ACCEPTANCE_SCRIPT.read_text() + wrapper = tmp_path / "camera_details.sh" + wrapper.write_text( + "\n".join( + [ + "set -euo pipefail", + _extract_bash_function(script_source, "log"), + _extract_bash_function( + script_source, "log_camera_provider_no_go_details" + ), + 'log_file="$1"', + 'log_camera_provider_no_go_details "$2"', + ] + ) + ) + return subprocess.run( + ["bash", str(wrapper), str(tmp_path / "camera.log"), status_text], + check=False, + text=True, + capture_output=True, + ) + + +def _run_speech_no_go_details( + tmp_path: Path, + status_text: str, +) -> subprocess.CompletedProcess[str]: + script_source = HARDWARE_ACCEPTANCE_SCRIPT.read_text() + wrapper = tmp_path / "speech_details.sh" + wrapper.write_text( + "\n".join( + [ + "set -euo pipefail", + _extract_bash_function(script_source, "log"), + _extract_bash_function(script_source, "log_speech_no_go_details"), + 'log_file="$1"', + 'log_speech_no_go_details "$2"', + ] + ) + ) + return subprocess.run( + ["bash", str(wrapper), str(tmp_path / "speech.log"), status_text], + check=False, + text=True, + capture_output=True, + ) + + +def _run_seat_guide_no_go_details( + tmp_path: Path, + status_text: str, +) -> subprocess.CompletedProcess[str]: + script_source = HARDWARE_ACCEPTANCE_SCRIPT.read_text() + wrapper = tmp_path / "seat_guide_details.sh" + wrapper.write_text( + "\n".join( + [ + "set -euo pipefail", + _extract_bash_function(script_source, "log"), + _extract_bash_function(script_source, "log_seat_guide_no_go_details"), + 'log_file="$1"', + 'log_seat_guide_no_go_details "$2"', + ] + ) + ) + return subprocess.run( + ["bash", str(wrapper), str(tmp_path / "seat_guide.log"), status_text], + check=False, + text=True, + capture_output=True, + ) + + +def _run_stream_wait_check( + tmp_path: Path, + stream_text: str, + expected_text: str, + start_offset: int = 0, +) -> subprocess.CompletedProcess[str]: + stream_file = tmp_path / "stream.txt" + stream_file.write_text(stream_text) + script_source = HARDWARE_ACCEPTANCE_SCRIPT.read_text() + wrapper = tmp_path / "stream_wait.sh" + wrapper.write_text( + "\n".join( + [ + "set -euo pipefail", + _extract_bash_function(script_source, "wait_for_stream_text"), + 'wait_for_stream_text "$1" "$2" 0 "$3"', + ] + ) + ) + return subprocess.run( + ["bash", str(wrapper), str(stream_file), expected_text, str(start_offset)], + check=False, + text=True, + capture_output=True, + ) + + +@pytest.mark.parametrize( + ("status_text", "expected_url"), + [ + ( + "WebInput status: web=started; url=http://localhost:5555.", + "http://localhost:5555", + ), + ( + "WebInput status: web=started; url=http://127.0.0.1:6001.", + "http://127.0.0.1:6001", + ), + ( + "WebInput status: web=not_started; url=unavailable.", + "", + ), + ], +) +def test_hardware_acceptance_extracts_web_input_url( + tmp_path: Path, + status_text: str, + expected_url: str, +) -> None: + assert _run_web_input_url_extract(tmp_path, status_text) == expected_url + + +@pytest.mark.parametrize( + ("status_text", "expected_sequence"), + [ + ( + "SeatGuide navigation status: navigation=IDLE; goal_reached=false; goal_sequence=0.", + "0", + ), + ( + "old goal_sequence=1\nnew goal_sequence=2", + "2", + ), + ( + "SeatGuide navigation status: navigation=missing; goal_reached=unknown.", + "", + ), + ], +) +def test_hardware_acceptance_extracts_goal_sequence( + tmp_path: Path, + status_text: str, + expected_sequence: str, +) -> None: + assert _run_goal_sequence_extract(tmp_path, status_text) == expected_sequence + + +@pytest.mark.parametrize( + ("previous_sequence", "status_text", "expected_returncode"), + [ + ( + 1, + "SeatGuide navigation status: navigation=IDLE; goal_reached=true; goal_sequence=2.", + 0, + ), + ( + 1, + "SeatGuide navigation status: navigation=IDLE; goal_reached=true; goal_sequence=1.", + 1, + ), + ( + 1, + "SeatGuide navigation status: navigation=FOLLOWING_PATH; goal_reached=false; goal_sequence=2.", + 1, + ), + ( + 1, + "SeatGuide navigation status: navigation=missing; goal_reached=unknown.", + 1, + ), + ], +) +def test_hardware_acceptance_goal_completion_requires_new_reached_goal( + tmp_path: Path, + previous_sequence: int, + status_text: str, + expected_returncode: int, +) -> None: + result = _run_goal_completion_check( + tmp_path, + previous_sequence=previous_sequence, + status_text=status_text, + ) + + assert result.returncode == expected_returncode + + +@pytest.mark.parametrize( + ("status_text", "expected_returncode"), + [ + ( + "SeatGuide preflight ready: navigation=IDLE; perception=camera seats=2 people=0; selected=seat_1; goal=(1.65, 2.00, yaw=0.00); speaker=connected.", + 0, + ), + ( + "SeatGuide preflight ready: navigation=FOLLOWING_PATH; perception=camera seats=2 people=0; selected=seat_1; goal=(1.65, 2.00, yaw=0.00); speaker=connected.", + 1, + ), + ( + "SeatGuide preflight ready: navigation=IDLE; perception=camera seats=2 people=0; selected=seat_1; goal=(1.65, 2.00, yaw=0.00); speaker=missing.", + 1, + ), + ( + "SeatGuide preflight no-go: navigation=IDLE; perception=camera no seats; speaker=connected.", + 1, + ), + ], +) +def test_hardware_acceptance_preflight_requires_navigation_and_speaker_ready( + tmp_path: Path, + status_text: str, + expected_returncode: int, +) -> None: + result = _run_preflight_ready_check(tmp_path, status_text) + + assert result.returncode == expected_returncode + + +@pytest.mark.parametrize( + ("status_text", "expected_returncode"), + [ + ( + "WebInput status: web=started; thread=running; seat_route=seat_guide_direct; responses=connected; voice_upload=connected; stt=connected; human_transport=connected; url=http://localhost:5555.", + 0, + ), + ( + "WebInput status: web=started; thread=not_running; seat_route=seat_guide_direct; responses=connected; voice_upload=connected; stt=connected; human_transport=connected; url=http://localhost:5555.", + 1, + ), + ( + "WebInput status: web=started; thread=running; seat_route=agent_only; responses=connected; voice_upload=connected; stt=connected; human_transport=connected; url=http://localhost:5555.", + 1, + ), + ( + "WebInput status: web=started; thread=running; seat_route=seat_guide_direct; responses=missing; voice_upload=connected; stt=connected; human_transport=connected; url=http://localhost:5555.", + 1, + ), + ( + "WebInput status: web=started; thread=running; seat_route=seat_guide_direct; responses=connected; voice_upload=missing; stt=connected; human_transport=connected; url=http://localhost:5555.", + 1, + ), + ( + "WebInput status: web=started; thread=running; seat_route=seat_guide_direct; responses=connected; voice_upload=connected; stt=missing; human_transport=connected; url=http://localhost:5555.", + 1, + ), + ( + "WebInput status: web=started; thread=running; seat_route=seat_guide_direct; responses=connected; voice_upload=connected; stt=error(RuntimeError: whisper missing); human_transport=connected; url=http://localhost:5555.", + 1, + ), + ( + "WebInput status: web=started; thread=running; seat_route=seat_guide_direct; responses=connected; voice_upload=connected; stt=connected; human_transport=missing; url=http://localhost:5555.", + 1, + ), + ], +) +def test_hardware_acceptance_web_input_requires_complete_voice_route( + tmp_path: Path, + status_text: str, + expected_returncode: int, +) -> None: + result = _run_web_input_ready_check(tmp_path, status_text) + + assert result.returncode == expected_returncode + + +def test_hardware_acceptance_web_input_no_go_details_are_actionable( + tmp_path: Path, +) -> None: + status_text = ( + "WebInput status: web=not_started; thread=not_running; seat_route=agent_only; " + "responses=missing; voice_upload=missing; stt=missing; human_transport=missing; url=unavailable." + ) + + result = _run_web_input_no_go_details(tmp_path, status_text) + + assert result.returncode == 0 + assert "WebInput server is not started" in result.stdout + assert "server thread is not running" in result.stdout + assert "not directly wired to SeatGuide" in result.stdout + assert "response stream is missing" in result.stdout + assert "browser audio upload endpoint is not connected" in result.stdout + assert "speech-to-text pipeline is unavailable" in result.stdout + assert "fallback transport is missing" in result.stdout + + +@pytest.mark.parametrize( + ("status_text", "expected_returncode"), + [ + ( + "CameraSeatObservationProvider status: image=160x120; image_fresh=true; odom=(1.00, 2.00, yaw=0.50); odom_fresh=true; detection_model=qwen; credential=present; override=inactive; configured_fallback_seats=0; configured_fallback_people=0.", + 0, + ), + ( + "CameraSeatObservationProvider status: image=160x120; image_fresh=true; odom=(1.00, 2.00, yaw=0.50); odom_fresh=true; detection_model=qwen; credential=missing; override=inactive; configured_fallback_seats=0; configured_fallback_people=0.", + 1, + ), + ( + "CameraSeatObservationProvider status: image=missing; image_fresh=missing; odom=(1.00, 2.00, yaw=0.50); odom_fresh=true; detection_model=qwen; credential=present; override=inactive; configured_fallback_seats=0; configured_fallback_people=0.", + 1, + ), + ( + "CameraSeatObservationProvider status: image=160x120; image_fresh=true; odom=missing; odom_fresh=missing; detection_model=qwen; credential=present; override=inactive; configured_fallback_seats=0; configured_fallback_people=0.", + 1, + ), + ( + "CameraSeatObservationProvider status: image=160x120; image_fresh=false; odom=(1.00, 2.00, yaw=0.50); odom_fresh=true; detection_model=qwen; credential=present; override=inactive; configured_fallback_seats=0; configured_fallback_people=0.", + 1, + ), + ( + "CameraSeatObservationProvider status: image=160x120; image_fresh=true; odom=(1.00, 2.00, yaw=0.50); odom_fresh=false; detection_model=qwen; credential=present; override=inactive; configured_fallback_seats=0; configured_fallback_people=0.", + 1, + ), + ( + "CameraSeatObservationProvider status: detection_model=qwen; credential=present; override=inactive; configured_fallback_seats=0; configured_fallback_people=0.", + 1, + ), + ( + "CameraSeatObservationProvider status: image=160x120; image_fresh=true; odom=(1.00, 2.00, yaw=0.50); odom_fresh=true; detection_model=qwen; credential=present; override=active; configured_fallback_seats=0; configured_fallback_people=0.", + 1, + ), + ( + "CameraSeatObservationProvider status: image=160x120; image_fresh=true; odom=(1.00, 2.00, yaw=0.50); odom_fresh=true; detection_model=qwen; credential=present; override=inactive; configured_fallback_seats=1; configured_fallback_people=0.", + 1, + ), + ( + "CameraSeatObservationProvider status: image=160x120; image_fresh=true; odom=(1.00, 2.00, yaw=0.50); odom_fresh=true; detection_model=qwen; credential=present; override=inactive; configured_fallback_seats=0; configured_fallback_people=1.", + 1, + ), + ], +) +def test_hardware_acceptance_camera_provider_requires_live_inputs( + tmp_path: Path, + status_text: str, + expected_returncode: int, +) -> None: + result = _run_camera_provider_ready_check(tmp_path, status_text) + + assert result.returncode == expected_returncode + + +def test_hardware_acceptance_camera_provider_no_go_details_are_actionable( + tmp_path: Path, +) -> None: + status_text = ( + "CameraSeatObservationProvider status: image=missing; image_fresh=false; " + "odom=missing; odom_fresh=false; detection_model=qwen; credential=missing; " + "override=active; " + "configured_fallback_seats=1; configured_fallback_people=1." + ) + + result = _run_camera_provider_no_go_details(tmp_path, status_text) + + assert result.returncode == 0 + assert "ALIBABA_API_KEY" in result.stdout + assert "Camera image is missing" in result.stdout + assert "Camera image is stale" in result.stdout + assert "Odometry is missing" in result.stdout + assert "Odometry is stale" in result.stdout + assert "Runtime seat-scene override is active" in result.stdout + assert "Configured fallback seats/people are non-zero" in result.stdout + + +def test_hardware_acceptance_speech_no_go_details_are_actionable( + tmp_path: Path, +) -> None: + status_text = ( + "SpeakSkill status: tts=unavailable; reason=OPENAI_API_KEY is not set; " + "audio_output=missing; background_speech_threads=0." + ) + + result = _run_speech_no_go_details(tmp_path, status_text) + + assert result.returncode == 0 + assert "OPENAI_API_KEY" in result.stdout + assert "Audio output is not connected" in result.stdout + + +def test_hardware_acceptance_seat_guide_no_go_details_are_actionable( + tmp_path: Path, +) -> None: + status_text = ( + "SeatGuide readiness report: SeatGuide scene source=stale_camera_image: " + "no seats visible or configured; 0 people detected. | " + "SeatGuide preflight no-go: navigation=FOLLOWING_PATH; " + "perception=stale_camera_odom no seats; speaker=missing. | " + "SeatGuide preflight no-go: perception=camera_detection_error no seats. | " + "SeatGuide preview source=configured_fallback: no empty seat available." + ) + + result = _run_seat_guide_no_go_details(tmp_path, status_text) + + assert result.returncode == 0 + assert "Navigation is busy" in result.stdout + assert "speaker wiring is missing" in result.stdout + assert "cannot see chairs" in result.stdout + assert "camera frames are stale" in result.stdout + assert "odometry is stale" in result.stdout + assert "camera detection failed" in result.stdout + assert "fallback/calibrated coordinates" in result.stdout + assert "none are empty" in result.stdout + + +def test_hardware_acceptance_logs_seat_guide_details_for_preflight_failures() -> None: + script = HARDWARE_ACCEPTANCE_SCRIPT.read_text() + + assert "log_seat_guide_no_go_details()" in script + assert "seat_guide_status did not report live camera perception" in script + assert script.count('log_seat_guide_no_go_details "${') >= 4 + + +def test_hardware_acceptance_has_actionable_mcp_tools_failure_message() -> None: + script = HARDWARE_ACCEPTANCE_SCRIPT.read_text() + + assert 'if ! tools="$(run_dimos mcp list-tools 2>&1)"; then' in script + assert "Hardware acceptance no-go: MCP tools are unavailable." in script + assert "Hardware acceptance no-go: missing MCP tool" in script + assert "SeatGuide, WebInput, camera provider, and SpeakSkill modules" in script + assert "unitree-go2-seat-guide-agentic and includes McpServer" in script + assert 'require_tool "${tools}" "speak"' in script + assert "SeatGuide audio check. I can guide you to an empty seat." in script + assert "Operator audio confirmation" in script + assert 'require_output_contains "${audio_check_output}" "Spoke: SeatGuide audio check"' in script + assert "Transcript saved to: ${log_file}" in script + + +def test_hardware_acceptance_missing_stack_reports_transcript_path() -> None: + script = HARDWARE_ACCEPTANCE_SCRIPT.read_text() + missing_stack_block = script.split( + 'if grep -q "No running DimOS instance" <<<"${status_output}"; then', + maxsplit=1, + )[1].split("exit 2", maxsplit=1)[0] + + assert "No running DimOS stack found." in missing_stack_block + assert "dimos run unitree-go2-seat-guide-agentic --robot-ip" in missing_stack_block + assert 'log "Transcript saved to: ${log_file}"' in missing_stack_block + + +@pytest.mark.parametrize( + ("stream_text", "expected_text", "expected_returncode"), + [ + ("event: message\ndata: SeatGuide preflight ready\n", "SeatGuide preflight ready", 0), + ("event: message\ndata: Navigating to (1.00, 2.00)\n", "Navigating to", 0), + ("event: message\ndata: still thinking\n", "Navigating to", 1), + ], +) +def test_hardware_acceptance_waits_for_webinput_stream_text( + tmp_path: Path, + stream_text: str, + expected_text: str, + expected_returncode: int, +) -> None: + result = _run_stream_wait_check(tmp_path, stream_text, expected_text) + + assert result.returncode == expected_returncode + + +def test_hardware_acceptance_stream_wait_ignores_text_before_start_offset( + tmp_path: Path, +) -> None: + stale_text = "event: message\ndata: Navigating to stale goal\n" + result = _run_stream_wait_check( + tmp_path, + stale_text + "event: message\ndata: still waiting\n", + "Navigating to", + start_offset=len(stale_text), + ) + + assert result.returncode == 1 + + +def test_hardware_acceptance_stream_gates_use_offset_wait_result() -> None: + script = HARDWARE_ACCEPTANCE_SCRIPT.read_text() + + assert 'grep -q "SeatGuide preflight ready" "${stream_file}"' not in script + assert 'grep -q "Navigating to" "${stream_file}"' not in script + assert script.count('if [[ "${stream_matched}" != "1" ]]; then') == 3 + + +def test_hardware_acceptance_text_stream_failures_cleanup() -> None: + script = HARDWARE_ACCEPTANCE_SCRIPT.read_text() + + post_failure_block = script.split( + 'log "Hardware acceptance no-go: WebInput /submit_query request failed."', + maxsplit=1, + )[1].split("exit 3", maxsplit=1)[0] + text_wait_failure_block = script.split( + 'log "Hardware acceptance no-go: WebInput text route did not publish a ready SeatGuide preview response."', + maxsplit=1, + )[1].split("exit 3", maxsplit=1)[0] + + assert "stop_stream" in post_failure_block + assert 'rm -f "${stream_file}"' in post_failure_block + assert 'rm -f "${stream_file}"' in text_wait_failure_block + + +def test_hardware_acceptance_has_interrupt_stream_cleanup_trap() -> None: + script = HARDWARE_ACCEPTANCE_SCRIPT.read_text() + + assert "cleanup_active_stream()" in script + assert "trap cleanup_active_stream EXIT" in script + assert "trap 'cleanup_active_stream; exit 130' INT" in script + assert "trap 'cleanup_active_stream; exit 143' TERM" in script + assert script.count('active_stream_file="${stream_file}"') == 3 + assert script.count('active_stream_pid="${stream_pid}"') == 3 + + +def test_hardware_acceptance_auto_verifies_transcript_after_live_request() -> None: + script = HARDWARE_ACCEPTANCE_SCRIPT.read_text() + + assert 'acceptance_log_verifier="${script_dir}/demo_seat_guide_verify_acceptance_log"' in script + assert "verify_acceptance_log()" in script + assert 'log "+ ${acceptance_log_verifier} ${log_file}"' in script + live_tail = script.split( + 'capture_dimos_log "Capturing DimOS log snapshot after live request..."', + maxsplit=1, + )[1] + assert live_tail.index("verify_acceptance_log") < live_tail.index( + 'log "Live request sent. Continue monitoring with: dimos log -f"' + ) + + +@pytest.mark.parametrize( + "blueprint", + ["unitree-go2-seat-guide", "unitree-go2-seat-guide-agentic"], +) +def test_hardware_acceptance_registry_guard_accepts_hardware_run( + tmp_path: Path, + blueprint: str, +) -> None: + result = _run_hardware_registry_guard( + tmp_path, + run_id="hardware", + registry={ + "run_id": "hardware", + "blueprint": blueprint, + "cli_args": [blueprint], + "config_overrides": {"replay": False, "simulation": ""}, + "original_argv": ["dimos", "run", blueprint], + }, + ) + + assert result.returncode == 0 + assert "Hardware run mode: hardware." in result.stdout + assert f"Hardware blueprint: {blueprint}" in result.stdout + + +@pytest.mark.parametrize( + ("registry", "expected_output"), + [ + ( + {"cli_args": ["--replay=true"], "config_overrides": {}}, + "replay mode", + ), + ( + {"cli_args": [], "config_overrides": {"replay": True}}, + "replay mode", + ), + ( + {"cli_args": [], "config_overrides": {}, "replay": True}, + "replay mode", + ), + ( + {"cli_args": ["--simulation=dimsim"], "config_overrides": {}}, + "simulation mode", + ), + ( + {"cli_args": [], "config_overrides": {"simulation": "customsim"}}, + "simulation mode", + ), + ( + {"cli_args": [], "config_overrides": {"simulation": True}}, + "simulation mode", + ), + ( + {"cli_args": [], "config_overrides": {}, "simulation": True}, + "simulation mode", + ), + ( + { + "blueprint": "unitree-go2-agentic", + "cli_args": ["unitree-go2-agentic"], + "config_overrides": {"replay": False, "simulation": ""}, + }, + "not a SeatGuide Go2 blueprint", + ), + ], +) +def test_hardware_acceptance_registry_guard_rejects_non_hardware_runs( + tmp_path: Path, + registry: dict[str, Any], + expected_output: str, +) -> None: + registry = { + "run_id": "not-hardware", + "original_argv": ["dimos", "run", "unitree-go2-seat-guide-agentic"], + **registry, + } + + result = _run_hardware_registry_guard( + tmp_path, + run_id="not-hardware", + registry=registry, + ) + + assert result.returncode == 3 + assert expected_output in result.stdout + + +@pytest.mark.parametrize( + "tool_name", + ["seat_guide_status", "preview_empty_seat_goal"], +) +def test_no_motion_smoke_calls_scene_and_goal_preview(tool_name: str) -> None: + script = SMOKE_SCRIPT.read_text() + + assert f'require_tool "${{tools}}" "{tool_name}"' in script + assert f"run_dimos mcp call {tool_name}" in script + + +def test_no_motion_smoke_requires_web_input_voice_path_ready() -> None: + script = SMOKE_SCRIPT.read_text() + + assert "require_output_contains()" in script + assert "web_input_output=" in script + for expected in [ + "web=started", + "thread=running", + "seat_route=seat_guide_direct", + "responses=connected", + "voice_upload=connected", + "stt=connected", + "human_transport=connected", + ]: + assert f'require_output_contains "${{web_input_output}}" "{expected}"' in script + + +def test_no_motion_smoke_has_actionable_missing_stack_and_mcp_messages() -> None: + script = SMOKE_SCRIPT.read_text() + + assert 'status_output="$(run_dimos status)"' in script + assert "No running DimOS stack found." in script + assert "dimos --replay run unitree-go2-seat-guide-agentic --daemon" in script + assert "dimos run unitree-go2-seat-guide-agentic --robot-ip" in script + assert "SeatGuide smoke no-go: MCP tools are unavailable." in script + assert "SeatGuide smoke no-go: missing MCP tool" in script + assert "SeatGuide, WebInput, camera provider, and SpeakSkill modules" in script + assert "includes McpServer" in script + + +@pytest.mark.parametrize( + "blueprint", + ["unitree-go2-seat-guide", "unitree-go2-seat-guide-agentic"], +) +def test_no_motion_smoke_registry_guard_accepts_seat_guide_stack( + tmp_path: Path, + blueprint: str, +) -> None: + result = _run_smoke_registry_guard( + tmp_path, + run_id="seat-guide-smoke", + registry={ + "run_id": "seat-guide-smoke", + "blueprint": blueprint, + "original_argv": ["dimos", "run", blueprint], + }, + ) + + assert result.returncode == 0 + + +def test_no_motion_smoke_registry_guard_rejects_general_go2_stack( + tmp_path: Path, +) -> None: + result = _run_smoke_registry_guard( + tmp_path, + run_id="general-go2", + registry={ + "run_id": "general-go2", + "blueprint": "unitree-go2-agentic", + "original_argv": ["dimos", "run", "unitree-go2-agentic"], + }, + ) + + assert result.returncode == 3 + assert "not a SeatGuide Go2 blueprint" in result.stderr + + +def test_replay_smoke_starts_seat_guide_stack_and_runs_no_motion_smoke() -> None: + script = REPLAY_SMOKE_SCRIPT.read_text() + + assert "run_dimos --replay run unitree-go2-seat-guide-agentic --daemon" in script + assert "demo_seat_guide_smoke" in script + assert "unitree-go2-agentic" not in script.replace( + "unitree-go2-seat-guide-agentic", "" + ) + + +def test_hardware_bringup_starts_real_stack_then_runs_smoke_and_acceptance() -> None: + script = HARDWARE_BRINGUP_SCRIPT.read_text() + + assert 'robot_ip="${SEAT_GUIDE_ROBOT_IP:-192.168.123.161}"' in script + assert 'run_dimos run unitree-go2-seat-guide-agentic --robot-ip "${robot_ip}" --daemon' in script + assert "demo_seat_guide_smoke" in script + assert "demo_seat_guide_hardware_acceptance" in script + assert "unitree-go2-agentic" not in script.replace( + "unitree-go2-seat-guide-agentic", "" + ) + + +def test_hardware_bringup_requires_real_perception_and_speech_credentials() -> None: + script = HARDWARE_BRINGUP_SCRIPT.read_text() + + assert 'ALIBABA_API_KEY' in script + assert 'OPENAI_API_KEY' in script + assert "SeatGuide bring-up no-go: ALIBABA_API_KEY is not set." in script + assert "SeatGuide bring-up no-go: OPENAI_API_KEY is not set." in script + + +def test_hardware_bringup_allows_existing_stack_and_smoke_skip() -> None: + script = HARDWARE_BRINGUP_SCRIPT.read_text() + + assert "--skip-start" in script + assert "--skip-smoke" in script + assert "Using the currently running DimOS stack." in script + assert "Skipping no-motion smoke checks." in script + + +@pytest.mark.parametrize("script_path", SEAT_GUIDE_SCRIPTS) +def test_seat_guide_demo_scripts_are_directly_executable(script_path: Path) -> None: + assert script_path.read_text().startswith("#!/usr/bin/env bash") + assert os.access(script_path, os.X_OK) + + +def test_seat_guide_doc_does_not_recommend_rejected_general_go2_stack() -> None: + doc = SEAT_GUIDE_DOC.read_text() + + assert "bin/demo_seat_guide_hardware_bringup --robot-ip" in doc + assert "dimos run unitree-go2-seat-guide-agentic --robot-ip" in doc + assert "dimos --replay run unitree-go2-seat-guide-agentic --daemon" in doc + assert "dimos run unitree-go2-agentic --robot-ip" not in doc + + +def test_seat_guide_doc_has_parallel_hardware_day_checklist() -> None: + doc = SEAT_GUIDE_DOC.read_text() + + assert "### Parallel hardware-day checklist" in doc + for track in [ + "Voice intake", + "Perception", + "Planner", + "Navigation", + "Speech feedback", + "Acceptance evidence", + ]: + assert f"| {track} |" in doc + assert "bin/demo_seat_guide_verify_acceptance_log " in doc + + +def test_seat_guide_doc_describes_smoke_webinput_gate() -> None: + doc = SEAT_GUIDE_DOC.read_text() + + smoke_section = doc.split("bin/demo_seat_guide_smoke", maxsplit=1)[0].rsplit( + "Run the no-motion smoke script", maxsplit=1 + )[1] + for expected in [ + "web=started", + "thread=running", + "seat_route=seat_guide_direct", + "responses=connected", + "voice_upload=connected", + "stt=connected", + "human_transport=connected", + ]: + assert expected in smoke_section + + +def test_seat_guide_doc_keeps_direct_mcp_out_of_live_bringup_commands() -> None: + doc = SEAT_GUIDE_DOC.read_text() + no_motion_section = doc.split("Run the real voice path:", maxsplit=1)[0] + + assert "Run the no-motion readiness path" in no_motion_section + assert "dimos mcp call handle_seat_request --json-args" not in no_motion_section + assert "verifier rejects that path" in doc + + +def test_go2_system_prompt_mentions_seat_guide_flow() -> None: + assert "handle_seat_request" in SYSTEM_PROMPT + assert "seat_guide_status" in SYSTEM_PROMPT + assert "camera_seat_provider_status" in SYSTEM_PROMPT + assert "web_input_status" in SYSTEM_PROMPT + assert "speech_status" in SYSTEM_PROMPT + assert "preview_empty_seat_goal" in SYSTEM_PROMPT + assert "seat_guide_navigation_status" in SYSTEM_PROMPT + assert "goal_reached=true" in SYSTEM_PROMPT + assert "STT pipeline is connected" in SYSTEM_PROMPT + assert "set_seat_scene" in SYSTEM_PROMPT + assert "Do not claim live chair/person perception is active" in SYSTEM_PROMPT diff --git a/dimos/agents/skills/test_speak_skill.py b/dimos/agents/skills/test_speak_skill.py new file mode 100644 index 0000000000..87293eeecf --- /dev/null +++ b/dimos/agents/skills/test_speak_skill.py @@ -0,0 +1,61 @@ +# Copyright 2026 Dimensional Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import json + +from dimos.agents.skills.speak_skill import SpeakSkill + + +def test_speak_skill_start_is_noop_without_openai_api_key(monkeypatch) -> None: + monkeypatch.delenv("OPENAI_API_KEY", raising=False) + skill = SpeakSkill() + + try: + skill.start() + assert ( + skill.speak("hello", blocking=False) == "Speech unavailable: OPENAI_API_KEY is not set" + ) + assert skill.speech_status() == ( + "SpeakSkill status: tts=unavailable; reason=OPENAI_API_KEY is not set; " + "audio_output=missing; background_speech_threads=0." + ) + finally: + skill.stop() + + +def test_speak_skill_status_reports_ready_when_tts_is_initialized() -> None: + skill = SpeakSkill() + try: + skill._tts_node = object() + skill._audio_output = object() + + assert skill.speech_status() == ( + "SpeakSkill status: tts=ready; audio_output=connected; " + "background_speech_threads=0." + ) + finally: + skill._close_module() + + +def test_speak_skill_exposes_status_schema() -> None: + skill = SpeakSkill() + try: + skill_infos = {info.func_name: json.loads(info.args_schema) for info in skill.get_skills()} + finally: + skill._close_module() + + assert "speech_status" in skill_infos + status_schema = skill_infos["speech_status"] + assert "text-to-speech readiness" in status_schema["description"] + assert status_schema.get("properties", {}) == {} diff --git a/dimos/agents/system_prompt.py b/dimos/agents/system_prompt.py index 54f713f538..f0a7b236f1 100644 --- a/dimos/agents/system_prompt.py +++ b/dimos/agents/system_prompt.py @@ -32,6 +32,21 @@ - During `start_exploration`, avoid calling other skills except `stop_movement`. - Always run `execute_sport_command("RecoveryStand")` after dynamic movements (flips, jumps, sit) before navigating. +## SeatGuide Flow +- If the user asks for an empty seat, chair, or place to sit in a conference room, call `handle_seat_request` with the user's exact request text. +- If the user asks to preview, preflight, test, or check a SeatGuide request without moving, call `preview_seat_request` instead. +- `handle_seat_request` uses the configured conference room scene provider, requires live camera perception by default, selects an empty seat, starts navigation, and speaks feedback when speakers are connected. +- Use `seat_guide_readiness_report` as the first no-motion hardware check; it combines scene status, preflight, and goal preview. +- Use `seat_guide_preflight` before the first real hardware run; by default it requires live camera perception and checks navigation, selected goal, and speaker wiring without moving. +- Use `seat_guide_status` during bring-up or uncertainty to inspect visible/configured seats and people without moving. +- Use `camera_seat_provider_status` during bring-up to confirm camera frames, odometry, input freshness, VLM credentials, and fallback/override state before running detection. +- Use `web_input_status` during bring-up to confirm browser microphone/text input is running, browser audio upload is connected, the STT pipeline is connected, and requests are routed directly to SeatGuide. +- Use `speech_status` during bring-up to confirm text-to-speech and local audio output readiness before relying on spoken feedback. +- Use `preview_empty_seat_goal` before live navigation during bring-up to inspect the selected chair and map-frame goal without moving. +- After a live SeatGuide request starts navigation, use `seat_guide_navigation_status` to verify `goal_reached=true` before claiming the task is complete. +- If the room layout has not been calibrated yet, use `set_seat_scene` with map-frame chair poses and person positions; only pass `require_live_perception=false` for explicit fallback calibration. +- Do not claim live chair/person perception is active unless a real perception-backed scene provider has been configured. + ## GPS Navigation Flow For outdoor/GPS-based navigation: 1. Use `get_gps_position_for_queries` to look up coordinates for landmarks diff --git a/dimos/agents/web_human_input.py b/dimos/agents/web_human_input.py index 0a4fe7c3f3..c680c3a7e4 100644 --- a/dimos/agents/web_human_input.py +++ b/dimos/agents/web_human_input.py @@ -13,11 +13,17 @@ # limitations under the License. from threading import Thread -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Any import reactivex as rx import reactivex.operators as ops +from dimos.agents.annotation import skill +from dimos.agents.skills.seat_guide import ( + SeatGuideRequestSpec, + is_seat_guide_preview_request, + parse_seat_guide_intent, +) from dimos.constants import DEFAULT_THREAD_JOIN_TIMEOUT from dimos.core.core import rpc from dimos.core.module import Module @@ -32,10 +38,22 @@ logger = setup_logger() +def _create_whisper_node(): + # Do not force English here. SeatGuide's primary demo phrase is Chinese, and + # Whisper can auto-detect language when `language` is omitted. + from dimos.stream.audio.stt.node_whisper import WhisperNode + + return WhisperNode(modelopts={"fp16": False}) + + class WebInput(Module): _web_interface: RobotWebInterface | None = None _thread: Thread | None = None _human_transport: pLCMTransport[str] | None = None + _seat_guide: SeatGuideRequestSpec | None = None + _agent_responses: rx.subject.Subject[str] | None = None + _stt_node: Any | None = None + _stt_error: str | None = None @rpc def start(self) -> None: @@ -45,31 +63,31 @@ def start(self) -> None: audio_subject: rx.subject.Subject[AudioEvent] = rx.subject.Subject() + self._agent_responses = rx.subject.Subject() self._web_interface = RobotWebInterface( port=5555, - text_streams={"agent_responses": rx.subject.Subject()}, + text_streams={"agent_responses": self._agent_responses}, audio_subject=audio_subject, ) - normalizer = AudioNormalizer() - - # Here to prevent unwanted imports in the file. - from dimos.stream.audio.stt.node_whisper import WhisperNode - - stt_node = WhisperNode() + unsub = self._web_interface.query_stream.subscribe(self._route_text) + self.register_disposable(unsub) - # Connect audio pipeline: browser audio → normalizer → whisper - normalizer.consume_audio(audio_subject.pipe(ops.share())) - stt_node.consume_audio(normalizer.emit_audio()) + try: + normalizer = AudioNormalizer() + stt_node = _create_whisper_node() + self._stt_node = stt_node + self._stt_error = None - # Subscribe to both text input sources - # 1. Direct text from web interface - unsub = self._web_interface.query_stream.subscribe(self._human_transport.publish) - self.register_disposable(unsub) + normalizer.consume_audio(audio_subject.pipe(ops.share())) + stt_node.consume_audio(normalizer.emit_audio()) - # 2. Transcribed text from STT - unsub = stt_node.emit_text().subscribe(self._human_transport.publish) - self.register_disposable(unsub) + unsub = stt_node.emit_text().subscribe(self._route_text) + self.register_disposable(unsub) + except Exception as exc: + self._stt_node = None + self._stt_error = f"{type(exc).__name__}: {exc}" + logger.exception("WebInput speech-to-text pipeline unavailable") self._thread = Thread(target=self._web_interface.run, daemon=True) self._thread.start() @@ -85,3 +103,78 @@ def stop(self) -> None: if self._human_transport: self._human_transport.lcm.stop() super().stop() + + @skill + def web_input_status(self) -> str: + """Report WebInput voice and text routing readiness. + + Use this during Go2 bring-up to confirm the browser microphone/text + entry point is running, SeatGuide direct routing is connected, and + SeatGuide responses can be streamed back to the web UI. + """ + web_state = "started" if self._web_interface is not None else "not_started" + thread_state = ( + "running" + if self._thread is not None and self._thread.is_alive() + else "not_running" + ) + seat_route = ( + "seat_guide_direct" if self._seat_guide is not None else "agent_only" + ) + response_stream = ( + "connected" if self._agent_responses is not None else "missing" + ) + voice_upload = ( + "connected" + if self._web_interface is not None + and getattr(self._web_interface, "audio_subject", None) is not None + else "missing" + ) + if self._stt_node is not None: + stt_state = "connected" + elif getattr(self, "_stt_error", None): + stt_state = f"error({self._stt_error})" + else: + stt_state = "missing" + human_transport = ( + "connected" if self._human_transport is not None else "missing" + ) + url = ( + f"http://localhost:{self._web_interface.port}" + if self._web_interface is not None + else "unavailable" + ) + return ( + f"WebInput status: web={web_state}; thread={thread_state}; " + f"seat_route={seat_route}; responses={response_stream}; " + f"voice_upload={voice_upload}; stt={stt_state}; " + f"human_transport={human_transport}; url={url}." + ) + + def _route_text(self, text: str) -> None: + logger.info("WebInput received text", text=text) + if parse_seat_guide_intent(text).should_find_seat and self._seat_guide is not None: + try: + if is_seat_guide_preview_request(text): + logger.info("WebInput routing text to SeatGuide preview", text=text) + response = self._seat_guide.preview_seat_request(text) + else: + logger.info("WebInput routing text to SeatGuide live request", text=text) + response = self._seat_guide.handle_seat_request(text) + self._publish_agent_response(response) + return + except Exception: + logger.exception( + "SeatGuide direct route failed; publishing text to normal agent path" + ) + + if self._human_transport is None: + logger.warning("Dropping human input because human transport is not initialized") + return + logger.info("WebInput routing text to agent path", text=text) + self._human_transport.publish(text) + + def _publish_agent_response(self, text: str) -> None: + if self._agent_responses is None: + return + self._agent_responses.on_next(text) diff --git a/dimos/robot/all_blueprints.py b/dimos/robot/all_blueprints.py index 3d101cca79..ed646217cf 100644 --- a/dimos/robot/all_blueprints.py +++ b/dimos/robot/all_blueprints.py @@ -105,6 +105,8 @@ "unitree-go2-memory": "dimos.robot.unitree.go2.blueprints.smart.unitree_go2:unitree_go2_memory", "unitree-go2-relocalization": "dimos.robot.unitree.go2.blueprints.smart.unitree_go2:unitree_go2_relocalization", "unitree-go2-ros": "dimos.robot.unitree.go2.blueprints.smart.unitree_go2_ros:unitree_go2_ros", + "unitree-go2-seat-guide": "dimos.robot.unitree.go2.blueprints.agentic.unitree_go2_seat_guide:unitree_go2_seat_guide", + "unitree-go2-seat-guide-agentic": "dimos.robot.unitree.go2.blueprints.agentic.unitree_go2_seat_guide_agentic:unitree_go2_seat_guide_agentic", "unitree-go2-security": "dimos.robot.unitree.go2.blueprints.agentic.unitree_go2_security:unitree_go2_security", "unitree-go2-spatial": "dimos.robot.unitree.go2.blueprints.smart.unitree_go2_spatial:unitree_go2_spatial", "unitree-go2-temporal-memory": "dimos.robot.unitree.go2.blueprints.agentic.unitree_go2_temporal_memory:unitree_go2_temporal_memory", @@ -128,6 +130,7 @@ "b-box-navigation-module": "dimos.navigation.bbox_navigation.BBoxNavigationModule", "b1-connection-module": "dimos.robot.unitree.b1.connection.B1ConnectionModule", "camera-module": "dimos.hardware.sensors.camera.module.CameraModule", + "camera-seat-observation-provider": "dimos.agents.skills.seat_guide.CameraSeatObservationProvider", "cartesian-motion-controller": "dimos.manipulation.control.servo_control.cartesian_motion_controller.CartesianMotionController", "control-coordinator": "dimos.control.coordinator.ControlCoordinator", "cost-mapper": "dimos.mapping.costmapper.CostMapper", @@ -199,12 +202,14 @@ "replanning-a-star-planner": "dimos.navigation.replanning_a_star.module.ReplanningAStarPlanner", "rerun-bridge-module": "dimos.visualization.rerun.bridge.RerunBridgeModule", "rerun-web-socket-server": "dimos.visualization.rerun.websocket_server.RerunWebSocketServer", + "seat-guide-skill-container": "dimos.agents.skills.seat_guide.SeatGuideSkillContainer", "security-module": "dimos.experimental.security_demo.security_module.SecurityModule", "semantic-search": "dimos.memory2.module.SemanticSearch", "simple-phone-teleop": "dimos.teleop.phone.phone_extensions.SimplePhoneTeleop", "simple-planner": "dimos.navigation.nav_stack.modules.simple_planner.simple_planner.SimplePlanner", "spatial-memory": "dimos.perception.spatial_perception.SpatialMemory", "speak-skill": "dimos.agents.skills.speak_skill.SpeakSkill", + "synthetic-seat-observation-provider": "dimos.agents.skills.seat_guide.SyntheticSeatObservationProvider", "tare-planner": "dimos.navigation.nav_stack.modules.tare_planner.tare_planner.TarePlanner", "temporal-memory": "dimos.perception.experimental.temporal_memory.temporal_memory.TemporalMemory", "terrain-analysis": "dimos.navigation.nav_stack.modules.terrain_analysis.terrain_analysis.TerrainAnalysis", diff --git a/dimos/robot/unitree/go2/blueprints/agentic/_common_agentic.py b/dimos/robot/unitree/go2/blueprints/agentic/_common_agentic.py index 93312225bc..bcfdc390a9 100644 --- a/dimos/robot/unitree/go2/blueprints/agentic/_common_agentic.py +++ b/dimos/robot/unitree/go2/blueprints/agentic/_common_agentic.py @@ -15,6 +15,7 @@ from dimos.agents.skills.navigation import NavigationSkillContainer from dimos.agents.skills.person_follow import PersonFollowSkillContainer +from dimos.agents.skills.seat_guide import CameraSeatObservationProvider, SeatGuideSkillContainer from dimos.agents.skills.speak_skill import SpeakSkill from dimos.agents.web_human_input import WebInput from dimos.core.coordination.blueprints import autoconnect @@ -24,6 +25,8 @@ _common_agentic = autoconnect( NavigationSkillContainer.blueprint(), PersonFollowSkillContainer.blueprint(camera_info=GO2Connection.camera_info_static), + CameraSeatObservationProvider.blueprint(), + SeatGuideSkillContainer.blueprint(), UnitreeSkillContainer.blueprint(), WebInput.blueprint(), SpeakSkill.blueprint(), diff --git a/dimos/robot/unitree/go2/blueprints/agentic/unitree_go2_seat_guide.py b/dimos/robot/unitree/go2/blueprints/agentic/unitree_go2_seat_guide.py new file mode 100644 index 0000000000..dc2ab2368b --- /dev/null +++ b/dimos/robot/unitree/go2/blueprints/agentic/unitree_go2_seat_guide.py @@ -0,0 +1,29 @@ +#!/usr/bin/env python3 +# Copyright 2026 Dimensional Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from dimos.agents.mcp.mcp_server import McpServer +from dimos.core.coordination.blueprints import autoconnect +from dimos.perception.spatial_perception import SpatialMemory +from dimos.robot.unitree.go2.blueprints.agentic._common_agentic import _common_agentic +from dimos.robot.unitree.go2.blueprints.smart.unitree_go2 import unitree_go2 + +unitree_go2_seat_guide = autoconnect( + unitree_go2, + SpatialMemory.blueprint(), + McpServer.blueprint(), + _common_agentic, +).global_config(n_workers=10) + +__all__ = ["unitree_go2_seat_guide"] diff --git a/dimos/robot/unitree/go2/blueprints/agentic/unitree_go2_seat_guide_agentic.py b/dimos/robot/unitree/go2/blueprints/agentic/unitree_go2_seat_guide_agentic.py new file mode 100644 index 0000000000..94983028b6 --- /dev/null +++ b/dimos/robot/unitree/go2/blueprints/agentic/unitree_go2_seat_guide_agentic.py @@ -0,0 +1,31 @@ +#!/usr/bin/env python3 +# Copyright 2026 Dimensional Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from dimos.agents.mcp.mcp_client import McpClient +from dimos.agents.mcp.mcp_server import McpServer +from dimos.core.coordination.blueprints import autoconnect +from dimos.perception.spatial_perception import SpatialMemory +from dimos.robot.unitree.go2.blueprints.agentic._common_agentic import _common_agentic +from dimos.robot.unitree.go2.blueprints.smart.unitree_go2 import unitree_go2 + +unitree_go2_seat_guide_agentic = autoconnect( + unitree_go2, + SpatialMemory.blueprint(), + McpServer.blueprint(), + McpClient.blueprint(), + _common_agentic, +).global_config(n_workers=10) + +__all__ = ["unitree_go2_seat_guide_agentic"] diff --git a/docs/agents/seat_guide_modules.md b/docs/agents/seat_guide_modules.md new file mode 100644 index 0000000000..c9adc69d3c --- /dev/null +++ b/docs/agents/seat_guide_modules.md @@ -0,0 +1,400 @@ +# SeatGuide Dog module split + +This is the first demo-oriented module plan for the conference room seat-finding +hackathon idea. The goal is to keep each boundary testable without a Go2 while +the default Go2 path uses real browser/Whisper voice input, camera-backed VLM +seat/person recognition, robot navigation, and speech feedback. + +## Demo-critical flow + +User asks: "Find me an empty seat." + +The system scans a conference room with one long table, detects chairs and +people, selects the nearest reachable empty chair, navigates beside it, and +speaks a short instruction. + +## Modules + +| Module | Owner boundary | Input | Output | Can build in parallel | Current status | +| --- | --- | --- | --- | --- | --- | +| Voice Command Intake | Converts speech or typed text into the `find_empty_seat` intent. | Browser microphone, web input, or agent text | SeatGuide request intent | Yes | `WebInput` routes SeatGuide voice/text directly to `handle_seat_request()` | +| Seat Perception | Detects chair poses and person positions in the conference room. | Go2 RGB camera frames/replay frames plus odometry | `SeatSceneObservation` in map frame | Yes | `CameraSeatObservationProvider` implemented with VLM detection, odom-backed map projection, and explicit calibrated fallback | +| Seat Occupancy Planner | Decides which chairs are empty and picks the nearest empty chair. | Chair poses, person positions, robot pose | Selected seat and guide pose | Yes | Implemented in `SeatGuidePlanner` | +| Guide Navigation | Sends the robot to a pose beside the selected chair and reports completion. | Guide pose in `map` frame | Navigation goal plus `goal_reached` status | Yes | Uses `NavigationInterfaceSpec` | +| Speech Feedback | Tells the user what was found and where to follow. | Planner result and navigation status | Spoken phrase | Yes | Optional `SpeakSkillSpec` integration implemented | +| Acceptance Harness | Runs the same flow without Go2 hardware. | Fixed synthetic or recorded image layout | Test result and expected goal pose | Yes | Covered by unit tests | + +## Current software boundary + +`dimos.agents.skills.seat_guide.SeatGuideSkillContainer` is the integration +module exposed to the agent. It supports direct debug input and provider-backed +real perception input. + +Direct skill arguments: + +- `seats`: flat `[x, y, yaw, x, y, yaw, ...]` chair poses in the `map` frame +- `people`: flat `[x, y, x, y, ...]` person positions in the `map` frame +- `robot_x`, `robot_y`: robot position for nearest-seat selection + +Provider-backed scene: + +- `SeatObservationProviderSpec.get_seat_scene()` +- `CameraSeatObservationProvider` subscribes to `color_image` and `odom`, asks the VLM for `chair` and `person` detections separately, and converts image-space detections to an approximate map-frame scene using the latest robot pose +- `camera_seat_provider_status()` reports camera frame, odometry, input freshness, VLM credential, runtime override, and fallback configuration readiness without running VLM detection +- `CameraSeatObservationProvider.set_seat_scene()` remains available as explicit runtime calibration/fallback when camera/VLM detection is unavailable +- `SyntheticSeatObservationProvider` remains for repeatable Go2-free tests and demos +- `unitree-go2-seat-guide` and `unitree-go2-seat-guide-agentic` include `CameraSeatObservationProvider` so the default SeatGuide bring-up path uses real camera recognition +- the default `qwen` VLM path requires `ALIBABA_API_KEY`; without it, SeatGuide reports `camera_detection_error` instead of silently treating missing credentials as a real no-seat observation + +Voice/text intake: + +- `parse_seat_guide_intent(text)` recognizes simple English and Chinese seat-finding requests +- `handle_seat_request(text)` rejects unrelated text or delegates to `find_empty_seat_from_scene()`; by default it only navigates from live `camera` perception +- `preview_seat_request(text)` validates a spoken/typed SeatGuide request and runs no-motion preflight instead of navigation +- `seat_guide_readiness_report()` runs scene status, live-perception preflight, and goal preview in one no-motion report +- `seat_guide_preflight()` checks navigation state, scene source, empty/occupied seat counts, selected seat/goal, and speaker wiring without moving; hardware acceptance requires `navigation=IDLE` and `speaker=connected`, it only passes live `camera` perception by default, and fallback calibration must be allowed explicitly with `require_live_perception=false` +- `seat_guide_status()` reports the current scene source, visible/configured seats, people, and robot pose without navigating +- `preview_empty_seat_goal()` runs the same planner and reports empty/occupied seat counts, the selected chair, and map-frame navigation goal without moving +- `seat_guide_navigation_status()` reports navigation state and `goal_reached` after a live SeatGuide request, so acceptance can prove the robot completed the task rather than only accepted a goal; if navigation already reported reached before the SeatGuide goal was sent, status waits to see `goal_reached=false` once before accepting a later `true` +- `WebInput` uses browser audio -> `WhisperNode` speech-to-text with language auto-detection; matching English or Chinese SeatGuide requests are routed directly to `handle_seat_request()` instead of waiting for the LLM to choose a tool, and the returned SeatGuide status is published to the web `agent_responses` text stream +- `web_input_status()` reports whether the browser voice/text entry point, browser audio upload endpoint, SeatGuide direct route, web response stream, STT pipeline, and `/human_input` fallback transport are connected +- unrelated text still goes through the normal `/human_input` agent path + +Scene source values used by `seat_guide_status()`: + +- `camera`: VLM detected at least one chair from the latest camera frame. +- `runtime_override`: operator-provided calibration from `set_seat_scene()`. +- `configured_fallback`: blueprint/static fallback scene. +- `no_camera_image`: no camera frame has arrived yet; check camera stream wiring. +- `camera_no_odom`: camera frames arrived but localization/odometry is missing; live navigation is no-go because SeatGuide cannot produce a trustworthy map-frame goal. +- `stale_camera_image`: camera frames are too old to prove live perception; restore the camera stream before live navigation. +- `stale_camera_odom`: odometry is too old to produce a trustworthy map-frame goal; restore localization before live navigation. +- `camera_no_seats_detected`: camera frames arrived, but VLM found no chairs; turn the robot toward the table or calibrate fallback. +- `camera_detection_error`: VLM detection raised an error; inspect logs/model setup or calibrate fallback. + +When live navigation is requested from a non-`camera` source, SeatGuide refuses +to move and reports the source, seat/person counts, robot pose, and a specific +next step. This is intentional: fallback coordinates can be useful for +calibration, but they should not be mistaken for real chair/person recognition. + +Speech feedback: + +- if `SpeakSkill` is connected, SeatGuide speaks result/failure messages with `blocking=False` +- if no speaker is connected, SeatGuide still returns text and the core tests keep running +- `speech_status()` reports whether OpenAI TTS and local audio output are ready without speaking + +This keeps perception independent from navigation. A later detector can return +the same scene contract without changing the planner tests. + +## Unit-test acceptance + +The Go2-free acceptance path is: + +1. Build a synthetic long-table room layout. +2. Mark chairs occupied when a person is within 0.75 meters. +3. Select the nearest empty chair to the robot. +4. Compute a navigation pose beside the chair. +5. Verify a fake navigator receives the expected `PoseStamped`. +6. Verify `SyntheticSeatObservationProvider` can feed the same flow without + direct skill arguments. +7. Verify text requests such as "Please find me an empty seat" and "帮我找一个空位" + route into the same provider-backed flow. +8. Verify no-motion text requests such as "预检帮我找一个空位" route to `preview_seat_request()` and do not call navigation. +9. Verify `CameraSeatObservationProvider` converts camera/VLM chair/person detections into a map-frame `SeatSceneObservation` using latest odometry when available. +10. Verify `WebInput` creates Whisper without forcing English, so Chinese phrases such as "帮我找一个空位" can be transcribed by language auto-detection. +11. Verify `seat_guide_status()` can diagnose whether the scene came from camera detection, runtime calibration, or configured fallback before navigation is attempted. +12. Verify `seat_guide_preflight()` reports navigation/perception/speaker readiness without calling navigation, and that fallback scenes are no-go unless explicitly allowed. +13. Verify `seat_guide_readiness_report()` combines status, preflight, and preview without calling navigation. +14. Verify `handle_seat_request()` refuses fallback scenes by default and only navigates with fallback when `require_live_perception=false` is explicitly passed. +15. Verify `preview_empty_seat_goal()` reports the selected chair and map-frame goal without calling navigation. +16. Verify `seat_guide_navigation_status()` reports `goal_reached=true/false` and missing navigation without sending or canceling a goal. +17. Verify the SeatGuide MCP JSON-RPC path can list `seat_guide_status`, run preflight/readiness, preview the goal, call `handle_seat_request` with Chinese text, and report `goal_reached=true` without Go2 hardware. + +Run: + +```bash +uv run pytest dimos/agents/skills/test_seat_guide.py +``` + +## Simulation acceptance target + +If a simulator is available, use the same synthetic or camera-derived layout as the unit test and +run the Go2 agentic stack in replay or sim mode. The minimum sim/replay acceptance is +that browser/Whisper text reaches SeatGuide, `CameraSeatObservationProvider` +returns a scene from camera frames or an explicitly calibrated fallback, and navigation receives +a goal. + +Current replay evidence: + +- `dimos --replay run unitree-go2-seat-guide-agentic` starts with MCP tools exposed. +- `dimos mcp list-tools` includes `set_seat_scene`, `find_empty_seat_from_scene`, `preview_seat_request`, `seat_guide_readiness_report`, `seat_guide_preflight`, `seat_guide_navigation_status`, `preview_empty_seat_goal`, `handle_seat_request`, `seat_guide_status`, `server_status`, and `list_modules`. +- `web_input_status` reports `seat_route=seat_guide_direct`, `voice_upload=connected`, and `stt=connected` when browser/Whisper input is wired directly to SeatGuide. +- `camera_seat_provider_status` reports whether Go2 camera frames, odometry, input freshness, and VLM credentials are ready before detection is attempted. +- Calling `set_seat_scene` followed by `handle_seat_request` returned: `I found an empty seat seat_3. Please follow me to the chair beside the table. Navigating to (3.65, -1.00).` +- Posting typed text to `http://localhost:5555/submit_query` with `query=帮我找一个空位` returned success and triggered the same SeatGuide navigation path. +- In replay, the configured fallback goal produced a planner warning `No path found to the goal`; this confirms a navigation goal was sent, but not that the fallback coordinates are reachable in the replay map. + +## Tomorrow G2 bring-up path + +The intended real-perception bring-up path is: + +1. Place chairs in the Go2 camera view. +2. Start `unitree-go2-seat-guide-agentic` with the normal MCP server. +3. Run `seat_guide_status`, `seat_guide_preflight`, and `preview_empty_seat_goal` before any live motion. +4. If preflight reports `navigation=FOLLOWING_PATH` or `navigation=RECOVERY`, wait for the current action to finish or cancel it before asking SeatGuide to send a new goal. +5. Optionally call `set_seat_scene()` only if camera/VLM detection is unavailable or needs explicit fallback calibration. +6. Send or speak: "Please find me an empty seat" / "帮我找一个空位". +7. `WebInput` transcribes browser microphone audio with Whisper language auto-detection and routes matching text to `handle_seat_request(text)`. +8. `handle_seat_request()` parses the intent, reads the camera-backed scene, picks + the nearest empty chair, and calls `NavigationInterfaceSpec.set_goal()`. + +`set_seat_scene()` is now an explicit fallback/calibration tool, not the primary path. + +### Parallel hardware-day checklist + +Use this split when the Go2 is connected so the team can debug independently +before any live motion: + +| Track | Owner checks | Passing evidence | No-go action | +| --- | --- | --- | --- | +| Voice intake | Browser page opens; microphone permission granted; Chinese preview phrase reaches WebInput. | `web_input_status` shows `web=started`, `thread=running`, `seat_route=seat_guide_direct`, `responses=connected`, `voice_upload=connected`, `stt=connected`, `human_transport=connected`; acceptance log shows `WebInput received text` for `预检帮我找一个空位`. | Fix browser/microphone/WebInput before touching navigation. | +| Perception | Go2 camera frame, odometry, and Qwen credential are live; no fallback scene is active. | `camera_seat_provider_status` shows `image=x`, `image_fresh=true`, `odom=(...)`, `odom_fresh=true`, `credential=present`, `override=inactive`, `configured_fallback_seats=0`, `configured_fallback_people=0`; `seat_guide_status` starts with `SeatGuide scene source=camera:`. | Turn robot toward the table, fix `ALIBABA_API_KEY`, restore stale camera/odom streams, or explicitly mark fallback calibration as non-acceptance. | +| Planner | Empty/occupied counts and selected goal make sense before motion. | `seat_guide_preflight`, `seat_guide_readiness_report`, and `preview_empty_seat_goal` report `empty=N occupied=N`, `selected=...`, and `goal=(...)` without sending a goal. | Adjust camera view or chair/person layout before live voice. | +| Navigation | Robot is idle before SeatGuide sends the live goal and reports completion after it. | Preflight has `navigation=IDLE`; after live voice, `seat_guide_navigation_status` reports a new `goal_sequence` and `goal_reached=true`. | Wait/cancel existing navigation or inspect navigation logs; do not rerun live voice until idle. | +| Speech feedback | TTS and local audio output are ready, or the team agrees to use web text as the user-facing fallback. | `speech_status` shows `tts=ready` and `audio_output=connected`; hardware acceptance also calls `speak` with `SeatGuide audio check. I can guide you to an empty seat.`, requires `Spoke: SeatGuide audio check`, and requires operator confirmation `HEARD`. | Set `OPENAI_API_KEY` and connect audio before official acceptance. | +| Acceptance evidence | The run is hardware, not replay/sim, and uses the SeatGuide blueprint. | `bin/demo_seat_guide_hardware_acceptance` records the run registry, no-motion gates, browser microphone gates, camera source, speech output check plus operator heard confirmation, ordered WebInput route logs, and `goal_reached=true`; `bin/demo_seat_guide_verify_acceptance_log ` passes. | Treat failures as real no-go evidence; do not replace them with direct MCP live calls. | + +### Bring-up commands + +One-command real Go2 bring-up: + +```bash +export ALIBABA_API_KEY=... +export OPENAI_API_KEY=... +bin/demo_seat_guide_hardware_bringup --robot-ip 192.168.123.161 +``` + +This starts `unitree-go2-seat-guide-agentic`, runs the no-motion smoke checks, +then launches the hardware acceptance flow. Use `--skip-start` if the stack is +already running, or `--skip-smoke` only when repeating acceptance after a known +passing smoke run. + +Start the SeatGuide-focused Go2 stack. This keeps the real Go2 base, +navigation, camera, browser/Whisper voice input, MCP server, and SeatGuide +modules, while avoiding unrelated CUDA-only security demo modules: + +```bash +dimos run unitree-go2-seat-guide-agentic --robot-ip 192.168.123.161 --daemon +``` + +For local replay without a real robot: + +```bash +dimos --replay run unitree-go2-seat-guide-agentic --daemon +``` + +On macOS, DimOS replay and MCP require multicast on loopback for cross-process +LCM/RPC. If startup asks for this route and cannot run sudo non-interactively, +run it once in a terminal before replay or hardware bring-up: + +```bash +sudo route delete -net 224.0.0.0/4 || true +sudo route add -net 224.0.0.0/4 -interface lo0 +``` + +Do not use `PYTEST_VERSION=1` as a runtime workaround. It skips the system +configurator, but `McpServer/on_system_modules` can time out because the +cross-process LCM route is still missing. + +The general Go2 agentic stack is still available, but it includes unrelated +demo modules and is not the SeatGuide acceptance target. The hardware +acceptance script intentionally rejects that general stack; start +`unitree-go2-seat-guide` or `unitree-go2-seat-guide-agentic` for SeatGuide +acceptance. + +If `OPENAI_API_KEY` is not set, `McpClient` disables the LLM agent but the +direct voice route and MCP tools still start. `SpeakSkill` also degrades to a +no-op instead of failing startup. + +The default camera detector uses Qwen VLM. Set `ALIBABA_API_KEY` before hardware +bring-up, or configure a different supported `detection_model`. If the key is +missing, `seat_guide_status` reports `source=camera_detection_error`; use logs +or `set_seat_scene` only as an explicit fallback/calibration path. + +Confirm the SeatGuide tools are exposed: + +```bash +dimos mcp list-tools +dimos mcp modules +``` + +Run the no-motion smoke script against the already-running stack. It checks the +SeatGuide MCP tools and fails early unless `web_input_status` reports +`web=started`, `thread=running`, `seat_route=seat_guide_direct`, +`responses=connected`, `voice_upload=connected`, `stt=connected`, and `human_transport=connected`, then +runs the no-motion scene/status/preflight/preview checks: + +```bash +bin/demo_seat_guide_smoke +``` + +Run the hardware acceptance script against an already-running real Go2 stack. +It performs the no-motion WebInput, camera/VLM, scene, preflight, request +preview, and goal preview checks first. It only offers the `LIVE` prompt after +automated gates pass: the DimOS run registry must show a hardware run, not +`--replay` or `--simulation`, and the blueprint must be +`unitree-go2-seat-guide` or `unitree-go2-seat-guide-agentic`; WebInput must report `web=started`, +`thread=running`, `seat_route=seat_guide_direct`, `responses=connected`, +`voice_upload=connected`, `stt=connected`, and +`human_transport=connected`; camera frames, odometry, and VLM credentials must +be present, the camera provider runtime override must be inactive, and +configured fallback seats/people must both be zero; TTS/audio output must be ready; preflight must be ready with +`navigation=IDLE` and `speaker=connected`; the goal preview must select a seat; +and the script must resolve the active WebInput URL from `web_input_status`. Posting +`预检帮我找一个空位` to that WebInput HTTP text +endpoint must publish a `SeatGuide preflight ready` response on the web +`agent_responses` stream before `SEAT_GUIDE_WEBINPUT_TEXT_WAIT_S` seconds +(default `20`). It then opens a manual no-motion voice gate: the +operator presses Enter when ready, then uses the browser microphone to say +`预检帮我找一个空位`; the script only accepts a `SeatGuide preflight ready` response +that appears in the web response stream after that readiness point, before +`SEAT_GUIDE_WEBINPUT_VOICE_PREVIEW_WAIT_S` seconds (default `120`). After the +operator types `LIVE`, the script still does not call +`handle_seat_request` through MCP; it opens a live voice gate and requires the +operator to press Enter when ready, then say `帮我找一个空位` through the browser +microphone. The live gate passes only if the web response stream reports +`Navigating to` after that readiness point and before +`SEAT_GUIDE_WEBINPUT_VOICE_LIVE_WAIT_S` seconds (default `150`); after that, the +script polls `seat_guide_navigation_status()` until it reports both a new +`goal_sequence` and `goal_reached=true`; stale completion from a previous +navigation goal is suppressed until SeatGuide observes a reset. The transcript is saved under +`logs/seat_guide_acceptance/` by default; override with +`SEAT_GUIDE_ACCEPTANCE_LOG_DIR=/path/to/logs` when needed. The transcript +includes the MCP command outputs plus `dimos log -n 200` snapshots after the +no-motion checks, after the live navigation request, and on WebInput/navigation +failure paths. The saved logs must show `WebInput routing text to SeatGuide +preview` for the no-motion voice gate and `WebInput routing text to SeatGuide +live request` for the live gate, proving the request went through WebInput +rather than a direct MCP call: + +```bash +bin/demo_seat_guide_hardware_acceptance +``` + +The hardware script automatically audits the saved transcript after the live +request completes. It requires the DimOS run registry path, hardware run mode, +SeatGuide Go2 blueprint name, running WebInput server/thread/transport, direct +SeatGuide routing, resolved WebInput URL, camera/odometry/VLM readiness, +`image_fresh=true` and `odom_fresh=true`, TTS audio check phrase, `Spoke:` +completion, operator audio confirmation `HEARD`, typed and spoken no-motion +responses, explicit browser microphone no-motion/live gates with the required +spoken phrases, WebInput preview/live route logs, empty/occupied seat counts, +DimOS log snapshots after no-motion checks and after the live request, the +no-motion completion marker, `LIVE` confirmation, live voice navigation start, +goal-sequence polling, and +`goal_reached=true` completion. It also requires at least three +`WebInput received text` log events, +covering typed no-motion input, no-motion browser microphone input, and live +browser microphone input; those log events must include the recognized +SeatGuide phrases `预检帮我找一个空位` and `帮我找一个空位`, not just arbitrary +WebInput text. The verifier rejects transcripts that contain direct MCP live +calls to `handle_seat_request`, fallback scene calibration with +`set_seat_scene`, clearing fallback overrides, or +`require_live_perception=false`. The no-motion flow must appear in order: typed +WebInput preview, browser microphone no-motion gate, readiness prompt before +the spoken phrase, WebInput preview route, no-motion log snapshot, no-motion +completion marker, and `LIVE` confirmation. The live flow must then appear in +order: `LIVE` confirmation, browser microphone live gate, readiness prompt +before the spoken phrase, WebInput live SeatGuide route, navigation start, +`goal_reached=true`, and completion marker. + +To re-audit an existing transcript manually, run: + +```bash +bin/demo_seat_guide_verify_acceptance_log logs/seat_guide_acceptance/.log +``` + +Run the replay smoke wrapper when no Go2 is connected. It checks the macOS +multicast route before starting replay, starts `unitree-go2-seat-guide-agentic` +with `--replay`, runs the no-motion smoke, and stops the stack: + +```bash +bin/demo_seat_guide_replay_smoke +``` + +Run the no-motion readiness path without relying on microphone or LLM behavior: + +```bash +dimos mcp call seat_guide_status +dimos mcp call web_input_status +dimos mcp call camera_seat_provider_status +dimos mcp call speech_status +dimos mcp call seat_guide_readiness_report +dimos mcp call seat_guide_preflight +dimos mcp call seat_guide_navigation_status +dimos mcp call preview_seat_request --json-args '{"text": "预检帮我找一个空位"}' +dimos mcp call preview_empty_seat_goal +``` + +Do not use a direct MCP `handle_seat_request` call as the live hardware demo +evidence. The hardware acceptance verifier rejects that path because it bypasses +the required browser microphone -> Whisper -> WebInput route. + +Run the real voice path: + +1. Open the web interface printed by `WebInput` (`http://localhost:5555` by default). +2. Allow browser microphone access. +3. First speak "预检帮我找一个空位" or "preview find me an empty seat" to validate the real microphone path without motion. +4. Then speak "帮我找一个空位" or "Please find me an empty seat" when live navigation is intended. +5. The browser audio is transcribed by `WhisperNode` with language auto-detection; no-motion preview text is routed to `preview_seat_request()`, and live SeatGuide text is routed directly to `handle_seat_request()`. +6. Watch the web `agent_responses` stream for the exact SeatGuide result, especially if `SpeakSkill` is unavailable or audio output is hard to hear during bring-up. + +If MCP is healthy, this should route through: + +`seat_guide_readiness_report` for combined no-motion checks -> +`WebInput` -> `WhisperNode` -> `handle_seat_request` -> +`CameraSeatObservationProvider.get_seat_scene` using camera frames and odom -> +`SeatGuidePlanner.find_empty_seat` -> `NavigationInterfaceSpec.set_goal` -> +optional `SpeakSkillSpec.speak`. + +Calibrate the fallback scene at runtime if camera/VLM detection is not reliable: + +```bash +dimos mcp call set_seat_scene --json-args '{"seats": [0.0, -1.0, 0.0, 1.5, -1.0, 0.0, 3.0, -1.0, 0.0], "people": [0.1, -1.0, 1.6, -1.0], "robot_x": -1.0, "robot_y": -1.0}' +``` + +After fallback calibration, use explicit fallback preflight: + +```bash +dimos mcp call seat_guide_preflight --json-args '{"require_live_perception": false}' +``` + +To intentionally test fallback navigation without live camera recognition, also +pass the override on the request itself: + +```bash +dimos mcp call handle_seat_request --json-args '{"text": "帮我找一个空位", "require_live_perception": false}' +``` + +For the real G2 demo, use map-frame chair and aisle coordinates that are +reachable by the active map. A successful `handle_seat_request` response only +proves the goal was submitted; confirm `dimos log -f` does not show `No path +found to the goal` before relying on that calibration. + +Clear runtime calibration and return to blueprint defaults: + +```bash +dimos mcp call clear_seat_scene_override +``` + +Use the direct skill only for debugging the configured coordinates: + +```bash +dimos mcp call find_empty_seat --json-args '{"seats": [0.0, -1.0, 0.0, 1.5, -1.0, 0.0, 3.0, -1.0, 0.0], "people": [0.1, -1.0, 1.6, -1.0], "robot_x": -1.0, "robot_y": -1.0}' +``` + +Stop after testing: + +```bash +dimos stop +``` From 26dae6c286c63a272da75bc3c7ea11b044f43e89 Mon Sep 17 00:00:00 2001 From: Ernest Date: Thu, 28 May 2026 23:38:43 +0800 Subject: [PATCH 05/16] feat(mcp): support OpenRouter agent models --- dimos/agents/mcp/mcp_adapter.py | 2 +- dimos/agents/mcp/mcp_client.py | 59 ++++++++++++++++++++++++++++- dimos/agents/mcp/test_mcp_client.py | 48 ++++++++++++++++++++++- 3 files changed, 106 insertions(+), 3 deletions(-) diff --git a/dimos/agents/mcp/mcp_adapter.py b/dimos/agents/mcp/mcp_adapter.py index 213bf71e23..489b1ca7ae 100644 --- a/dimos/agents/mcp/mcp_adapter.py +++ b/dimos/agents/mcp/mcp_adapter.py @@ -41,7 +41,7 @@ logger = setup_logger() -DEFAULT_TIMEOUT = 30 +DEFAULT_TIMEOUT = 120 class McpError(Exception): diff --git a/dimos/agents/mcp/mcp_client.py b/dimos/agents/mcp/mcp_client.py index 95338c6fb0..a22a71bb03 100644 --- a/dimos/agents/mcp/mcp_client.py +++ b/dimos/agents/mcp/mcp_client.py @@ -25,6 +25,7 @@ from langchain_core.messages import HumanMessage from langchain_core.messages.base import BaseMessage from langchain_core.tools import StructuredTool +from langchain_openai import ChatOpenAI from langgraph.graph.state import CompiledStateGraph from reactivex.disposable import Disposable @@ -41,6 +42,8 @@ logger = setup_logger() +OPENROUTER_BASE_URL = "https://openrouter.ai/api/v1" + def _requires_openai_api_key(model: Any) -> bool: if not isinstance(model, str): @@ -48,6 +51,46 @@ def _requires_openai_api_key(model: Any) -> bool: return model.startswith("gpt-") or model.startswith("openai:") +def _uses_openrouter(model: Any) -> bool: + if not isinstance(model, str): + return False + return model.startswith("openrouter:") + + +def _openrouter_model_name(model: str) -> str: + if model.startswith("openrouter:"): + return model.removeprefix("openrouter:") + configured_model = os.getenv("OPENROUTER_MODEL") + if configured_model: + return configured_model + if model.startswith("openai:"): + return f"openai/{model.removeprefix('openai:')}" + if model.startswith("gpt-"): + return f"openai/{model}" + return model + + +def _openrouter_headers() -> dict[str, str] | None: + headers: dict[str, str] = {} + if referer := os.getenv("OPENROUTER_HTTP_REFERER"): + headers["HTTP-Referer"] = referer + if title := os.getenv("OPENROUTER_APP_TITLE"): + headers["X-OpenRouter-Title"] = title + return headers or None + + +def _build_openrouter_model(model: str) -> ChatOpenAI | None: + api_key = os.getenv("OPENROUTER_API_KEY") + if not api_key: + return None + return ChatOpenAI( + model=_openrouter_model_name(model), + api_key=api_key, + base_url=os.getenv("OPENROUTER_BASE_URL", OPENROUTER_BASE_URL), + default_headers=_openrouter_headers(), + ) + + class McpClientConfig(ModuleConfig): system_prompt: str | None = SYSTEM_PROMPT model: str = "gpt-4o" @@ -224,9 +267,23 @@ def on_system_modules(self, _modules: list[RPCClient]) -> None: from dimos.agents.testing import MockModel model = MockModel(json_path=self.config.model_fixture) + elif isinstance(model, str) and ( + _uses_openrouter(model) + or (_requires_openai_api_key(model) and os.getenv("OPENROUTER_API_KEY")) + ): + openrouter_model = _build_openrouter_model(model) + if openrouter_model is None: + logger.warning( + "McpClient agent disabled because OPENROUTER_API_KEY is not set", + model=model, + n_tools=len(tools), + ) + return + model = openrouter_model elif _requires_openai_api_key(model) and not os.getenv("OPENAI_API_KEY"): logger.warning( - "McpClient agent disabled because OPENAI_API_KEY is not set", + "McpClient agent disabled because OPENAI_API_KEY is not set. " + "Set OPENROUTER_API_KEY to use OpenRouter instead.", model=model, n_tools=len(tools), ) diff --git a/dimos/agents/mcp/test_mcp_client.py b/dimos/agents/mcp/test_mcp_client.py index 2560153ee3..0f9b1c5ddf 100644 --- a/dimos/agents/mcp/test_mcp_client.py +++ b/dimos/agents/mcp/test_mcp_client.py @@ -18,7 +18,13 @@ import pytest from dimos.agents.annotation import skill -from dimos.agents.mcp.mcp_client import _requires_openai_api_key +from dimos.agents.mcp.mcp_client import ( + OPENROUTER_BASE_URL, + _build_openrouter_model, + _openrouter_model_name, + _requires_openai_api_key, + _uses_openrouter, +) from dimos.core.module import Module from dimos.msgs.sensor_msgs.Image import Image from dimos.utils.data import get_data @@ -206,3 +212,43 @@ def test_requires_openai_api_key_for_gpt_models() -> None: assert _requires_openai_api_key("gpt-4o") assert _requires_openai_api_key("openai:gpt-4o") assert not _requires_openai_api_key("ollama:llama3") + + +def test_openrouter_model_detection_and_name_mapping(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.delenv("OPENROUTER_MODEL", raising=False) + + assert _uses_openrouter("openrouter:anthropic/claude-sonnet-4.5") + assert not _uses_openrouter("gpt-4o") + assert _openrouter_model_name("openrouter:anthropic/claude-sonnet-4.5") == ( + "anthropic/claude-sonnet-4.5" + ) + assert _openrouter_model_name("gpt-4o") == "openai/gpt-4o" + assert _openrouter_model_name("openai:gpt-4o-mini") == "openai/gpt-4o-mini" + + monkeypatch.setenv("OPENROUTER_MODEL", "google/gemini-2.5-flash") + assert _openrouter_model_name("gpt-4o") == "google/gemini-2.5-flash" + + +def test_build_openrouter_model_uses_openrouter_env(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("OPENROUTER_API_KEY", "test-openrouter-key") + monkeypatch.setenv("OPENROUTER_MODEL", "openai/gpt-4o-mini") + monkeypatch.setenv("OPENROUTER_HTTP_REFERER", "https://example.com") + monkeypatch.setenv("OPENROUTER_APP_TITLE", "DimOS Test") + + model = _build_openrouter_model("gpt-4o") + + assert model is not None + assert model.model_name == "openai/gpt-4o-mini" + assert str(model.openai_api_base).rstrip("/") == OPENROUTER_BASE_URL + assert model.default_headers == { + "HTTP-Referer": "https://example.com", + "X-OpenRouter-Title": "DimOS Test", + } + + +def test_build_openrouter_model_returns_none_without_key( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.delenv("OPENROUTER_API_KEY", raising=False) + + assert _build_openrouter_model("gpt-4o") is None From 00c0ad40460d8a7a0666377531d4bbb267f1c28d Mon Sep 17 00:00:00 2001 From: Ernest Date: Thu, 28 May 2026 23:39:09 +0800 Subject: [PATCH 06/16] feat(seat-guide): add Go2 hardware guidance flow --- bin/demo_seat_guide_hardware_acceptance | 29 +- bin/demo_seat_guide_hardware_bringup | 46 +- bin/demo_seat_guide_macos_camera_detect | 542 ++++++++++++ bin/demo_seat_guide_verify_acceptance_log | 8 +- bin/demo_seat_guide_web_camera | 49 ++ dimos/agents/skills/navigation.py | 11 +- dimos/agents/skills/seat_guide.py | 630 +++++++++++++- dimos/agents/skills/test_seat_guide.py | 805 +++++++++++++++++- dimos/agents/skills/test_speak_skill.py | 121 +++ .../skills/test_unitree_skill_container.py | 20 + dimos/agents/skills/unitree_speak_skill.py | 340 ++++++++ dimos/agents/web_human_input.py | 56 ++ dimos/models/vl/moondream.py | 6 +- .../blueprints/agentic/_seat_guide_agentic.py | 32 + .../agentic/unitree_go2_seat_guide.py | 6 +- .../agentic/unitree_go2_seat_guide_agentic.py | 6 +- dimos/robot/unitree/go2/connection.py | 2 +- dimos/robot/unitree/go2/connection_spec.py | 2 + .../robot/unitree/unitree_skill_container.py | 42 +- dimos/web/dimos_interface/api/server.py | 749 ++++++++++++++++ docs/agents/seat_guide_modules.md | 37 +- docs/agents/seat_guide_step_by_step_plan.md | 319 +++++++ .../agents/seat_guide_step_by_step_plan_en.md | 319 +++++++ docs/capabilities/agents/readme.md | 6 +- 24 files changed, 4100 insertions(+), 83 deletions(-) create mode 100755 bin/demo_seat_guide_macos_camera_detect create mode 100755 bin/demo_seat_guide_web_camera create mode 100644 dimos/agents/skills/unitree_speak_skill.py create mode 100644 dimos/robot/unitree/go2/blueprints/agentic/_seat_guide_agentic.py create mode 100644 docs/agents/seat_guide_step_by_step_plan.md create mode 100644 docs/agents/seat_guide_step_by_step_plan_en.md diff --git a/bin/demo_seat_guide_hardware_acceptance b/bin/demo_seat_guide_hardware_acceptance index df3796e927..ad5a941594 100755 --- a/bin/demo_seat_guide_hardware_acceptance +++ b/bin/demo_seat_guide_hardware_acceptance @@ -145,6 +145,10 @@ camera_provider_ready_for_hardware() { grep -Fq "credential=present" <<<"${output}" \ && grep -Eq "image=[0-9]+x[0-9]+" <<<"${output}" \ && grep -Fq "image_fresh=true" <<<"${output}" \ + && grep -Eq "camera_info=[0-9]+x[0-9]+" <<<"${output}" \ + && grep -Fq "camera_info_fresh=true" <<<"${output}" \ + && grep -Eq "lidar=[1-9][0-9]* points" <<<"${output}" \ + && grep -Fq "lidar_fresh=true" <<<"${output}" \ && grep -Fq "odom=(" <<<"${output}" \ && grep -Fq "odom_fresh=true" <<<"${output}" \ && grep -Fq "override=inactive" <<<"${output}" \ @@ -155,7 +159,7 @@ camera_provider_ready_for_hardware() { log_camera_provider_no_go_details() { local output="$1" if grep -Fq "credential=missing" <<<"${output}"; then - log " - VLM credential is missing. Export ALIBABA_API_KEY in the environment used to start the DimOS daemon, then restart the SeatGuide stack." + log " - VLM credential is missing. For qwen, export ALIBABA_API_KEY in the environment used to start the DimOS daemon, then restart the SeatGuide stack. For moondream, verify the local model cache." fi if grep -Fq "image=missing" <<<"${output}" || ! grep -Eq "image=[0-9]+x[0-9]+" <<<"${output}"; then log " - Camera image is missing. Check the Go2 camera stream and confirm the robot is publishing color_image." @@ -163,6 +167,18 @@ log_camera_provider_no_go_details() { if grep -Fq "image_fresh=false" <<<"${output}"; then log " - Camera image is stale. Restart/fix the camera stream before using visual detections for a live goal." fi + if grep -Fq "camera_info=missing" <<<"${output}" || ! grep -Eq "camera_info=[0-9]+x[0-9]+" <<<"${output}"; then + log " - Camera calibration is missing. Check GO2Connection.camera_info so 2D detections can be projected into 3D." + fi + if grep -Fq "camera_info_fresh=false" <<<"${output}"; then + log " - Camera calibration timestamps are stale. Restart/fix camera_info publishing before projecting detections into 3D." + fi + if grep -Fq "lidar=missing" <<<"${output}" || ! grep -Eq "lidar=[1-9][0-9]* points" <<<"${output}"; then + log " - LiDAR point cloud is missing. Check the Go2 L1 LiDAR stream; SeatGuide needs 3D points for real navigation targets." + fi + if grep -Fq "lidar_fresh=false" <<<"${output}"; then + log " - LiDAR point cloud is stale. Restart/fix the LiDAR stream before using live SeatGuide navigation." + fi if grep -Fq "odom=missing" <<<"${output}"; then log " - Odometry is missing. Check localization/odom before sending a map-frame navigation goal." fi @@ -224,7 +240,7 @@ log_seat_guide_no_go_details() { fi if grep -Fq "source=camera_detection_error" <<<"${output}" \ || grep -Fq "perception=camera_detection_error" <<<"${output}"; then - log " - SeatGuide camera detection failed. Check ALIBABA_API_KEY, VLM model setup, and DimOS logs." + log " - SeatGuide camera detection failed. Check the configured VLM model, required credential or local model cache, and DimOS logs." fi if grep -Fq "is not live camera" <<<"${output}" \ || grep -Fq "source=configured_fallback" <<<"${output}" \ @@ -642,7 +658,7 @@ log "" log "Checking current SeatGuide scene..." run_capture mcp call seat_guide_status scene_output="${run_output}" -if ! grep -Fq "SeatGuide scene source=camera:" <<<"${scene_output}"; then +if ! grep -Eq "SeatGuide scene source=camera(_3d)?:" <<<"${scene_output}"; then log "Hardware acceptance no-go: seat_guide_status did not report live camera perception." log_seat_guide_no_go_details "${scene_output}" log "Transcript saved to: ${log_file}" @@ -686,7 +702,12 @@ log "" log "Previewing selected goal without moving..." run_capture mcp call preview_empty_seat_goal preview_goal_output="${run_output}" -require_output_contains "${preview_goal_output}" "SeatGuide preview source=camera:" "preview_empty_seat_goal" +if ! grep -Eq "SeatGuide preview source=camera(_3d)?:" <<<"${preview_goal_output}"; then + log "Hardware acceptance no-go: preview_empty_seat_goal did not use live camera perception." + log_seat_guide_no_go_details "${preview_goal_output}" + log "Transcript saved to: ${log_file}" + exit 3 +fi require_output_contains "${preview_goal_output}" "selected" "preview_empty_seat_goal" require_output_contains "${preview_goal_output}" "goal=(" "preview_empty_seat_goal" diff --git a/bin/demo_seat_guide_hardware_bringup b/bin/demo_seat_guide_hardware_bringup index 6b623ef9b0..a494ebc2f8 100755 --- a/bin/demo_seat_guide_hardware_bringup +++ b/bin/demo_seat_guide_hardware_bringup @@ -3,20 +3,26 @@ set -euo pipefail usage() { cat <<'EOF' -Usage: bin/demo_seat_guide_hardware_bringup [--robot-ip ] [--skip-start] [--skip-smoke] +Usage: bin/demo_seat_guide_hardware_bringup [--robot-ip ] [--detection-model ] [--skip-start] [--skip-smoke] Start the real Go2 SeatGuide stack, run no-motion readiness checks, then run the browser-microphone hardware acceptance flow. Options: --robot-ip Go2 robot IP. Default: 192.168.123.161 + --detection-model + VLM detector. Default: moondream. Use qwen only when + ALIBABA_API_KEY is configured. --skip-start Use the currently running DimOS stack instead of starting one. --skip-smoke Skip the no-motion smoke wrapper and go straight to hardware acceptance. -h, --help Show this help. Required environment: - ALIBABA_API_KEY Qwen/VLM chair/person detection. - OPENAI_API_KEY TTS speech feedback. + ALIBABA_API_KEY Required only when --detection-model qwen. + OPENROUTER_API_KEY or OPENAI_API_KEY + LLM agent for normal voice/text commands. + OPENAI_API_KEY Optional for TTS speech feedback. Hardware acceptance + still reports TTS as no-go when this is missing. EOF } @@ -29,6 +35,7 @@ run_dimos() { } robot_ip="${SEAT_GUIDE_ROBOT_IP:-192.168.123.161}" +detection_model="${SEAT_GUIDE_DETECTION_MODEL:-moondream}" skip_start=0 skip_smoke=0 @@ -43,6 +50,15 @@ while [[ $# -gt 0 ]]; do robot_ip="$2" shift 2 ;; + --detection-model) + if [[ $# -lt 2 ]]; then + echo "Missing value for --detection-model." >&2 + usage >&2 + exit 2 + fi + detection_model="$2" + shift 2 + ;; --skip-start) skip_start=1 shift @@ -64,29 +80,41 @@ while [[ $# -gt 0 ]]; do done missing=0 -if [[ -z "${ALIBABA_API_KEY:-}" ]]; then - echo "SeatGuide bring-up no-go: ALIBABA_API_KEY is not set." >&2 +if [[ "${detection_model}" == "qwen" && -z "${ALIBABA_API_KEY:-}" ]]; then + echo "SeatGuide bring-up no-go: ALIBABA_API_KEY is not set for detection_model=qwen." >&2 missing=1 fi -if [[ -z "${OPENAI_API_KEY:-}" ]]; then - echo "SeatGuide bring-up no-go: OPENAI_API_KEY is not set." >&2 +if [[ -z "${OPENROUTER_API_KEY:-}" && -z "${OPENAI_API_KEY:-}" ]]; then + echo "SeatGuide bring-up no-go: neither OPENROUTER_API_KEY nor OPENAI_API_KEY is set." >&2 missing=1 fi if [[ "${missing}" != "0" ]]; then cat >&2 <<'EOF' Set the required keys in the same terminal that will start DimOS, for example: + export OPENROUTER_API_KEY=... + export OPENROUTER_MODEL=openai/gpt-4o-mini + +If you choose --detection-model qwen, also set: + export ALIBABA_API_KEY=... + +For spoken TTS feedback during official hardware acceptance, also set: + export OPENAI_API_KEY=... EOF exit 2 fi +if [[ -z "${OPENAI_API_KEY:-}" ]]; then + echo "SeatGuide bring-up warning: OPENAI_API_KEY is not set; TTS speech feedback will be unavailable." >&2 +fi + script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" if [[ "${skip_start}" != "1" ]]; then - echo "Starting real Go2 SeatGuide stack at robot IP ${robot_ip}..." - run_dimos run unitree-go2-seat-guide-agentic --robot-ip "${robot_ip}" --daemon + echo "Starting real Go2 SeatGuide stack at robot IP ${robot_ip} with detection_model=${detection_model}..." + run_dimos --robot-ip "${robot_ip}" --detection-model "${detection_model}" run unitree-go2-seat-guide-agentic --daemon else echo "Using the currently running DimOS stack." fi diff --git a/bin/demo_seat_guide_macos_camera_detect b/bin/demo_seat_guide_macos_camera_detect new file mode 100755 index 0000000000..43a5611d9e --- /dev/null +++ b/bin/demo_seat_guide_macos_camera_detect @@ -0,0 +1,542 @@ +#!/usr/bin/env -S uv run python +# Copyright 2026 Dimensional Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Capture a macOS webcam frame and annotate empty-seat detections with Moondream2.""" + +from __future__ import annotations + +import argparse +import base64 +from dataclasses import dataclass +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +import json +from pathlib import Path +import sys +import time +import webbrowser + +import cv2 +import numpy as np + +from dimos.models.vl.moondream import MoondreamVlModel +from dimos.msgs.sensor_msgs.Image import Image, ImageFormat + +Bbox = tuple[float, float, float, float] + + +@dataclass(frozen=True) +class SeatCandidate: + bbox: Bbox + occupied: bool + + +def _parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser( + description=( + "Capture one frame from a macOS webcam, detect chairs and people with " + "Moondream2, then draw empty-chair candidates." + ) + ) + parser.add_argument("--camera-index", type=int, default=0) + parser.add_argument("--width", type=int, default=1280) + parser.add_argument("--height", type=int, default=720) + parser.add_argument("--warmup-frames", type=int, default=5) + parser.add_argument("--max-objects", type=int, default=10) + parser.add_argument( + "--browser-camera", + action="store_true", + help="Use a local browser page for camera capture instead of OpenCV/AVFoundation.", + ) + parser.add_argument( + "--no-browser-fallback", + action="store_true", + help="Do not fall back to browser camera capture when OpenCV camera access is denied.", + ) + parser.add_argument("--port", type=int, default=5566, help="Browser camera server port.") + parser.add_argument( + "--preview", + action=argparse.BooleanOptionalAction, + default=True, + help="Show a live camera window before capturing a frame. Press Space/Enter to capture.", + ) + parser.add_argument( + "--image", + type=Path, + default=None, + help="Use an existing image instead of opening the macOS camera.", + ) + parser.add_argument( + "--occupancy-padding", + type=float, + default=0.15, + help="Expand chair boxes by this fraction before checking whether a person is sitting there.", + ) + parser.add_argument( + "--out", + type=Path, + default=Path("/tmp/seat_guide_macos_empty_seat.png"), + help="Annotated output image path.", + ) + parser.add_argument( + "--raw-out", + type=Path, + default=Path("/tmp/seat_guide_macos_webcam_raw.jpg"), + help="Raw captured frame path.", + ) + parser.add_argument("--no-open", action="store_true", help="Do not open the output image.") + return parser.parse_args() + + +def _capture_frame(args: argparse.Namespace) -> tuple[object, int, int]: + if args.image is not None: + frame = cv2.imread(str(args.image)) + if frame is None: + raise RuntimeError(f"Failed to read image: {args.image}") + height, width = frame.shape[:2] + return frame, width, height + + cap = cv2.VideoCapture(args.camera_index) + if not cap.isOpened(): + raise RuntimeError( + f"Failed to open camera index {args.camera_index}. " + "Check macOS Camera permission or try --camera-index 1." + ) + + cap.set(cv2.CAP_PROP_FRAME_WIDTH, args.width) + cap.set(cv2.CAP_PROP_FRAME_HEIGHT, args.height) + + frame = None + ok = False + window_name = "SeatGuide macOS camera - Space/Enter captures, q/Esc quits" + + for _ in range(max(0, args.warmup_frames)): + ok, frame = cap.read() + if not ok: + break + + if args.preview: + cv2.namedWindow(window_name, cv2.WINDOW_NORMAL) + while True: + ok, frame = cap.read() + if not ok or frame is None: + break + + preview = frame.copy() + cv2.rectangle(preview, (0, 0), (preview.shape[1], 44), (0, 0, 0), -1) + cv2.putText( + preview, + "SeatGuide camera preview: Space/Enter capture, q/Esc quit", + (12, 29), + cv2.FONT_HERSHEY_SIMPLEX, + 0.8, + (255, 255, 255), + 2, + cv2.LINE_AA, + ) + cv2.imshow(window_name, preview) + key = cv2.waitKey(1) & 0xFF + if key in (13, 32): + break + if key in (27, ord("q")): + cap.release() + cv2.destroyWindow(window_name) + raise RuntimeError("Camera preview was cancelled.") + cv2.destroyWindow(window_name) + else: + ok, frame = cap.read() + + cap.release() + + if not ok or frame is None: + raise RuntimeError(f"Failed to read a frame from camera index {args.camera_index}.") + + height, width = frame.shape[:2] + return frame, width, height + + +def _to_dimos_image(bgr: object) -> Image: + rgb = cv2.cvtColor(bgr, cv2.COLOR_BGR2RGB) + return Image.from_numpy( + rgb, + format=ImageFormat.RGB, + frame_id="mac_camera", + ts=time.time(), + ) + + +def _detect_bboxes(model: MoondreamVlModel, image: Image, query: str, max_objects: int) -> list[Bbox]: + result = model.query_detections(image, query, max_objects=max_objects) + return [detection.bbox for detection in result.detections] + + +def _expanded_bbox(bbox: Bbox, width: int, height: int, fraction: float) -> Bbox: + x1, y1, x2, y2 = bbox + pad_x = max(0.0, (x2 - x1) * fraction) + pad_y = max(0.0, (y2 - y1) * fraction) + return ( + max(0.0, x1 - pad_x), + max(0.0, y1 - pad_y), + min(float(width), x2 + pad_x), + min(float(height), y2 + pad_y), + ) + + +def _center(bbox: Bbox) -> tuple[float, float]: + x1, y1, x2, y2 = bbox + return (x1 + x2) / 2.0, (y1 + y2) / 2.0 + + +def _contains_point(bbox: Bbox, point: tuple[float, float]) -> bool: + x1, y1, x2, y2 = bbox + x, y = point + return x1 <= x <= x2 and y1 <= y <= y2 + + +def _classify_seats( + chairs: list[Bbox], + people: list[Bbox], + *, + width: int, + height: int, + occupancy_padding: float, +) -> list[SeatCandidate]: + people_centers = [_center(person) for person in people] + seats: list[SeatCandidate] = [] + for chair in chairs: + occupancy_region = _expanded_bbox(chair, width, height, occupancy_padding) + occupied = any(_contains_point(occupancy_region, center) for center in people_centers) + seats.append(SeatCandidate(bbox=chair, occupied=occupied)) + return seats + + +def _draw_label( + image: object, + label: str, + origin: tuple[int, int], + color: tuple[int, int, int], + *, + scale: float = 0.65, +) -> None: + x, y = origin + thickness = 2 + (text_w, text_h), baseline = cv2.getTextSize( + label, + cv2.FONT_HERSHEY_SIMPLEX, + scale, + thickness, + ) + top = max(0, y - text_h - baseline - 8) + cv2.rectangle(image, (x, top), (x + text_w + 8, top + text_h + baseline + 8), color, -1) + cv2.putText( + image, + label, + (x + 4, top + text_h + 3), + cv2.FONT_HERSHEY_SIMPLEX, + scale, + (255, 255, 255), + thickness, + cv2.LINE_AA, + ) + + +def _draw_results( + frame: object, + seats: list[SeatCandidate], + people: list[Bbox], +) -> object: + annotated = frame.copy() + green = (0, 180, 0) + red = (0, 0, 220) + blue = (220, 120, 0) + + for index, seat in enumerate(seats, start=1): + x1, y1, x2, y2 = [round(value) for value in seat.bbox] + color = red if seat.occupied else green + label = f"occupied chair {index}" if seat.occupied else f"EMPTY SEAT {index}" + cv2.rectangle(annotated, (x1, y1), (x2, y2), color, 3) + _draw_label(annotated, label, (x1, y1), color) + + for index, person in enumerate(people, start=1): + x1, y1, x2, y2 = [round(value) for value in person] + cv2.rectangle(annotated, (x1, y1), (x2, y2), blue, 2) + _draw_label(annotated, f"person {index}", (x1, y1), blue, scale=0.55) + + empty_count = sum(1 for seat in seats if not seat.occupied) + summary = f"chairs={len(seats)} people={len(people)} empty={empty_count}" + cv2.rectangle(annotated, (0, 0), (min(annotated.shape[1], 520), 42), (0, 0, 0), -1) + cv2.putText( + annotated, + summary, + (12, 29), + cv2.FONT_HERSHEY_SIMPLEX, + 0.85, + (255, 255, 255), + 2, + cv2.LINE_AA, + ) + return annotated + + +def _run_detection(frame: object, args: argparse.Namespace) -> tuple[dict[str, object], object]: + height, width = frame.shape[:2] + image = _to_dimos_image(frame) + print("SeatGuide detection: loading Moondream2 without torch.compile for demo latency.") + model = MoondreamVlModel(compile_model=False) + print("SeatGuide detection: detecting chairs...") + chairs = _detect_bboxes(model, image, "chair", args.max_objects) + print(f"SeatGuide detection: detected {len(chairs)} chair candidates.") + print("SeatGuide detection: detecting people...") + people = _detect_bboxes(model, image, "person", args.max_objects) + print(f"SeatGuide detection: detected {len(people)} people.") + seats = _classify_seats( + chairs, + people, + width=width, + height=height, + occupancy_padding=args.occupancy_padding, + ) + annotated = _draw_results(frame, seats, people) + empty_count = sum(1 for seat in seats if not seat.occupied) + summary: dict[str, object] = { + "chairs": len(seats), + "people": len(people), + "empty": empty_count, + "seats": [ + { + "id": f"seat_{index}", + "status": "empty" if not seat.occupied else "occupied", + "bbox": [round(value, 1) for value in seat.bbox], + } + for index, seat in enumerate(seats, start=1) + ], + } + return summary, annotated + + +def _write_detection_outputs( + frame: object, + annotated: object, + args: argparse.Namespace, +) -> None: + args.raw_out.parent.mkdir(parents=True, exist_ok=True) + cv2.imwrite(str(args.raw_out), frame) + args.out.parent.mkdir(parents=True, exist_ok=True) + cv2.imwrite(str(args.out), annotated) + + +def _html_page() -> bytes: + return b""" + + + + SeatGuide Camera + + + +
+
+

Camera Preview

+ + + +
Starting camera...
+
+
+

Moondream2 Result

+ Detection result will appear here +
+
+ + + + +""" + + +def _serve_browser_camera(args: argparse.Namespace) -> int: + class Handler(BaseHTTPRequestHandler): + def do_GET(self) -> None: + if self.path != "/": + self.send_error(404) + return + body = _html_page() + self.send_response(200) + self.send_header("content-type", "text/html; charset=utf-8") + self.send_header("content-length", str(len(body))) + self.end_headers() + self.wfile.write(body) + + def do_POST(self) -> None: + if self.path != "/detect": + self.send_error(404) + return + try: + content_length = int(self.headers.get("content-length", "0")) + payload = json.loads(self.rfile.read(content_length)) + print("SeatGuide browser camera: received frame; starting detection.") + image_data = str(payload["image"]).split(",", 1)[1] + encoded = base64.b64decode(image_data) + frame = cv2.imdecode(np.frombuffer(encoded, dtype=np.uint8), cv2.IMREAD_COLOR) + if frame is None: + raise RuntimeError("Failed to decode browser camera frame.") + summary, annotated = _run_detection(frame, args) + _write_detection_outputs(frame, annotated, args) + ok, png = cv2.imencode(".png", annotated) + if not ok: + raise RuntimeError("Failed to encode annotated image.") + response = { + **summary, + "saved": str(args.out), + "annotated_image": "data:image/png;base64," + + base64.b64encode(png.tobytes()).decode("ascii"), + } + body = json.dumps(response).encode("utf-8") + self.send_response(200) + self.send_header("content-type", "application/json") + self.send_header("content-length", str(len(body))) + self.end_headers() + self.wfile.write(body) + except Exception as exc: + body = json.dumps({"error": str(exc)}).encode("utf-8") + self.send_response(500) + self.send_header("content-type", "application/json") + self.send_header("content-length", str(len(body))) + self.end_headers() + self.wfile.write(body) + + def log_message(self, format: str, *args: object) -> None: + return + + server = ThreadingHTTPServer(("127.0.0.1", args.port), Handler) + url = f"http://127.0.0.1:{args.port}/" + print(f"SeatGuide browser camera server: {url}") + print("Use the browser preview, then click 'Detect empty seat'. Press Ctrl-C here to stop.") + webbrowser.open(url) + try: + server.serve_forever() + except KeyboardInterrupt: + pass + finally: + server.server_close() + return 0 + + +def main() -> int: + args = _parse_args() + if args.browser_camera: + return _serve_browser_camera(args) + try: + frame, width, height = _capture_frame(args) + summary, annotated = _run_detection(frame, args) + _write_detection_outputs(frame, annotated, args) + + print( + f"SeatGuide macOS camera result: chairs={summary['chairs']} " + f"people={summary['people']} empty={summary['empty']}" + ) + print(f"raw_image={args.raw_out}") + print(f"annotated_image={args.out}") + for seat in summary["seats"]: + print(f"{seat['id']}: {seat['status']} bbox={tuple(seat['bbox'])}") + + if not args.no_open: + import subprocess + + subprocess.run(["open", str(args.out)], check=False) + return 0 + except Exception as exc: + if not args.image and not args.no_browser_fallback: + print( + f"OpenCV camera failed ({exc}). Falling back to browser camera capture.", + file=sys.stderr, + ) + return _serve_browser_camera(args) + print(f"SeatGuide macOS camera detection failed: {exc}", file=sys.stderr) + return 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/bin/demo_seat_guide_verify_acceptance_log b/bin/demo_seat_guide_verify_acceptance_log index 9a6b6d465b..14dbfed1f0 100755 --- a/bin/demo_seat_guide_verify_acceptance_log +++ b/bin/demo_seat_guide_verify_acceptance_log @@ -121,6 +121,10 @@ require_log_contains "WebInput" "voice command intake module" require_log_contains "SpeakSkill" "speech feedback module" require_log_matches "image=[0-9]+x[0-9]+" "camera image readiness" require_log_contains "image_fresh=true" "fresh camera image readiness" +require_log_matches "camera_info=[0-9]+x[0-9]+" "camera calibration readiness" +require_log_contains "camera_info_fresh=true" "fresh camera calibration readiness" +require_log_matches "lidar=[1-9][0-9]* points" "LiDAR point cloud readiness" +require_log_contains "lidar_fresh=true" "fresh LiDAR readiness" require_log_contains "credential=present" "VLM credential readiness" require_log_contains "odom=(" "odometry readiness" require_log_contains "odom_fresh=true" "fresh odometry readiness" @@ -133,10 +137,10 @@ require_log_contains "SeatGuide audio check. I can guide you to an empty seat." require_log_contains "Spoke: SeatGuide audio check" "TTS audio check completion" require_log_contains "Audio output confirmation:" "operator TTS audio confirmation prompt" require_log_contains "Operator audio confirmation: HEARD" "operator heard TTS confirmation" -require_log_contains "SeatGuide scene source=camera:" "live camera perception" +require_log_matches "SeatGuide scene source=camera(_3d)?:" "live camera perception" require_log_contains "SeatGuide preflight ready" "no-motion preflight" require_log_contains "speaker=connected" "SeatGuide speaker wiring" -require_log_contains "SeatGuide preview source=camera:" "camera-backed goal preview" +require_log_matches "SeatGuide preview source=camera(_3d)?:" "camera-backed goal preview" require_log_matches "empty=[0-9]+ occupied=[0-9]+" "SeatGuide occupancy counts" require_log_contains "Captured WebInput agent_responses stream" "typed WebInput stream" require_log_contains "Manual no-motion voice gate:" "browser microphone no-motion gate" diff --git a/bin/demo_seat_guide_web_camera b/bin/demo_seat_guide_web_camera new file mode 100755 index 0000000000..29eab4428e --- /dev/null +++ b/bin/demo_seat_guide_web_camera @@ -0,0 +1,49 @@ +#!/usr/bin/env -S uv run python +# Copyright 2026 Dimensional Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Run the browser-camera SeatGuide validation page without a robot.""" + +from __future__ import annotations + +import argparse +import webbrowser + +from dimos.web.robot_web_interface import RobotWebInterface + + +def _parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser( + description="Run SeatGuide browser-camera validation without connecting to Go2." + ) + parser.add_argument("--host", default="127.0.0.1") + parser.add_argument("--port", type=int, default=5555) + parser.add_argument("--no-open", action="store_true") + return parser.parse_args() + + +def main() -> int: + args = _parse_args() + url = f"http://{args.host}:{args.port}/seat-guide-camera" + server = RobotWebInterface(host=args.host, port=args.port) + print(f"SeatGuide browser-camera validation: {url}") + print("This mode does not connect to Go2. Press Ctrl-C to stop.") + if not args.no_open: + webbrowser.open(url) + server.run() + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/dimos/agents/skills/navigation.py b/dimos/agents/skills/navigation.py index d88bec452e..d2c9cd04ab 100644 --- a/dimos/agents/skills/navigation.py +++ b/dimos/agents/skills/navigation.py @@ -43,7 +43,7 @@ class NavigationSkillContainer(Module): _skill_started: bool = False _similarity_threshold: float = 0.23 - _spatial_memory: SpatialMemorySpec + _spatial_memory: SpatialMemorySpec | None = None _navigation: NavigationInterfaceSpec _object_tracking: ObjectTrackingSpec | None = None @@ -104,6 +104,9 @@ def tag_location(self, location_name: str) -> str: rotation=(rotation.x, rotation.y, rotation.z), ) + if self._spatial_memory is None: + return "Spatial memory is unavailable, cannot tag location." + if not self._spatial_memory.tag_location(location): return f"Error: Failed to store '{location_name}' in the spatial memory" @@ -144,6 +147,9 @@ def navigate_with_text(self, query: str) -> str: return f"No tagged location called '{query}'. No object in view matching '{query}'. No matching location found in semantic map for '{query}'." def _navigate_by_tagged_location(self, query: str) -> str | None: + if self._spatial_memory is None: + return None + robot_location = self._spatial_memory.query_tagged_location(query) if not robot_location: @@ -227,6 +233,9 @@ def _get_bbox_for_current_frame(self, query: str) -> BBox | None: return get_object_bbox_from_image(self._vl_model, self._latest_image, query) def _navigate_using_semantic_map(self, query: str) -> str: + if self._spatial_memory is None: + return "Spatial memory is unavailable." + results = self._spatial_memory.query_by_text(query) if not results: diff --git a/dimos/agents/skills/seat_guide.py b/dimos/agents/skills/seat_guide.py index 17e39540e5..49c239a379 100644 --- a/dimos/agents/skills/seat_guide.py +++ b/dimos/agents/skills/seat_guide.py @@ -33,9 +33,16 @@ from dimos.models.vl.types import VlModelName from dimos.msgs.geometry_msgs.PoseStamped import PoseStamped from dimos.msgs.geometry_msgs.Quaternion import Quaternion +from dimos.msgs.geometry_msgs.Transform import Transform from dimos.msgs.geometry_msgs.Vector3 import Vector3 +from dimos.msgs.sensor_msgs.CameraInfo import CameraInfo from dimos.msgs.sensor_msgs.Image import Image +from dimos.msgs.sensor_msgs.PointCloud2 import PointCloud2 from dimos.navigation.navigation_spec import NavigationInterfaceSpec +from dimos.perception.detection.detectors.base import Detector +from dimos.perception.detection.type.detection2d.bbox import Detection2DBBox +from dimos.perception.detection.type.detection2d.imageDetections2D import ImageDetections2D +from dimos.perception.detection.type.detection3d.pointcloud import Detection3DPC from dimos.spec.utils import Spec from dimos.utils.logging_config import setup_logger @@ -88,6 +95,33 @@ class SeatObservationProviderSpec(Spec, Protocol): def get_seat_scene(self) -> SeatSceneObservation: ... +class ExplorationSpec(Spec, Protocol): + def begin_exploration(self) -> str: ... + def end_exploration(self) -> str: ... + + +class RelativeMoveSpec(Spec, Protocol): + def relative_move( + self, + forward: float = 0.0, + left: float = 0.0, + degrees: float = 0.0, + x: float = 0.0, + y: float = 0.0, + duration: float = 0.0, + ) -> str: ... + + +class DirectMoveSpec(Spec, Protocol): + def direct_move( + self, + x: float, + y: float = 0.0, + yaw: float = 0.0, + duration: float = 1.0, + ) -> str: ... + + class SeatGuideRequestSpec(Spec, Protocol): def handle_seat_request(self, text: str) -> str: ... def preview_seat_request(self, text: str) -> str: ... @@ -159,6 +193,9 @@ class SeatGuideSkillContainer(Module): _navigation: NavigationInterfaceSpec _seat_observation_provider: SeatObservationProviderSpec | None = None + _explorer: ExplorationSpec | None = None + _direct_mover: DirectMoveSpec | None = None + _relative_mover: RelativeMoveSpec | None = None _speaker: SpeakSkillSpec | None = None _seat_guide_goal_sequence: int = 0 _seat_guide_goal_reached_reset_required: bool = False @@ -178,6 +215,10 @@ def find_empty_seat( people: list[float], robot_x: float = 0.0, robot_y: float = 0.0, + wait_for_arrival: bool = False, + arrival_timeout_s: float = 60.0, + arrival_poll_s: float = 0.5, + arrival_message: str = "我已经到了, 空椅子在我右边, 请坐。", ) -> str: """Find an empty chair in a conference room and navigate next to it. @@ -191,6 +232,10 @@ def find_empty_seat( people: Flat person position list [x, y, x, y, ...]. robot_x: Robot x position used to choose the nearest empty seat. robot_y: Robot y position used to choose the nearest empty seat. + wait_for_arrival: When true, wait for navigation to finish before returning. + arrival_timeout_s: Maximum seconds to wait for arrival. + arrival_poll_s: Delay between navigation status checks. + arrival_message: Speech feedback to play after arrival. """ seat_observations = _parse_seats(seats) person_observations = _parse_people(people) @@ -247,10 +292,41 @@ def find_empty_seat( self._seat_guide_goal_sequence = getattr(self, "_seat_guide_goal_sequence", 0) + 1 self._seat_guide_goal_reached_reset_required = previous_goal_reached self._speak_feedback(result.spoken_summary) - return f"{result.spoken_summary} Navigating to ({result.goal_x:.2f}, {result.goal_y:.2f})." + navigating_message = ( + f"{result.spoken_summary} Navigating to ({result.goal_x:.2f}, {result.goal_y:.2f})." + ) + if not wait_for_arrival: + return navigating_message + + arrival_result = self._wait_for_arrival( + timeout_s=arrival_timeout_s, + poll_s=arrival_poll_s, + ) + if arrival_result == "arrived": + self._speak_feedback(arrival_message) + return f"{navigating_message} {arrival_message}" + if arrival_result == "failed": + message = ( + f"{navigating_message} I found the empty seat, but navigation stopped " + "before reaching it." + ) + self._speak_feedback(message) + return message + + message = ( + f"{navigating_message} I found the empty seat, but navigation did not " + f"finish within {arrival_timeout_s:.0f} seconds." + ) + self._speak_feedback(message) + return message @skill - def find_empty_seat_from_scene(self, require_live_perception: bool = True) -> str: + def find_empty_seat_from_scene( + self, + require_live_perception: bool = True, + wait_for_arrival: bool = False, + arrival_timeout_s: float = 60.0, + ) -> str: """Find an empty chair using the configured conference room observation provider. Use this for the SeatGuide demo when perception or a synthetic room provider @@ -260,6 +336,8 @@ def find_empty_seat_from_scene(self, require_live_perception: bool = True) -> st Args: require_live_perception: When true, only a camera-backed scene can trigger navigation. Set false only for explicit fallback calibration. + wait_for_arrival: When true, wait for navigation to finish before returning. + arrival_timeout_s: Maximum seconds to wait for arrival. """ if self._seat_observation_provider is None: message = "No seat observation provider is connected." @@ -267,7 +345,7 @@ def find_empty_seat_from_scene(self, require_live_perception: bool = True) -> st return message scene = self._seat_observation_provider.get_seat_scene() - if require_live_perception and scene.source != "camera": + if require_live_perception and not _is_live_camera_source(scene.source): message = _describe_live_perception_required(scene) self._speak_feedback(message) return message @@ -278,7 +356,213 @@ def find_empty_seat_from_scene(self, require_live_perception: bool = True) -> st people=people, robot_x=scene.robot_x, robot_y=scene.robot_y, + wait_for_arrival=wait_for_arrival, + arrival_timeout_s=arrival_timeout_s, + ) + + @skill + def search_for_empty_seat_from_scene( + self, + search_timeout_s: float = 30.0, + poll_interval_s: float = 0.5, + require_live_perception: bool = True, + wait_for_arrival: bool = False, + arrival_timeout_s: float = 60.0, + ) -> str: + """Move around while scanning for a visible empty chair, then navigate to it. + + Use this when the current camera view has no chair but the Go2 should + actively search the nearby area. The robot starts exploration, polls the + SeatGuide camera scene, stops exploration when a live camera seat is + visible, then sends the normal empty-seat navigation goal. + + Args: + search_timeout_s: Maximum time to search before stopping exploration. + poll_interval_s: Delay between camera scene checks during search. + require_live_perception: When true, only camera-backed detections can + trigger final navigation. + wait_for_arrival: When true, wait for navigation to finish before returning. + arrival_timeout_s: Maximum seconds to wait for arrival. + """ + if self._seat_observation_provider is None: + message = "No seat observation provider is connected." + self._speak_feedback(message) + return message + + if self._direct_mover is not None or self._relative_mover is not None: + return self.scan_for_empty_seat_from_scene( + max_turn_degrees=min(360.0, max(30.0, search_timeout_s * 12.0)), + step_degrees=30.0, + settle_s=max(0.5, poll_interval_s), + require_live_perception=require_live_perception, + wait_for_arrival=wait_for_arrival, + arrival_timeout_s=arrival_timeout_s, + ) + + if self._explorer is None: + message = ( + "I cannot see any seats yet, and no exploration module is connected " + "to search for one." + ) + self._speak_feedback(message) + return message + + search_timeout_s = max(1.0, min(search_timeout_s, 120.0)) + poll_interval_s = max(0.2, min(poll_interval_s, 5.0)) + initial_scene = self._seat_observation_provider.get_seat_scene() + if _scene_has_empty_seat(initial_scene) and ( + not require_live_perception or _is_live_camera_source(initial_scene.source) + ): + return self.find_empty_seat_from_scene( + require_live_perception=require_live_perception + ) + if ( + require_live_perception + and initial_scene.source != "camera_no_seats_detected" + and not _is_live_camera_source(initial_scene.source) + ): + message = _describe_live_perception_required(initial_scene) + self._speak_feedback(message) + return message + + self._explorer.begin_exploration() + exploration_active = True + deadline = time.time() + search_timeout_s + try: + while time.time() < deadline: + scene = self._seat_observation_provider.get_seat_scene() + if _scene_has_empty_seat(scene) and ( + not require_live_perception or _is_live_camera_source(scene.source) + ): + self._explorer.end_exploration() + exploration_active = False + return self.find_empty_seat_from_scene( + require_live_perception=require_live_perception, + wait_for_arrival=wait_for_arrival, + arrival_timeout_s=arrival_timeout_s, + ) + time.sleep(poll_interval_s) + finally: + if exploration_active: + self._explorer.end_exploration() + + message = ( + "I searched but still cannot see an empty seat. Please reposition me " + "or point the camera toward the conference table." ) + self._speak_feedback(message) + return message + + @skill + def scan_for_empty_seat_from_scene( + self, + max_turn_degrees: float = 360.0, + step_degrees: float = 30.0, + settle_s: float = 0.75, + turn_yaw_rate_rad_s: float = 0.5, + require_live_perception: bool = True, + wait_for_arrival: bool = False, + arrival_timeout_s: float = 60.0, + ) -> str: + """Rotate in place while scanning for a visible empty chair, then navigate. + + Use this for the SeatGuide demo when the current camera view does not + contain an empty chair. The robot turns in place in small increments, + checks camera-backed SeatGuide perception after each turn, and navigates + once an empty seat is visible. + + Args: + max_turn_degrees: Maximum total in-place scan angle. + step_degrees: Degrees to rotate per scan step. + settle_s: Delay after each turn before reading camera perception. + turn_yaw_rate_rad_s: Yaw velocity for direct in-place turns. + require_live_perception: When true, only camera-backed detections can + trigger final navigation. + wait_for_arrival: When true, wait for navigation to finish before returning. + arrival_timeout_s: Maximum seconds to wait for arrival. + """ + if self._seat_observation_provider is None: + message = "No seat observation provider is connected." + self._speak_feedback(message) + return message + if self._direct_mover is None and self._relative_mover is None: + message = ( + "I cannot rotate-scan for a seat because no relative movement " + "module is connected." + ) + self._speak_feedback(message) + return message + + max_turn_degrees = max(30.0, min(float(max_turn_degrees), 720.0)) + step_degrees = max(10.0, min(abs(float(step_degrees)), 90.0)) + settle_s = max(0.1, min(float(settle_s), 5.0)) + turn_yaw_rate_rad_s = max(0.1, min(abs(float(turn_yaw_rate_rad_s)), 1.5)) + steps = max(1, math.ceil(max_turn_degrees / step_degrees)) + + scene = self._seat_observation_provider.get_seat_scene() + if _scene_has_empty_seat(scene) and ( + not require_live_perception or _is_live_camera_source(scene.source) + ): + return self.find_empty_seat_from_scene( + require_live_perception=require_live_perception, + wait_for_arrival=wait_for_arrival, + arrival_timeout_s=arrival_timeout_s, + ) + if ( + require_live_perception + and not _is_live_camera_source(scene.source) + and scene.source != "camera_no_seats_detected" + ): + message = _describe_live_perception_required(scene) + self._speak_feedback(message) + return message + + for _ in range(steps): + move_result = self._turn_in_place( + degrees=step_degrees, + yaw_rate_rad_s=turn_yaw_rate_rad_s, + ) + if "failed" in move_result.lower() or "cancelled" in move_result.lower(): + message = f"SeatGuide scan stopped because rotation failed: {move_result}." + self._speak_feedback(message) + return message + time.sleep(settle_s) + + scene = self._seat_observation_provider.get_seat_scene() + if _scene_has_empty_seat(scene) and ( + not require_live_perception or _is_live_camera_source(scene.source) + ): + return self.find_empty_seat_from_scene( + require_live_perception=require_live_perception, + wait_for_arrival=wait_for_arrival, + arrival_timeout_s=arrival_timeout_s, + ) + + message = ( + "I rotated in place but still cannot see an empty seat. Please reposition me " + "or point the camera toward the conference table." + ) + self._speak_feedback(message) + return message + + def _turn_in_place(self, *, degrees: float, yaw_rate_rad_s: float) -> str: + if self._direct_mover is not None: + yaw_direction = 1.0 if degrees >= 0 else -1.0 + yaw = yaw_direction * yaw_rate_rad_s + duration = max(0.2, abs(math.radians(degrees)) / yaw_rate_rad_s) + return self._direct_mover.direct_move( + x=0.0, + y=0.0, + yaw=yaw, + duration=duration, + ) + if self._relative_mover is not None: + return self._relative_mover.relative_move( + forward=0.0, + left=0.0, + degrees=degrees, + ) + return "Rotation failed: no movement module is connected." @skill def preview_empty_seat_goal(self) -> str: @@ -295,7 +579,13 @@ def preview_empty_seat_goal(self) -> str: return _describe_goal_preview(scene) @skill - def handle_seat_request(self, text: str, require_live_perception: bool = True) -> str: + def handle_seat_request( + self, + text: str, + require_live_perception: bool = True, + wait_for_arrival: bool = True, + arrival_timeout_s: float = 60.0, + ) -> str: """Handle a spoken or typed request to find an empty conference room seat. This is the Go2-free voice intake boundary for the SeatGuide demo. Pass @@ -306,6 +596,8 @@ def handle_seat_request(self, text: str, require_live_perception: bool = True) - text: Transcribed or typed user request. require_live_perception: When true, only a camera-backed scene can trigger navigation. Set false only for explicit fallback calibration. + wait_for_arrival: When true, wait for navigation to finish before returning. + arrival_timeout_s: Maximum seconds to wait for arrival. """ intent = parse_seat_guide_intent(text) if not intent.should_find_seat: @@ -313,8 +605,29 @@ def handle_seat_request(self, text: str, require_live_perception: bool = True) - self._speak_feedback(message) return message + scene = ( + self._seat_observation_provider.get_seat_scene() + if self._seat_observation_provider is not None + else None + ) + if ( + scene is not None + and ( + (scene.source == "camera_no_seats_detected" and not scene.seats) + or (_is_live_camera_source(scene.source) and not _scene_has_empty_seat(scene)) + ) + and self._explorer is not None + ): + return self.search_for_empty_seat_from_scene( + require_live_perception=require_live_perception, + wait_for_arrival=wait_for_arrival, + arrival_timeout_s=arrival_timeout_s, + ) + return self.find_empty_seat_from_scene( - require_live_perception=require_live_perception + require_live_perception=require_live_perception, + wait_for_arrival=wait_for_arrival, + arrival_timeout_s=arrival_timeout_s, ) @skill @@ -444,6 +757,39 @@ def _navigation_goal_reached_or_false(self) -> bool: except Exception: return False + def _wait_for_arrival(self, *, timeout_s: float, poll_s: float) -> str: + timeout_s = max(1.0, min(float(timeout_s), 300.0)) + poll_s = max(0.1, min(float(poll_s), 5.0)) + start = time.time() + deadline = start + timeout_s + saw_non_idle = False + + while time.time() < deadline: + try: + state = self._navigation.get_state() + raw_goal_reached = self._navigation.is_goal_reached() + except Exception: + logger.warning("SeatGuide navigation arrival check failed", exc_info=True) + return "failed" + + goal_reached = raw_goal_reached + if getattr(self, "_seat_guide_goal_reached_reset_required", False): + if raw_goal_reached: + goal_reached = False + else: + self._seat_guide_goal_reached_reset_required = False + + if goal_reached: + return "arrived" + if state.name != "IDLE": + saw_non_idle = True + elif saw_non_idle or time.time() - start >= min(1.0, timeout_s): + return "failed" + + time.sleep(poll_s) + + return "timeout" + def _speak_feedback(self, text: str) -> None: if self._speaker is None: return @@ -478,7 +824,7 @@ def _describe_preflight( "SeatGuide preflight no-go: " f"{navigation_text}; perception={scene.source} no seats; {speaker_text}." ) - if require_live_perception and scene.source != "camera": + if require_live_perception and not _is_live_camera_source(scene.source): return ( "SeatGuide preflight no-go: " f"{navigation_text}; perception={scene.source} is not live camera; " @@ -588,7 +934,10 @@ def clear_seat_scene_override(self) -> str: class CameraSeatSceneConfig(SyntheticSeatSceneConfig): seats: list[float] = Field(default_factory=list) people: list[float] = Field(default_factory=list) - detection_model: VlModelName = "qwen" + detection_model: VlModelName | None = None + fast_detector_enabled: bool = True + fast_detector_model_name: str = "yolo11n.pt" + vlm_fallback_enabled: bool = False chair_distance_m: float = 2.0 lateral_span_m: float = 3.0 max_input_age_s: float = 5.0 @@ -599,11 +948,16 @@ class CameraSeatObservationProvider(Module): config: CameraSeatSceneConfig color_image: In[Image] + camera_info: In[CameraInfo] + lidar: In[PointCloud2] odom: In[PoseStamped] _latest_image: Image | None = None + _latest_camera_info: CameraInfo | None = None + _latest_lidar: PointCloud2 | None = None _latest_odom: PoseStamped | None = None _vl_model: VlModel | None = None + _fast_detector: Detector | None = None _scene_override: SeatSceneObservation | None = None _scene_lock: RLock = RLock() @@ -611,6 +965,8 @@ class CameraSeatObservationProvider(Module): def start(self) -> None: super().start() self.register_disposable(Disposable(self.color_image.subscribe(self._on_color_image))) + self.register_disposable(Disposable(self.camera_info.subscribe(self._on_camera_info))) + self.register_disposable(Disposable(self.lidar.subscribe(self._on_lidar))) self.register_disposable(Disposable(self.odom.subscribe(self._on_odom))) @rpc @@ -621,6 +977,14 @@ def _on_color_image(self, image: Image) -> None: with self._scene_lock: self._latest_image = image + def _on_camera_info(self, camera_info: CameraInfo) -> None: + with self._scene_lock: + self._latest_camera_info = camera_info + + def _on_lidar(self, lidar: PointCloud2) -> None: + with self._scene_lock: + self._latest_lidar = lidar + def _on_odom(self, odom: PoseStamped) -> None: with self._scene_lock: self._latest_odom = odom @@ -632,6 +996,8 @@ def get_seat_scene(self) -> SeatSceneObservation: return self._scene_override latest_image = self._latest_image latest_odom = self._latest_odom + latest_camera_info = getattr(self, "_latest_camera_info", None) + latest_lidar = getattr(self, "_latest_lidar", None) if latest_image is None: return _scene_from_flat_config(self.config, source="no_camera_image") @@ -643,7 +1009,12 @@ def get_seat_scene(self) -> SeatSceneObservation: return _scene_from_flat_config(self.config, source="stale_camera_odom") try: - detected_scene = self._detect_scene_from_image(latest_image, latest_odom) + detected_scene = self._detect_scene_from_image( + latest_image, + latest_odom, + camera_info=latest_camera_info, + lidar=latest_lidar, + ) except Exception: logger.warning( "Failed to detect conference room seats from camera image", exc_info=True @@ -706,6 +1077,8 @@ def camera_seat_provider_status(self) -> str: with self._scene_lock: override_active = self._scene_override is not None latest_image = self._latest_image + latest_camera_info = getattr(self, "_latest_camera_info", None) + latest_lidar = getattr(self, "_latest_lidar", None) latest_odom = self._latest_odom image_text = ( f"image={latest_image.width}x{latest_image.height}" @@ -718,43 +1091,84 @@ def camera_seat_provider_status(self) -> str: if latest_odom is not None else "odom=missing" ) + camera_info_text = ( + f"camera_info={latest_camera_info.width}x{latest_camera_info.height}" + if latest_camera_info is not None + else "camera_info=missing" + ) + lidar_text = ( + f"lidar={len(latest_lidar)} points" if latest_lidar is not None else "lidar=missing" + ) image_fresh_text = _freshness_text( latest_image.ts if latest_image is not None else None, self.config.max_input_age_s, ) + camera_info_fresh_text = _freshness_text( + latest_camera_info.ts if latest_camera_info is not None else None, + self.config.max_input_age_s, + ) + lidar_fresh_text = _freshness_text( + latest_lidar.ts if latest_lidar is not None else None, + self.config.max_input_age_s, + ) odom_fresh_text = _freshness_text( latest_odom.ts if latest_odom is not None else None, self.config.max_input_age_s, ) - credential_text = ( - "credential=present" - if self.config.detection_model != "qwen" or os.getenv("ALIBABA_API_KEY") - else "credential=missing" - ) + detection_model = self._detection_model() + credential_text = self._credential_status_for(detection_model) + detector_text = "fast_detector=yolo" if self.config.fast_detector_enabled else "fast_detector=off" return ( "CameraSeatObservationProvider status: " f"{image_text}; image_fresh={image_fresh_text}; " + f"{camera_info_text}; camera_info_fresh={camera_info_fresh_text}; " + f"{lidar_text}; lidar_fresh={lidar_fresh_text}; " f"{odom_text}; odom_fresh={odom_fresh_text}; " - f"detection_model={self.config.detection_model}; " + f"{detector_text}; " + f"detection_model={detection_model}; " f"{credential_text}; override={'active' if override_active else 'inactive'}; " f"configured_fallback_seats={len(_parse_seats(self.config.seats))}; " f"configured_fallback_people={len(_parse_people(self.config.people))}." ) def _detect_scene_from_image( - self, image: Image, odom: PoseStamped | None = None + self, + image: Image, + odom: PoseStamped | None = None, + *, + camera_info: CameraInfo | None = None, + lidar: PointCloud2 | None = None, ) -> SeatSceneObservation: - vl_model = self._get_vl_model() - chair_detections = vl_model.query_detections(image, "chair").detections - person_detections = vl_model.query_detections(image, "person").detections + chair_detections, person_detections = self._detect_chairs_and_people(image) robot_x, robot_y, robot_yaw = self._robot_pose_for_detection(odom) + transform = None + if camera_info is not None and lidar is not None: + transform = self.tf.get("camera_optical", lidar.frame_id, image.ts, 1.0) seats: list[SeatObservation] = [] people: list[PersonObservation] = [] + used_3d = False for detection in chair_detections: + seat_id = f"seat_{len(seats) + 1}" + seat = None + if camera_info is not None and lidar is not None and transform is not None: + seat = _bbox_to_seat_observation_3d( + seat_id=seat_id, + detection=detection, + camera_info=camera_info, + lidar=lidar, + world_to_optical_transform=transform, + robot_x=robot_x, + robot_y=robot_y, + ) + if seat is not None: + used_3d = True + seats.append(seat) + continue + seats.append( _bbox_to_seat_observation( - seat_id=f"seat_{len(seats) + 1}", + seat_id=seat_id, bbox=detection.bbox, image_width=image.width, robot_x=robot_x, @@ -765,6 +1179,19 @@ def _detect_scene_from_image( ) ) for detection in person_detections: + person = None + if camera_info is not None and lidar is not None and transform is not None: + person = _bbox_to_person_observation_3d( + detection=detection, + camera_info=camera_info, + lidar=lidar, + world_to_optical_transform=transform, + ) + if person is not None: + used_3d = True + people.append(person) + continue + people.append( _bbox_to_person_observation( bbox=detection.bbox, @@ -782,7 +1209,7 @@ def _detect_scene_from_image( people=people, robot_x=robot_x, robot_y=robot_y, - source="camera", + source="camera_3d" if used_3d else "camera", ) def _robot_pose_for_detection( @@ -795,16 +1222,62 @@ def _robot_pose_for_detection( def _get_vl_model(self) -> VlModel: if self._vl_model is not None: return self._vl_model - if self.config.detection_model == "qwen" and not os.getenv("ALIBABA_API_KEY"): + detection_model = self._detection_model() + if detection_model == "qwen" and not os.getenv("ALIBABA_API_KEY"): raise ValueError( "CameraSeatObservationProvider detection_model=qwen requires ALIBABA_API_KEY" ) from dimos.models.vl.create import create - self._vl_model = create(self.config.detection_model) + self._vl_model = create(detection_model) return self._vl_model + def _detect_chairs_and_people( + self, image: Image + ) -> tuple[list[Detection2DBBox], list[Detection2DBBox]]: + if getattr(self, "_vl_model", None) is not None: + return self._detect_chairs_and_people_with_vlm(image) + + if self.config.fast_detector_enabled: + try: + detections = self._get_fast_detector().process_image(image) + chairs = _detections_named(detections, {"chair"}) + people = _detections_named(detections, {"person"}) + if chairs or people or not self.config.vlm_fallback_enabled: + return chairs, people + except Exception: + logger.warning("SeatGuide fast detector failed; falling back to VLM", exc_info=True) + if not self.config.vlm_fallback_enabled: + return [], [] + + return self._detect_chairs_and_people_with_vlm(image) + + def _detect_chairs_and_people_with_vlm( + self, image: Image + ) -> tuple[list[Detection2DBBox], list[Detection2DBBox]]: + vl_model = self._get_vl_model() + chair_detections = vl_model.query_detections(image, "chair").detections + person_detections = vl_model.query_detections(image, "person").detections + return list(chair_detections), list(person_detections) + + def _get_fast_detector(self) -> Detector: + if getattr(self, "_fast_detector", None) is not None: + return self._fast_detector + + from dimos.perception.detection.detectors.yolo import Yolo2DDetector + + self._fast_detector = Yolo2DDetector(model_name=self.config.fast_detector_model_name) + return self._fast_detector + + def _detection_model(self) -> VlModelName: + return self.config.detection_model or self.config.g.detection_model + + def _credential_status_for(self, detection_model: VlModelName) -> str: + if detection_model == "qwen" and not os.getenv("ALIBABA_API_KEY"): + return "credential=missing" + return "credential=present" + def _parse_seats(values: list[float]) -> list[SeatObservation]: if len(values) % 3 != 0: @@ -843,6 +1316,17 @@ def _flatten_people(people: list[PersonObservation]) -> list[float]: return values +def _detections_named( + detections: ImageDetections2D[Detection2DBBox], + names: set[str], +) -> list[Detection2DBBox]: + return [ + detection + for detection in detections.detections + if detection.name.strip().lower() in names + ] + + def _scene_from_flat_config( config: SyntheticSeatSceneConfig, *, @@ -902,6 +1386,21 @@ def _describe_goal_preview(scene: SeatSceneObservation) -> str: ) +def _scene_has_empty_seat(scene: SeatSceneObservation) -> bool: + if not scene.seats: + return False + planner = SeatGuidePlanner() + return ( + planner.find_empty_seat( + scene.seats, + scene.people, + robot_x=scene.robot_x, + robot_y=scene.robot_y, + ) + is not None + ) + + def _describe_live_perception_required(scene: SeatSceneObservation) -> str: advice_by_source = { "no_camera_image": "check camera stream wiring and face the conference table", @@ -921,6 +1420,10 @@ def _describe_live_perception_required(scene: SeatSceneObservation) -> str: ) +def _is_live_camera_source(source: str) -> bool: + return source in {"camera", "camera_3d"} + + def _message_age_s(ts: float) -> float: return max(0.0, time.time() - ts) @@ -992,6 +1495,89 @@ def _bbox_to_person_observation( return PersonObservation(x=x, y=y) +def _bbox_to_seat_observation_3d( + *, + seat_id: str, + detection: Detection2DBBox, + camera_info: CameraInfo, + lidar: PointCloud2, + world_to_optical_transform: Transform, + robot_x: float, + robot_y: float, +) -> SeatObservation | None: + x, y = _bbox_to_map_xy_3d( + detection=detection, + camera_info=camera_info, + lidar=lidar, + world_to_optical_transform=world_to_optical_transform, + ) + if x is None or y is None: + return None + return SeatObservation( + seat_id=seat_id, + x=x, + y=y, + yaw=math.atan2(robot_y - y, robot_x - x), + ) + + +def _bbox_to_person_observation_3d( + *, + detection: Detection2DBBox, + camera_info: CameraInfo, + lidar: PointCloud2, + world_to_optical_transform: Transform, +) -> PersonObservation | None: + x, y = _bbox_to_map_xy_3d( + detection=detection, + camera_info=camera_info, + lidar=lidar, + world_to_optical_transform=world_to_optical_transform, + ) + if x is None or y is None: + return None + return PersonObservation(x=x, y=y) + + +def _bbox_to_map_xy_3d( + *, + detection: Detection2DBBox, + camera_info: CameraInfo, + lidar: PointCloud2, + world_to_optical_transform: Transform, +) -> tuple[float | None, float | None]: + detection_3d = Detection3DPC.from_2d( + det=detection, + world_pointcloud=lidar, + camera_info=camera_info, + world_to_optical_transform=world_to_optical_transform, + filters=[], + ) + if detection_3d is None: + return None, None + points, _ = detection_3d.pointcloud.as_numpy() + if len(points) == 0: + return None, None + xy = _robust_detection_xy(points) + return float(xy[0]), float(xy[1]) + + +def _robust_detection_xy(points: object) -> tuple[float, float]: + import numpy as np + + point_array = np.asarray(points, dtype=float) + finite = point_array[np.isfinite(point_array).all(axis=1)] + if len(finite) == 0: + raise ValueError("detection pointcloud has no finite points") + if finite.shape[1] >= 3: + height_threshold = np.percentile(finite[:, 2], 20) + above_floor = finite[finite[:, 2] >= height_threshold] + if len(above_floor) > 0: + finite = above_floor + xy = np.median(finite[:, :2], axis=0) + return float(xy[0]), float(xy[1]) + + def _camera_relative_to_map( *, forward_m: float, diff --git a/dimos/agents/skills/test_seat_guide.py b/dimos/agents/skills/test_seat_guide.py index 9c3875191e..70a9d0d517 100644 --- a/dimos/agents/skills/test_seat_guide.py +++ b/dimos/agents/skills/test_seat_guide.py @@ -53,12 +53,17 @@ from dimos.core.coordination.blueprints import autoconnect from dimos.core.coordination.module_coordinator import ModuleCoordinator from dimos.core.core import rpc +from dimos.core.global_config import GlobalConfig from dimos.core.module import Module from dimos.msgs.geometry_msgs.PoseStamped import PoseStamped from dimos.msgs.geometry_msgs.Quaternion import Quaternion from dimos.msgs.geometry_msgs.Vector3 import Vector3 +from dimos.msgs.sensor_msgs.CameraInfo import CameraInfo from dimos.msgs.sensor_msgs.Image import Image +from dimos.msgs.sensor_msgs.PointCloud2 import PointCloud2 from dimos.navigation.base import NavigationState +from dimos.perception.detection.type.detection2d.bbox import Detection2DBBox +from dimos.perception.detection.type.detection2d.imageDetections2D import ImageDetections2D from dimos.web.robot_web_interface import RobotWebInterface REPO_ROOT = Path(__file__).parents[3] @@ -108,6 +113,30 @@ def cancel_goal(self) -> bool: return True +class SequencedNavigation(FakeNavigation): + def __init__( + self, + *, + states: list[NavigationState], + goal_reached_values: list[bool], + ) -> None: + super().__init__(state=states[-1], goal_reached=goal_reached_values[-1]) + self.states = states + self.goal_reached_values = goal_reached_values + self.state_calls = 0 + self.goal_reached_calls = 0 + + def get_state(self) -> NavigationState: + index = min(self.state_calls, len(self.states) - 1) + self.state_calls += 1 + return self.states[index] + + def is_goal_reached(self) -> bool: + index = min(self.goal_reached_calls, len(self.goal_reached_values) - 1) + self.goal_reached_calls += 1 + return self.goal_reached_values[index] + + class FakeSeatObservationProvider: def __init__(self, scene: SeatSceneObservation) -> None: self.scene = scene @@ -126,6 +155,65 @@ def get_seat_scene(self) -> SeatSceneObservation: return super().get_seat_scene() +class SequenceSeatObservationProvider: + def __init__(self, scenes: list[SeatSceneObservation]) -> None: + self.scenes = scenes + self.calls = 0 + + def get_seat_scene(self) -> SeatSceneObservation: + index = min(self.calls, len(self.scenes) - 1) + self.calls += 1 + return self.scenes[index] + + +class FakeExplorer: + def __init__(self) -> None: + self.begin_calls = 0 + self.end_calls = 0 + + def begin_exploration(self) -> str: + self.begin_calls += 1 + return "Started exploration skill." + + def end_exploration(self) -> str: + self.end_calls += 1 + return "Stopped exploration." + + +class FakeRelativeMover: + def __init__(self, result: str = "Navigation goal reached") -> None: + self.result = result + self.moves: list[tuple[float, float, float]] = [] + + def relative_move( + self, + forward: float = 0.0, + left: float = 0.0, + degrees: float = 0.0, + x: float = 0.0, + y: float = 0.0, + duration: float = 0.0, + ) -> str: + self.moves.append((forward, left, degrees)) + return self.result + + +class FakeDirectMover: + def __init__(self, result: str = "Direct move sent") -> None: + self.result = result + self.moves: list[tuple[float, float, float, float]] = [] + + def direct_move( + self, + x: float, + y: float = 0.0, + yaw: float = 0.0, + duration: float = 1.0, + ) -> str: + self.moves.append((x, y, yaw, duration)) + return self.result + + class FakeSpeaker: def __init__(self, *, raises: bool = False) -> None: self.spoken: list[tuple[str, bool]] = [] @@ -204,6 +292,16 @@ def query_detections(self, image: Image, query: str) -> SimpleNamespace: return SimpleNamespace(detections=self._detections_by_query.get(query, [])) +class FakeFastDetector: + def __init__(self, detections: list[Any]) -> None: + self._detections = detections + self.calls = 0 + + def process_image(self, image: Image) -> ImageDetections2D: + self.calls += 1 + return ImageDetections2D(image, list(self._detections)) + + class OdomMutatingFakeVlModel(FakeVlModel): def __init__( self, @@ -460,7 +558,10 @@ def test_handle_seat_request_delegates_to_scene_provider() -> None: ) ) - message = skill.handle_seat_request("Please help me find an empty seat") + message = skill.handle_seat_request( + "Please help me find an empty seat", + wait_for_arrival=False, + ) assert "seat_2" in message assert fake_navigation.goal is not None @@ -484,13 +585,73 @@ def test_handle_seat_request_speaks_feedback_when_speaker_is_connected() -> None ) ) - skill.handle_seat_request("Please help me find an empty seat") + skill.handle_seat_request( + "Please help me find an empty seat", + wait_for_arrival=False, + ) assert fake_speaker.spoken == [ ("I found an empty seat seat_2. Please follow me to the chair beside the table.", False) ] +def test_handle_seat_request_waits_and_speaks_when_arrived() -> None: + skill = SeatGuideSkillContainer.__new__(SeatGuideSkillContainer) + fake_navigation = SequencedNavigation( + states=[NavigationState.IDLE, NavigationState.FOLLOWING_PATH, NavigationState.IDLE], + goal_reached_values=[False, False, True], + ) + fake_speaker = FakeSpeaker() + skill._navigation = fake_navigation + skill._speaker = fake_speaker + skill._seat_observation_provider = FakeSeatObservationProvider( + SeatSceneObservation( + seats=[SeatObservation("empty", x=1.0, y=0.0, yaw=0.0)], + people=[], + source="camera_3d", + ) + ) + + message = skill.handle_seat_request( + "Please help me find an empty seat", + wait_for_arrival=True, + arrival_timeout_s=2.0, + ) + + assert "我已经到了, 空椅子在我右边, 请坐。" in message + assert fake_speaker.spoken == [ + ("I found an empty seat seat_1. Please follow me to the chair beside the table.", False), + ("我已经到了, 空椅子在我右边, 请坐。", False), + ] + + +def test_handle_seat_request_reports_when_navigation_stops_before_arrival() -> None: + skill = SeatGuideSkillContainer.__new__(SeatGuideSkillContainer) + fake_navigation = SequencedNavigation( + states=[NavigationState.IDLE, NavigationState.FOLLOWING_PATH, NavigationState.IDLE], + goal_reached_values=[False, False, False], + ) + fake_speaker = FakeSpeaker() + skill._navigation = fake_navigation + skill._speaker = fake_speaker + skill._seat_observation_provider = FakeSeatObservationProvider( + SeatSceneObservation( + seats=[SeatObservation("empty", x=1.0, y=0.0, yaw=0.0)], + people=[], + source="camera_3d", + ) + ) + + message = skill.handle_seat_request( + "Please help me find an empty seat", + wait_for_arrival=True, + arrival_timeout_s=2.0, + ) + + assert "navigation stopped before reaching it" in message + assert fake_speaker.spoken[-1] == (message, False) + + def test_handle_seat_request_requires_live_camera_by_default() -> None: skill = SeatGuideSkillContainer.__new__(SeatGuideSkillContainer) fake_navigation = FakeNavigation() @@ -505,7 +666,10 @@ def test_handle_seat_request_requires_live_camera_by_default() -> None: ) ) - message = skill.handle_seat_request("Please help me find an empty seat") + message = skill.handle_seat_request( + "Please help me find an empty seat", + wait_for_arrival=False, + ) assert message == ( "SeatGuide requires live camera perception before navigation; " @@ -530,7 +694,10 @@ def test_handle_seat_request_reports_camera_detection_error_next_step() -> None: ) ) - message = skill.handle_seat_request("Please help me find an empty seat") + message = skill.handle_seat_request( + "Please help me find an empty seat", + wait_for_arrival=False, + ) assert message == ( "SeatGuide requires live camera perception before navigation; " @@ -555,13 +722,254 @@ def test_handle_seat_request_can_explicitly_allow_fallback_calibration() -> None message = skill.handle_seat_request( "Please help me find an empty seat", require_live_perception=False, + wait_for_arrival=False, + ) + + assert "seat_1" in message + assert fake_navigation.goal is not None + assert fake_navigation.goal.position.x == pytest.approx(1.65) + + +def test_handle_seat_request_searches_when_no_seat_visible_then_navigates() -> None: + skill = SeatGuideSkillContainer.__new__(SeatGuideSkillContainer) + fake_navigation = FakeNavigation() + fake_explorer = FakeExplorer() + skill._navigation = fake_navigation + skill._explorer = fake_explorer + skill._seat_observation_provider = SequenceSeatObservationProvider( + [ + SeatSceneObservation(seats=[], people=[], source="camera_no_seats_detected"), + SeatSceneObservation(seats=[], people=[], source="camera_no_seats_detected"), + SeatSceneObservation( + seats=[SeatObservation("visible", x=1.0, y=0.0, yaw=0.0)], + people=[], + source="camera_3d", + ), + ] + ) + + message = skill.handle_seat_request( + "Please help me find an empty seat", + wait_for_arrival=False, + ) + + assert "seat_1" in message + assert fake_explorer.begin_calls == 1 + assert fake_explorer.end_calls == 1 + assert fake_navigation.goal is not None + assert fake_navigation.goal.position.x == pytest.approx(1.65) + + +def test_handle_seat_request_prefers_rotate_scan_when_no_seat_visible() -> None: + skill = SeatGuideSkillContainer.__new__(SeatGuideSkillContainer) + fake_navigation = FakeNavigation() + fake_explorer = FakeExplorer() + fake_relative_mover = FakeRelativeMover() + skill._navigation = fake_navigation + skill._explorer = fake_explorer + skill._relative_mover = fake_relative_mover + skill._seat_observation_provider = SequenceSeatObservationProvider( + [ + SeatSceneObservation(seats=[], people=[], source="camera_no_seats_detected"), + SeatSceneObservation(seats=[], people=[], source="camera_no_seats_detected"), + SeatSceneObservation( + seats=[SeatObservation("visible", x=1.0, y=0.0, yaw=0.0)], + people=[], + source="camera_3d", + ), + SeatSceneObservation( + seats=[SeatObservation("visible", x=1.0, y=0.0, yaw=0.0)], + people=[], + source="camera_3d", + ), + ] + ) + + message = skill.handle_seat_request( + "Please help me find an empty seat", + wait_for_arrival=False, ) assert "seat_1" in message + assert fake_relative_mover.moves == [(0.0, 0.0, 30.0)] + assert fake_explorer.begin_calls == 0 + assert fake_explorer.end_calls == 0 assert fake_navigation.goal is not None assert fake_navigation.goal.position.x == pytest.approx(1.65) +def test_scan_for_empty_seat_prefers_direct_turn_over_relative_navigation() -> None: + skill = SeatGuideSkillContainer.__new__(SeatGuideSkillContainer) + fake_navigation = FakeNavigation() + fake_direct_mover = FakeDirectMover() + fake_relative_mover = FakeRelativeMover() + skill._navigation = fake_navigation + skill._direct_mover = fake_direct_mover + skill._relative_mover = fake_relative_mover + skill._seat_observation_provider = SequenceSeatObservationProvider( + [ + SeatSceneObservation(seats=[], people=[], source="camera_no_seats_detected"), + SeatSceneObservation( + seats=[SeatObservation("visible", x=1.0, y=0.0, yaw=0.0)], + people=[], + source="camera_3d", + ), + SeatSceneObservation( + seats=[SeatObservation("visible", x=1.0, y=0.0, yaw=0.0)], + people=[], + source="camera_3d", + ), + ] + ) + + message = skill.scan_for_empty_seat_from_scene( + max_turn_degrees=30.0, + step_degrees=30.0, + settle_s=0.1, + turn_yaw_rate_rad_s=0.5, + ) + + assert "seat_1" in message + assert len(fake_direct_mover.moves) == 1 + x, y, yaw, duration = fake_direct_mover.moves[0] + assert x == 0.0 + assert y == 0.0 + assert yaw == pytest.approx(0.5) + assert duration == pytest.approx(math.radians(30.0) / 0.5) + assert fake_relative_mover.moves == [] + assert fake_navigation.goal is not None + + +def test_handle_seat_request_searches_when_visible_seats_are_occupied() -> None: + skill = SeatGuideSkillContainer.__new__(SeatGuideSkillContainer) + fake_navigation = FakeNavigation() + fake_explorer = FakeExplorer() + skill._navigation = fake_navigation + skill._explorer = fake_explorer + skill._seat_observation_provider = SequenceSeatObservationProvider( + [ + SeatSceneObservation( + seats=[SeatObservation("occupied", x=1.0, y=0.0, yaw=0.0)], + people=[PersonObservation(x=1.1, y=0.0)], + source="camera_3d", + ), + SeatSceneObservation( + seats=[SeatObservation("occupied", x=1.0, y=0.0, yaw=0.0)], + people=[PersonObservation(x=1.1, y=0.0)], + source="camera_3d", + ), + SeatSceneObservation( + seats=[ + SeatObservation("occupied", x=1.0, y=0.0, yaw=0.0), + SeatObservation("empty", x=2.0, y=0.0, yaw=0.0), + ], + people=[PersonObservation(x=1.1, y=0.0)], + source="camera_3d", + ), + ] + ) + + message = skill.handle_seat_request( + "Please help me find an empty seat", + wait_for_arrival=False, + ) + + assert "seat_2" in message + assert fake_explorer.begin_calls == 1 + assert fake_explorer.end_calls == 1 + assert fake_navigation.goal is not None + assert fake_navigation.goal.position.x == pytest.approx(2.65) + + +def test_scan_for_empty_seat_rotates_until_empty_seat_visible() -> None: + skill = SeatGuideSkillContainer.__new__(SeatGuideSkillContainer) + fake_navigation = FakeNavigation() + fake_relative_mover = FakeRelativeMover() + skill._navigation = fake_navigation + skill._relative_mover = fake_relative_mover + skill._seat_observation_provider = SequenceSeatObservationProvider( + [ + SeatSceneObservation(seats=[], people=[], source="camera_no_seats_detected"), + SeatSceneObservation(seats=[], people=[], source="camera_no_seats_detected"), + SeatSceneObservation( + seats=[SeatObservation("visible", x=2.0, y=0.0, yaw=0.0)], + people=[], + source="camera_3d", + ), + SeatSceneObservation( + seats=[SeatObservation("visible", x=2.0, y=0.0, yaw=0.0)], + people=[], + source="camera_3d", + ), + ] + ) + + message = skill.scan_for_empty_seat_from_scene( + max_turn_degrees=90.0, + step_degrees=30.0, + settle_s=0.1, + ) + + assert "seat_1" in message + assert fake_relative_mover.moves == [(0.0, 0.0, 30.0), (0.0, 0.0, 30.0)] + assert fake_navigation.goal is not None + assert fake_navigation.goal.position.x == pytest.approx(2.65) + + +def test_scan_for_empty_seat_reports_rotation_failure() -> None: + skill = SeatGuideSkillContainer.__new__(SeatGuideSkillContainer) + fake_navigation = FakeNavigation() + fake_relative_mover = FakeRelativeMover("Navigation was cancelled or failed") + fake_speaker = FakeSpeaker() + skill._navigation = fake_navigation + skill._relative_mover = fake_relative_mover + skill._speaker = fake_speaker + skill._seat_observation_provider = FakeSeatObservationProvider( + SeatSceneObservation(seats=[], people=[], source="camera_no_seats_detected") + ) + + message = skill.scan_for_empty_seat_from_scene( + max_turn_degrees=30.0, + step_degrees=30.0, + settle_s=0.1, + ) + + assert message == ( + "SeatGuide scan stopped because rotation failed: " + "Navigation was cancelled or failed." + ) + assert fake_relative_mover.moves == [(0.0, 0.0, 30.0)] + assert fake_navigation.goal is None + assert fake_speaker.spoken == [(message, False)] + + +def test_search_for_empty_seat_stops_exploration_on_timeout() -> None: + skill = SeatGuideSkillContainer.__new__(SeatGuideSkillContainer) + fake_navigation = FakeNavigation() + fake_explorer = FakeExplorer() + fake_speaker = FakeSpeaker() + skill._navigation = fake_navigation + skill._explorer = fake_explorer + skill._speaker = fake_speaker + skill._seat_observation_provider = FakeSeatObservationProvider( + SeatSceneObservation(seats=[], people=[], source="camera_no_seats_detected") + ) + + message = skill.search_for_empty_seat_from_scene( + search_timeout_s=1.0, + poll_interval_s=0.2, + ) + + assert message == ( + "I searched but still cannot see an empty seat. Please reposition me " + "or point the camera toward the conference table." + ) + assert fake_explorer.begin_calls == 1 + assert fake_explorer.end_calls == 1 + assert fake_navigation.goal is None + assert fake_speaker.spoken == [(message, False)] + + def test_handle_seat_request_rejects_unrelated_text() -> None: skill = SeatGuideSkillContainer.__new__(SeatGuideSkillContainer) @@ -1013,6 +1421,144 @@ def test_web_input_submit_query_http_route_reaches_seat_guide_preview() -> None: assert fake_agent_responses.published == ["previewed"] +def test_web_input_phone_speaker_test_publishes_response() -> None: + web_input = WebInput.__new__(WebInput) + fake_agent_responses = FakeAgentResponses() + web_input._agent_responses = fake_agent_responses + + message = web_input.phone_speaker_test("i am here") + + assert message == "Phone speaker test sent: i am here; local=sent; cloud=not_configured" + assert fake_agent_responses.published == ["i am here"] + + +def test_web_input_phone_speaker_test_posts_cloud_response( + monkeypatch: pytest.MonkeyPatch, +) -> None: + class FakeResponse: + def raise_for_status(self) -> None: + return None + + calls: list[dict[str, Any]] = [] + + def fake_post(url: str, **kwargs: Any) -> FakeResponse: + calls.append({"url": url, **kwargs}) + return FakeResponse() + + monkeypatch.setenv("SEAT_GUIDE_SPEAKER_URL", "https://speaker.example") + monkeypatch.setenv("SEAT_GUIDE_SPEAKER_TOKEN", "test-token") + monkeypatch.setenv("SEAT_GUIDE_SPEAKER_DEVICE", "go2-lab") + monkeypatch.setattr(web_human_input_module.requests, "post", fake_post) + + web_input = WebInput.__new__(WebInput) + web_input._agent_responses = None + + message = web_input.phone_speaker_test("i am here") + + assert message == "Phone speaker test sent: i am here; local=missing; cloud=sent" + assert calls == [ + { + "url": "https://speaker.example/api/speak", + "json": {"device": "go2-lab", "text": "i am here"}, + "headers": { + "content-type": "application/json", + "authorization": "Bearer test-token", + }, + "timeout": 5.0, + } + ] + + +def test_web_input_phone_seat_request_routes_and_publishes_response() -> None: + web_input = WebInput.__new__(WebInput) + fake_seat_guide = FakeSeatGuideRequest() + fake_agent_responses = FakeAgentResponses() + web_input._seat_guide = fake_seat_guide + web_input._agent_responses = fake_agent_responses + + message = web_input.phone_seat_request("帮我找一个空位") + + assert message == "handled" + assert fake_seat_guide.requests == ["帮我找一个空位"] + assert fake_agent_responses.published == ["handled"] + + +def test_seat_guide_speaker_page_exposes_phone_speaker_stream() -> None: + interface = RobotWebInterface( + port=5555, + text_streams={"agent_responses": web_human_input_module.rx.subject.Subject()}, + ) + + response = TestClient(interface.app).get("/seat-guide-speaker") + + assert response.status_code == 200 + assert "/text_stream/agent_responses" in response.text + assert "speechSynthesis" in response.text + assert "Enable speaker" in response.text + + +def test_seat_guide_camera_detect_frame_returns_object_description() -> None: + class FakeSeatGuideCameraModel: + def query(self, image: Image, query: str) -> str: + return "The image shows a laptop and a coffee mug." + + def query_detections(self, image: Image, query: str, **kwargs: Any) -> SimpleNamespace: + detections = [SimpleNamespace(bbox=(10.0, 10.0, 40.0, 40.0))] if query == "chair" else [] + return SimpleNamespace(detections=detections) + + interface = RobotWebInterface(port=5555) + interface._seat_guide_model = FakeSeatGuideCameraModel() + + result = interface._detect_seat_guide_frame( + np.zeros((60, 80, 3), dtype=np.uint8), + detector="moondream", + ) + + assert result["detector"] == "moondream2" + assert result["description"] == "The image shows a laptop and a coffee mug." + assert result["chairs"] == 1 + assert result["people"] == 0 + assert result["empty"] == 1 + + +def test_seat_guide_camera_detect_frame_uses_yolo_detector() -> None: + class FakeSeatGuideYoloDetector: + def process_image(self, image: Image) -> ImageDetections2D: + return ImageDetections2D( + image, + [ + Detection2DBBox( + bbox=(10.0, 10.0, 40.0, 40.0), + track_id=1, + class_id=56, + confidence=0.9, + name="chair", + ts=image.ts, + image=image, + ), + Detection2DBBox( + bbox=(12.0, 12.0, 38.0, 38.0), + track_id=2, + class_id=0, + confidence=0.8, + name="person", + ts=image.ts, + image=image, + ), + ], + ) + + interface = RobotWebInterface(port=5555) + interface._seat_guide_yolo_detector = FakeSeatGuideYoloDetector() + + result = interface._detect_seat_guide_frame(np.zeros((60, 80, 3), dtype=np.uint8)) + + assert result["detector"] == "yolo11n" + assert result["chairs"] == 1 + assert result["people"] == 1 + assert result["empty"] == 0 + + def test_web_input_upload_audio_http_route_emits_audio_event( monkeypatch: pytest.MonkeyPatch, ) -> None: @@ -1228,6 +1774,7 @@ def test_autoconnect_injects_scene_provider_and_navigation() -> None: message = seat_guide.handle_seat_request( "Please find me an empty seat", require_live_perception=False, + wait_for_arrival=False, ) assert "seat_2" in message @@ -1263,6 +1810,7 @@ def test_autoconnect_uses_runtime_configured_synthetic_scene() -> None: message = seat_guide.handle_seat_request( "Please find me an empty seat", require_live_perception=False, + wait_for_arrival=False, ) assert "seat_2" in message @@ -1292,6 +1840,7 @@ def test_autoconnect_injects_speaker_for_feedback() -> None: seat_guide.handle_seat_request( "Please find me an empty seat", require_live_perception=False, + wait_for_arrival=False, ) assert speaker.get_spoken() == [ @@ -1327,6 +1876,16 @@ def test_seat_guide_exposes_agent_friendly_skills() -> None: "title": "Require Live Perception", "type": "boolean", }, + "wait_for_arrival": { + "default": True, + "title": "Wait For Arrival", + "type": "boolean", + }, + "arrival_timeout_s": { + "default": 60.0, + "title": "Arrival Timeout S", + "type": "number", + }, } assert request_schema["required"] == ["text"] @@ -1342,7 +1901,17 @@ def test_seat_guide_exposes_agent_friendly_skills() -> None: "default": True, "title": "Require Live Perception", "type": "boolean", - } + }, + "wait_for_arrival": { + "default": False, + "title": "Wait For Arrival", + "type": "boolean", + }, + "arrival_timeout_s": { + "default": 60.0, + "title": "Arrival Timeout S", + "type": "number", + }, } preflight_schema = skill_infos["seat_guide_preflight"] @@ -1572,23 +2141,25 @@ def test_seat_guide_go2_blueprints_include_real_runtime_modules() -> None: assert { "GO2Connection", - "SpatialMemory", "McpServer", "McpClient", "CameraSeatObservationProvider", "SeatGuideSkillContainer", "WebInput", - "SpeakSkill", + "UnitreeSpeakSkill", } <= agentic_modules assert { "GO2Connection", - "SpatialMemory", "McpServer", "CameraSeatObservationProvider", "SeatGuideSkillContainer", "WebInput", - "SpeakSkill", + "UnitreeSpeakSkill", } <= direct_modules + assert "SpatialMemory" not in agentic_modules + assert "SpatialMemory" not in direct_modules + assert "PersonFollowSkillContainer" not in agentic_modules + assert "PersonFollowSkillContainer" not in direct_modules assert "McpClient" not in direct_modules @@ -1709,6 +2280,151 @@ def test_camera_observation_provider_detects_scene_from_image() -> None: assert scene.source == "camera" +def test_camera_observation_provider_prefers_fast_detector() -> None: + image = Image.from_numpy(np.zeros((100, 100, 3), dtype=np.uint8)) + provider = CameraSeatObservationProvider.__new__(CameraSeatObservationProvider) + provider.config = CameraSeatSceneConfig( + seats=[], + people=[], + robot_x=0.0, + robot_y=0.0, + chair_distance_m=2.0, + lateral_span_m=4.0, + ) + provider._scene_override = None + provider._scene_lock = RLock() + provider._latest_image = image + provider._latest_odom = PoseStamped( + frame_id="map", + position=Vector3(0.0, 0.0, 0.0), + orientation=Quaternion.from_euler(Vector3(0.0, 0.0, 0.0)), + ) + provider._vl_model = None + provider._fast_detector = FakeFastDetector( + [ + Detection2DBBox( + bbox=(40.0, 20.0, 60.0, 80.0), + track_id=1, + class_id=56, + confidence=0.9, + name="chair", + ts=image.ts, + image=image, + ), + Detection2DBBox( + bbox=(80.0, 20.0, 100.0, 80.0), + track_id=2, + class_id=0, + confidence=0.8, + name="person", + ts=image.ts, + image=image, + ), + Detection2DBBox( + bbox=(10.0, 10.0, 20.0, 20.0), + track_id=3, + class_id=0, + confidence=0.7, + name="book", + ts=image.ts, + image=image, + ), + ] + ) + + scene = provider.get_seat_scene() + + assert provider._fast_detector.calls == 1 + assert len(scene.seats) == 1 + assert len(scene.people) == 1 + assert scene.source == "camera" + + +def test_camera_observation_provider_empty_fast_detector_does_not_fallback_to_vlm() -> None: + image = Image.from_numpy(np.zeros((100, 100, 3), dtype=np.uint8)) + provider = CameraSeatObservationProvider.__new__(CameraSeatObservationProvider) + provider.config = CameraSeatSceneConfig( + seats=[], + people=[], + robot_x=0.0, + robot_y=0.0, + ) + provider._scene_override = None + provider._scene_lock = RLock() + provider._latest_image = image + provider._latest_odom = PoseStamped( + frame_id="map", + position=Vector3(0.0, 0.0, 0.0), + orientation=Quaternion.from_euler(Vector3(0.0, 0.0, 0.0)), + ) + provider._vl_model = None + provider._fast_detector = FakeFastDetector([]) + + scene = provider.get_seat_scene() + + assert provider._fast_detector.calls == 1 + assert scene.source == "camera_no_seats_detected" + assert scene.seats == [] + + +def test_camera_observation_provider_projects_detections_with_lidar( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr(seat_guide_module.time, "time", lambda: 11.0) + provider = CameraSeatObservationProvider.__new__(CameraSeatObservationProvider) + provider.config = CameraSeatSceneConfig( + seats=[], + people=[], + robot_x=0.0, + robot_y=0.0, + chair_distance_m=2.0, + lateral_span_m=4.0, + ) + provider._scene_override = None + provider._scene_lock = RLock() + provider._latest_image = Image.from_numpy(np.zeros((100, 100, 3), dtype=np.uint8), ts=10.0) + provider._latest_camera_info = CameraInfo(width=100, height=100, ts=10.0) + provider._latest_lidar = PointCloud2.from_numpy( + np.array([[4.0, 1.0, 0.4], [4.2, 1.2, 0.5]], dtype=np.float32), + frame_id="world", + timestamp=10.0, + ) + provider._latest_odom = PoseStamped( + ts=10.0, + frame_id="map", + position=Vector3(1.0, 1.0, 0.0), + orientation=Quaternion.from_euler(Vector3(0.0, 0.0, 0.0)), + ) + provider._vl_model = FakeVlModel( + { + "chair": [SimpleNamespace(name="chair", bbox=(40.0, 20.0, 60.0, 80.0))], + "person": [], + } + ) + provider._tf = SimpleNamespace(get=lambda *args: SimpleNamespace()) + + def fake_from_2d(**kwargs: Any) -> SimpleNamespace: + return SimpleNamespace( + pointcloud=PointCloud2.from_numpy( + np.array( + [[3.0, 2.0, 0.1], [5.0, 4.0, 0.8], [5.2, 4.2, 0.9]], + dtype=np.float32, + ), + frame_id="world", + timestamp=10.0, + ) + ) + + monkeypatch.setattr(seat_guide_module.Detection3DPC, "from_2d", fake_from_2d) + + scene = provider.get_seat_scene() + + assert scene.source == "camera_3d" + assert scene.seats[0].x == pytest.approx(5.1) + assert scene.seats[0].y == pytest.approx(4.1) + assert scene.seats[0].yaw == pytest.approx(math.atan2(1.0 - 4.1, 1.0 - 5.1)) + + def test_camera_observation_provider_projects_detections_from_latest_odom() -> None: provider = CameraSeatObservationProvider.__new__(CameraSeatObservationProvider) provider.config = CameraSeatSceneConfig( @@ -1934,8 +2650,11 @@ def test_camera_seat_provider_status_reports_missing_runtime_inputs( assert provider.camera_seat_provider_status() == ( "CameraSeatObservationProvider status: image=missing; image_fresh=missing; " - "odom=missing; odom_fresh=missing; detection_model=qwen; " - "credential=missing; override=inactive; " + "camera_info=missing; camera_info_fresh=missing; " + "lidar=missing; lidar_fresh=missing; " + "odom=missing; odom_fresh=missing; fast_detector=yolo; " + "detection_model=moondream; " + "credential=present; override=inactive; " "configured_fallback_seats=1; configured_fallback_people=1." ) @@ -1952,6 +2671,12 @@ def test_camera_seat_provider_status_reports_live_inputs_and_credentials( provider._latest_image = Image.from_numpy( np.zeros((120, 160, 3), dtype=np.uint8), ts=100.0 ) + provider._latest_camera_info = CameraInfo(width=160, height=120, ts=100.0) + provider._latest_lidar = PointCloud2.from_numpy( + np.array([[1.0, 2.0, 0.5]], dtype=np.float32), + frame_id="world", + timestamp=100.0, + ) provider._latest_odom = PoseStamped( ts=100.0, frame_id="map", @@ -1961,13 +2686,33 @@ def test_camera_seat_provider_status_reports_live_inputs_and_credentials( assert provider.camera_seat_provider_status() == ( "CameraSeatObservationProvider status: image=160x120; " - "image_fresh=true; odom=(1.00, 2.00, yaw=0.50); " - "odom_fresh=true; detection_model=qwen; " + "image_fresh=true; camera_info=160x120; camera_info_fresh=true; " + "lidar=1 points; lidar_fresh=true; odom=(1.00, 2.00, yaw=0.50); " + "odom_fresh=true; fast_detector=yolo; detection_model=moondream; " "credential=present; override=inactive; configured_fallback_seats=0; " "configured_fallback_people=0." ) +def test_camera_seat_provider_status_reports_missing_qwen_credential( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.delenv("ALIBABA_API_KEY", raising=False) + provider = CameraSeatObservationProvider.__new__(CameraSeatObservationProvider) + provider.config = CameraSeatSceneConfig( + g=GlobalConfig(detection_model="moondream"), + detection_model="qwen", + ) + provider._scene_override = None + provider._scene_lock = RLock() + provider._latest_image = None + provider._latest_odom = None + + assert "detection_model=qwen; credential=missing" in ( + provider.camera_seat_provider_status() + ) + + def test_camera_seat_provider_status_reports_runtime_override() -> None: provider = CameraSeatObservationProvider.__new__(CameraSeatObservationProvider) provider.config = CameraSeatSceneConfig(seats=[], people=[]) @@ -2066,6 +2811,7 @@ def test_camera_observation_provider_reports_missing_qwen_key_as_detection_error robot_x=-1.0, robot_y=2.0, detection_model="qwen", + vlm_fallback_enabled=True, ) provider._scene_override = None provider._scene_lock = RLock() @@ -2172,6 +2918,10 @@ def _complete_acceptance_transcript() -> str: {"modules": {"CameraSeatObservationProvider": ["camera_seat_provider_status"], "SeatGuideSkillContainer": ["seat_guide_status"], "WebInput": ["web_input_status"], "SpeakSkill": ["speech_status"]}} image=160x120 image_fresh=true +camera_info=160x120 +camera_info_fresh=true +lidar=1200 points +lidar_fresh=true credential=present odom=(1.00, 2.00, yaw=0.50) odom_fresh=true @@ -2266,6 +3016,10 @@ def test_acceptance_log_verifier_accepts_earlier_stale_goal_reached_status( ("stt=connected; ", "WebInput speech-to-text pipeline"), ("image=160x120\n", "camera image readiness"), ("image_fresh=true\n", "fresh camera image readiness"), + ("camera_info=160x120\n", "camera calibration readiness"), + ("camera_info_fresh=true\n", "fresh camera calibration readiness"), + ("lidar=1200 points\n", "LiDAR point cloud readiness"), + ("lidar_fresh=true\n", "fresh LiDAR readiness"), ("odom_fresh=true\n", "fresh odometry readiness"), ("override=inactive\n", "camera runtime override disabled"), ("configured_fallback_seats=0\n", "camera fallback seats disabled"), @@ -3171,7 +3925,7 @@ def test_hardware_acceptance_web_input_no_go_details_are_actionable( ("status_text", "expected_returncode"), [ ( - "CameraSeatObservationProvider status: image=160x120; image_fresh=true; odom=(1.00, 2.00, yaw=0.50); odom_fresh=true; detection_model=qwen; credential=present; override=inactive; configured_fallback_seats=0; configured_fallback_people=0.", + "CameraSeatObservationProvider status: image=160x120; image_fresh=true; camera_info=160x120; camera_info_fresh=true; lidar=1200 points; lidar_fresh=true; odom=(1.00, 2.00, yaw=0.50); odom_fresh=true; detection_model=qwen; credential=present; override=inactive; configured_fallback_seats=0; configured_fallback_people=0.", 0, ), ( @@ -3583,7 +4337,11 @@ def test_hardware_bringup_starts_real_stack_then_runs_smoke_and_acceptance() -> script = HARDWARE_BRINGUP_SCRIPT.read_text() assert 'robot_ip="${SEAT_GUIDE_ROBOT_IP:-192.168.123.161}"' in script - assert 'run_dimos run unitree-go2-seat-guide-agentic --robot-ip "${robot_ip}" --daemon' in script + assert 'detection_model="${SEAT_GUIDE_DETECTION_MODEL:-moondream}"' in script + assert ( + 'run_dimos --robot-ip "${robot_ip}" --detection-model "${detection_model}" ' + "run unitree-go2-seat-guide-agentic --daemon" + ) in script assert "demo_seat_guide_smoke" in script assert "demo_seat_guide_hardware_acceptance" in script assert "unitree-go2-agentic" not in script.replace( @@ -3595,9 +4353,17 @@ def test_hardware_bringup_requires_real_perception_and_speech_credentials() -> N script = HARDWARE_BRINGUP_SCRIPT.read_text() assert 'ALIBABA_API_KEY' in script + assert 'OPENROUTER_API_KEY' in script assert 'OPENAI_API_KEY' in script - assert "SeatGuide bring-up no-go: ALIBABA_API_KEY is not set." in script - assert "SeatGuide bring-up no-go: OPENAI_API_KEY is not set." in script + assert ( + "SeatGuide bring-up no-go: ALIBABA_API_KEY is not set for detection_model=qwen." + in script + ) + assert ( + "SeatGuide bring-up no-go: neither OPENROUTER_API_KEY nor OPENAI_API_KEY is set." + in script + ) + assert "TTS speech feedback will be unavailable" in script def test_hardware_bringup_allows_existing_stack_and_smoke_skip() -> None: @@ -3619,7 +4385,10 @@ def test_seat_guide_doc_does_not_recommend_rejected_general_go2_stack() -> None: doc = SEAT_GUIDE_DOC.read_text() assert "bin/demo_seat_guide_hardware_bringup --robot-ip" in doc - assert "dimos run unitree-go2-seat-guide-agentic --robot-ip" in doc + assert ( + "dimos --robot-ip 192.168.123.161 --detection-model moondream " + "run unitree-go2-seat-guide-agentic --daemon" + ) in doc assert "dimos --replay run unitree-go2-seat-guide-agentic --daemon" in doc assert "dimos run unitree-go2-agentic --robot-ip" not in doc diff --git a/dimos/agents/skills/test_speak_skill.py b/dimos/agents/skills/test_speak_skill.py index 87293eeecf..d6e14633e3 100644 --- a/dimos/agents/skills/test_speak_skill.py +++ b/dimos/agents/skills/test_speak_skill.py @@ -13,8 +13,39 @@ # limitations under the License. import json +from types import SimpleNamespace from dimos.agents.skills.speak_skill import SpeakSkill +from dimos.agents.skills.unitree_speak_skill import UnitreeSpeakSkill, _generate_tone_wav +from dimos.robot.unitree.go2.blueprints.agentic._seat_guide_agentic import _seat_guide_agentic + + +class FakeGo2Connection: + def __init__(self) -> None: + self.requests = [] + + def publish_request(self, topic, data): + self.requests.append((topic, data)) + if data["api_id"] == 1001: + filename = None + for _, request_data in self.requests: + if request_data["api_id"] == 2001: + filename = json.loads(request_data["parameter"])["file_name"] + break + return { + "data": { + "data": json.dumps( + {"audio_list": [{"CUSTOM_NAME": filename, "UNIQUE_ID": "audio-1"}]} + ) + } + } + return {"data": {"data": "{}"}} + + +class FakeOpenAIClient: + audio = SimpleNamespace( + speech=SimpleNamespace(create=lambda **kwargs: SimpleNamespace(content=b"RIFF-wav-data")) + ) def test_speak_skill_start_is_noop_without_openai_api_key(monkeypatch) -> None: @@ -59,3 +90,93 @@ def test_speak_skill_exposes_status_schema() -> None: status_schema = skill_infos["speech_status"] assert "text-to-speech readiness" in status_schema["description"] assert status_schema.get("properties", {}) == {} + + +def test_unitree_speak_skill_start_is_noop_without_openai_api_key(monkeypatch) -> None: + monkeypatch.delenv("OPENAI_API_KEY", raising=False) + skill = UnitreeSpeakSkill() + + try: + skill.start() + assert ( + skill.speak("hello", blocking=False) + == "Robot speech unavailable: OPENAI_API_KEY is not set" + ) + assert skill.speech_status() == ( + "UnitreeSpeakSkill status: tts=unavailable; " + "reason=OPENAI_API_KEY is not set; robot_audio=missing; " + "background_speech_threads=0." + ) + finally: + skill.stop() + + +def test_unitree_speak_skill_uploads_and_plays_audio_on_robot() -> None: + skill = UnitreeSpeakSkill.__new__(UnitreeSpeakSkill) + skill._connection = FakeGo2Connection() + skill._openai_client = FakeOpenAIClient() + skill._speech_unavailable_reason = None + skill._bg_threads = [] + skill._bg_threads_lock = __import__("threading").Lock() + + result = skill.speak("我已经到了, 请坐。", blocking=True) + + api_ids = [data["api_id"] for _, data in skill._connection.requests] + assert result == "Spoke on robot: 我已经到了, 请坐。" + assert api_ids == [2001, 1001, 1007, 1004, 1002] + assert json.loads(skill._connection.requests[0][1]["parameter"])["file_type"] == "wav" + assert json.loads(skill._connection.requests[-1][1]["parameter"]) == { + "unique_id": "audio-1" + } + + +def test_unitree_speak_skill_audio_test_does_not_call_openai() -> None: + skill = UnitreeSpeakSkill.__new__(UnitreeSpeakSkill) + skill._connection = FakeGo2Connection() + skill._openai_client = None + skill._speech_unavailable_reason = "OPENAI_API_KEY is not set" + skill._bg_threads = [] + skill._bg_threads_lock = __import__("threading").Lock() + + result = skill.play_robot_audio_test() + + api_ids = [data["api_id"] for _, data in skill._connection.requests] + assert result.startswith("Robot audio test sent: filename=audio_test_") + assert "bytes=" in result + assert "unique_id=audio-1" in result + assert api_ids[-4:] == [1001, 1007, 1004, 1002] + assert api_ids[:-4] == [2001] * len(api_ids[:-4]) + upload_parameter = json.loads(skill._connection.requests[0][1]["parameter"]) + assert upload_parameter["file_type"] == "wav" + assert upload_parameter["file_size"] > 200000 + + +def test_unitree_speak_skill_megaphone_test_does_not_call_openai() -> None: + skill = UnitreeSpeakSkill.__new__(UnitreeSpeakSkill) + skill._connection = FakeGo2Connection() + skill._openai_client = None + skill._speech_unavailable_reason = "OPENAI_API_KEY is not set" + skill._bg_threads = [] + skill._bg_threads_lock = __import__("threading").Lock() + + result = skill.play_robot_megaphone_test() + + api_ids = [data["api_id"] for _, data in skill._connection.requests] + assert result == "Robot megaphone audio test sent: bytes=220544." + assert api_ids[0] == 4001 + assert api_ids[-1] == 4002 + assert all(api_id == 4003 for api_id in api_ids[1:-1]) + + +def test_generate_tone_wav_returns_wav_bytes() -> None: + wav_data = _generate_tone_wav() + + assert wav_data.startswith(b"RIFF") + assert b"WAVE" in wav_data[:16] + + +def test_seat_guide_blueprint_uses_unitree_speaker() -> None: + module_names = {atom.module.__name__ for atom in _seat_guide_agentic.blueprints} + + assert "UnitreeSpeakSkill" in module_names + assert "SpeakSkill" not in module_names diff --git a/dimos/agents/skills/test_unitree_skill_container.py b/dimos/agents/skills/test_unitree_skill_container.py index 30bf6139e8..b90292e310 100644 --- a/dimos/agents/skills/test_unitree_skill_container.py +++ b/dimos/agents/skills/test_unitree_skill_container.py @@ -20,6 +20,9 @@ from dimos.core.core import rpc from dimos.core.module import Module from dimos.msgs.geometry_msgs.PoseStamped import PoseStamped +from dimos.msgs.geometry_msgs.Quaternion import Quaternion +from dimos.msgs.geometry_msgs.Twist import Twist +from dimos.msgs.geometry_msgs.Vector3 import Vector3 from dimos.navigation.base import NavigationState from dimos.robot.unitree.unitree_skill_container import _UNITREE_COMMANDS, UnitreeSkillContainer @@ -43,6 +46,10 @@ def cancel_goal(self) -> bool: class StubGO2Connection(Module): + @rpc + def move(self, twist: Twist, duration: float = 0.0) -> bool: + return True + @rpc def publish_request(self, topic: str, data: dict[str, Any]) -> dict[Any, Any]: return {} @@ -70,3 +77,16 @@ def test_did_you_mean() -> None: suggestions = difflib.get_close_matches("Pounce", _UNITREE_COMMANDS.keys(), n=3, cutoff=0.6) assert "FrontPounce" in suggestions assert "Pose" in suggestions + + +def test_relative_move_accepts_velocity_style_aliases() -> None: + skill = UnitreeSkillContainer.__new__(UnitreeSkillContainer) + current_pose = PoseStamped( + position=Vector3(1.0, 2.0, 0.0), + orientation=Quaternion.from_euler(Vector3(0.0, 0.0, 0.0)), + ) + + goal = skill._generate_new_goal(current_pose, forward=0.4, left=-0.2, degrees=0.0) + + assert goal.position.x == 1.4 + assert goal.position.y == 1.8 diff --git a/dimos/agents/skills/unitree_speak_skill.py b/dimos/agents/skills/unitree_speak_skill.py new file mode 100644 index 0000000000..b99243a4e4 --- /dev/null +++ b/dimos/agents/skills/unitree_speak_skill.py @@ -0,0 +1,340 @@ +# Copyright 2026 Dimensional Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import base64 +import hashlib +import io +import json +import math +import os +import struct +import threading +import time +from typing import Any +import wave + +from openai import OpenAI +from unitree_webrtc_connect.constants import RTC_TOPIC + +from dimos.agents.annotation import skill +from dimos.constants import DEFAULT_THREAD_JOIN_TIMEOUT +from dimos.core.core import rpc +from dimos.core.module import Module +from dimos.robot.unitree.go2.connection_spec import GO2ConnectionSpec +from dimos.utils.logging_config import setup_logger + +logger = setup_logger() + +_AUDIO_API = { + "GET_AUDIO_LIST": 1001, + "SELECT_START_PLAY": 1002, + "PAUSE": 1003, + "UNSUSPEND": 1004, + "SET_PLAY_MODE": 1007, + "UPLOAD_AUDIO_FILE": 2001, + "ENTER_MEGAPHONE": 4001, + "EXIT_MEGAPHONE": 4002, + "UPLOAD_MEGAPHONE": 4003, +} +_PLAY_MODE_NO_CYCLE = "no_cycle" +_OPENAI_TTS_TIMEOUT_S = 60.0 + + +class UnitreeSpeakSkill(Module): + """Speak through the Unitree Go2 onboard speaker.""" + + _connection: GO2ConnectionSpec + _openai_client: OpenAI | None = None + _speech_unavailable_reason: str | None = None + _last_robot_audio_upload: dict[str, Any] | None = None + _bg_threads: list[threading.Thread] = [] + _bg_threads_lock: threading.Lock = threading.Lock() + + @rpc + def start(self) -> None: + super().start() + if not os.getenv("OPENAI_API_KEY"): + self._speech_unavailable_reason = "OPENAI_API_KEY is not set" + logger.warning("UnitreeSpeakSkill TTS disabled because OPENAI_API_KEY is not set") + return + self._openai_client = OpenAI(timeout=_OPENAI_TTS_TIMEOUT_S) + + @rpc + def stop(self) -> None: + with self._bg_threads_lock: + threads = list(self._bg_threads) + for thread in threads: + thread.join(timeout=DEFAULT_THREAD_JOIN_TIMEOUT) + super().stop() + + @skill + def speak(self, text: str, blocking: bool = True) -> str: + """Speak text out loud through the Unitree Go2 onboard speaker. + + Use this to communicate with nearby people from the robot body, not + from the operator computer. + + Args: + text: Text to synthesize and play on the robot. + blocking: When true, wait for upload/play request completion. + """ + if self._openai_client is None: + reason = self._speech_unavailable_reason or "TTS not initialized" + return f"Robot speech unavailable: {reason}" + + if not blocking: + thread = threading.Thread( + target=self._speak_bg, + args=(text,), + daemon=True, + name="UnitreeSpeakSkill-bg", + ) + with self._bg_threads_lock: + self._bg_threads.append(thread) + thread.start() + return f"Speaking on robot (non-blocking): {text}" + + return self._speak_blocking(text) + + @skill + def speech_status(self) -> str: + """Report Unitree Go2 onboard speaker readiness without speaking.""" + with self._bg_threads_lock: + active_background_threads = sum(1 for thread in self._bg_threads if thread.is_alive()) + if self._openai_client is None: + reason = self._speech_unavailable_reason or "TTS not initialized" + return ( + "UnitreeSpeakSkill status: tts=unavailable; " + f"reason={reason}; robot_audio=missing; " + f"background_speech_threads={active_background_threads}." + ) + robot_audio = "connected" if getattr(self, "_connection", None) is not None else "missing" + return ( + "UnitreeSpeakSkill status: tts=ready; " + f"robot_audio={robot_audio}; " + f"background_speech_threads={active_background_threads}." + ) + + @skill + def play_robot_audio_test(self) -> str: + """Play a short generated tone through the Unitree Go2 onboard speaker. + + Use this to test the robot audio upload/playback path without calling + OpenAI TTS. + """ + try: + wav_data = _generate_tone_wav() + filename = f"audio_test_{int(time.time() * 1000)}" + unique_id = self._upload_audio_to_robot(wav_data, filename) + self._play_audio_on_robot(unique_id) + return ( + "Robot audio test sent: " + f"filename={filename}; bytes={len(wav_data)}; unique_id={unique_id}." + ) + except Exception as exc: + logger.error("Unitree robot audio test failed", exc_info=True) + return f"Error playing robot audio test: {exc}" + + @skill + def play_robot_megaphone_test(self) -> str: + """Play a generated tone through the Unitree Go2 megaphone audio path. + + Use this when normal uploaded audio returns a unique_id but no sound is + heard from the robot. + """ + try: + wav_data = _generate_tone_wav() + self._upload_and_play_megaphone(wav_data, duration_s=5.0) + return f"Robot megaphone audio test sent: bytes={len(wav_data)}." + except Exception as exc: + logger.error("Unitree robot megaphone audio test failed", exc_info=True) + return f"Error playing robot megaphone audio test: {exc}" + + def _speak_bg(self, text: str) -> None: + try: + self._speak_blocking(text) + finally: + with self._bg_threads_lock: + self._bg_threads = [ + thread + for thread in self._bg_threads + if thread is not threading.current_thread() + ] + + def _speak_blocking(self, text: str) -> str: + try: + logger.info("Generating Unitree speech audio with OpenAI TTS") + wav_data = self._generate_wav(text) + logger.info("Uploading Unitree speech audio", bytes=len(wav_data)) + filename = f"speak_{int(time.time() * 1000)}" + unique_id = self._upload_audio_to_robot(wav_data, filename) + logger.info("Playing Unitree speech audio", unique_id=unique_id) + self._play_audio_on_robot(unique_id) + display_text = text[:50] + "..." if len(text) > 50 else text + return f"Spoke on robot: {display_text}" + except Exception as exc: + logger.error("Unitree robot speech failed", exc_info=True) + return f"Error speaking on robot: {exc}" + + def _generate_wav(self, text: str) -> bytes: + if self._openai_client is None: + raise RuntimeError("TTS not initialized") + response = self._openai_client.audio.speech.create( + model="tts-1", + voice="echo", + input=text, + speed=1.2, + response_format="wav", + ) + return response.content + + def _webrtc_request(self, api_id: int, parameter: dict[str, Any] | None = None) -> Any: + return self._connection.publish_request( + RTC_TOPIC["AUDIO_HUB_REQ"], + { + "api_id": api_id, + "parameter": json.dumps(parameter or {}), + }, + ) + + def _upload_audio_to_robot(self, audio_data: bytes, filename: str) -> str: + file_md5 = hashlib.md5(audio_data).hexdigest() + b64_data = base64.b64encode(audio_data).decode("utf-8") + chunk_size = 61440 + chunks = [b64_data[i : i + chunk_size] for i in range(0, len(b64_data), chunk_size)] + total_chunks = len(chunks) + + logger.info( + "Uploading Unitree audio", + filename=filename, + bytes=len(audio_data), + chunks=total_chunks, + ) + for index, chunk in enumerate(chunks, 1): + self._webrtc_request( + _AUDIO_API["UPLOAD_AUDIO_FILE"], + { + "file_name": filename, + "file_type": "wav", + "file_size": len(audio_data), + "current_block_index": index, + "total_block_number": total_chunks, + "block_content": chunk, + "current_block_size": len(chunk), + "file_md5": file_md5, + "create_time": int(time.time() * 1000), + }, + ) + + response: Any = None + unique_id: str | None = None + for _attempt in range(1, 6): + response = self._webrtc_request(_AUDIO_API["GET_AUDIO_LIST"], {}) + unique_id = _find_uploaded_audio_id(response, filename) + if unique_id is not None: + break + time.sleep(0.2) + self._last_robot_audio_upload = { + "filename": filename, + "bytes": len(audio_data), + "chunks": total_chunks, + "unique_id": unique_id, + "list_response": response, + } + if unique_id is None: + logger.warning( + "Could not find uploaded Unitree audio by filename", + filename=filename, + response=response, + ) + return filename + logger.info("Unitree audio uploaded", filename=filename, unique_id=unique_id) + return unique_id + + def _play_audio_on_robot(self, unique_id: str) -> None: + logger.info("Requesting Unitree audio playback", unique_id=unique_id) + self._webrtc_request(_AUDIO_API["SET_PLAY_MODE"], {"play_mode": _PLAY_MODE_NO_CYCLE}) + time.sleep(0.1) + self._webrtc_request(_AUDIO_API["UNSUSPEND"], {}) + time.sleep(0.1) + self._webrtc_request(_AUDIO_API["SELECT_START_PLAY"], {"unique_id": unique_id}) + + def _upload_and_play_megaphone(self, audio_data: bytes, *, duration_s: float) -> None: + logger.info("Entering Unitree megaphone mode", bytes=len(audio_data)) + self._webrtc_request(_AUDIO_API["ENTER_MEGAPHONE"], {}) + try: + time.sleep(0.2) + b64_data = base64.b64encode(audio_data).decode("utf-8") + chunk_size = 4096 + chunks = [b64_data[i : i + chunk_size] for i in range(0, len(b64_data), chunk_size)] + total_chunks = len(chunks) + logger.info("Uploading Unitree megaphone audio", chunks=total_chunks) + for index, chunk in enumerate(chunks, 1): + self._webrtc_request( + _AUDIO_API["UPLOAD_MEGAPHONE"], + { + "current_block_size": len(chunk), + "block_content": chunk, + "current_block_index": index, + "total_block_number": total_chunks, + }, + ) + if index < total_chunks: + time.sleep(0.02) + time.sleep(duration_s + 0.5) + finally: + logger.info("Exiting Unitree megaphone mode") + self._webrtc_request(_AUDIO_API["EXIT_MEGAPHONE"], {}) + + +def _find_uploaded_audio_id(response: Any, filename: str) -> str | None: + if not isinstance(response, dict): + return None + data = response.get("data") + if isinstance(data, dict): + data = data.get("data") + if not isinstance(data, str): + return None + try: + audio_list = json.loads(data).get("audio_list", []) + except json.JSONDecodeError: + return None + for audio in audio_list: + if isinstance(audio, dict) and audio.get("CUSTOM_NAME") == filename: + unique_id = audio.get("UNIQUE_ID") + return unique_id if isinstance(unique_id, str) else None + return None + + +def _generate_tone_wav( + *, + frequency_hz: float = 660.0, + duration_s: float = 5.0, + sample_rate: int = 22050, +) -> bytes: + buffer = io.BytesIO() + amplitude = 0.95 + total_samples = int(duration_s * sample_rate) + with wave.open(buffer, "wb") as wav_file: + wav_file.setnchannels(1) + wav_file.setsampwidth(2) + wav_file.setframerate(sample_rate) + for index in range(total_samples): + value = int( + 32767 + * amplitude + * math.sin(2.0 * math.pi * frequency_hz * index / sample_rate) + ) + wav_file.writeframes(struct.pack(" None: @@ -151,6 +156,35 @@ def web_input_status(self) -> str: f"human_transport={human_transport}; url={url}." ) + @skill + def phone_speaker_test(self, text: str = "SeatGuide speaker test.") -> str: + """Send a test message to the browser or phone speaker page. + + Args: + text: Text to speak on the connected browser or phone speaker page. + """ + if self._agent_responses is None: + local_result = "local=missing" + else: + self._publish_agent_response(text) + local_result = "local=sent" + cloud_result = self._post_cloud_speaker(text) + return f"Phone speaker test sent: {text}; {local_result}; {cloud_result}" + + @skill + def phone_seat_request(self, text: str = "Find an empty seat.") -> str: + """Route a SeatGuide request and speak the result on the phone page. + + Args: + text: SeatGuide request text, for example asking for an empty seat. + """ + if self._seat_guide is None: + return "SeatGuide direct route is not connected." + response = self._seat_guide.handle_seat_request(text) + self._publish_agent_response(response) + self._post_cloud_speaker(response) + return response + def _route_text(self, text: str) -> None: logger.info("WebInput received text", text=text) if parse_seat_guide_intent(text).should_find_seat and self._seat_guide is not None: @@ -178,3 +212,25 @@ def _publish_agent_response(self, text: str) -> None: if self._agent_responses is None: return self._agent_responses.on_next(text) + + def _post_cloud_speaker(self, text: str) -> str: + base_url = os.environ.get(self._speaker_cloud_url_env) + if not base_url: + return "cloud=not_configured" + token = os.environ.get(self._speaker_cloud_token_env) + device = os.environ.get(self._speaker_cloud_device_env, "go2-demo") + headers = {"content-type": "application/json"} + if token: + headers["authorization"] = f"Bearer {token}" + try: + response = requests.post( + f"{base_url.rstrip('/')}/api/speak", + json={"device": device, "text": text}, + headers=headers, + timeout=5.0, + ) + response.raise_for_status() + except requests.RequestException as exc: + logger.warning("Cloud phone speaker post failed", error=str(exc)) + return f"cloud=error({type(exc).__name__})" + return "cloud=sent" diff --git a/dimos/models/vl/moondream.py b/dimos/models/vl/moondream.py index e3cfe744ce..b181a92879 100644 --- a/dimos/models/vl/moondream.py +++ b/dimos/models/vl/moondream.py @@ -36,8 +36,9 @@ class MoondreamConfig(HuggingFaceModelConfig, VlModelConfig): """Configuration for MoondreamVlModel.""" model_name: str = "vikhyatk/moondream2" - dtype: torch.dtype = torch.bfloat16 + dtype: torch.dtype = torch.float32 auto_resize: tuple[int, int] | None = MOONDREAM_DEFAULT_AUTO_RESIZE + compile_model: bool = False class MoondreamVlModel(HuggingFaceModel, VlModel): @@ -52,7 +53,8 @@ def _model(self) -> AutoModelForCausalLM: trust_remote_code=self.config.trust_remote_code, torch_dtype=self.config.dtype, ).to(self.config.device) - model.compile() + if self.config.compile_model: + model.compile() return model def _to_pil(self, image: Image | np.ndarray[Any, Any]) -> PILImage.Image: diff --git a/dimos/robot/unitree/go2/blueprints/agentic/_seat_guide_agentic.py b/dimos/robot/unitree/go2/blueprints/agentic/_seat_guide_agentic.py new file mode 100644 index 0000000000..185526aa6e --- /dev/null +++ b/dimos/robot/unitree/go2/blueprints/agentic/_seat_guide_agentic.py @@ -0,0 +1,32 @@ +#!/usr/bin/env python3 +# Copyright 2026 Dimensional Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from dimos.agents.skills.navigation import NavigationSkillContainer +from dimos.agents.skills.seat_guide import CameraSeatObservationProvider, SeatGuideSkillContainer +from dimos.agents.skills.unitree_speak_skill import UnitreeSpeakSkill +from dimos.agents.web_human_input import WebInput +from dimos.core.coordination.blueprints import autoconnect +from dimos.robot.unitree.unitree_skill_container import UnitreeSkillContainer + +_seat_guide_agentic = autoconnect( + NavigationSkillContainer.blueprint(), + CameraSeatObservationProvider.blueprint(), + SeatGuideSkillContainer.blueprint(), + UnitreeSkillContainer.blueprint(), + WebInput.blueprint(), + UnitreeSpeakSkill.blueprint(), +) + +__all__ = ["_seat_guide_agentic"] diff --git a/dimos/robot/unitree/go2/blueprints/agentic/unitree_go2_seat_guide.py b/dimos/robot/unitree/go2/blueprints/agentic/unitree_go2_seat_guide.py index dc2ab2368b..ba93341858 100644 --- a/dimos/robot/unitree/go2/blueprints/agentic/unitree_go2_seat_guide.py +++ b/dimos/robot/unitree/go2/blueprints/agentic/unitree_go2_seat_guide.py @@ -15,15 +15,13 @@ from dimos.agents.mcp.mcp_server import McpServer from dimos.core.coordination.blueprints import autoconnect -from dimos.perception.spatial_perception import SpatialMemory -from dimos.robot.unitree.go2.blueprints.agentic._common_agentic import _common_agentic +from dimos.robot.unitree.go2.blueprints.agentic._seat_guide_agentic import _seat_guide_agentic from dimos.robot.unitree.go2.blueprints.smart.unitree_go2 import unitree_go2 unitree_go2_seat_guide = autoconnect( unitree_go2, - SpatialMemory.blueprint(), McpServer.blueprint(), - _common_agentic, + _seat_guide_agentic, ).global_config(n_workers=10) __all__ = ["unitree_go2_seat_guide"] diff --git a/dimos/robot/unitree/go2/blueprints/agentic/unitree_go2_seat_guide_agentic.py b/dimos/robot/unitree/go2/blueprints/agentic/unitree_go2_seat_guide_agentic.py index 94983028b6..02dfd6c32b 100644 --- a/dimos/robot/unitree/go2/blueprints/agentic/unitree_go2_seat_guide_agentic.py +++ b/dimos/robot/unitree/go2/blueprints/agentic/unitree_go2_seat_guide_agentic.py @@ -16,16 +16,14 @@ from dimos.agents.mcp.mcp_client import McpClient from dimos.agents.mcp.mcp_server import McpServer from dimos.core.coordination.blueprints import autoconnect -from dimos.perception.spatial_perception import SpatialMemory -from dimos.robot.unitree.go2.blueprints.agentic._common_agentic import _common_agentic +from dimos.robot.unitree.go2.blueprints.agentic._seat_guide_agentic import _seat_guide_agentic from dimos.robot.unitree.go2.blueprints.smart.unitree_go2 import unitree_go2 unitree_go2_seat_guide_agentic = autoconnect( unitree_go2, - SpatialMemory.blueprint(), McpServer.blueprint(), McpClient.blueprint(), - _common_agentic, + _seat_guide_agentic, ).global_config(n_workers=10) __all__ = ["unitree_go2_seat_guide_agentic"] diff --git a/dimos/robot/unitree/go2/connection.py b/dimos/robot/unitree/go2/connection.py index 5568a473ef..2e5ac52996 100644 --- a/dimos/robot/unitree/go2/connection.py +++ b/dimos/robot/unitree/go2/connection.py @@ -307,7 +307,7 @@ def _publish_tf(self, msg: PoseStamped) -> None: def publish_camera_info(self) -> None: while True: - self.camera_info.publish(self.camera_info_static) + self.camera_info.publish(self.camera_info_static.with_ts(time.time())) time.sleep(1.0) @rpc diff --git a/dimos/robot/unitree/go2/connection_spec.py b/dimos/robot/unitree/go2/connection_spec.py index dd6aab9c40..0a2614a95f 100644 --- a/dimos/robot/unitree/go2/connection_spec.py +++ b/dimos/robot/unitree/go2/connection_spec.py @@ -14,8 +14,10 @@ from typing import Any, Protocol +from dimos.msgs.geometry_msgs.Twist import Twist from dimos.spec.utils import Spec class GO2ConnectionSpec(Spec, Protocol): + def move(self, twist: Twist, duration: float = 0.0) -> bool: ... def publish_request(self, topic: str, data: dict[str, Any]) -> dict[Any, Any]: ... diff --git a/dimos/robot/unitree/unitree_skill_container.py b/dimos/robot/unitree/unitree_skill_container.py index 88194473e6..bacb5584e4 100644 --- a/dimos/robot/unitree/unitree_skill_container.py +++ b/dimos/robot/unitree/unitree_skill_container.py @@ -26,6 +26,7 @@ from dimos.core.module import Module from dimos.msgs.geometry_msgs.PoseStamped import PoseStamped from dimos.msgs.geometry_msgs.Quaternion import Quaternion +from dimos.msgs.geometry_msgs.Twist import Twist from dimos.msgs.geometry_msgs.Vector3 import Vector3 from dimos.navigation.base import NavigationState from dimos.navigation.navigation_spec import NavigationInterfaceSpec @@ -208,15 +209,49 @@ def stop(self) -> None: super().stop() @skill - def relative_move(self, forward: float = 0.0, left: float = 0.0, degrees: float = 0.0) -> str: + def direct_move( + self, x: float, y: float = 0.0, yaw: float = 0.0, duration: float = 1.0 + ) -> str: + """Move the Go2 with direct velocity commands for hardware bring-up. + + Use this before navigation-based SeatGuide tests to verify that the robot + can receive and execute low-level movement commands. Keep values small + during bring-up. + + Args: + x: Forward velocity in meters per second. Negative moves backward. + y: Left velocity in meters per second. Negative moves right. + yaw: Counter-clockwise yaw velocity in radians per second. Negative turns right. + duration: How long to keep sending the command in seconds. + """ + x, y, yaw, duration = float(x), float(y), float(yaw), float(duration) + twist = Twist(linear=Vector3(x, y, 0.0), angular=Vector3(0.0, 0.0, yaw)) + if self._connection.move(twist, duration=duration): + return f"Direct move sent: x={x}, y={y}, yaw={yaw}, duration={duration}." + return "Direct move failed to send." + + @skill + def relative_move( + self, + forward: float = 0.0, + left: float = 0.0, + degrees: float = 0.0, + x: float = 0.0, + y: float = 0.0, + duration: float = 0.0, + ) -> str: """Move the robot relative to its current position. The `degrees` arguments refers to the rotation the robot should be at the end, relative to its current rotation. + The `x`, `y`, and `duration` arguments are accepted for compatibility with + velocity-style movement requests; `x` maps to `forward`, `y` maps to `left`, + and `duration` is ignored because this skill sends a relative navigation goal. Example calls: # Move to a point that's 2 meters forward and 1 to the right. relative_move(forward=2, left=-1, degrees=0) + relative_move(x=2, y=-1, degrees=0) # Move back 1 meter, while still facing the same direction. relative_move(forward=-1, left=0, degrees=0) @@ -228,6 +263,11 @@ def relative_move(self, forward: float = 0.0, left: float = 0.0, degrees: float relative_move(forward=0, left=3, degrees=90) """ forward, left, degrees = float(forward), float(left), float(degrees) + x, y = float(x), float(y) + if forward == 0.0 and x != 0.0: + forward = x + if left == 0.0 and y != 0.0: + left = y tf = self.tf.get("world", "base_link") if tf is None: diff --git a/dimos/web/dimos_interface/api/server.py b/dimos/web/dimos_interface/api/server.py index b73a1e5fdb..64f7857362 100644 --- a/dimos/web/dimos_interface/api/server.py +++ b/dimos/web/dimos_interface/api/server.py @@ -26,6 +26,7 @@ # Fast Api & Uvicorn import asyncio +import base64 # For audio processing import io @@ -47,6 +48,7 @@ from reactivex.disposable import SingleAssignmentDisposable import soundfile as sf # type: ignore[import-untyped] from sse_starlette.sse import EventSourceResponse +from starlette.concurrency import run_in_threadpool import uvicorn from dimos.core.global_config import global_config @@ -101,6 +103,9 @@ def __init__( # type: ignore[no-untyped-def] self.query_subject = rx.subject.Subject() # type: ignore[var-annotated] self.query_stream = self.query_subject.pipe(ops.share()) self.audio_subject = audio_subject + self._seat_guide_model = None + self._seat_guide_yolo_detector = None + self._seat_guide_model_lock = Lock() for key in self.streams: if self.streams[key] is not None: @@ -314,6 +319,47 @@ async def upload_audio(file: UploadFile = File(...)): # type: ignore[no-untyped print(f"Failed to process uploaded audio: {e}") return JSONResponse(status_code=500, content={"success": False, "message": str(e)}) + @self.app.get("/seat-guide-camera", response_class=HTMLResponse) + async def seat_guide_camera(): # type: ignore[no-untyped-def] + """Browser-camera SeatGuide validation page.""" + return HTMLResponse(self._seat_guide_camera_page()) + + @self.app.get("/seat-guide-speaker", response_class=HTMLResponse) + async def seat_guide_speaker(): # type: ignore[no-untyped-def] + """Phone speaker page for SeatGuide arrival notifications.""" + return HTMLResponse(self._seat_guide_speaker_page()) + + @self.app.post("/seat_guide/detect_frame") + async def seat_guide_detect_frame(request: Request): # type: ignore[no-untyped-def] + """Detect chairs, people, and empty seats from a browser camera frame.""" + try: + payload = await request.json() + image_data = str(payload.get("image", "")) + if "," in image_data: + image_data = image_data.split(",", 1)[1] + if not image_data: + return JSONResponse( + status_code=400, + content={"success": False, "message": "Missing image data"}, + ) + encoded = base64.b64decode(image_data) + frame = cv2.imdecode(np.frombuffer(encoded, dtype=np.uint8), cv2.IMREAD_COLOR) + if frame is None: + return JSONResponse( + status_code=400, + content={"success": False, "message": "Unable to decode image"}, + ) + detector = str(payload.get("detector", "yolo")).strip().lower() + result = await run_in_threadpool( + self._detect_seat_guide_frame, + frame, + detector, + ) + return JSONResponse({"success": True, **result}) + except Exception as e: + print(f"SeatGuide camera detection failed: {e}") + return JSONResponse(status_code=500, content={"success": False, "message": str(e)}) + # Unitree API endpoints @self.app.get("/unitree/status") async def unitree_status(): # type: ignore[no-untyped-def] @@ -353,6 +399,709 @@ async def text_stream(key: str): # type: ignore[no-untyped-def] for key in self.streams: self.app.get(f"/video_feed/{key}")(self.create_video_feed_route(key)) # type: ignore[no-untyped-call] + def _detect_seat_guide_frame( + self, frame: np.ndarray, detector: str = "yolo" + ) -> dict[str, object]: + """Run local SeatGuide chair/person detection on one browser camera frame.""" + if detector == "moondream": + return self._detect_seat_guide_frame_moondream(frame) + if detector != "yolo": + raise ValueError(f"Unsupported SeatGuide detector: {detector}") + return self._detect_seat_guide_frame_yolo(frame) + + def _detect_seat_guide_frame_yolo(self, frame: np.ndarray) -> dict[str, object]: + """Run fast local YOLO chair/person detection on one browser camera frame.""" + import torch + + from dimos.msgs.sensor_msgs.Image import Image, ImageFormat + from dimos.perception.detection.detectors.yolo import Yolo2DDetector + + height, width = frame.shape[:2] + rgb = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB) + image = Image.from_numpy( + rgb, + format=ImageFormat.RGB, + frame_id="browser_camera", + ts=time.time(), + ) + + with self._seat_guide_model_lock: + if self._seat_guide_yolo_detector is None: + device = "cpu" + if torch.backends.mps.is_available(): + device = "mps" + elif torch.cuda.is_available(): + device = "cuda" + self._seat_guide_yolo_detector = Yolo2DDetector(device=device) + detections = self._seat_guide_yolo_detector.process_image(image) + + chair_detections = [ + detection + for detection in detections.detections + if detection.name.strip().lower() == "chair" + ] + person_detections = [ + detection + for detection in detections.detections + if detection.name.strip().lower() == "person" + ] + return self._seat_guide_detection_response( + frame=frame, + width=width, + height=height, + chair_boxes=[tuple(detection.bbox) for detection in chair_detections], + person_boxes=[tuple(detection.bbox) for detection in person_detections], + detector="yolo11n", + description="YOLO realtime mode detects chairs and people without semantic captioning.", + ) + + def _detect_seat_guide_frame_moondream(self, frame: np.ndarray) -> dict[str, object]: + """Run local Moondream chair/person detection on one browser camera frame.""" + import torch + + from dimos.models.vl.moondream import MoondreamVlModel + from dimos.msgs.sensor_msgs.Image import Image, ImageFormat + + height, width = frame.shape[:2] + rgb = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB) + image = Image.from_numpy( + rgb, + format=ImageFormat.RGB, + frame_id="browser_camera", + ts=time.time(), + ) + + with self._seat_guide_model_lock: + if self._seat_guide_model is None: + device = "cpu" + if torch.backends.mps.is_available(): + device = "mps" + elif torch.cuda.is_available(): + device = "cuda" + self._seat_guide_model = MoondreamVlModel( + compile_model=False, + device=device, + ) + model = self._seat_guide_model + object_description = model.query( + image, + "In one short sentence, name the main visible objects in this image.", + ).strip() + chair_detections = model.query_detections(image, "chair", max_objects=10).detections + person_detections = model.query_detections(image, "person", max_objects=10).detections + return self._seat_guide_detection_response( + frame=frame, + width=width, + height=height, + chair_boxes=[tuple(detection.bbox) for detection in chair_detections], + person_boxes=[tuple(detection.bbox) for detection in person_detections], + detector="moondream2", + description=object_description, + ) + + def _seat_guide_detection_response( + self, + *, + frame: np.ndarray, + width: int, + height: int, + chair_boxes: list[tuple[float, float, float, float]], + person_boxes: list[tuple[float, float, float, float]], + detector: str, + description: str, + ) -> dict[str, object]: + people = person_boxes + person_centers = [self._bbox_center(bbox) for bbox in people] + + seats: list[dict[str, object]] = [] + annotated = frame.copy() + for index, bbox in enumerate(chair_boxes, start=1): + occupied = any( + self._bbox_contains_point( + self._expanded_bbox(bbox, width, height, fraction=0.15), + center, + ) + for center in person_centers + ) + color = (0, 0, 220) if occupied else (0, 180, 0) + label = f"occupied chair {index}" if occupied else f"EMPTY SEAT {index}" + self._draw_detection_box(annotated, bbox, label, color) + seats.append( + { + "id": f"seat_{index}", + "status": "occupied" if occupied else "empty", + "bbox": [round(value, 1) for value in bbox], + } + ) + + for index, bbox in enumerate(people, start=1): + self._draw_detection_box(annotated, bbox, f"person {index}", (220, 120, 0)) + + empty_count = sum(1 for seat in seats if seat["status"] == "empty") + ok, png = cv2.imencode(".png", annotated) + if not ok: + raise RuntimeError("Unable to encode annotated image") + + return { + "detector": detector, + "description": description, + "chairs": len(seats), + "people": len(people), + "empty": empty_count, + "seats": seats, + "annotated_image": "data:image/png;base64," + + base64.b64encode(png.tobytes()).decode("ascii"), + } + + @staticmethod + def _bbox_center(bbox: tuple[float, float, float, float]) -> tuple[float, float]: + x1, y1, x2, y2 = bbox + return (x1 + x2) / 2.0, (y1 + y2) / 2.0 + + @staticmethod + def _expanded_bbox( + bbox: tuple[float, float, float, float], + width: int, + height: int, + *, + fraction: float, + ) -> tuple[float, float, float, float]: + x1, y1, x2, y2 = bbox + pad_x = max(0.0, (x2 - x1) * fraction) + pad_y = max(0.0, (y2 - y1) * fraction) + return ( + max(0.0, x1 - pad_x), + max(0.0, y1 - pad_y), + min(float(width), x2 + pad_x), + min(float(height), y2 + pad_y), + ) + + @staticmethod + def _bbox_contains_point( + bbox: tuple[float, float, float, float], point: tuple[float, float] + ) -> bool: + x1, y1, x2, y2 = bbox + x, y = point + return x1 <= x <= x2 and y1 <= y <= y2 + + @staticmethod + def _draw_detection_box( + image: np.ndarray, + bbox: tuple[float, float, float, float], + label: str, + color: tuple[int, int, int], + ) -> None: + x1, y1, x2, y2 = [round(value) for value in bbox] + cv2.rectangle(image, (x1, y1), (x2, y2), color, 3) + (text_w, text_h), baseline = cv2.getTextSize( + label, + cv2.FONT_HERSHEY_SIMPLEX, + 0.65, + 2, + ) + top = max(0, y1 - text_h - baseline - 8) + cv2.rectangle(image, (x1, top), (x1 + text_w + 8, top + text_h + baseline + 8), color, -1) + cv2.putText( + image, + label, + (x1 + 4, top + text_h + 3), + cv2.FONT_HERSHEY_SIMPLEX, + 0.65, + (255, 255, 255), + 2, + cv2.LINE_AA, + ) + + @staticmethod + def _seat_guide_camera_page() -> str: + return """ + + + + + SeatGuide Camera Validation + + + +
+

SeatGuide Camera Validation

+
camera=starting
+
+
+
+
Browser Camera-
+ +
+ + + + +
+
Requesting camera access...
+
+
+
Detection Resultobjects=waiting chairs=0 people=0 empty=0
+ Annotated detection result +
No detection run yet.
+
+
+ + + +""" + + @staticmethod + def _seat_guide_speaker_page() -> str: + return """ + + + + + SeatGuide Phone Speaker + + + +
+
+
+

SeatGuide Phone Speaker

+
+
audio=locked
+
+
+ + +
+
+
Agent responses
+
+
+
+ + +""" + @staticmethod def _ensure_certs(certs_dir: Path) -> tuple[str, str]: """Return (cert_path, key_path), generating self-signed certs if needed. diff --git a/docs/agents/seat_guide_modules.md b/docs/agents/seat_guide_modules.md index c9adc69d3c..3e0f60ac36 100644 --- a/docs/agents/seat_guide_modules.md +++ b/docs/agents/seat_guide_modules.md @@ -44,7 +44,7 @@ Provider-backed scene: - `CameraSeatObservationProvider.set_seat_scene()` remains available as explicit runtime calibration/fallback when camera/VLM detection is unavailable - `SyntheticSeatObservationProvider` remains for repeatable Go2-free tests and demos - `unitree-go2-seat-guide` and `unitree-go2-seat-guide-agentic` include `CameraSeatObservationProvider` so the default SeatGuide bring-up path uses real camera recognition -- the default `qwen` VLM path requires `ALIBABA_API_KEY`; without it, SeatGuide reports `camera_detection_error` instead of silently treating missing credentials as a real no-seat observation +- the default `moondream` VLM path uses the local Moondream2 model cache; if `qwen` is selected, missing `ALIBABA_API_KEY` makes SeatGuide report `camera_detection_error` instead of silently treating missing credentials as a real no-seat observation Voice/text intake: @@ -158,10 +158,10 @@ before any live motion: | Track | Owner checks | Passing evidence | No-go action | | --- | --- | --- | --- | | Voice intake | Browser page opens; microphone permission granted; Chinese preview phrase reaches WebInput. | `web_input_status` shows `web=started`, `thread=running`, `seat_route=seat_guide_direct`, `responses=connected`, `voice_upload=connected`, `stt=connected`, `human_transport=connected`; acceptance log shows `WebInput received text` for `预检帮我找一个空位`. | Fix browser/microphone/WebInput before touching navigation. | -| Perception | Go2 camera frame, odometry, and Qwen credential are live; no fallback scene is active. | `camera_seat_provider_status` shows `image=x`, `image_fresh=true`, `odom=(...)`, `odom_fresh=true`, `credential=present`, `override=inactive`, `configured_fallback_seats=0`, `configured_fallback_people=0`; `seat_guide_status` starts with `SeatGuide scene source=camera:`. | Turn robot toward the table, fix `ALIBABA_API_KEY`, restore stale camera/odom streams, or explicitly mark fallback calibration as non-acceptance. | +| Perception | Go2 camera frame, odometry, and the configured VLM detector are live; no fallback scene is active. | `camera_seat_provider_status` shows `image=x`, `image_fresh=true`, `odom=(...)`, `odom_fresh=true`, `detection_model=moondream`, `credential=present`, `override=inactive`, `configured_fallback_seats=0`, `configured_fallback_people=0`; `seat_guide_status` starts with `SeatGuide scene source=camera:`. | Turn robot toward the table, verify the local Moondream2 cache or selected VLM credential, restore stale camera/odom streams, or explicitly mark fallback calibration as non-acceptance. | | Planner | Empty/occupied counts and selected goal make sense before motion. | `seat_guide_preflight`, `seat_guide_readiness_report`, and `preview_empty_seat_goal` report `empty=N occupied=N`, `selected=...`, and `goal=(...)` without sending a goal. | Adjust camera view or chair/person layout before live voice. | | Navigation | Robot is idle before SeatGuide sends the live goal and reports completion after it. | Preflight has `navigation=IDLE`; after live voice, `seat_guide_navigation_status` reports a new `goal_sequence` and `goal_reached=true`. | Wait/cancel existing navigation or inspect navigation logs; do not rerun live voice until idle. | -| Speech feedback | TTS and local audio output are ready, or the team agrees to use web text as the user-facing fallback. | `speech_status` shows `tts=ready` and `audio_output=connected`; hardware acceptance also calls `speak` with `SeatGuide audio check. I can guide you to an empty seat.`, requires `Spoke: SeatGuide audio check`, and requires operator confirmation `HEARD`. | Set `OPENAI_API_KEY` and connect audio before official acceptance. | +| Speech feedback | TTS and local audio output are ready, or the team agrees to use web text as the user-facing fallback. | `speech_status` shows `tts=ready` and `audio_output=connected`; hardware acceptance also calls `speak` with `SeatGuide audio check. I can guide you to an empty seat.`, requires `Spoke: SeatGuide audio check`, and requires operator confirmation `HEARD`. | Set `OPENAI_API_KEY` and connect audio before official spoken-feedback acceptance; OpenRouter covers the agent, not TTS. | | Acceptance evidence | The run is hardware, not replay/sim, and uses the SeatGuide blueprint. | `bin/demo_seat_guide_hardware_acceptance` records the run registry, no-motion gates, browser microphone gates, camera source, speech output check plus operator heard confirmation, ordered WebInput route logs, and `goal_reached=true`; `bin/demo_seat_guide_verify_acceptance_log ` passes. | Treat failures as real no-go evidence; do not replace them with direct MCP live calls. | ### Bring-up commands @@ -169,7 +169,10 @@ before any live motion: One-command real Go2 bring-up: ```bash -export ALIBABA_API_KEY=... +export OPENROUTER_API_KEY=... +export OPENROUTER_MODEL=openai/gpt-4o-mini + +# Optional: only required for TTS speech feedback. export OPENAI_API_KEY=... bin/demo_seat_guide_hardware_bringup --robot-ip 192.168.123.161 ``` @@ -184,7 +187,7 @@ navigation, camera, browser/Whisper voice input, MCP server, and SeatGuide modules, while avoiding unrelated CUDA-only security demo modules: ```bash -dimos run unitree-go2-seat-guide-agentic --robot-ip 192.168.123.161 --daemon +dimos --robot-ip 192.168.123.161 --detection-model moondream run unitree-go2-seat-guide-agentic --daemon ``` For local replay without a real robot: @@ -212,14 +215,22 @@ acceptance script intentionally rejects that general stack; start `unitree-go2-seat-guide` or `unitree-go2-seat-guide-agentic` for SeatGuide acceptance. -If `OPENAI_API_KEY` is not set, `McpClient` disables the LLM agent but the -direct voice route and MCP tools still start. `SpeakSkill` also degrades to a -no-op instead of failing startup. - -The default camera detector uses Qwen VLM. Set `ALIBABA_API_KEY` before hardware -bring-up, or configure a different supported `detection_model`. If the key is -missing, `seat_guide_status` reports `source=camera_detection_error`; use logs -or `set_seat_scene` only as an explicit fallback/calibration path. +If `OPENROUTER_API_KEY` is set, `McpClient` routes the normal LLM agent through +OpenRouter's OpenAI-compatible chat API. Set `OPENROUTER_MODEL` to a +tool-calling model such as `openai/gpt-4o-mini`; otherwise DimOS maps the +default `gpt-4o` model to `openai/gpt-4o` on OpenRouter. If neither +`OPENROUTER_API_KEY` nor `OPENAI_API_KEY` is set, `McpClient` disables the LLM +agent but the direct SeatGuide voice route and MCP tools still start. +`SpeakSkill` still requires `OPENAI_API_KEY` for TTS and degrades to a no-op +instead of failing startup. + +The default camera detector uses the local Moondream2 VLM. Make sure the +`vikhyatk/moondream2` Hugging Face snapshot is cached before hardware bring-up, +or configure a different supported `detection_model`. If `qwen` is selected, +set `ALIBABA_API_KEY`; otherwise `camera_seat_provider_status` reports +`credential=missing` and `seat_guide_status` reports +`source=camera_detection_error`. Use logs or `set_seat_scene` only as an +explicit fallback/calibration path. Confirm the SeatGuide tools are exposed: diff --git a/docs/agents/seat_guide_step_by_step_plan.md b/docs/agents/seat_guide_step_by_step_plan.md new file mode 100644 index 0000000000..d4cf9b488b --- /dev/null +++ b/docs/agents/seat_guide_step_by_step_plan.md @@ -0,0 +1,319 @@ +# SeatGuide 机器狗空位引导 Step-by-Step 计划 + +目标:让用户通过浏览器麦克风或文字对 Go2 说“帮我找一个空位”,系统用真实相机识别椅子和人,判断空位,给导航下发目标,并用语音反馈结果。没有连接 G2 时,所有能本地验证的模块都必须有单测或 smoke 验证;连接 G2 后只跑硬件验收,不再临场拼功能。 + +## 总体模块拆分 + +| 模块 | 负责什么 | 输入 | 输出 | 是否可并行 | 当前验证方式 | +| --- | --- | --- | --- | --- | --- | +| 1. 基础语音/文字控制入口 | 接收浏览器麦克风、浏览器文字、普通 agent text,先识别普通运动/姿态命令,再识别找座位请求 | WebInput `/submit_query`、`/upload_audio`、Whisper 文本、agent text | 普通 agent tool call,或 SeatGuide preview/live 请求 | 是 | MCP tool 验收、WebInput 单测、HTTP TestClient、硬件验收脚本 | +| 2. 场景感知 | 用 Go2 RGB 图像 + odom + Moondream2/VLM 识别椅子和人,并投影成 map 坐标 | `color_image`、`odom`、本地 Moondream2 模型缓存 | `SeatSceneObservation` | 是 | Camera provider 单测、`camera_seat_provider_status` | +| 3. 空位规划 | 判断哪些椅子被占用,选择最近空位,生成机器人应到达的引导点 | 椅子位姿、人员位置、机器人位置 | 选中椅子、导航目标 pose | 是 | Planner 单测、`preview_empty_seat_goal` | +| 4. 导航执行 | 把目标 pose 发给已有导航模块,并读取完成状态 | SeatGuide goal pose | `set_goal()`、`goal_reached` | 部分并行 | fake navigator 单测、`seat_guide_navigation_status` | +| 5. 语音反馈 | 告诉用户找到哪个位置、是否需要跟随、失败原因 | SeatGuide 结果文本 | TTS 音频、web response text | 是 | `speech_status`、TTS audio check、operator `HEARD` | +| 6. 验收脚本 | 把 no-motion、真实语音、真实导航串起来,保存 transcript | 当前 DimOS stack | 通过/失败原因、验收日志 | 是 | `bin/demo_seat_guide_*` | + +## 阶段 1:基础语音控制验收 + +目的:先证明“人说一句话或输入一句话 -> 系统识别意图 -> 下发到 Go2 -> Go2 执行动作”这条最小闭环成立。这个阶段不做找空位,不依赖 VLM,不依赖座椅识别。 + +要做的工作: + +1. 用 MCP 直接调用姿态/移动工具,证明 Go2 控制工具本身可用。 +2. 用浏览器文字输入普通运动命令,证明文字能进入 agent 并触发 Go2 tool。 +3. 用浏览器麦克风说普通运动命令,证明麦克风 -> Whisper -> agent -> Go2 tool 链路可用。 +4. 验证停止/安全命令,确保每次小距离动作后可以停下。 +5. 只验收低风险动作:站立、恢复站立、小距离前进/后退、小角度转向;不要在第一阶段测试跳跃、翻滚等动态动作。 + +### 阶段 1 的可验收路径拆分 + +| 路径 | 入口 | 是否会让 Go2 移动 | 验收命令/动作 | 通过标准 | +| --- | --- | --- | --- | --- | +| 1A. MCP 姿态命令 | MCP tool | 可能改变姿态,不走位 | `dimos mcp call execute_sport_command --json-args '{"command_name":"BalanceStand"}'` | tool 返回成功,Go2 进入稳定站立/平衡状态 | +| 1B. MCP 小距离移动 | MCP tool | 是,小距离 | `relative_move` 前进 0.3m、后退 0.3m、左转 30 度 | Go2 按命令小幅移动或导航状态显示目标完成 | +| 1C. 浏览器文字 -> agent -> Go2 tool | Web 页面文字框或 `/submit_query` | 是,小距离 | 输入 `walk forward 30 centimeters`、`walk backward 30 centimeters` | 日志显示 WebInput 收到文本,非找座位请求进入普通 agent path,agent 调用 `relative_move` | +| 1D. 浏览器麦克风 -> Whisper -> agent -> Go2 tool | 电脑浏览器麦克风 | 是,小距离 | 对浏览器说 `walk forward 30 centimeters` 或中文等价命令 | 日志显示 Whisper 识别文本,agent 调用对应 Go2 tool,Go2 执行动作 | +| 1E. 停止/安全 | MCP tool 或 agent tool | 停止当前导航/动作 | `dimos mcp call stop_navigation` | 导航状态回到停止/空闲,不再继续移动 | + +推荐验收命令: + +```bash +dimos mcp call execute_sport_command --json-args '{"command_name":"BalanceStand"}' +dimos mcp call relative_move --json-args '{"forward":0.3,"left":0,"degrees":0}' +dimos mcp call relative_move --json-args '{"forward":-0.3,"left":0,"degrees":0}' +dimos mcp call relative_move --json-args '{"forward":0,"left":0,"degrees":30}' +dimos mcp call stop_navigation +``` + +通过标准: + +- MCP 直接调用能让 Go2 执行姿态和小距离移动。 +- 浏览器文字命令能触发普通 agent tool,而不是误进 SeatGuide。 +- 浏览器麦克风命令能完成语音识别,并触发同一个 Go2 tool。 +- 任何一次动作失败时,可以定位失败点是控制工具、agent tool selection、Whisper、WebInput,还是 Go2 连接。 +- 当前默认输入设备是 **电脑浏览器麦克风**,不是 Go2 机身麦克风;如果要使用 Go2 自带麦克风,需要后续单独增加输入模块。 + +## 阶段 2:SeatGuide 基础模块开发和本地单测 + +目的:不接机器狗也能证明核心逻辑正确。 + +要做的工作: + +1. 实现 SeatGuide 数据模型:椅子、人员、场景、规划结果、语音意图。 +2. 实现空位判断:人在椅子附近 0.75m 内则认为占用。 +3. 实现最近空位选择:从机器人当前位置选最近的空椅子。 +4. 实现引导点生成:目标点在椅子旁边的过道方向,而不是椅子中心。 +5. 实现 preview 和 live 两种路径:preview 不移动,live 才下发导航。 + +验收方式: + +```bash +uv run pytest dimos/agents/skills/test_seat_guide.py -q -k 'planner or find_empty_seat or preview_empty_seat_goal' +``` + +通过标准: + +- 能选出正确空位。 +- 被占用椅子不会被选中。 +- preview 不调用导航。 +- live 只在可导航且场景来源可信时调用导航。 + +## 阶段 3:SeatGuide 语音和 WebInput 链路 + +目的:用户可以通过浏览器输入或麦克风触发 SeatGuide,而不是必须手动 MCP call。 + +要做的工作: + +1. WebInput 文字输入 `/submit_query` 直接路由 SeatGuide 请求。 +2. 浏览器音频上传 `/upload_audio` 推入 `audio_subject`。 +3. Whisper 自动识别语言,不强制英文。 +4. 中文 preview 语句:`预检帮我找一个空位` 只做 no-motion 检查。 +5. 中文 live 语句:`帮我找一个空位` 才触发导航。 +6. WebInput 把 SeatGuide 返回结果推到 `agent_responses`,浏览器可见。 + +### 阶段 3 的可验收路径拆分 + +| 路径 | 入口 | 是否会让 Go2 移动 | 验收命令/动作 | 通过标准 | +| --- | --- | --- | --- | --- | +| 3A. 模块状态检查 | MCP tool | 否 | `dimos mcp call web_input_status` | 输出包含 `web=started`、`voice_upload=connected`、`stt=connected`、`seat_route=seat_guide_direct` | +| 3B. 浏览器文字 preview | Web 页面文字框或 `/submit_query` | 否 | 在 WebInput 页面输入 `预检帮我找一个空位`,或用硬件脚本自动 POST | `agent_responses` 出现 `SeatGuide preflight ready` 或明确 no-go 原因;导航目标不会下发 | +| 3C. 浏览器麦克风 preview | 电脑浏览器麦克风 | 否 | 打开 WebInput URL,允许麦克风,点击麦克风后说 `预检帮我找一个空位` | Whisper 识别文本后,WebInput 日志包含 `WebInput received text` 和 `WebInput routing text to SeatGuide preview` | +| 3D. 普通 agent text fallback | `/human_input` agent path | 否,除非 agent 后续显式调用工具 | 输入非找座位文本,例如 `what time is the meeting` | WebInput 不调用 SeatGuide,文本进入普通 agent path | +| 3E. 浏览器麦克风 live | 电脑浏览器麦克风 | 是 | 只在 no-motion 通过且现场安全后,说 `帮我找一个空位` | WebInput 日志包含 `WebInput routing text to SeatGuide live request`,SeatGuide 返回 `Navigating to ...` | + +推荐验收顺序: + +1. 先跑 3A,确认 WebInput/STT/浏览器音频上传都在线。 +2. 再跑 3B,确认文字入口和 SeatGuide preview 直连。 +3. 再跑 3C,确认电脑浏览器麦克风 -> Whisper -> SeatGuide preview 直连。 +4. 最后才跑 3E,因为它会下发真实导航目标。 + +注意:当前方案默认使用 **电脑浏览器麦克风**,不是直接使用 Go2 机身麦克风。用户对电脑浏览器说话,电脑把音频上传到 DimOS,Whisper 识别文本,然后 SeatGuide 给 Go2 下发导航。 + +验收方式: + +```bash +uv run pytest dimos/agents/skills/test_seat_guide.py -q -k 'web_input or upload_audio or submit_query' +``` + +通过标准: + +- `/submit_query` 能触发 SeatGuide preview。 +- `/upload_audio` 能产生 `AudioEvent`。 +- 未配置音频入口或音频解码失败时不能误报成功。 +- `web_input_status` 必须包含: + - `web=started` + - `thread=running` + - `seat_route=seat_guide_direct` + - `responses=connected` + - `voice_upload=connected` + - `stt=connected` + - `human_transport=connected` + +## 阶段 4:真实相机/VLM/odom 感知 + +目的:不能用假的 mock 当最终结果,硬件验收必须证明来自真实 camera source。 + +要做的工作: + +1. `CameraSeatObservationProvider` 订阅 `color_image` 和 `odom`。 +2. 使用 Qwen/VLM 分别检测 `chair` 和 `person`。 +3. 根据图像框中心和 odom 估算 map-frame 椅子/人员位置。 +4. 检查 stale image、stale odom、missing key、missing camera 等 no-go 状态。 +5. 保留 `set_seat_scene` 作为 fallback/calibration,但硬件验收不接受 fallback。 + +验收方式: + +```bash +uv run pytest dimos/agents/skills/test_seat_guide.py -q -k 'camera_observation_provider or camera_seat_provider_status' +``` + +硬件前检查: + +```bash +dimos mcp call camera_seat_provider_status +dimos mcp call seat_guide_status +``` + +通过标准: + +- `camera_seat_provider_status` 显示: + - `image=x` + - `image_fresh=true` + - `odom=(...)` + - `odom_fresh=true` + - `credential=present` + - `override=inactive` + - `configured_fallback_seats=0` + - `configured_fallback_people=0` +- `seat_guide_status` 必须以 `SeatGuide scene source=camera:` 开头。 + +## 阶段 5:导航和语音反馈 + +目的:找到空位以后,Go2 能真正下发导航目标,并给用户可听或可见反馈。 + +要做的工作: + +1. SeatGuide 注入 `NavigationInterfaceSpec`。 +2. live request 时调用 `set_goal(PoseStamped)`。 +3. 如果导航忙,拒绝覆盖当前任务。 +4. 读取 `navigation_state` 和 `goal_reached`。 +5. SeatGuide 注入 `SpeakSkillSpec`,可用时播报结果。 +6. `speech_status` 区分 TTS key 缺失、audio output 缺失和可用状态。 + +验收方式: + +```bash +uv run pytest dimos/agents/skills/test_seat_guide.py dimos/agents/skills/test_speak_skill.py -q +``` + +硬件前检查: + +```bash +dimos mcp call seat_guide_preflight +dimos mcp call preview_empty_seat_goal +dimos mcp call speech_status +dimos mcp call seat_guide_navigation_status +``` + +通过标准: + +- preflight 显示 `navigation=IDLE`。 +- preview 有 `selected=...` 和 `goal=(...)`。 +- speech 显示 `tts=ready` 和 `audio_output=connected`。 +- live 后 `seat_guide_navigation_status` 最终显示新的 `goal_sequence` 且 `goal_reached=true`。 + +## 阶段 6:Mac replay / SHM 视频流修复 + +目的:Mac 上 replay 的高带宽视频/点云/地图流不再因为 UDP/LCM 大包路径缺失导致看不到视频。 + +要做的工作: + +1. 集成 `dimensionalOS/dimos#2245` / `danvi/experimental/route-replay-through-SHM`。 +2. 将 Go2 replay 的高带宽流 route 到 `pSHMTransport`: + - `color_image` + - `lidar` + - `pointcloud` + - `global_map` + - `merged_map` + - `global_costmap` + - `navigation_costmap` +3. Rerun bridge 接收 SHM visual transports。 + +验收方式: + +```bash +uv run pytest dimos/protocol/pubsub/test_registry.py dimos/visualization/rerun/test_viewer_integration.py -q +bin/demo_seat_guide_replay_smoke +``` + +通过标准: + +- replay stack 能启动。 +- 日志里高带宽流显示 `transport=pSHMTransport`。 +- `bin/demo_seat_guide_replay_smoke` 能完整跑完并停止 stack。 + +## 阶段 7:真实 Go2 bring-up + +目的:你接上机器狗后只跑一个入口,不需要手动拼命令。 + +你需要手动准备: + +1. 机器狗上电,和 Mac 在同一网络。 +2. 确认 Go2 IP,默认示例是 `192.168.123.161`。 +3. 准备 API keys。普通 agent 可以使用 OpenRouter;TTS 语音播报仍然需要 OpenAI,找座位 VLM 仍然需要 Alibaba/Qwen: + +```bash +export OPENROUTER_API_KEY="你的 OpenRouter key" +export OPENROUTER_MODEL="openai/gpt-4o-mini" + +# 可选:只有需要机器狗语音播报/TTS 时才需要 +export OPENAI_API_KEY="你的 OpenAI key" +``` + +启动一键 bring-up: + +```bash +bin/demo_seat_guide_hardware_bringup --robot-ip 192.168.123.161 +``` + +这个脚本会自动执行: + +1. 检查本地 Moondream2 模型缓存,以及 agent 使用的 `OPENROUTER_API_KEY` 或 `OPENAI_API_KEY`。 +2. 启动 `unitree-go2-seat-guide-agentic`。 +3. 跑 `bin/demo_seat_guide_smoke` 做 no-motion 检查。 +4. 跑 `bin/demo_seat_guide_hardware_acceptance` 做真实浏览器语音和导航验收。 + +你在脚本过程中需要手动做: + +1. 打开脚本打印的 WebInput URL。 +2. 允许浏览器麦克风权限。 +3. 听到 TTS 检查语音后,在终端输入 `HEARD`。 +4. no-motion 阶段对浏览器说:`预检帮我找一个空位`。 +5. 确认 Go2 周围安全后,在终端输入 `LIVE`。 +6. live 阶段对浏览器说:`帮我找一个空位`。 + +通过标准: + +- no-motion 阶段所有 gate 通过。 +- live 阶段 WebInput 日志包含中文识别文本。 +- SeatGuide 返回 `Navigating to ...`。 +- 最终 `seat_guide_navigation_status` 显示新的 `goal_sequence` 和 `goal_reached=true`。 +- `bin/demo_seat_guide_verify_acceptance_log ` 通过。 + +## 阶段 8:失败时怎么分模块排查 + +| 失败位置 | 看什么命令 | 常见原因 | 处理方式 | +| --- | --- | --- | --- | +| WebInput 未启动 | `dimos mcp call web_input_status` | 端口占用、WebInput 模块未启动 | 检查 `dimos status`、重启 stack | +| 麦克风没进来 | `web_input_status`、浏览器权限 | `voice_upload=missing`、浏览器拒绝麦克风 | 允许麦克风权限,刷新 WebInput 页面 | +| STT 不工作 | `web_input_status` | Whisper/faster-whisper 初始化失败 | 看 DimOS log,确认依赖安装 | +| 没有图像 | `camera_seat_provider_status` | Go2 camera/replay stream 没到 | 转向桌子,确认 replay/SHM 流 | +| odom 缺失或过期 | `camera_seat_provider_status` | localization 没启动或 stale | 等待 odom,检查 Go2/replay stack | +| VLM 失败 | `seat_guide_status` | 本地 Moondream2 模型缺失、模型加载失败,或远程 VLM key 缺失 | 拉取模型或重新 export 对应 key,并重启 stack | +| 找不到椅子 | `seat_guide_status` | 摄像头没朝向桌子、光照/识别失败 | 调整机器人视角;只调试时可 fallback | +| 导航忙 | `seat_guide_preflight` | `navigation=FOLLOWING_PATH` 或 `RECOVERY` | 等任务结束或停止导航后重跑 | +| TTS 不可用 | `speech_status` | `OPENAI_API_KEY` 缺失或 audio output 缺失 | 如果验收需要语音播报,设置 OpenAI key 并连接音频输出;如果只验收浏览器文字反馈,可先记录为非阻塞项 | + +## 当前已完成状态 + +已完成: + +- SeatGuide planner / scene / intent / navigation integration。 +- WebInput 中文语音和文字直连 SeatGuide。 +- Camera/VLM/odom provider。 +- SpeakSkill readiness 和 TTS audio check。 +- Go2 SeatGuide blueprints。 +- macOS replay SHM route 集成。 +- 一键硬件 bring-up 脚本。 +- no-motion smoke、hardware acceptance、acceptance log verifier。 + +已验证: + +- SeatGuide/Speak/MCP 相关测试通过。 +- pubsub/Rerun SHM 相关测试通过。 +- `bin/demo_seat_guide_replay_smoke` 在 Mac 上完整跑完。 + +未完成: + +- 真实 Go2 硬件 transcript。最终完成标准必须包含真实浏览器麦克风输入、真实 camera/VLM/odom、TTS 可听确认、真实导航和 `goal_reached=true`。 diff --git a/docs/agents/seat_guide_step_by_step_plan_en.md b/docs/agents/seat_guide_step_by_step_plan_en.md new file mode 100644 index 0000000000..61d45b37d6 --- /dev/null +++ b/docs/agents/seat_guide_step_by_step_plan_en.md @@ -0,0 +1,319 @@ +# SeatGuide Robot Dog Empty-Seat Guidance Step-by-Step Plan + +Goal: let a user tell the Go2, through browser microphone or typed text, "find me an empty seat." The system should use the real camera to recognize chairs and people, decide which seats are empty, send a navigation goal, and respond with speech. When the Go2 is not connected, every locally verifiable module must have unit or smoke coverage. After the Go2 is connected, we should only need to run the hardware acceptance flow instead of assembling functionality on the spot. + +## Overall Module Split + +| Module | Responsibility | Input | Output | Can run in parallel | Current verification | +| --- | --- | --- | --- | --- | --- | +| 1. Basic voice/text control entry | Accept browser microphone, browser text, or normal agent text; first recognize basic movement/posture commands, then recognize seat-finding intent | WebInput `/submit_query`, `/upload_audio`, Whisper text, agent text | Normal agent tool call, or SeatGuide preview/live request | Yes | MCP tool acceptance, WebInput unit tests, HTTP TestClient, hardware acceptance script | +| 2. Scene perception | Use Go2 RGB image + odom + Moondream2/VLM to detect chairs and people, then project them into map coordinates | `color_image`, `odom`, local Moondream2 model cache | `SeatSceneObservation` | Yes | Camera provider unit tests, `camera_seat_provider_status` | +| 3. Empty-seat planning | Decide which chairs are occupied, select the nearest empty seat, and generate the guide pose for the robot | Chair poses, person positions, robot position | Selected chair and navigation goal pose | Yes | Planner unit tests, `preview_empty_seat_goal` | +| 4. Navigation execution | Send the target pose to the existing navigation module and read completion status | SeatGuide goal pose | `set_goal()`, `goal_reached` | Partially | Fake navigator unit tests, `seat_guide_navigation_status` | +| 5. Speech feedback | Tell the user which seat was found, whether to follow, or why the request failed | SeatGuide result text | TTS audio, web response text | Yes | `speech_status`, TTS audio check, operator `HEARD` | +| 6. Acceptance scripts | Chain no-motion checks, real voice input, and real navigation, then save a transcript | Current DimOS stack | Pass/fail reason and acceptance log | Yes | `bin/demo_seat_guide_*` | + +## Stage 1: Basic Voice-Control Acceptance + +Purpose: first prove the smallest working loop: "a person says or types a command -> the system recognizes the intent -> the command reaches the Go2 -> the Go2 executes it." This stage does not find seats, does not depend on VLM, and does not depend on chair detection. + +Work items: + +1. Call posture and movement tools directly through MCP to prove the Go2 control tools work. +2. Type normal movement commands in the browser to prove text enters the agent and triggers a Go2 tool. +3. Speak normal movement commands through the browser microphone to prove microphone -> Whisper -> agent -> Go2 tool works. +4. Verify stop/safety commands so every small movement can be stopped. +5. Accept only low-risk actions: stand, recovery stand, short forward/backward movement, and small turns. Do not test jumps, flips, or other dynamic motions in this first stage. + +### Stage 1 Acceptance Path Breakdown + +| Path | Entry point | Will it move the Go2? | Verification command/action | Pass criteria | +| --- | --- | --- | --- | --- | +| 1A. MCP posture command | MCP tool | May change posture, no walking | `dimos mcp call execute_sport_command --json-args '{"command_name":"BalanceStand"}'` | The tool returns success and the Go2 enters a stable standing/balancing state | +| 1B. MCP short movement | MCP tool | Yes, short distance | Run `relative_move` forward 0.3m, backward 0.3m, and turn left 30 degrees | The Go2 makes the small movement, or navigation status reports the goal completed | +| 1C. Browser text -> agent -> Go2 tool | Web page text box or `/submit_query` | Yes, short distance | Type `walk forward 30 centimeters` and `walk backward 30 centimeters` | Logs show WebInput received the text, non-seat text went to the normal agent path, and the agent called `relative_move` | +| 1D. Browser microphone -> Whisper -> agent -> Go2 tool | Computer browser microphone | Yes, short distance | Say `walk forward 30 centimeters`, or the equivalent Chinese command, into the browser | Logs show Whisper recognized the text, the agent called the matching Go2 tool, and the Go2 executed it | +| 1E. Stop/safety | MCP tool or agent tool | Stops current navigation/action | `dimos mcp call stop_navigation` | Navigation returns to stopped/idle and the robot does not continue moving | + +Recommended acceptance commands: + +```bash +dimos mcp call execute_sport_command --json-args '{"command_name":"BalanceStand"}' +dimos mcp call relative_move --json-args '{"forward":0.3,"left":0,"degrees":0}' +dimos mcp call relative_move --json-args '{"forward":-0.3,"left":0,"degrees":0}' +dimos mcp call relative_move --json-args '{"forward":0,"left":0,"degrees":30}' +dimos mcp call stop_navigation +``` + +Pass criteria: + +- Direct MCP calls can make the Go2 execute posture and short movement commands. +- Browser text commands trigger normal agent tools instead of incorrectly entering SeatGuide. +- Browser microphone commands are transcribed and trigger the same Go2 tool. +- If any action fails, the failure can be attributed to one layer: control tool, agent tool selection, Whisper, WebInput, or Go2 connection. +- The current default input device is the **computer browser microphone**, not the Go2 body microphone. Using the Go2 onboard microphone would require a separate input module later. + +## Stage 2: SeatGuide Core Module Development And Local Unit Tests + +Purpose: prove the core logic without connecting the robot dog. + +Work items: + +1. Implement SeatGuide data models: chairs, people, scene, planner result, and voice intent. +2. Implement occupancy detection: a chair is occupied if a person is within 0.75 meters. +3. Implement nearest-empty-seat selection from the robot's current position. +4. Implement guide-pose generation beside the chair in the aisle direction, not at the chair center. +5. Implement preview and live paths: preview never moves; live sends the navigation goal. + +Verification: + +```bash +uv run pytest dimos/agents/skills/test_seat_guide.py -q -k 'planner or find_empty_seat or preview_empty_seat_goal' +``` + +Pass criteria: + +- The correct empty seat is selected. +- Occupied chairs are not selected. +- Preview does not call navigation. +- Live only calls navigation when navigation is available and the scene source is trusted. + +## Stage 3: SeatGuide Voice And WebInput Path + +Purpose: allow the user to trigger SeatGuide through the browser text box or microphone instead of requiring a manual MCP call. + +Work items: + +1. Route WebInput text input from `/submit_query` directly to SeatGuide requests. +2. Push browser audio uploads from `/upload_audio` into `audio_subject`. +3. Let Whisper auto-detect language instead of forcing English. +4. Chinese preview phrase: `预检帮我找一个空位` only runs no-motion checks. +5. Chinese live phrase: `帮我找一个空位` triggers navigation. +6. Publish the SeatGuide response to `agent_responses` so it is visible in the browser. + +### Stage 3 Acceptance Path Breakdown + +| Path | Entry point | Will it move the Go2? | Verification command/action | Pass criteria | +| --- | --- | --- | --- | --- | +| 3A. Module status check | MCP tool | No | `dimos mcp call web_input_status` | Output includes `web=started`, `voice_upload=connected`, `stt=connected`, and `seat_route=seat_guide_direct` | +| 3B. Browser text preview | Web page text box or `/submit_query` | No | Type `预检帮我找一个空位` in the WebInput page, or let the hardware script POST it automatically | `agent_responses` shows `SeatGuide preflight ready` or a clear no-go reason; no navigation goal is sent | +| 3C. Browser microphone preview | Computer browser microphone | No | Open the WebInput URL, allow microphone access, click the microphone button, and say `预检帮我找一个空位` | After Whisper recognition, DimOS logs include `WebInput received text` and `WebInput routing text to SeatGuide preview` | +| 3D. Normal agent text fallback | `/human_input` agent path | No, unless the agent later explicitly calls a tool | Enter non-seat text such as `what time is the meeting` | WebInput does not call SeatGuide; the text goes to the normal agent path | +| 3E. Browser microphone live | Computer browser microphone | Yes | Only after no-motion checks pass and the physical area is safe, say `帮我找一个空位` | Logs include `WebInput routing text to SeatGuide live request`, and SeatGuide returns `Navigating to ...` | + +Recommended acceptance order: + +1. Run 3A first to confirm WebInput, STT, and browser audio upload are online. +2. Run 3B next to confirm browser text input routes directly to SeatGuide preview. +3. Run 3C next to confirm computer browser microphone -> Whisper -> SeatGuide preview. +4. Run 3E last because it sends a real navigation goal. + +Note: the current default path uses the **computer browser microphone**, not the Go2 body microphone. The user speaks to the computer browser, the computer uploads audio to DimOS, Whisper transcribes it, and then SeatGuide sends navigation to the Go2. + +Verification: + +```bash +uv run pytest dimos/agents/skills/test_seat_guide.py -q -k 'web_input or upload_audio or submit_query' +``` + +Pass criteria: + +- `/submit_query` can trigger SeatGuide preview. +- `/upload_audio` can produce an `AudioEvent`. +- Missing voice input configuration or audio decode failure cannot be reported as success. +- `web_input_status` must include: + - `web=started` + - `thread=running` + - `seat_route=seat_guide_direct` + - `responses=connected` + - `voice_upload=connected` + - `stt=connected` + - `human_transport=connected` + +## Stage 4: Real Camera/VLM/Odom Perception + +Purpose: the final result must not rely on fake mocks. Hardware acceptance must prove the scene came from the real camera source. + +Work items: + +1. `CameraSeatObservationProvider` subscribes to `color_image` and `odom`. +2. Use Qwen/VLM to detect `chair` and `person` separately. +3. Estimate map-frame chair/person positions from the bounding-box center and odometry. +4. Diagnose no-go states such as stale image, stale odom, missing key, and missing camera. +5. Keep `set_seat_scene` as fallback/calibration, but reject fallback for official hardware acceptance. + +Verification: + +```bash +uv run pytest dimos/agents/skills/test_seat_guide.py -q -k 'camera_observation_provider or camera_seat_provider_status' +``` + +Pre-hardware checks: + +```bash +dimos mcp call camera_seat_provider_status +dimos mcp call seat_guide_status +``` + +Pass criteria: + +- `camera_seat_provider_status` shows: + - `image=x` + - `image_fresh=true` + - `odom=(...)` + - `odom_fresh=true` + - `credential=present` + - `override=inactive` + - `configured_fallback_seats=0` + - `configured_fallback_people=0` +- `seat_guide_status` must start with `SeatGuide scene source=camera:`. + +## Stage 5: Navigation And Speech Feedback + +Purpose: after finding an empty seat, the Go2 must actually receive a navigation goal and provide audible or visible feedback to the user. + +Work items: + +1. Inject `NavigationInterfaceSpec` into SeatGuide. +2. On a live request, call `set_goal(PoseStamped)`. +3. If navigation is busy, refuse to overwrite the current task. +4. Read `navigation_state` and `goal_reached`. +5. Inject `SpeakSkillSpec` into SeatGuide and speak the result when available. +6. Use `speech_status` to distinguish missing TTS key, missing audio output, and ready state. + +Verification: + +```bash +uv run pytest dimos/agents/skills/test_seat_guide.py dimos/agents/skills/test_speak_skill.py -q +``` + +Pre-hardware checks: + +```bash +dimos mcp call seat_guide_preflight +dimos mcp call preview_empty_seat_goal +dimos mcp call speech_status +dimos mcp call seat_guide_navigation_status +``` + +Pass criteria: + +- Preflight shows `navigation=IDLE`. +- Preview includes `selected=...` and `goal=(...)`. +- Speech shows `tts=ready` and `audio_output=connected`. +- After live navigation, `seat_guide_navigation_status` eventually shows a new `goal_sequence` and `goal_reached=true`. + +## Stage 6: Mac Replay / SHM Video Stream Fix + +Purpose: on macOS replay, high-bandwidth video/pointcloud/map streams should not disappear because of UDP/LCM large-packet issues. + +Work items: + +1. Integrate `dimensionalOS/dimos#2245` / `danvi/experimental/route-replay-through-SHM`. +2. Route high-bandwidth Go2 replay streams through `pSHMTransport`: + - `color_image` + - `lidar` + - `pointcloud` + - `global_map` + - `merged_map` + - `global_costmap` + - `navigation_costmap` +3. Let the Rerun bridge receive SHM visual transports. + +Verification: + +```bash +uv run pytest dimos/protocol/pubsub/test_registry.py dimos/visualization/rerun/test_viewer_integration.py -q +bin/demo_seat_guide_replay_smoke +``` + +Pass criteria: + +- The replay stack starts. +- Logs show high-bandwidth streams with `transport=pSHMTransport`. +- `bin/demo_seat_guide_replay_smoke` completes and stops the stack. + +## Stage 7: Real Go2 Bring-Up + +Purpose: after connecting the robot dog, you should run one entry point instead of manually composing commands. + +Manual preparation: + +1. Power on the robot dog and connect it to the same network as the Mac. +2. Confirm the Go2 IP. The default example is `192.168.123.161`. +3. Prepare API keys. The normal agent can use OpenRouter; TTS speech output still requires OpenAI, and seat/person VLM still requires Alibaba/Qwen: + +```bash +export OPENROUTER_API_KEY="your OpenRouter key" +export OPENROUTER_MODEL="openai/gpt-4o-mini" + +# Optional: only needed for robot speech/TTS output +export OPENAI_API_KEY="your OpenAI key" +``` + +Start one-command bring-up: + +```bash +bin/demo_seat_guide_hardware_bringup --robot-ip 192.168.123.161 +``` + +The script automatically: + +1. Checks the local Moondream2 model cache, and the agent key from either `OPENROUTER_API_KEY` or `OPENAI_API_KEY`. +2. Starts `unitree-go2-seat-guide-agentic`. +3. Runs `bin/demo_seat_guide_smoke` for no-motion checks. +4. Runs `bin/demo_seat_guide_hardware_acceptance` for real browser voice input and navigation acceptance. + +Manual actions during the script: + +1. Open the WebInput URL printed by the script. +2. Allow browser microphone access. +3. After hearing the TTS check phrase, type `HEARD` in the terminal. +4. During the no-motion stage, say into the browser: `预检帮我找一个空位`. +5. After confirming the area around the Go2 is physically safe, type `LIVE` in the terminal. +6. During the live stage, say into the browser: `帮我找一个空位`. + +Pass criteria: + +- All no-motion gates pass. +- The live stage WebInput logs include the recognized Chinese text. +- SeatGuide returns `Navigating to ...`. +- `seat_guide_navigation_status` eventually shows a new `goal_sequence` and `goal_reached=true`. +- `bin/demo_seat_guide_verify_acceptance_log ` passes. + +## Stage 8: Module-Level Debugging When Something Fails + +| Failure point | Command to inspect | Common cause | Fix | +| --- | --- | --- | --- | +| WebInput is not started | `dimos mcp call web_input_status` | Port conflict or WebInput module did not start | Check `dimos status`, restart the stack | +| Microphone input does not arrive | `web_input_status`, browser permissions | `voice_upload=missing`, browser denied microphone access | Allow microphone permission, refresh the WebInput page | +| STT is not working | `web_input_status` | Whisper/faster-whisper initialization failed | Inspect DimOS logs and confirm dependencies | +| No image | `camera_seat_provider_status` | Go2 camera/replay stream did not arrive | Turn toward the table, confirm replay/SHM stream | +| Odom missing or stale | `camera_seat_provider_status` | Localization did not start or stale odom | Wait for odom, inspect Go2/replay stack | +| VLM failed | `seat_guide_status` | Missing local Moondream2 model, model load failure, or missing remote VLM key | Download the model or re-export the matching key, then restart the stack | +| No chairs found | `seat_guide_status` | Camera not facing the table, lighting or recognition issue | Adjust robot view; use fallback only for debugging | +| Navigation busy | `seat_guide_preflight` | `navigation=FOLLOWING_PATH` or `RECOVERY` | Wait for the task to finish or stop navigation before retrying | +| TTS unavailable | `speech_status` | Missing `OPENAI_API_KEY` or missing audio output | If acceptance requires spoken feedback, set the OpenAI key and connect audio output; if only browser text feedback is being accepted, record this as non-blocking first | + +## Current Completion Status + +Completed: + +- SeatGuide planner / scene / intent / navigation integration. +- WebInput Chinese voice and text directly routed to SeatGuide. +- Camera/VLM/odom provider. +- SpeakSkill readiness and TTS audio check. +- Go2 SeatGuide blueprints. +- macOS replay SHM route integration. +- One-command hardware bring-up script. +- No-motion smoke, hardware acceptance, and acceptance log verifier. + +Verified: + +- SeatGuide/Speak/MCP tests pass. +- pubsub/Rerun SHM tests pass. +- `bin/demo_seat_guide_replay_smoke` completes on Mac. + +Not complete yet: + +- Real Go2 hardware transcript. Final completion requires proof of real browser microphone input, real camera/VLM/odom, audible TTS confirmation, real navigation, and `goal_reached=true`. diff --git a/docs/capabilities/agents/readme.md b/docs/capabilities/agents/readme.md index 7cb26b7463..4c240a9925 100644 --- a/docs/capabilities/agents/readme.md +++ b/docs/capabilities/agents/readme.md @@ -17,7 +17,7 @@ Human Input ──→ Agent ──→ Skill Calls ──→ Robot - `agent: Out[BaseMessage]`: publishes agent responses (text, tool calls, images) - `agent_idle: Out[bool]`: signals when the agent is waiting for input -The agent uses LangGraph with a configurable LLM. The default is `gpt-4o` and you need to provide an `OPENAI_API_KEY` environment variable. On startup, it discovers all `@skill`-annotated methods across deployed modules via RPC and exposes them as LangChain tools. +The agent uses LangGraph with a configurable LLM. The default is `gpt-4o`; provide `OPENAI_API_KEY` for direct OpenAI, or provide `OPENROUTER_API_KEY` to route the agent through OpenRouter's OpenAI-compatible chat API. On startup, it discovers all `@skill`-annotated methods across deployed modules via RPC and exposes them as LangChain tools. ## Skills @@ -86,6 +86,8 @@ dimos mcp status # Server status | Config | Model | Notes | |--------|-------|-------| -| Default | `gpt-4o` | Best quality, requires `OPENAI_API_KEY` | +| Default | `gpt-4o` | Uses `OPENAI_API_KEY` unless `OPENROUTER_API_KEY` is set | +| OpenRouter default | `gpt-4o` mapped to `openai/gpt-4o` | Set `OPENROUTER_API_KEY`; optionally override with `OPENROUTER_MODEL` | +| `openrouter:` | OpenRouter model id | Example: `McpClient.blueprint(model="openrouter:openai/gpt-4o-mini")`; choose a model that supports tool calling | | `ollama:llama3.1` | Local Ollama | Requires `ollama serve` running | | Custom | Any LangChain-compatible | Set via `McpClient.blueprint(model="...")` | From f87edf794a1c18a1e8f8b52973b3773f7d836133 Mon Sep 17 00:00:00 2001 From: Ernest Date: Thu, 28 May 2026 23:39:25 +0800 Subject: [PATCH 07/16] feat(seat-guide): add Vercel phone speaker relay --- apps/seat-guide-speaker-vercel/.gitignore | 2 + apps/seat-guide-speaker-vercel/README.md | 45 + .../api/[...speaker].js | 74 + .../package-lock.json | 4841 +++++++++++++++++ apps/seat-guide-speaker-vercel/package.json | 13 + .../public/index.html | 238 + apps/seat-guide-speaker-vercel/vercel.json | 13 + 7 files changed, 5226 insertions(+) create mode 100644 apps/seat-guide-speaker-vercel/.gitignore create mode 100644 apps/seat-guide-speaker-vercel/README.md create mode 100644 apps/seat-guide-speaker-vercel/api/[...speaker].js create mode 100644 apps/seat-guide-speaker-vercel/package-lock.json create mode 100644 apps/seat-guide-speaker-vercel/package.json create mode 100644 apps/seat-guide-speaker-vercel/public/index.html create mode 100644 apps/seat-guide-speaker-vercel/vercel.json diff --git a/apps/seat-guide-speaker-vercel/.gitignore b/apps/seat-guide-speaker-vercel/.gitignore new file mode 100644 index 0000000000..6096ed2886 --- /dev/null +++ b/apps/seat-guide-speaker-vercel/.gitignore @@ -0,0 +1,2 @@ +.vercel +node_modules diff --git a/apps/seat-guide-speaker-vercel/README.md b/apps/seat-guide-speaker-vercel/README.md new file mode 100644 index 0000000000..d5c1da39e4 --- /dev/null +++ b/apps/seat-guide-speaker-vercel/README.md @@ -0,0 +1,45 @@ +# SeatGuide Speaker Vercel App + +This app lets an iPhone mounted on the Go2 act as the SeatGuide speaker. + +Flow: + +1. iPhone opens the deployed page with cellular data. +2. The page polls `/api/latest?device=go2-demo`. +3. Mac/DimOS posts arrival text to `/api/speak`. +4. The iPhone speaks the latest message with the local browser speaker. + +This minimal Vercel version stores only the latest message in serverless memory. +It is enough for quick demos, but can lose messages on cold starts or instance +changes. + +## Deploy + +Create a Vercel project from this directory: + +```bash +cd apps/seat-guide-speaker-vercel +npm install +npx vercel +``` + +No Redis or database is required for the quick demo version. + +## iPhone + +Open: + +```text +https:///?device=go2-demo +``` + +Tap `Enable speaker`. Keep Safari open and unlocked. + +## Mac Test + +```bash +curl -X POST "https:///api/speak" \ + -H "authorization: Bearer $SPEAKER_API_TOKEN" \ + -H "content-type: application/json" \ + -d '{"device":"go2-demo","text":"我已经到了, 请坐。"}' +``` diff --git a/apps/seat-guide-speaker-vercel/api/[...speaker].js b/apps/seat-guide-speaker-vercel/api/[...speaker].js new file mode 100644 index 0000000000..8539e114ed --- /dev/null +++ b/apps/seat-guide-speaker-vercel/api/[...speaker].js @@ -0,0 +1,74 @@ +const messages = globalThis.__seatGuideSpeakerMessages || new Map(); +globalThis.__seatGuideSpeakerMessages = messages; + +function json(res, status, body) { + res.statusCode = status; + res.setHeader("content-type", "application/json; charset=utf-8"); + res.end(JSON.stringify(body)); +} + +function sanitizeDevice(value) { + const device = String(value || "go2-demo") + .trim() + .replace(/[^a-zA-Z0-9_-]/g, "-") + .slice(0, 80); + return device || "go2-demo"; +} + +async function readBody(req) { + const chunks = []; + for await (const chunk of req) chunks.push(chunk); + const raw = Buffer.concat(chunks).toString("utf8"); + return raw ? JSON.parse(raw) : {}; +} + +async function handleSpeak(req, res) { + if (req.method !== "POST") { + json(res, 405, { ok: false, error: "method_not_allowed" }); + return; + } + + try { + const body = await readBody(req); + const text = String(body.text || "").trim(); + if (!text) { + json(res, 400, { ok: false, error: "missing_text" }); + return; + } + const device = sanitizeDevice(body.device); + const message = { + id: `${Date.now()}-${Math.random().toString(16).slice(2)}`, + device, + text: text.slice(0, 800), + createdAt: new Date().toISOString(), + }; + messages.set(device, message); + json(res, 200, { ok: true, storage: "memory", message }); + } catch (error) { + json(res, 500, { ok: false, error: String(error.message || error) }); + } +} + +function handleLatest(req, res) { + if (req.method !== "GET") { + json(res, 405, { ok: false, error: "method_not_allowed" }); + return; + } + const url = new URL(req.url, `https://${req.headers.host || "localhost"}`); + const device = sanitizeDevice(url.searchParams.get("device")); + json(res, 200, { ok: true, device, message: messages.get(device) || null }); +} + +export default async function handler(req, res) { + const url = new URL(req.url, `https://${req.headers.host || "localhost"}`); + const route = url.pathname.split("/").filter(Boolean).at(-1); + if (route === "speak") { + await handleSpeak(req, res); + return; + } + if (route === "latest") { + handleLatest(req, res); + return; + } + json(res, 404, { ok: false, error: "not_found" }); +} diff --git a/apps/seat-guide-speaker-vercel/package-lock.json b/apps/seat-guide-speaker-vercel/package-lock.json new file mode 100644 index 0000000000..a143cbbd83 --- /dev/null +++ b/apps/seat-guide-speaker-vercel/package-lock.json @@ -0,0 +1,4841 @@ +{ + "name": "seat-guide-speaker-vercel", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "seat-guide-speaker-vercel", + "version": "0.1.0", + "devDependencies": { + "vercel": "^54.5.1" + } + }, + "node_modules/@bytecodealliance/preview2-shim": { + "version": "0.17.6", + "resolved": "https://registry.npmjs.org/@bytecodealliance/preview2-shim/-/preview2-shim-0.17.6.tgz", + "integrity": "sha512-n3cM88gTen5980UOBAD6xDcNNL3ocTK8keab21bpx1ONdA+ARj7uD1qoFxOWCyKlkpSi195FH+GeAut7Oc6zZw==", + "dev": true, + "license": "(Apache-2.0 WITH LLVM-exception)" + }, + "node_modules/@edge-runtime/format": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/@edge-runtime/format/-/format-2.2.1.tgz", + "integrity": "sha512-JQTRVuiusQLNNLe2W9tnzBlV/GvSVcozLl4XZHk5swnRZ/v6jp8TqR8P7sqmJsQqblDZ3EztcWmLDbhRje/+8g==", + "dev": true, + "license": "MPL-2.0", + "engines": { + "node": ">=16" + } + }, + "node_modules/@edge-runtime/node-utils": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@edge-runtime/node-utils/-/node-utils-2.3.0.tgz", + "integrity": "sha512-uUtx8BFoO1hNxtHjp3eqVPC/mWImGb2exOfGjMLUoipuWgjej+f4o/VP4bUI8U40gu7Teogd5VTeZUkGvJSPOQ==", + "dev": true, + "license": "MPL-2.0", + "engines": { + "node": ">=16" + } + }, + "node_modules/@edge-runtime/ponyfill": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/@edge-runtime/ponyfill/-/ponyfill-2.4.2.tgz", + "integrity": "sha512-oN17GjFr69chu6sDLvXxdhg0Qe8EZviGSuqzR9qOiKh4MhFYGdBBcqRNzdmYeAdeRzOW2mM9yil4RftUQ7sUOA==", + "dev": true, + "license": "MPL-2.0", + "engines": { + "node": ">=16" + } + }, + "node_modules/@edge-runtime/primitives": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@edge-runtime/primitives/-/primitives-4.1.0.tgz", + "integrity": "sha512-Vw0lbJ2lvRUqc7/soqygUX216Xb8T3WBZ987oywz6aJqRxcwSVWwr9e+Nqo2m9bxobA9mdbWNNoRY6S9eko1EQ==", + "dev": true, + "license": "MPL-2.0", + "engines": { + "node": ">=16" + } + }, + "node_modules/@edge-runtime/vm": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@edge-runtime/vm/-/vm-3.2.0.tgz", + "integrity": "sha512-0dEVyRLM/lG4gp1R/Ik5bfPl/1wX00xFwd5KcNH602tzBa09oF7pbTKETEhR1GjZ75K6OJnYFu8II2dyMhONMw==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "@edge-runtime/primitives": "4.1.0" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/@emnapi/core": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", + "integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.1", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", + "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", + "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.0.tgz", + "integrity": "sha512-KuZrd2hRjz01y5JK9mEBSD3Vj3mbCvemhT466rSuJYeE/hjuBrHfjjcjMdTm/sz7au+++sdbJZJmuBwQLuw68A==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.0.tgz", + "integrity": "sha512-j67aezrPNYWJEOHUNLPj9maeJte7uSMM6gMoxfPC9hOg8N02JuQi/T7ewumf4tNvJadFkvLZMlAq73b9uwdMyQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.0.tgz", + "integrity": "sha512-CC3vt4+1xZrs97/PKDkl0yN7w8edvU2vZvAFGD16n9F0Cvniy5qvzRXjfO1l94efczkkQE6g1x0i73Qf5uthOQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.0.tgz", + "integrity": "sha512-wurMkF1nmQajBO1+0CJmcN17U4BP6GqNSROP8t0X/Jiw2ltYGLHpEksp9MpoBqkrFR3kv2/te6Sha26k3+yZ9Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.0.tgz", + "integrity": "sha512-uJOQKYCcHhg07DL7i8MzjvS2LaP7W7Pn/7uA0B5S1EnqAirJtbyw4yC5jQ5qcFjHK9l6o/MX9QisBg12kNkdHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.0.tgz", + "integrity": "sha512-8mG6arH3yB/4ZXiEnXof5MK72dE6zM9cDvUcPtxhUZsDjESl9JipZYW60C3JGreKCEP+p8P/72r69m4AZGJd5g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.0.tgz", + "integrity": "sha512-9FHtyO988CwNMMOE3YIeci+UV+x5Zy8fI2qHNpsEtSF83YPBmE8UWmfYAQg6Ux7Gsmd4FejZqnEUZCMGaNQHQw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.0.tgz", + "integrity": "sha512-zCMeMXI4HS/tXvJz8vWGexpZj2YVtRAihHLk1imZj4efx1BQzN76YFeKqlDr3bUWI26wHwLWPd3rwh6pe4EV7g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.0.tgz", + "integrity": "sha512-t76XLQDpxgmq2cNXKTVEB7O7YMb42atj2Re2Haf45HkaUpjM2J0UuJZDuaGbPbamzZ7bawyGFUkodL+zcE+jvQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.0.tgz", + "integrity": "sha512-AS18v0V+vZiLJyi/4LphvBE+OIX682Pu7ZYNsdUHyUKSoRwdnOsMf6FDekwoAFKej14WAkOef3zAORJgAtXnlQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.0.tgz", + "integrity": "sha512-Mz1jxqm/kfgKkc/KLHC5qIujMvnnarD9ra1cEcrs7qshTUSksPihGrWHVG5+osAIQ68577Zpww7SGapmzSt4Nw==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.0.tgz", + "integrity": "sha512-QbEREjdJeIreIAbdG2hLU1yXm1uu+LTdzoq1KCo4G4pFOLlvIspBm36QrQOar9LFduavoWX2msNFAAAY9j4BDg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.0.tgz", + "integrity": "sha512-sJz3zRNe4tO2wxvDpH/HYJilb6+2YJxo/ZNbVdtFiKDufzWq4JmKAiHy9iGoLjAV7r/W32VgaHGkk35cUXlNOg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.0.tgz", + "integrity": "sha512-z9N10FBD0DCS2dmSABDBb5TLAyF1/ydVb+N4pi88T45efQ/w4ohr/F/QYCkxDPnkhkp6AIpIcQKQ8F0ANoA2JA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.0.tgz", + "integrity": "sha512-pQdyAIZ0BWIC5GyvVFn5awDiO14TkT/19FTmFcPdDec94KJ1uZcmFs21Fo8auMXzD4Tt+diXu1LW1gHus9fhFQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.0.tgz", + "integrity": "sha512-hPlRWR4eIDDEci953RI1BLZitgi5uqcsjKMxwYfmi4LcwyWo2IcRP+lThVnKjNtk90pLS8nKdroXYOqW+QQH+w==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.0.tgz", + "integrity": "sha512-1hBWx4OUJE2cab++aVZ7pObD6s+DK4mPGpemtnAORBvb5l/g5xFGk0vc0PjSkrDs0XaXj9yyob3d14XqvnQ4gw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.0.tgz", + "integrity": "sha512-6m0sfQfxfQfy1qRuecMkJlf1cIzTOgyaeXaiVaaki8/v+WB+U4hc6ik15ZW6TAllRlg/WuQXxWj1jx6C+dfy3w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.0.tgz", + "integrity": "sha512-xbbOdfn06FtcJ9d0ShxxvSn2iUsGd/lgPIO2V3VZIPDbEaIj1/3nBBe1AwuEZKXVXkMmpr6LUAgMkLD/4D2PPA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.0.tgz", + "integrity": "sha512-fWgqR8uNbCQ/GGv0yhzttj6sU/9Z5/Sv/VGU3F5OuXK6J6SlriONKrQ7tNlwBrJZXRYk5jUhuWvF7GYzGguBZQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.0.tgz", + "integrity": "sha512-aCwlRdSNMNxkGGqQajMUza6uXzR/U0dIl1QmLjPtRbLOx3Gy3otfFu/VjATy4yQzo9yFDGTxYDo1FfAD9oRD2A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.0.tgz", + "integrity": "sha512-nyvsBccxNAsNYz2jVFYwEGuRRomqZ149A39SHWk4hV0jWxKM0hjBPm3AmdxcbHiFLbBSwG6SbpIcUbXjgyECfA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.0.tgz", + "integrity": "sha512-Q1KY1iJafM+UX6CFEL+F4HRTgygmEW568YMqDA5UV97AuZSm21b7SXIrRJDwXWPzr8MGr75fUZPV67FdtMHlHA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.0.tgz", + "integrity": "sha512-W1eyGNi6d+8kOmZIwi/EDjrL9nxQIQ0MiGqe/AWc6+IaHloxHSGoeRgDRKHFISThLmsewZ5nHFvGFWdBYlgKPg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.0.tgz", + "integrity": "sha512-30z1aKL9h22kQhilnYkORFYt+3wp7yZsHWus+wSKAJR8JtdfI76LJ4SBdMsCopTR3z/ORqVu5L1vtnHZWVj4cQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.0.tgz", + "integrity": "sha512-aIitBcjQeyOhMTImhLZmtxfdOcuNRpwlPNmlFKPcHQYPhEssw75Cl1TSXJXpMkzaua9FUetx/4OQKq7eJul5Cg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@fastify/busboy": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@fastify/busboy/-/busboy-2.1.1.tgz", + "integrity": "sha512-vBZP4NlzfOlerQTnba4aqZoMhE/a9HY7HRqoOPaETQcSQuWEIyZMHGfVu6w9wGtGK5fED5qRs2DteVCjOH60sA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14" + } + }, + "node_modules/@isaacs/balanced-match": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@isaacs/balanced-match/-/balanced-match-4.0.1.tgz", + "integrity": "sha512-yzMTt9lEb8Gv7zRioUilSglI0c0smZ9k5D65677DLWLtWJaXIS3CqcGyUFByYKlnUj6TkjLVs54fBl6+TiGQDQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/@isaacs/brace-expansion": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/@isaacs/brace-expansion/-/brace-expansion-5.0.1.tgz", + "integrity": "sha512-WMz71T1JS624nWj2n2fnYAuPovhv7EUhk69R6i9dsVyzxt5eM3bjwvgk9L+APE1TRscGysAVMANkB0jh0LQZrQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@isaacs/balanced-match": "^4.0.1" + }, + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/@isaacs/fs-minipass": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz", + "integrity": "sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "minipass": "^7.0.4" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@mapbox/node-pre-gyp": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@mapbox/node-pre-gyp/-/node-pre-gyp-2.0.3.tgz", + "integrity": "sha512-uwPAhccfFJlsfCxMYTwOdVfOz3xqyj8xYL3zJj8f0pb30tLohnnFPhLuqp4/qoEz8sNxe4SESZedcBojRefIzg==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "consola": "^3.2.3", + "detect-libc": "^2.0.0", + "https-proxy-agent": "^7.0.5", + "node-fetch": "^2.6.7", + "nopt": "^8.0.0", + "semver": "^7.5.3", + "tar": "^7.4.0" + }, + "bin": { + "node-pre-gyp": "bin/node-pre-gyp" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.4.tgz", + "integrity": "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@tybys/wasm-util": "^0.10.1" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1" + } + }, + "node_modules/@nodelib/fs.scandir": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.stat": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.walk": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@oxc-project/types": { + "version": "0.110.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.110.0.tgz", + "integrity": "sha512-6Ct21OIlrEnFEJk5LT4e63pk3btsI6/TusD/GStLi7wYlGJNOl1GI9qvXAnRAxQU9zqA2Oz+UwhfTOU2rPZVow==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, + "node_modules/@oxc-transform/binding-android-arm-eabi": { + "version": "0.111.0", + "resolved": "https://registry.npmjs.org/@oxc-transform/binding-android-arm-eabi/-/binding-android-arm-eabi-0.111.0.tgz", + "integrity": "sha512-NdFLicvorfHYu0g2ftjVJaH7+Dz27AQUNJOq8t/ofRUoWmczOodgUCHx8C1M1htCN4ZmhS/FzfSy6yd/UngJGg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-transform/binding-android-arm64": { + "version": "0.111.0", + "resolved": "https://registry.npmjs.org/@oxc-transform/binding-android-arm64/-/binding-android-arm64-0.111.0.tgz", + "integrity": "sha512-J2v9ajarD2FYlhHtjbgZUFsS2Kvi27pPxDWLGCy7i8tO60xBoozX9/ktSgbiE/QsxKaUhfv4zVKppKWUo71PmQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-transform/binding-darwin-arm64": { + "version": "0.111.0", + "resolved": "https://registry.npmjs.org/@oxc-transform/binding-darwin-arm64/-/binding-darwin-arm64-0.111.0.tgz", + "integrity": "sha512-2UYmExxpXzmiHTldhNlosWqG9Nc4US51K0GB9RLcGlTE23WO33vVo1NVAKwxPE+KYuhffwDnRYTovTMUjzwvZA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-transform/binding-darwin-x64": { + "version": "0.111.0", + "resolved": "https://registry.npmjs.org/@oxc-transform/binding-darwin-x64/-/binding-darwin-x64-0.111.0.tgz", + "integrity": "sha512-c4YRwfLV8Pj/ToiTCbndZaHxM2BD4W3bltr/fjXZcGypEK+U2RZFDL7tIZYT/tyneAC9hCORZKDaKhLLNuzPtA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-transform/binding-freebsd-x64": { + "version": "0.111.0", + "resolved": "https://registry.npmjs.org/@oxc-transform/binding-freebsd-x64/-/binding-freebsd-x64-0.111.0.tgz", + "integrity": "sha512-prvf32IcEuLnLZbNVomFosBu0CaZpyj3YsZ6epbOgJy8iJjfLsXBb+PrkO/NBKzjuJoJa2+u7jFKRE0KT7gSOw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-transform/binding-linux-arm-gnueabihf": { + "version": "0.111.0", + "resolved": "https://registry.npmjs.org/@oxc-transform/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-0.111.0.tgz", + "integrity": "sha512-+se3579Wp7VOk8TnTZCpT+obTAyzOw2b/UuoM0+51LtbzCSfjKxd4A+o7zRl7GyPrPZvx57KdbMOC9rWB1xNrw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-transform/binding-linux-arm-musleabihf": { + "version": "0.111.0", + "resolved": "https://registry.npmjs.org/@oxc-transform/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-0.111.0.tgz", + "integrity": "sha512-8faC99pStqaSDPK/vBgaagAHUeL0LcIzfeSjSiDTtvPGc3AwZIeqC1tx3CP15a6tWXjdgS/IUw4IjfD5HweBlg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-transform/binding-linux-arm64-gnu": { + "version": "0.111.0", + "resolved": "https://registry.npmjs.org/@oxc-transform/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-0.111.0.tgz", + "integrity": "sha512-HtfQv8j796gzI5WR/RaP6IMwFpiL0vYeDrUA1hYhlPzTHKYan/B+NlhJkKOI1v24yAl/yEnFmb0pxIxLNqBqBA==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-transform/binding-linux-arm64-musl": { + "version": "0.111.0", + "resolved": "https://registry.npmjs.org/@oxc-transform/binding-linux-arm64-musl/-/binding-linux-arm64-musl-0.111.0.tgz", + "integrity": "sha512-ARyfcMCIxVLDgLf6FQ8Oo1/TFySpnquV+vuSb4SFQZfYDqgMklzwv0NYXxWD0aB6enElyMDs6pQJBzusEKCkOg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-transform/binding-linux-ppc64-gnu": { + "version": "0.111.0", + "resolved": "https://registry.npmjs.org/@oxc-transform/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-0.111.0.tgz", + "integrity": "sha512-PKpVRrSvBNK3tv9vwxn7Fay+QWZmprPGlEqJcseBJllQc5mFMD4Q/w44chu5iR9ZLsDeSHzmNWrgMLo4J0sP2A==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-transform/binding-linux-riscv64-gnu": { + "version": "0.111.0", + "resolved": "https://registry.npmjs.org/@oxc-transform/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-0.111.0.tgz", + "integrity": "sha512-9bUml6rMgk+8GF5rvNMweFspkzSiCjqpV6HduwiUyexqfGKrmjq9IZOxxvnzkE2RGdQzP507NNDoVNYIoGQYuA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-transform/binding-linux-riscv64-musl": { + "version": "0.111.0", + "resolved": "https://registry.npmjs.org/@oxc-transform/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-0.111.0.tgz", + "integrity": "sha512-tzGCohGxaeH6KRJjfYZd4mHCoGjCai6N+zZi1Oj+tSDMAAdyvs1dRzYb8PNUGnybCg3Te4M0jLPzWZaSmnKraQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-transform/binding-linux-s390x-gnu": { + "version": "0.111.0", + "resolved": "https://registry.npmjs.org/@oxc-transform/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-0.111.0.tgz", + "integrity": "sha512-sRG1KIfZ0ML9ToEygm5aM/5GJeBA05uHlgW3M0Rx/DNWMJhuahLmqWuB02aWSmijndLfEKXLLXIWhvWupRG8lg==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-transform/binding-linux-x64-gnu": { + "version": "0.111.0", + "resolved": "https://registry.npmjs.org/@oxc-transform/binding-linux-x64-gnu/-/binding-linux-x64-gnu-0.111.0.tgz", + "integrity": "sha512-T0Kmvk+OdlUdABdXlDIf3MQReMzFfC75NEI9x8jxy5pKooACEFg0k0V8gyR3gq4DzbDCfucqFQDWNvSgIopAbQ==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-transform/binding-linux-x64-musl": { + "version": "0.111.0", + "resolved": "https://registry.npmjs.org/@oxc-transform/binding-linux-x64-musl/-/binding-linux-x64-musl-0.111.0.tgz", + "integrity": "sha512-EgoutsP3YfqzN8a9vpc9+XLr0bmBl0dA3uOMiP77+exATCPxJBkJErGmQkqk6RtTp5XqX6q6mB45qWQyKk6+pA==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-transform/binding-openharmony-arm64": { + "version": "0.111.0", + "resolved": "https://registry.npmjs.org/@oxc-transform/binding-openharmony-arm64/-/binding-openharmony-arm64-0.111.0.tgz", + "integrity": "sha512-d8J+ejc0j5WODbVwR/QxFaI65YMwvG0W53vcVCHwa6ja1QI5lpe7sislrefG2EFYgnY47voMRzlXab5d4gEcDw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-transform/binding-wasm32-wasi": { + "version": "0.111.0", + "resolved": "https://registry.npmjs.org/@oxc-transform/binding-wasm32-wasi/-/binding-wasm32-wasi-0.111.0.tgz", + "integrity": "sha512-HtyIZO8IwuZgXkyb56rysLz1OLbfLhEu8A3BeuyJXzUseAj96yuxgGt3cu3QYX9AXb9pfRfA3c/fvlhsDugyTQ==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@napi-rs/wasm-runtime": "^1.1.1" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@oxc-transform/binding-win32-arm64-msvc": { + "version": "0.111.0", + "resolved": "https://registry.npmjs.org/@oxc-transform/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-0.111.0.tgz", + "integrity": "sha512-YeP80Riptc0MkVVBnzbmoFuHVLUq278+MbwNo9sTLALmzTIJxJqN029xRZbG+Bun7aLsoZhmRnm3J5JZ1NcP5w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-transform/binding-win32-ia32-msvc": { + "version": "0.111.0", + "resolved": "https://registry.npmjs.org/@oxc-transform/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-0.111.0.tgz", + "integrity": "sha512-A6ztCXpoSHt6PbvGAFqB0MLOcGG7ZJrrPXY1iB0zfOB1atLgI8oNePGxPl03XSbwpiTsFJ1oo8rj9DXcBzgT9g==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-transform/binding-win32-x64-msvc": { + "version": "0.111.0", + "resolved": "https://registry.npmjs.org/@oxc-transform/binding-win32-x64-msvc/-/binding-win32-x64-msvc-0.111.0.tgz", + "integrity": "sha512-QddKW4kBH0Wof6Y65eYCNHM4iOGmCTWLLcNYY1FGswhzmTYOUVXajNROR+iCXAOFnOF0ldtsR79SyqgyHH1Bgg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@renovatebot/pep440": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@renovatebot/pep440/-/pep440-4.2.1.tgz", + "integrity": "sha512-2FK1hF93Fuf1laSdfiEmJvSJPVIDHEUTz68D3Fi9s0IZrrpaEcj6pTFBTbYvsgC5du4ogrtf5re7yMMvrKNgkw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.9.0 || ^22.11.0 || ^24", + "pnpm": "^10.0.0" + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.0.0-rc.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.1.tgz", + "integrity": "sha512-He6ZoCfv5D7dlRbrhNBkuMVIHd0GDnjJwbICE1OWpG7G3S2gmJ+eXkcNLJjzjNDpeI2aRy56ou39AJM9AD8YFA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.0.0-rc.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-rc.1.tgz", + "integrity": "sha512-YzJdn08kSOXnj85ghHauH2iHpOJ6eSmstdRTLyaziDcUxe9SyQJgGyx/5jDIhDvtOcNvMm2Ju7m19+S/Rm1jFg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.0.0-rc.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-rc.1.tgz", + "integrity": "sha512-cIvAbqM+ZVV6lBSKSBtlNqH5iCiW933t1q8j0H66B3sjbe8AxIRetVqfGgcHcJtMzBIkIALlL9fcDrElWLJQcQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.0.0-rc.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-rc.1.tgz", + "integrity": "sha512-rVt+B1B/qmKwCl1XD02wKfgh3vQPXRXdB/TicV2w6g7RVAM1+cZcpigwhLarqiVCxDObFZ7UgXCxPC7tpDoRog==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.0.0-rc.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-rc.1.tgz", + "integrity": "sha512-69YKwJJBOFprQa1GktPgbuBOfnn+EGxu8sBJ1TjPER+zhSpYeaU4N07uqmyBiksOLGXsMegymuecLobfz03h8Q==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.0.0-rc.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-rc.1.tgz", + "integrity": "sha512-9JDhHUf3WcLfnViFWm+TyorqUtnSAHaCzlSNmMOq824prVuuzDOK91K0Hl8DUcEb9M5x2O+d2/jmBMsetRIn3g==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.0.0-rc.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-rc.1.tgz", + "integrity": "sha512-UvApLEGholmxw/HIwmUnLq3CwdydbhaHHllvWiCTNbyGom7wTwOtz5OAQbAKZYyiEOeIXZNPkM7nA4Dtng7CLw==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.0.0-rc.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-rc.1.tgz", + "integrity": "sha512-uVctNgZHiGnJx5Fij7wHLhgw4uyZBVi6mykeWKOqE7bVy9Hcxn0fM/IuqdMwk6hXlaf9fFShDTFz2+YejP+x0A==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.0.0-rc.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-rc.1.tgz", + "integrity": "sha512-T6Eg0xWwcxd/MzBcuv4Z37YVbUbJxy5cMNnbIt/Yr99wFwli30O4BPlY8hKeGyn6lWNtU0QioBS46lVzDN38bg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.0.0-rc.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-rc.1.tgz", + "integrity": "sha512-PuGZVS2xNJyLADeh2F04b+Cz4NwvpglbtWACgrDOa5YDTEHKwmiTDjoD5eZ9/ptXtcpeFrMqD2H4Zn33KAh1Eg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi": { + "version": "1.0.0-rc.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-rc.1.tgz", + "integrity": "sha512-2mOxY562ihHlz9lEXuaGEIDCZ1vI+zyFdtsoa3M62xsEunDXQE+DVPO4S4x5MPK9tKulG/aFcA/IH5eVN257Cw==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@napi-rs/wasm-runtime": "^1.1.1" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.0.0-rc.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.1.tgz", + "integrity": "sha512-oQVOP5cfAWZwRD0Q3nGn/cA9FW3KhMMuQ0NIndALAe6obqjLhqYVYDiGGRGrxvnjJsVbpLwR14gIUYnpIcHR1g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.0.0-rc.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-rc.1.tgz", + "integrity": "sha512-Ydsxxx++FNOuov3wCBPaYjZrEvKOOGq3k+BF4BPridhg2pENfitSRD2TEuQ8i33bp5VptuNdC9IzxRKU031z5A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-rc.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.1.tgz", + "integrity": "sha512-UTBjtTxVOhodhzFVp/ayITaTETRHPUPYZPXQe0WU0wOgxghMojXxYjOiPOauKIYNWJAWS2fd7gJgGQK8GU8vDA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/pluginutils": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-5.3.0.tgz", + "integrity": "sha512-5EdhGZtnu3V88ces7s53hhfK5KSASnJZv8Lulpc04cWO3REESroJXg73DFsOmgbU2BhwV0E20bu2IDZb3VKW4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0", + "estree-walker": "^2.0.2", + "picomatch": "^4.0.2" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0" + }, + "peerDependenciesMeta": { + "rollup": { + "optional": true + } + } + }, + "node_modules/@sinclair/typebox": { + "version": "0.25.24", + "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.25.24.tgz", + "integrity": "sha512-XJfwUVUKDHF5ugKwIcxEgc9k8b7HbznCp6eUfWgu710hMPNIO4aw4/zB5RogDQz8nd6gyCDpU9O/m6qYEWY6yQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tootallnate/once": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-2.0.0.tgz", + "integrity": "sha512-XCuKFP5PS55gnMVu3dty8KPatLqUoy/ZYzDzAGCQ8JNFCkLXzmI7vNHCR+XpbZaMWQK/vQubr7PkYq8g470J/A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tootallnate/quickjs-emscripten": { + "version": "0.23.0", + "resolved": "https://registry.npmjs.org/@tootallnate/quickjs-emscripten/-/quickjs-emscripten-0.23.0.tgz", + "integrity": "sha512-C5Mc6rdnsaJDjO3UpGW/CQTHtCKaYlScZTly4JIu97Jxo/odCiH0ITnDXSJPTOrEKk/ycSZ0AOgTmkDtkOsvIA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@ts-morph/common": { + "version": "0.11.1", + "resolved": "https://registry.npmjs.org/@ts-morph/common/-/common-0.11.1.tgz", + "integrity": "sha512-7hWZS0NRpEsNV8vWJzg7FEz6V8MaLNeJOmwmghqUXTpzk16V1LLZhdo+4QvE/+zv4cVci0OviuJFnqhEfoV3+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-glob": "^3.2.7", + "minimatch": "^3.0.4", + "mkdirp": "^1.0.4", + "path-browserify": "^1.0.1" + } + }, + "node_modules/@ts-morph/common/node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.2", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.2.tgz", + "integrity": "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "20.11.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.11.0.tgz", + "integrity": "sha512-o9bjXmDNcF7GbM4CNQpmi+TutCgap/K3w1JyKgxAjqx41zp9qlIAVFi0IhCNsJcXolEqLWhbFbEeL0PvYm4pcQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~5.26.4" + } + }, + "node_modules/@vercel/backends": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/@vercel/backends/-/backends-0.8.1.tgz", + "integrity": "sha512-0av0e3NT7fIe4bRFYolMe4rfpORmURifHEuhkd8m3nHeOsekwWqFDBl8PdwwBx1soXg9L/kQe9K/519h26HLBA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@vercel/build-utils": "13.26.3", + "@vercel/nft": "1.5.0", + "execa": "3.2.0", + "fs-extra": "11.1.0", + "get-port": "5.1.1", + "oxc-transform": "0.111.0", + "path-to-regexp": "8.3.0", + "resolve.exports": "2.0.3", + "rolldown": "1.0.0-rc.1", + "srvx": "0.8.9", + "tsx": "4.21.0", + "zod": "3.22.4" + } + }, + "node_modules/@vercel/backends/node_modules/zod": { + "version": "3.22.4", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.22.4.tgz", + "integrity": "sha512-iC+8Io04lddc+mVqQ9AZ7OQ2MrUKGN+oIQyq1vemgt46jwCwLfhq7/pwnBnNXXXZb8VTVLKwp9EDkx+ryxIWmg==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/@vercel/blob": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/@vercel/blob/-/blob-2.4.0.tgz", + "integrity": "sha512-ncQ8CRb6XoEAYJwjOTRGpACRT6h/AeY+/33gLyeVxG5BIes27OPm1jmqreF+JHjcTmGhClTP+kBpmyLfbV0xew==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "async-retry": "^1.3.3", + "is-buffer": "^2.0.5", + "is-node-process": "^1.2.0", + "throttleit": "^2.1.0", + "undici": "^6.23.0" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@vercel/build-utils": { + "version": "13.26.3", + "resolved": "https://registry.npmjs.org/@vercel/build-utils/-/build-utils-13.26.3.tgz", + "integrity": "sha512-56N47yvDJCrwBNJg7Ty0d+52AvAVD5PFfaKF9iM7toJXTihkh1NHrRS2n06eopoNGAEVA8mTZE++lC5rk3pb8Q==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@vercel/python-analysis": "0.11.1", + "cjs-module-lexer": "1.2.3", + "es-module-lexer": "1.5.0" + } + }, + "node_modules/@vercel/cervel": { + "version": "0.1.9", + "resolved": "https://registry.npmjs.org/@vercel/cervel/-/cervel-0.1.9.tgz", + "integrity": "sha512-ZD/OgnpheuSR6g/YvzRjSpe7SE8lMMezCJf3is7SMs3M1Z3pgbvfNGOvNd36AyncTTSYOMgRZpv1pKA29E/aXg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@vercel/backends": "0.8.1" + }, + "bin": { + "cervel": "bin/cervel.mjs" + } + }, + "node_modules/@vercel/cli-config": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/@vercel/cli-config/-/cli-config-0.1.2.tgz", + "integrity": "sha512-XQOcuCM+8tKjh3sfgGRKRuNh78u2D8uGpDJIFcCtFi2tUqbGvqmJo790XX7+Bwakk08y0FCrs2JlEjvvwRhpAg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "xdg-app-paths": "5", + "zod": "4.1.11" + } + }, + "node_modules/@vercel/detect-agent": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@vercel/detect-agent/-/detect-agent-1.2.3.tgz", + "integrity": "sha512-VYNCgUc0nOmC4WJmWw9GkrKdfr8Zl4/rxhC5SvgacBgxiW9W/9NRttUoHHXV8xdII3MaRgkZZVX8Ikzc/Jmjag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=14" + } + }, + "node_modules/@vercel/elysia": { + "version": "0.1.82", + "resolved": "https://registry.npmjs.org/@vercel/elysia/-/elysia-0.1.82.tgz", + "integrity": "sha512-A1KiH4Ydswm8x+E4nN5wV7LYmNGnZ6q4TdtNEOb6q8wwVe3GEjwFTOQC7yqFScibRMvhUpFooBlNgGU7BtVJ6Q==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@vercel/node": "5.8.6", + "@vercel/static-config": "3.4.0" + } + }, + "node_modules/@vercel/error-utils": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@vercel/error-utils/-/error-utils-2.1.0.tgz", + "integrity": "sha512-DiJcXBOB9N6QM4d7hYPM9Ck/AUjzBl58XNQPxS74o7CuvIanjzrGgygP/70VsyEASeIJMazk1LrhwcNTR/eZGQ==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/@vercel/express": { + "version": "0.1.92", + "resolved": "https://registry.npmjs.org/@vercel/express/-/express-0.1.92.tgz", + "integrity": "sha512-ZTQqtiyt7IAL9e4QZgx1ZUa6ngRV97zfVNOriBvTXtcZzFVbrDxmTlTVQq3NDw54TYyyigpMb1VmsWUOxXPWJQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@vercel/cervel": "0.1.9", + "@vercel/nft": "1.5.0", + "@vercel/node": "5.8.6", + "@vercel/static-config": "3.4.0", + "fs-extra": "11.1.0", + "path-to-regexp": "8.3.0", + "ts-morph": "12.0.0", + "zod": "3.22.4" + } + }, + "node_modules/@vercel/express/node_modules/zod": { + "version": "3.22.4", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.22.4.tgz", + "integrity": "sha512-iC+8Io04lddc+mVqQ9AZ7OQ2MrUKGN+oIQyq1vemgt46jwCwLfhq7/pwnBnNXXXZb8VTVLKwp9EDkx+ryxIWmg==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/@vercel/fastify": { + "version": "0.1.85", + "resolved": "https://registry.npmjs.org/@vercel/fastify/-/fastify-0.1.85.tgz", + "integrity": "sha512-ifItSoqkijMrCZNsWtsu70LfreNExQoErw7ttoMY/bpwg0kKQaNf9xav7L0qZgGXQJ5lTbWUDeSkcxa4Zo0tpw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@vercel/node": "5.8.6", + "@vercel/static-config": "3.4.0" + } + }, + "node_modules/@vercel/fun": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@vercel/fun/-/fun-1.3.0.tgz", + "integrity": "sha512-8erw9uPe0dFg45THkNxmjtvMX143SkZebmjgSVbcM3XCkXu3RIiBaJMcMNG8aaS+rnTuw8+d4De9HVT0M/r3wg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@tootallnate/once": "2.0.0", + "async-listen": "1.2.0", + "debug": "4.3.4", + "generic-pool": "3.4.2", + "micro": "9.3.5-canary.3", + "ms": "2.1.1", + "node-fetch": "2.6.7", + "path-to-regexp": "8.2.0", + "promisepipe": "3.0.0", + "semver": "7.5.4", + "stat-mode": "0.3.0", + "stream-to-promise": "2.2.0", + "tar": "7.5.7", + "tinyexec": "0.3.2", + "tree-kill": "1.2.2", + "uid-promise": "1.0.0", + "xdg-app-paths": "5.1.0", + "yauzl-promise": "2.1.3" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/@vercel/fun/node_modules/path-to-regexp": { + "version": "8.2.0", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.2.0.tgz", + "integrity": "sha512-TdrF7fW9Rphjq4RjrW0Kp2AW0Ahwu9sRGTkS6bvDi0SCwZlEZYmcfDbEsTz8RVk0EHIS/Vd1bv3JhG+1xZuAyQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=16" + } + }, + "node_modules/@vercel/fun/node_modules/xdg-app-paths": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/xdg-app-paths/-/xdg-app-paths-5.1.0.tgz", + "integrity": "sha512-RAQ3WkPf4KTU1A8RtFx3gWywzVKe00tfOPFfl2NDGqbIFENQO4kqAJp7mhQjNj/33W5x5hiWWUdyfPq/5SU3QA==", + "dev": true, + "license": "MIT", + "dependencies": { + "xdg-portable": "^7.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/@vercel/gatsby-plugin-vercel-analytics": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/@vercel/gatsby-plugin-vercel-analytics/-/gatsby-plugin-vercel-analytics-1.0.11.tgz", + "integrity": "sha512-iTEA0vY6RBPuEzkwUTVzSHDATo1aF6bdLLspI68mQ/BTbi5UQEGjpjyzdKOVcSYApDtFU6M6vypZ1t4vIEnHvw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "web-vitals": "0.2.4" + } + }, + "node_modules/@vercel/gatsby-plugin-vercel-builder": { + "version": "2.2.9", + "resolved": "https://registry.npmjs.org/@vercel/gatsby-plugin-vercel-builder/-/gatsby-plugin-vercel-builder-2.2.9.tgz", + "integrity": "sha512-VJukNlVhzlSh/d1tZHBho8LFlO4oYHoGrNd/uUMhNDlqNAytAwpo07nc0CISFA12TFGjkoTs9hCykOsApjQs9g==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@sinclair/typebox": "0.25.24", + "@vercel/build-utils": "13.26.3", + "esbuild": "0.27.0", + "etag": "1.8.1", + "fs-extra": "11.1.0" + } + }, + "node_modules/@vercel/go": { + "version": "3.8.0", + "resolved": "https://registry.npmjs.org/@vercel/go/-/go-3.8.0.tgz", + "integrity": "sha512-ftQqQMn3sGdL8mdIqfcS3YZg6dazM/h4s0jkY37oVV1rPdh7Aq/GL0oMjv1L+PoIk5uJEAyBan7C8Yisp4LH+g==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/@vercel/h3": { + "version": "0.1.91", + "resolved": "https://registry.npmjs.org/@vercel/h3/-/h3-0.1.91.tgz", + "integrity": "sha512-R+QDhiaETBR2o1usGzjRgCLXMmQIcX2ndLGwpY1bXeznkueE2P+rWCUWnfEHxbKnjrciQLXN54z3zAjljlppxA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@vercel/node": "5.8.6", + "@vercel/static-config": "3.4.0" + } + }, + "node_modules/@vercel/hono": { + "version": "0.2.85", + "resolved": "https://registry.npmjs.org/@vercel/hono/-/hono-0.2.85.tgz", + "integrity": "sha512-zHKpWN3663cPLWAYq7U2lZWFY7vMtc16aAcwMMeUQoodoo4KUMclt8mPYN5fwvmPWwuZQOPI3FN1bYszgGDweQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@vercel/nft": "1.5.0", + "@vercel/node": "5.8.6", + "@vercel/static-config": "3.4.0", + "fs-extra": "11.1.0", + "path-to-regexp": "8.3.0", + "ts-morph": "12.0.0", + "zod": "3.22.4" + } + }, + "node_modules/@vercel/hono/node_modules/zod": { + "version": "3.22.4", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.22.4.tgz", + "integrity": "sha512-iC+8Io04lddc+mVqQ9AZ7OQ2MrUKGN+oIQyq1vemgt46jwCwLfhq7/pwnBnNXXXZb8VTVLKwp9EDkx+ryxIWmg==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/@vercel/hydrogen": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/@vercel/hydrogen/-/hydrogen-1.3.8.tgz", + "integrity": "sha512-ANCJg+FyZQpP2tntc9GUXQDGtOpQ/soykJGB1WBeCqn96QJFfSzRHHz1MHCh163MrKjO5Hx5cnCYUgRYRXVSjA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@vercel/static-config": "3.4.0", + "ts-morph": "12.0.0" + } + }, + "node_modules/@vercel/koa": { + "version": "0.1.65", + "resolved": "https://registry.npmjs.org/@vercel/koa/-/koa-0.1.65.tgz", + "integrity": "sha512-iPBWRtemNI0nZpUMtl0hoa9UReRo6FvqNd63r4eHF6uUZb6bDGNiQPSMQ/3hSzUc4ZbllIF36TlzxG/EtUc7UQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@vercel/node": "5.8.6", + "@vercel/static-config": "3.4.0" + } + }, + "node_modules/@vercel/nestjs": { + "version": "0.2.86", + "resolved": "https://registry.npmjs.org/@vercel/nestjs/-/nestjs-0.2.86.tgz", + "integrity": "sha512-9EwLUWemUAgBRjjRm5GQXjeiVS7qdH3Y1AMCQiDfBB0m0qp7fy2IsgNRgcpuZEzRB0ImcDvyVlnxPZJnQcwKDg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@vercel/node": "5.8.6", + "@vercel/static-config": "3.4.0" + } + }, + "node_modules/@vercel/next": { + "version": "4.17.4", + "resolved": "https://registry.npmjs.org/@vercel/next/-/next-4.17.4.tgz", + "integrity": "sha512-XsvV4pwphvrSgRTlpkSOiraST9ZrzEXRpEKABaR3cLnVf/2OvY4ZHb7uGDWX1ogNKadEZKSVgk5nKBueornANw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@vercel/nft": "1.5.0" + } + }, + "node_modules/@vercel/nft": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/@vercel/nft/-/nft-1.5.0.tgz", + "integrity": "sha512-IWTDeIoWhQ7ZtRO/JRKH+jhmeQvZYhtGPmzw/QGDY+wDCQqfm25P9yIdoAFagu4fWsK4IwZXDFIjrmp5rRm/sA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@mapbox/node-pre-gyp": "^2.0.0", + "@rollup/pluginutils": "^5.1.3", + "acorn": "^8.6.0", + "acorn-import-attributes": "^1.9.5", + "async-sema": "^3.1.1", + "bindings": "^1.4.0", + "estree-walker": "2.0.2", + "glob": "^13.0.0", + "graceful-fs": "^4.2.9", + "node-gyp-build": "^4.2.2", + "picomatch": "^4.0.2", + "resolve-from": "^5.0.0" + }, + "bin": { + "nft": "out/cli.js" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@vercel/node": { + "version": "5.8.6", + "resolved": "https://registry.npmjs.org/@vercel/node/-/node-5.8.6.tgz", + "integrity": "sha512-bZ1XZADW7nTK6foB0PMk3u82RWwwl/GBTeMVUSCUhy6CWYak7URJD9oUeXhaX7y6lMNZX6kfJ5SBH6FeRxOdNQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@edge-runtime/node-utils": "2.3.0", + "@edge-runtime/primitives": "4.1.0", + "@edge-runtime/vm": "3.2.0", + "@types/node": "20.11.0", + "@vercel/build-utils": "13.26.3", + "@vercel/error-utils": "2.1.0", + "@vercel/nft": "1.5.0", + "@vercel/static-config": "3.4.0", + "async-listen": "3.0.0", + "cjs-module-lexer": "1.2.3", + "edge-runtime": "2.5.9", + "es-module-lexer": "1.4.1", + "esbuild": "0.27.0", + "etag": "1.8.1", + "mime-types": "2.1.35", + "node-fetch": "2.6.9", + "path-to-regexp": "6.1.0", + "path-to-regexp-updated": "npm:path-to-regexp@6.3.0", + "ts-morph": "12.0.0", + "tsx": "4.21.0", + "typescript": "npm:typescript@5.9.3", + "undici": "5.28.4" + } + }, + "node_modules/@vercel/node/node_modules/async-listen": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/async-listen/-/async-listen-3.0.0.tgz", + "integrity": "sha512-V+SsTpDqkrWTimiotsyl33ePSjA5/KrithwupuvJ6ztsqPvGv6ge4OredFhPffVXiLN/QUWvE0XcqJaYgt6fOg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/@vercel/node/node_modules/es-module-lexer": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.4.1.tgz", + "integrity": "sha512-cXLGjP0c4T3flZJKQSuziYoq7MlT+rnvfZjfp7h+I7K9BNX54kP9nyWvdbwjQ4u1iWbOL4u96fgeZLToQlZC7w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@vercel/node/node_modules/node-fetch": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.6.9.tgz", + "integrity": "sha512-DJm/CJkZkRjKKj4Zi4BsKVZh3ValV5IR5s7LVZnW+6YMh0W1BfNA8XSs6DLMGYlId5F3KnA70uu2qepcR08Qqg==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-url": "^5.0.0" + }, + "engines": { + "node": "4.x || >=6.0.0" + }, + "peerDependencies": { + "encoding": "^0.1.0" + }, + "peerDependenciesMeta": { + "encoding": { + "optional": true + } + } + }, + "node_modules/@vercel/node/node_modules/path-to-regexp": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-6.1.0.tgz", + "integrity": "sha512-h9DqehX3zZZDCEm+xbfU0ZmwCGFCAAraPJWMXJ4+v32NjZJilVg3k1TcKsRgIb8IQ/izZSaydDc1OhJCZvs2Dw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@vercel/node/node_modules/undici": { + "version": "5.28.4", + "resolved": "https://registry.npmjs.org/undici/-/undici-5.28.4.tgz", + "integrity": "sha512-72RFADWFqKmUb2hmmvNODKL3p9hcB6Gt2DOQMis1SEBaV6a4MH8soBvzg+95CYhCKPFedut2JY9bMfrDl9D23g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@fastify/busboy": "^2.0.0" + }, + "engines": { + "node": ">=14.0" + } + }, + "node_modules/@vercel/oidc": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@vercel/oidc/-/oidc-3.2.0.tgz", + "integrity": "sha512-UycprH3T6n3jH0k44NHMa7pnFHGu/N05MjojYr+Mc6I7obkoLIJujSWwin1pCvdy/eOxrI/l3uDLQsmcrOb4ug==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">= 20" + } + }, + "node_modules/@vercel/prepare-flags-definitions": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/@vercel/prepare-flags-definitions/-/prepare-flags-definitions-0.2.1.tgz", + "integrity": "sha512-ouXTsqn7I9xZ1KKezgvn/w3tZeQHL/tc52j9GHiOYi6kT8xgdbT8s2x8C9BQr44iceX0hfhtZwk9q7NuI2Tqbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@vercel/python": { + "version": "6.43.3", + "resolved": "https://registry.npmjs.org/@vercel/python/-/python-6.43.3.tgz", + "integrity": "sha512-JB4ldGumk/DvRx6m41llO+jYtGlrve6Qc59YyKOnU++f42VYKA74jYEadPknrqhelmCCFaQQJF/7BD97PuWr6A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@vercel/python-analysis": "0.11.1" + } + }, + "node_modules/@vercel/python-analysis": { + "version": "0.11.1", + "resolved": "https://registry.npmjs.org/@vercel/python-analysis/-/python-analysis-0.11.1.tgz", + "integrity": "sha512-EPPLuXJQhIDUx08H9nG76AR2HSgBquwe3OAX5s2w20M923iaWeGGVkhX/4yZ89CJfXEZgE1Aj/mX7lVHOVIcYA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@bytecodealliance/preview2-shim": "0.17.6", + "@renovatebot/pep440": "4.2.1", + "fs-extra": "11.1.1", + "js-yaml": "4.1.1", + "minimatch": "10.1.1", + "smol-toml": "1.5.2", + "zod": "3.22.4" + } + }, + "node_modules/@vercel/python-analysis/node_modules/fs-extra": { + "version": "11.1.1", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.1.1.tgz", + "integrity": "sha512-MGIE4HOvQCeUCzmlHs0vXpih4ysz4wg9qiSAu6cd42lVwPbTM1TjV7RusoyQqMmk/95gdQZX72u+YW+c3eEpFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=14.14" + } + }, + "node_modules/@vercel/python-analysis/node_modules/zod": { + "version": "3.22.4", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.22.4.tgz", + "integrity": "sha512-iC+8Io04lddc+mVqQ9AZ7OQ2MrUKGN+oIQyq1vemgt46jwCwLfhq7/pwnBnNXXXZb8VTVLKwp9EDkx+ryxIWmg==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/@vercel/redwood": { + "version": "2.4.14", + "resolved": "https://registry.npmjs.org/@vercel/redwood/-/redwood-2.4.14.tgz", + "integrity": "sha512-LSM8rN8hMU98ZFmL4X3ckIuB6k+X6L6HaXRITIdxti83YTUOkZUOoO7iB9mthez5rgYYF6vPlANOG7OrnKhTKw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@vercel/nft": "1.5.0", + "@vercel/static-config": "3.4.0", + "semver": "6.3.1", + "ts-morph": "12.0.0" + } + }, + "node_modules/@vercel/redwood/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/@vercel/remix-builder": { + "version": "5.8.3", + "resolved": "https://registry.npmjs.org/@vercel/remix-builder/-/remix-builder-5.8.3.tgz", + "integrity": "sha512-0Ke3Pk7SSSf/RLvMdWwhrNeEzIKw1luJi2OeTFdAVNjnrRUcTpBkFG6MaUeRQ9o0RcifSIKJCLvZp+w7bm3oQg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@vercel/error-utils": "2.1.0", + "@vercel/nft": "1.5.0", + "@vercel/static-config": "3.4.0", + "path-to-regexp": "6.1.0", + "path-to-regexp-updated": "npm:path-to-regexp@6.3.0", + "ts-morph": "12.0.0" + } + }, + "node_modules/@vercel/remix-builder/node_modules/path-to-regexp": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-6.1.0.tgz", + "integrity": "sha512-h9DqehX3zZZDCEm+xbfU0ZmwCGFCAAraPJWMXJ4+v32NjZJilVg3k1TcKsRgIb8IQ/izZSaydDc1OhJCZvs2Dw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@vercel/ruby": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/@vercel/ruby/-/ruby-2.4.0.tgz", + "integrity": "sha512-YI7Amyf09hHZWOqDHgJO92XcKh6Pye8rrmJFhlP6euG3o6QjoZzJj7Z2WzjSrDRGMewzEK4uz2+CbNpNS7gLog==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/@vercel/rust": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@vercel/rust/-/rust-1.3.0.tgz", + "integrity": "sha512-z1X0z1NM+ISunm/scgRpuENNwcKlh7DjXn0QvKE0n10DcaYqxkBQVK8iRA9X05xSK9AzPlnB9DHdGKiXZO5buw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "execa": "5", + "smol-toml": "1.5.2" + } + }, + "node_modules/@vercel/rust/node_modules/execa": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/execa/-/execa-5.1.1.tgz", + "integrity": "sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cross-spawn": "^7.0.3", + "get-stream": "^6.0.0", + "human-signals": "^2.1.0", + "is-stream": "^2.0.0", + "merge-stream": "^2.0.0", + "npm-run-path": "^4.0.1", + "onetime": "^5.1.2", + "signal-exit": "^3.0.3", + "strip-final-newline": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sindresorhus/execa?sponsor=1" + } + }, + "node_modules/@vercel/rust/node_modules/get-stream": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-6.0.1.tgz", + "integrity": "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@vercel/rust/node_modules/human-signals": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-2.1.0.tgz", + "integrity": "sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=10.17.0" + } + }, + "node_modules/@vercel/rust/node_modules/signal-exit": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/@vercel/sandbox": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/@vercel/sandbox/-/sandbox-1.9.0.tgz", + "integrity": "sha512-zgr1ad0tkT1xZn/8Vxo60wOUOLqMAVGo4WqJQ8/UDcUtWynNJsBjI2tiMdWZrAo9EKH1MIqEzJNkcclF0UT1EQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@vercel/oidc": "3.2.0", + "async-retry": "1.3.3", + "jsonlines": "0.1.1", + "ms": "2.1.3", + "picocolors": "^1.1.1", + "tar-stream": "3.1.7", + "undici": "^7.16.0", + "xdg-app-paths": "5.1.0", + "zod": "3.24.4" + } + }, + "node_modules/@vercel/sandbox/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@vercel/sandbox/node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/@vercel/sandbox/node_modules/undici": { + "version": "7.26.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.26.0.tgz", + "integrity": "sha512-3O9Tf67pGhgOv9jM35AbhkXAKi13f3oy3aE4CSgr+TckGeY+/iu97ZXN+J7DpHPzLbVApFd1IFhcnBjREYXYcg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.18.1" + } + }, + "node_modules/@vercel/sandbox/node_modules/xdg-app-paths": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/xdg-app-paths/-/xdg-app-paths-5.1.0.tgz", + "integrity": "sha512-RAQ3WkPf4KTU1A8RtFx3gWywzVKe00tfOPFfl2NDGqbIFENQO4kqAJp7mhQjNj/33W5x5hiWWUdyfPq/5SU3QA==", + "dev": true, + "license": "MIT", + "dependencies": { + "xdg-portable": "^7.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/@vercel/sandbox/node_modules/zod": { + "version": "3.24.4", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.24.4.tgz", + "integrity": "sha512-OdqJE9UDRPwWsrHjLN2F8bPxvwJBK22EHLWtanu0LSYr5YqzsaaW3RMgmjwr8Rypg5k+meEJdSPXJZXE/yqOMg==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/@vercel/static-build": { + "version": "2.9.32", + "resolved": "https://registry.npmjs.org/@vercel/static-build/-/static-build-2.9.32.tgz", + "integrity": "sha512-2gd6m1zwHKcwR+gXY67E+SI4CtTcvYV1R+wa6MymADnL0ksE49JG4BivCmu2n6oIGs64QjDimVpYDK2hoJlSBw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@vercel/gatsby-plugin-vercel-analytics": "1.0.11", + "@vercel/gatsby-plugin-vercel-builder": "2.2.9", + "@vercel/static-config": "3.4.0", + "ts-morph": "12.0.0" + } + }, + "node_modules/@vercel/static-config": { + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/@vercel/static-config/-/static-config-3.4.0.tgz", + "integrity": "sha512-wCq90CMUB//ggnFh77NQO1xaLFsS4LigQIqKrH6ohnr9Br/KI1FhlErx62WfCOuueWaW+LVsbLOqNXIUjK8t6A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "ajv": "8.6.3", + "json-schema-to-ts": "1.6.4", + "ts-morph": "12.0.0" + } + }, + "node_modules/abbrev": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-3.0.1.tgz", + "integrity": "sha512-AO2ac6pjRB3SJmGJo+v5/aK6Omggp6fsLrs6wN9bd35ulu4cCwaAU9+7ZhXjeqHVkaHThLuzH0nZr0YpCDhygg==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/acorn": { + "version": "8.16.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", + "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-import-attributes": { + "version": "1.9.5", + "resolved": "https://registry.npmjs.org/acorn-import-attributes/-/acorn-import-attributes-1.9.5.tgz", + "integrity": "sha512-n02Vykv5uA3eHGM/Z2dQrcD56kL8TyDb2p1+0P83PClMnC/nc+anbQRhIOWnSq4Ke/KvDPrY3C9hDtC/A3eHnQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^8" + } + }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/ajv": { + "version": "8.6.3", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.6.3.tgz", + "integrity": "sha512-SMJOdDP6LqTkD0Uq8qLi+gMwSt0imXLSV080qFVwJCpH9U6Mb+SUGHAXM0KNbcBPguytWyvFxcHgMLe2D2XSpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/any-promise": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz", + "integrity": "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==", + "dev": true, + "license": "MIT" + }, + "node_modules/arg": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/arg/-/arg-4.1.0.tgz", + "integrity": "sha512-ZWc51jO3qegGkVh8Hwpv636EkbesNV5ZNQPCtRa+0qytRYPEs9IYT9qITY9buezqUH5uqyzlWLcufrzU2rffdg==", + "dev": true, + "license": "MIT" + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true, + "license": "Python-2.0" + }, + "node_modules/ast-types": { + "version": "0.13.4", + "resolved": "https://registry.npmjs.org/ast-types/-/ast-types-0.13.4.tgz", + "integrity": "sha512-x1FCFnFifvYDDzTaLII71vG5uvDwgtmDTEVWAxrgeiR8VjMONcCXJx7E+USjDtHlwFmt9MysbqgF9b9Vjr6w+w==", + "dev": true, + "license": "MIT", + "dependencies": { + "tslib": "^2.0.1" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/async-listen": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/async-listen/-/async-listen-1.2.0.tgz", + "integrity": "sha512-CcEtRh/oc9Jc4uWeUwdpG/+Mb2YUHKmdaTf0gUr7Wa+bfp4xx70HOb3RuSTJMvqKNB1TkdTfjLdrcz2X4rkkZA==", + "dev": true, + "license": "MIT" + }, + "node_modules/async-retry": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/async-retry/-/async-retry-1.3.3.tgz", + "integrity": "sha512-wfr/jstw9xNi/0teMHrRW7dsz3Lt5ARhYNZ2ewpadnhaIp5mbALhOAP+EAdsC7t4Z6wqsDVv9+W6gm1Dk9mEyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "retry": "0.13.1" + } + }, + "node_modules/async-sema": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/async-sema/-/async-sema-3.1.1.tgz", + "integrity": "sha512-tLRNUXati5MFePdAk8dw7Qt7DpxPB60ofAgn8WRhW6a2rcimZnYBP9oxHiv0OHy+Wz7kPMG+t4LGdt31+4EmGg==", + "dev": true, + "license": "MIT" + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/b4a": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/b4a/-/b4a-1.8.1.tgz", + "integrity": "sha512-aiqre1Nr0B/6DgE2N5vwTc+2/oQZ4Wh1t4NznYY4E00y8LCt6NqdRv81so00oo27D8MVKTpUa/MwUUtBLXCoDw==", + "dev": true, + "license": "Apache-2.0", + "peerDependencies": { + "react-native-b4a": "*" + }, + "peerDependenciesMeta": { + "react-native-b4a": { + "optional": true + } + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/bare-events": { + "version": "2.8.3", + "resolved": "https://registry.npmjs.org/bare-events/-/bare-events-2.8.3.tgz", + "integrity": "sha512-HdUm8EMQBLaJvGUdidNNbqpA1kYkwNcb+MYxkxCLAPJGQzlv9J0C24h8V65Z4c5GLd/JEALDvpFCQgpLJqc0zw==", + "dev": true, + "license": "Apache-2.0", + "peerDependencies": { + "bare-abort-controller": "*" + }, + "peerDependenciesMeta": { + "bare-abort-controller": { + "optional": true + } + } + }, + "node_modules/basic-ftp": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/basic-ftp/-/basic-ftp-5.3.1.tgz", + "integrity": "sha512-bopVNp6ugyA150DDuZfPFdt1KZ5a94ZDiwX4hMgZDzF+GttD80lEy8kj98kbyhLXnPvhtIo93mdnLIjpCAeeOw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/bindings": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz", + "integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "file-uri-to-path": "1.0.0" + } + }, + "node_modules/brace-expansion": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.15.tgz", + "integrity": "sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/buffer-crc32": { + "version": "0.2.13", + "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-0.2.13.tgz", + "integrity": "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/bytes": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.0.tgz", + "integrity": "sha512-zauLjrfCG+xvoyaqLoV8bLVXXNGC4JqlxFCutSDWA6fJrTo2ZuvLYTqZ7aHBLZSMOopbzwv8f+wZcVzfVTI2Dg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/chokidar": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.0.tgz", + "integrity": "sha512-mxIojEAQcuEvT/lyXq+jf/3cO/KoA6z4CeNDGGevTybECPOMFCnQy3OPahluUkbqgPNGw5Bi78UC7Po6Lhy+NA==", + "dev": true, + "license": "MIT", + "dependencies": { + "readdirp": "^4.0.1" + }, + "engines": { + "node": ">= 14.16.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/chownr": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-3.0.0.tgz", + "integrity": "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/cjs-module-lexer": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/cjs-module-lexer/-/cjs-module-lexer-1.2.3.tgz", + "integrity": "sha512-0TNiGstbQmCFwt4akjjBg5pLRTSyj/PkWQ1ZoO2zntmg9yLqSRxwEa4iCfQLGjqhiqBfOJa7W/E8wfGrTDmlZQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/code-block-writer": { + "version": "10.1.1", + "resolved": "https://registry.npmjs.org/code-block-writer/-/code-block-writer-10.1.1.tgz", + "integrity": "sha512-67ueh2IRGst/51p0n6FvPrnRjAGHY5F8xdjkgrYE7DDzpJe6qA07RYQ9VcoUeo5ATOjSOiWpSL3SWBRRbempMw==", + "dev": true, + "license": "MIT" + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "dev": true, + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true, + "license": "MIT" + }, + "node_modules/consola": { + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/consola/-/consola-3.4.2.tgz", + "integrity": "sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^14.18.0 || >=16.10.0" + } + }, + "node_modules/content-type": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.4.tgz", + "integrity": "sha512-hIP3EEPs8tB9AT1L+NUqtwOAps4mk2Zob89MWXMHjHWg9milF/j4osnnQLXBCBFBk/tvIG/tUc9mOUJiPBhPXA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/convert-hrtime": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/convert-hrtime/-/convert-hrtime-3.0.0.tgz", + "integrity": "sha512-7V+KqSvMiHp8yWDuwfww06XleMWVVB9b9tURBx+G7UTADuo5hYPuowKloz4OzOqbPezxgo+fdQ1522WzPG4OeA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/cookie-es": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/cookie-es/-/cookie-es-2.0.1.tgz", + "integrity": "sha512-aVf4A4hI2w70LnF7GG+7xDQUkliwiXWXFvTjkip4+b64ygDQ2sJPRSKFDHbxn8o0xu9QzPkMuuiWIXyFSE2slA==", + "dev": true, + "license": "MIT" + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/data-uri-to-buffer": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-6.0.2.tgz", + "integrity": "sha512-7hvf7/GW8e86rW0ptuwS3OcBGDjIi6SZva7hCyWC0yYry2cOPmLIjXAUHI6DK2HsnwJd9ifmt57i8eV2n4YNpw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/debug": { + "version": "4.3.4", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", + "integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "2.1.2" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/debug/node_modules/ms": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", + "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==", + "dev": true, + "license": "MIT" + }, + "node_modules/degenerator": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/degenerator/-/degenerator-5.0.1.tgz", + "integrity": "sha512-TllpMR/t0M5sqCXfj85i4XaAzxmS5tVA16dqvdkMwGmzI+dXLXnw3J+3Vdv7VKw+ThlTMboK6i9rnZ6Nntj5CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ast-types": "^0.13.4", + "escodegen": "^2.1.0", + "esprima": "^4.0.1" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/depd": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/depd/-/depd-1.1.2.tgz", + "integrity": "sha512-7emPTl6Dpo6JRXOXjLRxck+FlLRX5847cLKEn00PLAgc3g2hTZZgr+e4c2v6QpSmLeFP3n5yUo7ft6avBK/5jQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/edge-runtime": { + "version": "2.5.9", + "resolved": "https://registry.npmjs.org/edge-runtime/-/edge-runtime-2.5.9.tgz", + "integrity": "sha512-pk+k0oK0PVXdlT4oRp4lwh+unuKB7Ng4iZ2HB+EZ7QCEQizX360Rp/F4aRpgpRgdP2ufB35N+1KppHmYjqIGSg==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "@edge-runtime/format": "2.2.1", + "@edge-runtime/ponyfill": "2.4.2", + "@edge-runtime/vm": "3.2.0", + "async-listen": "3.0.1", + "mri": "1.2.0", + "picocolors": "1.0.0", + "pretty-ms": "7.0.1", + "signal-exit": "4.0.2", + "time-span": "4.0.0" + }, + "bin": { + "edge-runtime": "dist/cli/index.js" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/edge-runtime/node_modules/async-listen": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/async-listen/-/async-listen-3.0.1.tgz", + "integrity": "sha512-cWMaNwUJnf37C/S5TfCkk/15MwbPRwVYALA2jtjkbHjCmAPiDXyNJy2q3p1KAZzDLHAWyarUWSujUoHR4pEgrA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/end-of-stream": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", + "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", + "dev": true, + "license": "MIT", + "dependencies": { + "once": "^1.4.0" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-module-lexer": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.5.0.tgz", + "integrity": "sha512-pqrTKmwEIgafsYZAGw9kszYzmagcE/n4dbgwGWLEXg7J4QFJVQRBld8j3Q3GNez79jzxZshq0bcT962QHOghjw==", + "dev": true, + "license": "MIT" + }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/esbuild": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.0.tgz", + "integrity": "sha512-jd0f4NHbD6cALCyGElNpGAOtWxSq46l9X/sWB0Nzd5er4Kz2YTm+Vl0qKFT9KUJvD8+fiO8AvoHhFvEatfVixA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.27.0", + "@esbuild/android-arm": "0.27.0", + "@esbuild/android-arm64": "0.27.0", + "@esbuild/android-x64": "0.27.0", + "@esbuild/darwin-arm64": "0.27.0", + "@esbuild/darwin-x64": "0.27.0", + "@esbuild/freebsd-arm64": "0.27.0", + "@esbuild/freebsd-x64": "0.27.0", + "@esbuild/linux-arm": "0.27.0", + "@esbuild/linux-arm64": "0.27.0", + "@esbuild/linux-ia32": "0.27.0", + "@esbuild/linux-loong64": "0.27.0", + "@esbuild/linux-mips64el": "0.27.0", + "@esbuild/linux-ppc64": "0.27.0", + "@esbuild/linux-riscv64": "0.27.0", + "@esbuild/linux-s390x": "0.27.0", + "@esbuild/linux-x64": "0.27.0", + "@esbuild/netbsd-arm64": "0.27.0", + "@esbuild/netbsd-x64": "0.27.0", + "@esbuild/openbsd-arm64": "0.27.0", + "@esbuild/openbsd-x64": "0.27.0", + "@esbuild/openharmony-arm64": "0.27.0", + "@esbuild/sunos-x64": "0.27.0", + "@esbuild/win32-arm64": "0.27.0", + "@esbuild/win32-ia32": "0.27.0", + "@esbuild/win32-x64": "0.27.0" + } + }, + "node_modules/escodegen": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/escodegen/-/escodegen-2.1.0.tgz", + "integrity": "sha512-2NlIDTwUWJN0mRPQOdtQBzbUHvdGY2P1VXSyU83Q3xKxM7WHX2Ql8dKq782Q9TgQUNOLEzEYu9bzLNj1q88I5w==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "esprima": "^4.0.1", + "estraverse": "^5.2.0", + "esutils": "^2.0.2" + }, + "bin": { + "escodegen": "bin/escodegen.js", + "esgenerate": "bin/esgenerate.js" + }, + "engines": { + "node": ">=6.0" + }, + "optionalDependencies": { + "source-map": "~0.6.1" + } + }, + "node_modules/esprima": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", + "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", + "dev": true, + "license": "BSD-2-Clause", + "bin": { + "esparse": "bin/esparse.js", + "esvalidate": "bin/esvalidate.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estree-walker": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", + "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", + "dev": true, + "license": "MIT" + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/events-intercept": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/events-intercept/-/events-intercept-2.0.0.tgz", + "integrity": "sha512-blk1va0zol9QOrdZt0rFXo5KMkNPVSp92Eju/Qz8THwKWKRKeE0T8Br/1aW6+Edkyq9xHYgYxn2QtOnUKPUp+Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/events-universal": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/events-universal/-/events-universal-1.0.1.tgz", + "integrity": "sha512-LUd5euvbMLpwOF8m6ivPCbhQeSiYVNb8Vs0fQ8QjXo0JTkEHpz8pxdQf0gStltaPpw0Cca8b39KxvK9cfKRiAw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "bare-events": "^2.7.0" + } + }, + "node_modules/execa": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/execa/-/execa-3.2.0.tgz", + "integrity": "sha512-kJJfVbI/lZE1PZYDI5VPxp8zXPO9rtxOkhpZ0jMKha56AI9y2gGVC6bkukStQf0ka5Rh15BA5m7cCCH4jmHqkw==", + "dev": true, + "license": "MIT", + "dependencies": { + "cross-spawn": "^7.0.0", + "get-stream": "^5.0.0", + "human-signals": "^1.1.1", + "is-stream": "^2.0.0", + "merge-stream": "^2.0.0", + "npm-run-path": "^4.0.0", + "onetime": "^5.1.0", + "p-finally": "^2.0.0", + "signal-exit": "^3.0.2", + "strip-final-newline": "^2.0.0" + }, + "engines": { + "node": "^8.12.0 || >=9.7.0" + } + }, + "node_modules/execa/node_modules/signal-exit": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-fifo": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/fast-fifo/-/fast-fifo-1.3.2.tgz", + "integrity": "sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-glob": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", + "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.8" + }, + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/fastq": { + "version": "1.20.1", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", + "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==", + "dev": true, + "license": "ISC", + "dependencies": { + "reusify": "^1.0.4" + } + }, + "node_modules/fd-slicer": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/fd-slicer/-/fd-slicer-1.1.0.tgz", + "integrity": "sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "pend": "~1.2.0" + } + }, + "node_modules/file-uri-to-path": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz", + "integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/form-data": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", + "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", + "dev": true, + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fs-extra": { + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.1.0.tgz", + "integrity": "sha512-0rcTq621PD5jM/e0a3EJoGC/1TC5ZBCERW82LQuwfGnCa1V8w7dpYH1yNu+SLb6E5dkeCBzKEyLGlFrnr+dUyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=14.14" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/generic-pool": { + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/generic-pool/-/generic-pool-3.4.2.tgz", + "integrity": "sha512-H7cUpwCQSiJmAHM4c/aFu6fUfrhWXW1ncyh8ftxEPMu6AiYkHw9K8br720TGPZJbk5eOH2bynjZD1yPvdDAmag==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-port": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/get-port/-/get-port-5.1.1.tgz", + "integrity": "sha512-g/Q1aTSDOxFpchXC4i8ZWvxA1lnPqx/JHqcpIw0/LX9T8x/GBbi6YnlN5nhaKIFkT8oFsscUKgDJYxfwfS6QsQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/get-stream": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-5.2.0.tgz", + "integrity": "sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA==", + "dev": true, + "license": "MIT", + "dependencies": { + "pump": "^3.0.0" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/get-tsconfig": { + "version": "4.14.0", + "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.14.0.tgz", + "integrity": "sha512-yTb+8DXzDREzgvYmh6s9vHsSVCHeC0G3PI5bEXNBHtmshPnO+S5O7qgLEOn0I5QvMy6kpZN8K1NKGyilLb93wA==", + "dev": true, + "license": "MIT", + "dependencies": { + "resolve-pkg-maps": "^1.0.0" + }, + "funding": { + "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" + } + }, + "node_modules/get-uri": { + "version": "6.0.5", + "resolved": "https://registry.npmjs.org/get-uri/-/get-uri-6.0.5.tgz", + "integrity": "sha512-b1O07XYq8eRuVzBNgJLstU6FYc1tS6wnMtF1I1D9lE8LxZSOGZ7LhxN54yPP6mGw5f2CkXY2BQUL9Fx41qvcIg==", + "dev": true, + "license": "MIT", + "dependencies": { + "basic-ftp": "^5.0.2", + "data-uri-to-buffer": "^6.0.2", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/glob": { + "version": "13.0.6", + "resolved": "https://registry.npmjs.org/glob/-/glob-13.0.6.tgz", + "integrity": "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "minimatch": "^10.2.2", + "minipass": "^7.1.3", + "path-scurry": "^2.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/glob/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/glob/node_modules/brace-expansion": { + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz", + "integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/glob/node_modules/minimatch": { + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.5" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.3.tgz", + "integrity": "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/http-errors": { + "version": "1.7.3", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-1.7.3.tgz", + "integrity": "sha512-ZTTX0MWrsQ2ZAhA1cejAwDLycFsd7I7nVtnkT3Ol0aqodaKW+0CTZDQ1uBv5whptCnc8e8HeRRJxRs0kmm/Qfw==", + "dev": true, + "license": "MIT", + "dependencies": { + "depd": "~1.1.2", + "inherits": "2.0.4", + "setprototypeof": "1.1.1", + "statuses": ">= 1.5.0 < 2", + "toidentifier": "1.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/http-proxy-agent": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/human-signals": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-1.1.1.tgz", + "integrity": "sha512-SEQu7vl8KjNL2eoGBLF3+wAjpsNfA9XMlXAYj/3EdaNfAlxKthD1xjEQfGOUhllCGGJVNY34bRr6lPINhNjyZw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8.12.0" + } + }, + "node_modules/iconv-lite": { + "version": "0.4.24", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", + "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "dev": true, + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/ip-address": { + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz", + "integrity": "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, + "node_modules/is-buffer": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/is-buffer/-/is-buffer-2.0.5.tgz", + "integrity": "sha512-i2R6zNFDwgEHJyQUtJEk0XFi1i0dPFn/oqjK3/vPCcDeJvW5NQ83V8QbicfF1SupOaB0h8ntgBC2YiE7dfyctQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-node-process": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/is-node-process/-/is-node-process-1.2.0.tgz", + "integrity": "sha512-Vg4o6/fqPxIjtxgUH5QLJhwZ7gW5diGCVlXpuUfELC62CuxM1iHcRe51f2W1FDy04Ai4KJkagKjx3XaqyfRKXw==", + "dev": true, + "license": "MIT" + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/is-stream": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", + "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/jose": { + "version": "5.9.6", + "resolved": "https://registry.npmjs.org/jose/-/jose-5.9.6.tgz", + "integrity": "sha512-AMlnetc9+CV9asI19zHmrgS/WYsWUwCn2R7RzlbJWD7F9eWYUTGyBmU9o6PxngtLGOiDGPRu+Uc4fhKzbpteZQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, + "node_modules/js-yaml": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", + "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", + "dev": true, + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/json-schema-to-ts": { + "version": "1.6.4", + "resolved": "https://registry.npmjs.org/json-schema-to-ts/-/json-schema-to-ts-1.6.4.tgz", + "integrity": "sha512-pR4yQ9DHz6itqswtHCm26mw45FSNfQ9rEQjosaZErhn5J3J2sIViQiz8rDaezjKAhFGpmsoczYVBgGHzFw/stA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/json-schema": "^7.0.6", + "ts-toolbelt": "^6.15.5" + } + }, + "node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "dev": true, + "license": "MIT" + }, + "node_modules/jsonfile": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.1.tgz", + "integrity": "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/jsonlines": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/jsonlines/-/jsonlines-0.1.1.tgz", + "integrity": "sha512-ekDrAGso79Cvf+dtm+mL8OBI2bmAOt3gssYs833De/C9NmIpWDWyUO4zPgB5x2/OhY366dkhgfPMYfwZF7yOZA==", + "dev": true, + "license": "MIT" + }, + "node_modules/lru-cache": { + "version": "11.5.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.1.tgz", + "integrity": "sha512-RPimw/7aMdv2oqRrxKwvZXcPfwBrn/JZ2xYcY9Hus/6LaS3VOAKVWKWgNLCFSiOm1ESXinjsDlidVU7JlnCN2A==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/luxon": { + "version": "3.7.2", + "resolved": "https://registry.npmjs.org/luxon/-/luxon-3.7.2.tgz", + "integrity": "sha512-vtEhXh/gNjI9Yg1u4jX/0YVPMvxzHuGgCm6tC5kZyb08yjGWGnqAjGJvcXbqQR2P3MyMEFnRbpcdFS6PBcLqew==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/merge-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", + "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==", + "dev": true, + "license": "MIT" + }, + "node_modules/merge2": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/micro": { + "version": "9.3.5-canary.3", + "resolved": "https://registry.npmjs.org/micro/-/micro-9.3.5-canary.3.tgz", + "integrity": "sha512-viYIo9PefV+w9dvoIBh1gI44Mvx1BOk67B4BpC2QK77qdY0xZF0Q+vWLt/BII6cLkIc8rLmSIcJaB/OrXXKe1g==", + "dev": true, + "license": "MIT", + "dependencies": { + "arg": "4.1.0", + "content-type": "1.0.4", + "raw-body": "2.4.1" + }, + "bin": { + "micro": "bin/micro.js" + }, + "engines": { + "node": ">= 8.0.0" + } + }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/micromatch/node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "dev": true, + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mimic-fn": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", + "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/minimatch": { + "version": "10.1.1", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.1.1.tgz", + "integrity": "sha512-enIvLvRAFZYXJzkCYG5RKmPfrFArdLv+R+lbQ53BmIMLIry74bjKzX6iHAm8WYamJkhSSEabrWN5D97XnKObjQ==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/brace-expansion": "^5.0.0" + }, + "engines": { + "node": "20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/minipass": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/minizlib": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-3.1.0.tgz", + "integrity": "sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "minipass": "^7.1.2" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/mkdirp": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz", + "integrity": "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==", + "dev": true, + "license": "MIT", + "bin": { + "mkdirp": "bin/cmd.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/mri": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/mri/-/mri-1.2.0.tgz", + "integrity": "sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/ms": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.1.tgz", + "integrity": "sha512-tgp+dl5cGk28utYktBsrFqA7HKgrhgPsg6Z/EfhWI4gl1Hwq8B/GmY/0oXZ6nF8hDVesS/FpnYaD/kOWhYQvyg==", + "dev": true, + "license": "MIT" + }, + "node_modules/netmask": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/netmask/-/netmask-2.1.1.tgz", + "integrity": "sha512-eonl3sLUha+S1GzTPxychyhnUzKyeQkZ7jLjKrBagJgPla13F+uQ71HgpFefyHgqrjEbCPkDArxYsjY8/+gLKA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4.0" + } + }, + "node_modules/node-fetch": { + "version": "2.6.7", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.6.7.tgz", + "integrity": "sha512-ZjMPFEfVx5j+y2yF35Kzx5sF7kDzxuDj6ziH4FFbOp87zKDZNx8yExJIb05OGF4Nlt9IHFIMBkRl41VdvcNdbQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-url": "^5.0.0" + }, + "engines": { + "node": "4.x || >=6.0.0" + }, + "peerDependencies": { + "encoding": "^0.1.0" + }, + "peerDependenciesMeta": { + "encoding": { + "optional": true + } + } + }, + "node_modules/node-gyp-build": { + "version": "4.8.4", + "resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.8.4.tgz", + "integrity": "sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==", + "dev": true, + "license": "MIT", + "bin": { + "node-gyp-build": "bin.js", + "node-gyp-build-optional": "optional.js", + "node-gyp-build-test": "build-test.js" + } + }, + "node_modules/nopt": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/nopt/-/nopt-8.1.0.tgz", + "integrity": "sha512-ieGu42u/Qsa4TFktmaKEwM6MQH0pOWnaB3htzh0JRtx84+Mebc0cbZYN5bC+6WTZ4+77xrL9Pn5m7CV6VIkV7A==", + "dev": true, + "license": "ISC", + "dependencies": { + "abbrev": "^3.0.0" + }, + "bin": { + "nopt": "bin/nopt.js" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/npm-run-path": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-4.0.1.tgz", + "integrity": "sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "dev": true, + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/onetime": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz", + "integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "mimic-fn": "^2.1.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/os-paths": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/os-paths/-/os-paths-4.4.0.tgz", + "integrity": "sha512-wrAwOeXp1RRMFfQY8Sy7VaGVmPocaLwSFOYCGKSyo8qmJ+/yaafCl5BCA1IQZWqFSRBrKDYFeR9d/VyQzfH/jg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6.0" + } + }, + "node_modules/oxc-transform": { + "version": "0.111.0", + "resolved": "https://registry.npmjs.org/oxc-transform/-/oxc-transform-0.111.0.tgz", + "integrity": "sha512-oa5KKSDNLHZGaiqIGAbCWXeN9IJUAz9MElWcQX90epDxdKc9Hrt/BsLj3K4gDqfAYa5dwdH+ZCFJG9hR74fiGg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/sponsors/Boshen" + }, + "optionalDependencies": { + "@oxc-transform/binding-android-arm-eabi": "0.111.0", + "@oxc-transform/binding-android-arm64": "0.111.0", + "@oxc-transform/binding-darwin-arm64": "0.111.0", + "@oxc-transform/binding-darwin-x64": "0.111.0", + "@oxc-transform/binding-freebsd-x64": "0.111.0", + "@oxc-transform/binding-linux-arm-gnueabihf": "0.111.0", + "@oxc-transform/binding-linux-arm-musleabihf": "0.111.0", + "@oxc-transform/binding-linux-arm64-gnu": "0.111.0", + "@oxc-transform/binding-linux-arm64-musl": "0.111.0", + "@oxc-transform/binding-linux-ppc64-gnu": "0.111.0", + "@oxc-transform/binding-linux-riscv64-gnu": "0.111.0", + "@oxc-transform/binding-linux-riscv64-musl": "0.111.0", + "@oxc-transform/binding-linux-s390x-gnu": "0.111.0", + "@oxc-transform/binding-linux-x64-gnu": "0.111.0", + "@oxc-transform/binding-linux-x64-musl": "0.111.0", + "@oxc-transform/binding-openharmony-arm64": "0.111.0", + "@oxc-transform/binding-wasm32-wasi": "0.111.0", + "@oxc-transform/binding-win32-arm64-msvc": "0.111.0", + "@oxc-transform/binding-win32-ia32-msvc": "0.111.0", + "@oxc-transform/binding-win32-x64-msvc": "0.111.0" + } + }, + "node_modules/p-finally": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/p-finally/-/p-finally-2.0.1.tgz", + "integrity": "sha512-vpm09aKwq6H9phqRQzecoDpD8TmVyGw70qmWlyq5onxY7tqyTTFVvxMykxQSQKILBSFlbXpypIw2T1Ml7+DDtw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/pac-proxy-agent": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/pac-proxy-agent/-/pac-proxy-agent-7.2.0.tgz", + "integrity": "sha512-TEB8ESquiLMc0lV8vcd5Ql/JAKAoyzHFXaStwjkzpOpC5Yv+pIzLfHvjTSdf3vpa2bMiUQrg9i6276yn8666aA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@tootallnate/quickjs-emscripten": "^0.23.0", + "agent-base": "^7.1.2", + "debug": "^4.3.4", + "get-uri": "^6.0.1", + "http-proxy-agent": "^7.0.0", + "https-proxy-agent": "^7.0.6", + "pac-resolver": "^7.0.1", + "socks-proxy-agent": "^8.0.5" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/pac-resolver": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/pac-resolver/-/pac-resolver-7.0.1.tgz", + "integrity": "sha512-5NPgf87AT2STgwa2ntRMr45jTKrYBGkVU36yT0ig/n/GMAa3oPqhZfIQ2kMEimReg0+t9kZViDVZ83qfVUlckg==", + "dev": true, + "license": "MIT", + "dependencies": { + "degenerator": "^5.0.0", + "netmask": "^2.0.2" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/parse-ms": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/parse-ms/-/parse-ms-2.1.0.tgz", + "integrity": "sha512-kHt7kzLoS9VBZfUsiKjv43mr91ea+U05EyKkEtqp7vNbHxmaVuEqN7XxeEVnGrMtYOAxGrDElSi96K7EgO1zCA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/path-browserify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-browserify/-/path-browserify-1.0.1.tgz", + "integrity": "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==", + "dev": true, + "license": "MIT" + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-scurry": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.2.tgz", + "integrity": "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^11.0.0", + "minipass": "^7.1.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/path-to-regexp": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.3.0.tgz", + "integrity": "sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA==", + "dev": true, + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/path-to-regexp-updated": { + "name": "path-to-regexp", + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-6.3.0.tgz", + "integrity": "sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/pend": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz", + "integrity": "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.0.0.tgz", + "integrity": "sha512-1fygroTLlHu66zi26VoTDv8yRgm0Fccecssto+MhsZ0D/DGW2sm8E8AjW7NU5VVTRt5GxbeZ5qBuJr+HyLYkjQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pretty-ms": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/pretty-ms/-/pretty-ms-7.0.1.tgz", + "integrity": "sha512-973driJZvxiGOQ5ONsFhOF/DtzPMOMtgC11kCpUrPGMTgqp2q/1gwzCquocrN33is0VZ5GFHXZYMM9l6h67v2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "parse-ms": "^2.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/promisepipe": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/promisepipe/-/promisepipe-3.0.0.tgz", + "integrity": "sha512-V6TbZDJ/ZswevgkDNpGt/YqNCiZP9ASfgU+p83uJE6NrGtvSGoOcHLiDCqkMs2+yg7F5qHdLV8d0aS8O26G/KA==", + "dev": true, + "license": "MIT" + }, + "node_modules/proxy-agent": { + "version": "6.4.0", + "resolved": "https://registry.npmjs.org/proxy-agent/-/proxy-agent-6.4.0.tgz", + "integrity": "sha512-u0piLU+nCOHMgGjRbimiXmA9kM/L9EHh3zL81xCdp7m+Y2pHIsnmbdDoEDoAz5geaonNR6q6+yOPQs6n4T6sBQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.0.2", + "debug": "^4.3.4", + "http-proxy-agent": "^7.0.1", + "https-proxy-agent": "^7.0.3", + "lru-cache": "^7.14.1", + "pac-proxy-agent": "^7.0.1", + "proxy-from-env": "^1.1.0", + "socks-proxy-agent": "^8.0.2" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/proxy-agent/node_modules/lru-cache": { + "version": "7.18.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-7.18.3.tgz", + "integrity": "sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/proxy-from-env": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", + "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", + "dev": true, + "license": "MIT" + }, + "node_modules/pump": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz", + "integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "end-of-stream": "^1.1.0", + "once": "^1.3.1" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/queue-microtask": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/raw-body": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.4.1.tgz", + "integrity": "sha512-9WmIKF6mkvA0SLmA2Knm9+qj89e+j1zqgyn8aXGd7+nAduPoqgI9lO57SAZNn/Byzo5P7JhXTyg9PzaJbH73bA==", + "dev": true, + "license": "MIT", + "dependencies": { + "bytes": "3.1.0", + "http-errors": "1.7.3", + "iconv-lite": "0.4.24", + "unpipe": "1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/readdirp": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", + "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.18.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/resolve-from": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", + "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/resolve-pkg-maps": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", + "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" + } + }, + "node_modules/resolve.exports": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/resolve.exports/-/resolve.exports-2.0.3.tgz", + "integrity": "sha512-OcXjMsGdhL4XnbShKpAcSqPMzQoYkYyhbEaeSko47MjRP9NfEQMhZkXL1DoFlt9LWQn4YttrdnV6X2OiyzBi+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/retry": { + "version": "0.13.1", + "resolved": "https://registry.npmjs.org/retry/-/retry-0.13.1.tgz", + "integrity": "sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/reusify": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", + "dev": true, + "license": "MIT", + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/rolldown": { + "version": "1.0.0-rc.1", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.1.tgz", + "integrity": "sha512-M3AeZjYE6UclblEf531Hch0WfVC/NOL43Cc+WdF3J50kk5/fvouHhDumSGTh0oRjbZ8C4faaVr5r6Nx1xMqDGg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.110.0", + "@rolldown/pluginutils": "1.0.0-rc.1" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm64": "1.0.0-rc.1", + "@rolldown/binding-darwin-arm64": "1.0.0-rc.1", + "@rolldown/binding-darwin-x64": "1.0.0-rc.1", + "@rolldown/binding-freebsd-x64": "1.0.0-rc.1", + "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.1", + "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.1", + "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.1", + "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.1", + "@rolldown/binding-linux-x64-musl": "1.0.0-rc.1", + "@rolldown/binding-openharmony-arm64": "1.0.0-rc.1", + "@rolldown/binding-wasm32-wasi": "1.0.0-rc.1", + "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.1", + "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.1" + } + }, + "node_modules/run-parallel": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", + "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "queue-microtask": "^1.2.2" + } + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "dev": true, + "license": "MIT" + }, + "node_modules/sandbox": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/sandbox/-/sandbox-2.5.6.tgz", + "integrity": "sha512-tnFr7nyiuEhsAGb+xy60SDbij0790X+FgDljh3J/2HaRM6yQgNJkQKHbDH8ld7mR+PozXGgEfJ2Dc/5OyFnwsg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@vercel/sandbox": "1.9.0", + "debug": "^4.4.1", + "zod": "^4.1.1" + }, + "bin": { + "sandbox": "bin/sandbox.mjs", + "sbx": "bin/sandbox.mjs" + } + }, + "node_modules/sandbox/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/sandbox/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/semver": { + "version": "7.5.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.5.4.tgz", + "integrity": "sha512-1bCSESV6Pv+i21Hvpxp3Dx+pSD8lIPt8uVjRrxAUt/nbswYc+tK6Y2btiULjd4+fnq15PX+nqQDC7Oft7WkwcA==", + "dev": true, + "license": "ISC", + "dependencies": { + "lru-cache": "^6.0.0" + }, + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/semver/node_modules/lru-cache": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", + "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/semver/node_modules/yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "dev": true, + "license": "ISC" + }, + "node_modules/setprototypeof": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.1.1.tgz", + "integrity": "sha512-JvdAWfbXeIGaZ9cILp38HntZSFSo3mWg6xGcJJsd+d4aRMOqauag1C63dJfDw7OaMYwEbHMOxEZ1lqVRYP2OAw==", + "dev": true, + "license": "ISC" + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/signal-exit": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.0.2.tgz", + "integrity": "sha512-MY2/qGx4enyjprQnFaZsHib3Yadh3IXyV2C321GY0pjGfVBu4un0uDJkwgdxqO+Rdx8JMT8IfJIRwbYVz3Ob3Q==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/smart-buffer": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz", + "integrity": "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6.0.0", + "npm": ">= 3.0.0" + } + }, + "node_modules/smol-toml": { + "version": "1.5.2", + "resolved": "https://registry.npmjs.org/smol-toml/-/smol-toml-1.5.2.tgz", + "integrity": "sha512-QlaZEqcAH3/RtNyet1IPIYPsEWAaYyXXv1Krsi+1L/QHppjX4Ifm8MQsBISz9vE8cHicIq3clogsheili5vhaQ==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">= 18" + }, + "funding": { + "url": "https://github.com/sponsors/cyyynthia" + } + }, + "node_modules/socks": { + "version": "2.8.9", + "resolved": "https://registry.npmjs.org/socks/-/socks-2.8.9.tgz", + "integrity": "sha512-LJhUYUvItdQ0LkJTmPeaEObWXAqFyfmP85x0tch/ez9cahmhlBBLbIqDFnvBnUJGagb0JbIQrkBs1wJ+yRYpEw==", + "dev": true, + "license": "MIT", + "dependencies": { + "ip-address": "^10.1.1", + "smart-buffer": "^4.2.0" + }, + "engines": { + "node": ">= 10.0.0", + "npm": ">= 3.0.0" + } + }, + "node_modules/socks-proxy-agent": { + "version": "8.0.5", + "resolved": "https://registry.npmjs.org/socks-proxy-agent/-/socks-proxy-agent-8.0.5.tgz", + "integrity": "sha512-HehCEsotFqbPW9sJ8WVYB6UbmIMv7kUUORIF2Nncq4VQvBfNBLibW9YZR5dlYCSUhwcD628pRllm7n+E+YTzJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "^4.3.4", + "socks": "^2.8.3" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true, + "license": "BSD-3-Clause", + "optional": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/srvx": { + "version": "0.8.9", + "resolved": "https://registry.npmjs.org/srvx/-/srvx-0.8.9.tgz", + "integrity": "sha512-wYc3VLZHRzwYrWJhkEqkhLb31TI0SOkfYZDkUhXdp3NoCnNS0FqajiQszZZjfow/VYEuc6Q5sZh9nM6kPy2NBQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "cookie-es": "^2.0.0" + }, + "bin": { + "srvx": "bin/srvx.mjs" + }, + "engines": { + "node": ">=20.16.0" + } + }, + "node_modules/stat-mode": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/stat-mode/-/stat-mode-0.3.0.tgz", + "integrity": "sha512-QjMLR0A3WwFY2aZdV0okfFEJB5TRjkggXZjxP3A1RsWsNHNu3YPv8btmtc6iCFZ0Rul3FE93OYogvhOUClU+ng==", + "dev": true, + "license": "MIT" + }, + "node_modules/statuses": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-1.5.0.tgz", + "integrity": "sha512-OpZ3zP+jT1PI7I8nemJX4AKmAX070ZkYPVWV/AaKTJl+tXCTGyVdC1a4SL8RUQYEwk/f34ZX8UTykN68FwrqAA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/stream-to-array": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/stream-to-array/-/stream-to-array-2.3.0.tgz", + "integrity": "sha512-UsZtOYEn4tWU2RGLOXr/o/xjRBftZRlG3dEWoaHr8j4GuypJ3isitGbVyjQKAuMu+xbiop8q224TjiZWc4XTZA==", + "dev": true, + "license": "MIT", + "dependencies": { + "any-promise": "^1.1.0" + } + }, + "node_modules/stream-to-promise": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/stream-to-promise/-/stream-to-promise-2.2.0.tgz", + "integrity": "sha512-HAGUASw8NT0k8JvIVutB2Y/9iBk7gpgEyAudXwNJmZERdMITGdajOa4VJfD/kNiA3TppQpTP4J+CtcHwdzKBAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "any-promise": "~1.3.0", + "end-of-stream": "~1.1.0", + "stream-to-array": "~2.3.0" + } + }, + "node_modules/stream-to-promise/node_modules/end-of-stream": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.1.0.tgz", + "integrity": "sha512-EoulkdKF/1xa92q25PbjuDcgJ9RDHYU2Rs3SCIvs2/dSQ3BpmxneNHmA/M7fe60M3PrV7nNGTTNbkK62l6vXiQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "once": "~1.3.0" + } + }, + "node_modules/stream-to-promise/node_modules/once": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/once/-/once-1.3.3.tgz", + "integrity": "sha512-6vaNInhu+CHxtONf3zw3vq4SP2DOQhjBvIa3rNcG0+P7eKWlYH6Peu7rHizSloRU2EwMz6GraLieis9Ac9+p1w==", + "dev": true, + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/streamx": { + "version": "2.26.0", + "resolved": "https://registry.npmjs.org/streamx/-/streamx-2.26.0.tgz", + "integrity": "sha512-VvNG1K72Po/xwJzxZFnZ++Tbrv4lwSptsbkFuzXCJAYZvCK5nnxsvXU6ajqkv7chyiI1Y0YXq2Jh8Iy8Y7NF/A==", + "dev": true, + "license": "MIT", + "dependencies": { + "events-universal": "^1.0.0", + "fast-fifo": "^1.3.2", + "text-decoder": "^1.1.0" + } + }, + "node_modules/strip-final-newline": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-2.0.0.tgz", + "integrity": "sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/tar": { + "version": "7.5.7", + "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.7.tgz", + "integrity": "sha512-fov56fJiRuThVFXD6o6/Q354S7pnWMJIVlDBYijsTNx6jKSE4pvrDTs6lUnmGvNyfJwFQQwWy3owKz1ucIhveQ==", + "deprecated": "Old versions of tar are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/fs-minipass": "^4.0.0", + "chownr": "^3.0.0", + "minipass": "^7.1.2", + "minizlib": "^3.1.0", + "yallist": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/tar-stream": { + "version": "3.1.7", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-3.1.7.tgz", + "integrity": "sha512-qJj60CXt7IU1Ffyc3NJMjh6EkuCFej46zUqJ4J7pqYlThyd9bO0XBTmcOIhSzZJVWfsLks0+nle/j538YAW9RQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "b4a": "^1.6.4", + "fast-fifo": "^1.2.0", + "streamx": "^2.15.0" + } + }, + "node_modules/text-decoder": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/text-decoder/-/text-decoder-1.2.7.tgz", + "integrity": "sha512-vlLytXkeP4xvEq2otHeJfSQIRyWxo/oZGEbXrtEEF9Hnmrdly59sUbzZ/QgyWuLYHctCHxFF4tRQZNQ9k60ExQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "b4a": "^1.6.4" + } + }, + "node_modules/throttleit": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/throttleit/-/throttleit-2.1.0.tgz", + "integrity": "sha512-nt6AMGKW1p/70DF/hGBdJB57B8Tspmbp5gfJ8ilhLnt7kkr2ye7hzD6NVG8GGErk2HWF34igrL2CXmNIkzKqKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/time-span": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/time-span/-/time-span-4.0.0.tgz", + "integrity": "sha512-MyqZCTGLDZ77u4k+jqg4UlrzPTPZ49NDlaekU6uuFaJLzPIN1woaRXCbGeqOfxwc3Y37ZROGAJ614Rdv7Olt+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "convert-hrtime": "^3.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/tinyexec": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", + "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/toidentifier": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.0.tgz", + "integrity": "sha512-yaOH/Pk/VEhBWWTlhI+qXxDFXlejDGcQipMlyxda9nthulaxLZUNcUqFxokp0vcYnvteJln5FNQDRrxj3YcbVw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/tr46": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", + "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", + "dev": true, + "license": "MIT" + }, + "node_modules/tree-kill": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz", + "integrity": "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==", + "dev": true, + "license": "MIT", + "bin": { + "tree-kill": "cli.js" + } + }, + "node_modules/ts-morph": { + "version": "12.0.0", + "resolved": "https://registry.npmjs.org/ts-morph/-/ts-morph-12.0.0.tgz", + "integrity": "sha512-VHC8XgU2fFW7yO1f/b3mxKDje1vmyzFXHWzOYmKEkCEwcLjDtbdLgBQviqj4ZwP4MJkQtRo6Ha2I29lq/B+VxA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@ts-morph/common": "~0.11.0", + "code-block-writer": "^10.1.1" + } + }, + "node_modules/ts-toolbelt": { + "version": "6.15.5", + "resolved": "https://registry.npmjs.org/ts-toolbelt/-/ts-toolbelt-6.15.5.tgz", + "integrity": "sha512-FZIXf1ksVyLcfr7M317jbB67XFJhOO1YqdTcuGaq9q5jLUoTikukZ+98TPjKiP2jC5CgmYdWWYs0s2nLSU0/1A==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD" + }, + "node_modules/tsx": { + "version": "4.21.0", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz", + "integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.27.0", + "get-tsconfig": "^4.7.5" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/uid-promise": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/uid-promise/-/uid-promise-1.0.0.tgz", + "integrity": "sha512-R8375j0qwXyIu/7R0tjdF06/sElHqbmdmWC9M2qQHpEVbvE4I5+38KJI7LUUmQMp7NVq4tKHiBMkT0NFM453Ig==", + "dev": true, + "license": "MIT" + }, + "node_modules/undici": { + "version": "6.26.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-6.26.0.tgz", + "integrity": "sha512-4yqz8a3n5HmGTlsbADNtr/dJlhkh/55Rq798G6ibiULcXbDtaLpTl1pvdqcbFfeoj3iSi52lePFM7h9H21cw/A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.17" + } + }, + "node_modules/undici-types": { + "version": "5.26.5", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", + "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==", + "dev": true, + "license": "MIT" + }, + "node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/vercel": { + "version": "54.5.1", + "resolved": "https://registry.npmjs.org/vercel/-/vercel-54.5.1.tgz", + "integrity": "sha512-ST78YP0nF/OptCnNEpVewmXamPGyr9eIGzSmhgXf+pt9K/BE6tjgg+Ai5NM3eWZTI7e0I8cdkkcGYb5MkQj+Cw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@vercel/backends": "0.8.1", + "@vercel/blob": "2.4.0", + "@vercel/build-utils": "13.26.3", + "@vercel/cli-config": "0.1.2", + "@vercel/detect-agent": "1.2.3", + "@vercel/elysia": "0.1.82", + "@vercel/express": "0.1.92", + "@vercel/fastify": "0.1.85", + "@vercel/fun": "1.3.0", + "@vercel/go": "3.8.0", + "@vercel/h3": "0.1.91", + "@vercel/hono": "0.2.85", + "@vercel/hydrogen": "1.3.8", + "@vercel/koa": "0.1.65", + "@vercel/nestjs": "0.2.86", + "@vercel/next": "4.17.4", + "@vercel/node": "5.8.6", + "@vercel/prepare-flags-definitions": "0.2.1", + "@vercel/python": "6.43.3", + "@vercel/redwood": "2.4.14", + "@vercel/remix-builder": "5.8.3", + "@vercel/ruby": "2.4.0", + "@vercel/rust": "1.3.0", + "@vercel/static-build": "2.9.32", + "chokidar": "4.0.0", + "esbuild": "0.27.0", + "form-data": "^4.0.0", + "jose": "5.9.6", + "luxon": "^3.4.0", + "proxy-agent": "6.4.0", + "sandbox": "2.5.6", + "smol-toml": "1.5.2", + "zod": "4.1.11" + }, + "bin": { + "vc": "dist/vc.js", + "vercel": "dist/vc.js" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/web-vitals": { + "version": "0.2.4", + "resolved": "https://registry.npmjs.org/web-vitals/-/web-vitals-0.2.4.tgz", + "integrity": "sha512-6BjspCO9VriYy12z356nL6JBS0GYeEcA457YyRzD+dD6XYCQ75NKhcOHUMHentOE7OcVCIXXDvOm0jKFfQG2Gg==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/webidl-conversions": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", + "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", + "dev": true, + "license": "BSD-2-Clause" + }, + "node_modules/whatwg-url": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", + "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tr46": "~0.0.3", + "webidl-conversions": "^3.0.0" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/xdg-app-paths": { + "version": "5.5.1", + "resolved": "https://registry.npmjs.org/xdg-app-paths/-/xdg-app-paths-5.5.1.tgz", + "integrity": "sha512-hI3flOB4PLZIy5prbtTpirobtPE2ZtZ52szO+2mM9Efp6ErM398La+C1lIpNWDfNoQk+6Lsi6nMcCwVB7pxeMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "os-paths": "^4.0.1", + "xdg-portable": "^7.2.0" + }, + "engines": { + "node": ">= 6.0" + } + }, + "node_modules/xdg-portable": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/xdg-portable/-/xdg-portable-7.3.0.tgz", + "integrity": "sha512-sqMMuL1rc0FmMBOzCpd0yuy9trqF2yTTVe+E9ogwCSWQCdDEtQUwrZPT6AxqtsFGRNxycgncbP/xmOOSPw5ZUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "os-paths": "^4.0.1" + }, + "engines": { + "node": ">= 6.0" + } + }, + "node_modules/yallist": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-5.0.0.tgz", + "integrity": "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/yauzl": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/yauzl/-/yauzl-2.10.0.tgz", + "integrity": "sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer-crc32": "~0.2.3", + "fd-slicer": "~1.1.0" + } + }, + "node_modules/yauzl-clone": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/yauzl-clone/-/yauzl-clone-1.0.4.tgz", + "integrity": "sha512-igM2RRCf3k8TvZoxR2oguuw4z1xasOnA31joCqHIyLkeWrvAc2Jgay5ISQ2ZplinkoGaJ6orCz56Ey456c5ESA==", + "dev": true, + "license": "MIT", + "dependencies": { + "events-intercept": "^2.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/yauzl-promise": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/yauzl-promise/-/yauzl-promise-2.1.3.tgz", + "integrity": "sha512-A1pf6fzh6eYkK0L4Qp7g9jzJSDrM6nN0bOn5T0IbY4Yo3w+YkWlHFkJP7mzknMXjqusHFHlKsK2N+4OLsK2MRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "yauzl": "^2.9.1", + "yauzl-clone": "^1.0.4" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/zod": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.1.11.tgz", + "integrity": "sha512-WPsqwxITS2tzx1bzhIKsEs19ABD5vmCVa4xBo2tq/SrV4RNZtfws1EnCWQXM6yh8bD08a1idvkB5MZSBiZsjwg==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + } + } +} diff --git a/apps/seat-guide-speaker-vercel/package.json b/apps/seat-guide-speaker-vercel/package.json new file mode 100644 index 0000000000..788fa9f5ee --- /dev/null +++ b/apps/seat-guide-speaker-vercel/package.json @@ -0,0 +1,13 @@ +{ + "name": "seat-guide-speaker-vercel", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "dev": "vercel dev", + "lint": "node --check 'api/[...speaker].js'" + }, + "devDependencies": { + "vercel": "^54.5.1" + } +} diff --git a/apps/seat-guide-speaker-vercel/public/index.html b/apps/seat-guide-speaker-vercel/public/index.html new file mode 100644 index 0000000000..08d906ab4c --- /dev/null +++ b/apps/seat-guide-speaker-vercel/public/index.html @@ -0,0 +1,238 @@ + + + + + + SeatGuide Speaker + + + +
+
+

SeatGuide Speaker

+
audio=locked
+
+
+ + + +
+
+
Messages
+
+
+
+ + + diff --git a/apps/seat-guide-speaker-vercel/vercel.json b/apps/seat-guide-speaker-vercel/vercel.json new file mode 100644 index 0000000000..e27ea66ef7 --- /dev/null +++ b/apps/seat-guide-speaker-vercel/vercel.json @@ -0,0 +1,13 @@ +{ + "headers": [ + { + "source": "/(.*)", + "headers": [ + { + "key": "Cache-Control", + "value": "no-store" + } + ] + } + ] +} From 06a5bc64435b3900b8a032a80b4acb9b9016fb7a Mon Sep 17 00:00:00 2001 From: Ernest Date: Fri, 29 May 2026 04:11:23 +0800 Subject: [PATCH 08/16] chore(seat-guide): trim Vercel speaker dependencies --- apps/seat-guide-speaker-vercel/README.md | 2 - .../package-lock.json | 4841 ----------------- apps/seat-guide-speaker-vercel/package.json | 4 - 3 files changed, 4847 deletions(-) delete mode 100644 apps/seat-guide-speaker-vercel/package-lock.json diff --git a/apps/seat-guide-speaker-vercel/README.md b/apps/seat-guide-speaker-vercel/README.md index d5c1da39e4..65821be143 100644 --- a/apps/seat-guide-speaker-vercel/README.md +++ b/apps/seat-guide-speaker-vercel/README.md @@ -19,7 +19,6 @@ Create a Vercel project from this directory: ```bash cd apps/seat-guide-speaker-vercel -npm install npx vercel ``` @@ -39,7 +38,6 @@ Tap `Enable speaker`. Keep Safari open and unlocked. ```bash curl -X POST "https:///api/speak" \ - -H "authorization: Bearer $SPEAKER_API_TOKEN" \ -H "content-type: application/json" \ -d '{"device":"go2-demo","text":"我已经到了, 请坐。"}' ``` diff --git a/apps/seat-guide-speaker-vercel/package-lock.json b/apps/seat-guide-speaker-vercel/package-lock.json deleted file mode 100644 index a143cbbd83..0000000000 --- a/apps/seat-guide-speaker-vercel/package-lock.json +++ /dev/null @@ -1,4841 +0,0 @@ -{ - "name": "seat-guide-speaker-vercel", - "version": "0.1.0", - "lockfileVersion": 3, - "requires": true, - "packages": { - "": { - "name": "seat-guide-speaker-vercel", - "version": "0.1.0", - "devDependencies": { - "vercel": "^54.5.1" - } - }, - "node_modules/@bytecodealliance/preview2-shim": { - "version": "0.17.6", - "resolved": "https://registry.npmjs.org/@bytecodealliance/preview2-shim/-/preview2-shim-0.17.6.tgz", - "integrity": "sha512-n3cM88gTen5980UOBAD6xDcNNL3ocTK8keab21bpx1ONdA+ARj7uD1qoFxOWCyKlkpSi195FH+GeAut7Oc6zZw==", - "dev": true, - "license": "(Apache-2.0 WITH LLVM-exception)" - }, - "node_modules/@edge-runtime/format": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/@edge-runtime/format/-/format-2.2.1.tgz", - "integrity": "sha512-JQTRVuiusQLNNLe2W9tnzBlV/GvSVcozLl4XZHk5swnRZ/v6jp8TqR8P7sqmJsQqblDZ3EztcWmLDbhRje/+8g==", - "dev": true, - "license": "MPL-2.0", - "engines": { - "node": ">=16" - } - }, - "node_modules/@edge-runtime/node-utils": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/@edge-runtime/node-utils/-/node-utils-2.3.0.tgz", - "integrity": "sha512-uUtx8BFoO1hNxtHjp3eqVPC/mWImGb2exOfGjMLUoipuWgjej+f4o/VP4bUI8U40gu7Teogd5VTeZUkGvJSPOQ==", - "dev": true, - "license": "MPL-2.0", - "engines": { - "node": ">=16" - } - }, - "node_modules/@edge-runtime/ponyfill": { - "version": "2.4.2", - "resolved": "https://registry.npmjs.org/@edge-runtime/ponyfill/-/ponyfill-2.4.2.tgz", - "integrity": "sha512-oN17GjFr69chu6sDLvXxdhg0Qe8EZviGSuqzR9qOiKh4MhFYGdBBcqRNzdmYeAdeRzOW2mM9yil4RftUQ7sUOA==", - "dev": true, - "license": "MPL-2.0", - "engines": { - "node": ">=16" - } - }, - "node_modules/@edge-runtime/primitives": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/@edge-runtime/primitives/-/primitives-4.1.0.tgz", - "integrity": "sha512-Vw0lbJ2lvRUqc7/soqygUX216Xb8T3WBZ987oywz6aJqRxcwSVWwr9e+Nqo2m9bxobA9mdbWNNoRY6S9eko1EQ==", - "dev": true, - "license": "MPL-2.0", - "engines": { - "node": ">=16" - } - }, - "node_modules/@edge-runtime/vm": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/@edge-runtime/vm/-/vm-3.2.0.tgz", - "integrity": "sha512-0dEVyRLM/lG4gp1R/Ik5bfPl/1wX00xFwd5KcNH602tzBa09oF7pbTKETEhR1GjZ75K6OJnYFu8II2dyMhONMw==", - "dev": true, - "license": "MPL-2.0", - "dependencies": { - "@edge-runtime/primitives": "4.1.0" - }, - "engines": { - "node": ">=16" - } - }, - "node_modules/@emnapi/core": { - "version": "1.10.0", - "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", - "integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "@emnapi/wasi-threads": "1.2.1", - "tslib": "^2.4.0" - } - }, - "node_modules/@emnapi/runtime": { - "version": "1.10.0", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", - "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, - "node_modules/@emnapi/wasi-threads": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", - "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, - "node_modules/@esbuild/aix-ppc64": { - "version": "0.27.0", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.0.tgz", - "integrity": "sha512-KuZrd2hRjz01y5JK9mEBSD3Vj3mbCvemhT466rSuJYeE/hjuBrHfjjcjMdTm/sz7au+++sdbJZJmuBwQLuw68A==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "aix" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-arm": { - "version": "0.27.0", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.0.tgz", - "integrity": "sha512-j67aezrPNYWJEOHUNLPj9maeJte7uSMM6gMoxfPC9hOg8N02JuQi/T7ewumf4tNvJadFkvLZMlAq73b9uwdMyQ==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-arm64": { - "version": "0.27.0", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.0.tgz", - "integrity": "sha512-CC3vt4+1xZrs97/PKDkl0yN7w8edvU2vZvAFGD16n9F0Cvniy5qvzRXjfO1l94efczkkQE6g1x0i73Qf5uthOQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-x64": { - "version": "0.27.0", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.0.tgz", - "integrity": "sha512-wurMkF1nmQajBO1+0CJmcN17U4BP6GqNSROP8t0X/Jiw2ltYGLHpEksp9MpoBqkrFR3kv2/te6Sha26k3+yZ9Q==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/darwin-arm64": { - "version": "0.27.0", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.0.tgz", - "integrity": "sha512-uJOQKYCcHhg07DL7i8MzjvS2LaP7W7Pn/7uA0B5S1EnqAirJtbyw4yC5jQ5qcFjHK9l6o/MX9QisBg12kNkdHg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/darwin-x64": { - "version": "0.27.0", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.0.tgz", - "integrity": "sha512-8mG6arH3yB/4ZXiEnXof5MK72dE6zM9cDvUcPtxhUZsDjESl9JipZYW60C3JGreKCEP+p8P/72r69m4AZGJd5g==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/freebsd-arm64": { - "version": "0.27.0", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.0.tgz", - "integrity": "sha512-9FHtyO988CwNMMOE3YIeci+UV+x5Zy8fI2qHNpsEtSF83YPBmE8UWmfYAQg6Ux7Gsmd4FejZqnEUZCMGaNQHQw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/freebsd-x64": { - "version": "0.27.0", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.0.tgz", - "integrity": "sha512-zCMeMXI4HS/tXvJz8vWGexpZj2YVtRAihHLk1imZj4efx1BQzN76YFeKqlDr3bUWI26wHwLWPd3rwh6pe4EV7g==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-arm": { - "version": "0.27.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.0.tgz", - "integrity": "sha512-t76XLQDpxgmq2cNXKTVEB7O7YMb42atj2Re2Haf45HkaUpjM2J0UuJZDuaGbPbamzZ7bawyGFUkodL+zcE+jvQ==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-arm64": { - "version": "0.27.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.0.tgz", - "integrity": "sha512-AS18v0V+vZiLJyi/4LphvBE+OIX682Pu7ZYNsdUHyUKSoRwdnOsMf6FDekwoAFKej14WAkOef3zAORJgAtXnlQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-ia32": { - "version": "0.27.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.0.tgz", - "integrity": "sha512-Mz1jxqm/kfgKkc/KLHC5qIujMvnnarD9ra1cEcrs7qshTUSksPihGrWHVG5+osAIQ68577Zpww7SGapmzSt4Nw==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-loong64": { - "version": "0.27.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.0.tgz", - "integrity": "sha512-QbEREjdJeIreIAbdG2hLU1yXm1uu+LTdzoq1KCo4G4pFOLlvIspBm36QrQOar9LFduavoWX2msNFAAAY9j4BDg==", - "cpu": [ - "loong64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-mips64el": { - "version": "0.27.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.0.tgz", - "integrity": "sha512-sJz3zRNe4tO2wxvDpH/HYJilb6+2YJxo/ZNbVdtFiKDufzWq4JmKAiHy9iGoLjAV7r/W32VgaHGkk35cUXlNOg==", - "cpu": [ - "mips64el" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-ppc64": { - "version": "0.27.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.0.tgz", - "integrity": "sha512-z9N10FBD0DCS2dmSABDBb5TLAyF1/ydVb+N4pi88T45efQ/w4ohr/F/QYCkxDPnkhkp6AIpIcQKQ8F0ANoA2JA==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-riscv64": { - "version": "0.27.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.0.tgz", - "integrity": "sha512-pQdyAIZ0BWIC5GyvVFn5awDiO14TkT/19FTmFcPdDec94KJ1uZcmFs21Fo8auMXzD4Tt+diXu1LW1gHus9fhFQ==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-s390x": { - "version": "0.27.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.0.tgz", - "integrity": "sha512-hPlRWR4eIDDEci953RI1BLZitgi5uqcsjKMxwYfmi4LcwyWo2IcRP+lThVnKjNtk90pLS8nKdroXYOqW+QQH+w==", - "cpu": [ - "s390x" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-x64": { - "version": "0.27.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.0.tgz", - "integrity": "sha512-1hBWx4OUJE2cab++aVZ7pObD6s+DK4mPGpemtnAORBvb5l/g5xFGk0vc0PjSkrDs0XaXj9yyob3d14XqvnQ4gw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/netbsd-arm64": { - "version": "0.27.0", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.0.tgz", - "integrity": "sha512-6m0sfQfxfQfy1qRuecMkJlf1cIzTOgyaeXaiVaaki8/v+WB+U4hc6ik15ZW6TAllRlg/WuQXxWj1jx6C+dfy3w==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/netbsd-x64": { - "version": "0.27.0", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.0.tgz", - "integrity": "sha512-xbbOdfn06FtcJ9d0ShxxvSn2iUsGd/lgPIO2V3VZIPDbEaIj1/3nBBe1AwuEZKXVXkMmpr6LUAgMkLD/4D2PPA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openbsd-arm64": { - "version": "0.27.0", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.0.tgz", - "integrity": "sha512-fWgqR8uNbCQ/GGv0yhzttj6sU/9Z5/Sv/VGU3F5OuXK6J6SlriONKrQ7tNlwBrJZXRYk5jUhuWvF7GYzGguBZQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openbsd-x64": { - "version": "0.27.0", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.0.tgz", - "integrity": "sha512-aCwlRdSNMNxkGGqQajMUza6uXzR/U0dIl1QmLjPtRbLOx3Gy3otfFu/VjATy4yQzo9yFDGTxYDo1FfAD9oRD2A==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openharmony-arm64": { - "version": "0.27.0", - "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.0.tgz", - "integrity": "sha512-nyvsBccxNAsNYz2jVFYwEGuRRomqZ149A39SHWk4hV0jWxKM0hjBPm3AmdxcbHiFLbBSwG6SbpIcUbXjgyECfA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openharmony" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/sunos-x64": { - "version": "0.27.0", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.0.tgz", - "integrity": "sha512-Q1KY1iJafM+UX6CFEL+F4HRTgygmEW568YMqDA5UV97AuZSm21b7SXIrRJDwXWPzr8MGr75fUZPV67FdtMHlHA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "sunos" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-arm64": { - "version": "0.27.0", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.0.tgz", - "integrity": "sha512-W1eyGNi6d+8kOmZIwi/EDjrL9nxQIQ0MiGqe/AWc6+IaHloxHSGoeRgDRKHFISThLmsewZ5nHFvGFWdBYlgKPg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-ia32": { - "version": "0.27.0", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.0.tgz", - "integrity": "sha512-30z1aKL9h22kQhilnYkORFYt+3wp7yZsHWus+wSKAJR8JtdfI76LJ4SBdMsCopTR3z/ORqVu5L1vtnHZWVj4cQ==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-x64": { - "version": "0.27.0", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.0.tgz", - "integrity": "sha512-aIitBcjQeyOhMTImhLZmtxfdOcuNRpwlPNmlFKPcHQYPhEssw75Cl1TSXJXpMkzaua9FUetx/4OQKq7eJul5Cg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@fastify/busboy": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/@fastify/busboy/-/busboy-2.1.1.tgz", - "integrity": "sha512-vBZP4NlzfOlerQTnba4aqZoMhE/a9HY7HRqoOPaETQcSQuWEIyZMHGfVu6w9wGtGK5fED5qRs2DteVCjOH60sA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=14" - } - }, - "node_modules/@isaacs/balanced-match": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/@isaacs/balanced-match/-/balanced-match-4.0.1.tgz", - "integrity": "sha512-yzMTt9lEb8Gv7zRioUilSglI0c0smZ9k5D65677DLWLtWJaXIS3CqcGyUFByYKlnUj6TkjLVs54fBl6+TiGQDQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": "20 || >=22" - } - }, - "node_modules/@isaacs/brace-expansion": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/@isaacs/brace-expansion/-/brace-expansion-5.0.1.tgz", - "integrity": "sha512-WMz71T1JS624nWj2n2fnYAuPovhv7EUhk69R6i9dsVyzxt5eM3bjwvgk9L+APE1TRscGysAVMANkB0jh0LQZrQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@isaacs/balanced-match": "^4.0.1" - }, - "engines": { - "node": "20 || >=22" - } - }, - "node_modules/@isaacs/fs-minipass": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz", - "integrity": "sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==", - "dev": true, - "license": "ISC", - "dependencies": { - "minipass": "^7.0.4" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@mapbox/node-pre-gyp": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/@mapbox/node-pre-gyp/-/node-pre-gyp-2.0.3.tgz", - "integrity": "sha512-uwPAhccfFJlsfCxMYTwOdVfOz3xqyj8xYL3zJj8f0pb30tLohnnFPhLuqp4/qoEz8sNxe4SESZedcBojRefIzg==", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "consola": "^3.2.3", - "detect-libc": "^2.0.0", - "https-proxy-agent": "^7.0.5", - "node-fetch": "^2.6.7", - "nopt": "^8.0.0", - "semver": "^7.5.3", - "tar": "^7.4.0" - }, - "bin": { - "node-pre-gyp": "bin/node-pre-gyp" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/@napi-rs/wasm-runtime": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.4.tgz", - "integrity": "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "@tybys/wasm-util": "^0.10.1" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/Brooooooklyn" - }, - "peerDependencies": { - "@emnapi/core": "^1.7.1", - "@emnapi/runtime": "^1.7.1" - } - }, - "node_modules/@nodelib/fs.scandir": { - "version": "2.1.5", - "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", - "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@nodelib/fs.stat": "2.0.5", - "run-parallel": "^1.1.9" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/@nodelib/fs.stat": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", - "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 8" - } - }, - "node_modules/@nodelib/fs.walk": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", - "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@nodelib/fs.scandir": "2.1.5", - "fastq": "^1.6.0" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/@oxc-project/types": { - "version": "0.110.0", - "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.110.0.tgz", - "integrity": "sha512-6Ct21OIlrEnFEJk5LT4e63pk3btsI6/TusD/GStLi7wYlGJNOl1GI9qvXAnRAxQU9zqA2Oz+UwhfTOU2rPZVow==", - "dev": true, - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/Boshen" - } - }, - "node_modules/@oxc-transform/binding-android-arm-eabi": { - "version": "0.111.0", - "resolved": "https://registry.npmjs.org/@oxc-transform/binding-android-arm-eabi/-/binding-android-arm-eabi-0.111.0.tgz", - "integrity": "sha512-NdFLicvorfHYu0g2ftjVJaH7+Dz27AQUNJOq8t/ofRUoWmczOodgUCHx8C1M1htCN4ZmhS/FzfSy6yd/UngJGg==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@oxc-transform/binding-android-arm64": { - "version": "0.111.0", - "resolved": "https://registry.npmjs.org/@oxc-transform/binding-android-arm64/-/binding-android-arm64-0.111.0.tgz", - "integrity": "sha512-J2v9ajarD2FYlhHtjbgZUFsS2Kvi27pPxDWLGCy7i8tO60xBoozX9/ktSgbiE/QsxKaUhfv4zVKppKWUo71PmQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@oxc-transform/binding-darwin-arm64": { - "version": "0.111.0", - "resolved": "https://registry.npmjs.org/@oxc-transform/binding-darwin-arm64/-/binding-darwin-arm64-0.111.0.tgz", - "integrity": "sha512-2UYmExxpXzmiHTldhNlosWqG9Nc4US51K0GB9RLcGlTE23WO33vVo1NVAKwxPE+KYuhffwDnRYTovTMUjzwvZA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@oxc-transform/binding-darwin-x64": { - "version": "0.111.0", - "resolved": "https://registry.npmjs.org/@oxc-transform/binding-darwin-x64/-/binding-darwin-x64-0.111.0.tgz", - "integrity": "sha512-c4YRwfLV8Pj/ToiTCbndZaHxM2BD4W3bltr/fjXZcGypEK+U2RZFDL7tIZYT/tyneAC9hCORZKDaKhLLNuzPtA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@oxc-transform/binding-freebsd-x64": { - "version": "0.111.0", - "resolved": "https://registry.npmjs.org/@oxc-transform/binding-freebsd-x64/-/binding-freebsd-x64-0.111.0.tgz", - "integrity": "sha512-prvf32IcEuLnLZbNVomFosBu0CaZpyj3YsZ6epbOgJy8iJjfLsXBb+PrkO/NBKzjuJoJa2+u7jFKRE0KT7gSOw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@oxc-transform/binding-linux-arm-gnueabihf": { - "version": "0.111.0", - "resolved": "https://registry.npmjs.org/@oxc-transform/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-0.111.0.tgz", - "integrity": "sha512-+se3579Wp7VOk8TnTZCpT+obTAyzOw2b/UuoM0+51LtbzCSfjKxd4A+o7zRl7GyPrPZvx57KdbMOC9rWB1xNrw==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@oxc-transform/binding-linux-arm-musleabihf": { - "version": "0.111.0", - "resolved": "https://registry.npmjs.org/@oxc-transform/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-0.111.0.tgz", - "integrity": "sha512-8faC99pStqaSDPK/vBgaagAHUeL0LcIzfeSjSiDTtvPGc3AwZIeqC1tx3CP15a6tWXjdgS/IUw4IjfD5HweBlg==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@oxc-transform/binding-linux-arm64-gnu": { - "version": "0.111.0", - "resolved": "https://registry.npmjs.org/@oxc-transform/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-0.111.0.tgz", - "integrity": "sha512-HtfQv8j796gzI5WR/RaP6IMwFpiL0vYeDrUA1hYhlPzTHKYan/B+NlhJkKOI1v24yAl/yEnFmb0pxIxLNqBqBA==", - "cpu": [ - "arm64" - ], - "dev": true, - "libc": [ - "glibc" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@oxc-transform/binding-linux-arm64-musl": { - "version": "0.111.0", - "resolved": "https://registry.npmjs.org/@oxc-transform/binding-linux-arm64-musl/-/binding-linux-arm64-musl-0.111.0.tgz", - "integrity": "sha512-ARyfcMCIxVLDgLf6FQ8Oo1/TFySpnquV+vuSb4SFQZfYDqgMklzwv0NYXxWD0aB6enElyMDs6pQJBzusEKCkOg==", - "cpu": [ - "arm64" - ], - "dev": true, - "libc": [ - "musl" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@oxc-transform/binding-linux-ppc64-gnu": { - "version": "0.111.0", - "resolved": "https://registry.npmjs.org/@oxc-transform/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-0.111.0.tgz", - "integrity": "sha512-PKpVRrSvBNK3tv9vwxn7Fay+QWZmprPGlEqJcseBJllQc5mFMD4Q/w44chu5iR9ZLsDeSHzmNWrgMLo4J0sP2A==", - "cpu": [ - "ppc64" - ], - "dev": true, - "libc": [ - "glibc" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@oxc-transform/binding-linux-riscv64-gnu": { - "version": "0.111.0", - "resolved": "https://registry.npmjs.org/@oxc-transform/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-0.111.0.tgz", - "integrity": "sha512-9bUml6rMgk+8GF5rvNMweFspkzSiCjqpV6HduwiUyexqfGKrmjq9IZOxxvnzkE2RGdQzP507NNDoVNYIoGQYuA==", - "cpu": [ - "riscv64" - ], - "dev": true, - "libc": [ - "glibc" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@oxc-transform/binding-linux-riscv64-musl": { - "version": "0.111.0", - "resolved": "https://registry.npmjs.org/@oxc-transform/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-0.111.0.tgz", - "integrity": "sha512-tzGCohGxaeH6KRJjfYZd4mHCoGjCai6N+zZi1Oj+tSDMAAdyvs1dRzYb8PNUGnybCg3Te4M0jLPzWZaSmnKraQ==", - "cpu": [ - "riscv64" - ], - "dev": true, - "libc": [ - "musl" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@oxc-transform/binding-linux-s390x-gnu": { - "version": "0.111.0", - "resolved": "https://registry.npmjs.org/@oxc-transform/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-0.111.0.tgz", - "integrity": "sha512-sRG1KIfZ0ML9ToEygm5aM/5GJeBA05uHlgW3M0Rx/DNWMJhuahLmqWuB02aWSmijndLfEKXLLXIWhvWupRG8lg==", - "cpu": [ - "s390x" - ], - "dev": true, - "libc": [ - "glibc" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@oxc-transform/binding-linux-x64-gnu": { - "version": "0.111.0", - "resolved": "https://registry.npmjs.org/@oxc-transform/binding-linux-x64-gnu/-/binding-linux-x64-gnu-0.111.0.tgz", - "integrity": "sha512-T0Kmvk+OdlUdABdXlDIf3MQReMzFfC75NEI9x8jxy5pKooACEFg0k0V8gyR3gq4DzbDCfucqFQDWNvSgIopAbQ==", - "cpu": [ - "x64" - ], - "dev": true, - "libc": [ - "glibc" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@oxc-transform/binding-linux-x64-musl": { - "version": "0.111.0", - "resolved": "https://registry.npmjs.org/@oxc-transform/binding-linux-x64-musl/-/binding-linux-x64-musl-0.111.0.tgz", - "integrity": "sha512-EgoutsP3YfqzN8a9vpc9+XLr0bmBl0dA3uOMiP77+exATCPxJBkJErGmQkqk6RtTp5XqX6q6mB45qWQyKk6+pA==", - "cpu": [ - "x64" - ], - "dev": true, - "libc": [ - "musl" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@oxc-transform/binding-openharmony-arm64": { - "version": "0.111.0", - "resolved": "https://registry.npmjs.org/@oxc-transform/binding-openharmony-arm64/-/binding-openharmony-arm64-0.111.0.tgz", - "integrity": "sha512-d8J+ejc0j5WODbVwR/QxFaI65YMwvG0W53vcVCHwa6ja1QI5lpe7sislrefG2EFYgnY47voMRzlXab5d4gEcDw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openharmony" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@oxc-transform/binding-wasm32-wasi": { - "version": "0.111.0", - "resolved": "https://registry.npmjs.org/@oxc-transform/binding-wasm32-wasi/-/binding-wasm32-wasi-0.111.0.tgz", - "integrity": "sha512-HtyIZO8IwuZgXkyb56rysLz1OLbfLhEu8A3BeuyJXzUseAj96yuxgGt3cu3QYX9AXb9pfRfA3c/fvlhsDugyTQ==", - "cpu": [ - "wasm32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "@napi-rs/wasm-runtime": "^1.1.1" - }, - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/@oxc-transform/binding-win32-arm64-msvc": { - "version": "0.111.0", - "resolved": "https://registry.npmjs.org/@oxc-transform/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-0.111.0.tgz", - "integrity": "sha512-YeP80Riptc0MkVVBnzbmoFuHVLUq278+MbwNo9sTLALmzTIJxJqN029xRZbG+Bun7aLsoZhmRnm3J5JZ1NcP5w==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@oxc-transform/binding-win32-ia32-msvc": { - "version": "0.111.0", - "resolved": "https://registry.npmjs.org/@oxc-transform/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-0.111.0.tgz", - "integrity": "sha512-A6ztCXpoSHt6PbvGAFqB0MLOcGG7ZJrrPXY1iB0zfOB1atLgI8oNePGxPl03XSbwpiTsFJ1oo8rj9DXcBzgT9g==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@oxc-transform/binding-win32-x64-msvc": { - "version": "0.111.0", - "resolved": "https://registry.npmjs.org/@oxc-transform/binding-win32-x64-msvc/-/binding-win32-x64-msvc-0.111.0.tgz", - "integrity": "sha512-QddKW4kBH0Wof6Y65eYCNHM4iOGmCTWLLcNYY1FGswhzmTYOUVXajNROR+iCXAOFnOF0ldtsR79SyqgyHH1Bgg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@renovatebot/pep440": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/@renovatebot/pep440/-/pep440-4.2.1.tgz", - "integrity": "sha512-2FK1hF93Fuf1laSdfiEmJvSJPVIDHEUTz68D3Fi9s0IZrrpaEcj6pTFBTbYvsgC5du4ogrtf5re7yMMvrKNgkw==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": "^20.9.0 || ^22.11.0 || ^24", - "pnpm": "^10.0.0" - } - }, - "node_modules/@rolldown/binding-android-arm64": { - "version": "1.0.0-rc.1", - "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.1.tgz", - "integrity": "sha512-He6ZoCfv5D7dlRbrhNBkuMVIHd0GDnjJwbICE1OWpG7G3S2gmJ+eXkcNLJjzjNDpeI2aRy56ou39AJM9AD8YFA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-darwin-arm64": { - "version": "1.0.0-rc.1", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-rc.1.tgz", - "integrity": "sha512-YzJdn08kSOXnj85ghHauH2iHpOJ6eSmstdRTLyaziDcUxe9SyQJgGyx/5jDIhDvtOcNvMm2Ju7m19+S/Rm1jFg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-darwin-x64": { - "version": "1.0.0-rc.1", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-rc.1.tgz", - "integrity": "sha512-cIvAbqM+ZVV6lBSKSBtlNqH5iCiW933t1q8j0H66B3sjbe8AxIRetVqfGgcHcJtMzBIkIALlL9fcDrElWLJQcQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-freebsd-x64": { - "version": "1.0.0-rc.1", - "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-rc.1.tgz", - "integrity": "sha512-rVt+B1B/qmKwCl1XD02wKfgh3vQPXRXdB/TicV2w6g7RVAM1+cZcpigwhLarqiVCxDObFZ7UgXCxPC7tpDoRog==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-linux-arm-gnueabihf": { - "version": "1.0.0-rc.1", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-rc.1.tgz", - "integrity": "sha512-69YKwJJBOFprQa1GktPgbuBOfnn+EGxu8sBJ1TjPER+zhSpYeaU4N07uqmyBiksOLGXsMegymuecLobfz03h8Q==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-linux-arm64-gnu": { - "version": "1.0.0-rc.1", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-rc.1.tgz", - "integrity": "sha512-9JDhHUf3WcLfnViFWm+TyorqUtnSAHaCzlSNmMOq824prVuuzDOK91K0Hl8DUcEb9M5x2O+d2/jmBMsetRIn3g==", - "cpu": [ - "arm64" - ], - "dev": true, - "libc": [ - "glibc" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-linux-arm64-musl": { - "version": "1.0.0-rc.1", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-rc.1.tgz", - "integrity": "sha512-UvApLEGholmxw/HIwmUnLq3CwdydbhaHHllvWiCTNbyGom7wTwOtz5OAQbAKZYyiEOeIXZNPkM7nA4Dtng7CLw==", - "cpu": [ - "arm64" - ], - "dev": true, - "libc": [ - "musl" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-linux-x64-gnu": { - "version": "1.0.0-rc.1", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-rc.1.tgz", - "integrity": "sha512-uVctNgZHiGnJx5Fij7wHLhgw4uyZBVi6mykeWKOqE7bVy9Hcxn0fM/IuqdMwk6hXlaf9fFShDTFz2+YejP+x0A==", - "cpu": [ - "x64" - ], - "dev": true, - "libc": [ - "glibc" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-linux-x64-musl": { - "version": "1.0.0-rc.1", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-rc.1.tgz", - "integrity": "sha512-T6Eg0xWwcxd/MzBcuv4Z37YVbUbJxy5cMNnbIt/Yr99wFwli30O4BPlY8hKeGyn6lWNtU0QioBS46lVzDN38bg==", - "cpu": [ - "x64" - ], - "dev": true, - "libc": [ - "musl" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-openharmony-arm64": { - "version": "1.0.0-rc.1", - "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-rc.1.tgz", - "integrity": "sha512-PuGZVS2xNJyLADeh2F04b+Cz4NwvpglbtWACgrDOa5YDTEHKwmiTDjoD5eZ9/ptXtcpeFrMqD2H4Zn33KAh1Eg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openharmony" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-wasm32-wasi": { - "version": "1.0.0-rc.1", - "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-rc.1.tgz", - "integrity": "sha512-2mOxY562ihHlz9lEXuaGEIDCZ1vI+zyFdtsoa3M62xsEunDXQE+DVPO4S4x5MPK9tKulG/aFcA/IH5eVN257Cw==", - "cpu": [ - "wasm32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "@napi-rs/wasm-runtime": "^1.1.1" - }, - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/@rolldown/binding-win32-arm64-msvc": { - "version": "1.0.0-rc.1", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.1.tgz", - "integrity": "sha512-oQVOP5cfAWZwRD0Q3nGn/cA9FW3KhMMuQ0NIndALAe6obqjLhqYVYDiGGRGrxvnjJsVbpLwR14gIUYnpIcHR1g==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-win32-x64-msvc": { - "version": "1.0.0-rc.1", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-rc.1.tgz", - "integrity": "sha512-Ydsxxx++FNOuov3wCBPaYjZrEvKOOGq3k+BF4BPridhg2pENfitSRD2TEuQ8i33bp5VptuNdC9IzxRKU031z5A==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/pluginutils": { - "version": "1.0.0-rc.1", - "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.1.tgz", - "integrity": "sha512-UTBjtTxVOhodhzFVp/ayITaTETRHPUPYZPXQe0WU0wOgxghMojXxYjOiPOauKIYNWJAWS2fd7gJgGQK8GU8vDA==", - "dev": true, - "license": "MIT" - }, - "node_modules/@rollup/pluginutils": { - "version": "5.3.0", - "resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-5.3.0.tgz", - "integrity": "sha512-5EdhGZtnu3V88ces7s53hhfK5KSASnJZv8Lulpc04cWO3REESroJXg73DFsOmgbU2BhwV0E20bu2IDZb3VKW4Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/estree": "^1.0.0", - "estree-walker": "^2.0.2", - "picomatch": "^4.0.2" - }, - "engines": { - "node": ">=14.0.0" - }, - "peerDependencies": { - "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0" - }, - "peerDependenciesMeta": { - "rollup": { - "optional": true - } - } - }, - "node_modules/@sinclair/typebox": { - "version": "0.25.24", - "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.25.24.tgz", - "integrity": "sha512-XJfwUVUKDHF5ugKwIcxEgc9k8b7HbznCp6eUfWgu710hMPNIO4aw4/zB5RogDQz8nd6gyCDpU9O/m6qYEWY6yQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/@tootallnate/once": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-2.0.0.tgz", - "integrity": "sha512-XCuKFP5PS55gnMVu3dty8KPatLqUoy/ZYzDzAGCQ8JNFCkLXzmI7vNHCR+XpbZaMWQK/vQubr7PkYq8g470J/A==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 10" - } - }, - "node_modules/@tootallnate/quickjs-emscripten": { - "version": "0.23.0", - "resolved": "https://registry.npmjs.org/@tootallnate/quickjs-emscripten/-/quickjs-emscripten-0.23.0.tgz", - "integrity": "sha512-C5Mc6rdnsaJDjO3UpGW/CQTHtCKaYlScZTly4JIu97Jxo/odCiH0ITnDXSJPTOrEKk/ycSZ0AOgTmkDtkOsvIA==", - "dev": true, - "license": "MIT" - }, - "node_modules/@ts-morph/common": { - "version": "0.11.1", - "resolved": "https://registry.npmjs.org/@ts-morph/common/-/common-0.11.1.tgz", - "integrity": "sha512-7hWZS0NRpEsNV8vWJzg7FEz6V8MaLNeJOmwmghqUXTpzk16V1LLZhdo+4QvE/+zv4cVci0OviuJFnqhEfoV3+g==", - "dev": true, - "license": "MIT", - "dependencies": { - "fast-glob": "^3.2.7", - "minimatch": "^3.0.4", - "mkdirp": "^1.0.4", - "path-browserify": "^1.0.1" - } - }, - "node_modules/@ts-morph/common/node_modules/minimatch": { - "version": "3.1.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", - "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", - "dev": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^1.1.7" - }, - "engines": { - "node": "*" - } - }, - "node_modules/@tybys/wasm-util": { - "version": "0.10.2", - "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.2.tgz", - "integrity": "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, - "node_modules/@types/estree": { - "version": "1.0.9", - "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", - "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/json-schema": { - "version": "7.0.15", - "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", - "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/node": { - "version": "20.11.0", - "resolved": "https://registry.npmjs.org/@types/node/-/node-20.11.0.tgz", - "integrity": "sha512-o9bjXmDNcF7GbM4CNQpmi+TutCgap/K3w1JyKgxAjqx41zp9qlIAVFi0IhCNsJcXolEqLWhbFbEeL0PvYm4pcQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "undici-types": "~5.26.4" - } - }, - "node_modules/@vercel/backends": { - "version": "0.8.1", - "resolved": "https://registry.npmjs.org/@vercel/backends/-/backends-0.8.1.tgz", - "integrity": "sha512-0av0e3NT7fIe4bRFYolMe4rfpORmURifHEuhkd8m3nHeOsekwWqFDBl8PdwwBx1soXg9L/kQe9K/519h26HLBA==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@vercel/build-utils": "13.26.3", - "@vercel/nft": "1.5.0", - "execa": "3.2.0", - "fs-extra": "11.1.0", - "get-port": "5.1.1", - "oxc-transform": "0.111.0", - "path-to-regexp": "8.3.0", - "resolve.exports": "2.0.3", - "rolldown": "1.0.0-rc.1", - "srvx": "0.8.9", - "tsx": "4.21.0", - "zod": "3.22.4" - } - }, - "node_modules/@vercel/backends/node_modules/zod": { - "version": "3.22.4", - "resolved": "https://registry.npmjs.org/zod/-/zod-3.22.4.tgz", - "integrity": "sha512-iC+8Io04lddc+mVqQ9AZ7OQ2MrUKGN+oIQyq1vemgt46jwCwLfhq7/pwnBnNXXXZb8VTVLKwp9EDkx+ryxIWmg==", - "dev": true, - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/colinhacks" - } - }, - "node_modules/@vercel/blob": { - "version": "2.4.0", - "resolved": "https://registry.npmjs.org/@vercel/blob/-/blob-2.4.0.tgz", - "integrity": "sha512-ncQ8CRb6XoEAYJwjOTRGpACRT6h/AeY+/33gLyeVxG5BIes27OPm1jmqreF+JHjcTmGhClTP+kBpmyLfbV0xew==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "async-retry": "^1.3.3", - "is-buffer": "^2.0.5", - "is-node-process": "^1.2.0", - "throttleit": "^2.1.0", - "undici": "^6.23.0" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@vercel/build-utils": { - "version": "13.26.3", - "resolved": "https://registry.npmjs.org/@vercel/build-utils/-/build-utils-13.26.3.tgz", - "integrity": "sha512-56N47yvDJCrwBNJg7Ty0d+52AvAVD5PFfaKF9iM7toJXTihkh1NHrRS2n06eopoNGAEVA8mTZE++lC5rk3pb8Q==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@vercel/python-analysis": "0.11.1", - "cjs-module-lexer": "1.2.3", - "es-module-lexer": "1.5.0" - } - }, - "node_modules/@vercel/cervel": { - "version": "0.1.9", - "resolved": "https://registry.npmjs.org/@vercel/cervel/-/cervel-0.1.9.tgz", - "integrity": "sha512-ZD/OgnpheuSR6g/YvzRjSpe7SE8lMMezCJf3is7SMs3M1Z3pgbvfNGOvNd36AyncTTSYOMgRZpv1pKA29E/aXg==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@vercel/backends": "0.8.1" - }, - "bin": { - "cervel": "bin/cervel.mjs" - } - }, - "node_modules/@vercel/cli-config": { - "version": "0.1.2", - "resolved": "https://registry.npmjs.org/@vercel/cli-config/-/cli-config-0.1.2.tgz", - "integrity": "sha512-XQOcuCM+8tKjh3sfgGRKRuNh78u2D8uGpDJIFcCtFi2tUqbGvqmJo790XX7+Bwakk08y0FCrs2JlEjvvwRhpAg==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "xdg-app-paths": "5", - "zod": "4.1.11" - } - }, - "node_modules/@vercel/detect-agent": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/@vercel/detect-agent/-/detect-agent-1.2.3.tgz", - "integrity": "sha512-VYNCgUc0nOmC4WJmWw9GkrKdfr8Zl4/rxhC5SvgacBgxiW9W/9NRttUoHHXV8xdII3MaRgkZZVX8Ikzc/Jmjag==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=14" - } - }, - "node_modules/@vercel/elysia": { - "version": "0.1.82", - "resolved": "https://registry.npmjs.org/@vercel/elysia/-/elysia-0.1.82.tgz", - "integrity": "sha512-A1KiH4Ydswm8x+E4nN5wV7LYmNGnZ6q4TdtNEOb6q8wwVe3GEjwFTOQC7yqFScibRMvhUpFooBlNgGU7BtVJ6Q==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@vercel/node": "5.8.6", - "@vercel/static-config": "3.4.0" - } - }, - "node_modules/@vercel/error-utils": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/@vercel/error-utils/-/error-utils-2.1.0.tgz", - "integrity": "sha512-DiJcXBOB9N6QM4d7hYPM9Ck/AUjzBl58XNQPxS74o7CuvIanjzrGgygP/70VsyEASeIJMazk1LrhwcNTR/eZGQ==", - "dev": true, - "license": "Apache-2.0" - }, - "node_modules/@vercel/express": { - "version": "0.1.92", - "resolved": "https://registry.npmjs.org/@vercel/express/-/express-0.1.92.tgz", - "integrity": "sha512-ZTQqtiyt7IAL9e4QZgx1ZUa6ngRV97zfVNOriBvTXtcZzFVbrDxmTlTVQq3NDw54TYyyigpMb1VmsWUOxXPWJQ==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@vercel/cervel": "0.1.9", - "@vercel/nft": "1.5.0", - "@vercel/node": "5.8.6", - "@vercel/static-config": "3.4.0", - "fs-extra": "11.1.0", - "path-to-regexp": "8.3.0", - "ts-morph": "12.0.0", - "zod": "3.22.4" - } - }, - "node_modules/@vercel/express/node_modules/zod": { - "version": "3.22.4", - "resolved": "https://registry.npmjs.org/zod/-/zod-3.22.4.tgz", - "integrity": "sha512-iC+8Io04lddc+mVqQ9AZ7OQ2MrUKGN+oIQyq1vemgt46jwCwLfhq7/pwnBnNXXXZb8VTVLKwp9EDkx+ryxIWmg==", - "dev": true, - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/colinhacks" - } - }, - "node_modules/@vercel/fastify": { - "version": "0.1.85", - "resolved": "https://registry.npmjs.org/@vercel/fastify/-/fastify-0.1.85.tgz", - "integrity": "sha512-ifItSoqkijMrCZNsWtsu70LfreNExQoErw7ttoMY/bpwg0kKQaNf9xav7L0qZgGXQJ5lTbWUDeSkcxa4Zo0tpw==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@vercel/node": "5.8.6", - "@vercel/static-config": "3.4.0" - } - }, - "node_modules/@vercel/fun": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/@vercel/fun/-/fun-1.3.0.tgz", - "integrity": "sha512-8erw9uPe0dFg45THkNxmjtvMX143SkZebmjgSVbcM3XCkXu3RIiBaJMcMNG8aaS+rnTuw8+d4De9HVT0M/r3wg==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@tootallnate/once": "2.0.0", - "async-listen": "1.2.0", - "debug": "4.3.4", - "generic-pool": "3.4.2", - "micro": "9.3.5-canary.3", - "ms": "2.1.1", - "node-fetch": "2.6.7", - "path-to-regexp": "8.2.0", - "promisepipe": "3.0.0", - "semver": "7.5.4", - "stat-mode": "0.3.0", - "stream-to-promise": "2.2.0", - "tar": "7.5.7", - "tinyexec": "0.3.2", - "tree-kill": "1.2.2", - "uid-promise": "1.0.0", - "xdg-app-paths": "5.1.0", - "yauzl-promise": "2.1.3" - }, - "engines": { - "node": ">= 18" - } - }, - "node_modules/@vercel/fun/node_modules/path-to-regexp": { - "version": "8.2.0", - "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.2.0.tgz", - "integrity": "sha512-TdrF7fW9Rphjq4RjrW0Kp2AW0Ahwu9sRGTkS6bvDi0SCwZlEZYmcfDbEsTz8RVk0EHIS/Vd1bv3JhG+1xZuAyQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=16" - } - }, - "node_modules/@vercel/fun/node_modules/xdg-app-paths": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/xdg-app-paths/-/xdg-app-paths-5.1.0.tgz", - "integrity": "sha512-RAQ3WkPf4KTU1A8RtFx3gWywzVKe00tfOPFfl2NDGqbIFENQO4kqAJp7mhQjNj/33W5x5hiWWUdyfPq/5SU3QA==", - "dev": true, - "license": "MIT", - "dependencies": { - "xdg-portable": "^7.0.0" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/@vercel/gatsby-plugin-vercel-analytics": { - "version": "1.0.11", - "resolved": "https://registry.npmjs.org/@vercel/gatsby-plugin-vercel-analytics/-/gatsby-plugin-vercel-analytics-1.0.11.tgz", - "integrity": "sha512-iTEA0vY6RBPuEzkwUTVzSHDATo1aF6bdLLspI68mQ/BTbi5UQEGjpjyzdKOVcSYApDtFU6M6vypZ1t4vIEnHvw==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "web-vitals": "0.2.4" - } - }, - "node_modules/@vercel/gatsby-plugin-vercel-builder": { - "version": "2.2.9", - "resolved": "https://registry.npmjs.org/@vercel/gatsby-plugin-vercel-builder/-/gatsby-plugin-vercel-builder-2.2.9.tgz", - "integrity": "sha512-VJukNlVhzlSh/d1tZHBho8LFlO4oYHoGrNd/uUMhNDlqNAytAwpo07nc0CISFA12TFGjkoTs9hCykOsApjQs9g==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@sinclair/typebox": "0.25.24", - "@vercel/build-utils": "13.26.3", - "esbuild": "0.27.0", - "etag": "1.8.1", - "fs-extra": "11.1.0" - } - }, - "node_modules/@vercel/go": { - "version": "3.8.0", - "resolved": "https://registry.npmjs.org/@vercel/go/-/go-3.8.0.tgz", - "integrity": "sha512-ftQqQMn3sGdL8mdIqfcS3YZg6dazM/h4s0jkY37oVV1rPdh7Aq/GL0oMjv1L+PoIk5uJEAyBan7C8Yisp4LH+g==", - "dev": true, - "license": "Apache-2.0" - }, - "node_modules/@vercel/h3": { - "version": "0.1.91", - "resolved": "https://registry.npmjs.org/@vercel/h3/-/h3-0.1.91.tgz", - "integrity": "sha512-R+QDhiaETBR2o1usGzjRgCLXMmQIcX2ndLGwpY1bXeznkueE2P+rWCUWnfEHxbKnjrciQLXN54z3zAjljlppxA==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@vercel/node": "5.8.6", - "@vercel/static-config": "3.4.0" - } - }, - "node_modules/@vercel/hono": { - "version": "0.2.85", - "resolved": "https://registry.npmjs.org/@vercel/hono/-/hono-0.2.85.tgz", - "integrity": "sha512-zHKpWN3663cPLWAYq7U2lZWFY7vMtc16aAcwMMeUQoodoo4KUMclt8mPYN5fwvmPWwuZQOPI3FN1bYszgGDweQ==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@vercel/nft": "1.5.0", - "@vercel/node": "5.8.6", - "@vercel/static-config": "3.4.0", - "fs-extra": "11.1.0", - "path-to-regexp": "8.3.0", - "ts-morph": "12.0.0", - "zod": "3.22.4" - } - }, - "node_modules/@vercel/hono/node_modules/zod": { - "version": "3.22.4", - "resolved": "https://registry.npmjs.org/zod/-/zod-3.22.4.tgz", - "integrity": "sha512-iC+8Io04lddc+mVqQ9AZ7OQ2MrUKGN+oIQyq1vemgt46jwCwLfhq7/pwnBnNXXXZb8VTVLKwp9EDkx+ryxIWmg==", - "dev": true, - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/colinhacks" - } - }, - "node_modules/@vercel/hydrogen": { - "version": "1.3.8", - "resolved": "https://registry.npmjs.org/@vercel/hydrogen/-/hydrogen-1.3.8.tgz", - "integrity": "sha512-ANCJg+FyZQpP2tntc9GUXQDGtOpQ/soykJGB1WBeCqn96QJFfSzRHHz1MHCh163MrKjO5Hx5cnCYUgRYRXVSjA==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@vercel/static-config": "3.4.0", - "ts-morph": "12.0.0" - } - }, - "node_modules/@vercel/koa": { - "version": "0.1.65", - "resolved": "https://registry.npmjs.org/@vercel/koa/-/koa-0.1.65.tgz", - "integrity": "sha512-iPBWRtemNI0nZpUMtl0hoa9UReRo6FvqNd63r4eHF6uUZb6bDGNiQPSMQ/3hSzUc4ZbllIF36TlzxG/EtUc7UQ==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@vercel/node": "5.8.6", - "@vercel/static-config": "3.4.0" - } - }, - "node_modules/@vercel/nestjs": { - "version": "0.2.86", - "resolved": "https://registry.npmjs.org/@vercel/nestjs/-/nestjs-0.2.86.tgz", - "integrity": "sha512-9EwLUWemUAgBRjjRm5GQXjeiVS7qdH3Y1AMCQiDfBB0m0qp7fy2IsgNRgcpuZEzRB0ImcDvyVlnxPZJnQcwKDg==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@vercel/node": "5.8.6", - "@vercel/static-config": "3.4.0" - } - }, - "node_modules/@vercel/next": { - "version": "4.17.4", - "resolved": "https://registry.npmjs.org/@vercel/next/-/next-4.17.4.tgz", - "integrity": "sha512-XsvV4pwphvrSgRTlpkSOiraST9ZrzEXRpEKABaR3cLnVf/2OvY4ZHb7uGDWX1ogNKadEZKSVgk5nKBueornANw==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@vercel/nft": "1.5.0" - } - }, - "node_modules/@vercel/nft": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/@vercel/nft/-/nft-1.5.0.tgz", - "integrity": "sha512-IWTDeIoWhQ7ZtRO/JRKH+jhmeQvZYhtGPmzw/QGDY+wDCQqfm25P9yIdoAFagu4fWsK4IwZXDFIjrmp5rRm/sA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@mapbox/node-pre-gyp": "^2.0.0", - "@rollup/pluginutils": "^5.1.3", - "acorn": "^8.6.0", - "acorn-import-attributes": "^1.9.5", - "async-sema": "^3.1.1", - "bindings": "^1.4.0", - "estree-walker": "2.0.2", - "glob": "^13.0.0", - "graceful-fs": "^4.2.9", - "node-gyp-build": "^4.2.2", - "picomatch": "^4.0.2", - "resolve-from": "^5.0.0" - }, - "bin": { - "nft": "out/cli.js" - }, - "engines": { - "node": ">=20" - } - }, - "node_modules/@vercel/node": { - "version": "5.8.6", - "resolved": "https://registry.npmjs.org/@vercel/node/-/node-5.8.6.tgz", - "integrity": "sha512-bZ1XZADW7nTK6foB0PMk3u82RWwwl/GBTeMVUSCUhy6CWYak7URJD9oUeXhaX7y6lMNZX6kfJ5SBH6FeRxOdNQ==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@edge-runtime/node-utils": "2.3.0", - "@edge-runtime/primitives": "4.1.0", - "@edge-runtime/vm": "3.2.0", - "@types/node": "20.11.0", - "@vercel/build-utils": "13.26.3", - "@vercel/error-utils": "2.1.0", - "@vercel/nft": "1.5.0", - "@vercel/static-config": "3.4.0", - "async-listen": "3.0.0", - "cjs-module-lexer": "1.2.3", - "edge-runtime": "2.5.9", - "es-module-lexer": "1.4.1", - "esbuild": "0.27.0", - "etag": "1.8.1", - "mime-types": "2.1.35", - "node-fetch": "2.6.9", - "path-to-regexp": "6.1.0", - "path-to-regexp-updated": "npm:path-to-regexp@6.3.0", - "ts-morph": "12.0.0", - "tsx": "4.21.0", - "typescript": "npm:typescript@5.9.3", - "undici": "5.28.4" - } - }, - "node_modules/@vercel/node/node_modules/async-listen": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/async-listen/-/async-listen-3.0.0.tgz", - "integrity": "sha512-V+SsTpDqkrWTimiotsyl33ePSjA5/KrithwupuvJ6ztsqPvGv6ge4OredFhPffVXiLN/QUWvE0XcqJaYgt6fOg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 14" - } - }, - "node_modules/@vercel/node/node_modules/es-module-lexer": { - "version": "1.4.1", - "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.4.1.tgz", - "integrity": "sha512-cXLGjP0c4T3flZJKQSuziYoq7MlT+rnvfZjfp7h+I7K9BNX54kP9nyWvdbwjQ4u1iWbOL4u96fgeZLToQlZC7w==", - "dev": true, - "license": "MIT" - }, - "node_modules/@vercel/node/node_modules/node-fetch": { - "version": "2.6.9", - "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.6.9.tgz", - "integrity": "sha512-DJm/CJkZkRjKKj4Zi4BsKVZh3ValV5IR5s7LVZnW+6YMh0W1BfNA8XSs6DLMGYlId5F3KnA70uu2qepcR08Qqg==", - "dev": true, - "license": "MIT", - "dependencies": { - "whatwg-url": "^5.0.0" - }, - "engines": { - "node": "4.x || >=6.0.0" - }, - "peerDependencies": { - "encoding": "^0.1.0" - }, - "peerDependenciesMeta": { - "encoding": { - "optional": true - } - } - }, - "node_modules/@vercel/node/node_modules/path-to-regexp": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-6.1.0.tgz", - "integrity": "sha512-h9DqehX3zZZDCEm+xbfU0ZmwCGFCAAraPJWMXJ4+v32NjZJilVg3k1TcKsRgIb8IQ/izZSaydDc1OhJCZvs2Dw==", - "dev": true, - "license": "MIT" - }, - "node_modules/@vercel/node/node_modules/undici": { - "version": "5.28.4", - "resolved": "https://registry.npmjs.org/undici/-/undici-5.28.4.tgz", - "integrity": "sha512-72RFADWFqKmUb2hmmvNODKL3p9hcB6Gt2DOQMis1SEBaV6a4MH8soBvzg+95CYhCKPFedut2JY9bMfrDl9D23g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@fastify/busboy": "^2.0.0" - }, - "engines": { - "node": ">=14.0" - } - }, - "node_modules/@vercel/oidc": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/@vercel/oidc/-/oidc-3.2.0.tgz", - "integrity": "sha512-UycprH3T6n3jH0k44NHMa7pnFHGu/N05MjojYr+Mc6I7obkoLIJujSWwin1pCvdy/eOxrI/l3uDLQsmcrOb4ug==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">= 20" - } - }, - "node_modules/@vercel/prepare-flags-definitions": { - "version": "0.2.1", - "resolved": "https://registry.npmjs.org/@vercel/prepare-flags-definitions/-/prepare-flags-definitions-0.2.1.tgz", - "integrity": "sha512-ouXTsqn7I9xZ1KKezgvn/w3tZeQHL/tc52j9GHiOYi6kT8xgdbT8s2x8C9BQr44iceX0hfhtZwk9q7NuI2Tqbw==", - "dev": true, - "license": "MIT" - }, - "node_modules/@vercel/python": { - "version": "6.43.3", - "resolved": "https://registry.npmjs.org/@vercel/python/-/python-6.43.3.tgz", - "integrity": "sha512-JB4ldGumk/DvRx6m41llO+jYtGlrve6Qc59YyKOnU++f42VYKA74jYEadPknrqhelmCCFaQQJF/7BD97PuWr6A==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@vercel/python-analysis": "0.11.1" - } - }, - "node_modules/@vercel/python-analysis": { - "version": "0.11.1", - "resolved": "https://registry.npmjs.org/@vercel/python-analysis/-/python-analysis-0.11.1.tgz", - "integrity": "sha512-EPPLuXJQhIDUx08H9nG76AR2HSgBquwe3OAX5s2w20M923iaWeGGVkhX/4yZ89CJfXEZgE1Aj/mX7lVHOVIcYA==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@bytecodealliance/preview2-shim": "0.17.6", - "@renovatebot/pep440": "4.2.1", - "fs-extra": "11.1.1", - "js-yaml": "4.1.1", - "minimatch": "10.1.1", - "smol-toml": "1.5.2", - "zod": "3.22.4" - } - }, - "node_modules/@vercel/python-analysis/node_modules/fs-extra": { - "version": "11.1.1", - "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.1.1.tgz", - "integrity": "sha512-MGIE4HOvQCeUCzmlHs0vXpih4ysz4wg9qiSAu6cd42lVwPbTM1TjV7RusoyQqMmk/95gdQZX72u+YW+c3eEpFQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "graceful-fs": "^4.2.0", - "jsonfile": "^6.0.1", - "universalify": "^2.0.0" - }, - "engines": { - "node": ">=14.14" - } - }, - "node_modules/@vercel/python-analysis/node_modules/zod": { - "version": "3.22.4", - "resolved": "https://registry.npmjs.org/zod/-/zod-3.22.4.tgz", - "integrity": "sha512-iC+8Io04lddc+mVqQ9AZ7OQ2MrUKGN+oIQyq1vemgt46jwCwLfhq7/pwnBnNXXXZb8VTVLKwp9EDkx+ryxIWmg==", - "dev": true, - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/colinhacks" - } - }, - "node_modules/@vercel/redwood": { - "version": "2.4.14", - "resolved": "https://registry.npmjs.org/@vercel/redwood/-/redwood-2.4.14.tgz", - "integrity": "sha512-LSM8rN8hMU98ZFmL4X3ckIuB6k+X6L6HaXRITIdxti83YTUOkZUOoO7iB9mthez5rgYYF6vPlANOG7OrnKhTKw==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@vercel/nft": "1.5.0", - "@vercel/static-config": "3.4.0", - "semver": "6.3.1", - "ts-morph": "12.0.0" - } - }, - "node_modules/@vercel/redwood/node_modules/semver": { - "version": "6.3.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", - "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - } - }, - "node_modules/@vercel/remix-builder": { - "version": "5.8.3", - "resolved": "https://registry.npmjs.org/@vercel/remix-builder/-/remix-builder-5.8.3.tgz", - "integrity": "sha512-0Ke3Pk7SSSf/RLvMdWwhrNeEzIKw1luJi2OeTFdAVNjnrRUcTpBkFG6MaUeRQ9o0RcifSIKJCLvZp+w7bm3oQg==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@vercel/error-utils": "2.1.0", - "@vercel/nft": "1.5.0", - "@vercel/static-config": "3.4.0", - "path-to-regexp": "6.1.0", - "path-to-regexp-updated": "npm:path-to-regexp@6.3.0", - "ts-morph": "12.0.0" - } - }, - "node_modules/@vercel/remix-builder/node_modules/path-to-regexp": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-6.1.0.tgz", - "integrity": "sha512-h9DqehX3zZZDCEm+xbfU0ZmwCGFCAAraPJWMXJ4+v32NjZJilVg3k1TcKsRgIb8IQ/izZSaydDc1OhJCZvs2Dw==", - "dev": true, - "license": "MIT" - }, - "node_modules/@vercel/ruby": { - "version": "2.4.0", - "resolved": "https://registry.npmjs.org/@vercel/ruby/-/ruby-2.4.0.tgz", - "integrity": "sha512-YI7Amyf09hHZWOqDHgJO92XcKh6Pye8rrmJFhlP6euG3o6QjoZzJj7Z2WzjSrDRGMewzEK4uz2+CbNpNS7gLog==", - "dev": true, - "license": "Apache-2.0" - }, - "node_modules/@vercel/rust": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/@vercel/rust/-/rust-1.3.0.tgz", - "integrity": "sha512-z1X0z1NM+ISunm/scgRpuENNwcKlh7DjXn0QvKE0n10DcaYqxkBQVK8iRA9X05xSK9AzPlnB9DHdGKiXZO5buw==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "execa": "5", - "smol-toml": "1.5.2" - } - }, - "node_modules/@vercel/rust/node_modules/execa": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/execa/-/execa-5.1.1.tgz", - "integrity": "sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==", - "dev": true, - "license": "MIT", - "dependencies": { - "cross-spawn": "^7.0.3", - "get-stream": "^6.0.0", - "human-signals": "^2.1.0", - "is-stream": "^2.0.0", - "merge-stream": "^2.0.0", - "npm-run-path": "^4.0.1", - "onetime": "^5.1.2", - "signal-exit": "^3.0.3", - "strip-final-newline": "^2.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sindresorhus/execa?sponsor=1" - } - }, - "node_modules/@vercel/rust/node_modules/get-stream": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-6.0.1.tgz", - "integrity": "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/@vercel/rust/node_modules/human-signals": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-2.1.0.tgz", - "integrity": "sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=10.17.0" - } - }, - "node_modules/@vercel/rust/node_modules/signal-exit": { - "version": "3.0.7", - "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", - "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", - "dev": true, - "license": "ISC" - }, - "node_modules/@vercel/sandbox": { - "version": "1.9.0", - "resolved": "https://registry.npmjs.org/@vercel/sandbox/-/sandbox-1.9.0.tgz", - "integrity": "sha512-zgr1ad0tkT1xZn/8Vxo60wOUOLqMAVGo4WqJQ8/UDcUtWynNJsBjI2tiMdWZrAo9EKH1MIqEzJNkcclF0UT1EQ==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@vercel/oidc": "3.2.0", - "async-retry": "1.3.3", - "jsonlines": "0.1.1", - "ms": "2.1.3", - "picocolors": "^1.1.1", - "tar-stream": "3.1.7", - "undici": "^7.16.0", - "xdg-app-paths": "5.1.0", - "zod": "3.24.4" - } - }, - "node_modules/@vercel/sandbox/node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "dev": true, - "license": "MIT" - }, - "node_modules/@vercel/sandbox/node_modules/picocolors": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", - "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", - "dev": true, - "license": "ISC" - }, - "node_modules/@vercel/sandbox/node_modules/undici": { - "version": "7.26.0", - "resolved": "https://registry.npmjs.org/undici/-/undici-7.26.0.tgz", - "integrity": "sha512-3O9Tf67pGhgOv9jM35AbhkXAKi13f3oy3aE4CSgr+TckGeY+/iu97ZXN+J7DpHPzLbVApFd1IFhcnBjREYXYcg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=20.18.1" - } - }, - "node_modules/@vercel/sandbox/node_modules/xdg-app-paths": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/xdg-app-paths/-/xdg-app-paths-5.1.0.tgz", - "integrity": "sha512-RAQ3WkPf4KTU1A8RtFx3gWywzVKe00tfOPFfl2NDGqbIFENQO4kqAJp7mhQjNj/33W5x5hiWWUdyfPq/5SU3QA==", - "dev": true, - "license": "MIT", - "dependencies": { - "xdg-portable": "^7.0.0" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/@vercel/sandbox/node_modules/zod": { - "version": "3.24.4", - "resolved": "https://registry.npmjs.org/zod/-/zod-3.24.4.tgz", - "integrity": "sha512-OdqJE9UDRPwWsrHjLN2F8bPxvwJBK22EHLWtanu0LSYr5YqzsaaW3RMgmjwr8Rypg5k+meEJdSPXJZXE/yqOMg==", - "dev": true, - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/colinhacks" - } - }, - "node_modules/@vercel/static-build": { - "version": "2.9.32", - "resolved": "https://registry.npmjs.org/@vercel/static-build/-/static-build-2.9.32.tgz", - "integrity": "sha512-2gd6m1zwHKcwR+gXY67E+SI4CtTcvYV1R+wa6MymADnL0ksE49JG4BivCmu2n6oIGs64QjDimVpYDK2hoJlSBw==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@vercel/gatsby-plugin-vercel-analytics": "1.0.11", - "@vercel/gatsby-plugin-vercel-builder": "2.2.9", - "@vercel/static-config": "3.4.0", - "ts-morph": "12.0.0" - } - }, - "node_modules/@vercel/static-config": { - "version": "3.4.0", - "resolved": "https://registry.npmjs.org/@vercel/static-config/-/static-config-3.4.0.tgz", - "integrity": "sha512-wCq90CMUB//ggnFh77NQO1xaLFsS4LigQIqKrH6ohnr9Br/KI1FhlErx62WfCOuueWaW+LVsbLOqNXIUjK8t6A==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "ajv": "8.6.3", - "json-schema-to-ts": "1.6.4", - "ts-morph": "12.0.0" - } - }, - "node_modules/abbrev": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-3.0.1.tgz", - "integrity": "sha512-AO2ac6pjRB3SJmGJo+v5/aK6Omggp6fsLrs6wN9bd35ulu4cCwaAU9+7ZhXjeqHVkaHThLuzH0nZr0YpCDhygg==", - "dev": true, - "license": "ISC", - "engines": { - "node": "^18.17.0 || >=20.5.0" - } - }, - "node_modules/acorn": { - "version": "8.16.0", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", - "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", - "dev": true, - "license": "MIT", - "bin": { - "acorn": "bin/acorn" - }, - "engines": { - "node": ">=0.4.0" - } - }, - "node_modules/acorn-import-attributes": { - "version": "1.9.5", - "resolved": "https://registry.npmjs.org/acorn-import-attributes/-/acorn-import-attributes-1.9.5.tgz", - "integrity": "sha512-n02Vykv5uA3eHGM/Z2dQrcD56kL8TyDb2p1+0P83PClMnC/nc+anbQRhIOWnSq4Ke/KvDPrY3C9hDtC/A3eHnQ==", - "dev": true, - "license": "MIT", - "peerDependencies": { - "acorn": "^8" - } - }, - "node_modules/agent-base": { - "version": "7.1.4", - "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", - "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 14" - } - }, - "node_modules/ajv": { - "version": "8.6.3", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.6.3.tgz", - "integrity": "sha512-SMJOdDP6LqTkD0Uq8qLi+gMwSt0imXLSV080qFVwJCpH9U6Mb+SUGHAXM0KNbcBPguytWyvFxcHgMLe2D2XSpw==", - "dev": true, - "license": "MIT", - "dependencies": { - "fast-deep-equal": "^3.1.1", - "json-schema-traverse": "^1.0.0", - "require-from-string": "^2.0.2", - "uri-js": "^4.2.2" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/epoberezkin" - } - }, - "node_modules/any-promise": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz", - "integrity": "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==", - "dev": true, - "license": "MIT" - }, - "node_modules/arg": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/arg/-/arg-4.1.0.tgz", - "integrity": "sha512-ZWc51jO3qegGkVh8Hwpv636EkbesNV5ZNQPCtRa+0qytRYPEs9IYT9qITY9buezqUH5uqyzlWLcufrzU2rffdg==", - "dev": true, - "license": "MIT" - }, - "node_modules/argparse": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", - "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", - "dev": true, - "license": "Python-2.0" - }, - "node_modules/ast-types": { - "version": "0.13.4", - "resolved": "https://registry.npmjs.org/ast-types/-/ast-types-0.13.4.tgz", - "integrity": "sha512-x1FCFnFifvYDDzTaLII71vG5uvDwgtmDTEVWAxrgeiR8VjMONcCXJx7E+USjDtHlwFmt9MysbqgF9b9Vjr6w+w==", - "dev": true, - "license": "MIT", - "dependencies": { - "tslib": "^2.0.1" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/async-listen": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/async-listen/-/async-listen-1.2.0.tgz", - "integrity": "sha512-CcEtRh/oc9Jc4uWeUwdpG/+Mb2YUHKmdaTf0gUr7Wa+bfp4xx70HOb3RuSTJMvqKNB1TkdTfjLdrcz2X4rkkZA==", - "dev": true, - "license": "MIT" - }, - "node_modules/async-retry": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/async-retry/-/async-retry-1.3.3.tgz", - "integrity": "sha512-wfr/jstw9xNi/0teMHrRW7dsz3Lt5ARhYNZ2ewpadnhaIp5mbALhOAP+EAdsC7t4Z6wqsDVv9+W6gm1Dk9mEyw==", - "dev": true, - "license": "MIT", - "dependencies": { - "retry": "0.13.1" - } - }, - "node_modules/async-sema": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/async-sema/-/async-sema-3.1.1.tgz", - "integrity": "sha512-tLRNUXati5MFePdAk8dw7Qt7DpxPB60ofAgn8WRhW6a2rcimZnYBP9oxHiv0OHy+Wz7kPMG+t4LGdt31+4EmGg==", - "dev": true, - "license": "MIT" - }, - "node_modules/asynckit": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", - "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", - "dev": true, - "license": "MIT" - }, - "node_modules/b4a": { - "version": "1.8.1", - "resolved": "https://registry.npmjs.org/b4a/-/b4a-1.8.1.tgz", - "integrity": "sha512-aiqre1Nr0B/6DgE2N5vwTc+2/oQZ4Wh1t4NznYY4E00y8LCt6NqdRv81so00oo27D8MVKTpUa/MwUUtBLXCoDw==", - "dev": true, - "license": "Apache-2.0", - "peerDependencies": { - "react-native-b4a": "*" - }, - "peerDependenciesMeta": { - "react-native-b4a": { - "optional": true - } - } - }, - "node_modules/balanced-match": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", - "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", - "dev": true, - "license": "MIT" - }, - "node_modules/bare-events": { - "version": "2.8.3", - "resolved": "https://registry.npmjs.org/bare-events/-/bare-events-2.8.3.tgz", - "integrity": "sha512-HdUm8EMQBLaJvGUdidNNbqpA1kYkwNcb+MYxkxCLAPJGQzlv9J0C24h8V65Z4c5GLd/JEALDvpFCQgpLJqc0zw==", - "dev": true, - "license": "Apache-2.0", - "peerDependencies": { - "bare-abort-controller": "*" - }, - "peerDependenciesMeta": { - "bare-abort-controller": { - "optional": true - } - } - }, - "node_modules/basic-ftp": { - "version": "5.3.1", - "resolved": "https://registry.npmjs.org/basic-ftp/-/basic-ftp-5.3.1.tgz", - "integrity": "sha512-bopVNp6ugyA150DDuZfPFdt1KZ5a94ZDiwX4hMgZDzF+GttD80lEy8kj98kbyhLXnPvhtIo93mdnLIjpCAeeOw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10.0.0" - } - }, - "node_modules/bindings": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz", - "integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "file-uri-to-path": "1.0.0" - } - }, - "node_modules/brace-expansion": { - "version": "1.1.15", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.15.tgz", - "integrity": "sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" - } - }, - "node_modules/braces": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", - "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", - "dev": true, - "license": "MIT", - "dependencies": { - "fill-range": "^7.1.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/buffer-crc32": { - "version": "0.2.13", - "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-0.2.13.tgz", - "integrity": "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": "*" - } - }, - "node_modules/bytes": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.0.tgz", - "integrity": "sha512-zauLjrfCG+xvoyaqLoV8bLVXXNGC4JqlxFCutSDWA6fJrTo2ZuvLYTqZ7aHBLZSMOopbzwv8f+wZcVzfVTI2Dg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/call-bind-apply-helpers": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", - "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "function-bind": "^1.1.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/chokidar": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.0.tgz", - "integrity": "sha512-mxIojEAQcuEvT/lyXq+jf/3cO/KoA6z4CeNDGGevTybECPOMFCnQy3OPahluUkbqgPNGw5Bi78UC7Po6Lhy+NA==", - "dev": true, - "license": "MIT", - "dependencies": { - "readdirp": "^4.0.1" - }, - "engines": { - "node": ">= 14.16.0" - }, - "funding": { - "url": "https://paulmillr.com/funding/" - } - }, - "node_modules/chownr": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/chownr/-/chownr-3.0.0.tgz", - "integrity": "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==", - "dev": true, - "license": "BlueOak-1.0.0", - "engines": { - "node": ">=18" - } - }, - "node_modules/cjs-module-lexer": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/cjs-module-lexer/-/cjs-module-lexer-1.2.3.tgz", - "integrity": "sha512-0TNiGstbQmCFwt4akjjBg5pLRTSyj/PkWQ1ZoO2zntmg9yLqSRxwEa4iCfQLGjqhiqBfOJa7W/E8wfGrTDmlZQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/code-block-writer": { - "version": "10.1.1", - "resolved": "https://registry.npmjs.org/code-block-writer/-/code-block-writer-10.1.1.tgz", - "integrity": "sha512-67ueh2IRGst/51p0n6FvPrnRjAGHY5F8xdjkgrYE7DDzpJe6qA07RYQ9VcoUeo5ATOjSOiWpSL3SWBRRbempMw==", - "dev": true, - "license": "MIT" - }, - "node_modules/combined-stream": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", - "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", - "dev": true, - "license": "MIT", - "dependencies": { - "delayed-stream": "~1.0.0" - }, - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/concat-map": { - "version": "0.0.1", - "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", - "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", - "dev": true, - "license": "MIT" - }, - "node_modules/consola": { - "version": "3.4.2", - "resolved": "https://registry.npmjs.org/consola/-/consola-3.4.2.tgz", - "integrity": "sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^14.18.0 || >=16.10.0" - } - }, - "node_modules/content-type": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.4.tgz", - "integrity": "sha512-hIP3EEPs8tB9AT1L+NUqtwOAps4mk2Zob89MWXMHjHWg9milF/j4osnnQLXBCBFBk/tvIG/tUc9mOUJiPBhPXA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/convert-hrtime": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/convert-hrtime/-/convert-hrtime-3.0.0.tgz", - "integrity": "sha512-7V+KqSvMiHp8yWDuwfww06XleMWVVB9b9tURBx+G7UTADuo5hYPuowKloz4OzOqbPezxgo+fdQ1522WzPG4OeA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/cookie-es": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/cookie-es/-/cookie-es-2.0.1.tgz", - "integrity": "sha512-aVf4A4hI2w70LnF7GG+7xDQUkliwiXWXFvTjkip4+b64ygDQ2sJPRSKFDHbxn8o0xu9QzPkMuuiWIXyFSE2slA==", - "dev": true, - "license": "MIT" - }, - "node_modules/cross-spawn": { - "version": "7.0.6", - "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", - "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", - "dev": true, - "license": "MIT", - "dependencies": { - "path-key": "^3.1.0", - "shebang-command": "^2.0.0", - "which": "^2.0.1" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/data-uri-to-buffer": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-6.0.2.tgz", - "integrity": "sha512-7hvf7/GW8e86rW0ptuwS3OcBGDjIi6SZva7hCyWC0yYry2cOPmLIjXAUHI6DK2HsnwJd9ifmt57i8eV2n4YNpw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 14" - } - }, - "node_modules/debug": { - "version": "4.3.4", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", - "integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "ms": "2.1.2" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } - } - }, - "node_modules/debug/node_modules/ms": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", - "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==", - "dev": true, - "license": "MIT" - }, - "node_modules/degenerator": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/degenerator/-/degenerator-5.0.1.tgz", - "integrity": "sha512-TllpMR/t0M5sqCXfj85i4XaAzxmS5tVA16dqvdkMwGmzI+dXLXnw3J+3Vdv7VKw+ThlTMboK6i9rnZ6Nntj5CQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "ast-types": "^0.13.4", - "escodegen": "^2.1.0", - "esprima": "^4.0.1" - }, - "engines": { - "node": ">= 14" - } - }, - "node_modules/delayed-stream": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", - "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.4.0" - } - }, - "node_modules/depd": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/depd/-/depd-1.1.2.tgz", - "integrity": "sha512-7emPTl6Dpo6JRXOXjLRxck+FlLRX5847cLKEn00PLAgc3g2hTZZgr+e4c2v6QpSmLeFP3n5yUo7ft6avBK/5jQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/detect-libc": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", - "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=8" - } - }, - "node_modules/dunder-proto": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", - "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind-apply-helpers": "^1.0.1", - "es-errors": "^1.3.0", - "gopd": "^1.2.0" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/edge-runtime": { - "version": "2.5.9", - "resolved": "https://registry.npmjs.org/edge-runtime/-/edge-runtime-2.5.9.tgz", - "integrity": "sha512-pk+k0oK0PVXdlT4oRp4lwh+unuKB7Ng4iZ2HB+EZ7QCEQizX360Rp/F4aRpgpRgdP2ufB35N+1KppHmYjqIGSg==", - "dev": true, - "license": "MPL-2.0", - "dependencies": { - "@edge-runtime/format": "2.2.1", - "@edge-runtime/ponyfill": "2.4.2", - "@edge-runtime/vm": "3.2.0", - "async-listen": "3.0.1", - "mri": "1.2.0", - "picocolors": "1.0.0", - "pretty-ms": "7.0.1", - "signal-exit": "4.0.2", - "time-span": "4.0.0" - }, - "bin": { - "edge-runtime": "dist/cli/index.js" - }, - "engines": { - "node": ">=16" - } - }, - "node_modules/edge-runtime/node_modules/async-listen": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/async-listen/-/async-listen-3.0.1.tgz", - "integrity": "sha512-cWMaNwUJnf37C/S5TfCkk/15MwbPRwVYALA2jtjkbHjCmAPiDXyNJy2q3p1KAZzDLHAWyarUWSujUoHR4pEgrA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 14" - } - }, - "node_modules/end-of-stream": { - "version": "1.4.5", - "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", - "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", - "dev": true, - "license": "MIT", - "dependencies": { - "once": "^1.4.0" - } - }, - "node_modules/es-define-property": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", - "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/es-errors": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", - "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/es-module-lexer": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.5.0.tgz", - "integrity": "sha512-pqrTKmwEIgafsYZAGw9kszYzmagcE/n4dbgwGWLEXg7J4QFJVQRBld8j3Q3GNez79jzxZshq0bcT962QHOghjw==", - "dev": true, - "license": "MIT" - }, - "node_modules/es-object-atoms": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", - "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", - "dev": true, - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/es-set-tostringtag": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", - "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", - "dev": true, - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "get-intrinsic": "^1.2.6", - "has-tostringtag": "^1.0.2", - "hasown": "^2.0.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/esbuild": { - "version": "0.27.0", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.0.tgz", - "integrity": "sha512-jd0f4NHbD6cALCyGElNpGAOtWxSq46l9X/sWB0Nzd5er4Kz2YTm+Vl0qKFT9KUJvD8+fiO8AvoHhFvEatfVixA==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "bin": { - "esbuild": "bin/esbuild" - }, - "engines": { - "node": ">=18" - }, - "optionalDependencies": { - "@esbuild/aix-ppc64": "0.27.0", - "@esbuild/android-arm": "0.27.0", - "@esbuild/android-arm64": "0.27.0", - "@esbuild/android-x64": "0.27.0", - "@esbuild/darwin-arm64": "0.27.0", - "@esbuild/darwin-x64": "0.27.0", - "@esbuild/freebsd-arm64": "0.27.0", - "@esbuild/freebsd-x64": "0.27.0", - "@esbuild/linux-arm": "0.27.0", - "@esbuild/linux-arm64": "0.27.0", - "@esbuild/linux-ia32": "0.27.0", - "@esbuild/linux-loong64": "0.27.0", - "@esbuild/linux-mips64el": "0.27.0", - "@esbuild/linux-ppc64": "0.27.0", - "@esbuild/linux-riscv64": "0.27.0", - "@esbuild/linux-s390x": "0.27.0", - "@esbuild/linux-x64": "0.27.0", - "@esbuild/netbsd-arm64": "0.27.0", - "@esbuild/netbsd-x64": "0.27.0", - "@esbuild/openbsd-arm64": "0.27.0", - "@esbuild/openbsd-x64": "0.27.0", - "@esbuild/openharmony-arm64": "0.27.0", - "@esbuild/sunos-x64": "0.27.0", - "@esbuild/win32-arm64": "0.27.0", - "@esbuild/win32-ia32": "0.27.0", - "@esbuild/win32-x64": "0.27.0" - } - }, - "node_modules/escodegen": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/escodegen/-/escodegen-2.1.0.tgz", - "integrity": "sha512-2NlIDTwUWJN0mRPQOdtQBzbUHvdGY2P1VXSyU83Q3xKxM7WHX2Ql8dKq782Q9TgQUNOLEzEYu9bzLNj1q88I5w==", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "esprima": "^4.0.1", - "estraverse": "^5.2.0", - "esutils": "^2.0.2" - }, - "bin": { - "escodegen": "bin/escodegen.js", - "esgenerate": "bin/esgenerate.js" - }, - "engines": { - "node": ">=6.0" - }, - "optionalDependencies": { - "source-map": "~0.6.1" - } - }, - "node_modules/esprima": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", - "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", - "dev": true, - "license": "BSD-2-Clause", - "bin": { - "esparse": "bin/esparse.js", - "esvalidate": "bin/esvalidate.js" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/estraverse": { - "version": "5.3.0", - "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", - "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", - "dev": true, - "license": "BSD-2-Clause", - "engines": { - "node": ">=4.0" - } - }, - "node_modules/estree-walker": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", - "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", - "dev": true, - "license": "MIT" - }, - "node_modules/esutils": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", - "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", - "dev": true, - "license": "BSD-2-Clause", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/etag": { - "version": "1.8.1", - "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", - "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/events-intercept": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/events-intercept/-/events-intercept-2.0.0.tgz", - "integrity": "sha512-blk1va0zol9QOrdZt0rFXo5KMkNPVSp92Eju/Qz8THwKWKRKeE0T8Br/1aW6+Edkyq9xHYgYxn2QtOnUKPUp+Q==", - "dev": true, - "license": "MIT" - }, - "node_modules/events-universal": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/events-universal/-/events-universal-1.0.1.tgz", - "integrity": "sha512-LUd5euvbMLpwOF8m6ivPCbhQeSiYVNb8Vs0fQ8QjXo0JTkEHpz8pxdQf0gStltaPpw0Cca8b39KxvK9cfKRiAw==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "bare-events": "^2.7.0" - } - }, - "node_modules/execa": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/execa/-/execa-3.2.0.tgz", - "integrity": "sha512-kJJfVbI/lZE1PZYDI5VPxp8zXPO9rtxOkhpZ0jMKha56AI9y2gGVC6bkukStQf0ka5Rh15BA5m7cCCH4jmHqkw==", - "dev": true, - "license": "MIT", - "dependencies": { - "cross-spawn": "^7.0.0", - "get-stream": "^5.0.0", - "human-signals": "^1.1.1", - "is-stream": "^2.0.0", - "merge-stream": "^2.0.0", - "npm-run-path": "^4.0.0", - "onetime": "^5.1.0", - "p-finally": "^2.0.0", - "signal-exit": "^3.0.2", - "strip-final-newline": "^2.0.0" - }, - "engines": { - "node": "^8.12.0 || >=9.7.0" - } - }, - "node_modules/execa/node_modules/signal-exit": { - "version": "3.0.7", - "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", - "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", - "dev": true, - "license": "ISC" - }, - "node_modules/fast-deep-equal": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", - "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", - "dev": true, - "license": "MIT" - }, - "node_modules/fast-fifo": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/fast-fifo/-/fast-fifo-1.3.2.tgz", - "integrity": "sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/fast-glob": { - "version": "3.3.3", - "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", - "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@nodelib/fs.stat": "^2.0.2", - "@nodelib/fs.walk": "^1.2.3", - "glob-parent": "^5.1.2", - "merge2": "^1.3.0", - "micromatch": "^4.0.8" - }, - "engines": { - "node": ">=8.6.0" - } - }, - "node_modules/fastq": { - "version": "1.20.1", - "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", - "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==", - "dev": true, - "license": "ISC", - "dependencies": { - "reusify": "^1.0.4" - } - }, - "node_modules/fd-slicer": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/fd-slicer/-/fd-slicer-1.1.0.tgz", - "integrity": "sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g==", - "dev": true, - "license": "MIT", - "dependencies": { - "pend": "~1.2.0" - } - }, - "node_modules/file-uri-to-path": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz", - "integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==", - "dev": true, - "license": "MIT" - }, - "node_modules/fill-range": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", - "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", - "dev": true, - "license": "MIT", - "dependencies": { - "to-regex-range": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/form-data": { - "version": "4.0.5", - "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", - "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", - "dev": true, - "license": "MIT", - "dependencies": { - "asynckit": "^0.4.0", - "combined-stream": "^1.0.8", - "es-set-tostringtag": "^2.1.0", - "hasown": "^2.0.2", - "mime-types": "^2.1.12" - }, - "engines": { - "node": ">= 6" - } - }, - "node_modules/fs-extra": { - "version": "11.1.0", - "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.1.0.tgz", - "integrity": "sha512-0rcTq621PD5jM/e0a3EJoGC/1TC5ZBCERW82LQuwfGnCa1V8w7dpYH1yNu+SLb6E5dkeCBzKEyLGlFrnr+dUyw==", - "dev": true, - "license": "MIT", - "dependencies": { - "graceful-fs": "^4.2.0", - "jsonfile": "^6.0.1", - "universalify": "^2.0.0" - }, - "engines": { - "node": ">=14.14" - } - }, - "node_modules/fsevents": { - "version": "2.3.3", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", - "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^8.16.0 || ^10.6.0 || >=11.0.0" - } - }, - "node_modules/function-bind": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", - "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", - "dev": true, - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/generic-pool": { - "version": "3.4.2", - "resolved": "https://registry.npmjs.org/generic-pool/-/generic-pool-3.4.2.tgz", - "integrity": "sha512-H7cUpwCQSiJmAHM4c/aFu6fUfrhWXW1ncyh8ftxEPMu6AiYkHw9K8br720TGPZJbk5eOH2bynjZD1yPvdDAmag==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 4" - } - }, - "node_modules/get-intrinsic": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", - "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind-apply-helpers": "^1.0.2", - "es-define-property": "^1.0.1", - "es-errors": "^1.3.0", - "es-object-atoms": "^1.1.1", - "function-bind": "^1.1.2", - "get-proto": "^1.0.1", - "gopd": "^1.2.0", - "has-symbols": "^1.1.0", - "hasown": "^2.0.2", - "math-intrinsics": "^1.1.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/get-port": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/get-port/-/get-port-5.1.1.tgz", - "integrity": "sha512-g/Q1aTSDOxFpchXC4i8ZWvxA1lnPqx/JHqcpIw0/LX9T8x/GBbi6YnlN5nhaKIFkT8oFsscUKgDJYxfwfS6QsQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/get-proto": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", - "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", - "dev": true, - "license": "MIT", - "dependencies": { - "dunder-proto": "^1.0.1", - "es-object-atoms": "^1.0.0" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/get-stream": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-5.2.0.tgz", - "integrity": "sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA==", - "dev": true, - "license": "MIT", - "dependencies": { - "pump": "^3.0.0" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/get-tsconfig": { - "version": "4.14.0", - "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.14.0.tgz", - "integrity": "sha512-yTb+8DXzDREzgvYmh6s9vHsSVCHeC0G3PI5bEXNBHtmshPnO+S5O7qgLEOn0I5QvMy6kpZN8K1NKGyilLb93wA==", - "dev": true, - "license": "MIT", - "dependencies": { - "resolve-pkg-maps": "^1.0.0" - }, - "funding": { - "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" - } - }, - "node_modules/get-uri": { - "version": "6.0.5", - "resolved": "https://registry.npmjs.org/get-uri/-/get-uri-6.0.5.tgz", - "integrity": "sha512-b1O07XYq8eRuVzBNgJLstU6FYc1tS6wnMtF1I1D9lE8LxZSOGZ7LhxN54yPP6mGw5f2CkXY2BQUL9Fx41qvcIg==", - "dev": true, - "license": "MIT", - "dependencies": { - "basic-ftp": "^5.0.2", - "data-uri-to-buffer": "^6.0.2", - "debug": "^4.3.4" - }, - "engines": { - "node": ">= 14" - } - }, - "node_modules/glob": { - "version": "13.0.6", - "resolved": "https://registry.npmjs.org/glob/-/glob-13.0.6.tgz", - "integrity": "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==", - "dev": true, - "license": "BlueOak-1.0.0", - "dependencies": { - "minimatch": "^10.2.2", - "minipass": "^7.1.3", - "path-scurry": "^2.0.2" - }, - "engines": { - "node": "18 || 20 || >=22" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/glob-parent": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", - "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", - "dev": true, - "license": "ISC", - "dependencies": { - "is-glob": "^4.0.1" - }, - "engines": { - "node": ">= 6" - } - }, - "node_modules/glob/node_modules/balanced-match": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", - "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", - "dev": true, - "license": "MIT", - "engines": { - "node": "18 || 20 || >=22" - } - }, - "node_modules/glob/node_modules/brace-expansion": { - "version": "5.0.6", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz", - "integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^4.0.2" - }, - "engines": { - "node": "18 || 20 || >=22" - } - }, - "node_modules/glob/node_modules/minimatch": { - "version": "10.2.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", - "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", - "dev": true, - "license": "BlueOak-1.0.0", - "dependencies": { - "brace-expansion": "^5.0.5" - }, - "engines": { - "node": "18 || 20 || >=22" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/gopd": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", - "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/graceful-fs": { - "version": "4.2.11", - "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", - "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", - "dev": true, - "license": "ISC" - }, - "node_modules/has-symbols": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", - "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/has-tostringtag": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", - "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", - "dev": true, - "license": "MIT", - "dependencies": { - "has-symbols": "^1.0.3" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/hasown": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.3.tgz", - "integrity": "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==", - "dev": true, - "license": "MIT", - "dependencies": { - "function-bind": "^1.1.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/http-errors": { - "version": "1.7.3", - "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-1.7.3.tgz", - "integrity": "sha512-ZTTX0MWrsQ2ZAhA1cejAwDLycFsd7I7nVtnkT3Ol0aqodaKW+0CTZDQ1uBv5whptCnc8e8HeRRJxRs0kmm/Qfw==", - "dev": true, - "license": "MIT", - "dependencies": { - "depd": "~1.1.2", - "inherits": "2.0.4", - "setprototypeof": "1.1.1", - "statuses": ">= 1.5.0 < 2", - "toidentifier": "1.0.0" - }, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/http-proxy-agent": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", - "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", - "dev": true, - "license": "MIT", - "dependencies": { - "agent-base": "^7.1.0", - "debug": "^4.3.4" - }, - "engines": { - "node": ">= 14" - } - }, - "node_modules/https-proxy-agent": { - "version": "7.0.6", - "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", - "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", - "dev": true, - "license": "MIT", - "dependencies": { - "agent-base": "^7.1.2", - "debug": "4" - }, - "engines": { - "node": ">= 14" - } - }, - "node_modules/human-signals": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-1.1.1.tgz", - "integrity": "sha512-SEQu7vl8KjNL2eoGBLF3+wAjpsNfA9XMlXAYj/3EdaNfAlxKthD1xjEQfGOUhllCGGJVNY34bRr6lPINhNjyZw==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=8.12.0" - } - }, - "node_modules/iconv-lite": { - "version": "0.4.24", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", - "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", - "dev": true, - "license": "MIT", - "dependencies": { - "safer-buffer": ">= 2.1.2 < 3" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/inherits": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", - "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", - "dev": true, - "license": "ISC" - }, - "node_modules/ip-address": { - "version": "10.2.0", - "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz", - "integrity": "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 12" - } - }, - "node_modules/is-buffer": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/is-buffer/-/is-buffer-2.0.5.tgz", - "integrity": "sha512-i2R6zNFDwgEHJyQUtJEk0XFi1i0dPFn/oqjK3/vPCcDeJvW5NQ83V8QbicfF1SupOaB0h8ntgBC2YiE7dfyctQ==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT", - "engines": { - "node": ">=4" - } - }, - "node_modules/is-extglob": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", - "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/is-glob": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", - "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", - "dev": true, - "license": "MIT", - "dependencies": { - "is-extglob": "^2.1.1" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/is-node-process": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/is-node-process/-/is-node-process-1.2.0.tgz", - "integrity": "sha512-Vg4o6/fqPxIjtxgUH5QLJhwZ7gW5diGCVlXpuUfELC62CuxM1iHcRe51f2W1FDy04Ai4KJkagKjx3XaqyfRKXw==", - "dev": true, - "license": "MIT" - }, - "node_modules/is-number": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", - "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.12.0" - } - }, - "node_modules/is-stream": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", - "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/isexe": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", - "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", - "dev": true, - "license": "ISC" - }, - "node_modules/jose": { - "version": "5.9.6", - "resolved": "https://registry.npmjs.org/jose/-/jose-5.9.6.tgz", - "integrity": "sha512-AMlnetc9+CV9asI19zHmrgS/WYsWUwCn2R7RzlbJWD7F9eWYUTGyBmU9o6PxngtLGOiDGPRu+Uc4fhKzbpteZQ==", - "dev": true, - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/panva" - } - }, - "node_modules/js-yaml": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", - "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", - "dev": true, - "license": "MIT", - "dependencies": { - "argparse": "^2.0.1" - }, - "bin": { - "js-yaml": "bin/js-yaml.js" - } - }, - "node_modules/json-schema-to-ts": { - "version": "1.6.4", - "resolved": "https://registry.npmjs.org/json-schema-to-ts/-/json-schema-to-ts-1.6.4.tgz", - "integrity": "sha512-pR4yQ9DHz6itqswtHCm26mw45FSNfQ9rEQjosaZErhn5J3J2sIViQiz8rDaezjKAhFGpmsoczYVBgGHzFw/stA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/json-schema": "^7.0.6", - "ts-toolbelt": "^6.15.5" - } - }, - "node_modules/json-schema-traverse": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", - "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", - "dev": true, - "license": "MIT" - }, - "node_modules/jsonfile": { - "version": "6.2.1", - "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.1.tgz", - "integrity": "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "universalify": "^2.0.0" - }, - "optionalDependencies": { - "graceful-fs": "^4.1.6" - } - }, - "node_modules/jsonlines": { - "version": "0.1.1", - "resolved": "https://registry.npmjs.org/jsonlines/-/jsonlines-0.1.1.tgz", - "integrity": "sha512-ekDrAGso79Cvf+dtm+mL8OBI2bmAOt3gssYs833De/C9NmIpWDWyUO4zPgB5x2/OhY366dkhgfPMYfwZF7yOZA==", - "dev": true, - "license": "MIT" - }, - "node_modules/lru-cache": { - "version": "11.5.1", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.1.tgz", - "integrity": "sha512-RPimw/7aMdv2oqRrxKwvZXcPfwBrn/JZ2xYcY9Hus/6LaS3VOAKVWKWgNLCFSiOm1ESXinjsDlidVU7JlnCN2A==", - "dev": true, - "license": "BlueOak-1.0.0", - "engines": { - "node": "20 || >=22" - } - }, - "node_modules/luxon": { - "version": "3.7.2", - "resolved": "https://registry.npmjs.org/luxon/-/luxon-3.7.2.tgz", - "integrity": "sha512-vtEhXh/gNjI9Yg1u4jX/0YVPMvxzHuGgCm6tC5kZyb08yjGWGnqAjGJvcXbqQR2P3MyMEFnRbpcdFS6PBcLqew==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - } - }, - "node_modules/math-intrinsics": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", - "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/merge-stream": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", - "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==", - "dev": true, - "license": "MIT" - }, - "node_modules/merge2": { - "version": "1.4.1", - "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", - "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 8" - } - }, - "node_modules/micro": { - "version": "9.3.5-canary.3", - "resolved": "https://registry.npmjs.org/micro/-/micro-9.3.5-canary.3.tgz", - "integrity": "sha512-viYIo9PefV+w9dvoIBh1gI44Mvx1BOk67B4BpC2QK77qdY0xZF0Q+vWLt/BII6cLkIc8rLmSIcJaB/OrXXKe1g==", - "dev": true, - "license": "MIT", - "dependencies": { - "arg": "4.1.0", - "content-type": "1.0.4", - "raw-body": "2.4.1" - }, - "bin": { - "micro": "bin/micro.js" - }, - "engines": { - "node": ">= 8.0.0" - } - }, - "node_modules/micromatch": { - "version": "4.0.8", - "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", - "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", - "dev": true, - "license": "MIT", - "dependencies": { - "braces": "^3.0.3", - "picomatch": "^2.3.1" - }, - "engines": { - "node": ">=8.6" - } - }, - "node_modules/micromatch/node_modules/picomatch": { - "version": "2.3.2", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", - "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8.6" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, - "node_modules/mime-db": { - "version": "1.52.0", - "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", - "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/mime-types": { - "version": "2.1.35", - "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", - "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", - "dev": true, - "license": "MIT", - "dependencies": { - "mime-db": "1.52.0" - }, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/mimic-fn": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", - "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/minimatch": { - "version": "10.1.1", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.1.1.tgz", - "integrity": "sha512-enIvLvRAFZYXJzkCYG5RKmPfrFArdLv+R+lbQ53BmIMLIry74bjKzX6iHAm8WYamJkhSSEabrWN5D97XnKObjQ==", - "dev": true, - "license": "BlueOak-1.0.0", - "dependencies": { - "@isaacs/brace-expansion": "^5.0.0" - }, - "engines": { - "node": "20 || >=22" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/minipass": { - "version": "7.1.3", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", - "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", - "dev": true, - "license": "BlueOak-1.0.0", - "engines": { - "node": ">=16 || 14 >=14.17" - } - }, - "node_modules/minizlib": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-3.1.0.tgz", - "integrity": "sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw==", - "dev": true, - "license": "MIT", - "dependencies": { - "minipass": "^7.1.2" - }, - "engines": { - "node": ">= 18" - } - }, - "node_modules/mkdirp": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz", - "integrity": "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==", - "dev": true, - "license": "MIT", - "bin": { - "mkdirp": "bin/cmd.js" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/mri": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/mri/-/mri-1.2.0.tgz", - "integrity": "sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=4" - } - }, - "node_modules/ms": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.1.tgz", - "integrity": "sha512-tgp+dl5cGk28utYktBsrFqA7HKgrhgPsg6Z/EfhWI4gl1Hwq8B/GmY/0oXZ6nF8hDVesS/FpnYaD/kOWhYQvyg==", - "dev": true, - "license": "MIT" - }, - "node_modules/netmask": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/netmask/-/netmask-2.1.1.tgz", - "integrity": "sha512-eonl3sLUha+S1GzTPxychyhnUzKyeQkZ7jLjKrBagJgPla13F+uQ71HgpFefyHgqrjEbCPkDArxYsjY8/+gLKA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4.0" - } - }, - "node_modules/node-fetch": { - "version": "2.6.7", - "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.6.7.tgz", - "integrity": "sha512-ZjMPFEfVx5j+y2yF35Kzx5sF7kDzxuDj6ziH4FFbOp87zKDZNx8yExJIb05OGF4Nlt9IHFIMBkRl41VdvcNdbQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "whatwg-url": "^5.0.0" - }, - "engines": { - "node": "4.x || >=6.0.0" - }, - "peerDependencies": { - "encoding": "^0.1.0" - }, - "peerDependenciesMeta": { - "encoding": { - "optional": true - } - } - }, - "node_modules/node-gyp-build": { - "version": "4.8.4", - "resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.8.4.tgz", - "integrity": "sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==", - "dev": true, - "license": "MIT", - "bin": { - "node-gyp-build": "bin.js", - "node-gyp-build-optional": "optional.js", - "node-gyp-build-test": "build-test.js" - } - }, - "node_modules/nopt": { - "version": "8.1.0", - "resolved": "https://registry.npmjs.org/nopt/-/nopt-8.1.0.tgz", - "integrity": "sha512-ieGu42u/Qsa4TFktmaKEwM6MQH0pOWnaB3htzh0JRtx84+Mebc0cbZYN5bC+6WTZ4+77xrL9Pn5m7CV6VIkV7A==", - "dev": true, - "license": "ISC", - "dependencies": { - "abbrev": "^3.0.0" - }, - "bin": { - "nopt": "bin/nopt.js" - }, - "engines": { - "node": "^18.17.0 || >=20.5.0" - } - }, - "node_modules/npm-run-path": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-4.0.1.tgz", - "integrity": "sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==", - "dev": true, - "license": "MIT", - "dependencies": { - "path-key": "^3.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/once": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", - "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", - "dev": true, - "license": "ISC", - "dependencies": { - "wrappy": "1" - } - }, - "node_modules/onetime": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz", - "integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==", - "dev": true, - "license": "MIT", - "dependencies": { - "mimic-fn": "^2.1.0" - }, - "engines": { - "node": ">=6" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/os-paths": { - "version": "4.4.0", - "resolved": "https://registry.npmjs.org/os-paths/-/os-paths-4.4.0.tgz", - "integrity": "sha512-wrAwOeXp1RRMFfQY8Sy7VaGVmPocaLwSFOYCGKSyo8qmJ+/yaafCl5BCA1IQZWqFSRBrKDYFeR9d/VyQzfH/jg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 6.0" - } - }, - "node_modules/oxc-transform": { - "version": "0.111.0", - "resolved": "https://registry.npmjs.org/oxc-transform/-/oxc-transform-0.111.0.tgz", - "integrity": "sha512-oa5KKSDNLHZGaiqIGAbCWXeN9IJUAz9MElWcQX90epDxdKc9Hrt/BsLj3K4gDqfAYa5dwdH+ZCFJG9hR74fiGg==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^20.19.0 || >=22.12.0" - }, - "funding": { - "url": "https://github.com/sponsors/Boshen" - }, - "optionalDependencies": { - "@oxc-transform/binding-android-arm-eabi": "0.111.0", - "@oxc-transform/binding-android-arm64": "0.111.0", - "@oxc-transform/binding-darwin-arm64": "0.111.0", - "@oxc-transform/binding-darwin-x64": "0.111.0", - "@oxc-transform/binding-freebsd-x64": "0.111.0", - "@oxc-transform/binding-linux-arm-gnueabihf": "0.111.0", - "@oxc-transform/binding-linux-arm-musleabihf": "0.111.0", - "@oxc-transform/binding-linux-arm64-gnu": "0.111.0", - "@oxc-transform/binding-linux-arm64-musl": "0.111.0", - "@oxc-transform/binding-linux-ppc64-gnu": "0.111.0", - "@oxc-transform/binding-linux-riscv64-gnu": "0.111.0", - "@oxc-transform/binding-linux-riscv64-musl": "0.111.0", - "@oxc-transform/binding-linux-s390x-gnu": "0.111.0", - "@oxc-transform/binding-linux-x64-gnu": "0.111.0", - "@oxc-transform/binding-linux-x64-musl": "0.111.0", - "@oxc-transform/binding-openharmony-arm64": "0.111.0", - "@oxc-transform/binding-wasm32-wasi": "0.111.0", - "@oxc-transform/binding-win32-arm64-msvc": "0.111.0", - "@oxc-transform/binding-win32-ia32-msvc": "0.111.0", - "@oxc-transform/binding-win32-x64-msvc": "0.111.0" - } - }, - "node_modules/p-finally": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/p-finally/-/p-finally-2.0.1.tgz", - "integrity": "sha512-vpm09aKwq6H9phqRQzecoDpD8TmVyGw70qmWlyq5onxY7tqyTTFVvxMykxQSQKILBSFlbXpypIw2T1Ml7+DDtw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/pac-proxy-agent": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/pac-proxy-agent/-/pac-proxy-agent-7.2.0.tgz", - "integrity": "sha512-TEB8ESquiLMc0lV8vcd5Ql/JAKAoyzHFXaStwjkzpOpC5Yv+pIzLfHvjTSdf3vpa2bMiUQrg9i6276yn8666aA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@tootallnate/quickjs-emscripten": "^0.23.0", - "agent-base": "^7.1.2", - "debug": "^4.3.4", - "get-uri": "^6.0.1", - "http-proxy-agent": "^7.0.0", - "https-proxy-agent": "^7.0.6", - "pac-resolver": "^7.0.1", - "socks-proxy-agent": "^8.0.5" - }, - "engines": { - "node": ">= 14" - } - }, - "node_modules/pac-resolver": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/pac-resolver/-/pac-resolver-7.0.1.tgz", - "integrity": "sha512-5NPgf87AT2STgwa2ntRMr45jTKrYBGkVU36yT0ig/n/GMAa3oPqhZfIQ2kMEimReg0+t9kZViDVZ83qfVUlckg==", - "dev": true, - "license": "MIT", - "dependencies": { - "degenerator": "^5.0.0", - "netmask": "^2.0.2" - }, - "engines": { - "node": ">= 14" - } - }, - "node_modules/parse-ms": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/parse-ms/-/parse-ms-2.1.0.tgz", - "integrity": "sha512-kHt7kzLoS9VBZfUsiKjv43mr91ea+U05EyKkEtqp7vNbHxmaVuEqN7XxeEVnGrMtYOAxGrDElSi96K7EgO1zCA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/path-browserify": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/path-browserify/-/path-browserify-1.0.1.tgz", - "integrity": "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==", - "dev": true, - "license": "MIT" - }, - "node_modules/path-key": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", - "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/path-scurry": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.2.tgz", - "integrity": "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==", - "dev": true, - "license": "BlueOak-1.0.0", - "dependencies": { - "lru-cache": "^11.0.0", - "minipass": "^7.1.2" - }, - "engines": { - "node": "18 || 20 || >=22" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/path-to-regexp": { - "version": "8.3.0", - "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.3.0.tgz", - "integrity": "sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA==", - "dev": true, - "license": "MIT", - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/path-to-regexp-updated": { - "name": "path-to-regexp", - "version": "6.3.0", - "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-6.3.0.tgz", - "integrity": "sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/pend": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz", - "integrity": "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==", - "dev": true, - "license": "MIT" - }, - "node_modules/picocolors": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.0.0.tgz", - "integrity": "sha512-1fygroTLlHu66zi26VoTDv8yRgm0Fccecssto+MhsZ0D/DGW2sm8E8AjW7NU5VVTRt5GxbeZ5qBuJr+HyLYkjQ==", - "dev": true, - "license": "ISC" - }, - "node_modules/picomatch": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", - "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, - "node_modules/pretty-ms": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/pretty-ms/-/pretty-ms-7.0.1.tgz", - "integrity": "sha512-973driJZvxiGOQ5ONsFhOF/DtzPMOMtgC11kCpUrPGMTgqp2q/1gwzCquocrN33is0VZ5GFHXZYMM9l6h67v2Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "parse-ms": "^2.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/promisepipe": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/promisepipe/-/promisepipe-3.0.0.tgz", - "integrity": "sha512-V6TbZDJ/ZswevgkDNpGt/YqNCiZP9ASfgU+p83uJE6NrGtvSGoOcHLiDCqkMs2+yg7F5qHdLV8d0aS8O26G/KA==", - "dev": true, - "license": "MIT" - }, - "node_modules/proxy-agent": { - "version": "6.4.0", - "resolved": "https://registry.npmjs.org/proxy-agent/-/proxy-agent-6.4.0.tgz", - "integrity": "sha512-u0piLU+nCOHMgGjRbimiXmA9kM/L9EHh3zL81xCdp7m+Y2pHIsnmbdDoEDoAz5geaonNR6q6+yOPQs6n4T6sBQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "agent-base": "^7.0.2", - "debug": "^4.3.4", - "http-proxy-agent": "^7.0.1", - "https-proxy-agent": "^7.0.3", - "lru-cache": "^7.14.1", - "pac-proxy-agent": "^7.0.1", - "proxy-from-env": "^1.1.0", - "socks-proxy-agent": "^8.0.2" - }, - "engines": { - "node": ">= 14" - } - }, - "node_modules/proxy-agent/node_modules/lru-cache": { - "version": "7.18.3", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-7.18.3.tgz", - "integrity": "sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA==", - "dev": true, - "license": "ISC", - "engines": { - "node": ">=12" - } - }, - "node_modules/proxy-from-env": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", - "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", - "dev": true, - "license": "MIT" - }, - "node_modules/pump": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz", - "integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==", - "dev": true, - "license": "MIT", - "dependencies": { - "end-of-stream": "^1.1.0", - "once": "^1.3.1" - } - }, - "node_modules/punycode": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", - "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/queue-microtask": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", - "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT" - }, - "node_modules/raw-body": { - "version": "2.4.1", - "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.4.1.tgz", - "integrity": "sha512-9WmIKF6mkvA0SLmA2Knm9+qj89e+j1zqgyn8aXGd7+nAduPoqgI9lO57SAZNn/Byzo5P7JhXTyg9PzaJbH73bA==", - "dev": true, - "license": "MIT", - "dependencies": { - "bytes": "3.1.0", - "http-errors": "1.7.3", - "iconv-lite": "0.4.24", - "unpipe": "1.0.0" - }, - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/readdirp": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", - "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 14.18.0" - }, - "funding": { - "type": "individual", - "url": "https://paulmillr.com/funding/" - } - }, - "node_modules/require-from-string": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", - "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/resolve-from": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", - "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/resolve-pkg-maps": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", - "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==", - "dev": true, - "license": "MIT", - "funding": { - "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" - } - }, - "node_modules/resolve.exports": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/resolve.exports/-/resolve.exports-2.0.3.tgz", - "integrity": "sha512-OcXjMsGdhL4XnbShKpAcSqPMzQoYkYyhbEaeSko47MjRP9NfEQMhZkXL1DoFlt9LWQn4YttrdnV6X2OiyzBi+A==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10" - } - }, - "node_modules/retry": { - "version": "0.13.1", - "resolved": "https://registry.npmjs.org/retry/-/retry-0.13.1.tgz", - "integrity": "sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 4" - } - }, - "node_modules/reusify": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", - "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", - "dev": true, - "license": "MIT", - "engines": { - "iojs": ">=1.0.0", - "node": ">=0.10.0" - } - }, - "node_modules/rolldown": { - "version": "1.0.0-rc.1", - "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.1.tgz", - "integrity": "sha512-M3AeZjYE6UclblEf531Hch0WfVC/NOL43Cc+WdF3J50kk5/fvouHhDumSGTh0oRjbZ8C4faaVr5r6Nx1xMqDGg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@oxc-project/types": "=0.110.0", - "@rolldown/pluginutils": "1.0.0-rc.1" - }, - "bin": { - "rolldown": "bin/cli.mjs" - }, - "engines": { - "node": "^20.19.0 || >=22.12.0" - }, - "optionalDependencies": { - "@rolldown/binding-android-arm64": "1.0.0-rc.1", - "@rolldown/binding-darwin-arm64": "1.0.0-rc.1", - "@rolldown/binding-darwin-x64": "1.0.0-rc.1", - "@rolldown/binding-freebsd-x64": "1.0.0-rc.1", - "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.1", - "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.1", - "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.1", - "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.1", - "@rolldown/binding-linux-x64-musl": "1.0.0-rc.1", - "@rolldown/binding-openharmony-arm64": "1.0.0-rc.1", - "@rolldown/binding-wasm32-wasi": "1.0.0-rc.1", - "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.1", - "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.1" - } - }, - "node_modules/run-parallel": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", - "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT", - "dependencies": { - "queue-microtask": "^1.2.2" - } - }, - "node_modules/safer-buffer": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", - "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", - "dev": true, - "license": "MIT" - }, - "node_modules/sandbox": { - "version": "2.5.6", - "resolved": "https://registry.npmjs.org/sandbox/-/sandbox-2.5.6.tgz", - "integrity": "sha512-tnFr7nyiuEhsAGb+xy60SDbij0790X+FgDljh3J/2HaRM6yQgNJkQKHbDH8ld7mR+PozXGgEfJ2Dc/5OyFnwsg==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@vercel/sandbox": "1.9.0", - "debug": "^4.4.1", - "zod": "^4.1.1" - }, - "bin": { - "sandbox": "bin/sandbox.mjs", - "sbx": "bin/sandbox.mjs" - } - }, - "node_modules/sandbox/node_modules/debug": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", - "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "dev": true, - "license": "MIT", - "dependencies": { - "ms": "^2.1.3" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } - } - }, - "node_modules/sandbox/node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "dev": true, - "license": "MIT" - }, - "node_modules/semver": { - "version": "7.5.4", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.5.4.tgz", - "integrity": "sha512-1bCSESV6Pv+i21Hvpxp3Dx+pSD8lIPt8uVjRrxAUt/nbswYc+tK6Y2btiULjd4+fnq15PX+nqQDC7Oft7WkwcA==", - "dev": true, - "license": "ISC", - "dependencies": { - "lru-cache": "^6.0.0" - }, - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/semver/node_modules/lru-cache": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", - "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", - "dev": true, - "license": "ISC", - "dependencies": { - "yallist": "^4.0.0" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/semver/node_modules/yallist": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", - "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", - "dev": true, - "license": "ISC" - }, - "node_modules/setprototypeof": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.1.1.tgz", - "integrity": "sha512-JvdAWfbXeIGaZ9cILp38HntZSFSo3mWg6xGcJJsd+d4aRMOqauag1C63dJfDw7OaMYwEbHMOxEZ1lqVRYP2OAw==", - "dev": true, - "license": "ISC" - }, - "node_modules/shebang-command": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", - "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", - "dev": true, - "license": "MIT", - "dependencies": { - "shebang-regex": "^3.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/shebang-regex": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", - "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/signal-exit": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.0.2.tgz", - "integrity": "sha512-MY2/qGx4enyjprQnFaZsHib3Yadh3IXyV2C321GY0pjGfVBu4un0uDJkwgdxqO+Rdx8JMT8IfJIRwbYVz3Ob3Q==", - "dev": true, - "license": "ISC", - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/smart-buffer": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz", - "integrity": "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 6.0.0", - "npm": ">= 3.0.0" - } - }, - "node_modules/smol-toml": { - "version": "1.5.2", - "resolved": "https://registry.npmjs.org/smol-toml/-/smol-toml-1.5.2.tgz", - "integrity": "sha512-QlaZEqcAH3/RtNyet1IPIYPsEWAaYyXXv1Krsi+1L/QHppjX4Ifm8MQsBISz9vE8cHicIq3clogsheili5vhaQ==", - "dev": true, - "license": "BSD-3-Clause", - "engines": { - "node": ">= 18" - }, - "funding": { - "url": "https://github.com/sponsors/cyyynthia" - } - }, - "node_modules/socks": { - "version": "2.8.9", - "resolved": "https://registry.npmjs.org/socks/-/socks-2.8.9.tgz", - "integrity": "sha512-LJhUYUvItdQ0LkJTmPeaEObWXAqFyfmP85x0tch/ez9cahmhlBBLbIqDFnvBnUJGagb0JbIQrkBs1wJ+yRYpEw==", - "dev": true, - "license": "MIT", - "dependencies": { - "ip-address": "^10.1.1", - "smart-buffer": "^4.2.0" - }, - "engines": { - "node": ">= 10.0.0", - "npm": ">= 3.0.0" - } - }, - "node_modules/socks-proxy-agent": { - "version": "8.0.5", - "resolved": "https://registry.npmjs.org/socks-proxy-agent/-/socks-proxy-agent-8.0.5.tgz", - "integrity": "sha512-HehCEsotFqbPW9sJ8WVYB6UbmIMv7kUUORIF2Nncq4VQvBfNBLibW9YZR5dlYCSUhwcD628pRllm7n+E+YTzJw==", - "dev": true, - "license": "MIT", - "dependencies": { - "agent-base": "^7.1.2", - "debug": "^4.3.4", - "socks": "^2.8.3" - }, - "engines": { - "node": ">= 14" - } - }, - "node_modules/source-map": { - "version": "0.6.1", - "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", - "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", - "dev": true, - "license": "BSD-3-Clause", - "optional": true, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/srvx": { - "version": "0.8.9", - "resolved": "https://registry.npmjs.org/srvx/-/srvx-0.8.9.tgz", - "integrity": "sha512-wYc3VLZHRzwYrWJhkEqkhLb31TI0SOkfYZDkUhXdp3NoCnNS0FqajiQszZZjfow/VYEuc6Q5sZh9nM6kPy2NBQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "cookie-es": "^2.0.0" - }, - "bin": { - "srvx": "bin/srvx.mjs" - }, - "engines": { - "node": ">=20.16.0" - } - }, - "node_modules/stat-mode": { - "version": "0.3.0", - "resolved": "https://registry.npmjs.org/stat-mode/-/stat-mode-0.3.0.tgz", - "integrity": "sha512-QjMLR0A3WwFY2aZdV0okfFEJB5TRjkggXZjxP3A1RsWsNHNu3YPv8btmtc6iCFZ0Rul3FE93OYogvhOUClU+ng==", - "dev": true, - "license": "MIT" - }, - "node_modules/statuses": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/statuses/-/statuses-1.5.0.tgz", - "integrity": "sha512-OpZ3zP+jT1PI7I8nemJX4AKmAX070ZkYPVWV/AaKTJl+tXCTGyVdC1a4SL8RUQYEwk/f34ZX8UTykN68FwrqAA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/stream-to-array": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/stream-to-array/-/stream-to-array-2.3.0.tgz", - "integrity": "sha512-UsZtOYEn4tWU2RGLOXr/o/xjRBftZRlG3dEWoaHr8j4GuypJ3isitGbVyjQKAuMu+xbiop8q224TjiZWc4XTZA==", - "dev": true, - "license": "MIT", - "dependencies": { - "any-promise": "^1.1.0" - } - }, - "node_modules/stream-to-promise": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/stream-to-promise/-/stream-to-promise-2.2.0.tgz", - "integrity": "sha512-HAGUASw8NT0k8JvIVutB2Y/9iBk7gpgEyAudXwNJmZERdMITGdajOa4VJfD/kNiA3TppQpTP4J+CtcHwdzKBAw==", - "dev": true, - "license": "MIT", - "dependencies": { - "any-promise": "~1.3.0", - "end-of-stream": "~1.1.0", - "stream-to-array": "~2.3.0" - } - }, - "node_modules/stream-to-promise/node_modules/end-of-stream": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.1.0.tgz", - "integrity": "sha512-EoulkdKF/1xa92q25PbjuDcgJ9RDHYU2Rs3SCIvs2/dSQ3BpmxneNHmA/M7fe60M3PrV7nNGTTNbkK62l6vXiQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "once": "~1.3.0" - } - }, - "node_modules/stream-to-promise/node_modules/once": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/once/-/once-1.3.3.tgz", - "integrity": "sha512-6vaNInhu+CHxtONf3zw3vq4SP2DOQhjBvIa3rNcG0+P7eKWlYH6Peu7rHizSloRU2EwMz6GraLieis9Ac9+p1w==", - "dev": true, - "license": "ISC", - "dependencies": { - "wrappy": "1" - } - }, - "node_modules/streamx": { - "version": "2.26.0", - "resolved": "https://registry.npmjs.org/streamx/-/streamx-2.26.0.tgz", - "integrity": "sha512-VvNG1K72Po/xwJzxZFnZ++Tbrv4lwSptsbkFuzXCJAYZvCK5nnxsvXU6ajqkv7chyiI1Y0YXq2Jh8Iy8Y7NF/A==", - "dev": true, - "license": "MIT", - "dependencies": { - "events-universal": "^1.0.0", - "fast-fifo": "^1.3.2", - "text-decoder": "^1.1.0" - } - }, - "node_modules/strip-final-newline": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-2.0.0.tgz", - "integrity": "sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/tar": { - "version": "7.5.7", - "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.7.tgz", - "integrity": "sha512-fov56fJiRuThVFXD6o6/Q354S7pnWMJIVlDBYijsTNx6jKSE4pvrDTs6lUnmGvNyfJwFQQwWy3owKz1ucIhveQ==", - "deprecated": "Old versions of tar are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", - "dev": true, - "license": "BlueOak-1.0.0", - "dependencies": { - "@isaacs/fs-minipass": "^4.0.0", - "chownr": "^3.0.0", - "minipass": "^7.1.2", - "minizlib": "^3.1.0", - "yallist": "^5.0.0" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/tar-stream": { - "version": "3.1.7", - "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-3.1.7.tgz", - "integrity": "sha512-qJj60CXt7IU1Ffyc3NJMjh6EkuCFej46zUqJ4J7pqYlThyd9bO0XBTmcOIhSzZJVWfsLks0+nle/j538YAW9RQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "b4a": "^1.6.4", - "fast-fifo": "^1.2.0", - "streamx": "^2.15.0" - } - }, - "node_modules/text-decoder": { - "version": "1.2.7", - "resolved": "https://registry.npmjs.org/text-decoder/-/text-decoder-1.2.7.tgz", - "integrity": "sha512-vlLytXkeP4xvEq2otHeJfSQIRyWxo/oZGEbXrtEEF9Hnmrdly59sUbzZ/QgyWuLYHctCHxFF4tRQZNQ9k60ExQ==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "b4a": "^1.6.4" - } - }, - "node_modules/throttleit": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/throttleit/-/throttleit-2.1.0.tgz", - "integrity": "sha512-nt6AMGKW1p/70DF/hGBdJB57B8Tspmbp5gfJ8ilhLnt7kkr2ye7hzD6NVG8GGErk2HWF34igrL2CXmNIkzKqKw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/time-span": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/time-span/-/time-span-4.0.0.tgz", - "integrity": "sha512-MyqZCTGLDZ77u4k+jqg4UlrzPTPZ49NDlaekU6uuFaJLzPIN1woaRXCbGeqOfxwc3Y37ZROGAJ614Rdv7Olt+g==", - "dev": true, - "license": "MIT", - "dependencies": { - "convert-hrtime": "^3.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/tinyexec": { - "version": "0.3.2", - "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", - "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", - "dev": true, - "license": "MIT" - }, - "node_modules/to-regex-range": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", - "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "is-number": "^7.0.0" - }, - "engines": { - "node": ">=8.0" - } - }, - "node_modules/toidentifier": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.0.tgz", - "integrity": "sha512-yaOH/Pk/VEhBWWTlhI+qXxDFXlejDGcQipMlyxda9nthulaxLZUNcUqFxokp0vcYnvteJln5FNQDRrxj3YcbVw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.6" - } - }, - "node_modules/tr46": { - "version": "0.0.3", - "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", - "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", - "dev": true, - "license": "MIT" - }, - "node_modules/tree-kill": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz", - "integrity": "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==", - "dev": true, - "license": "MIT", - "bin": { - "tree-kill": "cli.js" - } - }, - "node_modules/ts-morph": { - "version": "12.0.0", - "resolved": "https://registry.npmjs.org/ts-morph/-/ts-morph-12.0.0.tgz", - "integrity": "sha512-VHC8XgU2fFW7yO1f/b3mxKDje1vmyzFXHWzOYmKEkCEwcLjDtbdLgBQviqj4ZwP4MJkQtRo6Ha2I29lq/B+VxA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@ts-morph/common": "~0.11.0", - "code-block-writer": "^10.1.1" - } - }, - "node_modules/ts-toolbelt": { - "version": "6.15.5", - "resolved": "https://registry.npmjs.org/ts-toolbelt/-/ts-toolbelt-6.15.5.tgz", - "integrity": "sha512-FZIXf1ksVyLcfr7M317jbB67XFJhOO1YqdTcuGaq9q5jLUoTikukZ+98TPjKiP2jC5CgmYdWWYs0s2nLSU0/1A==", - "dev": true, - "license": "Apache-2.0" - }, - "node_modules/tslib": { - "version": "2.8.1", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", - "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", - "dev": true, - "license": "0BSD" - }, - "node_modules/tsx": { - "version": "4.21.0", - "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz", - "integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==", - "dev": true, - "license": "MIT", - "dependencies": { - "esbuild": "~0.27.0", - "get-tsconfig": "^4.7.5" - }, - "bin": { - "tsx": "dist/cli.mjs" - }, - "engines": { - "node": ">=18.0.0" - }, - "optionalDependencies": { - "fsevents": "~2.3.3" - } - }, - "node_modules/typescript": { - "version": "5.9.3", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", - "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", - "dev": true, - "license": "Apache-2.0", - "bin": { - "tsc": "bin/tsc", - "tsserver": "bin/tsserver" - }, - "engines": { - "node": ">=14.17" - } - }, - "node_modules/uid-promise": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/uid-promise/-/uid-promise-1.0.0.tgz", - "integrity": "sha512-R8375j0qwXyIu/7R0tjdF06/sElHqbmdmWC9M2qQHpEVbvE4I5+38KJI7LUUmQMp7NVq4tKHiBMkT0NFM453Ig==", - "dev": true, - "license": "MIT" - }, - "node_modules/undici": { - "version": "6.26.0", - "resolved": "https://registry.npmjs.org/undici/-/undici-6.26.0.tgz", - "integrity": "sha512-4yqz8a3n5HmGTlsbADNtr/dJlhkh/55Rq798G6ibiULcXbDtaLpTl1pvdqcbFfeoj3iSi52lePFM7h9H21cw/A==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18.17" - } - }, - "node_modules/undici-types": { - "version": "5.26.5", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", - "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==", - "dev": true, - "license": "MIT" - }, - "node_modules/universalify": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", - "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 10.0.0" - } - }, - "node_modules/unpipe": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", - "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/uri-js": { - "version": "4.4.1", - "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", - "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "punycode": "^2.1.0" - } - }, - "node_modules/vercel": { - "version": "54.5.1", - "resolved": "https://registry.npmjs.org/vercel/-/vercel-54.5.1.tgz", - "integrity": "sha512-ST78YP0nF/OptCnNEpVewmXamPGyr9eIGzSmhgXf+pt9K/BE6tjgg+Ai5NM3eWZTI7e0I8cdkkcGYb5MkQj+Cw==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@vercel/backends": "0.8.1", - "@vercel/blob": "2.4.0", - "@vercel/build-utils": "13.26.3", - "@vercel/cli-config": "0.1.2", - "@vercel/detect-agent": "1.2.3", - "@vercel/elysia": "0.1.82", - "@vercel/express": "0.1.92", - "@vercel/fastify": "0.1.85", - "@vercel/fun": "1.3.0", - "@vercel/go": "3.8.0", - "@vercel/h3": "0.1.91", - "@vercel/hono": "0.2.85", - "@vercel/hydrogen": "1.3.8", - "@vercel/koa": "0.1.65", - "@vercel/nestjs": "0.2.86", - "@vercel/next": "4.17.4", - "@vercel/node": "5.8.6", - "@vercel/prepare-flags-definitions": "0.2.1", - "@vercel/python": "6.43.3", - "@vercel/redwood": "2.4.14", - "@vercel/remix-builder": "5.8.3", - "@vercel/ruby": "2.4.0", - "@vercel/rust": "1.3.0", - "@vercel/static-build": "2.9.32", - "chokidar": "4.0.0", - "esbuild": "0.27.0", - "form-data": "^4.0.0", - "jose": "5.9.6", - "luxon": "^3.4.0", - "proxy-agent": "6.4.0", - "sandbox": "2.5.6", - "smol-toml": "1.5.2", - "zod": "4.1.11" - }, - "bin": { - "vc": "dist/vc.js", - "vercel": "dist/vc.js" - }, - "engines": { - "node": ">= 18" - } - }, - "node_modules/web-vitals": { - "version": "0.2.4", - "resolved": "https://registry.npmjs.org/web-vitals/-/web-vitals-0.2.4.tgz", - "integrity": "sha512-6BjspCO9VriYy12z356nL6JBS0GYeEcA457YyRzD+dD6XYCQ75NKhcOHUMHentOE7OcVCIXXDvOm0jKFfQG2Gg==", - "dev": true, - "license": "Apache-2.0" - }, - "node_modules/webidl-conversions": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", - "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", - "dev": true, - "license": "BSD-2-Clause" - }, - "node_modules/whatwg-url": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", - "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", - "dev": true, - "license": "MIT", - "dependencies": { - "tr46": "~0.0.3", - "webidl-conversions": "^3.0.0" - } - }, - "node_modules/which": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", - "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", - "dev": true, - "license": "ISC", - "dependencies": { - "isexe": "^2.0.0" - }, - "bin": { - "node-which": "bin/node-which" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/wrappy": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", - "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", - "dev": true, - "license": "ISC" - }, - "node_modules/xdg-app-paths": { - "version": "5.5.1", - "resolved": "https://registry.npmjs.org/xdg-app-paths/-/xdg-app-paths-5.5.1.tgz", - "integrity": "sha512-hI3flOB4PLZIy5prbtTpirobtPE2ZtZ52szO+2mM9Efp6ErM398La+C1lIpNWDfNoQk+6Lsi6nMcCwVB7pxeMQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "os-paths": "^4.0.1", - "xdg-portable": "^7.2.0" - }, - "engines": { - "node": ">= 6.0" - } - }, - "node_modules/xdg-portable": { - "version": "7.3.0", - "resolved": "https://registry.npmjs.org/xdg-portable/-/xdg-portable-7.3.0.tgz", - "integrity": "sha512-sqMMuL1rc0FmMBOzCpd0yuy9trqF2yTTVe+E9ogwCSWQCdDEtQUwrZPT6AxqtsFGRNxycgncbP/xmOOSPw5ZUw==", - "dev": true, - "license": "MIT", - "dependencies": { - "os-paths": "^4.0.1" - }, - "engines": { - "node": ">= 6.0" - } - }, - "node_modules/yallist": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-5.0.0.tgz", - "integrity": "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==", - "dev": true, - "license": "BlueOak-1.0.0", - "engines": { - "node": ">=18" - } - }, - "node_modules/yauzl": { - "version": "2.10.0", - "resolved": "https://registry.npmjs.org/yauzl/-/yauzl-2.10.0.tgz", - "integrity": "sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g==", - "dev": true, - "license": "MIT", - "dependencies": { - "buffer-crc32": "~0.2.3", - "fd-slicer": "~1.1.0" - } - }, - "node_modules/yauzl-clone": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/yauzl-clone/-/yauzl-clone-1.0.4.tgz", - "integrity": "sha512-igM2RRCf3k8TvZoxR2oguuw4z1xasOnA31joCqHIyLkeWrvAc2Jgay5ISQ2ZplinkoGaJ6orCz56Ey456c5ESA==", - "dev": true, - "license": "MIT", - "dependencies": { - "events-intercept": "^2.0.0" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/yauzl-promise": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/yauzl-promise/-/yauzl-promise-2.1.3.tgz", - "integrity": "sha512-A1pf6fzh6eYkK0L4Qp7g9jzJSDrM6nN0bOn5T0IbY4Yo3w+YkWlHFkJP7mzknMXjqusHFHlKsK2N+4OLsK2MRA==", - "dev": true, - "license": "MIT", - "dependencies": { - "yauzl": "^2.9.1", - "yauzl-clone": "^1.0.4" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/zod": { - "version": "4.1.11", - "resolved": "https://registry.npmjs.org/zod/-/zod-4.1.11.tgz", - "integrity": "sha512-WPsqwxITS2tzx1bzhIKsEs19ABD5vmCVa4xBo2tq/SrV4RNZtfws1EnCWQXM6yh8bD08a1idvkB5MZSBiZsjwg==", - "dev": true, - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/colinhacks" - } - } - } -} diff --git a/apps/seat-guide-speaker-vercel/package.json b/apps/seat-guide-speaker-vercel/package.json index 788fa9f5ee..6a88b3acdc 100644 --- a/apps/seat-guide-speaker-vercel/package.json +++ b/apps/seat-guide-speaker-vercel/package.json @@ -4,10 +4,6 @@ "private": true, "type": "module", "scripts": { - "dev": "vercel dev", "lint": "node --check 'api/[...speaker].js'" - }, - "devDependencies": { - "vercel": "^54.5.1" } } From 9e6fa3c09ceeaf29029523cf93163bf97a82fac5 Mon Sep 17 00:00:00 2001 From: Ernest Date: Fri, 29 May 2026 09:24:12 +0800 Subject: [PATCH 09/16] refactor(seat-guide): remove unsupported Go2 audio path --- bin/demo_seat_guide_hardware_acceptance | 49 +-- bin/demo_seat_guide_hardware_bringup | 14 +- bin/demo_seat_guide_smoke | 7 +- bin/demo_seat_guide_verify_acceptance_log | 10 - dimos/agents/skills/seat_guide.py | 52 +-- dimos/agents/skills/speak_skill.py | 32 +- dimos/agents/skills/test_seat_guide.py | 250 ++----------- dimos/agents/skills/test_speak_skill.py | 182 ---------- dimos/agents/skills/unitree_speak_skill.py | 340 ------------------ dimos/agents/system_prompt.py | 6 +- .../blueprints/agentic/_seat_guide_agentic.py | 2 - docs/agents/seat_guide_modules.md | 38 +- docs/agents/seat_guide_step_by_step_plan.md | 37 +- .../agents/seat_guide_step_by_step_plan_en.md | 37 +- 14 files changed, 93 insertions(+), 963 deletions(-) delete mode 100644 dimos/agents/skills/test_speak_skill.py delete mode 100644 dimos/agents/skills/unitree_speak_skill.py diff --git a/bin/demo_seat_guide_hardware_acceptance b/bin/demo_seat_guide_hardware_acceptance index ad5a941594..01a361bf5b 100755 --- a/bin/demo_seat_guide_hardware_acceptance +++ b/bin/demo_seat_guide_hardware_acceptance @@ -100,8 +100,7 @@ seat_guide_goal_completed_after_sequence() { seat_guide_preflight_ready_for_hardware() { local output="$1" grep -Fq "SeatGuide preflight ready" <<<"${output}" \ - && grep -Fq "navigation=IDLE" <<<"${output}" \ - && grep -Fq "speaker=connected" <<<"${output}" + && grep -Fq "navigation=IDLE" <<<"${output}" } web_input_ready_for_seat_guide() { @@ -194,16 +193,6 @@ log_camera_provider_no_go_details() { fi } -log_speech_no_go_details() { - local output="$1" - if ! grep -Fq "tts=ready" <<<"${output}"; then - log " - TTS is not ready. Export OPENAI_API_KEY in the environment used to start the DimOS daemon, then restart the SeatGuide stack." - fi - if ! grep -Fq "audio_output=connected" <<<"${output}"; then - log " - Audio output is not connected. Check the robot/local speaker device before relying on spoken feedback." - fi -} - log_seat_guide_no_go_details() { local output="$1" if grep -Fq "navigation=FOLLOWING_PATH" <<<"${output}" \ @@ -214,9 +203,6 @@ log_seat_guide_no_go_details() { || grep -Fq "navigation=error(" <<<"${output}"; then log " - Navigation is unavailable. Check the NavigationSkillContainer wiring and navigation logs before sending a SeatGuide goal." fi - if grep -Fq "speaker=missing" <<<"${output}"; then - log " - SeatGuide speaker wiring is missing. Confirm SpeakSkill is in the SeatGuide blueprint and connected before official hardware acceptance." - fi if grep -Fq "source=no_camera_image" <<<"${output}" \ || grep -Fq "perception=no_camera_image" <<<"${output}"; then log " - SeatGuide has no camera image. Check the Go2 camera stream and turn the robot toward the conference table." @@ -523,7 +509,7 @@ require_tool() { local tool_name="$2" if ! grep -q "\"${tool_name}\"" <<<"${tools}"; then log "Hardware acceptance no-go: missing MCP tool '${tool_name}'." - log "Confirm the running blueprint is unitree-go2-seat-guide or unitree-go2-seat-guide-agentic and includes the SeatGuide, WebInput, camera provider, and SpeakSkill modules." + log "Confirm the running blueprint is unitree-go2-seat-guide or unitree-go2-seat-guide-agentic and includes the SeatGuide, WebInput, and camera provider modules." log "Transcript saved to: ${log_file}" exit 3 fi @@ -587,8 +573,6 @@ require_tool "${tools}" "seat_guide_navigation_status" require_tool "${tools}" "preview_seat_request" require_tool "${tools}" "preview_empty_seat_goal" require_tool "${tools}" "handle_seat_request" -require_tool "${tools}" "speech_status" -require_tool "${tools}" "speak" log "" log "Checking SeatGuide module wiring..." @@ -597,7 +581,6 @@ modules_output="${run_output}" require_output_contains "${modules_output}" "CameraSeatObservationProvider" "mcp modules" require_output_contains "${modules_output}" "SeatGuideSkillContainer" "mcp modules" require_output_contains "${modules_output}" "WebInput" "mcp modules" -require_output_contains "${modules_output}" "SpeakSkill" "mcp modules" log "" log "Checking WebInput route..." @@ -626,34 +609,6 @@ if ! camera_provider_ready_for_hardware "${camera_output}"; then exit 3 fi -log "" -log "Checking speech output readiness..." -run_capture mcp call speech_status -speech_output="${run_output}" -if ! grep -Fq "tts=ready" <<<"${speech_output}" \ - || ! grep -Fq "audio_output=connected" <<<"${speech_output}"; then - log "Hardware acceptance no-go: speech_status was not hardware ready." - log_speech_no_go_details "${speech_output}" - log "Transcript saved to: ${log_file}" - exit 3 -fi -run_capture mcp call speak --json-args '{"text": "SeatGuide audio check. I can guide you to an empty seat.", "blocking": true}' -audio_check_output="${run_output}" -require_output_contains "${audio_check_output}" "Spoke: SeatGuide audio check" "speak audio check" -cat <<'EOF' | tee -a "${log_file}" - -Audio output confirmation: - Type HEARD if you clearly heard: SeatGuide audio check. I can guide you to an empty seat. - Anything else is a no-go for official hardware acceptance. -EOF -read -r audio_confirmation -log "Operator audio confirmation: ${audio_confirmation}" -if [[ "${audio_confirmation}" != "HEARD" ]]; then - log "Hardware acceptance no-go: operator did not confirm audible SeatGuide speech output." - log "Transcript saved to: ${log_file}" - exit 3 -fi - log "" log "Checking current SeatGuide scene..." run_capture mcp call seat_guide_status diff --git a/bin/demo_seat_guide_hardware_bringup b/bin/demo_seat_guide_hardware_bringup index a494ebc2f8..2fce09f6db 100755 --- a/bin/demo_seat_guide_hardware_bringup +++ b/bin/demo_seat_guide_hardware_bringup @@ -21,8 +21,6 @@ Required environment: ALIBABA_API_KEY Required only when --detection-model qwen. OPENROUTER_API_KEY or OPENAI_API_KEY LLM agent for normal voice/text commands. - OPENAI_API_KEY Optional for TTS speech feedback. Hardware acceptance - still reports TTS as no-go when this is missing. EOF } @@ -99,17 +97,10 @@ If you choose --detection-model qwen, also set: export ALIBABA_API_KEY=... -For spoken TTS feedback during official hardware acceptance, also set: - - export OPENAI_API_KEY=... EOF exit 2 fi -if [[ -z "${OPENAI_API_KEY:-}" ]]; then - echo "SeatGuide bring-up warning: OPENAI_API_KEY is not set; TTS speech feedback will be unavailable." >&2 -fi - script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" if [[ "${skip_start}" != "1" ]]; then @@ -138,9 +129,8 @@ SeatGuide hardware acceptance will now verify the real browser microphone path. You will need to: 1. Open the WebInput URL printed by the script. 2. Allow microphone access. - 3. Type HEARD after the TTS audio check is audible. - 4. Type LIVE only when the Go2 is physically clear to move. - 5. Say the prompted Chinese phrases into the browser microphone. + 3. Type LIVE only when the Go2 is physically clear to move. + 4. Say the prompted Chinese phrases into the browser microphone. EOF "${script_dir}/demo_seat_guide_hardware_acceptance" diff --git a/bin/demo_seat_guide_smoke b/bin/demo_seat_guide_smoke index 3bbc46b13e..d2e99ad1f4 100755 --- a/bin/demo_seat_guide_smoke +++ b/bin/demo_seat_guide_smoke @@ -15,7 +15,7 @@ require_tool() { local tool_name="$2" if ! grep -q "\"${tool_name}\"" <<<"${tools}"; then echo "SeatGuide smoke no-go: missing MCP tool '${tool_name}'." >&2 - echo "Confirm the running blueprint is unitree-go2-seat-guide or unitree-go2-seat-guide-agentic and includes SeatGuide, WebInput, camera provider, and SpeakSkill modules." >&2 + echo "Confirm the running blueprint is unitree-go2-seat-guide or unitree-go2-seat-guide-agentic and includes SeatGuide, WebInput, and camera provider modules." >&2 exit 3 fi } @@ -94,7 +94,6 @@ require_tool "${tools}" "seat_guide_status" require_tool "${tools}" "preview_empty_seat_goal" require_tool "${tools}" "web_input_status" require_tool "${tools}" "camera_seat_provider_status" -require_tool "${tools}" "speech_status" echo echo "Checking WebInput voice/text route status..." @@ -116,10 +115,6 @@ echo echo "Checking current SeatGuide scene..." run_dimos mcp call seat_guide_status -echo -echo "Checking speech output readiness..." -run_dimos mcp call speech_status - echo echo "Running no-motion readiness report..." run_dimos mcp call seat_guide_readiness_report diff --git a/bin/demo_seat_guide_verify_acceptance_log b/bin/demo_seat_guide_verify_acceptance_log index 14dbfed1f0..9c3b575103 100755 --- a/bin/demo_seat_guide_verify_acceptance_log +++ b/bin/demo_seat_guide_verify_acceptance_log @@ -118,7 +118,6 @@ require_log_contains "Using WebInput URL: http" "resolved WebInput URL" require_log_contains "CameraSeatObservationProvider" "camera perception module" require_log_contains "SeatGuideSkillContainer" "SeatGuide planner/navigation module" require_log_contains "WebInput" "voice command intake module" -require_log_contains "SpeakSkill" "speech feedback module" require_log_matches "image=[0-9]+x[0-9]+" "camera image readiness" require_log_contains "image_fresh=true" "fresh camera image readiness" require_log_matches "camera_info=[0-9]+x[0-9]+" "camera calibration readiness" @@ -131,15 +130,8 @@ require_log_contains "odom_fresh=true" "fresh odometry readiness" require_log_contains "override=inactive" "camera runtime override disabled" require_log_contains "configured_fallback_seats=0" "camera fallback seats disabled" require_log_contains "configured_fallback_people=0" "camera fallback people disabled" -require_log_contains "tts=ready" "TTS readiness" -require_log_contains "audio_output=connected" "audio output readiness" -require_log_contains "SeatGuide audio check. I can guide you to an empty seat." "TTS audio check phrase" -require_log_contains "Spoke: SeatGuide audio check" "TTS audio check completion" -require_log_contains "Audio output confirmation:" "operator TTS audio confirmation prompt" -require_log_contains "Operator audio confirmation: HEARD" "operator heard TTS confirmation" require_log_matches "SeatGuide scene source=camera(_3d)?:" "live camera perception" require_log_contains "SeatGuide preflight ready" "no-motion preflight" -require_log_contains "speaker=connected" "SeatGuide speaker wiring" require_log_matches "SeatGuide preview source=camera(_3d)?:" "camera-backed goal preview" require_log_matches "empty=[0-9]+ occupied=[0-9]+" "SeatGuide occupancy counts" require_log_contains "Captured WebInput agent_responses stream" "typed WebInput stream" @@ -158,8 +150,6 @@ require_log_order "Captured WebInput agent_responses stream" "Manual no-motion v require_log_order "Manual no-motion voice gate:" "Press Enter here when ready." "no-motion voice gate before readiness prompt order" require_log_order_after "Manual no-motion voice gate:" "Press Enter here when ready." "Click the microphone button and say: 预检帮我找一个空位" "no-motion readiness before speech order" require_log_order "Manual no-motion voice gate:" "Captured WebInput voice agent_responses stream" "no-motion voice gate before voice stream order" -require_log_order "Spoke: SeatGuide audio check" "Operator audio confirmation: HEARD" "TTS completion before operator audio confirmation order" -require_log_order "Operator audio confirmation: HEARD" "SeatGuide scene source=camera:" "operator audio confirmation before perception order" require_log_order "WebInput routing text to SeatGuide preview" "Capturing DimOS log snapshot after no-motion checks" "no-motion preview before log snapshot order" require_log_order "Capturing DimOS log snapshot after no-motion checks" "No-motion checks completed." "no-motion snapshot before completion order" require_log_contains "Operator confirmation: LIVE" "operator live confirmation" diff --git a/dimos/agents/skills/seat_guide.py b/dimos/agents/skills/seat_guide.py index 49c239a379..44e54373cb 100644 --- a/dimos/agents/skills/seat_guide.py +++ b/dimos/agents/skills/seat_guide.py @@ -25,7 +25,6 @@ from reactivex.disposable import Disposable from dimos.agents.annotation import skill -from dimos.agents.skills.speak_skill_spec import SpeakSkillSpec from dimos.core.core import rpc from dimos.core.module import Module, ModuleConfig from dimos.core.stream import In @@ -196,7 +195,6 @@ class SeatGuideSkillContainer(Module): _explorer: ExplorationSpec | None = None _direct_mover: DirectMoveSpec | None = None _relative_mover: RelativeMoveSpec | None = None - _speaker: SpeakSkillSpec | None = None _seat_guide_goal_sequence: int = 0 _seat_guide_goal_reached_reset_required: bool = False @@ -235,7 +233,7 @@ def find_empty_seat( wait_for_arrival: When true, wait for navigation to finish before returning. arrival_timeout_s: Maximum seconds to wait for arrival. arrival_poll_s: Delay between navigation status checks. - arrival_message: Speech feedback to play after arrival. + arrival_message: Text response to return after arrival. """ seat_observations = _parse_seats(seats) person_observations = _parse_people(people) @@ -244,7 +242,6 @@ def find_empty_seat( "I cannot see any seats yet. Please face the conference table or calibrate " "the room layout." ) - self._speak_feedback(message) return message planner = SeatGuidePlanner() @@ -256,7 +253,6 @@ def find_empty_seat( ) if result is None: message = "I could not find an empty seat in the conference room." - self._speak_feedback(message) return message goal = PoseStamped( @@ -270,7 +266,6 @@ def find_empty_seat( f"Found empty seat {result.seat.seat_id}, but navigation is not ready " f"for a new goal: {navigation_text}." ) - self._speak_feedback(message) return message previous_goal_reached = self._navigation_goal_reached_or_false() @@ -281,17 +276,14 @@ def find_empty_seat( f"Found empty seat {result.seat.seat_id}, but navigation raised an error: " f"{exc}." ) - self._speak_feedback(message) return message if not goal_started: message = f"Found empty seat {result.seat.seat_id}, but failed to start navigation." - self._speak_feedback(message) return message self._seat_guide_goal_sequence = getattr(self, "_seat_guide_goal_sequence", 0) + 1 self._seat_guide_goal_reached_reset_required = previous_goal_reached - self._speak_feedback(result.spoken_summary) navigating_message = ( f"{result.spoken_summary} Navigating to ({result.goal_x:.2f}, {result.goal_y:.2f})." ) @@ -303,21 +295,18 @@ def find_empty_seat( poll_s=arrival_poll_s, ) if arrival_result == "arrived": - self._speak_feedback(arrival_message) return f"{navigating_message} {arrival_message}" if arrival_result == "failed": message = ( f"{navigating_message} I found the empty seat, but navigation stopped " "before reaching it." ) - self._speak_feedback(message) return message message = ( f"{navigating_message} I found the empty seat, but navigation did not " f"finish within {arrival_timeout_s:.0f} seconds." ) - self._speak_feedback(message) return message @skill @@ -341,13 +330,11 @@ def find_empty_seat_from_scene( """ if self._seat_observation_provider is None: message = "No seat observation provider is connected." - self._speak_feedback(message) return message scene = self._seat_observation_provider.get_seat_scene() if require_live_perception and not _is_live_camera_source(scene.source): message = _describe_live_perception_required(scene) - self._speak_feedback(message) return message seats = _flatten_seats(scene.seats) people = _flatten_people(scene.people) @@ -386,7 +373,6 @@ def search_for_empty_seat_from_scene( """ if self._seat_observation_provider is None: message = "No seat observation provider is connected." - self._speak_feedback(message) return message if self._direct_mover is not None or self._relative_mover is not None: @@ -404,7 +390,6 @@ def search_for_empty_seat_from_scene( "I cannot see any seats yet, and no exploration module is connected " "to search for one." ) - self._speak_feedback(message) return message search_timeout_s = max(1.0, min(search_timeout_s, 120.0)) @@ -422,7 +407,6 @@ def search_for_empty_seat_from_scene( and not _is_live_camera_source(initial_scene.source) ): message = _describe_live_perception_required(initial_scene) - self._speak_feedback(message) return message self._explorer.begin_exploration() @@ -450,7 +434,6 @@ def search_for_empty_seat_from_scene( "I searched but still cannot see an empty seat. Please reposition me " "or point the camera toward the conference table." ) - self._speak_feedback(message) return message @skill @@ -483,14 +466,12 @@ def scan_for_empty_seat_from_scene( """ if self._seat_observation_provider is None: message = "No seat observation provider is connected." - self._speak_feedback(message) return message if self._direct_mover is None and self._relative_mover is None: message = ( "I cannot rotate-scan for a seat because no relative movement " "module is connected." ) - self._speak_feedback(message) return message max_turn_degrees = max(30.0, min(float(max_turn_degrees), 720.0)) @@ -514,7 +495,6 @@ def scan_for_empty_seat_from_scene( and scene.source != "camera_no_seats_detected" ): message = _describe_live_perception_required(scene) - self._speak_feedback(message) return message for _ in range(steps): @@ -524,7 +504,6 @@ def scan_for_empty_seat_from_scene( ) if "failed" in move_result.lower() or "cancelled" in move_result.lower(): message = f"SeatGuide scan stopped because rotation failed: {move_result}." - self._speak_feedback(message) return message time.sleep(settle_s) @@ -542,7 +521,6 @@ def scan_for_empty_seat_from_scene( "I rotated in place but still cannot see an empty seat. Please reposition me " "or point the camera toward the conference table." ) - self._speak_feedback(message) return message def _turn_in_place(self, *, degrees: float, yaw_rate_rad_s: float) -> str: @@ -602,7 +580,6 @@ def handle_seat_request( intent = parse_seat_guide_intent(text) if not intent.should_find_seat: message = "I did not hear a request to find an empty seat." - self._speak_feedback(message) return message scene = ( @@ -644,11 +621,9 @@ def preview_seat_request(self, text: str) -> str: intent = parse_seat_guide_intent(text) if not intent.should_find_seat: message = "I did not hear a request to find an empty seat." - self._speak_feedback(message) return message message = self.seat_guide_preflight() - self._speak_feedback(message) return message @skill @@ -668,7 +643,7 @@ def seat_guide_preflight(self, require_live_perception: bool = True) -> str: return ( "SeatGuide preflight no-go: " f"{self._navigation_readiness_text()[0]}; perception=missing; " - f"{self._speaker_readiness_text()}." + "feedback=phone_or_web." ) scene = self._seat_observation_provider.get_seat_scene() @@ -790,14 +765,6 @@ def _wait_for_arrival(self, *, timeout_s: float, poll_s: float) -> str: return "timeout" - def _speak_feedback(self, text: str) -> None: - if self._speaker is None: - return - try: - self._speaker.speak(text, blocking=False) - except Exception: - logger.warning("SeatGuide speech feedback failed", exc_info=True) - def _navigation_readiness_text(self) -> tuple[str, bool]: if not hasattr(self, "_navigation") or self._navigation is None: return "navigation=missing", False @@ -807,9 +774,6 @@ def _navigation_readiness_text(self) -> tuple[str, bool]: except Exception as exc: return f"navigation=error({exc})", False - def _speaker_readiness_text(self) -> str: - return "speaker=connected" if self._speaker is not None else "speaker=missing" - def _describe_preflight( self, scene: SeatSceneObservation, @@ -817,18 +781,19 @@ def _describe_preflight( require_live_perception: bool, ) -> str: navigation_text, navigation_ok = self._navigation_readiness_text() - speaker_text = self._speaker_readiness_text() if not scene.seats: return ( "SeatGuide preflight no-go: " - f"{navigation_text}; perception={scene.source} no seats; {speaker_text}." + f"{navigation_text}; perception={scene.source} no seats; " + "feedback=phone_or_web." ) if require_live_perception and not _is_live_camera_source(scene.source): return ( "SeatGuide preflight no-go: " f"{navigation_text}; perception={scene.source} is not live camera; " - f"seats={len(scene.seats)} people={len(scene.people)}; {speaker_text}." + f"seats={len(scene.seats)} people={len(scene.people)}; " + "feedback=phone_or_web." ) planner = SeatGuidePlanner() @@ -843,7 +808,8 @@ def _describe_preflight( return ( "SeatGuide preflight no-go: " f"{navigation_text}; perception={scene.source}; no empty seat; " - f"empty={empty_count} occupied={occupied_count}; {speaker_text}." + f"empty={empty_count} occupied={occupied_count}; " + "feedback=phone_or_web." ) verdict = "ready" if navigation_ok else "no-go" @@ -853,7 +819,7 @@ def _describe_preflight( f"empty={empty_count} occupied={occupied_count}; " f"selected={result.seat.seat_id}; " f"goal=({result.goal_x:.2f}, {result.goal_y:.2f}, yaw={result.goal_yaw:.2f}); " - f"{speaker_text}." + "feedback=phone_or_web." ) diff --git a/dimos/agents/skills/speak_skill.py b/dimos/agents/skills/speak_skill.py index 9391287e72..b46de157c4 100644 --- a/dimos/agents/skills/speak_skill.py +++ b/dimos/agents/skills/speak_skill.py @@ -12,7 +12,6 @@ # See the License for the specific language governing permissions and # limitations under the License. -import os import threading import time @@ -32,7 +31,6 @@ class SpeakSkill(Module): _tts_node: OpenAITTSNode | None = None _audio_output: SounddeviceAudioOutput | None = None - _speech_unavailable_reason: str | None = None _audio_lock: threading.Lock = threading.Lock() _bg_threads: list[threading.Thread] = [] _bg_threads_lock: threading.Lock = threading.Lock() @@ -40,10 +38,6 @@ class SpeakSkill(Module): @rpc def start(self) -> None: super().start() - if not os.getenv("OPENAI_API_KEY"): - self._speech_unavailable_reason = "OPENAI_API_KEY is not set" - logger.warning("SpeakSkill TTS disabled because OPENAI_API_KEY is not set") - return self._tts_node = OpenAITTSNode(speed=1.2, voice=Voice.ONYX) self._audio_output = SounddeviceAudioOutput(sample_rate=24000) self._audio_output.consume_audio(self._tts_node.emit_audio()) @@ -75,8 +69,7 @@ def speak(self, text: str, blocking: bool = True) -> str: speak("Hello, I am your robot assistant.") """ if self._tts_node is None: - reason = self._speech_unavailable_reason or "TTS not initialized" - return f"Speech unavailable: {reason}" + return "Error: TTS not initialized" if not blocking: thread = threading.Thread( @@ -89,29 +82,6 @@ def speak(self, text: str, blocking: bool = True) -> str: return self._speak_blocking(text) - @skill - def speech_status(self) -> str: - """Report text-to-speech readiness without speaking. - - Use this during hardware bring-up to confirm OpenAI TTS and the local - audio output are initialized before relying on spoken feedback. - """ - with self._bg_threads_lock: - active_background_threads = sum(1 for thread in self._bg_threads if thread.is_alive()) - if self._tts_node is None: - reason = self._speech_unavailable_reason or "TTS not initialized" - return ( - "SpeakSkill status: tts=unavailable; " - f"reason={reason}; audio_output=missing; " - f"background_speech_threads={active_background_threads}." - ) - audio_output = "connected" if self._audio_output is not None else "missing" - return ( - "SpeakSkill status: tts=ready; " - f"audio_output={audio_output}; " - f"background_speech_threads={active_background_threads}." - ) - def _speak_bg(self, text: str) -> None: try: self._speak_blocking(text) diff --git a/dimos/agents/skills/test_seat_guide.py b/dimos/agents/skills/test_seat_guide.py index 70a9d0d517..f0e4f043e2 100644 --- a/dimos/agents/skills/test_seat_guide.py +++ b/dimos/agents/skills/test_seat_guide.py @@ -214,18 +214,6 @@ def direct_move( return self.result -class FakeSpeaker: - def __init__(self, *, raises: bool = False) -> None: - self.spoken: list[tuple[str, bool]] = [] - self.raises = raises - - def speak(self, text: str, blocking: bool = True) -> str: - if self.raises: - raise RuntimeError("audio device unavailable") - self.spoken.append((text, blocking)) - return f"Spoke: {text}" - - class FakeSeatGuideRequest: def __init__(self, *, raises: bool = False) -> None: self.requests: list[str] = [] @@ -349,23 +337,6 @@ def get_last_goal_xy(self) -> tuple[float, float] | None: return self._last_goal.position.x, self._last_goal.position.y -class RecordingSpeaker(Module): - _spoken: list[str] - - def __init__(self, **kwargs: Any) -> None: - super().__init__(**kwargs) - self._spoken = [] - - @rpc - def speak(self, text: str, blocking: bool = True) -> str: - self._spoken.append(text) - return f"Spoke: {text}" - - @rpc - def get_spoken(self) -> list[str]: - return self._spoken - - def test_planner_selects_nearest_empty_seat() -> None: planner = SeatGuidePlanner(occupied_radius_m=0.75, aisle_offset_m=0.5) seats = [ @@ -483,21 +454,6 @@ def test_skill_refuses_to_override_active_navigation_goal() -> None: assert fake_navigation.goal is None -def test_skill_continues_when_speech_feedback_raises() -> None: - skill = SeatGuideSkillContainer.__new__(SeatGuideSkillContainer) - fake_navigation = FakeNavigation() - skill._navigation = fake_navigation - skill._speaker = FakeSpeaker(raises=True) - - message = skill.find_empty_seat(seats=[1.0, 0.0, 0.0], people=[]) - - assert message == ( - "I found an empty seat seat_1. Please follow me to the chair beside the table. " - "Navigating to (1.65, 0.00)." - ) - assert fake_navigation.goal is not None - - def test_skill_uses_connected_observation_provider() -> None: skill = SeatGuideSkillContainer.__new__(SeatGuideSkillContainer) fake_navigation = FakeNavigation() @@ -530,9 +486,7 @@ def test_skill_reports_missing_observation_provider() -> None: def test_skill_reports_no_visible_seats_separately() -> None: skill = SeatGuideSkillContainer.__new__(SeatGuideSkillContainer) - fake_speaker = FakeSpeaker() skill._navigation = FakeNavigation() - skill._speaker = fake_speaker message = skill.find_empty_seat(seats=[], people=[]) @@ -540,7 +494,6 @@ def test_skill_reports_no_visible_seats_separately() -> None: "I cannot see any seats yet. Please face the conference table or calibrate " "the room layout." ) - assert fake_speaker.spoken == [(message, False)] def test_handle_seat_request_delegates_to_scene_provider() -> None: @@ -568,42 +521,13 @@ def test_handle_seat_request_delegates_to_scene_provider() -> None: assert fake_navigation.goal.position.x == pytest.approx(1.65) -def test_handle_seat_request_speaks_feedback_when_speaker_is_connected() -> None: - skill = SeatGuideSkillContainer.__new__(SeatGuideSkillContainer) - fake_navigation = FakeNavigation() - fake_speaker = FakeSpeaker() - skill._navigation = fake_navigation - skill._speaker = fake_speaker - skill._seat_observation_provider = FakeSeatObservationProvider( - SeatSceneObservation( - seats=[ - SeatObservation("occupied", x=0.0, y=0.0, yaw=0.0), - SeatObservation("empty", x=1.0, y=0.0, yaw=0.0), - ], - people=[PersonObservation(x=0.1, y=0.0)], - source="camera", - ) - ) - - skill.handle_seat_request( - "Please help me find an empty seat", - wait_for_arrival=False, - ) - - assert fake_speaker.spoken == [ - ("I found an empty seat seat_2. Please follow me to the chair beside the table.", False) - ] - - -def test_handle_seat_request_waits_and_speaks_when_arrived() -> None: +def test_handle_seat_request_waits_and_reports_when_arrived() -> None: skill = SeatGuideSkillContainer.__new__(SeatGuideSkillContainer) fake_navigation = SequencedNavigation( states=[NavigationState.IDLE, NavigationState.FOLLOWING_PATH, NavigationState.IDLE], goal_reached_values=[False, False, True], ) - fake_speaker = FakeSpeaker() skill._navigation = fake_navigation - skill._speaker = fake_speaker skill._seat_observation_provider = FakeSeatObservationProvider( SeatSceneObservation( seats=[SeatObservation("empty", x=1.0, y=0.0, yaw=0.0)], @@ -619,10 +543,6 @@ def test_handle_seat_request_waits_and_speaks_when_arrived() -> None: ) assert "我已经到了, 空椅子在我右边, 请坐。" in message - assert fake_speaker.spoken == [ - ("I found an empty seat seat_1. Please follow me to the chair beside the table.", False), - ("我已经到了, 空椅子在我右边, 请坐。", False), - ] def test_handle_seat_request_reports_when_navigation_stops_before_arrival() -> None: @@ -631,9 +551,7 @@ def test_handle_seat_request_reports_when_navigation_stops_before_arrival() -> N states=[NavigationState.IDLE, NavigationState.FOLLOWING_PATH, NavigationState.IDLE], goal_reached_values=[False, False, False], ) - fake_speaker = FakeSpeaker() skill._navigation = fake_navigation - skill._speaker = fake_speaker skill._seat_observation_provider = FakeSeatObservationProvider( SeatSceneObservation( seats=[SeatObservation("empty", x=1.0, y=0.0, yaw=0.0)], @@ -649,15 +567,12 @@ def test_handle_seat_request_reports_when_navigation_stops_before_arrival() -> N ) assert "navigation stopped before reaching it" in message - assert fake_speaker.spoken[-1] == (message, False) def test_handle_seat_request_requires_live_camera_by_default() -> None: skill = SeatGuideSkillContainer.__new__(SeatGuideSkillContainer) fake_navigation = FakeNavigation() - fake_speaker = FakeSpeaker() skill._navigation = fake_navigation - skill._speaker = fake_speaker skill._seat_observation_provider = FakeSeatObservationProvider( SeatSceneObservation( seats=[SeatObservation("fallback", x=1.0, y=0.0, yaw=0.0)], @@ -677,7 +592,6 @@ def test_handle_seat_request_requires_live_camera_by_default() -> None: "next=use require_live_perception=false only for explicit fallback calibration." ) assert fake_navigation.goal is None - assert fake_speaker.spoken == [(message, False)] def test_handle_seat_request_reports_camera_detection_error_next_step() -> None: @@ -920,10 +834,8 @@ def test_scan_for_empty_seat_reports_rotation_failure() -> None: skill = SeatGuideSkillContainer.__new__(SeatGuideSkillContainer) fake_navigation = FakeNavigation() fake_relative_mover = FakeRelativeMover("Navigation was cancelled or failed") - fake_speaker = FakeSpeaker() skill._navigation = fake_navigation skill._relative_mover = fake_relative_mover - skill._speaker = fake_speaker skill._seat_observation_provider = FakeSeatObservationProvider( SeatSceneObservation(seats=[], people=[], source="camera_no_seats_detected") ) @@ -940,17 +852,14 @@ def test_scan_for_empty_seat_reports_rotation_failure() -> None: ) assert fake_relative_mover.moves == [(0.0, 0.0, 30.0)] assert fake_navigation.goal is None - assert fake_speaker.spoken == [(message, False)] def test_search_for_empty_seat_stops_exploration_on_timeout() -> None: skill = SeatGuideSkillContainer.__new__(SeatGuideSkillContainer) fake_navigation = FakeNavigation() fake_explorer = FakeExplorer() - fake_speaker = FakeSpeaker() skill._navigation = fake_navigation skill._explorer = fake_explorer - skill._speaker = fake_speaker skill._seat_observation_provider = FakeSeatObservationProvider( SeatSceneObservation(seats=[], people=[], source="camera_no_seats_detected") ) @@ -967,7 +876,6 @@ def test_search_for_empty_seat_stops_exploration_on_timeout() -> None: assert fake_explorer.begin_calls == 1 assert fake_explorer.end_calls == 1 assert fake_navigation.goal is None - assert fake_speaker.spoken == [(message, False)] def test_handle_seat_request_rejects_unrelated_text() -> None: @@ -981,9 +889,7 @@ def test_handle_seat_request_rejects_unrelated_text() -> None: def test_preview_seat_request_runs_preflight_without_navigating() -> None: skill = SeatGuideSkillContainer.__new__(SeatGuideSkillContainer) fake_navigation = FakeNavigation() - fake_speaker = FakeSpeaker() skill._navigation = fake_navigation - skill._speaker = fake_speaker skill._seat_observation_provider = FakeSeatObservationProvider( SeatSceneObservation( seats=[SeatObservation("empty", x=2.0, y=0.0, yaw=0.0)], @@ -998,7 +904,6 @@ def test_preview_seat_request_runs_preflight_without_navigating() -> None: assert "SeatGuide preflight ready" in message assert fake_navigation.goal is None - assert fake_speaker.spoken == [(message, False)] def test_seat_guide_status_describes_current_scene() -> None: @@ -1090,7 +995,6 @@ def test_seat_guide_preflight_reports_ready_without_navigating() -> None: skill = SeatGuideSkillContainer.__new__(SeatGuideSkillContainer) fake_navigation = FakeNavigation() skill._navigation = fake_navigation - skill._speaker = FakeSpeaker() skill._seat_observation_provider = FakeSeatObservationProvider( SeatSceneObservation( seats=[ @@ -1109,7 +1013,7 @@ def test_seat_guide_preflight_reports_ready_without_navigating() -> None: assert message == ( "SeatGuide preflight ready: navigation=IDLE; perception=camera seats=2 people=1; " "empty=1 occupied=1; selected=empty; " - "goal=(2.65, 0.00, yaw=0.00); speaker=connected." + "goal=(2.65, 0.00, yaw=0.00); feedback=phone_or_web." ) assert fake_navigation.goal is None @@ -1118,18 +1022,16 @@ def test_seat_guide_preflight_reports_no_go_without_provider() -> None: skill = SeatGuideSkillContainer.__new__(SeatGuideSkillContainer) skill._navigation = FakeNavigation() skill._seat_observation_provider = None - skill._speaker = None assert ( skill.seat_guide_preflight() - == "SeatGuide preflight no-go: navigation=IDLE; perception=missing; speaker=missing." + == "SeatGuide preflight no-go: navigation=IDLE; perception=missing; feedback=phone_or_web." ) def test_seat_guide_preflight_reports_no_go_without_visible_seats() -> None: skill = SeatGuideSkillContainer.__new__(SeatGuideSkillContainer) skill._navigation = FakeNavigation() - skill._speaker = None skill._seat_observation_provider = FakeSeatObservationProvider( SeatSceneObservation(seats=[], people=[], source="camera_detection_error") ) @@ -1137,14 +1039,13 @@ def test_seat_guide_preflight_reports_no_go_without_visible_seats() -> None: assert ( skill.seat_guide_preflight() == "SeatGuide preflight no-go: navigation=IDLE; perception=camera_detection_error " - "no seats; speaker=missing." + "no seats; feedback=phone_or_web." ) def test_seat_guide_preflight_reports_no_empty_seat_counts() -> None: skill = SeatGuideSkillContainer.__new__(SeatGuideSkillContainer) skill._navigation = FakeNavigation() - skill._speaker = None skill._seat_observation_provider = FakeSeatObservationProvider( SeatSceneObservation( seats=[ @@ -1161,7 +1062,7 @@ def test_seat_guide_preflight_reports_no_empty_seat_counts() -> None: assert skill.seat_guide_preflight() == ( "SeatGuide preflight no-go: navigation=IDLE; perception=camera; " - "no empty seat; empty=0 occupied=2; speaker=missing." + "no empty seat; empty=0 occupied=2; feedback=phone_or_web." ) @@ -1169,7 +1070,6 @@ def test_seat_guide_preflight_reports_no_go_when_navigation_is_busy() -> None: skill = SeatGuideSkillContainer.__new__(SeatGuideSkillContainer) fake_navigation = FakeNavigation(state=NavigationState.FOLLOWING_PATH) skill._navigation = fake_navigation - skill._speaker = None skill._seat_observation_provider = FakeSeatObservationProvider( SeatSceneObservation( seats=[SeatObservation("empty", x=2.0, y=0.0, yaw=0.0)], @@ -1183,7 +1083,7 @@ def test_seat_guide_preflight_reports_no_go_when_navigation_is_busy() -> None: assert message == ( "SeatGuide preflight no-go: navigation=FOLLOWING_PATH; " "perception=camera seats=1 people=0; empty=1 occupied=0; selected=empty; " - "goal=(2.65, 0.00, yaw=0.00); speaker=missing." + "goal=(2.65, 0.00, yaw=0.00); feedback=phone_or_web." ) assert fake_navigation.goal is None @@ -1192,7 +1092,6 @@ def test_seat_guide_preflight_requires_live_camera_by_default() -> None: skill = SeatGuideSkillContainer.__new__(SeatGuideSkillContainer) fake_navigation = FakeNavigation() skill._navigation = fake_navigation - skill._speaker = None skill._seat_observation_provider = FakeSeatObservationProvider( SeatSceneObservation( seats=[SeatObservation("fallback", x=2.0, y=0.0, yaw=0.0)], @@ -1205,7 +1104,7 @@ def test_seat_guide_preflight_requires_live_camera_by_default() -> None: assert message == ( "SeatGuide preflight no-go: navigation=IDLE; perception=configured_fallback " - "is not live camera; seats=1 people=0; speaker=missing." + "is not live camera; seats=1 people=0; feedback=phone_or_web." ) assert fake_navigation.goal is None @@ -1214,7 +1113,6 @@ def test_seat_guide_preflight_can_explicitly_allow_fallback_calibration() -> Non skill = SeatGuideSkillContainer.__new__(SeatGuideSkillContainer) fake_navigation = FakeNavigation() skill._navigation = fake_navigation - skill._speaker = None skill._seat_observation_provider = FakeSeatObservationProvider( SeatSceneObservation( seats=[SeatObservation("fallback", x=2.0, y=0.0, yaw=0.0)], @@ -1234,7 +1132,6 @@ def test_seat_guide_readiness_report_combines_no_motion_checks() -> None: skill = SeatGuideSkillContainer.__new__(SeatGuideSkillContainer) fake_navigation = FakeNavigation() skill._navigation = fake_navigation - skill._speaker = FakeSpeaker() skill._seat_observation_provider = FakeSeatObservationProvider( SeatSceneObservation( seats=[ @@ -1261,7 +1158,6 @@ def test_seat_guide_readiness_report_keeps_fallback_no_go_by_default() -> None: skill = SeatGuideSkillContainer.__new__(SeatGuideSkillContainer) fake_navigation = FakeNavigation() skill._navigation = fake_navigation - skill._speaker = None skill._seat_observation_provider = FakeSeatObservationProvider( SeatSceneObservation( seats=[SeatObservation("fallback", x=2.0, y=0.0, yaw=0.0)], @@ -1288,7 +1184,6 @@ def test_seat_guide_readiness_report_reads_scene_once() -> None: ) ) skill._navigation = FakeNavigation() - skill._speaker = None skill._seat_observation_provider = provider skill.seat_guide_readiness_report() @@ -1819,7 +1714,7 @@ def test_autoconnect_uses_runtime_configured_synthetic_scene() -> None: coordinator.stop() -def test_autoconnect_injects_speaker_for_feedback() -> None: +def test_autoconnect_wires_seat_guide_without_robot_speaker() -> None: blueprint = autoconnect( SeatGuideSkillContainer.blueprint(), SyntheticSeatObservationProvider.blueprint( @@ -1829,23 +1724,19 @@ def test_autoconnect_injects_speaker_for_feedback() -> None: robot_y=0.0, ), RecordingNavigation.blueprint(), - RecordingSpeaker.blueprint(), ) coordinator = ModuleCoordinator.build(blueprint, {"g": {"viewer": "none"}}) try: seat_guide = coordinator.get_instance(SeatGuideSkillContainer) - speaker = coordinator.get_instance(RecordingSpeaker) - seat_guide.handle_seat_request( + message = seat_guide.handle_seat_request( "Please find me an empty seat", require_live_perception=False, wait_for_arrival=False, ) - assert speaker.get_spoken() == [ - "I found an empty seat seat_2. Please follow me to the chair beside the table." - ] + assert "seat_2" in message finally: coordinator.stop() @@ -2146,7 +2037,6 @@ def test_seat_guide_go2_blueprints_include_real_runtime_modules() -> None: "CameraSeatObservationProvider", "SeatGuideSkillContainer", "WebInput", - "UnitreeSpeakSkill", } <= agentic_modules assert { "GO2Connection", @@ -2154,8 +2044,9 @@ def test_seat_guide_go2_blueprints_include_real_runtime_modules() -> None: "CameraSeatObservationProvider", "SeatGuideSkillContainer", "WebInput", - "UnitreeSpeakSkill", } <= direct_modules + assert "UnitreeSpeakSkill" not in agentic_modules + assert "UnitreeSpeakSkill" not in direct_modules assert "SpatialMemory" not in agentic_modules assert "SpatialMemory" not in direct_modules assert "PersonFollowSkillContainer" not in agentic_modules @@ -2915,7 +2806,7 @@ def _complete_acceptance_transcript() -> str: Hardware blueprint: unitree-go2-seat-guide-agentic WebInput status: web=started; thread=running; seat_route=seat_guide_direct; responses=connected; voice_upload=connected; stt=connected; human_transport=connected; url=http://localhost:5555. Using WebInput URL: http://localhost:5555 -{"modules": {"CameraSeatObservationProvider": ["camera_seat_provider_status"], "SeatGuideSkillContainer": ["seat_guide_status"], "WebInput": ["web_input_status"], "SpeakSkill": ["speech_status"]}} +{"modules": {"CameraSeatObservationProvider": ["camera_seat_provider_status"], "SeatGuideSkillContainer": ["seat_guide_status"], "WebInput": ["web_input_status"]}} image=160x120 image_fresh=true camera_info=160x120 @@ -2928,14 +2819,8 @@ def _complete_acceptance_transcript() -> str: override=inactive configured_fallback_seats=0 configured_fallback_people=0 -tts=ready -audio_output=connected -+ dimos mcp call speak --json-args {"text": "SeatGuide audio check. I can guide you to an empty seat.", "blocking": true} -Spoke: SeatGuide audio check. I can guide you to an empty seat. -Audio output confirmation: -Operator audio confirmation: HEARD SeatGuide scene source=camera: 2 seats [seat_1=(1.00, 2.00, yaw=0.00)], 0 people [none], robot=(1.00, 2.00). -SeatGuide preflight ready: navigation=IDLE; perception=camera seats=2 people=0; empty=2 occupied=0; selected=seat_1; goal=(1.65, 2.00, yaw=0.00); speaker=connected. +SeatGuide preflight ready: navigation=IDLE; perception=camera seats=2 people=0; empty=2 occupied=0; selected=seat_1; goal=(1.65, 2.00, yaw=0.00); feedback=phone_or_web. SeatGuide preview source=camera: selected seat_1 empty=2 occupied=0 seat=(1.00, 2.00, yaw=0.00) goal=(1.65, 2.00, yaw=0.00). Captured WebInput agent_responses stream Manual no-motion voice gate: @@ -3053,12 +2938,6 @@ def test_acceptance_log_verifier_accepts_earlier_stale_goal_reached_status( "Capturing DimOS log snapshot after live request\n", "live DimOS log snapshot", ), - ("speaker=connected", "SeatGuide speaker wiring"), - ( - "Spoke: SeatGuide audio check. I can guide you to an empty seat.\n", - "TTS audio check completion", - ), - ("Operator audio confirmation: HEARD\n", "operator heard TTS confirmation"), ("goal_reached=true\n", "navigation completion"), ], ) @@ -3263,33 +3142,6 @@ def test_acceptance_log_verifier_rejects_navigation_before_live_route( assert "live route before navigation order" in result.stderr -def test_acceptance_log_verifier_rejects_audio_confirmation_before_tts_completion( - tmp_path: Path, -) -> None: - log_file = tmp_path / "acceptance.log" - log_file.write_text( - _complete_acceptance_transcript().replace( - "Spoke: SeatGuide audio check. I can guide you to an empty seat.\n" - "Audio output confirmation:\n" - "Operator audio confirmation: HEARD\n", - "Audio output confirmation:\n" - "Operator audio confirmation: HEARD\n" - "Spoke: SeatGuide audio check. I can guide you to an empty seat.\n", - 1, - ) - ) - - result = subprocess.run( - ["bash", str(ACCEPTANCE_LOG_VERIFIER), str(log_file)], - check=False, - text=True, - capture_output=True, - ) - - assert result.returncode == 3 - assert "TTS completion before operator audio confirmation order" in result.stderr - - def test_acceptance_log_verifier_rejects_goal_reached_before_polling( tmp_path: Path, ) -> None: @@ -3656,31 +3508,6 @@ def _run_camera_provider_no_go_details( ) -def _run_speech_no_go_details( - tmp_path: Path, - status_text: str, -) -> subprocess.CompletedProcess[str]: - script_source = HARDWARE_ACCEPTANCE_SCRIPT.read_text() - wrapper = tmp_path / "speech_details.sh" - wrapper.write_text( - "\n".join( - [ - "set -euo pipefail", - _extract_bash_function(script_source, "log"), - _extract_bash_function(script_source, "log_speech_no_go_details"), - 'log_file="$1"', - 'log_speech_no_go_details "$2"', - ] - ) - ) - return subprocess.run( - ["bash", str(wrapper), str(tmp_path / "speech.log"), status_text], - check=False, - text=True, - capture_output=True, - ) - - def _run_seat_guide_no_go_details( tmp_path: Path, status_text: str, @@ -3827,24 +3654,20 @@ def test_hardware_acceptance_goal_completion_requires_new_reached_goal( ("status_text", "expected_returncode"), [ ( - "SeatGuide preflight ready: navigation=IDLE; perception=camera seats=2 people=0; selected=seat_1; goal=(1.65, 2.00, yaw=0.00); speaker=connected.", + "SeatGuide preflight ready: navigation=IDLE; perception=camera seats=2 people=0; selected=seat_1; goal=(1.65, 2.00, yaw=0.00); feedback=phone_or_web.", 0, ), ( - "SeatGuide preflight ready: navigation=FOLLOWING_PATH; perception=camera seats=2 people=0; selected=seat_1; goal=(1.65, 2.00, yaw=0.00); speaker=connected.", + "SeatGuide preflight ready: navigation=FOLLOWING_PATH; perception=camera seats=2 people=0; selected=seat_1; goal=(1.65, 2.00, yaw=0.00); feedback=phone_or_web.", 1, ), ( - "SeatGuide preflight ready: navigation=IDLE; perception=camera seats=2 people=0; selected=seat_1; goal=(1.65, 2.00, yaw=0.00); speaker=missing.", - 1, - ), - ( - "SeatGuide preflight no-go: navigation=IDLE; perception=camera no seats; speaker=connected.", + "SeatGuide preflight no-go: navigation=IDLE; perception=camera no seats; feedback=phone_or_web.", 1, ), ], ) -def test_hardware_acceptance_preflight_requires_navigation_and_speaker_ready( +def test_hardware_acceptance_preflight_requires_navigation_ready( tmp_path: Path, status_text: str, expected_returncode: int, @@ -3998,21 +3821,6 @@ def test_hardware_acceptance_camera_provider_no_go_details_are_actionable( assert "Configured fallback seats/people are non-zero" in result.stdout -def test_hardware_acceptance_speech_no_go_details_are_actionable( - tmp_path: Path, -) -> None: - status_text = ( - "SpeakSkill status: tts=unavailable; reason=OPENAI_API_KEY is not set; " - "audio_output=missing; background_speech_threads=0." - ) - - result = _run_speech_no_go_details(tmp_path, status_text) - - assert result.returncode == 0 - assert "OPENAI_API_KEY" in result.stdout - assert "Audio output is not connected" in result.stdout - - def test_hardware_acceptance_seat_guide_no_go_details_are_actionable( tmp_path: Path, ) -> None: @@ -4020,7 +3828,7 @@ def test_hardware_acceptance_seat_guide_no_go_details_are_actionable( "SeatGuide readiness report: SeatGuide scene source=stale_camera_image: " "no seats visible or configured; 0 people detected. | " "SeatGuide preflight no-go: navigation=FOLLOWING_PATH; " - "perception=stale_camera_odom no seats; speaker=missing. | " + "perception=stale_camera_odom no seats; feedback=phone_or_web. | " "SeatGuide preflight no-go: perception=camera_detection_error no seats. | " "SeatGuide preview source=configured_fallback: no empty seat available." ) @@ -4029,7 +3837,6 @@ def test_hardware_acceptance_seat_guide_no_go_details_are_actionable( assert result.returncode == 0 assert "Navigation is busy" in result.stdout - assert "speaker wiring is missing" in result.stdout assert "cannot see chairs" in result.stdout assert "camera frames are stale" in result.stdout assert "odometry is stale" in result.stdout @@ -4052,12 +3859,11 @@ def test_hardware_acceptance_has_actionable_mcp_tools_failure_message() -> None: assert 'if ! tools="$(run_dimos mcp list-tools 2>&1)"; then' in script assert "Hardware acceptance no-go: MCP tools are unavailable." in script assert "Hardware acceptance no-go: missing MCP tool" in script - assert "SeatGuide, WebInput, camera provider, and SpeakSkill modules" in script + assert "SeatGuide, WebInput, and camera provider modules" in script assert "unitree-go2-seat-guide-agentic and includes McpServer" in script - assert 'require_tool "${tools}" "speak"' in script - assert "SeatGuide audio check. I can guide you to an empty seat." in script - assert "Operator audio confirmation" in script - assert 'require_output_contains "${audio_check_output}" "Spoke: SeatGuide audio check"' in script + assert 'require_tool "${tools}" "speak"' not in script + assert "SeatGuide audio check. I can guide you to an empty seat." not in script + assert "Operator audio confirmation" not in script assert "Transcript saved to: ${log_file}" in script @@ -4281,7 +4087,7 @@ def test_no_motion_smoke_has_actionable_missing_stack_and_mcp_messages() -> None assert "dimos run unitree-go2-seat-guide-agentic --robot-ip" in script assert "SeatGuide smoke no-go: MCP tools are unavailable." in script assert "SeatGuide smoke no-go: missing MCP tool" in script - assert "SeatGuide, WebInput, camera provider, and SpeakSkill modules" in script + assert "SeatGuide, WebInput, and camera provider modules" in script assert "includes McpServer" in script @@ -4349,7 +4155,7 @@ def test_hardware_bringup_starts_real_stack_then_runs_smoke_and_acceptance() -> ) -def test_hardware_bringup_requires_real_perception_and_speech_credentials() -> None: +def test_hardware_bringup_requires_real_perception_and_agent_credentials() -> None: script = HARDWARE_BRINGUP_SCRIPT.read_text() assert 'ALIBABA_API_KEY' in script @@ -4363,7 +4169,7 @@ def test_hardware_bringup_requires_real_perception_and_speech_credentials() -> N "SeatGuide bring-up no-go: neither OPENROUTER_API_KEY nor OPENAI_API_KEY is set." in script ) - assert "TTS speech feedback will be unavailable" in script + assert "TTS speech feedback will be unavailable" not in script def test_hardware_bringup_allows_existing_stack_and_smoke_skip() -> None: @@ -4402,7 +4208,7 @@ def test_seat_guide_doc_has_parallel_hardware_day_checklist() -> None: "Perception", "Planner", "Navigation", - "Speech feedback", + "Phone feedback", "Acceptance evidence", ]: assert f"| {track} |" in doc @@ -4441,7 +4247,7 @@ def test_go2_system_prompt_mentions_seat_guide_flow() -> None: assert "seat_guide_status" in SYSTEM_PROMPT assert "camera_seat_provider_status" in SYSTEM_PROMPT assert "web_input_status" in SYSTEM_PROMPT - assert "speech_status" in SYSTEM_PROMPT + assert "phone_speaker_test" in SYSTEM_PROMPT assert "preview_empty_seat_goal" in SYSTEM_PROMPT assert "seat_guide_navigation_status" in SYSTEM_PROMPT assert "goal_reached=true" in SYSTEM_PROMPT diff --git a/dimos/agents/skills/test_speak_skill.py b/dimos/agents/skills/test_speak_skill.py deleted file mode 100644 index d6e14633e3..0000000000 --- a/dimos/agents/skills/test_speak_skill.py +++ /dev/null @@ -1,182 +0,0 @@ -# Copyright 2026 Dimensional Inc. -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -import json -from types import SimpleNamespace - -from dimos.agents.skills.speak_skill import SpeakSkill -from dimos.agents.skills.unitree_speak_skill import UnitreeSpeakSkill, _generate_tone_wav -from dimos.robot.unitree.go2.blueprints.agentic._seat_guide_agentic import _seat_guide_agentic - - -class FakeGo2Connection: - def __init__(self) -> None: - self.requests = [] - - def publish_request(self, topic, data): - self.requests.append((topic, data)) - if data["api_id"] == 1001: - filename = None - for _, request_data in self.requests: - if request_data["api_id"] == 2001: - filename = json.loads(request_data["parameter"])["file_name"] - break - return { - "data": { - "data": json.dumps( - {"audio_list": [{"CUSTOM_NAME": filename, "UNIQUE_ID": "audio-1"}]} - ) - } - } - return {"data": {"data": "{}"}} - - -class FakeOpenAIClient: - audio = SimpleNamespace( - speech=SimpleNamespace(create=lambda **kwargs: SimpleNamespace(content=b"RIFF-wav-data")) - ) - - -def test_speak_skill_start_is_noop_without_openai_api_key(monkeypatch) -> None: - monkeypatch.delenv("OPENAI_API_KEY", raising=False) - skill = SpeakSkill() - - try: - skill.start() - assert ( - skill.speak("hello", blocking=False) == "Speech unavailable: OPENAI_API_KEY is not set" - ) - assert skill.speech_status() == ( - "SpeakSkill status: tts=unavailable; reason=OPENAI_API_KEY is not set; " - "audio_output=missing; background_speech_threads=0." - ) - finally: - skill.stop() - - -def test_speak_skill_status_reports_ready_when_tts_is_initialized() -> None: - skill = SpeakSkill() - try: - skill._tts_node = object() - skill._audio_output = object() - - assert skill.speech_status() == ( - "SpeakSkill status: tts=ready; audio_output=connected; " - "background_speech_threads=0." - ) - finally: - skill._close_module() - - -def test_speak_skill_exposes_status_schema() -> None: - skill = SpeakSkill() - try: - skill_infos = {info.func_name: json.loads(info.args_schema) for info in skill.get_skills()} - finally: - skill._close_module() - - assert "speech_status" in skill_infos - status_schema = skill_infos["speech_status"] - assert "text-to-speech readiness" in status_schema["description"] - assert status_schema.get("properties", {}) == {} - - -def test_unitree_speak_skill_start_is_noop_without_openai_api_key(monkeypatch) -> None: - monkeypatch.delenv("OPENAI_API_KEY", raising=False) - skill = UnitreeSpeakSkill() - - try: - skill.start() - assert ( - skill.speak("hello", blocking=False) - == "Robot speech unavailable: OPENAI_API_KEY is not set" - ) - assert skill.speech_status() == ( - "UnitreeSpeakSkill status: tts=unavailable; " - "reason=OPENAI_API_KEY is not set; robot_audio=missing; " - "background_speech_threads=0." - ) - finally: - skill.stop() - - -def test_unitree_speak_skill_uploads_and_plays_audio_on_robot() -> None: - skill = UnitreeSpeakSkill.__new__(UnitreeSpeakSkill) - skill._connection = FakeGo2Connection() - skill._openai_client = FakeOpenAIClient() - skill._speech_unavailable_reason = None - skill._bg_threads = [] - skill._bg_threads_lock = __import__("threading").Lock() - - result = skill.speak("我已经到了, 请坐。", blocking=True) - - api_ids = [data["api_id"] for _, data in skill._connection.requests] - assert result == "Spoke on robot: 我已经到了, 请坐。" - assert api_ids == [2001, 1001, 1007, 1004, 1002] - assert json.loads(skill._connection.requests[0][1]["parameter"])["file_type"] == "wav" - assert json.loads(skill._connection.requests[-1][1]["parameter"]) == { - "unique_id": "audio-1" - } - - -def test_unitree_speak_skill_audio_test_does_not_call_openai() -> None: - skill = UnitreeSpeakSkill.__new__(UnitreeSpeakSkill) - skill._connection = FakeGo2Connection() - skill._openai_client = None - skill._speech_unavailable_reason = "OPENAI_API_KEY is not set" - skill._bg_threads = [] - skill._bg_threads_lock = __import__("threading").Lock() - - result = skill.play_robot_audio_test() - - api_ids = [data["api_id"] for _, data in skill._connection.requests] - assert result.startswith("Robot audio test sent: filename=audio_test_") - assert "bytes=" in result - assert "unique_id=audio-1" in result - assert api_ids[-4:] == [1001, 1007, 1004, 1002] - assert api_ids[:-4] == [2001] * len(api_ids[:-4]) - upload_parameter = json.loads(skill._connection.requests[0][1]["parameter"]) - assert upload_parameter["file_type"] == "wav" - assert upload_parameter["file_size"] > 200000 - - -def test_unitree_speak_skill_megaphone_test_does_not_call_openai() -> None: - skill = UnitreeSpeakSkill.__new__(UnitreeSpeakSkill) - skill._connection = FakeGo2Connection() - skill._openai_client = None - skill._speech_unavailable_reason = "OPENAI_API_KEY is not set" - skill._bg_threads = [] - skill._bg_threads_lock = __import__("threading").Lock() - - result = skill.play_robot_megaphone_test() - - api_ids = [data["api_id"] for _, data in skill._connection.requests] - assert result == "Robot megaphone audio test sent: bytes=220544." - assert api_ids[0] == 4001 - assert api_ids[-1] == 4002 - assert all(api_id == 4003 for api_id in api_ids[1:-1]) - - -def test_generate_tone_wav_returns_wav_bytes() -> None: - wav_data = _generate_tone_wav() - - assert wav_data.startswith(b"RIFF") - assert b"WAVE" in wav_data[:16] - - -def test_seat_guide_blueprint_uses_unitree_speaker() -> None: - module_names = {atom.module.__name__ for atom in _seat_guide_agentic.blueprints} - - assert "UnitreeSpeakSkill" in module_names - assert "SpeakSkill" not in module_names diff --git a/dimos/agents/skills/unitree_speak_skill.py b/dimos/agents/skills/unitree_speak_skill.py deleted file mode 100644 index b99243a4e4..0000000000 --- a/dimos/agents/skills/unitree_speak_skill.py +++ /dev/null @@ -1,340 +0,0 @@ -# Copyright 2026 Dimensional Inc. -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -import base64 -import hashlib -import io -import json -import math -import os -import struct -import threading -import time -from typing import Any -import wave - -from openai import OpenAI -from unitree_webrtc_connect.constants import RTC_TOPIC - -from dimos.agents.annotation import skill -from dimos.constants import DEFAULT_THREAD_JOIN_TIMEOUT -from dimos.core.core import rpc -from dimos.core.module import Module -from dimos.robot.unitree.go2.connection_spec import GO2ConnectionSpec -from dimos.utils.logging_config import setup_logger - -logger = setup_logger() - -_AUDIO_API = { - "GET_AUDIO_LIST": 1001, - "SELECT_START_PLAY": 1002, - "PAUSE": 1003, - "UNSUSPEND": 1004, - "SET_PLAY_MODE": 1007, - "UPLOAD_AUDIO_FILE": 2001, - "ENTER_MEGAPHONE": 4001, - "EXIT_MEGAPHONE": 4002, - "UPLOAD_MEGAPHONE": 4003, -} -_PLAY_MODE_NO_CYCLE = "no_cycle" -_OPENAI_TTS_TIMEOUT_S = 60.0 - - -class UnitreeSpeakSkill(Module): - """Speak through the Unitree Go2 onboard speaker.""" - - _connection: GO2ConnectionSpec - _openai_client: OpenAI | None = None - _speech_unavailable_reason: str | None = None - _last_robot_audio_upload: dict[str, Any] | None = None - _bg_threads: list[threading.Thread] = [] - _bg_threads_lock: threading.Lock = threading.Lock() - - @rpc - def start(self) -> None: - super().start() - if not os.getenv("OPENAI_API_KEY"): - self._speech_unavailable_reason = "OPENAI_API_KEY is not set" - logger.warning("UnitreeSpeakSkill TTS disabled because OPENAI_API_KEY is not set") - return - self._openai_client = OpenAI(timeout=_OPENAI_TTS_TIMEOUT_S) - - @rpc - def stop(self) -> None: - with self._bg_threads_lock: - threads = list(self._bg_threads) - for thread in threads: - thread.join(timeout=DEFAULT_THREAD_JOIN_TIMEOUT) - super().stop() - - @skill - def speak(self, text: str, blocking: bool = True) -> str: - """Speak text out loud through the Unitree Go2 onboard speaker. - - Use this to communicate with nearby people from the robot body, not - from the operator computer. - - Args: - text: Text to synthesize and play on the robot. - blocking: When true, wait for upload/play request completion. - """ - if self._openai_client is None: - reason = self._speech_unavailable_reason or "TTS not initialized" - return f"Robot speech unavailable: {reason}" - - if not blocking: - thread = threading.Thread( - target=self._speak_bg, - args=(text,), - daemon=True, - name="UnitreeSpeakSkill-bg", - ) - with self._bg_threads_lock: - self._bg_threads.append(thread) - thread.start() - return f"Speaking on robot (non-blocking): {text}" - - return self._speak_blocking(text) - - @skill - def speech_status(self) -> str: - """Report Unitree Go2 onboard speaker readiness without speaking.""" - with self._bg_threads_lock: - active_background_threads = sum(1 for thread in self._bg_threads if thread.is_alive()) - if self._openai_client is None: - reason = self._speech_unavailable_reason or "TTS not initialized" - return ( - "UnitreeSpeakSkill status: tts=unavailable; " - f"reason={reason}; robot_audio=missing; " - f"background_speech_threads={active_background_threads}." - ) - robot_audio = "connected" if getattr(self, "_connection", None) is not None else "missing" - return ( - "UnitreeSpeakSkill status: tts=ready; " - f"robot_audio={robot_audio}; " - f"background_speech_threads={active_background_threads}." - ) - - @skill - def play_robot_audio_test(self) -> str: - """Play a short generated tone through the Unitree Go2 onboard speaker. - - Use this to test the robot audio upload/playback path without calling - OpenAI TTS. - """ - try: - wav_data = _generate_tone_wav() - filename = f"audio_test_{int(time.time() * 1000)}" - unique_id = self._upload_audio_to_robot(wav_data, filename) - self._play_audio_on_robot(unique_id) - return ( - "Robot audio test sent: " - f"filename={filename}; bytes={len(wav_data)}; unique_id={unique_id}." - ) - except Exception as exc: - logger.error("Unitree robot audio test failed", exc_info=True) - return f"Error playing robot audio test: {exc}" - - @skill - def play_robot_megaphone_test(self) -> str: - """Play a generated tone through the Unitree Go2 megaphone audio path. - - Use this when normal uploaded audio returns a unique_id but no sound is - heard from the robot. - """ - try: - wav_data = _generate_tone_wav() - self._upload_and_play_megaphone(wav_data, duration_s=5.0) - return f"Robot megaphone audio test sent: bytes={len(wav_data)}." - except Exception as exc: - logger.error("Unitree robot megaphone audio test failed", exc_info=True) - return f"Error playing robot megaphone audio test: {exc}" - - def _speak_bg(self, text: str) -> None: - try: - self._speak_blocking(text) - finally: - with self._bg_threads_lock: - self._bg_threads = [ - thread - for thread in self._bg_threads - if thread is not threading.current_thread() - ] - - def _speak_blocking(self, text: str) -> str: - try: - logger.info("Generating Unitree speech audio with OpenAI TTS") - wav_data = self._generate_wav(text) - logger.info("Uploading Unitree speech audio", bytes=len(wav_data)) - filename = f"speak_{int(time.time() * 1000)}" - unique_id = self._upload_audio_to_robot(wav_data, filename) - logger.info("Playing Unitree speech audio", unique_id=unique_id) - self._play_audio_on_robot(unique_id) - display_text = text[:50] + "..." if len(text) > 50 else text - return f"Spoke on robot: {display_text}" - except Exception as exc: - logger.error("Unitree robot speech failed", exc_info=True) - return f"Error speaking on robot: {exc}" - - def _generate_wav(self, text: str) -> bytes: - if self._openai_client is None: - raise RuntimeError("TTS not initialized") - response = self._openai_client.audio.speech.create( - model="tts-1", - voice="echo", - input=text, - speed=1.2, - response_format="wav", - ) - return response.content - - def _webrtc_request(self, api_id: int, parameter: dict[str, Any] | None = None) -> Any: - return self._connection.publish_request( - RTC_TOPIC["AUDIO_HUB_REQ"], - { - "api_id": api_id, - "parameter": json.dumps(parameter or {}), - }, - ) - - def _upload_audio_to_robot(self, audio_data: bytes, filename: str) -> str: - file_md5 = hashlib.md5(audio_data).hexdigest() - b64_data = base64.b64encode(audio_data).decode("utf-8") - chunk_size = 61440 - chunks = [b64_data[i : i + chunk_size] for i in range(0, len(b64_data), chunk_size)] - total_chunks = len(chunks) - - logger.info( - "Uploading Unitree audio", - filename=filename, - bytes=len(audio_data), - chunks=total_chunks, - ) - for index, chunk in enumerate(chunks, 1): - self._webrtc_request( - _AUDIO_API["UPLOAD_AUDIO_FILE"], - { - "file_name": filename, - "file_type": "wav", - "file_size": len(audio_data), - "current_block_index": index, - "total_block_number": total_chunks, - "block_content": chunk, - "current_block_size": len(chunk), - "file_md5": file_md5, - "create_time": int(time.time() * 1000), - }, - ) - - response: Any = None - unique_id: str | None = None - for _attempt in range(1, 6): - response = self._webrtc_request(_AUDIO_API["GET_AUDIO_LIST"], {}) - unique_id = _find_uploaded_audio_id(response, filename) - if unique_id is not None: - break - time.sleep(0.2) - self._last_robot_audio_upload = { - "filename": filename, - "bytes": len(audio_data), - "chunks": total_chunks, - "unique_id": unique_id, - "list_response": response, - } - if unique_id is None: - logger.warning( - "Could not find uploaded Unitree audio by filename", - filename=filename, - response=response, - ) - return filename - logger.info("Unitree audio uploaded", filename=filename, unique_id=unique_id) - return unique_id - - def _play_audio_on_robot(self, unique_id: str) -> None: - logger.info("Requesting Unitree audio playback", unique_id=unique_id) - self._webrtc_request(_AUDIO_API["SET_PLAY_MODE"], {"play_mode": _PLAY_MODE_NO_CYCLE}) - time.sleep(0.1) - self._webrtc_request(_AUDIO_API["UNSUSPEND"], {}) - time.sleep(0.1) - self._webrtc_request(_AUDIO_API["SELECT_START_PLAY"], {"unique_id": unique_id}) - - def _upload_and_play_megaphone(self, audio_data: bytes, *, duration_s: float) -> None: - logger.info("Entering Unitree megaphone mode", bytes=len(audio_data)) - self._webrtc_request(_AUDIO_API["ENTER_MEGAPHONE"], {}) - try: - time.sleep(0.2) - b64_data = base64.b64encode(audio_data).decode("utf-8") - chunk_size = 4096 - chunks = [b64_data[i : i + chunk_size] for i in range(0, len(b64_data), chunk_size)] - total_chunks = len(chunks) - logger.info("Uploading Unitree megaphone audio", chunks=total_chunks) - for index, chunk in enumerate(chunks, 1): - self._webrtc_request( - _AUDIO_API["UPLOAD_MEGAPHONE"], - { - "current_block_size": len(chunk), - "block_content": chunk, - "current_block_index": index, - "total_block_number": total_chunks, - }, - ) - if index < total_chunks: - time.sleep(0.02) - time.sleep(duration_s + 0.5) - finally: - logger.info("Exiting Unitree megaphone mode") - self._webrtc_request(_AUDIO_API["EXIT_MEGAPHONE"], {}) - - -def _find_uploaded_audio_id(response: Any, filename: str) -> str | None: - if not isinstance(response, dict): - return None - data = response.get("data") - if isinstance(data, dict): - data = data.get("data") - if not isinstance(data, str): - return None - try: - audio_list = json.loads(data).get("audio_list", []) - except json.JSONDecodeError: - return None - for audio in audio_list: - if isinstance(audio, dict) and audio.get("CUSTOM_NAME") == filename: - unique_id = audio.get("UNIQUE_ID") - return unique_id if isinstance(unique_id, str) else None - return None - - -def _generate_tone_wav( - *, - frequency_hz: float = 660.0, - duration_s: float = 5.0, - sample_rate: int = 22050, -) -> bytes: - buffer = io.BytesIO() - amplitude = 0.95 - total_samples = int(duration_s * sample_rate) - with wave.open(buffer, "wb") as wav_file: - wav_file.setnchannels(1) - wav_file.setsampwidth(2) - wav_file.setframerate(sample_rate) - for index in range(total_samples): - value = int( - 32767 - * amplitude - * math.sin(2.0 * math.pi * frequency_hz * index / sample_rate) - ) - wav_file.writeframes(struct.pack("x`, `image_fresh=true`, `odom=(...)`, `odom_fresh=true`, `detection_model=moondream`, `credential=present`, `override=inactive`, `configured_fallback_seats=0`, `configured_fallback_people=0`; `seat_guide_status` starts with `SeatGuide scene source=camera:`. | Turn robot toward the table, verify the local Moondream2 cache or selected VLM credential, restore stale camera/odom streams, or explicitly mark fallback calibration as non-acceptance. | | Planner | Empty/occupied counts and selected goal make sense before motion. | `seat_guide_preflight`, `seat_guide_readiness_report`, and `preview_empty_seat_goal` report `empty=N occupied=N`, `selected=...`, and `goal=(...)` without sending a goal. | Adjust camera view or chair/person layout before live voice. | | Navigation | Robot is idle before SeatGuide sends the live goal and reports completion after it. | Preflight has `navigation=IDLE`; after live voice, `seat_guide_navigation_status` reports a new `goal_sequence` and `goal_reached=true`. | Wait/cancel existing navigation or inspect navigation logs; do not rerun live voice until idle. | -| Speech feedback | TTS and local audio output are ready, or the team agrees to use web text as the user-facing fallback. | `speech_status` shows `tts=ready` and `audio_output=connected`; hardware acceptance also calls `speak` with `SeatGuide audio check. I can guide you to an empty seat.`, requires `Spoke: SeatGuide audio check`, and requires operator confirmation `HEARD`. | Set `OPENAI_API_KEY` and connect audio before official spoken-feedback acceptance; OpenRouter covers the agent, not TTS. | +| Phone feedback | The web response stream is visible, and a mounted phone can play messages if audible feedback is required. | `web_input_status` shows `responses=connected`; optional phone relay checks can use `phone_speaker_test`. | Keep the phone speaker page open on the mounted phone; do not depend on Go2 body audio. | | Acceptance evidence | The run is hardware, not replay/sim, and uses the SeatGuide blueprint. | `bin/demo_seat_guide_hardware_acceptance` records the run registry, no-motion gates, browser microphone gates, camera source, speech output check plus operator heard confirmation, ordered WebInput route logs, and `goal_reached=true`; `bin/demo_seat_guide_verify_acceptance_log ` passes. | Treat failures as real no-go evidence; do not replace them with direct MCP live calls. | ### Bring-up commands @@ -171,9 +171,6 @@ One-command real Go2 bring-up: ```bash export OPENROUTER_API_KEY=... export OPENROUTER_MODEL=openai/gpt-4o-mini - -# Optional: only required for TTS speech feedback. -export OPENAI_API_KEY=... bin/demo_seat_guide_hardware_bringup --robot-ip 192.168.123.161 ``` @@ -221,8 +218,6 @@ tool-calling model such as `openai/gpt-4o-mini`; otherwise DimOS maps the default `gpt-4o` model to `openai/gpt-4o` on OpenRouter. If neither `OPENROUTER_API_KEY` nor `OPENAI_API_KEY` is set, `McpClient` disables the LLM agent but the direct SeatGuide voice route and MCP tools still start. -`SpeakSkill` still requires `OPENAI_API_KEY` for TTS and degrades to a no-op -instead of failing startup. The default camera detector uses the local Moondream2 VLM. Make sure the `vikhyatk/moondream2` Hugging Face snapshot is cached before hardware bring-up, @@ -259,8 +254,8 @@ automated gates pass: the DimOS run registry must show a hardware run, not `voice_upload=connected`, `stt=connected`, and `human_transport=connected`; camera frames, odometry, and VLM credentials must be present, the camera provider runtime override must be inactive, and -configured fallback seats/people must both be zero; TTS/audio output must be ready; preflight must be ready with -`navigation=IDLE` and `speaker=connected`; the goal preview must select a seat; +configured fallback seats/people must both be zero; preflight must be ready with +`navigation=IDLE`; the goal preview must select a seat; and the script must resolve the active WebInput URL from `web_input_status`. Posting `预检帮我找一个空位` to that WebInput HTTP text endpoint must publish a `SeatGuide preflight ready` response on the web @@ -296,8 +291,7 @@ The hardware script automatically audits the saved transcript after the live request completes. It requires the DimOS run registry path, hardware run mode, SeatGuide Go2 blueprint name, running WebInput server/thread/transport, direct SeatGuide routing, resolved WebInput URL, camera/odometry/VLM readiness, -`image_fresh=true` and `odom_fresh=true`, TTS audio check phrase, `Spoke:` -completion, operator audio confirmation `HEARD`, typed and spoken no-motion +`image_fresh=true` and `odom_fresh=true`, typed and spoken no-motion responses, explicit browser microphone no-motion/live gates with the required spoken phrases, WebInput preview/live route logs, empty/occupied seat counts, DimOS log snapshots after no-motion checks and after the live request, the @@ -339,7 +333,6 @@ Run the no-motion readiness path without relying on microphone or LLM behavior: dimos mcp call seat_guide_status dimos mcp call web_input_status dimos mcp call camera_seat_provider_status -dimos mcp call speech_status dimos mcp call seat_guide_readiness_report dimos mcp call seat_guide_preflight dimos mcp call seat_guide_navigation_status @@ -358,15 +351,14 @@ Run the real voice path: 3. First speak "预检帮我找一个空位" or "preview find me an empty seat" to validate the real microphone path without motion. 4. Then speak "帮我找一个空位" or "Please find me an empty seat" when live navigation is intended. 5. The browser audio is transcribed by `WhisperNode` with language auto-detection; no-motion preview text is routed to `preview_seat_request()`, and live SeatGuide text is routed directly to `handle_seat_request()`. -6. Watch the web `agent_responses` stream for the exact SeatGuide result, especially if `SpeakSkill` is unavailable or audio output is hard to hear during bring-up. +6. Watch the web `agent_responses` stream for the exact SeatGuide result; use the phone speaker page only when audible feedback is required. If MCP is healthy, this should route through: `seat_guide_readiness_report` for combined no-motion checks -> `WebInput` -> `WhisperNode` -> `handle_seat_request` -> `CameraSeatObservationProvider.get_seat_scene` using camera frames and odom -> -`SeatGuidePlanner.find_empty_seat` -> `NavigationInterfaceSpec.set_goal` -> -optional `SpeakSkillSpec.speak`. +`SeatGuidePlanner.find_empty_seat` -> `NavigationInterfaceSpec.set_goal`. Calibrate the fallback scene at runtime if camera/VLM detection is not reliable: diff --git a/docs/agents/seat_guide_step_by_step_plan.md b/docs/agents/seat_guide_step_by_step_plan.md index d4cf9b488b..0d6ea56b5b 100644 --- a/docs/agents/seat_guide_step_by_step_plan.md +++ b/docs/agents/seat_guide_step_by_step_plan.md @@ -1,6 +1,6 @@ # SeatGuide 机器狗空位引导 Step-by-Step 计划 -目标:让用户通过浏览器麦克风或文字对 Go2 说“帮我找一个空位”,系统用真实相机识别椅子和人,判断空位,给导航下发目标,并用语音反馈结果。没有连接 G2 时,所有能本地验证的模块都必须有单测或 smoke 验证;连接 G2 后只跑硬件验收,不再临场拼功能。 +目标:让用户通过浏览器麦克风或文字对 Go2 说“帮我找一个空位”,系统用真实相机识别椅子和人,判断空位,给导航下发目标,并通过网页/手机反馈结果。没有连接 G2 时,所有能本地验证的模块都必须有单测或 smoke 验证;连接 G2 后只跑硬件验收,不再临场拼功能。 ## 总体模块拆分 @@ -10,7 +10,7 @@ | 2. 场景感知 | 用 Go2 RGB 图像 + odom + Moondream2/VLM 识别椅子和人,并投影成 map 坐标 | `color_image`、`odom`、本地 Moondream2 模型缓存 | `SeatSceneObservation` | 是 | Camera provider 单测、`camera_seat_provider_status` | | 3. 空位规划 | 判断哪些椅子被占用,选择最近空位,生成机器人应到达的引导点 | 椅子位姿、人员位置、机器人位置 | 选中椅子、导航目标 pose | 是 | Planner 单测、`preview_empty_seat_goal` | | 4. 导航执行 | 把目标 pose 发给已有导航模块,并读取完成状态 | SeatGuide goal pose | `set_goal()`、`goal_reached` | 部分并行 | fake navigator 单测、`seat_guide_navigation_status` | -| 5. 语音反馈 | 告诉用户找到哪个位置、是否需要跟随、失败原因 | SeatGuide 结果文本 | TTS 音频、web response text | 是 | `speech_status`、TTS audio check、operator `HEARD` | +| 5. 手机/网页反馈 | 告诉用户找到哪个位置、是否需要跟随、失败原因 | SeatGuide 结果文本 | web response text、手机扬声器 relay | 是 | `web_input_status`、可选 `phone_speaker_test` | | 6. 验收脚本 | 把 no-motion、真实语音、真实导航串起来,保存 transcript | 当前 DimOS stack | 通过/失败原因、验收日志 | 是 | `bin/demo_seat_guide_*` | ## 阶段 1:基础语音控制验收 @@ -168,9 +168,9 @@ dimos mcp call seat_guide_status - `configured_fallback_people=0` - `seat_guide_status` 必须以 `SeatGuide scene source=camera:` 开头。 -## 阶段 5:导航和语音反馈 +## 阶段 5:导航和手机/网页反馈 -目的:找到空位以后,Go2 能真正下发导航目标,并给用户可听或可见反馈。 +目的:找到空位以后,Go2 能真正下发导航目标,并通过网页或绑在机器狗上的手机给用户可见/可听反馈。 要做的工作: @@ -178,13 +178,13 @@ dimos mcp call seat_guide_status 2. live request 时调用 `set_goal(PoseStamped)`。 3. 如果导航忙,拒绝覆盖当前任务。 4. 读取 `navigation_state` 和 `goal_reached`。 -5. SeatGuide 注入 `SpeakSkillSpec`,可用时播报结果。 -6. `speech_status` 区分 TTS key 缺失、audio output 缺失和可用状态。 +5. SeatGuide 返回明确的结果文本,并让 WebInput 发布到 `agent_responses`。 +6. 如果需要可听反馈,让手机打开 speaker relay 页面并用 `phone_speaker_test` 验证。 验收方式: ```bash -uv run pytest dimos/agents/skills/test_seat_guide.py dimos/agents/skills/test_speak_skill.py -q +uv run pytest dimos/agents/skills/test_seat_guide.py -q ``` 硬件前检查: @@ -192,7 +192,6 @@ uv run pytest dimos/agents/skills/test_seat_guide.py dimos/agents/skills/test_sp ```bash dimos mcp call seat_guide_preflight dimos mcp call preview_empty_seat_goal -dimos mcp call speech_status dimos mcp call seat_guide_navigation_status ``` @@ -200,7 +199,7 @@ dimos mcp call seat_guide_navigation_status - preflight 显示 `navigation=IDLE`。 - preview 有 `selected=...` 和 `goal=(...)`。 -- speech 显示 `tts=ready` 和 `audio_output=connected`。 +- WebInput response stream 可见;需要声音时手机 relay 可播放测试消息。 - live 后 `seat_guide_navigation_status` 最终显示新的 `goal_sequence` 且 `goal_reached=true`。 ## 阶段 6:Mac replay / SHM 视频流修复 @@ -241,14 +240,11 @@ bin/demo_seat_guide_replay_smoke 1. 机器狗上电,和 Mac 在同一网络。 2. 确认 Go2 IP,默认示例是 `192.168.123.161`。 -3. 准备 API keys。普通 agent 可以使用 OpenRouter;TTS 语音播报仍然需要 OpenAI,找座位 VLM 仍然需要 Alibaba/Qwen: +3. 准备 API keys。普通 agent 可以使用 OpenRouter;找座位 VLM 如果选择 Qwen 仍然需要 Alibaba/Qwen: ```bash export OPENROUTER_API_KEY="你的 OpenRouter key" export OPENROUTER_MODEL="openai/gpt-4o-mini" - -# 可选:只有需要机器狗语音播报/TTS 时才需要 -export OPENAI_API_KEY="你的 OpenAI key" ``` 启动一键 bring-up: @@ -268,10 +264,9 @@ bin/demo_seat_guide_hardware_bringup --robot-ip 192.168.123.161 1. 打开脚本打印的 WebInput URL。 2. 允许浏览器麦克风权限。 -3. 听到 TTS 检查语音后,在终端输入 `HEARD`。 -4. no-motion 阶段对浏览器说:`预检帮我找一个空位`。 -5. 确认 Go2 周围安全后,在终端输入 `LIVE`。 -6. live 阶段对浏览器说:`帮我找一个空位`。 +3. no-motion 阶段对浏览器说:`预检帮我找一个空位`。 +4. 确认 Go2 周围安全后,在终端输入 `LIVE`。 +5. live 阶段对浏览器说:`帮我找一个空位`。 通过标准: @@ -293,7 +288,7 @@ bin/demo_seat_guide_hardware_bringup --robot-ip 192.168.123.161 | VLM 失败 | `seat_guide_status` | 本地 Moondream2 模型缺失、模型加载失败,或远程 VLM key 缺失 | 拉取模型或重新 export 对应 key,并重启 stack | | 找不到椅子 | `seat_guide_status` | 摄像头没朝向桌子、光照/识别失败 | 调整机器人视角;只调试时可 fallback | | 导航忙 | `seat_guide_preflight` | `navigation=FOLLOWING_PATH` 或 `RECOVERY` | 等任务结束或停止导航后重跑 | -| TTS 不可用 | `speech_status` | `OPENAI_API_KEY` 缺失或 audio output 缺失 | 如果验收需要语音播报,设置 OpenAI key 并连接音频输出;如果只验收浏览器文字反馈,可先记录为非阻塞项 | +| 手机反馈不可用 | `web_input_status` / `phone_speaker_test` | 手机没有打开 relay 页面或网络不可达 | 先确认 web response stream;需要声音时让手机访问可用的 relay 页面 | ## 当前已完成状态 @@ -302,7 +297,7 @@ bin/demo_seat_guide_hardware_bringup --robot-ip 192.168.123.161 - SeatGuide planner / scene / intent / navigation integration。 - WebInput 中文语音和文字直连 SeatGuide。 - Camera/VLM/odom provider。 -- SpeakSkill readiness 和 TTS audio check。 +- WebInput response stream 和可选手机 speaker relay。 - Go2 SeatGuide blueprints。 - macOS replay SHM route 集成。 - 一键硬件 bring-up 脚本。 @@ -310,10 +305,10 @@ bin/demo_seat_guide_hardware_bringup --robot-ip 192.168.123.161 已验证: -- SeatGuide/Speak/MCP 相关测试通过。 +- SeatGuide/MCP 相关测试通过。 - pubsub/Rerun SHM 相关测试通过。 - `bin/demo_seat_guide_replay_smoke` 在 Mac 上完整跑完。 未完成: -- 真实 Go2 硬件 transcript。最终完成标准必须包含真实浏览器麦克风输入、真实 camera/VLM/odom、TTS 可听确认、真实导航和 `goal_reached=true`。 +- 真实 Go2 硬件 transcript。最终完成标准必须包含真实浏览器麦克风输入、真实 camera/VLM/odom、真实导航和 `goal_reached=true`。 diff --git a/docs/agents/seat_guide_step_by_step_plan_en.md b/docs/agents/seat_guide_step_by_step_plan_en.md index 61d45b37d6..04a1660248 100644 --- a/docs/agents/seat_guide_step_by_step_plan_en.md +++ b/docs/agents/seat_guide_step_by_step_plan_en.md @@ -1,6 +1,6 @@ # SeatGuide Robot Dog Empty-Seat Guidance Step-by-Step Plan -Goal: let a user tell the Go2, through browser microphone or typed text, "find me an empty seat." The system should use the real camera to recognize chairs and people, decide which seats are empty, send a navigation goal, and respond with speech. When the Go2 is not connected, every locally verifiable module must have unit or smoke coverage. After the Go2 is connected, we should only need to run the hardware acceptance flow instead of assembling functionality on the spot. +Goal: let a user tell the Go2, through browser microphone or typed text, "find me an empty seat." The system should use the real camera to recognize chairs and people, decide which seats are empty, send a navigation goal, and respond through the web or phone relay. When the Go2 is not connected, every locally verifiable module must have unit or smoke coverage. After the Go2 is connected, we should only need to run the hardware acceptance flow instead of assembling functionality on the spot. ## Overall Module Split @@ -10,7 +10,7 @@ Goal: let a user tell the Go2, through browser microphone or typed text, "find m | 2. Scene perception | Use Go2 RGB image + odom + Moondream2/VLM to detect chairs and people, then project them into map coordinates | `color_image`, `odom`, local Moondream2 model cache | `SeatSceneObservation` | Yes | Camera provider unit tests, `camera_seat_provider_status` | | 3. Empty-seat planning | Decide which chairs are occupied, select the nearest empty seat, and generate the guide pose for the robot | Chair poses, person positions, robot position | Selected chair and navigation goal pose | Yes | Planner unit tests, `preview_empty_seat_goal` | | 4. Navigation execution | Send the target pose to the existing navigation module and read completion status | SeatGuide goal pose | `set_goal()`, `goal_reached` | Partially | Fake navigator unit tests, `seat_guide_navigation_status` | -| 5. Speech feedback | Tell the user which seat was found, whether to follow, or why the request failed | SeatGuide result text | TTS audio, web response text | Yes | `speech_status`, TTS audio check, operator `HEARD` | +| 5. Phone/web feedback | Tell the user which seat was found, whether to follow, or why the request failed | SeatGuide result text | Web response text, phone speaker relay | Yes | `web_input_status`, optional `phone_speaker_test` | | 6. Acceptance scripts | Chain no-motion checks, real voice input, and real navigation, then save a transcript | Current DimOS stack | Pass/fail reason and acceptance log | Yes | `bin/demo_seat_guide_*` | ## Stage 1: Basic Voice-Control Acceptance @@ -168,9 +168,9 @@ Pass criteria: - `configured_fallback_people=0` - `seat_guide_status` must start with `SeatGuide scene source=camera:`. -## Stage 5: Navigation And Speech Feedback +## Stage 5: Navigation And Phone/Web Feedback -Purpose: after finding an empty seat, the Go2 must actually receive a navigation goal and provide audible or visible feedback to the user. +Purpose: after finding an empty seat, the Go2 must actually receive a navigation goal and provide visible feedback through the web, or audible feedback through a phone mounted on the robot. Work items: @@ -178,13 +178,13 @@ Work items: 2. On a live request, call `set_goal(PoseStamped)`. 3. If navigation is busy, refuse to overwrite the current task. 4. Read `navigation_state` and `goal_reached`. -5. Inject `SpeakSkillSpec` into SeatGuide and speak the result when available. -6. Use `speech_status` to distinguish missing TTS key, missing audio output, and ready state. +5. Return clear SeatGuide result text and publish it to the WebInput `agent_responses` stream. +6. If audible feedback is required, open the speaker relay on the mounted phone and verify it with `phone_speaker_test`. Verification: ```bash -uv run pytest dimos/agents/skills/test_seat_guide.py dimos/agents/skills/test_speak_skill.py -q +uv run pytest dimos/agents/skills/test_seat_guide.py -q ``` Pre-hardware checks: @@ -192,7 +192,6 @@ Pre-hardware checks: ```bash dimos mcp call seat_guide_preflight dimos mcp call preview_empty_seat_goal -dimos mcp call speech_status dimos mcp call seat_guide_navigation_status ``` @@ -200,7 +199,7 @@ Pass criteria: - Preflight shows `navigation=IDLE`. - Preview includes `selected=...` and `goal=(...)`. -- Speech shows `tts=ready` and `audio_output=connected`. +- The WebInput response stream is visible; if sound is needed, the phone relay can play a test message. - After live navigation, `seat_guide_navigation_status` eventually shows a new `goal_sequence` and `goal_reached=true`. ## Stage 6: Mac Replay / SHM Video Stream Fix @@ -241,14 +240,11 @@ Manual preparation: 1. Power on the robot dog and connect it to the same network as the Mac. 2. Confirm the Go2 IP. The default example is `192.168.123.161`. -3. Prepare API keys. The normal agent can use OpenRouter; TTS speech output still requires OpenAI, and seat/person VLM still requires Alibaba/Qwen: +3. Prepare API keys. The normal agent can use OpenRouter; seat/person VLM still requires Alibaba/Qwen when Qwen is selected: ```bash export OPENROUTER_API_KEY="your OpenRouter key" export OPENROUTER_MODEL="openai/gpt-4o-mini" - -# Optional: only needed for robot speech/TTS output -export OPENAI_API_KEY="your OpenAI key" ``` Start one-command bring-up: @@ -268,10 +264,9 @@ Manual actions during the script: 1. Open the WebInput URL printed by the script. 2. Allow browser microphone access. -3. After hearing the TTS check phrase, type `HEARD` in the terminal. -4. During the no-motion stage, say into the browser: `预检帮我找一个空位`. -5. After confirming the area around the Go2 is physically safe, type `LIVE` in the terminal. -6. During the live stage, say into the browser: `帮我找一个空位`. +3. During the no-motion stage, say into the browser: `预检帮我找一个空位`. +4. After confirming the area around the Go2 is physically safe, type `LIVE` in the terminal. +5. During the live stage, say into the browser: `帮我找一个空位`. Pass criteria: @@ -293,7 +288,7 @@ Pass criteria: | VLM failed | `seat_guide_status` | Missing local Moondream2 model, model load failure, or missing remote VLM key | Download the model or re-export the matching key, then restart the stack | | No chairs found | `seat_guide_status` | Camera not facing the table, lighting or recognition issue | Adjust robot view; use fallback only for debugging | | Navigation busy | `seat_guide_preflight` | `navigation=FOLLOWING_PATH` or `RECOVERY` | Wait for the task to finish or stop navigation before retrying | -| TTS unavailable | `speech_status` | Missing `OPENAI_API_KEY` or missing audio output | If acceptance requires spoken feedback, set the OpenAI key and connect audio output; if only browser text feedback is being accepted, record this as non-blocking first | +| Phone feedback unavailable | `web_input_status` / `phone_speaker_test` | Phone has not opened the relay page or the relay is unreachable | First confirm the web response stream; if sound is needed, open a reachable relay page on the phone | ## Current Completion Status @@ -302,7 +297,7 @@ Completed: - SeatGuide planner / scene / intent / navigation integration. - WebInput Chinese voice and text directly routed to SeatGuide. - Camera/VLM/odom provider. -- SpeakSkill readiness and TTS audio check. +- WebInput response stream and optional phone speaker relay. - Go2 SeatGuide blueprints. - macOS replay SHM route integration. - One-command hardware bring-up script. @@ -310,10 +305,10 @@ Completed: Verified: -- SeatGuide/Speak/MCP tests pass. +- SeatGuide/MCP tests pass. - pubsub/Rerun SHM tests pass. - `bin/demo_seat_guide_replay_smoke` completes on Mac. Not complete yet: -- Real Go2 hardware transcript. Final completion requires proof of real browser microphone input, real camera/VLM/odom, audible TTS confirmation, real navigation, and `goal_reached=true`. +- Real Go2 hardware transcript. Final completion requires proof of real browser microphone input, real camera/VLM/odom, real navigation, and `goal_reached=true`. From e0e6bed3a621381d8d4c28c7354137d12fe46155 Mon Sep 17 00:00:00 2001 From: Ernest Date: Fri, 29 May 2026 09:32:39 +0800 Subject: [PATCH 10/16] fix(seat-guide): close phone relay guidance loop --- bin/demo_seat_guide_hardware_bringup | 16 +++--- dimos/agents/skills/seat_guide.py | 17 ++++--- dimos/agents/skills/test_seat_guide.py | 49 +++++++++++++++++-- dimos/agents/web_human_input.py | 2 + .../go2/blueprints/agentic/_common_agentic.py | 3 -- docs/agents/seat_guide_modules.md | 20 ++++++++ docs/agents/seat_guide_step_by_step_plan.md | 4 +- .../agents/seat_guide_step_by_step_plan_en.md | 4 +- 8 files changed, 89 insertions(+), 26 deletions(-) diff --git a/bin/demo_seat_guide_hardware_bringup b/bin/demo_seat_guide_hardware_bringup index 2fce09f6db..e4654416de 100755 --- a/bin/demo_seat_guide_hardware_bringup +++ b/bin/demo_seat_guide_hardware_bringup @@ -19,8 +19,11 @@ Options: Required environment: ALIBABA_API_KEY Required only when --detection-model qwen. + +Optional environment: OPENROUTER_API_KEY or OPENAI_API_KEY - LLM agent for normal voice/text commands. + Enables the normal LLM agent path. SeatGuide direct voice + routing and MCP tools still work without it. EOF } @@ -82,17 +85,10 @@ if [[ "${detection_model}" == "qwen" && -z "${ALIBABA_API_KEY:-}" ]]; then echo "SeatGuide bring-up no-go: ALIBABA_API_KEY is not set for detection_model=qwen." >&2 missing=1 fi -if [[ -z "${OPENROUTER_API_KEY:-}" && -z "${OPENAI_API_KEY:-}" ]]; then - echo "SeatGuide bring-up no-go: neither OPENROUTER_API_KEY nor OPENAI_API_KEY is set." >&2 - missing=1 -fi if [[ "${missing}" != "0" ]]; then cat >&2 <<'EOF' Set the required keys in the same terminal that will start DimOS, for example: - export OPENROUTER_API_KEY=... - export OPENROUTER_MODEL=openai/gpt-4o-mini - If you choose --detection-model qwen, also set: export ALIBABA_API_KEY=... @@ -101,6 +97,10 @@ EOF exit 2 fi +if [[ -z "${OPENROUTER_API_KEY:-}" && -z "${OPENAI_API_KEY:-}" ]]; then + echo "SeatGuide bring-up note: no LLM API key is set; normal agent chat will be disabled, but direct SeatGuide voice/MCP routing still works." >&2 +fi + script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" if [[ "${skip_start}" != "1" ]]; then diff --git a/dimos/agents/skills/seat_guide.py b/dimos/agents/skills/seat_guide.py index 44e54373cb..ae24b69503 100644 --- a/dimos/agents/skills/seat_guide.py +++ b/dimos/agents/skills/seat_guide.py @@ -72,7 +72,7 @@ class SeatGuideResult: goal_x: float goal_y: float goal_yaw: float - spoken_summary: str + guidance_summary: str @dataclass(frozen=True) @@ -163,7 +163,7 @@ def find_empty_seat( goal_x=goal_x, goal_y=goal_y, goal_yaw=selected.yaw, - spoken_summary=( + guidance_summary=( f"I found an empty seat {selected.seat_id}. " "Please follow me to the chair beside the table." ), @@ -285,7 +285,7 @@ def find_empty_seat( self._seat_guide_goal_sequence = getattr(self, "_seat_guide_goal_sequence", 0) + 1 self._seat_guide_goal_reached_reset_required = previous_goal_reached navigating_message = ( - f"{result.spoken_summary} Navigating to ({result.goal_x:.2f}, {result.goal_y:.2f})." + f"{result.guidance_summary} Navigating to ({result.goal_x:.2f}, {result.goal_y:.2f})." ) if not wait_for_arrival: return navigating_message @@ -587,13 +587,18 @@ def handle_seat_request( if self._seat_observation_provider is not None else None ) + can_scan_or_explore = ( + self._direct_mover is not None + or self._relative_mover is not None + or self._explorer is not None + ) if ( scene is not None and ( (scene.source == "camera_no_seats_detected" and not scene.seats) or (_is_live_camera_source(scene.source) and not _scene_has_empty_seat(scene)) ) - and self._explorer is not None + and can_scan_or_explore ): return self.search_for_empty_seat_from_scene( require_live_perception=require_live_perception, @@ -632,8 +637,8 @@ def seat_guide_preflight(self, require_live_perception: bool = True) -> str: Use this on the real Go2 before asking a person to follow the robot. It checks navigation reachability at the interface level, the current seat - scene source, whether an empty seat can be selected, and whether speech - feedback is connected. This never calls navigation. + scene source, and whether an empty seat can be selected. This never + calls navigation. Args: require_live_perception: When true, only a camera-backed scene can diff --git a/dimos/agents/skills/test_seat_guide.py b/dimos/agents/skills/test_seat_guide.py index f0e4f043e2..6ac25f9271 100644 --- a/dimos/agents/skills/test_seat_guide.py +++ b/dimos/agents/skills/test_seat_guide.py @@ -712,6 +712,39 @@ def test_handle_seat_request_prefers_rotate_scan_when_no_seat_visible() -> None: assert fake_navigation.goal.position.x == pytest.approx(1.65) +def test_handle_seat_request_uses_rotate_scan_without_explorer() -> None: + skill = SeatGuideSkillContainer.__new__(SeatGuideSkillContainer) + fake_navigation = FakeNavigation() + fake_relative_mover = FakeRelativeMover() + skill._navigation = fake_navigation + skill._relative_mover = fake_relative_mover + skill._seat_observation_provider = SequenceSeatObservationProvider( + [ + SeatSceneObservation(seats=[], people=[], source="camera_no_seats_detected"), + SeatSceneObservation(seats=[], people=[], source="camera_no_seats_detected"), + SeatSceneObservation( + seats=[SeatObservation("visible", x=1.0, y=0.0, yaw=0.0)], + people=[], + source="camera_3d", + ), + SeatSceneObservation( + seats=[SeatObservation("visible", x=1.0, y=0.0, yaw=0.0)], + people=[], + source="camera_3d", + ), + ] + ) + + message = skill.handle_seat_request( + "Please help me find an empty seat", + wait_for_arrival=False, + ) + + assert "seat_1" in message + assert fake_relative_mover.moves == [(0.0, 0.0, 30.0)] + assert fake_navigation.goal is not None + + def test_scan_for_empty_seat_prefers_direct_turn_over_relative_navigation() -> None: skill = SeatGuideSkillContainer.__new__(SeatGuideSkillContainer) fake_navigation = FakeNavigation() @@ -1228,9 +1261,11 @@ def test_web_input_routes_seat_voice_text_directly_to_seat_guide() -> None: fake_seat_guide = FakeSeatGuideRequest() fake_transport = FakeHumanTransport() fake_agent_responses = FakeAgentResponses() + cloud_posts: list[str] = [] web_input._seat_guide = fake_seat_guide web_input._human_transport = fake_transport web_input._agent_responses = fake_agent_responses + web_input._post_cloud_speaker = cloud_posts.append web_input._route_text("帮我找一个空位") @@ -1238,6 +1273,7 @@ def test_web_input_routes_seat_voice_text_directly_to_seat_guide() -> None: assert fake_seat_guide.preview_requests == [] assert fake_transport.published == [] assert fake_agent_responses.published == ["handled"] + assert cloud_posts == ["handled"] def test_web_input_logs_live_seat_guide_route_for_voice_bringup( @@ -1248,9 +1284,11 @@ def test_web_input_logs_live_seat_guide_route_for_voice_bringup( fake_transport = FakeHumanTransport() fake_agent_responses = FakeAgentResponses() fake_logger = FakeLogger() + cloud_posts: list[str] = [] web_input._seat_guide = fake_seat_guide web_input._human_transport = fake_transport web_input._agent_responses = fake_agent_responses + web_input._post_cloud_speaker = cloud_posts.append monkeypatch.setattr(web_human_input_module, "logger", fake_logger) web_input._route_text("帮我找一个空位") @@ -1269,9 +1307,11 @@ def test_web_input_routes_preview_seat_voice_text_without_navigation_request( fake_transport = FakeHumanTransport() fake_agent_responses = FakeAgentResponses() fake_logger = FakeLogger() + cloud_posts: list[str] = [] web_input._seat_guide = fake_seat_guide web_input._human_transport = fake_transport web_input._agent_responses = fake_agent_responses + web_input._post_cloud_speaker = cloud_posts.append monkeypatch.setattr(web_human_input_module, "logger", fake_logger) web_input._route_text("预检帮我找一个空位") @@ -1280,6 +1320,7 @@ def test_web_input_routes_preview_seat_voice_text_without_navigation_request( assert fake_seat_guide.requests == [] assert fake_transport.published == [] assert fake_agent_responses.published == ["previewed"] + assert cloud_posts == [] assert fake_logger.info_calls == [ ("WebInput received text", {"text": "预检帮我找一个空位"}), ( @@ -4155,7 +4196,7 @@ def test_hardware_bringup_starts_real_stack_then_runs_smoke_and_acceptance() -> ) -def test_hardware_bringup_requires_real_perception_and_agent_credentials() -> None: +def test_hardware_bringup_requires_real_perception_and_allows_no_agent_key() -> None: script = HARDWARE_BRINGUP_SCRIPT.read_text() assert 'ALIBABA_API_KEY' in script @@ -4165,10 +4206,8 @@ def test_hardware_bringup_requires_real_perception_and_agent_credentials() -> No "SeatGuide bring-up no-go: ALIBABA_API_KEY is not set for detection_model=qwen." in script ) - assert ( - "SeatGuide bring-up no-go: neither OPENROUTER_API_KEY nor OPENAI_API_KEY is set." - in script - ) + assert "neither OPENROUTER_API_KEY nor OPENAI_API_KEY is set" not in script + assert "direct SeatGuide voice/MCP routing still works" in script assert "TTS speech feedback will be unavailable" not in script diff --git a/dimos/agents/web_human_input.py b/dimos/agents/web_human_input.py index ed379235f8..fe76bdd05d 100644 --- a/dimos/agents/web_human_input.py +++ b/dimos/agents/web_human_input.py @@ -196,6 +196,8 @@ def _route_text(self, text: str) -> None: logger.info("WebInput routing text to SeatGuide live request", text=text) response = self._seat_guide.handle_seat_request(text) self._publish_agent_response(response) + if not is_seat_guide_preview_request(text): + self._post_cloud_speaker(response) return except Exception: logger.exception( diff --git a/dimos/robot/unitree/go2/blueprints/agentic/_common_agentic.py b/dimos/robot/unitree/go2/blueprints/agentic/_common_agentic.py index bcfdc390a9..93312225bc 100644 --- a/dimos/robot/unitree/go2/blueprints/agentic/_common_agentic.py +++ b/dimos/robot/unitree/go2/blueprints/agentic/_common_agentic.py @@ -15,7 +15,6 @@ from dimos.agents.skills.navigation import NavigationSkillContainer from dimos.agents.skills.person_follow import PersonFollowSkillContainer -from dimos.agents.skills.seat_guide import CameraSeatObservationProvider, SeatGuideSkillContainer from dimos.agents.skills.speak_skill import SpeakSkill from dimos.agents.web_human_input import WebInput from dimos.core.coordination.blueprints import autoconnect @@ -25,8 +24,6 @@ _common_agentic = autoconnect( NavigationSkillContainer.blueprint(), PersonFollowSkillContainer.blueprint(camera_info=GO2Connection.camera_info_static), - CameraSeatObservationProvider.blueprint(), - SeatGuideSkillContainer.blueprint(), UnitreeSkillContainer.blueprint(), WebInput.blueprint(), SpeakSkill.blueprint(), diff --git a/docs/agents/seat_guide_modules.md b/docs/agents/seat_guide_modules.md index 86b2b20d5e..61bfa40d83 100644 --- a/docs/agents/seat_guide_modules.md +++ b/docs/agents/seat_guide_modules.md @@ -13,6 +13,24 @@ The system scans a conference room with one long table, detects chairs and people, selects the nearest reachable empty chair, navigates beside it, and publishes a short instruction to the web or phone relay. +End-to-end demo data path: + +1. User speaks or types a SeatGuide request in the browser, or calls + `phone_seat_request`. +2. `WebInput` routes matching SeatGuide text directly to `handle_seat_request`; + unrelated text stays on the normal agent path. +3. `CameraSeatObservationProvider` reads the latest RGB frame, camera + calibration, LiDAR/pointcloud, and odometry. It detects chairs/people and + produces map-frame `SeatSceneObservation` data. If no chair is visible but a + direct/relative mover is wired, SeatGuide rotates in place and checks again. +4. `SeatGuidePlanner` classifies occupied chairs, selects the nearest empty + chair, and computes the guide pose beside that chair in the `map` frame. +5. `NavigationInterfaceSpec.set_goal()` receives the guide pose; SeatGuide waits + for `goal_reached=true` when live requests use the default arrival wait. +6. After navigation completes or fails, `WebInput` publishes the result to the + web response stream and posts the same result to the configured phone speaker + relay. The phone plays the message; Go2 body audio is not used. + ## Modules | Module | Owner boundary | Input | Output | Can build in parallel | Current status | @@ -169,6 +187,8 @@ before any live motion: One-command real Go2 bring-up: ```bash +# Optional: enables the normal LLM agent path. SeatGuide direct voice/MCP +# routing still works without it. export OPENROUTER_API_KEY=... export OPENROUTER_MODEL=openai/gpt-4o-mini bin/demo_seat_guide_hardware_bringup --robot-ip 192.168.123.161 diff --git a/docs/agents/seat_guide_step_by_step_plan.md b/docs/agents/seat_guide_step_by_step_plan.md index 0d6ea56b5b..96f6952dbe 100644 --- a/docs/agents/seat_guide_step_by_step_plan.md +++ b/docs/agents/seat_guide_step_by_step_plan.md @@ -240,7 +240,7 @@ bin/demo_seat_guide_replay_smoke 1. 机器狗上电,和 Mac 在同一网络。 2. 确认 Go2 IP,默认示例是 `192.168.123.161`。 -3. 准备 API keys。普通 agent 可以使用 OpenRouter;找座位 VLM 如果选择 Qwen 仍然需要 Alibaba/Qwen: +3. 可选准备普通 agent 的 API key。SeatGuide 直连语音/MCP 路径不需要 LLM key;如果选择 Qwen 作为找座位 VLM,仍然需要 Alibaba/Qwen: ```bash export OPENROUTER_API_KEY="你的 OpenRouter key" @@ -255,7 +255,7 @@ bin/demo_seat_guide_hardware_bringup --robot-ip 192.168.123.161 这个脚本会自动执行: -1. 检查本地 Moondream2 模型缓存,以及 agent 使用的 `OPENROUTER_API_KEY` 或 `OPENAI_API_KEY`。 +1. 检查本地 Moondream2 模型缓存;如果没有 agent key,普通 agent chat 会禁用,但 SeatGuide 直连路径仍然可用。 2. 启动 `unitree-go2-seat-guide-agentic`。 3. 跑 `bin/demo_seat_guide_smoke` 做 no-motion 检查。 4. 跑 `bin/demo_seat_guide_hardware_acceptance` 做真实浏览器语音和导航验收。 diff --git a/docs/agents/seat_guide_step_by_step_plan_en.md b/docs/agents/seat_guide_step_by_step_plan_en.md index 04a1660248..3325c0ec9f 100644 --- a/docs/agents/seat_guide_step_by_step_plan_en.md +++ b/docs/agents/seat_guide_step_by_step_plan_en.md @@ -240,7 +240,7 @@ Manual preparation: 1. Power on the robot dog and connect it to the same network as the Mac. 2. Confirm the Go2 IP. The default example is `192.168.123.161`. -3. Prepare API keys. The normal agent can use OpenRouter; seat/person VLM still requires Alibaba/Qwen when Qwen is selected: +3. Optionally prepare an API key for the normal agent. The SeatGuide direct voice/MCP path does not need an LLM key; seat/person VLM still requires Alibaba/Qwen when Qwen is selected: ```bash export OPENROUTER_API_KEY="your OpenRouter key" @@ -255,7 +255,7 @@ bin/demo_seat_guide_hardware_bringup --robot-ip 192.168.123.161 The script automatically: -1. Checks the local Moondream2 model cache, and the agent key from either `OPENROUTER_API_KEY` or `OPENAI_API_KEY`. +1. Checks the local Moondream2 model cache; if no agent key is set, normal agent chat is disabled but the direct SeatGuide route still works. 2. Starts `unitree-go2-seat-guide-agentic`. 3. Runs `bin/demo_seat_guide_smoke` for no-motion checks. 4. Runs `bin/demo_seat_guide_hardware_acceptance` for real browser voice input and navigation acceptance. From a1167d6cd4fa812ad9e1982ae73b5ab963162844 Mon Sep 17 00:00:00 2001 From: Ernest Date: Fri, 29 May 2026 09:41:16 +0800 Subject: [PATCH 11/16] chore(seat-guide): remove obsolete moondream camera demo --- bin/demo_seat_guide_macos_camera_detect | 542 ------------------ docs/agents/seat_guide_modules.md | 12 +- docs/agents/seat_guide_step_by_step_plan.md | 6 +- .../agents/seat_guide_step_by_step_plan_en.md | 6 +- 4 files changed, 11 insertions(+), 555 deletions(-) delete mode 100755 bin/demo_seat_guide_macos_camera_detect diff --git a/bin/demo_seat_guide_macos_camera_detect b/bin/demo_seat_guide_macos_camera_detect deleted file mode 100755 index 43a5611d9e..0000000000 --- a/bin/demo_seat_guide_macos_camera_detect +++ /dev/null @@ -1,542 +0,0 @@ -#!/usr/bin/env -S uv run python -# Copyright 2026 Dimensional Inc. -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -"""Capture a macOS webcam frame and annotate empty-seat detections with Moondream2.""" - -from __future__ import annotations - -import argparse -import base64 -from dataclasses import dataclass -from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer -import json -from pathlib import Path -import sys -import time -import webbrowser - -import cv2 -import numpy as np - -from dimos.models.vl.moondream import MoondreamVlModel -from dimos.msgs.sensor_msgs.Image import Image, ImageFormat - -Bbox = tuple[float, float, float, float] - - -@dataclass(frozen=True) -class SeatCandidate: - bbox: Bbox - occupied: bool - - -def _parse_args() -> argparse.Namespace: - parser = argparse.ArgumentParser( - description=( - "Capture one frame from a macOS webcam, detect chairs and people with " - "Moondream2, then draw empty-chair candidates." - ) - ) - parser.add_argument("--camera-index", type=int, default=0) - parser.add_argument("--width", type=int, default=1280) - parser.add_argument("--height", type=int, default=720) - parser.add_argument("--warmup-frames", type=int, default=5) - parser.add_argument("--max-objects", type=int, default=10) - parser.add_argument( - "--browser-camera", - action="store_true", - help="Use a local browser page for camera capture instead of OpenCV/AVFoundation.", - ) - parser.add_argument( - "--no-browser-fallback", - action="store_true", - help="Do not fall back to browser camera capture when OpenCV camera access is denied.", - ) - parser.add_argument("--port", type=int, default=5566, help="Browser camera server port.") - parser.add_argument( - "--preview", - action=argparse.BooleanOptionalAction, - default=True, - help="Show a live camera window before capturing a frame. Press Space/Enter to capture.", - ) - parser.add_argument( - "--image", - type=Path, - default=None, - help="Use an existing image instead of opening the macOS camera.", - ) - parser.add_argument( - "--occupancy-padding", - type=float, - default=0.15, - help="Expand chair boxes by this fraction before checking whether a person is sitting there.", - ) - parser.add_argument( - "--out", - type=Path, - default=Path("/tmp/seat_guide_macos_empty_seat.png"), - help="Annotated output image path.", - ) - parser.add_argument( - "--raw-out", - type=Path, - default=Path("/tmp/seat_guide_macos_webcam_raw.jpg"), - help="Raw captured frame path.", - ) - parser.add_argument("--no-open", action="store_true", help="Do not open the output image.") - return parser.parse_args() - - -def _capture_frame(args: argparse.Namespace) -> tuple[object, int, int]: - if args.image is not None: - frame = cv2.imread(str(args.image)) - if frame is None: - raise RuntimeError(f"Failed to read image: {args.image}") - height, width = frame.shape[:2] - return frame, width, height - - cap = cv2.VideoCapture(args.camera_index) - if not cap.isOpened(): - raise RuntimeError( - f"Failed to open camera index {args.camera_index}. " - "Check macOS Camera permission or try --camera-index 1." - ) - - cap.set(cv2.CAP_PROP_FRAME_WIDTH, args.width) - cap.set(cv2.CAP_PROP_FRAME_HEIGHT, args.height) - - frame = None - ok = False - window_name = "SeatGuide macOS camera - Space/Enter captures, q/Esc quits" - - for _ in range(max(0, args.warmup_frames)): - ok, frame = cap.read() - if not ok: - break - - if args.preview: - cv2.namedWindow(window_name, cv2.WINDOW_NORMAL) - while True: - ok, frame = cap.read() - if not ok or frame is None: - break - - preview = frame.copy() - cv2.rectangle(preview, (0, 0), (preview.shape[1], 44), (0, 0, 0), -1) - cv2.putText( - preview, - "SeatGuide camera preview: Space/Enter capture, q/Esc quit", - (12, 29), - cv2.FONT_HERSHEY_SIMPLEX, - 0.8, - (255, 255, 255), - 2, - cv2.LINE_AA, - ) - cv2.imshow(window_name, preview) - key = cv2.waitKey(1) & 0xFF - if key in (13, 32): - break - if key in (27, ord("q")): - cap.release() - cv2.destroyWindow(window_name) - raise RuntimeError("Camera preview was cancelled.") - cv2.destroyWindow(window_name) - else: - ok, frame = cap.read() - - cap.release() - - if not ok or frame is None: - raise RuntimeError(f"Failed to read a frame from camera index {args.camera_index}.") - - height, width = frame.shape[:2] - return frame, width, height - - -def _to_dimos_image(bgr: object) -> Image: - rgb = cv2.cvtColor(bgr, cv2.COLOR_BGR2RGB) - return Image.from_numpy( - rgb, - format=ImageFormat.RGB, - frame_id="mac_camera", - ts=time.time(), - ) - - -def _detect_bboxes(model: MoondreamVlModel, image: Image, query: str, max_objects: int) -> list[Bbox]: - result = model.query_detections(image, query, max_objects=max_objects) - return [detection.bbox for detection in result.detections] - - -def _expanded_bbox(bbox: Bbox, width: int, height: int, fraction: float) -> Bbox: - x1, y1, x2, y2 = bbox - pad_x = max(0.0, (x2 - x1) * fraction) - pad_y = max(0.0, (y2 - y1) * fraction) - return ( - max(0.0, x1 - pad_x), - max(0.0, y1 - pad_y), - min(float(width), x2 + pad_x), - min(float(height), y2 + pad_y), - ) - - -def _center(bbox: Bbox) -> tuple[float, float]: - x1, y1, x2, y2 = bbox - return (x1 + x2) / 2.0, (y1 + y2) / 2.0 - - -def _contains_point(bbox: Bbox, point: tuple[float, float]) -> bool: - x1, y1, x2, y2 = bbox - x, y = point - return x1 <= x <= x2 and y1 <= y <= y2 - - -def _classify_seats( - chairs: list[Bbox], - people: list[Bbox], - *, - width: int, - height: int, - occupancy_padding: float, -) -> list[SeatCandidate]: - people_centers = [_center(person) for person in people] - seats: list[SeatCandidate] = [] - for chair in chairs: - occupancy_region = _expanded_bbox(chair, width, height, occupancy_padding) - occupied = any(_contains_point(occupancy_region, center) for center in people_centers) - seats.append(SeatCandidate(bbox=chair, occupied=occupied)) - return seats - - -def _draw_label( - image: object, - label: str, - origin: tuple[int, int], - color: tuple[int, int, int], - *, - scale: float = 0.65, -) -> None: - x, y = origin - thickness = 2 - (text_w, text_h), baseline = cv2.getTextSize( - label, - cv2.FONT_HERSHEY_SIMPLEX, - scale, - thickness, - ) - top = max(0, y - text_h - baseline - 8) - cv2.rectangle(image, (x, top), (x + text_w + 8, top + text_h + baseline + 8), color, -1) - cv2.putText( - image, - label, - (x + 4, top + text_h + 3), - cv2.FONT_HERSHEY_SIMPLEX, - scale, - (255, 255, 255), - thickness, - cv2.LINE_AA, - ) - - -def _draw_results( - frame: object, - seats: list[SeatCandidate], - people: list[Bbox], -) -> object: - annotated = frame.copy() - green = (0, 180, 0) - red = (0, 0, 220) - blue = (220, 120, 0) - - for index, seat in enumerate(seats, start=1): - x1, y1, x2, y2 = [round(value) for value in seat.bbox] - color = red if seat.occupied else green - label = f"occupied chair {index}" if seat.occupied else f"EMPTY SEAT {index}" - cv2.rectangle(annotated, (x1, y1), (x2, y2), color, 3) - _draw_label(annotated, label, (x1, y1), color) - - for index, person in enumerate(people, start=1): - x1, y1, x2, y2 = [round(value) for value in person] - cv2.rectangle(annotated, (x1, y1), (x2, y2), blue, 2) - _draw_label(annotated, f"person {index}", (x1, y1), blue, scale=0.55) - - empty_count = sum(1 for seat in seats if not seat.occupied) - summary = f"chairs={len(seats)} people={len(people)} empty={empty_count}" - cv2.rectangle(annotated, (0, 0), (min(annotated.shape[1], 520), 42), (0, 0, 0), -1) - cv2.putText( - annotated, - summary, - (12, 29), - cv2.FONT_HERSHEY_SIMPLEX, - 0.85, - (255, 255, 255), - 2, - cv2.LINE_AA, - ) - return annotated - - -def _run_detection(frame: object, args: argparse.Namespace) -> tuple[dict[str, object], object]: - height, width = frame.shape[:2] - image = _to_dimos_image(frame) - print("SeatGuide detection: loading Moondream2 without torch.compile for demo latency.") - model = MoondreamVlModel(compile_model=False) - print("SeatGuide detection: detecting chairs...") - chairs = _detect_bboxes(model, image, "chair", args.max_objects) - print(f"SeatGuide detection: detected {len(chairs)} chair candidates.") - print("SeatGuide detection: detecting people...") - people = _detect_bboxes(model, image, "person", args.max_objects) - print(f"SeatGuide detection: detected {len(people)} people.") - seats = _classify_seats( - chairs, - people, - width=width, - height=height, - occupancy_padding=args.occupancy_padding, - ) - annotated = _draw_results(frame, seats, people) - empty_count = sum(1 for seat in seats if not seat.occupied) - summary: dict[str, object] = { - "chairs": len(seats), - "people": len(people), - "empty": empty_count, - "seats": [ - { - "id": f"seat_{index}", - "status": "empty" if not seat.occupied else "occupied", - "bbox": [round(value, 1) for value in seat.bbox], - } - for index, seat in enumerate(seats, start=1) - ], - } - return summary, annotated - - -def _write_detection_outputs( - frame: object, - annotated: object, - args: argparse.Namespace, -) -> None: - args.raw_out.parent.mkdir(parents=True, exist_ok=True) - cv2.imwrite(str(args.raw_out), frame) - args.out.parent.mkdir(parents=True, exist_ok=True) - cv2.imwrite(str(args.out), annotated) - - -def _html_page() -> bytes: - return b""" - - - - SeatGuide Camera - - - -
-
-

Camera Preview

- - - -
Starting camera...
-
-
-

Moondream2 Result

- Detection result will appear here -
-
- - - - -""" - - -def _serve_browser_camera(args: argparse.Namespace) -> int: - class Handler(BaseHTTPRequestHandler): - def do_GET(self) -> None: - if self.path != "/": - self.send_error(404) - return - body = _html_page() - self.send_response(200) - self.send_header("content-type", "text/html; charset=utf-8") - self.send_header("content-length", str(len(body))) - self.end_headers() - self.wfile.write(body) - - def do_POST(self) -> None: - if self.path != "/detect": - self.send_error(404) - return - try: - content_length = int(self.headers.get("content-length", "0")) - payload = json.loads(self.rfile.read(content_length)) - print("SeatGuide browser camera: received frame; starting detection.") - image_data = str(payload["image"]).split(",", 1)[1] - encoded = base64.b64decode(image_data) - frame = cv2.imdecode(np.frombuffer(encoded, dtype=np.uint8), cv2.IMREAD_COLOR) - if frame is None: - raise RuntimeError("Failed to decode browser camera frame.") - summary, annotated = _run_detection(frame, args) - _write_detection_outputs(frame, annotated, args) - ok, png = cv2.imencode(".png", annotated) - if not ok: - raise RuntimeError("Failed to encode annotated image.") - response = { - **summary, - "saved": str(args.out), - "annotated_image": "data:image/png;base64," - + base64.b64encode(png.tobytes()).decode("ascii"), - } - body = json.dumps(response).encode("utf-8") - self.send_response(200) - self.send_header("content-type", "application/json") - self.send_header("content-length", str(len(body))) - self.end_headers() - self.wfile.write(body) - except Exception as exc: - body = json.dumps({"error": str(exc)}).encode("utf-8") - self.send_response(500) - self.send_header("content-type", "application/json") - self.send_header("content-length", str(len(body))) - self.end_headers() - self.wfile.write(body) - - def log_message(self, format: str, *args: object) -> None: - return - - server = ThreadingHTTPServer(("127.0.0.1", args.port), Handler) - url = f"http://127.0.0.1:{args.port}/" - print(f"SeatGuide browser camera server: {url}") - print("Use the browser preview, then click 'Detect empty seat'. Press Ctrl-C here to stop.") - webbrowser.open(url) - try: - server.serve_forever() - except KeyboardInterrupt: - pass - finally: - server.server_close() - return 0 - - -def main() -> int: - args = _parse_args() - if args.browser_camera: - return _serve_browser_camera(args) - try: - frame, width, height = _capture_frame(args) - summary, annotated = _run_detection(frame, args) - _write_detection_outputs(frame, annotated, args) - - print( - f"SeatGuide macOS camera result: chairs={summary['chairs']} " - f"people={summary['people']} empty={summary['empty']}" - ) - print(f"raw_image={args.raw_out}") - print(f"annotated_image={args.out}") - for seat in summary["seats"]: - print(f"{seat['id']}: {seat['status']} bbox={tuple(seat['bbox'])}") - - if not args.no_open: - import subprocess - - subprocess.run(["open", str(args.out)], check=False) - return 0 - except Exception as exc: - if not args.image and not args.no_browser_fallback: - print( - f"OpenCV camera failed ({exc}). Falling back to browser camera capture.", - file=sys.stderr, - ) - return _serve_browser_camera(args) - print(f"SeatGuide macOS camera detection failed: {exc}", file=sys.stderr) - return 1 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/docs/agents/seat_guide_modules.md b/docs/agents/seat_guide_modules.md index 61bfa40d83..7f5804d218 100644 --- a/docs/agents/seat_guide_modules.md +++ b/docs/agents/seat_guide_modules.md @@ -3,7 +3,7 @@ This is the first demo-oriented module plan for the conference room seat-finding hackathon idea. The goal is to keep each boundary testable without a Go2 while the default Go2 path uses real browser/Whisper voice input, camera-backed VLM -seat/person recognition, robot navigation, and phone/web feedback. +seat/person recognition (YOLO fast path, VLM fallback), robot navigation, and phone/web feedback. ## Demo-critical flow @@ -57,12 +57,12 @@ Direct skill arguments: Provider-backed scene: - `SeatObservationProviderSpec.get_seat_scene()` -- `CameraSeatObservationProvider` subscribes to `color_image` and `odom`, asks the VLM for `chair` and `person` detections separately, and converts image-space detections to an approximate map-frame scene using the latest robot pose +- `CameraSeatObservationProvider` subscribes to `color_image` and `odom`, runs YOLO fast detection for `chair` and `person` by default, and converts image-space detections to an approximate map-frame scene using the latest robot pose - `camera_seat_provider_status()` reports camera frame, odometry, input freshness, VLM credential, runtime override, and fallback configuration readiness without running VLM detection - `CameraSeatObservationProvider.set_seat_scene()` remains available as explicit runtime calibration/fallback when camera/VLM detection is unavailable - `SyntheticSeatObservationProvider` remains for repeatable Go2-free tests and demos - `unitree-go2-seat-guide` and `unitree-go2-seat-guide-agentic` include `CameraSeatObservationProvider` so the default SeatGuide bring-up path uses real camera recognition -- the default `moondream` VLM path uses the local Moondream2 model cache; if `qwen` is selected, missing `ALIBABA_API_KEY` makes SeatGuide report `camera_detection_error` instead of silently treating missing credentials as a real no-seat observation +- the default runtime path uses YOLO `yolo11n.pt` as the fast chair/person detector. `moondream` and `qwen` remain VLM fallback options when `vlm_fallback_enabled` is turned on; if `qwen` is selected, missing `ALIBABA_API_KEY` makes SeatGuide report `camera_detection_error` instead of silently treating missing credentials as a real no-seat observation Voice/text intake: @@ -176,7 +176,7 @@ before any live motion: | Track | Owner checks | Passing evidence | No-go action | | --- | --- | --- | --- | | Voice intake | Browser page opens; microphone permission granted; Chinese preview phrase reaches WebInput. | `web_input_status` shows `web=started`, `thread=running`, `seat_route=seat_guide_direct`, `responses=connected`, `voice_upload=connected`, `stt=connected`, `human_transport=connected`; acceptance log shows `WebInput received text` for `预检帮我找一个空位`. | Fix browser/microphone/WebInput before touching navigation. | -| Perception | Go2 camera frame, odometry, and the configured VLM detector are live; no fallback scene is active. | `camera_seat_provider_status` shows `image=x`, `image_fresh=true`, `odom=(...)`, `odom_fresh=true`, `detection_model=moondream`, `credential=present`, `override=inactive`, `configured_fallback_seats=0`, `configured_fallback_people=0`; `seat_guide_status` starts with `SeatGuide scene source=camera:`. | Turn robot toward the table, verify the local Moondream2 cache or selected VLM credential, restore stale camera/odom streams, or explicitly mark fallback calibration as non-acceptance. | +| Perception | Go2 camera frame, odometry, and the YOLO fast detector are live; no fallback scene is active. | `camera_seat_provider_status` shows `image=x`, `image_fresh=true`, `odom=(...)`, `odom_fresh=true`, `fast_detector=yolo`, `override=inactive`, `configured_fallback_seats=0`, `configured_fallback_people=0`; `seat_guide_status` starts with `SeatGuide scene source=camera:`. | Turn robot toward the table, restore stale camera/odom streams, or explicitly mark fallback calibration as non-acceptance. | | Planner | Empty/occupied counts and selected goal make sense before motion. | `seat_guide_preflight`, `seat_guide_readiness_report`, and `preview_empty_seat_goal` report `empty=N occupied=N`, `selected=...`, and `goal=(...)` without sending a goal. | Adjust camera view or chair/person layout before live voice. | | Navigation | Robot is idle before SeatGuide sends the live goal and reports completion after it. | Preflight has `navigation=IDLE`; after live voice, `seat_guide_navigation_status` reports a new `goal_sequence` and `goal_reached=true`. | Wait/cancel existing navigation or inspect navigation logs; do not rerun live voice until idle. | | Phone feedback | The web response stream is visible, and a mounted phone can play messages if audible feedback is required. | `web_input_status` shows `responses=connected`; optional phone relay checks can use `phone_speaker_test`. | Keep the phone speaker page open on the mounted phone; do not depend on Go2 body audio. | @@ -239,9 +239,7 @@ default `gpt-4o` model to `openai/gpt-4o` on OpenRouter. If neither `OPENROUTER_API_KEY` nor `OPENAI_API_KEY` is set, `McpClient` disables the LLM agent but the direct SeatGuide voice route and MCP tools still start. -The default camera detector uses the local Moondream2 VLM. Make sure the -`vikhyatk/moondream2` Hugging Face snapshot is cached before hardware bring-up, -or configure a different supported `detection_model`. If `qwen` is selected, +The default camera detector uses YOLO `yolo11n.pt` for low-latency chair/person detection. Moondream and Qwen are VLM fallback options only when `vlm_fallback_enabled` is enabled. If `qwen` is selected, set `ALIBABA_API_KEY`; otherwise `camera_seat_provider_status` reports `credential=missing` and `seat_guide_status` reports `source=camera_detection_error`. Use logs or `set_seat_scene` only as an diff --git a/docs/agents/seat_guide_step_by_step_plan.md b/docs/agents/seat_guide_step_by_step_plan.md index 96f6952dbe..631fcc0d6b 100644 --- a/docs/agents/seat_guide_step_by_step_plan.md +++ b/docs/agents/seat_guide_step_by_step_plan.md @@ -7,7 +7,7 @@ | 模块 | 负责什么 | 输入 | 输出 | 是否可并行 | 当前验证方式 | | --- | --- | --- | --- | --- | --- | | 1. 基础语音/文字控制入口 | 接收浏览器麦克风、浏览器文字、普通 agent text,先识别普通运动/姿态命令,再识别找座位请求 | WebInput `/submit_query`、`/upload_audio`、Whisper 文本、agent text | 普通 agent tool call,或 SeatGuide preview/live 请求 | 是 | MCP tool 验收、WebInput 单测、HTTP TestClient、硬件验收脚本 | -| 2. 场景感知 | 用 Go2 RGB 图像 + odom + Moondream2/VLM 识别椅子和人,并投影成 map 坐标 | `color_image`、`odom`、本地 Moondream2 模型缓存 | `SeatSceneObservation` | 是 | Camera provider 单测、`camera_seat_provider_status` | +| 2. 场景感知 | 用 Go2 RGB 图像 + odom + YOLO 快速识别椅子和人,必要时用 VLM fallback,并投影成 map 坐标 | `color_image`、`odom`、YOLO `yolo11n.pt` | `SeatSceneObservation` | 是 | Camera provider 单测、`camera_seat_provider_status` | | 3. 空位规划 | 判断哪些椅子被占用,选择最近空位,生成机器人应到达的引导点 | 椅子位姿、人员位置、机器人位置 | 选中椅子、导航目标 pose | 是 | Planner 单测、`preview_empty_seat_goal` | | 4. 导航执行 | 把目标 pose 发给已有导航模块,并读取完成状态 | SeatGuide goal pose | `set_goal()`、`goal_reached` | 部分并行 | fake navigator 单测、`seat_guide_navigation_status` | | 5. 手机/网页反馈 | 告诉用户找到哪个位置、是否需要跟随、失败原因 | SeatGuide 结果文本 | web response text、手机扬声器 relay | 是 | `web_input_status`、可选 `phone_speaker_test` | @@ -255,7 +255,7 @@ bin/demo_seat_guide_hardware_bringup --robot-ip 192.168.123.161 这个脚本会自动执行: -1. 检查本地 Moondream2 模型缓存;如果没有 agent key,普通 agent chat 会禁用,但 SeatGuide 直连路径仍然可用。 +1. 检查 YOLO 快速检测路径;如果没有 agent key,普通 agent chat 会禁用,但 SeatGuide 直连路径仍然可用。 2. 启动 `unitree-go2-seat-guide-agentic`。 3. 跑 `bin/demo_seat_guide_smoke` 做 no-motion 检查。 4. 跑 `bin/demo_seat_guide_hardware_acceptance` 做真实浏览器语音和导航验收。 @@ -285,7 +285,7 @@ bin/demo_seat_guide_hardware_bringup --robot-ip 192.168.123.161 | STT 不工作 | `web_input_status` | Whisper/faster-whisper 初始化失败 | 看 DimOS log,确认依赖安装 | | 没有图像 | `camera_seat_provider_status` | Go2 camera/replay stream 没到 | 转向桌子,确认 replay/SHM 流 | | odom 缺失或过期 | `camera_seat_provider_status` | localization 没启动或 stale | 等待 odom,检查 Go2/replay stack | -| VLM 失败 | `seat_guide_status` | 本地 Moondream2 模型缺失、模型加载失败,或远程 VLM key 缺失 | 拉取模型或重新 export 对应 key,并重启 stack | +| YOLO/VLM 失败 | `seat_guide_status` | YOLO 模型加载失败,或启用 VLM fallback 后远程 VLM key 缺失 | 确认 `yolo11n.pt` 可加载;如果使用 Qwen fallback,重新 export 对应 key,并重启 stack | | 找不到椅子 | `seat_guide_status` | 摄像头没朝向桌子、光照/识别失败 | 调整机器人视角;只调试时可 fallback | | 导航忙 | `seat_guide_preflight` | `navigation=FOLLOWING_PATH` 或 `RECOVERY` | 等任务结束或停止导航后重跑 | | 手机反馈不可用 | `web_input_status` / `phone_speaker_test` | 手机没有打开 relay 页面或网络不可达 | 先确认 web response stream;需要声音时让手机访问可用的 relay 页面 | diff --git a/docs/agents/seat_guide_step_by_step_plan_en.md b/docs/agents/seat_guide_step_by_step_plan_en.md index 3325c0ec9f..fc535094a6 100644 --- a/docs/agents/seat_guide_step_by_step_plan_en.md +++ b/docs/agents/seat_guide_step_by_step_plan_en.md @@ -7,7 +7,7 @@ Goal: let a user tell the Go2, through browser microphone or typed text, "find m | Module | Responsibility | Input | Output | Can run in parallel | Current verification | | --- | --- | --- | --- | --- | --- | | 1. Basic voice/text control entry | Accept browser microphone, browser text, or normal agent text; first recognize basic movement/posture commands, then recognize seat-finding intent | WebInput `/submit_query`, `/upload_audio`, Whisper text, agent text | Normal agent tool call, or SeatGuide preview/live request | Yes | MCP tool acceptance, WebInput unit tests, HTTP TestClient, hardware acceptance script | -| 2. Scene perception | Use Go2 RGB image + odom + Moondream2/VLM to detect chairs and people, then project them into map coordinates | `color_image`, `odom`, local Moondream2 model cache | `SeatSceneObservation` | Yes | Camera provider unit tests, `camera_seat_provider_status` | +| 2. Scene perception | Use Go2 RGB image + odom + YOLO fast detection for chairs and people, with optional VLM fallback, then project them into map coordinates | `color_image`, `odom`, YOLO `yolo11n.pt` | `SeatSceneObservation` | Yes | Camera provider unit tests, `camera_seat_provider_status` | | 3. Empty-seat planning | Decide which chairs are occupied, select the nearest empty seat, and generate the guide pose for the robot | Chair poses, person positions, robot position | Selected chair and navigation goal pose | Yes | Planner unit tests, `preview_empty_seat_goal` | | 4. Navigation execution | Send the target pose to the existing navigation module and read completion status | SeatGuide goal pose | `set_goal()`, `goal_reached` | Partially | Fake navigator unit tests, `seat_guide_navigation_status` | | 5. Phone/web feedback | Tell the user which seat was found, whether to follow, or why the request failed | SeatGuide result text | Web response text, phone speaker relay | Yes | `web_input_status`, optional `phone_speaker_test` | @@ -255,7 +255,7 @@ bin/demo_seat_guide_hardware_bringup --robot-ip 192.168.123.161 The script automatically: -1. Checks the local Moondream2 model cache; if no agent key is set, normal agent chat is disabled but the direct SeatGuide route still works. +1. Checks the YOLO fast detection path; if no agent key is set, normal agent chat is disabled but the direct SeatGuide route still works. 2. Starts `unitree-go2-seat-guide-agentic`. 3. Runs `bin/demo_seat_guide_smoke` for no-motion checks. 4. Runs `bin/demo_seat_guide_hardware_acceptance` for real browser voice input and navigation acceptance. @@ -285,7 +285,7 @@ Pass criteria: | STT is not working | `web_input_status` | Whisper/faster-whisper initialization failed | Inspect DimOS logs and confirm dependencies | | No image | `camera_seat_provider_status` | Go2 camera/replay stream did not arrive | Turn toward the table, confirm replay/SHM stream | | Odom missing or stale | `camera_seat_provider_status` | Localization did not start or stale odom | Wait for odom, inspect Go2/replay stack | -| VLM failed | `seat_guide_status` | Missing local Moondream2 model, model load failure, or missing remote VLM key | Download the model or re-export the matching key, then restart the stack | +| YOLO/VLM failed | `seat_guide_status` | YOLO model load failure, or missing remote VLM key after enabling VLM fallback | Confirm `yolo11n.pt` can load; if using Qwen fallback, re-export the matching key, then restart the stack | | No chairs found | `seat_guide_status` | Camera not facing the table, lighting or recognition issue | Adjust robot view; use fallback only for debugging | | Navigation busy | `seat_guide_preflight` | `navigation=FOLLOWING_PATH` or `RECOVERY` | Wait for the task to finish or stop navigation before retrying | | Phone feedback unavailable | `web_input_status` / `phone_speaker_test` | Phone has not opened the relay page or the relay is unreachable | First confirm the web response stream; if sound is needed, open a reachable relay page on the phone | From ff82f4ca3c6909e3eb33dd3e987516ce69b8d3ec Mon Sep 17 00:00:00 2001 From: Ueti999 Date: Thu, 28 May 2026 23:54:35 +0800 Subject: [PATCH 12/16] feat: add SeatFinder and SeatPlanner skills MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SeatFinderSkill: continuous YOLO-based empty-seat detector with in-place scan (slow yaw rate + 10Hz cmd_vel publishing) that surfaces the nearest unoccupied chair/couch/bench using person-box overlap as occupancy test. SeatPlanner: on-demand variant for the manual-map flow — operator drives the Go2 to build the voxel map, then triggers find_empty_seat_now via MCP; the planner projects the chosen 2D detection to a 3D PoseStamped on goal_request without publishing any cmd_vel itself. --- dimos/agents/skills/seat_finder.py | 245 ++++++++++++++++++++++++++++ dimos/agents/skills/seat_planner.py | 227 ++++++++++++++++++++++++++ 2 files changed, 472 insertions(+) create mode 100644 dimos/agents/skills/seat_finder.py create mode 100644 dimos/agents/skills/seat_planner.py diff --git a/dimos/agents/skills/seat_finder.py b/dimos/agents/skills/seat_finder.py new file mode 100644 index 0000000000..5d0508b1e4 --- /dev/null +++ b/dimos/agents/skills/seat_finder.py @@ -0,0 +1,245 @@ +# Copyright 2025-2026 Dimensional Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from __future__ import annotations + +import time +from threading import RLock +from typing import TYPE_CHECKING, Any + +import cv2 +from dimos_lcm.std_msgs import Bool # type: ignore[import-untyped] + +from dimos.agents.annotation import skill +from dimos.core.core import rpc +from dimos.core.module import Module, ModuleConfig +from dimos.core.stream import In, Out +from dimos.msgs.geometry_msgs.PoseStamped import PoseStamped +from dimos.msgs.geometry_msgs.Twist import Twist +from dimos.msgs.geometry_msgs.Vector3 import Vector3 +from dimos.msgs.sensor_msgs.CameraInfo import CameraInfo +from dimos.msgs.sensor_msgs.Image import Image, sharpness_window +from dimos.msgs.sensor_msgs.PointCloud2 import PointCloud2 +from dimos.perception.detection.detectors.yolo import Yolo2DDetector +from dimos.perception.detection.type.detection3d.pointcloud import Detection3DPC +from dimos.utils.logging_config import setup_logger +from dimos.utils.reactive import backpressure + +if TYPE_CHECKING: + from reactivex.abc import DisposableBase + + from dimos.perception.detection.type.detection2d.bbox import Detection2DBBox + from dimos.perception.detection.type.detection2d.imageDetections2D import ImageDetections2D + +logger = setup_logger() + +# COCO classes treated as seats, and the fraction of a seat's box that must be +# covered by a person box before we call it occupied. +SEAT_CLASSES = ("chair", "couch", "bench") +OCCUPANCY_OVERLAP = 0.2 + +# In-place scan: rotate slowly while the continuous detector watches, until a +# target is found or we've turned roughly all the way around. +SCAN_YAW_RATE = 0.5 # rad/s (slow, to keep motion blur low) [rad/s] +# Publish cmd_vel at ~10 Hz; the Go2 stops between commands if they arrive too +# slowly, so a low rate means it never actually turns. +SCAN_TICK = 0.1 # seconds between cmd_vel publishes [s] +SCAN_DURATION = 14.0 # ~one full revolution at SCAN_YAW_RATE [s] +SCAN_LOG_EVERY = 2.0 # seconds between progress logs [s] + + +class Config(ModuleConfig): + camera_info: CameraInfo + # Sharpest-frame target frequency (Hz). The detector only runs on the + # crispest frame in each window, which suppresses motion blur. + detect_freq: float = 5.0 + + +class SeatFinderSkill(Module): + """Self-contained seat/object finder: scan in place, then navigate to one. + + Detection runs continuously on a sharpness-filtered, backpressured stream + (motion-robust). When a skill is called the module first stops frontier + exploration (so it cannot clobber our goal), rotates in place until a target + is seen, projects it to a 3D pose via the pointcloud, and publishes that pose + on ``goal_request`` for the A* planner. Owning the whole flow keeps a single + navigation-goal source, avoiding the explorer/seat-goal conflict. + """ + + config: Config + + color_image: In[Image] + pointcloud: In[PointCloud2] + goal_request: Out[PoseStamped] + detections_image: Out[Image] + stop_explore_cmd: Out[Bool] + cmd_vel: Out[Twist] + + def __init__(self, **kwargs: Any) -> None: + super().__init__(**kwargs) + self._detector = Yolo2DDetector() + self._latest: ImageDetections2D | None = None + self._lock = RLock() + self._subscription: DisposableBase | None = None + + @rpc + def start(self) -> None: + super().start() + sharp = backpressure( + sharpness_window(self.config.detect_freq, self.color_image.pure_observable()) + ) + self._subscription = sharp.subscribe( + on_next=self._on_frame, + on_error=lambda e: logger.exception("Error in seat detection loop", exc_info=e), + ) + + @rpc + def stop(self) -> None: + if self._subscription is not None: + self._subscription.dispose() + self._subscription = None + self.cmd_vel.publish(Twist.zero()) + super().stop() + + def _on_frame(self, image: Image) -> None: + detections = self._detector.process_image(image) + with self._lock: + self._latest = detections + self.detections_image.publish(self._annotate(image, detections.detections)) + + @skill + def find_empty_seat(self) -> str: + """Look around for an empty seat, chair, or sofa and navigate to it. + + Use this when asked to guide someone to a free seat. The robot turns in + place to search, then heads to the seat. Do NOT also call exploration or + other movement tools; this skill owns the search and the motion. + """ + return self._scan_and_navigate(self._select_empty_seats, "empty seat") + + @skill + def find_object(self, query: str) -> str: + """Look around for an object named `query` and navigate next to it. + + Use this to locate and approach a specific item (e.g. "bottle", + "backpack", "chair"). Matches the YOLO (COCO) class name. The robot turns + in place to search; do NOT also call exploration or movement tools. + """ + return self._scan_and_navigate(lambda dets: self._select_by_name(dets, query), query) + + def _scan_and_navigate(self, selector: Any, label: str) -> str: + # Single goal source: stop the frontier explorer so it can't overwrite + # the goal we are about to publish. + self.stop_explore_cmd.publish(Bool(data=True)) + + detections, candidates = self._scan_in_place(selector) + logger.info( + f"SeatFinder: label={label!r} matched={len(candidates)} " + f"names={[d.name for d in detections.detections] if detections else []}" + ) + if detections is None or not candidates: + return f"No {label} found after looking around." + + pointcloud = self.pointcloud.get_next() + transform = self.tf.get("camera_optical", pointcloud.frame_id, detections.image.ts, 5.0) + if not transform: + return f"Could not resolve the camera transform, cannot locate the {label}." + + best = max(candidates, key=lambda d: d.bbox_2d_volume()) + target3d = Detection3DPC.from_2d( + best, + world_pointcloud=pointcloud, + camera_info=self.config.camera_info, + world_to_optical_transform=transform, + ) + if target3d is None: + return f"Found a {label} but could not compute its 3D position." + + pose = target3d.pose + self.goal_request.publish(pose) + return ( + f"Found a {label} at ({pose.position.x:.2f}, {pose.position.y:.2f}). " + "Navigating there now." + ) + + def _scan_in_place(self, selector: Any) -> tuple[ImageDetections2D | None, list[Detection2DBBox]]: + """Rotate slowly in place until the continuous detector yields a match + (or we've turned all the way around). Always stops the robot on exit.""" + deadline = time.time() + SCAN_DURATION + next_log = 0.0 + yaw = Twist( + linear=Vector3(0.0, 0.0, 0.0), angular=Vector3(0.0, 0.0, SCAN_YAW_RATE) + ) + try: + while True: + with self._lock: + detections = self._latest + candidates = selector(detections.detections) if detections is not None else [] + if candidates or time.time() >= deadline: + return detections, candidates + now = time.time() + if now >= next_log: + seen = [d.name for d in detections.detections] if detections else [] + logger.info(f"SeatFinder scan: rotating, currently seeing {seen}") + next_log = now + SCAN_LOG_EVERY + self.cmd_vel.publish(yaw) + time.sleep(SCAN_TICK) + finally: + self.cmd_vel.publish(Twist.zero()) + + def _select_empty_seats(self, detections: list[Detection2DBBox]) -> list[Detection2DBBox]: + seats = [d for d in detections if d.name in SEAT_CLASSES] + persons = [d for d in detections if d.name == "person"] + return [s for s in seats if not self._is_occupied(s, persons)] + + def _select_by_name( + self, detections: list[Detection2DBBox], query: str + ) -> list[Detection2DBBox]: + q = query.lower() + return [d for d in detections if d.name.lower() in q or q in d.name.lower()] + + def _is_occupied(self, seat: Detection2DBBox, persons: list[Detection2DBBox]) -> bool: + sx1, sy1, sx2, sy2 = seat.bbox + seat_area = max(1.0, (sx2 - sx1) * (sy2 - sy1)) + for p in persons: + px1, py1, px2, py2 = p.bbox + iw = max(0.0, min(sx2, px2) - max(sx1, px1)) + ih = max(0.0, min(sy2, py2) - max(sy1, py1)) + if (iw * ih) / seat_area > OCCUPANCY_OVERLAP: + return True + return False + + def _annotate(self, image: Image, detections: list[Detection2DBBox]) -> Image: + img = image.to_opencv().copy() + persons = [d for d in detections if d.name == "person"] + for d in detections: + x1, y1, x2, y2 = (int(v) for v in d.bbox) + if d.name in SEAT_CLASSES: + occupied = self._is_occupied(d, persons) + color = (0, 0, 255) if occupied else (0, 255, 0) + text = f"{d.name} {'occupied' if occupied else 'EMPTY'}" + elif d.name == "person": + color = (255, 0, 0) + text = "person" + else: + color = (150, 150, 150) + text = d.name + cv2.rectangle(img, (x1, y1), (x2, y2), color, 2) + cv2.putText( + img, text, (x1, max(15, y1 - 6)), cv2.FONT_HERSHEY_SIMPLEX, 0.5, color, 2 + ) + return Image.from_opencv(img, ts=image.ts) + + +__all__ = ["SeatFinderSkill"] diff --git a/dimos/agents/skills/seat_planner.py b/dimos/agents/skills/seat_planner.py new file mode 100644 index 0000000000..790121d202 --- /dev/null +++ b/dimos/agents/skills/seat_planner.py @@ -0,0 +1,227 @@ +"""On-demand YOLO empty-seat picker for the manual-map → auto-find demo. + +Flow: + 1. Operator drives the Go2 manually (Rerun click-to-goal or teleop) to build + the voxel map and bring the seats into view. + 2. Operator runs `dimos mcp call find_empty_seat_now` from another terminal. + 3. This skill grabs the latest YOLO detections, picks an unoccupied seat, + projects it to a 3D pose via the world voxel cloud, and publishes the pose + to `goal_request`. A* draws the path; if MovementManager is enabled the + robot walks there. + +YOLO runs continuously so the annotated image is always available in the +viewer. The skill itself never publishes cmd_vel and never rotates. +""" + +from __future__ import annotations + +from threading import RLock +from typing import TYPE_CHECKING, Any + +import cv2 + +import math +import threading + +from reactivex.disposable import Disposable + +from dimos.agents.annotation import skill +from dimos.core.core import rpc +from dimos.core.module import Module, ModuleConfig +from dimos.core.stream import In, Out +from dimos.msgs.geometry_msgs.PoseStamped import PoseStamped +from dimos.msgs.geometry_msgs.Quaternion import Quaternion +from dimos.msgs.geometry_msgs.Vector3 import Vector3, make_vector3 +from dimos.msgs.sensor_msgs.CameraInfo import CameraInfo +from dimos.msgs.sensor_msgs.Image import Image, sharpness_window +from dimos.msgs.sensor_msgs.PointCloud2 import PointCloud2 +from dimos.perception.detection.detectors.yolo import Yolo2DDetector +from dimos.perception.detection.type.detection3d.pointcloud import Detection3DPC +from dimos.utils.logging_config import setup_logger +from dimos.utils.reactive import backpressure + +if TYPE_CHECKING: + from reactivex.abc import DisposableBase + + from dimos.perception.detection.type.detection2d.bbox import Detection2DBBox + from dimos.perception.detection.type.detection2d.imageDetections2D import ImageDetections2D + +logger = setup_logger() + +SEAT_CLASSES = ("chair", "couch", "bench") +OCCUPANCY_OVERLAP = 0.2 + +# Spoken / typed phrases that trigger find_empty_seat_now via /human_input. +# Matched case-insensitively as substrings, so partial Whisper transcriptions +# still fire (e.g. "椅子まで行って" matches "椅子"). +SEAT_TRIGGER_KEYWORDS = ("椅子", "空席", "席まで", "chair", "seat", "vacant") + + +class Config(ModuleConfig): + camera_info: CameraInfo + detect_freq: float = 5.0 # YOLO inference rate on sharpest frame [Hz] + + +class SeatPlanner(Module): + config: Config + + color_image: In[Image] + pointcloud: In[PointCloud2] + human_input: In[str] + goal_request: Out[PoseStamped] + detections_image: Out[Image] + + def __init__(self, **kwargs: Any) -> None: + super().__init__(**kwargs) + self._detector = Yolo2DDetector() + self._latest: ImageDetections2D | None = None + self._lock = RLock() + self._subscription: DisposableBase | None = None + self._voice_busy = threading.Lock() + + @rpc + def start(self) -> None: + super().start() + sharp = backpressure( + sharpness_window(self.config.detect_freq, self.color_image.pure_observable()) + ) + self._subscription = sharp.subscribe( + on_next=self._on_frame, + on_error=lambda e: logger.exception("SeatPlanner detection error", exc_info=e), + ) + self.register_disposable(Disposable(self.human_input.subscribe(self._on_human_input))) + + @rpc + def stop(self) -> None: + if self._subscription is not None: + self._subscription.dispose() + self._subscription = None + super().stop() + + def _on_human_input(self, text: str) -> None: + lower = text.lower() + if not any(kw.lower() in lower for kw in SEAT_TRIGGER_KEYWORDS): + return + if not self._voice_busy.acquire(blocking=False): + logger.info(f"SeatPlanner: voice trigger ignored, already running ({text!r})") + return + logger.info(f"SeatPlanner: voice trigger fired by {text!r}") + threading.Thread(target=self._voice_worker, daemon=True, name="SeatPlannerVoice").start() + + def _voice_worker(self) -> None: + try: + result = self.find_empty_seat_now() + logger.info(f"SeatPlanner: voice trigger result: {result}") + finally: + self._voice_busy.release() + + def _on_frame(self, image: Image) -> None: + detections = self._detector.process_image(image) + with self._lock: + self._latest = detections + self.detections_image.publish(self._annotate(image, detections.detections)) + + @skill + def navigate_to_point(self, x: float, y: float, yaw_deg: float = 0.0) -> str: + """Publish a world-frame goal pose so the A* planner drives the robot there. + + Args: + x: target X in the `map` frame [m] + y: target Y in the `map` frame [m] + yaw_deg: final heading in degrees (0 = +X), default 0 + """ + pose = PoseStamped( + position=make_vector3(float(x), float(y), 0.0), + orientation=Quaternion.from_euler(Vector3(0.0, 0.0, math.radians(float(yaw_deg)))), + frame_id="map", + ) + self.goal_request.publish(pose) + msg = f"Published waypoint goal at ({x:.2f}, {y:.2f}, yaw={yaw_deg:.0f}deg)." + logger.info(f"SeatPlanner: {msg}") + return msg + + @skill + def find_empty_seat_now(self) -> str: + """Pick an empty seat in the current camera view and publish a 3D goal. + + Uses the latest YOLO detections (chair/couch/bench minus person-overlap) + and the world voxel cloud. Returns immediately; downstream A* plans the + path. The robot only walks there if MovementManager is enabled. + """ + with self._lock: + detections = self._latest + + if detections is None: + return "No detections yet — wait a second after the camera comes up." + + seats = [d for d in detections.detections if d.name in SEAT_CLASSES] + persons = [d for d in detections.detections if d.name == "person"] + empty = [s for s in seats if not self._is_occupied(s, persons)] + + seen = [d.name for d in detections.detections] + logger.info( + f"SeatPlanner: seen={seen} seats={len(seats)} persons={len(persons)} empty={len(empty)}" + ) + + if not empty: + return f"No empty seat in view. Saw {len(seats)} seat(s), {len(persons)} person(s)." + + try: + pointcloud = self.pointcloud.get_next(timeout=2.0) + except Exception as e: + return f"Pointcloud unavailable: {e}" + + transform = self.tf.get("camera_optical", pointcloud.frame_id, detections.image.ts, 2.0) + if not transform: + return "Camera transform unavailable — drive the robot a bit then retry." + + best = max(empty, key=lambda d: d.bbox_2d_volume()) + target3d = Detection3DPC.from_2d( + best, + world_pointcloud=pointcloud, + camera_info=self.config.camera_info, + world_to_optical_transform=transform, + ) + if target3d is None: + return "Found an empty seat but 3D projection failed (no cloud points in bbox)." + + pose = target3d.pose + self.goal_request.publish(pose) + msg = f"Published goal at ({pose.position.x:.2f}, {pose.position.y:.2f})." + logger.info(f"SeatPlanner: {msg}") + return msg + + def _is_occupied(self, seat: Detection2DBBox, persons: list[Detection2DBBox]) -> bool: + sx1, sy1, sx2, sy2 = seat.bbox + seat_area = max(1.0, (sx2 - sx1) * (sy2 - sy1)) + for p in persons: + px1, py1, px2, py2 = p.bbox + iw = max(0.0, min(sx2, px2) - max(sx1, px1)) + ih = max(0.0, min(sy2, py2) - max(sy1, py1)) + if (iw * ih) / seat_area > OCCUPANCY_OVERLAP: + return True + return False + + def _annotate(self, image: Image, detections: list[Detection2DBBox]) -> Image: + img = image.to_opencv().copy() + persons = [d for d in detections if d.name == "person"] + for d in detections: + x1, y1, x2, y2 = (int(v) for v in d.bbox) + if d.name in SEAT_CLASSES: + occupied = self._is_occupied(d, persons) + color = (0, 0, 255) if occupied else (0, 255, 0) + text = f"{d.name} {'occupied' if occupied else 'EMPTY'}" + elif d.name == "person": + color = (255, 0, 0) + text = "person" + else: + color = (150, 150, 150) + text = d.name + cv2.rectangle(img, (x1, y1), (x2, y2), color, 2) + cv2.putText( + img, text, (x1, max(15, y1 - 6)), cv2.FONT_HERSHEY_SIMPLEX, 0.5, color, 2 + ) + return Image.from_opencv(img, ts=image.ts) + + +__all__ = ["SeatPlanner"] From e2338a0c615031e80c393af1231b8c621b64db52 Mon Sep 17 00:00:00 2001 From: Ueti999 Date: Thu, 28 May 2026 23:54:55 +0800 Subject: [PATCH 13/16] feat: add Go2 guide and seat-demo agentic blueprints MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit unitree_go2_guide: slim "guide dog" blueprint that leads a leashed person to an empty seat. Drops SecurityModule, SpatialMemory, PerceiveLoopSkill and PersonFollowSkill to keep GPU/compute light; keeps nav stack, agent, voice I/O and SeatFinder. unitree_go2_seat_demo (+ _record, _reuse): manual-map → on-demand YOLO seat-find blueprint variants wired to SeatPlanner via McpServer. The record/reuse variants attach a RecordingModule for capturing and replaying the manual mapping pass. --- .../blueprints/agentic/unitree_go2_guide.py | 66 +++++++++++++++++++ .../agentic/unitree_go2_seat_demo.py | 48 ++++++++++++++ .../agentic/unitree_go2_seat_demo_record.py | 50 ++++++++++++++ .../agentic/unitree_go2_seat_demo_reuse.py | 49 ++++++++++++++ 4 files changed, 213 insertions(+) create mode 100644 dimos/robot/unitree/go2/blueprints/agentic/unitree_go2_guide.py create mode 100644 dimos/robot/unitree/go2/blueprints/agentic/unitree_go2_seat_demo.py create mode 100644 dimos/robot/unitree/go2/blueprints/agentic/unitree_go2_seat_demo_record.py create mode 100644 dimos/robot/unitree/go2/blueprints/agentic/unitree_go2_seat_demo_reuse.py diff --git a/dimos/robot/unitree/go2/blueprints/agentic/unitree_go2_guide.py b/dimos/robot/unitree/go2/blueprints/agentic/unitree_go2_guide.py new file mode 100644 index 0000000000..7a5450ec1d --- /dev/null +++ b/dimos/robot/unitree/go2/blueprints/agentic/unitree_go2_guide.py @@ -0,0 +1,66 @@ +#!/usr/bin/env python3 +# Copyright 2025-2026 Dimensional Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Slim "guide dog" blueprint: navigate a leashed person to an empty seat. + +Trimmed from unitree-go2-agentic to keep GPU/compute light for guiding. Drops +the heavy modules that are unused here: SecurityModule (EdgeTAM, eager CUDA +load), SpatialMemory (CLIP), PerceiveLoopSkill and PersonFollowSkill (we lead, +not follow). Keeps the base nav stack, the agent, voice I/O and SeatFinder. + +SeatFinder runs its own continuous, sharpness-filtered YOLO stream and publishes +an annotated frame on ``/seatfinder/detections`` so the viewer can show it. +""" + +from dimos.agents.mcp.mcp_client import McpClient +from dimos.agents.mcp.mcp_server import McpServer +from dimos.agents.skills.seat_finder import SeatFinderSkill +from dimos.agents.skills.speak_skill import SpeakSkill +from dimos.agents.web_human_input import WebInput +from dimos.core.coordination.blueprints import autoconnect +from dimos.core.transport import LCMTransport +from dimos.msgs.sensor_msgs.Image import Image +from dimos.robot.unitree.go2.blueprints.smart.unitree_go2 import unitree_go2 +from dimos.robot.unitree.go2.connection import GO2Connection +from dimos.robot.unitree.unitree_skill_container import UnitreeSkillContainer + +unitree_go2_guide = ( + autoconnect( + unitree_go2, + McpServer.blueprint(), + McpClient.blueprint(), + SeatFinderSkill.blueprint(camera_info=GO2Connection.camera_info_static), + UnitreeSkillContainer.blueprint(), + WebInput.blueprint(), + SpeakSkill.blueprint(), + ) + .remappings( + [ + # 3D projection needs a world-frame cloud; use the VoxelGrid map + # (the raw GO2 /pointcloud is not populated here), like Detection3D. + (SeatFinderSkill, "pointcloud", "global_map"), + ] + ) + .transports( + { + ("detections_image", SeatFinderSkill): LCMTransport( + "/seatfinder/detections", Image + ), + } + ) + .global_config(n_workers=8, robot_model="unitree_go2") +) + +__all__ = ["unitree_go2_guide"] diff --git a/dimos/robot/unitree/go2/blueprints/agentic/unitree_go2_seat_demo.py b/dimos/robot/unitree/go2/blueprints/agentic/unitree_go2_seat_demo.py new file mode 100644 index 0000000000..6e145c6ad6 --- /dev/null +++ b/dimos/robot/unitree/go2/blueprints/agentic/unitree_go2_seat_demo.py @@ -0,0 +1,48 @@ +#!/usr/bin/env python3 +"""Manual-map → on-demand YOLO seat-find demo (no LLM). + +Operator flow: + 1. Launch this blueprint. Rerun opens with the live camera + global map. + 2. Drive the Go2 manually — click-to-goal on the map in Rerun, or use + keyboard teleop — to explore and build the voxel map. + 3. From another terminal, trigger detection on demand: + dimos mcp call find_empty_seat_now + SeatPlanner picks an empty seat in the current view, projects it to 3D, + and publishes goal_request. A* draws the path and (if MovementManager is + enabled) the robot walks there. + +McpServer exposes the @skill over HTTP; McpClient (LLM agent) is not included. +""" + +from dimos.agents.mcp.mcp_server import McpServer +from dimos.agents.skills.seat_planner import SeatPlanner +from dimos.agents.web_human_input import WebInput +from dimos.core.coordination.blueprints import autoconnect +from dimos.core.transport import LCMTransport +from dimos.msgs.sensor_msgs.Image import Image +from dimos.robot.unitree.go2.blueprints.smart.unitree_go2 import unitree_go2 +from dimos.robot.unitree.go2.connection import GO2Connection + +unitree_go2_seat_demo = ( + autoconnect( + unitree_go2, + McpServer.blueprint(), + WebInput.blueprint(), + SeatPlanner.blueprint(camera_info=GO2Connection.camera_info_static), + ) + .remappings( + [ + (SeatPlanner, "pointcloud", "global_map"), + ] + ) + .transports( + { + ("detections_image", SeatPlanner): LCMTransport( + "/seatplanner/detections", Image + ), + } + ) + .global_config(n_workers=6, robot_model="unitree_go2") +) + +__all__ = ["unitree_go2_seat_demo"] diff --git a/dimos/robot/unitree/go2/blueprints/agentic/unitree_go2_seat_demo_record.py b/dimos/robot/unitree/go2/blueprints/agentic/unitree_go2_seat_demo_record.py new file mode 100644 index 0000000000..a472f7d71d --- /dev/null +++ b/dimos/robot/unitree/go2/blueprints/agentic/unitree_go2_seat_demo_record.py @@ -0,0 +1,50 @@ +#!/usr/bin/env python3 +"""Seat-demo + recording: makes a reusable map while you map manually. + +Same skills as `unitree-go2-seat-demo` but adds Go2Memory recording on top +of the plain smart stack — *without* MarkerTfModule so we don't get TF spam +from missing AprilTags. LiDAR/odom/color get written to `recording_go2.db` +for later premap export. + +Operator flow (paired with `unitree-go2-seat-demo-reuse`): + 1. dimos run unitree-go2-seat-demo-record + → manually click-to-goal in Rerun and walk the robot over the area. + 2. Ctrl+C to stop (recording is flushed to recording_go2.db in the cwd). + 3. dimos export-premap recording_go2 + → produces data/recording_go2_twopass_map.pc2.lcm + 4. Next session: use `unitree-go2-seat-demo-reuse` with that premap. +""" + +from dimos.agents.mcp.mcp_server import McpServer +from dimos.agents.skills.seat_planner import SeatPlanner +from dimos.agents.web_human_input import WebInput +from dimos.core.coordination.blueprints import autoconnect +from dimos.core.transport import LCMTransport +from dimos.msgs.sensor_msgs.Image import Image +from dimos.robot.unitree.go2.blueprints.smart.unitree_go2 import Go2Memory, unitree_go2 +from dimos.robot.unitree.go2.connection import GO2Connection + +unitree_go2_seat_demo_record = ( + autoconnect( + unitree_go2, + Go2Memory.blueprint(), + McpServer.blueprint(), + WebInput.blueprint(), + SeatPlanner.blueprint(camera_info=GO2Connection.camera_info_static), + ) + .remappings( + [ + (SeatPlanner, "pointcloud", "global_map"), + ] + ) + .transports( + { + ("detections_image", SeatPlanner): LCMTransport( + "/seatplanner/detections", Image + ), + } + ) + .global_config(n_workers=8, robot_model="unitree_go2") +) + +__all__ = ["unitree_go2_seat_demo_record"] diff --git a/dimos/robot/unitree/go2/blueprints/agentic/unitree_go2_seat_demo_reuse.py b/dimos/robot/unitree/go2/blueprints/agentic/unitree_go2_seat_demo_reuse.py new file mode 100644 index 0000000000..279433fa41 --- /dev/null +++ b/dimos/robot/unitree/go2/blueprints/agentic/unitree_go2_seat_demo_reuse.py @@ -0,0 +1,49 @@ +#!/usr/bin/env python3 +"""Seat-demo on top of a previously-recorded premap. + +Same skills as `unitree-go2-seat-demo` but layered on `unitree_go2_relocalization` +so a saved premap is loaded and the live scan gets ICP-aligned to it. Once +relocalize succeeds you can navigate by map-frame coordinates without +re-walking the room. + +Operator flow: + 1. dimos run unitree-go2-seat-demo-reuse \\ + -o relocalizationmodule.map_file= \\ + -o relocalizationmodule.publish_loaded_map=true + 2. Wait for `relocalize: fitness=... TF 'world' -> 'map' published` in the log. + 3. Click-to-goal in Rerun / dimos mcp call navigate_to_point / voice + "椅子まで行って" — same as the no-map demo. +""" + +from dimos.agents.mcp.mcp_server import McpServer +from dimos.agents.skills.seat_planner import SeatPlanner +from dimos.agents.web_human_input import WebInput +from dimos.core.coordination.blueprints import autoconnect +from dimos.core.transport import LCMTransport +from dimos.msgs.sensor_msgs.Image import Image +from dimos.robot.unitree.go2.blueprints.smart.unitree_go2 import unitree_go2_relocalization +from dimos.robot.unitree.go2.connection import GO2Connection + +unitree_go2_seat_demo_reuse = ( + autoconnect( + unitree_go2_relocalization, + McpServer.blueprint(), + WebInput.blueprint(), + SeatPlanner.blueprint(camera_info=GO2Connection.camera_info_static), + ) + .remappings( + [ + (SeatPlanner, "pointcloud", "global_map"), + ] + ) + .transports( + { + ("detections_image", SeatPlanner): LCMTransport( + "/seatplanner/detections", Image + ), + } + ) + .global_config(n_workers=8, robot_model="unitree_go2") +) + +__all__ = ["unitree_go2_seat_demo_reuse"] From cf149ceed56c69002fc6c18b9b62f67e634cdf30 Mon Sep 17 00:00:00 2001 From: Ueti999 Date: Thu, 28 May 2026 23:55:12 +0800 Subject: [PATCH 14/16] feat: add seat_check_webcam standalone YOLO test script MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Webcam-only test for the empty-seat logic — no robot needed. Mirrors SeatFinderSkill's detection (YOLO chairs/couches + people, occupancy via person-box overlap) and renders an annotated overlay: green = empty seat, red = occupied seat, blue = person. Usage: .venv/bin/python seat_check_webcam.py [--camera 0] --- seat_check_webcam.py | 98 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 98 insertions(+) create mode 100644 seat_check_webcam.py diff --git a/seat_check_webcam.py b/seat_check_webcam.py new file mode 100644 index 0000000000..138d80bc83 --- /dev/null +++ b/seat_check_webcam.py @@ -0,0 +1,98 @@ +#!/usr/bin/env python3 +"""Standalone webcam test for the YOLO empty-seat logic (no robot needed). + +Mirrors SeatFinderSkill's detection: YOLO detects chairs/couches and people, +then a seat is "occupied" if a person box overlaps it past a threshold. + +Usage: + .venv/bin/python seat_check_webcam.py [--camera 0] + +Overlay: + green box = empty seat + red box = occupied seat + blue box = person + gray box = other detected object +Keys: q / ESC to quit. +""" + +from __future__ import annotations + +import argparse + +import cv2 + +from dimos.msgs.sensor_msgs.Image import Image +from dimos.perception.detection.detectors.yolo import Yolo2DDetector + +SEAT_CLASSES = ("chair", "couch", "bench") +OCCUPANCY_OVERLAP = 0.2 + + +def is_occupied(seat, persons) -> bool: + sx1, sy1, sx2, sy2 = seat.bbox + seat_area = max(1.0, (sx2 - sx1) * (sy2 - sy1)) + for p in persons: + px1, py1, px2, py2 = p.bbox + iw = max(0.0, min(sx2, px2) - max(sx1, px1)) + ih = max(0.0, min(sy2, py2) - max(sy1, py1)) + if (iw * ih) / seat_area > OCCUPANCY_OVERLAP: + return True + return False + + +def main() -> None: + parser = argparse.ArgumentParser() + parser.add_argument("--camera", type=int, default=0, help="webcam index") + args = parser.parse_args() + + print("Loading YOLO...") + detector = Yolo2DDetector() + + cap = cv2.VideoCapture(args.camera) + if not cap.isOpened(): + raise SystemExit(f"Could not open camera index {args.camera}") + print("Running. green=empty seat, red=occupied, blue=person. q/ESC to quit.") + + while True: + ok, frame = cap.read() + if not ok: + break + + detections = detector.process_image(Image.from_opencv(frame)).detections + persons = [d for d in detections if d.name == "person"] + seats = [d for d in detections if d.name in SEAT_CLASSES] + + empty = 0 + for d in detections: + x1, y1, x2, y2 = (int(v) for v in d.bbox) + if d.name in SEAT_CLASSES: + occupied = is_occupied(d, persons) + color = (0, 0, 255) if occupied else (0, 255, 0) + label = f"{d.name} {'OCCUPIED' if occupied else 'EMPTY'} {d.confidence:.2f}" + empty += 0 if occupied else 1 + elif d.name == "person": + color = (255, 0, 0) + label = f"person {d.confidence:.2f}" + else: + color = (150, 150, 150) + label = f"{d.name} {d.confidence:.2f}" + cv2.rectangle(frame, (x1, y1), (x2, y2), color, 2) + cv2.putText( + frame, label, (x1, max(15, y1 - 6)), + cv2.FONT_HERSHEY_SIMPLEX, 0.5, color, 2, + ) + + cv2.putText( + frame, f"seats={len(seats)} empty={empty} persons={len(persons)}", + (10, 25), cv2.FONT_HERSHEY_SIMPLEX, 0.7, (255, 255, 0), 2, + ) + cv2.imshow("YOLO empty-seat check", frame) + if cv2.waitKey(1) & 0xFF in (ord("q"), 27): + break + + cap.release() + cv2.destroyAllWindows() + + +if __name__ == "__main__": + main() From f3d9e39c35620683c5892ffa8f446323edf064eb Mon Sep 17 00:00:00 2001 From: Ueti999 Date: Thu, 28 May 2026 23:55:56 +0800 Subject: [PATCH 15/16] chore: add seat-finder debug captures 39 annotated YOLO frames (~7MB) from seat_check_webcam.py and the live SeatFinder runs, kept for repro and regression debugging of the empty-seat detection logic. --- .../20260527_172117_836311_water_bottle_detected.jpg | 3 +++ .../20260527_172117_836311_water_bottle_input.jpg | 3 +++ .../20260527_172437_391512_empty_seat_detected.jpg | 3 +++ seat_finder_debug/20260527_172437_391512_empty_seat_input.jpg | 3 +++ seat_finder_debug/20260527_180550_283951_detected.jpg | 3 +++ seat_finder_debug/20260527_180550_439986_detected.jpg | 3 +++ seat_finder_debug/20260527_180550_833871_detected.jpg | 3 +++ seat_finder_debug/20260527_180550_910656_detected.jpg | 3 +++ seat_finder_debug/20260527_180551_048564_detected.jpg | 3 +++ seat_finder_debug/20260527_180807_743520_detected.jpg | 3 +++ seat_finder_debug/20260527_180807_879260_detected.jpg | 3 +++ seat_finder_debug/20260527_180808_033034_detected.jpg | 3 +++ seat_finder_debug/20260527_180808_151100_detected.jpg | 3 +++ seat_finder_debug/20260527_180808_309451_detected.jpg | 3 +++ seat_finder_debug/20260527_180853_002112_detected.jpg | 3 +++ seat_finder_debug/20260527_180853_215366_detected.jpg | 3 +++ seat_finder_debug/20260527_180853_383793_detected.jpg | 3 +++ seat_finder_debug/20260527_180853_608283_detected.jpg | 3 +++ seat_finder_debug/20260527_180853_840790_detected.jpg | 3 +++ seat_finder_debug/20260527_180925_389559_detected.jpg | 3 +++ seat_finder_debug/20260527_180925_531399_detected.jpg | 3 +++ seat_finder_debug/20260527_180925_676604_detected.jpg | 3 +++ seat_finder_debug/20260527_180925_798203_detected.jpg | 3 +++ seat_finder_debug/20260527_180925_935687_detected.jpg | 3 +++ seat_finder_debug/20260527_180959_909873_detected.jpg | 3 +++ seat_finder_debug/20260527_181000_036868_detected.jpg | 3 +++ seat_finder_debug/20260527_181000_194737_detected.jpg | 3 +++ seat_finder_debug/20260527_181000_313897_detected.jpg | 3 +++ seat_finder_debug/20260527_181000_405459_detected.jpg | 3 +++ seat_finder_debug/20260527_181034_779314_detected.jpg | 3 +++ seat_finder_debug/20260527_181034_892697_detected.jpg | 3 +++ seat_finder_debug/20260527_181034_979220_detected.jpg | 3 +++ seat_finder_debug/20260527_181035_118276_detected.jpg | 3 +++ seat_finder_debug/20260527_181035_248860_detected.jpg | 3 +++ seat_finder_debug/20260527_181139_388855_detected.jpg | 3 +++ seat_finder_debug/20260527_181139_490821_detected.jpg | 3 +++ seat_finder_debug/20260527_181139_629386_detected.jpg | 3 +++ seat_finder_debug/20260527_181139_796071_detected.jpg | 3 +++ seat_finder_debug/20260527_181139_908647_detected.jpg | 3 +++ 39 files changed, 117 insertions(+) create mode 100644 seat_finder_debug/20260527_172117_836311_water_bottle_detected.jpg create mode 100644 seat_finder_debug/20260527_172117_836311_water_bottle_input.jpg create mode 100644 seat_finder_debug/20260527_172437_391512_empty_seat_detected.jpg create mode 100644 seat_finder_debug/20260527_172437_391512_empty_seat_input.jpg create mode 100644 seat_finder_debug/20260527_180550_283951_detected.jpg create mode 100644 seat_finder_debug/20260527_180550_439986_detected.jpg create mode 100644 seat_finder_debug/20260527_180550_833871_detected.jpg create mode 100644 seat_finder_debug/20260527_180550_910656_detected.jpg create mode 100644 seat_finder_debug/20260527_180551_048564_detected.jpg create mode 100644 seat_finder_debug/20260527_180807_743520_detected.jpg create mode 100644 seat_finder_debug/20260527_180807_879260_detected.jpg create mode 100644 seat_finder_debug/20260527_180808_033034_detected.jpg create mode 100644 seat_finder_debug/20260527_180808_151100_detected.jpg create mode 100644 seat_finder_debug/20260527_180808_309451_detected.jpg create mode 100644 seat_finder_debug/20260527_180853_002112_detected.jpg create mode 100644 seat_finder_debug/20260527_180853_215366_detected.jpg create mode 100644 seat_finder_debug/20260527_180853_383793_detected.jpg create mode 100644 seat_finder_debug/20260527_180853_608283_detected.jpg create mode 100644 seat_finder_debug/20260527_180853_840790_detected.jpg create mode 100644 seat_finder_debug/20260527_180925_389559_detected.jpg create mode 100644 seat_finder_debug/20260527_180925_531399_detected.jpg create mode 100644 seat_finder_debug/20260527_180925_676604_detected.jpg create mode 100644 seat_finder_debug/20260527_180925_798203_detected.jpg create mode 100644 seat_finder_debug/20260527_180925_935687_detected.jpg create mode 100644 seat_finder_debug/20260527_180959_909873_detected.jpg create mode 100644 seat_finder_debug/20260527_181000_036868_detected.jpg create mode 100644 seat_finder_debug/20260527_181000_194737_detected.jpg create mode 100644 seat_finder_debug/20260527_181000_313897_detected.jpg create mode 100644 seat_finder_debug/20260527_181000_405459_detected.jpg create mode 100644 seat_finder_debug/20260527_181034_779314_detected.jpg create mode 100644 seat_finder_debug/20260527_181034_892697_detected.jpg create mode 100644 seat_finder_debug/20260527_181034_979220_detected.jpg create mode 100644 seat_finder_debug/20260527_181035_118276_detected.jpg create mode 100644 seat_finder_debug/20260527_181035_248860_detected.jpg create mode 100644 seat_finder_debug/20260527_181139_388855_detected.jpg create mode 100644 seat_finder_debug/20260527_181139_490821_detected.jpg create mode 100644 seat_finder_debug/20260527_181139_629386_detected.jpg create mode 100644 seat_finder_debug/20260527_181139_796071_detected.jpg create mode 100644 seat_finder_debug/20260527_181139_908647_detected.jpg diff --git a/seat_finder_debug/20260527_172117_836311_water_bottle_detected.jpg b/seat_finder_debug/20260527_172117_836311_water_bottle_detected.jpg new file mode 100644 index 0000000000..0dfa99258d --- /dev/null +++ b/seat_finder_debug/20260527_172117_836311_water_bottle_detected.jpg @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:7299717a08a4cd01f88ea0ccdd4dc336c094598d643613ea0c2f66033f2f211b +size 230544 diff --git a/seat_finder_debug/20260527_172117_836311_water_bottle_input.jpg b/seat_finder_debug/20260527_172117_836311_water_bottle_input.jpg new file mode 100644 index 0000000000..0dfa99258d --- /dev/null +++ b/seat_finder_debug/20260527_172117_836311_water_bottle_input.jpg @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:7299717a08a4cd01f88ea0ccdd4dc336c094598d643613ea0c2f66033f2f211b +size 230544 diff --git a/seat_finder_debug/20260527_172437_391512_empty_seat_detected.jpg b/seat_finder_debug/20260527_172437_391512_empty_seat_detected.jpg new file mode 100644 index 0000000000..e06aee3309 --- /dev/null +++ b/seat_finder_debug/20260527_172437_391512_empty_seat_detected.jpg @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:c4500d132d42f2dd2f31e67ee0684d97c6bf2e71c4b96d690fb79ac6e367f122 +size 159453 diff --git a/seat_finder_debug/20260527_172437_391512_empty_seat_input.jpg b/seat_finder_debug/20260527_172437_391512_empty_seat_input.jpg new file mode 100644 index 0000000000..e06aee3309 --- /dev/null +++ b/seat_finder_debug/20260527_172437_391512_empty_seat_input.jpg @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:c4500d132d42f2dd2f31e67ee0684d97c6bf2e71c4b96d690fb79ac6e367f122 +size 159453 diff --git a/seat_finder_debug/20260527_180550_283951_detected.jpg b/seat_finder_debug/20260527_180550_283951_detected.jpg new file mode 100644 index 0000000000..642821c3b9 --- /dev/null +++ b/seat_finder_debug/20260527_180550_283951_detected.jpg @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:22834cf5ae5c674db68f8b292cb0dfdab75ba694adc5142af14fbb156625981e +size 144616 diff --git a/seat_finder_debug/20260527_180550_439986_detected.jpg b/seat_finder_debug/20260527_180550_439986_detected.jpg new file mode 100644 index 0000000000..022c56554a --- /dev/null +++ b/seat_finder_debug/20260527_180550_439986_detected.jpg @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:1babeec2683a01d378bf791b612ec57dae6e762c36b20aad92c01f19fcd9be4d +size 144330 diff --git a/seat_finder_debug/20260527_180550_833871_detected.jpg b/seat_finder_debug/20260527_180550_833871_detected.jpg new file mode 100644 index 0000000000..405d81d707 --- /dev/null +++ b/seat_finder_debug/20260527_180550_833871_detected.jpg @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:77f8d3e485f41f9b1a88d2e37db2cfbba928689ec5d9419c68a6c92ca4993c05 +size 144568 diff --git a/seat_finder_debug/20260527_180550_910656_detected.jpg b/seat_finder_debug/20260527_180550_910656_detected.jpg new file mode 100644 index 0000000000..dc98e6e8ac --- /dev/null +++ b/seat_finder_debug/20260527_180550_910656_detected.jpg @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:f98c3bd61594a9a4f2c173d8c82c3feb3c1b1974d5b9927446af380f9dd7f6cd +size 144961 diff --git a/seat_finder_debug/20260527_180551_048564_detected.jpg b/seat_finder_debug/20260527_180551_048564_detected.jpg new file mode 100644 index 0000000000..25af882004 --- /dev/null +++ b/seat_finder_debug/20260527_180551_048564_detected.jpg @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:2c334cb9ad288095630cd8d50107bf6839267fede1147aa139e1cc675d1a2d2f +size 144857 diff --git a/seat_finder_debug/20260527_180807_743520_detected.jpg b/seat_finder_debug/20260527_180807_743520_detected.jpg new file mode 100644 index 0000000000..2f7142b668 --- /dev/null +++ b/seat_finder_debug/20260527_180807_743520_detected.jpg @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:3d97dec8f0de1e69114eb7b4d285bb0ac9c384e7cac8cbf8e33405de22a18fe2 +size 184062 diff --git a/seat_finder_debug/20260527_180807_879260_detected.jpg b/seat_finder_debug/20260527_180807_879260_detected.jpg new file mode 100644 index 0000000000..51f52ec661 --- /dev/null +++ b/seat_finder_debug/20260527_180807_879260_detected.jpg @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:2e3d05a36d5765e709a9be41630acfc4dac6a36c32061d28f5726583222dbc0d +size 184226 diff --git a/seat_finder_debug/20260527_180808_033034_detected.jpg b/seat_finder_debug/20260527_180808_033034_detected.jpg new file mode 100644 index 0000000000..b68a562097 --- /dev/null +++ b/seat_finder_debug/20260527_180808_033034_detected.jpg @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:026c4f21ce56b94dbb7d9043edb563f41f91604c5af95606be0951b3c387aa0d +size 185774 diff --git a/seat_finder_debug/20260527_180808_151100_detected.jpg b/seat_finder_debug/20260527_180808_151100_detected.jpg new file mode 100644 index 0000000000..e78d84dadf --- /dev/null +++ b/seat_finder_debug/20260527_180808_151100_detected.jpg @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:6d8cef0e66a7ff061e7f6d12bc84226431ba6946a570cc161cd99dd0788e1548 +size 184764 diff --git a/seat_finder_debug/20260527_180808_309451_detected.jpg b/seat_finder_debug/20260527_180808_309451_detected.jpg new file mode 100644 index 0000000000..0b3ec88e02 --- /dev/null +++ b/seat_finder_debug/20260527_180808_309451_detected.jpg @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:62c23b625ac5005d9c86df507aea81b77b9bd5fa9f591094cca6056d7ad17338 +size 186031 diff --git a/seat_finder_debug/20260527_180853_002112_detected.jpg b/seat_finder_debug/20260527_180853_002112_detected.jpg new file mode 100644 index 0000000000..5d24516815 --- /dev/null +++ b/seat_finder_debug/20260527_180853_002112_detected.jpg @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:b860a815ddcac151ea01255759b66acb455ee42cb753ac6238621344181f5e98 +size 192513 diff --git a/seat_finder_debug/20260527_180853_215366_detected.jpg b/seat_finder_debug/20260527_180853_215366_detected.jpg new file mode 100644 index 0000000000..156527b142 --- /dev/null +++ b/seat_finder_debug/20260527_180853_215366_detected.jpg @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:2b944d6ee0cb2dfa9d4895138c4ddd1f0373bbc8fd5140ca11d325cb963328b8 +size 194357 diff --git a/seat_finder_debug/20260527_180853_383793_detected.jpg b/seat_finder_debug/20260527_180853_383793_detected.jpg new file mode 100644 index 0000000000..65d30ed79b --- /dev/null +++ b/seat_finder_debug/20260527_180853_383793_detected.jpg @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:642bd97f7e0e6087cfa0c1e6e016b5b1db49e3ea1eaf1535946ef4e87279dbca +size 195924 diff --git a/seat_finder_debug/20260527_180853_608283_detected.jpg b/seat_finder_debug/20260527_180853_608283_detected.jpg new file mode 100644 index 0000000000..936398f714 --- /dev/null +++ b/seat_finder_debug/20260527_180853_608283_detected.jpg @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:a4630943da451be20c0322c00d7cb456bc8879194640f6d78d7ae699f6d28415 +size 196322 diff --git a/seat_finder_debug/20260527_180853_840790_detected.jpg b/seat_finder_debug/20260527_180853_840790_detected.jpg new file mode 100644 index 0000000000..ae10849298 --- /dev/null +++ b/seat_finder_debug/20260527_180853_840790_detected.jpg @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:e3eaead2609ec1009ed94c68029a5574d361ee905a22ee15ef920fd5562e3d32 +size 197316 diff --git a/seat_finder_debug/20260527_180925_389559_detected.jpg b/seat_finder_debug/20260527_180925_389559_detected.jpg new file mode 100644 index 0000000000..4c3f7b0fee --- /dev/null +++ b/seat_finder_debug/20260527_180925_389559_detected.jpg @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:b5ccb4bd24399391e319afb0122e225680deddd78e69883d56ece90daa04a084 +size 179811 diff --git a/seat_finder_debug/20260527_180925_531399_detected.jpg b/seat_finder_debug/20260527_180925_531399_detected.jpg new file mode 100644 index 0000000000..a71e72cb44 --- /dev/null +++ b/seat_finder_debug/20260527_180925_531399_detected.jpg @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:3fc0956ff2e60f76a9c261ba50ca1796c16a068b20ffc55691f94cae5d27bc31 +size 179894 diff --git a/seat_finder_debug/20260527_180925_676604_detected.jpg b/seat_finder_debug/20260527_180925_676604_detected.jpg new file mode 100644 index 0000000000..3d1f0fb800 --- /dev/null +++ b/seat_finder_debug/20260527_180925_676604_detected.jpg @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:a0d9a5328485b4f1f76f926d820bdc30c4951e4bb7638be6edcbb0397c70e66b +size 180541 diff --git a/seat_finder_debug/20260527_180925_798203_detected.jpg b/seat_finder_debug/20260527_180925_798203_detected.jpg new file mode 100644 index 0000000000..2727977915 --- /dev/null +++ b/seat_finder_debug/20260527_180925_798203_detected.jpg @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:e6489dce09b6e5e5e890dbaaabcfd4d51264bb1c7bfd6a139a3871d0b144a5ca +size 180880 diff --git a/seat_finder_debug/20260527_180925_935687_detected.jpg b/seat_finder_debug/20260527_180925_935687_detected.jpg new file mode 100644 index 0000000000..d523c40f67 --- /dev/null +++ b/seat_finder_debug/20260527_180925_935687_detected.jpg @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:d9bd522c6b00ad9f9a81610a11bc8a855dda93303cee40c6a46a2f7d02b6b8fb +size 181045 diff --git a/seat_finder_debug/20260527_180959_909873_detected.jpg b/seat_finder_debug/20260527_180959_909873_detected.jpg new file mode 100644 index 0000000000..4caf7058f4 --- /dev/null +++ b/seat_finder_debug/20260527_180959_909873_detected.jpg @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:66e189c6c88c698afb3f6ef9b1e719bb8cfceb3d5d6443d0fd068104b4dd8156 +size 184034 diff --git a/seat_finder_debug/20260527_181000_036868_detected.jpg b/seat_finder_debug/20260527_181000_036868_detected.jpg new file mode 100644 index 0000000000..c9eda8e5c8 --- /dev/null +++ b/seat_finder_debug/20260527_181000_036868_detected.jpg @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:afa05136e7f99f7bb4043bab13f59b9b20e581e65a8f93b3e66102f0d08c5208 +size 181715 diff --git a/seat_finder_debug/20260527_181000_194737_detected.jpg b/seat_finder_debug/20260527_181000_194737_detected.jpg new file mode 100644 index 0000000000..059195267d --- /dev/null +++ b/seat_finder_debug/20260527_181000_194737_detected.jpg @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:3b9f67cc51feea782fd14aecfb076890537428b325cdc76de00e599a6c19fda3 +size 181566 diff --git a/seat_finder_debug/20260527_181000_313897_detected.jpg b/seat_finder_debug/20260527_181000_313897_detected.jpg new file mode 100644 index 0000000000..28cf09b14b --- /dev/null +++ b/seat_finder_debug/20260527_181000_313897_detected.jpg @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:19ef72048d0146c2dd11056d44b8acad9621e1efc06aa4fbb2070ec90bd6a4ee +size 183921 diff --git a/seat_finder_debug/20260527_181000_405459_detected.jpg b/seat_finder_debug/20260527_181000_405459_detected.jpg new file mode 100644 index 0000000000..895a47ef49 --- /dev/null +++ b/seat_finder_debug/20260527_181000_405459_detected.jpg @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:c192c3a3bd7714fece293b96b4e2b0c52a17552e160d48743d4c78cc4f9d93c9 +size 181575 diff --git a/seat_finder_debug/20260527_181034_779314_detected.jpg b/seat_finder_debug/20260527_181034_779314_detected.jpg new file mode 100644 index 0000000000..6995524a87 --- /dev/null +++ b/seat_finder_debug/20260527_181034_779314_detected.jpg @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:973552949fcab196179242305437e06ba0dfa4e125cb57ede523f507c46a338c +size 184046 diff --git a/seat_finder_debug/20260527_181034_892697_detected.jpg b/seat_finder_debug/20260527_181034_892697_detected.jpg new file mode 100644 index 0000000000..9379fb6c1f --- /dev/null +++ b/seat_finder_debug/20260527_181034_892697_detected.jpg @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:6d0f4cf3169aee54672e24d2ce44984e5f40d079d4c4c9e7a76a1d6f6529c7d3 +size 184045 diff --git a/seat_finder_debug/20260527_181034_979220_detected.jpg b/seat_finder_debug/20260527_181034_979220_detected.jpg new file mode 100644 index 0000000000..dfa7b55788 --- /dev/null +++ b/seat_finder_debug/20260527_181034_979220_detected.jpg @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:d9f19afa577d8337e51cc7d4563a2f0743e6d4a8ccd306cc03164cc71f8cc9e9 +size 183739 diff --git a/seat_finder_debug/20260527_181035_118276_detected.jpg b/seat_finder_debug/20260527_181035_118276_detected.jpg new file mode 100644 index 0000000000..774f324297 --- /dev/null +++ b/seat_finder_debug/20260527_181035_118276_detected.jpg @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:58bebde1638df7a6e732b51029829da3b4810f09fee6d309426d92a38e01f500 +size 183985 diff --git a/seat_finder_debug/20260527_181035_248860_detected.jpg b/seat_finder_debug/20260527_181035_248860_detected.jpg new file mode 100644 index 0000000000..6736ed72ce --- /dev/null +++ b/seat_finder_debug/20260527_181035_248860_detected.jpg @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:fa42927b571ed1f6751576ebfda8e2c049ab478903d2754a8536a828c479be19 +size 184570 diff --git a/seat_finder_debug/20260527_181139_388855_detected.jpg b/seat_finder_debug/20260527_181139_388855_detected.jpg new file mode 100644 index 0000000000..b19f7575d7 --- /dev/null +++ b/seat_finder_debug/20260527_181139_388855_detected.jpg @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:4e40bdb6d737be74f177978ddf64a25b60cebcaa983c60a25de22a094167fed8 +size 301734 diff --git a/seat_finder_debug/20260527_181139_490821_detected.jpg b/seat_finder_debug/20260527_181139_490821_detected.jpg new file mode 100644 index 0000000000..b3323c7644 --- /dev/null +++ b/seat_finder_debug/20260527_181139_490821_detected.jpg @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:a75c93f29525d08758c609a2e6025eec9d8013d62481aaf804f485cb8132dbf9 +size 259527 diff --git a/seat_finder_debug/20260527_181139_629386_detected.jpg b/seat_finder_debug/20260527_181139_629386_detected.jpg new file mode 100644 index 0000000000..0d03f8787b --- /dev/null +++ b/seat_finder_debug/20260527_181139_629386_detected.jpg @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:b2ff36c7fdbc06de1839f01d9ef393c240a0c8a05f1bbf8628ecb0b1c9aff32d +size 237124 diff --git a/seat_finder_debug/20260527_181139_796071_detected.jpg b/seat_finder_debug/20260527_181139_796071_detected.jpg new file mode 100644 index 0000000000..85b89b466f --- /dev/null +++ b/seat_finder_debug/20260527_181139_796071_detected.jpg @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:a2eadf86cee65404e1dd12d5265c6f6ff448128c69a4677f7ec72872044cc10b +size 222092 diff --git a/seat_finder_debug/20260527_181139_908647_detected.jpg b/seat_finder_debug/20260527_181139_908647_detected.jpg new file mode 100644 index 0000000000..d92e654183 --- /dev/null +++ b/seat_finder_debug/20260527_181139_908647_detected.jpg @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:e56ddf46611bb16be48f6e4df6dccd687af14c40211802e9013590b2b5cde1c1 +size 217439 From 5f65d1044859cf0cddfffa5cfeb33cf9f5345d84 Mon Sep 17 00:00:00 2001 From: Ernest Date: Fri, 29 May 2026 11:56:49 +0800 Subject: [PATCH 16/16] chore: clean up seat finder branch artifacts --- ...eck_webcam.py => demo_seat_check_webcam.py | 16 ++++++++++++++- dimos/agents/skills/seat_finder.py | 2 +- dimos/agents/skills/seat_planner.py | 20 +++++++++++++++---- dimos/robot/all_blueprints.py | 6 ++++++ .../agentic/unitree_go2_seat_demo.py | 14 +++++++++++++ .../agentic/unitree_go2_seat_demo_record.py | 14 +++++++++++++ .../agentic/unitree_go2_seat_demo_reuse.py | 14 +++++++++++++ ...27_172117_836311_water_bottle_detected.jpg | 3 --- ...60527_172117_836311_water_bottle_input.jpg | 3 --- ...0527_172437_391512_empty_seat_detected.jpg | 3 --- ...0260527_172437_391512_empty_seat_input.jpg | 3 --- .../20260527_180550_283951_detected.jpg | 3 --- .../20260527_180550_439986_detected.jpg | 3 --- .../20260527_180550_833871_detected.jpg | 3 --- .../20260527_180550_910656_detected.jpg | 3 --- .../20260527_180551_048564_detected.jpg | 3 --- .../20260527_180807_743520_detected.jpg | 3 --- .../20260527_180807_879260_detected.jpg | 3 --- .../20260527_180808_033034_detected.jpg | 3 --- .../20260527_180808_151100_detected.jpg | 3 --- .../20260527_180808_309451_detected.jpg | 3 --- .../20260527_180853_002112_detected.jpg | 3 --- .../20260527_180853_215366_detected.jpg | 3 --- .../20260527_180853_383793_detected.jpg | 3 --- .../20260527_180853_608283_detected.jpg | 3 --- .../20260527_180853_840790_detected.jpg | 3 --- .../20260527_180925_389559_detected.jpg | 3 --- .../20260527_180925_531399_detected.jpg | 3 --- .../20260527_180925_676604_detected.jpg | 3 --- .../20260527_180925_798203_detected.jpg | 3 --- .../20260527_180925_935687_detected.jpg | 3 --- .../20260527_180959_909873_detected.jpg | 3 --- .../20260527_181000_036868_detected.jpg | 3 --- .../20260527_181000_194737_detected.jpg | 3 --- .../20260527_181000_313897_detected.jpg | 3 --- .../20260527_181000_405459_detected.jpg | 3 --- .../20260527_181034_779314_detected.jpg | 3 --- .../20260527_181034_892697_detected.jpg | 3 --- .../20260527_181034_979220_detected.jpg | 3 --- .../20260527_181035_118276_detected.jpg | 3 --- .../20260527_181035_248860_detected.jpg | 3 --- .../20260527_181139_388855_detected.jpg | 3 --- .../20260527_181139_490821_detected.jpg | 3 --- .../20260527_181139_629386_detected.jpg | 3 --- .../20260527_181139_796071_detected.jpg | 3 --- .../20260527_181139_908647_detected.jpg | 3 --- 46 files changed, 80 insertions(+), 123 deletions(-) rename seat_check_webcam.py => demo_seat_check_webcam.py (82%) delete mode 100644 seat_finder_debug/20260527_172117_836311_water_bottle_detected.jpg delete mode 100644 seat_finder_debug/20260527_172117_836311_water_bottle_input.jpg delete mode 100644 seat_finder_debug/20260527_172437_391512_empty_seat_detected.jpg delete mode 100644 seat_finder_debug/20260527_172437_391512_empty_seat_input.jpg delete mode 100644 seat_finder_debug/20260527_180550_283951_detected.jpg delete mode 100644 seat_finder_debug/20260527_180550_439986_detected.jpg delete mode 100644 seat_finder_debug/20260527_180550_833871_detected.jpg delete mode 100644 seat_finder_debug/20260527_180550_910656_detected.jpg delete mode 100644 seat_finder_debug/20260527_180551_048564_detected.jpg delete mode 100644 seat_finder_debug/20260527_180807_743520_detected.jpg delete mode 100644 seat_finder_debug/20260527_180807_879260_detected.jpg delete mode 100644 seat_finder_debug/20260527_180808_033034_detected.jpg delete mode 100644 seat_finder_debug/20260527_180808_151100_detected.jpg delete mode 100644 seat_finder_debug/20260527_180808_309451_detected.jpg delete mode 100644 seat_finder_debug/20260527_180853_002112_detected.jpg delete mode 100644 seat_finder_debug/20260527_180853_215366_detected.jpg delete mode 100644 seat_finder_debug/20260527_180853_383793_detected.jpg delete mode 100644 seat_finder_debug/20260527_180853_608283_detected.jpg delete mode 100644 seat_finder_debug/20260527_180853_840790_detected.jpg delete mode 100644 seat_finder_debug/20260527_180925_389559_detected.jpg delete mode 100644 seat_finder_debug/20260527_180925_531399_detected.jpg delete mode 100644 seat_finder_debug/20260527_180925_676604_detected.jpg delete mode 100644 seat_finder_debug/20260527_180925_798203_detected.jpg delete mode 100644 seat_finder_debug/20260527_180925_935687_detected.jpg delete mode 100644 seat_finder_debug/20260527_180959_909873_detected.jpg delete mode 100644 seat_finder_debug/20260527_181000_036868_detected.jpg delete mode 100644 seat_finder_debug/20260527_181000_194737_detected.jpg delete mode 100644 seat_finder_debug/20260527_181000_313897_detected.jpg delete mode 100644 seat_finder_debug/20260527_181000_405459_detected.jpg delete mode 100644 seat_finder_debug/20260527_181034_779314_detected.jpg delete mode 100644 seat_finder_debug/20260527_181034_892697_detected.jpg delete mode 100644 seat_finder_debug/20260527_181034_979220_detected.jpg delete mode 100644 seat_finder_debug/20260527_181035_118276_detected.jpg delete mode 100644 seat_finder_debug/20260527_181035_248860_detected.jpg delete mode 100644 seat_finder_debug/20260527_181139_388855_detected.jpg delete mode 100644 seat_finder_debug/20260527_181139_490821_detected.jpg delete mode 100644 seat_finder_debug/20260527_181139_629386_detected.jpg delete mode 100644 seat_finder_debug/20260527_181139_796071_detected.jpg delete mode 100644 seat_finder_debug/20260527_181139_908647_detected.jpg diff --git a/seat_check_webcam.py b/demo_seat_check_webcam.py similarity index 82% rename from seat_check_webcam.py rename to demo_seat_check_webcam.py index 138d80bc83..ad3316768b 100644 --- a/seat_check_webcam.py +++ b/demo_seat_check_webcam.py @@ -1,11 +1,25 @@ #!/usr/bin/env python3 +# Copyright 2025-2026 Dimensional Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + """Standalone webcam test for the YOLO empty-seat logic (no robot needed). Mirrors SeatFinderSkill's detection: YOLO detects chairs/couches and people, then a seat is "occupied" if a person box overlaps it past a threshold. Usage: - .venv/bin/python seat_check_webcam.py [--camera 0] + .venv/bin/python demo_seat_check_webcam.py [--camera 0] Overlay: green box = empty seat diff --git a/dimos/agents/skills/seat_finder.py b/dimos/agents/skills/seat_finder.py index 5d0508b1e4..f8cf00627a 100644 --- a/dimos/agents/skills/seat_finder.py +++ b/dimos/agents/skills/seat_finder.py @@ -14,8 +14,8 @@ from __future__ import annotations -import time from threading import RLock +import time from typing import TYPE_CHECKING, Any import cv2 diff --git a/dimos/agents/skills/seat_planner.py b/dimos/agents/skills/seat_planner.py index 790121d202..a6ce6f164f 100644 --- a/dimos/agents/skills/seat_planner.py +++ b/dimos/agents/skills/seat_planner.py @@ -1,3 +1,17 @@ +# Copyright 2025-2026 Dimensional Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + """On-demand YOLO empty-seat picker for the manual-map → auto-find demo. Flow: @@ -15,14 +29,12 @@ from __future__ import annotations +import math +import threading from threading import RLock from typing import TYPE_CHECKING, Any import cv2 - -import math -import threading - from reactivex.disposable import Disposable from dimos.agents.annotation import skill diff --git a/dimos/robot/all_blueprints.py b/dimos/robot/all_blueprints.py index e0398d05fa..ae3dc8c057 100644 --- a/dimos/robot/all_blueprints.py +++ b/dimos/robot/all_blueprints.py @@ -101,11 +101,15 @@ "unitree-go2-coordinator": "dimos.robot.unitree.go2.blueprints.basic.unitree_go2_coordinator:unitree_go2_coordinator", "unitree-go2-detection": "dimos.robot.unitree.go2.blueprints.smart.unitree_go2_detection:unitree_go2_detection", "unitree-go2-fleet": "dimos.robot.unitree.go2.blueprints.basic.unitree_go2_fleet:unitree_go2_fleet", + "unitree-go2-guide": "dimos.robot.unitree.go2.blueprints.agentic.unitree_go2_guide:unitree_go2_guide", "unitree-go2-keyboard-teleop": "dimos.robot.unitree.go2.blueprints.basic.unitree_go2_keyboard_teleop:unitree_go2_keyboard_teleop", "unitree-go2-markers": "dimos.robot.unitree.go2.blueprints.smart.unitree_go2:unitree_go2_markers", "unitree-go2-memory": "dimos.robot.unitree.go2.blueprints.smart.unitree_go2:unitree_go2_memory", "unitree-go2-relocalization": "dimos.robot.unitree.go2.blueprints.smart.unitree_go2:unitree_go2_relocalization", "unitree-go2-ros": "dimos.robot.unitree.go2.blueprints.smart.unitree_go2_ros:unitree_go2_ros", + "unitree-go2-seat-demo": "dimos.robot.unitree.go2.blueprints.agentic.unitree_go2_seat_demo:unitree_go2_seat_demo", + "unitree-go2-seat-demo-record": "dimos.robot.unitree.go2.blueprints.agentic.unitree_go2_seat_demo_record:unitree_go2_seat_demo_record", + "unitree-go2-seat-demo-reuse": "dimos.robot.unitree.go2.blueprints.agentic.unitree_go2_seat_demo_reuse:unitree_go2_seat_demo_reuse", "unitree-go2-seat-guide": "dimos.robot.unitree.go2.blueprints.agentic.unitree_go2_seat_guide:unitree_go2_seat_guide", "unitree-go2-seat-guide-agentic": "dimos.robot.unitree.go2.blueprints.agentic.unitree_go2_seat_guide_agentic:unitree_go2_seat_guide_agentic", "unitree-go2-security": "dimos.robot.unitree.go2.blueprints.agentic.unitree_go2_security:unitree_go2_security", @@ -203,7 +207,9 @@ "replanning-a-star-planner": "dimos.navigation.replanning_a_star.module.ReplanningAStarPlanner", "rerun-bridge-module": "dimos.visualization.rerun.bridge.RerunBridgeModule", "rerun-web-socket-server": "dimos.visualization.rerun.websocket_server.RerunWebSocketServer", + "seat-finder-skill": "dimos.agents.skills.seat_finder.SeatFinderSkill", "seat-guide-skill-container": "dimos.agents.skills.seat_guide.SeatGuideSkillContainer", + "seat-planner": "dimos.agents.skills.seat_planner.SeatPlanner", "security-module": "dimos.experimental.security_demo.security_module.SecurityModule", "semantic-search": "dimos.memory2.module.SemanticSearch", "simple-phone-teleop": "dimos.teleop.phone.phone_extensions.SimplePhoneTeleop", diff --git a/dimos/robot/unitree/go2/blueprints/agentic/unitree_go2_seat_demo.py b/dimos/robot/unitree/go2/blueprints/agentic/unitree_go2_seat_demo.py index 6e145c6ad6..d50c6881ee 100644 --- a/dimos/robot/unitree/go2/blueprints/agentic/unitree_go2_seat_demo.py +++ b/dimos/robot/unitree/go2/blueprints/agentic/unitree_go2_seat_demo.py @@ -1,4 +1,18 @@ #!/usr/bin/env python3 +# Copyright 2025-2026 Dimensional Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + """Manual-map → on-demand YOLO seat-find demo (no LLM). Operator flow: diff --git a/dimos/robot/unitree/go2/blueprints/agentic/unitree_go2_seat_demo_record.py b/dimos/robot/unitree/go2/blueprints/agentic/unitree_go2_seat_demo_record.py index a472f7d71d..9f80b5a557 100644 --- a/dimos/robot/unitree/go2/blueprints/agentic/unitree_go2_seat_demo_record.py +++ b/dimos/robot/unitree/go2/blueprints/agentic/unitree_go2_seat_demo_record.py @@ -1,4 +1,18 @@ #!/usr/bin/env python3 +# Copyright 2025-2026 Dimensional Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + """Seat-demo + recording: makes a reusable map while you map manually. Same skills as `unitree-go2-seat-demo` but adds Go2Memory recording on top diff --git a/dimos/robot/unitree/go2/blueprints/agentic/unitree_go2_seat_demo_reuse.py b/dimos/robot/unitree/go2/blueprints/agentic/unitree_go2_seat_demo_reuse.py index 279433fa41..312fe6545d 100644 --- a/dimos/robot/unitree/go2/blueprints/agentic/unitree_go2_seat_demo_reuse.py +++ b/dimos/robot/unitree/go2/blueprints/agentic/unitree_go2_seat_demo_reuse.py @@ -1,4 +1,18 @@ #!/usr/bin/env python3 +# Copyright 2025-2026 Dimensional Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + """Seat-demo on top of a previously-recorded premap. Same skills as `unitree-go2-seat-demo` but layered on `unitree_go2_relocalization` diff --git a/seat_finder_debug/20260527_172117_836311_water_bottle_detected.jpg b/seat_finder_debug/20260527_172117_836311_water_bottle_detected.jpg deleted file mode 100644 index 0dfa99258d..0000000000 --- a/seat_finder_debug/20260527_172117_836311_water_bottle_detected.jpg +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:7299717a08a4cd01f88ea0ccdd4dc336c094598d643613ea0c2f66033f2f211b -size 230544 diff --git a/seat_finder_debug/20260527_172117_836311_water_bottle_input.jpg b/seat_finder_debug/20260527_172117_836311_water_bottle_input.jpg deleted file mode 100644 index 0dfa99258d..0000000000 --- a/seat_finder_debug/20260527_172117_836311_water_bottle_input.jpg +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:7299717a08a4cd01f88ea0ccdd4dc336c094598d643613ea0c2f66033f2f211b -size 230544 diff --git a/seat_finder_debug/20260527_172437_391512_empty_seat_detected.jpg b/seat_finder_debug/20260527_172437_391512_empty_seat_detected.jpg deleted file mode 100644 index e06aee3309..0000000000 --- a/seat_finder_debug/20260527_172437_391512_empty_seat_detected.jpg +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:c4500d132d42f2dd2f31e67ee0684d97c6bf2e71c4b96d690fb79ac6e367f122 -size 159453 diff --git a/seat_finder_debug/20260527_172437_391512_empty_seat_input.jpg b/seat_finder_debug/20260527_172437_391512_empty_seat_input.jpg deleted file mode 100644 index e06aee3309..0000000000 --- a/seat_finder_debug/20260527_172437_391512_empty_seat_input.jpg +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:c4500d132d42f2dd2f31e67ee0684d97c6bf2e71c4b96d690fb79ac6e367f122 -size 159453 diff --git a/seat_finder_debug/20260527_180550_283951_detected.jpg b/seat_finder_debug/20260527_180550_283951_detected.jpg deleted file mode 100644 index 642821c3b9..0000000000 --- a/seat_finder_debug/20260527_180550_283951_detected.jpg +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:22834cf5ae5c674db68f8b292cb0dfdab75ba694adc5142af14fbb156625981e -size 144616 diff --git a/seat_finder_debug/20260527_180550_439986_detected.jpg b/seat_finder_debug/20260527_180550_439986_detected.jpg deleted file mode 100644 index 022c56554a..0000000000 --- a/seat_finder_debug/20260527_180550_439986_detected.jpg +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:1babeec2683a01d378bf791b612ec57dae6e762c36b20aad92c01f19fcd9be4d -size 144330 diff --git a/seat_finder_debug/20260527_180550_833871_detected.jpg b/seat_finder_debug/20260527_180550_833871_detected.jpg deleted file mode 100644 index 405d81d707..0000000000 --- a/seat_finder_debug/20260527_180550_833871_detected.jpg +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:77f8d3e485f41f9b1a88d2e37db2cfbba928689ec5d9419c68a6c92ca4993c05 -size 144568 diff --git a/seat_finder_debug/20260527_180550_910656_detected.jpg b/seat_finder_debug/20260527_180550_910656_detected.jpg deleted file mode 100644 index dc98e6e8ac..0000000000 --- a/seat_finder_debug/20260527_180550_910656_detected.jpg +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:f98c3bd61594a9a4f2c173d8c82c3feb3c1b1974d5b9927446af380f9dd7f6cd -size 144961 diff --git a/seat_finder_debug/20260527_180551_048564_detected.jpg b/seat_finder_debug/20260527_180551_048564_detected.jpg deleted file mode 100644 index 25af882004..0000000000 --- a/seat_finder_debug/20260527_180551_048564_detected.jpg +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:2c334cb9ad288095630cd8d50107bf6839267fede1147aa139e1cc675d1a2d2f -size 144857 diff --git a/seat_finder_debug/20260527_180807_743520_detected.jpg b/seat_finder_debug/20260527_180807_743520_detected.jpg deleted file mode 100644 index 2f7142b668..0000000000 --- a/seat_finder_debug/20260527_180807_743520_detected.jpg +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:3d97dec8f0de1e69114eb7b4d285bb0ac9c384e7cac8cbf8e33405de22a18fe2 -size 184062 diff --git a/seat_finder_debug/20260527_180807_879260_detected.jpg b/seat_finder_debug/20260527_180807_879260_detected.jpg deleted file mode 100644 index 51f52ec661..0000000000 --- a/seat_finder_debug/20260527_180807_879260_detected.jpg +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:2e3d05a36d5765e709a9be41630acfc4dac6a36c32061d28f5726583222dbc0d -size 184226 diff --git a/seat_finder_debug/20260527_180808_033034_detected.jpg b/seat_finder_debug/20260527_180808_033034_detected.jpg deleted file mode 100644 index b68a562097..0000000000 --- a/seat_finder_debug/20260527_180808_033034_detected.jpg +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:026c4f21ce56b94dbb7d9043edb563f41f91604c5af95606be0951b3c387aa0d -size 185774 diff --git a/seat_finder_debug/20260527_180808_151100_detected.jpg b/seat_finder_debug/20260527_180808_151100_detected.jpg deleted file mode 100644 index e78d84dadf..0000000000 --- a/seat_finder_debug/20260527_180808_151100_detected.jpg +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:6d8cef0e66a7ff061e7f6d12bc84226431ba6946a570cc161cd99dd0788e1548 -size 184764 diff --git a/seat_finder_debug/20260527_180808_309451_detected.jpg b/seat_finder_debug/20260527_180808_309451_detected.jpg deleted file mode 100644 index 0b3ec88e02..0000000000 --- a/seat_finder_debug/20260527_180808_309451_detected.jpg +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:62c23b625ac5005d9c86df507aea81b77b9bd5fa9f591094cca6056d7ad17338 -size 186031 diff --git a/seat_finder_debug/20260527_180853_002112_detected.jpg b/seat_finder_debug/20260527_180853_002112_detected.jpg deleted file mode 100644 index 5d24516815..0000000000 --- a/seat_finder_debug/20260527_180853_002112_detected.jpg +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:b860a815ddcac151ea01255759b66acb455ee42cb753ac6238621344181f5e98 -size 192513 diff --git a/seat_finder_debug/20260527_180853_215366_detected.jpg b/seat_finder_debug/20260527_180853_215366_detected.jpg deleted file mode 100644 index 156527b142..0000000000 --- a/seat_finder_debug/20260527_180853_215366_detected.jpg +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:2b944d6ee0cb2dfa9d4895138c4ddd1f0373bbc8fd5140ca11d325cb963328b8 -size 194357 diff --git a/seat_finder_debug/20260527_180853_383793_detected.jpg b/seat_finder_debug/20260527_180853_383793_detected.jpg deleted file mode 100644 index 65d30ed79b..0000000000 --- a/seat_finder_debug/20260527_180853_383793_detected.jpg +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:642bd97f7e0e6087cfa0c1e6e016b5b1db49e3ea1eaf1535946ef4e87279dbca -size 195924 diff --git a/seat_finder_debug/20260527_180853_608283_detected.jpg b/seat_finder_debug/20260527_180853_608283_detected.jpg deleted file mode 100644 index 936398f714..0000000000 --- a/seat_finder_debug/20260527_180853_608283_detected.jpg +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:a4630943da451be20c0322c00d7cb456bc8879194640f6d78d7ae699f6d28415 -size 196322 diff --git a/seat_finder_debug/20260527_180853_840790_detected.jpg b/seat_finder_debug/20260527_180853_840790_detected.jpg deleted file mode 100644 index ae10849298..0000000000 --- a/seat_finder_debug/20260527_180853_840790_detected.jpg +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:e3eaead2609ec1009ed94c68029a5574d361ee905a22ee15ef920fd5562e3d32 -size 197316 diff --git a/seat_finder_debug/20260527_180925_389559_detected.jpg b/seat_finder_debug/20260527_180925_389559_detected.jpg deleted file mode 100644 index 4c3f7b0fee..0000000000 --- a/seat_finder_debug/20260527_180925_389559_detected.jpg +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:b5ccb4bd24399391e319afb0122e225680deddd78e69883d56ece90daa04a084 -size 179811 diff --git a/seat_finder_debug/20260527_180925_531399_detected.jpg b/seat_finder_debug/20260527_180925_531399_detected.jpg deleted file mode 100644 index a71e72cb44..0000000000 --- a/seat_finder_debug/20260527_180925_531399_detected.jpg +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:3fc0956ff2e60f76a9c261ba50ca1796c16a068b20ffc55691f94cae5d27bc31 -size 179894 diff --git a/seat_finder_debug/20260527_180925_676604_detected.jpg b/seat_finder_debug/20260527_180925_676604_detected.jpg deleted file mode 100644 index 3d1f0fb800..0000000000 --- a/seat_finder_debug/20260527_180925_676604_detected.jpg +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:a0d9a5328485b4f1f76f926d820bdc30c4951e4bb7638be6edcbb0397c70e66b -size 180541 diff --git a/seat_finder_debug/20260527_180925_798203_detected.jpg b/seat_finder_debug/20260527_180925_798203_detected.jpg deleted file mode 100644 index 2727977915..0000000000 --- a/seat_finder_debug/20260527_180925_798203_detected.jpg +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:e6489dce09b6e5e5e890dbaaabcfd4d51264bb1c7bfd6a139a3871d0b144a5ca -size 180880 diff --git a/seat_finder_debug/20260527_180925_935687_detected.jpg b/seat_finder_debug/20260527_180925_935687_detected.jpg deleted file mode 100644 index d523c40f67..0000000000 --- a/seat_finder_debug/20260527_180925_935687_detected.jpg +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:d9bd522c6b00ad9f9a81610a11bc8a855dda93303cee40c6a46a2f7d02b6b8fb -size 181045 diff --git a/seat_finder_debug/20260527_180959_909873_detected.jpg b/seat_finder_debug/20260527_180959_909873_detected.jpg deleted file mode 100644 index 4caf7058f4..0000000000 --- a/seat_finder_debug/20260527_180959_909873_detected.jpg +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:66e189c6c88c698afb3f6ef9b1e719bb8cfceb3d5d6443d0fd068104b4dd8156 -size 184034 diff --git a/seat_finder_debug/20260527_181000_036868_detected.jpg b/seat_finder_debug/20260527_181000_036868_detected.jpg deleted file mode 100644 index c9eda8e5c8..0000000000 --- a/seat_finder_debug/20260527_181000_036868_detected.jpg +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:afa05136e7f99f7bb4043bab13f59b9b20e581e65a8f93b3e66102f0d08c5208 -size 181715 diff --git a/seat_finder_debug/20260527_181000_194737_detected.jpg b/seat_finder_debug/20260527_181000_194737_detected.jpg deleted file mode 100644 index 059195267d..0000000000 --- a/seat_finder_debug/20260527_181000_194737_detected.jpg +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:3b9f67cc51feea782fd14aecfb076890537428b325cdc76de00e599a6c19fda3 -size 181566 diff --git a/seat_finder_debug/20260527_181000_313897_detected.jpg b/seat_finder_debug/20260527_181000_313897_detected.jpg deleted file mode 100644 index 28cf09b14b..0000000000 --- a/seat_finder_debug/20260527_181000_313897_detected.jpg +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:19ef72048d0146c2dd11056d44b8acad9621e1efc06aa4fbb2070ec90bd6a4ee -size 183921 diff --git a/seat_finder_debug/20260527_181000_405459_detected.jpg b/seat_finder_debug/20260527_181000_405459_detected.jpg deleted file mode 100644 index 895a47ef49..0000000000 --- a/seat_finder_debug/20260527_181000_405459_detected.jpg +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:c192c3a3bd7714fece293b96b4e2b0c52a17552e160d48743d4c78cc4f9d93c9 -size 181575 diff --git a/seat_finder_debug/20260527_181034_779314_detected.jpg b/seat_finder_debug/20260527_181034_779314_detected.jpg deleted file mode 100644 index 6995524a87..0000000000 --- a/seat_finder_debug/20260527_181034_779314_detected.jpg +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:973552949fcab196179242305437e06ba0dfa4e125cb57ede523f507c46a338c -size 184046 diff --git a/seat_finder_debug/20260527_181034_892697_detected.jpg b/seat_finder_debug/20260527_181034_892697_detected.jpg deleted file mode 100644 index 9379fb6c1f..0000000000 --- a/seat_finder_debug/20260527_181034_892697_detected.jpg +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:6d0f4cf3169aee54672e24d2ce44984e5f40d079d4c4c9e7a76a1d6f6529c7d3 -size 184045 diff --git a/seat_finder_debug/20260527_181034_979220_detected.jpg b/seat_finder_debug/20260527_181034_979220_detected.jpg deleted file mode 100644 index dfa7b55788..0000000000 --- a/seat_finder_debug/20260527_181034_979220_detected.jpg +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:d9f19afa577d8337e51cc7d4563a2f0743e6d4a8ccd306cc03164cc71f8cc9e9 -size 183739 diff --git a/seat_finder_debug/20260527_181035_118276_detected.jpg b/seat_finder_debug/20260527_181035_118276_detected.jpg deleted file mode 100644 index 774f324297..0000000000 --- a/seat_finder_debug/20260527_181035_118276_detected.jpg +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:58bebde1638df7a6e732b51029829da3b4810f09fee6d309426d92a38e01f500 -size 183985 diff --git a/seat_finder_debug/20260527_181035_248860_detected.jpg b/seat_finder_debug/20260527_181035_248860_detected.jpg deleted file mode 100644 index 6736ed72ce..0000000000 --- a/seat_finder_debug/20260527_181035_248860_detected.jpg +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:fa42927b571ed1f6751576ebfda8e2c049ab478903d2754a8536a828c479be19 -size 184570 diff --git a/seat_finder_debug/20260527_181139_388855_detected.jpg b/seat_finder_debug/20260527_181139_388855_detected.jpg deleted file mode 100644 index b19f7575d7..0000000000 --- a/seat_finder_debug/20260527_181139_388855_detected.jpg +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:4e40bdb6d737be74f177978ddf64a25b60cebcaa983c60a25de22a094167fed8 -size 301734 diff --git a/seat_finder_debug/20260527_181139_490821_detected.jpg b/seat_finder_debug/20260527_181139_490821_detected.jpg deleted file mode 100644 index b3323c7644..0000000000 --- a/seat_finder_debug/20260527_181139_490821_detected.jpg +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:a75c93f29525d08758c609a2e6025eec9d8013d62481aaf804f485cb8132dbf9 -size 259527 diff --git a/seat_finder_debug/20260527_181139_629386_detected.jpg b/seat_finder_debug/20260527_181139_629386_detected.jpg deleted file mode 100644 index 0d03f8787b..0000000000 --- a/seat_finder_debug/20260527_181139_629386_detected.jpg +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:b2ff36c7fdbc06de1839f01d9ef393c240a0c8a05f1bbf8628ecb0b1c9aff32d -size 237124 diff --git a/seat_finder_debug/20260527_181139_796071_detected.jpg b/seat_finder_debug/20260527_181139_796071_detected.jpg deleted file mode 100644 index 85b89b466f..0000000000 --- a/seat_finder_debug/20260527_181139_796071_detected.jpg +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:a2eadf86cee65404e1dd12d5265c6f6ff448128c69a4677f7ec72872044cc10b -size 222092 diff --git a/seat_finder_debug/20260527_181139_908647_detected.jpg b/seat_finder_debug/20260527_181139_908647_detected.jpg deleted file mode 100644 index d92e654183..0000000000 --- a/seat_finder_debug/20260527_181139_908647_detected.jpg +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:e56ddf46611bb16be48f6e4df6dccd687af14c40211802e9013590b2b5cde1c1 -size 217439