node-red-contrib-knx-ultimate 6.2.0 → 6.2.2

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')
@@ -4977,11 +4994,11 @@ module.exports = function (RED) {
4977
4994
  node.outputtopic = node.topic
4978
4995
  node.dpt = ''
4979
4996
 
4980
- node.notifyreadrequest = config.notifyreadrequest !== undefined ? coerceBoolean(config.notifyreadrequest) : true
4997
+ node.notifyreadrequest = true
4981
4998
  node.notifyreadrequestalsorespondtobus = 'false'
4982
4999
  node.notifyreadrequestalsorespondtobusdefaultvalueifnotinitialized = ''
4983
- node.notifyresponse = config.notifyresponse !== undefined ? coerceBoolean(config.notifyresponse) : true
4984
- node.notifywrite = config.notifywrite !== undefined ? coerceBoolean(config.notifywrite) : true
5000
+ node.notifyresponse = true
5001
+ node.notifywrite = true
4985
5002
  node.initialread = false
4986
5003
  node.listenallga = true
4987
5004
  node.outputtype = 'write'
@@ -4996,16 +5013,16 @@ module.exports = function (RED) {
4996
5013
  node.emitIntervalSec = Number(config.emitIntervalSec || 0)
4997
5014
  node.topN = Number(config.topN || 10)
4998
5015
 
4999
- node.rateWindowSec = Number(config.rateWindowSec || 10)
5000
- node.maxTelegramPerSecOverall = Number(config.maxTelegramPerSecOverall || 0)
5001
- node.maxTelegramPerSecPerGA = Number(config.maxTelegramPerSecPerGA || 0)
5016
+ node.rateWindowSec = 10
5017
+ node.maxTelegramPerSecOverall = 0
5018
+ node.maxTelegramPerSecPerGA = 0
5002
5019
 
5003
- node.flapWindowSec = Number(config.flapWindowSec || 30)
5004
- node.flapMaxChanges = Number(config.flapMaxChanges || 0)
5020
+ node.flapWindowSec = 30
5021
+ node.flapMaxChanges = 0
5005
5022
 
5006
- node.enablePattern = config.enablePattern !== undefined ? coerceBoolean(config.enablePattern) : true
5007
- node.patternMaxLagMs = Number(config.patternMaxLagMs || 1500)
5008
- node.patternMinCount = Number(config.patternMinCount || 8)
5023
+ node.enablePattern = true
5024
+ node.patternMaxLagMs = 1500
5025
+ node.patternMinCount = 8
5009
5026
 
5010
5027
  node.llmEnabled = config.llmEnabled !== undefined ? coerceBoolean(config.llmEnabled) : false
5011
5028
  node.llmProvider = config.llmProvider || 'openai_compat'
@@ -5020,14 +5037,14 @@ module.exports = function (RED) {
5020
5037
  // Prefer Node-RED credentials store, fallback to legacy config field (backward compatible)
5021
5038
  node.llmApiKey = sanitizeApiKey((node.credentials && node.credentials.llmApiKey) ? node.credentials.llmApiKey : (config.llmApiKey || ''))
5022
5039
  node.llmModel = config.llmModel || (node.llmProvider === 'anthropic' ? ANTHROPIC_DEFAULT_MODEL : 'gpt-4o-mini')
5023
- node.llmSystemPrompt = config.llmSystemPrompt || 'You are a KNX building automation assistant. Analyze KNX bus traffic and provide actionable insights.'
5040
+ node.llmSystemPrompt = 'You are a KNX building automation assistant. Analyze KNX bus traffic and provide actionable insights.'
5024
5041
  node.llmTemperature = (config.llmTemperature === undefined || config.llmTemperature === '') ? 0.2 : Number(config.llmTemperature)
5025
5042
  node.llmMaxTokens = (config.llmMaxTokens === undefined || config.llmMaxTokens === '') ? 50000 : Number(config.llmMaxTokens)
5026
5043
  node.llmTimeoutMs = (config.llmTimeoutMs === undefined || config.llmTimeoutMs === '') ? 120000 : Number(config.llmTimeoutMs)
5027
5044
  node.llmMaxEventsInPrompt = (config.llmMaxEventsInPrompt === undefined || config.llmMaxEventsInPrompt === '') ? 120 : Number(config.llmMaxEventsInPrompt)
5028
- node.llmIncludeRaw = config.llmIncludeRaw !== undefined ? coerceBoolean(config.llmIncludeRaw) : false
5045
+ node.llmIncludeRaw = false
5029
5046
  node.llmIncludeFlowContext = config.llmIncludeFlowContext !== undefined ? coerceBoolean(config.llmIncludeFlowContext) : true
5030
- node.llmIncludeDocsSnippets = config.llmIncludeDocsSnippets !== undefined ? coerceBoolean(config.llmIncludeDocsSnippets) : true
5047
+ node.llmIncludeDocsSnippets = true
5031
5048
  node.llmDocsLanguage = config.llmDocsLanguage ? String(config.llmDocsLanguage) : 'it'
5032
5049
  node.llmDocsMaxSnippets = (config.llmDocsMaxSnippets === undefined || config.llmDocsMaxSnippets === '') ? 5 : Number(config.llmDocsMaxSnippets)
5033
5050
  node.llmDocsMaxChars = (config.llmDocsMaxChars === undefined || config.llmDocsMaxChars === '') ? 60000 : Number(config.llmDocsMaxChars)
@@ -5036,6 +5053,14 @@ module.exports = function (RED) {
5036
5053
  node.chatAdapterPreset = String(config.chatAdapterPreset || 'none')
5037
5054
  node.chatInputCode = String(config.chatInputCode || '')
5038
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)
5039
5064
 
5040
5065
  const pushStatus = (status) => {
5041
5066
  if (!status) return
@@ -5122,6 +5147,16 @@ module.exports = function (RED) {
5122
5147
  node._gaLabelCsvCache = { ref: null, map: {} }
5123
5148
  node._busConnectionWatchTimer = null
5124
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
5125
5160
  node._busConnectionState = (node.serverKNX && typeof node.serverKNX.linkStatus === 'string')
5126
5161
  ? String(node.serverKNX.linkStatus).toLowerCase()
5127
5162
  : 'unknown'
@@ -6234,6 +6269,7 @@ module.exports = function (RED) {
6234
6269
  const wantsFunctionNodeSourceContext = node.llmIncludeFlowContext && shouldIncludeFunctionNodeSourceContext(question)
6235
6270
  const areasSnapshot = buildAreasSnapshot({ summary })
6236
6271
  const areasContext = buildAreasPromptContext(areasSnapshot)
6272
+ const homeMemoryContext = getHomeMemoryPromptContext({ maxChars: compactMode ? 2200 : 6000 })
6237
6273
  const summaryForPrompt = buildLlmSummarySnapshot(summary)
6238
6274
  const summaryText = truncatePromptText(safeStringify(summaryForPrompt), compactMode ? 4000 : 10000)
6239
6275
  const lines = recent.map(t => {
@@ -6315,6 +6351,8 @@ module.exports = function (RED) {
6315
6351
  '',
6316
6352
  areasContext || '',
6317
6353
  areasContext ? '' : '',
6354
+ homeMemoryContext || '',
6355
+ homeMemoryContext ? '' : '',
6318
6356
  flowContext ? 'Node-RED context:' : '',
6319
6357
  flowContext || '',
6320
6358
  flowContext ? '' : '',
@@ -6355,10 +6393,10 @@ module.exports = function (RED) {
6355
6393
  if (node._gaCatalogCache && node._gaCatalogCache.ref === csv && node._gaCatalogCache.roleOverridesKey === roleOverridesKey && Array.isArray(node._gaCatalogCache.snapshot)) {
6356
6394
  return node._gaCatalogCache.snapshot
6357
6395
  }
6358
- const snapshot = applyGaRoleOverridesToCatalog({
6396
+ const snapshot = enrichKnxAiHomeCatalog(applyGaRoleOverridesToCatalog({
6359
6397
  catalog: buildGaCatalogFromCsv(csv),
6360
6398
  roleOverrides
6361
- })
6399
+ }))
6362
6400
  node._gaCatalogCache = { ref: csv, roleOverridesKey, snapshot }
6363
6401
  return snapshot
6364
6402
  }
@@ -6386,6 +6424,147 @@ module.exports = function (RED) {
6386
6424
 
6387
6425
  const getHistoryArchiveFile = (dayKey) => path.join(getHistoryArchiveDir(), `${String(dayKey || '').trim() || formatArchiveDayKey(Date.now())}.jsonl`)
6388
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
+
6389
6568
  const pruneHistoryArchiveFiles = ({ force = false } = {}) => {
6390
6569
  if (node.historyStoreToDisk !== true) return
6391
6570
  const retentionDays = Math.max(1, Math.round(Number.isFinite(Number(node.historyStoreRetentionDays)) ? Number(node.historyStoreRetentionDays) : 1))
@@ -8593,11 +8772,15 @@ module.exports = function (RED) {
8593
8772
  const role = String(item && item.role ? item.role : 'neutral').trim()
8594
8773
  const dpt = String(item && item.dpt ? item.dpt : '').trim() || '?'
8595
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 : {}
8596
8776
  const valueOptions = (Array.isArray(item && item.valueOptions) ? item.valueOptions : [])
8597
8777
  .slice(0, 20)
8598
8778
  .map(option => `${option.value}=${option.label}`)
8599
8779
  .join(', ')
8600
- return `${item.ga} | dpt ${dpt} | role ${role} | ${label}${valueOptions ? ` | values ${valueOptions}` : ''}`
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}` : ''}`
8601
8784
  })
8602
8785
  const conversationLines = history.flatMap(turn => [
8603
8786
  `User: ${turn.question}`,
@@ -8628,6 +8811,8 @@ module.exports = function (RED) {
8628
8811
  history.length ? 'RECENT CONVERSATION:' : '',
8629
8812
  history.length ? conversationLines.join('\n') : '',
8630
8813
  history.length ? '' : '',
8814
+ getHomeMemoryPromptContext({ maxChars: 6000 }),
8815
+ '',
8631
8816
  analysisContext,
8632
8817
  '',
8633
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):`,
@@ -9136,6 +9321,267 @@ module.exports = function (RED) {
9136
9321
  node._gaState.set(ga, state)
9137
9322
  }
9138
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
+
9139
9585
  // Called by knxUltimate-config.js
9140
9586
  node.handleSend = (msg) => {
9141
9587
  try {
@@ -9149,6 +9595,7 @@ module.exports = function (RED) {
9149
9595
  trimHistory(now)
9150
9596
  maybeEmitGAAnomalies(telegram)
9151
9597
  maybeEmitOverallAnomaly(now)
9598
+ processProactiveTelegram(telegram)
9152
9599
  scheduleRealtimeSummaryRebuild()
9153
9600
  } catch (error) {
9154
9601
  try { node.sysLogger?.error(`knxUltimateAI handleSend error: ${error.message || error}`) } catch (e) { /* ignore */ }
@@ -9174,6 +9621,11 @@ module.exports = function (RED) {
9174
9621
  node._lastSummaryAt = 0
9175
9622
  node._conversationSessions = new Map()
9176
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 })
9177
9629
  if (node._summaryRebuildTimer) {
9178
9630
  clearTimeout(node._summaryRebuildTimer)
9179
9631
  node._summaryRebuildTimer = null
@@ -9225,6 +9677,7 @@ module.exports = function (RED) {
9225
9677
  const readCommands = preparedCommands.filter(command => command && command.event === 'GroupValue_Read')
9226
9678
  const writeCommands = preparedCommands.filter(command => !command || command.event !== 'GroupValue_Read')
9227
9679
  const language = resolveKnxAiLanguage(msg, node.llmDocsLanguage || 'en', question, ret.language)
9680
+ rememberHomeOwner({ sessionId, language })
9228
9681
  const copy = getKnxAiConfirmationCopy(language)
9229
9682
  const awaitingConfirmation = node.llmAllowKnxCommands &&
9230
9683
  node.llmRequireCommandConfirmation &&
@@ -9542,8 +9995,16 @@ module.exports = function (RED) {
9542
9995
 
9543
9996
  node.on('close', function (done) {
9544
9997
  try {
9998
+ node._closing = true
9545
9999
  if (node._timerEmit) clearInterval(node._timerEmit)
9546
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()
9547
10008
  if (node._summaryRebuildTimer) {
9548
10009
  clearTimeout(node._summaryRebuildTimer)
9549
10010
  node._summaryRebuildTimer = null
@@ -9579,10 +10040,23 @@ module.exports = function (RED) {
9579
10040
  try {
9580
10041
  pruneHistoryArchiveFiles({ force: true })
9581
10042
  loadRecentHistoryFromDisk()
10043
+ loadHomeMemoryFromDisk()
9582
10044
  } catch (error) {
9583
10045
  node.sysLogger?.warn(`KNX AI history startup error: ${error.message || error}`)
9584
10046
  }
9585
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
+
9586
10060
  if (node._busConnectionWatchTimer) clearInterval(node._busConnectionWatchTimer)
9587
10061
  node._busConnectionWatchTimer = setInterval(() => {
9588
10062
  pollBusConnectionStatus()