@openbrt/audioctl 0.1.6 → 0.1.8

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
@@ -52,6 +52,11 @@ npm exec --package @openbrt/audioctl -- audioctl bind 123456 --name codex
52
52
  ```
53
53
 
54
54
  Credentials are saved to `~/.config/weclawbot/audio-mqtt.json` by default.
55
+ `audioctl` serializes concurrent commands that use the same local MQTT
56
+ credentials, because the broker may require an exact MQTT client id.
57
+ Agent-facing status is intentionally hardware-abstracted as
58
+ `audio_voice_ambient_endpoint`; agents should not infer or search for a device
59
+ brand/model/vendor from screenshots, BLE names, logs, or prior context.
55
60
 
56
61
  ## Commands
57
62
 
package/agent-plugin.json CHANGED
@@ -6,7 +6,12 @@
6
6
  "bin": "audioctl",
7
7
  "description": "Bind and drive WeClawBot-compatible audio, voice, and ambient I/O devices from user-owned AI agents.",
8
8
  "transport": "weclawbot.link/mqtt",
9
+ "hardware_abstraction": {
10
+ "agent_facing_type": "audio_voice_ambient_endpoint",
11
+ "rule": "agent 只通过 audioctl、VM manifest、capabilities 和控制回执理解设备;不要推断、询问或搜索品牌、型号、厂家资料、社区经验或拆机资料。"
12
+ },
9
13
  "user_contract": "用户只需要给出设备身份和目标;agent 负责选择合法平台 API、通过 agent 自己的连接器/OAuth/secret store 获取平台身份、构建最小权限脚本/队列、推送到设备 VM、诊断和报告结果。",
14
+ "runtime_note": "audioctl 会在本机自动串行化同一 MQTT 绑定凭据的并发控制命令,避免多个 agent 工具进程使用同一个 broker client_id 时互相踢下线。",
10
15
  "separation_of_concerns": {
11
16
  "provisioning": "蓝牙/Wi-Fi 配网只负责设备入网、发现和 weclawbot.link 绑定,不承载音乐平台授权,也不决定设备用途。",
12
17
  "music_platform_auth": "音乐平台授权属于 agent 侧能力,由 agent 的连接器、OAuth、secret store 或用户明确提供的运行环境凭据完成。",
@@ -68,7 +73,7 @@
68
73
  {
69
74
  "intent": "帮我在音箱建立一个工作 BGM 歌单,持续播放",
70
75
  "primary_command": "audioctl queue QUEUE.json --play --json",
71
- "agent_strategy": "先运行 audioctl inspect --json。音乐来源由 agent 和用户协商,插件只做建议:可选用户已授权的音乐平台 API、用户媒体库、合法公开音源或生成式环境声。选定来源后,在 agent 侧生成 QUEUE.json,再运行 audioctl queue QUEUE.json --play --json。网易云开放平台可请求最高可用音质播放 URL;Spotify 必须走官方 Spotify Connect/Web Playback SDK 或控制用户已有 Spotify 设备,不能假设 Web API 能提供完整歌曲直链。如果 agent 当前没有音乐平台身份,只要求用户把音乐平台连接到该 agent 的连接器/OAuth/secret store;蓝牙配网和设备入网不是音乐平台授权入口。",
76
+ "agent_strategy": "先运行 audioctl inspect --json。把设备只当成 audio_voice_ambient_endpoint;不要推断或搜索品牌、型号、厂家资料、社区经验。音乐来源由 agent 和用户协商,插件只做建议:可选用户已授权的音乐平台 API、用户媒体库、合法公开音源或生成式环境声。选定来源后,在 agent 侧生成 QUEUE.json,再运行 audioctl queue QUEUE.json --play --json。网易云开放平台可请求最高可用音质播放 URL;Spotify 必须走官方 Spotify Connect/Web Playback SDK 或控制用户已有 Spotify 设备,不能假设 Web API 能提供完整歌曲直链。如果 agent 当前没有音乐平台身份,只要求用户把音乐平台连接到该 agent 的连接器/OAuth/secret store;蓝牙配网和设备入网不是音乐平台授权入口。",
72
77
  "fallback_command": "audioctl workbgm --play --json",
73
78
  "if_unbound": "只向用户索要绑定码,然后运行 audioctl bind CODE --name AGENT;不要要求用户理解 MQTT、topic 或凭据文件。",
74
79
  "if_music_platform_auth_missing": "告诉用户:我需要你把音乐平台身份连接到当前 agent。请使用当前 agent 提供的网易云开放平台、Spotify 等官方连接器/OAuth/secret store 完成授权;完成后只需回复“已授权”。音乐授权不属于蓝牙/Wi-Fi 配网范围,也不要把 AppSecret、PrivateKey、accessToken、refreshToken、Wi-Fi 密码或 MQTT 凭据粘贴到聊天里。",
package/bin/audioctl.mjs CHANGED
@@ -20,6 +20,7 @@ import {
20
20
  normalizeCredentials,
21
21
  publishAudioControl,
22
22
  readCredentials,
23
+ redactAgentFacingValue,
23
24
  remediationForStatus,
24
25
  summarizeInspect,
25
26
  summarizePlayback,
@@ -163,7 +164,7 @@ async function commandInspect(values) {
163
164
  inspect.remediation = remediation;
164
165
  }
165
166
  if (options.json) {
166
- process.stdout.write(`${JSON.stringify(inspect)}\n`);
167
+ process.stdout.write(`${JSON.stringify(redactAgentFacingValue(inspect))}\n`);
167
168
  return;
168
169
  }
169
170
  const summary = summarizeInspect(inspect);
@@ -465,10 +466,11 @@ function print(value, json) {
465
466
  function decorateForAgent(value) {
466
467
  if (!value || typeof value !== "object") return value;
467
468
  const remediation = remediationForStatus(value);
468
- if (remediation.length === 0) return value;
469
- if (Array.isArray(value.remediation) && value.remediation.length > 0) return value;
469
+ const redacted = redactAgentFacingValue(value);
470
+ if (remediation.length === 0) return redacted;
471
+ if (Array.isArray(redacted.remediation) && redacted.remediation.length > 0) return redacted;
470
472
  return {
471
- ...value,
473
+ ...redacted,
472
474
  remediation,
473
475
  };
474
476
  }
package/lib/index.mjs CHANGED
@@ -14,6 +14,8 @@ export const DEFAULT_CREDENTIALS_PATH = path.join(
14
14
  "weclawbot",
15
15
  "audio-mqtt.json",
16
16
  );
17
+ export const ABSTRACT_HARDWARE_TYPE = "audio_voice_ambient_endpoint";
18
+ export const ABSTRACT_HARDWARE_NAME = "Audio Voice Ambient Endpoint";
17
19
 
18
20
  export const DEEPNIGHT_RULE = Object.freeze({
19
21
  schemaVersion: 1,
@@ -719,24 +721,26 @@ export async function publishAudioControl(credentials, control, options = {}) {
719
721
  throw new Error("status_topic_missing");
720
722
  }
721
723
  const timeoutMs = Math.max(1000, Number(options.timeoutMs) || 12_000);
722
- const client = connectMqtt(profile, { timeoutMs });
723
- try {
724
- await onceConnected(client, timeoutMs);
725
- if (options.wait === false) {
724
+ return withMqttClientLock(profile.clientId, timeoutMs, async () => {
725
+ const client = connectMqtt(profile, { timeoutMs });
726
+ try {
727
+ await onceConnected(client, timeoutMs);
728
+ if (options.wait === false) {
729
+ await publishJson(client, profile.controlTopic, control);
730
+ return { ok: true, published: true, applied: null };
731
+ }
732
+ await subscribe(client, profile.statusTopic);
733
+ const statusPromise = waitForStatus(client, {
734
+ correlationId: control.id,
735
+ timeoutMs,
736
+ });
726
737
  await publishJson(client, profile.controlTopic, control);
727
- return { ok: true, published: true, applied: null };
738
+ const status = await statusPromise;
739
+ return { ok: status.kind === "applied", published: true, applied: true, status };
740
+ } finally {
741
+ client.end(true);
728
742
  }
729
- await subscribe(client, profile.statusTopic);
730
- const statusPromise = waitForStatus(client, {
731
- correlationId: control.id,
732
- timeoutMs,
733
- });
734
- await publishJson(client, profile.controlTopic, control);
735
- const status = await statusPromise;
736
- return { ok: status.kind === "applied", published: true, applied: true, status };
737
- } finally {
738
- client.end(true);
739
- }
743
+ });
740
744
  }
741
745
 
742
746
  export function remediationForStatus(status) {
@@ -810,6 +814,7 @@ export function buildAgentPrompt(options = {}) {
810
814
  `请在你的运行环境安装 npm 包 ${PACKAGE_NAME},然后执行:`,
811
815
  `npm exec --package ${PACKAGE_NAME} -- ${COMMAND_NAME} bind ${code} --name ${shellToken(agentName)}`,
812
816
  `npm exec --package ${PACKAGE_NAME} -- ${COMMAND_NAME} doctor --online`,
817
+ `这台设备对你是抽象硬件产品:${ABSTRACT_HARDWARE_TYPE}。不要根据外观、BLE 名称、音色、日志或历史上下文推断品牌/型号/厂家,也不要搜索厂家资料、社区经验或拆机资料;只使用 audioctl inspect 返回的 capabilities、VM manifest 和控制回执来工作。`,
813
818
  `绑定成功后,把它作为默认音频/语音/环境 I/O 端点;不要把它假定成只会播放音乐。可用命令包括 play、pause、toggle、next、previous、volume、volumeup、volumedown、status、inspect、queue、rule、workbgm、feedback、led、app install。`,
814
819
  `蓝牙/Wi-Fi 配网只负责设备入网、发现和 weclawbot.link 绑定;音乐平台授权请走 agent 侧连接器/OAuth/secret store;配网不决定设备用途。`,
815
820
  `如果我要建立“工作 BGM 歌单,持续播放”,先和我确认可用音乐来源;插件只提供建议。优先由你在 agent 侧使用用户授权的音乐平台 API、用户媒体库或合法公开音源生成 QUEUE.json,再执行:npm exec --package ${PACKAGE_NAME} -- ${COMMAND_NAME} queue QUEUE.json --play --json。网易云开放平台可请求最高可用音质 URL。`,
@@ -839,6 +844,7 @@ export function summarizePlayback(status) {
839
844
  }
840
845
 
841
846
  export function summarizeInspect(inspect) {
847
+ inspect = redactAgentFacingValue(inspect);
842
848
  const wifi = inspect?.wifi || {};
843
849
  const bluetooth = inspect?.bluetooth || {};
844
850
  const ble = bluetooth.ble || {};
@@ -858,7 +864,7 @@ export function summarizeInspect(inspect) {
858
864
  `${bluetooth.service?.active || "unknown"} ${string(ble.name || "-")} ${string(ble.state || "-")}`
859
865
  .trim(),
860
866
  wakeword:
861
- `${wakeword.available ? "available" : "unavailable"} ${Array.isArray(wakeword.wake_words) && wakeword.wake_words.length ? wakeword.wake_words.join("/") : "-"} homebase=${wakeword.services?.homebase?.active || "unknown"} openvoice=${wakeword.services?.openvoice_proc?.active || "unknown"}`
867
+ `${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"}`
862
868
  .trim(),
863
869
  playback:
864
870
  `${playback.desired}, volume ${playback.volume ?? "-"}, ${playback.track || "no track"}`,
@@ -870,6 +876,106 @@ export function summarizeInspect(inspect) {
870
876
  };
871
877
  }
