@openbrt/audioctl 0.1.11 → 0.1.13

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -83,6 +83,12 @@ audioctl light set all --color amber --brightness 0.35
83
83
  audioctl light timeline ./hero.light.json
84
84
  audioctl light bind ./hero.light-bindings.json
85
85
  audioctl light clear
86
+ audioctl wakeword status --json
87
+ audioctl wakeword configure ./wakeword.json --json
88
+ audioctl wakeword bind ./wakeword-bindings.json --json
89
+ audioctl wakeword trigger work_mode --source agent --json
90
+ audioctl firmware check --json
91
+ audioctl firmware update --yes --json
86
92
  audioctl feedback signal ready
87
93
  audioctl feedback flash cyan --repeat 2
88
94
  audioctl feedback volume 66
@@ -97,8 +103,60 @@ audioctl prompt 123456 --agent codex --alias 视听房音箱
97
103
 
98
104
  `inspect` asks the device for a redacted configuration/status snapshot over the
99
105
  existing MQTT channel. Agent scripts can read Wi-Fi connection state, BLE
100
- provisioning state, wake-word capability, playback, VM/app status, and health
101
- without receiving Wi-Fi passwords, MQTT credentials, or music-platform secrets.
106
+ provisioning state, wake-word capability, playback, VM/app status, firmware
107
+ runtime version, and health without receiving Wi-Fi passwords, MQTT
108
+ credentials, or music-platform secrets.
109
+
110
+ `wakeword` is the logical voice-trigger binding layer. It does not train or
111
+ replace the low-level acoustic wake model. It maps existing device wake events,
112
+ ASR phrases, or agent-injected test phrases to VM/app events or safe local
113
+ actions. Agents should read `audioctl inspect --json` first and use reported
114
+ `wakeword.commands` / `voice.wakeword.*` capabilities, not product-specific
115
+ vendor research.
116
+
117
+ ```bash
118
+ audioctl wakeword status --json
119
+ audioctl wakeword configure ./wakeword.json --json
120
+ audioctl wakeword bind ./wakeword-bindings.json --json
121
+ audioctl wakeword trigger work_mode --source agent --json
122
+ audioctl wakeword clear --json
123
+ ```
124
+
125
+ Example wakeword config:
126
+
127
+ ```json
128
+ {
129
+ "schema": "weclawbot.wakeword.config.v1",
130
+ "mode": "logical_voice_trigger",
131
+ "language": "zh",
132
+ "phrases": [
133
+ { "id": "work_mode", "text": "开始工作", "aliases": ["工作模式"] }
134
+ ],
135
+ "bindings": [
136
+ {
137
+ "phrase_id": "work_mode",
138
+ "action": "app.event",
139
+ "feedback": ["feedback.beep", "feedback.signal:wake"]
140
+ }
141
+ ]
142
+ }
143
+ ```
144
+
145
+ `firmware check` reads the official firmware index from
146
+ `https://weclawbot.link/firmware/audio/manifest.json`, compares it with
147
+ `inspect.firmware.version`, and reports whether a host/runtime update is
148
+ available. `firmware update` is intentionally gated: without `--yes` it only
149
+ prints the update plan and `confirmation_required`. An agent must show the
150
+ version, source and notes to the user first, then run `audioctl firmware update
151
+ --yes --json` only after the user explicitly confirms. Firmware updates are for
152
+ the device host/runtime and VM manager; they do not erase Wi-Fi, MQTT binding,
153
+ music-platform settings, queues, rules, or installed VM app slots.
154
+
155
+ ```bash
156
+ audioctl firmware check --json
157
+ audioctl firmware update --json # dry plan, no mutation
158
+ audioctl firmware update --yes --json # runs only after explicit user approval
159
+ ```
102
160
 
103
161
  `light` controls expose programmable visual surfaces. Agents should use
104
162
  `audioctl light describe --json` to inspect surfaces such as `all`, `ring`, and
@@ -265,3 +323,60 @@ Light messages use a distinct command envelope:
265
323
 
266
324
  Supported light commands are `describe`, `set`, `timeline`, `bind`, and
267
325
  `clear`. Continuous light timing and event reactions run on the device VM.
326
+
327
+ Wakeword messages configure the logical voice-trigger layer:
328
+
329
+ ```json
330
+ {
331
+ "schema": "weclawbot.control.v1",
332
+ "id": "wakeword_<uuid>",
333
+ "kind": "wakeword_command",
334
+ "wakeword": {
335
+ "command": "configure",
336
+ "config": {
337
+ "schema": "weclawbot.wakeword.config.v1",
338
+ "mode": "logical_voice_trigger",
339
+ "phrases": [
340
+ { "id": "work_mode", "text": "开始工作" }
341
+ ],
342
+ "bindings": [
343
+ { "phrase_id": "work_mode", "action": "app.event" }
344
+ ]
345
+ }
346
+ }
347
+ }
348
+ ```
349
+
350
+ Supported wakeword commands are `status`, `configure`, `bind`, `trigger`, and
351
+ `clear`. This is a VM/app event binding layer, not low-level acoustic-model
352
+ training.
353
+
354
+ Firmware update messages also use the same MQTT envelope, but carry only an
355
+ official release descriptor. The device downloads the artifact itself from
356
+ `weclawbot.link`, enforces HTTPS, checks SHA-256 and byte size, installs from a
357
+ staging directory, restarts local services, and reports health through
358
+ `inspect`.
359
+
360
+ ```json
361
+ {
362
+ "schema": "weclawbot.control.v1",
363
+ "id": "firmware_<uuid>",
364
+ "kind": "firmware_update",
365
+ "firmware": {
366
+ "schema": "weclawbot.firmware.update.v1",
367
+ "confirmed_by_user": true,
368
+ "release": {
369
+ "schema": "weclawbot.firmware.release.v1",
370
+ "device_class": "audio_voice_ambient_endpoint",
371
+ "runtime": "rokid_music_runtime",
372
+ "version": "0.1.2",
373
+ "channel": "stable",
374
+ "artifact": {
375
+ "url": "https://weclawbot.link/firmware/audio/rokid-music-runtime-0.1.2.tar",
376
+ "sha256": "<64 hex chars>",
377
+ "bytes": 123456
378
+ }
379
+ }
380
+ }
381
+ }
382
+ ```
package/agent-plugin.json CHANGED
@@ -18,7 +18,8 @@
18
18
  "不要读取或解析 audioctl 本地 MQTT 凭据文件",
