iobroker.assistant 1.2.0 → 1.3.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 (58) hide show
  1. package/README.md +11 -0
  2. package/admin/custom/.vite/manifest.json +18 -18
  3. package/admin/custom/assets/Components-x2Dfv8qE.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 +249 -9
  44. package/build/lib/nlu.js.map +1 -1
  45. package/build/lib/piAgent.js +89 -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/openrouter.js +220 -0
  52. package/build/lib/voice/openrouter.js.map +1 -0
  53. package/build/main.js +573 -59
  54. package/build/main.js.map +1 -1
  55. package/io-package.json +27 -17
  56. package/package.json +1 -1
  57. package/admin/custom/assets/Components-BSewCBfS.js +0 -2
  58. 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.
@@ -89,6 +92,36 @@ const LOCAL_LLM_MAX_TOKENS = 256;
89
92
  * The replies the assistant speaks verbatim, whatever the question was. They are pre-synthesised into
90
93
  * the TTS cache at startup; everything else is cached on first use.
91
94
  */
95
+ /** Fields of the Voice tab the voice test takes from the unsaved form (everything an STT or TTS engine is built from). */
96
+ const VOICE_FORM_FIELDS = [
97
+ 'voiceLanguage',
98
+ 'sttProvider',
99
+ 'sttModel',
100
+ 'openrouterSttModel',
101
+ 'voskModel',
102
+ 'ttsProvider',
103
+ 'ttsVoice',
104
+ 'ttsModel',
105
+ 'voiceCredentialType',
106
+ 'voiceApiKey',
107
+ 'voiceCredentialId',
108
+ 'openrouterSpeechKey',
109
+ 'openrouterCredentialId',
110
+ 'openrouterTtsModel',
111
+ 'openrouterVoice',
112
+ 'azureSpeechKey',
113
+ 'azureSpeechRegion',
114
+ 'azureCredentialId',
115
+ 'azureVoice',
116
+ 'awsAccessKeyId',
117
+ 'awsSecretAccessKey',
118
+ 'awsRegion',
119
+ 'awsCredentialId',
120
+ 'awsVoice',
121
+ 'piperVoice',
122
+ ];
123
+ /** Of those, the protected ones: the browser never gets them, so an empty value there means "keep the saved one" */
124
+ const VOICE_FORM_SECRETS = ['voiceApiKey', 'openrouterSpeechKey', 'azureSpeechKey', 'awsSecretAccessKey'];
92
125
  /** How long to wait for a device to confirm a write, and how often to look. */
93
126
  const ACK_TIMEOUT_MS = 2000;
94
127
  const ACK_POLL_MS = 150;
@@ -197,8 +230,18 @@ function describeIntent(intent) {
197
230
  else if (intent.hour !== undefined) {
198
231
  text += ` ${intent.hour}:${String(intent.minute ?? 0).padStart(2, '0')}`;
199
232
  }
233
+ if (intent.delaySec !== undefined) {
234
+ text += ` in ${intent.delaySec} s`;
235
+ }
236
+ else if (intent.at) {
237
+ text += ` at ${intent.at.day ? `+${intent.at.day}d ` : ''}${intent.at.hour}:${String(intent.at.minute).padStart(2, '0')}`;
238
+ }
200
239
  return text;
201
240
  }
