node-red-contrib-knx-ultimate 6.1.1 → 6.2.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.
@@ -5,6 +5,23 @@ const fs = require('fs')
5
5
  const path = require('path')
6
6
  const { spawn } = require('child_process')
7
7
  const { getRequestAccessToken, normalizeAuthFromAccessTokenQuery } = require('./utils/httpAdminAccessToken')
8
+ const {
9
+ HOME_MEMORY_MAX_EDUCATION_CHARS,
10
+ HOME_MEMORY_MAX_SEMANTIC_OBJECTS,
11
+ addBoundedKnxAiNotification,
12
+ addBoundedKnxAiObservation,
13
+ buildKnxAiHomeMemoryMarkdown,
14
+ buildKnxAiProactiveFallback,
15
+ clampHomeMemoryKb,
16
+ classifyKnxAiOpenState,
17
+ createEmptyKnxAiHomeMemory,
18
+ enrichKnxAiHomeCatalog,
19
+ isKnxAiQuietTime,
20
+ normalizeKnxAiHomeMemory,
21
+ normalizeHomeLanguage,
22
+ parseKnxAiHomeMemoryMarkdown,
23
+ updateKnxAiCoverHabit
24
+ } = require('./utils/knxAiHomeMemory')
8
25
  let googleTranslateTTS = null
