From cd3c1fae48cdcfda5bf83fe25ec8953e92d10085 Mon Sep 17 00:00:00 2001 From: LauraGPT Date: Mon, 3 Aug 2026 21:51:38 +0000 Subject: [PATCH] feat: add OpenClaw realtime transcription plugin --- README.md | 5 +- README_zh.md | 5 +- integrations/openclaw/.gitignore | 3 + integrations/openclaw/LICENSE | 21 + integrations/openclaw/README.md | 108 + integrations/openclaw/openclaw.plugin.json | 19 + integrations/openclaw/package-lock.json | 1751 +++++++++++++++++ integrations/openclaw/package.json | 79 + integrations/openclaw/scripts/build.mjs | 21 + integrations/openclaw/src/index.ts | 12 + .../src/realtime-transcription-provider.ts | 338 ++++ .../openclaw/tests/package-contract.test.ts | 34 + .../realtime-transcription-provider.test.ts | 339 ++++ integrations/openclaw/vitest.config.ts | 41 + 14 files changed, 2774 insertions(+), 2 deletions(-) create mode 100644 integrations/openclaw/.gitignore create mode 100644 integrations/openclaw/LICENSE create mode 100644 integrations/openclaw/README.md create mode 100644 integrations/openclaw/openclaw.plugin.json create mode 100644 integrations/openclaw/package-lock.json create mode 100644 integrations/openclaw/package.json create mode 100644 integrations/openclaw/scripts/build.mjs create mode 100644 integrations/openclaw/src/index.ts create mode 100644 integrations/openclaw/src/realtime-transcription-provider.ts create mode 100644 integrations/openclaw/tests/package-contract.test.ts create mode 100644 integrations/openclaw/tests/realtime-transcription-provider.test.ts create mode 100644 integrations/openclaw/vitest.config.ts diff --git a/README.md b/README.md index b5255890c..3a96b5169 100644 --- a/README.md +++ b/README.md @@ -22,7 +22,7 @@

- Quick Start · Colab · Benchmark · Model selection · Migration guide · Use cases · Community integrations · Deployment matrix · Deployment hub · Troubleshooting · Models · Agent Integration · Docs · Contribute + Quick Start · Colab · Benchmark · Model selection · Migration guide · Use cases · Community integrations · Deployment matrix · Deployment hub · Troubleshooting · Models · Agent Integration · OpenClaw · Docs · Contribute

--- @@ -113,6 +113,8 @@ results = model.generate(["audio1.wav", "audio2.wav"], language="auto") > **Deploy as API server:** `funasr-server --device cuda` → OpenAI-compatible endpoint at localhost:8000 > > **Use with AI agents:** [MCP Server](examples/mcp_server/) for Claude/Cursor · [OpenAI API](examples/openai_api/) for LangChain/Dify/AutoGen +> +> **Use with voice agents:** [OpenClaw realtime plugin](integrations/openclaw/) for self-hosted Talk and Voice Call transcription ### Why FunASR? @@ -322,6 +324,7 @@ asset is published. ## What's new +- 2026/08/04: **OpenClaw realtime transcription integration** — the new [`openclaw-funasr`](integrations/openclaw/) source package connects self-hosted FunASR `online`, `offline`, and `2pass` WebSocket recognition to OpenClaw Talk and Voice Call. Its 8 kHz G.711 mu-law conversion, 60 ms framing, partial/final transcripts, reconnect limits, package installation, and runtime registration have been verified against OpenClaw `2026.7.2`; npm and ClawHub publication will follow the required [upstream SDK change](https://github.com/openclaw/openclaw/pull/118977). - 2026/07/31: **v1.4.0 on PyPI** — `AutoModel` now rejects the common `vda_model` misspelling before model download and points to `vad_model`, so VAD-dependent segmentation, speaker processing, and `sentence_info` are not silently disabled. The GitHub source release also updates the legacy WebSocket file runtime: clients wait for an explicit end-of-input acknowledgement while the server flushes pending offline, online, and 2pass audio and reports finalization failures. Install the Python package with `python -m pip install -U "funasr==1.4.0"`. [Release ->](https://github.com/modelscope/FunASR/releases/tag/v1.4.0) - 2026/07/27: **v1.3.30 on PyPI** — container-formatted WAV, MP3, FLAC, OGG, MP4/M4A, and WebM audio bytes are now decoded through their codecs instead of being misread as raw PCM. OpenAI-compatible responses preserve speaker labels, VAD sentence timing survives punctuation mismatch, trusted browser clients can opt in to CORS, and vLLM VAD chunks are capped at 30 seconds. The GitHub release also includes the current prebuilt llama.cpp runtime for nine desktop and server targets. Install with `python -m pip install -U "funasr==1.3.30"`. [Release ->](https://github.com/modelscope/FunASR/releases/tag/v1.3.30) - 2026/07/24: **v1.3.29 hotfix on PyPI** — SenseVoice long-audio inference now returns each VAD speech region through `sentence_info` when token timestamps and a punctuation model are unavailable. Subtitle clients receive the recognized text with real millisecond start/end bounds instead of one zero-length or full-media cue. Install with `python -m pip install -U "funasr==1.3.29"`. [Release ->](https://github.com/modelscope/FunASR/releases/tag/v1.3.29) diff --git a/README_zh.md b/README_zh.md index a7486a871..e171a0dbb 100644 --- a/README_zh.md +++ b/README_zh.md @@ -21,7 +21,7 @@

- 快速开始 · Colab · 性能评测 · 模型选择 · 迁移指南 · 场景速览 · 社区集成 · 部署选型 · 部署中心 · 排障 FAQ · 模型列表 · Agent 集成 · 文档 · 贡献 + 快速开始 · Colab · 性能评测 · 模型选择 · 迁移指南 · 场景速览 · 社区集成 · 部署选型 · 部署中心 · 排障 FAQ · 模型列表 · Agent 集成 · OpenClaw · 文档 · 贡献

