node-red-contrib-knx-ultimate 4.3.6 → 4.3.7

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.
@@ -875,6 +875,106 @@ const readJsonFileSafe = (filePath, fallbackValue) => {
875
875
  }
876
876
  }
877
877
 
878
+ const formatArchiveDayKey = (ts) => {
879
+ try {
880
+ return new Date(ts).toISOString().slice(0, 10)
881
+ } catch (error) {
882
+ return new Date().toISOString().slice(0, 10)
883
+ }
884
+ }
885
+
886
+ const collectArchiveDayKeysBetween = ({ fromTs, toTs }) => {
887
+ const out = []
888
+ const start = Number(fromTs || 0)
889
+ const end = Number(toTs || 0)
890
+ if (!Number.isFinite(start) || !Number.isFinite(end) || end < start) return out
891
+ const cursor = new Date(start)
892
+ cursor.setUTCHours(0, 0, 0, 0)
893
+ const endDay = new Date(end)
894
+ endDay.setUTCHours(0, 0, 0, 0)
895
+ while (cursor.getTime() <= endDay.getTime()) {
896
+ out.push(cursor.toISOString().slice(0, 10))
897
+ cursor.setUTCDate(cursor.getUTCDate() + 1)
898
+ }
899
+ return out
900
+ }
901
+
902
+ const startOfLocalDayMs = (ts) => {
903
+ const d = new Date(ts)
904
+ d.setHours(0, 0, 0, 0)
905
+ return d.getTime()
906
+ }
907
+
908
+ const endOfLocalDayMs = (ts) => {
909
+ const d = new Date(ts)
910
+ d.setHours(23, 59, 59, 999)
911
+ return d.getTime()
912
+ }
913
+
914
+ const parseQuestionTimeRange = (question, nowTs = Date.now()) => {
915
+ const text = String(question || '').trim().toLowerCase()
916
+ if (!text) return null
917
+
918
+ const exactDates = Array.from(text.matchAll(/\b(\d{4}-\d{2}-\d{2})\b/g)).map(match => match[1])
919
+ if (exactDates.length >= 2) {
920
+ const start = new Date(`${exactDates[0]}T00:00:00`).getTime()
921
+ const end = new Date(`${exactDates[1]}T23:59:59.999`).getTime()
922
+ if (Number.isFinite(start) && Number.isFinite(end) && end >= start) {
923
+ return { fromTs: start, toTs: end, label: `${exactDates[0]}..${exactDates[1]}`, explicit: true }
924
+ }
925
+ }
926
+ if (exactDates.length === 1) {
927
+ const start = new Date(`${exactDates[0]}T00:00:00`).getTime()
928
+ const end = new Date(`${exactDates[0]}T23:59:59.999`).getTime()
929
+ if (Number.isFinite(start) && Number.isFinite(end)) {
930
+ return { fromTs: start, toTs: end, label: exactDates[0], explicit: true }
931
+ }
932
+ }
933
+
934
+ const dayStart = startOfLocalDayMs(nowTs)
935
+ const dayEnd = endOfLocalDayMs(nowTs)
936
+ const yesterdayStart = dayStart - (24 * 60 * 60 * 1000)
937
+ const yesterdayEnd = dayStart - 1
938
+
939
+ if (/\b(stamattina|this morning)\b/.test(text)) {
940
+ return { fromTs: dayStart, toTs: Math.min(dayEnd, dayStart + (12 * 60 * 60 * 1000) - 1), label: 'this morning', explicit: true }
941
+ }
942
+ if (/\b(oggi pomeriggio|this afternoon)\b/.test(text)) {
943
+ return { fromTs: dayStart + (12 * 60 * 60 * 1000), toTs: Math.min(dayEnd, dayStart + (18 * 60 * 60 * 1000) - 1), label: 'this afternoon', explicit: true }
944
+ }
945
+ if (/\b(stasera|this evening|tonight)\b/.test(text)) {
946
+ return { fromTs: dayStart + (18 * 60 * 60 * 1000), toTs: dayEnd, label: 'this evening', explicit: true }
947
+ }
948
+ if (/\b(oggi|today)\b/.test(text)) {
949
+ return { fromTs: dayStart, toTs: dayEnd, label: 'today', explicit: true }
950
+ }
951
+ if (/\b(ieri|yesterday)\b/.test(text)) {
952
+ return { fromTs: yesterdayStart, toTs: yesterdayEnd, label: 'yesterday', explicit: true }
953
+ }
954
+
955
+ const lastDaysMatch = text.match(/\b(?:last|ultimi)\s+(\d{1,3})\s+(?:day|days|giorno|giorni)\b/)
956
+ if (lastDaysMatch) {
957
+ const days = Math.max(1, Number(lastDaysMatch[1] || 1))
958
+ return {
959
+ fromTs: nowTs - (days * 24 * 60 * 60 * 1000),
960
+ toTs: nowTs,
961
+ label: `last ${days} days`,
962
+ explicit: true
963
+ }
964
+ }
965
+
966
+ if (/\b(this week|questa settimana)\b/.test(text)) {
967
+ const d = new Date(nowTs)
968
+ const dow = d.getDay()
969
+ const offset = dow === 0 ? 6 : dow - 1
970
+ d.setHours(0, 0, 0, 0)
971
+ d.setDate(d.getDate() - offset)
972
+ return { fromTs: d.getTime(), toTs: nowTs, label: 'this week', explicit: true }
973
+ }
974
+
975
+ return null
976
+ }
977
+
878
978
  const normalizeAreaOverridePayload = (payload) => {
879
979
  const p = payload && typeof payload === 'object' ? payload : {}
880
980
  const normalized = {}
@@ -2602,13 +2702,14 @@ module.exports = function (RED) {
2602
2702
  return catalog
2603
2703
  }
2604
2704
 
2605
- const buildKnxUltimateFlowInventory = ({ maxNodes = 80 } = {}) => {
2705
+ const buildKnxUltimateProjectInventory = () => {
2606
2706
  const tabById = new Map()
2607
2707
  const gatewaysById = new Map()
2608
- const knxNodes = []
2708
+ const flowNodes = []
2609
2709
 
2610
2710
  try {
2611
2711
  if (typeof RED.nodes.eachNode !== 'function') return ''
2712
+ const gaRe = /\b\d{1,3}\/\d{1,3}\/\d{1,3}\b/g
2612
2713
 
2613
2714
  // First pass: collect tabs + gateways
2614
2715
  RED.nodes.eachNode((n) => {
@@ -2627,11 +2728,11 @@ module.exports = function (RED) {
2627
2728
  }
2628
2729
  })
2629
2730
 
2630
- // Second pass: collect KNX Ultimate nodes (all flows)
2731
+ // Second pass: collect all flow nodes that may help the LLM understand KNX logic.
2631
2732
  RED.nodes.eachNode((n) => {
2632
2733
  if (!n || typeof n !== 'object') return
2633
2734
  const type = String(n.type || '')
2634
- if (!type.startsWith('knxUltimate') || type === 'knxUltimate-config') return
2735
+ if (type === 'tab' || type === 'subflow' || type === 'knxUltimate-config') return
2635
2736
 
2636
2737
  const tabId = String(n.z || '')
2637
2738
  const tabLabel = tabById.get(tabId) || ''
@@ -2640,6 +2741,16 @@ module.exports = function (RED) {
2640
2741
  const server = String(n.server || '')
2641
2742
  const gw = gatewaysById.get(server) || null
2642
2743
 
2744
+ const gaRefs = new Set()
2745
+ extractGAsFromValue({ value: n, outSet: gaRefs, gaRe, maxItems: 24 })
2746
+ const gaList = Array.from(gaRefs.values()).slice(0, 24)
2747
+
2748
+ const shortenSnippet = (value, maxLen = 140) => {
2749
+ const text = String(value || '').replace(/\s+/g, ' ').trim()
2750
+ if (!text) return ''
2751
+ return text.length > maxLen ? `${text.slice(0, Math.max(0, maxLen - 3))}...` : text
2752
+ }
2753
+
2643
2754
  const entry = {
2644
2755
  tabLabel,
2645
2756
  type,
@@ -2648,7 +2759,12 @@ module.exports = function (RED) {
2648
2759
  gatewayId: server,
2649
2760
  gatewayName: gw ? gw.name : '',
2650
2761
  topic: n.topic !== undefined ? String(n.topic) : '',
2651
- dpt: n.dpt !== undefined ? String(n.dpt) : ''
2762
+ dpt: n.dpt !== undefined ? String(n.dpt) : '',
2763
+ gaRefs: gaList,
2764
+ payload: n.payload !== undefined ? shortenSnippet(n.payload, 80) : '',
2765
+ payloadType: n.payloadType !== undefined ? String(n.payloadType) : '',
2766
+ outputTopic: n.outputtopic !== undefined ? String(n.outputtopic) : '',
2767
+ setTopicType: n.setTopicType !== undefined ? String(n.setTopicType) : ''
2652
2768
  }
2653
2769
 
2654
2770
  if (type === 'knxUltimate') {
@@ -2669,17 +2785,31 @@ module.exports = function (RED) {
2669
2785
  entry.gaRewriteRules = n.gaRewriteRules !== undefined ? String(n.gaRewriteRules) : ''
2670
2786
  entry.rewriteSource = n.rewriteSource === true || n.rewriteSource === 'true'
2671
2787
  entry.srcRewriteRules = n.srcRewriteRules !== undefined ? String(n.srcRewriteRules) : ''
2672
- }
2673
-
2674
- knxNodes.push(entry)
2788
+ } else if (type === 'function') {
2789
+ entry.funcSnippet = shortenSnippet(n.func, 220)
2790
+ } else if (type === 'change') {
2791
+ entry.rulesSnippet = shortenSnippet(safeStringify(n.rules), 180)
2792
+ } else if (type === 'inject') {
2793
+ entry.injectOnce = n.once === true || n.once === 'true'
2794
+ entry.repeat = n.repeat !== undefined ? String(n.repeat) : ''
2795
+ entry.crontab = n.crontab !== undefined ? String(n.crontab) : ''
2796
+ } else if (type === 'template') {
2797
+ entry.templateSnippet = shortenSnippet(n.template, 180)
2798
+ } else if (type === 'switch') {
2799
+ entry.rulesSnippet = shortenSnippet(safeStringify(n.rules), 180)
2800
+ } else if (type === 'api-current-state' || type === 'server-state-changed') {
2801
+ entry.entityId = n.entityid !== undefined ? String(n.entityid) : ''
2802
+ }
2803
+
2804
+ flowNodes.push(entry)
2675
2805
  })
2676
2806
  } catch (error) {
2677
2807
  return ''
2678
2808
  }
2679
2809
 
2680
- if (!knxNodes.length && !gatewaysById.size) return ''
2810
+ if (!flowNodes.length && !gatewaysById.size) return ''
2681
2811
 
2682
- const sorted = knxNodes
2812
+ const sorted = flowNodes
2683
2813
  .sort((a, b) => {
2684
2814
  const at = (a.tabLabel || '').localeCompare(b.tabLabel || '')
2685
2815
  if (at !== 0) return at
@@ -2687,13 +2817,12 @@ module.exports = function (RED) {
2687
2817
  if (an !== 0) return an
2688
2818
  return (a.type || '').localeCompare(b.type || '')
2689
2819
  })
2690
- .slice(0, Math.max(0, Number(maxNodes) || 0))
2691
2820
 
2692
2821
  const shorten = (id) => (id && id.length > 8) ? id.slice(0, 8) : id
2693
2822
  const safeLine = (s) => String(s || '').replace(/\s+/g, ' ').trim()
2694
2823
 
2695
2824
  const lines = []
2696
- lines.push('Node-RED flow inventory (KNX Ultimate):')
2825
+ lines.push('Node-RED project inventory:')
2697
2826
 
2698
2827
  if (gatewaysById.size) {
2699
2828
  lines.push(`Gateways (knxUltimate-config): ${gatewaysById.size}`)
@@ -2707,7 +2836,7 @@ module.exports = function (RED) {
2707
2836
  if (gatewaysById.size > 20) lines.push('- ...')
2708
2837
  }
2709
2838
 
2710
- lines.push(`KNX Ultimate nodes: ${knxNodes.length}${knxNodes.length > sorted.length ? ` (showing first ${sorted.length})` : ''}`)
2839
+ lines.push(`Project nodes: ${flowNodes.length}`)
2711
2840
  for (const n of sorted) {
2712
2841
  const parts = []
2713
2842
  if (n.tabLabel) parts.push(`[${safeLine(n.tabLabel)}]`)
@@ -2716,6 +2845,7 @@ module.exports = function (RED) {
2716
2845
  if (n.name) parts.push(`name="${safeLine(n.name)}"`)
2717
2846
  if (n.gatewayName) parts.push(`gw="${safeLine(n.gatewayName)}"`)
2718
2847
  if (!n.gatewayName && n.gatewayId) parts.push(`gwId=${shorten(n.gatewayId)}`)
2848
+ if (Array.isArray(n.gaRefs) && n.gaRefs.length) parts.push(`gaRefs="${safeLine(n.gaRefs.join(','))}"`)
2719
2849
 
2720
2850
  if (n.type === 'knxUltimate') {
2721
2851
  if (n.topic) parts.push(`topic=${safeLine(n.topic)}`)
@@ -2733,8 +2863,26 @@ module.exports = function (RED) {
2733
2863
  if (n.gaRewriteRules) parts.push(`gaRewriteRules="${safeLine(n.gaRewriteRules)}"`)
2734
2864
  if (n.rewriteSource) parts.push('rewriteSource=true')
2735
2865
  if (n.srcRewriteRules) parts.push(`srcRewriteRules="${safeLine(n.srcRewriteRules)}"`)
2866
+ } else if (n.type === 'function') {
2867
+ if (n.funcSnippet) parts.push(`func="${safeLine(n.funcSnippet)}"`)
2868
+ } else if (n.type === 'change' || n.type === 'switch') {
2869
+ if (n.rulesSnippet) parts.push(`rules="${safeLine(n.rulesSnippet)}"`)
2870
+ } else if (n.type === 'inject') {
2871
+ if (n.topic) parts.push(`topic=${safeLine(n.topic)}`)
2872
+ if (n.payload) parts.push(`payload="${safeLine(n.payload)}"`)
2873
+ if (n.payloadType) parts.push(`payloadType=${safeLine(n.payloadType)}`)
2874
+ if (n.injectOnce) parts.push('once=true')
2875
+ if (n.repeat) parts.push(`repeat=${safeLine(n.repeat)}`)
2876
+ if (n.crontab) parts.push(`crontab="${safeLine(n.crontab)}"`)
2877
+ } else if (n.type === 'template') {
2878
+ if (n.templateSnippet) parts.push(`template="${safeLine(n.templateSnippet)}"`)
2879
+ } else if (n.type === 'api-current-state' || n.type === 'server-state-changed') {
2880
+ if (n.entityId) parts.push(`entityId=${safeLine(n.entityId)}`)
2736
2881
  } else {
2737
2882
  if (n.topic) parts.push(`topic=${safeLine(n.topic)}`)
2883
+ if (n.payload) parts.push(`payload="${safeLine(n.payload)}"`)
2884
+ if (n.outputTopic) parts.push(`outputTopic=${safeLine(n.outputTopic)}`)
2885
+ if (n.setTopicType) parts.push(`setTopicType=${safeLine(n.setTopicType)}`)
2738
2886
  }
2739
2887
  lines.push(`- ${parts.join(' ')}`)
2740
2888
  }
@@ -3531,6 +3679,8 @@ module.exports = function (RED) {
3531
3679
 
3532
3680
  node.analysisWindowSec = Number(config.analysisWindowSec || 60)
3533
3681
  node.historyWindowSec = Number(config.historyWindowSec || 300)
3682
+ node.historyStoreToDisk = config.historyStoreToDisk !== undefined ? coerceBoolean(config.historyStoreToDisk) : false
3683
+ node.historyStoreRetentionDays = Math.max(1, Number.isFinite(Number(config.historyStoreRetentionDays)) ? Number(config.historyStoreRetentionDays) : 10)
3534
3684
  node.emitIntervalSec = Number(config.emitIntervalSec || 0)
3535
3685
  node.topN = Number(config.topN || 10)
3536
3686
 
@@ -3561,9 +3711,6 @@ module.exports = function (RED) {
3561
3711
  node.llmMaxEventsInPrompt = (config.llmMaxEventsInPrompt === undefined || config.llmMaxEventsInPrompt === '') ? 120 : Number(config.llmMaxEventsInPrompt)
3562
3712
  node.llmIncludeRaw = config.llmIncludeRaw !== undefined ? coerceBoolean(config.llmIncludeRaw) : false
3563
3713
  node.llmIncludeFlowContext = config.llmIncludeFlowContext !== undefined ? coerceBoolean(config.llmIncludeFlowContext) : true
3564
- node.llmMaxFlowNodesInPrompt = (config.llmMaxFlowNodesInPrompt === undefined || config.llmMaxFlowNodesInPrompt === '')
3565
- ? 400
3566
- : Number(config.llmMaxFlowNodesInPrompt)
3567
3714
  node.llmIncludeDocsSnippets = config.llmIncludeDocsSnippets !== undefined ? coerceBoolean(config.llmIncludeDocsSnippets) : true
3568
3715
  node.llmDocsLanguage = config.llmDocsLanguage ? String(config.llmDocsLanguage) : 'it'
3569
3716
  node.llmDocsMaxSnippets = (config.llmDocsMaxSnippets === undefined || config.llmDocsMaxSnippets === '') ? 5 : Number(config.llmDocsMaxSnippets)
@@ -3632,6 +3779,7 @@ module.exports = function (RED) {
3632
3779
  node._gaRateSeries = new Map()
3633
3780
  node._gaLabelCsvCache = { ref: null, map: {} }
3634
3781
  node._busConnectionWatchTimer = null
3782
+ node._historyDiskLastPruneAt = 0
3635
3783
  node._busConnectionState = (node.serverKNX && typeof node.serverKNX.linkStatus === 'string')
3636
3784
  ? String(node.serverKNX.linkStatus).toLowerCase()
3637
3785
  : 'unknown'
@@ -4738,7 +4886,8 @@ module.exports = function (RED) {
4738
4886
  const compactMode = compact === true
4739
4887
  const maxEventsRequested = Math.max(10, Number(node.llmMaxEventsInPrompt) || 120)
4740
4888
  const maxEvents = Math.min(compactMode ? 80 : 240, maxEventsRequested)
4741
- const recent = node._history.slice(-maxEvents)
4889
+ const promptEvents = selectTelegramsForPrompt({ question, maxEvents })
4890
+ const recent = Array.isArray(promptEvents.events) ? promptEvents.events : []
4742
4891
  const wantsSvgChart = shouldGenerateSvgChart(question)
4743
4892
  const areasSnapshot = buildAreasSnapshot({ summary })
4744
4893
  const areasContext = buildAreasPromptContext(areasSnapshot)
@@ -4751,6 +4900,7 @@ module.exports = function (RED) {
4751
4900
  return `${new Date(t.ts).toISOString()} ${t.event} ${t.source} -> ${t.destination}${devName} dpt=${t.dpt} payload=${payloadStr}${rawStr}`
4752
4901
  })
4753
4902
  const recentLines = takeLastItemsByCharBudget(lines, compactMode ? 2600 : 7000)
4903
+ const archiveScopeLine = `Prompt event source: ${promptEvents.source}. Time range: ${promptEvents.range && promptEvents.range.label ? promptEvents.range.label : 'recent events'}. Events selected: ${recent.length}.`
4754
4904
 
4755
4905
  let flowContext = ''
4756
4906
  if (node.llmIncludeFlowContext) {
@@ -4760,11 +4910,7 @@ module.exports = function (RED) {
4760
4910
  if (node._flowContextCache && node._flowContextCache.text && (now - (node._flowContextCache.at || 0)) < ttlMs) {
4761
4911
  flowContext = node._flowContextCache.text
4762
4912
  } else {
4763
- const configuredMaxFlowNodes = Math.max(0, Number(node.llmMaxFlowNodesInPrompt) || 0)
4764
- const maxFlowNodes = compactMode
4765
- ? (configuredMaxFlowNodes > 0 ? Math.min(configuredMaxFlowNodes, 30) : 0)
4766
- : configuredMaxFlowNodes
4767
- flowContext = buildKnxUltimateFlowInventory({ maxNodes: maxFlowNodes })
4913
+ flowContext = buildKnxUltimateProjectInventory()
4768
4914
  flowContext = truncatePromptText(flowContext, flowMaxChars)
4769
4915
  node._flowContextCache = { at: now, text: flowContext }
4770
4916
  }
@@ -4812,7 +4958,9 @@ module.exports = function (RED) {
4812
4958
  wantsSvgChart ? '- Do not use JavaScript, external URLs, or <foreignObject>.' : '',
4813
4959
  wantsSvgChart ? '- Prefer width via viewBox and include labels + legend when useful.' : '',
4814
4960
  wantsSvgChart ? '' : '',
4815
- 'Recent KNX telegrams:',
4961
+ archiveScopeLine,
4962
+ '',
4963
+ 'Selected KNX telegrams:',
4816
4964
  recentLines.join('\n'),
4817
4965
  '',
4818
4966
  'User request:',
@@ -4859,6 +5007,172 @@ module.exports = function (RED) {
4859
5007
  return path.join(baseDir, 'knxai', 'config', `knxai-config-${node.id}.json`)
4860
5008
  }
4861
5009
 
5010
+ const getHistoryArchiveDir = () => {
5011
+ const baseDir = (node.serverKNX && node.serverKNX.userDir)
5012
+ ? node.serverKNX.userDir
5013
+ : path.join(RED.settings.userDir, 'knxultimatestorage')
5014
+ return path.join(baseDir, 'knxai', 'history', node.id)
5015
+ }
5016
+
5017
+ const getHistoryArchiveFile = (dayKey) => path.join(getHistoryArchiveDir(), `${String(dayKey || '').trim() || formatArchiveDayKey(Date.now())}.jsonl`)
5018
+
5019
+ const pruneHistoryArchiveFiles = ({ force = false } = {}) => {
5020
+ if (node.historyStoreToDisk !== true) return
5021
+ const retentionDays = Math.max(1, Math.round(Number.isFinite(Number(node.historyStoreRetentionDays)) ? Number(node.historyStoreRetentionDays) : 1))
5022
+ const now = nowMs()
5023
+ if (!force && (now - Number(node._historyDiskLastPruneAt || 0)) < (60 * 60 * 1000)) return
5024
+ node._historyDiskLastPruneAt = now
5025
+ const dirPath = getHistoryArchiveDir()
5026
+ try {
5027
+ if (!fs.existsSync(dirPath)) return
5028
+ const entries = fs.readdirSync(dirPath, { withFileTypes: true })
5029
+ const cutoffTs = now - ((retentionDays - 1) * 24 * 60 * 60 * 1000)
5030
+ const cutoffDayKey = formatArchiveDayKey(cutoffTs)
5031
+ for (let i = 0; i < entries.length; i++) {
5032
+ const entry = entries[i]
5033
+ if (!entry || !entry.isFile()) continue
5034
+ const match = String(entry.name || '').match(/^(\d{4}-\d{2}-\d{2})\.jsonl$/)
5035
+ if (!match) continue
5036
+ const dayKey = match[1]
5037
+ if (dayKey < cutoffDayKey) {
5038
+ try { fs.unlinkSync(path.join(dirPath, entry.name)) } catch (error) { /* ignore */ }
5039
+ }
5040
+ }
5041
+ } catch (error) {
5042
+ node.sysLogger?.warn(`KNX AI history prune error: ${error.message || error}`)
5043
+ }
5044
+ }
5045
+
5046
+ const persistTelegramToDisk = (telegram) => {
5047
+ if (node.historyStoreToDisk !== true || !telegram || typeof telegram !== 'object') return
5048
+ const archiveDir = getHistoryArchiveDir()
5049
+ if (!ensureDirectorySync(archiveDir)) return
5050
+ const dayKey = formatArchiveDayKey(telegram.ts || Date.now())
5051
+ const filePath = getHistoryArchiveFile(dayKey)
5052
+ const line = JSON.stringify(telegram) + '\n'
5053
+ fs.appendFile(filePath, line, 'utf8', (error) => {
5054
+ if (error) node.sysLogger?.warn(`KNX AI history append error: ${error.message || error}`)
5055
+ })
5056
+ pruneHistoryArchiveFiles()
5057
+ }
5058
+
5059
+ const loadRecentHistoryFromDisk = () => {
5060
+ if (node.historyStoreToDisk !== true) return
5061
+ const archiveDir = getHistoryArchiveDir()
5062
+ try {
5063
+ if (!fs.existsSync(archiveDir)) return
5064
+ const now = nowMs()
5065
+ const cutoffTs = now - (Math.max(5, Number(node.historyWindowSec || 5)) * 1000)
5066
+ const dayKeys = collectArchiveDayKeysBetween({ fromTs: cutoffTs, toTs: now })
5067
+ if (!dayKeys.length) return
5068
+ const restored = []
5069
+ for (let i = 0; i < dayKeys.length; i++) {
5070
+ const filePath = getHistoryArchiveFile(dayKeys[i])
5071
+ if (!fs.existsSync(filePath)) continue
5072
+ const raw = fs.readFileSync(filePath, 'utf8')
5073
+ if (!raw || String(raw).trim() === '') continue
5074
+ const lines = raw.split(/\r?\n/)
5075
+ for (let j = 0; j < lines.length; j++) {
5076
+ const line = lines[j]
5077
+ if (!line) continue
5078
+ try {
5079
+ const telegram = JSON.parse(line)
5080
+ const ts = Number(telegram && telegram.ts ? telegram.ts : 0)
5081
+ if (!Number.isFinite(ts) || ts < cutoffTs || ts > now) continue
5082
+ restored.push(telegram)
5083
+ } catch (error) {
5084
+ // Ignore malformed archive rows.
5085
+ }
5086
+ }
5087
+ }
5088
+ if (!restored.length) return
5089
+ restored.sort((a, b) => Number(a.ts || 0) - Number(b.ts || 0))
5090
+ node._history = restored
5091
+ trimHistory(now)
5092
+ } catch (error) {
5093
+ node.sysLogger?.warn(`KNX AI history restore error: ${error.message || error}`)
5094
+ }
5095
+ }
5096
+
5097
+ const loadHistorySliceFromDisk = ({ fromTs, toTs, limit = 240 } = {}) => {
5098
+ if (node.historyStoreToDisk !== true) return []
5099
+ const archiveDir = getHistoryArchiveDir()
5100
+ try {
5101
+ if (!fs.existsSync(archiveDir)) return []
5102
+ const from = Number(fromTs || 0)
5103
+ const to = Number(toTs || 0)
5104
+ if (!Number.isFinite(from) || !Number.isFinite(to) || to < from) return []
5105
+ const dayKeys = collectArchiveDayKeysBetween({ fromTs: from, toTs: to })
5106
+ if (!dayKeys.length) return []
5107
+ const items = []
5108
+ for (let i = 0; i < dayKeys.length; i++) {
5109
+ const filePath = getHistoryArchiveFile(dayKeys[i])
5110
+ if (!fs.existsSync(filePath)) continue
5111
+ const raw = fs.readFileSync(filePath, 'utf8')
5112
+ if (!raw || String(raw).trim() === '') continue
5113
+ const lines = raw.split(/\r?\n/)
5114
+ for (let j = 0; j < lines.length; j++) {
5115
+ const line = lines[j]
5116
+ if (!line) continue
5117
+ try {
5118
+ const telegram = JSON.parse(line)
5119
+ const ts = Number(telegram && telegram.ts ? telegram.ts : 0)
5120
+ if (!Number.isFinite(ts) || ts < from || ts > to) continue
5121
+ items.push(telegram)
5122
+ } catch (error) {
5123
+ // Ignore malformed archive rows.
5124
+ }
5125
+ }
5126
+ }
5127
+ if (!items.length) return []
5128
+ items.sort((a, b) => Number(a.ts || 0) - Number(b.ts || 0))
5129
+ return items.slice(-Math.max(1, Number(limit || 1)))
5130
+ } catch (error) {
5131
+ node.sysLogger?.warn(`KNX AI history load slice error: ${error.message || error}`)
5132
+ return []
5133
+ }
5134
+ }
5135
+
5136
+ const selectTelegramsForPrompt = ({ question, maxEvents }) => {
5137
+ const now = nowMs()
5138
+ const maxItems = Math.max(10, Number(maxEvents) || 120)
5139
+ const explicitRange = parseQuestionTimeRange(question, now)
5140
+ const fallbackRange = node.historyStoreToDisk === true
5141
+ ? { fromTs: now - (24 * 60 * 60 * 1000), toTs: now, label: 'last 24 hours', explicit: false }
5142
+ : { fromTs: now - (Math.max(5, Number(node.historyWindowSec || 5)) * 1000), toTs: now, label: 'memory window', explicit: false }
5143
+ const range = explicitRange || fallbackRange
5144
+
5145
+ let selected = []
5146
+ let source = 'memory'
5147
+ if (node.historyStoreToDisk === true) {
5148
+ const diskItems = loadHistorySliceFromDisk({ fromTs: range.fromTs, toTs: range.toTs, limit: maxItems * 3 })
5149
+ const memoryItems = node._history.filter(t => Number(t && t.ts ? t.ts : 0) >= range.fromTs && Number(t && t.ts ? t.ts : 0) <= range.toTs)
5150
+ const dedupe = new Map()
5151
+ diskItems.concat(memoryItems).forEach((telegram) => {
5152
+ if (!telegram || typeof telegram !== 'object') return
5153
+ const key = [
5154
+ Number(telegram.ts || 0),
5155
+ String(telegram.event || ''),
5156
+ String(telegram.source || ''),
5157
+ String(telegram.destination || ''),
5158
+ normalizeValueForCompare(telegram.payload),
5159
+ String(telegram.rawHex || '')
5160
+ ].join('|')
5161
+ dedupe.set(key, telegram)
5162
+ })
5163
+ selected = Array.from(dedupe.values()).sort((a, b) => Number(a.ts || 0) - Number(b.ts || 0)).slice(-maxItems)
5164
+ source = 'archive+memory'
5165
+ } else {
5166
+ selected = node._history.slice(-maxItems)
5167
+ }
5168
+
5169
+ return {
5170
+ events: selected,
5171
+ source,
5172
+ range
5173
+ }
5174
+ }
5175
+
4862
5176
  const loadPersistedAiConfig = () => {
4863
5177
  if (node._persistedAiConfigCache && typeof node._persistedAiConfigCache === 'object') return node._persistedAiConfigCache
4864
5178
  const configPath = getAiConfigStorageFile()
@@ -7068,6 +7382,7 @@ module.exports = function (RED) {
7068
7382
  const telegram = extractTelegram(msg)
7069
7383
  if (!telegram) return
7070
7384
  node._history.push(telegram)
7385
+ persistTelegramToDisk(telegram)
7071
7386
  resolveTelegramWaiters(telegram)
7072
7387
  trackTransitionTelemetry(telegram)
7073
7388
  const now = telegram.ts
@@ -7319,6 +7634,13 @@ module.exports = function (RED) {
7319
7634
  }, Math.max(5, node.emitIntervalSec) * 1000)
7320
7635
  }
7321
7636
 
7637
+ try {
7638
+ pruneHistoryArchiveFiles({ force: true })
7639
+ loadRecentHistoryFromDisk()
7640
+ } catch (error) {
7641
+ node.sysLogger?.warn(`KNX AI history startup error: ${error.message || error}`)
7642
+ }
7643
+
7322
7644
  if (node._busConnectionWatchTimer) clearInterval(node._busConnectionWatchTimer)
7323
7645
  node._busConnectionWatchTimer = setInterval(() => {
7324
7646
  pollBusConnectionStatus()
@@ -31,6 +31,8 @@ Hier sind alle Felder aufgeführt, wie sie im KNX-AI-Editor sichtbar sind.
31
31
  ### Analysis
32
32
  - **Analysis window (seconds)**: Hauptfenster für Summary/Rate-Berechnung.
33
33
  - **History window (seconds)**: Aufbewahrungsfenster der internen Telegramm-Historie.
34
+ - **Captured telegrams also on disk archivieren**: Speichert Telegramme zusätzlich zu RAM in `knxultimatestorage/knxai/history/<node-id>/YYYY-MM-DD.jsonl`.
35
+ - **Aufbewahrung des Festplattenarchivs (Tage)**: Anzahl Tage, die Archivdateien auf Platte behalten werden, bevor sie automatisch gelöscht werden.
34
36
  - **Max stored events**: Maximale Anzahl Telegramme im Speicher.
35
37
  - **Auto emit summary (seconds, 0=off)**: Periodisches Summary-Intervall.
36
38
  - **Top list size**: Anzahl Top-Gruppenadressen/Quellen in der Summary.
@@ -45,27 +47,32 @@ Hier sind alle Felder aufgeführt, wie sie im KNX-AI-Editor sichtbar sind.
45
47
  - **Flap window (seconds)**: Zeitfenster für Flapping-/Wechselraten-Erkennung.
46
48
  - **Max changes per GA in window (0=off)**: Maximal erlaubte Änderungen im Fenster.
47
49
 
48
- ### LLM Assistant
49
- - Der Tab **LLM Assistant** steht im Editor jetzt an erster Stelle für eine schnellere Einrichtung.
50
+ ### KI-Assistent
50
51
  - **Enable LLM assistant**: Aktiviert Ask/Chat-Funktionen.
51
52
  - **Provider**: LLM-Backend (OpenAI-compatible oder Ollama).
52
53
  - **Endpoint URL**: URL des Chat/Completions-Endpunkts.
53
54
  - **API key**: API-Schlüssel (für lokales Ollama nicht erforderlich).
54
55
  - **Model**: Modell-ID/Name.
55
- - **System prompt**: Globale Instruktion für KNX-Analyse.
56
- - **Temperature**: Sampling-Temperatur.
57
- - **Max tokens**: Maximalzahl Completion-Tokens.
58
- - **Timeout (ms)**: HTTP-Timeout für LLM-Anfragen.
59
- - **Recent events included**: Max. Anzahl aktueller Events im Prompt.
56
+ - **System prompt**: Globale Instruktion für KNX-Analyse (Advanced).
57
+ - Wenn das Festplattenarchiv aktiv ist, nutzt **Ask** standardmäßig dieses Archiv: explizite Datumsangaben/Zeitbereiche werden beachtet, sonst durchsucht der Assistent die letzten 24 Stunden plus aktuelle RAM-Events.
60
58
  - **Include raw payload hex**: Rohe Hex-Payload im Prompt einfügen.
61
- - **Include Node-RED KNX node inventory**: Flow-Inventar im Prompt einfügen.
62
- - **Max flow nodes included**: Limit der inkludierten Flow-Nodes.
59
+ - **Node-RED-Projektinventar einbeziehen**: Nimmt das gesamte Node-RED-Projektinventar in den Prompt auf, einschließlich KNX-Nodes und anderer hilfreicher Nodes wie function/change/inject/template, wenn sie KNX-Logik oder Gruppenadressen enthalten.
63
60
  - **Include documentation snippets (help/README/examples)**: Doku-Kontext einfügen.
64
61
  - **Docs language**: Bevorzugte Sprache der Doku-Snippets.
65
- - **Max docs snippets**: Max. Anzahl Doku-Snippets.
66
- - **Max docs chars**: Max. Gesamtzeichen aus Doku.
67
62
  - Button **Refresh**: Provider abfragen und verfügbare Modelle laden.
68
63
 
64
+ ### Advanced
65
+ - **Analysis window (seconds)**: Hauptfenster für Summary/Rate-Berechnung.
66
+ - **Max stored events**: Maximale Anzahl Telegramme im Speicher.
67
+ - **Top list size**: Anzahl Top-Gruppenadressen/Quellen in der Summary.
68
+ - **Pattern max lag (ms)**: Maximaler Zeitabstand für Pattern-Korrelation.
69
+ - **Pattern min occurrences**: Mindestanzahl, bevor ein Pattern gemeldet wird.
70
+ - **Rate window (seconds)**: Gleitendes Zeitfenster für Rate-Prüfungen.
71
+ - **Max overall telegrams/sec (0=off)**: Schwellwert für gesamten Bus.
72
+ - **Max telegrams/sec per GA (0=off)**: Schwellwert pro Gruppenadresse.
73
+ - **Flap window (seconds)**: Zeitfenster für Flapping-/Wechselraten-Erkennung.
74
+ - **Max changes per GA in window (0=off)**: Maximal erlaubte Änderungen im Fenster.
75
+
69
76
  ### Ollama Schnellstart (lokal)
70
77
  - **Provider = Ollama** auswählen.
71
78
  - Standard-Endpoint: `http://localhost:11434/api/chat`.
@@ -3,9 +3,11 @@
3
3
  "title": "KNX AI (Traffic Analyzer)",
4
4
  "sections": {
5
5
  "capture": "Capture",
6
- "analysis": "Analysis",
7
- "anomalies": "Anomalies",
8
- "llm": "LLM Assistant"
6
+ "storage": "Speicher & Zusammenfassung",
7
+ "detection": "Erkennung & Warnungen",
8
+ "llmConnection": "KI-Assistent-Verbindung",
9
+ "llmContext": "KI-Assistent-Kontext",
10
+ "advanced": "Erweiterte Einstellungen"
9
11
  },
10
12
  "properties": {
11
13
  "server": "Gateway",
@@ -16,6 +18,8 @@
16
18
  "notifyreadrequest": "Capture GroupValue_Read",
17
19
  "analysisWindowSec": "Analysis window (seconds)",
18
20
  "historyWindowSec": "History window (seconds)",
21
+ "historyStoreToDisk": "Captured telegrams also on disk archivieren",
22
+ "historyStoreRetentionDays": "Aufbewahrung des Festplattenarchivs (Tage)",
19
23
  "maxEvents": "Max stored events",
20
24
  "emitIntervalSec": "Auto emit summary (seconds, 0=off)",
21
25
  "topN": "Top list size",
@@ -33,17 +37,10 @@
33
37
  "llmApiKey": "API key",
34
38
  "llmModel": "Model",
35
39
  "llmSystemPrompt": "System prompt",
36
- "llmTemperature": "Temperature",
37
- "llmMaxTokens": "Max tokens",
38
- "llmTimeoutMs": "Timeout (ms)",
39
- "llmMaxEventsInPrompt": "Recent events included",
40
40
  "llmIncludeRaw": "Include raw payload hex",
41
- "llmIncludeFlowContext": "Include Node-RED KNX node inventory",
42
- "llmMaxFlowNodesInPrompt": "Max flow nodes included",
41
+ "llmIncludeFlowContext": "Node-RED-Projektinventar einbeziehen",
43
42
  "llmIncludeDocsSnippets": "Include documentation snippets (help/README/examples)",
44
- "llmDocsLanguage": "Docs language",
45
- "llmDocsMaxSnippets": "Max docs snippets",
46
- "llmDocsMaxChars": "Max docs chars"
43
+ "llmDocsLanguage": "Docs language"
47
44
  },
48
45
  "outputs": {
49
46
  "summary": "Zusammenfassung/Statistik",
@@ -31,6 +31,8 @@ All fields exposed in the KNX AI editor are listed below.
31
31
  ### Analysis
32
32
  - **Analysis window (seconds)**: Main analysis window used for summaries/rates.
33
33
  - **History window (seconds)**: Retention window for internal telegram history.
34
+ - **Also archive captured telegrams to disk**: Stores captured telegrams in `knxultimatestorage/knxai/history/<node-id>/YYYY-MM-DD.jsonl` in addition to RAM.
35
+ - **Disk archive retention (days)**: Number of days kept on disk before old archive files are deleted automatically.
34
36
  - **Max stored events**: Maximum number of telegrams kept in memory.
35
37
  - **Auto emit summary (seconds, 0=off)**: Periodic summary output interval.
36
38
  - **Top list size**: Number of top group addresses/sources in summary.
@@ -45,27 +47,32 @@ All fields exposed in the KNX AI editor are listed below.
45
47
  - **Flap window (seconds)**: Time window for flapping/change-rate detection.
46
48
  - **Max changes per GA in window (0=off)**: Max allowed changes in flap window.
47
49
 
48
- ### LLM Assistant
49
- - The **LLM Assistant** tab is shown first in the editor for faster setup.
50
+ ### AI Assistant
50
51
  - **Enable LLM assistant**: Enable Ask/chat assistant features.
51
52
  - **Provider**: Select LLM backend (OpenAI-compatible or Ollama).
52
53
  - **Endpoint URL**: Chat/completions endpoint URL.
53
54
  - **API key**: API key (not required for local Ollama).
54
55
  - **Model**: Model ID/name.
55
- - **System prompt**: Global instruction for KNX analysis behavior.
56
- - **Temperature**: Sampling temperature.
57
- - **Max tokens**: Max completion tokens.
58
- - **Timeout (ms)**: HTTP timeout for LLM requests.
59
- - **Recent events included**: Max recent telegram events in prompt.
56
+ - **System prompt**: Global instruction for KNX analysis behavior (Advanced).
57
+ - If disk archive is enabled, **Ask** uses the archive by default: explicit dates/ranges are honored, otherwise the assistant searches the last 24 hours plus current RAM events.
60
58
  - **Include raw payload hex**: Include raw telegram hex in prompt.
61
- - **Include Node-RED KNX node inventory**: Include flow inventory in prompt.
62
- - **Max flow nodes included**: Limit nodes included from flow inventory.
59
+ - **Include Node-RED project inventory**: Include the whole Node-RED project inventory in the prompt, including KNX nodes and other useful nodes such as function/change/inject/template when they contain KNX-related logic or group addresses.
63
60
  - **Include documentation snippets (help/README/examples)**: Include docs context.
64
61
  - **Docs language**: Preferred language for docs snippets.
65
- - **Max docs snippets**: Max number of docs snippets.
66
- - **Max docs chars**: Max total docs characters.
67
62
  - **Refresh** button: Query provider and load available model IDs.
68
63
 
64
+ ### Advanced
65
+ - **Analysis window (seconds)**: Main analysis window used for summaries/rates.
66
+ - **Max stored events**: Maximum number of telegrams kept in memory.
67
+ - **Top list size**: Number of top group addresses/sources in summary.
68
+ - **Pattern max lag (ms)**: Max time gap for pattern transition matching.
69
+ - **Pattern min occurrences**: Minimum occurrences before a pattern is reported.
70
+ - **Rate window (seconds)**: Sliding time window for anomaly rate checks.
71
+ - **Max overall telegrams/sec (0=off)**: Overall bus rate threshold.
72
+ - **Max telegrams/sec per GA (0=off)**: Per-group-address rate threshold.
73
+ - **Flap window (seconds)**: Time window for flapping/change-rate detection.
74
+ - **Max changes per GA in window (0=off)**: Max allowed changes in flap window.
75
+
69
76
  ### Ollama quick setup (local)
70
77
  - Choose **Provider = Ollama**.
71
78
  - Default endpoint: `http://localhost:11434/api/chat`.