9
26
  try {
10
27
  googleTranslateTTS = require('google-translate-tts')
@@ -738,6 +755,626 @@ const extractJsonFragmentFromText = (value) => {
738
755
  throw new Error(`The LLM response did not contain valid JSON${preview ? ` (preview: ${preview})` : ''}`)
739
756
  }
740
757
 
758
+ const parseKnxAiConversationResponse = (value) => {
759
+ const parsed = extractJsonFragmentFromText(value)
760
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
761
+ throw new Error('The LLM conversation response must be a JSON object')
762
+ }
763
+ const reply = String(parsed.reply !== undefined
764
+ ? parsed.reply
765
+ : parsed.answer !== undefined
766
+ ? parsed.answer
767
+ : parsed.text !== undefined
768
+ ? parsed.text
769
+ : '').trim()
770
+ const commands = Array.isArray(parsed.commands)
771
+ ? parsed.commands
772
+ : Array.isArray(parsed.actions)
773
+ ? parsed.actions
774
+ : []
775
+ const language = String(parsed.language !== undefined
776
+ ? parsed.language
777
+ : parsed.locale !== undefined
778
+ ? parsed.locale
779
+ : '').trim()
780
+ return { reply, commands, language }
781
+ }
782
+
783
+ const extractKnxAiQuestion = (msg) => {
784
+ const source = msg && typeof msg === 'object' ? msg : {}
785
+ if (source.prompt !== undefined && source.prompt !== null) return String(source.prompt).trim()
786
+ if (typeof source.payload === 'string') return source.payload.trim()
787
+ if (source.payload && typeof source.payload === 'object') {
788
+ if (typeof source.payload.content === 'string') return source.payload.content.trim()
789
+ if (typeof source.payload.text === 'string') return source.payload.text.trim()
790
+ if (source.payload.message && typeof source.payload.message.text === 'string') return source.payload.message.text.trim()
791
+ }
792
+ if (source.originalMessage && typeof source.originalMessage === 'object') {
793
+ if (typeof source.originalMessage.text === 'string') return source.originalMessage.text.trim()
794
+ if (source.originalMessage.message && typeof source.originalMessage.message.text === 'string') return source.originalMessage.message.text.trim()
795
+ }
796
+ return source.payload === undefined ? '' : safeStringify(source.payload)
797
+ }
798
+
799
+ const resolveKnxAiSessionId = (msg) => {
800
+ const source = msg && typeof msg === 'object' ? msg : {}
801
+ const candidates = [
802
+ source.knxAi && source.knxAi.sessionId,
803
+ source.sessionId,
804
+ source.chatId,
805
+ source.payload && typeof source.payload === 'object' ? source.payload.chatId : '',
806
+ source.payload && source.payload.chat && typeof source.payload.chat === 'object' ? source.payload.chat.id : '',
807
+ source.originalMessage && source.originalMessage.chat ? source.originalMessage.chat.id : '',
808
+ source.originalMessage && source.originalMessage.message && source.originalMessage.message.chat
809
+ ? source.originalMessage.message.chat.id
810
+ : ''
811
+ ]
812
+ const hit = candidates.find(candidate => candidate !== undefined && candidate !== null && String(candidate).trim() !== '')
813
+ return String(hit === undefined ? 'default' : hit).trim().slice(0, 160) || 'default'
814
+ }
815
+
816
+ const classifyKnxAiConfirmation = ({ msg, question, topic } = {}) => {
817
+ const source = msg && typeof msg === 'object' ? msg : {}
818
+ if (source.knxAi && source.knxAi.confirm === true) return 'confirm'
819
+ if (source.knxAi && source.knxAi.confirm === false) return 'cancel'
820
+ const topicValue = String(topic || '').trim().toLowerCase()
821
+ if (topicValue === 'confirm') return 'confirm'
822
+ if (topicValue === 'cancel') return 'cancel'
823
+ const normalized = String(question || '')
824
+ .normalize('NFKD')
825
+ .replace(/[\u0300-\u036f]/g, '')
826
+ .trim()
827
+ .toLowerCase()
828
+ .replace(/[.!?。!?]+$/g, '')
829
+ .replace(/\s+/g, ' ')
830
+ const confirmations = new Set([
831
+ 'confirm', 'confirmed', 'yes', 'y', 'ok', 'proceed',
832
+ 'conferma', 'confermo', 'si', 'procedi',
833
+ 'confirmer', 'confirme', 'oui',
834
+ 'bestatigen', 'bestatige', 'ja',
835
+ 'confirmar', 'confirmo', 'adelante',
836
+ '确认', '是'
837
+ ])
838
+ const cancellations = new Set([
839
+ 'cancel', 'cancelled', 'no', 'n', 'stop',
840
+ 'annulla', 'annullo', 'no grazie',
841
+ 'annuler', 'annule', 'non',
842
+ 'abbrechen', 'abbruch', 'nein',
843
+ 'cancelar', 'cancelo',
844
+ '取消', '否'
845
+ ])
846
+ if (confirmations.has(normalized)) return 'confirm'
847
+ if (cancellations.has(normalized)) return 'cancel'
848
+ return 'none'
849
+ }
850
+
851
+ const detectKnxAiLanguageFromText = (value) => {
852
+ const raw = String(value || '').trim()
853
+ if (!raw) return ''
854
+ if (/[\u3400-\u9fff]/u.test(raw)) return 'zh'
855
+ const normalized = raw
856
+ .normalize('NFKD')
857
+ .replace(/[\u0300-\u036f]/g, '')
858
+ .toLowerCase()
859
+ const tokens = new Set(normalized.match(/[a-z]+/g) || [])
860
+ const dictionaries = {
861
+ it: ['accendi', 'spegni', 'luce', 'luci', 'soggiorno', 'cucina', 'apri', 'chiudi', 'alza', 'abbassa', 'tapparella', 'tapparelle'],
862
+ en: ['turn', 'switch', 'light', 'lights', 'living', 'room', 'kitchen', 'open', 'close', 'raise', 'lower', 'blind', 'blinds'],
863
+ de: ['schalte', 'licht', 'lichter', 'wohnzimmer', 'kuche', 'offne', 'schliesse', 'hoch', 'runter', 'rollladen'],
864
+ fr: ['allume', 'eteins', 'lumiere', 'lumieres', 'salon', 'cuisine', 'ouvre', 'ferme', 'monte', 'baisse', 'volet'],
865
+ es: ['enciende', 'apaga', 'luz', 'luces', 'salon', 'cocina', 'abre', 'cierra', 'sube', 'baja', 'persiana']
866
+ }
867
+ const scores = Object.entries(dictionaries).map(([language, words]) => ({
868
+ language,
869
+ score: words.reduce((total, word) => total + (tokens.has(word) ? 1 : 0), 0)
870
+ })).sort((a, b) => b.score - a.score)
871
+ if (!scores[0] || scores[0].score === 0) return ''
872
+ if (scores[1] && scores[0].score === scores[1].score) return ''
873
+ return scores[0].language
874
+ }
875
+
876
+ const resolveKnxAiLanguage = (msg, fallback = 'en', question = '', responseLanguage = '') => {
877
+ const source = msg && typeof msg === 'object' ? msg : {}
878
+ const explicitCandidates = [
879
+ source.knxAi && source.knxAi.language,
880
+ source.language,
881
+ source.payload && typeof source.payload === 'object' ? source.payload.language : ''
882
+ ]
883
+ const inferredCandidates = [
884
+ responseLanguage,
885
+ detectKnxAiLanguageFromText(question),
886
+ source.originalMessage && source.originalMessage.from ? source.originalMessage.from.language_code : '',
887
+ source.originalMessage && source.originalMessage.message && source.originalMessage.message.from
888
+ ? source.originalMessage.message.from.language_code
889
+ : ''
890
+ ]
891
+ const supported = new Set(['en', 'it', 'de', 'fr', 'es', 'zh'])
892
+ for (const candidate of explicitCandidates.concat(inferredCandidates)) {
893
+ if (candidate === undefined || candidate === null || String(candidate).trim() === '') continue
894
+ const language = normalizeLanguageCode(candidate, '')
895
+ if (supported.has(language)) return language
896
+ }
897
+ const fallbackLanguage = normalizeLanguageCode(fallback, 'en')
898
+ return supported.has(fallbackLanguage) ? fallbackLanguage : 'en'
899
+ }
900
+
901
+ const getKnxAiConfirmationCopy = (language) => {
902
+ const copies = {
903
+ en: {
904
+ preview: 'KNX changes awaiting confirmation',
905
+ instruction: 'Reply exactly CONFIRM to proceed or CANCEL to discard them. The request expires in 5 minutes.',
906
+ confirmLabel: 'Confirm',
907
+ cancelLabel: 'Cancel',
908
+ confirmed: count => `Confirmed: ${count} KNX command(s) forwarded to the flow. Execution still requires KNX status feedback.`,
909
+ cancelled: 'Cancelled: no KNX command was sent.',
910
+ expired: 'The pending KNX command request has expired. Please repeat the original request.',
911
+ missing: 'There is no KNX command request awaiting confirmation.',
912
+ invalid: details => `The pending KNX commands are no longer valid and were not sent: ${details}.`
913
+ },
914
+ it: {
915
+ preview: 'Modifiche KNX in attesa di conferma',
916
+ instruction: 'Rispondi esattamente CONFERMA per procedere oppure ANNULLA per eliminarle. La richiesta scade tra 5 minuti.',
917
+ confirmLabel: 'Conferma',
918
+ cancelLabel: 'Annulla',
919
+ confirmed: count => `Confermato: ${count} comando/i KNX inoltrato/i al flow. L'esecuzione deve comunque essere verificata tramite lo stato KNX.`,
920
+ cancelled: 'Annullato: non è stato inviato alcun comando KNX.',
921
+ expired: 'La richiesta di comandi KNX è scaduta. Ripeti la richiesta originale.',
922
+ missing: 'Non ci sono comandi KNX in attesa di conferma.',
923
+ invalid: details => `I comandi KNX in attesa non sono più validi e non sono stati inviati: ${details}.`
924
+ },
925
+ de: {
926
+ preview: 'KNX-Änderungen warten auf Bestätigung',
927
+ instruction: 'Antworte genau mit BESTÄTIGEN oder ABBRECHEN. Die Anfrage läuft nach 5 Minuten ab.',
928
+ confirmLabel: 'Bestätigen',
929
+ cancelLabel: 'Abbrechen',
930
+ confirmed: count => `Bestätigt: ${count} KNX-Befehl(e) an den Flow weitergegeben. Die Ausführung muss über KNX-Statusfeedback geprüft werden.`,
931
+ cancelled: 'Abgebrochen: Es wurde kein KNX-Befehl gesendet.',
932
+ expired: 'Die ausstehende KNX-Anfrage ist abgelaufen. Bitte die ursprüngliche Anfrage wiederholen.',
933
+ missing: 'Es wartet keine KNX-Anfrage auf Bestätigung.',
934
+ invalid: details => `Die ausstehenden KNX-Befehle sind nicht mehr gültig und wurden nicht gesendet: ${details}.`
935
+ },
936
+ fr: {
937
+ preview: 'Modifications KNX en attente de confirmation',
938
+ instruction: 'Répondez exactement CONFIRMER pour continuer ou ANNULER pour abandonner. La demande expire dans 5 minutes.',
939
+ confirmLabel: 'Confirmer',
940
+ cancelLabel: 'Annuler',
941
+ confirmed: count => `Confirmé : ${count} commande(s) KNX transmise(s) au flow. L'exécution doit encore être vérifiée par un retour d'état KNX.`,
942
+ cancelled: 'Annulé : aucune commande KNX n’a été envoyée.',
943
+ expired: 'La demande de commandes KNX a expiré. Répétez la demande initiale.',
944
+ missing: 'Aucune commande KNX n’est en attente de confirmation.',
945
+ invalid: details => `Les commandes KNX en attente ne sont plus valides et n’ont pas été envoyées : ${details}.`
946
+ },
947
+ es: {
948
+ preview: 'Cambios KNX pendientes de confirmación',
949
+ instruction: 'Responde exactamente CONFIRMAR para continuar o CANCELAR para descartarlos. La solicitud caduca en 5 minutos.',
950
+ confirmLabel: 'Confirmar',
951
+ cancelLabel: 'Cancelar',
952
+ confirmed: count => `Confirmado: ${count} comando(s) KNX enviado(s) al flow. La ejecución aún debe verificarse mediante el estado KNX.`,
953
+ cancelled: 'Cancelado: no se envió ningún comando KNX.',
954
+ expired: 'La solicitud de comandos KNX ha caducado. Repite la solicitud original.',
955
+ missing: 'No hay comandos KNX pendientes de confirmación.',
956
+ invalid: details => `Los comandos KNX pendientes ya no son válidos y no se enviaron: ${details}.`
957
+ },
958
+ zh: {
959
+ preview: '等待确认的 KNX 更改',
960
+ instruction: '请准确回复“确认”以继续,或回复“取消”以放弃。请求将在 5 分钟后过期。',
961
+ confirmLabel: '确认',
962
+ cancelLabel: '取消',
963
+ confirmed: count => `已确认:${count} 条 KNX 命令已转发到 flow。仍需通过 KNX 状态反馈确认执行结果。`,
964
+ cancelled: '已取消:未发送任何 KNX 命令。',
965
+ expired: '待处理的 KNX 命令请求已过期,请重新发送原始请求。',
966
+ missing: '当前没有等待确认的 KNX 命令。',
967
+ invalid: details => `待处理的 KNX 命令已失效,未发送:${details}。`
968
+ }
969
+ }
970
+ return copies[language] || copies.en
971
+ }
972
+
973
+ const getKnxAiReadCopy = (language) => {
974
+ const copies = {
975
+ en: {
976
+ heading: 'Updated KNX readings',
977
+ noResponse: 'The KNX read request was sent, but no device replied within the timeout.',
978
+ partial: 'No response received for'
979
+ },
980
+ it: {
981
+ heading: 'Letture KNX aggiornate',
982
+ noResponse: 'La richiesta di lettura KNX è stata inviata, ma nessun dispositivo ha risposto entro il timeout.',
983
+ partial: 'Nessuna risposta ricevuta per'
984
+ },
985
+ de: {
986
+ heading: 'Aktualisierte KNX-Messwerte',
987
+ noResponse: 'Die KNX-Leseanfrage wurde gesendet, aber innerhalb des Timeouts hat kein Gerät geantwortet.',
988
+ partial: 'Keine Antwort erhalten für'
989
+ },
990
+ fr: {
991
+ heading: 'Lectures KNX actualisées',
992
+ noResponse: 'La demande de lecture KNX a été envoyée, mais aucun appareil n’a répondu avant l’expiration du délai.',
993
+ partial: 'Aucune réponse reçue pour'
994
+ },
995
+ es: {
996
+ heading: 'Lecturas KNX actualizadas',
997
+ noResponse: 'La solicitud de lectura KNX se envió, pero ningún dispositivo respondió antes de agotarse el tiempo.',
998
+ partial: 'No se recibió respuesta para'
999
+ },
1000
+ zh: {
1001
+ heading: '已更新的 KNX 读数',
1002
+ noResponse: 'KNX 读取请求已发送,但在超时前没有设备响应。',
1003
+ partial: '未收到响应'
1004
+ }
1005
+ }
1006
+ return copies[language] || copies.en
1007
+ }
1008
+
1009
+ const formatKnxAiReadResults = ({ operations, results, language }) => {
1010
+ const reads = Array.isArray(operations) ? operations : []
1011
+ const settled = Array.isArray(results) ? results : []
1012
+ const copy = getKnxAiReadCopy(language)
1013
+ const values = []
1014
+ const missing = []
1015
+ reads.forEach((operation, index) => {
1016
+ const result = settled[index]
1017
+ const label = String(operation && (operation.label || operation.destination) ? (operation.label || operation.destination) : '').trim()
1018
+ if (!result || result.status !== 'fulfilled' || !result.value) {
1019
+ missing.push(label || String(operation && operation.destination ? operation.destination : '').trim())
1020
+ return
1021
+ }
1022
+ const telegram = result.value
1023
+ const rawValue = telegram.payload
1024
+ const value = rawValue && typeof rawValue === 'object' ? safeStringify(rawValue) : String(rawValue)
1025
+ const unit = String(telegram.payloadmeasureunit || '').trim()
1026
+ values.push(`- ${label || operation.destination}: ${value}${unit ? ` ${unit}` : ''}`)
1027
+ })
1028
+ if (values.length === 0) return copy.noResponse
1029
+ const lines = [`${copy.heading}:`, ...values]
1030
+ if (missing.length > 0) lines.push('', `${copy.partial}: ${missing.filter(Boolean).join(', ')}.`)
1031
+ return lines.join('\n')
1032
+ }
1033
+
1034
+ const buildKnxAiConfirmationRequest = ({
1035
+ sessionId,
1036
+ expiresAt,
1037
+ commandCount,
1038
+ copy
1039
+ }) => {
1040
+ const resolvedSessionId = String(sessionId || 'default')
1041
+ const resolvedExpiresAt = Number(expiresAt || 0)
1042
+ const buildAction = ({ id, label, confirm }) => ({
1043
+ id,
1044
+ label,
1045
+ callbackData: id,
1046
+ message: {
1047
+ topic: id,
1048
+ knxAi: {
1049
+ confirm,
1050
+ sessionId: resolvedSessionId
1051
+ }
1052
+ }
1053
+ })
1054
+ return {
1055
+ required: true,
1056
+ status: 'pending',
1057
+ sessionId: resolvedSessionId,
1058
+ expiresAt: resolvedExpiresAt,
1059
+ expiresAtIso: resolvedExpiresAt > 0 ? new Date(resolvedExpiresAt).toISOString() : '',
1060
+ commandCount: Math.max(0, Number(commandCount) || 0),
1061
+ actions: [
1062
+ buildAction({ id: 'confirm', label: copy.confirmLabel, confirm: true }),
1063
+ buildAction({ id: 'cancel', label: copy.cancelLabel, confirm: false })
1064
+ ]
1065
+ }
1066
+ }
1067
+
1068
+ const cloneKnxAiInputMessage = (inputMessage, cloneMessage, onError) => {
1069
+ const source = inputMessage && typeof inputMessage === 'object' ? inputMessage : {}
1070
+ if (typeof cloneMessage === 'function') {
1071
+ try {
1072
+ const cloned = cloneMessage(source)
1073
+ if (cloned && typeof cloned === 'object') return cloned
1074
+ } catch (error) {
1075
+ try {
1076
+ if (typeof onError === 'function') onError(error)
1077
+ } catch (reportError) {
1078
+ // Reporting a clone error must never propagate to the Node-RED runtime.
1079
+ }
1080
+ // Fall back to a shallow copy if a custom message property cannot be cloned.
1081
+ }
1082
+ }
1083
+ try {
1084
+ return Object.assign({}, source)
1085
+ } catch (error) {
1086
+ try {
1087
+ if (typeof onError === 'function') onError(error)
1088
+ } catch (reportError) {
1089
+ // Reporting a clone error must never propagate to the Node-RED runtime.
1090
+ }
1091
+ return {}
1092
+ }
1093
+ }
1094
+
1095
+ const safeKnxAiSend = ({ outputs, send, onError }) => {
1096
+ try {
1097
+ if (typeof send !== 'function') throw new Error('KNX AI output sender is unavailable')
1098
+ send(outputs)
1099
+ return true
1100
+ } catch (error) {
1101
+ try {
1102
+ if (typeof onError === 'function') onError(error)
1103
+ } catch (reportError) {
1104
+ // Reporting an output error must never propagate to the Node-RED runtime.
1105
+ }
1106
+ return false
1107
+ }
1108
+ }
1109
+
1110
+ const compileKnxAiChatAdapter = ({ code, direction = 'chat' } = {}) => {
1111
+ const source = String(code || '').trim()
1112
+ if (!source) return null
1113
+ const adapterDirection = String(direction || 'chat').trim() || 'chat'
1114
+ try {
1115
+ return {
1116
+ direction: adapterDirection,
1117
+ run: new Function('msg', 'inputMessage', 'node', 'RED', `"use strict";\n${source}`) // eslint-disable-line no-new-func
1118
+ }
1119
+ } catch (error) {
1120
+ throw new Error(`Invalid KNX AI ${adapterDirection} adapter: ${error.message || error}`)
1121
+ }
1122
+ }
1123
+
1124
+ const executeKnxAiChatAdapter = ({
1125
+ adapter,
1126
+ msg,
1127
+ inputMessage,
1128
+ node,
1129
+ RED
1130
+ } = {}) => {
1131
+ if (!adapter || typeof adapter.run !== 'function') return msg
1132
+ const result = adapter.run(msg, inputMessage, node, RED)
1133
+ if (result && typeof result.then === 'function') {
1134
+ throw new Error(`KNX AI ${adapter.direction || 'chat'} adapter must be synchronous`)
1135
+ }
1136
+ if (result === undefined || result === null) return null
1137
+ if (!result || typeof result !== 'object' || Array.isArray(result)) {
1138
+ throw new Error(`KNX AI ${adapter.direction || 'chat'} adapter must return msg or no value`)
1139
+ }
1140
+ return result
1141
+ }
1142
+
1143
+ const buildKnxAiUniversalMessage = ({
1144
+ command,
1145
+ question,
1146
+ sessionId,
1147
+ confirmed,
1148
+ index,
1149
+ inputMessage
1150
+ }) => {
1151
+ const operation = command && typeof command === 'object' ? command : {}
1152
+ const event = operation.event === 'GroupValue_Read' ? 'GroupValue_Read' : 'GroupValue_Write'
1153
+ const outputMessage = {
1154
+ topic: operation.destination,
1155
+ destination: operation.destination,
1156
+ dpt: operation.dpt,
1157
+ payload: event === 'GroupValue_Read' ? '' : operation.payload,
1158
+ event,
1159
+ inputMessage,
1160
+ knxAi: {
1161
+ type: event === 'GroupValue_Read' ? 'knx_read' : 'knx_command',
1162
+ source: 'llm',
1163
+ question,
1164
+ sessionId,
1165
+ confirmed: event === 'GroupValue_Write' && confirmed === true,
1166
+ index,
1167
+ label: operation.label || '',
1168
+ reason: operation.reason || ''
1169
+ }
1170
+ }
1171
+ if (event === 'GroupValue_Read') outputMessage.readstatus = true
1172
+ return outputMessage
1173
+ }
1174
+
1175
+ const formatKnxAiCommandPreview = ({ commands, copy }) => {
1176
+ const lines = (Array.isArray(commands) ? commands : []).map((command, index) => {
1177
+ const payload = typeof command.payload === 'string' ? command.payload : safeStringify(command.payload)
1178
+ return `${index + 1}. ${command.label || command.destination} — ${command.destination} / DPT ${command.dpt} → ${payload}`
1179
+ })
1180
+ return [
1181
+ copy.preview + ':',
1182
+ ...lines,
1183
+ '',
1184
+ copy.instruction
1185
+ ].join('\n')
1186
+ }
1187
+
1188
+ const validateKnxAiPayloadForDpt = ({ dpt, payload }) => {
1189
+ const resolved = dptlib.resolve(dpt)
1190
+ const base = resolved && resolved.basetype ? resolved.basetype : {}
1191
+ const subtype = resolved && resolved.subtype ? resolved.subtype : {}
1192
+ const main = String(dpt || '').split('.')[0]
1193
+
1194
+ if (base.valuetype === 'composite' && (!payload || typeof payload !== 'object' || Array.isArray(payload))) {
1195
+ throw new Error(`DPT ${dpt} requires an object payload`)
1196
+ }
1197
+ if (main === '1' && typeof payload !== 'boolean') {
1198
+ throw new Error(`DPT ${dpt} requires a boolean payload`)
1199
+ }
1200
+ if (main === '3') {
1201
+ const direction = Number(payload && payload.decr_incr)
1202
+ const data = Number(payload && payload.data)
1203
+ if (![0, 1].includes(direction) || !Number.isInteger(data) || data < 0 || data > 7) {
1204
+ throw new Error(`DPT ${dpt} requires {decr_incr:0|1,data:0..7}`)
1205
+ }
1206
+ }
1207
+ if (main === '232') {
1208
+ const channels = ['red', 'green', 'blue'].map(key => Number(payload && payload[key]))
1209
+ if (channels.some(value => !Number.isInteger(value) || value < 0 || value > 255)) {
1210
+ throw new Error(`DPT ${dpt} requires {red,green,blue} values from 0 to 255`)
1211
+ }
1212
+ }
1213
+
1214
+ if (typeof payload === 'number') {
1215
+ const candidateRange = Array.isArray(subtype.scalar_range) && subtype.scalar_range.length === 2
1216
+ ? subtype.scalar_range
1217
+ : Array.isArray(subtype.range) && subtype.range.length === 2
1218
+ ? subtype.range
1219
+ : Array.isArray(base.range) && base.range.length === 2
1220
+ ? base.range
1221
+ : null
1222
+ if (candidateRange && Number.isFinite(Number(candidateRange[0])) && Number.isFinite(Number(candidateRange[1]))) {
1223
+ const min = Number(candidateRange[0])
1224
+ const max = Number(candidateRange[1])
1225
+ if (payload < min || payload > max) throw new Error(`DPT ${dpt} payload must be between ${min} and ${max}`)
1226
+ }
1227
+ }
1228
+
1229
+ if (subtype.enc && typeof subtype.enc === 'object' && !Array.isArray(subtype.enc) && main !== '1') {
1230
+ const allowed = new Set(Object.keys(subtype.enc).map(key => String(key)))
1231
+ if (!allowed.has(String(payload))) throw new Error(`DPT ${dpt} payload is not an allowed enumerated value`)
1232
+ }
1233
+
1234
+ if (resolved && typeof resolved.formatAPDU === 'function') resolved.formatAPDU(payload)
1235
+ return payload
1236
+ }
1237
+
1238
+ const coerceKnxAiCommandPayload = (value, { dpt } = {}) => {
1239
+ const dptId = String(dpt || '').trim()
1240
+ const parsed = parseActuatorPayloadInput(value)
1241
+ const main = dptId.split('.')[0]
1242
+ if (main === '1') {
1243
+ if (parsed === true || parsed === false) return parsed
1244
+ if (parsed === 1) return true
1245
+ if (parsed === 0) return false
1246
+ const rawBoolean = typeof parsed === 'string' ? parsed.trim() : String(parsed)
1247
+ const normalized = rawBoolean.toLowerCase()
1248
+ if (['true', '1', 'on'].includes(normalized)) return true
1249
+ if (['false', '0', 'off'].includes(normalized)) return false
1250
+ const normalizedLabel = normalizeSignalText(rawBoolean)
1251
+ const booleanOption = getDptValueOptions(dptId).find(item => {
1252
+ return normalizeSignalText(item.label) === normalizedLabel
1253
+ })
1254
+ if (booleanOption) {
1255
+ const optionValue = parseActuatorPayloadInput(booleanOption.value)
1256
+ if (optionValue === true || optionValue === 1) return true
1257
+ if (optionValue === false || optionValue === 0) return false
1258
+ }
1259
+ throw new Error(`DPT ${dptId} payload must be true/false, 1/0, on/off, or an exact ETS value label`)
1260
+ }
1261
+ if (typeof parsed !== 'string') return parsed
1262
+ const raw = parsed.trim()
1263
+ if (main === '16') return raw
1264
+ const normalizedRaw = normalizeSignalText(raw)
1265
+ const option = getDptValueOptions(dptId).find(item => {
1266
+ return String(item.value) === raw || normalizeSignalText(item.label) === normalizedRaw
1267
+ })
1268
+ if (option) return parseActuatorPayloadInput(option.value)
1269
+ throw new Error(`DPT ${dptId} payload must be a typed JSON value or an exact ETS value label`)
1270
+ }
1271
+
1272
+ const resolveKnxAiOperationEvent = (candidate) => {
1273
+ const item = candidate && typeof candidate === 'object' ? candidate : {}
1274
+ const raw = String(item.event || item.operation || item.action || '').trim().toLowerCase()
1275
+ if (['groupvalue_read', 'read', 'query', 'request_status'].includes(raw)) return 'GroupValue_Read'
1276
+ return 'GroupValue_Write'
1277
+ }
1278
+
1279
+ const normalizeKnxAiCommandCandidates = ({
1280
+ commands,
1281
+ catalog,
1282
+ maxCommands = 5,
1283
+ maxReadCommands = 20,
1284
+ coercePayload = value => value
1285
+ } = {}) => {
1286
+ const sourceCommands = Array.isArray(commands) ? commands : []
1287
+ const safeCatalog = Array.isArray(catalog) ? catalog : []
1288
+ const catalogByGa = new Map(safeCatalog.map(item => [String(item && item.ga ? item.ga : '').trim(), item]))
1289
+ const accepted = []
1290
+ const rejected = []
1291
+ const writeLimit = Math.max(1, Math.min(20, Number(maxCommands) || 5))
1292
+ const readLimit = Math.max(1, Math.min(50, Number(maxReadCommands) || 20))
1293
+ const totalLimit = writeLimit + readLimit
1294
+ let acceptedWrites = 0
1295
+ let acceptedReads = 0
1296
+ let writeLimitReported = false
1297
+ let readLimitReported = false
1298
+
1299
+ sourceCommands.slice(0, totalLimit).forEach((candidate, index) => {
1300
+ try {
1301
+ const item = candidate && typeof candidate === 'object' ? candidate : {}
1302
+ const event = resolveKnxAiOperationEvent(item)
1303
+ const destination = String(item.destination || item.ga || item.groupAddress || item.address || '').trim()
1304
+ if (!destination) throw new Error('missing destination')
1305
+ const catalogItem = catalogByGa.get(destination)
1306
+ if (!catalogItem) throw new Error('destination is not present in the imported ETS catalog')
1307
+ if (event === 'GroupValue_Write' && String(catalogItem.role || '').trim().toLowerCase() !== 'command') {
1308
+ throw new Error('destination is not classified as a command group address')
1309
+ }
1310
+ const catalogDpt = String(catalogItem.dpt || '').trim()
1311
+ if (!catalogDpt) throw new Error('the ETS catalog has no DPT for this destination')
1312
+ const requestedDpt = String(item.dpt || '').trim()
1313
+ if (requestedDpt && requestedDpt !== catalogDpt) {
1314
+ throw new Error(`requested DPT ${requestedDpt} does not match ETS DPT ${catalogDpt}`)
1315
+ }
1316
+ if (event === 'GroupValue_Read') {
1317
+ if (acceptedReads >= readLimit) {
1318
+ if (readLimitReported) return
1319
+ readLimitReported = true
1320
+ throw new Error(`read limit exceeded (${readLimit})`)
1321
+ }
1322
+ acceptedReads += 1
1323
+ accepted.push({
1324
+ destination,
1325
+ dpt: catalogDpt,
1326
+ payload: '',
1327
+ readstatus: true,
1328
+ event,
1329
+ label: String(catalogItem.label || destination).trim(),
1330
+ reason: String(item.reason || '').trim(),
1331
+ sourceIndex: index
1332
+ })
1333
+ return
1334
+ }
1335
+ if (acceptedWrites >= writeLimit) {
1336
+ if (writeLimitReported) return
1337
+ writeLimitReported = true
1338
+ throw new Error(`command limit exceeded (${writeLimit})`)
1339
+ }
1340
+ const hasPayload = Object.prototype.hasOwnProperty.call(item, 'payload') || Object.prototype.hasOwnProperty.call(item, 'value')
1341
+ if (!hasPayload) throw new Error('missing payload')
1342
+ const rawPayload = Object.prototype.hasOwnProperty.call(item, 'payload') ? item.payload : item.value
1343
+ const payload = coercePayload(rawPayload, {
1344
+ dpt: catalogDpt,
1345
+ action: item.action,
1346
+ reason: item.reason,
1347
+ label: catalogItem.label,
1348
+ destination
1349
+ })
1350
+ validateKnxAiPayloadForDpt({ dpt: catalogDpt, payload })
1351
+ acceptedWrites += 1
1352
+ accepted.push({
1353
+ destination,
1354
+ dpt: catalogDpt,
1355
+ payload,
1356
+ event: 'GroupValue_Write',
1357
+ label: String(catalogItem.label || destination).trim(),
1358
+ reason: String(item.reason || '').trim(),
1359
+ sourceIndex: index
1360
+ })
1361
+ } catch (error) {
1362
+ rejected.push({
1363
+ sourceIndex: index,
1364
+ reason: error && error.message ? error.message : String(error)
1365
+ })
1366
+ }
1367
+ })
1368
+
1369
+ if (sourceCommands.length > totalLimit) {
1370
+ rejected.push({
1371
+ sourceIndex: totalLimit,
1372
+ reason: `operation limit exceeded (${totalLimit})`
1373
+ })
1374
+ }
1375
+ return { accepted, rejected }
1376
+ }
1377
+
741
1378
  const normalizeValueForCompare = (value) => {
742
1379
  if (value === undefined) return 'undefined'
743
1380
  if (value === null) return 'null'
@@ -2939,6 +3576,8 @@ const ensureOllamaServerRunning = async ({ baseUrl, autoStart = false, timeoutMs
2939
3576
  const isProbablyChatModelId = (id) => {
2940
3577
  const s = String(id || '').toLowerCase()
2941
3578
  if (!s) return false
3579
+ if (s === 'babbage-002' || s === 'davinci-002') return false
3580
+ if (s.includes('instruct')) return false
2942
3581
  if (s.includes('embedding')) return false
2943
3582
  if (s.includes('whisper')) return false
2944
3583
  if (s.includes('tts')) return false
@@ -2947,6 +3586,109 @@ const isProbablyChatModelId = (id) => {
2947
3586
  return true
2948
3587
  }
2949
3588
 
3589
+ const isChatCompletionsModelError = (value) => {
3590
+ const message = String(value || '').toLowerCase()
3591
+ return message.includes('not a chat model') ||
3592
+ message.includes('not supported in the v1/chat/completions endpoint') ||
3593
+ message.includes('only compatible with the legacy completions endpoint') ||
3594
+ message.includes('not chat completions') ||
3595
+ message.includes('does not support chat completions')
3596
+ }
3597
+
3598
+ const decorateChatCompletionsModelError = ({ error, model, url }) => {
3599
+ const originalMessage = String(error && error.message ? error.message : error || '').trim()
3600
+ const selectedModel = String(model || '').trim() || '(empty)'
3601
+ const endpoint = String(url || '').trim() || OPENAI_COMPAT_DEFAULT_CHAT_URL
3602
+ const decorated = new Error(
3603
+ `Model "${selectedModel}" is not compatible with the configured Chat Completions endpoint (${endpoint}). ` +
3604
+ 'Choose a chat-capable model, for example gpt-5.4 or gpt-4o-mini. ' +
3605
+ `Legacy /v1/completions models are not supported by KNX AI.${originalMessage ? ` Provider response: ${originalMessage}` : ''}`
3606
+ )
3607
+ if (error && error.status !== undefined) decorated.status = error.status
3608
+ if (error && error.response !== undefined) decorated.response = error.response
3609
+ return decorated
3610
+ }
3611
+
3612
+ const isUnsupportedTemperatureError = (value) => {
3613
+ const message = String(value || '').toLowerCase()
3614
+ return message.includes("unsupported value: 'temperature'") ||
3615
+ message.includes('unsupported parameter: temperature') ||
3616
+ (message.includes('temperature') && message.includes('only the default'))
3617
+ }
3618
+
3619
+ const isResponseFormatCompatibilityError = (value) => {
3620
+ const message = String(value || '')
3621
+ return message.includes("Unsupported parameter: 'response_format'") ||
3622
+ message.includes('Invalid schema for response_format') ||
3623
+ message.includes('response_format') ||
3624
+ message.includes('json_schema')
3625
+ }
3626
+
3627
+ const postOpenAiCompatibleChatWithFallbacks = async ({
3628
+ url,
3629
+ headers,
3630
+ body,
3631
+ timeoutMs,
3632
+ model,
3633
+ post = postJson
3634
+ }) => {
3635
+ let requestBody = Object.assign({}, body)
3636
+ let lastError = null
3637
+ const rejectedTokenParameters = new Set()
3638
+ const hasOwn = key => Object.prototype.hasOwnProperty.call(requestBody, key)
3639
+
3640
+ for (let attempt = 0; attempt < 6; attempt++) {
3641
+ try {
3642
+ return await post({ url, headers, body: requestBody, timeoutMs })
3643
+ } catch (error) {
3644
+ lastError = error
3645
+ const message = String(error && error.message ? error.message : '')
3646
+
3647
+ if (isChatCompletionsModelError(message)) {
3648
+ throw decorateChatCompletionsModelError({ error, model, url })
3649
+ }
3650
+
3651
+ if (isUnsupportedTemperatureError(message) && hasOwn('temperature')) {
3652
+ requestBody = Object.assign({}, requestBody)
3653
+ delete requestBody.temperature
3654
+ continue
3655
+ }
3656
+
3657
+ if (isResponseFormatCompatibilityError(message) && hasOwn('response_format')) {
3658
+ requestBody = Object.assign({}, requestBody)
3659
+ delete requestBody.response_format
3660
+ continue
3661
+ }
3662
+
3663
+ if (message.includes("Unsupported parameter: 'max_tokens'") && hasOwn('max_tokens')) {
3664
+ rejectedTokenParameters.add('max_tokens')
3665
+ const value = requestBody.max_tokens
3666
+ requestBody = Object.assign({}, requestBody)
3667
+ delete requestBody.max_tokens
3668
+ if (!rejectedTokenParameters.has('max_completion_tokens')) {
3669
+ requestBody.max_completion_tokens = value
3670
+ continue
3671
+ }
3672
+ }
3673
+
3674
+ if (message.includes("Unsupported parameter: 'max_completion_tokens'") && hasOwn('max_completion_tokens')) {
3675
+ rejectedTokenParameters.add('max_completion_tokens')
3676
+ const value = requestBody.max_completion_tokens
3677
+ requestBody = Object.assign({}, requestBody)
3678
+ delete requestBody.max_completion_tokens
3679
+ if (!rejectedTokenParameters.has('max_tokens')) {
3680
+ requestBody.max_tokens = value
3681
+ continue
3682
+ }
3683
+ }
3684
+
3685
+ throw error
3686
+ }
3687
+ }
3688
+
3689
+ throw lastError || new Error('OpenAI-compatible chat request failed after compatibility retries')
3690
+ }
3691
+
2950
3692
  module.exports = function (RED) {
2951
3693
  const flowGACache = { at: 0, set: new Set() }
2952
3694
  const flowNodeCatalogCache = { at: 0, catalog: null }
@@ -4306,6 +5048,19 @@ module.exports = function (RED) {
4306
5048
  node.llmDocsLanguage = config.llmDocsLanguage ? String(config.llmDocsLanguage) : 'it'
4307
5049
  node.llmDocsMaxSnippets = (config.llmDocsMaxSnippets === undefined || config.llmDocsMaxSnippets === '') ? 5 : Number(config.llmDocsMaxSnippets)
4308
5050
  node.llmDocsMaxChars = (config.llmDocsMaxChars === undefined || config.llmDocsMaxChars === '') ? 60000 : Number(config.llmDocsMaxChars)
5051
+ node.llmAllowKnxCommands = config.llmAllowKnxCommands !== undefined ? coerceBoolean(config.llmAllowKnxCommands) : false
5052
+ node.llmRequireCommandConfirmation = config.llmRequireCommandConfirmation !== undefined ? coerceBoolean(config.llmRequireCommandConfirmation) : true
5053
+ node.chatAdapterPreset = String(config.chatAdapterPreset || 'none')
5054
+ node.chatInputCode = String(config.chatInputCode || '')
5055
+ node.chatOutputCode = String(config.chatOutputCode || '')
5056
+ node.proactiveEnabled = config.proactiveEnabled !== undefined ? coerceBoolean(config.proactiveEnabled) : false
5057
+ node.proactiveRecipient = String(config.proactiveRecipient || '').trim()
5058
+ node.proactiveOpenMinutes = Math.max(1, Math.min(1440, Number(config.proactiveOpenMinutes) || 120))
5059
+ node.proactiveCooldownMinutes = Math.max(5, Math.min(10080, Number(config.proactiveCooldownMinutes) || 360))
5060
+ node.proactiveQuietStart = String(config.proactiveQuietStart || '23:00').trim()
5061
+ node.proactiveQuietEnd = String(config.proactiveQuietEnd || '07:00').trim()
5062
+ node.homeMemoryMaxKb = clampHomeMemoryKb(config.homeMemoryMaxKb)
5063
+ node.aiEducation = String(config.aiEducation || '').slice(0, HOME_MEMORY_MAX_EDUCATION_CHARS)
4309
5064
 
4310
5065
  const pushStatus = (status) => {
4311
5066
  if (!status) return
@@ -4326,6 +5081,25 @@ module.exports = function (RED) {
4326
5081
  pushStatus(status)
4327
5082
  }
4328
5083
 
5084
+ const compileConfiguredChatAdapter = ({ code, direction }) => {
5085
+ try {
5086
+ return compileKnxAiChatAdapter({ code, direction })
5087
+ } catch (error) {
5088
+ try { node.sysLogger?.error(`knxUltimateAI ${direction} adapter compile error: ${error.message || error}`) } catch (e) { /* ignore */ }
5089
+ try { node.error(error) } catch (e) { /* ignore */ }
5090
+ return null
5091
+ }
5092
+ }
5093
+
5094
+ node._chatInputAdapter = compileConfiguredChatAdapter({
5095
+ code: node.chatInputCode,
5096
+ direction: 'chat input'
5097
+ })
5098
+ node._chatOutputAdapter = compileConfiguredChatAdapter({
5099
+ code: node.chatOutputCode,
5100
+ direction: 'chat output'
5101
+ })
5102
+
4329
5103
  // Used to call the status update from the config node.
4330
5104
  node.setNodeStatus = ({ fill, shape, text, payload, GA, dpt, devicename }) => {
4331
5105
  try {
@@ -4357,6 +5131,8 @@ module.exports = function (RED) {
4357
5131
  node._summaryRebuildTimer = null
4358
5132
  node._anomalies = []
4359
5133
  node._assistantLog = []
5134
+ node._conversationSessions = new Map()
5135
+ node._pendingKnxCommands = new Map()
4360
5136
  node._flowContextCache = { at: 0, text: '' }
4361
5137
  node._docsContextCache = { at: 0, question: '', text: '' }
4362
5138
  node._areaSuggestionCache = { ref: null, snapshot: buildSuggestedAreasFromCsv([]) }
@@ -4371,6 +5147,16 @@ module.exports = function (RED) {
4371
5147
  node._gaLabelCsvCache = { ref: null, map: {} }
4372
5148
  node._busConnectionWatchTimer = null
4373
5149
  node._historyDiskLastPruneAt = 0
5150
+ node._homeMemory = createEmptyKnxAiHomeMemory()
5151
+ node._homeMemoryWriteTimer = null
5152
+ node._homeMemoryPeriodicTimer = null
5153
+ node._proactiveCheckTimer = null
5154
+ node._proactiveStates = new Map()
5155
+ node._proactiveInFlight = new Set()
5156
+ node._proactiveGlobalSentAt = []
5157
+ node._homeCatalogByGa = null
5158
+ node._homeCatalogSnapshotRef = null
5159
+ node._closing = false
4374
5160
  node._busConnectionState = (node.serverKNX && typeof node.serverKNX.linkStatus === 'string')
4375
5161
  ? String(node.serverKNX.linkStatus).toLowerCase()
4376
5162
  : 'unknown'
@@ -5483,6 +6269,7 @@ module.exports = function (RED) {
5483
6269
  const wantsFunctionNodeSourceContext = node.llmIncludeFlowContext && shouldIncludeFunctionNodeSourceContext(question)
5484
6270
  const areasSnapshot = buildAreasSnapshot({ summary })
5485
6271
  const areasContext = buildAreasPromptContext(areasSnapshot)
6272
+ const homeMemoryContext = getHomeMemoryPromptContext({ maxChars: compactMode ? 2200 : 6000 })
5486
6273
  const summaryForPrompt = buildLlmSummarySnapshot(summary)
5487
6274
  const summaryText = truncatePromptText(safeStringify(summaryForPrompt), compactMode ? 4000 : 10000)
5488
6275
  const lines = recent.map(t => {
@@ -5564,6 +6351,8 @@ module.exports = function (RED) {
5564
6351
  '',
5565
6352
  areasContext || '',
5566
6353
  areasContext ? '' : '',
6354
+ homeMemoryContext || '',
6355
+ homeMemoryContext ? '' : '',
5567
6356
  flowContext ? 'Node-RED context:' : '',
5568
6357
  flowContext || '',
5569
6358
  flowContext ? '' : '',
@@ -5604,10 +6393,10 @@ module.exports = function (RED) {
5604
6393
  if (node._gaCatalogCache && node._gaCatalogCache.ref === csv && node._gaCatalogCache.roleOverridesKey === roleOverridesKey && Array.isArray(node._gaCatalogCache.snapshot)) {
5605
6394
  return node._gaCatalogCache.snapshot
5606
6395
  }
5607
- const snapshot = applyGaRoleOverridesToCatalog({
6396
+ const snapshot = enrichKnxAiHomeCatalog(applyGaRoleOverridesToCatalog({
5608
6397
  catalog: buildGaCatalogFromCsv(csv),
5609
6398
  roleOverrides
5610
- })
6399
+ }))
5611
6400
  node._gaCatalogCache = { ref: csv, roleOverridesKey, snapshot }
5612
6401
  return snapshot
5613
6402
  }
@@ -5635,6 +6424,147 @@ module.exports = function (RED) {
5635
6424
 
5636
6425
  const getHistoryArchiveFile = (dayKey) => path.join(getHistoryArchiveDir(), `${String(dayKey || '').trim() || formatArchiveDayKey(Date.now())}.jsonl`)
5637
6426
 
6427
+ const getHomeMemoryFile = () => {
6428
+ const baseDir = (node.serverKNX && node.serverKNX.userDir)
6429
+ ? node.serverKNX.userDir
6430
+ : path.join(RED.settings.userDir, 'knxultimatestorage')
6431
+ return path.join(baseDir, 'knxai', 'memory', `knxai-home-memory-${node.id}.md`)
6432
+ }
6433
+
6434
+ const cleanupHomeMemoryTempFiles = () => {
6435
+ try {
6436
+ const filePath = getHomeMemoryFile()
6437
+ const dirPath = path.dirname(filePath)
6438
+ if (!fs.existsSync(dirPath)) return
6439
+ const prefix = `${path.basename(filePath)}.tmp-`
6440
+ fs.readdirSync(dirPath)
6441
+ .filter(name => String(name).startsWith(prefix))
6442
+ .forEach(name => {
6443
+ try { fs.unlinkSync(path.join(dirPath, name)) } catch (error) { /* ignore */ }
6444
+ })
6445
+ } catch (error) {
6446
+ try { node.sysLogger?.warn(`KNX AI home memory temporary-file cleanup error: ${error.message || error}`) } catch (logError) { /* ignore */ }
6447
+ }
6448
+ }
6449
+
6450
+ const getHomeCatalogMap = () => {
6451
+ const snapshot = getGaCatalogSnapshot()
6452
+ if (node._homeCatalogSnapshotRef === snapshot && node._homeCatalogByGa instanceof Map) return node._homeCatalogByGa
6453
+ node._homeCatalogSnapshotRef = snapshot
6454
+ node._homeCatalogByGa = new Map(snapshot.map(item => [String(item && item.ga ? item.ga : '').trim(), item]))
6455
+ return node._homeCatalogByGa
6456
+ }
6457
+
6458
+ const synchronizeHomeMemorySemanticObjects = () => {
6459
+ const semanticObjects = getGaCatalogSnapshot()
6460
+ .filter(item => item && item.semantic && item.semantic.kind !== 'unknown')
6461
+ .sort((a, b) => Number(b.semantic.confidence || 0) - Number(a.semantic.confidence || 0))
6462
+ .slice(0, HOME_MEMORY_MAX_SEMANTIC_OBJECTS)
6463
+ .map(item => ({
6464
+ ga: item.ga,
6465
+ dpt: item.dpt,
6466
+ label: item.label || item.etsName || item.ga,
6467
+ kind: item.semantic.kind,
6468
+ area: item.semantic.area || '',
6469
+ role: item.role || 'neutral',
6470
+ confidence: Number(item.semantic.confidence || 0)
6471
+ }))
6472
+ node._homeMemory.semanticObjects = semanticObjects
6473
+ node._homeMemory.updatedAt = new Date().toISOString()
6474
+ }
6475
+
6476
+ const persistHomeMemoryNow = () => {
6477
+ try {
6478
+ synchronizeHomeMemorySemanticObjects()
6479
+ const rendered = buildKnxAiHomeMemoryMarkdown({
6480
+ memory: node._homeMemory,
6481
+ education: node.aiEducation,
6482
+ maxKb: node.homeMemoryMaxKb
6483
+ })
6484
+ node._homeMemory = rendered.memory
6485
+ const filePath = getHomeMemoryFile()
6486
+ const dirPath = path.dirname(filePath)
6487
+ if (!ensureDirectorySync(dirPath)) throw new Error(`Unable to create ${dirPath}`)
6488
+ const tempPath = `${filePath}.tmp-${process.pid}-${Date.now()}`
6489
+ try {
6490
+ fs.writeFileSync(tempPath, rendered.markdown, 'utf8')
6491
+ fs.renameSync(tempPath, filePath)
6492
+ } catch (error) {
6493
+ try { if (fs.existsSync(tempPath)) fs.unlinkSync(tempPath) } catch (cleanupError) { /* ignore */ }
6494
+ throw error
6495
+ }
6496
+ return {
6497
+ filePath,
6498
+ bytes: rendered.bytes,
6499
+ maxBytes: rendered.maxBytes
6500
+ }
6501
+ } catch (error) {
6502
+ try { node.sysLogger?.warn(`KNX AI home memory write error: ${error.message || error}`) } catch (logError) { /* ignore */ }
6503
+ return null
6504
+ }
6505
+ }
6506
+
6507
+ const scheduleHomeMemoryPersist = ({ immediate = false } = {}) => {
6508
+ if (node._homeMemoryWriteTimer) {
6509
+ clearTimeout(node._homeMemoryWriteTimer)
6510
+ node._homeMemoryWriteTimer = null
6511
+ }
6512
+ if (immediate) return persistHomeMemoryNow()
6513
+ node._homeMemoryWriteTimer = setTimeout(() => {
6514
+ node._homeMemoryWriteTimer = null
6515
+ persistHomeMemoryNow()
6516
+ }, 1500)
6517
+ return null
6518
+ }
6519
+
6520
+ const loadHomeMemoryFromDisk = () => {
6521
+ const filePath = getHomeMemoryFile()
6522
+ try {
6523
+ cleanupHomeMemoryTempFiles()
6524
+ if (!fs.existsSync(filePath)) {
6525
+ node._homeMemory = createEmptyKnxAiHomeMemory()
6526
+ return scheduleHomeMemoryPersist({ immediate: true })
6527
+ }
6528
+ const stat = fs.statSync(filePath)
6529
+ const absoluteReadLimit = 4 * 1024 * 1024
6530
+ if (Number(stat.size || 0) > absoluteReadLimit) {
6531
+ throw new Error(`memory file exceeds the safe read limit (${absoluteReadLimit} bytes)`)
6532
+ }
6533
+ node._homeMemory = normalizeKnxAiHomeMemory(parseKnxAiHomeMemoryMarkdown(fs.readFileSync(filePath, 'utf8')))
6534
+ return scheduleHomeMemoryPersist({ immediate: true })
6535
+ } catch (error) {
6536
+ node._homeMemory = createEmptyKnxAiHomeMemory()
6537
+ try { node.sysLogger?.warn(`KNX AI home memory load error: ${error.message || error}`) } catch (logError) { /* ignore */ }
6538
+ return scheduleHomeMemoryPersist({ immediate: true })
6539
+ }
6540
+ }
6541
+
6542
+ const getHomeMemoryPromptContext = ({ maxChars = 6000 } = {}) => {
6543
+ const memory = normalizeKnxAiHomeMemory(node._homeMemory)
6544
+ const education = String(node.aiEducation || '').trim().slice(0, HOME_MEMORY_MAX_EDUCATION_CHARS)
6545
+ const habitLines = memory.habits.slice(-20).map(item => {
6546
+ return `- ${item.label || item.ga}: average open ${Number(item.averageMinutes || 0).toFixed(1)} min (${Number(item.samples || 0)} samples), last ${Number(item.lastMinutes || 0).toFixed(1)} min`
6547
+ })
6548
+ const observationLines = memory.observations.slice(-20).map(item => {
6549
+ return `- ${item.at || ''} ${item.label || item.ga || ''}: ${item.event || item.value || item.type || ''}`
6550
+ })
6551
+ const educationContext = [
6552
+ 'USER-MANAGED AI EDUCATION (authoritative; never rewrite or contradict it):',
6553
+ education || '(none)'
6554
+ ].join('\n')
6555
+ const learnedContext = [
6556
+ 'BOUNDED LEARNED HOME MEMORY:',
6557
+ habitLines.length ? habitLines.join('\n') : '(no stable habits learned yet)',
6558
+ observationLines.length ? `\nRecent significant observations:\n${observationLines.join('\n')}` : ''
6559
+ ].join('\n')
6560
+ const targetChars = Math.max(500, Number(maxChars) || 6000)
6561
+ const remainingChars = Math.max(0, targetChars - educationContext.length - 2)
6562
+ return [
6563
+ educationContext,
6564
+ remainingChars > 0 ? truncatePromptText(learnedContext, remainingChars) : ''
6565
+ ].filter(Boolean).join('\n\n')
6566
+ }
6567
+
5638
6568
  const pruneHistoryArchiveFiles = ({ force = false } = {}) => {
5639
6569
  if (node.historyStoreToDisk !== true) return
5640
6570
  const retentionDays = Math.max(1, Math.round(Number.isFinite(Number(node.historyStoreRetentionDays)) ? Number(node.historyStoreRetentionDays) : 1))
@@ -7603,47 +8533,15 @@ module.exports = function (RED) {
7603
8533
  })
7604
8534
  : baseBody
7605
8535
 
7606
- // Some OpenAI models (and some compatible gateways) require `max_completion_tokens` instead of `max_tokens`.
7607
- // Try with `max_tokens` first for broad compatibility, then fallback once if the server rejects it.
7608
- const bodyWithMaxTokens = Object.assign({ max_tokens: resolvedMaxTokens }, schemaBody)
7609
- const bodyWithMaxCompletionTokens = Object.assign({ max_completion_tokens: resolvedMaxTokens }, schemaBody)
7610
- const plainBodyWithMaxTokens = Object.assign({ max_tokens: resolvedMaxTokens }, baseBody)
7611
- const plainBodyWithMaxCompletionTokens = Object.assign({ max_completion_tokens: resolvedMaxTokens }, baseBody)
7612
-
7613
- const isResponseFormatCompatibilityError = (message) => {
7614
- const msg = String(message || '')
7615
- return msg.includes("Unsupported parameter: 'response_format'") ||
7616
- msg.includes('Invalid schema for response_format') ||
7617
- msg.includes('response_format') ||
7618
- msg.includes('json_schema')
7619
- }
7620
-
7621
- let json
7622
- try {
7623
- json = await postJson({ url, headers, body: bodyWithMaxTokens, timeoutMs: effectiveTimeoutMs })
7624
- } catch (error) {
7625
- const msg = (error && error.message) ? String(error.message) : ''
7626
- if (isResponseFormatCompatibilityError(msg)) {
7627
- try {
7628
- json = await postJson({ url, headers, body: plainBodyWithMaxTokens, timeoutMs: effectiveTimeoutMs })
7629
- } catch (innerError) {
7630
- const innerMsg = (innerError && innerError.message) ? String(innerError.message) : ''
7631
- if (innerMsg.includes("Unsupported parameter: 'max_tokens'") || innerMsg.includes('max_completion_tokens')) {
7632
- json = await postJson({ url, headers, body: plainBodyWithMaxCompletionTokens, timeoutMs: effectiveTimeoutMs })
7633
- } else if (innerMsg.includes("Unsupported parameter: 'max_completion_tokens'")) {
7634
- json = await postJson({ url, headers, body: plainBodyWithMaxTokens, timeoutMs: effectiveTimeoutMs })
7635
- } else {
7636
- throw innerError
7637
- }
7638
- }
7639
- } else if (msg.includes("Unsupported parameter: 'max_tokens'") || msg.includes('max_completion_tokens')) {
7640
- json = await postJson({ url, headers, body: bodyWithMaxCompletionTokens, timeoutMs: effectiveTimeoutMs })
7641
- } else if (msg.includes("Unsupported parameter: 'max_completion_tokens'")) {
7642
- json = await postJson({ url, headers, body: bodyWithMaxTokens, timeoutMs: effectiveTimeoutMs })
7643
- } else {
7644
- throw error
7645
- }
7646
- }
8536
+ // OpenAI-compatible providers differ on optional sampling, response-format,
8537
+ // and token-limit parameters. Retry only the rejected compatibility field.
8538
+ const json = await postOpenAiCompatibleChatWithFallbacks({
8539
+ url,
8540
+ headers,
8541
+ body: Object.assign({ max_tokens: resolvedMaxTokens }, schemaBody),
8542
+ timeoutMs: effectiveTimeoutMs,
8543
+ model: baseBody.model
8544
+ })
7647
8545
  const content = extractOpenAICompatText(json) || buildOpenAICompatFallbackText(json)
7648
8546
  const finishReason = String(json && json.choices && json.choices[0] && json.choices[0].finish_reason ? json.choices[0].finish_reason : '')
7649
8547
  return { provider: 'openai_compat', model: baseBody.model, content, finishReason }
@@ -7844,6 +8742,309 @@ module.exports = function (RED) {
7844
8742
  return Object.assign({}, ret, { content: finalContent, summary })
7845
8743
  }
7846
8744
 
8745
+ const getConversationHistory = (sessionId) => {
8746
+ const key = String(sessionId || 'default')
8747
+ const history = node._conversationSessions.get(key)
8748
+ return Array.isArray(history) ? history.slice(-8) : []
8749
+ }
8750
+
8751
+ const rememberConversationTurn = ({ sessionId, question, reply }) => {
8752
+ const key = String(sessionId || 'default')
8753
+ const history = getConversationHistory(key)
8754
+ history.push({
8755
+ question: String(question || '').trim(),
8756
+ reply: String(reply || '').trim()
8757
+ })
8758
+ node._conversationSessions.delete(key)
8759
+ node._conversationSessions.set(key, history.slice(-8))
8760
+ while (node._conversationSessions.size > 50) {
8761
+ const oldestKey = node._conversationSessions.keys().next().value
8762
+ node._conversationSessions.delete(oldestKey)
8763
+ }
8764
+ }
8765
+
8766
+ const callConversationalLLM = async ({ question, sessionId, requireConfirmation = true }) => {
8767
+ const summary = rebuildCachedSummaryNow()
8768
+ const catalog = getGaCatalogSnapshot()
8769
+ const history = getConversationHistory(sessionId)
8770
+ const gaLimit = 600
8771
+ const gaLines = catalog.slice(0, gaLimit).map((item) => {
8772
+ const role = String(item && item.role ? item.role : 'neutral').trim()
8773
+ const dpt = String(item && item.dpt ? item.dpt : '').trim() || '?'
8774
+ const label = String(item && item.label ? item.label : item && item.ga ? item.ga : '').trim()
8775
+ const semantic = item && item.semantic && typeof item.semantic === 'object' ? item.semantic : {}
8776
+ const valueOptions = (Array.isArray(item && item.valueOptions) ? item.valueOptions : [])
8777
+ .slice(0, 20)
8778
+ .map(option => `${option.value}=${option.label}`)
8779
+ .join(', ')
8780
+ const semanticText = semantic.kind && semantic.kind !== 'unknown'
8781
+ ? ` | semantic ${semantic.kind}${semantic.area ? `/${semantic.area}` : ''} confidence=${Number(semantic.confidence || 0).toFixed(2)}`
8782
+ : ''
8783
+ return `${item.ga} | dpt ${dpt} | role ${role} | ${label}${semanticText}${valueOptions ? ` | values ${valueOptions}` : ''}`
8784
+ })
8785
+ const conversationLines = history.flatMap(turn => [
8786
+ `User: ${turn.question}`,
8787
+ `Assistant: ${turn.reply}`
8788
+ ])
8789
+ const analysisContext = buildLLMPrompt({ question, summary, compact: true })
8790
+ const systemPrompt = [
8791
+ node.llmSystemPrompt || 'You are a KNX building automation assistant.',
8792
+ '',
8793
+ 'KNX CHAT AND CONTROL CONTRACT:',
8794
+ '- Return only one JSON object with exactly this shape: {"reply":"text for the user","language":"it","commands":[{"event":"GroupValue_Read|GroupValue_Write","destination":"1/2/3","dpt":"1.001","payload":null,"reason":"short reason"}]}.',
8795
+ '- Use the same language as the user for reply and reason.',
8796
+ '- Set language to the ISO code matching the current user request: en, it, de, fr, es, or zh.',
8797
+ '- For an explicit request to refresh, read, query, or retrieve a current KNX state, create GroupValue_Read operations for the exact relevant objects. Use payload null for reads.',
8798
+ '- GroupValue_Read is allowed for exact status, neutral, or command objects in AVAILABLE KNX OBJECTS because it does not modify the bus state.',
8799
+ '- For a question that can be answered from recent data, return commands as an empty array. If current data is missing or the user explicitly asks for a fresh read, request it instead of claiming that read-only objects cannot be queried.',
8800
+ '- Create a GroupValue_Write only when the user clearly asks to control an actuator now.',
8801
+ '- Never invent, guess, transform, or substitute a group address or DPT.',
8802
+ '- A GroupValue_Write destination must appear in AVAILABLE KNX OBJECTS with role command. Status and neutral objects must never receive GroupValue_Write.',
8803
+ '- Copy the DPT exactly from AVAILABLE KNX OBJECTS.',
8804
+ '- For every DPT 1.xxx GroupValue_Write, use a JSON boolean payload: true to activate and false to deactivate. Do not use numeric 1/0 or quoted boolean strings.',
8805
+ '- Emit the smallest necessary operation set, in execution order, with at most 5 writes and 20 reads.',
8806
+ '- Do not claim that an action succeeded. Say that the command is being forwarded or prepared; real KNX feedback is separate.',
8807
+ requireConfirmation ? '- When GroupValue_Write operations are present, explain the proposed changes only. The node appends the exact localized confirmation instructions; do not invent different confirmation wording. Writes have not been sent yet. GroupValue_Read operations do not require confirmation.' : '',
8808
+ '- If the request is ambiguous, unsafe, unsupported, or has no exact KNX object, ask a concise clarification and return no commands.'
8809
+ ].filter(Boolean).join('\n')
8810
+ const userContent = [
8811
+ history.length ? 'RECENT CONVERSATION:' : '',
8812
+ history.length ? conversationLines.join('\n') : '',
8813
+ history.length ? '' : '',
8814
+ getHomeMemoryPromptContext({ maxChars: 6000 }),
8815
+ '',
8816
+ analysisContext,
8817
+ '',
8818
+ `AVAILABLE KNX OBJECTS (showing ${Math.min(catalog.length, gaLimit)} of ${catalog.length}; every exact object may be read, but only role command may be written):`,
8819
+ gaLines.length ? gaLines.join('\n') : '(no ETS group addresses imported; return no commands)',
8820
+ '',
8821
+ 'Return the JSON object now.'
8822
+ ].join('\n')
8823
+ const configuredMaxTokens = Math.max(10000, Number(node.llmMaxTokens) || 0)
8824
+ const ret = await callLLMChat({
8825
+ systemPrompt,
8826
+ userContent,
8827
+ jsonSchema: {
8828
+ name: 'knx_ai_conversation',
8829
+ strict: true,
8830
+ schema: {
8831
+ type: 'object',
8832
+ additionalProperties: false,
8833
+ properties: {
8834
+ reply: { type: 'string' },
8835
+ language: { type: 'string', enum: ['en', 'it', 'de', 'fr', 'es', 'zh'] },
8836
+ commands: {
8837
+ type: 'array',
8838
+ maxItems: 25,
8839
+ items: {
8840
+ type: 'object',
8841
+ additionalProperties: false,
8842
+ properties: {
8843
+ event: { type: 'string', enum: ['GroupValue_Read', 'GroupValue_Write'] },
8844
+ destination: { type: 'string' },
8845
+ dpt: { type: 'string' },
8846
+ payload: {},
8847
+ reason: { type: 'string' }
8848
+ },
8849
+ required: ['event', 'destination', 'dpt', 'payload', 'reason']
8850
+ }
8851
+ }
8852
+ },
8853
+ required: ['reply', 'language', 'commands']
8854
+ }
8855
+ },
8856
+ maxTokensOverride: configuredMaxTokens
8857
+ })
8858
+
8859
+ let envelope
8860
+ try {
8861
+ envelope = parseKnxAiConversationResponse(ret.content)
8862
+ } catch (error) {
8863
+ return Object.assign({}, ret, {
8864
+ content: String(ret.content || '').trim() || 'The AI provider returned an empty response.',
8865
+ commands: [],
8866
+ rejectedCommands: [],
8867
+ summary,
8868
+ structuredOutputError: error.message || String(error)
8869
+ })
8870
+ }
8871
+
8872
+ const normalized = normalizeKnxAiCommandCandidates({
8873
+ commands: envelope.commands,
8874
+ catalog,
8875
+ maxCommands: 5,
8876
+ maxReadCommands: 20,
8877
+ coercePayload: (value, context) => coerceKnxAiCommandPayload(value, context)
8878
+ })
8879
+ let reply = envelope.reply || (normalized.accepted.length ? 'KNX command prepared.' : 'No response text was returned.')
8880
+ if (normalized.rejected.length) {
8881
+ const details = normalized.rejected.map(item => item.reason).join('; ')
8882
+ reply += `\n\nKNX command not sent: ${details}.`
8883
+ }
8884
+ return Object.assign({}, ret, {
8885
+ content: reply,
8886
+ language: envelope.language,
8887
+ commands: normalized.accepted,
8888
+ rejectedCommands: normalized.rejected,
8889
+ summary
8890
+ })
8891
+ }
8892
+
8893
+ const cloneInputMessage = inputMessage => cloneKnxAiInputMessage(
8894
+ inputMessage,
8895
+ RED.util && typeof RED.util.cloneMessage === 'function'
8896
+ ? message => RED.util.cloneMessage(message)
8897
+ : null,
8898
+ error => {
8899
+ try { node.sysLogger?.warn(`knxUltimateAI input message clone warning: ${error.message || error}`) } catch (e) { /* ignore */ }
8900
+ }
8901
+ )
8902
+
8903
+ const adaptAssistantOutput = (value, inputMessage) => {
8904
+ if (!node._chatOutputAdapter || value === null || value === undefined) return value
8905
+ const adaptOne = message => executeKnxAiChatAdapter({
8906
+ adapter: node._chatOutputAdapter,
8907
+ msg: message,
8908
+ inputMessage,
8909
+ node,
8910
+ RED
8911
+ })
8912
+ try {
8913
+ if (Array.isArray(value)) {
8914
+ const adapted = value.map(adaptOne).filter(message => message !== null)
8915
+ return adapted.length ? adapted : null
8916
+ }
8917
+ return adaptOne(value)
8918
+ } catch (error) {
8919
+ try { node.sysLogger?.error(`knxUltimateAI chat output adapter error: ${error.message || error}`) } catch (e) { /* ignore */ }
8920
+ try { node.error(error, inputMessage) } catch (e) { /* ignore */ }
8921
+ try { updateStatus({ fill: 'red', shape: 'dot', text: `Chat output adapter error: ${error.message || error}` }) } catch (e) { /* ignore */ }
8922
+ return null
8923
+ }
8924
+ }
8925
+
8926
+ const sendKnxAiOutputs = (outputs, inputMessage) => {
8927
+ const preparedOutputs = Array.isArray(outputs) ? outputs.slice() : outputs
8928
+ if (Array.isArray(preparedOutputs) && preparedOutputs.length > 2) {
8929
+ preparedOutputs[2] = adaptAssistantOutput(preparedOutputs[2], inputMessage)
8930
+ }
8931
+ return safeKnxAiSend({
8932
+ outputs: preparedOutputs,
8933
+ send: messages => node.send(messages),
8934
+ onError: error => {
8935
+ try { node.sysLogger?.error(`knxUltimateAI output error: ${error.message || error}`) } catch (e) { /* ignore */ }
8936
+ try { node.error(error, inputMessage) } catch (e) { /* ignore */ }
8937
+ try { updateStatus({ fill: 'red', shape: 'dot', text: `AI output error: ${error.message || error}` }) } catch (e) { /* ignore */ }
8938
+ }
8939
+ })
8940
+ }
8941
+
8942
+ const buildKnxAiReplyMessage = ({ inputMessage, content, metadata = {}, summary }) => {
8943
+ const replyMessage = cloneInputMessage(inputMessage)
8944
+ replyMessage.topic = node.outputtopic
8945
+ replyMessage.payload = content
8946
+ replyMessage.knxAi = metadata
8947
+ replyMessage.inputMessage = cloneInputMessage(inputMessage)
8948
+ if (summary !== undefined) replyMessage.summary = summary
8949
+ return replyMessage
8950
+ }
8951
+
8952
+ const buildKnxAiCommandMessages = ({ commands, question, sessionId, confirmed, inputMessage }) => {
8953
+ return (Array.isArray(commands) ? commands : []).map((command, index) => buildKnxAiUniversalMessage({
8954
+ command,
8955
+ question,
8956
+ sessionId,
8957
+ confirmed,
8958
+ index,
8959
+ inputMessage: cloneInputMessage(inputMessage)
8960
+ }))
8961
+ }
8962
+
8963
+ const handleKnxAiConfirmationDecision = ({ msg, question, sessionId, decision }) => {
8964
+ const pending = node._pendingKnxCommands.get(sessionId)
8965
+ const language = pending && pending.language
8966
+ ? pending.language
8967
+ : resolveKnxAiLanguage(msg, node.llmDocsLanguage || 'en', question)
8968
+ const copy = getKnxAiConfirmationCopy(language)
8969
+ if (!pending) {
8970
+ const reply = buildKnxAiReplyMessage({
8971
+ inputMessage: msg,
8972
+ content: copy.missing,
8973
+ metadata: { type: 'knx_confirmation_missing', sessionId }
8974
+ })
8975
+ sendKnxAiOutputs([null, null, reply, null], msg)
8976
+ return
8977
+ }
8978
+ node._pendingKnxCommands.delete(sessionId)
8979
+ if (Number(pending.expiresAt || 0) <= nowMs()) {
8980
+ const reply = buildKnxAiReplyMessage({
8981
+ inputMessage: msg,
8982
+ content: copy.expired,
8983
+ metadata: { type: 'knx_confirmation_expired', sessionId }
8984
+ })
8985
+ if (!sendKnxAiOutputs([null, null, reply, null], msg)) return
8986
+ updateStatus({ fill: 'grey', shape: 'dot', text: 'AI KNX confirmation expired' })
8987
+ return
8988
+ }
8989
+ if (decision === 'cancel') {
8990
+ const reply = buildKnxAiReplyMessage({
8991
+ inputMessage: msg,
8992
+ content: copy.cancelled,
8993
+ metadata: { type: 'knx_confirmation_cancelled', sessionId }
8994
+ })
8995
+ rememberConversationTurn({ sessionId, question: question || 'CANCEL', reply: copy.cancelled })
8996
+ if (!sendKnxAiOutputs([null, null, reply, null], msg)) return
8997
+ updateStatus({ fill: 'grey', shape: 'dot', text: 'AI KNX commands cancelled' })
8998
+ return
8999
+ }
9000
+
9001
+ const normalized = normalizeKnxAiCommandCandidates({
9002
+ commands: pending.commands,
9003
+ catalog: getGaCatalogSnapshot(),
9004
+ maxCommands: 5,
9005
+ coercePayload: (value, context) => coerceKnxAiCommandPayload(value, context)
9006
+ })
9007
+ if (normalized.rejected.length || !normalized.accepted.length) {
9008
+ const details = normalized.rejected.length
9009
+ ? normalized.rejected.map(item => item.reason).join('; ')
9010
+ : 'no valid command remains'
9011
+ const content = copy.invalid(details)
9012
+ const reply = buildKnxAiReplyMessage({
9013
+ inputMessage: msg,
9014
+ content,
9015
+ metadata: {
9016
+ type: 'knx_confirmation_invalid',
9017
+ sessionId,
9018
+ rejectedCommands: normalized.rejected
9019
+ }
9020
+ })
9021
+ rememberConversationTurn({ sessionId, question: question || 'CONFIRM', reply: content })
9022
+ if (!sendKnxAiOutputs([null, null, reply, null], msg)) return
9023
+ updateStatus({ fill: 'red', shape: 'dot', text: 'AI KNX confirmation validation failed' })
9024
+ return
9025
+ }
9026
+ const commandMessages = buildKnxAiCommandMessages({
9027
+ commands: normalized.accepted,
9028
+ question: pending.question,
9029
+ sessionId,
9030
+ confirmed: true,
9031
+ inputMessage: msg
9032
+ })
9033
+ const content = copy.confirmed(commandMessages.length)
9034
+ const reply = buildKnxAiReplyMessage({
9035
+ inputMessage: msg,
9036
+ content,
9037
+ metadata: {
9038
+ type: 'knx_confirmation_accepted',
9039
+ sessionId,
9040
+ commandCount: commandMessages.length
9041
+ }
9042
+ })
9043
+ rememberConversationTurn({ sessionId, question: question || 'CONFIRM', reply: content })
9044
+ if (!sendKnxAiOutputs([null, null, reply, commandMessages], msg)) return
9045
+ updateStatus({ fill: 'green', shape: 'dot', text: `AI confirmed, ${commandMessages.length} KNX command(s)` })
9046
+ }
9047
+
7847
9048
  const emitSummary = () => {
7848
9049
  try {
7849
9050
  const summary = rebuildCachedSummaryNow()
@@ -8120,6 +9321,267 @@ module.exports = function (RED) {
8120
9321
  node._gaState.set(ga, state)
8121
9322
  }
8122
9323
 
9324
+ const rememberHomeOwner = ({ sessionId, language } = {}) => {
9325
+ try {
9326
+ const normalizedSessionId = String(sessionId || '').trim()
9327
+ if (normalizedSessionId && normalizedSessionId !== 'default') {
9328
+ node._homeMemory.ownerSessionId = normalizedSessionId
9329
+ }
9330
+ if (language) node._homeMemory.ownerLanguage = normalizeHomeLanguage(language)
9331
+ scheduleHomeMemoryPersist()
9332
+ } catch (error) {
9333
+ try { node.sysLogger?.warn(`KNX AI home owner memory error: ${error.message || error}`) } catch (logError) { /* ignore */ }
9334
+ }
9335
+ }
9336
+
9337
+ const recordProactiveObservation = ({ catalogItem, telegram, event }) => {
9338
+ const semantic = catalogItem && catalogItem.semantic ? catalogItem.semantic : {}
9339
+ node._homeMemory = addBoundedKnxAiObservation(node._homeMemory, {
9340
+ at: new Date(Number(telegram.ts || nowMs())).toISOString(),
9341
+ type: 'semantic_state_change',
9342
+ event,
9343
+ ga: catalogItem.ga,
9344
+ dpt: catalogItem.dpt,
9345
+ label: catalogItem.label || telegram.devicename || catalogItem.ga,
9346
+ kind: semantic.kind || '',
9347
+ area: semantic.area || '',
9348
+ value: normalizeValueForCompare(telegram.payload)
9349
+ })
9350
+ scheduleHomeMemoryPersist()
9351
+ }
9352
+
9353
+ const processProactiveTelegram = (telegram) => {
9354
+ if (!telegram || !telegram.destination) return
9355
+ const catalogItem = getHomeCatalogMap().get(String(telegram.destination).trim())
9356
+ if (!catalogItem || !catalogItem.semantic) return
9357
+ const openState = classifyKnxAiOpenState({
9358
+ semantic: catalogItem.semantic,
9359
+ dpt: telegram.dpt || catalogItem.dpt,
9360
+ payload: telegram.payload,
9361
+ valueOptions: catalogItem.valueOptions
9362
+ })
9363
+ if (!openState || Number(openState.confidence || 0) < 0.7) return
9364
+ const ga = String(catalogItem.ga || telegram.destination).trim()
9365
+ const now = Number(telegram.ts || nowMs())
9366
+ const previous = node._proactiveStates.get(ga)
9367
+ if (openState.open) {
9368
+ if (previous && previous.open === true) {
9369
+ previous.lastSeenAt = now
9370
+ previous.value = openState.value
9371
+ node._proactiveStates.set(ga, previous)
9372
+ return
9373
+ }
9374
+ const lastNotification = normalizeKnxAiHomeMemory(node._homeMemory).notifications
9375
+ .filter(item => item && item.ga === ga)
9376
+ .sort((a, b) => String(a.at || '').localeCompare(String(b.at || '')))
9377
+ .pop()
9378
+ node._proactiveStates.set(ga, {
9379
+ ga,
9380
+ open: true,
9381
+ openedAt: now,
9382
+ lastSeenAt: now,
9383
+ lastSentAt: lastNotification ? Date.parse(lastNotification.at || '') || 0 : 0,
9384
+ value: openState.value,
9385
+ confidence: openState.confidence,
9386
+ catalogItem
9387
+ })
9388
+ recordProactiveObservation({
9389
+ catalogItem,
9390
+ telegram,
9391
+ event: openState.reason || 'opened'
9392
+ })
9393
+ return
9394
+ }
9395
+ if (previous && previous.open === true) {
9396
+ const durationMinutes = Math.max(0, (now - Number(previous.openedAt || now)) / 60000)
9397
+ node._homeMemory = updateKnxAiCoverHabit(node._homeMemory, {
9398
+ ga,
9399
+ label: catalogItem.label || ga,
9400
+ area: catalogItem.semantic.area || '',
9401
+ durationMinutes,
9402
+ at: new Date(now).toISOString()
9403
+ })
9404
+ recordProactiveObservation({
9405
+ catalogItem,
9406
+ telegram,
9407
+ event: `${openState.reason || 'closed'} after ${durationMinutes.toFixed(1)} minutes`
9408
+ })
9409
+ }
9410
+ node._proactiveStates.set(ga, {
9411
+ ga,
9412
+ open: false,
9413
+ openedAt: 0,
9414
+ lastSeenAt: now,
9415
+ lastSentAt: previous ? Number(previous.lastSentAt || 0) : 0,
9416
+ value: openState.value,
9417
+ confidence: openState.confidence,
9418
+ catalogItem
9419
+ })
9420
+ }
9421
+
9422
+ const createProactiveNotificationText = async ({ state, durationMinutes, language }) => {
9423
+ const label = state.catalogItem.label || state.ga
9424
+ const fallback = buildKnxAiProactiveFallback({ language, label, durationMinutes })
9425
+ const hasAuthoritativeEducation = String(node.aiEducation || '').trim() !== ''
9426
+ if (node.llmEnabled !== true) {
9427
+ return hasAuthoritativeEducation
9428
+ ? { notify: false, content: '' }
9429
+ : { notify: true, content: fallback }
9430
+ }
9431
+ try {
9432
+ const ret = await callLLMChat({
9433
+ systemPrompt: [
9434
+ 'You decide whether to send one concise proactive smart-home notification.',
9435
+ `Use language ${normalizeHomeLanguage(language)}.`,
9436
+ 'Return JSON only with exactly: {"notify":boolean,"message":"text"}.',
9437
+ 'Set notify=false when the authoritative user-managed AI Education says this condition is normal, allowed, unwanted, or should not generate a notification.',
9438
+ 'When notify=false, set message to an empty string.',
9439
+ 'Do not claim that a KNX command was sent or that an actuator changed.',
9440
+ 'The message must not contain Markdown, lists, addresses, DPTs, or technical details.',
9441
+ 'Explain the observed condition and end by asking whether the user wants help.',
9442
+ 'The user-managed AI Education is authoritative.'
9443
+ ].join('\n'),
9444
+ userContent: [
9445
+ getHomeMemoryPromptContext({ maxChars: 3500 }),
9446
+ '',
9447
+ `Observed object: ${label}`,
9448
+ `Semantic type: ${state.catalogItem.semantic.kind}`,
9449
+ `Semantic area: ${state.catalogItem.semantic.area || 'unknown'}`,
9450
+ `Condition duration: ${Math.max(1, Math.round(durationMinutes))} minutes`,
9451
+ 'Return the JSON decision now.'
9452
+ ].join('\n'),
9453
+ jsonSchema: {
9454
+ name: 'knx_ai_proactive_decision',
9455
+ strict: true,
9456
+ schema: {
9457
+ type: 'object',
9458
+ additionalProperties: false,
9459
+ properties: {
9460
+ notify: { type: 'boolean' },
9461
+ message: { type: 'string' }
9462
+ },
9463
+ required: ['notify', 'message']
9464
+ }
9465
+ },
9466
+ maxTokensOverride: 2000
9467
+ })
9468
+ const decision = extractJsonFragmentFromText(ret && ret.content)
9469
+ if (!decision || typeof decision !== 'object' || Array.isArray(decision) || typeof decision.notify !== 'boolean') {
9470
+ throw new Error('The proactive decision is not a valid JSON object')
9471
+ }
9472
+ if (decision.notify === false) return { notify: false, content: '' }
9473
+ const candidate = String(decision.message || '').trim()
9474
+ if (!candidate || candidate.length > 1200 || candidate.startsWith('{') || candidate.startsWith('```')) {
9475
+ return { notify: true, content: fallback }
9476
+ }
9477
+ return { notify: true, content: candidate }
9478
+ } catch (error) {
9479
+ try { node.sysLogger?.warn(`KNX AI proactive wording fallback: ${error.message || error}`) } catch (logError) { /* ignore */ }
9480
+ return hasAuthoritativeEducation
9481
+ ? { notify: false, content: '' }
9482
+ : { notify: true, content: fallback }
9483
+ }
9484
+ }
9485
+
9486
+ const emitProactiveNotification = async ({ state, durationMinutes }) => {
9487
+ if (node._closing === true) return false
9488
+ const recipient = String(node.proactiveRecipient || node._homeMemory.ownerSessionId || '').trim()
9489
+ if (node.chatAdapterPreset === 'windkh-telegrambot' && !recipient) return false
9490
+ const language = normalizeHomeLanguage(node._homeMemory.ownerLanguage || node.llmDocsLanguage || 'en')
9491
+ const notification = await createProactiveNotificationText({ state, durationMinutes, language })
9492
+ if (!notification.notify) return 'suppressed'
9493
+ const content = notification.content
9494
+ if (node._closing === true) return false
9495
+ const syntheticInputMessage = {
9496
+ topic: 'proactive',
9497
+ payload: Object.assign({
9498
+ type: 'message',
9499
+ content: ''
9500
+ }, recipient ? { chatId: recipient } : {}),
9501
+ sessionId: recipient || 'proactive',
9502
+ language,
9503
+ knxAi: {
9504
+ type: 'proactive_observation',
9505
+ destination: state.ga
9506
+ }
9507
+ }
9508
+ const metadata = {
9509
+ type: 'proactive_notification',
9510
+ reason: 'open_too_long',
9511
+ destination: state.ga,
9512
+ dpt: state.catalogItem.dpt,
9513
+ label: state.catalogItem.label || state.ga,
9514
+ semantic: state.catalogItem.semantic,
9515
+ openedAt: new Date(state.openedAt).toISOString(),
9516
+ durationMinutes: Number(durationMinutes.toFixed(1)),
9517
+ recipient,
9518
+ sessionId: recipient || 'proactive',
9519
+ language,
9520
+ requiresConfirmationForCommands: true
9521
+ }
9522
+ const replyMessage = buildKnxAiReplyMessage({
9523
+ inputMessage: syntheticInputMessage,
9524
+ content,
9525
+ metadata
9526
+ })
9527
+ if (!sendKnxAiOutputs([null, null, replyMessage, null], syntheticInputMessage)) return false
9528
+ node._homeMemory = addBoundedKnxAiNotification(node._homeMemory, {
9529
+ at: new Date().toISOString(),
9530
+ type: 'proactive_notification',
9531
+ reason: 'open_too_long',
9532
+ ga: state.ga,
9533
+ dpt: state.catalogItem.dpt,
9534
+ label: state.catalogItem.label || state.ga,
9535
+ durationMinutes: Number(durationMinutes.toFixed(1)),
9536
+ recipient
9537
+ })
9538
+ rememberConversationTurn({
9539
+ sessionId: recipient || 'proactive',
9540
+ question: '[Proactive home observation]',
9541
+ reply: content
9542
+ })
9543
+ scheduleHomeMemoryPersist({ immediate: true })
9544
+ return true
9545
+ }
9546
+
9547
+ const checkProactiveHomeState = () => {
9548
+ if (node._closing === true || node.proactiveEnabled !== true) return
9549
+ if (isKnxAiQuietTime({
9550
+ date: new Date(),
9551
+ start: node.proactiveQuietStart,
9552
+ end: node.proactiveQuietEnd
9553
+ })) return
9554
+ const now = nowMs()
9555
+ const thresholdMs = node.proactiveOpenMinutes * 60 * 1000
9556
+ const cooldownMs = node.proactiveCooldownMinutes * 60 * 1000
9557
+ node._proactiveGlobalSentAt = node._proactiveGlobalSentAt.filter(ts => (now - ts) < (60 * 60 * 1000))
9558
+ if (node._proactiveGlobalSentAt.length >= 3) return
9559
+ const candidate = Array.from(node._proactiveStates.values())
9560
+ .filter(state => {
9561
+ if (!state || state.open !== true || node._proactiveInFlight.has(state.ga)) return false
9562
+ if ((now - Number(state.openedAt || now)) < thresholdMs) return false
9563
+ if (Number(state.lastSentAt || 0) > 0 && (now - Number(state.lastSentAt)) < cooldownMs) return false
9564
+ return true
9565
+ })
9566
+ .sort((a, b) => Number(a.openedAt || 0) - Number(b.openedAt || 0))[0]
9567
+ if (!candidate) return
9568
+ candidate.lastSentAt = now
9569
+ node._proactiveInFlight.add(candidate.ga)
9570
+ const durationMinutes = Math.max(1, (now - Number(candidate.openedAt || now)) / 60000)
9571
+ Promise.resolve(emitProactiveNotification({ state: candidate, durationMinutes }))
9572
+ .then(result => {
9573
+ if (result === true) node._proactiveGlobalSentAt.push(now)
9574
+ else if (result !== 'suppressed') candidate.lastSentAt = 0
9575
+ })
9576
+ .catch(error => {
9577
+ candidate.lastSentAt = 0
9578
+ try { node.sysLogger?.warn(`KNX AI proactive notification error: ${error.message || error}`) } catch (logError) { /* ignore */ }
9579
+ })
9580
+ .finally(() => {
9581
+ node._proactiveInFlight.delete(candidate.ga)
9582
+ })
9583
+ }
9584
+
8123
9585
  // Called by knxUltimate-config.js
8124
9586
  node.handleSend = (msg) => {
8125
9587
  try {
@@ -8133,6 +9595,7 @@ module.exports = function (RED) {
8133
9595
  trimHistory(now)
8134
9596
  maybeEmitGAAnomalies(telegram)
8135
9597
  maybeEmitOverallAnomaly(now)
9598
+ processProactiveTelegram(telegram)
8136
9599
  scheduleRealtimeSummaryRebuild()
8137
9600
  } catch (error) {
8138
9601
  try { node.sysLogger?.error(`knxUltimateAI handleSend error: ${error.message || error}`) } catch (e) { /* ignore */ }
@@ -8156,6 +9619,13 @@ module.exports = function (RED) {
8156
9619
  }]
8157
9620
  node._lastSummary = null
8158
9621
  node._lastSummaryAt = 0
9622
+ node._conversationSessions = new Map()
9623
+ node._pendingKnxCommands = new Map()
9624
+ node._homeMemory = createEmptyKnxAiHomeMemory()
9625
+ node._proactiveStates = new Map()
9626
+ node._proactiveInFlight = new Set()
9627
+ node._proactiveGlobalSentAt = []
9628
+ scheduleHomeMemoryPersist({ immediate: true })
8159
9629
  if (node._summaryRebuildTimer) {
8160
9630
  clearTimeout(node._summaryRebuildTimer)
8161
9631
  node._summaryRebuildTimer = null
@@ -8165,40 +9635,205 @@ module.exports = function (RED) {
8165
9635
  return
8166
9636
  }
8167
9637
 
9638
+ if (cmd === 'confirm' || cmd === 'cancel') {
9639
+ const question = extractKnxAiQuestion(msg)
9640
+ const sessionId = resolveKnxAiSessionId(msg)
9641
+ handleKnxAiConfirmationDecision({
9642
+ msg,
9643
+ question,
9644
+ sessionId,
9645
+ decision: cmd === 'confirm' ? 'confirm' : 'cancel'
9646
+ })
9647
+ return
9648
+ }
9649
+
8168
9650
  if (cmd === 'summary' || cmd === 'stats' || cmd === 'top' || cmd === '') {
8169
9651
  emitSummary()
8170
9652
  return
8171
9653
  }
8172
9654
 
8173
9655
  if (cmd === 'ask') {
8174
- const question = (msg.prompt !== undefined)
8175
- ? String(msg.prompt)
8176
- : (typeof msg.payload === 'string' ? msg.payload : safeStringify(msg.payload))
9656
+ const question = extractKnxAiQuestion(msg)
9657
+ const sessionId = resolveKnxAiSessionId(msg)
9658
+ if (!question) throw new Error('Missing question')
9659
+ const decision = classifyKnxAiConfirmation({ msg, question, topic: cmd })
9660
+ if (node._pendingKnxCommands.has(sessionId) && decision !== 'none') {
9661
+ handleKnxAiConfirmationDecision({ msg, question, sessionId, decision })
9662
+ return
9663
+ }
9664
+ // A new natural-language request replaces an older unconfirmed plan in
9665
+ // the same chat, preventing a later confirmation from acting on stale intent.
9666
+ node._pendingKnxCommands.delete(sessionId)
8177
9667
  updateStatus({ fill: 'blue', shape: 'ring', text: 'AI thinking...' })
8178
9668
  try {
8179
- const ret = await callLLM({ question })
8180
- node._assistantLog.push({ at: new Date().toISOString(), question, content: ret.content, provider: ret.provider, model: ret.model })
9669
+ const ret = node.llmAllowKnxCommands
9670
+ ? await callConversationalLLM({
9671
+ question,
9672
+ sessionId,
9673
+ requireConfirmation: node.llmRequireCommandConfirmation
9674
+ })
9675
+ : await callLLM({ question })
9676
+ const preparedCommands = Array.isArray(ret.commands) ? ret.commands : []
9677
+ const readCommands = preparedCommands.filter(command => command && command.event === 'GroupValue_Read')
9678
+ const writeCommands = preparedCommands.filter(command => !command || command.event !== 'GroupValue_Read')
9679
+ const language = resolveKnxAiLanguage(msg, node.llmDocsLanguage || 'en', question, ret.language)
9680
+ rememberHomeOwner({ sessionId, language })
9681
+ const copy = getKnxAiConfirmationCopy(language)
9682
+ const awaitingConfirmation = node.llmAllowKnxCommands &&
9683
+ node.llmRequireCommandConfirmation &&
9684
+ writeCommands.length > 0
9685
+ let content = ret.content
9686
+ let commandsToEmit = preparedCommands
9687
+ let confirmationRequest = null
9688
+ if (awaitingConfirmation) {
9689
+ content = `${content}\n\n${formatKnxAiCommandPreview({ commands: writeCommands, copy })}`
9690
+ const expiresAt = nowMs() + (5 * 60 * 1000)
9691
+ node._pendingKnxCommands.set(sessionId, {
9692
+ question,
9693
+ commands: writeCommands,
9694
+ language,
9695
+ createdAt: nowMs(),
9696
+ expiresAt
9697
+ })
9698
+ confirmationRequest = buildKnxAiConfirmationRequest({
9699
+ sessionId,
9700
+ expiresAt,
9701
+ commandCount: writeCommands.length,
9702
+ copy
9703
+ })
9704
+ while (node._pendingKnxCommands.size > 50) {
9705
+ const oldestSessionId = node._pendingKnxCommands.keys().next().value
9706
+ node._pendingKnxCommands.delete(oldestSessionId)
9707
+ }
9708
+ commandsToEmit = readCommands
9709
+ }
9710
+ const commandMessages = buildKnxAiCommandMessages({
9711
+ commands: commandsToEmit,
9712
+ question,
9713
+ sessionId,
9714
+ confirmed: node.llmRequireCommandConfirmation !== true,
9715
+ inputMessage: msg
9716
+ })
9717
+ const emittedReadCommands = commandsToEmit.filter(command => command && command.event === 'GroupValue_Read')
9718
+ let readResults = []
9719
+ let readResultMetadata = []
9720
+ if (emittedReadCommands.length > 0) {
9721
+ const readStartedAt = nowMs()
9722
+ const readWaiters = emittedReadCommands.map(command => waitForTelegram({
9723
+ destination: command.destination,
9724
+ events: ['GroupValue_Response', 'GroupValue_Write'],
9725
+ minTs: readStartedAt,
9726
+ timeoutMs: 6000
9727
+ }))
9728
+ if (!sendKnxAiOutputs([null, null, null, commandMessages], msg)) return
9729
+ updateStatus({
9730
+ fill: 'blue',
9731
+ shape: 'ring',
9732
+ text: `AI waiting for ${emittedReadCommands.length} KNX read response(s)`
9733
+ })
9734
+ readResults = await Promise.allSettled(readWaiters)
9735
+ readResultMetadata = emittedReadCommands.map((command, index) => {
9736
+ const result = readResults[index]
9737
+ const telegram = result && result.status === 'fulfilled' ? result.value : null
9738
+ return {
9739
+ destination: command.destination,
9740
+ dpt: command.dpt,
9741
+ label: command.label || '',
9742
+ received: !!telegram,
9743
+ event: telegram ? telegram.event : '',
9744
+ payload: telegram ? telegram.payload : undefined,
9745
+ payloadmeasureunit: telegram ? telegram.payloadmeasureunit : ''
9746
+ }
9747
+ })
9748
+ const readReply = formatKnxAiReadResults({
9749
+ operations: emittedReadCommands,
9750
+ results: readResults,
9751
+ language
9752
+ })
9753
+ content = writeCommands.length > 0 ? `${readReply}\n\n${content}` : readReply
9754
+ }
9755
+ const assistantEntry = {
9756
+ at: new Date().toISOString(),
9757
+ question,
9758
+ content,
9759
+ provider: ret.provider,
9760
+ model: ret.model,
9761
+ sessionId,
9762
+ commandCount: writeCommands.length,
9763
+ readCount: readCommands.length,
9764
+ language,
9765
+ awaitingConfirmation,
9766
+ rejectedCommandCount: Array.isArray(ret.rejectedCommands) ? ret.rejectedCommands.length : 0
9767
+ }
9768
+ node._assistantLog.push(assistantEntry)
8181
9769
  while (node._assistantLog.length > 50) node._assistantLog.shift()
8182
- node.send([null, null, {
8183
- topic: node.outputtopic,
8184
- payload: ret.content,
8185
- knxAi: { type: 'llm', provider: ret.provider, model: ret.model, question },
8186
- summary: ret.summary
8187
- }])
8188
- updateStatus({ fill: 'green', shape: 'dot', text: 'AI answer ready' })
9770
+ if (node.llmAllowKnxCommands) rememberConversationTurn({ sessionId, question, reply: content })
9771
+ const replyMessage = buildKnxAiReplyMessage({
9772
+ inputMessage: msg,
9773
+ content,
9774
+ metadata: {
9775
+ type: 'llm',
9776
+ provider: ret.provider,
9777
+ model: ret.model,
9778
+ question,
9779
+ sessionId,
9780
+ language,
9781
+ operationCount: preparedCommands.length,
9782
+ commandCount: writeCommands.length,
9783
+ readCount: readCommands.length,
9784
+ readResults: readResultMetadata,
9785
+ awaitingConfirmation,
9786
+ confirmationExpiresAt: confirmationRequest ? confirmationRequest.expiresAt : 0,
9787
+ confirmationRequest,
9788
+ rejectedCommands: Array.isArray(ret.rejectedCommands) ? ret.rejectedCommands : [],
9789
+ structuredOutputError: ret.structuredOutputError || ''
9790
+ },
9791
+ summary: emittedReadCommands.length > 0 ? rebuildCachedSummaryNow() : ret.summary
9792
+ })
9793
+ if (emittedReadCommands.length > 0) {
9794
+ if (!sendKnxAiOutputs([null, null, replyMessage, null], msg)) return
9795
+ } else if (!sendKnxAiOutputs([null, null, replyMessage, commandMessages.length ? commandMessages : null], msg)) {
9796
+ return
9797
+ }
9798
+ updateStatus({
9799
+ fill: awaitingConfirmation ? 'yellow' : 'green',
9800
+ shape: awaitingConfirmation ? 'ring' : 'dot',
9801
+ text: awaitingConfirmation
9802
+ ? `AI waiting for confirmation (${writeCommands.length} KNX command(s))`
9803
+ : readCommands.length
9804
+ ? `AI answer ready, ${readResultMetadata.filter(item => item.received).length}/${readCommands.length} KNX read(s) received`
9805
+ : commandMessages.length
9806
+ ? `AI answer ready, ${commandMessages.length} KNX command(s)`
9807
+ : 'AI answer ready'
9808
+ })
8189
9809
  } catch (error) {
8190
9810
  node._assistantLog.push({ at: new Date().toISOString(), question, error: error.message || String(error) })
8191
9811
  while (node._assistantLog.length > 50) node._assistantLog.shift()
8192
- node.send([null, null, {
8193
- topic: node.outputtopic,
8194
- payload: { error: error.message || String(error) },
8195
- knxAi: { type: 'llm_error', question }
8196
- }])
9812
+ const replyMessage = buildKnxAiReplyMessage({
9813
+ inputMessage: msg,
9814
+ content: { error: error.message || String(error) },
9815
+ metadata: { type: 'llm_error', question }
9816
+ })
9817
+ if (!sendKnxAiOutputs([null, null, replyMessage, null], msg)) return
8197
9818
  updateStatus({ fill: 'red', shape: 'dot', text: `AI error: ${error.message || error}` })
8198
9819
  }
8199
9820
  return
8200
9821
  }
8201
9822
 
9823
+ if (cmd === 'clear_chat') {
9824
+ const sessionId = resolveKnxAiSessionId(msg)
9825
+ node._conversationSessions.delete(sessionId)
9826
+ node._pendingKnxCommands.delete(sessionId)
9827
+ const replyMessage = buildKnxAiReplyMessage({
9828
+ inputMessage: msg,
9829
+ content: { ok: true, sessionId },
9830
+ metadata: { type: 'conversation_reset', sessionId }
9831
+ })
9832
+ if (!sendKnxAiOutputs([null, null, replyMessage, null], msg)) return
9833
+ updateStatus({ fill: 'grey', shape: 'dot', text: `AI chat cleared (${sessionId})` })
9834
+ return
9835
+ }
9836
+
8202
9837
  if (cmd === 'run_profile') {
8203
9838
  const areaId = msg.areaId !== undefined ? String(msg.areaId) : (msg.payload && msg.payload.areaId ? String(msg.payload.areaId) : '')
8204
9839
  const profileId = msg.profileId !== undefined ? String(msg.profileId) : (msg.payload && msg.payload.profileId ? String(msg.payload.profileId) : '')
@@ -8249,7 +9884,7 @@ module.exports = function (RED) {
8249
9884
  return
8250
9885
  }
8251
9886
 
8252
- node.warn(`knxUltimateAI: unknown command '${cmd}'. Supported: reset, summary, ask, run_profile, run_actuator_test, run_test_plan`)
9887
+ node.warn(`knxUltimateAI: unknown command '${cmd}'. Supported: reset, summary, ask, confirm, cancel, clear_chat, run_profile, run_actuator_test, run_test_plan`)
8253
9888
  } catch (error) {
8254
9889
  try { node.sysLogger?.error(`knxUltimateAI handleCommand error: ${error.message || error}`) } catch (e) { /* ignore */ }
8255
9890
  try { node.error(error) } catch (e) { /* ignore */ }
@@ -8329,11 +9964,27 @@ module.exports = function (RED) {
8329
9964
 
8330
9965
  node.on('input', function (msg) {
8331
9966
  try {
8332
- const p = handleCommand(msg)
9967
+ let adaptedMessage = msg
9968
+ try {
9969
+ adaptedMessage = executeKnxAiChatAdapter({
9970
+ adapter: node._chatInputAdapter,
9971
+ msg,
9972
+ inputMessage: msg,
9973
+ node,
9974
+ RED
9975
+ })
9976
+ } catch (error) {
9977
+ try { node.sysLogger?.error(`knxUltimateAI chat input adapter error: ${error.message || error}`) } catch (e) { /* ignore */ }
9978
+ try { node.error(error, msg) } catch (e) { /* ignore */ }
9979
+ try { updateStatus({ fill: 'red', shape: 'dot', text: `Chat input adapter error: ${error.message || error}` }) } catch (e) { /* ignore */ }
9980
+ return
9981
+ }
9982
+ if (!adaptedMessage) return
9983
+ const p = handleCommand(adaptedMessage)
8333
9984
  if (p && typeof p.catch === 'function') {
8334
9985
  p.catch((error) => {
8335
9986
  try { node.sysLogger?.error(`knxUltimateAI input error: ${error.message || error}`) } catch (e) { /* ignore */ }
8336
- try { node.error(error) } catch (e) { /* ignore */ }
9987
+ try { node.error(error, adaptedMessage) } catch (e) { /* ignore */ }
8337
9988
  })
8338
9989
  }
8339
9990
  } catch (error) {
@@ -8344,8 +9995,16 @@ module.exports = function (RED) {
8344
9995
 
8345
9996
  node.on('close', function (done) {
8346
9997
  try {
9998
+ node._closing = true
8347
9999
  if (node._timerEmit) clearInterval(node._timerEmit)
8348
10000
  if (node._busConnectionWatchTimer) clearInterval(node._busConnectionWatchTimer)
10001
+ if (node._homeMemoryPeriodicTimer) clearInterval(node._homeMemoryPeriodicTimer)
10002
+ if (node._proactiveCheckTimer) clearInterval(node._proactiveCheckTimer)
10003
+ if (node._homeMemoryWriteTimer) {
10004
+ clearTimeout(node._homeMemoryWriteTimer)
10005
+ node._homeMemoryWriteTimer = null
10006
+ }
10007
+ persistHomeMemoryNow()
8349
10008
  if (node._summaryRebuildTimer) {
8350
10009
  clearTimeout(node._summaryRebuildTimer)
8351
10010
  node._summaryRebuildTimer = null
@@ -8381,10 +10040,23 @@ module.exports = function (RED) {
8381
10040
  try {
8382
10041
  pruneHistoryArchiveFiles({ force: true })
8383
10042
  loadRecentHistoryFromDisk()
10043
+ loadHomeMemoryFromDisk()
8384
10044
  } catch (error) {
8385
10045
  node.sysLogger?.warn(`KNX AI history startup error: ${error.message || error}`)
8386
10046
  }
8387
10047
 
10048
+ if (node._homeMemoryPeriodicTimer) clearInterval(node._homeMemoryPeriodicTimer)
10049
+ node._homeMemoryPeriodicTimer = setInterval(() => {
10050
+ try { persistHomeMemoryNow() } catch (error) { /* persistHomeMemoryNow already guards */ }
10051
+ }, 15 * 60 * 1000)
10052
+
10053
+ if (node._proactiveCheckTimer) clearInterval(node._proactiveCheckTimer)
10054
+ node._proactiveCheckTimer = setInterval(() => {
10055
+ try { checkProactiveHomeState() } catch (error) {
10056
+ try { node.sysLogger?.warn(`KNX AI proactive check error: ${error.message || error}`) } catch (logError) { /* ignore */ }
10057
+ }
10058
+ }, 30 * 1000)
10059
+
8388
10060
  if (node._busConnectionWatchTimer) clearInterval(node._busConnectionWatchTimer)
8389
10061
  node._busConnectionWatchTimer = setInterval(() => {
8390
10062
  pollBusConnectionStatus()
@@ -8400,3 +10072,30 @@ module.exports = function (RED) {
8400
10072
  }
8401
10073
  })
8402
10074
  }
10075
+
10076
+ module.exports.__test = {
10077
+ buildKnxAiConfirmationRequest,
10078
+ buildKnxAiUniversalMessage,
10079
+ classifyKnxAiConfirmation,
10080
+ cloneKnxAiInputMessage,
10081
+ compileKnxAiChatAdapter,
10082
+ coerceKnxAiCommandPayload,
10083
+ detectKnxAiLanguageFromText,
10084
+ executeKnxAiChatAdapter,
10085
+ extractKnxAiQuestion,
10086
+ formatKnxAiCommandPreview,
10087
+ formatKnxAiReadResults,
10088
+ getKnxAiConfirmationCopy,
10089
+ getKnxAiReadCopy,
10090
+ isChatCompletionsModelError,
10091
+ isProbablyChatModelId,
10092
+ isUnsupportedTemperatureError,
10093
+ normalizeKnxAiCommandCandidates,
10094
+ parseKnxAiConversationResponse,
10095
+ postOpenAiCompatibleChatWithFallbacks,
10096
+ resolveKnxAiLanguage,
10097
+ resolveKnxAiOperationEvent,
10098
+ resolveKnxAiSessionId,
10099
+ safeKnxAiSend,
10100
+ validateKnxAiPayloadForDpt
10101
+ }