19
19
  "不要直接 import mqtt、打开或保持 MQTT 长连接来控制设备",
20
20
  "不要写 agent 端 while/sleep/BPM loop 来反复发布 feedback/LED 命令",
21
- "不要把 weclawbot.link 当作实时灯效、音频分析或本地交互的数据面"
21
+ "不要把 weclawbot.link 当作实时灯效、音频分析或本地交互的数据面",
22
+ "不要在未获得用户明确确认时执行固件更新;agent 只能先发现新版并说明变更、版本和来源"
22
23
  ],
23
24
  "continuous_feedback": "连续灯效、节拍灯、呼吸灯、动画和按键反馈必须通过 audioctl light timeline/bind、VM app 或兼容的 feedback effect 一次下发,在设备本地运行;停止使用 audioctl light clear 或 feedback clear。"
24
25
  },
@@ -28,7 +29,8 @@
28
29
  "把用户自然语言目标拆成需要的 I/O、媒体、反馈、输入绑定和持久运行需求",
29
30
  "在用户已授权的平台、连接器、OAuth 或 secret store 中选择合法数据来源",
30
31
  "决定使用 queue、rule、light timeline/bind、feedback、input binding 或 VM app install 的组合",
31
- "在设备 VM 内放置需要离线、低延迟或持续运行的逻辑"
32
+ "在设备 VM 内放置需要离线、低延迟或持续运行的逻辑",
33
+ "运行 audioctl firmware check 发现官网固件新版,并在用户确认后运行 audioctl firmware update --yes"
32
34
  ],
33
35
  "not_prescribed": "插件不会也无法穷举所有 agent 方案;agent 应读取 capabilities 后自己想办法达到目标。"
34
36
  },
@@ -80,9 +82,14 @@
80
82
  "light.timeline",
81
83
  "light.bind",
82
84
  "light.clear",
85
+ "voice.wakeword.configure",
86
+ "voice.wakeword.bind",
87
+ "voice.wakeword.trigger",
88
+ "voice.event.bind",
83
89
  "event.bind",
84
90
  "iot.control",
85
91
  "device.inspect",
92
+ "firmware.update",
86
93
  "music.autopilot",
87
94
  "music.set_rule",
88
95
  "music.set_queue",
@@ -117,6 +124,19 @@
117
124
  "intent": "让音箱作为语音遥控、家庭智能或语音互动端点",
118
125
  "primary_command": "audioctl app install MANIFEST.json APP.wasm",
119
126
  "agent_strategy": "不要把设备假定为音乐播放器。先运行 audioctl inspect --json 读取设备能力,再生成声明最小 capabilities 的 VM app。语音、TTS、家居控制等能力必须通过 manifest capabilities 显式声明;设备侧只执行授权范围内的 I/O。"
127
+ },
128
+ {
129
+ "intent": "配置唤醒词或语音短语来执行某个操作",
130
+ "primary_command": "audioctl wakeword configure WAKEWORD.json --json",
131
+ "agent_strategy": "先运行 audioctl inspect --json 读取 wakeword.configurable、wakeword.commands、voice.wakeword.* 和 voice.event.bind capabilities。把 wakeword 视为逻辑语音触发绑定层:它把设备已有唤醒事件、ASR 短语或 agent 注入的测试短语映射到 VM/app 事件或本地动作。不要搜索品牌/型号/厂家资料,不要尝试训练或替换底层声学唤醒模型。需要让 VM app 处理时,在 app manifest 里声明最小 voice.wakeword.bind / voice.event.bind 能力和 wakeword_bindings;需要只测试触发时用 audioctl wakeword trigger WORD --json。",
132
+ "if_missing_capability": "如果 inspect 未报告 wakeword.configurable 或 voice.wakeword.*,先运行 audioctl firmware check --json;发现官网新版后向用户说明并请求确认升级。"
133
+ },
134
+ {
135
+ "intent": "检查并升级设备固件",
136
+ "primary_command": "audioctl firmware check --json",
137
+ "agent_strategy": "先运行 audioctl firmware check --json,从 https://weclawbot.link/firmware/audio/manifest.json 发现最新官方固件和当前设备版本。只向用户展示版本、来源、release notes 和风险提示;不得自行执行更新。只有用户明确确认后,才运行 audioctl firmware update --yes --json。固件更新是设备 host/runtime 更新,不等同于 VM app install;更新过程中不要读取 MQTT 凭据或私钥。",
138
+ "if_update_available": "向用户确认:官网发现新固件,是否现在更新?说明版本、预计会重启部分本地服务、不会清除 Wi-Fi/MQTT/音乐平台/VM app 配置。",
139
+ "if_user_confirms": "运行 audioctl firmware update --yes --json,并在完成后运行 audioctl inspect --json 验证 firmware.version、health 和 vm 状态。"
120
140
  }
121
141
  ],
122
142
  "commands": [
@@ -144,6 +164,10 @@
144
164
  "name": "light",
145
165
  "usage": "audioctl light describe | set SURFACE --color COLOR [--brightness 0..1] | timeline TIMELINE.json | bind BINDINGS.json | clear"
146
166
  },
167
+ {
168
+ "name": "wakeword",
169
+ "usage": "audioctl wakeword status | configure CONFIG.json | bind BINDINGS.json | trigger WORD [--source NAME] | clear"
170
+ },
147
171
  {
148
172
  "name": "led",
149
173
  "usage": "audioctl led flash COLOR [--repeat N]"
@@ -164,6 +188,10 @@
164
188
  "name": "app install",
165
189
  "usage": "audioctl app install MANIFEST.json APP.wasm"
166
190
  },
