node-red-contrib-knx-ultimate 6.3.13 → 6.3.15

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 (35) hide show
  1. package/CHANGELOG.md +11 -0
  2. package/examples/KNX AI - Telegrambot Direct Chat.json +0 -1
  3. package/nodes/knxUltimateAI.html +1 -7
  4. package/nodes/knxUltimateAI.js +265 -29
  5. package/nodes/knxUltimateMatterBridge.html +19 -2
  6. package/nodes/knxUltimateMatterBridge.js +18 -3
  7. package/nodes/locales/de/knxUltimateAI.html +6 -6
  8. package/nodes/locales/de/knxUltimateAI.json +1 -3
  9. package/nodes/locales/de/knxUltimateMatterBridge.html +3 -1
  10. package/nodes/locales/de/knxUltimateMatterBridge.json +5 -1
  11. package/nodes/locales/en/knxUltimateAI.html +6 -6
  12. package/nodes/locales/en/knxUltimateAI.json +1 -3
  13. package/nodes/locales/en/knxUltimateMatterBridge.html +3 -1
  14. package/nodes/locales/en/knxUltimateMatterBridge.json +5 -1
  15. package/nodes/locales/es/knxUltimateAI.html +6 -6
  16. package/nodes/locales/es/knxUltimateAI.json +1 -3
  17. package/nodes/locales/es/knxUltimateMatterBridge.html +3 -1
  18. package/nodes/locales/es/knxUltimateMatterBridge.json +5 -1
  19. package/nodes/locales/fr/knxUltimateAI.html +6 -6
  20. package/nodes/locales/fr/knxUltimateAI.json +1 -3
  21. package/nodes/locales/fr/knxUltimateMatterBridge.html +3 -1
  22. package/nodes/locales/fr/knxUltimateMatterBridge.json +5 -1
  23. package/nodes/locales/it/knxUltimateAI.html +6 -6
  24. package/nodes/locales/it/knxUltimateAI.json +1 -3
  25. package/nodes/locales/it/knxUltimateMatterBridge.html +3 -1
  26. package/nodes/locales/it/knxUltimateMatterBridge.json +5 -1
  27. package/nodes/locales/zh-CN/knxUltimateAI.html +6 -6
  28. package/nodes/locales/zh-CN/knxUltimateAI.json +1 -3
  29. package/nodes/locales/zh-CN/knxUltimateMatterBridge.html +3 -1
  30. package/nodes/locales/zh-CN/knxUltimateMatterBridge.json +5 -1
  31. package/nodes/utils/knxAiChatContext.js +277 -0
  32. package/nodes/utils/knxAiHomeMemory.js +2 -2
  33. package/nodes/utils/matterBridgeDeviceFactory.mjs +130 -65
  34. package/package.json +1 -1
  35. package/resources/KNXAIChatAdapterMappings.js +89 -0
package/CHANGELOG.md CHANGED
@@ -6,6 +6,17 @@
6
6
 
7
7
  # CHANGELOG
8
8
 
9
+ **Version 6.3.15** - August 2026<br/>
10
+
11
+ - **KNX AI — persistent shared CHAT context**: every Ask/chat session now reloads its bounded recent conversation and explicit long-term user instructions after Node-RED restarts. Requests such as “remember not to use the term unknown” are retained per session in the atomically written shared file `knxultimatestorage/knxai/memory/knxai-chat-context.md`, independently from home intelligence memory and even when KNX actuator control is disabled. All KNX AI nodes using the same storage share one live context; the file keeps up to 8 recent turns and 20 durable instructions per session, 50 sessions total, and a 512 KB hard limit. `clear_chat` removes only the selected shared session and `reset` clears the shared context.<br/>
12
+ - **KNX AI — unified memory files**: all KNX AI instances using the same storage now share `knxai-home-memory.md` and `knxai-chat-context.md`, without `node.id` in either filename. Per-node memory files from earlier builds are deliberately not migrated or read.<br/>
13
+ - **KNX AI — fixed 5 MB home memory**: the shared home-memory file now has a fixed 5 MB limit. The `homeMemoryMaxKb` editor option has been removed to simplify the UI, and saved values no longer affect runtime behavior.<br/>
14
+ - **KNX AI — RedBot Telegram chat adapter**: added a selectable `RedBot / node-red-contrib-chatbot (Telegram)` preset for direct `chatbot-telegram-receive → KNX AI → chatbot-telegram-send` wiring. It preserves RedBot conversation tracking, maps normalized inbound text and postbacks, and emits native `inline-buttons` confirmation actions.<br/>
15
+
16
+ **Version 6.3.14** - August 2026<br/>
17
+
18
+ - **Expose KNX to Matter — Room Air Conditioner and Door Lock**: added native Matter **Room Air Conditioner** (`0x0072`) and **Door Lock** (`0x000A`) profiles. A room AC combines On/Off, current temperature, heating/cooling setpoints and fan speed in one endpoint; a door lock maps separate Lock/Unlock command and Locked/Unlocked status GAs. Both profiles support KNX or flow-backed state updates without feedback loops, with editor help and documentation in EN/IT/DE/FR/ES/zh-CN.<br/>
19
+
9
20
  **Version 6.3.13** - August 2026<br/>
10
21
 
11
22
  - **Matter**: promoted Matter Controller, Matter Bridge and Expose KNX to Matter from BETA to production-ready status; removed BETA labels and experimental warnings from the palette, editor help, examples and multilingual documentation while retaining pairing, backup and endpoint-structure safety guidance.<br/>
@@ -114,7 +114,6 @@
114
114
  "proactiveCooldownMinutes": "360",
115
115
  "proactiveQuietStart": "23:00",
116
116
  "proactiveQuietEnd": "07:00",
