@openbrt/audioctl 0.1.12 → 0.1.14
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 +98 -2
- package/agent-plugin.json +37 -2
- package/bin/audioctl.mjs +121 -2
- package/lib/index.mjs +388 -2
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -83,8 +83,13 @@ 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
|
|
86
90
|
audioctl firmware check --json
|
|
87
91
|
audioctl firmware update --yes --json
|
|
92
|
+
audioctl report "center LED active=true but user cannot see it" --kind capability_mismatch --area light --include-inspect --json
|
|
88
93
|
audioctl feedback signal ready
|
|
89
94
|
audioctl feedback flash cyan --repeat 2
|
|
90
95
|
audioctl feedback volume 66
|
|
@@ -103,6 +108,41 @@ provisioning state, wake-word capability, playback, VM/app status, firmware
|
|
|
103
108
|
runtime version, and health without receiving Wi-Fi passwords, MQTT
|
|
104
109
|
credentials, or music-platform secrets.
|
|
105
110
|
|
|
111
|
+
`wakeword` is the logical voice-trigger binding layer. It does not train or
|
|
112
|
+
replace the low-level acoustic wake model. It maps existing device wake events,
|
|
113
|
+
ASR phrases, or agent-injected test phrases to VM/app events or safe local
|
|
114
|
+
actions. Agents should read `audioctl inspect --json` first and use reported
|
|
115
|
+
`wakeword.commands` / `voice.wakeword.*` capabilities, not product-specific
|
|
116
|
+
vendor research.
|
|
117
|
+
|
|
118
|
+
```bash
|
|
119
|
+
audioctl wakeword status --json
|
|
120
|
+
audioctl wakeword configure ./wakeword.json --json
|
|
121
|
+
audioctl wakeword bind ./wakeword-bindings.json --json
|
|
122
|
+
audioctl wakeword trigger work_mode --source agent --json
|
|
123
|
+
audioctl wakeword clear --json
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
Example wakeword config:
|
|
127
|
+
|
|
128
|
+
```json
|
|
129
|
+
{
|
|
130
|
+
"schema": "weclawbot.wakeword.config.v1",
|
|
131
|
+
"mode": "logical_voice_trigger",
|
|
132
|
+
"language": "zh",
|
|
133
|
+
"phrases": [
|
|
134
|
+
{ "id": "work_mode", "text": "开始工作", "aliases": ["工作模式"] }
|
|
135
|
+
],
|
|
136
|
+
"bindings": [
|
|
137
|
+
{
|
|
138
|
+
"phrase_id": "work_mode",
|
|
139
|
+
"action": "app.event",
|
|
140
|
+
"feedback": ["feedback.beep", "feedback.signal:wake"]
|
|
141
|
+
}
|
|
142
|
+
]
|
|
143
|
+
}
|
|
144
|
+
```
|
|
145
|
+
|
|
106
146
|
`firmware check` reads the official firmware index from
|
|
107
147
|
`https://weclawbot.link/firmware/audio/manifest.json`, compares it with
|
|
108
148
|
`inspect.firmware.version`, and reports whether a host/runtime update is
|
|
@@ -285,6 +325,62 @@ Light messages use a distinct command envelope:
|
|
|
285
325
|
Supported light commands are `describe`, `set`, `timeline`, `bind`, and
|
|
286
326
|
`clear`. Continuous light timing and event reactions run on the device VM.
|
|
287
327
|
|
|
328
|
+
## Agent feedback reports
|
|
329
|
+
|
|
330
|
+
`audioctl report` is the agent-to-maintainer feedback channel for issues,
|
|
331
|
+
suggestions, capability mismatches, UX gaps, and documentation gaps. It writes a
|
|
332
|
+
redacted JSON report to `~/WeClawBot/agent-feedback` by default, or to
|
|
333
|
+
`WEC_AUDIO_REPORTS_DIR` / `--reports-dir DIR` when set.
|
|
334
|
+
|
|
335
|
+
User observation wins over status. A device returning `applied`, `active=true`,
|
|
336
|
+
or a capability entry only means the runtime accepted the command; it does not
|
|
337
|
+
prove the user can physically see, hear, or feel the result. If a user reports
|
|
338
|
+
that a center LED is not visible, a touch gesture is unreliable, or a beep is not
|
|
339
|
+
audible, agents should fall back to a confirmed visible/audible/controllable
|
|
340
|
+
behavior, then create a local report:
|
|
341
|
+
|
|
342
|
+
```bash
|
|
343
|
+
audioctl report "center LED surface is reported active but not visually observable by the user" \
|
|
344
|
+
--kind capability_mismatch \
|
|
345
|
+
--area light \
|
|
346
|
+
--observed "light_active=true, user still cannot see center LED" \
|
|
347
|
+
--expected "visible center-light cue or capability should be marked unavailable" \
|
|
348
|
+
--include-inspect \
|
|
349
|
+
--json
|
|
350
|
+
```
|
|
351
|
+
|
|
352
|
+
Reports are local by default. Agents must ask the user before posting a report
|
|
353
|
+
to a public issue tracker, chat, email, or any other external system. Reports
|
|
354
|
+
must not include MQTT credentials, music-platform tokens, Wi-Fi passwords, or
|
|
355
|
+
other secrets.
|
|
356
|
+
|
|
357
|
+
Wakeword messages configure the logical voice-trigger layer:
|
|
358
|
+
|
|
359
|
+
```json
|
|
360
|
+
{
|
|
361
|
+
"schema": "weclawbot.control.v1",
|
|
362
|
+
"id": "wakeword_<uuid>",
|
|
363
|
+
"kind": "wakeword_command",
|
|
364
|
+
"wakeword": {
|
|
365
|
+
"command": "configure",
|
|
366
|
+
"config": {
|
|
367
|
+
"schema": "weclawbot.wakeword.config.v1",
|
|
368
|
+
"mode": "logical_voice_trigger",
|
|
369
|
+
"phrases": [
|
|
370
|
+
{ "id": "work_mode", "text": "开始工作" }
|
|
371
|
+
],
|
|
372
|
+
"bindings": [
|
|
373
|
+
{ "phrase_id": "work_mode", "action": "app.event" }
|
|
374
|
+
]
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
```
|
|
379
|
+
|
|
380
|
+
Supported wakeword commands are `status`, `configure`, `bind`, `trigger`, and
|
|
381
|
+
`clear`. This is a VM/app event binding layer, not low-level acoustic-model
|
|
382
|
+
training.
|
|
383
|
+
|
|
288
384
|
Firmware update messages also use the same MQTT envelope, but carry only an
|
|
289
385
|
official release descriptor. The device downloads the artifact itself from
|
|
290
386
|
`weclawbot.link`, enforces HTTPS, checks SHA-256 and byte size, installs from a
|
|
@@ -303,10 +399,10 @@ staging directory, restarts local services, and reports health through
|
|
|
303
399
|
"schema": "weclawbot.firmware.release.v1",
|
|
304
400
|
"device_class": "audio_voice_ambient_endpoint",
|
|
305
401
|
"runtime": "rokid_music_runtime",
|
|
306
|
-
"version": "0.1.
|
|
402
|
+
"version": "0.1.2",
|
|
307
403
|
"channel": "stable",
|
|
308
404
|
"artifact": {
|
|
309
|
-
"url": "https://weclawbot.link/firmware/audio/rokid-music-runtime-0.1.
|
|
405
|
+
"url": "https://weclawbot.link/firmware/audio/rokid-music-runtime-0.1.2.tar",
|
|
310
406
|
"sha256": "<64 hex chars>",
|
|
311
407
|
"bytes": 123456
|
|
312
408
|
}
|
package/agent-plugin.json
CHANGED
|
@@ -39,6 +39,17 @@
|
|
|
39
39
|
"music_platform_auth": "音乐平台授权属于 agent 侧能力,由 agent 的连接器、OAuth、secret store 或用户明确提供的运行环境凭据完成。",
|
|
40
40
|
"device_purpose": "设备用途由 agent 安装的 VM app 和显式 capabilities 决定,不由配网流程决定。"
|
|
41
41
|
},
|
|
42
|
+
"agent_feedback_channel": {
|
|
43
|
+
"purpose": "让用户 agent 在能力声明、控制回执、真实体验、文档或插件引导不一致时留下结构化反馈。",
|
|
44
|
+
"primary_command": "audioctl report \"MESSAGE\" --kind issue --area other --json",
|
|
45
|
+
"rules": [
|
|
46
|
+
"用户的现场视觉、听觉、触控反馈优先于设备返回的 active=true、applied 或 capability 声明。",
|
|
47
|
+
"如果用户说某个 surface 看不到、按键不灵、蜂鸣不明显或体验不符合目标,agent 不要继续用状态回执证明它应该可用;先降级到用户确认可见/可听/可控的方案。",
|
|
48
|
+
"出现 capability mismatch 时运行 audioctl report \"MESSAGE\" --kind capability_mismatch --area light --include-inspect --json 生成本地脱敏报告。",
|
|
49
|
+
"报告默认只保存到本机,不自动提交外部系统;外发前必须获得用户确认。",
|
|
50
|
+
"报告不得包含 MQTT 凭据、音乐平台 token、Wi-Fi 密码、AppSecret、PrivateKey 或其他密钥。"
|
|
51
|
+
]
|
|
52
|
+
},
|
|
42
53
|
"device_classes": [
|
|
43
54
|
"audio_io",
|
|
44
55
|
"audio_output",
|
|
@@ -82,6 +93,10 @@
|
|
|
82
93
|
"light.timeline",
|
|
83
94
|
"light.bind",
|
|
84
95
|
"light.clear",
|
|
96
|
+
"voice.wakeword.configure",
|
|
97
|
+
"voice.wakeword.bind",
|
|
98
|
+
"voice.wakeword.trigger",
|
|
99
|
+
"voice.event.bind",
|
|
85
100
|
"event.bind",
|
|
86
101
|
"iot.control",
|
|
87
102
|
"device.inspect",
|
|
@@ -113,14 +128,26 @@
|
|
|
113
128
|
{
|
|
114
129
|
"intent": "帮我把设备灯效、音量灯和按键反馈音接上",
|
|
115
130
|
"primary_command": "audioctl feedback signal ready",
|
|
116
|
-
"agent_strategy": "先运行 audioctl inspect --json 或 audioctl light describe --json 读取 light.surfaces、events、limits 以及 feedback/input capabilities。MQTT 是控制面,不是实时灯效数据面;不要读取 MQTT 凭据、不要 import mqtt、不要保持 MQTT 长连接、不要写 agent 端 BPM/while/sleep 循环去刷 LED。一次性语义提示可用 audioctl feedback signal/flash/volume/beep/clear;复杂灯效优先使用 audioctl light timeline 和 audioctl light bind,把外圈、中心等 surface 的 keyframes 与 media/input 事件绑定到设备本地。feedback effect 只是兼容/demo 入口,不要把 music_beat/pulse/breathing 当成能力边界。物理按键/触控属于 VM app 的 input.button.bind 能力,具体绑定由 agent 根据用户目标和 capabilities
|
|
117
|
-
"if_missing_capability": "不要伪造灯效或按键能力;如果 inspect 未报告对应 capability,只说明当前设备适配未提供该能力,并建议安装或升级设备 VM。"
|
|
131
|
+
"agent_strategy": "先运行 audioctl inspect --json 或 audioctl light describe --json 读取 light.surfaces、events、limits 以及 feedback/input capabilities。MQTT 是控制面,不是实时灯效数据面;不要读取 MQTT 凭据、不要 import mqtt、不要保持 MQTT 长连接、不要写 agent 端 BPM/while/sleep 循环去刷 LED。一次性语义提示可用 audioctl feedback signal/flash/volume/beep/clear;复杂灯效优先使用 audioctl light timeline 和 audioctl light bind,把外圈、中心等 surface 的 keyframes 与 media/input 事件绑定到设备本地。feedback effect 只是兼容/demo 入口,不要把 music_beat/pulse/breathing 当成能力边界。物理按键/触控属于 VM app 的 input.button.bind 能力,具体绑定由 agent 根据用户目标和 capabilities 自行规划。用户的现场视觉/听觉/触控反馈优先于 active=true、applied 或 capability 声明;如果用户说 center LED 或任何 surface 肉眼不可见,不要继续堆亮度并声称成功,先降级到可见的 ring/all/声音反馈,再生成本地报告。",
|
|
132
|
+
"if_missing_capability": "不要伪造灯效或按键能力;如果 inspect 未报告对应 capability,只说明当前设备适配未提供该能力,并建议安装或升级设备 VM。",
|
|
133
|
+
"if_user_reports_mismatch": "运行 audioctl report \"User reports requested feedback is not physically observable despite applied/active status\" --kind capability_mismatch --area light --include-inspect --json;报告只保存本机,外发前必须问用户。"
|
|
134
|
+
},
|
|
135
|
+
{
|
|
136
|
+
"intent": "反馈设备、插件或 VM app 的问题/建议",
|
|
137
|
+
"primary_command": "audioctl report \"MESSAGE\" --kind issue --area other --json",
|
|
138
|
+
"agent_strategy": "把用户反馈整理为 observed/expected/reproduction/recommendation。需要设备状态佐证时加 --include-inspect;报告会脱敏并默认保存到本机。不要把 MQTT 凭据、音乐平台 token、Wi-Fi 密码或私钥写进报告;外发到 issue、聊天、邮件或其他系统前必须获得用户明确确认。"
|
|
118
139
|
},
|
|
119
140
|
{
|
|
120
141
|
"intent": "让音箱作为语音遥控、家庭智能或语音互动端点",
|
|
121
142
|
"primary_command": "audioctl app install MANIFEST.json APP.wasm",
|
|
122
143
|
"agent_strategy": "不要把设备假定为音乐播放器。先运行 audioctl inspect --json 读取设备能力,再生成声明最小 capabilities 的 VM app。语音、TTS、家居控制等能力必须通过 manifest capabilities 显式声明;设备侧只执行授权范围内的 I/O。"
|
|
123
144
|
},
|
|
145
|
+
{
|
|
146
|
+
"intent": "配置唤醒词或语音短语来执行某个操作",
|
|
147
|
+
"primary_command": "audioctl wakeword configure WAKEWORD.json --json",
|
|
148
|
+
"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。",
|
|
149
|
+
"if_missing_capability": "如果 inspect 未报告 wakeword.configurable 或 voice.wakeword.*,先运行 audioctl firmware check --json;发现官网新版后向用户说明并请求确认升级。"
|
|
150
|
+
},
|
|
124
151
|
{
|
|
125
152
|
"intent": "检查并升级设备固件",
|
|
126
153
|
"primary_command": "audioctl firmware check --json",
|
|
@@ -154,6 +181,10 @@
|
|
|
154
181
|
"name": "light",
|
|
155
182
|
"usage": "audioctl light describe | set SURFACE --color COLOR [--brightness 0..1] | timeline TIMELINE.json | bind BINDINGS.json | clear"
|
|
156
183
|
},
|
|
184
|
+
{
|
|
185
|
+
"name": "wakeword",
|
|
186
|
+
"usage": "audioctl wakeword status | configure CONFIG.json | bind BINDINGS.json | trigger WORD [--source NAME] | clear"
|
|
187
|
+
},
|
|
157
188
|
{
|
|
158
189
|
"name": "led",
|
|
159
190
|
"usage": "audioctl led flash COLOR [--repeat N]"
|
|
@@ -178,6 +209,10 @@
|
|
|
178
209
|
"name": "firmware",
|
|
179
210
|
"usage": "audioctl firmware check [--manifest URL] | firmware update [--manifest URL] --yes"
|
|
180
211
|
},
|
|
212
|
+
{
|
|
213
|
+
"name": "report",
|
|
214
|
+
"usage": "audioctl report MESSAGE [--kind issue|suggestion|capability_mismatch|ux_feedback|docs_feedback] [--area light|audio|input|wakeword|firmware|vm|plugin|docs|other] [--include-inspect] --json"
|
|
215
|
+
},
|
|
181
216
|
{
|
|
182
217
|
"name": "workbgm",
|
|
183
218
|
"usage": "audioctl workbgm --play"
|
package/bin/audioctl.mjs
CHANGED
|
@@ -8,6 +8,7 @@ import {
|
|
|
8
8
|
DEFAULT_CREDENTIALS_PATH,
|
|
9
9
|
DEFAULT_ENDPOINT,
|
|
10
10
|
DEFAULT_FIRMWARE_MANIFEST_URL,
|
|
11
|
+
DEFAULT_REPORTS_DIR,
|
|
11
12
|
DEEPNIGHT_RULE,
|
|
12
13
|
PACKAGE_NAME,
|
|
13
14
|
bindAgent,
|
|
@@ -18,6 +19,7 @@ import {
|
|
|
18
19
|
buildFirmwareUpdateControl,
|
|
19
20
|
buildLightControl,
|
|
20
21
|
buildQueueControl,
|
|
22
|
+
buildWakewordControl,
|
|
21
23
|
buildWorkBgmAppInstallControl,
|
|
22
24
|
expandPath,
|
|
23
25
|
normalizeCredentials,
|
|
@@ -27,6 +29,7 @@ import {
|
|
|
27
29
|
readCredentials,
|
|
28
30
|
redactAgentFacingValue,
|
|
29
31
|
remediationForStatus,
|
|
32
|
+
saveAgentFeedbackReport,
|
|
30
33
|
summarizeInspect,
|
|
31
34
|
summarizePlayback,
|
|
32
35
|
testConnection,
|
|
@@ -52,6 +55,7 @@ async function run(name, values) {
|
|
|
52
55
|
if (name === "inspect") return commandInspect(values);
|
|
53
56
|
if (name === "prompt") return commandPrompt(values);
|
|
54
57
|
if (name === "manifest") return commandManifest();
|
|
58
|
+
if (name === "report") return commandReport(values);
|
|
55
59
|
if (name === "firmware") return commandFirmware(values);
|
|
56
60
|
if (name === "app") return commandApp(values);
|
|
57
61
|
if (name === "queue") return commandQueue(values);
|
|
@@ -61,6 +65,7 @@ async function run(name, values) {
|
|
|
61
65
|
if (name === "volume") return commandVolume(values);
|
|
62
66
|
if (name === "seek") return commandSeek(values);
|
|
63
67
|
if (name === "light") return commandLight(values);
|
|
68
|
+
if (name === "wakeword" || name === "voice") return commandWakeword(values);
|
|
64
69
|
if (name === "feedback" || name === "led") return commandFeedback(values);
|
|
65
70
|
return commandControl(name, values);
|
|
66
71
|
}
|
|
@@ -71,6 +76,7 @@ function usage(exitCode = 0) {
|
|
|
71
76
|
${COMMAND_NAME} doctor [--online] [--json]
|
|
72
77
|
${COMMAND_NAME} inspect [--json]
|
|
73
78
|
${COMMAND_NAME} prompt CODE [--agent AGENT] [--alias NAME]
|
|
79
|
+
${COMMAND_NAME} report MESSAGE [--kind issue|suggestion|capability_mismatch|ux_feedback|docs_feedback] [--area light|audio|input|wakeword|firmware|vm|plugin|docs|other]
|
|
74
80
|
${COMMAND_NAME} play|pause|toggle|next|previous|volumeup|volumedown|status
|
|
75
81
|
${COMMAND_NAME} volume 0..100
|
|
76
82
|
${COMMAND_NAME} seek MILLISECONDS
|
|
@@ -85,6 +91,11 @@ function usage(exitCode = 0) {
|
|
|
85
91
|
${COMMAND_NAME} light timeline TIMELINE.json
|
|
86
92
|
${COMMAND_NAME} light bind BINDINGS.json
|
|
87
93
|
${COMMAND_NAME} light clear
|
|
94
|
+
${COMMAND_NAME} wakeword status
|
|
95
|
+
${COMMAND_NAME} wakeword configure CONFIG.json
|
|
96
|
+
${COMMAND_NAME} wakeword bind BINDINGS.json
|
|
97
|
+
${COMMAND_NAME} wakeword trigger WORD [--source NAME]
|
|
98
|
+
${COMMAND_NAME} wakeword clear
|
|
88
99
|
${COMMAND_NAME} firmware check [--manifest URL]
|
|
89
100
|
${COMMAND_NAME} firmware update [--manifest URL] [--yes]
|
|
90
101
|
${COMMAND_NAME} rule RULE.json
|
|
@@ -98,6 +109,7 @@ Options:
|
|
|
98
109
|
--credentials FILE override audioctl-managed local credential file
|
|
99
110
|
--endpoint URL default: ${DEFAULT_ENDPOINT}
|
|
100
111
|
--manifest URL default: ${DEFAULT_FIRMWARE_MANIFEST_URL}
|
|
112
|
+
--reports-dir DIR default: ${DEFAULT_REPORTS_DIR}
|
|
101
113
|
--timeout SECONDS default: 12
|
|
102
114
|
--json machine-readable output
|
|
103
115
|
--nowait publish without waiting for device status
|
|
@@ -216,6 +228,45 @@ async function commandManifest() {
|
|
|
216
228
|
process.stdout.write(`${JSON.stringify(manifest, null, 2)}\n`);
|
|
217
229
|
}
|
|
218
230
|
|
|
231
|
+
async function commandReport(values) {
|
|
232
|
+
const options = parseOptions(values, {
|
|
233
|
+
credentials: credentialsPath(),
|
|
234
|
+
timeout: 12,
|
|
235
|
+
json: false,
|
|
236
|
+
include_inspect: false,
|
|
237
|
+
kind: "issue",
|
|
238
|
+
area: "other",
|
|
239
|
+
severity: "medium",
|
|
240
|
+
reports_dir: process.env.WEC_AUDIO_REPORTS_DIR || DEFAULT_REPORTS_DIR,
|
|
241
|
+
});
|
|
242
|
+
const message = String(options._.join(" ") || options.message || "").trim();
|
|
243
|
+
if (!message) throw new Error("report_requires_message");
|
|
244
|
+
let inspect = null;
|
|
245
|
+
if (options.include_inspect) {
|
|
246
|
+
const credentials = await readCredentials(options.credentials);
|
|
247
|
+
const delivery = await publishAudioControl(credentials, buildAudioControl("status"), {
|
|
248
|
+
timeoutMs: Number(options.timeout) * 1000,
|
|
249
|
+
});
|
|
250
|
+
inspect = delivery.status?.inspect || delivery.status || null;
|
|
251
|
+
}
|
|
252
|
+
const saved = await saveAgentFeedbackReport({
|
|
253
|
+
kind: options.kind || options.type,
|
|
254
|
+
area: options.area || options.component,
|
|
255
|
+
severity: options.severity,
|
|
256
|
+
title: options.title,
|
|
257
|
+
message,
|
|
258
|
+
observed: options.observed || options.actual,
|
|
259
|
+
expected: options.expected,
|
|
260
|
+
reproduction: options.reproduction || options.steps,
|
|
261
|
+
recommendation: options.recommendation || options.suggestion,
|
|
262
|
+
agent: options.agent || options.name,
|
|
263
|
+
inspect,
|
|
264
|
+
}, {
|
|
265
|
+
reportsDir: options.reports_dir,
|
|
266
|
+
});
|
|
267
|
+
print(saved, options.json);
|
|
268
|
+
}
|
|
269
|
+
|
|
219
270
|
async function commandFirmware(values) {
|
|
220
271
|
const subcommand = String(values[0] || "check").replace(/-/gu, "_").toLowerCase();
|
|
221
272
|
if (!new Set(["check", "update", "upgrade"]).has(subcommand)) {
|
|
@@ -456,6 +507,56 @@ async function commandLight(values) {
|
|
|
456
507
|
if (delivery.status?.kind === "rejected") process.exitCode = 1;
|
|
457
508
|
}
|
|
458
509
|
|
|
510
|
+
async function commandWakeword(values) {
|
|
511
|
+
const subcommand = String(values[0] || "status").replace(/-/gu, "_").toLowerCase();
|
|
512
|
+
const options = parseOptions(values.slice(1), {
|
|
513
|
+
credentials: credentialsPath(),
|
|
514
|
+
timeout: 12,
|
|
515
|
+
json: false,
|
|
516
|
+
nowait: false,
|
|
517
|
+
source: "agent",
|
|
518
|
+
});
|
|
519
|
+
let control;
|
|
520
|
+
if (subcommand === "status" || subcommand === "describe" || subcommand === "info") {
|
|
521
|
+
control = buildWakewordControl("status");
|
|
522
|
+
} else if (subcommand === "configure" || subcommand === "config" || subcommand === "set") {
|
|
523
|
+
const file = String(options._[0] || "");
|
|
524
|
+
if (!file) throw new Error("wakeword_configure_requires_file");
|
|
525
|
+
control = buildWakewordControl("configure", {
|
|
526
|
+
config: JSON.parse(await fs.readFile(file, "utf8")),
|
|
527
|
+
});
|
|
528
|
+
} else if (subcommand === "bind" || subcommand === "bindings") {
|
|
529
|
+
const file = String(options._[0] || "");
|
|
530
|
+
if (!file) throw new Error("wakeword_bind_requires_file");
|
|
531
|
+
const payload = JSON.parse(await fs.readFile(file, "utf8"));
|
|
532
|
+
control = buildWakewordControl("bind", {
|
|
533
|
+
bindings: Array.isArray(payload) ? payload : payload.bindings,
|
|
534
|
+
});
|
|
535
|
+
} else if (subcommand === "trigger" || subcommand === "test" || subcommand === "event") {
|
|
536
|
+
control = buildWakewordControl("trigger", {
|
|
537
|
+
word: options._[0] || options.word || options.phrase || "factory_wake",
|
|
538
|
+
source: options.source,
|
|
539
|
+
});
|
|
540
|
+
} else if (subcommand === "clear" || subcommand === "reset") {
|
|
541
|
+
control = buildWakewordControl("clear");
|
|
542
|
+
} else {
|
|
543
|
+
throw new Error(`wakeword_subcommand_unsupported: ${subcommand}`);
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
const credentials = await readCredentials(options.credentials);
|
|
547
|
+
const delivery = await publishAudioControl(credentials, control, {
|
|
548
|
+
timeoutMs: Number(options.timeout) * 1000,
|
|
549
|
+
wait: !options.nowait,
|
|
550
|
+
});
|
|
551
|
+
if (control.wakeword.command === "status" && options.json) {
|
|
552
|
+
const wakeword = delivery.status?.inspect?.wakeword || delivery.status?.wakeword || {};
|
|
553
|
+
process.stdout.write(`${JSON.stringify(redactAgentFacingValue(wakeword))}\n`);
|
|
554
|
+
} else {
|
|
555
|
+
print(delivery.status || delivery, options.json);
|
|
556
|
+
}
|
|
557
|
+
if (delivery.status?.kind === "rejected") process.exitCode = 1;
|
|
558
|
+
}
|
|
559
|
+
|
|
459
560
|
async function commandFeedback(values) {
|
|
460
561
|
const subcommand = String(values[0] || "").replace(/-/gu, "_").toLowerCase();
|
|
461
562
|
const shorthandSignals = new Set([
|
|
@@ -564,8 +665,8 @@ function parseOptions(values, defaults = {}) {
|
|
|
564
665
|
options._.push(value);
|
|
565
666
|
continue;
|
|
566
667
|
}
|
|
567
|
-
const key = value.slice(2);
|
|
568
|
-
if (key === "json" || key === "online" || key === "nowait" || key === "play" || key === "yes" || key === "force") {
|
|
668
|
+
const key = value.slice(2).replace(/-/gu, "_");
|
|
669
|
+
if (key === "json" || key === "online" || key === "nowait" || key === "play" || key === "yes" || key === "force" || key === "include_inspect") {
|
|
569
670
|
options[key] = true;
|
|
570
671
|
continue;
|
|
571
672
|
}
|
|
@@ -580,15 +681,33 @@ function parseOptions(values, defaults = {}) {
|
|
|
580
681
|
"endpoint",
|
|
581
682
|
"effect",
|
|
582
683
|
"channel",
|
|
684
|
+
"actual",
|
|
685
|
+
"area",
|
|
686
|
+
"component",
|
|
687
|
+
"expected",
|
|
688
|
+
"kind",
|
|
583
689
|
"level",
|
|
584
690
|
"manifest",
|
|
691
|
+
"message",
|
|
585
692
|
"name",
|
|
693
|
+
"observed",
|
|
586
694
|
"pattern",
|
|
587
695
|
"profile",
|
|
588
696
|
"repeat",
|
|
697
|
+
"phrase",
|
|
698
|
+
"recommendation",
|
|
699
|
+
"reports_dir",
|
|
700
|
+
"reproduction",
|
|
701
|
+
"severity",
|
|
702
|
+
"source",
|
|
703
|
+
"steps",
|
|
704
|
+
"suggestion",
|
|
589
705
|
"surface",
|
|
590
706
|
"timeout",
|
|
707
|
+
"title",
|
|
708
|
+
"type",
|
|
591
709
|
"volume",
|
|
710
|
+
"word",
|
|
592
711
|
]).has(key)) {
|
|
593
712
|
throw new Error(`option_unknown: --${key}`);
|
|
594
713
|
}
|
package/lib/index.mjs
CHANGED
|
@@ -16,6 +16,11 @@ export const DEFAULT_CREDENTIALS_PATH = path.join(
|
|
|
16
16
|
"weclawbot",
|
|
17
17
|
"audio-mqtt.json",
|
|
18
18
|
);
|
|
19
|
+
export const DEFAULT_REPORTS_DIR = path.join(
|
|
20
|
+
os.homedir(),
|
|
21
|
+
"WeClawBot",
|
|
22
|
+
"agent-feedback",
|
|
23
|
+
);
|
|
19
24
|
export const ABSTRACT_HARDWARE_TYPE = "audio_voice_ambient_endpoint";
|
|
20
25
|
export const ABSTRACT_HARDWARE_NAME = "Audio Voice Ambient Endpoint";
|
|
21
26
|
|
|
@@ -52,6 +57,7 @@ export const WORKBGM_RULE = Object.freeze({
|
|
|
52
57
|
export const PLAYABLE_QUEUE_SCHEMA = "weclawbot.playable_queue.v1";
|
|
53
58
|
export const LIGHT_TIMELINE_SCHEMA = "weclawbot.light.timeline.v1";
|
|
54
59
|
export const LIGHT_BINDINGS_SCHEMA = "weclawbot.light.bind.v1";
|
|
60
|
+
export const WAKEWORD_CONFIG_SCHEMA = "weclawbot.wakeword.config.v1";
|
|
55
61
|
export const FIRMWARE_INDEX_SCHEMA = "weclawbot.firmware.index.v1";
|
|
56
62
|
export const FIRMWARE_RELEASE_SCHEMA = "weclawbot.firmware.release.v1";
|
|
57
63
|
|
|
@@ -106,6 +112,13 @@ export const LIGHT_EVENTS = Object.freeze([
|
|
|
106
112
|
"input.ring.single_click",
|
|
107
113
|
"input.ring.clockwise",
|
|
108
114
|
"input.ring.counter_clockwise",
|
|
115
|
+
"voice.wakeword.triggered",
|
|
116
|
+
"voice.phrase.matched",
|
|
117
|
+
]);
|
|
118
|
+
|
|
119
|
+
export const WAKEWORD_EVENTS = Object.freeze([
|
|
120
|
+
"voice.wakeword.triggered",
|
|
121
|
+
"voice.phrase.matched",
|
|
109
122
|
]);
|
|
110
123
|
|
|
111
124
|
const WORKBGM_APP_WASM_BASE64 =
|
|
@@ -257,6 +270,16 @@ export function buildLightControl(command, options = {}) {
|
|
|
257
270
|
};
|
|
258
271
|
}
|
|
259
272
|
|
|
273
|
+
export function buildWakewordControl(command, options = {}) {
|
|
274
|
+
const wakeword = normalizeWakewordCommand(command, options);
|
|
275
|
+
return {
|
|
276
|
+
schema: "weclawbot.control.v1",
|
|
277
|
+
id: string(options.id) || `wakeword_${crypto.randomUUID()}`,
|
|
278
|
+
kind: "wakeword_command",
|
|
279
|
+
wakeword,
|
|
280
|
+
};
|
|
281
|
+
}
|
|
282
|
+
|
|
260
283
|
export function buildQueueControl(options = {}) {
|
|
261
284
|
return buildAudioControl("set_queue", {
|
|
262
285
|
id: options.id,
|
|
@@ -719,6 +742,157 @@ function normalizeLightEvent(value) {
|
|
|
719
742
|
return event;
|
|
720
743
|
}
|
|
721
744
|
|
|
745
|
+
export function normalizeWakewordCommand(command, options = {}) {
|
|
746
|
+
const aliases = new Map([
|
|
747
|
+
["info", "status"],
|
|
748
|
+
["describe", "status"],
|
|
749
|
+
["config", "configure"],
|
|
750
|
+
["set", "configure"],
|
|
751
|
+
["update", "configure"],
|
|
752
|
+
["bindings", "bind"],
|
|
753
|
+
["binding", "bind"],
|
|
754
|
+
["event", "trigger"],
|
|
755
|
+
["phrase", "trigger"],
|
|
756
|
+
["test", "trigger"],
|
|
757
|
+
["reset", "clear"],
|
|
758
|
+
]);
|
|
759
|
+
const raw = string(command || options.command || options.action || "status")
|
|
760
|
+
.replace(/-/gu, "_")
|
|
761
|
+
.toLowerCase();
|
|
762
|
+
const normalized = aliases.get(raw) || raw;
|
|
763
|
+
if (!new Set(["status", "configure", "bind", "trigger", "clear"]).has(normalized)) {
|
|
764
|
+
throw new Error(`wakeword_command_unsupported: ${command}`);
|
|
765
|
+
}
|
|
766
|
+
const wakeword = { command: normalized };
|
|
767
|
+
if (normalized === "configure") {
|
|
768
|
+
wakeword.config = normalizeWakewordConfig(
|
|
769
|
+
options.config && typeof options.config === "object" ? options.config : options,
|
|
770
|
+
);
|
|
771
|
+
}
|
|
772
|
+
if (normalized === "bind") {
|
|
773
|
+
const source =
|
|
774
|
+
Array.isArray(options.bindings)
|
|
775
|
+
? { bindings: options.bindings }
|
|
776
|
+
: Array.isArray(options.binding)
|
|
777
|
+
? { bindings: options.binding }
|
|
778
|
+
: options.bindings && typeof options.bindings === "object"
|
|
779
|
+
? options.bindings
|
|
780
|
+
: options.binding && typeof options.binding === "object"
|
|
781
|
+
? options.binding
|
|
782
|
+
: options.config && typeof options.config === "object"
|
|
783
|
+
? options.config
|
|
784
|
+
: options;
|
|
785
|
+
wakeword.bindings = Array.isArray(source.bindings)
|
|
786
|
+
? source.bindings.slice(0, 32).map(normalizeWakewordBinding)
|
|
787
|
+
: [];
|
|
788
|
+
}
|
|
789
|
+
if (normalized === "trigger") {
|
|
790
|
+
const word = string(
|
|
791
|
+
options.word ||
|
|
792
|
+
options.phrase ||
|
|
793
|
+
options.phrase_id ||
|
|
794
|
+
options.phraseId ||
|
|
795
|
+
options.value ||
|
|
796
|
+
"factory_wake",
|
|
797
|
+
);
|
|
798
|
+
wakeword.word = word.slice(0, 80) || "factory_wake";
|
|
799
|
+
wakeword.source = string(options.source || "agent").slice(0, 80) || "agent";
|
|
800
|
+
}
|
|
801
|
+
return wakeword;
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
export function normalizeWakewordConfig(value = {}) {
|
|
805
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
806
|
+
throw new Error("wakeword_config_invalid");
|
|
807
|
+
}
|
|
808
|
+
const phrasesInput = Array.isArray(value.phrases) ? value.phrases : [];
|
|
809
|
+
const bindingsInput = Array.isArray(value.bindings) ? value.bindings : [];
|
|
810
|
+
return {
|
|
811
|
+
schema: WAKEWORD_CONFIG_SCHEMA,
|
|
812
|
+
mode: "logical_voice_trigger",
|
|
813
|
+
language: string(value.language || "zh").slice(0, 16),
|
|
814
|
+
runtime_detection: "factory_wake_or_agent_injected_phrase",
|
|
815
|
+
phrases: phrasesInput.slice(0, 12).map(normalizeWakewordPhrase),
|
|
816
|
+
bindings: bindingsInput.slice(0, 32).map(normalizeWakewordBinding),
|
|
817
|
+
};
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
export function normalizeWakewordPhrase(value, index = 0) {
|
|
821
|
+
const text = string(value?.text || value?.phrase || value?.word);
|
|
822
|
+
const id = normalizeWakewordIdentifier(
|
|
823
|
+
value?.id || value?.phrase_id,
|
|
824
|
+
`phrase_${index + 1}`,
|
|
825
|
+
);
|
|
826
|
+
if (!text || text.length > 40) {
|
|
827
|
+
throw new Error(`wakeword_phrase_invalid:${index + 1}`);
|
|
828
|
+
}
|
|
829
|
+
const aliases = Array.isArray(value.aliases)
|
|
830
|
+
? value.aliases.map(string).filter(Boolean).slice(0, 8)
|
|
831
|
+
: [];
|
|
832
|
+
for (const alias of aliases) {
|
|
833
|
+
if (alias.length > 40) throw new Error(`wakeword_alias_invalid:${index + 1}`);
|
|
834
|
+
}
|
|
835
|
+
const sensitivity = Number(value.sensitivity);
|
|
836
|
+
return {
|
|
837
|
+
id,
|
|
838
|
+
text,
|
|
839
|
+
aliases,
|
|
840
|
+
enabled: value.enabled !== false,
|
|
841
|
+
sensitivity: Number.isFinite(sensitivity)
|
|
842
|
+
? Math.max(0, Math.min(1, Math.round(sensitivity * 1000) / 1000))
|
|
843
|
+
: 0.5,
|
|
844
|
+
};
|
|
845
|
+
}
|
|
846
|
+
|
|
847
|
+
export function normalizeWakewordBinding(value, index = 0) {
|
|
848
|
+
const phraseId = normalizeWakewordIdentifier(
|
|
849
|
+
value?.phrase_id || value?.phraseId || value?.id || "*",
|
|
850
|
+
"*",
|
|
851
|
+
{ wildcard: true },
|
|
852
|
+
);
|
|
853
|
+
const action = string(value?.action || "app.event")
|
|
854
|
+
.replace(/-/gu, "_")
|
|
855
|
+
.toLowerCase()
|
|
856
|
+
.slice(0, 80);
|
|
857
|
+
if (
|
|
858
|
+
!new Set([
|
|
859
|
+
"app.event",
|
|
860
|
+
"music.play",
|
|
861
|
+
"music.pause",
|
|
862
|
+
"music.toggle",
|
|
863
|
+
"music.next",
|
|
864
|
+
"music.previous",
|
|
865
|
+
"feedback.beep",
|
|
866
|
+
"light.clear",
|
|
867
|
+
]).has(action) &&
|
|
868
|
+
!action.startsWith("feedback.signal:")
|
|
869
|
+
) {
|
|
870
|
+
throw new Error(`wakeword_action_unsupported:${index + 1}`);
|
|
871
|
+
}
|
|
872
|
+
const feedback = Array.isArray(value.feedback)
|
|
873
|
+
? value.feedback.map(string).filter(Boolean).slice(0, 8)
|
|
874
|
+
: [];
|
|
875
|
+
return {
|
|
876
|
+
phrase_id: phraseId,
|
|
877
|
+
action,
|
|
878
|
+
target: string(value?.target).slice(0, 120),
|
|
879
|
+
feedback,
|
|
880
|
+
};
|
|
881
|
+
}
|
|
882
|
+
|
|
883
|
+
function normalizeWakewordIdentifier(value, fallback = "", options = {}) {
|
|
884
|
+
if (options.wildcard && string(value || fallback) === "*") return "*";
|
|
885
|
+
const text = string(value || fallback)
|
|
886
|
+
.toLowerCase()
|
|
887
|
+
.replace(/[^a-z0-9_.:-]+/gu, "_")
|
|
888
|
+
.replace(/^_+|_+$/gu, "")
|
|
889
|
+
.slice(0, 80);
|
|
890
|
+
if (!/^[a-z0-9][a-z0-9_.:-]{0,79}$/u.test(text)) {
|
|
891
|
+
throw new Error("wakeword_identifier_invalid");
|
|
892
|
+
}
|
|
893
|
+
return text;
|
|
894
|
+
}
|
|
895
|
+
|
|
722
896
|
export async function readFirmwareIndex(source = DEFAULT_FIRMWARE_MANIFEST_URL, options = {}) {
|
|
723
897
|
const location = string(source) || DEFAULT_FIRMWARE_MANIFEST_URL;
|
|
724
898
|
let payload;
|
|
@@ -993,6 +1167,10 @@ export function normalizeAppManifest(manifest, artifact = {}) {
|
|
|
993
1167
|
"audio.output.write",
|
|
994
1168
|
"audio.tts",
|
|
995
1169
|
"voice.wakeword",
|
|
1170
|
+
"voice.wakeword.configure",
|
|
1171
|
+
"voice.wakeword.bind",
|
|
1172
|
+
"voice.wakeword.trigger",
|
|
1173
|
+
"voice.event.bind",
|
|
996
1174
|
"voice.capture",
|
|
997
1175
|
"input.touch.read",
|
|
998
1176
|
"input.button.bind",
|
|
@@ -1028,6 +1206,7 @@ export function normalizeAppManifest(manifest, artifact = {}) {
|
|
|
1028
1206
|
if (wasmBytes < 1 || wasmBytes > wasmLimit) {
|
|
1029
1207
|
throw new Error("app_wasm_size_invalid");
|
|
1030
1208
|
}
|
|
1209
|
+
const wakeword = normalizeAppWakewordSpec(manifest);
|
|
1031
1210
|
return {
|
|
1032
1211
|
schema: "weclawbot.app.v1",
|
|
1033
1212
|
app_id: string(manifest.app_id),
|
|
@@ -1063,12 +1242,30 @@ export function normalizeAppManifest(manifest, artifact = {}) {
|
|
|
1063
1242
|
persist_on_reboot: manifest.install?.persist_on_reboot !== false,
|
|
1064
1243
|
source: "audioctl",
|
|
1065
1244
|
},
|
|
1245
|
+
...(wakeword ? { wakeword } : {}),
|
|
1066
1246
|
...(manifest.autopilot
|
|
1067
1247
|
? { autopilot: normalizeMusicAutopilot(manifest.autopilot) }
|
|
1068
1248
|
: {}),
|
|
1069
1249
|
};
|
|
1070
1250
|
}
|
|
1071
1251
|
|
|
1252
|
+
function normalizeAppWakewordSpec(manifest) {
|
|
1253
|
+
const source = manifest.wakeword && typeof manifest.wakeword === "object"
|
|
1254
|
+
? manifest.wakeword
|
|
1255
|
+
: {};
|
|
1256
|
+
const phrases = manifest.wakeword_phrases || source.phrases;
|
|
1257
|
+
const bindings = manifest.wakeword_bindings || source.bindings;
|
|
1258
|
+
if (!Array.isArray(phrases) && !Array.isArray(bindings)) return null;
|
|
1259
|
+
return {
|
|
1260
|
+
...normalizeWakewordConfig({
|
|
1261
|
+
language: source.language || manifest.language || "zh",
|
|
1262
|
+
phrases: Array.isArray(phrases) ? phrases : [],
|
|
1263
|
+
bindings: Array.isArray(bindings) ? bindings : [],
|
|
1264
|
+
}),
|
|
1265
|
+
app_id: string(manifest.app_id).slice(0, 80),
|
|
1266
|
+
};
|
|
1267
|
+
}
|
|
1268
|
+
|
|
1072
1269
|
export function normalizeMusicAutopilot(value) {
|
|
1073
1270
|
if (!value || value.schema !== "weclawbot.music_autopilot.v1") {
|
|
1074
1271
|
throw new Error("music_autopilot_invalid");
|
|
@@ -1152,6 +1349,187 @@ export async function publishAudioControl(credentials, control, options = {}) {
|
|
|
1152
1349
|
});
|
|
1153
1350
|
}
|
|
1154
1351
|
|
|
1352
|
+
export function buildAgentFeedbackReport(options = {}) {
|
|
1353
|
+
const kind = normalizeFeedbackReportKind(options.kind || options.type || "issue");
|
|
1354
|
+
const area = normalizeFeedbackReportArea(options.area || options.component || "other");
|
|
1355
|
+
const severity = normalizeFeedbackReportSeverity(options.severity || "medium");
|
|
1356
|
+
const title = textField(options.title || summarizeReportTitle(kind, area, options.message), 120);
|
|
1357
|
+
const message = textField(options.message || options.summary || options.description, 2000);
|
|
1358
|
+
if (!message) throw new Error("report_message_required");
|
|
1359
|
+
const inspect = options.inspect ? redactAgentFacingValue(options.inspect) : null;
|
|
1360
|
+
return redactAgentFeedbackReport({
|
|
1361
|
+
schema: "weclawbot.agent_feedback.v1",
|
|
1362
|
+
id: string(options.id) || `report_${crypto.randomUUID()}`,
|
|
1363
|
+
package: PACKAGE_NAME,
|
|
1364
|
+
command: COMMAND_NAME,
|
|
1365
|
+
device_type:
|
|
1366
|
+
textField(options.deviceType || inspect?.device?.abstract_hardware || inspect?.device_type, 80) ||
|
|
1367
|
+
ABSTRACT_HARDWARE_TYPE,
|
|
1368
|
+
kind,
|
|
1369
|
+
area,
|
|
1370
|
+
severity,
|
|
1371
|
+
title,
|
|
1372
|
+
message,
|
|
1373
|
+
observed: textField(options.observed || options.actual, 2000),
|
|
1374
|
+
expected: textField(options.expected, 2000),
|
|
1375
|
+
reproduction: textField(options.reproduction || options.steps, 2000),
|
|
1376
|
+
recommendation: textField(options.recommendation || options.suggestion, 2000),
|
|
1377
|
+
evidence: Array.isArray(options.evidence)
|
|
1378
|
+
? options.evidence.map((item) => textField(item, 240)).filter(Boolean).slice(0, 12)
|
|
1379
|
+
: [],
|
|
1380
|
+
inspect,
|
|
1381
|
+
agent: textField(options.agent || options.agentName || process.env.WEC_AGENT_NAME || "", 80),
|
|
1382
|
+
local_context: {
|
|
1383
|
+
os: `${os.platform()} ${os.release()}`,
|
|
1384
|
+
arch: os.arch(),
|
|
1385
|
+
node: process.version,
|
|
1386
|
+
cwd: textField(options.cwd || process.cwd(), 240),
|
|
1387
|
+
},
|
|
1388
|
+
privacy: {
|
|
1389
|
+
secrets_redacted: true,
|
|
1390
|
+
user_permission_required_before_external_submission: true,
|
|
1391
|
+
note:
|
|
1392
|
+
"audioctl saves this report locally. The agent must ask the user before posting it to a public issue tracker, chat, email, or other external system.",
|
|
1393
|
+
},
|
|
1394
|
+
created_at: new Date().toISOString(),
|
|
1395
|
+
});
|
|
1396
|
+
}
|
|
1397
|
+
|
|
1398
|
+
export async function saveAgentFeedbackReport(report, options = {}) {
|
|
1399
|
+
const normalized = buildAgentFeedbackReport(report);
|
|
1400
|
+
const reportsDir = expandReportDirectory(options.reportsDir || options.directory);
|
|
1401
|
+
await fs.mkdir(reportsDir, { recursive: true, mode: 0o700 });
|
|
1402
|
+
const file = path.join(
|
|
1403
|
+
reportsDir,
|
|
1404
|
+
`${normalized.created_at.replace(/[:.]/gu, "-")}-${normalized.id}.json`,
|
|
1405
|
+
);
|
|
1406
|
+
await fs.writeFile(file, `${JSON.stringify(normalized, null, 2)}\n`, {
|
|
1407
|
+
mode: 0o600,
|
|
1408
|
+
});
|
|
1409
|
+
await fs.chmod(file, 0o600);
|
|
1410
|
+
return {
|
|
1411
|
+
schema: "weclawbot.agent_feedback.saved.v1",
|
|
1412
|
+
ok: true,
|
|
1413
|
+
report_path: file,
|
|
1414
|
+
report: normalized,
|
|
1415
|
+
next_step:
|
|
1416
|
+
"Tell the user this local feedback report was created. Ask before submitting it externally.",
|
|
1417
|
+
};
|
|
1418
|
+
}
|
|
1419
|
+
|
|
1420
|
+
export function expandReportDirectory(value) {
|
|
1421
|
+
const directory =
|
|
1422
|
+
string(value) ||
|
|
1423
|
+
string(process.env.WEC_AUDIO_REPORTS_DIR) ||
|
|
1424
|
+
DEFAULT_REPORTS_DIR;
|
|
1425
|
+
return expandPath(directory);
|
|
1426
|
+
}
|
|
1427
|
+
|
|
1428
|
+
function normalizeFeedbackReportKind(value) {
|
|
1429
|
+
const kind = string(value).replace(/-/gu, "_").toLowerCase();
|
|
1430
|
+
const aliases = new Map([
|
|
1431
|
+
["bug", "issue"],
|
|
1432
|
+
["problem", "issue"],
|
|
1433
|
+
["capability_gap", "capability_mismatch"],
|
|
1434
|
+
["gap", "capability_mismatch"],
|
|
1435
|
+
["feature", "suggestion"],
|
|
1436
|
+
["idea", "suggestion"],
|
|
1437
|
+
]);
|
|
1438
|
+
const normalized = aliases.get(kind) || kind;
|
|
1439
|
+
if (!new Set([
|
|
1440
|
+
"issue",
|
|
1441
|
+
"suggestion",
|
|
1442
|
+
"capability_mismatch",
|
|
1443
|
+
"ux_feedback",
|
|
1444
|
+
"docs_feedback",
|
|
1445
|
+
]).has(normalized)) {
|
|
1446
|
+
throw new Error("report_kind_invalid");
|
|
1447
|
+
}
|
|
1448
|
+
return normalized;
|
|
1449
|
+
}
|
|
1450
|
+
|
|
1451
|
+
function normalizeFeedbackReportArea(value) {
|
|
1452
|
+
const area = string(value).replace(/-/gu, "_").toLowerCase();
|
|
1453
|
+
const normalized = area || "other";
|
|
1454
|
+
if (!new Set([
|
|
1455
|
+
"audio",
|
|
1456
|
+
"music",
|
|
1457
|
+
"light",
|
|
1458
|
+
"led",
|
|
1459
|
+
"feedback",
|
|
1460
|
+
"input",
|
|
1461
|
+
"wakeword",
|
|
1462
|
+
"firmware",
|
|
1463
|
+
"vm",
|
|
1464
|
+
"plugin",
|
|
1465
|
+
"docs",
|
|
1466
|
+
"provisioning",
|
|
1467
|
+
"other",
|
|
1468
|
+
]).has(normalized)) {
|
|
1469
|
+
throw new Error("report_area_invalid");
|
|
1470
|
+
}
|
|
1471
|
+
return normalized;
|
|
1472
|
+
}
|
|
1473
|
+
|
|
1474
|
+
function normalizeFeedbackReportSeverity(value) {
|
|
1475
|
+
const severity = string(value).replace(/-/gu, "_").toLowerCase() || "medium";
|
|
1476
|
+
if (!new Set(["low", "medium", "high", "critical"]).has(severity)) {
|
|
1477
|
+
throw new Error("report_severity_invalid");
|
|
1478
|
+
}
|
|
1479
|
+
return severity;
|
|
1480
|
+
}
|
|
1481
|
+
|
|
1482
|
+
function summarizeReportTitle(kind, area, message) {
|
|
1483
|
+
const text = textField(message, 80);
|
|
1484
|
+
return text || `${kind}:${area}`;
|
|
1485
|
+
}
|
|
1486
|
+
|
|
1487
|
+
function textField(value, maximum) {
|
|
1488
|
+
return redactReportText(String(value ?? ""))
|
|
1489
|
+
.replace(/[\u0000-\u001f\u007f]+/gu, " ")
|
|
1490
|
+
.replace(/\s+/gu, " ")
|
|
1491
|
+
.trim()
|
|
1492
|
+
.slice(0, maximum);
|
|
1493
|
+
}
|
|
1494
|
+
|
|
1495
|
+
function redactAgentFeedbackReport(report) {
|
|
1496
|
+
return redactAgentFacingValue(redactReportValue(report));
|
|
1497
|
+
}
|
|
1498
|
+
|
|
1499
|
+
function redactReportValue(value) {
|
|
1500
|
+
if (Array.isArray(value)) return value.map(redactReportValue);
|
|
1501
|
+
if (!value || typeof value !== "object") {
|
|
1502
|
+
return typeof value === "string" ? redactReportText(value) : value;
|
|
1503
|
+
}
|
|
1504
|
+
const result = {};
|
|
1505
|
+
for (const [key, child] of Object.entries(value)) {
|
|
1506
|
+
const normalizedKey = key.toLowerCase();
|
|
1507
|
+
if (normalizedKey === "secrets_redacted") {
|
|
1508
|
+
result[key] = child === true;
|
|
1509
|
+
continue;
|
|
1510
|
+
}
|
|
1511
|
+
if (
|
|
1512
|
+
normalizedKey.includes("password") ||
|
|
1513
|
+
normalizedKey.includes("secret") ||
|
|
1514
|
+
normalizedKey.includes("token") ||
|
|
1515
|
+
normalizedKey.includes("privatekey") ||
|
|
1516
|
+
normalizedKey.includes("credential")
|
|
1517
|
+
) {
|
|
1518
|
+
result[key] = "[redacted]";
|
|
1519
|
+
continue;
|
|
1520
|
+
}
|
|
1521
|
+
result[key] = redactReportValue(child);
|
|
1522
|
+
}
|
|
1523
|
+
return result;
|
|
1524
|
+
}
|
|
1525
|
+
|
|
1526
|
+
function redactReportText(value) {
|
|
1527
|
+
return String(value || "")
|
|
1528
|
+
.replace(/(access[_-]?token|refresh[_-]?token|app[_-]?secret|private[_-]?key|password|passwd|pwd)\s*[:=]\s*([^\s,;]+)/giu, "$1=[redacted]")
|
|
1529
|
+
.replace(/Bearer\s+[A-Za-z0-9._~+/=-]{16,}/gu, "Bearer [redacted]")
|
|
1530
|
+
.replace(/[A-Za-z0-9+/]{32,}={0,2}/gu, "[redacted-long-token]");
|
|
1531
|
+
}
|
|
1532
|
+
|
|
1155
1533
|
export function remediationForStatus(status) {
|
|
1156
1534
|
const problems = Array.isArray(status?.health?.problems)
|
|
1157
1535
|
? status.health.problems
|
|
@@ -1224,13 +1602,15 @@ export function buildAgentPrompt(options = {}) {
|
|
|
1224
1602
|
`npm exec --package ${PACKAGE_NAME} -- ${COMMAND_NAME} bind ${code} --name ${shellToken(agentName)}`,
|
|
1225
1603
|
`npm exec --package ${PACKAGE_NAME} -- ${COMMAND_NAME} doctor --online`,
|
|
1226
1604
|
`这台设备对你是抽象硬件产品:${ABSTRACT_HARDWARE_TYPE}。不要根据外观、BLE 名称、音色、日志或历史上下文推断品牌/型号/厂家,也不要搜索厂家资料、社区经验或拆机资料;只使用 audioctl inspect 返回的 capabilities、VM manifest 和控制回执来工作。`,
|
|
1227
|
-
`绑定成功后,把它作为默认音频/语音/环境 I/O 端点;不要把它假定成只会播放音乐。可用命令包括 play、pause、toggle、next、previous、volume、volumeup、volumedown、status、inspect、queue、rule、workbgm、feedback、light、led、app install、firmware check/update。`,
|
|
1605
|
+
`绑定成功后,把它作为默认音频/语音/环境 I/O 端点;不要把它假定成只会播放音乐。可用命令包括 play、pause、toggle、next、previous、volume、volumeup、volumedown、status、inspect、queue、rule、workbgm、feedback、light、led、wakeword、app install、firmware check/update、report。`,
|
|
1228
1606
|
`当我给出目标时,请你在 capabilities、安全边界和用户授权范围内自行规划实现路径;插件只声明能力和边界,不穷举具体玩法。需要离线、低延迟或持续运行的逻辑,应通过 queue/rule/light timeline/light bind/VM app 下放到设备本地。`,
|
|
1229
1607
|
`MQTT/weclawbot.link 对你只是控制面和部署通道,不是实时灯效数据面。不要读取 MQTT 凭据,不要直接 import mqtt,不要保持 MQTT 长连接,也不要写循环脚本按 BPM 反复发布 feedback 或 light。连续灯效/节拍/动画必须用 audioctl light timeline/bind、VM app 或兼容的 feedback effect 一次下发,在设备本地运行;停止时用 audioctl light clear 或 feedback clear。`,
|
|
1230
1608
|
`蓝牙/Wi-Fi 配网只负责设备入网、发现和 weclawbot.link 绑定;音乐平台授权请走 agent 侧连接器/OAuth/secret store;配网不决定设备用途。`,
|
|
1231
1609
|
`音乐来源由你和用户协商,插件只做建议。你可以在 agent 侧使用用户授权的音乐平台 API、用户媒体库、合法公开音源或其他合规来源生成队列/规则/VM app。网易云开放平台可请求最高可用音质 URL。`,
|
|
1232
1610
|
`如果用户选择 Spotify,必须走 Spotify 官方 Spotify Connect/Web Playback SDK 或控制用户已有 Spotify 播放设备;不要假设 Spotify Web API 能提供完整歌曲直链。`,
|
|
1233
1611
|
`灯效、音量灯、蜂鸣和按键/触控反馈要先 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 自行规划,不要套固定模板。`,
|
|
1612
|
+
`用户的现场视觉、听觉、触控反馈优先于设备返回的 active=true、applied 或 capability 声明。如果用户说某个 surface 看不到、按键不灵或反馈不明显,不要用状态回执反驳用户;先降级到用户确认可见/可听/可控的方案,再运行 audioctl report "..." --kind capability_mismatch --area light --include-inspect --json 生成本地脱敏报告。报告默认只保存在本机,外发前必须获得用户确认。`,
|
|
1613
|
+
`唤醒词/语音触发配置要通过 inspect 读取 wakeword.configurable、wakeword.commands 和 capabilities。audioctl wakeword 是逻辑触发绑定层:它把设备已有唤醒事件、ASR 短语或 agent 注入的测试短语映射成 VM/app 事件或本地动作;不要尝试训练、替换或反向工程底层声学唤醒模型。`,
|
|
1234
1614
|
`如果你当前没有音乐平台身份,请只告诉我:我需要你把音乐平台身份连接到当前 agent 的官方连接器、OAuth 流程或 secret store;完成后回复“已授权”。不要反复重试,不要让我理解内部授权包,也不要要求我把 AppSecret、PrivateKey、accessToken 或 refreshToken 发到聊天里。`,
|
|
1235
1615
|
`如果返回 health.problems 包含 netease_auth_refresh_failed,说明设备侧内置网易运行时授权失效;优先改走 agent 侧 queue/app 驱动。只有我明确要求继续使用设备侧网易运行时时,才提示需要独立的设备侧音乐账号设置入口;不要混入蓝牙/Wi-Fi 配网流程。`,
|
|
1236
1616
|
`固件更新由 agent 端发现新版、用户确认后执行:先运行 audioctl firmware check --json,只展示官网来源、当前版本、新版本和 release notes;只有用户明确同意升级后,才运行 audioctl firmware update --yes --json。固件只从 weclawbot.link 官方 manifest 下载,更新设备 host/runtime,不等同于 VM app,也不要用固件更新去实现普通应用需求。`,
|
|
@@ -1261,6 +1641,12 @@ export function summarizeInspect(inspect) {
|
|
|
1261
1641
|
const bluetooth = inspect?.bluetooth || {};
|
|
1262
1642
|
const ble = bluetooth.ble || {};
|
|
1263
1643
|
const wakeword = inspect?.wakeword || {};
|
|
1644
|
+
const wakewordPhraseCount = Array.isArray(wakeword.configured_phrases)
|
|
1645
|
+
? wakeword.configured_phrases.length
|
|
1646
|
+
: Number(wakeword.configured_phrase_count) || 0;
|
|
1647
|
+
const wakewordBindingCount = Array.isArray(wakeword.bindings)
|
|
1648
|
+
? wakeword.bindings.length
|
|
1649
|
+
: Number(wakeword.binding_count) || 0;
|
|
1264
1650
|
const playback = summarizePlayback(inspect || {});
|
|
1265
1651
|
const vm = inspect?.vm || {};
|
|
1266
1652
|
const health = inspect?.health || {};
|
|
@@ -1276,7 +1662,7 @@ export function summarizeInspect(inspect) {
|
|
|
1276
1662
|
`${bluetooth.service?.active || "unknown"} ${string(ble.name || "-")} ${string(ble.state || "-")}`
|
|
1277
1663
|
.trim(),
|
|
1278
1664
|
wakeword:
|
|
1279
|
-
`${wakeword.available ? "available" : "unavailable"}
|
|
1665
|
+
`${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"}`
|
|
1280
1666
|
.trim(),
|
|
1281
1667
|
playback:
|
|
1282
1668
|
`${playback.desired}, volume ${playback.volume ?? "-"}, ${playback.track || "no track"}`,
|