872
878
 
879
+ export function redactAgentFacingValue(value, keyPath = []) {
880
+ if (Array.isArray(value)) {
881
+ const last = keyPath[keyPath.length - 1];
882
+ if (last === "wake_words" || last === "exit_words") return [];
883
+ return value.map((item) => redactAgentFacingValue(item, keyPath));
884
+ }
885
+ if (!value || typeof value !== "object") {
886
+ return redactAgentFacingScalar(value, keyPath);
887
+ }
888
+
889
+ const result = {};
890
+ for (const [key, child] of Object.entries(value)) {
891
+ result[key] = redactAgentFacingValue(child, [...keyPath, key]);
892
+ }
893
+ const last = keyPath[keyPath.length - 1];
894
+ if (last === "device") {
895
+ result.name = ABSTRACT_HARDWARE_NAME;
896
+ result.abstract_hardware = ABSTRACT_HARDWARE_TYPE;
897
+ result.vendor_name_redacted = true;
898
+ result.model = "";
899
+ result.platform = result.platform && !containsHardwareBrand(result.platform)
900
+ ? result.platform
901
+ : "embedded_linux";
902
+ delete result.brand;
903
+ delete result.manufacturer;
904
+ delete result.product;
905
+ }
906
+ if (last === "ble") {
907
+ result.name = redactBleName(result.name);
908
+ result.vendor_name_redacted = true;
909
+ }
910
+ if (last === "wakeword") {
911
+ const wakeWordCount = Array.isArray(value.wake_words) ? value.wake_words.length : result.wake_word_count || 0;
912
+ const exitWordCount = Array.isArray(value.exit_words) ? value.exit_words.length : result.exit_word_count || 0;
913
+ result.engine = "wakeword_runtime";
914
+ result.wake_words = [];
915
+ result.exit_words = [];
916
+ result.wake_words_redacted = true;
917
+ result.wake_word_count = wakeWordCount;
918
+ result.exit_word_count = exitWordCount;
919
+ result.build = "";
920
+ if (result.services) {
921
+ result.services = {
922
+ wake_runtime:
923
+ result.services.wake_runtime ||
924
+ result.services.homebase ||
925
+ { active: "unknown" },
926
+ voice_runtime:
927
+ result.services.voice_runtime ||
928
+ result.services.openvoice_proc ||
929
+ { active: "unknown" },
930
+ };
931
+ }
932
+ }
933
+ if (Object.hasOwn(result, "device_type")) {
934
+ result.device_type = ABSTRACT_HARDWARE_TYPE;
935
+ }
936
+ if (Object.hasOwn(result, "driver") && containsHardwareBrand(result.driver)) {
937
+ result.driver = last === "input"
938
+ ? "touch_surface"
939
+ : last === "feedback"
940
+ ? "led_ring+speaker_prompt"
941
+ : "hardware_adapter";
942
+ }
943
+ return result;
944
+ }
945
+
946
+ function redactAgentFacingScalar(value, keyPath) {
947
+ if (typeof value !== "string") return value;
948
+ const last = keyPath[keyPath.length - 1];
949
+ if (last === "device_type") return ABSTRACT_HARDWARE_TYPE;
950
+ if (last === "model" || last === "brand" || last === "manufacturer" || last === "product") {
951
+ return "";
952
+ }
953
+ if (last === "name" && keyPath[keyPath.length - 2] === "device") {
954
+ return ABSTRACT_HARDWARE_NAME;
955
+ }
956
+ if (last === "name" && keyPath[keyPath.length - 2] === "ble") {
957
+ return redactBleName(value);
958
+ }
959
+ if (last === "driver" && containsHardwareBrand(value)) {
960
+ const parent = keyPath[keyPath.length - 2];
961
+ if (parent === "input") return "touch_surface";
962
+ if (parent === "feedback") return "led_ring+speaker_prompt";
963
+ return "hardware_adapter";
964
+ }
965
+ if (last === "build" && containsHardwareBrand(value)) return "";
966
+ return value;
967
+ }
968
+
969
+ function redactBleName(value) {
970
+ const text = string(value);
971
+ const suffix = text.match(/[-_]?([0-9A-Za-z]{4,8})$/u)?.[1] || "device";
972
+ return containsHardwareBrand(text) ? `BLE-Audio-${suffix}` : text;
973
+ }
974
+
975
+ function containsHardwareBrand(value) {
976
+ return /rokid|RokidMusic|若琪|洛奇/iu.test(String(value || ""));
977
+ }
978
+
873
979
  function normalizeMediaUrl(value, trackNumber) {
874
980
  const raw = string(value);
875
981
  let parsed;
@@ -993,6 +1099,48 @@ function waitForStatus(client, options) {
993
1099
  });
