iobroker.assistant 0.1.2

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.
Files changed (98) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +176 -0
  3. package/admin/assistant.svg +19 -0
  4. package/admin/custom/.vite/manifest.json +169 -0
  5. package/admin/custom/assets/Components-DcnuVFM6.js +1 -0
  6. package/admin/custom/assets/_virtual_mf-localSharedImportMap___mfe_internal__ConfigCustomAssistant__mf_owner__1-DogEjv3U.js +1 -0
  7. package/admin/custom/assets/_virtual_mf___mfe_internal__ConfigCustomAssistant__mf_owner__1__loadShare___mf_0_emotion_mf_1_react__loadShare__.js-Dw5TkU1t.js +1 -0
  8. package/admin/custom/assets/_virtual_mf___mfe_internal__ConfigCustomAssistant__mf_owner__1__loadShare___mf_0_emotion_mf_1_styled__loadShare__.js-BEFjTs9S.js +1 -0
  9. package/admin/custom/assets/_virtual_mf___mfe_internal__ConfigCustomAssistant__mf_owner__1__loadShare___mf_0_iobroker_mf_1_gui_mf_2_components__loadShare__.js-C4r3udVz.js +1243 -0
  10. package/admin/custom/assets/_virtual_mf___mfe_internal__ConfigCustomAssistant__mf_owner__1__loadShare___mf_0_iobroker_mf_1_json_mf_2_config__loadShare__.js-DlOBHPL0.js +1494 -0
  11. package/admin/custom/assets/hostInit-DKMI-sLp.js +1 -0
  12. package/admin/custom/assets/index-B8wW0kQx.js +1 -0
  13. package/admin/custom/assets/lib-DpE8sc4h.js +3 -0
  14. package/admin/custom/assets/mf-entry-bootstrap-0-e7fa582d.js +36 -0
  15. package/admin/custom/assets/rolldown-runtime-C0FnF6B9.js +1 -0
  16. package/admin/custom/assets/virtualExposes-DOTO22UK.js +1 -0
  17. package/admin/custom/assets/virtual_mf-REMOTE_ENTRY_ID___mfe_internal__ConfigCustomAssistant__customComponents_js-CQa59yVi.js +1 -0
  18. package/admin/custom/assets/virtual_mf-exposes-ssr___mfe_internal__ConfigCustomAssistant__customComponents_js-9g2Fj8tk.js +2 -0
  19. package/admin/custom/assets/virtual_mf-exposes___mfe_internal__ConfigCustomAssistant__customComponents_js-BTUQTbYo.js +2 -0
  20. package/admin/custom/assets/vite-preload-helper-B7qeedMF.js +1 -0
  21. package/admin/custom/customComponents.js +1 -0
  22. package/admin/custom/customComponents.ssr.js +1 -0
  23. package/admin/custom/i18n/de.json +58 -0
  24. package/admin/custom/i18n/en.json +58 -0
  25. package/admin/custom/i18n/es.json +34 -0
  26. package/admin/custom/i18n/fr.json +34 -0
  27. package/admin/custom/i18n/it.json +34 -0
  28. package/admin/custom/i18n/nl.json +34 -0
  29. package/admin/custom/i18n/pl.json +34 -0
  30. package/admin/custom/i18n/pt.json +34 -0
  31. package/admin/custom/i18n/ru.json +34 -0
  32. package/admin/custom/i18n/uk.json +34 -0
  33. package/admin/custom/i18n/zh-cn.json +34 -0
  34. package/admin/custom/index.html +32 -0
  35. package/admin/custom/mf-manifest.json +1 -0
  36. package/admin/custom/mf-stats.json +1 -0
  37. package/admin/i18n/de.json +130 -0
  38. package/admin/i18n/en.json +130 -0
  39. package/admin/i18n/es.json +106 -0
  40. package/admin/i18n/fr.json +106 -0
  41. package/admin/i18n/it.json +106 -0
  42. package/admin/i18n/nl.json +106 -0
  43. package/admin/i18n/pl.json +106 -0
  44. package/admin/i18n/pt.json +106 -0
  45. package/admin/i18n/ru.json +106 -0
  46. package/admin/i18n/uk.json +106 -0
  47. package/admin/i18n/zh-cn.json +106 -0
  48. package/admin/jsonConfig.json +743 -0
  49. package/admin/sounds/alarm.wav +0 -0
  50. package/admin/sounds/timer.wav +0 -0
  51. package/build/lib/alarms.js +281 -0
  52. package/build/lib/alarms.js.map +1 -0
  53. package/build/lib/context.js +52 -0
  54. package/build/lib/context.js.map +1 -0
  55. package/build/lib/credentials.js +121 -0
  56. package/build/lib/credentials.js.map +1 -0
  57. package/build/lib/llm.js +303 -0
  58. package/build/lib/llm.js.map +1 -0
  59. package/build/lib/localLlm.js +221 -0
  60. package/build/lib/localLlm.js.map +1 -0
  61. package/build/lib/memory.js +190 -0
  62. package/build/lib/memory.js.map +1 -0
  63. package/build/lib/nlu.js +929 -0
  64. package/build/lib/nlu.js.map +1 -0
  65. package/build/lib/timers.js +245 -0
  66. package/build/lib/timers.js.map +1 -0
  67. package/build/lib/tools.js +299 -0
  68. package/build/lib/tools.js.map +1 -0
  69. package/build/lib/voice/aws.js +102 -0
  70. package/build/lib/voice/aws.js.map +1 -0
  71. package/build/lib/voice/azure.js +144 -0
  72. package/build/lib/voice/azure.js.map +1 -0
  73. package/build/lib/voice/download.js +76 -0
  74. package/build/lib/voice/download.js.map +1 -0
  75. package/build/lib/voice/engines.js +89 -0
  76. package/build/lib/voice/engines.js.map +1 -0
  77. package/build/lib/voice/lang.js +33 -0
  78. package/build/lib/voice/lang.js.map +1 -0
  79. package/build/lib/voice/piper.js +191 -0
  80. package/build/lib/voice/piper.js.map +1 -0
  81. package/build/lib/voice/protocol.js +35 -0
  82. package/build/lib/voice/protocol.js.map +1 -0
  83. package/build/lib/voice/stt.js +116 -0
  84. package/build/lib/voice/stt.js.map +1 -0
  85. package/build/lib/voice/tts.js +36 -0
  86. package/build/lib/voice/tts.js.map +1 -0
  87. package/build/lib/voice/voiceServer.js +249 -0
  88. package/build/lib/voice/voiceServer.js.map +1 -0
  89. package/build/lib/voice/vosk.js +260 -0
  90. package/build/lib/voice/vosk.js.map +1 -0
  91. package/build/lib/voice/wyoming.js +320 -0
  92. package/build/lib/voice/wyoming.js.map +1 -0
  93. package/build/lib/weather.js +423 -0
  94. package/build/lib/weather.js.map +1 -0
  95. package/build/main.js +3152 -0
  96. package/build/main.js.map +1 -0
  97. package/io-package.json +528 -0
  98. package/package.json +68 -0