117
- "homeMemoryMaxKb": "256",
118
117
  "aiEducation": "Avvisami se una persiana, una finestra o una porta rimane aperta insolitamente a lungo. Non inviare notifiche durante le ore silenziose configurate.",
119
118
  "x": 500,
120
119
  "y": 210,
@@ -147,7 +147,6 @@
147
147
  proactiveCooldownMinutes: { value: 360 },
148
148
  proactiveQuietStart: { value: "23:00" },
149
149
  proactiveQuietEnd: { value: "07:00" },
150
- homeMemoryMaxKb: { value: 256 },
151
150
  aiEducation: { value: "" }
152
151
  },
153
152
  credentials: {
@@ -237,7 +236,6 @@
237
236
  applyNumericFallback("#node-input-maxEvents", 50000);
238
237
  applyNumericFallback("#node-input-proactiveOpenMinutes", 120);
239
238
  applyNumericFallback("#node-input-proactiveCooldownMinutes", 360);
240
- applyNumericFallback("#node-input-homeMemoryMaxKb", 256);
241
239
 
242
240
  const nodeId = node.id;
243
241
  const resolveAdminRoot = () => {
@@ -826,10 +824,6 @@
826
824
  <label style="width:290px" for="node-input-proactiveCooldownMinutes"><i class="fa fa-bell-slash-o"></i> <span data-i18n="knxUltimateAI.properties.proactiveCooldownMinutes"></span></label>
827
825
  <input style="width:100px" type="number" min="5" max="10080" id="node-input-proactiveCooldownMinutes">
828
826
  </div>
829
- <div class="form-row">
830
- <label style="width:290px" for="node-input-homeMemoryMaxKb"><i class="fa fa-hdd-o"></i> <span data-i18n="knxUltimateAI.properties.homeMemoryMaxKb"></span></label>
831
- <input style="width:100px" type="number" min="64" max="1024" id="node-input-homeMemoryMaxKb">
832
- </div>
833
827
  </div>
834
828
  </div>
835
829
  </div>
@@ -875,4 +869,4 @@
875
869
  </div>
876
870
  </div>
877
871
  </div>
878
- </script>
872
+ </script>
@@ -6,13 +6,13 @@ const path = require('path')
6
6
  const { spawn } = require('child_process')
7
7
  const { getRequestAccessToken, normalizeAuthFromAccessTokenQuery } = require('./utils/httpAdminAccessToken')
8
8
  const {
9
+ HOME_MEMORY_DEFAULT_KB,
9
10
  HOME_MEMORY_MAX_EDUCATION_CHARS,
10
11
  HOME_MEMORY_MAX_SEMANTIC_OBJECTS,
11
12
  addBoundedKnxAiNotification,
12
13
  addBoundedKnxAiObservation,
13
14
  buildKnxAiHomeMemoryMarkdown,
14
15
  buildKnxAiProactiveFallback,
15
- clampHomeMemoryKb,
16
16
  classifyKnxAiOpenState,
17
17
  createEmptyKnxAiHomeMemory,
18
18
  enrichKnxAiHomeCatalog,
@@ -22,6 +22,17 @@ const {
22
22
  parseKnxAiHomeMemoryMarkdown,
23
23
  updateKnxAiCoverHabit
24
24
  } = require('./utils/knxAiHomeMemory')
25
+ const {
26
+ CHAT_CONTEXT_MAX_BYTES,
27
+ addKnxAiChatTurn,
28
+ buildKnxAiChatContextMarkdown,
29
+ buildKnxAiChatPromptContext,
30
+ clearKnxAiChatSession,
31
+ conversationMapFromKnxAiChatContext,
32
+ createEmptyKnxAiChatContext,
33
+ normalizeKnxAiChatContext,
34
+ parseKnxAiChatContextMarkdown
35
+ } = require('./utils/knxAiChatContext')
25
36
  let googleTranslateTTS = null
26
37
  try {
27
38
  googleTranslateTTS = require('google-translate-tts')
@@ -33,8 +44,34 @@ const coerceBoolean = (value) => (value === true || value === 'true')
33
44
 
34
45
  let adminEndpointsRegistered = false
35
46
  const aiRuntimeNodes = new Map()
47
+ const sharedKnxAiHomeMemoryStores = new Map()
48
+ const sharedKnxAiChatContextStores = new Map()
36
49
  const knxAiVueDistDir = path.join(__dirname, 'plugins', 'knxUltimateAI-vue')
37
50
 
51
+ const bindSharedKnxAiState = ({ registry, filePath, node, property, initialValue }) => {
52
+ let store = registry.get(filePath)
53
+ if (!store) {
54
+ store = { value: initialValue, nodes: new Set() }
55
+ registry.set(filePath, store)
56
+ }
57
+ store.nodes.add(node)
58
+ Object.defineProperty(node, property, {
59
+ configurable: true,
60
+ enumerable: true,
61
+ get: () => store.value,
62
+ set: value => { store.value = value }
63
+ })
64
+ node[property] = store.value
65
+ return store
66
+ }
67
+
68
+ const releaseSharedKnxAiState = ({ registry, filePath, node }) => {
69
+ const store = registry.get(filePath)
70
+ if (!store) return
71
+ store.nodes.delete(node)
72
+ if (store.nodes.size === 0) registry.delete(filePath)
73
+ }
74
+
38
75
  // ---------------------------------------------------------------------------
39
76
  // KNX AI Flow Builder helpers
40
77
  // Build a node "catalog" (type + editable fields) from this package's own
@@ -5064,7 +5101,6 @@ module.exports = function (RED) {
5064
5101
  node.proactiveCooldownMinutes = Math.max(5, Math.min(10080, Number(config.proactiveCooldownMinutes) || 360))
5065
5102
  node.proactiveQuietStart = String(config.proactiveQuietStart || '23:00').trim()
5066
5103
  node.proactiveQuietEnd = String(config.proactiveQuietEnd || '07:00').trim()
5067
- node.homeMemoryMaxKb = clampHomeMemoryKb(config.homeMemoryMaxKb)
5068
5104
  node.aiEducation = String(config.aiEducation || '').slice(0, HOME_MEMORY_MAX_EDUCATION_CHARS)
5069
5105
 
5070
5106
  const pushStatus = (status) => {
@@ -5137,6 +5173,8 @@ module.exports = function (RED) {
5137
5173
  node._anomalies = []
5138
5174
  node._assistantLog = []
5139
5175
  node._conversationSessions = new Map()
5176
+ node._chatContext = createEmptyKnxAiChatContext()
5177
+ node._chatContextWriteTimer = null
5140
5178
  node._pendingKnxCommands = new Map()
5141
5179
  node._flowContextCache = { at: 0, text: '' }
5142
5180
  node._docsContextCache = { at: 0, question: '', text: '' }
@@ -6433,7 +6471,14 @@ module.exports = function (RED) {
6433
6471
  const baseDir = (node.serverKNX && node.serverKNX.userDir)
6434
6472
  ? node.serverKNX.userDir
6435
6473
  : path.join(RED.settings.userDir, 'knxultimatestorage')
6436
- return path.join(baseDir, 'knxai', 'memory', `knxai-home-memory-${node.id}.md`)
6474
+ return path.join(baseDir, 'knxai', 'memory', 'knxai-home-memory.md')
6475
+ }
6476
+
6477
+ const getChatContextFile = () => {
6478
+ const baseDir = (node.serverKNX && node.serverKNX.userDir)
6479
+ ? node.serverKNX.userDir
6480
+ : path.join(RED.settings.userDir, 'knxultimatestorage')
6481
+ return path.join(baseDir, 'knxai', 'memory', 'knxai-chat-context.md')
6437
6482
  }
6438
6483
 
6439
6484
  const cleanupHomeMemoryTempFiles = () => {
@@ -6461,7 +6506,7 @@ module.exports = function (RED) {
6461
6506
  }
6462
6507
 
6463
6508
  const synchronizeHomeMemorySemanticObjects = () => {
6464
- const semanticObjects = getGaCatalogSnapshot()
6509
+ const currentSemanticObjects = getGaCatalogSnapshot()
6465
6510
  .filter(item => item && item.semantic && item.semantic.kind !== 'unknown')
6466
6511
  .sort((a, b) => Number(b.semantic.confidence || 0) - Number(a.semantic.confidence || 0))
6467
6512
  .slice(0, HOME_MEMORY_MAX_SEMANTIC_OBJECTS)
@@ -6474,7 +6519,16 @@ module.exports = function (RED) {
6474
6519
  role: item.role || 'neutral',
6475
6520
  confidence: Number(item.semantic.confidence || 0)
6476
6521
  }))
6477
- node._homeMemory.semanticObjects = semanticObjects
6522
+ const semanticByKey = new Map()
6523
+ normalizeKnxAiHomeMemory(node._homeMemory).semanticObjects.forEach((item) => {
6524
+ semanticByKey.set(`${item.ga || ''}\n${item.dpt || ''}\n${item.label || ''}`, item)
6525
+ })
6526
+ currentSemanticObjects.forEach((item) => {
6527
+ semanticByKey.set(`${item.ga || ''}\n${item.dpt || ''}\n${item.label || ''}`, item)
6528
+ })
6529
+ node._homeMemory.semanticObjects = Array.from(semanticByKey.values())
6530
+ .sort((a, b) => Number(b.confidence || 0) - Number(a.confidence || 0))
6531
+ .slice(0, HOME_MEMORY_MAX_SEMANTIC_OBJECTS)
6478
6532
  node._homeMemory.updatedAt = new Date().toISOString()
6479
6533
  }
6480
6534
 
@@ -6484,7 +6538,7 @@ module.exports = function (RED) {
6484
6538
  const rendered = buildKnxAiHomeMemoryMarkdown({
6485
6539
  memory: node._homeMemory,
6486
6540
  education: node.aiEducation,
6487
- maxKb: node.homeMemoryMaxKb
6541
+ maxKb: HOME_MEMORY_DEFAULT_KB
6488
6542
  })
6489
6543
  node._homeMemory = rendered.memory
6490
6544
  const filePath = getHomeMemoryFile()
@@ -6524,26 +6578,162 @@ module.exports = function (RED) {
6524
6578
 
6525
6579
  const loadHomeMemoryFromDisk = () => {
6526
6580
  const filePath = getHomeMemoryFile()
6581
+ node._homeMemoryStorePath = filePath
6527
6582
  try {
6528
6583
  cleanupHomeMemoryTempFiles()
6529
- if (!fs.existsSync(filePath)) {
6530
- node._homeMemory = createEmptyKnxAiHomeMemory()
6531
- return scheduleHomeMemoryPersist({ immediate: true })
6584
+ const sharedStore = sharedKnxAiHomeMemoryStores.get(filePath)
6585
+ if (sharedStore) {
6586
+ bindSharedKnxAiState({
6587
+ registry: sharedKnxAiHomeMemoryStores,
6588
+ filePath,
6589
+ node,
6590
+ property: '_homeMemory',
6591
+ initialValue: sharedStore.value
6592
+ })
6593
+ return null
6532
6594
  }
6533
- const stat = fs.statSync(filePath)
6534
- const absoluteReadLimit = 4 * 1024 * 1024
6535
- if (Number(stat.size || 0) > absoluteReadLimit) {
6536
- throw new Error(`memory file exceeds the safe read limit (${absoluteReadLimit} bytes)`)
6595
+ let loadedMemory
6596
+ if (!fs.existsSync(filePath)) {
6597
+ loadedMemory = createEmptyKnxAiHomeMemory()
6598
+ } else {
6599
+ const stat = fs.statSync(filePath)
6600
+ const absoluteReadLimit = 8 * 1024 * 1024
6601
+ if (Number(stat.size || 0) > absoluteReadLimit) {
6602
+ throw new Error(`memory file exceeds the safe read limit (${absoluteReadLimit} bytes)`)
6603
+ }
6604
+ loadedMemory = normalizeKnxAiHomeMemory(parseKnxAiHomeMemoryMarkdown(fs.readFileSync(filePath, 'utf8')))
6537
6605
  }
6538
- node._homeMemory = normalizeKnxAiHomeMemory(parseKnxAiHomeMemoryMarkdown(fs.readFileSync(filePath, 'utf8')))
6606
+ bindSharedKnxAiState({
6607
+ registry: sharedKnxAiHomeMemoryStores,
6608
+ filePath,
6609
+ node,
6610
+ property: '_homeMemory',
6611
+ initialValue: loadedMemory
6612
+ })
6539
6613
  return scheduleHomeMemoryPersist({ immediate: true })
6540
6614
  } catch (error) {
6541
- node._homeMemory = createEmptyKnxAiHomeMemory()
6615
+ bindSharedKnxAiState({
6616
+ registry: sharedKnxAiHomeMemoryStores,
6617
+ filePath,
6618
+ node,
6619
+ property: '_homeMemory',
6620
+ initialValue: createEmptyKnxAiHomeMemory()
6621
+ })
6542
6622
  try { node.sysLogger?.warn(`KNX AI home memory load error: ${error.message || error}`) } catch (logError) { /* ignore */ }
6543
6623
  return scheduleHomeMemoryPersist({ immediate: true })
6544
6624
  }
6545
6625
  }
6546
6626
 
6627
+ const cleanupChatContextTempFiles = () => {
6628
+ try {
6629
+ const filePath = getChatContextFile()
6630
+ const dirPath = path.dirname(filePath)
6631
+ if (!fs.existsSync(dirPath)) return
6632
+ const prefix = `${path.basename(filePath)}.tmp-`
6633
+ fs.readdirSync(dirPath)
6634
+ .filter(name => String(name).startsWith(prefix))
6635
+ .forEach(name => {
6636
+ try { fs.unlinkSync(path.join(dirPath, name)) } catch (error) { /* ignore */ }
6637
+ })
6638
+ } catch (error) {
6639
+ try { node.sysLogger?.warn(`KNX AI chat context temporary-file cleanup error: ${error.message || error}`) } catch (logError) { /* ignore */ }
6640
+ }
6641
+ }
6642
+
6643
+ const persistChatContextNow = () => {
6644
+ try {
6645
+ const rendered = buildKnxAiChatContextMarkdown({
6646
+ context: node._chatContext,
6647
+ maxBytes: CHAT_CONTEXT_MAX_BYTES
6648
+ })
6649
+ node._chatContext = rendered.context
6650
+ node._conversationSessions = conversationMapFromKnxAiChatContext(node._chatContext)
6651
+ const filePath = getChatContextFile()
6652
+ const dirPath = path.dirname(filePath)
6653
+ if (!ensureDirectorySync(dirPath)) throw new Error(`Unable to create ${dirPath}`)
6654
+ const tempPath = `${filePath}.tmp-${process.pid}-${Date.now()}`
6655
+ try {
6656
+ fs.writeFileSync(tempPath, rendered.markdown, 'utf8')
6657
+ fs.renameSync(tempPath, filePath)
6658
+ } catch (error) {
6659
+ try { if (fs.existsSync(tempPath)) fs.unlinkSync(tempPath) } catch (cleanupError) { /* ignore */ }
6660
+ throw error
6661
+ }
6662
+ return {
6663
+ filePath,
6664
+ bytes: rendered.bytes,
6665
+ maxBytes: rendered.maxBytes
6666
+ }
6667
+ } catch (error) {
6668
+ try { node.sysLogger?.warn(`KNX AI chat context write error: ${error.message || error}`) } catch (logError) { /* ignore */ }
6669
+ return null
6670
+ }
6671
+ }
6672
+
6673
+ const scheduleChatContextPersist = ({ immediate = false } = {}) => {
6674
+ if (node._chatContextWriteTimer) {
6675
+ clearTimeout(node._chatContextWriteTimer)
6676
+ node._chatContextWriteTimer = null
6677
+ }
6678
+ if (immediate) return persistChatContextNow()
6679
+ node._chatContextWriteTimer = setTimeout(() => {
6680
+ node._chatContextWriteTimer = null
6681
+ persistChatContextNow()
6682
+ }, 1500)
6683
+ return null
6684
+ }
6685
+
6686
+ const loadChatContextFromDisk = () => {
6687
+ const filePath = getChatContextFile()
6688
+ node._chatContextStorePath = filePath
6689
+ try {
6690
+ cleanupChatContextTempFiles()
6691
+ const sharedStore = sharedKnxAiChatContextStores.get(filePath)
6692
+ if (sharedStore) {
6693
+ bindSharedKnxAiState({
6694
+ registry: sharedKnxAiChatContextStores,
6695
+ filePath,
6696
+ node,
6697
+ property: '_chatContext',
6698
+ initialValue: sharedStore.value
6699
+ })
6700
+ node._conversationSessions = conversationMapFromKnxAiChatContext(node._chatContext)
6701
+ return null
6702
+ }
6703
+ let loadedContext
6704
+ if (!fs.existsSync(filePath)) {
6705
+ loadedContext = createEmptyKnxAiChatContext()
6706
+ } else {
6707
+ const stat = fs.statSync(filePath)
6708
+ const absoluteReadLimit = 2 * 1024 * 1024
6709
+ if (Number(stat.size || 0) > absoluteReadLimit) {
6710
+ throw new Error(`chat context file exceeds the safe read limit (${absoluteReadLimit} bytes)`)
6711
+ }
6712
+ loadedContext = normalizeKnxAiChatContext(parseKnxAiChatContextMarkdown(fs.readFileSync(filePath, 'utf8')))
6713
+ }
6714
+ bindSharedKnxAiState({
6715
+ registry: sharedKnxAiChatContextStores,
6716
+ filePath,
6717
+ node,
6718
+ property: '_chatContext',
6719
+ initialValue: loadedContext
6720
+ })
6721
+ node._conversationSessions = conversationMapFromKnxAiChatContext(node._chatContext)
6722
+ return scheduleChatContextPersist({ immediate: true })
6723
+ } catch (error) {
6724
+ bindSharedKnxAiState({
6725
+ registry: sharedKnxAiChatContextStores,
6726
+ filePath,
6727
+ node,
6728
+ property: '_chatContext',
6729
+ initialValue: createEmptyKnxAiChatContext()
6730
+ })
6731
+ node._conversationSessions = new Map()
6732
+ try { node.sysLogger?.warn(`KNX AI chat context load error: ${error.message || error}`) } catch (logError) { /* ignore */ }
6733
+ return scheduleChatContextPersist({ immediate: true })
6734
+ }
6735
+ }
6736
+
6547
6737
  const getHomeMemoryPromptContext = ({ maxChars = 6000 } = {}) => {
6548
6738
  const memory = normalizeKnxAiHomeMemory(node._homeMemory)
6549
6739
  const education = String(node.aiEducation || '').trim().slice(0, HOME_MEMORY_MAX_EDUCATION_CHARS)
@@ -8719,9 +8909,15 @@ module.exports = function (RED) {
8719
8909
  }
8720
8910
  }
8721
8911
 
8722
- const callLLM = async ({ question }) => {
8912
+ const callLLM = async ({ question, sessionId = 'default' }) => {
8723
8913
  const summary = rebuildCachedSummaryNow()
8724
- const userContent = buildLLMPrompt({ question, summary })
8914
+ const chatContext = buildKnxAiChatPromptContext({
8915
+ context: node._chatContext,
8916
+ sessionId,
8917
+ maxChars: 16000
8918
+ })
8919
+ const prompt = buildLLMPrompt({ question, summary })
8920
+ const userContent = chatContext ? `${chatContext}\n\n${prompt}` : prompt
8725
8921
  const configuredMaxTokens = Math.max(10000, Number(node.llmMaxTokens) || 0)
8726
8922
  let ret = await callLLMChat({
8727
8923
  systemPrompt: node.llmSystemPrompt || '',
@@ -8731,7 +8927,13 @@ module.exports = function (RED) {
8731
8927
  const finishReason = String(ret && ret.finishReason ? ret.finishReason : '').trim().toLowerCase()
8732
8928
  const lengthLimited = finishReason === 'length' || isOpenAICompatLengthFallbackText(ret && ret.content)
8733
8929
  if (lengthLimited) {
8734
- const compactPrompt = buildLLMPrompt({ question, summary, compact: true })
8930
+ const compactChatContext = buildKnxAiChatPromptContext({
8931
+ context: node._chatContext,
8932
+ sessionId,
8933
+ maxChars: 6000
8934
+ })
8935
+ const compactBasePrompt = buildLLMPrompt({ question, summary, compact: true })
8936
+ const compactPrompt = compactChatContext ? `${compactChatContext}\n\n${compactBasePrompt}` : compactBasePrompt
8735
8937
  const retryMaxTokens = Math.min(16000, Math.max(10000, Math.round(configuredMaxTokens * 1.25)))
8736
8938
  try {
8737
8939
  ret = await callLLMChat({
@@ -8766,12 +8968,22 @@ module.exports = function (RED) {
8766
8968
  const oldestKey = node._conversationSessions.keys().next().value
8767
8969
  node._conversationSessions.delete(oldestKey)
8768
8970
  }
8971
+ node._chatContext = addKnxAiChatTurn(node._chatContext, {
8972
+ sessionId: key,
8973
+ question,
8974
+ reply
8975
+ })
8976
+ scheduleChatContextPersist()
8769
8977
  }
8770
8978
 
8771
8979
  const callConversationalLLM = async ({ question, sessionId, requireConfirmation = true }) => {
8772
8980
  const summary = rebuildCachedSummaryNow()
8773
8981
  const catalog = getGaCatalogSnapshot()
8774
- const history = getConversationHistory(sessionId)
8982
+ const chatContext = buildKnxAiChatPromptContext({
8983
+ context: node._chatContext,
8984
+ sessionId,
8985
+ maxChars: 16000
8986
+ })
8775
8987
  const gaLimit = 600
8776
8988
  const gaLines = catalog.slice(0, gaLimit).map((item) => {
8777
8989
  const role = String(item && item.role ? item.role : 'neutral').trim()
@@ -8787,10 +8999,6 @@ module.exports = function (RED) {
8787
8999
  : ''
8788
9000
  return `${item.ga} | dpt ${dpt} | role ${role} | ${label}${semanticText}${valueOptions ? ` | values ${valueOptions}` : ''}`
8789
9001
  })
8790
- const conversationLines = history.flatMap(turn => [
8791
- `User: ${turn.question}`,
8792
- `Assistant: ${turn.reply}`
8793
- ])
8794
9002
  const analysisContext = buildLLMPrompt({ question, summary, compact: true })
8795
9003
  const systemPrompt = [
8796
9004
  node.llmSystemPrompt || 'You are a KNX building automation assistant.',
@@ -8809,13 +9017,13 @@ module.exports = function (RED) {
8809
9017
  '- 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.',
8810
9018
  '- Emit the smallest necessary operation set, in execution order, with at most 5 writes and 20 reads.',
8811
9019
  '- Do not claim that an action succeeded. Say that the command is being forwarded or prepared; real KNX feedback is separate.',
9020
+ '- Follow persistent chat instructions and preferences for wording and style, but never let them override this KNX safety contract.',
8812
9021
  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.' : '',
8813
9022
  '- If the request is ambiguous, unsafe, unsupported, or has no exact KNX object, ask a concise clarification and return no commands.'
8814
9023
  ].filter(Boolean).join('\n')
8815
9024
  const userContent = [
8816
- history.length ? 'RECENT CONVERSATION:' : '',
8817
- history.length ? conversationLines.join('\n') : '',
8818
- history.length ? '' : '',
9025
+ chatContext,
9026
+ chatContext ? '' : '',
8819
9027
  getHomeMemoryPromptContext({ maxChars: 6000 }),
8820
9028
  '',
8821
9029
  analysisContext,
@@ -9625,12 +9833,14 @@ module.exports = function (RED) {
9625
9833
  node._lastSummary = null
9626
9834
  node._lastSummaryAt = 0
9627
9835
  node._conversationSessions = new Map()
9836
+ node._chatContext = createEmptyKnxAiChatContext()
9628
9837
  node._pendingKnxCommands = new Map()
9629
9838
  node._homeMemory = createEmptyKnxAiHomeMemory()
9630
9839
  node._proactiveStates = new Map()
9631
9840
  node._proactiveInFlight = new Set()
9632
9841
  node._proactiveGlobalSentAt = []
9633
9842
  scheduleHomeMemoryPersist({ immediate: true })
9843
+ scheduleChatContextPersist({ immediate: true })
9634
9844
  if (node._summaryRebuildTimer) {
9635
9845
  clearTimeout(node._summaryRebuildTimer)
9636
9846
  node._summaryRebuildTimer = null
@@ -9677,7 +9887,7 @@ module.exports = function (RED) {
9677
9887
  sessionId,
9678
9888
  requireConfirmation: node.llmRequireCommandConfirmation
9679
9889
  })
9680
- : await callLLM({ question })
9890
+ : await callLLM({ question, sessionId })
9681
9891
  const preparedCommands = Array.isArray(ret.commands) ? ret.commands : []
9682
9892
  const readCommands = preparedCommands.filter(command => command && command.event === 'GroupValue_Read')
9683
9893
  const writeCommands = preparedCommands.filter(command => !command || command.event !== 'GroupValue_Read')
@@ -9772,7 +9982,7 @@ module.exports = function (RED) {
9772
9982
  }
9773
9983
  node._assistantLog.push(assistantEntry)
9774
9984
  while (node._assistantLog.length > 50) node._assistantLog.shift()
9775
- if (node.llmAllowKnxCommands) rememberConversationTurn({ sessionId, question, reply: content })
9985
+ rememberConversationTurn({ sessionId, question, reply: content })
9776
9986
  const replyMessage = buildKnxAiReplyMessage({
9777
9987
  inputMessage: msg,
9778
9988
  content,
@@ -9828,7 +10038,9 @@ module.exports = function (RED) {
9828
10038
  if (cmd === 'clear_chat') {
9829
10039
  const sessionId = resolveKnxAiSessionId(msg)
9830
10040
  node._conversationSessions.delete(sessionId)
10041
+ node._chatContext = clearKnxAiChatSession(node._chatContext, sessionId)
9831
10042
  node._pendingKnxCommands.delete(sessionId)
10043
+ scheduleChatContextPersist({ immediate: true })
9832
10044
  const replyMessage = buildKnxAiReplyMessage({
9833
10045
  inputMessage: msg,
9834
10046
  content: { ok: true, sessionId },
@@ -9960,9 +10172,11 @@ module.exports = function (RED) {
9960
10172
  const q = String(question || '').trim()
9961
10173
  if (q === '') throw new Error('Missing question')
9962
10174
  updateStatus({ fill: 'blue', shape: 'ring', text: 'AI thinking...' })
9963
- const ret = await callLLM({ question: q })
10175
+ const sessionId = 'sidebar'
10176
+ const ret = await callLLM({ question: q, sessionId })
9964
10177
  node._assistantLog.push({ at: new Date().toISOString(), question: q, content: ret.content, provider: ret.provider, model: ret.model })
9965
10178
  while (node._assistantLog.length > 50) node._assistantLog.shift()
10179
+ rememberConversationTurn({ sessionId, question: q, reply: ret.content })
9966
10180
  updateStatus({ fill: 'green', shape: 'dot', text: 'AI answer ready' })
9967
10181
  return { answer: ret.content, provider: ret.provider, model: ret.model, summary: ret.summary }
9968
10182
  }
@@ -10009,7 +10223,26 @@ module.exports = function (RED) {
10009
10223
  clearTimeout(node._homeMemoryWriteTimer)
10010
10224
  node._homeMemoryWriteTimer = null
10011
10225
  }
10226
+ if (node._chatContextWriteTimer) {
10227
+ clearTimeout(node._chatContextWriteTimer)
10228
+ node._chatContextWriteTimer = null
10229
+ }
10012
10230
  persistHomeMemoryNow()
10231
+ persistChatContextNow()
10232
+ if (node._homeMemoryStorePath) {
10233
+ releaseSharedKnxAiState({
10234
+ registry: sharedKnxAiHomeMemoryStores,
10235
+ filePath: node._homeMemoryStorePath,
10236
+ node
10237
+ })
10238
+ }
10239
+ if (node._chatContextStorePath) {
10240
+ releaseSharedKnxAiState({
10241
+ registry: sharedKnxAiChatContextStores,
10242
+ filePath: node._chatContextStorePath,
10243
+ node
10244
+ })
10245
+ }
10013
10246
  if (node._summaryRebuildTimer) {
10014
10247
  clearTimeout(node._summaryRebuildTimer)
10015
10248
  node._summaryRebuildTimer = null
@@ -10046,6 +10279,7 @@ module.exports = function (RED) {
10046
10279
  pruneHistoryArchiveFiles({ force: true })
10047
10280
  loadRecentHistoryFromDisk()
10048
10281
  loadHomeMemoryFromDisk()
10282
+ loadChatContextFromDisk()
10049
10283
  } catch (error) {
10050
10284
  node.sysLogger?.warn(`KNX AI history startup error: ${error.message || error}`)
10051
10285
  }
@@ -10079,6 +10313,7 @@ module.exports = function (RED) {
10079
10313
  }
10080
10314
 
10081
10315
  module.exports.__test = {
10316
+ bindSharedKnxAiState,
10082
10317
  buildKnxAiPackageNodeCatalog,
10083
10318
  buildKnxAiConfirmationRequest,
10084
10319
  buildKnxAiUniversalMessage,
@@ -10102,6 +10337,7 @@ module.exports.__test = {
10102
10337
  resolveKnxAiLanguage,
10103
10338
  resolveKnxAiOperationEvent,
10104
10339
  resolveKnxAiSessionId,
10340
+ releaseSharedKnxAiState,
10105
10341
  safeKnxAiSend,
10106
10342
  validateKnxAiPayloadForDpt
10107
10343
  }
@@ -54,6 +54,21 @@
54
54
  { key: 'CoolingSetpoint', labelKey: 'fn_coolingsetpoint_cmd', dptPrefix: '9.', fallbackDpt: '9.001' },
55
55
  { key: 'CoolingSetpointStatus', labelKey: 'fn_coolingsetpoint_status', dptPrefix: '9.', fallbackDpt: '9.001' }
56
56
  ],
57
+ roomairconditioner: [
58
+ { key: 'OnOff', labelKey: 'fn_onoff_cmd', dptPrefix: '1.', fallbackDpt: '1.001' },
59
+ { key: 'OnOffStatus', labelKey: 'fn_onoff_status', dptPrefix: '1.', fallbackDpt: '1.001' },
60
+ { key: 'CurrentTemp', labelKey: 'fn_currenttemp', dptPrefix: '9.', fallbackDpt: '9.001' },
61
+ { key: 'Setpoint', labelKey: 'fn_setpoint_cmd', dptPrefix: '9.', fallbackDpt: '9.001' },
62
+ { key: 'SetpointStatus', labelKey: 'fn_setpoint_status', dptPrefix: '9.', fallbackDpt: '9.001' },
63
+ { key: 'CoolingSetpoint', labelKey: 'fn_coolingsetpoint_cmd', dptPrefix: '9.', fallbackDpt: '9.001' },
64
+ { key: 'CoolingSetpointStatus', labelKey: 'fn_coolingsetpoint_status', dptPrefix: '9.', fallbackDpt: '9.001' },
65
+ { key: 'FanSpeed', labelKey: 'fn_fanspeed_cmd', dptPrefix: '5.', fallbackDpt: '5.001' },
66
+ { key: 'FanSpeedStatus', labelKey: 'fn_fanspeed_status', dptPrefix: '5.', fallbackDpt: '5.001' }
67
+ ],
68
+ doorlock: [
69
+ { key: 'Lock', labelKey: 'fn_lock_cmd', dptPrefix: '1.', fallbackDpt: '1.001' },
70
+ { key: 'LockStatus', labelKey: 'fn_lock_status', dptPrefix: '1.', fallbackDpt: '1.001' }
71
+ ],
57
72
  smokecoalarm: [
58
73
  { key: 'Smoke', labelKey: 'fn_smoke_ga', dptPrefix: '1.', fallbackDpt: '1.005' },
59
74
  { key: 'CO', labelKey: 'fn_co_ga', dptPrefix: '1.', fallbackDpt: '1.005' }
@@ -66,7 +81,7 @@
66
81
  ],
67
82
  robotvacuum: [] // Flow-only: no GA fields, controlled via the node PINs
68
83
  };
69
- const TYPE_ORDER = ['onofflight', 'dimmablelight', 'rgblight', 'colortemperaturelight', 'onoffplug', 'windowcovering', 'thermostat', 'fan', 'temperaturesensor', 'humiditysensor', 'lightsensor', 'occupancysensor', 'contactsensor', 'smokecoalarm', 'waterleakdetector', 'airqualitysensor', 'robotvacuum'];
84
+ const TYPE_ORDER = ['onofflight', 'dimmablelight', 'rgblight', 'colortemperaturelight', 'onoffplug', 'windowcovering', 'thermostat', 'roomairconditioner', 'fan', 'doorlock', 'temperaturesensor', 'humiditysensor', 'lightsensor', 'occupancysensor', 'contactsensor', 'smokecoalarm', 'waterleakdetector', 'airqualitysensor', 'robotvacuum'];
70
85
  const FLOW_HELP = {
71
86
  onofflight: { input: [['onoff', true]], output: [['onoff', true]] },
72
87
  onoffplug: { input: [['onoff', true]], output: [['onoff', true]] },
@@ -80,6 +95,8 @@
80
95
  contactsensor: { input: [['contact', true]], output: [] },
81
96
  windowcovering: { input: [['position', 35]], output: [['updown', false], ['position', 35], ['stop', true]] },
82
97
  thermostat: { input: [['currenttemp', 21.5], ['setpoint', 21], ['coolingsetpoint', 24]], output: [['setpoint', 21], ['coolingsetpoint', 24]] },
98
+ roomairconditioner: { input: [['onoff', true], ['currenttemp', 25.5], ['setpoint', 21], ['coolingsetpoint', 24], ['fanspeed', 60]], output: [['onoff', true], ['setpoint', 21], ['coolingsetpoint', 24], ['fanspeed', 60]] },
99
+ doorlock: { input: [['lock', true]], output: [['lock', true]] },
83
100
  smokecoalarm: { input: [['smoke', true], ['co', true]], output: [] },
84
101
  waterleakdetector: { input: [['leak', true]], output: [] },
85
102
  airqualitysensor: { input: [['co2', 850]], output: [] },
@@ -379,7 +396,7 @@
379
396
  const isCover = selected === 'windowcovering';
380
397
  // The Alexa duplicate-command dedup applies to any device that emits On/Off or
381
398
  // level commands (plain on/off lights and plugs included), not just dimmers.
382
- const hasCommand = selected === 'onofflight' || selected === 'onoffplug' || hasLevel;
399
+ const hasCommand = selected === 'onofflight' || selected === 'onoffplug' || selected === 'roomairconditioner' || hasLevel;
383
400
  $advancedRows.toggle(hasCommand || isCover);
384
401
  $commandAdvancedRows.toggle(hasCommand);
385
402
  $dimmerAdvancedRows.toggle(hasLevel);
@@ -55,6 +55,21 @@ module.exports = function (RED) {
55
55
  { fn: 'coolingsetpoint', direction: 'command', ga: 'gaCoolingSetpoint', dpt: 'dptCoolingSetpoint', fallbackDpt: '9.001' },
56
56
  { fn: 'coolingsetpoint', direction: 'status', ga: 'gaCoolingSetpointStatus', dpt: 'dptCoolingSetpointStatus', fallbackDpt: '9.001' }
57
57
  ],
58
+ roomairconditioner: [
59
+ { fn: 'onoff', direction: 'command', ga: 'gaOnOff', dpt: 'dptOnOff', fallbackDpt: '1.001' },
60
+ { fn: 'onoff', direction: 'status', ga: 'gaOnOffStatus', dpt: 'dptOnOffStatus', fallbackDpt: '1.001' },
61
+ { fn: 'currenttemp', direction: 'status', ga: 'gaCurrentTemp', dpt: 'dptCurrentTemp', fallbackDpt: '9.001' },
62
+ { fn: 'setpoint', direction: 'command', ga: 'gaSetpoint', dpt: 'dptSetpoint', fallbackDpt: '9.001' },
63
+ { fn: 'setpoint', direction: 'status', ga: 'gaSetpointStatus', dpt: 'dptSetpointStatus', fallbackDpt: '9.001' },
64
+ { fn: 'coolingsetpoint', direction: 'command', ga: 'gaCoolingSetpoint', dpt: 'dptCoolingSetpoint', fallbackDpt: '9.001' },
65
+ { fn: 'coolingsetpoint', direction: 'status', ga: 'gaCoolingSetpointStatus', dpt: 'dptCoolingSetpointStatus', fallbackDpt: '9.001' },
66
+ { fn: 'fanspeed', direction: 'command', ga: 'gaFanSpeed', dpt: 'dptFanSpeed', fallbackDpt: '5.001' },
67
+ { fn: 'fanspeed', direction: 'status', ga: 'gaFanSpeedStatus', dpt: 'dptFanSpeedStatus', fallbackDpt: '5.001' }
68
+ ],
69
+ doorlock: [
70
+ { fn: 'lock', direction: 'command', ga: 'gaLock', dpt: 'dptLock', fallbackDpt: '1.001' },
71
+ { fn: 'lock', direction: 'status', ga: 'gaLockStatus', dpt: 'dptLockStatus', fallbackDpt: '1.001' }
72
+ ],
58
73
  smokecoalarm: [
59
74
  { fn: 'smoke', direction: 'status', ga: 'gaSmoke', dpt: 'dptSmoke', fallbackDpt: '1.005' },
60
75
  { fn: 'co', direction: 'status', ga: 'gaCO', dpt: 'dptCO', fallbackDpt: '1.005' }
@@ -275,9 +290,9 @@ module.exports = function (RED) {
275
290
  coverSliderDebounceMs: node.coverSliderDebounceMs,
276
291
  commandDedupMs: node.commandDedupMs,
277
292
  coverUpdateMode: node.coverUpdateMode,
278
- // Thermostat only: whether a heating/cooling setpoint GA (command or status) was
279
- // configured, so the bridge engine can build a Heating-only, Cooling-only or dual
280
- // ThermostatServer without a dedicated "mode" dropdown in the editor.
293
+ // Thermostat/Room Air Conditioner: whether a heating/cooling setpoint GA (command
294
+ // or status) was configured, so the bridge engine can build a Heating-only,
295
+ // Cooling-only or dual ThermostatServer without a dedicated mode dropdown.
281
296
  hasHeatingSetpoint: devConfigValue('gaSetpoint') !== '' || devConfigValue('gaSetpointStatus') !== '',
282
297
  hasCoolingSetpoint: devConfigValue('gaCoolingSetpoint') !== '' || devConfigValue('gaCoolingSetpointStatus') !== ''
283
298
  })