iobroker.assistant 1.1.0 → 1.2.0

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 (71) hide show
  1. package/README.md +63 -1
  2. package/admin/custom/.vite/manifest.json +62 -62
  3. package/admin/custom/assets/Components-BSewCBfS.js +2 -0
  4. package/admin/custom/assets/{_virtual_mf-localSharedImportMap___mfe_internal__ConfigCustomAssistant__mf_owner__1-A4gakomR.js → _virtual_mf-localSharedImportMap___mfe_internal__ConfigCustomAssistant__mf_owner__1-C5NPM0Of.js} +1 -1
  5. package/admin/custom/assets/{_virtual_mf___mfe_internal__ConfigCustomAssistant__mf_owner__201375093093590__loadShare___mf_0_emotion_mf_1_styled__loadShare__.js-CwqmfMvn.js → _virtual_mf___mfe_internal__ConfigCustomAssistant__mf_owner__163539842885036__loadShare___mf_0_emotion_mf_1_styled__loadShare__.js-IaosaymG.js} +1 -1
  6. package/admin/custom/assets/{_virtual_mf___mfe_internal__ConfigCustomAssistant__mf_owner__201375093093590__loadShare___mf_0_iobroker_mf_1_gui_mf_2_components__loadShare__.js-BMuLYe6f.js → _virtual_mf___mfe_internal__ConfigCustomAssistant__mf_owner__163539842885036__loadShare___mf_0_iobroker_mf_1_gui_mf_2_components__loadShare__.js-Cdyjt5KT.js} +39 -39
  7. package/admin/custom/assets/{_virtual_mf___mfe_internal__ConfigCustomAssistant__mf_owner__201375093093590__loadShare__shared-BxjZmnpc.js → _virtual_mf___mfe_internal__ConfigCustomAssistant__mf_owner__163539842885036__loadShare__shared-Dsw5zWS8.js} +1 -1
  8. package/admin/custom/assets/_virtual_mf___mfe_internal__ConfigCustomAssistant__mf_owner__163539842885036__prebuild___mf_0_iobroker_mf_1_json_mf_2_config__prebuild__-SgSagTOv.js +122 -0
  9. package/admin/custom/assets/{hostInit-4roC4hEj.js → hostInit-jJe8bIfV.js} +1 -1
  10. package/admin/custom/assets/{lib-CchzgTUs.js → lib-CL8mXvI6.js} +1 -1
  11. package/admin/custom/assets/{mf-entry-bootstrap-0-4806bbe4.js → mf-entry-bootstrap-0-ab227aa6.js} +2 -2
  12. package/admin/custom/assets/{pendingShares-DLlv8yFY.js → pendingShares-wrGtfliQ.js} +1 -1
  13. package/admin/custom/customComponents.js +2 -2
  14. package/admin/custom/i18n/de.json +40 -0
  15. package/admin/custom/i18n/en.json +40 -0
  16. package/admin/custom/i18n/es.json +40 -0
  17. package/admin/custom/i18n/fr.json +40 -0
  18. package/admin/custom/i18n/it.json +40 -0
  19. package/admin/custom/i18n/nl.json +40 -0
  20. package/admin/custom/i18n/pl.json +40 -0
  21. package/admin/custom/i18n/pt.json +40 -0
  22. package/admin/custom/i18n/ru.json +40 -0
  23. package/admin/custom/i18n/uk.json +40 -0
  24. package/admin/custom/i18n/zh-cn.json +40 -0
  25. package/admin/custom/index.html +2 -2
  26. package/admin/custom/mf-manifest.json +1 -1
  27. package/admin/custom/mf-stats.json +1 -1
  28. package/admin/i18n/de.json +6 -2
  29. package/admin/i18n/en.json +6 -2
  30. package/admin/i18n/es.json +6 -2
  31. package/admin/i18n/fr.json +6 -2
  32. package/admin/i18n/it.json +6 -2
  33. package/admin/i18n/nl.json +6 -2
  34. package/admin/i18n/pl.json +6 -2
  35. package/admin/i18n/pt.json +6 -2
  36. package/admin/i18n/ru.json +6 -2
  37. package/admin/i18n/uk.json +6 -2
  38. package/admin/i18n/zh-cn.json +6 -2
  39. package/admin/jsonConfig.json +29 -2
  40. package/build/lib/journal.js +298 -2
  41. package/build/lib/journal.js.map +1 -1
  42. package/build/lib/llm.js +105 -4
  43. package/build/lib/llm.js.map +1 -1
  44. package/build/lib/localLlm.js +47 -10
  45. package/build/lib/localLlm.js.map +1 -1
  46. package/build/lib/nlu.js +124 -5
  47. package/build/lib/nlu.js.map +1 -1
  48. package/build/lib/tools.js +3 -0
  49. package/build/lib/tools.js.map +1 -1
  50. package/build/lib/voice/esphome.js +81 -7
  51. package/build/lib/voice/esphome.js.map +1 -1
  52. package/build/lib/voice/esphomeProto.js.map +1 -1
  53. package/build/lib/voice/mediaServer.js +29 -1
  54. package/build/lib/voice/mediaServer.js.map +1 -1
  55. package/build/lib/voice/protocol.js.map +1 -1
  56. package/build/lib/voice/voiceServer.js +81 -7
  57. package/build/lib/voice/voiceServer.js.map +1 -1
  58. package/build/lib/voice/wakeWords.js +284 -0
  59. package/build/lib/voice/wakeWords.js.map +1 -0
  60. package/build/lib/voice/wyoming.js +12 -4
  61. package/build/lib/voice/wyoming.js.map +1 -1
  62. package/build/lib/weather.js +160 -0
  63. package/build/lib/weather.js.map +1 -1
  64. package/build/main.js +716 -74
  65. package/build/main.js.map +1 -1
  66. package/io-package.json +27 -25
  67. package/package.json +5 -5
  68. package/admin/custom/assets/Components-C6qcpaRC.js +0 -1
  69. package/admin/custom/assets/_virtual_mf___mfe_internal__ConfigCustomAssistant__mf_owner__201375093093590__prebuild___mf_0_iobroker_mf_1_json_mf_2_config__prebuild__-TM4l0OYm.js +0 -122
  70. /package/admin/custom/assets/{_virtual_mf___mfe_internal__ConfigCustomAssistant__mf_owner__201375093093590__loadShare___mf_0_emotion_mf_1_react__loadShare__.js-BkH7fzDw.js → _virtual_mf___mfe_internal__ConfigCustomAssistant__mf_owner__163539842885036__loadShare___mf_0_emotion_mf_1_react__loadShare__.js-BkH7fzDw.js} +0 -0
  71. /package/admin/custom/assets/{_virtual_mf___mfe_internal__ConfigCustomAssistant__mf_owner__201375093093590__prebuild__react_mf_1_jsx_mf_2_runtime__prebuild__-M61rjtmp.js → _virtual_mf___mfe_internal__ConfigCustomAssistant__mf_owner__163539842885036__prebuild__react_mf_1_jsx_mf_2_runtime__prebuild__-M61rjtmp.js} +0 -0
package/build/main.js CHANGED
@@ -63,6 +63,7 @@ const wyoming_1 = require("./lib/voice/wyoming");
63
63
  const esphome_1 = require("./lib/voice/esphome");
64
64
  const esphomeEntities_1 = require("./lib/voice/esphomeEntities");
65
65
  const mediaServer_1 = require("./lib/voice/mediaServer");
66
+ const wakeWords_1 = require("./lib/voice/wakeWords");
66
67
  const tone_1 = require("./lib/voice/tone");
67
68
  const engines_1 = require("./lib/voice/engines");
68
69
  /**
@@ -70,6 +71,20 @@ const engines_1 = require("./lib/voice/engines");
70
71
  * so re-reading their whole state tree on every single request would be pure overhead.
71
72
  */
72
73
  const WEATHER_CTX_TTL = 5 * 60 * 1000;
74
+ /**
75
+ * How long the NLU device model stays valid. Building it costs a `list_devices` plus a name lookup per
76
+ * device, and one spoken command needs it up to three times (STT hints, NLU, the cloud device list).
77
+ * Enum and name changes drop it at once (see {@link ListCache.onClear}); the TTL only catches devices
78
+ * that were added or renamed elsewhere.
79
+ */
80
+ const NLU_DEVICES_TTL_MS = 60_000;
81
+ /** Default for `localLlmTimeoutSec`: after this the local model is stopped and the next tier answers. */
82
+ const LOCAL_LLM_TIMEOUT_SEC = 15;
83
+ /**
84
+ * Answer length cap of the local model. It answers small talk and general questions, mostly by voice, and
85
+ * on a Raspberry Pi every token costs about a fifth of a second — 512 tokens would be a minute and a half.
86
+ */
87
+ const LOCAL_LLM_MAX_TOKENS = 256;
73
88
  /**
74
89
  * The replies the assistant speaks verbatim, whatever the question was. They are pre-synthesised into
75
90
  * the TTS cache at startup; everything else is cached on first use.
@@ -163,7 +178,27 @@ function iaqLabel(value, unit, lang) {
163
178
  return lang === 'ru' ? row[3] : lang === 'de' ? row[1] : row[2];
164
179
  }
165
180
  /** NLU actions that only switch something; their success is what the confirmation tone replaces. */
