iobroker.assistant 1.2.0 → 1.3.1

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 (60) hide show
  1. package/README.md +17 -0
  2. package/admin/custom/.vite/manifest.json +18 -18
  3. package/admin/custom/assets/Components-D77NmS_I.js +2 -0
  4. package/admin/custom/assets/{_virtual_mf-localSharedImportMap___mfe_internal__ConfigCustomAssistant__mf_owner__1-C5NPM0Of.js → _virtual_mf-localSharedImportMap___mfe_internal__ConfigCustomAssistant__mf_owner__1-B4YbqhBb.js} +1 -1
  5. package/admin/custom/assets/{_virtual_mf___mfe_internal__ConfigCustomAssistant__mf_owner__163539842885036__loadShare___mf_0_iobroker_mf_1_gui_mf_2_components__loadShare__.js-Cdyjt5KT.js → _virtual_mf___mfe_internal__ConfigCustomAssistant__mf_owner__163539842885036__loadShare___mf_0_iobroker_mf_1_gui_mf_2_components__loadShare__.js-o4EU_Ut5.js} +10 -10
  6. package/admin/custom/assets/{_virtual_mf___mfe_internal__ConfigCustomAssistant__mf_owner__163539842885036__loadShare__shared-Dsw5zWS8.js → _virtual_mf___mfe_internal__ConfigCustomAssistant__mf_owner__163539842885036__loadShare__shared--be2hWbV.js} +1 -1
  7. package/admin/custom/assets/_virtual_mf___mfe_internal__ConfigCustomAssistant__mf_owner__163539842885036__prebuild___mf_0_iobroker_mf_1_json_mf_2_config__prebuild__-BsTz_EWE.js +122 -0
  8. package/admin/custom/assets/{hostInit-jJe8bIfV.js → hostInit-Blh7a-Qe.js} +1 -1
  9. package/admin/custom/assets/{lib-CL8mXvI6.js → lib-DBbNtolG.js} +1 -1
  10. package/admin/custom/assets/{mf-entry-bootstrap-0-ab227aa6.js → mf-entry-bootstrap-0-7abd30d3.js} +2 -2
  11. package/admin/custom/assets/{pendingShares-wrGtfliQ.js → pendingShares-U0D1MYK4.js} +1 -1
  12. package/admin/custom/customComponents.js +2 -2
  13. package/admin/custom/i18n/de.json +16 -0
  14. package/admin/custom/i18n/en.json +16 -0
  15. package/admin/custom/i18n/es.json +16 -0
  16. package/admin/custom/i18n/fr.json +16 -0
  17. package/admin/custom/i18n/it.json +16 -0
  18. package/admin/custom/i18n/nl.json +16 -0
  19. package/admin/custom/i18n/pl.json +16 -0
  20. package/admin/custom/i18n/pt.json +16 -0
  21. package/admin/custom/i18n/ru.json +16 -0
  22. package/admin/custom/i18n/uk.json +16 -0
  23. package/admin/custom/i18n/zh-cn.json +16 -0
  24. package/admin/custom/index.html +2 -2
  25. package/admin/custom/mf-manifest.json +1 -1
  26. package/admin/custom/mf-stats.json +1 -1
  27. package/admin/i18n/de.json +25 -2
  28. package/admin/i18n/en.json +25 -2
  29. package/admin/i18n/es.json +25 -2
  30. package/admin/i18n/fr.json +25 -2
  31. package/admin/i18n/it.json +25 -2
  32. package/admin/i18n/nl.json +25 -2
  33. package/admin/i18n/pl.json +25 -2
  34. package/admin/i18n/pt.json +25 -2
  35. package/admin/i18n/ru.json +25 -2
  36. package/admin/i18n/uk.json +25 -2
  37. package/admin/i18n/zh-cn.json +25 -2
  38. package/admin/jsonConfig.json +197 -13
  39. package/build/lib/credentials.js +11 -5
  40. package/build/lib/credentials.js.map +1 -1
  41. package/build/lib/llm.js +17 -3
  42. package/build/lib/llm.js.map +1 -1
  43. package/build/lib/nlu.js +263 -9
  44. package/build/lib/nlu.js.map +1 -1
  45. package/build/lib/piAgent.js +94 -0
  46. package/build/lib/piAgent.js.map +1 -0
  47. package/build/lib/systemAi.js +124 -0
  48. package/build/lib/systemAi.js.map +1 -0
  49. package/build/lib/voice/engines.js +31 -3
  50. package/build/lib/voice/engines.js.map +1 -1
  51. package/build/lib/voice/esphome.js +3 -0
  52. package/build/lib/voice/esphome.js.map +1 -1
  53. package/build/lib/voice/openrouter.js +220 -0
  54. package/build/lib/voice/openrouter.js.map +1 -0
  55. package/build/main.js +691 -116
  56. package/build/main.js.map +1 -1
  57. package/io-package.json +40 -30
  58. package/package.json +1 -1
  59. package/admin/custom/assets/Components-BSewCBfS.js +0 -2
  60. 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 +0 -122
package/build/main.js CHANGED
@@ -66,6 +66,9 @@ const mediaServer_1 = require("./lib/voice/mediaServer");
66
66
  const wakeWords_1 = require("./lib/voice/wakeWords");
67
67
  const tone_1 = require("./lib/voice/tone");
68
68
  const engines_1 = require("./lib/voice/engines");
69
+ const openrouter_1 = require("./lib/voice/openrouter");
70
+ const piAgent_1 = require("./lib/piAgent");
71
+ const systemAi_1 = require("./lib/systemAi");
69
72
  /**
70
73
  * How long a rendered weather context line stays valid. Weather adapters refresh every few minutes at best,
71
74
  * so re-reading their whole state tree on every single request would be pure overhead.
@@ -78,6 +81,12 @@ const WEATHER_CTX_TTL = 5 * 60 * 1000;
78
81
  * that were added or renamed elsewhere.
79
82
  */
80
83
  const NLU_DEVICES_TTL_MS = 60_000;
84
+ /**
85
+ * How long a question of the cloud model about a script keeps the next request of that source with the cloud model
86
+ * (the reply, e.g. "the kitchen light, every hour", says nothing about a script). As long as pi-agent waits for a
87
+ * confirmation by text.
88
+ */
89
+ const SCRIPT_TOPIC_MS = 2 * 60_000;
81
90
  /** Default for `localLlmTimeoutSec`: after this the local model is stopped and the next tier answers. */
82
91
  const LOCAL_LLM_TIMEOUT_SEC = 15;
83
92
  /**
@@ -89,6 +98,36 @@ const LOCAL_LLM_MAX_TOKENS = 256;
89
98
  * The replies the assistant speaks verbatim, whatever the question was. They are pre-synthesised into
90
99
  * the TTS cache at startup; everything else is cached on first use.
91
100
  */
101
+ /** Fields of the Voice tab the voice test takes from the unsaved form (everything an STT or TTS engine is built from). */
102
+ const VOICE_FORM_FIELDS = [
103
+ 'voiceLanguage',
104
+ 'sttProvider',
105
+ 'sttModel',
106
+ 'openrouterSttModel',
107
+ 'voskModel',
108
+ 'ttsProvider',
109
+ 'ttsVoice',
110
+ 'ttsModel',
111
+ 'voiceCredentialType',
112
+ 'voiceApiKey',
113
+ 'voiceCredentialId',
114
+ 'openrouterSpeechKey',
115
+ 'openrouterCredentialId',
116
+ 'openrouterTtsModel',
117
+ 'openrouterVoice',
118
+ 'azureSpeechKey',
119
+ 'azureSpeechRegion',
120
+ 'azureCredentialId',
121
+ 'azureVoice',
122
+ 'awsAccessKeyId',
123
+ 'awsSecretAccessKey',
124
+ 'awsRegion',
125
+ 'awsCredentialId',
126
+ 'awsVoice',
127
+ 'piperVoice',
128
+ ];
129
+ /** Of those, the protected ones: the browser never gets them, so an empty value there means "keep the saved one" */
130
+ const VOICE_FORM_SECRETS = ['voiceApiKey', 'openrouterSpeechKey', 'azureSpeechKey', 'awsSecretAccessKey'];
92
131
  /** How long to wait for a device to confirm a write, and how often to look. */
93
132
  const ACK_TIMEOUT_MS = 2000;
94
133
  const ACK_POLL_MS = 150;
@@ -197,8 +236,18 @@ function describeIntent(intent) {
197
236
  else if (intent.hour !== undefined) {
198
237
  text += ` ${intent.hour}:${String(intent.minute ?? 0).padStart(2, '0')}`;
199
238
  }
239
+ if (intent.delaySec !== undefined) {
240
+ text += ` in ${intent.delaySec} s`;
241
+ }
242
+ else if (intent.at) {
243
+ text += ` at ${intent.at.day ? `+${intent.at.day}d ` : ''}${intent.at.hour}:${String(intent.at.minute).padStart(2, '0')}`;
244
+ }
200
245
  return text;
201
246
  }