@@ -0,0 +1,299 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.ListCache = exports.LIST_CACHE_TTL_MS = void 0;
4
+ exports.deviceStateIds = deviceStateIds;
5
+ exports.deviceKey = deviceKey;
6
+ exports.isToolAllowed = isToolAllowed;
7
+ exports.buildMcpTools = buildMcpTools;
8
+ /** All control state ids of a `list_devices` device entry. */
9
+ function deviceStateIds(dev) {
10
+ return Object.values(dev.controls || {})
11
+ .map(c => c?.stateId)
12
+ .filter((x) => !!x);
13
+ }
14
+ /**
15
+ * Stable per-device key used by both the backend write-guard and the admin ACL editor. Keyed by the
16
+ * device's primary (lexicographically first) control state id — unique per device and independent of the
17
+ * (often non-unique) type-detector name, so two "SET" controls no longer collide on one ACL entry.
18
+ */
19
+ function deviceKey(stateIds) {
20
+ return [...stateIds].sort()[0] || '';
21
+ }
22
+ /** Maps every known `@iobroker/mcp-server` tool to an access category. */
23
+ const TOOL_CATEGORY = {
24
+ get_states: 'stateRead',
25
+ list_devices: 'devices',
26
+ list_rooms: 'devices',
27
+ list_functions: 'devices',
28
+ set_state: 'stateWrite',
29
+ set_states: 'stateWrite',
30
+ get_object: 'objectReadAll',
31
+ search_objects: 'objectReadAll',
32
+ get_logs: 'logRead',
33
+ write_log: 'logWrite',
34
+ history_query: 'history',
35
+ read_file: 'files',
36
+ list_files: 'files',
37
+ file_exists: 'files',
38
+ system_info: 'system',
39
+ list_hosts: 'system',
40
+ list_instances: 'system',
41
+ list_adapters: 'system',
42
+ search_adapter_repository: 'system',
43
+ ping_host: 'system',
44
+ set_object: 'objectChange',
45
+ delete_object: 'objectChange',
46
+ create_state: 'objectChange',
47
+ create_scene: 'objectChange',
48
+ write_file: 'objectChange',
49
+ delete_file: 'objectChange',
50
+ rename_file: 'objectChange',
51
+ mkdir: 'objectChange',
52
+ };
53
+ /**
54
+ * Read-only, system-wide-scan tools whose results are stable enough to serve from a short-TTL cache.
55
+ * Each of these runs the ioBroker type-detector over *all* objects (`list_devices` alone does five
56
+ * full `getObjectView` scans), so repeat calls inside one conversation are the main avoidable cost.
57
+ */
58
+ const CACHEABLE_TOOLS = new Set(['list_devices', 'list_rooms', 'list_functions']);
59
+ /** Default lifetime of a cached device/room/function listing (ms). */
60
+ exports.LIST_CACHE_TTL_MS = 30_000;
61
+ /**
62
+ * Tiny time-based cache: memoizes a tool result per (tool, args) for {@link ttlMs}. Not invalidated on
63
+ * object changes — a freshly added device appears after at most one TTL, which is fine for an assistant.
64
+ * Call {@link ListCache.clear} to force a refresh (e.g. from an `objectChange` subscription).
65
+ */
66
+ class ListCache {
67
+ ttlMs;
68
+ now;
69
+ store = new Map();
70
+ constructor(ttlMs = exports.LIST_CACHE_TTL_MS, now = Date.now) {
71
+ this.ttlMs = ttlMs;
72
+ this.now = now;
73
+ }
74
+ async run(key, produce) {
75
+ const hit = this.store.get(key);
76
+ const now = this.now();
77
+ if (hit && now - hit.at < this.ttlMs) {
78
+ return hit.val;
79
+ }
80
+ const val = await produce();
81
+ this.store.set(key, { at: now, val });
82
+ return val;
83
+ }
84
+ clear() {
85
+ this.store.clear();
86
+ }
87
+ }
88
+ exports.ListCache = ListCache;
89
+ /** Decide whether a tool is allowed by the current access configuration. Unknown tools are denied. */
90
+ function isToolAllowed(name, access) {
91
+ switch (TOOL_CATEGORY[name]) {
92
+ case 'stateRead':
93
+ case 'devices':
94
+ return true; // reading device/state values is always allowed
95
+ case 'stateWrite':
96
+ return access.allowWriteStates;
97
+ case 'objectReadAll':
98
+ return access.readObjects === 'all';
99
+ case 'logRead':
100
+ return access.allowReadLogs;
101
+ case 'logWrite':
102
+ return access.allowWriteLogs;
103
+ case 'history':
104
+ return access.allowHistory;
105
+ case 'files':
106
+ return access.allowFiles;
107
+ case 'system':
108
+ return access.allowSystemInfo;
109
+ case 'objectChange':
110
+ return access.allowObjectChange;
111
+ default:
112
+ return false; // unknown/unmapped tool → deny (safe default for an access list)
113
+ }
114
+ }
115
+ /** Device types that default to write-disabled (must be explicitly allowed) for safety, e.g. door locks. */
116
+ const DEFAULT_WRITE_FALSE_TYPES = new Set(['lock']);
117
+ /**
118
+ * Build the stateId → deviceKey map and the set of keys that default to write-disabled (e.g. locks),
119
+ * from the MCP `list_devices` tool (best-effort; empty on any error).
120
+ */
121
+ async function loadStateDeviceMap(mcp) {
122
+ const stateToKey = new Map();
123
+ const defaultWriteFalse = new Set();
124
+ try {
125
+ const res = await mcp.callTool('list_devices', {});
126
+ const parsed = JSON.parse(res.text);
127
+ for (const room of parsed.data?.rooms || []) {
128
+ for (const dev of room.devicesInRoom || []) {
129
+ const ids = deviceStateIds(dev);
130
+ const key = deviceKey(ids);
131
+ // Every control of the device shares the device's key, so the write-guard resolves any
132
+ // of its states to the same ACL entry.
133
+ for (const id of ids) {
134
+ stateToKey.set(id, key);
135
+ }
136
+ if (DEFAULT_WRITE_FALSE_TYPES.has(String(dev.deviceType ?? ''))) {
137
+ defaultWriteFalse.add(key);
138
+ }
139
+ }
140
+ }
141
+ }
142
+ catch {
143
+ /* no device map → no per-device enforcement */
144
+ }
145
+ return { stateToKey, defaultWriteFalse };
146
+ }
147
+ /** Device types dropped from `list_devices` so the LLM never treats them as controllable devices. */
148
+ const HIDDEN_LLM_TYPES = new Set(['button']);
149
+ /**
150
+ * Post-process a `list_devices` result for the LLM: drop hidden types (write-only buttons) and devices
151
+ * the user set to read-disabled (`acl[key].read === false`), and — when a resolver is given — rewrite
152
+ * each `deviceName` to the friendly name (smartName → parent name). Original text on any parse problem.
153
+ */
154
+ async function postProcessListDevices(text, acl, resolveName) {
155
+ let parsed;
156
+ try {
157
+ parsed = JSON.parse(text);
158
+ }
159
+ catch {
160
+ return text;
161
+ }
162
+ const rooms = parsed.data?.rooms;
163
+ if (!Array.isArray(rooms)) {
164
+ return text;
165
+ }
166
+ for (const room of rooms) {
167
+ const kept = (room.devicesInRoom || []).filter(d => {
168
+ if (HIDDEN_LLM_TYPES.has(String(d.deviceType ?? ''))) {
169
+ return false;
170
+ }
171
+ const key = deviceKey(deviceStateIds(d));
172
+ return !(key && acl[key]?.read === false); // hide read-disabled devices from the LLM
173
+ });
174
+ const trimmed = [];
175
+ for (const dev of kept) {
176
+ const ids = deviceStateIds(dev);
177
+ const name = resolveName && ids.length
178
+ ? (await resolveName(deviceKey(ids), String(dev.deviceName ?? ''))) || dev.deviceName
179
+ : dev.deviceName;
180
+ // Keep only what the LLM needs to act (name/type + controlType→{stateId,writable}); drop the
181
+ // verbose role/unit/min/max/ioBrokerValueType/friendlyDeviceNames to cut tokens & latency.
182
+ const controls = {};
183
+ for (const [ct, c] of Object.entries(dev.controls || {})) {
184
+ controls[ct] = { stateId: c?.stateId, writable: c?.writable };
185
+ }
186
+ trimmed.push({ deviceName: name, deviceType: dev.deviceType, controls });
187
+ }
188
+ room.devicesInRoom = trimmed;
189
+ }
190
+ return JSON.stringify(parsed);
191
+ }
192
+ /** Wrap `get_states` so it drops values of devices the user set to read-disabled. */
193
+ function guardRead(baseRun, stateToKey, acl) {
194
+ return async (args) => {
195
+ const text = (await baseRun(args));
196
+ let parsed;
197
+ try {
198
+ parsed = JSON.parse(text);
199
+ }
200
+ catch {
201
+ return text;
202
+ }
203
+ const states = parsed.data?.states;
204
+ if (Array.isArray(states) && parsed.data) {
205
+ parsed.data.states = states.filter(s => {
206
+ const key = s?.id ? stateToKey.get(s.id) : undefined;
207
+ return !(key && acl[key]?.read === false);
208
+ });
209
+ }
210
+ return JSON.stringify(parsed);
211
+ };
212
+ }
213
+ /**
214
+ * Wrap a state-writing tool so it refuses writes to states of devices the user marked read-only.
215
+ * Write is allowed when the explicit ACL entry says so; without an entry, the device type's default
216
+ * applies (write-disabled for {@link DEFAULT_WRITE_FALSE_TYPES} like locks, allowed otherwise).
217
+ */
218
+ function guardWrite(name, baseRun, stateToKey, acl, defaultWriteFalse) {
219
+ return async (args) => {
220
+ const ids = name === 'set_states'
221
+ ? (args.states || []).map(s => s?.id).filter((x) => !!x)
222
+ : typeof args.id === 'string'
223
+ ? [args.id]
224
+ : [];
225
+ for (const id of ids) {
226
+ const key = stateToKey.get(id);
227
+ if (!key) {
228
+ continue;
229
+ }
230
+ const entry = acl[key];
231
+ const writeAllowed = entry ? entry.write !== false : !defaultWriteFalse.has(key);
232
+ if (!writeAllowed) {
233
+ return JSON.stringify({
234
+ ok: false,
235
+ error: `Writing "${id}" is not allowed: device "${key}" is read-only in the assistant settings.`,
236
+ });
237
+ }
238
+ }
239
+ return baseRun(args);
240
+ };
241
+ }
242
+ /**
243
+ * Build the LLM tool set from the in-process ioBroker MCP server, filtered by the access config and
244
+ * enforcing the per-device write ACL.
245
+ *
246
+ * All tools come from `@iobroker/mcp-server` — nothing is duplicated here. Gating layers:
247
+ * 1. `createInProcessMcp({ allowSetState, allowObjectChange })` — which tools exist at all.
248
+ * 2. `isToolAllowed()` — fine-grained category toggles (logs/history/files/object-read scope/…).
249
+ * 3. `guardWrite()` — per-device write ACL (`access.deviceAcl`), e.g. keep a lock read-only.
250
+ */
251
+ async function buildMcpTools(mcp, access, cache = new ListCache(), resolveName) {
252
+ const infos = await mcp.listTools();
253
+ const acl = access.deviceAcl || {};
254
+ // Write guard: whenever writing is enabled (explicit ACL + type defaults like locks).
255
+ const enforceWrite = access.allowWriteStates;
256
+ // Read guard: whenever any device is set read-disabled (hide it from list_devices + get_states).
257
+ const enforceRead = Object.values(acl).some(a => a && a.read === false);
258
+ const { stateToKey, defaultWriteFalse } = enforceWrite || enforceRead
259
+ ? await loadStateDeviceMap(mcp)
260
+ : { stateToKey: new Map(), defaultWriteFalse: new Set() };
261
+ const tools = [];
262
+ const denied = [];
263
+ for (const info of infos) {
264
+ if (!isToolAllowed(info.name, access)) {
265
+ denied.push(info.name);
266
+ continue;
267
+ }
268
+ const callTool = async (args) => {
269
+ const text = (await mcp.callTool(info.name, args)).text;
270
+ // For the LLM: drop buttons + read-disabled devices, and use friendly names.
271
+ return info.name === 'list_devices' ? postProcessListDevices(text, acl, resolveName) : text;
272
+ };
273
+ // Serve stable, expensive read-only listings from the short-TTL cache; everything else runs live.
274
+ const baseRun = CACHEABLE_TOOLS.has(info.name)
275
+ ? (args) => cache.run(`${info.name}:${JSON.stringify(args ?? {})}`, () => callTool(args))
276
+ : callTool;
277
+ let run = baseRun;
278
+ if (enforceWrite && (info.name === 'set_state' || info.name === 'set_states')) {
279
+ run = guardWrite(info.name, baseRun, stateToKey, acl, defaultWriteFalse);
280
+ }
281
+ else if (enforceRead && info.name === 'get_states') {
282
+ run = guardRead(baseRun, stateToKey, acl);
283
+ }
284
+ tools.push({ name: info.name, description: info.description || '', parameters: info.inputSchema, run });
285
+ }
286
+ // Synthetic (non-MCP) tool: lets the model bust the device/room cache and re-scan. Useful when a
287
+ // device the user mentions is missing from list_devices (stale cache) or was just added/renamed.
288
+ tools.push({
289
+ name: 'refresh_device_cache',
290
+ description: 'Clear the cached device, room and function listings so the next list_devices, list_rooms or list_functions call performs a fresh scan. Call this when a device or room the user mentions is not in the list, or when the user says a device was just added, renamed or reassigned, then retry.',
291
+ parameters: { type: 'object', properties: {}, additionalProperties: false },
292
+ run: () => {
293
+ cache.clear();
294
+ return Promise.resolve(JSON.stringify({ ok: true, data: { cleared: true } }));
295
+ },
296
+ });
297
+ return { tools, denied, cache };
298
+ }
299
+ //# sourceMappingURL=tools.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"tools.js","sourceRoot":"","sources":["../../src/lib/tools.ts"],"names":[],"mappings":";;;AAmCA,wCAIC;AAOD,8BAEC;AAsFD,sCAwBC;AAsKD,sCAwDC;AA1VD,8DAA8D;AAC9D,SAAgB,cAAc,CAAC,GAAmB;IAC9C,OAAO,MAAM,CAAC,MAAM,CAAC,GAAG,CAAC,QAAQ,IAAI,EAAE,CAAC;SACnC,GAAG,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,EAAE,OAAO,CAAC;SACpB,MAAM,CAAC,CAAC,CAAC,EAAe,EAAE,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC;AACzC,CAAC;AAED;;;;GAIG;AACH,SAAgB,SAAS,CAAC,QAAkB;IACxC,OAAO,CAAC,GAAG,QAAQ,CAAC,CAAC,IAAI,EAAE,CAAC,CAAC,CAAC,IAAI,EAAE,CAAC;AACzC,CAAC;AAcD,0EAA0E;AAC1E,MAAM,aAAa,GAAiC;IAChD,UAAU,EAAE,WAAW;IACvB,YAAY,EAAE,SAAS;IACvB,UAAU,EAAE,SAAS;IACrB,cAAc,EAAE,SAAS;IACzB,SAAS,EAAE,YAAY;IACvB,UAAU,EAAE,YAAY;IACxB,UAAU,EAAE,eAAe;IAC3B,cAAc,EAAE,eAAe;IAC/B,QAAQ,EAAE,SAAS;IACnB,SAAS,EAAE,UAAU;IACrB,aAAa,EAAE,SAAS;IACxB,SAAS,EAAE,OAAO;IAClB,UAAU,EAAE,OAAO;IACnB,WAAW,EAAE,OAAO;IACpB,WAAW,EAAE,QAAQ;IACrB,UAAU,EAAE,QAAQ;IACpB,cAAc,EAAE,QAAQ;IACxB,aAAa,EAAE,QAAQ;IACvB,yBAAyB,EAAE,QAAQ;IACnC,SAAS,EAAE,QAAQ;IACnB,UAAU,EAAE,cAAc;IAC1B,aAAa,EAAE,cAAc;IAC7B,YAAY,EAAE,cAAc;IAC5B,YAAY,EAAE,cAAc;IAC5B,UAAU,EAAE,cAAc;IAC1B,WAAW,EAAE,cAAc;IAC3B,WAAW,EAAE,cAAc;IAC3B,KAAK,EAAE,cAAc;CACxB,CAAC;AAEF;;;;GAIG;AACH,MAAM,eAAe,GAAG,IAAI,GAAG,CAAC,CAAC,cAAc,EAAE,YAAY,EAAE,gBAAgB,CAAC,CAAC,CAAC;AAElF,sEAAsE;AACzD,QAAA,iBAAiB,GAAG,MAAM,CAAC;AAExC;;;;GAIG;AACH,MAAa,SAAS;IAIG;IACA;IAJJ,KAAK,GAAG,IAAI,GAAG,EAAuC,CAAC;IAExE,YACqB,QAAgB,yBAAiB,EACjC,MAAoB,IAAI,CAAC,GAAG;QAD5B,UAAK,GAAL,KAAK,CAA4B;QACjC,QAAG,GAAH,GAAG,CAAyB;IAC9C,CAAC;IAEJ,KAAK,CAAC,GAAG,CAAC,GAAW,EAAE,OAA8B;QACjD,MAAM,GAAG,GAAG,IAAI,CAAC,KAAK,CAAC,GAAG,CAAC,GAAG,CAAC,CAAC;QAChC,MAAM,GAAG,GAAG,IAAI,CAAC,GAAG,EAAE,CAAC;QACvB,IAAI,GAAG,IAAI,GAAG,GAAG,GAAG,CAAC,EAAE,GAAG,IAAI,CAAC,KAAK,EAAE,CAAC;YACnC,OAAO,GAAG,CAAC,GAAG,CAAC;QACnB,CAAC;QACD,MAAM,GAAG,GAAG,MAAM,OAAO,EAAE,CAAC;QAC5B,IAAI,CAAC,KAAK,CAAC,GAAG,CAAC,GAAG,EAAE,EAAE,EAAE,EAAE,GAAG,EAAE,GAAG,EAAE,CAAC,CAAC;QACtC,OAAO,GAAG,CAAC;IACf,CAAC;IAED,KAAK;QACD,IAAI,CAAC,KAAK,CAAC,KAAK,EAAE,CAAC;IACvB,CAAC;CACJ;AAtBD,8BAsBC;AAED,sGAAsG;AACtG,SAAgB,aAAa,CAAC,IAAY,EAAE,MAAkB;IAC1D,QAAQ,aAAa,CAAC,IAAI,CAAC,EAAE,CAAC;QAC1B,KAAK,WAAW,CAAC;QACjB,KAAK,SAAS;YACV,OAAO,IAAI,CAAC,CAAC,gDAAgD;QACjE,KAAK,YAAY;YACb,OAAO,MAAM,CAAC,gBAAgB,CAAC;QACnC,KAAK,eAAe;YAChB,OAAO,MAAM,CAAC,WAAW,KAAK,KAAK,CAAC;QACxC,KAAK,SAAS;YACV,OAAO,MAAM,CAAC,aAAa,CAAC;QAChC,KAAK,UAAU;YACX,OAAO,MAAM,CAAC,cAAc,CAAC;QACjC,KAAK,SAAS;YACV,OAAO,MAAM,CAAC,YAAY,CAAC;QAC/B,KAAK,OAAO;YACR,OAAO,MAAM,CAAC,UAAU,CAAC;QAC7B,KAAK,QAAQ;YACT,OAAO,MAAM,CAAC,eAAe,CAAC;QAClC,KAAK,cAAc;YACf,OAAO,MAAM,CAAC,iBAAiB,CAAC;QACpC;YACI,OAAO,KAAK,CAAC,CAAC,iEAAiE;IACvF,CAAC;AACL,CAAC;AAED,4GAA4G;AAC5G,MAAM,yBAAyB,GAAG,IAAI,GAAG,CAAC,CAAC,MAAM,CAAC,CAAC,CAAC;AAEpD;;;GAGG;AACH,KAAK,UAAU,kBAAkB,CAC7B,GAAiB;IAEjB,MAAM,UAAU,GAAG,IAAI,GAAG,EAAkB,CAAC;IAC7C,MAAM,iBAAiB,GAAG,IAAI,GAAG,EAAU,CAAC;IAC5C,IAAI,CAAC;QACD,MAAM,GAAG,GAAG,MAAM,GAAG,CAAC,QAAQ,CAAC,cAAc,EAAE,EAAE,CAAC,CAAC;QACnD,MAAM,MAAM,GAAG,IAAI,CAAC,KAAK,CAAC,GAAG,CAAC,IAAI,CAEjC,CAAC;QACF,KAAK,MAAM,IAAI,IAAI,MAAM,CAAC,IAAI,EAAE,KAAK,IAAI,EAAE,EAAE,CAAC;YAC1C,KAAK,MAAM,GAAG,IAAI,IAAI,CAAC,aAAa,IAAI,EAAE,EAAE,CAAC;gBACzC,MAAM,GAAG,GAAG,cAAc,CAAC,GAAG,CAAC,CAAC;gBAChC,MAAM,GAAG,GAAG,SAAS,CAAC,GAAG,CAAC,CAAC;gBAC3B,uFAAuF;gBACvF,uCAAuC;gBACvC,KAAK,MAAM,EAAE,IAAI,GAAG,EAAE,CAAC;oBACnB,UAAU,CAAC,GAAG,CAAC,EAAE,EAAE,GAAG,CAAC,CAAC;gBAC5B,CAAC;gBACD,IAAI,yBAAyB,CAAC,GAAG,CAAC,MAAM,CAAC,GAAG,CAAC,UAAU,IAAI,EAAE,CAAC,CAAC,EAAE,CAAC;oBAC9D,iBAAiB,CAAC,GAAG,CAAC,GAAG,CAAC,CAAC;gBAC/B,CAAC;YACL,CAAC;QACL,CAAC;IACL,CAAC;IAAC,MAAM,CAAC;QACL,+CAA+C;IACnD,CAAC;IACD,OAAO,EAAE,UAAU,EAAE,iBAAiB,EAAE,CAAC;AAC7C,CAAC;AAOD,qGAAqG;AACrG,MAAM,gBAAgB,GAAG,IAAI,GAAG,CAAC,CAAC,QAAQ,CAAC,CAAC,CAAC;AAE7C;;;;GAIG;AACH,KAAK,UAAU,sBAAsB,CACjC,IAAY,EACZ,GAAc,EACd,WAAoE;IAEpE,IAAI,MAAyB,CAAC;IAC9B,IAAI,CAAC;QACD,MAAM,GAAG,IAAI,CAAC,KAAK,CAAC,IAAI,CAAsB,CAAC;IACnD,CAAC;IAAC,MAAM,CAAC;QACL,OAAO,IAAI,CAAC;IAChB,CAAC;IACD,MAAM,KAAK,GAAG,MAAM,CAAC,IAAI,EAAE,KAAK,CAAC;IACjC,IAAI,CAAC,KAAK,CAAC,OAAO,CAAC,KAAK,CAAC,EAAE,CAAC;QACxB,OAAO,IAAI,CAAC;IAChB,CAAC;IACD,KAAK,MAAM,IAAI,IAAI,KAAK,EAAE,CAAC;QACvB,MAAM,IAAI,GAAG,CAAC,IAAI,CAAC,aAAa,IAAI,EAAE,CAAC,CAAC,MAAM,CAAC,CAAC,CAAC,EAAE;YAC/C,IAAI,gBAAgB,CAAC,GAAG,CAAC,MAAM,CAAC,CAAC,CAAC,UAAU,IAAI,EAAE,CAAC,CAAC,EAAE,CAAC;gBACnD,OAAO,KAAK,CAAC;YACjB,CAAC;YACD,MAAM,GAAG,GAAG,SAAS,CAAC,cAAc,CAAC,CAAC,CAAC,CAAC,CAAC;YACzC,OAAO,CAAC,CAAC,GAAG,IAAI,GAAG,CAAC,GAAG,CAAC,EAAE,IAAI,KAAK,KAAK,CAAC,CAAC,CAAC,0CAA0C;QACzF,CAAC,CAAC,CAAC;QACH,MAAM,OAAO,GAAqB,EAAE,CAAC;QACrC,KAAK,MAAM,GAAG,IAAI,IAAI,EAAE,CAAC;YACrB,MAAM,GAAG,GAAG,cAAc,CAAC,GAAG,CAAC,CAAC;YAChC,MAAM,IAAI,GACN,WAAW,IAAI,GAAG,CAAC,MAAM;gBACrB,CAAC,CAAC,CAAC,MAAM,WAAW,CAAC,SAAS,CAAC,GAAG,CAAC,EAAE,MAAM,CAAC,GAAG,CAAC,UAAU,IAAI,EAAE,CAAC,CAAC,CAAC,IAAI,GAAG,CAAC,UAAU;gBACrF,CAAC,CAAC,GAAG,CAAC,UAAU,CAAC;YACzB,6FAA6F;YAC7F,2FAA2F;YAC3F,MAAM,QAAQ,GAA6D,EAAE,CAAC;YAC9E,KAAK,MAAM,CAAC,EAAE,EAAE,CAAC,CAAC,IAAI,MAAM,CAAC,OAAO,CAAC,GAAG,CAAC,QAAQ,IAAI,EAAE,CAAC,EAAE,CAAC;gBACvD,QAAQ,CAAC,EAAE,CAAC,GAAG,EAAE,OAAO,EAAE,CAAC,EAAE,OAAO,EAAE,QAAQ,EAAE,CAAC,EAAE,QAAQ,EAAE,CAAC;YAClE,CAAC;YACD,OAAO,CAAC,IAAI,CAAC,EAAE,UAAU,EAAE,IAAI,EAAE,UAAU,EAAE,GAAG,CAAC,UAAU,EAAE,QAAQ,EAAE,CAAC,CAAC;QAC7E,CAAC;QACD,IAAI,CAAC,aAAa,GAAG,OAAO,CAAC;IACjC,CAAC;IACD,OAAO,IAAI,CAAC,SAAS,CAAC,MAAM,CAAC,CAAC;AAClC,CAAC;AAED,qFAAqF;AACrF,SAAS,SAAS,CACd,OAA4D,EAC5D,UAA+B,EAC/B,GAAc;IAEd,OAAO,KAAK,EAAE,IAA6B,EAAoB,EAAE;QAC7D,MAAM,IAAI,GAAG,CAAC,MAAM,OAAO,CAAC,IAAI,CAAC,CAAW,CAAC;QAC7C,IAAI,MAAiD,CAAC;QACtD,IAAI,CAAC;YACD,MAAM,GAAG,IAAI,CAAC,KAAK,CAAC,IAAI,CAA8C,CAAC;QAC3E,CAAC;QAAC,MAAM,CAAC;YACL,OAAO,IAAI,CAAC;QAChB,CAAC;QACD,MAAM,MAAM,GAAG,MAAM,CAAC,IAAI,EAAE,MAAM,CAAC;QACnC,IAAI,KAAK,CAAC,OAAO,CAAC,MAAM,CAAC,IAAI,MAAM,CAAC,IAAI,EAAE,CAAC;YACvC,MAAM,CAAC,IAAI,CAAC,MAAM,GAAG,MAAM,CAAC,MAAM,CAAC,CAAC,CAAC,EAAE;gBACnC,MAAM,GAAG,GAAG,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,UAAU,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,CAAC,SAAS,CAAC;gBACrD,OAAO,CAAC,CAAC,GAAG,IAAI,GAAG,CAAC,GAAG,CAAC,EAAE,IAAI,KAAK,KAAK,CAAC,CAAC;YAC9C,CAAC,CAAC,CAAC;QACP,CAAC;QACD,OAAO,IAAI,CAAC,SAAS,CAAC,MAAM,CAAC,CAAC;IAClC,CAAC,CAAC;AACN,CAAC;AAED;;;;GAIG;AACH,SAAS,UAAU,CACf,IAAY,EACZ,OAA4D,EAC5D,UAA+B,EAC/B,GAAc,EACd,iBAA8B;IAE9B,OAAO,KAAK,EAAE,IAA6B,EAAoB,EAAE;QAC7D,MAAM,GAAG,GACL,IAAI,KAAK,YAAY;YACjB,CAAC,CAAC,CAAE,IAAI,CAAC,MAAwC,IAAI,EAAE,CAAC,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,MAAM,CAAC,CAAC,CAAC,EAAe,EAAE,CAAC,CAAC,CAAC,CAAC,CAAC;YACxG,CAAC,CAAC,OAAO,IAAI,CAAC,EAAE,KAAK,QAAQ;gBAC3B,CAAC,CAAC,CAAC,IAAI,CAAC,EAAE,CAAC;gBACX,CAAC,CAAC,EAAE,CAAC;QACf,KAAK,MAAM,EAAE,IAAI,GAAG,EAAE,CAAC;YACnB,MAAM,GAAG,GAAG,UAAU,CAAC,GAAG,CAAC,EAAE,CAAC,CAAC;YAC/B,IAAI,CAAC,GAAG,EAAE,CAAC;gBACP,SAAS;YACb,CAAC;YACD,MAAM,KAAK,GAAG,GAAG,CAAC,GAAG,CAAC,CAAC;YACvB,MAAM,YAAY,GAAG,KAAK,CAAC,CAAC,CAAC,KAAK,CAAC,KAAK,KAAK,KAAK,CAAC,CAAC,CAAC,CAAC,iBAAiB,CAAC,GAAG,CAAC,GAAG,CAAC,CAAC;YACjF,IAAI,CAAC,YAAY,EAAE,CAAC;gBAChB,OAAO,IAAI,CAAC,SAAS,CAAC;oBAClB,EAAE,EAAE,KAAK;oBACT,KAAK,EAAE,YAAY,EAAE,6BAA6B,GAAG,2CAA2C;iBACnG,CAAC,CAAC;YACP,CAAC;QACL,CAAC;QACD,OAAO,OAAO,CAAC,IAAI,CAAC,CAAC;IACzB,CAAC,CAAC;AACN,CAAC;AAED;;;;;;;;GAQG;AACI,KAAK,UAAU,aAAa,CAC/B,GAAiB,EACjB,MAAkB,EAClB,QAAmB,IAAI,SAAS,EAAE,EAClC,WAAoE;IAEpE,MAAM,KAAK,GAAG,MAAM,GAAG,CAAC,SAAS,EAAE,CAAC;IACpC,MAAM,GAAG,GAAG,MAAM,CAAC,SAAS,IAAI,EAAE,CAAC;IACnC,sFAAsF;IACtF,MAAM,YAAY,GAAG,MAAM,CAAC,gBAAgB,CAAC;IAC7C,iGAAiG;IACjG,MAAM,WAAW,GAAG,MAAM,CAAC,MAAM,CAAC,GAAG,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,IAAI,CAAC,CAAC,IAAI,KAAK,KAAK,CAAC,CAAC;IACxE,MAAM,EAAE,UAAU,EAAE,iBAAiB,EAAE,GACnC,YAAY,IAAI,WAAW;QACvB,CAAC,CAAC,MAAM,kBAAkB,CAAC,GAAG,CAAC;QAC/B,CAAC,CAAC,EAAE,UAAU,EAAE,IAAI,GAAG,EAAkB,EAAE,iBAAiB,EAAE,IAAI,GAAG,EAAU,EAAE,CAAC;IAE1F,MAAM,KAAK,GAAW,EAAE,CAAC;IACzB,MAAM,MAAM,GAAa,EAAE,CAAC;IAC5B,KAAK,MAAM,IAAI,IAAI,KAAK,EAAE,CAAC;QACvB,IAAI,CAAC,aAAa,CAAC,IAAI,CAAC,IAAI,EAAE,MAAM,CAAC,EAAE,CAAC;YACpC,MAAM,CAAC,IAAI,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;YACvB,SAAS;QACb,CAAC;QACD,MAAM,QAAQ,GAAG,KAAK,EAAE,IAA6B,EAAmB,EAAE;YACtE,MAAM,IAAI,GAAG,CAAC,MAAM,GAAG,CAAC,QAAQ,CAAC,IAAI,CAAC,IAAI,EAAE,IAAI,CAAC,CAAC,CAAC,IAAI,CAAC;YACxD,6EAA6E;YAC7E,OAAO,IAAI,CAAC,IAAI,KAAK,cAAc,CAAC,CAAC,CAAC,sBAAsB,CAAC,IAAI,EAAE,GAAG,EAAE,WAAW,CAAC,CAAC,CAAC,CAAC,IAAI,CAAC;QAChG,CAAC,CAAC;QACF,kGAAkG;QAClG,MAAM,OAAO,GAAwD,eAAe,CAAC,GAAG,CAAC,IAAI,CAAC,IAAI,CAAC;YAC/F,CAAC,CAAC,CAAC,IAAI,EAAmB,EAAE,CAAC,KAAK,CAAC,GAAG,CAAC,GAAG,IAAI,CAAC,IAAI,IAAI,IAAI,CAAC,SAAS,CAAC,IAAI,IAAI,EAAE,CAAC,EAAE,EAAE,GAAG,EAAE,CAAC,QAAQ,CAAC,IAAI,CAAC,CAAC;YAC1G,CAAC,CAAC,QAAQ,CAAC;QACf,IAAI,GAAG,GAAG,OAAO,CAAC;QAClB,IAAI,YAAY,IAAI,CAAC,IAAI,CAAC,IAAI,KAAK,WAAW,IAAI,IAAI,CAAC,IAAI,KAAK,YAAY,CAAC,EAAE,CAAC;YAC5E,GAAG,GAAG,UAAU,CAAC,IAAI,CAAC,IAAI,EAAE,OAAO,EAAE,UAAU,EAAE,GAAG,EAAE,iBAAiB,CAAC,CAAC;QAC7E,CAAC;aAAM,IAAI,WAAW,IAAI,IAAI,CAAC,IAAI,KAAK,YAAY,EAAE,CAAC;YACnD,GAAG,GAAG,SAAS,CAAC,OAAO,EAAE,UAAU,EAAE,GAAG,CAAC,CAAC;QAC9C,CAAC;QACD,KAAK,CAAC,IAAI,CAAC,EAAE,IAAI,EAAE,IAAI,CAAC,IAAI,EAAE,WAAW,EAAE,IAAI,CAAC,WAAW,IAAI,EAAE,EAAE,UAAU,EAAE,IAAI,CAAC,WAAW,EAAE,GAAG,EAAE,CAAC,CAAC;IAC5G,CAAC;IAED,iGAAiG;IACjG,iGAAiG;IACjG,KAAK,CAAC,IAAI,CAAC;QACP,IAAI,EAAE,sBAAsB;QAC5B,WAAW,EACP,gSAAgS;QACpS,UAAU,EAAE,EAAE,IAAI,EAAE,QAAQ,EAAE,UAAU,EAAE,EAAE,EAAE,oBAAoB,EAAE,KAAK,EAAE;QAC3E,GAAG,EAAE,GAAoB,EAAE;YACvB,KAAK,CAAC,KAAK,EAAE,CAAC;YACd,OAAO,OAAO,CAAC,OAAO,CAAC,IAAI,CAAC,SAAS,CAAC,EAAE,EAAE,EAAE,IAAI,EAAE,IAAI,EAAE,EAAE,OAAO,EAAE,IAAI,EAAE,EAAE,CAAC,CAAC,CAAC;QAClF,CAAC;KACJ,CAAC,CAAC;IAEH,OAAO,EAAE,KAAK,EAAE,MAAM,EAAE,KAAK,EAAE,CAAC;AACpC,CAAC","sourcesContent":["import type { InProcessMcp } from '@iobroker/mcp-server';\n\n/** An LLM-callable tool: name, description, JSON-schema parameters, and a runner. */\nexport interface Tool {\n name: string;\n description: string;\n /** JSON schema for the tool arguments. */\n parameters: Record<string, unknown>;\n run(args: Record<string, unknown>): Promise<unknown>;\n}\n\n/** Per-device read/write override, keyed by {@link deviceKey}. Absent → governed by the coarse toggles. */\nexport type DeviceAcl = Record<string, { read: boolean; write: boolean }>;\n\n/** The access toggles from the adapter config that gate which MCP tools the LLM may use. */\nexport interface ToolAccess {\n allowWriteStates: boolean;\n allowObjectChange: boolean;\n readObjects: 'devices' | 'all';\n allowReadLogs: boolean;\n allowWriteLogs: boolean;\n allowHistory: boolean;\n allowFiles: boolean;\n allowSystemInfo: boolean;\n deviceAcl: DeviceAcl;\n}\n\n/** One `list_devices` device entry (as far as we care: its controls' state ids). */\nexport interface McpDeviceEntry {\n deviceName?: string;\n deviceType?: string;\n controls?: Record<string, { stateId?: string; writable?: boolean }>;\n}\n\n/** All control state ids of a `list_devices` device entry. */\nexport function deviceStateIds(dev: McpDeviceEntry): string[] {\n return Object.values(dev.controls || {})\n .map(c => c?.stateId)\n .filter((x): x is string => !!x);\n}\n\n/**\n * Stable per-device key used by both the backend write-guard and the admin ACL editor. Keyed by the\n * device's primary (lexicographically first) control state id — unique per device and independent of the\n * (often non-unique) type-detector name, so two \"SET\" controls no longer collide on one ACL entry.\n */\nexport function deviceKey(stateIds: string[]): string {\n return [...stateIds].sort()[0] || '';\n}\n\ntype ToolCategory =\n | 'stateRead'\n | 'stateWrite'\n | 'devices'\n | 'objectReadAll'\n | 'logRead'\n | 'logWrite'\n | 'history'\n | 'files'\n | 'system'\n | 'objectChange';\n\n/** Maps every known `@iobroker/mcp-server` tool to an access category. */\nconst TOOL_CATEGORY: Record<string, ToolCategory> = {\n get_states: 'stateRead',\n list_devices: 'devices',\n list_rooms: 'devices',\n list_functions: 'devices',\n set_state: 'stateWrite',\n set_states: 'stateWrite',\n get_object: 'objectReadAll',\n search_objects: 'objectReadAll',\n get_logs: 'logRead',\n write_log: 'logWrite',\n history_query: 'history',\n read_file: 'files',\n list_files: 'files',\n file_exists: 'files',\n system_info: 'system',\n list_hosts: 'system',\n list_instances: 'system',\n list_adapters: 'system',\n search_adapter_repository: 'system',\n ping_host: 'system',\n set_object: 'objectChange',\n delete_object: 'objectChange',\n create_state: 'objectChange',\n create_scene: 'objectChange',\n write_file: 'objectChange',\n delete_file: 'objectChange',\n rename_file: 'objectChange',\n mkdir: 'objectChange',\n};\n\n/**\n * Read-only, system-wide-scan tools whose results are stable enough to serve from a short-TTL cache.\n * Each of these runs the ioBroker type-detector over *all* objects (`list_devices` alone does five\n * full `getObjectView` scans), so repeat calls inside one conversation are the main avoidable cost.\n */\nconst CACHEABLE_TOOLS = new Set(['list_devices', 'list_rooms', 'list_functions']);\n\n/** Default lifetime of a cached device/room/function listing (ms). */\nexport const LIST_CACHE_TTL_MS = 30_000;\n\n/**\n * Tiny time-based cache: memoizes a tool result per (tool, args) for {@link ttlMs}. Not invalidated on\n * object changes — a freshly added device appears after at most one TTL, which is fine for an assistant.\n * Call {@link ListCache.clear} to force a refresh (e.g. from an `objectChange` subscription).\n */\nexport class ListCache {\n private readonly store = new Map<string, { at: number; val: string }>();\n\n constructor(\n private readonly ttlMs: number = LIST_CACHE_TTL_MS,\n private readonly now: () => number = Date.now,\n ) {}\n\n async run(key: string, produce: () => Promise<string>): Promise<string> {\n const hit = this.store.get(key);\n const now = this.now();\n if (hit && now - hit.at < this.ttlMs) {\n return hit.val;\n }\n const val = await produce();\n this.store.set(key, { at: now, val });\n return val;\n }\n\n clear(): void {\n this.store.clear();\n }\n}\n\n/** Decide whether a tool is allowed by the current access configuration. Unknown tools are denied. */\nexport function isToolAllowed(name: string, access: ToolAccess): boolean {\n switch (TOOL_CATEGORY[name]) {\n case 'stateRead':\n case 'devices':\n return true; // reading device/state values is always allowed\n case 'stateWrite':\n return access.allowWriteStates;\n case 'objectReadAll':\n return access.readObjects === 'all';\n case 'logRead':\n return access.allowReadLogs;\n case 'logWrite':\n return access.allowWriteLogs;\n case 'history':\n return access.allowHistory;\n case 'files':\n return access.allowFiles;\n case 'system':\n return access.allowSystemInfo;\n case 'objectChange':\n return access.allowObjectChange;\n default:\n return false; // unknown/unmapped tool → deny (safe default for an access list)\n }\n}\n\n/** Device types that default to write-disabled (must be explicitly allowed) for safety, e.g. door locks. */\nconst DEFAULT_WRITE_FALSE_TYPES = new Set(['lock']);\n\n/**\n * Build the stateId → deviceKey map and the set of keys that default to write-disabled (e.g. locks),\n * from the MCP `list_devices` tool (best-effort; empty on any error).\n */\nasync function loadStateDeviceMap(\n mcp: InProcessMcp,\n): Promise<{ stateToKey: Map<string, string>; defaultWriteFalse: Set<string> }> {\n const stateToKey = new Map<string, string>();\n const defaultWriteFalse = new Set<string>();\n try {\n const res = await mcp.callTool('list_devices', {});\n const parsed = JSON.parse(res.text) as {\n data?: { rooms?: { devicesInRoom?: McpDeviceEntry[] }[] };\n };\n for (const room of parsed.data?.rooms || []) {\n for (const dev of room.devicesInRoom || []) {\n const ids = deviceStateIds(dev);\n const key = deviceKey(ids);\n // Every control of the device shares the device's key, so the write-guard resolves any\n // of its states to the same ACL entry.\n for (const id of ids) {\n stateToKey.set(id, key);\n }\n if (DEFAULT_WRITE_FALSE_TYPES.has(String(dev.deviceType ?? ''))) {\n defaultWriteFalse.add(key);\n }\n }\n }\n } catch {\n /* no device map → no per-device enforcement */\n }\n return { stateToKey, defaultWriteFalse };\n}\n\n/** JSON shape of a `list_devices` result, for post-processing the device names the LLM sees. */\ninterface ListDevicesResult {\n data?: { rooms?: { devicesInRoom?: McpDeviceEntry[] }[] };\n}\n\n/** Device types dropped from `list_devices` so the LLM never treats them as controllable devices. */\nconst HIDDEN_LLM_TYPES = new Set(['button']);\n\n/**\n * Post-process a `list_devices` result for the LLM: drop hidden types (write-only buttons) and devices\n * the user set to read-disabled (`acl[key].read === false`), and — when a resolver is given — rewrite\n * each `deviceName` to the friendly name (smartName → parent name). Original text on any parse problem.\n */\nasync function postProcessListDevices(\n text: string,\n acl: DeviceAcl,\n resolveName?: (stateId: string, fallback: string) => Promise<string>,\n): Promise<string> {\n let parsed: ListDevicesResult;\n try {\n parsed = JSON.parse(text) as ListDevicesResult;\n } catch {\n return text;\n }\n const rooms = parsed.data?.rooms;\n if (!Array.isArray(rooms)) {\n return text;\n }\n for (const room of rooms) {\n const kept = (room.devicesInRoom || []).filter(d => {\n if (HIDDEN_LLM_TYPES.has(String(d.deviceType ?? ''))) {\n return false;\n }\n const key = deviceKey(deviceStateIds(d));\n return !(key && acl[key]?.read === false); // hide read-disabled devices from the LLM\n });\n const trimmed: McpDeviceEntry[] = [];\n for (const dev of kept) {\n const ids = deviceStateIds(dev);\n const name =\n resolveName && ids.length\n ? (await resolveName(deviceKey(ids), String(dev.deviceName ?? ''))) || dev.deviceName\n : dev.deviceName;\n // Keep only what the LLM needs to act (name/type + controlType→{stateId,writable}); drop the\n // verbose role/unit/min/max/ioBrokerValueType/friendlyDeviceNames to cut tokens & latency.\n const controls: Record<string, { stateId?: string; writable?: boolean }> = {};\n for (const [ct, c] of Object.entries(dev.controls || {})) {\n controls[ct] = { stateId: c?.stateId, writable: c?.writable };\n }\n trimmed.push({ deviceName: name, deviceType: dev.deviceType, controls });\n }\n room.devicesInRoom = trimmed;\n }\n return JSON.stringify(parsed);\n}\n\n/** Wrap `get_states` so it drops values of devices the user set to read-disabled. */\nfunction guardRead(\n baseRun: (args: Record<string, unknown>) => Promise<unknown>,\n stateToKey: Map<string, string>,\n acl: DeviceAcl,\n): (args: Record<string, unknown>) => Promise<unknown> {\n return async (args: Record<string, unknown>): Promise<unknown> => {\n const text = (await baseRun(args)) as string;\n let parsed: { data?: { states?: { id?: string }[] } };\n try {\n parsed = JSON.parse(text) as { data?: { states?: { id?: string }[] } };\n } catch {\n return text;\n }\n const states = parsed.data?.states;\n if (Array.isArray(states) && parsed.data) {\n parsed.data.states = states.filter(s => {\n const key = s?.id ? stateToKey.get(s.id) : undefined;\n return !(key && acl[key]?.read === false);\n });\n }\n return JSON.stringify(parsed);\n };\n}\n\n/**\n * Wrap a state-writing tool so it refuses writes to states of devices the user marked read-only.\n * Write is allowed when the explicit ACL entry says so; without an entry, the device type's default\n * applies (write-disabled for {@link DEFAULT_WRITE_FALSE_TYPES} like locks, allowed otherwise).\n */\nfunction guardWrite(\n name: string,\n baseRun: (args: Record<string, unknown>) => Promise<unknown>,\n stateToKey: Map<string, string>,\n acl: DeviceAcl,\n defaultWriteFalse: Set<string>,\n): (args: Record<string, unknown>) => Promise<unknown> {\n return async (args: Record<string, unknown>): Promise<unknown> => {\n const ids =\n name === 'set_states'\n ? ((args.states as { id?: string }[] | undefined) || []).map(s => s?.id).filter((x): x is string => !!x)\n : typeof args.id === 'string'\n ? [args.id]\n : [];\n for (const id of ids) {\n const key = stateToKey.get(id);\n if (!key) {\n continue;\n }\n const entry = acl[key];\n const writeAllowed = entry ? entry.write !== false : !defaultWriteFalse.has(key);\n if (!writeAllowed) {\n return JSON.stringify({\n ok: false,\n error: `Writing \"${id}\" is not allowed: device \"${key}\" is read-only in the assistant settings.`,\n });\n }\n }\n return baseRun(args);\n };\n}\n\n/**\n * Build the LLM tool set from the in-process ioBroker MCP server, filtered by the access config and\n * enforcing the per-device write ACL.\n *\n * All tools come from `@iobroker/mcp-server` — nothing is duplicated here. Gating layers:\n * 1. `createInProcessMcp({ allowSetState, allowObjectChange })` — which tools exist at all.\n * 2. `isToolAllowed()` — fine-grained category toggles (logs/history/files/object-read scope/…).\n * 3. `guardWrite()` — per-device write ACL (`access.deviceAcl`), e.g. keep a lock read-only.\n */\nexport async function buildMcpTools(\n mcp: InProcessMcp,\n access: ToolAccess,\n cache: ListCache = new ListCache(),\n resolveName?: (stateId: string, fallback: string) => Promise<string>,\n): Promise<{ tools: Tool[]; denied: string[]; cache: ListCache }> {\n const infos = await mcp.listTools();\n const acl = access.deviceAcl || {};\n // Write guard: whenever writing is enabled (explicit ACL + type defaults like locks).\n const enforceWrite = access.allowWriteStates;\n // Read guard: whenever any device is set read-disabled (hide it from list_devices + get_states).\n const enforceRead = Object.values(acl).some(a => a && a.read === false);\n const { stateToKey, defaultWriteFalse } =\n enforceWrite || enforceRead\n ? await loadStateDeviceMap(mcp)\n : { stateToKey: new Map<string, string>(), defaultWriteFalse: new Set<string>() };\n\n const tools: Tool[] = [];\n const denied: string[] = [];\n for (const info of infos) {\n if (!isToolAllowed(info.name, access)) {\n denied.push(info.name);\n continue;\n }\n const callTool = async (args: Record<string, unknown>): Promise<string> => {\n const text = (await mcp.callTool(info.name, args)).text;\n // For the LLM: drop buttons + read-disabled devices, and use friendly names.\n return info.name === 'list_devices' ? postProcessListDevices(text, acl, resolveName) : text;\n };\n // Serve stable, expensive read-only listings from the short-TTL cache; everything else runs live.\n const baseRun: (args: Record<string, unknown>) => Promise<unknown> = CACHEABLE_TOOLS.has(info.name)\n ? (args): Promise<string> => cache.run(`${info.name}:${JSON.stringify(args ?? {})}`, () => callTool(args))\n : callTool;\n let run = baseRun;\n if (enforceWrite && (info.name === 'set_state' || info.name === 'set_states')) {\n run = guardWrite(info.name, baseRun, stateToKey, acl, defaultWriteFalse);\n } else if (enforceRead && info.name === 'get_states') {\n run = guardRead(baseRun, stateToKey, acl);\n }\n tools.push({ name: info.name, description: info.description || '', parameters: info.inputSchema, run });\n }\n\n // Synthetic (non-MCP) tool: lets the model bust the device/room cache and re-scan. Useful when a\n // device the user mentions is missing from list_devices (stale cache) or was just added/renamed.\n tools.push({\n name: 'refresh_device_cache',\n description:\n 'Clear the cached device, room and function listings so the next list_devices, list_rooms or list_functions call performs a fresh scan. Call this when a device or room the user mentions is not in the list, or when the user says a device was just added, renamed or reassigned, then retry.',\n parameters: { type: 'object', properties: {}, additionalProperties: false },\n run: (): Promise<string> => {\n cache.clear();\n return Promise.resolve(JSON.stringify({ ok: true, data: { cleared: true } }));\n },\n });\n\n return { tools, denied, cache };\n}\n"]}
@@ -0,0 +1,102 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.AwsTts = exports.AwsStt = void 0;
4
+ exports.listPollyVoices = listPollyVoices;
5
+ /**
6
+ * AWS speech engines: STT via Amazon Transcribe (streaming) and TTS via Amazon Polly.
7
+ * Credentials: accessKeyId + secretAccessKey + region.
8
+ */
9
+ const client_polly_1 = require("@aws-sdk/client-polly");
10
+ const client_transcribe_streaming_1 = require("@aws-sdk/client-transcribe-streaming");
11
+ const lang_1 = require("./lang");
12
+ /** Reasonable default Polly voice per language when none is configured. */
13
+ const DEFAULT_POLLY_VOICE = {
14
+ de: 'Vicki',
15
+ en: 'Joanna',
16
+ ru: 'Tatyana',
17
+ fr: 'Lea',
18
+ it: 'Bianca',
19
+ es: 'Lucia',
20
+ nl: 'Laura',
21
+ pl: 'Ewa',
22
+ pt: 'Ines',
23
+ };
24
+ /** ~100 ms of 16 kHz mono 16-bit PCM per Transcribe audio chunk. */
25
+ const STT_CHUNK_BYTES = 3200;
26
+ /** List available Polly voice ids, optionally filtered by language (locale, e.g. 'de-DE'). */
27
+ async function listPollyVoices(creds, languageCode) {
28
+ const client = new client_polly_1.PollyClient({
29
+ region: creds.region,
30
+ credentials: { accessKeyId: creds.accessKeyId, secretAccessKey: creds.secretAccessKey },
31
+ });
32
+ const res = await client.send(new client_polly_1.DescribeVoicesCommand(languageCode ? { LanguageCode: languageCode } : {}));
33
+ return (res.Voices || []).map(v => v.Id).filter((id) => !!id);
34
+ }
35
+ class AwsStt {
36
+ client;
37
+ constructor(creds) {
38
+ this.client = new client_transcribe_streaming_1.TranscribeStreamingClient({
39
+ region: creds.region,
40
+ credentials: { accessKeyId: creds.accessKeyId, secretAccessKey: creds.secretAccessKey },
41
+ });
42
+ }
43
+ // Note: the SttEngine `hints` vocabulary is intentionally not applied here. Amazon Transcribe biases
44
+ // only via a *custom vocabulary* that must be created/registered ahead of time (CreateVocabulary) and
45
+ // referenced by name — there is no inline per-request word list for streaming. So we skip biasing for AWS.
46
+ async transcribe(pcm, sampleRate, lang) {
47
+ // Must be an async generator: Transcribe's AudioStream is an AsyncIterable.
48
+ // eslint-disable-next-line @typescript-eslint/require-await
49
+ async function* audio() {
50
+ for (let off = 0; off < pcm.length; off += STT_CHUNK_BYTES) {
51
+ yield { AudioEvent: { AudioChunk: pcm.subarray(off, off + STT_CHUNK_BYTES) } };
52
+ }
53
+ }
54
+ const resp = await this.client.send(new client_transcribe_streaming_1.StartStreamTranscriptionCommand({
55
+ LanguageCode: (0, lang_1.isoToLocale)(lang),
56
+ MediaSampleRateHertz: sampleRate,
57
+ MediaEncoding: 'pcm',
58
+ AudioStream: audio(),
59
+ }));
60
+ let text = '';
61
+ for await (const event of resp.TranscriptResultStream || []) {
62
+ for (const result of event.TranscriptEvent?.Transcript?.Results || []) {
63
+ const transcript = result.Alternatives?.[0]?.Transcript;
64
+ if (!result.IsPartial && transcript) {
65
+ text += (text ? ' ' : '') + transcript;
66
+ }
67
+ }
68
+ }
69
+ return text.trim();
70
+ }
71
+ }
72
+ exports.AwsStt = AwsStt;
73
+ class AwsTts {
74
+ voice;
75
+ client;
76
+ constructor(creds,
77
+ /** Polly VoiceId (e.g. 'Vicki', 'Tatyana'); empty → default per language. */
78
+ voice) {
79
+ this.voice = voice;
80
+ this.client = new client_polly_1.PollyClient({
81
+ region: creds.region,
82
+ credentials: { accessKeyId: creds.accessKeyId, secretAccessKey: creds.secretAccessKey },
83
+ });
84
+ }
85
+ async synthesize(text, lang) {
86
+ const iso = (lang || 'en').split('-')[0].toLowerCase();
87
+ const voice = this.voice || DEFAULT_POLLY_VOICE[iso] || 'Joanna';
88
+ const resp = await this.client.send(new client_polly_1.SynthesizeSpeechCommand({
89
+ Text: text,
90
+ OutputFormat: 'pcm', // 16-bit signed 1-channel little-endian
91
+ SampleRate: '16000',
92
+ VoiceId: voice,
93
+ }));
94
+ if (!resp.AudioStream) {
95
+ throw new Error('Polly returned no audio stream');
96
+ }
97
+ const bytes = await resp.AudioStream.transformToByteArray();
98
+ return { pcm: Buffer.from(bytes), sampleRate: 16000 };
99
+ }
100
+ }
101
+ exports.AwsTts = AwsTts;
102
+ //# sourceMappingURL=aws.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"aws.js","sourceRoot":"","sources":["../../../src/lib/voice/aws.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * AWS speech engines: STT via Amazon Transcribe (streaming) and TTS via Amazon Polly.\n * Credentials: accessKeyId + secretAccessKey + region.\n */\nimport {\n PollyClient,\n SynthesizeSpeechCommand,\n DescribeVoicesCommand,\n type VoiceId,\n type LanguageCode as PollyLanguageCode,\n} from '@aws-sdk/client-polly';\nimport {\n TranscribeStreamingClient,\n StartStreamTranscriptionCommand,\n type AudioStream,\n type LanguageCode,\n} from '@aws-sdk/client-transcribe-streaming';\nimport { isoToLocale } from './lang';\nimport type { SttEngine } from './stt';\nimport type { TtsEngine, TtsResult } from './tts';\n\nexport interface AwsCreds {\n accessKeyId: string;\n secretAccessKey: string;\n region: string;\n}\n\n/** Reasonable default Polly voice per language when none is configured. */\nconst DEFAULT_POLLY_VOICE: Record<string, string> = {\n de: 'Vicki',\n en: 'Joanna',\n ru: 'Tatyana',\n fr: 'Lea',\n it: 'Bianca',\n es: 'Lucia',\n nl: 'Laura',\n pl: 'Ewa',\n pt: 'Ines',\n};\n\n/** ~100 ms of 16 kHz mono 16-bit PCM per Transcribe audio chunk. */\nconst STT_CHUNK_BYTES = 3200;\n\n/** List available Polly voice ids, optionally filtered by language (locale, e.g. 'de-DE'). */\nexport async function listPollyVoices(creds: AwsCreds, languageCode: string): Promise<string[]> {\n const client = new PollyClient({\n region: creds.region,\n credentials: { accessKeyId: creds.accessKeyId, secretAccessKey: creds.secretAccessKey },\n });\n const res = await client.send(\n new DescribeVoicesCommand(languageCode ? { LanguageCode: languageCode as PollyLanguageCode } : {}),\n );\n return (res.Voices || []).map(v => v.Id).filter((id): id is VoiceId => !!id);\n}\n\nexport class AwsStt implements SttEngine {\n private readonly client: TranscribeStreamingClient;\n\n constructor(creds: AwsCreds) {\n this.client = new TranscribeStreamingClient({\n region: creds.region,\n credentials: { accessKeyId: creds.accessKeyId, secretAccessKey: creds.secretAccessKey },\n });\n }\n\n // Note: the SttEngine `hints` vocabulary is intentionally not applied here. Amazon Transcribe biases\n // only via a *custom vocabulary* that must be created/registered ahead of time (CreateVocabulary) and\n // referenced by name — there is no inline per-request word list for streaming. So we skip biasing for AWS.\n async transcribe(pcm: Buffer, sampleRate: number, lang: string): Promise<string> {\n // Must be an async generator: Transcribe's AudioStream is an AsyncIterable.\n // eslint-disable-next-line @typescript-eslint/require-await\n async function* audio(): AsyncGenerator<AudioStream> {\n for (let off = 0; off < pcm.length; off += STT_CHUNK_BYTES) {\n yield { AudioEvent: { AudioChunk: pcm.subarray(off, off + STT_CHUNK_BYTES) } };\n }\n }\n\n const resp = await this.client.send(\n new StartStreamTranscriptionCommand({\n LanguageCode: isoToLocale(lang) as LanguageCode,\n MediaSampleRateHertz: sampleRate,\n MediaEncoding: 'pcm',\n AudioStream: audio(),\n }),\n );\n\n let text = '';\n for await (const event of resp.TranscriptResultStream || []) {\n for (const result of event.TranscriptEvent?.Transcript?.Results || []) {\n const transcript = result.Alternatives?.[0]?.Transcript;\n if (!result.IsPartial && transcript) {\n text += (text ? ' ' : '') + transcript;\n }\n }\n }\n return text.trim();\n }\n}\n\nexport class AwsTts implements TtsEngine {\n private readonly client: PollyClient;\n\n constructor(\n creds: AwsCreds,\n /** Polly VoiceId (e.g. 'Vicki', 'Tatyana'); empty → default per language. */\n private readonly voice: string,\n ) {\n this.client = new PollyClient({\n region: creds.region,\n credentials: { accessKeyId: creds.accessKeyId, secretAccessKey: creds.secretAccessKey },\n });\n }\n\n async synthesize(text: string, lang: string): Promise<TtsResult> {\n const iso = (lang || 'en').split('-')[0].toLowerCase();\n const voice = this.voice || DEFAULT_POLLY_VOICE[iso] || 'Joanna';\n const resp = await this.client.send(\n new SynthesizeSpeechCommand({\n Text: text,\n OutputFormat: 'pcm', // 16-bit signed 1-channel little-endian\n SampleRate: '16000',\n VoiceId: voice as VoiceId,\n }),\n );\n if (!resp.AudioStream) {\n throw new Error('Polly returned no audio stream');\n }\n const bytes = await resp.AudioStream.transformToByteArray();\n return { pcm: Buffer.from(bytes), sampleRate: 16000 };\n }\n}\n"]}
@@ -0,0 +1,144 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
35
+ Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.AzureTts = exports.AzureStt = void 0;
37
+ exports.listAzureVoices = listAzureVoices;
38
+ /**
39
+ * Azure Cognitive Services Speech engines (STT + TTS).
40
+ * Credentials: a subscription key + region (e.g. 'westeurope').
41
+ */
42
+ const sdk = __importStar(require("microsoft-cognitiveservices-speech-sdk"));
43
+ const lang_1 = require("./lang");
44
+ /** List available Azure voices (short names like 'de-DE-KatjaNeural'), optionally filtered by locale. */
45
+ async function listAzureVoices(key, region, locale) {
46
+ const speechConfig = sdk.SpeechConfig.fromSubscription(key, region);
47
+ const synth = new sdk.SpeechSynthesizer(speechConfig, null);
48
+ try {
49
+ const res = await synth.getVoicesAsync(locale || '');
50
+ if (res.reason === sdk.ResultReason.VoicesListRetrieved) {
51
+ return res.voices.map(v => v.shortName);
52
+ }
53
+ throw new Error(res.errorDetails || 'Azure voice list failed');
54
+ }
55
+ finally {
56
+ synth.close();
57
+ }
58
+ }
59
+ /** Azure STT via `recognizeOnceAsync` fed from a PCM push stream (one short utterance). */
60
+ class AzureStt {
61
+ key;
62
+ region;
63
+ constructor(key, region) {
64
+ this.key = key;
65
+ this.region = region;
66
+ }
67
+ async transcribe(pcm, sampleRate, lang, hints) {
68
+ const speechConfig = sdk.SpeechConfig.fromSubscription(this.key, this.region);
69
+ speechConfig.speechRecognitionLanguage = (0, lang_1.isoToLocale)(lang);
70
+ const format = sdk.AudioStreamFormat.getWaveFormatPCM(sampleRate, 16, 1);
71
+ const pushStream = sdk.AudioInputStream.createPushStream(format);
72
+ // The push stream needs a standalone ArrayBuffer (not the pooled Node Buffer view).
73
+ pushStream.write(pcm.buffer.slice(pcm.byteOffset, pcm.byteOffset + pcm.byteLength));
74
+ pushStream.close();
75
+ const recognizer = new sdk.SpeechRecognizer(speechConfig, sdk.AudioConfig.fromStreamInput(pushStream));
76
+ // A phrase list softly boosts recognition of the given domain vocabulary (device/room names) without
77
+ // restricting the recognizer to only those words.
78
+ if (hints?.length) {
79
+ const phraseList = sdk.PhraseListGrammar.fromRecognizer(recognizer);
80
+ for (const h of hints) {
81
+ const term = (h || '').trim();
82
+ if (term) {
83
+ phraseList.addPhrase(term);
84
+ }
85
+ }
86
+ }
87
+ try {
88
+ const result = await new Promise((resolve, reject) => {
89
+ recognizer.recognizeOnceAsync(resolve, reject);
90
+ });
91
+ if (result.reason === sdk.ResultReason.RecognizedSpeech) {
92
+ return result.text.trim();
93
+ }
94
+ if (result.reason === sdk.ResultReason.Canceled) {
95
+ const details = sdk.CancellationDetails.fromResult(result);
96
+ throw new Error(`Azure STT canceled: ${details.errorDetails || details.reason}`);
97
+ }
98
+ return '';
99
+ }
100
+ finally {
101
+ recognizer.close();
102
+ }
103
+ }
104
+ }
105
+ exports.AzureStt = AzureStt;
106
+ /** Azure TTS to raw 24 kHz mono 16-bit PCM (no server-side playback). */
107
+ class AzureTts {
108
+ key;
109
+ region;
110
+ voice;
111
+ constructor(key, region,
112
+ /** Full Azure voice name (e.g. 'de-DE-KatjaNeural'); empty → pick by language. */
113
+ voice) {
114
+ this.key = key;
115
+ this.region = region;
116
+ this.voice = voice;
117
+ }
118
+ async synthesize(text, lang) {
119
+ const speechConfig = sdk.SpeechConfig.fromSubscription(this.key, this.region);
120
+ speechConfig.speechSynthesisOutputFormat = sdk.SpeechSynthesisOutputFormat.Raw24Khz16BitMonoPcm;
121
+ if (this.voice) {
122
+ speechConfig.speechSynthesisVoiceName = this.voice;
123
+ }
124
+ else {
125
+ speechConfig.speechSynthesisLanguage = (0, lang_1.isoToLocale)(lang);
126
+ }
127
+ // audioConfig=null → synthesise to memory only, no default-speaker output.
128
+ const synth = new sdk.SpeechSynthesizer(speechConfig, null);
129
+ try {
130
+ const result = await new Promise((resolve, reject) => {
131
+ synth.speakTextAsync(text, resolve, reject);
132
+ });
133
+ if (result.reason === sdk.ResultReason.SynthesizingAudioCompleted) {
134
+ return { pcm: Buffer.from(result.audioData), sampleRate: 24000 };
135
+ }
136
+ throw new Error(`Azure TTS failed: ${result.errorDetails || result.reason}`);
137
+ }
138
+ finally {
139
+ synth.close();
140
+ }
141
+ }
142
+ }
143
+ exports.AzureTts = AzureTts;
144
+ //# sourceMappingURL=azure.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"azure.js","sourceRoot":"","sources":["../../../src/lib/voice/azure.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * Azure Cognitive Services Speech engines (STT + TTS).\n * Credentials: a subscription key + region (e.g. 'westeurope').\n */\nimport * as sdk from 'microsoft-cognitiveservices-speech-sdk';\nimport { isoToLocale } from './lang';\nimport type { SttEngine } from './stt';\nimport type { TtsEngine, TtsResult } from './tts';\n\n/** List available Azure voices (short names like 'de-DE-KatjaNeural'), optionally filtered by locale. */\nexport async function listAzureVoices(key: string, region: string, locale: string): Promise<string[]> {\n const speechConfig = sdk.SpeechConfig.fromSubscription(key, region);\n const synth = new sdk.SpeechSynthesizer(speechConfig, null);\n try {\n const res = await synth.getVoicesAsync(locale || '');\n if (res.reason === sdk.ResultReason.VoicesListRetrieved) {\n return res.voices.map(v => v.shortName);\n }\n throw new Error(res.errorDetails || 'Azure voice list failed');\n } finally {\n synth.close();\n }\n}\n\n/** Azure STT via `recognizeOnceAsync` fed from a PCM push stream (one short utterance). */\nexport class AzureStt implements SttEngine {\n constructor(\n private readonly key: string,\n private readonly region: string,\n ) {}\n\n async transcribe(pcm: Buffer, sampleRate: number, lang: string, hints?: string[]): Promise<string> {\n const speechConfig = sdk.SpeechConfig.fromSubscription(this.key, this.region);\n speechConfig.speechRecognitionLanguage = isoToLocale(lang);\n\n const format = sdk.AudioStreamFormat.getWaveFormatPCM(sampleRate, 16, 1);\n const pushStream = sdk.AudioInputStream.createPushStream(format);\n // The push stream needs a standalone ArrayBuffer (not the pooled Node Buffer view).\n pushStream.write(pcm.buffer.slice(pcm.byteOffset, pcm.byteOffset + pcm.byteLength) as ArrayBuffer);\n pushStream.close();\n\n const recognizer = new sdk.SpeechRecognizer(speechConfig, sdk.AudioConfig.fromStreamInput(pushStream));\n // A phrase list softly boosts recognition of the given domain vocabulary (device/room names) without\n // restricting the recognizer to only those words.\n if (hints?.length) {\n const phraseList = sdk.PhraseListGrammar.fromRecognizer(recognizer);\n for (const h of hints) {\n const term = (h || '').trim();\n if (term) {\n phraseList.addPhrase(term);\n }\n }\n }\n try {\n const result = await new Promise<sdk.SpeechRecognitionResult>((resolve, reject) => {\n recognizer.recognizeOnceAsync(resolve, reject);\n });\n if (result.reason === sdk.ResultReason.RecognizedSpeech) {\n return result.text.trim();\n }\n if (result.reason === sdk.ResultReason.Canceled) {\n const details = sdk.CancellationDetails.fromResult(result);\n throw new Error(`Azure STT canceled: ${details.errorDetails || details.reason}`);\n }\n return '';\n } finally {\n recognizer.close();\n }\n }\n}\n\n/** Azure TTS to raw 24 kHz mono 16-bit PCM (no server-side playback). */\nexport class AzureTts implements TtsEngine {\n constructor(\n private readonly key: string,\n private readonly region: string,\n /** Full Azure voice name (e.g. 'de-DE-KatjaNeural'); empty → pick by language. */\n private readonly voice: string,\n ) {}\n\n async synthesize(text: string, lang: string): Promise<TtsResult> {\n const speechConfig = sdk.SpeechConfig.fromSubscription(this.key, this.region);\n speechConfig.speechSynthesisOutputFormat = sdk.SpeechSynthesisOutputFormat.Raw24Khz16BitMonoPcm;\n if (this.voice) {\n speechConfig.speechSynthesisVoiceName = this.voice;\n } else {\n speechConfig.speechSynthesisLanguage = isoToLocale(lang);\n }\n\n // audioConfig=null → synthesise to memory only, no default-speaker output.\n const synth = new sdk.SpeechSynthesizer(speechConfig, null);\n try {\n const result = await new Promise<sdk.SpeechSynthesisResult>((resolve, reject) => {\n synth.speakTextAsync(text, resolve, reject);\n });\n if (result.reason === sdk.ResultReason.SynthesizingAudioCompleted) {\n return { pcm: Buffer.from(result.audioData), sampleRate: 24000 };\n }\n throw new Error(`Azure TTS failed: ${result.errorDetails || result.reason}`);\n } finally {\n synth.close();\n }\n }\n}\n"]}