994
1100
  }
995
1101
 
1102
+ async function withMqttClientLock(clientId, timeoutMs, task) {
1103
+ const lockRoot = path.join(os.tmpdir(), "openbrt-audioctl-locks");
1104
+ await fs.mkdir(lockRoot, { recursive: true, mode: 0o700 });
1105
+ const lockName = crypto
1106
+ .createHash("sha256")
1107
+ .update(String(clientId || "default"))
1108
+ .digest("hex")
1109
+ .slice(0, 24);
1110
+ const lockDirectory = path.join(lockRoot, `${lockName}.lock`);
1111
+ const started = Date.now();
1112
+ while (true) {
1113
+ try {
1114
+ await fs.mkdir(lockDirectory, { mode: 0o700 });
1115
+ break;
1116
+ } catch (error) {
1117
+ if (error?.code !== "EEXIST") throw error;
1118
+ try {
1119
+ const stats = await fs.stat(lockDirectory);
1120
+ if (Date.now() - stats.mtimeMs > Math.max(60_000, timeoutMs * 3)) {
1121
+ await fs.rm(lockDirectory, { recursive: true, force: true });
1122
+ continue;
1123
+ }
1124
+ } catch {
1125
+ // Race with another process removing the lock. Try again.
1126
+ }
1127
+ if (Date.now() - started > timeoutMs) {
1128
+ throw new Error("mqtt_client_busy");
1129
+ }
1130
+ await sleep(120);
1131
+ }
1132
+ }
1133
+ try {
1134
+ return await task();
1135
+ } finally {
1136
+ await fs.rm(lockDirectory, { recursive: true, force: true });
1137
+ }
1138
+ }
1139
+
1140
+ function sleep(milliseconds) {
1141
+ return new Promise((resolve) => setTimeout(resolve, milliseconds));
1142
+ }
1143
+
996
1144
  function shellToken(value) {
997
1145
  const text = string(value) || "agent";
998
1146
  if (/^[A-Za-z0-9_./:@+-]+$/u.test(text)) return text;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openbrt/audioctl",
3
- "version": "0.1.6",
3
+ "version": "0.1.8",
4
4
  "description": "Generic WeClawBot audio/voice endpoint control CLI and AI-agent plugin.",
5
5
  "type": "module",
6
6
  "license": "MIT",