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.
- package/CHANGELOG.md +14 -4
- package/examples/KNX AI - Telegrambot Direct Chat.json +18 -0
- package/nodes/knxUltimateAI.html +361 -251
- package/nodes/knxUltimateAI.js +491 -17
- package/nodes/locales/de/knxUltimateAI.html +50 -31
- package/nodes/locales/de/knxUltimateAI.json +27 -9
- package/nodes/locales/en/knxUltimateAI.html +54 -30
- package/nodes/locales/en/knxUltimateAI.json +27 -9
- package/nodes/locales/es/knxUltimateAI.html +50 -31
- package/nodes/locales/es/knxUltimateAI.json +27 -9
- package/nodes/locales/fr/knxUltimateAI.html +50 -31
- package/nodes/locales/fr/knxUltimateAI.json +27 -9
- package/nodes/locales/it/knxUltimateAI.html +54 -30
- package/nodes/locales/it/knxUltimateAI.json +27 -9
- package/nodes/locales/zh-CN/knxUltimateAI.html +50 -31
- package/nodes/locales/zh-CN/knxUltimateAI.json +27 -9
- package/nodes/utils/knxAiHomeMemory.js +507 -0
- package/package.json +2 -2
package/nodes/knxUltimateAI.js
CHANGED
|
@@ -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 =
|
|
4997
|
+
node.notifyreadrequest = true
|
|
4981
4998
|
node.notifyreadrequestalsorespondtobus = 'false'
|
|
4982
4999
|
node.notifyreadrequestalsorespondtobusdefaultvalueifnotinitialized = ''
|
|
4983
|
-
node.notifyresponse =
|
|
4984
|
-
node.notifywrite =
|
|
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 =
|
|
5000
|
-
node.maxTelegramPerSecOverall =
|
|
5001
|
-
node.maxTelegramPerSecPerGA =
|
|
5016
|
+
node.rateWindowSec = 10
|
|
5017
|
+
node.maxTelegramPerSecOverall = 0
|
|
5018
|
+
node.maxTelegramPerSecPerGA = 0
|
|
5002
5019
|
|
|
5003
|
-
node.flapWindowSec =
|
|
5004
|
-
node.flapMaxChanges =
|
|
5020
|
+
node.flapWindowSec = 30
|
|
5021
|
+
node.flapMaxChanges = 0
|
|
5005
5022
|
|
|
5006
|
-
node.enablePattern =
|
|
5007
|
-
node.patternMaxLagMs =
|
|
5008
|
-
node.patternMinCount =
|
|
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 =
|
|
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 =
|
|
5045
|
+
node.llmIncludeRaw = false
|
|
5029
5046
|
node.llmIncludeFlowContext = config.llmIncludeFlowContext !== undefined ? coerceBoolean(config.llmIncludeFlowContext) : true
|
|
5030
|
-
node.llmIncludeDocsSnippets =
|
|
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
|
-
|
|
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()
|