166
- const CONTROL_ACTIONS = new Set(['on', 'off', 'level', 'color']);
181
+ const CONTROL_ACTIONS = new Set(['on', 'off', 'level', 'color', 'volumeSet']);
182
+ /** An intent in one line for the request trace: "level Rollo (Büro) = 30", "timerSet 300 s", "alarmSet 7:05". */
183
+ function describeIntent(intent) {
184
+ let text = intent.action;
185
+ if (intent.device) {
186
+ text += ` ${intent.device.name}${intent.device.room ? ` (${intent.device.room})` : ''}`;
187
+ }
188
+ else if (intent.room) {
189
+ text += ` (${intent.room})`;
190
+ }
191
+ if (intent.value !== undefined) {
192
+ text += ` = ${JSON.stringify(intent.value)}`;
193
+ }
194
+ else if (intent.durationSec !== undefined) {
195
+ text += ` ${intent.durationSec} s`;
196
+ }
197
+ else if (intent.hour !== undefined) {
198
+ text += ` ${intent.hour}:${String(intent.minute ?? 0).padStart(2, '0')}`;
199
+ }
200
+ return text;
201
+ }
167
202
  const FIXED_REPLIES = {
168
203
  de: ['Ok.', 'Erledigt.'],
169
204
  en: ['Okay.', 'Done.'],
@@ -200,6 +235,8 @@ class Assistant extends adapter_core_1.Adapter {
200
235
  journal = null;
201
236
  /** Short-TTL cache for the expensive device/room/function listings; invalidated on enum changes. */
202
237
  listCache = null;
238
+ /** The NLU device model of {@link getNluDevices}, shared by all callers (and concurrent requests). */
239
+ nluDevices = null;
203
240
  /** Cache of a `<base>.SET` level state id → its writable boolean sibling switch (`<base>.ON_SET`/`.ON`), or ''. */
204
241
  siblingSwitch = new Map();
205
242
  /** Tier-1a local LLM (node-llama-cpp), lazily loaded when enabled + installed; null otherwise. */
@@ -216,6 +253,10 @@ class Assistant extends adapter_core_1.Adapter {
216
253
  esphome = null;
217
254
  /** Serves spoken replies to ESPHome satellites over HTTP; null unless those are enabled. */
218
255
  media = null;
256
+ /** Uploaded wake words (`assistant.0/wakewords/`), offered to every ESPHome satellite. */
257
+ customWakeWords = new Map();
258
+ /** Loads {@link customWakeWords} once from the file storage; later calls wait for the same load. */
259
+ customWakeWordsLoaded = null;
219
260
  /** Short-term per-source conversation memory (in-memory, TTL) for follow-up questions. */
220
261
  context = new context_1.ConversationStore();
221
262
  /** Questions the assistant asked and is waiting for an answer to (`askUser`), keyed by source. */
@@ -230,6 +271,8 @@ class Assistant extends adapter_core_1.Adapter {
230
271
  mirroredTimers = new Map();
231
272
  /** Per satellite: resolves once its control objects exist, so state writes can wait for them. */
232
273
  satControlsReady = new Map();
274
+ /** Satellites whose `volume` state exists and is subscribed (see updateSatelliteVolume). */
275
+ satVolumeEnsured = new Set();
233
276
  /** Native (ioBroker) satellite state-id → sender instance id, so we can push announcements back to it. */
234
277
  nativeSatFrom = new Map();
235
278
  /** Countdown timers / reminders (roadmap #2); mirrored into `timers.*` states. Null until onReady. */
@@ -304,6 +347,9 @@ class Assistant extends adapter_core_1.Adapter {
304
347
  allowObjectChange: cfg.allowObjectChange,
305
348
  }));
306
349
  this.listCache = new tools_1.ListCache();
350
+ this.listCache.onClear = () => {
351
+ this.nluDevices = null;
352
+ };
307
353
  const { tools, denied } = await (0, tools_1.buildMcpTools)(this.mcp, cfg, this.listCache, (id, fb) => this.resolveDeviceName(id, fb));
308
354
  // Let the cloud LLM handle timers/alarms too (for phrasings the rule-based NLU misses).
309
355
  tools.push(...this.buildTimerTools(), ...this.buildAlarmTools());
@@ -347,6 +393,7 @@ class Assistant extends adapter_core_1.Adapter {
347
393
  maxTokens: cfg.maxTokens || 1024,
348
394
  tools: tools.map(t => this.journalTool(t)),
349
395
  log: this.log,
396
+ trace: step => this.journal?.step(step),
350
397
  });
351
398
  }
352
399
  }
@@ -434,7 +481,8 @@ class Assistant extends adapter_core_1.Adapter {
434
481
  language,
435
482
  stt,
436
483
  tts,
437
- answer: (question, sttMs) => this.answer(question, 'wyoming', { transport: 'wyoming', sttMs }),
484
+ answer: (question, sttMs, audioMs) => this.answer(question, 'wyoming', { transport: 'wyoming', sttMs, audioMs }),
485
+ onSpoken: info => this.reportSpoken('wyoming', info),
438
486
  getHints: () => this.buildSttHints(),
439
487
  log: this.log,
440
488
  });
@@ -489,6 +537,10 @@ class Assistant extends adapter_core_1.Adapter {
489
537
  this.media = null;
490
538
  return;
491
539
  }
540
+ await this.loadCustomWakeWords();
541
+ for (const word of this.customWakeWords.values()) {
542
+ this.serveWakeWord(word);
543
+ }
492
544
  this.esphome = new esphome_1.EsphomeSatellites({
493
545
  devices,
494
546
  language,
@@ -499,7 +551,10 @@ class Assistant extends adapter_core_1.Adapter {
499
551
  room: origin.room,
500
552
  wakeWord: origin.wakeWord,
501
553
  sttMs: origin.sttMs,
554
+ audioMs: origin.audioMs,
555
+ vad: origin.vad,
502
556
  }),
557
+ onSpoken: (device, info) => this.reportSpoken(device, info),
503
558
  getHints: () => this.buildSttHints(),
504
559
  media: this.media,
505
560
  mediaHost: (cfg.esphomeMediaHost || '').trim(),
@@ -517,7 +572,15 @@ class Assistant extends adapter_core_1.Adapter {
517
572
  onEntityState: (device, room, entity) => {
518
573
  this.updateSatelliteControl(device, room, entity).catch(e => this.log.debug(`satellite control update failed: ${e.message}`));
519
574
  },
520
- expectsFollowUp: answer => this.expectsFollowUp(answer),
575
+ expectsFollowUp: (answer, device) => this.followUpFor(device, answer),
576
+ externalWakeWords: () => [...this.customWakeWords.values()].map(w => ({
577
+ id: w.id,
578
+ phrase: w.phrase,
579
+ languages: w.languages,
580
+ size: w.size,
581
+ sha256: w.sha256,
582
+ path: `${wakeWords_1.WAKE_WORD_URL_PREFIX}${w.id}.json`,
583
+ })),
521
584
  });
522
585
  await this.esphome.start();
523
586
  this.log.info(`ESPHome satellites: ${devices.length} device(s), STT=${cfg.sttProvider || 'openai'}, TTS=${cfg.ttsProvider || 'openai'}.`);