241
+ /** A device command for later ("in 2 Stunden", "um 22 Uhr") — goes into the calendar, not to the device */
242
+ function isDeferred(intent) {
243
+ return intent.delaySec !== undefined || !!intent.at;
244
+ }
202
245
  const FIXED_REPLIES = {
203
246
  de: ['Ok.', 'Erledigt.'],
204
247
  en: ['Okay.', 'Done.'],
@@ -228,6 +271,12 @@ function languageLabel(lang) {
228
271
  class Assistant extends adapter_core_1.Adapter {
229
272
  /** Cloud LLM; `null` with provider "none" (local tiers only) or before `onReady` finished. */
230
273
  agent = null;
274
+ /** Provider "pi-agent": the cloud tier is a pi-agent instance instead of {@link agent}. */
275
+ piAgent = null;
276
+ /** The cloud LLM of the instance (own or system-wide settings); `null` without one. */
277
+ llm = null;
278
+ /** What of `system.ai` this instance depends on, while it takes the system-wide AI settings. */
279
+ systemAiWatch = null;
231
280
  /** Set once `onReady` initialised the pipeline — with or without a cloud {@link agent}. */
232
281
  ready = false;
233
282
  mcp = null;
@@ -330,15 +379,30 @@ class Assistant extends adapter_core_1.Adapter {
330
379
  // alarms, the local model) answer. The ioBroker tools are still built: the NLU switches through them.
331
380
  const provider = cfg.provider || 'openai';
332
381
  const localOnly = provider === 'none';
333
- const apiKey = localOnly ? '' : (0, llm_1.effectiveApiKey)(cfg.provider, await (0, credentials_1.resolveApiKey)(this, cfg));
382
+ // Provider "pi-agent": the adapter pi-agent is the cloud tier, with its own model, key and tools.
383
+ const harness = provider === 'pi-agent';
384
+ // Provider "system" (or no key of its own): provider, model and key of the system-wide AI settings.
385
+ if (!localOnly && !harness && (0, systemAi_1.usesSystemAi)(cfg)) {
386
+ await this.watchSystemAi();
387
+ }
388
+ const resolved = localOnly || harness ? null : await (0, systemAi_1.resolveLlm)(this, cfg);
389
+ this.llm = resolved && !('error' in resolved) ? resolved : null;
390
+ // speech of the same vendor reuses the key of the LLM
391
+ const llmKey = this.llm ? { provider: this.llm.provider, apiKey: this.llm.apiKey } : null;
334
392
  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.');
393
+ if (harness && !(cfg.piAgentInstance || '').trim()) {
394
+ this.log.warn('Provider "pi-agent" without an instance — choose the pi-agent instance in the adapter settings.');
395
+ await this.setStateAsync('info.connection', { val: false, ack: true });
396
+ return;
397
+ }
398
+ if (resolved && 'error' in resolved) {
399
+ this.log.warn(resolved.error);
339
400
  await this.setStateAsync('info.connection', { val: false, ack: true });
340
401
  return;
341
402
  }
403
+ if (this.llm?.system) {
404
+ this.log.info(`LLM from the system-wide AI settings: ${this.llm.provider} · ${this.llm.model || 'auto'}`);
405
+ }
342
406
  try {
343
407
  this.mcp = this.journalMcp(await (0, mcp_server_1.createInProcessMcp)({
344
408
  adapter: this,
@@ -359,6 +423,9 @@ class Assistant extends adapter_core_1.Adapter {
359
423
  if ((cfg.weatherInstance || '').trim()) {
360
424
  tools.push(this.buildWeatherTool());
361
425
  }
426
+ if ((cfg.calendarInstance || '').trim()) {
427
+ tools.push(...this.buildCalendarTools());
428
+ }
362
429
  if (cfg.voiceEnabled) {
363
430
  // Only useful with satellites: without voice there is nothing to speak on.
364
431
  tools.push(this.buildAnnounceTool());
@@ -382,13 +449,15 @@ class Assistant extends adapter_core_1.Adapter {
382
449
  // Bust the device/room cache when room/function memberships change (new device shows within TTL anyway).
383
450
  this.subscribeForeignObjects('enum.rooms.*');
384
451
  this.subscribeForeignObjects('enum.functions.*');
385
- if (provider !== 'none') {
386
- const prov = (0, llm_1.resolveProvider)(provider, cfg.baseUrl);
452
+ if (harness) {
453
+ this.piAgent = new piAgent_1.PiAgentHarness(this, cfg.piAgentInstance.trim(), Math.max(5, Number(cfg.piAgentTimeoutSec) || piAgent_1.PI_AGENT_TIMEOUT_SEC) * 1000);
454
+ }
455
+ else if (this.llm) {
387
456
  this.agent = new llm_1.LlmAgent({
388
- provider,
389
- apiKey,
390
- model: cfg.model || prov.defaultModel,
391
- baseUrl: prov.baseUrl,
457
+ provider: this.llm.provider,
458
+ apiKey: this.llm.apiKey,
459
+ model: this.llm.model,
460
+ baseUrl: this.llm.baseUrl,
392
461
  systemPrompt: cfg.systemPrompt || '',
393
462
  maxTokens: cfg.maxTokens || 1024,
394
463
  tools: tools.map(t => this.journalTool(t)),
@@ -438,8 +507,10 @@ class Assistant extends adapter_core_1.Adapter {
438
507
  await this.setupPresence();
439
508
  await this.setupTriggers();
440
509
  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.');
510
+ ? `Assistant ready (provider=${this.llm?.provider}, model=${this.agent.model || 'auto'}).`
511
+ : this.piAgent
512
+ ? `Assistant ready (cloud tier: ${this.piAgent.instance}).`
513
+ : 'Assistant ready without an LLM provider — answering locally only.');
443
514
  if (cfg.useLocalLlm) {
444
515
  void this.ensureLocalLlm(); // background: model load/download must not block onReady
445
516
  }
@@ -448,20 +519,20 @@ class Assistant extends adapter_core_1.Adapter {
448
519
  // Subscribe regardless of the UDP server so writes are always handled (and logged if unroutable).
449
520
  this.subscribeStates('tts.text');
450
521
  if (cfg.udpServerEnabled) {
451
- await this.startVoiceServer(apiKey);
522
+ await this.startVoiceServer(llmKey);
452
523
  }
453
524
  }
454
525
  if (cfg.wyomingEnabled) {
455
- await this.startWyoming(apiKey);
526
+ await this.startWyoming(llmKey);
456
527
  }
457
528
  if (cfg.esphomeEnabled) {
458
- await this.startEsphome(apiKey);
529
+ await this.startEsphome(llmKey);
459
530
  }
460
531
  }
461
532
  /** Start the Wyoming TCP endpoint, bridging to the same STT/answer/TTS pipeline as the UDP server. */
462
- async startWyoming(mainApiKey) {
533
+ async startWyoming(llmKey) {
463
534
  const cfg = this.config;
464
- const creds = await (0, credentials_1.resolveVoiceCredentials)(this, cfg, mainApiKey);
535
+ const creds = await (0, credentials_1.resolveVoiceCredentials)(this, cfg, llmKey);
465
536
  const ctx = this.voiceContext(cfg, creds);
466
537
  const language = cfg.voiceLanguage || this.language || '';
467
538
  let stt;
@@ -502,14 +573,14 @@ class Assistant extends adapter_core_1.Adapter {
502
573
  * linux-voice-assistant). The adapter is the client here — the devices listen on TCP 6053 — and the
503
574
  * spoken reply is handed over as a URL, so the little media server comes up together with them.
504
575
  */
505
- async startEsphome(mainApiKey) {
576
+ async startEsphome(llmKey) {
506
577
  const cfg = this.config;
507
578
  const devices = (cfg.esphomeDevices || []).filter(d => d?.ip?.trim());
508
579
  if (!devices.length) {
509
580
  this.log.warn('ESPHome satellites are enabled, but no device is configured.');
510
581
  return;
511
582
  }
512
- const creds = await (0, credentials_1.resolveVoiceCredentials)(this, cfg, mainApiKey);
583
+ const creds = await (0, credentials_1.resolveVoiceCredentials)(this, cfg, llmKey);
513
584
  const ctx = this.voiceContext(cfg, creds);
514
585
  const language = cfg.voiceLanguage || this.language || '';
515
586
  let stt;
@@ -597,6 +668,7 @@ class Assistant extends adapter_core_1.Adapter {
597
668
  azure: (cfg.azureVoice || '').trim(),
598
669
  aws: (cfg.awsVoice || '').trim(),
599
670
  piper: (cfg.piperVoice || '').trim(),
671
+ openrouter: (cfg.openrouterVoice || '').trim(),
600
672
  },
601
673
  sttFallback: cfg.sttFallback || '',
602
674
  ttsFallback: cfg.ttsFallback || '',
@@ -605,6 +677,9 @@ class Assistant extends adapter_core_1.Adapter {
605
677
  voskModel: (cfg.voskModel || '').trim(),
606
678
  sttModel: (cfg.sttModel || '').trim(),
607
679
  ttsModel: (cfg.ttsModel || '').trim(),
680
+ openrouterSttModel: (cfg.openrouterSttModel || '').trim(),
681
+ openrouterTtsModel: (cfg.openrouterTtsModel || '').trim(),
682
+ decodeAudio: audio => this.decodeAudioBufferToPcm(audio),
608
683
  };
609
684
  }
610
685
  /**
@@ -612,9 +687,9 @@ class Assistant extends adapter_core_1.Adapter {
612
687
  * AWS cloud, or Vosk / Piper locally); credentials come from the encrypted config fields or the
613
688
  * central credential store (voiceCredentialType).
614
689
  */
615
- async startVoiceServer(mainApiKey) {
690
+ async startVoiceServer(llmKey) {
616
691
  const cfg = this.config;
617
- const creds = await (0, credentials_1.resolveVoiceCredentials)(this, cfg, mainApiKey);
692
+ const creds = await (0, credentials_1.resolveVoiceCredentials)(this, cfg, llmKey);
618
693
  const ctx = this.voiceContext(cfg, creds);
619
694
  // Raw language (e.g. 'de' or 'zh-cn') — each engine normalises it (ISO for cloud, model per lang for local).
620
695
  const language = cfg.voiceLanguage || this.language || '';
@@ -721,6 +796,7 @@ class Assistant extends adapter_core_1.Adapter {
721
796
  azure: cfg.azureVoice,
722
797
  aws: cfg.awsVoice,
723
798
  piper: cfg.piperVoice,
799
+ openrouter: cfg.openrouterVoice,
724
800
  };
725
801
  this.journal?.spoken(source, {
726
802
  ...info,
@@ -740,8 +816,7 @@ class Assistant extends adapter_core_1.Adapter {
740
816
  return this.speechEngines;
741
817
  }
742
818
  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);
819
+ const creds = await (0, credentials_1.resolveVoiceCredentials)(this, cfg, await this.speechLlmKey());
745
820
  const ctx = this.voiceContext(cfg, creds);
746
821
  const language = cfg.voiceLanguage || this.language || '';
747
822
  try {
@@ -829,8 +904,7 @@ class Assistant extends adapter_core_1.Adapter {
829
904
  const cfg = { ...this.config, ...overrides };
830
905
  const provider = msg.ttsProvider || cfg.ttsProvider || 'openai';
831
906
  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);
907
+ const creds = await (0, credentials_1.resolveVoiceCredentials)(this, cfg, await this.speechLlmKey());
834
908
  return await (0, engines_1.listVoices)(provider, this.voiceContext(cfg, creds), language);
835
909
  }
836
910
  catch (e) {
@@ -1745,7 +1819,36 @@ class Assistant extends adapter_core_1.Adapter {
1745
1819
  });
1746
1820
  }
1747
1821
  /** Invalidate the cached device/room/function listings when a room/function enum changes. */
1748
- onObjectChange(id, _obj) {
1822
+ /** Vendor and key of the cloud LLM: speech of the same vendor (OpenAI, OpenRouter) reuses the key. */
1823
+ async speechLlmKey() {
1824
+ const provider = this.config.provider || 'openai';
1825
+ const llm = this.llm ?? (provider === 'none' || provider === 'pi-agent' ? null : await (0, systemAi_1.resolveLlm)(this, this.config));
1826
+ return llm && !('error' in llm) ? { provider: llm.provider, apiKey: llm.apiKey } : null;
1827
+ }
1828
+ /**
1829
+ * Restart when the system-wide AI settings this instance takes change — also when they are still empty, so
1830
+ * setting them up in admin starts the instance. The credential is watched too: a new key needs no new id.
1831
+ */
1832
+ async watchSystemAi() {
1833
+ const native = await (0, systemAi_1.readSystemAi)(this);
1834
+ const credentialId = (0, systemAi_1.systemAiSelection)(native)?.credentialId || '';
1835
+ this.systemAiWatch = { fingerprint: (0, systemAi_1.systemAiFingerprint)(native), credentialId };
1836
+ await this.subscribeForeignObjectsAsync(systemAi_1.SYSTEM_AI_ID);
1837
+ if (credentialId) {
1838
+ await this.subscribeForeignObjectsAsync(credentialId);
1839
+ }
1840
+ }
1841
+ onObjectChange(id, obj) {
1842
+ if (this.systemAiWatch && (id === systemAi_1.SYSTEM_AI_ID || id === this.systemAiWatch.credentialId)) {
1843
+ // A credential has no fingerprint: a change of it is a new key.
1844
+ if (id === systemAi_1.SYSTEM_AI_ID && (0, systemAi_1.systemAiFingerprint)(obj?.native) === this.systemAiWatch.fingerprint) {
1845
+ return;
1846
+ }
1847
+ this.log.info(`The system-wide AI settings changed (${id}) — restarting.`);
1848
+ this.systemAiWatch = null;
1849
+ this.restart();
1850
+ return;
1851
+ }
1749
1852
  if (this.listCache && (id.startsWith('enum.rooms.') || id.startsWith('enum.functions.'))) {
1750
1853
  this.listCache.clear();
1751
1854
  this.log.debug(`list cache cleared (object changed: ${id})`);
@@ -2003,14 +2106,23 @@ class Assistant extends adapter_core_1.Adapter {
2003
2106
  }
2004
2107
  return;
2005
2108
  }
2006
- // STT model dropdown (Vosk): suggest model names for the selected language.
2007
- if (obj.command === 'getSttModels') {
2109
+ // STT/TTS model dropdowns (Vosk, OpenRouter): suggest model names for the selected provider.
2110
+ if (obj.command === 'getSttModels' || obj.command === 'getTtsModels') {
2008
2111
  const msg = (obj.message || {});
2009
2112
  const cfg = this.config;
2010
- const provider = msg.sttProvider || cfg.sttProvider || 'openai';
2011
2113
  const language = msg.language || cfg.voiceLanguage || this.language || '';
2114
+ let models = [];
2115
+ try {
2116
+ models =
2117
+ obj.command === 'getSttModels'
2118
+ ? await (0, engines_1.listSttModels)(msg.sttProvider || cfg.sttProvider || 'openai', language)
2119
+ : await (0, engines_1.listTtsModels)(msg.ttsProvider || cfg.ttsProvider || 'openai');
2120
+ }
2121
+ catch (e) {
2122
+ this.log.warn(`${obj.command} failed: ${e.message}`);
2123
+ }
2012
2124
  if (obj.callback) {
2013
- this.sendTo(obj.from, obj.command, (0, engines_1.listSttModels)(provider, language), obj.callback);
2125
+ this.sendTo(obj.from, obj.command, models, obj.callback);
2014
2126
  }
2015
2127
  return;
2016
2128
  }
@@ -2385,10 +2497,20 @@ class Assistant extends adapter_core_1.Adapter {
2385
2497
  }
2386
2498
  return;
2387
2499
  }
2388
- // Chat: synthesize an answer to speech (WAV) via the configured TTS engine.
2500
+ // Chat and the voice test of the settings: synthesize to speech (WAV) via the configured TTS engine,
2501
+ // or via the unsaved settings of the Voice tab (`config`).
2389
2502
  if (obj.command === 'tts') {
2390
- const { text, language } = (obj.message || {});
2391
- const result = await this.synthesizeToWav(String(text ?? ''), language);
2503
+ const { text, language, config } = (obj.message || {});
2504
+ const cfg = config ? this.withVoiceForm(config) : this.config;
2505
+ const result = await this.synthesizeToWav(String(text ?? ''), language, cfg);
2506
+ if (obj.callback) {
2507
+ this.sendTo(obj.from, obj.command, result, obj.callback);
2508
+ }
2509
+ return;
2510
+ }
2511
+ // Voice test of the settings: recognize a recording with the (unsaved) speech-to-text settings.
2512
+ if (obj.command === 'stt') {
2513
+ const result = await this.transcribeForTest((obj.message || {}));
2392
2514
  if (obj.callback) {
2393
2515
  this.sendTo(obj.from, obj.command, result, obj.callback);
2394
2516
  }
@@ -2567,7 +2689,8 @@ class Assistant extends adapter_core_1.Adapter {
2567
2689
  data: {
2568
2690
  provider,
2569
2691
  fallback: cfg.sttFallback || undefined,
2570
- model: (cfg.sttModel || cfg.voskModel || '').trim() || undefined,
2692
+ model: (provider === 'openrouter' ? cfg.openrouterSttModel : cfg.sttModel || cfg.voskModel || '').trim() ||
2693
+ undefined,
2571
2694
  language: cfg.voiceLanguage || this.language || '',
2572
2695
  audioMs: origin.audioMs,
2573
2696
  wakeWord: origin.wakeWord,
@@ -2595,19 +2718,52 @@ class Assistant extends adapter_core_1.Adapter {
2595
2718
  if (followUp) {
2596
2719
  this.journal?.note({ followUp: true });
2597
2720
  }
2598
- const result = raw.replace(/\s*\[\[LISTEN\]\]\s*/gi, ' ').trim();
2721
+ let result = raw.replace(/\s*\[\[(?:LISTEN|DONE)\]\]\s*/gi, ' ').trim();
2722
+ // The LLM answers a switch command that worked with [[DONE]] alone (see doneHint): a beep on the satellite
2723
+ // instead of a sentence. Where nothing can beep (chat, Telegram), a short word. With more text, that is said.
2724
+ let toned = false;
2725
+ if (!result && /\[\[DONE\]\]/i.test(raw)) {
2726
+ toned = await this.playConfirmationTone(source);
2727
+ if (!toned) {
2728
+ result = this.doneText();
2729
+ }
2730
+ }
2599
2731
  this.journal?.step({
2600
2732
  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 },
2733
+ title: toned
2734
+ ? 'Confirmed with a tone instead of words'
2735
+ : result
2736
+ ? `Answer: ${result.length} characters${followUp ? ', waits for a reply' : ''}`
2737
+ : 'No spoken answer',
2738
+ data: { answer: result, raw: raw !== result ? raw : undefined, followUp, tone: toned || undefined },
2605
2739
  });
2606
- if (useCtx && result) {
2607
- this.context.add(source, question, result);
2740
+ if (useCtx && (result || toned)) {
2741
+ this.context.add(source, question, result || this.doneText());
2608
2742
  }
2609
2743
  return result;
2610
2744
  }
2745
+ /**
2746
+ * System-prompt instruction for `confirmWithTone`: a switch command that worked is answered with [[DONE]]
2747
+ * alone, which becomes a beep on the satellite. Empty when the option (or voice) is off.
2748
+ */
2749
+ doneHint() {
2750
+ if (!this.config.confirmWithTone || !this.config.voiceEnabled) {
2751
+ return '';
2752
+ }
2753
+ const lang = String(this.config.voiceLanguage || this.language || 'en');
2754
+ if (lang === 'ru') {
2755
+ return 'Если запрос был только командой включить, выключить или установить устройства на значение и всё получилось, ответь только управляющим маркером [[DONE]] (вместо фразы прозвучит короткий сигнал). На вопросы, при ошибках или если нужно что-то сообщить, отвечай как обычно.';
2756
+ }
2757
+ if (lang === 'de') {
2758
+ 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.';
2759
+ }
2760
+ 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.';
2761
+ }
2762
+ /** What [[DONE]] says where no beep can be played */
2763
+ doneText() {
2764
+ const lang = String(this.config.voiceLanguage || this.language || 'en');
2765
+ return lang === 'ru' ? 'Готово.' : lang === 'de' ? 'Erledigt.' : 'Done.';
2766
+ }
2611
2767
  /** Per source: whether its last {@link answer} asked the user something (LLM-signalled via [[LISTEN]]). */
2612
2768
  followUps = new Map();
2613
2769
  /**
@@ -2800,8 +2956,9 @@ class Assistant extends adapter_core_1.Adapter {
2800
2956
  // the thread and the tools) handle follow-ups instead. Without a cloud LLM there is nobody to hand
2801
2957
  // follow-ups to, so the local model takes them too.
2802
2958
  const useLocal = !!(this.config.useLocalLlm && this.localLlm);
2803
- const localFirst = useLocal && (!this.agent || !!this.config.localLlmFirst);
2804
- if (localFirst && (!history.length || !this.agent)) {
2959
+ const cloud = !!(this.agent || this.piAgent);
2960
+ const localFirst = useLocal && (!cloud || !!this.config.localLlmFirst);
2961
+ if (localFirst && (!history.length || !cloud)) {
2805
2962
  const ans = await this.askLocalLlm(question, weather, source);
2806
2963
  if (ans) {
2807
2964
  return ans;
@@ -2822,6 +2979,9 @@ class Assistant extends adapter_core_1.Adapter {
2822
2979
  }
2823
2980
  // Tier 2: cloud LLM with full tool access. Inject a compact device list into the system prompt so
2824
2981
  // the model can act without a first `list_devices` round-trip (kept cached via prompt caching).
2982
+ if (this.piAgent) {
2983
+ return this.askPiAgent(this.piAgent, question, weather, source, useLocal && !localFirst);
2984
+ }
2825
2985
  if (!this.agent) {
2826
2986
  // Provider "none": no tier further down. Say so instead of failing — over voice an error is
2827
2987
  // read out as a technical message.
@@ -2838,7 +2998,8 @@ class Assistant extends adapter_core_1.Adapter {
2838
2998
  const mem = this.buildMemoryContext();
2839
2999
  // Only ask the model to emit the follow-up marker when satellites can actually re-open the mic.
2840
3000
  const followUp = this.config.voiceEnabled ? this.followUpHint() : '';
2841
- const parts = [this.config.systemPrompt || '', followUp, mem, ctx].filter(Boolean);
3001
+ const done = this.doneHint();
3002
+ const parts = [this.config.systemPrompt || '', followUp, done, mem, ctx].filter(Boolean);
2842
3003
  const sys = parts.length ? parts.join('\n\n') : undefined;
2843
3004
  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
3005
  // Prepend the current date/time (and the weather line, if a source is configured) to the user turn —
@@ -2847,7 +3008,7 @@ class Assistant extends adapter_core_1.Adapter {
2847
3008
  const questionForLlm = [time, weather, question].filter(Boolean).join('\n\n');
2848
3009
  this.journal?.note({
2849
3010
  tier: 'cloud',
2850
- detail: `${this.config.provider || 'openai'} · ${this.agent.model || 'auto'}`,
3011
+ detail: `${this.llm?.provider || this.config.provider || 'openai'} · ${this.agent.model || 'auto'}`,
2851
3012
  });
2852
3013
  // What went into the prompt, piece by piece. The device list is only counted: the model's opening
2853
3014
  // step carries the whole system prompt, and with it the list.
@@ -2858,6 +3019,7 @@ class Assistant extends adapter_core_1.Adapter {
2858
3019
  data: {
2859
3020
  systemPrompt: this.config.systemPrompt || '',
2860
3021
  followUpHint: followUp || undefined,
3022
+ doneHint: done || undefined,
2861
3023
  memory: mem || undefined,
2862
3024
  devices: deviceLines,
2863
3025
  time,
@@ -2884,6 +3046,39 @@ class Assistant extends adapter_core_1.Adapter {
2884
3046
  throw e;
2885
3047
  }
2886
3048
  }
3049
+ /**
3050
+ * Cloud tier through the adapter pi-agent: one pi-agent conversation per source. The style, the
3051
+ * follow-up marker, the memory and the device list are the instructions of that conversation (pi-agent
3052
+ * re-configures it only when they change); time, weather and presence change every turn and go in front
3053
+ * of the question. The history stays in pi-agent, so nothing of {@link ConversationStore} is sent.
3054
+ */
3055
+ async askPiAgent(harness, question, weather, source, localFallback) {
3056
+ const followUp = this.config.voiceEnabled ? this.followUpHint() : '';
3057
+ // The device list maps names to state ids, so pi-agent can act without searching first. It is the
3058
+ // first to go when the instructions get too long — pi-agent can still search.
3059
+ const { text: instructions, dropped } = (0, piAgent_1.composeInstructions)([this.config.systemPrompt || '', followUp, this.doneHint()], [this.buildMemoryContext(), await this.buildDeviceContext()]);
3060
+ if (dropped) {
3061
+ this.log.debug(`pi-agent: ${dropped} context block(s) left out, the instructions would be too long.`);
3062
+ }
3063
+ const text = [this.buildTimeContext(), weather, this.buildPresenceContext(), question]
3064
+ .filter(Boolean)
3065
+ .join('\n\n');
3066
+ this.log.info(`Cloud tier ${harness.instance} (source='${source}') — asking pi-agent…`);
3067
+ this.journal?.note({ tier: 'cloud', detail: harness.instance });
3068
+ try {
3069
+ return await harness.ask({ text, conversation: source, instructions });
3070
+ }
3071
+ catch (e) {
3072
+ if (localFallback) {
3073
+ this.log.warn(`pi-agent failed (${e.message}) — asking the local LLM instead.`);
3074
+ const ans = await this.askLocalLlm(question, weather, source);
3075
+ if (ans) {
3076
+ return ans;
3077
+ }
3078
+ }
3079
+ throw e;
3080
+ }
3081
+ }
2887
3082
  /**
2888
3083
  * Ask the local model, bounded by `localLlmTimeoutSec`. Returns its answer, or null when it handed off,
2889
3084
  * timed out or failed — the caller then goes on to the next tier.
@@ -2974,10 +3169,11 @@ class Assistant extends adapter_core_1.Adapter {
2974
3169
  const room = d.room ? `${d.room}, ` : '';
2975
3170
  lines.push(`- ${d.name} (${room}${d.type}): ${ids.join(', ')}`);
2976
3171
  }
3172
+ const lang = String(this.config.voiceLanguage || this.language || 'en');
3173
+ lines.push(...this.satelliteVolumeLines(lang));
2977
3174
  if (!lines.length) {
2978
3175
  return '';
2979
3176
  }
2980
- const lang = String(this.config.voiceLanguage || this.language || 'en');
2981
3177
  const header = lang === 'ru'
2982
3178
  ? 'Известные устройства — «имя (комната, тип): stateId». Используй эти stateId напрямую с set_state/get_states; вызывай list_devices только если нужного устройства здесь нет.'
2983
3179
  : lang === 'de'
@@ -2985,6 +3181,41 @@ class Assistant extends adapter_core_1.Adapter {
2985
3181
  : '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
3182
  return `${header}\n${lines.join('\n')}`;
2987
3183
  }
3184
+ /**
3185
+ * The playback volume of every voice satellite as a line of the device list. Without it, "Lautstärke auf 50
3186
+ * Prozent" that the rules miss reaches the LLM (or pi-agent) with only the media devices of the room in view, and
3187
+ * the model turns those up instead of the speaker that heard the request. The scales differ per transport.
3188
+ */
3189
+ satelliteVolumeLines(lang) {
3190
+ const label = (satId) => lang === 'ru'
3191
+ ? `Громкость голосового спутника ${satId}`
3192
+ : lang === 'de'
3193
+ ? `Lautstärke des Sprachsatelliten ${satId}`
3194
+ : `Volume of the voice satellite ${satId}`;
3195
+ const lines = [];
3196
+ const seen = new Set();
3197
+ for (const [satId, instance] of this.nativeSatFrom) {
3198
+ seen.add(satId);
3199
+ lines.push(`- ${label(satId)} (${satId}, level.volume 0-100): ${instance}.volume`);
3200
+ }
3201
+ for (const [satId, device] of this.satDeviceById) {
3202
+ if (seen.has(satId)) {
3203
+ continue;
3204
+ }
3205
+ const player = this.esphome?.entities(device).find(e => e.kind === 'mediaPlayer');
3206
+ if (player) {
3207
+ seen.add(satId);
3208
+ const id = `${this.namespace}.satellites.${satId}.controls.${player.objectId}.volume`;
3209
+ lines.push(`- ${label(satId)} (${satId}, level.volume 0-1): ${id}`);
3210
+ }
3211
+ }
3212
+ for (const satId of this.satVolumeEnsured) {
3213
+ if (!seen.has(satId)) {
3214
+ lines.push(`- ${label(satId)} (${satId}, level.volume 0-100): ${this.namespace}.satellites.${satId}.volume`);
3215
+ }
3216
+ }
3217
+ return lines;
3218
+ }
2988
3219
  /**
2989
3220
  * List a port we listen on in the host's used-resources registry (js-controller ≥ 8), so the admin shows it
2990
3221
  * as occupied when someone configures another instance. `common.declareUsedResources` is set because the
@@ -3266,7 +3497,8 @@ class Assistant extends adapter_core_1.Adapter {
3266
3497
  if (this.config.confirmWithTone &&
3267
3498
  answer &&
3268
3499
  !failed &&
3269
- intents.every(i => CONTROL_ACTIONS.has(i.action)) &&
3500
+ // a command for later says when it will run — a beep would hide that
3501
+ intents.every(i => CONTROL_ACTIONS.has(i.action) && !isDeferred(i)) &&
3270
3502
  (await this.playConfirmationTone(source))) {
3271
3503
  this.log.debug(`NLU confirmed with a tone instead of "${answer}".`);
3272
3504
  this.journal?.step({ stage: 'nlu', title: 'Confirmed with a tone instead of words', data: { answer } });
@@ -3301,6 +3533,9 @@ class Assistant extends adapter_core_1.Adapter {
3301
3533
  if (!deviceCount) {
3302
3534
  return 'no devices known';
3303
3535
  }
3536
+ if (isDeferred(intent) && !(this.config.calendarInstance || '').trim()) {
3537
+ return 'a command for later needs a calendar (fullcalendar) instance in the settings';
3538
+ }
3304
3539
  return ['on', 'off', 'level', 'color'].includes(intent.action) && !this.config.allowWriteStates
3305
3540
  ? 'writing states is not allowed (allowWriteStates)'
3306
3541
  : '';
@@ -3325,8 +3560,226 @@ class Assistant extends adapter_core_1.Adapter {
3325
3560
  case 'volumeSet':
3326
3561
  return this.executeVolumeIntent(intent, source);
3327
3562
  default:
3328
- return this.executeIntent(intent);
3563
+ return isDeferred(intent) ? this.executeDeferredIntent(intent) : this.executeIntent(intent);
3564
+ }
3565
+ }
3566
+ /**
3567
+ * Send a message to the calendar instance (fullcalendar). Its answer `{error}` becomes an exception.
3568
+ *
3569
+ * @param command `addEvent`, `listEvents`, `deleteEvent`, …
3570
+ * @param message the parameters
3571
+ */
3572
+ async calendarCall(command, message) {
3573
+ const instance = (this.config.calendarInstance || '').trim().replace(/^system\.adapter\./, '');
3574
+ if (!instance) {
3575
+ throw new Error('No calendar instance configured in the assistant settings');
3576
+ }
3577
+ const alive = await this.getForeignStateAsync(`system.adapter.${instance}.alive`);
3578
+ if (!alive?.val) {
3579
+ throw new Error(`The calendar ${instance} is not running`);
3580
+ }
3581
+ const res = (await this.sendToAsync(instance, command, message, { timeout: 10000 }));
3582
+ if (!res) {
3583
+ throw new Error(`The calendar ${instance} did not answer`);
3584
+ }
3585
+ if (res.error) {
3586
+ throw new Error(res.error);
3587
+ }
3588
+ return res;
3589
+ }
3590
+ /**
3591
+ * Whether the per-device ACL forbids writing this state: an explicit entry, else the default of the device
3592
+ * type (locks are read-only). States outside the device list are not restricted (like `set_state`).
3593
+ */
3594
+ async writeForbidden(stateId) {
3595
+ const { devices } = await this.getNluDevices();
3596
+ const device = devices.find(d => Object.values(d.controls).includes(stateId));
3597
+ if (!device) {
3598
+ return false;
3599
+ }
3600
+ const entry = this.config.deviceAcl?.[(0, tools_1.deviceKey)(Object.values(device.controls))];
3601
+ return entry ? entry.write === false : device.type === 'lock';
3602
+ }
3603
+ /** "22:30", "morgen um 22:30", "30.10. um 7:00" — when a planned command runs, for the spoken answer */
3604
+ describeWhen(time, lang) {
3605
+ const pad = (n) => String(n).padStart(2, '0');
3606
+ const clock = `${time.getHours()}:${pad(time.getMinutes())}`;
3607
+ const now = new Date();
3608
+ const days = Math.round((new Date(time.getFullYear(), time.getMonth(), time.getDate()).getTime() -
3609
+ new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime()) /
3610
+ 86_400_000);
3611
+ const date = `${pad(time.getDate())}.${pad(time.getMonth() + 1)}.`;
3612
+ if (lang === 'ru') {
3613
+ return days === 0 ? `в ${clock}` : days === 1 ? `завтра в ${clock}` : `${date} в ${clock}`;
3614
+ }
3615
+ if (lang === 'de') {
3616
+ return days === 0 ? `um ${clock}` : days === 1 ? `morgen um ${clock}` : `am ${date} um ${clock}`;
3329
3617
  }
3618
+ return days === 0 ? `at ${clock}` : days === 1 ? `tomorrow at ${clock}` : `on ${date} at ${clock}`;
3619
+ }
3620
+ /**
3621
+ * A device command for later ("Licht in 2 Stunden aus", "выключи свет через 2 часа"): one event in the calendar
3622
+ * (fullcalendar) per write — the calendar shows it, survives restarts and can be cancelled there.
3623
+ */
3624
+ async executeDeferredIntent(intent) {
3625
+ const device = intent.device;
3626
+ if (!device || !intent.stateId) {
3627
+ throw new Error('deferred intent without a device');
3628
+ }
3629
+ const lang = String(this.config.voiceLanguage || this.language || 'en');
3630
+ const pick = (rus, ger, eng) => lang === 'ru' ? rus : lang === 'de' ? ger : eng;
3631
+ const room = intent.room || device.room;
3632
+ const where = room ? ` (${room})` : '';
3633
+ const dev = device.name;
3634
+ if (await this.writeForbidden(intent.stateId)) {
3635
+ return pick(`${dev}${where} только для чтения.`, `${dev}${where} ist schreibgeschützt.`, `${dev}${where} is read-only.`);
3636
+ }
3637
+ let time;
3638
+ if (intent.delaySec !== undefined) {
3639
+ time = new Date(Date.now() + intent.delaySec * 1000);
3640
+ }
3641
+ else {
3642
+ const { hour, minute, day } = intent.at;
3643
+ const now = new Date();
3644
+ time = new Date(now.getFullYear(), now.getMonth(), now.getDate() + (day ?? 0), hour, minute, 0, 0);
3645
+ if (time.getTime() <= now.getTime()) {
3646
+ if (day !== undefined) {
3647
+ return pick(`${this.describeWhen(time, lang)} уже прошло.`, `${this.describeWhen(time, lang).replace(/^um /, '')} ist schon vorbei.`, `${this.describeWhen(time, lang).replace(/^at /, '')} has already passed.`);
3648
+ }
3649
+ time.setDate(time.getDate() + 1); // "um 7 Uhr" after 7 o'clock: tomorrow
3650
+ }
3651
+ }
3652
+ const name = `${dev}${where}`;
3653
+ await this.calendarCall('addEvent', { oid: intent.stateId, value: intent.value, start: time.getTime(), name });
3654
+ // a dimmer with its own switch: the switch goes into the calendar, too
3655
+ if (intent.also) {
3656
+ await this.calendarCall('addEvent', {
3657
+ oid: intent.also.stateId,
3658
+ value: intent.also.value,
3659
+ start: time.getTime(),
3660
+ name,
3661
+ });
3662
+ }
3663
+ this.log.info(`Planned in the calendar: ${intent.stateId} = ${JSON.stringify(intent.value)} at ${time.toString()}`);
3664
+ const when = this.describeWhen(time, lang);
3665
+ switch (intent.action) {
3666
+ case 'on':
3667
+ return pick(`Хорошо, ${when} включу: ${dev}${where}.`, `Okay, ${dev}${where} wird ${when} eingeschaltet.`, `Okay, ${dev}${where} will be turned on ${when}.`);
3668
+ case 'off':
3669
+ return pick(`Хорошо, ${when} выключу: ${dev}${where}.`, `Okay, ${dev}${where} wird ${when} ausgeschaltet.`, `Okay, ${dev}${where} will be turned off ${when}.`);
3670
+ case 'level':
3671
+ 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}.`);
3672
+ default:
3673
+ return pick(`Хорошо, запланировано ${when}: ${dev}${where}.`, `Okay, für ${dev}${where} ${when} eingeplant.`, `Okay, planned for ${dev}${where} ${when}.`);
3674
+ }
3675
+ }
3676
+ /**
3677
+ * LLM tools for the calendar (fullcalendar): plan a state change for later, list what is planned, cancel it.
3678
+ * Planning respects the same write rules as `set_state` (the coarse switch and the per-device ACL).
3679
+ */
3680
+ buildCalendarTools() {
3681
+ const fail = (e) => JSON.stringify({ ok: false, error: e.message });
3682
+ const tools = [
3683
+ {
3684
+ name: 'list_scheduled',
3685
+ 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".',
3686
+ parameters: {
3687
+ type: 'object',
3688
+ properties: {
3689
+ days: { type: 'number', description: 'How many days ahead, default 1' },
3690
+ from: { type: 'string', description: 'Start, local time YYYY-MM-DDTHH:mm' },
3691
+ to: { type: 'string', description: 'End, local time YYYY-MM-DDTHH:mm' },
3692
+ },
3693
+ additionalProperties: false,
3694
+ },
3695
+ run: async (args) => {
3696
+ try {
3697
+ const res = await this.calendarCall('listEvents', {
3698
+ days: typeof args.days === 'number' ? args.days : 1,
3699
+ from: args.from,
3700
+ to: args.to,
3701
+ max: 50,
3702
+ });
3703
+ return JSON.stringify({ ok: true, events: res.events });
3704
+ }
3705
+ catch (e) {
3706
+ return fail(e);
3707
+ }
3708
+ },
3709
+ },
3710
+ ];
3711
+ if (!this.config.allowWriteStates) {
3712
+ return tools;
3713
+ }
3714
+ tools.push({
3715
+ name: 'schedule_state',
3716
+ 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.',
3717
+ parameters: {
3718
+ type: 'object',
3719
+ properties: {
3720
+ id: { type: 'string', description: 'State ID' },
3721
+ value: { type: ['boolean', 'number', 'string'], description: 'Value to write' },
3722
+ in_minutes: { type: 'number', description: 'Delay in minutes from now' },
3723
+ at: { type: 'string', description: 'Local time YYYY-MM-DDTHH:mm' },
3724
+ duration_minutes: { type: 'number', description: 'Optional: write end_value this much later' },
3725
+ end_value: { type: ['boolean', 'number', 'string'], description: 'Value after the duration' },
3726
+ name: {
3727
+ type: 'string',
3728
+ description: 'Short title for the calendar, e.g. "Licht Wohnzimmer aus"',
3729
+ },
3730
+ },
3731
+ required: ['id', 'value'],
3732
+ additionalProperties: false,
3733
+ },
3734
+ run: async (args) => {
3735
+ try {
3736
+ const id = typeof args.id === 'string' ? args.id : '';
3737
+ if (await this.writeForbidden(id)) {
3738
+ return JSON.stringify({
3739
+ ok: false,
3740
+ error: `Writing "${id}" is not allowed: the device is read-only in the assistant settings.`,
3741
+ });
3742
+ }
3743
+ if (args.in_minutes === undefined && !args.at) {
3744
+ return JSON.stringify({ ok: false, error: 'Give "in_minutes" or "at"' });
3745
+ }
3746
+ const res = await this.calendarCall('addEvent', {
3747
+ oid: id,
3748
+ value: args.value,
3749
+ in: typeof args.in_minutes === 'number' ? Math.round(args.in_minutes * 60) : undefined,
3750
+ start: args.in_minutes === undefined ? args.at : undefined,
3751
+ duration: typeof args.duration_minutes === 'number'
3752
+ ? Math.round(args.duration_minutes * 60)
3753
+ : undefined,
3754
+ endValue: args.end_value,
3755
+ name: typeof args.name === 'string' ? args.name : undefined,
3756
+ });
3757
+ return JSON.stringify({ ok: true, eventId: res.id, start: res.start });
3758
+ }
3759
+ catch (e) {
3760
+ return fail(e);
3761
+ }
3762
+ },
3763
+ }, {
3764
+ name: 'cancel_scheduled',
3765
+ description: 'Delete a planned event from the calendar, by its "id" from list_scheduled or schedule_state.',
3766
+ parameters: {
3767
+ type: 'object',
3768
+ properties: { id: { type: 'string', description: 'Event ID' } },
3769
+ required: ['id'],
3770
+ additionalProperties: false,
3771
+ },
3772
+ run: async (args) => {
3773
+ try {
3774
+ await this.calendarCall('deleteEvent', { id: typeof args.id === 'string' ? args.id : '' });
3775
+ return JSON.stringify({ ok: true });
3776
+ }
3777
+ catch (e) {
3778
+ return fail(e);
3779
+ }
3780
+ },
3781
+ });
3782
+ return tools;
3330
3783
  }
3331
3784
  /**
3332
3785
  * Speak the weather straight from the configured adapter. Throws when the report has nothing for the
@@ -5916,13 +6369,52 @@ class Assistant extends adapter_core_1.Adapter {
5916
6369
  return { ok: false, error: e.message };
5917
6370
  }
5918
6371
  }
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);
6372
+ /**
6373
+ * The saved configuration with the speech settings of an unsaved form on top. Only the speech fields count,
6374
+ * and a key only when one was typed: protected fields never reach the browser, so they are empty there.
6375
+ * Without fallbacks: a test must show whether the chosen engine works, not whether the reserve does.
6376
+ */
6377
+ withVoiceForm(form) {
6378
+ const cfg = { ...this.config, sttFallback: '', ttsFallback: '' };
6379
+ for (const key of VOICE_FORM_FIELDS) {
6380
+ const value = form[key];
6381
+ if (typeof value !== 'string' || (VOICE_FORM_SECRETS.includes(key) && !value.trim())) {
6382
+ continue;
6383
+ }
6384
+ cfg[key] = value;
6385
+ }
6386
+ return cfg;
6387
+ }
6388
+ /** Build the TTS engine of a configuration (throws when no voice key is available). */
6389
+ async buildTtsEngine(cfg = this.config) {
6390
+ const creds = await (0, credentials_1.resolveVoiceCredentials)(this, cfg, await this.speechLlmKey());
5924
6391
  return (0, engines_1.createTtsEngine)(cfg.ttsProvider || 'openai', this.voiceContext(cfg, creds));
5925
6392
  }
6393
+ /**
6394
+ * Speech recognition for the voice test of the settings: a recording of the browser, an uploaded file or the
6395
+ * test sentence the TTS just spoke. WAV is read directly, anything else goes through ffmpeg.
6396
+ */
6397
+ async transcribeForTest(msg) {
6398
+ try {
6399
+ if (!msg.audio) {
6400
+ return { error: 'no audio' };
6401
+ }
6402
+ const cfg = msg.config ? this.withVoiceForm(msg.config) : this.config;
6403
+ const data = Buffer.from(msg.audio, 'base64');
6404
+ const { pcm, sampleRate } = (msg.format || 'wav') === 'wav' && data.toString('latin1', 0, 4) === 'RIFF'
6405
+ ? (0, openrouter_1.pcmFromResponse)(data, '')
6406
+ : await this.decodeAudioBufferToPcm(data);
6407
+ const creds = await (0, credentials_1.resolveVoiceCredentials)(this, cfg, await this.speechLlmKey());
6408
+ const stt = (0, engines_1.createSttEngine)(cfg.sttProvider || 'openai', this.voiceContext(cfg, creds));
6409
+ const language = msg.language || cfg.voiceLanguage || this.language || '';
6410
+ const start = Date.now();
6411
+ const text = await stt.transcribe(pcm, sampleRate, language, await this.buildSttHints());
6412
+ return { text, ms: Date.now() - start, audioMs: Math.round((pcm.length / 2 / sampleRate) * 1000) };
6413
+ }
6414
+ catch (e) {
6415
+ return { error: e.message };
6416
+ }
6417
+ }
5926
6418
  /** True if backend TTS can run (a voice key is configured) — drives the Chat play button. */
5927
6419
  async isTtsAvailable() {
5928
6420
  try {
@@ -5934,15 +6426,23 @@ class Assistant extends adapter_core_1.Adapter {
5934
6426
  }
5935
6427
  }
5936
6428
  /** Synthesize `text` to a base64 WAV via the configured TTS engine (used by the Chat play button). */
5937
- async synthesizeToWav(text, language) {
6429
+ async synthesizeToWav(text, language, cfg = this.config) {
5938
6430
  if (!text.trim()) {
5939
6431
  return { error: 'empty text' };
5940
6432
  }
5941
6433
  try {
5942
- const tts = await this.buildTtsEngine();
5943
- const lang = language || this.config.voiceLanguage || this.language || '';
6434
+ const tts = await this.buildTtsEngine(cfg);
6435
+ const lang = language || cfg.voiceLanguage || this.language || '';
6436
+ const start = Date.now();
5944
6437
  const { pcm, sampleRate } = await tts.synthesize(text, lang);
5945
- return { audio: this.pcmToWav(pcm, sampleRate).toString('base64'), mime: 'audio/wav' };
6438
+ return {
6439
+ audio: this.pcmToWav(pcm, sampleRate).toString('base64'),
6440
+ mime: 'audio/wav',
6441
+ sampleRate,
6442
+ // a wrong rate shows up as an implausible length of the spoken text
6443
+ audioMs: Math.round((pcm.length / 2 / sampleRate) * 1000),
6444
+ ms: Date.now() - start,
6445
+ };
5946
6446
  }
5947
6447
  catch (e) {
5948
6448
  return { error: e.message };
@@ -5974,7 +6474,8 @@ class Assistant extends adapter_core_1.Adapter {
5974
6474
  async getModels(msg) {
5975
6475
  const cfg = this.config;
5976
6476
  const provider = msg.provider || cfg.provider || 'openai';
5977
- if (provider === 'none') {
6477
+ // `system`: the model is chosen in the system settings of admin
6478
+ if (provider === 'none' || provider === 'pi-agent' || provider === 'system') {
5978
6479
  return [];
5979
6480
  }
5980
6481
  const apiKey = (0, llm_1.effectiveApiKey)(provider, await (0, credentials_1.resolveApiKey)(this, cfg, {
@@ -6009,6 +6510,19 @@ class Assistant extends adapter_core_1.Adapter {
6009
6510
  if (provider === 'none') {
6010
6511
  return { error: 'No LLM provider selected.' };
6011
6512
  }
6513
+ if (provider === 'pi-agent') {
6514
+ return { error: 'pi-agent has its own connection test in its instance settings.' };
6515
+ }
6516
+ if (provider === 'system') {
6517
+ const llm = await (0, systemAi_1.resolveSystemLlm)(this, await (0, systemAi_1.readSystemAi)(this), true);
6518
+ if ('error' in llm) {
6519
+ return { error: llm.error };
6520
+ }
6521
+ const res = await new llm_1.LlmAgent({ ...llm, maxTokens: 16, tools: [], log: this.log }).testConnection();
6522
+ return res.ok
6523
+ ? { result: `Connection OK (${llm.provider} · ${llm.model || 'auto'})` }
6524
+ : { error: res.error || 'unknown error' };
6525
+ }
6012
6526
  const apiKey = (0, llm_1.effectiveApiKey)(provider, await (0, credentials_1.resolveApiKey)(this, cfg, {
6013
6527
  credentialType: msg.credentialType,
6014
6528
  apiKey: msg.apiKey,