191
+ {
192
+ "name": "firmware",
193
+ "usage": "audioctl firmware check [--manifest URL] | firmware update [--manifest URL] --yes"
194
+ },
167
195
  {
168
196
  "name": "workbgm",
169
197
  "usage": "audioctl workbgm --play"
package/bin/audioctl.mjs CHANGED
@@ -7,6 +7,7 @@ import {
7
7
  COMMAND_NAME,
8
8
  DEFAULT_CREDENTIALS_PATH,
9
9
  DEFAULT_ENDPOINT,
10
+ DEFAULT_FIRMWARE_MANIFEST_URL,
10
11
  DEEPNIGHT_RULE,
11
12
  PACKAGE_NAME,
12
13
  bindAgent,
@@ -14,12 +15,16 @@ import {
14
15
  buildAgentPrompt,
15
16
  buildAudioControl,
16
17
  buildFeedbackControl,
18
+ buildFirmwareUpdateControl,
17
19
  buildLightControl,
18
20
  buildQueueControl,
21
+ buildWakewordControl,
19
22
  buildWorkBgmAppInstallControl,
20
23
  expandPath,
21
24
  normalizeCredentials,
25
+ planFirmwareUpdate,
22
26
  publishAudioControl,
27
+ readFirmwareIndex,
23
28
  readCredentials,
24
29
  redactAgentFacingValue,
25
30
  remediationForStatus,
@@ -48,6 +53,7 @@ async function run(name, values) {
48
53
  if (name === "inspect") return commandInspect(values);
49
54
  if (name === "prompt") return commandPrompt(values);
50
55
  if (name === "manifest") return commandManifest();
56
+ if (name === "firmware") return commandFirmware(values);
51
57
  if (name === "app") return commandApp(values);
52
58
  if (name === "queue") return commandQueue(values);
53
59
  if (name === "rule") return commandRule(values);
@@ -56,6 +62,7 @@ async function run(name, values) {
56
62
  if (name === "volume") return commandVolume(values);
57
63
  if (name === "seek") return commandSeek(values);
58
64
  if (name === "light") return commandLight(values);
65
+ if (name === "wakeword" || name === "voice") return commandWakeword(values);
59
66
  if (name === "feedback" || name === "led") return commandFeedback(values);
60
67
  return commandControl(name, values);
61
68
  }
@@ -80,6 +87,13 @@ function usage(exitCode = 0) {
80
87
  ${COMMAND_NAME} light timeline TIMELINE.json
81
88
  ${COMMAND_NAME} light bind BINDINGS.json
82
89
  ${COMMAND_NAME} light clear
90
+ ${COMMAND_NAME} wakeword status
91
+ ${COMMAND_NAME} wakeword configure CONFIG.json
92
+ ${COMMAND_NAME} wakeword bind BINDINGS.json
93
+ ${COMMAND_NAME} wakeword trigger WORD [--source NAME]
94
+ ${COMMAND_NAME} wakeword clear
95
+ ${COMMAND_NAME} firmware check [--manifest URL]
96
+ ${COMMAND_NAME} firmware update [--manifest URL] [--yes]
83
97
  ${COMMAND_NAME} rule RULE.json
84
98
  ${COMMAND_NAME} queue QUEUE.json [--play]
85
99
  ${COMMAND_NAME} app install MANIFEST.json APP.wasm
@@ -90,6 +104,7 @@ function usage(exitCode = 0) {
90
104
  Options:
91
105
  --credentials FILE override audioctl-managed local credential file
92
106
  --endpoint URL default: ${DEFAULT_ENDPOINT}
107
+ --manifest URL default: ${DEFAULT_FIRMWARE_MANIFEST_URL}
93
108
  --timeout SECONDS default: 12
94
109
  --json machine-readable output
95
110
  --nowait publish without waiting for device status
@@ -208,6 +223,78 @@ async function commandManifest() {
208
223
  process.stdout.write(`${JSON.stringify(manifest, null, 2)}\n`);
209
224
  }
210
225
 
226
+ async function commandFirmware(values) {
227
+ const subcommand = String(values[0] || "check").replace(/-/gu, "_").toLowerCase();
228
+ if (!new Set(["check", "update", "upgrade"]).has(subcommand)) {
229
+ throw new Error(`firmware_subcommand_unsupported: ${subcommand}`);
230
+ }
231
+ const options = parseOptions(values.slice(1), {
232
+ credentials: credentialsPath(),
233
+ manifest: process.env.WEC_FIRMWARE_MANIFEST || DEFAULT_FIRMWARE_MANIFEST_URL,
234
+ channel: "stable",
235
+ timeout: subcommand === "check" ? 20 : 120,
236
+ json: false,
237
+ nowait: false,
238
+ yes: false,
239
+ force: false,
240
+ });
241
+ const credentials = await readCredentials(options.credentials);
242
+ const firmwareIndex = await readFirmwareIndex(options.manifest, {
243
+ timeoutMs: Number(options.timeout) * 1000,
244
+ });
245
+ const statusDelivery = await publishAudioControl(credentials, buildAudioControl("status"), {
246
+ timeoutMs: Number(options.timeout) * 1000,
247
+ });
248
+ if (statusDelivery.status?.kind === "rejected") {
249
+ print(statusDelivery.status, options.json);
250
+ process.exitCode = 1;
251
+ return;
252
+ }
253
+ const inspect = statusDelivery.status?.inspect || statusDelivery.status || {};
254
+ const plan = planFirmwareUpdate(firmwareIndex, inspect, {
255
+ channel: options.channel,
256
+ force: options.force,
257
+ });
258
+ if (subcommand === "check") {
259
+ print(plan, options.json);
260
+ return;
261
+ }
262
+ if (!plan.supported) {
263
+ print({
264
+ ...plan,
265
+ update_started: false,
266
+ reason: "device_does_not_advertise_firmware.update",
267
+ }, options.json);
268
+ process.exitCode = 1;
269
+ return;
270
+ }
271
+ if (!plan.release || !plan.update_available) {
272
+ print({
273
+ ...plan,
274
+ update_started: false,
275
+ }, options.json);
276
+ return;
277
+ }
278
+ if (!options.yes) {
279
+ print({
280
+ ...plan,
281
+ update_started: false,
282
+ confirmation_required: true,
283
+ next_step: `Ask the user to confirm, then run: ${COMMAND_NAME} firmware update --yes`,
284
+ }, options.json);
285
+ return;
286
+ }
287
+ const control = buildFirmwareUpdateControl(plan.release, {
288
+ yes: true,
289
+ });
290
+ const delivery = await publishAudioControl(credentials, control, {
291
+ timeoutMs: Number(options.timeout) * 1000,
292
+ wait: !options.nowait,
293
+ });
294
+ print(delivery.status || delivery, options.json);
295
+ if (delivery.status?.kind === "rejected") process.exitCode = 1;
296
+ }
297
+
211
298
  async function commandRule(values) {
212
299
  const options = parseOptions(values, {
213
300
  credentials: credentialsPath(),
@@ -376,6 +463,56 @@ async function commandLight(values) {
376
463
  if (delivery.status?.kind === "rejected") process.exitCode = 1;
377
464
  }
378
465
 
466
+ async function commandWakeword(values) {
467
+ const subcommand = String(values[0] || "status").replace(/-/gu, "_").toLowerCase();
468
+ const options = parseOptions(values.slice(1), {
469
+ credentials: credentialsPath(),
470
+ timeout: 12,
471
+ json: false,
472
+ nowait: false,
473
+ source: "agent",
474
+ });
475
+ let control;
476
+ if (subcommand === "status" || subcommand === "describe" || subcommand === "info") {
477
+ control = buildWakewordControl("status");
478
+ } else if (subcommand === "configure" || subcommand === "config" || subcommand === "set") {
479
+ const file = String(options._[0] || "");
480
+ if (!file) throw new Error("wakeword_configure_requires_file");
481
+ control = buildWakewordControl("configure", {
482
+ config: JSON.parse(await fs.readFile(file, "utf8")),
483
+ });
484
+ } else if (subcommand === "bind" || subcommand === "bindings") {
485
+ const file = String(options._[0] || "");
486
+ if (!file) throw new Error("wakeword_bind_requires_file");
487
+ const payload = JSON.parse(await fs.readFile(file, "utf8"));
488
+ control = buildWakewordControl("bind", {
489
+ bindings: Array.isArray(payload) ? payload : payload.bindings,
490
+ });
491
+ } else if (subcommand === "trigger" || subcommand === "test" || subcommand === "event") {
492
+ control = buildWakewordControl("trigger", {
493
+ word: options._[0] || options.word || options.phrase || "factory_wake",
494
+ source: options.source,
495
+ });
496
+ } else if (subcommand === "clear" || subcommand === "reset") {
497
+ control = buildWakewordControl("clear");
498
+ } else {
499
+ throw new Error(`wakeword_subcommand_unsupported: ${subcommand}`);
500
+ }
501
+
502
+ const credentials = await readCredentials(options.credentials);
503
+ const delivery = await publishAudioControl(credentials, control, {
504
+ timeoutMs: Number(options.timeout) * 1000,
505
+ wait: !options.nowait,
506
+ });
507
+ if (control.wakeword.command === "status" && options.json) {
508
+ const wakeword = delivery.status?.inspect?.wakeword || delivery.status?.wakeword || {};
509
+ process.stdout.write(`${JSON.stringify(redactAgentFacingValue(wakeword))}\n`);
510
+ } else {
511
+ print(delivery.status || delivery, options.json);
512
+ }
513
+ if (delivery.status?.kind === "rejected") process.exitCode = 1;
514
+ }
515
+
379
516
  async function commandFeedback(values) {
380
517
  const subcommand = String(values[0] || "").replace(/-/gu, "_").toLowerCase();
381
518
  const shorthandSignals = new Set([
@@ -485,7 +622,7 @@ function parseOptions(values, defaults = {}) {
485
622
  continue;
486
623
  }
487
624
  const key = value.slice(2);
488
- if (key === "json" || key === "online" || key === "nowait" || key === "play") {
625
+ if (key === "json" || key === "online" || key === "nowait" || key === "play" || key === "yes" || key === "force") {
489
626
  options[key] = true;
490
627
  continue;
491
628
  }
@@ -499,14 +636,19 @@ function parseOptions(values, defaults = {}) {
499
636
  "credentials",
500
637
  "endpoint",
501
638
  "effect",
639
+ "channel",
502
640
  "level",
641
+ "manifest",
503
642
  "name",
504
643
  "pattern",
505
644
  "profile",
506
645
  "repeat",
646
+ "phrase",
647
+ "source",
507
648
  "surface",
508
649
  "timeout",
509
650
  "volume",
651
+ "word",
510
652
  ]).has(key)) {
511
653
  throw new Error(`option_unknown: --${key}`);
512
654
  }
package/lib/index.mjs CHANGED
@@ -8,6 +8,8 @@ import mqtt from "mqtt";
8
8
  export const PACKAGE_NAME = "@openbrt/audioctl";
9
9
  export const COMMAND_NAME = "audioctl";
10
10
  export const DEFAULT_ENDPOINT = "https://weclawbot.link/byoa";
11
+ export const DEFAULT_FIRMWARE_MANIFEST_URL =
12
+ "https://weclawbot.link/firmware/audio/manifest.json";
11
13
  export const DEFAULT_CREDENTIALS_PATH = path.join(
12
14
  os.homedir(),
13
15
  ".config",
@@ -50,6 +52,9 @@ export const WORKBGM_RULE = Object.freeze({
50
52
  export const PLAYABLE_QUEUE_SCHEMA = "weclawbot.playable_queue.v1";
51
53
  export const LIGHT_TIMELINE_SCHEMA = "weclawbot.light.timeline.v1";
52
54
  export const LIGHT_BINDINGS_SCHEMA = "weclawbot.light.bind.v1";
55
+ export const WAKEWORD_CONFIG_SCHEMA = "weclawbot.wakeword.config.v1";
56
+ export const FIRMWARE_INDEX_SCHEMA = "weclawbot.firmware.index.v1";
57
+ export const FIRMWARE_RELEASE_SCHEMA = "weclawbot.firmware.release.v1";
53
58
 
54
59
  export const FEEDBACK_COLOR_NAMES = Object.freeze({
55
60
  red: Object.freeze({ red: 255, green: 0, blue: 0, alpha: 255 }),
@@ -102,6 +107,13 @@ export const LIGHT_EVENTS = Object.freeze([
102
107
  "input.ring.single_click",
103
108
  "input.ring.clockwise",
104
109
  "input.ring.counter_clockwise",
110
+ "voice.wakeword.triggered",
111
+ "voice.phrase.matched",
112
+ ]);
113
+
114
+ export const WAKEWORD_EVENTS = Object.freeze([
115
+ "voice.wakeword.triggered",
116
+ "voice.phrase.matched",
105
117
  ]);
106
118
 
107
119
  const WORKBGM_APP_WASM_BASE64 =
@@ -253,6 +265,16 @@ export function buildLightControl(command, options = {}) {
253
265
  };
254
266
  }
255
267
 
268
+ export function buildWakewordControl(command, options = {}) {
269
+ const wakeword = normalizeWakewordCommand(command, options);
270
+ return {
271
+ schema: "weclawbot.control.v1",
272
+ id: string(options.id) || `wakeword_${crypto.randomUUID()}`,
273
+ kind: "wakeword_command",
274
+ wakeword,
275
+ };
276
+ }
277
+
256
278
  export function buildQueueControl(options = {}) {
257
279
  return buildAudioControl("set_queue", {
258
280
  id: options.id,
@@ -284,6 +306,20 @@ export function buildAppInstallControl(options = {}) {
284
306
  };
285
307
  }
286
308
 
309
+ export function buildFirmwareUpdateControl(release, options = {}) {
310
+ const firmwareRelease = normalizeFirmwareRelease(release);
311
+ return {
312
+ schema: "weclawbot.control.v1",
313
+ id: string(options.id) || `firmware_${crypto.randomUUID()}`,
314
+ kind: "firmware_update",
315
+ firmware: {
316
+ schema: "weclawbot.firmware.update.v1",
317
+ release: firmwareRelease,
318
+ confirmed_by_user: options.confirmedByUser === true || options.yes === true,
319
+ },
320
+ };
321
+ }
322
+
287
323
  export function buildWorkBgmAppInstallControl(options = {}) {
288
324
  const play = options.play === true || options.autoplay === true;
289
325
  return buildAppInstallControl({
@@ -701,6 +737,293 @@ function normalizeLightEvent(value) {
701
737
  return event;
702
738
  }
703
739
 
740
+ export function normalizeWakewordCommand(command, options = {}) {
741
+ const aliases = new Map([
742
+ ["info", "status"],
743
+ ["describe", "status"],
744
+ ["config", "configure"],
745
+ ["set", "configure"],
746
+ ["update", "configure"],
747
+ ["bindings", "bind"],
748
+ ["binding", "bind"],
749
+ ["event", "trigger"],
750
+ ["phrase", "trigger"],
751
+ ["test", "trigger"],
752
+ ["reset", "clear"],
753
+ ]);
754
+ const raw = string(command || options.command || options.action || "status")
755
+ .replace(/-/gu, "_")
756
+ .toLowerCase();
757
+ const normalized = aliases.get(raw) || raw;
758
+ if (!new Set(["status", "configure", "bind", "trigger", "clear"]).has(normalized)) {
759
+ throw new Error(`wakeword_command_unsupported: ${command}`);
760
+ }
761
+ const wakeword = { command: normalized };
762
+ if (normalized === "configure") {
763
+ wakeword.config = normalizeWakewordConfig(
764
+ options.config && typeof options.config === "object" ? options.config : options,
765
+ );
766
+ }
767
+ if (normalized === "bind") {
768
+ const source =
769
+ Array.isArray(options.bindings)
770
+ ? { bindings: options.bindings }
771
+ : Array.isArray(options.binding)
772
+ ? { bindings: options.binding }
773
+ : options.bindings && typeof options.bindings === "object"
774
+ ? options.bindings
775
+ : options.binding && typeof options.binding === "object"
776
+ ? options.binding
777
+ : options.config && typeof options.config === "object"
778
+ ? options.config
779
+ : options;
780
+ wakeword.bindings = Array.isArray(source.bindings)
781
+ ? source.bindings.slice(0, 32).map(normalizeWakewordBinding)
782
+ : [];
783
+ }
784
+ if (normalized === "trigger") {
785
+ const word = string(
786
+ options.word ||
787
+ options.phrase ||
788
+ options.phrase_id ||
789
+ options.phraseId ||
790
+ options.value ||
791
+ "factory_wake",
792
+ );
793
+ wakeword.word = word.slice(0, 80) || "factory_wake";
794
+ wakeword.source = string(options.source || "agent").slice(0, 80) || "agent";
795
+ }
796
+ return wakeword;
797
+ }
798
+
799
+ export function normalizeWakewordConfig(value = {}) {
800
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
801
+ throw new Error("wakeword_config_invalid");
802
+ }
803
+ const phrasesInput = Array.isArray(value.phrases) ? value.phrases : [];
804
+ const bindingsInput = Array.isArray(value.bindings) ? value.bindings : [];
805
+ return {
806
+ schema: WAKEWORD_CONFIG_SCHEMA,
807
+ mode: "logical_voice_trigger",
808
+ language: string(value.language || "zh").slice(0, 16),
809
+ runtime_detection: "factory_wake_or_agent_injected_phrase",
810
+ phrases: phrasesInput.slice(0, 12).map(normalizeWakewordPhrase),
811
+ bindings: bindingsInput.slice(0, 32).map(normalizeWakewordBinding),
812
+ };
813
+ }
814
+
815
+ export function normalizeWakewordPhrase(value, index = 0) {
816
+ const text = string(value?.text || value?.phrase || value?.word);
817
+ const id = normalizeWakewordIdentifier(
818
+ value?.id || value?.phrase_id,
819
+ `phrase_${index + 1}`,
820
+ );
821
+ if (!text || text.length > 40) {
822
+ throw new Error(`wakeword_phrase_invalid:${index + 1}`);
823
+ }
824
+ const aliases = Array.isArray(value.aliases)
825
+ ? value.aliases.map(string).filter(Boolean).slice(0, 8)
826
+ : [];
827
+ for (const alias of aliases) {
828
+ if (alias.length > 40) throw new Error(`wakeword_alias_invalid:${index + 1}`);
829
+ }
830
+ const sensitivity = Number(value.sensitivity);
831
+ return {
832
+ id,
833
+ text,
834
+ aliases,
835
+ enabled: value.enabled !== false,
836
+ sensitivity: Number.isFinite(sensitivity)
837
+ ? Math.max(0, Math.min(1, Math.round(sensitivity * 1000) / 1000))
838
+ : 0.5,
839
+ };
840
+ }
841
+
842
+ export function normalizeWakewordBinding(value, index = 0) {
843
+ const phraseId = normalizeWakewordIdentifier(
844
+ value?.phrase_id || value?.phraseId || value?.id || "*",
845
+ "*",
846
+ { wildcard: true },
847
+ );
848
+ const action = string(value?.action || "app.event")
849
+ .replace(/-/gu, "_")
850
+ .toLowerCase()
851
+ .slice(0, 80);
852
+ if (
853
+ !new Set([
854
+ "app.event",
855
+ "music.play",
856
+ "music.pause",
857
+ "music.toggle",
858
+ "music.next",
859
+ "music.previous",
860
+ "feedback.beep",
861
+ "light.clear",
862
+ ]).has(action) &&
863
+ !action.startsWith("feedback.signal:")
864
+ ) {
865
+ throw new Error(`wakeword_action_unsupported:${index + 1}`);
866
+ }
867
+ const feedback = Array.isArray(value.feedback)
868
+ ? value.feedback.map(string).filter(Boolean).slice(0, 8)
869
+ : [];
870
+ return {
871
+ phrase_id: phraseId,
872
+ action,
873
+ target: string(value?.target).slice(0, 120),
874
+ feedback,
875
+ };
876
+ }
877
+
878
+ function normalizeWakewordIdentifier(value, fallback = "", options = {}) {
879
+ if (options.wildcard && string(value || fallback) === "*") return "*";
880
+ const text = string(value || fallback)
881
+ .toLowerCase()
882
+ .replace(/[^a-z0-9_.:-]+/gu, "_")
883
+ .replace(/^_+|_+$/gu, "")
884
+ .slice(0, 80);
885
+ if (!/^[a-z0-9][a-z0-9_.:-]{0,79}$/u.test(text)) {
886
+ throw new Error("wakeword_identifier_invalid");
887
+ }
888
+ return text;
889
+ }
890
+
891
+ export async function readFirmwareIndex(source = DEFAULT_FIRMWARE_MANIFEST_URL, options = {}) {
892
+ const location = string(source) || DEFAULT_FIRMWARE_MANIFEST_URL;
893
+ let payload;
894
+ if (/^https?:\/\//iu.test(location)) {
895
+ const response = await fetch(location, {
896
+ headers: { accept: "application/json" },
897
+ signal: AbortSignal.timeout(Math.max(1000, Number(options.timeoutMs) || 15_000)),
898
+ });
899
+ if (!response.ok) {
900
+ throw new Error(`firmware_manifest_http_${response.status}`);
901
+ }
902
+ payload = await response.json();
903
+ } else if (location.startsWith("file://")) {
904
+ payload = JSON.parse(await fs.readFile(new URL(location), "utf8"));
905
+ } else {
906
+ payload = JSON.parse(await fs.readFile(expandPath(location), "utf8"));
907
+ }
908
+ return normalizeFirmwareIndex(payload, location);
909
+ }
910
+
911
+ export function normalizeFirmwareIndex(value, source = DEFAULT_FIRMWARE_MANIFEST_URL) {
912
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
913
+ throw new Error("firmware_manifest_invalid");
914
+ }
915
+ const releasesInput = Array.isArray(value.releases) ? value.releases : [];
916
+ if (value.schema !== FIRMWARE_INDEX_SCHEMA || releasesInput.length < 1) {
917
+ throw new Error("firmware_manifest_invalid");
918
+ }
919
+ return {
920
+ schema: FIRMWARE_INDEX_SCHEMA,
921
+ device_class: string(value.device_class || ABSTRACT_HARDWARE_TYPE).slice(0, 80),
922
+ channel: string(value.channel || "stable").slice(0, 40),
923
+ generated_at: string(value.generated_at).slice(0, 80),
924
+ source: string(source).slice(0, 240),
925
+ releases: releasesInput.map(normalizeFirmwareRelease),
926
+ };
927
+ }
928
+
929
+ export function normalizeFirmwareRelease(value) {
930
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
931
+ throw new Error("firmware_release_invalid");
932
+ }
933
+ const artifact = value.artifact && typeof value.artifact === "object" ? value.artifact : {};
934
+ const url = string(artifact.url || value.url);
935
+ if (!/^https:\/\/(?:www\.)?weclawbot\.link\/firmware\//iu.test(url)) {
936
+ throw new Error("firmware_artifact_url_invalid");
937
+ }
938
+ const sha256 = string(artifact.sha256 || value.sha256).toLowerCase();
939
+ if (!/^[0-9a-f]{64}$/u.test(sha256)) {
940
+ throw new Error("firmware_artifact_sha256_invalid");
941
+ }
942
+ const bytes = Math.floor(Number(artifact.bytes || artifact.size || value.bytes || value.size) || 0);
943
+ if (bytes < 1 || bytes > 96 * 1024 * 1024) {
944
+ throw new Error("firmware_artifact_size_invalid");
945
+ }
946
+ const version = string(value.version).replace(/^v/iu, "");
947
+ if (!/^[0-9]+(?:\.[0-9]+){1,3}(?:[-+][A-Za-z0-9_.-]+)?$/u.test(version)) {
948
+ throw new Error("firmware_version_invalid");
949
+ }
950
+ return {
951
+ schema: FIRMWARE_RELEASE_SCHEMA,
952
+ device_class: string(value.device_class || ABSTRACT_HARDWARE_TYPE).slice(0, 80),
953
+ runtime: string(value.runtime || "rokid_music_runtime").slice(0, 80),
954
+ version,
955
+ channel: string(value.channel || "stable").slice(0, 40),
956
+ host_api: string(value.host_api || "weclawbot.host.v0").slice(0, 80),
957
+ released_at: string(value.released_at).slice(0, 80),
958
+ min_current_version: string(value.min_current_version || "0.0.0").replace(/^v/iu, "").slice(0, 40),
959
+ requires_user_confirmation: value.requires_user_confirmation !== false,
960
+ notes: Array.isArray(value.notes)
961
+ ? value.notes.map((note) => string(note).slice(0, 180)).filter(Boolean).slice(0, 12)
962
+ : [],
963
+ artifact: {
964
+ url,
965
+ sha256,
966
+ bytes,
967
+ format: string(artifact.format || "tar").slice(0, 32),
968
+ },
969
+ };
970
+ }
971
+
972
+ export function compareVersions(a, b) {
973
+ const parse = (value) => string(value)
974
+ .replace(/^v/iu, "")
975
+ .split(/[-+]/u)[0]
976
+ .split(".")
977
+ .map((part) => Number.parseInt(part, 10) || 0);
978
+ const left = parse(a);
979
+ const right = parse(b);
980
+ const length = Math.max(left.length, right.length, 3);
981
+ for (let index = 0; index < length; index += 1) {
982
+ const difference = (left[index] || 0) - (right[index] || 0);
983
+ if (difference !== 0) return difference > 0 ? 1 : -1;
984
+ }
985
+ return 0;
986
+ }
987
+
988
+ export function planFirmwareUpdate(index, inspect = {}, options = {}) {
989
+ const firmwareIndex = normalizeFirmwareIndex(index, index?.source || DEFAULT_FIRMWARE_MANIFEST_URL);
990
+ const deviceClass =
991
+ string(inspect.device_type) ||
992
+ string(inspect.device?.abstract_hardware) ||
993
+ ABSTRACT_HARDWARE_TYPE;
994
+ const channel = string(options.channel || firmwareIndex.channel || "stable");
995
+ const currentVersion =
996
+ string(inspect.firmware?.version || inspect.device?.firmware_version || "0.0.0")
997
+ .replace(/^v/iu, "") || "0.0.0";
998
+ const capabilities = new Set(Array.isArray(inspect.capabilities) ? inspect.capabilities : []);
999
+ const candidates = firmwareIndex.releases
1000
+ .filter((release) => release.device_class === deviceClass)
1001
+ .filter((release) => !channel || release.channel === channel)
1002
+ .filter((release) => compareVersions(currentVersion, release.min_current_version || "0.0.0") >= 0)
1003
+ .sort((a, b) => compareVersions(b.version, a.version));
1004
+ const release = candidates[0] || null;
1005
+ const supported = capabilities.size === 0 || capabilities.has("firmware.update");
1006
+ const updateAvailable = Boolean(release) &&
1007
+ (options.force === true || compareVersions(release.version, currentVersion) > 0);
1008
+ return {
1009
+ schema: "weclawbot.firmware.plan.v1",
1010
+ device_class: deviceClass,
1011
+ current_version: currentVersion,
1012
+ channel,
1013
+ supported,
1014
+ update_available: updateAvailable,
1015
+ latest_version: release?.version || "",
1016
+ release,
1017
+ source: firmwareIndex.source,
1018
+ requires_user_confirmation: Boolean(updateAvailable && release?.requires_user_confirmation !== false),
1019
+ summary: release
1020
+ ? (updateAvailable
1021
+ ? `Firmware ${release.version} is available for ${deviceClass}.`
1022
+ : `Firmware ${currentVersion} is up to date for ${deviceClass}.`)
1023
+ : `No firmware release found for ${deviceClass} on ${channel}.`,
1024
+ };
1025
+ }
1026
+
704
1027
  function normalizeIdentifier(value, options = {}) {
705
1028
  const text = string(value);
706
1029
  const maximum = Number(options.maximum) || 80;
@@ -839,6 +1162,10 @@ export function normalizeAppManifest(manifest, artifact = {}) {
839
1162
  "audio.output.write",
840
1163
  "audio.tts",
841
1164
  "voice.wakeword",
1165
+ "voice.wakeword.configure",
1166
+ "voice.wakeword.bind",
1167
+ "voice.wakeword.trigger",
1168
+ "voice.event.bind",
842
1169
  "voice.capture",
843
1170
  "input.touch.read",
844
1171
  "input.button.bind",
@@ -874,6 +1201,7 @@ export function normalizeAppManifest(manifest, artifact = {}) {
874
1201
  if (wasmBytes < 1 || wasmBytes > wasmLimit) {
875
1202
  throw new Error("app_wasm_size_invalid");
876
1203
  }
1204
+ const wakeword = normalizeAppWakewordSpec(manifest);
877
1205
  return {
878
1206
  schema: "weclawbot.app.v1",
879
1207
  app_id: string(manifest.app_id),
@@ -909,12 +1237,30 @@ export function normalizeAppManifest(manifest, artifact = {}) {
909
1237
  persist_on_reboot: manifest.install?.persist_on_reboot !== false,
910
1238
  source: "audioctl",
911
1239
  },
1240
+ ...(wakeword ? { wakeword } : {}),
912
1241
  ...(manifest.autopilot
913
1242
  ? { autopilot: normalizeMusicAutopilot(manifest.autopilot) }
914
1243
  : {}),
915
1244
  };
916
1245
  }
917
1246
 
1247
+ function normalizeAppWakewordSpec(manifest) {
1248
+ const source = manifest.wakeword && typeof manifest.wakeword === "object"
1249
+ ? manifest.wakeword
1250
+ : {};
1251
+ const phrases = manifest.wakeword_phrases || source.phrases;
1252
+ const bindings = manifest.wakeword_bindings || source.bindings;
1253
+ if (!Array.isArray(phrases) && !Array.isArray(bindings)) return null;
1254
+ return {
1255
+ ...normalizeWakewordConfig({
1256
+ language: source.language || manifest.language || "zh",
1257
+ phrases: Array.isArray(phrases) ? phrases : [],
1258
+ bindings: Array.isArray(bindings) ? bindings : [],
1259
+ }),
1260
+ app_id: string(manifest.app_id).slice(0, 80),
1261
+ };
1262
+ }
1263
+
918
1264
  export function normalizeMusicAutopilot(value) {
919
1265
  if (!value || value.schema !== "weclawbot.music_autopilot.v1") {
920
1266
  throw new Error("music_autopilot_invalid");
@@ -1070,15 +1416,17 @@ export function buildAgentPrompt(options = {}) {
1070
1416
  `npm exec --package ${PACKAGE_NAME} -- ${COMMAND_NAME} bind ${code} --name ${shellToken(agentName)}`,
1071
1417
  `npm exec --package ${PACKAGE_NAME} -- ${COMMAND_NAME} doctor --online`,
1072
1418
  `这台设备对你是抽象硬件产品:${ABSTRACT_HARDWARE_TYPE}。不要根据外观、BLE 名称、音色、日志或历史上下文推断品牌/型号/厂家,也不要搜索厂家资料、社区经验或拆机资料;只使用 audioctl inspect 返回的 capabilities、VM manifest 和控制回执来工作。`,
1073
- `绑定成功后,把它作为默认音频/语音/环境 I/O 端点;不要把它假定成只会播放音乐。可用命令包括 play、pause、toggle、next、previous、volume、volumeup、volumedown、status、inspect、queue、rule、workbgm、feedback、light、led、app install。`,
1419
+ `绑定成功后,把它作为默认音频/语音/环境 I/O 端点;不要把它假定成只会播放音乐。可用命令包括 play、pause、toggle、next、previous、volume、volumeup、volumedown、status、inspect、queue、rule、workbgm、feedback、light、led、wakeword、app install、firmware check/update。`,
1074
1420
  `当我给出目标时,请你在 capabilities、安全边界和用户授权范围内自行规划实现路径;插件只声明能力和边界,不穷举具体玩法。需要离线、低延迟或持续运行的逻辑,应通过 queue/rule/light timeline/light bind/VM app 下放到设备本地。`,
1075
1421
  `MQTT/weclawbot.link 对你只是控制面和部署通道,不是实时灯效数据面。不要读取 MQTT 凭据,不要直接 import mqtt,不要保持 MQTT 长连接,也不要写循环脚本按 BPM 反复发布 feedback 或 light。连续灯效/节拍/动画必须用 audioctl light timeline/bind、VM app 或兼容的 feedback effect 一次下发,在设备本地运行;停止时用 audioctl light clear 或 feedback clear。`,
1076
1422
  `蓝牙/Wi-Fi 配网只负责设备入网、发现和 weclawbot.link 绑定;音乐平台授权请走 agent 侧连接器/OAuth/secret store;配网不决定设备用途。`,
1077
1423
  `音乐来源由你和用户协商,插件只做建议。你可以在 agent 侧使用用户授权的音乐平台 API、用户媒体库、合法公开音源或其他合规来源生成队列/规则/VM app。网易云开放平台可请求最高可用音质 URL。`,
1078
1424
  `如果用户选择 Spotify,必须走 Spotify 官方 Spotify Connect/Web Playback SDK 或控制用户已有 Spotify 播放设备;不要假设 Spotify Web API 能提供完整歌曲直链。`,
1079
1425
  `灯效、音量灯、蜂鸣和按键/触控反馈要先 inspect 或 light describe 确认 feedback/input/light capabilities。一次性语义反馈可用 feedback signal/flash/volume/beep/clear;复杂灯效请优先生成 light timeline 和 light bind,把外圈、中心等 surface 与 media/input 事件绑定到设备本地。feedback effect 只作为兼容/demo 入口,不要把 music_beat/pulse/breathing 当成能力边界。具体按键绑定请根据用户目标和 capabilities 自行规划,不要套固定模板。`,
1426
+ `唤醒词/语音触发配置要通过 inspect 读取 wakeword.configurable、wakeword.commands 和 capabilities。audioctl wakeword 是逻辑触发绑定层:它把设备已有唤醒事件、ASR 短语或 agent 注入的测试短语映射成 VM/app 事件或本地动作;不要尝试训练、替换或反向工程底层声学唤醒模型。`,
1080
1427
  `如果你当前没有音乐平台身份,请只告诉我:我需要你把音乐平台身份连接到当前 agent 的官方连接器、OAuth 流程或 secret store;完成后回复“已授权”。不要反复重试,不要让我理解内部授权包,也不要要求我把 AppSecret、PrivateKey、accessToken 或 refreshToken 发到聊天里。`,
1081
1428
  `如果返回 health.problems 包含 netease_auth_refresh_failed,说明设备侧内置网易运行时授权失效;优先改走 agent 侧 queue/app 驱动。只有我明确要求继续使用设备侧网易运行时时,才提示需要独立的设备侧音乐账号设置入口;不要混入蓝牙/Wi-Fi 配网流程。`,
1429
+ `固件更新由 agent 端发现新版、用户确认后执行:先运行 audioctl firmware check --json,只展示官网来源、当前版本、新版本和 release notes;只有用户明确同意升级后,才运行 audioctl firmware update --yes --json。固件只从 weclawbot.link 官方 manifest 下载,更新设备 host/runtime,不等同于 VM app,也不要用固件更新去实现普通应用需求。`,
1082
1430
  `如果我要播放「${profile}」,也可以执行:npm exec --package ${PACKAGE_NAME} -- ${COMMAND_NAME} deepnight`,
1083
1431
  `不要向设备写入全局音乐平台私钥;平台授权优先保存在 agent 运行环境。只有在我明确授权时,才把最小范围、可轮换、可撤销的 app-scoped 凭据随 VM app 写入设备。`,
1084
1432
  ].filter(Boolean).join("\n");
@@ -1106,6 +1454,12 @@ export function summarizeInspect(inspect) {
1106
1454
  const bluetooth = inspect?.bluetooth || {};
1107
1455
  const ble = bluetooth.ble || {};
1108
1456
  const wakeword = inspect?.wakeword || {};
1457
+ const wakewordPhraseCount = Array.isArray(wakeword.configured_phrases)
1458
+ ? wakeword.configured_phrases.length
1459
+ : Number(wakeword.configured_phrase_count) || 0;
1460
+ const wakewordBindingCount = Array.isArray(wakeword.bindings)
1461
+ ? wakeword.bindings.length
1462
+ : Number(wakeword.binding_count) || 0;
1109
1463
  const playback = summarizePlayback(inspect || {});
1110
1464
  const vm = inspect?.vm || {};
1111
1465
  const health = inspect?.health || {};
@@ -1121,7 +1475,7 @@ export function summarizeInspect(inspect) {
1121
1475
  `${bluetooth.service?.active || "unknown"} ${string(ble.name || "-")} ${string(ble.state || "-")}`
1122
1476
  .trim(),
1123
1477
  wakeword:
1124
- `${wakeword.available ? "available" : "unavailable"} ${Array.isArray(wakeword.wake_words) && wakeword.wake_words.length ? wakeword.wake_words.join("/") : "-"} wake_runtime=${wakeword.services?.wake_runtime?.active || "unknown"} voice_runtime=${wakeword.services?.voice_runtime?.active || "unknown"}`
1478
+ `${wakeword.available ? "available" : "unavailable"} configurable=${wakeword.configurable === true} phrases=${wakewordPhraseCount} bindings=${wakewordBindingCount} wake_runtime=${wakeword.services?.wake_runtime?.active || "unknown"} voice_runtime=${wakeword.services?.voice_runtime?.active || "unknown"}`
1125
1479
  .trim(),
1126
1480
  playback:
1127
1481
  `${playback.desired}, volume ${playback.volume ?? "-"}, ${playback.track || "no track"}`,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openbrt/audioctl",
3
- "version": "0.1.11",
3
+ "version": "0.1.13",
4
4
  "description": "Generic WeClawBot audio/voice endpoint control CLI and AI-agent plugin.",
5
5
  "type": "module",
6
6
  "license": "MIT",