--- @@ -103,6 +103,8 @@ results = model.generate(["audio1.wav", "audio2.wav"], language="auto") > **部署为 API 服务:** `funasr-server --device cuda` → 本地 OpenAI 兼容接口 localhost:8000 > > **接入 AI Agent:** [MCP 服务](examples/mcp_server/) 支持 Claude/Cursor · [OpenAI API](examples/openai_api/README_zh.md) 支持 LangChain/Dify/AutoGen +> +> **接入语音 Agent:** [OpenClaw 实时转写插件](integrations/openclaw/) 支持私有部署的 Talk 与 Voice Call 转写 ### 为什么选 FunASR? @@ -147,6 +149,7 @@ Whisper 是单个模型,**FunASR 是一个工具箱**——按场景挑模型 ## 最新动态 +- 2026/08/04:**OpenClaw 实时转写集成** — 新增 [`openclaw-funasr`](integrations/openclaw/) 源码包,把私有部署的 FunASR `online`、`offline` 与 `2pass` WebSocket 识别接入 OpenClaw Talk 和 Voice Call。8 kHz G.711 mu-law 转换、60 ms 分帧、partial/final 文本、重连上限、安装包与运行时注册均已基于 OpenClaw `2026.7.2` 验证;npm 与 ClawHub 发布将在所需的[上游 SDK 改动](https://github.com/openclaw/openclaw/pull/118977)合入后进行。 - 2026/07/31:**v1.4.0 已发布到 PyPI** — `AutoModel` 现在会在下载模型前拒绝常见的 `vda_model` 误拼写并明确提示使用 `vad_model`,避免依赖 VAD 的分段、说话人处理和 `sentence_info` 被静默关闭。GitHub 源码发布同时更新 legacy WebSocket 文件运行时:客户端会等待明确的输入结束确认,服务端先刷新待处理的 offline、online 与 2pass 音频,并把收尾失败返回给客户端。Python 包安装命令:`python -m pip install -U "funasr==1.4.0"`。[发布页 →](https://github.com/modelscope/FunASR/releases/tag/v1.4.0) - 2026/07/27:**v1.3.30 已发布到 PyPI** — WAV、MP3、FLAC、OGG、MP4/M4A 和 WebM 等容器格式的音频字节现在会通过对应编解码器解码,不再被误当作原始 PCM。OpenAI 兼容响应会保留说话人标签,标点不匹配时仍保留 VAD 分句时间,受信任的浏览器客户端可按需启用 CORS,vLLM 的 VAD 分段上限为 30 秒。GitHub 发布页还同时提供覆盖九种桌面和服务器目标的当前 llama.cpp 预编译运行包。安装命令:`python -m pip install -U "funasr==1.3.30"`。[发布页 →](https://github.com/modelscope/FunASR/releases/tag/v1.3.30) - 2026/07/24:**v1.3.29 热修复已发布到 PyPI** — SenseVoice 长音频在没有词级时间戳和标点模型时,现在会通过 `sentence_info` 返回每个 VAD 语音片段。字幕客户端可直接获得识别文本及真实的毫秒级起止时间,不再退化为零时长或覆盖整段媒体的单条字幕。安装命令:`python -m pip install -U "funasr==1.3.29"`。[发布页 →](https://github.com/modelscope/FunASR/releases/tag/v1.3.29) diff --git a/integrations/openclaw/.gitignore b/integrations/openclaw/.gitignore new file mode 100644 index 000000000..e72899590 --- /dev/null +++ b/integrations/openclaw/.gitignore @@ -0,0 +1,3 @@ +dist/ +node_modules/ +*.tgz diff --git a/integrations/openclaw/LICENSE b/integrations/openclaw/LICENSE new file mode 100644 index 000000000..9ea3374ce --- /dev/null +++ b/integrations/openclaw/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025 FunASR + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/integrations/openclaw/README.md b/integrations/openclaw/README.md new file mode 100644 index 000000000..76e8d0e01 --- /dev/null +++ b/integrations/openclaw/README.md @@ -0,0 +1,108 @@ +# FunASR for OpenClaw + +`openclaw-funasr` connects OpenClaw Talk and Voice Call to a self-hosted +[FunASR](https://github.com/modelscope/FunASR) WebSocket server. Audio stays on +infrastructure you control. + +## Capabilities + +- FunASR `online`, `offline`, and `2pass` recognition modes +- OpenClaw's 8 kHz G.711 mu-law input converted to 16 kHz PCM +- Official 60 ms FunASR audio frames and the required `binary` WebSocket subprotocol +- Partial and final transcripts, hotwords, inverse text normalization, and optional Bearer auth +- Bounded audio queues and transcript retention inherited from the OpenClaw session helper + +## Compatibility + +The first release requires OpenClaw `>=2026.7.2` with +[openclaw/openclaw#118977](https://github.com/openclaw/openclaw/pull/118977). +Publishing to npm and ClawHub is intentionally gated on that SDK change landing +in an OpenClaw release; the compatibility floor will be pinned to the exact +release before publication. + +## Start FunASR + +Run the official FunASR WebSocket server from this repository. The server must +be reachable from the OpenClaw Gateway and negotiate the `binary` subprotocol. +See [runtime/python/websocket](../../runtime/python/websocket) for Docker, +model, TLS, and client examples. + +The default server endpoint is commonly `ws://127.0.0.1:10095`, but the plugin +does not assume a URL. Set it explicitly in OpenClaw or through +`FUNASR_WS_URL`. + +## Build and test + +```bash +cd integrations/openclaw +npm ci +OPENCLAW_ROOT=/absolute/path/to/openclaw npm test +npm pack +``` + +`OPENCLAW_ROOT` must point to an OpenClaw checkout containing #118977. The test +suite exercises the real shared WebSocket session helper, including protocol +negotiation, audio conversion, 60 ms framing, finalization, reconnect handling, +bounded transcript state, and package metadata. + +Install the packed artifact before publication: + +```bash +openclaw plugins install npm-pack:/absolute/path/to/openclaw-funasr-0.1.0.tgz --force +openclaw plugins inspect funasr --runtime --json +``` + +## Configure OpenClaw + +FunASR provider options live under Voice Call's generic streaming provider map. +The same provider map is currently used by Talk transcription sessions. + +```json5 +{ + plugins: { + entries: { + "voice-call": { + config: { + streaming: { + enabled: true, + provider: "funasr", + providers: { + funasr: { + baseUrl: "ws://127.0.0.1:10095", + mode: "2pass", + hotwords: "OpenClaw,FunASR", + useItn: true + } + } + } + } + } + } + } +} +``` + +Options: + +| Option | Default | Description | +| --- | --- | --- | +| `baseUrl` | `FUNASR_WS_URL` | `http`, `https`, `ws`, or `wss` endpoint | +| `apiKey` | `FUNASR_API_KEY` | Optional Bearer token for a reverse proxy | +| `mode` | `2pass` | `online`, `offline`, or `2pass` | +| `hotwords` | empty | FunASR hotword string | +| `useItn` | `true` | Enable inverse text normalization | + +Embedded credentials in `baseUrl` are rejected. Use `apiKey` or +`FUNASR_API_KEY` instead. + +## Verified behavior + +The end-to-end proof used the official FunASR server, a public 5.55 second +16 kHz sample converted to the same 8 kHz mu-law frames emitted by OpenClaw, +and the plugin's production adapter. It streamed 278 audio frames, produced +seven partial transcripts, returned the exact final transcript, and completed +without client errors. + +## License + +MIT diff --git a/integrations/openclaw/openclaw.plugin.json b/integrations/openclaw/openclaw.plugin.json new file mode 100644 index 000000000..b6e01f110 --- /dev/null +++ b/integrations/openclaw/openclaw.plugin.json @@ -0,0 +1,19 @@ +{ + "id": "funasr", + "name": "FunASR Realtime Transcription", + "description": "Self-hosted realtime speech recognition through FunASR", + "activation": { + "onStartup": false + }, + "enabledByDefault": true, + "contracts": { + "realtimeTranscriptionProviders": [ + "funasr" + ] + }, + "configSchema": { + "type": "object", + "additionalProperties": false, + "properties": {} + } +} diff --git a/integrations/openclaw/package-lock.json b/integrations/openclaw/package-lock.json new file mode 100644 index 000000000..5b565e527 --- /dev/null +++ b/integrations/openclaw/package-lock.json @@ -0,0 +1,1751 @@ +{ + "name": "openclaw-funasr", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "openclaw-funasr", + "version": "0.1.0", + "license": "MIT", + "devDependencies": { + "@types/node": "26.1.2", + "@types/ws": "8.18.1", + "esbuild": "0.28.1", + "vitest": "4.1.10", + "ws": "8.21.1" + }, + "engines": { + "node": "^22.22.3 || >=24.15.0" + }, + "peerDependencies": { + "openclaw": ">=2026.7.2" + }, + "peerDependenciesMeta": { + "openclaw": { + "optional": true + } + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz", + "integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz", + "integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz", + "integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz", + "integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz", + "integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz", + "integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz", + "integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz", + "integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz", + "integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz", + "integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz", + "integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz", + "integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz", + "integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz", + "integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz", + "integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz", + "integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz", + "integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz", + "integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz", + "integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz", + "integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz", + "integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz", + "integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz", + "integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz", + "integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz", + "integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz", + "integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@oxc-project/types": { + "version": "0.142.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.142.0.tgz", + "integrity": "sha512-7W+2q5AKQVU36fkaryontrHn3YDt1RyUYXatw9i5H8ocYe2sPKSFB6eS8WNPeRKiN1qAWWZUPm7gwFzJGrccqQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.2.tgz", + "integrity": "sha512-l7x215OGvo1s52JWmR8U/DAVzEDWBCIbTm28aeJV/WDTSHgcKXaZTuBT0hJMs5NggilfJTW3clZVvd24yfKJxA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.2.tgz", + "integrity": "sha512-9u9Xv6c1AJZT0FfwH5vrMG5Jjcwhc1MlyrPu0XfTqkzsmqfks2M6W/o5XwAJgVVN/jHpqqngC1WevHKKTIUtIA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.2.tgz", + "integrity": "sha512-9W1mbGZAfW3oqd85bhBkmpyHCCzL1TeG/zFFP3vg7b0rlly8cxOcre5nXwz+LHazCwac2MNWgPdPCHndABjpWQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.2.tgz", + "integrity": "sha512-0p1lhiCSCyaerFwtrdZQUx7NqGk6LQnaRKWX7tFQqwQgvX0rjM15cIkm3pax1UpEakK14C4mOxx/jSqCBdBRqQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.2.tgz", + "integrity": "sha512-e+cOJXrJ2L3zx6YzqPg+f6Wbk3V1cKB8bOhbaYdVYN3DdquzNdRAmrbETz1qnt5yp/c7JNlNjmITiA2cVneQ7w==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.2.tgz", + "integrity": "sha512-JsSMsj6sNat/MuhG5fnBD7QgbtpHKVe30x5/bAVirDHdhoQRXJkF6xc0Jqk8O4fiCUQAzMOoH9wZi3m60c8wtg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.2.tgz", + "integrity": "sha512-B5G/zJdHaoJn9vD50eGHWkiWfmq8Uhi3IiLPJTzmZTrAalk1bztUikSXo0qga18ibE0IXboyeMUnhPjhAJ45wQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.2.tgz", + "integrity": "sha512-6mC/awzKka8W6EoekjegpfGkjz8jXWDX63pqu/HYVpyKtZfu65Jsh4QAH3Kej3CAv/c1oGX7psTmFEbr0mDxLA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.2.tgz", + "integrity": "sha512-412MX9fJLdA1IK28EZnc8jYv2HRTleOZgfLQumJ5zy7OeJLZlg/CETwFaXjNmGVxG51cFHpKLqb5LKvBC+HsHA==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.2.tgz", + "integrity": "sha512-Q/+HI/ToJafZ1iCqGgVQXUEkIjufHCTF0gBQ2a5o3cg7GJ2h0qyq3nvvSmU+bGda2/7ygXpTY4TM6gO9OhQ0ZA==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.2.tgz", + "integrity": "sha512-ZKp/w41n6wCvxzxQHtQSbuphfX3Y4cCvbjkKHusrLx4lh+JWLTU7StSltO/DKARISzbj368d+qUaCjI8K2wzXw==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.2.tgz", + "integrity": "sha512-pxE6xD4KS3eAROkKK5yrhB9/3+vhlhVGMvlQLbdpzrBGDbKrnzx3RLwPaHvasLo6jgaiBLn7e04Df9C4tYhjmA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.2.tgz", + "integrity": "sha512-4MqEue5re+xIZzAWsB8sj0P1kqZySWqIuN4t6QaIO/YA6SFwySOLruvWQFzfmqk8LBK2P30KCSJwf6mJCZZ5/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.2.tgz", + "integrity": "sha512-NweNxxD0Nf9t8v7kodun45Ijp3EIwYY+uydPP6qBEYvfBqhIjN6dZMzlQja3tqX/aLs3F3Uz+AxDpKgRhpOZQg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/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/node": { + "version": "26.1.2", + "resolved": "https://registry.npmjs.org/@types/node/-/node-26.1.2.tgz", + "integrity": "sha512-Vu4a5UFA9rIIFJ7rB/Vaafh9lrCQszopTCx6KjFboXTGQbPNasehVR5TEiithSDGyd1DEiUByggTZsg8jukeIg==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~8.3.0" + } + }, + "node_modules/@types/ws": { + "version": "8.18.1", + "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", + "integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@vitest/expect": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.10.tgz", + "integrity": "sha512-YsCn+qAk1GWjQOWFEsEcL2gNQ0zmVmQu3T03qP6UyjhtmdtwtbuI+DASn/7iQB3HGTXkdBwGddzxPlmiql5vlA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.1.10", + "@vitest/utils": "4.1.10", + "chai": "^6.2.2", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.10.tgz", + "integrity": "sha512-v0xaezt+DKEmKfaxg133ldzADrwLGd7Ze1MfQQTYfvs8OqZIwbxyxaYURivwV7sWy5fqn3rH5uOrSp07bp44Ow==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.1.10", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.10.tgz", + "integrity": "sha512-W1HsjSH4MXQ9YfmmhLAoIYf1HRfekQCGngeIgcei6MP5QQGWUe0gkopdZQaVCFO+JDJMrAJGwa5pRpNpvy4P8Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.10.tgz", + "integrity": "sha512-IKI6kpIH+LmpROplyLwBBaCfMgOZOMsygVa6BARD6ahA04VRuJSa6OaVG7kRvSEMD870Vd91rSSw0eegtWyLGg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.1.10", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.10.tgz", + "integrity": "sha512-xRkfOT1qpTAi/Ti4Y1LtfRc3kEuqxGw59eN2jN9pRWMtS/XDevekhcFSqvQqjUNGksfjMJu3Y+oJ+4Ypn2OaJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.10", + "@vitest/utils": "4.1.10", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.10.tgz", + "integrity": "sha512-PLf/Ugvoq5wO/b4rwYCR1h2PSIdXz7wnkQFMiUpLdtM7l6pqVFcQIBEHyT1+l+cj7mNwAfZHzqXqDyjvOuwbDw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.10.tgz", + "integrity": "sha512-fy9am/HWxbaGt/Sawrp90vt6Y6jQwf1RX77cz3uwoJwJVMli/e1IEwRPnMNJ7vKfPTwo0diXifkpPvwH9v7nGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.10", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/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/es-module-lexer": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.1.tgz", + "integrity": "sha512-shc1dbU90Yl/xq1QrC7QRtfcwURZuVRfPhZbDoldJ1cn1gzDvBaBWlv0eFolj5+0znnPJz5TXLxsN77X/12KTA==", + "dev": true, + "license": "MIT" + }, + "node_modules/esbuild": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz", + "integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.1", + "@esbuild/android-arm": "0.28.1", + "@esbuild/android-arm64": "0.28.1", + "@esbuild/android-x64": "0.28.1", + "@esbuild/darwin-arm64": "0.28.1", + "@esbuild/darwin-x64": "0.28.1", + "@esbuild/freebsd-arm64": "0.28.1", + "@esbuild/freebsd-x64": "0.28.1", + "@esbuild/linux-arm": "0.28.1", + "@esbuild/linux-arm64": "0.28.1", + "@esbuild/linux-ia32": "0.28.1", + "@esbuild/linux-loong64": "0.28.1", + "@esbuild/linux-mips64el": "0.28.1", + "@esbuild/linux-ppc64": "0.28.1", + "@esbuild/linux-riscv64": "0.28.1", + "@esbuild/linux-s390x": "0.28.1", + "@esbuild/linux-x64": "0.28.1", + "@esbuild/netbsd-arm64": "0.28.1", + "@esbuild/netbsd-x64": "0.28.1", + "@esbuild/openbsd-arm64": "0.28.1", + "@esbuild/openbsd-x64": "0.28.1", + "@esbuild/openharmony-arm64": "0.28.1", + "@esbuild/sunos-x64": "0.28.1", + "@esbuild/win32-arm64": "0.28.1", + "@esbuild/win32-ia32": "0.28.1", + "@esbuild/win32-x64": "0.28.1" + } + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/expect-type": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/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/lightningcss": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz", + "integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.33.0", + "lightningcss-darwin-arm64": "1.33.0", + "lightningcss-darwin-x64": "1.33.0", + "lightningcss-freebsd-x64": "1.33.0", + "lightningcss-linux-arm-gnueabihf": "1.33.0", + "lightningcss-linux-arm64-gnu": "1.33.0", + "lightningcss-linux-arm64-musl": "1.33.0", + "lightningcss-linux-x64-gnu": "1.33.0", + "lightningcss-linux-x64-musl": "1.33.0", + "lightningcss-win32-arm64-msvc": "1.33.0", + "lightningcss-win32-x64-msvc": "1.33.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz", + "integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz", + "integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz", + "integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz", + "integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz", + "integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz", + "integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz", + "integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz", + "integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz", + "integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz", + "integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz", + "integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/nanoid": { + "version": "3.3.17", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.17.tgz", + "integrity": "sha512-xQLf0A3HOMlgHq0n247/LRuAOYmB7dXJ/DvAxGvsSBij45XtBSmQycu+F8ODbHwns/XyFZagyL1+J0Offw1E0g==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/obug": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.4.tgz", + "integrity": "sha512-4a+OsYv9UktOJKE+l1A4OufDgdRF9PifWj+tJnHURo/P+WOxpG4GzUFL9qCalmWauao6ogiG+QvnCovwPoyAWA==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT", + "engines": { + "node": ">=12.20.0" + } + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.25", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.25.tgz", + "integrity": "sha512-DTPx3RWSSnWyzLxQnlH0rJP+EW5ekl16ZU4/psbIhA0e53kJfdgaN5vKM+xP7yJtXVu+nfdVFmlgFDEKAe4Pyw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.16", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/rolldown": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.2.tgz", + "integrity": "sha512-opwpo1tQBAcpSUJDt94B7hhLNGOKjCdE//XXjeLrnx9b83bjnw45tXdg1b09yEw/VLFBJGZpwRULMmOZo7ol+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.142.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm64": "1.2.2", + "@rolldown/binding-darwin-arm64": "1.2.2", + "@rolldown/binding-darwin-x64": "1.2.2", + "@rolldown/binding-freebsd-x64": "1.2.2", + "@rolldown/binding-linux-arm-gnueabihf": "1.2.2", + "@rolldown/binding-linux-arm64-gnu": "1.2.2", + "@rolldown/binding-linux-arm64-musl": "1.2.2", + "@rolldown/binding-linux-ppc64-gnu": "1.2.2", + "@rolldown/binding-linux-s390x-gnu": "1.2.2", + "@rolldown/binding-linux-x64-gnu": "1.2.2", + "@rolldown/binding-linux-x64-musl": "1.2.2", + "@rolldown/binding-openharmony-arm64": "1.2.2", + "@rolldown/binding-win32-arm64-msvc": "1.2.2", + "@rolldown/binding-win32-x64-msvc": "1.2.2" + } + }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.2.0.tgz", + "integrity": "sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.3.0.tgz", + "integrity": "sha512-QKAl9m8gWWGHV8jZcPeym6j+XULi6tOf1mT83WYJ4Lk2ytW/uwAWkrP0uFsdoYMdueVJ0qs26wZ+23xeB4ibNQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyrainbow": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.1.tgz", + "integrity": "sha512-yau8yJdTt989Mm0Bd/236QnzEiPf2xLLTqUZRUJOo/3CB078LSwzei343DgtJVmfJKJE3TMINY1u42SQsP6mXw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/undici-types": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz", + "integrity": "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/vite": { + "version": "8.2.0", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.2.0.tgz", + "integrity": "sha512-pn+CFpM0lwDeKwmOq1ZaBK/9sjorZcgqxki6MbY/jPEVd9vichIlmlD4HmQ5wdP5EgqQCFRaACBxMC7uEGc6lQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "lightningcss": "^1.33.0", + "picomatch": "^4.0.5", + "postcss": "^8.5.23", + "rolldown": "~1.2.0", + "tinyglobby": "^0.2.17" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.4.0", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vitest": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.10.tgz", + "integrity": "sha512-R9jUTe5S4Qb0HCd4TNqpC7oGcrMssMRGXLW80ubjWsW9VH5GF8y1Y0SFLY9AbqSk6nt0PnOx4H4WNJYZ13GUPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.1.10", + "@vitest/mocker": "4.1.10", + "@vitest/pretty-format": "4.1.10", + "@vitest/runner": "4.1.10", + "@vitest/snapshot": "4.1.10", + "@vitest/spy": "4.1.10", + "@vitest/utils": "4.1.10", + "es-module-lexer": "^2.0.0", + "expect-type": "^1.3.0", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^4.0.0-rc.1", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.1.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.1.10", + "@vitest/browser-preview": "4.1.10", + "@vitest/browser-webdriverio": "4.1.10", + "@vitest/coverage-istanbul": "4.1.10", + "@vitest/coverage-v8": "4.1.10", + "@vitest/ui": "4.1.10", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/ws": { + "version": "8.21.1", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.1.tgz", + "integrity": "sha512-+0NTnW77fFN/DjQi6k/Sq/Yvk4Sgajw7urW8V+asjXnRgDs9gyGkdb7EzgfhA4goXsRIZKE28fzIXBHEzhuiWw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + } + } +} diff --git a/integrations/openclaw/package.json b/integrations/openclaw/package.json new file mode 100644 index 000000000..6b2613a0d --- /dev/null +++ b/integrations/openclaw/package.json @@ -0,0 +1,79 @@ +{ + "name": "openclaw-funasr", + "version": "0.1.0", + "description": "Self-hosted FunASR realtime transcription for OpenClaw", + "license": "MIT", + "type": "module", + "main": "./dist/index.js", + "exports": { + ".": "./dist/index.js" + }, + "files": [ + "dist", + "LICENSE", + "README.md", + "openclaw.plugin.json" + ], + "scripts": { + "build": "node ./scripts/build.mjs", + "test": "npm run build && vitest run --config ./vitest.config.ts", + "check": "npm test", + "prepack": "npm run build" + }, + "engines": { + "node": "^22.22.3 || >=24.15.0" + }, + "peerDependencies": { + "openclaw": ">=2026.7.2" + }, + "peerDependenciesMeta": { + "openclaw": { + "optional": true + } + }, + "devDependencies": { + "@types/node": "26.1.2", + "@types/ws": "8.18.1", + "esbuild": "0.28.1", + "vitest": "4.1.10", + "ws": "8.21.1" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/modelscope/FunASR.git", + "directory": "integrations/openclaw" + }, + "homepage": "https://github.com/modelscope/FunASR/tree/main/integrations/openclaw", + "bugs": { + "url": "https://github.com/modelscope/FunASR/issues" + }, + "keywords": [ + "asr", + "funasr", + "openclaw", + "speech-to-text", + "transcription", + "websocket" + ], + "openclaw": { + "extensions": [ + "./dist/index.js" + ], + "install": { + "clawhubSpec": "clawhub:openclaw-funasr", + "npmSpec": "openclaw-funasr", + "defaultChoice": "clawhub", + "minHostVersion": ">=2026.7.2" + }, + "compat": { + "pluginApi": ">=2026.7.2" + }, + "build": { + "openclawVersion": "2026.7.2" + }, + "release": { + "publishToClawHub": true, + "publishToNpm": true + } + } +} diff --git a/integrations/openclaw/scripts/build.mjs b/integrations/openclaw/scripts/build.mjs new file mode 100644 index 000000000..b5c4916c4 --- /dev/null +++ b/integrations/openclaw/scripts/build.mjs @@ -0,0 +1,21 @@ +import { rm } from "node:fs/promises"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { build } from "esbuild"; + +const root = resolve(dirname(fileURLToPath(import.meta.url)), ".."); +const outputDirectory = resolve(root, "dist"); + +await rm(outputDirectory, { force: true, recursive: true }); +await build({ + bundle: true, + entryPoints: [resolve(root, "src/index.ts")], + external: ["openclaw/*"], + format: "esm", + legalComments: "none", + logLevel: "info", + outfile: resolve(outputDirectory, "index.js"), + platform: "node", + sourcemap: true, + target: "node22", +}); diff --git a/integrations/openclaw/src/index.ts b/integrations/openclaw/src/index.ts new file mode 100644 index 000000000..c6253af2b --- /dev/null +++ b/integrations/openclaw/src/index.ts @@ -0,0 +1,12 @@ +// FunASR plugin entrypoint registers realtime transcription support. +import { definePluginEntry } from "openclaw/plugin-sdk/plugin-entry"; +import { buildFunAsrRealtimeTranscriptionProvider } from "./realtime-transcription-provider.js"; + +export default definePluginEntry({ + id: "funasr", + name: "FunASR Realtime Transcription", + description: "Self-hosted realtime speech recognition through FunASR", + register(api) { + api.registerRealtimeTranscriptionProvider(buildFunAsrRealtimeTranscriptionProvider()); + }, +}); diff --git a/integrations/openclaw/src/realtime-transcription-provider.ts b/integrations/openclaw/src/realtime-transcription-provider.ts new file mode 100644 index 000000000..2770d9be2 --- /dev/null +++ b/integrations/openclaw/src/realtime-transcription-provider.ts @@ -0,0 +1,338 @@ +// FunASR provider adapts OpenClaw telephony audio to the official WebSocket API. +import { + createRealtimeTranscriptionWebSocketSession, + type RealtimeTranscriptionProviderConfig, + type RealtimeTranscriptionProviderPlugin, + type RealtimeTranscriptionSession, + type RealtimeTranscriptionSessionCreateRequest, + type RealtimeTranscriptionWebSocketTransport, +} from "openclaw/plugin-sdk/realtime-transcription"; +import { mulawToPcm, resamplePcm } from "openclaw/plugin-sdk/realtime-voice"; +import { normalizeResolvedSecretInputString } from "openclaw/plugin-sdk/secret-input"; +import { + asOptionalRecord as readRecord, + normalizeOptionalString, + parseBooleanValue as readBoolean, +} from "openclaw/plugin-sdk/string-coerce-runtime"; + +type FunAsrMode = "online" | "offline" | "2pass"; + +type FunAsrRealtimeTranscriptionProviderConfig = { + apiKey?: string; + baseUrl?: string; + hotwords?: string; + mode?: FunAsrMode; + useItn?: boolean; +}; + +type FunAsrRealtimeTranscriptionSessionConfig = RealtimeTranscriptionSessionCreateRequest & { + baseUrl: string; + mode: FunAsrMode; + useItn: boolean; + apiKey?: string; + hotwords?: string; +}; + +type FunAsrRealtimeTranscriptionEvent = { + error?: unknown; + is_end?: boolean; + is_final?: boolean; + mode?: string; + text?: string; +}; + +const FUNASR_INPUT_SAMPLE_RATE = 8_000; +const FUNASR_OUTPUT_SAMPLE_RATE = 16_000; +const FUNASR_PCM_FRAME_BYTES = 1_920; +const FUNASR_DEFAULT_MODE: FunAsrMode = "2pass"; +const FUNASR_CONNECT_TIMEOUT_MS = 10_000; +const FUNASR_CLOSE_TIMEOUT_MS = 5_000; +const FUNASR_MAX_RECONNECT_ATTEMPTS = 3; +const FUNASR_RECONNECT_DELAY_MS = 1_000; +const FUNASR_MAX_QUEUED_BYTES = 2 * 1024 * 1024; +const FUNASR_MAX_RETAINED_TRANSCRIPT_BYTES = 256 * 1024; + +function readNestedFunAsrConfig(rawConfig: RealtimeTranscriptionProviderConfig) { + const raw = readRecord(rawConfig); + const providers = readRecord(raw?.providers); + return readRecord(providers?.funasr ?? raw?.funasr ?? raw) ?? {}; +} + +function normalizeFunAsrMode(value: unknown): FunAsrMode | undefined { + const normalized = normalizeOptionalString(value)?.toLowerCase(); + if (!normalized) { + return undefined; + } + if (normalized === "online" || normalized === "offline" || normalized === "2pass") { + return normalized; + } + throw new Error( + `Invalid FunASR mode: "${normalized}" (expected online, offline, or 2pass)`, + ); +} + +function normalizeProviderConfig( + config: RealtimeTranscriptionProviderConfig, +): FunAsrRealtimeTranscriptionProviderConfig { + const raw = readNestedFunAsrConfig(config); + return { + apiKey: normalizeResolvedSecretInputString({ + value: raw.apiKey ?? raw.api_key, + path: "plugins.entries.voice-call.config.streaming.providers.funasr.apiKey", + }), + baseUrl: normalizeOptionalString(raw.baseUrl ?? raw.base_url), + hotwords: normalizeOptionalString(raw.hotwords), + mode: normalizeFunAsrMode(raw.mode), + useItn: readBoolean(raw.useItn ?? raw.use_itn ?? raw.itn), + }; +} + +function normalizeFunAsrWebSocketUrl(value?: string): string { + const resolved = normalizeOptionalString(value ?? process.env.FUNASR_WS_URL); + if (!resolved) { + throw new Error("FunASR WebSocket URL missing"); + } + let parsed: URL; + try { + parsed = new URL(resolved); + } catch { + throw new Error("Invalid FunASR WebSocket URL: value is not a valid URL"); + } + if (parsed.protocol === "http:") { + parsed.protocol = "ws:"; + } else if (parsed.protocol === "https:") { + parsed.protocol = "wss:"; + } else if (parsed.protocol !== "ws:" && parsed.protocol !== "wss:") { + throw new Error( + `Invalid FunASR WebSocket URL: unsupported scheme "${parsed.protocol}" (expected http, https, ws, or wss)`, + ); + } + if (parsed.username || parsed.password) { + throw new Error( + "Invalid FunASR WebSocket URL: embedded credentials are not allowed; configure apiKey instead", + ); + } + return parsed.toString(); +} + +function readErrorDetail(value: unknown): string { + if (typeof value === "string" && value.trim()) { + return value.trim(); + } + const record = readRecord(value); + return ( + normalizeOptionalString(record?.message) ?? + normalizeOptionalString(record?.code) ?? + "unknown server error" + ); +} + +function createFunAsrRealtimeTranscriptionSession( + config: FunAsrRealtimeTranscriptionSessionConfig, +): RealtimeTranscriptionSession { + let currentPartial = ""; + let finalizeRequested = false; + let openedOnce = false; + let pendingPcm = Buffer.alloc(0); + let speechStarted = false; + + const queuePcm = ( + pcm: Buffer, + transport: RealtimeTranscriptionWebSocketTransport, + ) => { + const combined = pendingPcm.length > 0 ? Buffer.concat([pendingPcm, pcm]) : pcm; + let offset = 0; + while (combined.length - offset >= FUNASR_PCM_FRAME_BYTES) { + transport.sendBinary(combined.subarray(offset, offset + FUNASR_PCM_FRAME_BYTES)); + offset += FUNASR_PCM_FRAME_BYTES; + } + pendingPcm = Buffer.from(combined.subarray(offset)); + }; + + const flushPendingPcm = (transport: RealtimeTranscriptionWebSocketTransport) => { + if (pendingPcm.length > 0) { + transport.sendBinary(pendingPcm); + pendingPcm = Buffer.alloc(0); + } + }; + + const clearTurn = () => { + currentPartial = ""; + speechStarted = false; + }; + + const emitSpeechStart = () => { + if (!speechStarted) { + speechStarted = true; + config.onSpeechStart?.(); + } + }; + + const failRetainedTranscript = (transport: RealtimeTranscriptionWebSocketTransport) => { + clearTurn(); + config.onError?.( + new Error( + `FunASR realtime retained transcript exceeded ${FUNASR_MAX_RETAINED_TRANSCRIPT_BYTES} bytes`, + ), + ); + transport.closeNow(); + }; + + const appendPartial = ( + text: string, + transport: RealtimeTranscriptionWebSocketTransport, + ): string | undefined => { + const next = currentPartial + text; + if (Buffer.byteLength(next, "utf8") > FUNASR_MAX_RETAINED_TRANSCRIPT_BYTES) { + failRetainedTranscript(transport); + return undefined; + } + currentPartial = next; + return currentPartial.trim(); + }; + + const emitFinal = ( + text: string, + transport: RealtimeTranscriptionWebSocketTransport, + ) => { + const normalized = text.trim(); + if (Buffer.byteLength(normalized, "utf8") > FUNASR_MAX_RETAINED_TRANSCRIPT_BYTES) { + failRetainedTranscript(transport); + return; + } + clearTurn(); + if (normalized) { + config.onTranscript?.(normalized); + } + if (finalizeRequested) { + transport.closeNow(); + } + }; + + const handleEvent = ( + event: FunAsrRealtimeTranscriptionEvent, + transport: RealtimeTranscriptionWebSocketTransport, + ) => { + if (event.is_end) { + if (event.is_final === false || event.error !== undefined) { + config.onError?.( + new Error(`FunASR server failed to finalize input: ${readErrorDetail(event.error)}`), + ); + } + transport.closeNow(); + return; + } + + const text = typeof event.text === "string" ? event.text : ""; + if (!text.trim()) { + return; + } + emitSpeechStart(); + + if (event.mode === "2pass-online") { + const partial = appendPartial(text, transport); + if (partial) { + config.onPartial?.(partial); + } + return; + } + + if (event.mode === "online") { + const partial = appendPartial(text, transport); + if (!partial) { + return; + } + if (event.is_final) { + emitFinal(currentPartial, transport); + } else { + config.onPartial?.(partial); + } + return; + } + + if (event.mode === "offline" || event.mode === "2pass-offline" || event.is_final) { + emitFinal(text, transport); + } + }; + + return createRealtimeTranscriptionWebSocketSession({ + providerId: "funasr", + callbacks: config, + url: config.baseUrl, + protocols: ["binary"], + headers: config.apiKey ? { Authorization: `Bearer ${config.apiKey}` } : undefined, + readyOnOpen: true, + connectTimeoutMs: FUNASR_CONNECT_TIMEOUT_MS, + closeTimeoutMs: FUNASR_CLOSE_TIMEOUT_MS, + maxReconnectAttempts: FUNASR_MAX_RECONNECT_ATTEMPTS, + reconnectDelayMs: FUNASR_RECONNECT_DELAY_MS, + maxQueuedBytes: FUNASR_MAX_QUEUED_BYTES, + connectTimeoutMessage: "FunASR realtime transcription connection timeout", + connectClosedBeforeReadyMessage: + "FunASR realtime transcription connection closed before ready", + reconnectLimitMessage: "FunASR realtime transcription reconnect limit reached", + onOpen: (transport) => { + if (openedOnce && currentPartial.trim()) { + config.onError?.( + new Error("FunASR connection interrupted; the current partial transcript was discarded"), + ); + } + openedOnce = true; + finalizeRequested = false; + pendingPcm = Buffer.alloc(0); + clearTurn(); + transport.sendJson({ + mode: config.mode, + chunk_size: [5, 10, 5], + chunk_interval: 10, + encoder_chunk_look_back: 4, + decoder_chunk_look_back: 1, + audio_fs: FUNASR_OUTPUT_SAMPLE_RATE, + wav_name: "openclaw", + wav_format: "pcm", + is_speaking: true, + hotwords: config.hotwords ?? "", + itn: config.useItn, + }); + }, + sendAudio: (audio, transport) => { + const pcm8k = mulawToPcm(audio); + queuePcm( + resamplePcm(pcm8k, FUNASR_INPUT_SAMPLE_RATE, FUNASR_OUTPUT_SAMPLE_RATE), + transport, + ); + }, + onClose: (transport) => { + if (finalizeRequested) { + return; + } + finalizeRequested = true; + flushPendingPcm(transport); + transport.sendJson({ is_speaking: false, is_end: true }); + }, + onMessage: (event, transport) => handleEvent(event, transport), + }); +} + +export function buildFunAsrRealtimeTranscriptionProvider(): RealtimeTranscriptionProviderPlugin { + return { + id: "funasr", + label: "FunASR Realtime Transcription", + aliases: ["fun-asr", "funasr-realtime"], + autoSelectOrder: 45, + resolveConfig: ({ rawConfig }) => normalizeProviderConfig(rawConfig), + isConfigured: ({ providerConfig }) => + Boolean(normalizeProviderConfig(providerConfig).baseUrl || process.env.FUNASR_WS_URL), + createSession: (req) => { + const providerConfig = normalizeProviderConfig(req.providerConfig); + return createFunAsrRealtimeTranscriptionSession({ + ...req, + apiKey: providerConfig.apiKey || process.env.FUNASR_API_KEY, + baseUrl: normalizeFunAsrWebSocketUrl(providerConfig.baseUrl), + hotwords: providerConfig.hotwords, + mode: providerConfig.mode ?? FUNASR_DEFAULT_MODE, + useItn: providerConfig.useItn ?? true, + }); + }, + }; +} diff --git a/integrations/openclaw/tests/package-contract.test.ts b/integrations/openclaw/tests/package-contract.test.ts new file mode 100644 index 000000000..f4d6a65b2 --- /dev/null +++ b/integrations/openclaw/tests/package-contract.test.ts @@ -0,0 +1,34 @@ +import { existsSync, readFileSync } from "node:fs"; +import { resolve } from "node:path"; +import { describe, expect, it } from "vitest"; + +const root = resolve(import.meta.dirname, ".."); +const packageJson = JSON.parse(readFileSync(resolve(root, "package.json"), "utf8")) as { + files?: string[]; + name?: string; + openclaw?: { + build?: { openclawVersion?: string }; + compat?: { pluginApi?: string }; + extensions?: string[]; + }; +}; +const manifest = JSON.parse(readFileSync(resolve(root, "openclaw.plugin.json"), "utf8")) as { + contracts?: { realtimeTranscriptionProviders?: string[] }; + id?: string; +}; + +describe("package contract", () => { + it("ships the built entrypoint declared to OpenClaw", () => { + expect(packageJson.name).toBe("openclaw-funasr"); + expect(packageJson.openclaw?.extensions).toEqual(["./dist/index.js"]); + expect(packageJson.files).toContain("dist"); + expect(existsSync(resolve(root, "dist/index.js"))).toBe(true); + }); + + it("declares the provider ownership and host API floor", () => { + expect(manifest.id).toBe("funasr"); + expect(manifest.contracts?.realtimeTranscriptionProviders).toEqual(["funasr"]); + expect(packageJson.openclaw?.compat?.pluginApi).toBe(">=2026.7.2"); + expect(packageJson.openclaw?.build?.openclawVersion).toBe("2026.7.2"); + }); +}); diff --git a/integrations/openclaw/tests/realtime-transcription-provider.test.ts b/integrations/openclaw/tests/realtime-transcription-provider.test.ts new file mode 100644 index 000000000..486fe0c0a --- /dev/null +++ b/integrations/openclaw/tests/realtime-transcription-provider.test.ts @@ -0,0 +1,339 @@ +// FunASR tests cover the official realtime WebSocket protocol adapter. +import { createServer } from "node:http"; +import type { AddressInfo } from "node:net"; +import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import type WebSocket from "ws"; +import type { RawData } from "ws"; +import { WebSocketServer } from "ws"; +import { buildFunAsrRealtimeTranscriptionProvider } from "../src/realtime-transcription-provider.js"; + +let cleanup: (() => Promise) | undefined; + +function rawDataToBuffer(data: RawData): Buffer { + if (Buffer.isBuffer(data)) { + return data; + } + if (Array.isArray(data)) { + return Buffer.concat(data); + } + return Buffer.from(data); +} + +function parseClientMessage(data: RawData): Record { + return JSON.parse(rawDataToBuffer(data).toString("utf8")) as Record; +} + +async function createFunAsrServer(params: { + onConnection?: (ws: WebSocket) => void; + onRequest?: (url: URL, headers: Record) => void; +}) { + const server = createServer(); + const wss = new WebSocketServer({ noServer: true, maxPayload: 1024 * 1024 }); + const clients = new Set(); + + server.on("upgrade", (request, socket, head) => { + params.onRequest?.(new URL(request.url ?? "/", "http://127.0.0.1"), request.headers); + wss.handleUpgrade(request, socket, head, (ws) => { + clients.add(ws); + ws.on("close", () => clients.delete(ws)); + params.onConnection?.(ws); + }); + }); + + await new Promise((resolve) => { + server.listen(0, "127.0.0.1", () => resolve()); + }); + const port = (server.address() as AddressInfo).port; + cleanup = async () => { + for (const ws of clients) { + ws.terminate(); + } + await new Promise((resolve) => { + wss.close(() => resolve()); + }); + await new Promise((resolve) => { + server.close(() => resolve()); + }); + }; + return { baseUrl: `http://127.0.0.1:${port}/asr` }; +} + +describe("buildFunAsrRealtimeTranscriptionProvider", () => { + afterEach(async () => { + await cleanup?.(); + cleanup = undefined; + vi.unstubAllEnvs(); + }); + + it("normalizes nested provider config", () => { + const provider = buildFunAsrRealtimeTranscriptionProvider(); + const resolved = provider.resolveConfig?.({ + cfg: {} as OpenClawConfig, + rawConfig: { + providers: { + funasr: { + base_url: "ws://127.0.0.1:10095/asr", + api_key: "proxy-token", + mode: "online", + hotwords: "OpenClaw,FunASR", + use_itn: "false", + }, + }, + }, + }); + + expect(resolved).toEqual({ + apiKey: "proxy-token", + baseUrl: "ws://127.0.0.1:10095/asr", + hotwords: "OpenClaw,FunASR", + mode: "online", + useItn: false, + }); + }); + + it("uses environment fallbacks without requiring an API key", () => { + vi.stubEnv("FUNASR_WS_URL", "wss://asr.example.test/socket"); + vi.stubEnv("FUNASR_API_KEY", "env-token"); + const provider = buildFunAsrRealtimeTranscriptionProvider(); + + expect(provider.isConfigured({ providerConfig: {} })).toBe(true); + expect(() => provider.createSession({ providerConfig: {} })).not.toThrow(); + }); + + it("requires an explicit FunASR endpoint", () => { + vi.stubEnv("FUNASR_WS_URL", ""); + const provider = buildFunAsrRealtimeTranscriptionProvider(); + + expect(provider.isConfigured({ providerConfig: {} })).toBe(false); + expect(() => provider.createSession({ providerConfig: {} })).toThrow( + "FunASR WebSocket URL missing", + ); + }); + + it.each(["not a url", "ftp://files.example.test/private-marker"])( + "rejects and redacts invalid endpoint %s", + (baseUrl) => { + const provider = buildFunAsrRealtimeTranscriptionProvider(); + try { + provider.createSession({ providerConfig: { baseUrl } }); + throw new Error("expected rejection"); + } catch (error) { + const message = (error as Error).message; + expect(message).toMatch(/^Invalid FunASR WebSocket URL:/); + expect(message).not.toContain("private-marker"); + } + }, + ); + + it("sends protocol setup before converted 16 kHz PCM audio", async () => { + const frames: Array<{ binary: boolean; data: Buffer }> = []; + let authorization: string | string[] | undefined; + let subprotocol: string | string[] | undefined; + const server = await createFunAsrServer({ + onRequest: (_url, headers) => { + authorization = headers.authorization; + subprotocol = headers["sec-websocket-protocol"]; + }, + onConnection: (ws) => { + ws.on("message", (data, binary) => frames.push({ binary, data: rawDataToBuffer(data) })); + }, + }); + const session = buildFunAsrRealtimeTranscriptionProvider().createSession({ + providerConfig: { + apiKey: "proxy-token", + baseUrl: server.baseUrl, + hotwords: "OpenClaw,FunASR", + }, + }); + + await session.connect(); + session.sendAudio(Buffer.alloc(160, 0xff)); + session.sendAudio(Buffer.alloc(160, 0xff)); + session.sendAudio(Buffer.alloc(160, 0xff)); + await vi.waitFor(() => expect(frames).toHaveLength(2)); + session.close(); + + expect(authorization).toBe("Bearer proxy-token"); + expect(subprotocol).toBe("binary"); + expect(frames[0]?.binary).toBe(false); + expect(parseClientMessage(frames[0]!.data)).toEqual({ + audio_fs: 16000, + chunk_interval: 10, + chunk_size: [5, 10, 5], + decoder_chunk_look_back: 1, + encoder_chunk_look_back: 4, + hotwords: "OpenClaw,FunASR", + is_speaking: true, + itn: true, + mode: "2pass", + wav_format: "pcm", + wav_name: "openclaw", + }); + expect(frames[1]?.binary).toBe(true); + expect(frames[1]?.data).toHaveLength(1_920); + }); + + it("flushes a short PCM tail before the end-of-input control", async () => { + const frames: Array<{ binary: boolean; data: Buffer }> = []; + const server = await createFunAsrServer({ + onConnection: (ws) => { + ws.on("message", (data, binary) => { + frames.push({ binary, data: rawDataToBuffer(data) }); + if (!binary && parseClientMessage(data).is_end === true) { + ws.send(JSON.stringify({ mode: "2pass", is_final: true, is_end: true })); + } + }); + }, + }); + const session = buildFunAsrRealtimeTranscriptionProvider().createSession({ + providerConfig: { baseUrl: server.baseUrl }, + }); + + await session.connect(); + session.sendAudio(Buffer.alloc(160, 0xff)); + session.sendAudio(Buffer.alloc(160, 0xff)); + session.close(); + await vi.waitFor(() => expect(frames).toHaveLength(3)); + + expect(frames[1]?.binary).toBe(true); + expect(frames[1]?.data).toHaveLength(1_280); + expect(frames[2]?.binary).toBe(false); + expect(parseClientMessage(frames[2]!.data)).toEqual({ is_end: true, is_speaking: false }); + }); + + it("accumulates 2pass partials and replaces them with the offline final", async () => { + const onPartial = vi.fn(); + const onSpeechStart = vi.fn(); + const onTranscript = vi.fn(); + const server = await createFunAsrServer({ + onConnection: (ws) => { + ws.once("message", () => { + ws.send(JSON.stringify({ mode: "2pass-online", text: "hello " })); + ws.send(JSON.stringify({ mode: "2pass-online", text: "world" })); + ws.send(JSON.stringify({ mode: "2pass-offline", text: "hello world", is_final: true })); + }); + }, + }); + const session = buildFunAsrRealtimeTranscriptionProvider().createSession({ + providerConfig: { baseUrl: server.baseUrl }, + onPartial, + onSpeechStart, + onTranscript, + }); + + await session.connect(); + await vi.waitFor(() => expect(onTranscript).toHaveBeenCalledWith("hello world")); + session.close(); + + expect(onPartial.mock.calls.map(([text]) => text)).toEqual(["hello", "hello world"]); + expect(onSpeechStart).toHaveBeenCalledTimes(1); + expect(onTranscript).toHaveBeenCalledTimes(1); + }); + + it("commits accumulated online text when the server marks it final", async () => { + const onTranscript = vi.fn(); + const server = await createFunAsrServer({ + onConnection: (ws) => { + ws.once("message", () => { + ws.send(JSON.stringify({ mode: "online", text: "ni " })); + ws.send(JSON.stringify({ mode: "online", text: "hao", is_final: true })); + }); + }, + }); + const session = buildFunAsrRealtimeTranscriptionProvider().createSession({ + providerConfig: { baseUrl: server.baseUrl, mode: "online" }, + onTranscript, + }); + + await session.connect(); + await vi.waitFor(() => expect(onTranscript).toHaveBeenCalledWith("ni hao")); + session.close(); + }); + + it("requests explicit end-of-input and closes on the server acknowledgement", async () => { + const controls: Array> = []; + const server = await createFunAsrServer({ + onConnection: (ws) => { + ws.on("message", (data, binary) => { + if (binary) { + return; + } + const message = parseClientMessage(data); + controls.push(message); + if (message.is_end === true) { + ws.send(JSON.stringify({ mode: "2pass", is_final: true, is_end: true })); + } + }); + }, + }); + const session = buildFunAsrRealtimeTranscriptionProvider().createSession({ + providerConfig: { baseUrl: server.baseUrl }, + }); + + await session.connect(); + session.close(); + await vi.waitFor(() => expect(controls.at(-1)?.is_end).toBe(true)); + + expect(controls.at(-1)).toEqual({ is_end: true, is_speaking: false }); + expect(session.isConnected()).toBe(false); + }); + + it("surfaces a failed final acknowledgement", async () => { + const onError = vi.fn(); + const server = await createFunAsrServer({ + onConnection: (ws) => { + ws.on("message", (data, binary) => { + if (!binary && parseClientMessage(data).is_end === true) { + ws.send( + JSON.stringify({ + mode: "2pass", + is_final: false, + is_end: true, + error: "offline inference failed", + }), + ); + } + }); + }, + }); + const session = buildFunAsrRealtimeTranscriptionProvider().createSession({ + providerConfig: { baseUrl: server.baseUrl }, + onError, + }); + + await session.connect(); + session.close(); + await vi.waitFor(() => + expect(onError).toHaveBeenCalledWith( + expect.objectContaining({ + message: "FunASR server failed to finalize input: offline inference failed", + }), + ), + ); + }); + + it("closes a stream that exceeds the retained transcript bound", async () => { + const onError = vi.fn(); + const server = await createFunAsrServer({ + onConnection: (ws) => { + ws.once("message", () => { + ws.send(JSON.stringify({ mode: "online", text: "x".repeat(300 * 1024) })); + }); + }, + }); + const session = buildFunAsrRealtimeTranscriptionProvider().createSession({ + providerConfig: { baseUrl: server.baseUrl, mode: "online" }, + onError, + }); + + await session.connect(); + await vi.waitFor(() => + expect(onError).toHaveBeenCalledWith( + expect.objectContaining({ + message: expect.stringContaining("retained transcript exceeded"), + }), + ), + ); + }); +}); diff --git a/integrations/openclaw/vitest.config.ts b/integrations/openclaw/vitest.config.ts new file mode 100644 index 000000000..87da8a8b1 --- /dev/null +++ b/integrations/openclaw/vitest.config.ts @@ -0,0 +1,41 @@ +import { existsSync, readFileSync, realpathSync } from "node:fs"; +import { resolve } from "node:path"; +import { pathToFileURL } from "node:url"; +import { defineConfig } from "vitest/config"; + +const configuredRoot = process.env.OPENCLAW_ROOT?.trim(); +if (!configuredRoot) { + throw new Error("OPENCLAW_ROOT must point to an OpenClaw source checkout"); +} + +const openClawRoot = realpathSync(configuredRoot); +const packageJsonPath = resolve(openClawRoot, "package.json"); +const realtimeSdkPath = resolve(openClawRoot, "src/plugin-sdk/realtime-transcription.ts"); +if (!existsSync(realtimeSdkPath)) { + throw new Error(`OPENCLAW_ROOT is missing ${realtimeSdkPath}`); +} +const packageJson = JSON.parse(readFileSync(packageJsonPath, "utf8")) as { name?: string }; +if (packageJson.name !== "openclaw") { + throw new Error("OPENCLAW_ROOT does not contain the OpenClaw package"); +} + +const sharedConfigUrl = pathToFileURL( + resolve(openClawRoot, "test/vitest/vitest.shared.config.ts"), +).href; +const { sharedVitestConfig } = (await import(sharedConfigUrl)) as { + sharedVitestConfig: { resolve?: { alias?: unknown[] } }; +}; +const openClawAliases = Array.isArray(sharedVitestConfig.resolve?.alias) + ? sharedVitestConfig.resolve.alias + : []; + +export default defineConfig({ + resolve: { + alias: openClawAliases, + }, + test: { + environment: "node", + include: ["tests/**/*.test.ts"], + testTimeout: 10_000, + }, +});