247
+ /** A device command for later ("in 2 Stunden", "um 22 Uhr") — goes into the calendar, not to the device */
248
+ function isDeferred(intent) {
249
+ return intent.delaySec !== undefined || !!intent.at;
250
+ }
202
251
  const FIXED_REPLIES = {
203
252
  de: ['Ok.', 'Erledigt.'],
204
253
  en: ['Okay.', 'Done.'],
@@ -228,6 +277,12 @@ function languageLabel(lang) {
228
277
  class Assistant extends adapter_core_1.Adapter {
229
278
  /** Cloud LLM; `null` with provider "none" (local tiers only) or before `onReady` finished. */
230
279
  agent = null;
280
+ /** Provider "pi-agent": the cloud tier is a pi-agent instance instead of {@link agent}. */
281
+ piAgent = null;
282
+ /** The cloud LLM of the instance (own or system-wide settings); `null` without one. */
283
+ llm = null;
284
+ /** What of `system.ai` this instance depends on, while it takes the system-wide AI settings. */
285
+ systemAiWatch = null;
231
286
  /** Set once `onReady` initialised the pipeline — with or without a cloud {@link agent}. */
232
287
  ready = false;
233
288
  mcp = null;
@@ -330,15 +385,30 @@ class Assistant extends adapter_core_1.Adapter {
330
385
  // alarms, the local model) answer. The ioBroker tools are still built: the NLU switches through them.
331
386
  const provider = cfg.provider || 'openai';
332
387
  const localOnly = provider === 'none';
333
- const apiKey = localOnly ? '' : (0, llm_1.effectiveApiKey)(cfg.provider, await (0, credentials_1.resolveApiKey)(this, cfg));
388
+ // Provider "pi-agent": the adapter pi-agent is the cloud tier, with its own model, key and tools.
389
+ const harness = provider === 'pi-agent';
390
+ // Provider "system" (or no key of its own): provider, model and key of the system-wide AI settings.
391
+ if (!localOnly && !harness && (0, systemAi_1.usesSystemAi)(cfg)) {
392
+ await this.watchSystemAi();
393
+ }
394
+ const resolved = localOnly || harness ? null : await (0, systemAi_1.resolveLlm)(this, cfg);
395
+ this.llm = resolved && !('error' in resolved) ? resolved : null;
396
+ // speech of the same vendor reuses the key of the LLM
397
+ const llmKey = this.llm ? { provider: this.llm.provider, apiKey: this.llm.apiKey } : null;
334
398
  await this.setupJournal();
335
- if (!localOnly && !apiKey) {
336
- this.log.warn(cfg.credentialType === 'manager'
337
- ? 'No credential selected (manager mode) — pick an API-key credential in the adapter settings.'
338
- : 'No API key configured — open the adapter settings and enter your LLM API key.');
399
+ if (harness && !(cfg.piAgentInstance || '').trim()) {
400
+ this.log.warn('Provider "pi-agent" without an instance — choose the pi-agent instance in the adapter settings.');
339
401
  await this.setStateAsync('info.connection', { val: false, ack: true });
340
402
  return;
341
403
  }
404
+ if (resolved && 'error' in resolved) {
405
+ this.log.warn(resolved.error);
406
+ await this.setStateAsync('info.connection', { val: false, ack: true });
407
+ return;
408
+ }
409
+ if (this.llm?.system) {
410
+ this.log.info(`LLM from the system-wide AI settings: ${this.llm.provider} · ${this.llm.model || 'auto'}`);
411
+ }
342
412
  try {
343
413
  this.mcp = this.journalMcp(await (0, mcp_server_1.createInProcessMcp)({
344
414
  adapter: this,
@@ -359,6 +429,9 @@ class Assistant extends adapter_core_1.Adapter {
359
429
  if ((cfg.weatherInstance || '').trim()) {
360
430
  tools.push(this.buildWeatherTool());
361
431
  }
432
+ if ((cfg.calendarInstance || '').trim()) {
433
+ tools.push(...this.buildCalendarTools());
434
+ }
362
435
  if (cfg.voiceEnabled) {
363
436
  // Only useful with satellites: without voice there is nothing to speak on.
364
437
  tools.push(this.buildAnnounceTool());
@@ -382,13 +455,15 @@ class Assistant extends adapter_core_1.Adapter {
382
455
  // Bust the device/room cache when room/function memberships change (new device shows within TTL anyway).
383
456
  this.subscribeForeignObjects('enum.rooms.*');
384
457
  this.subscribeForeignObjects('enum.functions.*');
385
- if (provider !== 'none') {
386
- const prov = (0, llm_1.resolveProvider)(provider, cfg.baseUrl);
458
+ if (harness) {
459
+ this.piAgent = new piAgent_1.PiAgentHarness(this, cfg.piAgentInstance.trim(), Math.max(5, Number(cfg.piAgentTimeoutSec) || piAgent_1.PI_AGENT_TIMEOUT_SEC) * 1000);
460
+ }
461
+ else if (this.llm) {
387
462
  this.agent = new llm_1.LlmAgent({
388
- provider,
389
- apiKey,
390
- model: cfg.model || prov.defaultModel,
391
- baseUrl: prov.baseUrl,
463
+ provider: this.llm.provider,
464
+ apiKey: this.llm.apiKey,
465
+ model: this.llm.model,
466
+ baseUrl: this.llm.baseUrl,
392
467
  systemPrompt: cfg.systemPrompt || '',
393
468
  maxTokens: cfg.maxTokens || 1024,
394
469
  tools: tools.map(t => this.journalTool(t)),
@@ -438,8 +513,10 @@ class Assistant extends adapter_core_1.Adapter {
438
513
  await this.setupPresence();
439
514
  await this.setupTriggers();
440
515
  this.log.info(this.agent
441
- ? `Assistant ready (provider=${cfg.provider}, model=${this.agent.model || 'auto'}).`
442
- : 'Assistant ready without an LLM provider — answering locally only.');
516
+ ? `Assistant ready (provider=${this.llm?.provider}, model=${this.agent.model || 'auto'}).`
517
+ : this.piAgent
518
+ ? `Assistant ready (cloud tier: ${this.piAgent.instance}).`
519
+ : 'Assistant ready without an LLM provider — answering locally only.');
443
520
  if (cfg.useLocalLlm) {
444
521
  void this.ensureLocalLlm(); // background: model load/download must not block onReady
445
522
  }
@@ -448,20 +525,20 @@ class Assistant extends adapter_core_1.Adapter {
448
525
  // Subscribe regardless of the UDP server so writes are always handled (and logged if unroutable).
449
526
  this.subscribeStates('tts.text');
450
527
  if (cfg.udpServerEnabled) {
451
- await this.startVoiceServer(apiKey);
528
+ await this.startVoiceServer(llmKey);
452
529
  }
453
530
  }
454
531
  if (cfg.wyomingEnabled) {
455
- await this.startWyoming(apiKey);
532
+ await this.startWyoming(llmKey);
456
533
  }
457
534
  if (cfg.esphomeEnabled) {
458
- await this.startEsphome(apiKey);
535
+ await this.startEsphome(llmKey);
459
536
  }
460
537
  }
461
538
  /** Start the Wyoming TCP endpoint, bridging to the same STT/answer/TTS pipeline as the UDP server. */
462
- async startWyoming(mainApiKey) {
539
+ async startWyoming(llmKey) {
463
540
  const cfg = this.config;
464
- const creds = await (0, credentials_1.resolveVoiceCredentials)(this, cfg, mainApiKey);
541
+ const creds = await (0, credentials_1.resolveVoiceCredentials)(this, cfg, llmKey);
465
542
  const ctx = this.voiceContext(cfg, creds);
466
543
  const language = cfg.voiceLanguage || this.language || '';
467
544
  let stt;
@@ -502,14 +579,14 @@ class Assistant extends adapter_core_1.Adapter {
502
579
  * linux-voice-assistant). The adapter is the client here — the devices listen on TCP 6053 — and the
503
580
  * spoken reply is handed over as a URL, so the little media server comes up together with them.
504
581
  */
505
- async startEsphome(mainApiKey) {
582
+ async startEsphome(llmKey) {
506
583
  const cfg = this.config;
507
584
  const devices = (cfg.esphomeDevices || []).filter(d => d?.ip?.trim());
508
585
  if (!devices.length) {
509
586
  this.log.warn('ESPHome satellites are enabled, but no device is configured.');
510
587
  return;
511
588
  }
512
- const creds = await (0, credentials_1.resolveVoiceCredentials)(this, cfg, mainApiKey);
589
+ const creds = await (0, credentials_1.resolveVoiceCredentials)(this, cfg, llmKey);
513
590
  const ctx = this.voiceContext(cfg, creds);
514
591
  const language = cfg.voiceLanguage || this.language || '';
515
592
  let stt;
@@ -597,6 +674,7 @@ class Assistant extends adapter_core_1.Adapter {
597
674
  azure: (cfg.azureVoice || '').trim(),
598
675
  aws: (cfg.awsVoice || '').trim(),
599
676
  piper: (cfg.piperVoice || '').trim(),
677
+ openrouter: (cfg.openrouterVoice || '').trim(),
600
678
  },
601
679
  sttFallback: cfg.sttFallback || '',
602
680
  ttsFallback: cfg.ttsFallback || '',
@@ -605,6 +683,9 @@ class Assistant extends adapter_core_1.Adapter {
605
683
  voskModel: (cfg.voskModel || '').trim(),
606
684
  sttModel: (cfg.sttModel || '').trim(),
607
685
  ttsModel: (cfg.ttsModel || '').trim(),
686
+ openrouterSttModel: (cfg.openrouterSttModel || '').trim(),
687
+ openrouterTtsModel: (cfg.openrouterTtsModel || '').trim(),
688
+ decodeAudio: audio => this.decodeAudioBufferToPcm(audio),
608
689
  };
609
690
  }
610
691
  /**
@@ -612,9 +693,9 @@ class Assistant extends adapter_core_1.Adapter {
612
693
  * AWS cloud, or Vosk / Piper locally); credentials come from the encrypted config fields or the
613
694
  * central credential store (voiceCredentialType).
614
695
  */
615
- async startVoiceServer(mainApiKey) {
696
+ async startVoiceServer(llmKey) {
616
697
  const cfg = this.config;
617
- const creds = await (0, credentials_1.resolveVoiceCredentials)(this, cfg, mainApiKey);
698
+ const creds = await (0, credentials_1.resolveVoiceCredentials)(this, cfg, llmKey);
618
699
  const ctx = this.voiceContext(cfg, creds);
619
700
  // Raw language (e.g. 'de' or 'zh-cn') — each engine normalises it (ISO for cloud, model per lang for local).
620
701
  const language = cfg.voiceLanguage || this.language || '';
@@ -721,6 +802,7 @@ class Assistant extends adapter_core_1.Adapter {
721
802
  azure: cfg.azureVoice,
722
803
  aws: cfg.awsVoice,
723
804
  piper: cfg.piperVoice,
805
+ openrouter: cfg.openrouterVoice,
724
806
  };
725
807
  this.journal?.spoken(source, {
726
808
  ...info,
@@ -740,8 +822,7 @@ class Assistant extends adapter_core_1.Adapter {
740
822
  return this.speechEngines;
741
823
  }
742
824
  const cfg = this.config;
743
- const mainKey = await (0, credentials_1.resolveApiKey)(this, cfg);
744
- const creds = await (0, credentials_1.resolveVoiceCredentials)(this, cfg, mainKey);
825
+ const creds = await (0, credentials_1.resolveVoiceCredentials)(this, cfg, await this.speechLlmKey());
745
826
  const ctx = this.voiceContext(cfg, creds);
746
827
  const language = cfg.voiceLanguage || this.language || '';
747
828
  try {
@@ -788,6 +869,8 @@ class Assistant extends adapter_core_1.Adapter {
788
869
  const sttStart = Date.now();
789
870
  const text = (await engines.stt.transcribe(pcm, rate, language, await this.buildSttHints())).trim();
790
871
  if (!text) {
872
+ const ms = Math.round((pcm.length / 2 / rate) * 1000);
873
+ this.log.info(`Voice Q (${source}): (empty) — speech recognition returned no text for ${ms} ms of audio`);
791
874
  return { text: '', answer: '' };
792
875
  }
793
876
  const answer = await this.answerVoice(text, source, {
@@ -829,8 +912,7 @@ class Assistant extends adapter_core_1.Adapter {
829
912
  const cfg = { ...this.config, ...overrides };
830
913
  const provider = msg.ttsProvider || cfg.ttsProvider || 'openai';
831
914
  const language = msg.language || cfg.voiceLanguage || this.language || '';
832
- const mainKey = await (0, credentials_1.resolveApiKey)(this, this.config);
833
- const creds = await (0, credentials_1.resolveVoiceCredentials)(this, cfg, mainKey);
915
+ const creds = await (0, credentials_1.resolveVoiceCredentials)(this, cfg, await this.speechLlmKey());
834
916
  return await (0, engines_1.listVoices)(provider, this.voiceContext(cfg, creds), language);
835
917
  }
836
918
  catch (e) {
@@ -1745,7 +1827,36 @@ class Assistant extends adapter_core_1.Adapter {
1745
1827
  });
1746
1828
  }
1747
1829
  /** Invalidate the cached device/room/function listings when a room/function enum changes. */
1748
- onObjectChange(id, _obj) {
1830
+ /** Vendor and key of the cloud LLM: speech of the same vendor (OpenAI, OpenRouter) reuses the key. */
1831
+ async speechLlmKey() {
1832
+ const provider = this.config.provider || 'openai';
1833
+ const llm = this.llm ?? (provider === 'none' || provider === 'pi-agent' ? null : await (0, systemAi_1.resolveLlm)(this, this.config));
1834
+ return llm && !('error' in llm) ? { provider: llm.provider, apiKey: llm.apiKey } : null;
1835
+ }
1836
+ /**
1837
+ * Restart when the system-wide AI settings this instance takes change — also when they are still empty, so
1838
+ * setting them up in admin starts the instance. The credential is watched too: a new key needs no new id.
1839
+ */
1840
+ async watchSystemAi() {
1841
+ const native = await (0, systemAi_1.readSystemAi)(this);
1842
+ const credentialId = (0, systemAi_1.systemAiSelection)(native)?.credentialId || '';
1843
+ this.systemAiWatch = { fingerprint: (0, systemAi_1.systemAiFingerprint)(native), credentialId };
1844
+ await this.subscribeForeignObjectsAsync(systemAi_1.SYSTEM_AI_ID);
1845
+ if (credentialId) {
1846
+ await this.subscribeForeignObjectsAsync(credentialId);
1847
+ }
1848
+ }
1849
+ onObjectChange(id, obj) {
1850
+ if (this.systemAiWatch && (id === systemAi_1.SYSTEM_AI_ID || id === this.systemAiWatch.credentialId)) {
1851
+ // A credential has no fingerprint: a change of it is a new key.
1852
+ if (id === systemAi_1.SYSTEM_AI_ID && (0, systemAi_1.systemAiFingerprint)(obj?.native) === this.systemAiWatch.fingerprint) {
1853
+ return;
1854
+ }
1855
+ this.log.info(`The system-wide AI settings changed (${id}) — restarting.`);
1856
+ this.systemAiWatch = null;
1857
+ this.restart();
1858
+ return;
1859
+ }
1749
1860
  if (this.listCache && (id.startsWith('enum.rooms.') || id.startsWith('enum.functions.'))) {
1750
1861
  this.listCache.clear();
1751
1862
  this.log.debug(`list cache cleared (object changed: ${id})`);
@@ -2003,14 +2114,23 @@ class Assistant extends adapter_core_1.Adapter {
2003
2114
  }
2004
2115
  return;
2005
2116
  }
2006
- // STT model dropdown (Vosk): suggest model names for the selected language.
2007
- if (obj.command === 'getSttModels') {
2117
+ // STT/TTS model dropdowns (Vosk, OpenRouter): suggest model names for the selected provider.
2118
+ if (obj.command === 'getSttModels' || obj.command === 'getTtsModels') {
2008
2119
  const msg = (obj.message || {});
2009
2120
  const cfg = this.config;
2010
- const provider = msg.sttProvider || cfg.sttProvider || 'openai';
2011
2121
  const language = msg.language || cfg.voiceLanguage || this.language || '';
2122
+ let models = [];
2123
+ try {
2124
+ models =
2125
+ obj.command === 'getSttModels'
2126
+ ? await (0, engines_1.listSttModels)(msg.sttProvider || cfg.sttProvider || 'openai', language)
2127
+ : await (0, engines_1.listTtsModels)(msg.ttsProvider || cfg.ttsProvider || 'openai');
2128
+ }
2129
+ catch (e) {
2130
+ this.log.warn(`${obj.command} failed: ${e.message}`);
2131
+ }
2012
2132
  if (obj.callback) {
2013
- this.sendTo(obj.from, obj.command, (0, engines_1.listSttModels)(provider, language), obj.callback);
2133
+ this.sendTo(obj.from, obj.command, models, obj.callback);
2014
2134
  }
2015
2135
  return;
2016
2136
  }
@@ -2385,10 +2505,20 @@ class Assistant extends adapter_core_1.Adapter {
2385
2505
  }
2386
2506
  return;
2387
2507
  }
2388
- // Chat: synthesize an answer to speech (WAV) via the configured TTS engine.
2508
+ // Chat and the voice test of the settings: synthesize to speech (WAV) via the configured TTS engine,
2509
+ // or via the unsaved settings of the Voice tab (`config`).
2389
2510
  if (obj.command === 'tts') {
2390
- const { text, language } = (obj.message || {});
2391
- const result = await this.synthesizeToWav(String(text ?? ''), language);
2511
+ const { text, language, config } = (obj.message || {});
2512
+ const cfg = config ? this.withVoiceForm(config) : this.config;
2513
+ const result = await this.synthesizeToWav(String(text ?? ''), language, cfg);
2514
+ if (obj.callback) {
2515
+ this.sendTo(obj.from, obj.command, result, obj.callback);
2516
+ }
2517
+ return;
2518
+ }
2519
+ // Voice test of the settings: recognize a recording with the (unsaved) speech-to-text settings.
2520
+ if (obj.command === 'stt') {
2521
+ const result = await this.transcribeForTest((obj.message || {}));
2392
2522
  if (obj.callback) {
2393
2523
  this.sendTo(obj.from, obj.command, result, obj.callback);
2394
2524
  }
@@ -2567,7 +2697,8 @@ class Assistant extends adapter_core_1.Adapter {
2567
2697
  data: {
2568
2698
  provider,
2569
2699
  fallback: cfg.sttFallback || undefined,
2570
- model: (cfg.sttModel || cfg.voskModel || '').trim() || undefined,
2700
+ model: (provider === 'openrouter' ? cfg.openrouterSttModel : cfg.sttModel || cfg.voskModel || '').trim() ||
2701
+ undefined,
2571
2702
  language: cfg.voiceLanguage || this.language || '',
2572
2703
  audioMs: origin.audioMs,
2573
2704
  wakeWord: origin.wakeWord,
@@ -2586,30 +2717,89 @@ class Assistant extends adapter_core_1.Adapter {
2586
2717
  data: history,
2587
2718
  });
2588
2719
  }
2589
- const raw = await this.produceAnswer(question, history, source);
2720
+ const cloudOnly = this.cloudOnlyReason(question, source);
2721
+ const raw = await this.produceAnswer(question, history, source, cloudOnly);
2590
2722
  // The LLM signals "I'm waiting for the user's reply" by appending the [[LISTEN]] control marker
2591
2723
  // (see followUpHint). Strip it here so it's neither stored, shown nor spoken, and keep it per source:
2592
2724
  // two satellites answered at the same time must not hand each other their mic-on.
2593
2725
  const followUp = /\[\[LISTEN\]\]/i.test(raw);
2594
2726
  this.followUps.set(source, followUp);
2727
+ // A script conversation goes on while the cloud model asks back: the reply goes to it as well.
2728
+ if (cloudOnly && followUp) {
2729
+ this.scriptTopics.set(source, Date.now());
2730
+ }
2731
+ else {
2732
+ this.scriptTopics.delete(source);
2733
+ }
2595
2734
  if (followUp) {
2596
2735
  this.journal?.note({ followUp: true });
2597
2736
  }
2598
- const result = raw.replace(/\s*\[\[LISTEN\]\]\s*/gi, ' ').trim();
2737
+ let result = raw.replace(/\s*\[\[(?:LISTEN|DONE)\]\]\s*/gi, ' ').trim();
2738
+ // The LLM answers a switch command that worked with [[DONE]] alone (see doneHint): a beep on the satellite
2739
+ // instead of a sentence. Where nothing can beep (chat, Telegram), a short word. With more text, that is said.
2740
+ let toned = false;
2741
+ if (!result && /\[\[DONE\]\]/i.test(raw)) {
2742
+ toned = await this.playConfirmationTone(source);
2743
+ if (!toned) {
2744
+ result = this.doneText();
2745
+ }
2746
+ }
2599
2747
  this.journal?.step({
2600
2748
  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 },
2749
+ title: toned
2750
+ ? 'Confirmed with a tone instead of words'
2751
+ : result
2752
+ ? `Answer: ${result.length} characters${followUp ? ', waits for a reply' : ''}`
2753
+ : 'No spoken answer',
2754
+ data: { answer: result, raw: raw !== result ? raw : undefined, followUp, tone: toned || undefined },
2605
2755
  });
2606
- if (useCtx && result) {
2607
- this.context.add(source, question, result);
2756
+ if (useCtx && (result || toned)) {
2757
+ this.context.add(source, question, result || this.doneText());
2608
2758
  }
2609
2759
  return result;
2610
2760
  }
2761
+ /**
2762
+ * System-prompt instruction for `confirmWithTone`: a switch command that worked is answered with [[DONE]]
2763
+ * alone, which becomes a beep on the satellite. Empty when the option (or voice) is off.
2764
+ */
2765
+ doneHint() {
2766
+ if (!this.config.confirmWithTone || !this.config.voiceEnabled) {
2767
+ return '';
2768
+ }
2769
+ const lang = String(this.config.voiceLanguage || this.language || 'en');
2770
+ if (lang === 'ru') {
2771
+ return 'Если запрос был только командой включить, выключить или установить устройства на значение и всё получилось, ответь только управляющим маркером [[DONE]] (вместо фразы прозвучит короткий сигнал). На вопросы, при ошибках или если нужно что-то сообщить, отвечай как обычно.';
2772
+ }
2773
+ if (lang === 'de') {
2774
+ return 'Wenn die Anfrage nur ein Befehl war, Geräte ein- oder auszuschalten oder auf einen Wert zu stellen, und alles geklappt hat, antworte ausschließlich mit dem Steuer-Marker [[DONE]] (statt eines Satzes ertönt ein kurzer Bestätigungston). Bei Fragen, bei Fehlern oder wenn du etwas mitteilen musst, antworte normal.';
2775
+ }
2776
+ return 'If the request was only a command to switch devices on or off or to set them to a value, and everything worked, answer with the control marker [[DONE]] alone (a short beep plays instead of a sentence). For questions, errors or when you have something to tell, answer normally.';
2777
+ }
2778
+ /** What [[DONE]] says where no beep can be played */
2779
+ doneText() {
2780
+ const lang = String(this.config.voiceLanguage || this.language || 'en');
2781
+ return lang === 'ru' ? 'Готово.' : lang === 'de' ? 'Erledigt.' : 'Done.';
2782
+ }
2611
2783
  /** Per source: whether its last {@link answer} asked the user something (LLM-signalled via [[LISTEN]]). */
2612
2784
  followUps = new Map();
2785
+ /** Per source: when the cloud model last asked back about a script (see {@link cloudOnlyReason}). */
2786
+ scriptTopics = new Map();
2787
+ /**
2788
+ * Whether a request skips routines, YouTube, the rule NLU and the local model: when it is about a script, or answers
2789
+ * a question the cloud model asked about one. Writing or changing a script is work for the cloud model, and the
2790
+ * rules would read its details as commands of their own ("… that switches the kitchen light every hour" was answered
2791
+ * with the time). Without a cloud tier the local ones are all there is, so nothing is skipped.
2792
+ */
2793
+ cloudOnlyReason(question, source) {
2794
+ if (!this.agent && !this.piAgent) {
2795
+ return null;
2796
+ }
2797
+ if ((0, nlu_1.mentionsScript)(question)) {
2798
+ return 'script';
2799
+ }
2800
+ const since = this.scriptTopics.get(source);
2801
+ return since !== undefined && Date.now() - since < SCRIPT_TOPIC_MS ? 'scriptReply' : null;
2802
+ }
2613
2803
  /**
2614
2804
  * Should the satellite behind `source` re-open its mic after this reply? Primarily the LLM's own
2615
2805
  * [[LISTEN]] signal, with a question mark in the reply as the fallback (the NLU and the local tiers
@@ -2700,7 +2890,72 @@ class Assistant extends adapter_core_1.Adapter {
2700
2890
  const time = new Intl.DateTimeFormat(locale, { hour: '2-digit', minute: '2-digit' }).format(now);
2701
2891
  return lang === 'ru' ? `Сейчас ${time}.` : lang === 'de' ? `Es ist ${time} Uhr.` : `It's ${time}.`;
2702
2892
  }
2703
- async produceAnswer(question, history, source = '') {
2893
+ /**
2894
+ * The tiers that answer without a model: routines, YouTube and the rule NLU. Null when none of them understood the
2895
+ * request.
2896
+ */
2897
+ async answerLocally(question, source) {
2898
+ // A routine is a macro the user wrote down, so it wins over everything that interprets: the NLU
2899
+ // would find "Licht" in "Gute Nacht, Licht aus" and do half of it, and the LLM would cost a
2900
+ // round-trip for a decision that has already been made.
2901
+ const routine = (0, routines_1.matchRoutine)(question, this.routines);
2902
+ if (routine) {
2903
+ this.log.info(`Routine '${routine.name}' triggered (source='${source}').`);
2904
+ this.journal?.note({ tier: 'routine', detail: routine.name });
2905
+ this.journal?.step({ stage: 'routine', title: `Routine "${routine.name}" matched`, data: routine });
2906
+ await this.runRoutine(routine, source);
2907
+ return routine.reply;
2908
+ }
2909
+ if (this.routines.length) {
2910
+ this.journal?.step({
2911
+ stage: 'routine',
2912
+ title: `No routine matched (${this.routines.length} configured)`,
2913
+ data: { phrases: this.routines.map(r => ({ name: r.name, phrases: r.phrases })) },
2914
+ });
2915
+ }
2916
+ // Tier 0: rule-based NLU (device commands) — fastest, offline, free.
2917
+ if (!this.config.useLocalNlu) {
2918
+ this.journal?.step({ stage: 'nlu', title: 'Rule-based NLU is switched off (useLocalNlu)' });
2919
+ return null;
2920
+ }
2921
+ // "Spiele den Kanal X ab" first: the device NLU would find the TV in "… auf dem Fernseher" and
2922
+ // answer with its power state.
2923
+ const yt = this.youtubePlayers.length ? (0, youtube_1.parseYouTubeCommand)(question, this.youtubePlayers) : null;
2924
+ if (yt) {
2925
+ this.log.info(`YouTube request understood locally: ${yt.kind} "${yt.query}" (source='${source}').`);
2926
+ this.journal?.note({ tier: 'nlu', detail: `youtube ${yt.kind}` });
2927
+ const start = Date.now();
2928
+ const res = await this.playYouTube(yt.query, yt.kind, yt.player);
2929
+ this.journal?.step({
2930
+ stage: 'youtube',
2931
+ title: `YouTube ${yt.kind} "${yt.query}"${yt.player ? ` on "${yt.player}"` : ''}`,
2932
+ ms: Date.now() - start,
2933
+ data: { parsed: yt, result: res },
2934
+ });
2935
+ return res.text;
2936
+ }
2937
+ if (this.youtubePlayers.length) {
2938
+ this.journal?.step({ stage: 'youtube', title: 'Not a YouTube command' });
2939
+ }
2940
+ try {
2941
+ const handled = await this.tryLocalNlu(question, source);
2942
+ if (handled !== null) {
2943
+ this.log.info(`Answered by rule-based NLU (source='${source}').`);
2944
+ return handled;
2945
+ }
2946
+ }
2947
+ catch (e) {
2948
+ this.log.debug(`NLU skipped: ${e.message}`);
2949
+ this.journal?.step({
2950
+ stage: 'nlu',
2951
+ level: 'warn',
2952
+ title: `NLU gave up, the request goes on: ${e.message}`,
2953
+ data: (0, journal_1.errorInfo)(e),
2954
+ });
2955
+ }
2956
+ return null;
2957
+ }
2958
+ async produceAnswer(question, history, source = '', cloudOnly = null) {
2704
2959
  this.log.debug(`answer: source='${source}', context=${history.length} turn(s)`);
2705
2960
  // A ringing timer/alarm is silenced by a stop word ("stop"/"halt"/"aufhören"/…) — checked first so
2706
2961
  // it always wins over the normal pipeline while something is ringing.
@@ -2728,64 +2983,22 @@ class Assistant extends adapter_core_1.Adapter {
2728
2983
  return '';
2729
2984
  }
2730
2985
  }
2731
- // A routine is a macro the user wrote down, so it wins over everything that interprets: the NLU
2732
- // would find "Licht" in "Gute Nacht, Licht aus" and do half of it, and the LLM would cost a
2733
- // round-trip for a decision that has already been made.
2734
- const routine = (0, routines_1.matchRoutine)(question, this.routines);
2735
- if (routine) {
2736
- this.log.info(`Routine '${routine.name}' triggered (source='${source}').`);
2737
- this.journal?.note({ tier: 'routine', detail: routine.name });
2738
- this.journal?.step({ stage: 'routine', title: `Routine "${routine.name}" matched`, data: routine });
2739
- await this.runRoutine(routine, source);
2740
- return routine.reply;
2741
- }
2742
- if (this.routines.length) {
2986
+ if (cloudOnly) {
2987
+ const why = cloudOnly === 'script'
2988
+ ? 'it is about a script'
2989
+ : 'it answers a question of the cloud model about a script';
2990
+ this.log.info(`Routines, NLU and local LLM skipped (source='${source}'): ${why}.`);
2743
2991
  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 })) },
2992
+ stage: 'nlu',
2993
+ title: `Routines, NLU and local LLM skipped: ${why}`,
2994
+ data: { reason: cloudOnly },
2747
2995
  });
2748
2996
  }
2749
- // Tier 0: rule-based NLU (device commands) — fastest, offline, free.
2750
- if (this.config.useLocalNlu) {
2751
- // "Spiele den Kanal X ab" first: the device NLU would find the TV in "… auf dem Fernseher" and
2752
- // answer with its power state.
2753
- const yt = this.youtubePlayers.length ? (0, youtube_1.parseYouTubeCommand)(question, this.youtubePlayers) : null;
2754
- if (yt) {
2755
- this.log.info(`YouTube request understood locally: ${yt.kind} "${yt.query}" (source='${source}').`);
2756
- this.journal?.note({ tier: 'nlu', detail: `youtube ${yt.kind}` });
2757
- const start = Date.now();
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
- });
2765
- return res.text;
2766
- }
2767
- if (this.youtubePlayers.length) {
2768
- this.journal?.step({ stage: 'youtube', title: 'Not a YouTube command' });
2769
- }
2770
- try {
2771
- const handled = await this.tryLocalNlu(question, source);
2772
- if (handled !== null) {
2773
- this.log.info(`Answered by rule-based NLU (source='${source}').`);
2774
- return handled;
2775
- }
2776
- }
2777
- catch (e) {
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
- });
2785
- }
2786
- }
2787
2997
  else {
2788
- this.journal?.step({ stage: 'nlu', title: 'Rule-based NLU is switched off (useLocalNlu)' });
2998
+ const local = await this.answerLocally(question, source);
2999
+ if (local !== null) {
3000
+ return local;
3001
+ }
2789
3002
  }
2790
3003
  // Current weather from the configured adapter — prepended to the user turn of BOTH LLM tiers so
2791
3004
  // "how's the weather?" is answered from real data (the local model has no tools and would otherwise
@@ -2799,9 +3012,11 @@ class Assistant extends adapter_core_1.Adapter {
2799
3012
  // so it would answer a follow-up like "yes" context-free and swallow it — let the cloud LLM (which has
2800
3013
  // the thread and the tools) handle follow-ups instead. Without a cloud LLM there is nobody to hand
2801
3014
  // follow-ups to, so the local model takes them too.
2802
- const useLocal = !!(this.config.useLocalLlm && this.localLlm);
2803
- const localFirst = useLocal && (!this.agent || !!this.config.localLlmFirst);
2804
- if (localFirst && (!history.length || !this.agent)) {
3015
+ // A script request does not even fall back to it: a model without tools cannot write a script.
3016
+ const useLocal = !cloudOnly && !!(this.config.useLocalLlm && this.localLlm);
3017
+ const cloud = !!(this.agent || this.piAgent);
3018
+ const localFirst = useLocal && (!cloud || !!this.config.localLlmFirst);
3019
+ if (localFirst && (!history.length || !cloud)) {
2805
3020
  const ans = await this.askLocalLlm(question, weather, source);
2806
3021
  if (ans) {
2807
3022
  return ans;
@@ -2822,6 +3037,9 @@ class Assistant extends adapter_core_1.Adapter {
2822
3037
  }
2823
3038
  // Tier 2: cloud LLM with full tool access. Inject a compact device list into the system prompt so
2824
3039
  // the model can act without a first `list_devices` round-trip (kept cached via prompt caching).
3040
+ if (this.piAgent) {
3041
+ return this.askPiAgent(this.piAgent, question, weather, source, useLocal && !localFirst);
3042
+ }
2825
3043
  if (!this.agent) {
2826
3044
  // Provider "none": no tier further down. Say so instead of failing — over voice an error is
2827
3045
  // read out as a technical message.
@@ -2838,7 +3056,8 @@ class Assistant extends adapter_core_1.Adapter {
2838
3056
  const mem = this.buildMemoryContext();
2839
3057
  // Only ask the model to emit the follow-up marker when satellites can actually re-open the mic.
2840
3058
  const followUp = this.config.voiceEnabled ? this.followUpHint() : '';
2841
- const parts = [this.config.systemPrompt || '', followUp, mem, ctx].filter(Boolean);
3059
+ const done = this.doneHint();
3060
+ const parts = [this.config.systemPrompt || '', followUp, done, mem, ctx].filter(Boolean);
2842
3061
  const sys = parts.length ? parts.join('\n\n') : undefined;
2843
3062
  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…`);
2844
3063
  // Prepend the current date/time (and the weather line, if a source is configured) to the user turn —
@@ -2847,7 +3066,7 @@ class Assistant extends adapter_core_1.Adapter {
2847
3066
  const questionForLlm = [time, weather, question].filter(Boolean).join('\n\n');
2848
3067
  this.journal?.note({
2849
3068
  tier: 'cloud',
2850
- detail: `${this.config.provider || 'openai'} · ${this.agent.model || 'auto'}`,
3069
+ detail: `${this.llm?.provider || this.config.provider || 'openai'} · ${this.agent.model || 'auto'}`,
2851
3070
  });
2852
3071
  // What went into the prompt, piece by piece. The device list is only counted: the model's opening
2853
3072
  // step carries the whole system prompt, and with it the list.
@@ -2858,6 +3077,7 @@ class Assistant extends adapter_core_1.Adapter {
2858
3077
  data: {
2859
3078
  systemPrompt: this.config.systemPrompt || '',
2860
3079
  followUpHint: followUp || undefined,
3080
+ doneHint: done || undefined,
2861
3081
  memory: mem || undefined,
2862
3082
  devices: deviceLines,
2863
3083
  time,
@@ -2884,6 +3104,42 @@ class Assistant extends adapter_core_1.Adapter {
2884
3104
  throw e;
2885
3105
  }
2886
3106
  }
3107
+ /**
3108
+ * Cloud tier through the adapter pi-agent: one pi-agent conversation per source. The style, the
3109
+ * follow-up marker, the memory and the device list are the instructions of that conversation (pi-agent
3110
+ * re-configures it only when they change); time, weather and presence change every turn and go in front
3111
+ * of the question. The history stays in pi-agent, so nothing of {@link ConversationStore} is sent.
3112
+ */
3113
+ async askPiAgent(harness, question, weather, source, localFallback) {
3114
+ const followUp = this.config.voiceEnabled ? this.followUpHint() : '';
3115
+ // The device list maps names to state ids, so pi-agent can act without searching first. It is the
3116
+ // first to go when the instructions get too long — pi-agent can still search.
3117
+ const { text: instructions, dropped } = (0, piAgent_1.composeInstructions)([this.config.systemPrompt || '', followUp, this.doneHint()], [this.buildMemoryContext(), await this.buildDeviceContext()]);
3118
+ if (dropped) {
3119
+ this.log.debug(`pi-agent: ${dropped} context block(s) left out, the instructions would be too long.`);
3120
+ }
3121
+ const text = [this.buildTimeContext(), weather, this.buildPresenceContext(), question]
3122
+ .filter(Boolean)
3123
+ .join('\n\n');
3124
+ this.log.info(`Cloud tier ${harness.instance} (source='${source}') — asking pi-agent…`);
3125
+ this.journal?.note({ tier: 'cloud', detail: harness.instance });
3126
+ try {
3127
+ // pi-agent asks its questions to confirm a write ("… Say yes or no.") in this language, not in the system
3128
+ // language: a satellite listening in Russian does not hear a German "Ja"
3129
+ const language = String(this.config.voiceLanguage || this.language || '');
3130
+ return await harness.ask({ text, conversation: source, instructions, language });
3131
+ }
3132
+ catch (e) {
3133
+ if (localFallback) {
3134
+ this.log.warn(`pi-agent failed (${e.message}) — asking the local LLM instead.`);
3135
+ const ans = await this.askLocalLlm(question, weather, source);
3136
+ if (ans) {
3137
+ return ans;
3138
+ }
3139
+ }
3140
+ throw e;
3141
+ }
3142
+ }
2887
3143
  /**
2888
3144
  * Ask the local model, bounded by `localLlmTimeoutSec`. Returns its answer, or null when it handed off,
2889
3145
  * timed out or failed — the caller then goes on to the next tier.
@@ -2974,10 +3230,11 @@ class Assistant extends adapter_core_1.Adapter {
2974
3230
  const room = d.room ? `${d.room}, ` : '';
2975
3231
  lines.push(`- ${d.name} (${room}${d.type}): ${ids.join(', ')}`);
2976
3232
  }
3233
+ const lang = String(this.config.voiceLanguage || this.language || 'en');
3234
+ lines.push(...this.satelliteVolumeLines(lang));
2977
3235
  if (!lines.length) {
2978
3236
  return '';
2979
3237
  }
2980
- const lang = String(this.config.voiceLanguage || this.language || 'en');
2981
3238
  const header = lang === 'ru'
2982
3239
  ? 'Известные устройства — «имя (комната, тип): stateId». Используй эти stateId напрямую с set_state/get_states; вызывай list_devices только если нужного устройства здесь нет.'
2983
3240
  : lang === 'de'
@@ -2985,6 +3242,41 @@ class Assistant extends adapter_core_1.Adapter {
2985
3242
  : 'Known devices — "name (room, type): stateId". Use these stateIds directly with set_state/get_states; only call list_devices if a needed device is missing here.';
2986
3243
  return `${header}\n${lines.join('\n')}`;
2987
3244
  }
3245
+ /**
3246
+ * The playback volume of every voice satellite as a line of the device list. Without it, "Lautstärke auf 50
3247
+ * Prozent" that the rules miss reaches the LLM (or pi-agent) with only the media devices of the room in view, and
3248
+ * the model turns those up instead of the speaker that heard the request. The scales differ per transport.
3249
+ */
3250
+ satelliteVolumeLines(lang) {
3251
+ const label = (satId) => lang === 'ru'
3252
+ ? `Громкость голосового спутника ${satId}`
3253
+ : lang === 'de'
3254
+ ? `Lautstärke des Sprachsatelliten ${satId}`
3255
+ : `Volume of the voice satellite ${satId}`;
3256
+ const lines = [];
3257
+ const seen = new Set();
3258
+ for (const [satId, instance] of this.nativeSatFrom) {
3259
+ seen.add(satId);
3260
+ lines.push(`- ${label(satId)} (${satId}, level.volume 0-100): ${instance}.volume`);
3261
+ }
3262
+ for (const [satId, device] of this.satDeviceById) {
3263
+ if (seen.has(satId)) {
3264
+ continue;
3265
+ }
3266
+ const player = this.esphome?.entities(device).find(e => e.kind === 'mediaPlayer');
3267
+ if (player) {
3268
+ seen.add(satId);
3269
+ const id = `${this.namespace}.satellites.${satId}.controls.${player.objectId}.volume`;
3270
+ lines.push(`- ${label(satId)} (${satId}, level.volume 0-1): ${id}`);
3271
+ }
3272
+ }
3273
+ for (const satId of this.satVolumeEnsured) {
3274
+ if (!seen.has(satId)) {
3275
+ lines.push(`- ${label(satId)} (${satId}, level.volume 0-100): ${this.namespace}.satellites.${satId}.volume`);
3276
+ }
3277
+ }
3278
+ return lines;
3279
+ }
2988
3280
  /**
2989
3281
  * List a port we listen on in the host's used-resources registry (js-controller ≥ 8), so the admin shows it
2990
3282
  * as occupied when someone configures another instance. `common.declareUsedResources` is set because the
@@ -3266,7 +3558,8 @@ class Assistant extends adapter_core_1.Adapter {
3266
3558
  if (this.config.confirmWithTone &&
3267
3559
  answer &&
3268
3560
  !failed &&
3269
- intents.every(i => CONTROL_ACTIONS.has(i.action)) &&
3561
+ // a command for later says when it will run — a beep would hide that
3562
+ intents.every(i => CONTROL_ACTIONS.has(i.action) && !isDeferred(i)) &&
3270
3563
  (await this.playConfirmationTone(source))) {
3271
3564
  this.log.debug(`NLU confirmed with a tone instead of "${answer}".`);
3272
3565
  this.journal?.step({ stage: 'nlu', title: 'Confirmed with a tone instead of words', data: { answer } });
@@ -3301,6 +3594,9 @@ class Assistant extends adapter_core_1.Adapter {
3301
3594
  if (!deviceCount) {
3302
3595
  return 'no devices known';
3303
3596
  }
3597
+ if (isDeferred(intent) && !(this.config.calendarInstance || '').trim()) {
3598
+ return 'a command for later needs a calendar (fullcalendar) instance in the settings';
3599
+ }
3304
3600
  return ['on', 'off', 'level', 'color'].includes(intent.action) && !this.config.allowWriteStates
3305
3601
  ? 'writing states is not allowed (allowWriteStates)'
3306
3602
  : '';
@@ -3325,8 +3621,226 @@ class Assistant extends adapter_core_1.Adapter {
3325
3621
  case 'volumeSet':
3326
3622
  return this.executeVolumeIntent(intent, source);
3327
3623
  default:
3328
- return this.executeIntent(intent);
3624
+ return isDeferred(intent) ? this.executeDeferredIntent(intent) : this.executeIntent(intent);
3625
+ }
3626
+ }
3627
+ /**
3628
+ * Send a message to the calendar instance (fullcalendar). Its answer `{error}` becomes an exception.
3629
+ *
3630
+ * @param command `addEvent`, `listEvents`, `deleteEvent`, …
3631
+ * @param message the parameters
3632
+ */
3633
+ async calendarCall(command, message) {
3634
+ const instance = (this.config.calendarInstance || '').trim().replace(/^system\.adapter\./, '');
3635
+ if (!instance) {
3636
+ throw new Error('No calendar instance configured in the assistant settings');
3637
+ }
3638
+ const alive = await this.getForeignStateAsync(`system.adapter.${instance}.alive`);
3639
+ if (!alive?.val) {
3640
+ throw new Error(`The calendar ${instance} is not running`);
3641
+ }
3642
+ const res = (await this.sendToAsync(instance, command, message, { timeout: 10000 }));
3643
+ if (!res) {
3644
+ throw new Error(`The calendar ${instance} did not answer`);
3645
+ }
3646
+ if (res.error) {
3647
+ throw new Error(res.error);
3648
+ }
3649
+ return res;
3650
+ }
3651
+ /**
3652
+ * Whether the per-device ACL forbids writing this state: an explicit entry, else the default of the device
3653
+ * type (locks are read-only). States outside the device list are not restricted (like `set_state`).
3654
+ */
3655
+ async writeForbidden(stateId) {
3656
+ const { devices } = await this.getNluDevices();
3657
+ const device = devices.find(d => Object.values(d.controls).includes(stateId));
3658
+ if (!device) {
3659
+ return false;
3660
+ }
3661
+ const entry = this.config.deviceAcl?.[(0, tools_1.deviceKey)(Object.values(device.controls))];
3662
+ return entry ? entry.write === false : device.type === 'lock';
3663
+ }
3664
+ /** "22:30", "morgen um 22:30", "30.10. um 7:00" — when a planned command runs, for the spoken answer */
3665
+ describeWhen(time, lang) {
3666
+ const pad = (n) => String(n).padStart(2, '0');
3667
+ const clock = `${time.getHours()}:${pad(time.getMinutes())}`;
3668
+ const now = new Date();
3669
+ const days = Math.round((new Date(time.getFullYear(), time.getMonth(), time.getDate()).getTime() -
3670
+ new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime()) /
3671
+ 86_400_000);
3672
+ const date = `${pad(time.getDate())}.${pad(time.getMonth() + 1)}.`;
3673
+ if (lang === 'ru') {
3674
+ return days === 0 ? `в ${clock}` : days === 1 ? `завтра в ${clock}` : `${date} в ${clock}`;
3675
+ }
3676
+ if (lang === 'de') {
3677
+ return days === 0 ? `um ${clock}` : days === 1 ? `morgen um ${clock}` : `am ${date} um ${clock}`;
3678
+ }
3679
+ return days === 0 ? `at ${clock}` : days === 1 ? `tomorrow at ${clock}` : `on ${date} at ${clock}`;
3680
+ }
3681
+ /**
3682
+ * A device command for later ("Licht in 2 Stunden aus", "выключи свет через 2 часа"): one event in the calendar
3683
+ * (fullcalendar) per write — the calendar shows it, survives restarts and can be cancelled there.
3684
+ */
3685
+ async executeDeferredIntent(intent) {
3686
+ const device = intent.device;
3687
+ if (!device || !intent.stateId) {
3688
+ throw new Error('deferred intent without a device');
3689
+ }
3690
+ const lang = String(this.config.voiceLanguage || this.language || 'en');
3691
+ const pick = (rus, ger, eng) => lang === 'ru' ? rus : lang === 'de' ? ger : eng;
3692
+ const room = intent.room || device.room;
3693
+ const where = room ? ` (${room})` : '';
3694
+ const dev = device.name;
3695
+ if (await this.writeForbidden(intent.stateId)) {
3696
+ return pick(`${dev}${where} только для чтения.`, `${dev}${where} ist schreibgeschützt.`, `${dev}${where} is read-only.`);
3697
+ }
3698
+ let time;
3699
+ if (intent.delaySec !== undefined) {
3700
+ time = new Date(Date.now() + intent.delaySec * 1000);
3701
+ }
3702
+ else {
3703
+ const { hour, minute, day } = intent.at;
3704
+ const now = new Date();
3705
+ time = new Date(now.getFullYear(), now.getMonth(), now.getDate() + (day ?? 0), hour, minute, 0, 0);
3706
+ if (time.getTime() <= now.getTime()) {
3707
+ if (day !== undefined) {
3708
+ return pick(`${this.describeWhen(time, lang)} уже прошло.`, `${this.describeWhen(time, lang).replace(/^um /, '')} ist schon vorbei.`, `${this.describeWhen(time, lang).replace(/^at /, '')} has already passed.`);
3709
+ }
3710
+ time.setDate(time.getDate() + 1); // "um 7 Uhr" after 7 o'clock: tomorrow
3711
+ }
3712
+ }
3713
+ const name = `${dev}${where}`;
3714
+ await this.calendarCall('addEvent', { oid: intent.stateId, value: intent.value, start: time.getTime(), name });
3715
+ // a dimmer with its own switch: the switch goes into the calendar, too
3716
+ if (intent.also) {
3717
+ await this.calendarCall('addEvent', {
3718
+ oid: intent.also.stateId,
3719
+ value: intent.also.value,
3720
+ start: time.getTime(),
3721
+ name,
3722
+ });
3723
+ }
3724
+ this.log.info(`Planned in the calendar: ${intent.stateId} = ${JSON.stringify(intent.value)} at ${time.toString()}`);
3725
+ const when = this.describeWhen(time, lang);
3726
+ switch (intent.action) {
3727
+ case 'on':
3728
+ return pick(`Хорошо, ${when} включу: ${dev}${where}.`, `Okay, ${dev}${where} wird ${when} eingeschaltet.`, `Okay, ${dev}${where} will be turned on ${when}.`);
3729
+ case 'off':
3730
+ return pick(`Хорошо, ${when} выключу: ${dev}${where}.`, `Okay, ${dev}${where} wird ${when} ausgeschaltet.`, `Okay, ${dev}${where} will be turned off ${when}.`);
3731
+ case 'level':
3732
+ return pick(`Хорошо, ${when} поставлю ${dev}${where} на ${String(intent.value)} %.`, `Okay, ${dev}${where} wird ${when} auf ${String(intent.value)} % gestellt.`, `Okay, ${dev}${where} will be set to ${String(intent.value)} % ${when}.`);
3733
+ default:
3734
+ return pick(`Хорошо, запланировано ${when}: ${dev}${where}.`, `Okay, für ${dev}${where} ${when} eingeplant.`, `Okay, planned for ${dev}${where} ${when}.`);
3735
+ }
3736
+ }
3737
+ /**
3738
+ * LLM tools for the calendar (fullcalendar): plan a state change for later, list what is planned, cancel it.
3739
+ * Planning respects the same write rules as `set_state` (the coarse switch and the per-device ACL).
3740
+ */
3741
+ buildCalendarTools() {
3742
+ const fail = (e) => JSON.stringify({ ok: false, error: e.message });
3743
+ const tools = [
3744
+ {
3745
+ name: 'list_scheduled',
3746
+ description: 'List the planned events of the calendar (state changes at a time, also recurring ones), in local time of the house. Optional "days" ahead (default 1 = the next 24 hours) or a "from"/"to" range as local time "YYYY-MM-DDTHH:mm".',
3747
+ parameters: {
3748
+ type: 'object',
3749
+ properties: {
3750
+ days: { type: 'number', description: 'How many days ahead, default 1' },
3751
+ from: { type: 'string', description: 'Start, local time YYYY-MM-DDTHH:mm' },
3752
+ to: { type: 'string', description: 'End, local time YYYY-MM-DDTHH:mm' },
3753
+ },
3754
+ additionalProperties: false,
3755
+ },
3756
+ run: async (args) => {
3757
+ try {
3758
+ const res = await this.calendarCall('listEvents', {
3759
+ days: typeof args.days === 'number' ? args.days : 1,
3760
+ from: args.from,
3761
+ to: args.to,
3762
+ max: 50,
3763
+ });
3764
+ return JSON.stringify({ ok: true, events: res.events });
3765
+ }
3766
+ catch (e) {
3767
+ return fail(e);
3768
+ }
3769
+ },
3770
+ },
3771
+ ];
3772
+ if (!this.config.allowWriteStates) {
3773
+ return tools;
3329
3774
  }
3775
+ tools.push({
3776
+ name: 'schedule_state',
3777
+ description: 'Plan a state change for later in the calendar: write "value" to state "id" after "in_minutes" or at "at" (local time of the house, "YYYY-MM-DDTHH:mm"). Use it for "in 2 hours", "at 10 pm", "tomorrow morning" — never set_state for something meant for later. With "duration_minutes" the state gets "end_value" after that time (for a boolean the opposite by default). Tell the user the planned time.',
3778
+ parameters: {
3779
+ type: 'object',
3780
+ properties: {
3781
+ id: { type: 'string', description: 'State ID' },
3782
+ value: { type: ['boolean', 'number', 'string'], description: 'Value to write' },
3783
+ in_minutes: { type: 'number', description: 'Delay in minutes from now' },
3784
+ at: { type: 'string', description: 'Local time YYYY-MM-DDTHH:mm' },
3785
+ duration_minutes: { type: 'number', description: 'Optional: write end_value this much later' },
3786
+ end_value: { type: ['boolean', 'number', 'string'], description: 'Value after the duration' },
3787
+ name: {
3788
+ type: 'string',
3789
+ description: 'Short title for the calendar, e.g. "Licht Wohnzimmer aus"',
3790
+ },
3791
+ },
3792
+ required: ['id', 'value'],
3793
+ additionalProperties: false,
3794
+ },
3795
+ run: async (args) => {
3796
+ try {
3797
+ const id = typeof args.id === 'string' ? args.id : '';
3798
+ if (await this.writeForbidden(id)) {
3799
+ return JSON.stringify({
3800
+ ok: false,
3801
+ error: `Writing "${id}" is not allowed: the device is read-only in the assistant settings.`,
3802
+ });
3803
+ }
3804
+ if (args.in_minutes === undefined && !args.at) {
3805
+ return JSON.stringify({ ok: false, error: 'Give "in_minutes" or "at"' });
3806
+ }
3807
+ const res = await this.calendarCall('addEvent', {
3808
+ oid: id,
3809
+ value: args.value,
3810
+ in: typeof args.in_minutes === 'number' ? Math.round(args.in_minutes * 60) : undefined,
3811
+ start: args.in_minutes === undefined ? args.at : undefined,
3812
+ duration: typeof args.duration_minutes === 'number'
3813
+ ? Math.round(args.duration_minutes * 60)
3814
+ : undefined,
3815
+ endValue: args.end_value,
3816
+ name: typeof args.name === 'string' ? args.name : undefined,
3817
+ });
3818
+ return JSON.stringify({ ok: true, eventId: res.id, start: res.start });
3819
+ }
3820
+ catch (e) {
3821
+ return fail(e);
3822
+ }
3823
+ },
3824
+ }, {
3825
+ name: 'cancel_scheduled',
3826
+ description: 'Delete a planned event from the calendar, by its "id" from list_scheduled or schedule_state.',
3827
+ parameters: {
3828
+ type: 'object',
3829
+ properties: { id: { type: 'string', description: 'Event ID' } },
3830
+ required: ['id'],
3831
+ additionalProperties: false,
3832
+ },
3833
+ run: async (args) => {
3834
+ try {
3835
+ await this.calendarCall('deleteEvent', { id: typeof args.id === 'string' ? args.id : '' });
3836
+ return JSON.stringify({ ok: true });
3837
+ }
3838
+ catch (e) {
3839
+ return fail(e);
3840
+ }
3841
+ },
3842
+ });
3843
+ return tools;
3330
3844
  }
3331
3845
  /**
3332
3846
  * Speak the weather straight from the configured adapter. Throws when the report has nothing for the
@@ -5916,13 +6430,52 @@ class Assistant extends adapter_core_1.Adapter {
5916
6430
  return { ok: false, error: e.message };
5917
6431
  }
5918
6432
  }
5919
- /** Build the configured TTS engine (throws when no voice key is available). */
5920
- async buildTtsEngine() {
5921
- const cfg = this.config;
5922
- const mainApiKey = (await (0, credentials_1.resolveApiKey)(this, cfg)) || '';
5923
- const creds = await (0, credentials_1.resolveVoiceCredentials)(this, cfg, mainApiKey);
6433
+ /**
6434
+ * The saved configuration with the speech settings of an unsaved form on top. Only the speech fields count,
6435
+ * and a key only when one was typed: protected fields never reach the browser, so they are empty there.
6436
+ * Without fallbacks: a test must show whether the chosen engine works, not whether the reserve does.
6437
+ */
6438
+ withVoiceForm(form) {
6439
+ const cfg = { ...this.config, sttFallback: '', ttsFallback: '' };
6440
+ for (const key of VOICE_FORM_FIELDS) {
6441
+ const value = form[key];
6442
+ if (typeof value !== 'string' || (VOICE_FORM_SECRETS.includes(key) && !value.trim())) {
6443
+ continue;
6444
+ }
6445
+ cfg[key] = value;
6446
+ }
6447
+ return cfg;
6448
+ }
6449
+ /** Build the TTS engine of a configuration (throws when no voice key is available). */
6450
+ async buildTtsEngine(cfg = this.config) {
6451
+ const creds = await (0, credentials_1.resolveVoiceCredentials)(this, cfg, await this.speechLlmKey());
5924
6452
  return (0, engines_1.createTtsEngine)(cfg.ttsProvider || 'openai', this.voiceContext(cfg, creds));
5925
6453
  }
6454
+ /**
6455
+ * Speech recognition for the voice test of the settings: a recording of the browser, an uploaded file or the
6456
+ * test sentence the TTS just spoke. WAV is read directly, anything else goes through ffmpeg.
6457
+ */
6458
+ async transcribeForTest(msg) {
6459
+ try {
6460
+ if (!msg.audio) {
6461
+ return { error: 'no audio' };
6462
+ }
6463
+ const cfg = msg.config ? this.withVoiceForm(msg.config) : this.config;
6464
+ const data = Buffer.from(msg.audio, 'base64');
6465
+ const { pcm, sampleRate } = (msg.format || 'wav') === 'wav' && data.toString('latin1', 0, 4) === 'RIFF'
6466
+ ? (0, openrouter_1.pcmFromResponse)(data, '')
6467
+ : await this.decodeAudioBufferToPcm(data);
6468
+ const creds = await (0, credentials_1.resolveVoiceCredentials)(this, cfg, await this.speechLlmKey());
6469
+ const stt = (0, engines_1.createSttEngine)(cfg.sttProvider || 'openai', this.voiceContext(cfg, creds));
6470
+ const language = msg.language || cfg.voiceLanguage || this.language || '';
6471
+ const start = Date.now();
6472
+ const text = await stt.transcribe(pcm, sampleRate, language, await this.buildSttHints());
6473
+ return { text, ms: Date.now() - start, audioMs: Math.round((pcm.length / 2 / sampleRate) * 1000) };
6474
+ }
6475
+ catch (e) {
6476
+ return { error: e.message };
6477
+ }
6478
+ }
5926
6479
  /** True if backend TTS can run (a voice key is configured) — drives the Chat play button. */
5927
6480
  async isTtsAvailable() {
5928
6481
  try {
@@ -5934,15 +6487,23 @@ class Assistant extends adapter_core_1.Adapter {
5934
6487
  }
5935
6488
  }
5936
6489
  /** Synthesize `text` to a base64 WAV via the configured TTS engine (used by the Chat play button). */
5937
- async synthesizeToWav(text, language) {
6490
+ async synthesizeToWav(text, language, cfg = this.config) {
5938
6491
  if (!text.trim()) {
5939
6492
  return { error: 'empty text' };
5940
6493
  }
5941
6494
  try {
5942
- const tts = await this.buildTtsEngine();
5943
- const lang = language || this.config.voiceLanguage || this.language || '';
6495
+ const tts = await this.buildTtsEngine(cfg);
6496
+ const lang = language || cfg.voiceLanguage || this.language || '';
6497
+ const start = Date.now();
5944
6498
  const { pcm, sampleRate } = await tts.synthesize(text, lang);
5945
- return { audio: this.pcmToWav(pcm, sampleRate).toString('base64'), mime: 'audio/wav' };
6499
+ return {
6500
+ audio: this.pcmToWav(pcm, sampleRate).toString('base64'),
6501
+ mime: 'audio/wav',
6502
+ sampleRate,
6503
+ // a wrong rate shows up as an implausible length of the spoken text
6504
+ audioMs: Math.round((pcm.length / 2 / sampleRate) * 1000),
6505
+ ms: Date.now() - start,
6506
+ };
5946
6507
  }
5947
6508
  catch (e) {
5948
6509
  return { error: e.message };
@@ -5974,7 +6535,8 @@ class Assistant extends adapter_core_1.Adapter {
5974
6535
  async getModels(msg) {
5975
6536
  const cfg = this.config;
5976
6537
  const provider = msg.provider || cfg.provider || 'openai';
5977
- if (provider === 'none') {
6538
+ // `system`: the model is chosen in the system settings of admin
6539
+ if (provider === 'none' || provider === 'pi-agent' || provider === 'system') {
5978
6540
  return [];
5979
6541
  }
5980
6542
  const apiKey = (0, llm_1.effectiveApiKey)(provider, await (0, credentials_1.resolveApiKey)(this, cfg, {
@@ -6009,6 +6571,19 @@ class Assistant extends adapter_core_1.Adapter {
6009
6571
  if (provider === 'none') {
6010
6572
  return { error: 'No LLM provider selected.' };
6011
6573
  }
6574
+ if (provider === 'pi-agent') {
6575
+ return { error: 'pi-agent has its own connection test in its instance settings.' };
6576
+ }
6577
+ if (provider === 'system') {
6578
+ const llm = await (0, systemAi_1.resolveSystemLlm)(this, await (0, systemAi_1.readSystemAi)(this), true);
6579
+ if ('error' in llm) {
6580
+ return { error: llm.error };
6581
+ }
6582
+ const res = await new llm_1.LlmAgent({ ...llm, maxTokens: 16, tools: [], log: this.log }).testConnection();
6583
+ return res.ok
6584
+ ? { result: `Connection OK (${llm.provider} · ${llm.model || 'auto'})` }
6585
+ : { error: res.error || 'unknown error' };
6586
+ }
6012
6587
  const apiKey = (0, llm_1.effectiveApiKey)(provider, await (0, credentials_1.resolveApiKey)(this, cfg, {
6013
6588
  credentialType: msg.credentialType,
6014
6589
  apiKey: msg.apiKey,