@@ -572,7 +635,17 @@ class Assistant extends adapter_core_1.Adapter {
572
635
  language,
573
636
  stt,
574
637
  tts,
575
- answer: (question, ctx) => this.answerVoice(question, ctx.device, { transport: 'udp', room: ctx.room, sttMs: ctx.sttMs }),
638
+ answer: (question, ctx) => this.answerVoice(question, ctx.device, {
639
+ transport: 'udp',
640
+ room: ctx.room,
641
+ sttMs: ctx.sttMs,
642
+ audioMs: ctx.audioMs,
643
+ }),
644
+ onSpoken: (device, info) => this.reportSpoken(device, info),
645
+ expectsFollowUp: (answer, device) => this.followUpFor(device, answer),
646
+ onVolume: (device, room, level) => {
647
+ this.updateSatelliteVolume(device, room, level).catch(e => this.log.debug(`satellite volume update failed: ${e.message}`));
648
+ },
576
649
  getHints: () => this.buildSttHints(),
577
650
  log: this.log,
578
651
  onStatus: (device, room, state) => {
@@ -639,6 +712,23 @@ class Assistant extends adapter_core_1.Adapter {
639
712
  expectsFollowUp(answer) {
640
713
  return /[??]/.test(answer || '');
641
714
  }
715
+ /** A transport synthesised the reply to `source`'s latest request — the journal shows it with that request. */
716
+ reportSpoken(source, info) {
717
+ const cfg = this.config;
718
+ const provider = cfg.ttsProvider || 'openai';
719
+ const voices = {
720
+ openai: cfg.ttsVoice || 'alloy',
721
+ azure: cfg.azureVoice,
722
+ aws: cfg.awsVoice,
723
+ piper: cfg.piperVoice,
724
+ };
725
+ this.journal?.spoken(source, {
726
+ ...info,
727
+ provider,
728
+ fallback: cfg.ttsFallback || undefined,
729
+ voice: (voices[provider] || '').trim() || undefined,
730
+ });
731
+ }
642
732
  /** Record the origin of the current text.request/response ('' = state write, 'chat' = message, else satellite). */
643
733
  setQuerySource(source) {
644
734
  this.setStateAsync('text.querySource', { val: source, ack: true }).catch(() => { });
@@ -705,17 +795,18 @@ class Assistant extends adapter_core_1.Adapter {
705
795
  room,
706
796
  wakeWord: typeof msg.wakeWord === 'string' ? msg.wakeWord : undefined,
707
797
  sttMs: Date.now() - sttStart,
798
+ audioMs: Math.round((pcm.length / 2 / rate) * 1000),
708
799
  });
709
800
  if (!answer) {
710
801
  return { text, answer: '' };
711
802
  }
803
+ const ttsStart = Date.now();
712
804
  const reply = await engines.tts.synthesize(answer, language);
713
- // Follow-up: primarily the LLM's own signal ([[LISTEN]] → lastFollowUp); "?" heuristic as fallback.
714
- const llmSignalled = this.lastFollowUp;
715
- const listen = llmSignalled || this.expectsFollowUp(answer);
716
- if (listen) {
717
- this.log.info(`→ mic-on to satellite '${source}' (${llmSignalled ? 'LLM signalled a follow-up' : 'reply is a question (fallback)'}).`);
718
- }
805
+ this.reportSpoken(source, {
806
+ ms: Date.now() - ttsStart,
807
+ audioMs: (reply.pcm.length / 2 / reply.sampleRate) * 1000,
808
+ });
809
+ const listen = this.followUpFor(source, answer);
719
810
  return { text, answer, audio: reply.pcm.toString('base64'), sampleRate: reply.sampleRate, listen };
720
811
  }
721
812
  catch (e) {
@@ -778,6 +869,33 @@ class Assistant extends adapter_core_1.Adapter {
778
869
  await this.setStateAsync(`${base}.room`, { val: room, ack: true });
779
870
  }
780
871
  }
872
+ /**
873
+ * Mirror the playback volume a UDP satellite reported into `satellites.<id>.volume`. The state only
874
+ * appears for satellites that speak the `volume` message — an older one would never answer a write.
875
+ */
876
+ async updateSatelliteVolume(device, room, level) {
877
+ const id = await this.ensureSatelliteObjects(device, room);
878
+ const stateId = `satellites.${id}.volume`;
879
+ if (!this.satVolumeEnsured.has(id)) {
880
+ await this.setObjectNotExistsAsync(stateId, {
881
+ type: 'state',
882
+ common: {
883
+ name: 'Playback volume',
884
+ type: 'number',
885
+ role: 'level.volume',
886
+ unit: '%',
887
+ min: 0,
888
+ max: 100,
889
+ read: true,
890
+ write: true,
891
+ },
892
+ native: {},
893
+ });
894
+ this.subscribeStates(stateId);
895
+ this.satVolumeEnsured.add(id);
896
+ }
897
+ await this.setStateAsync(stateId, { val: level, ack: true });
898
+ }
781
899
  /**
782
900
  * Create the object tree for one satellite and return its (sanitised) state id, without touching
783
901
  * any value. Callers that only need somewhere to hang a child object use this rather than
@@ -913,11 +1031,111 @@ class Assistant extends adapter_core_1.Adapter {
913
1031
  this.satWakeWordStatesEnsured.add(id);
914
1032
  }
915
1033
  await this.setStateAsync(`${base}.wakeWords`, { val: config.active.join(','), ack: true });
1034
+ // `custom` marks the uploaded ones, so the settings dialog can offer to delete them.
1035
+ const words = config.available.map(w => (this.customWakeWords.has(w.id) ? { ...w, custom: true } : w));
916
1036
  await this.setStateAsync(`${base}.availableWakeWords`, {
917
- val: JSON.stringify({ max: config.max, words: config.available }),
1037
+ val: JSON.stringify({ max: config.max, words }),
918
1038
  ack: true,
919
1039
  });
920
1040
  }
1041
+ /** Read the uploaded wake words (`<instance>/wakewords/<id>.json` + `.tflite`) once. */
1042
+ loadCustomWakeWords() {
1043
+ this.customWakeWordsLoaded ||= (async () => {
1044
+ let entries = [];
1045
+ try {
1046
+ entries = await this.readDirAsync(this.namespace, wakeWords_1.WAKE_WORD_DIR);
1047
+ }
1048
+ catch {
1049
+ return; // nothing uploaded yet
1050
+ }
1051
+ for (const entry of entries) {
1052
+ const id = entry.file.replace(/\.json$/, '');
1053
+ if (entry.isDir || !entry.file.endsWith('.json') || !wakeWords_1.WAKE_WORD_ID_RE.test(id)) {
1054
+ continue;
1055
+ }
1056
+ try {
1057
+ const config = await this.readStoredFile(`${wakeWords_1.WAKE_WORD_DIR}/${id}.json`, 'utf8');
1058
+ const model = await this.readStoredFile(`${wakeWords_1.WAKE_WORD_DIR}/${id}.tflite`, 'binary');
1059
+ const word = (0, wakeWords_1.loadWakeWord)(id, config, model);
1060
+ this.customWakeWords.set(id, word);
1061
+ // Uploaded before the upload checked the model: keep it, so it can be deleted in the dialog.
1062
+ const problem = (0, wakeWords_1.microModelProblem)(model);
1063
+ if (problem) {
1064
+ this.log.warn(`Custom wake word ${word.phrase} (${id}) cannot run on the device: ${problem}. Delete it in the satellite settings.`);
1065
+ }
1066
+ }
1067
+ catch (e) {
1068
+ this.log.warn(`Custom wake word ${id} skipped: ${e.message}`);
1069
+ }
1070
+ }
1071
+ if (this.customWakeWords.size) {
1072
+ this.log.info(`Custom wake words: ${[...this.customWakeWords.values()].map(w => `${w.phrase} (${w.id})`).join(', ')}`);
1073
+ }
1074
+ })();
1075
+ return this.customWakeWordsLoaded;
1076
+ }
1077
+ /**
1078
+ * Read a file from this instance's file storage as a Buffer. Depending on the backend and the file's
1079
+ * type it comes back as a Buffer or a string — `encoding` says how to read the latter.
1080
+ */
1081
+ async readStoredFile(name, encoding) {
1082
+ const res = (await this.readFileAsync(this.namespace, name));
1083
+ const f = Buffer.isBuffer(res) || typeof res === 'string' ? res : res.file;
1084
+ if (f == null) {
1085
+ throw new Error(`${name} not found`);
1086
+ }
1087
+ return Buffer.isBuffer(f) ? f : Buffer.from(f, encoding);
1088
+ }
1089
+ serveWakeWord(word) {
1090
+ this.media?.serve(`${wakeWords_1.WAKE_WORD_URL_PREFIX}${word.id}.json`, word.config, 'application/json');
1091
+ this.media?.serve(`${wakeWords_1.WAKE_WORD_URL_PREFIX}${word.id}.tflite`, word.model, 'application/octet-stream');
1092
+ }
1093
+ /**
1094
+ * Store an uploaded wake word and offer it to the connected satellites. It shows up in their wake
1095
+ * word list right away; a device downloads the model only when it is selected there.
1096
+ */
1097
+ async addCustomWakeWord(message) {
1098
+ await this.loadCustomWakeWords();
1099
+ const text = (value) => (typeof value === 'string' ? value : undefined);
1100
+ const languages = (Array.isArray(message.languages)
1101
+ ? message.languages.map(String)
1102
+ : (text(message.languages) ?? '').split(/[,;\s]+/)).filter(Boolean);
1103
+ const word = (0, wakeWords_1.prepareWakeWord)({
1104
+ model: Buffer.from(text(message.model) ?? '', 'base64'),
1105
+ fileName: text(message.fileName),
1106
+ config: message.config && typeof message.config === 'object'
1107
+ ? message.config
1108
+ : text(message.config),
1109
+ phrase: text(message.phrase),
1110
+ languages,
1111
+ }, this.config.voiceLanguage || this.language || '');
1112
+ if (!this.customWakeWords.has(word.id)) {
1113
+ await this.writeFileAsync(this.namespace, `${wakeWords_1.WAKE_WORD_DIR}/${word.id}.tflite`, word.model);
1114
+ await this.writeFileAsync(this.namespace, `${wakeWords_1.WAKE_WORD_DIR}/${word.id}.json`, word.config);
1115
+ this.customWakeWords.set(word.id, word);
1116
+ this.serveWakeWord(word);
1117
+ this.log.info(`Custom wake word uploaded: ${word.phrase} (${word.id}, ${word.size} bytes)`);
1118
+ }
1119
+ const devices = this.esphome?.refreshWakeWords() ?? 0;
1120
+ return { id: word.id, phrase: word.phrase, languages: word.languages, size: word.size, devices };
1121
+ }
1122
+ /**
1123
+ * Remove an uploaded wake word: it is no longer offered or served. A device that already downloaded
1124
+ * it keeps its copy — the ESPHome API cannot delete a model from a device.
1125
+ */
1126
+ async deleteCustomWakeWord(id) {
1127
+ await this.loadCustomWakeWords();
1128
+ if (!wakeWords_1.WAKE_WORD_ID_RE.test(id) || !this.customWakeWords.has(id)) {
1129
+ throw new Error(`unknown wake word ${id}`);
1130
+ }
1131
+ for (const ext of ['json', 'tflite']) {
1132
+ await this.delFileAsync(this.namespace, `${wakeWords_1.WAKE_WORD_DIR}/${id}.${ext}`).catch(() => undefined);
1133
+ this.media?.unserve(`${wakeWords_1.WAKE_WORD_URL_PREFIX}${id}.${ext}`);
1134
+ }
1135
+ this.customWakeWords.delete(id);
1136
+ this.esphome?.refreshWakeWords();
1137
+ this.log.info(`Custom wake word deleted: ${id}`);
1138
+ }
921
1139
  /**
922
1140
  * Mirror everything an ESPHome satellite exposes besides the voice pipeline into
923
1141
  * `satellites.<id>.controls.*` — microphone gain and volume, noise suppression, wake-word and
@@ -1281,7 +1499,8 @@ class Assistant extends adapter_core_1.Adapter {
1281
1499
  await Promise.all(allowed.map(d => this.voice.announce(d, pcm, sampleRate)));
1282
1500
  delivered += allowed.length;
1283
1501
  if (listen) {
1284
- allowed.forEach(d => this.voice.listen(d));
1502
+ const playMs = (pcm.length / 2 / sampleRate) * 1000;
1503
+ allowed.forEach(d => this.voice.listen(d, playMs));
1285
1504
  }
1286
1505
  }
1287
1506
  catch (e) {
@@ -1630,6 +1849,15 @@ class Assistant extends adapter_core_1.Adapter {
1630
1849
  await this.setStateAsync('dnd', { val: this.globalDnd, ack: true });
1631
1850
  return;
1632
1851
  }
1852
+ // Playback volume of a UDP satellite — not acked here: the satellite reports the level it took.
1853
+ const satVolume = id.match(/\.satellites\.([^.]+)\.volume$/);
1854
+ if (satVolume) {
1855
+ const device = this.satDeviceById.get(satVolume[1]) || satVolume[1];
1856
+ if (!this.voice?.volume(device, { level: Number(state.val) })) {
1857
+ this.log.warn(`Cannot set the volume of '${device}': it is not a registered UDP satellite.`);
1858
+ }
1859
+ return;
1860
+ }
1633
1861
  const satDnd = id.match(/\.satellites\.([^.]+)\.dnd$/);
1634
1862
  if (satDnd) {
1635
1863
  this.dndById.set(satDnd[1], !!state.val);
@@ -2014,6 +2242,41 @@ class Assistant extends adapter_core_1.Adapter {
2014
2242
  }
2015
2243
  return;
2016
2244
  }
2245
+ // Settings dialog / scripts: own wake words for the ESPHome satellites (microWakeWord models).
2246
+ // uploadWakeWord: { model: base64 .tflite, fileName?, config?: JSON text, phrase?, languages? }
2247
+ // → { ok, id, phrase, languages, size, devices } | { error }
2248
+ // deleteWakeWord: { id } → { ok } | { error }; listWakeWords → { wakeWords: [{id, phrase, …}] }
2249
+ if (obj.command === 'uploadWakeWord' || obj.command === 'deleteWakeWord' || obj.command === 'listWakeWords') {
2250
+ const m = (obj.message || {});
2251
+ let payload;
2252
+ try {
2253
+ if (obj.command === 'uploadWakeWord') {
2254
+ payload = { ok: true, ...(await this.addCustomWakeWord(m)) };
2255
+ }
2256
+ else if (obj.command === 'deleteWakeWord') {
2257
+ await this.deleteCustomWakeWord(typeof m.id === 'string' ? m.id : '');
2258
+ payload = { ok: true };
2259
+ }
2260
+ else {
2261
+ await this.loadCustomWakeWords();
2262
+ payload = {
2263
+ wakeWords: [...this.customWakeWords.values()].map(w => ({
2264
+ id: w.id,
2265
+ phrase: w.phrase,
2266
+ languages: w.languages,
2267
+ size: w.size,
2268
+ })),
2269
+ };
2270
+ }
2271
+ }
2272
+ catch (e) {
2273
+ payload = { error: e.message };
2274
+ }
2275
+ if (obj.callback) {
2276
+ this.sendTo(obj.from, obj.command, payload, obj.callback);
2277
+ }
2278
+ return;
2279
+ }
2017
2280
  // Scripts: everything an ESPHome satellite exposes besides the voice pipeline.
2018
2281
  // message = { device? }; without a device, every connected one is listed.
2019
2282
  if (obj.command === 'getControls') {
@@ -2220,6 +2483,18 @@ class Assistant extends adapter_core_1.Adapter {
2220
2483
  }
2221
2484
  return;
2222
2485
  }
2486
+ // Admin chat debug view: one request with every step of its processing (message = { id }).
2487
+ if (obj.command === 'getJournalTrace') {
2488
+ const id = String(obj.message?.id || '');
2489
+ const entry = this.journal?.get(id);
2490
+ const result = entry
2491
+ ? { entry, steps: await this.journal.trace(id) }
2492
+ : { error: this.journal?.enabled ? 'unknown request' : 'journal disabled' };
2493
+ if (obj.callback) {
2494
+ this.sendTo(obj.from, obj.command, result, obj.callback);
2495
+ }
2496
+ return;
2497
+ }
2223
2498
  if (obj.command === 'clearJournal') {
2224
2499
  this.journal?.clear();
2225
2500
  if (obj.callback) {
@@ -2271,28 +2546,92 @@ class Assistant extends adapter_core_1.Adapter {
2271
2546
  */
2272
2547
  answer(question, source = '', origin = {}) {
2273
2548
  return this.requestOrigin.run({ source, room: origin.room || '' }, () => this.journal
2274
- ? this.journal.run(question, source, origin, () => this.answerTracked(question, source))
2549
+ ? this.journal.run(question, source, origin, () => {
2550
+ this.traceSpeechInput(question, origin);
2551
+ return this.answerTracked(question, source);
2552
+ })
2275
2553
  : this.answerTracked(question, source));
2276
2554
  }
2555
+ /** Speech recognition happened before the request: put it at the head of its trace. */
2556
+ traceSpeechInput(question, origin) {
2557
+ if (origin.sttMs === undefined) {
2558
+ return;
2559
+ }
2560
+ const cfg = this.config;
2561
+ const provider = cfg.sttProvider || 'openai';
2562
+ const audio = origin.audioMs !== undefined ? `, ${(origin.audioMs / 1000).toFixed(1)} s of audio` : '';
2563
+ this.journal?.step({
2564
+ stage: 'stt',
2565
+ title: `Speech recognition: ${provider}${audio}`,
2566
+ ms: origin.sttMs,
2567
+ data: {
2568
+ provider,
2569
+ fallback: cfg.sttFallback || undefined,
2570
+ model: (cfg.sttModel || cfg.voskModel || '').trim() || undefined,
2571
+ language: cfg.voiceLanguage || this.language || '',
2572
+ audioMs: origin.audioMs,
2573
+ wakeWord: origin.wakeWord,
2574
+ vad: origin.vad,
2575
+ transcript: question,
2576
+ },
2577
+ });
2578
+ }
2277
2579
  async answerTracked(question, source) {
2278
2580
  const useCtx = this.config.useConversationContext !== false;
2279
2581
  const history = useCtx ? this.context.get(source) : [];
2582
+ if (history.length) {
2583
+ this.journal?.step({
2584
+ stage: 'context',
2585
+ title: `Conversation context: ${history.length} earlier turn(s) of '${source}'`,
2586
+ data: history,
2587
+ });
2588
+ }
2280
2589
  const raw = await this.produceAnswer(question, history, source);
2281
2590
  // The LLM signals "I'm waiting for the user's reply" by appending the [[LISTEN]] control marker
2282
- // (see followUpHint). Strip it here so it's neither stored, shown nor spoken, and expose it as the
2283
- // follow-up flag. Single-user voice → a shared field is race-free enough (read right after await).
2284
- this.lastFollowUp = /\[\[LISTEN\]\]/i.test(raw);
2285
- if (this.lastFollowUp) {
2591
+ // (see followUpHint). Strip it here so it's neither stored, shown nor spoken, and keep it per source:
2592
+ // two satellites answered at the same time must not hand each other their mic-on.
2593
+ const followUp = /\[\[LISTEN\]\]/i.test(raw);
2594
+ this.followUps.set(source, followUp);
2595
+ if (followUp) {
2286
2596
  this.journal?.note({ followUp: true });
2287
2597
  }
2288
2598
  const result = raw.replace(/\s*\[\[LISTEN\]\]\s*/gi, ' ').trim();
2599
+ this.journal?.step({
2600
+ stage: 'answer',
2601
+ title: result
2602
+ ? `Answer: ${result.length} characters${followUp ? ', waits for a reply' : ''}`
2603
+ : 'No spoken answer',
2604
+ data: { answer: result, raw: raw !== result ? raw : undefined, followUp },
2605
+ });
2289
2606
  if (useCtx && result) {
2290
2607
  this.context.add(source, question, result);
2291
2608
  }
2292
2609
  return result;
2293
2610
  }
2294
- /** Whether the LAST {@link answer} asked the user something (LLM-signalled via [[LISTEN]]). */
2295
- lastFollowUp = false;
2611
+ /** Per source: whether its last {@link answer} asked the user something (LLM-signalled via [[LISTEN]]). */
2612
+ followUps = new Map();
2613
+ /**
2614
+ * Should the satellite behind `source` re-open its mic after this reply? Primarily the LLM's own
2615
+ * [[LISTEN]] signal, with a question mark in the reply as the fallback (the NLU and the local tiers
2616
+ * cannot emit the marker, but they do ask "Meinst du Büro oder Wohnzimmer?").
2617
+ */
2618
+ wantsFollowUp(source, answer) {
2619
+ if (!answer) {
2620
+ return null;
2621
+ }
2622
+ if (this.followUps.get(source)) {
2623
+ return 'llm';
2624
+ }
2625
+ return this.expectsFollowUp(answer) ? 'question' : null;
2626
+ }
2627
+ /** {@link wantsFollowUp} for a voice transport, logged, as the `listen` flag of the reply. */
2628
+ followUpFor(source, answer) {
2629
+ const why = this.wantsFollowUp(source, answer);
2630
+ if (why) {
2631
+ this.log.info(`→ mic-on to satellite '${source}' (${why === 'llm' ? 'LLM signalled a follow-up' : 'reply is a question'}).`);
2632
+ }
2633
+ return !!why;
2634
+ }
2296
2635
  /**
2297
2636
  * System-prompt instruction letting the LLM explicitly signal a follow-up: append [[LISTEN]] when it
2298
2637
  * asks the user something and waits for an answer. More reliable than a "?" heuristic and lets a
@@ -2369,6 +2708,7 @@ class Assistant extends adapter_core_1.Adapter {
2369
2708
  const n = this.stopRinging();
2370
2709
  this.log.info(`Ring stopped by voice (${n} session(s)).`);
2371
2710
  this.journal?.note({ tier: 'stop' });
2711
+ this.journal?.step({ stage: 'stop', title: `Stop word: silenced ${n} ringing timer/alarm(s)` });
2372
2712
  const lang = String(this.config.voiceLanguage || this.language || 'en');
2373
2713
  return lang === 'ru' ? 'Хорошо.' : lang === 'de' ? 'Ok.' : 'Okay.';
2374
2714
  }
@@ -2380,6 +2720,11 @@ class Assistant extends adapter_core_1.Adapter {
2380
2720
  if (this.pending.deliver(source, question)) {
2381
2721
  this.log.info(`Answer to "${asked}" from source '${source || 'any'}': ${question}`);
2382
2722
  this.journal?.note({ tier: 'askReply', detail: asked || undefined });
2723
+ this.journal?.step({
2724
+ stage: 'askReply',
2725
+ title: `Reply to the open question "${asked}"`,
2726
+ data: { question: asked, reply: question },
2727
+ });
2383
2728
  return '';
2384
2729
  }
2385
2730
  }
@@ -2390,9 +2735,17 @@ class Assistant extends adapter_core_1.Adapter {
2390
2735
  if (routine) {
2391
2736
  this.log.info(`Routine '${routine.name}' triggered (source='${source}').`);
2392
2737
  this.journal?.note({ tier: 'routine', detail: routine.name });
2738
+ this.journal?.step({ stage: 'routine', title: `Routine "${routine.name}" matched`, data: routine });
2393
2739
  await this.runRoutine(routine, source);
2394
2740
  return routine.reply;
2395
2741
  }
2742
+ if (this.routines.length) {
2743
+ this.journal?.step({
2744
+ stage: 'routine',
2745
+ title: `No routine matched (${this.routines.length} configured)`,
2746
+ data: { phrases: this.routines.map(r => ({ name: r.name, phrases: r.phrases })) },
2747
+ });
2748
+ }
2396
2749
  // Tier 0: rule-based NLU (device commands) — fastest, offline, free.
2397
2750
  if (this.config.useLocalNlu) {
2398
2751
  // "Spiele den Kanal X ab" first: the device NLU would find the TV in "… auf dem Fernseher" and
@@ -2401,9 +2754,19 @@ class Assistant extends adapter_core_1.Adapter {
2401
2754
  if (yt) {
2402
2755
  this.log.info(`YouTube request understood locally: ${yt.kind} "${yt.query}" (source='${source}').`);
2403
2756
  this.journal?.note({ tier: 'nlu', detail: `youtube ${yt.kind}` });
2757
+ const start = Date.now();
2404
2758
  const res = await this.playYouTube(yt.query, yt.kind, yt.player);
2759
+ this.journal?.step({
2760
+ stage: 'youtube',
2761
+ title: `YouTube ${yt.kind} "${yt.query}"${yt.player ? ` on "${yt.player}"` : ''}`,
2762
+ ms: Date.now() - start,
2763
+ data: { parsed: yt, result: res },
2764
+ });
2405
2765
  return res.text;
2406
2766
  }
2767
+ if (this.youtubePlayers.length) {
2768
+ this.journal?.step({ stage: 'youtube', title: 'Not a YouTube command' });
2769
+ }
2407
2770
  try {
2408
2771
  const handled = await this.tryLocalNlu(question, source);
2409
2772
  if (handled !== null) {
@@ -2413,36 +2776,49 @@ class Assistant extends adapter_core_1.Adapter {
2413
2776
  }
2414
2777
  catch (e) {
2415
2778
  this.log.debug(`NLU skipped: ${e.message}`);
2779
+ this.journal?.step({
2780
+ stage: 'nlu',
2781
+ level: 'warn',
2782
+ title: `NLU gave up, the request goes on: ${e.message}`,
2783
+ data: (0, journal_1.errorInfo)(e),
2784
+ });
2416
2785
  }
2417
2786
  }
2787
+ else {
2788
+ this.journal?.step({ stage: 'nlu', title: 'Rule-based NLU is switched off (useLocalNlu)' });
2789
+ }
2418
2790
  // Current weather from the configured adapter — prepended to the user turn of BOTH LLM tiers so
2419
2791
  // "how's the weather?" is answered from real data (the local model has no tools and would otherwise
2420
2792
  // invent a forecast; the cloud model saves a get_weather round-trip). Cached, see buildWeatherContext.
2421
2793
  const weather = await this.buildWeatherContext();
2422
2794
  // Tier 1a: local LLM — answers general questions offline; emits HANDOFF for anything needing tools.
2795
+ // With a cloud LLM it only goes first when asked to (`localLlmFirst`): on a Raspberry Pi a small model
2796
+ // needs 10–25 s, often only to hand off, and the cloud then starts from zero. By default it is the
2797
+ // fallback for when the cloud fails (see below).
2423
2798
  // Skipped inside an ongoing conversation (history present): the tool-free local model gets no history,
2424
2799
  // so it would answer a follow-up like "yes" context-free and swallow it — let the cloud LLM (which has
2425
2800
  // the thread and the tools) handle follow-ups instead. Without a cloud LLM there is nobody to hand
2426
2801
  // follow-ups to, so the local model takes them too.
2427
- if (this.config.useLocalLlm && this.localLlm && (!history.length || !this.agent)) {
2428
- try {
2429
- // The local model has no tools: without these lines it would invent the weather and guess
2430
- // who is at home.
2431
- const facts = [weather, this.buildPresenceContext()].filter(Boolean).join('\n\n');
2432
- const ans = await this.localLlm.ask(facts ? `${facts}\n\n${question}` : question);
2433
- if (ans && !(0, localLlm_1.isHandoff)(ans)) {
2434
- this.log.info(`Answered by local LLM (source='${source}').`);
2435
- this.journal?.note({ tier: 'localLlm', detail: this.localLlmName() });
2436
- return ans;
2437
- }
2438
- this.log.debug('Local LLM handed off to the cloud LLM.');
2439
- }
2440
- catch (e) {
2441
- this.log.debug(`Local LLM error: ${e.message}`);
2802
+ const useLocal = !!(this.config.useLocalLlm && this.localLlm);
2803
+ const localFirst = useLocal && (!this.agent || !!this.config.localLlmFirst);
2804
+ if (localFirst && (!history.length || !this.agent)) {
2805
+ const ans = await this.askLocalLlm(question, weather, source);
2806
+ if (ans) {
2807
+ return ans;
2442
2808
  }
2443
2809
  }
2444
- else if (this.config.useLocalLlm && this.localLlm) {
2810
+ else if (localFirst) {
2445
2811
  this.log.debug('Local LLM skipped (follow-up in an ongoing conversation → cloud LLM).');
2812
+ this.journal?.step({
2813
+ stage: 'localLlm',
2814
+ title: 'Local LLM skipped: follow-up in an ongoing conversation goes to the cloud LLM',
2815
+ });
2816
+ }
2817
+ else if (useLocal) {
2818
+ this.journal?.step({
2819
+ stage: 'localLlm',
2820
+ title: 'Local LLM not asked first: it is the fallback for when the cloud LLM fails (localLlmFirst)',
2821
+ });
2446
2822
  }
2447
2823
  // Tier 2: cloud LLM with full tool access. Inject a compact device list into the system prompt so
2448
2824
  // the model can act without a first `list_devices` round-trip (kept cached via prompt caching).
@@ -2451,6 +2827,11 @@ class Assistant extends adapter_core_1.Adapter {
2451
2827
  // read out as a technical message.
2452
2828
  this.log.info(`No local tier could answer and no LLM provider is configured (source='${source}').`);
2453
2829
  this.journal?.note({ tier: 'none' });
2830
+ this.journal?.step({
2831
+ stage: 'none',
2832
+ level: 'warn',
2833
+ title: 'Nothing local understood the request and no LLM provider is configured',
2834
+ });
2454
2835
  return this.localOnlyFallback();
2455
2836
  }
2456
2837
  const ctx = await this.buildDeviceContext();
@@ -2462,12 +2843,99 @@ class Assistant extends adapter_core_1.Adapter {
2462
2843
  this.log.info(`Cloud LLM [${this.agent.model || 'auto'}] (source='${source}', context=${history.length} turn(s)${mem ? ', memory' : ''}${weather ? ', weather' : ''}${this.presence?.configured ? ', presence' : ''}) — running tool loop…`);
2463
2844
  // Prepend the current date/time (and the weather line, if a source is configured) to the user turn —
2464
2845
  // cache-safe, see buildTimeContext. The stored history keeps the untouched question.
2465
- const questionForLlm = [this.buildTimeContext(), weather, question].filter(Boolean).join('\n\n');
2846
+ const time = this.buildTimeContext();
2847
+ const questionForLlm = [time, weather, question].filter(Boolean).join('\n\n');
2466
2848
  this.journal?.note({
2467
2849
  tier: 'cloud',
2468
2850
  detail: `${this.config.provider || 'openai'} · ${this.agent.model || 'auto'}`,
2469
2851
  });
2470
- return this.agent.ask(questionForLlm, sys, history);
2852
+ // What went into the prompt, piece by piece. The device list is only counted: the model's opening
2853
+ // step carries the whole system prompt, and with it the list.
2854
+ const deviceLines = ctx ? ctx.split('\n').length - 1 : 0;
2855
+ this.journal?.step({
2856
+ stage: 'context',
2857
+ title: `Prompt: ${deviceLines} devices${mem ? ', memory' : ''}${weather ? ', weather' : ''}${followUp ? ', follow-up marker' : ''}`,
2858
+ data: {
2859
+ systemPrompt: this.config.systemPrompt || '',
2860
+ followUpHint: followUp || undefined,
2861
+ memory: mem || undefined,
2862
+ devices: deviceLines,
2863
+ time,
2864
+ weather: weather || undefined,
2865
+ },
2866
+ });
2867
+ try {
2868
+ return await this.agent.ask(questionForLlm, sys, history);
2869
+ }
2870
+ catch (e) {
2871
+ // Offline, quota, outage: the local model is the fallback — unless it was already asked above.
2872
+ if (useLocal && !localFirst) {
2873
+ this.log.warn(`Cloud LLM failed (${e.message}) — asking the local LLM instead.`);
2874
+ this.journal?.step({
2875
+ stage: 'localLlm',
2876
+ level: 'warn',
2877
+ title: `Cloud LLM failed (${e.message}) — asking the local LLM instead`,
2878
+ });
2879
+ const ans = await this.askLocalLlm(question, weather, source);
2880
+ if (ans) {
2881
+ return ans;
2882
+ }
2883
+ }
2884
+ throw e;
2885
+ }
2886
+ }
2887
+ /**
2888
+ * Ask the local model, bounded by `localLlmTimeoutSec`. Returns its answer, or null when it handed off,
2889
+ * timed out or failed — the caller then goes on to the next tier.
2890
+ */
2891
+ async askLocalLlm(question, weather, source) {
2892
+ if (!this.localLlm) {
2893
+ return null;
2894
+ }
2895
+ const timeoutMs = Math.max(1, Number(this.config.localLlmTimeoutSec) || LOCAL_LLM_TIMEOUT_SEC) * 1000;
2896
+ const name = this.localLlmName();
2897
+ // The local model has no tools: without these lines it would invent the weather and guess who is
2898
+ // at home.
2899
+ const facts = [weather, this.buildPresenceContext()].filter(Boolean).join('\n\n');
2900
+ const prompt = facts ? `${facts}\n\n${question}` : question;
2901
+ const busy = this.localLlm.running;
2902
+ const data = { model: name, timeoutMs, system: this.localLlm.systemPrompt(), prompt };
2903
+ const start = Date.now();
2904
+ try {
2905
+ const ans = await this.localLlm.ask(prompt, timeoutMs);
2906
+ const ms = Date.now() - start;
2907
+ if (ans && !(0, localLlm_1.isHandoff)(ans)) {
2908
+ this.log.info(`Answered by local LLM (source='${source}').`);
2909
+ this.journal?.note({ tier: 'localLlm', detail: name });
2910
+ this.journal?.step({
2911
+ stage: 'localLlm',
2912
+ title: `Local LLM ${name}: answered`,
2913
+ ms,
2914
+ data: { ...data, answer: ans },
2915
+ });
2916
+ return ans;
2917
+ }
2918
+ this.log.debug('Local LLM handed off.');
2919
+ this.journal?.step({
2920
+ stage: 'localLlm',
2921
+ title: busy
2922
+ ? `Local LLM ${name}: busy with another question → handed off`
2923
+ : `Local LLM ${name}: handed off`,
2924
+ ms,
2925
+ data: { ...data, answer: ans },
2926
+ });
2927
+ }
2928
+ catch (e) {
2929
+ this.log.info(`Local LLM gave no answer: ${e.message}`);
2930
+ this.journal?.step({
2931
+ stage: 'localLlm',
2932
+ level: 'warn',
2933
+ title: `Local LLM ${name}: ${e.message}`,
2934
+ ms: Date.now() - start,
2935
+ data: { ...data, error: (0, journal_1.errorInfo)(e) },
2936
+ });
2937
+ }
2938
+ return null;
2471
2939
  }
2472
2940
  /** Reply when nothing local understood the request and there is no cloud LLM (provider "none"). */
2473
2941
  localOnlyFallback() {
@@ -2578,35 +3046,27 @@ class Assistant extends adapter_core_1.Adapter {
2578
3046
  else {
2579
3047
  // Switched off: do not keep what was recorded while it was on.
2580
3048
  await fs.promises.rm(file, { force: true }).catch(() => { });
3049
+ await fs.promises.rm((0, journal_1.traceDirFor)(file), { recursive: true, force: true }).catch(() => { });
2581
3050
  }
2582
3051
  }
2583
- /** Wrap the MCP connection so every state the pipeline reads or writes lands in the journal entry. */
3052
+ /**
3053
+ * Wrap the MCP connection so every state the pipeline reads or writes lands in the journal entry — and a
3054
+ * call the NLU makes directly, with its result, in the request's trace.
3055
+ */
2584
3056
  journalMcp(mcp) {
2585
3057
  return {
2586
3058
  listTools: () => mcp.listTools(),
2587
- callTool: (name, args) => {
2588
- this.journal?.stateAccess(name, args);
2589
- return mcp.callTool(name, args);
2590
- },
3059
+ callTool: (name, args) => this.journal
3060
+ ? this.journal.stateCall(name, args, () => mcp.callTool(name, args))
3061
+ : mcp.callTool(name, args),
2591
3062
  close: () => mcp.close(),
2592
3063
  };
2593
3064
  }
2594
- /** Wrap an LLM tool so its calls (arguments, result, duration) land in the journal entry. */
3065
+ /** Wrap an LLM tool so its calls (arguments, result, duration) land in the journal entry and its trace. */
2595
3066
  journalTool(tool) {
2596
3067
  return {
2597
3068
  ...tool,
2598
- run: async (args) => {
2599
- const start = Date.now();
2600
- try {
2601
- const result = await tool.run(args);
2602
- this.journal?.tool(tool.name, args, Date.now() - start, result);
2603
- return result;
2604
- }
2605
- catch (e) {
2606
- this.journal?.tool(tool.name, args, Date.now() - start, undefined, e.message);
2607
- throw e;
2608
- }
2609
- },
3069
+ run: args => (this.journal ? this.journal.toolCall(tool.name, args, () => tool.run(args)) : tool.run(args)),
2610
3070
  };
2611
3071
  }
2612
3072
  /** Short name of the local model for the journal ("qwen2.5-1.5b-instruct-q4_k_m"). */
@@ -2704,7 +3164,7 @@ class Assistant extends adapter_core_1.Adapter {
2704
3164
  dataDir,
2705
3165
  modelUrl: (this.config.localLlmModelUrl || '').trim() || localLlm_1.DEFAULT_LOCAL_MODEL_URL,
2706
3166
  systemPrompt: this.config.systemPrompt || '',
2707
- maxTokens: this.config.maxTokens || 512,
3167
+ maxTokens: Math.min(this.config.maxTokens || LOCAL_LLM_MAX_TOKENS, LOCAL_LLM_MAX_TOKENS),
2708
3168
  log: this.log,
2709
3169
  });
2710
3170
  this.lastLlmPct = -1;
@@ -2737,20 +3197,56 @@ class Assistant extends adapter_core_1.Adapter {
2737
3197
  }
2738
3198
  // Timer intents match device-independently (parse() checks them first), so build the NLU even
2739
3199
  // when no devices are known and always try timers before bailing out.
3200
+ const start = Date.now();
2740
3201
  const { rooms, devices } = await this.getNluDevices();
2741
- const intents = new nlu_1.Nlu(rooms, devices, this.getNluAliases()).parseAll(question);
2742
- if (!intents.length || !intents.every(i => this.canExecuteNlu(i, devices.length))) {
3202
+ const aliases = this.getNluAliases();
3203
+ const intents = new nlu_1.Nlu(rooms, devices, aliases, {
3204
+ weather: !!(this.config.weatherInstance || '').trim(),
3205
+ }).parseAll(question);
3206
+ const blockers = intents.map(i => this.nluBlocker(i, devices.length));
3207
+ this.journal?.step({
3208
+ stage: 'nlu',
3209
+ title: !intents.length
3210
+ ? `NLU: nothing recognised (${devices.length} devices, ${rooms.length} rooms known)`
3211
+ : blockers.some(Boolean)
3212
+ ? `NLU: ${intents.map(i => i.action).join(' + ')} — cannot run here: ${blockers.filter(Boolean).join('; ')}`
3213
+ : `NLU: ${intents.map(describeIntent).join(' + ')}`,
3214
+ level: intents.length && blockers.some(Boolean) ? 'warn' : undefined,
3215
+ ms: Date.now() - start,
3216
+ data: {
3217
+ devices: devices.length,
3218
+ rooms,
3219
+ aliases: aliases.length ? aliases : undefined,
3220
+ intents: intents.map((intent, i) => ({ ...intent, blockedBecause: blockers[i] || undefined })),
3221
+ },
3222
+ });
3223
+ if (!intents.length || blockers.some(Boolean)) {
2743
3224
  return null; // nothing matched, or one part cannot run here → let the LLM answer the whole thing
2744
3225
  }
2745
3226
  this.journal?.note({ tier: 'nlu', detail: intents.map(i => i.action).join(' + ') });
2746
3227
  const answers = [];
2747
3228
  let failed = false;
2748
3229
  for (const intent of intents) {
3230
+ const started = Date.now();
2749
3231
  try {
2750
- answers.push(await this.executeNluIntent(intent, source));
3232
+ const answer = await this.executeNluIntent(intent, source);
3233
+ answers.push(answer);
3234
+ this.journal?.step({
3235
+ stage: 'nlu',
3236
+ title: `Executed ${describeIntent(intent)}`,
3237
+ ms: Date.now() - started,
3238
+ data: { answer },
3239
+ });
2751
3240
  }
2752
3241
  catch (e) {
2753
3242
  failed = true;
3243
+ this.journal?.step({
3244
+ stage: 'nlu',
3245
+ level: 'error',
3246
+ title: `${describeIntent(intent)} failed: ${e.message}${answers.length ? '' : ' → the LLM takes over'}`,
3247
+ ms: Date.now() - started,
3248
+ data: (0, journal_1.errorInfo)(e),
3249
+ });
2754
3250
  // Nothing executed yet → hand the whole utterance to the LLM. Afterwards a state has already
2755
3251
  // been written and the LLM would repeat it, so report the failed part instead of bailing out.
2756
3252
  if (!answers.length) {
@@ -2773,35 +3269,44 @@ class Assistant extends adapter_core_1.Adapter {
2773
3269
  intents.every(i => CONTROL_ACTIONS.has(i.action)) &&
2774
3270
  (await this.playConfirmationTone(source))) {
2775
3271
  this.log.debug(`NLU confirmed with a tone instead of "${answer}".`);
3272
+ this.journal?.step({ stage: 'nlu', title: 'Confirmed with a tone instead of words', data: { answer } });
2776
3273
  return '';
2777
3274
  }
2778
3275
  return answer;
2779
3276
  }
2780
3277
  /**
2781
- * Can this intent run locally at all? Checked for **every** part of a combined command before anything
2782
- * is executed: a missing manager, no known devices or disabled writes send the whole utterance to the
2783
- * LLM (which can explain why) instead of silently doing nothing — or only half of it.
3278
+ * Why this intent cannot run locally ('' = it can). Checked for **every** part of a combined command
3279
+ * before anything is executed: a missing manager, no known devices or disabled writes send the whole
3280
+ * utterance to the LLM (which can explain why) instead of silently doing nothing — or only half of it.
2784
3281
  */
2785
- canExecuteNlu(intent, deviceCount) {
3282
+ nluBlocker(intent, deviceCount) {
2786
3283
  switch (intent.action) {
2787
3284
  case 'timerSet':
2788
3285
  case 'timerQuery':
2789
3286
  case 'timerCancel':
2790
- return !!this.timers;
3287
+ return this.timers ? '' : 'timers are not running';
2791
3288
  case 'alarmSet':
2792
3289
  case 'alarmQuery':
2793
3290
  case 'alarmCancel':
2794
- return !!this.alarms;
3291
+ return this.alarms ? '' : 'alarms are not running';
2795
3292
  case 'timeQuery':
2796
3293
  case 'dateQuery':
2797
- return true;
3294
+ return '';
3295
+ case 'weatherQuery':
3296
+ return (this.config.weatherInstance || '').trim() ? '' : 'no weather instance configured';
3297
+ case 'volumeSet':
3298
+ return this.voice || this.esphome || this.nativeSatFrom.size ? '' : 'no satellites';
2798
3299
  default:
2799
3300
  // Device intents; writes additionally require the coarse "allow control" toggle.
2800
- return (deviceCount > 0 &&
2801
- (!['on', 'off', 'level', 'color'].includes(intent.action) || !!this.config.allowWriteStates));
3301
+ if (!deviceCount) {
3302
+ return 'no devices known';
3303
+ }
3304
+ return ['on', 'off', 'level', 'color'].includes(intent.action) && !this.config.allowWriteStates
3305
+ ? 'writing states is not allowed (allowWriteStates)'
3306
+ : '';
2802
3307
  }
2803
3308
  }
2804
- /** Execute one intent that {@link canExecuteNlu} accepted, and return its spoken answer. */
3309
+ /** Execute one intent that {@link nluBlocker} accepted, and return its spoken answer. */
2805
3310
  async executeNluIntent(intent, source) {
2806
3311
  switch (intent.action) {
2807
3312
  case 'timerSet':
@@ -2815,10 +3320,97 @@ class Assistant extends adapter_core_1.Adapter {
2815
3320
  case 'timeQuery':
2816
3321
  case 'dateQuery':
2817
3322
  return this.executeTimeIntent(intent);
3323
+ case 'weatherQuery':
3324
+ return this.executeWeatherIntent(intent);
3325
+ case 'volumeSet':
3326
+ return this.executeVolumeIntent(intent, source);
2818
3327
  default:
2819
3328
  return this.executeIntent(intent);
2820
3329
  }
2821
3330
  }
3331
+ /**
3332
+ * Speak the weather straight from the configured adapter. Throws when the report has nothing for the
3333
+ * asked day (or the adapter has no mapper), so the question goes on to the LLM and its tool instead.
3334
+ */
3335
+ async executeWeatherIntent(intent) {
3336
+ const res = await this.readWeather(this.config.weatherInstance || '');
3337
+ if (!res.report) {
3338
+ throw new Error(res.error || 'no weather report');
3339
+ }
3340
+ const lang = String(this.config.voiceLanguage || this.language || 'en');
3341
+ const text = (0, weather_1.buildWeatherAnswer)(res.report, lang, intent.when);
3342
+ if (!text) {
3343
+ throw new Error(`the weather report has no data for '${intent.when || 'now'}'`);
3344
+ }
3345
+ return text;
3346
+ }
3347
+ /**
3348
+ * Change the playback volume of a satellite: the one in the named room, else the one that heard the
3349
+ * command. Each transport has its own knob — the native satellite's `volume` state, the media player
3350
+ * of an ESPHome device, the `volume` message of a UDP satellite.
3351
+ */
3352
+ async executeVolumeIntent(intent, source) {
3353
+ const lang = String(this.config.voiceLanguage || this.language || 'en');
3354
+ const satId = intent.room ? this.resolveSatelliteId(intent.room) : this.announceTargetForSource(source);
3355
+ const absolute = typeof intent.value === 'number' ? intent.value : null;
3356
+ const delta = intent.delta || 0;
3357
+ const next = (current) => Math.min(100, Math.max(0, Math.round(absolute ?? current + delta)));
3358
+ // 1 процент, 2 процента, 5 процентов (11–14 take the last form).
3359
+ const ruPercent = (n) => n % 100 > 10 && n % 100 < 20
3360
+ ? 'процентов'
3361
+ : n % 10 === 1
3362
+ ? 'процент'
3363
+ : n % 10 >= 2 && n % 10 <= 4
3364
+ ? 'процента'
3365
+ : 'процентов';
3366
+ const confirm = (level) => lang === 'ru'
3367
+ ? `Громкость ${level} ${ruPercent(level)}.`
3368
+ : lang === 'de'
3369
+ ? `Lautstärke ${level} Prozent.`
3370
+ : `Volume ${level} percent.`;
3371
+ if (satId) {
3372
+ const device = this.satDeviceById.get(satId) || satId;
3373
+ const instance = this.nativeSatFrom.get(satId);
3374
+ if (instance) {
3375
+ const stateId = `${instance}.volume`;
3376
+ const current = Number((await this.getForeignStateAsync(stateId))?.val ?? 50);
3377
+ const level = next(Number.isFinite(current) ? current : 50);
3378
+ await this.setForeignStateAsync(stateId, level);
3379
+ return confirm(level);
3380
+ }
3381
+ const player = this.esphome?.entities(device).find(e => e.kind === 'mediaPlayer');
3382
+ if (player) {
3383
+ const current = (player.value?.volume ?? 0.5) * 100;
3384
+ const level = next(current);
3385
+ if (this.esphome.setEntity(device, player.objectId, level / 100)) {
3386
+ return confirm(level);
3387
+ }
3388
+ }
3389
+ // A UDP satellite works out a relative step itself (it knows its level) and reports the result.
3390
+ if (this.voice?.has(device)) {
3391
+ this.voice.volume(device, absolute !== null ? { level: absolute } : { delta });
3392
+ if (absolute !== null) {
3393
+ return confirm(next(0));
3394
+ }
3395
+ return lang === 'ru'
3396
+ ? delta > 0
3397
+ ? 'Сделала громче.'
3398
+ : 'Сделала тише.'
3399
+ : lang === 'de'
3400
+ ? delta > 0
3401
+ ? 'Etwas lauter.'
3402
+ : 'Etwas leiser.'
3403
+ : delta > 0
3404
+ ? 'A bit louder.'
3405
+ : 'A bit quieter.';
3406
+ }
3407
+ }
3408
+ return lang === 'ru'
3409
+ ? 'Я не знаю, громкость какой колонки изменить.'
3410
+ : lang === 'de'
3411
+ ? 'Ich weiß nicht, bei welchem Lautsprecher ich die Lautstärke ändern soll.'
3412
+ : "I don't know which speaker's volume to change.";
3413
+ }
2822
3414
  /** Localized "this part did not work" sentence for a failed part of a combined command. */
2823
3415
  nluFailureText(intent) {
2824
3416
  const lang = String(this.config.voiceLanguage || this.language || 'en');
@@ -2868,14 +3460,36 @@ class Assistant extends adapter_core_1.Adapter {
2868
3460
  return [];
2869
3461
  }
2870
3462
  }
2871
- /** Device model for the NLU: friendly name, room, type and controls (controlType → state id). */
2872
- async getNluDevices() {
3463
+ /**
3464
+ * Device model for the NLU: friendly name, room, type and controls (controlType → state id). Cached
3465
+ * for {@link NLU_DEVICES_TTL_MS}; requests arriving while it is being built wait for the same build.
3466
+ */
3467
+ getNluDevices() {
2873
3468
  if (!this.mcp) {
2874
- return { rooms: [], devices: [] };
3469
+ return Promise.resolve({ rooms: [], devices: [] });
2875
3470
  }
2876
3471
  // Match names in the assistant's interaction language (voice language, else system) so a
2877
3472
  // Russian command matches Russian smartNames.
2878
3473
  const nluLang = (this.config.voiceLanguage || this.language || 'en');
3474
+ const now = Date.now();
3475
+ const hit = this.nluDevices;
3476
+ if (hit && hit.key === nluLang && now - hit.at < NLU_DEVICES_TTL_MS) {
3477
+ return hit.result;
3478
+ }
3479
+ const entry = { key: nluLang, at: now, result: this.buildNluDevices(nluLang) };
3480
+ this.nluDevices = entry;
3481
+ // An empty model is usually a failed build (the manager not ready yet) — don't keep it for a minute.
3482
+ void entry.result.then(r => {
3483
+ if (!r.devices.length && this.nluDevices === entry) {
3484
+ this.nluDevices = null;
3485
+ }
3486
+ });
3487
+ return entry.result;
3488
+ }
3489
+ async buildNluDevices(nluLang) {
3490
+ if (!this.mcp) {
3491
+ return { rooms: [], devices: [] };
3492
+ }
2879
3493
  try {
2880
3494
  const res = await this.mcp.callTool('list_devices', { language: nluLang });
2881
3495
  const parsed = JSON.parse(res.text);
@@ -3507,16 +4121,30 @@ class Assistant extends adapter_core_1.Adapter {
3507
4121
  if (!this.config.verifyWrites || !stateId) {
3508
4122
  return true;
3509
4123
  }
3510
- const deadline = Date.now() + ACK_TIMEOUT_MS;
4124
+ const start = Date.now();
4125
+ const deadline = start + ACK_TIMEOUT_MS;
3511
4126
  let last;
3512
4127
  while (Date.now() < deadline) {
3513
4128
  await this.delay(ACK_POLL_MS);
3514
4129
  last = await this.getForeignStateAsync(stateId).catch(() => null);
3515
4130
  if (last?.ack && valuesMatch(last.val, expected)) {
3516
4131
  this.log.debug(`write to ${stateId} confirmed by the device.`);
4132
+ this.journal?.step({
4133
+ stage: 'state',
4134
+ title: `${stateId} confirmed by the device`,
4135
+ ms: Date.now() - start,
4136
+ data: { expected, state: last },
4137
+ });
3517
4138
  return true;
3518
4139
  }
3519
4140
  }
4141
+ this.journal?.step({
4142
+ stage: 'state',
4143
+ level: 'warn',
4144
+ title: `${stateId}: no confirmation within ${ACK_TIMEOUT_MS} ms`,
4145
+ ms: Date.now() - start,
4146
+ data: { expected, state: last },
4147
+ });
3520
4148
  this.log.warn(`No confirmation for ${stateId} within ${ACK_TIMEOUT_MS} ms ` +
3521
4149
  `(last: ${JSON.stringify(last?.val)}, ack=${String(last?.ack)}). ` +
3522
4150
  'If this device never acknowledges, switch "Verify device feedback" off.');
@@ -4729,12 +5357,26 @@ class Assistant extends adapter_core_1.Adapter {
4729
5357
  */
4730
5358
  async runRoutine(routine, source) {
4731
5359
  const origin = this.announceTargetForSource(source);
4732
- for (const action of routine.actions) {
5360
+ for (const [i, action] of routine.actions.entries()) {
5361
+ const start = Date.now();
4733
5362
  try {
4734
5363
  await this.runTriggerAction({ id: `routine:${routine.name}`, when: [] }, action, origin);
5364
+ this.journal?.step({
5365
+ stage: 'routine',
5366
+ title: `Routine action ${i + 1}/${routine.actions.length}`,
5367
+ ms: Date.now() - start,
5368
+ data: action,
5369
+ });
4735
5370
  }
4736
5371
  catch (e) {
4737
5372
  this.log.warn(`Routine '${routine.name}': an action failed — ${e.message}`);
5373
+ this.journal?.step({
5374
+ stage: 'routine',
5375
+ level: 'error',
5376
+ title: `Routine action ${i + 1}/${routine.actions.length} failed: ${e.message}`,
5377
+ ms: Date.now() - start,
5378
+ data: { action, error: (0, journal_1.errorInfo)(e) },
5379
+ });
4738
5380
  }
4739
5381
  }
4740
5382
  }