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

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.
@@ -424,6 +424,8 @@ const computeAnomalySeverity = (payload) => {
424
424
 
425
425
  const SVG_REQUEST_RE = /\b(svg|chart|graph|plot|diagram|bar|pie|line|grafico|grafici|diagramma|istogramma|torta)\b/i
426
426
  const SVG_PRESENT_RE = /```svg[\s\S]*?```|<svg[\s>][\s\S]*?<\/svg>/i
427
+ const FUNCTION_NODE_CODE_REVIEW_RE = /\b(function|function node|nodo function|nodi function)\b/i
428
+ const JAVASCRIPT_REVIEW_RE = /\b(js|javascript|java\s*script|code|codice|script|sorgente|source|errore|errori|error|bug|review|reviewa|analizza|analy(?:s|z)e|check|controlla|debug)\b/i
427
429
 
428
430
  const escapeXml = (value) => String(value || '')
429
431
  .replace(/&/g, '&amp;')
@@ -440,6 +442,17 @@ const truncateLabel = (value, maxLen = 14) => {
440
442
 
441
443
  const shouldGenerateSvgChart = (question) => SVG_REQUEST_RE.test(String(question || ''))
442
444
 
445
+ const shouldIncludeFunctionNodeSourceContext = (question) => {
446
+ const q = String(question || '').trim()
447
+ if (!q) return false
448
+ return FUNCTION_NODE_CODE_REVIEW_RE.test(q) && JAVASCRIPT_REVIEW_RE.test(q)
449
+ }
450
+
451
+ const normalizeCodeBlockText = (value) => String(value || '')
452
+ .replace(/\r\n/g, '\n')
453
+ .replace(/\r/g, '\n')
454
+ .trim()
455
+
443
456
  const stripPayloadDecimals = (value) => {
444
457
  if (value === undefined || value === null) return value
445
458
  if (typeof value === 'number') {
@@ -875,6 +888,106 @@ const readJsonFileSafe = (filePath, fallbackValue) => {
875
888
  }
876
889
  }
877
890
 
891
+ const formatArchiveDayKey = (ts) => {
892
+ try {
893
+ return new Date(ts).toISOString().slice(0, 10)
894
+ } catch (error) {
895
+ return new Date().toISOString().slice(0, 10)
896
+ }
897
+ }
898
+
899
+ const collectArchiveDayKeysBetween = ({ fromTs, toTs }) => {
900
+ const out = []
901
+ const start = Number(fromTs || 0)
902
+ const end = Number(toTs || 0)
903
+ if (!Number.isFinite(start) || !Number.isFinite(end) || end < start) return out
904
+ const cursor = new Date(start)
905
+ cursor.setUTCHours(0, 0, 0, 0)
906
+ const endDay = new Date(end)
907
+ endDay.setUTCHours(0, 0, 0, 0)
908
+ while (cursor.getTime() <= endDay.getTime()) {
909
+ out.push(cursor.toISOString().slice(0, 10))
910
+ cursor.setUTCDate(cursor.getUTCDate() + 1)
911
+ }
912
+ return out
913
+ }
914
+
915
+ const startOfLocalDayMs = (ts) => {
916
+ const d = new Date(ts)
917
+ d.setHours(0, 0, 0, 0)
918
+ return d.getTime()
919
+ }
920
+
921
+ const endOfLocalDayMs = (ts) => {
922
+ const d = new Date(ts)
923
+ d.setHours(23, 59, 59, 999)
924
+ return d.getTime()
925
+ }
926
+
927
+ const parseQuestionTimeRange = (question, nowTs = Date.now()) => {
928
+ const text = String(question || '').trim().toLowerCase()
929
+ if (!text) return null
930
+
931
+ const exactDates = Array.from(text.matchAll(/\b(\d{4}-\d{2}-\d{2})\b/g)).map(match => match[1])
932
+ if (exactDates.length >= 2) {
933
+ const start = new Date(`${exactDates[0]}T00:00:00`).getTime()
934
+ const end = new Date(`${exactDates[1]}T23:59:59.999`).getTime()
935
+ if (Number.isFinite(start) && Number.isFinite(end) && end >= start) {
936
+ return { fromTs: start, toTs: end, label: `${exactDates[0]}..${exactDates[1]}`, explicit: true }
937
+ }
938
+ }
939
+ if (exactDates.length === 1) {
940
+ const start = new Date(`${exactDates[0]}T00:00:00`).getTime()
941
+ const end = new Date(`${exactDates[0]}T23:59:59.999`).getTime()
942
+ if (Number.isFinite(start) && Number.isFinite(end)) {
943
+ return { fromTs: start, toTs: end, label: exactDates[0], explicit: true }
944
+ }
945
+ }
946
+
947
+ const dayStart = startOfLocalDayMs(nowTs)
948
+ const dayEnd = endOfLocalDayMs(nowTs)
949
+ const yesterdayStart = dayStart - (24 * 60 * 60 * 1000)
950
+ const yesterdayEnd = dayStart - 1
951
+
952
+ if (/\b(stamattina|this morning)\b/.test(text)) {
953
+ return { fromTs: dayStart, toTs: Math.min(dayEnd, dayStart + (12 * 60 * 60 * 1000) - 1), label: 'this morning', explicit: true }
954
+ }
955
+ if (/\b(oggi pomeriggio|this afternoon)\b/.test(text)) {
956
+ return { fromTs: dayStart + (12 * 60 * 60 * 1000), toTs: Math.min(dayEnd, dayStart + (18 * 60 * 60 * 1000) - 1), label: 'this afternoon', explicit: true }
957
+ }
958
+ if (/\b(stasera|this evening|tonight)\b/.test(text)) {
959
+ return { fromTs: dayStart + (18 * 60 * 60 * 1000), toTs: dayEnd, label: 'this evening', explicit: true }
960
+ }
961
+ if (/\b(oggi|today)\b/.test(text)) {
962
+ return { fromTs: dayStart, toTs: dayEnd, label: 'today', explicit: true }
963
+ }
964
+ if (/\b(ieri|yesterday)\b/.test(text)) {
965
+ return { fromTs: yesterdayStart, toTs: yesterdayEnd, label: 'yesterday', explicit: true }
966
+ }
967
+
968
+ const lastDaysMatch = text.match(/\b(?:last|ultimi)\s+(\d{1,3})\s+(?:day|days|giorno|giorni)\b/)
969
+ if (lastDaysMatch) {
970
+ const days = Math.max(1, Number(lastDaysMatch[1] || 1))
971
+ return {
972
+ fromTs: nowTs - (days * 24 * 60 * 60 * 1000),
973
+ toTs: nowTs,
974
+ label: `last ${days} days`,
975
+ explicit: true
976
+ }
977
+ }
978
+
979
+ if (/\b(this week|questa settimana)\b/.test(text)) {
980
+ const d = new Date(nowTs)
981
+ const dow = d.getDay()
982
+ const offset = dow === 0 ? 6 : dow - 1
983
+ d.setHours(0, 0, 0, 0)
984
+ d.setDate(d.getDate() - offset)
985
+ return { fromTs: d.getTime(), toTs: nowTs, label: 'this week', explicit: true }
986
+ }
987
+
988
+ return null
989
+ }
990
+
878
991
  const normalizeAreaOverridePayload = (payload) => {
879
992
  const p = payload && typeof payload === 'object' ? payload : {}
880
993
  const normalized = {}
@@ -2602,13 +2715,14 @@ module.exports = function (RED) {
2602
2715
  return catalog
2603
2716
  }
2604
2717
 
2605
- const buildKnxUltimateFlowInventory = ({ maxNodes = 80 } = {}) => {
2718
+ const buildKnxUltimateProjectInventory = () => {
2606
2719
  const tabById = new Map()
2607
2720
  const gatewaysById = new Map()
2608
- const knxNodes = []
2721
+ const flowNodes = []
2609
2722
 
2610
2723
  try {
2611
2724
  if (typeof RED.nodes.eachNode !== 'function') return ''
2725
+ const gaRe = /\b\d{1,3}\/\d{1,3}\/\d{1,3}\b/g
2612
2726
 
2613
2727
  // First pass: collect tabs + gateways
2614
2728
  RED.nodes.eachNode((n) => {
@@ -2627,11 +2741,11 @@ module.exports = function (RED) {
2627
2741
  }
2628
2742
  })
2629
2743
 
2630
- // Second pass: collect KNX Ultimate nodes (all flows)
2744
+ // Second pass: collect all flow nodes that may help the LLM understand KNX logic.
2631
2745
  RED.nodes.eachNode((n) => {
2632
2746
  if (!n || typeof n !== 'object') return
2633
2747
  const type = String(n.type || '')
2634
- if (!type.startsWith('knxUltimate') || type === 'knxUltimate-config') return
2748
+ if (type === 'tab' || type === 'subflow' || type === 'knxUltimate-config') return
2635
2749
 
2636
2750
  const tabId = String(n.z || '')
2637
2751
  const tabLabel = tabById.get(tabId) || ''
@@ -2640,6 +2754,16 @@ module.exports = function (RED) {
2640
2754
  const server = String(n.server || '')
2641
2755
  const gw = gatewaysById.get(server) || null
2642
2756
 
2757
+ const gaRefs = new Set()
2758
+ extractGAsFromValue({ value: n, outSet: gaRefs, gaRe, maxItems: 24 })
2759
+ const gaList = Array.from(gaRefs.values()).slice(0, 24)
2760
+
2761
+ const shortenSnippet = (value, maxLen = 140) => {
2762
+ const text = String(value || '').replace(/\s+/g, ' ').trim()
2763
+ if (!text) return ''
2764
+ return text.length > maxLen ? `${text.slice(0, Math.max(0, maxLen - 3))}...` : text
2765
+ }
2766
+
2643
2767
  const entry = {
2644
2768
  tabLabel,
2645
2769
  type,
@@ -2648,7 +2772,12 @@ module.exports = function (RED) {
2648
2772
  gatewayId: server,
2649
2773
  gatewayName: gw ? gw.name : '',
2650
2774
  topic: n.topic !== undefined ? String(n.topic) : '',
2651
- dpt: n.dpt !== undefined ? String(n.dpt) : ''
2775
+ dpt: n.dpt !== undefined ? String(n.dpt) : '',
2776
+ gaRefs: gaList,
2777
+ payload: n.payload !== undefined ? shortenSnippet(n.payload, 80) : '',
2778
+ payloadType: n.payloadType !== undefined ? String(n.payloadType) : '',
2779
+ outputTopic: n.outputtopic !== undefined ? String(n.outputtopic) : '',
2780
+ setTopicType: n.setTopicType !== undefined ? String(n.setTopicType) : ''
2652
2781
  }
2653
2782
 
2654
2783
  if (type === 'knxUltimate') {
@@ -2669,17 +2798,31 @@ module.exports = function (RED) {
2669
2798
  entry.gaRewriteRules = n.gaRewriteRules !== undefined ? String(n.gaRewriteRules) : ''
2670
2799
  entry.rewriteSource = n.rewriteSource === true || n.rewriteSource === 'true'
2671
2800
  entry.srcRewriteRules = n.srcRewriteRules !== undefined ? String(n.srcRewriteRules) : ''
2672
- }
2673
-
2674
- knxNodes.push(entry)
2801
+ } else if (type === 'function') {
2802
+ entry.funcSnippet = shortenSnippet(n.func, 220)
2803
+ } else if (type === 'change') {
2804
+ entry.rulesSnippet = shortenSnippet(safeStringify(n.rules), 180)
2805
+ } else if (type === 'inject') {
2806
+ entry.injectOnce = n.once === true || n.once === 'true'
2807
+ entry.repeat = n.repeat !== undefined ? String(n.repeat) : ''
2808
+ entry.crontab = n.crontab !== undefined ? String(n.crontab) : ''
2809
+ } else if (type === 'template') {
2810
+ entry.templateSnippet = shortenSnippet(n.template, 180)
2811
+ } else if (type === 'switch') {
2812
+ entry.rulesSnippet = shortenSnippet(safeStringify(n.rules), 180)
2813
+ } else if (type === 'api-current-state' || type === 'server-state-changed') {
2814
+ entry.entityId = n.entityid !== undefined ? String(n.entityid) : ''
2815
+ }
2816
+
2817
+ flowNodes.push(entry)
2675
2818
  })
2676
2819
  } catch (error) {
2677
2820
  return ''
2678
2821
  }
2679
2822
 
2680
- if (!knxNodes.length && !gatewaysById.size) return ''
2823
+ if (!flowNodes.length && !gatewaysById.size) return ''
2681
2824
 
2682
- const sorted = knxNodes
2825
+ const sorted = flowNodes
2683
2826
  .sort((a, b) => {
2684
2827
  const at = (a.tabLabel || '').localeCompare(b.tabLabel || '')
2685
2828
  if (at !== 0) return at
@@ -2687,13 +2830,12 @@ module.exports = function (RED) {
2687
2830
  if (an !== 0) return an
2688
2831
  return (a.type || '').localeCompare(b.type || '')
2689
2832
  })
2690
- .slice(0, Math.max(0, Number(maxNodes) || 0))
2691
2833
 
2692
2834
  const shorten = (id) => (id && id.length > 8) ? id.slice(0, 8) : id
2693
2835
  const safeLine = (s) => String(s || '').replace(/\s+/g, ' ').trim()
2694
2836
 
2695
2837
  const lines = []
2696
- lines.push('Node-RED flow inventory (KNX Ultimate):')
2838
+ lines.push('Node-RED project inventory:')
2697
2839
 
2698
2840
  if (gatewaysById.size) {
2699
2841
  lines.push(`Gateways (knxUltimate-config): ${gatewaysById.size}`)
@@ -2707,7 +2849,7 @@ module.exports = function (RED) {
2707
2849
  if (gatewaysById.size > 20) lines.push('- ...')
2708
2850
  }
2709
2851
 
2710
- lines.push(`KNX Ultimate nodes: ${knxNodes.length}${knxNodes.length > sorted.length ? ` (showing first ${sorted.length})` : ''}`)
2852
+ lines.push(`Project nodes: ${flowNodes.length}`)
2711
2853
  for (const n of sorted) {
2712
2854
  const parts = []
2713
2855
  if (n.tabLabel) parts.push(`[${safeLine(n.tabLabel)}]`)
@@ -2716,6 +2858,7 @@ module.exports = function (RED) {
2716
2858
  if (n.name) parts.push(`name="${safeLine(n.name)}"`)
2717
2859
  if (n.gatewayName) parts.push(`gw="${safeLine(n.gatewayName)}"`)
2718
2860
  if (!n.gatewayName && n.gatewayId) parts.push(`gwId=${shorten(n.gatewayId)}`)
2861
+ if (Array.isArray(n.gaRefs) && n.gaRefs.length) parts.push(`gaRefs="${safeLine(n.gaRefs.join(','))}"`)
2719
2862
 
2720
2863
  if (n.type === 'knxUltimate') {
2721
2864
  if (n.topic) parts.push(`topic=${safeLine(n.topic)}`)
@@ -2733,8 +2876,26 @@ module.exports = function (RED) {
2733
2876
  if (n.gaRewriteRules) parts.push(`gaRewriteRules="${safeLine(n.gaRewriteRules)}"`)
2734
2877
  if (n.rewriteSource) parts.push('rewriteSource=true')
2735
2878
  if (n.srcRewriteRules) parts.push(`srcRewriteRules="${safeLine(n.srcRewriteRules)}"`)
2879
+ } else if (n.type === 'function') {
2880
+ if (n.funcSnippet) parts.push(`func="${safeLine(n.funcSnippet)}"`)
2881
+ } else if (n.type === 'change' || n.type === 'switch') {
2882
+ if (n.rulesSnippet) parts.push(`rules="${safeLine(n.rulesSnippet)}"`)
2883
+ } else if (n.type === 'inject') {
2884
+ if (n.topic) parts.push(`topic=${safeLine(n.topic)}`)
2885
+ if (n.payload) parts.push(`payload="${safeLine(n.payload)}"`)
2886
+ if (n.payloadType) parts.push(`payloadType=${safeLine(n.payloadType)}`)
2887
+ if (n.injectOnce) parts.push('once=true')
2888
+ if (n.repeat) parts.push(`repeat=${safeLine(n.repeat)}`)
2889
+ if (n.crontab) parts.push(`crontab="${safeLine(n.crontab)}"`)
2890
+ } else if (n.type === 'template') {
2891
+ if (n.templateSnippet) parts.push(`template="${safeLine(n.templateSnippet)}"`)
2892
+ } else if (n.type === 'api-current-state' || n.type === 'server-state-changed') {
2893
+ if (n.entityId) parts.push(`entityId=${safeLine(n.entityId)}`)
2736
2894
  } else {
2737
2895
  if (n.topic) parts.push(`topic=${safeLine(n.topic)}`)
2896
+ if (n.payload) parts.push(`payload="${safeLine(n.payload)}"`)
2897
+ if (n.outputTopic) parts.push(`outputTopic=${safeLine(n.outputTopic)}`)
2898
+ if (n.setTopicType) parts.push(`setTopicType=${safeLine(n.setTopicType)}`)
2738
2899
  }
2739
2900
  lines.push(`- ${parts.join(' ')}`)
2740
2901
  }
@@ -2742,6 +2903,147 @@ module.exports = function (RED) {
2742
2903
  return lines.join('\n').trim()
2743
2904
  }
2744
2905
 
2906
+ const buildFunctionNodeSourceContext = ({ maxChars = 12000, maxNodes = 12 } = {}) => {
2907
+ try {
2908
+ const functionNodes = []
2909
+ const tabById = new Map()
2910
+
2911
+ RED.nodes.eachNode((n) => {
2912
+ if (!n || typeof n !== 'object') return
2913
+ if (String(n.type || '') === 'tab') {
2914
+ tabById.set(String(n.id || ''), String(n.label || n.name || ''))
2915
+ }
2916
+ })
2917
+
2918
+ RED.nodes.eachNode((n) => {
2919
+ if (!n || typeof n !== 'object') return
2920
+ if (String(n.type || '') !== 'function') return
2921
+
2922
+ const func = normalizeCodeBlockText(n.func)
2923
+ const initialize = normalizeCodeBlockText(n.initialize)
2924
+ const finalize = normalizeCodeBlockText(n.finalize)
2925
+ if (!func && !initialize && !finalize) return
2926
+
2927
+ const gaRefs = new Set()
2928
+ extractGAsFromValue({ value: n, outSet: gaRefs, gaRe, maxItems: 24 })
2929
+
2930
+ functionNodes.push({
2931
+ id: String(n.id || ''),
2932
+ name: String(n.name || ''),
2933
+ tabLabel: tabById.get(String(n.z || '')) || '',
2934
+ outputs: Number.isFinite(Number(n.outputs)) ? Number(n.outputs) : '',
2935
+ libs: Array.isArray(n.libs) ? n.libs : [],
2936
+ gaRefs: Array.from(gaRefs.values()).slice(0, 24),
2937
+ func,
2938
+ initialize,
2939
+ finalize
2940
+ })
2941
+ })
2942
+
2943
+ if (!functionNodes.length) return ''
2944
+
2945
+ const shorten = (id) => (id && id.length > 8) ? id.slice(0, 8) : id
2946
+ const safeLine = (s) => String(s || '').replace(/\s+/g, ' ').trim()
2947
+ const limit = Math.max(1200, Number(maxChars) || 0)
2948
+ const nodeLimit = Math.max(1, Number(maxNodes) || 1)
2949
+ const lines = [
2950
+ 'Node-RED Function node source code:',
2951
+ 'The following JavaScript comes from the live Node-RED flow. Review it directly. If any block is truncated, say that explicitly.'
2952
+ ]
2953
+
2954
+ let totalChars = lines.join('\n').length
2955
+ let included = 0
2956
+ let truncatedBlocks = 0
2957
+
2958
+ const sortedNodes = functionNodes
2959
+ .sort((a, b) => {
2960
+ const at = (a.tabLabel || '').localeCompare(b.tabLabel || '')
2961
+ if (at !== 0) return at
2962
+ const an = (a.name || a.id).localeCompare(b.name || b.id)
2963
+ if (an !== 0) return an
2964
+ return (a.id || '').localeCompare(b.id || '')
2965
+ })
2966
+ .slice(0, nodeLimit)
2967
+
2968
+ const buildSection = (label, code, remainingChars) => {
2969
+ const normalized = normalizeCodeBlockText(code)
2970
+ if (!normalized) return ''
2971
+ const overhead = `${label}:\n\`\`\`javascript\n\n\`\`\``.length
2972
+ const availableCodeChars = Math.max(120, remainingChars - overhead)
2973
+ const truncated = normalized.length > availableCodeChars
2974
+ const finalCode = truncated ? truncatePromptText(normalized, availableCodeChars) : normalized
2975
+ return {
2976
+ text: `${label}:\n\`\`\`javascript\n${finalCode}\n\`\`\``,
2977
+ truncated
2978
+ }
2979
+ }
2980
+
2981
+ for (const item of sortedNodes) {
2982
+ if (included >= nodeLimit) break
2983
+ const remainingBeforeHeader = limit - totalChars
2984
+ if (remainingBeforeHeader < 220) break
2985
+
2986
+ const header = []
2987
+ header.push(`Function node ${included + 1}: ${item.name || shorten(item.id) || 'unnamed'}`)
2988
+ if (item.tabLabel) header.push(`tab="${safeLine(item.tabLabel)}"`)
2989
+ header.push(`id=${shorten(item.id)}`)
2990
+ if (item.outputs !== '') header.push(`outputs=${item.outputs}`)
2991
+ if (item.gaRefs.length) header.push(`gaRefs="${safeLine(item.gaRefs.join(','))}"`)
2992
+ if (item.libs.length) {
2993
+ const libsLabel = item.libs
2994
+ .map(lib => safeLine((lib && (lib.module || lib.var)) ? `${lib.var || ''}:${lib.module || ''}` : safeStringify(lib)))
2995
+ .filter(Boolean)
2996
+ .join(', ')
2997
+ if (libsLabel) header.push(`libs="${libsLabel}"`)
2998
+ }
2999
+
3000
+ const blockLines = [header.join(' | ')]
3001
+ let remainingForSections = limit - totalChars - header.join(' | ').length - 2
3002
+ if (remainingForSections < 180) break
3003
+
3004
+ const sections = [
3005
+ buildSection('Main function body', item.func, remainingForSections)
3006
+ ]
3007
+ remainingForSections -= sections[0] && sections[0].text ? sections[0].text.length + 1 : 0
3008
+
3009
+ const initSection = buildSection('On Start / initialize', item.initialize, remainingForSections)
3010
+ if (initSection && initSection.text) {
3011
+ sections.push(initSection)
3012
+ remainingForSections -= initSection.text.length + 1
3013
+ }
3014
+
3015
+ const finalizeSection = buildSection('On Stop / finalize', item.finalize, remainingForSections)
3016
+ if (finalizeSection && finalizeSection.text) sections.push(finalizeSection)
3017
+
3018
+ sections.forEach((section) => {
3019
+ if (section && section.truncated) truncatedBlocks += 1
3020
+ if (section && section.text) blockLines.push(section.text)
3021
+ })
3022
+
3023
+ const blockText = blockLines.filter(Boolean).join('\n')
3024
+ if (!blockText.trim()) continue
3025
+ if ((totalChars + blockText.length + 1) > limit && included > 0) break
3026
+
3027
+ lines.push(blockText)
3028
+ totalChars += blockText.length + 1
3029
+ included += 1
3030
+ if (totalChars >= limit) break
3031
+ }
3032
+
3033
+ const omittedCount = Math.max(0, functionNodes.length - included)
3034
+ if (omittedCount > 0) {
3035
+ lines.push(`Additional function nodes omitted due to prompt budget: ${omittedCount}.`)
3036
+ }
3037
+ if (truncatedBlocks > 0) {
3038
+ lines.push(`Truncated code blocks due to prompt budget: ${truncatedBlocks}.`)
3039
+ }
3040
+
3041
+ return lines.join('\n\n').trim()
3042
+ } catch (error) {
3043
+ return ''
3044
+ }
3045
+ }
3046
+
2745
3047
  if (!adminEndpointsRegistered) {
2746
3048
  adminEndpointsRegistered = true
2747
3049
 
@@ -3531,6 +3833,8 @@ module.exports = function (RED) {
3531
3833
 
3532
3834
  node.analysisWindowSec = Number(config.analysisWindowSec || 60)
3533
3835
  node.historyWindowSec = Number(config.historyWindowSec || 300)
3836
+ node.historyStoreToDisk = config.historyStoreToDisk !== undefined ? coerceBoolean(config.historyStoreToDisk) : false
3837
+ node.historyStoreRetentionDays = Math.max(1, Number.isFinite(Number(config.historyStoreRetentionDays)) ? Number(config.historyStoreRetentionDays) : 10)
3534
3838
  node.emitIntervalSec = Number(config.emitIntervalSec || 0)
3535
3839
  node.topN = Number(config.topN || 10)
3536
3840
 
@@ -3561,9 +3865,6 @@ module.exports = function (RED) {
3561
3865
  node.llmMaxEventsInPrompt = (config.llmMaxEventsInPrompt === undefined || config.llmMaxEventsInPrompt === '') ? 120 : Number(config.llmMaxEventsInPrompt)
3562
3866
  node.llmIncludeRaw = config.llmIncludeRaw !== undefined ? coerceBoolean(config.llmIncludeRaw) : false
3563
3867
  node.llmIncludeFlowContext = config.llmIncludeFlowContext !== undefined ? coerceBoolean(config.llmIncludeFlowContext) : true
3564
- node.llmMaxFlowNodesInPrompt = (config.llmMaxFlowNodesInPrompt === undefined || config.llmMaxFlowNodesInPrompt === '')
3565
- ? 400
3566
- : Number(config.llmMaxFlowNodesInPrompt)
3567
3868
  node.llmIncludeDocsSnippets = config.llmIncludeDocsSnippets !== undefined ? coerceBoolean(config.llmIncludeDocsSnippets) : true
3568
3869
  node.llmDocsLanguage = config.llmDocsLanguage ? String(config.llmDocsLanguage) : 'it'
3569
3870
  node.llmDocsMaxSnippets = (config.llmDocsMaxSnippets === undefined || config.llmDocsMaxSnippets === '') ? 5 : Number(config.llmDocsMaxSnippets)
@@ -3632,6 +3933,7 @@ module.exports = function (RED) {
3632
3933
  node._gaRateSeries = new Map()
3633
3934
  node._gaLabelCsvCache = { ref: null, map: {} }
3634
3935
  node._busConnectionWatchTimer = null
3936
+ node._historyDiskLastPruneAt = 0
3635
3937
  node._busConnectionState = (node.serverKNX && typeof node.serverKNX.linkStatus === 'string')
3636
3938
  ? String(node.serverKNX.linkStatus).toLowerCase()
3637
3939
  : 'unknown'
@@ -4738,8 +5040,10 @@ module.exports = function (RED) {
4738
5040
  const compactMode = compact === true
4739
5041
  const maxEventsRequested = Math.max(10, Number(node.llmMaxEventsInPrompt) || 120)
4740
5042
  const maxEvents = Math.min(compactMode ? 80 : 240, maxEventsRequested)
4741
- const recent = node._history.slice(-maxEvents)
5043
+ const promptEvents = selectTelegramsForPrompt({ question, maxEvents })
5044
+ const recent = Array.isArray(promptEvents.events) ? promptEvents.events : []
4742
5045
  const wantsSvgChart = shouldGenerateSvgChart(question)
5046
+ const wantsFunctionNodeSourceContext = node.llmIncludeFlowContext && shouldIncludeFunctionNodeSourceContext(question)
4743
5047
  const areasSnapshot = buildAreasSnapshot({ summary })
4744
5048
  const areasContext = buildAreasPromptContext(areasSnapshot)
4745
5049
  const summaryForPrompt = buildLlmSummarySnapshot(summary)
@@ -4751,6 +5055,7 @@ module.exports = function (RED) {
4751
5055
  return `${new Date(t.ts).toISOString()} ${t.event} ${t.source} -> ${t.destination}${devName} dpt=${t.dpt} payload=${payloadStr}${rawStr}`
4752
5056
  })
4753
5057
  const recentLines = takeLastItemsByCharBudget(lines, compactMode ? 2600 : 7000)
5058
+ const archiveScopeLine = `Prompt event source: ${promptEvents.source}. Time range: ${promptEvents.range && promptEvents.range.label ? promptEvents.range.label : 'recent events'}. Events selected: ${recent.length}.`
4754
5059
 
4755
5060
  let flowContext = ''
4756
5061
  if (node.llmIncludeFlowContext) {
@@ -4760,17 +5065,38 @@ module.exports = function (RED) {
4760
5065
  if (node._flowContextCache && node._flowContextCache.text && (now - (node._flowContextCache.at || 0)) < ttlMs) {
4761
5066
  flowContext = node._flowContextCache.text
4762
5067
  } 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 })
5068
+ flowContext = buildKnxUltimateProjectInventory()
4768
5069
  flowContext = truncatePromptText(flowContext, flowMaxChars)
4769
5070
  node._flowContextCache = { at: now, text: flowContext }
4770
5071
  }
4771
5072
  flowContext = truncatePromptText(flowContext, flowMaxChars)
4772
5073
  }
4773
5074
 
5075
+ let functionNodeSourceContext = ''
5076
+ if (wantsFunctionNodeSourceContext) {
5077
+ const sourceMaxChars = compactMode ? 4500 : 18000
5078
+ const sourceMaxNodes = compactMode ? 4 : 12
5079
+ const ttlMs = 10 * 1000
5080
+ const now = nowMs()
5081
+ if (
5082
+ node._functionNodeSourceContextCache &&
5083
+ node._functionNodeSourceContextCache.text &&
5084
+ node._functionNodeSourceContextCache.maxChars === sourceMaxChars &&
5085
+ node._functionNodeSourceContextCache.maxNodes === sourceMaxNodes &&
5086
+ (now - (node._functionNodeSourceContextCache.at || 0)) < ttlMs
5087
+ ) {
5088
+ functionNodeSourceContext = node._functionNodeSourceContextCache.text
5089
+ } else {
5090
+ functionNodeSourceContext = buildFunctionNodeSourceContext({ maxChars: sourceMaxChars, maxNodes: sourceMaxNodes })
5091
+ node._functionNodeSourceContextCache = {
5092
+ at: now,
5093
+ maxChars: sourceMaxChars,
5094
+ maxNodes: sourceMaxNodes,
5095
+ text: functionNodeSourceContext
5096
+ }
5097
+ }
5098
+ }
5099
+
4774
5100
  let docsContext = ''
4775
5101
  if (node.llmIncludeDocsSnippets) {
4776
5102
  const docsMaxCharsConfigured = Math.max(500, Math.min(5000, Number(node.llmDocsMaxChars) || 500))
@@ -4804,6 +5130,8 @@ module.exports = function (RED) {
4804
5130
  flowContext ? 'Node-RED context:' : '',
4805
5131
  flowContext || '',
4806
5132
  flowContext ? '' : '',
5133
+ functionNodeSourceContext || '',
5134
+ functionNodeSourceContext ? '' : '',
4807
5135
  docsContext || '',
4808
5136
  docsContext ? '' : '',
4809
5137
  wantsSvgChart ? 'SVG output rules:' : '',
@@ -4812,7 +5140,9 @@ module.exports = function (RED) {
4812
5140
  wantsSvgChart ? '- Do not use JavaScript, external URLs, or <foreignObject>.' : '',
4813
5141
  wantsSvgChart ? '- Prefer width via viewBox and include labels + legend when useful.' : '',
4814
5142
  wantsSvgChart ? '' : '',
4815
- 'Recent KNX telegrams:',
5143
+ archiveScopeLine,
5144
+ '',
5145
+ 'Selected KNX telegrams:',
4816
5146
  recentLines.join('\n'),
4817
5147
  '',
4818
5148
  'User request:',
@@ -4859,6 +5189,172 @@ module.exports = function (RED) {
4859
5189
  return path.join(baseDir, 'knxai', 'config', `knxai-config-${node.id}.json`)
4860
5190
  }
4861
5191
 
5192
+ const getHistoryArchiveDir = () => {
5193
+ const baseDir = (node.serverKNX && node.serverKNX.userDir)
5194
+ ? node.serverKNX.userDir
5195
+ : path.join(RED.settings.userDir, 'knxultimatestorage')
5196
+ return path.join(baseDir, 'knxai', 'history', node.id)
5197
+ }
5198
+
5199
+ const getHistoryArchiveFile = (dayKey) => path.join(getHistoryArchiveDir(), `${String(dayKey || '').trim() || formatArchiveDayKey(Date.now())}.jsonl`)
5200
+
5201
+ const pruneHistoryArchiveFiles = ({ force = false } = {}) => {
5202
+ if (node.historyStoreToDisk !== true) return
5203
+ const retentionDays = Math.max(1, Math.round(Number.isFinite(Number(node.historyStoreRetentionDays)) ? Number(node.historyStoreRetentionDays) : 1))
5204
+ const now = nowMs()
5205
+ if (!force && (now - Number(node._historyDiskLastPruneAt || 0)) < (60 * 60 * 1000)) return
5206
+ node._historyDiskLastPruneAt = now
5207
+ const dirPath = getHistoryArchiveDir()
5208
+ try {
5209
+ if (!fs.existsSync(dirPath)) return
5210
+ const entries = fs.readdirSync(dirPath, { withFileTypes: true })
5211
+ const cutoffTs = now - ((retentionDays - 1) * 24 * 60 * 60 * 1000)
5212
+ const cutoffDayKey = formatArchiveDayKey(cutoffTs)
5213
+ for (let i = 0; i < entries.length; i++) {
5214
+ const entry = entries[i]
5215
+ if (!entry || !entry.isFile()) continue
5216
+ const match = String(entry.name || '').match(/^(\d{4}-\d{2}-\d{2})\.jsonl$/)
5217
+ if (!match) continue
5218
+ const dayKey = match[1]
5219
+ if (dayKey < cutoffDayKey) {
5220
+ try { fs.unlinkSync(path.join(dirPath, entry.name)) } catch (error) { /* ignore */ }
5221
+ }
5222
+ }
5223
+ } catch (error) {
5224
+ node.sysLogger?.warn(`KNX AI history prune error: ${error.message || error}`)
5225
+ }
5226
+ }
5227
+
5228
+ const persistTelegramToDisk = (telegram) => {
5229
+ if (node.historyStoreToDisk !== true || !telegram || typeof telegram !== 'object') return
5230
+ const archiveDir = getHistoryArchiveDir()
5231
+ if (!ensureDirectorySync(archiveDir)) return
5232
+ const dayKey = formatArchiveDayKey(telegram.ts || Date.now())
5233
+ const filePath = getHistoryArchiveFile(dayKey)
5234
+ const line = JSON.stringify(telegram) + '\n'
5235
+ fs.appendFile(filePath, line, 'utf8', (error) => {
5236
+ if (error) node.sysLogger?.warn(`KNX AI history append error: ${error.message || error}`)
5237
+ })
5238
+ pruneHistoryArchiveFiles()
5239
+ }
5240
+
5241
+ const loadRecentHistoryFromDisk = () => {
5242
+ if (node.historyStoreToDisk !== true) return
5243
+ const archiveDir = getHistoryArchiveDir()
5244
+ try {
5245
+ if (!fs.existsSync(archiveDir)) return
5246
+ const now = nowMs()
5247
+ const cutoffTs = now - (Math.max(5, Number(node.historyWindowSec || 5)) * 1000)
5248
+ const dayKeys = collectArchiveDayKeysBetween({ fromTs: cutoffTs, toTs: now })
5249
+ if (!dayKeys.length) return
5250
+ const restored = []
5251
+ for (let i = 0; i < dayKeys.length; i++) {
5252
+ const filePath = getHistoryArchiveFile(dayKeys[i])
5253
+ if (!fs.existsSync(filePath)) continue
5254
+ const raw = fs.readFileSync(filePath, 'utf8')
5255
+ if (!raw || String(raw).trim() === '') continue
5256
+ const lines = raw.split(/\r?\n/)
5257
+ for (let j = 0; j < lines.length; j++) {
5258
+ const line = lines[j]
5259
+ if (!line) continue
5260
+ try {
5261
+ const telegram = JSON.parse(line)
5262
+ const ts = Number(telegram && telegram.ts ? telegram.ts : 0)
5263
+ if (!Number.isFinite(ts) || ts < cutoffTs || ts > now) continue
5264
+ restored.push(telegram)
5265
+ } catch (error) {
5266
+ // Ignore malformed archive rows.
5267
+ }
5268
+ }
5269
+ }
5270
+ if (!restored.length) return
5271
+ restored.sort((a, b) => Number(a.ts || 0) - Number(b.ts || 0))
5272
+ node._history = restored
5273
+ trimHistory(now)
5274
+ } catch (error) {
5275
+ node.sysLogger?.warn(`KNX AI history restore error: ${error.message || error}`)
5276
+ }
5277
+ }
5278
+
5279
+ const loadHistorySliceFromDisk = ({ fromTs, toTs, limit = 240 } = {}) => {
5280
+ if (node.historyStoreToDisk !== true) return []
5281
+ const archiveDir = getHistoryArchiveDir()
5282
+ try {
5283
+ if (!fs.existsSync(archiveDir)) return []
5284
+ const from = Number(fromTs || 0)
5285
+ const to = Number(toTs || 0)
5286
+ if (!Number.isFinite(from) || !Number.isFinite(to) || to < from) return []
5287
+ const dayKeys = collectArchiveDayKeysBetween({ fromTs: from, toTs: to })
5288
+ if (!dayKeys.length) return []
5289
+ const items = []
5290
+ for (let i = 0; i < dayKeys.length; i++) {
5291
+ const filePath = getHistoryArchiveFile(dayKeys[i])
5292
+ if (!fs.existsSync(filePath)) continue
5293
+ const raw = fs.readFileSync(filePath, 'utf8')
5294
+ if (!raw || String(raw).trim() === '') continue
5295
+ const lines = raw.split(/\r?\n/)
5296
+ for (let j = 0; j < lines.length; j++) {
5297
+ const line = lines[j]
5298
+ if (!line) continue
5299
+ try {
5300
+ const telegram = JSON.parse(line)
5301
+ const ts = Number(telegram && telegram.ts ? telegram.ts : 0)
5302
+ if (!Number.isFinite(ts) || ts < from || ts > to) continue
5303
+ items.push(telegram)
5304
+ } catch (error) {
5305
+ // Ignore malformed archive rows.
5306
+ }
5307
+ }
5308
+ }
5309
+ if (!items.length) return []
5310
+ items.sort((a, b) => Number(a.ts || 0) - Number(b.ts || 0))
5311
+ return items.slice(-Math.max(1, Number(limit || 1)))
5312
+ } catch (error) {
5313
+ node.sysLogger?.warn(`KNX AI history load slice error: ${error.message || error}`)
5314
+ return []
5315
+ }
5316
+ }
5317
+
5318
+ const selectTelegramsForPrompt = ({ question, maxEvents }) => {
5319
+ const now = nowMs()
5320
+ const maxItems = Math.max(10, Number(maxEvents) || 120)
5321
+ const explicitRange = parseQuestionTimeRange(question, now)
5322
+ const fallbackRange = node.historyStoreToDisk === true
5323
+ ? { fromTs: now - (24 * 60 * 60 * 1000), toTs: now, label: 'last 24 hours', explicit: false }
5324
+ : { fromTs: now - (Math.max(5, Number(node.historyWindowSec || 5)) * 1000), toTs: now, label: 'memory window', explicit: false }
5325
+ const range = explicitRange || fallbackRange
5326
+
5327
+ let selected = []
5328
+ let source = 'memory'
5329
+ if (node.historyStoreToDisk === true) {
5330
+ const diskItems = loadHistorySliceFromDisk({ fromTs: range.fromTs, toTs: range.toTs, limit: maxItems * 3 })
5331
+ const memoryItems = node._history.filter(t => Number(t && t.ts ? t.ts : 0) >= range.fromTs && Number(t && t.ts ? t.ts : 0) <= range.toTs)
5332
+ const dedupe = new Map()
5333
+ diskItems.concat(memoryItems).forEach((telegram) => {
5334
+ if (!telegram || typeof telegram !== 'object') return
5335
+ const key = [
5336
+ Number(telegram.ts || 0),
5337
+ String(telegram.event || ''),
5338
+ String(telegram.source || ''),
5339
+ String(telegram.destination || ''),
5340
+ normalizeValueForCompare(telegram.payload),
5341
+ String(telegram.rawHex || '')
5342
+ ].join('|')
5343
+ dedupe.set(key, telegram)
5344
+ })
5345
+ selected = Array.from(dedupe.values()).sort((a, b) => Number(a.ts || 0) - Number(b.ts || 0)).slice(-maxItems)
5346
+ source = 'archive+memory'
5347
+ } else {
5348
+ selected = node._history.slice(-maxItems)
5349
+ }
5350
+
5351
+ return {
5352
+ events: selected,
5353
+ source,
5354
+ range
5355
+ }
5356
+ }
5357
+
4862
5358
  const loadPersistedAiConfig = () => {
4863
5359
  if (node._persistedAiConfigCache && typeof node._persistedAiConfigCache === 'object') return node._persistedAiConfigCache
4864
5360
  const configPath = getAiConfigStorageFile()
@@ -7068,6 +7564,7 @@ module.exports = function (RED) {
7068
7564
  const telegram = extractTelegram(msg)
7069
7565
  if (!telegram) return
7070
7566
  node._history.push(telegram)
7567
+ persistTelegramToDisk(telegram)
7071
7568
  resolveTelegramWaiters(telegram)
7072
7569
  trackTransitionTelemetry(telegram)
7073
7570
  const now = telegram.ts
@@ -7319,6 +7816,13 @@ module.exports = function (RED) {
7319
7816
  }, Math.max(5, node.emitIntervalSec) * 1000)
7320
7817
  }
7321
7818
 
7819
+ try {
7820
+ pruneHistoryArchiveFiles({ force: true })
7821
+ loadRecentHistoryFromDisk()
7822
+ } catch (error) {
7823
+ node.sysLogger?.warn(`KNX AI history startup error: ${error.message || error}`)
7824
+ }
7825
+
7322
7826
  if (node._busConnectionWatchTimer) clearInterval(node._busConnectionWatchTimer)
7323
7827
  node._busConnectionWatchTimer = setInterval(() => {
7324
7828
  pollBusConnectionStatus()