node-red-contrib-knx-ultimate 6.3.19 → 6.3.21

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 CHANGED
@@ -6,6 +6,11 @@
6
6
 
7
7
  # CHANGELOG
8
8
 
9
+ **Version 6.3.21** - August 2026<br/>
10
+
11
+ - **KNX AI — persistent adapter-event history**: every event published by an automatically detected adapter is now normalized into vendor-neutral metadata and appended to a node-specific daily JSONL archive. The archive follows the 10-day KNX retention, guaranteeing at least 24 hours of camera and future adapter events across Node-RED restarts without storing snapshot image data. The editor context card exposes the actual adapter-history directories.<br/>
12
+ - **KNX AI — authoritative historical queries**: web Assistant, Telegram, RedBot and custom CHAT channels now query both the adapter-event archive and the existing KNX daily telegram files. Historical prompts include totals calculated across every stored row in the requested interval plus relevance-selected detail samples, preventing sample size from being reported as the total. Natural-language ranges now include multilingual “last N hours” requests and are bounded by available retention.<br/>
13
+
9
14
  **Version 6.3.19** - August 2026<br/>
10
15
 
11
16
  - **KNX AI — conversational multi-step routines**: added coordinated routines such as leaving home, bedtime and cinema mode. KNX AI can now perform a first pass with up to 20 fresh KNX state reads, use the authoritative bus results to prepare an ordered plan of up to 12 validated writes, request one confirmation and then execute the complete routine. After confirmation it waits up to four seconds for immediate matching bus feedback, reports verified and unverified operations without treating missing immediate feedback as a device failure, and dispatches any explicitly requested TTS Ultimate announcement only after execution. Routine details, preliminary readings and execution results are exposed as structured chat metadata. The importable confirmation example, editor help and wiki documentation were updated in EN, IT, DE, FR, ES and zh-CN.<br/>
@@ -635,6 +635,7 @@
635
635
 
636
636
  const sourceLabels = {
637
637
  knxTraffic: t("knxUltimateAI.messages.chatContextSourceKnxTraffic", "Live KNX summary, anomalies, topology and selected telegrams."),
638
+ adapterHistory: t("knxUltimateAI.messages.chatContextSourceAdapterHistory", "Persistent history of automatically detected adapter events."),
638
639
  etsProject: t("knxUltimateAI.messages.chatContextSourceEtsProject", "ETS semantics and the full Node-RED project inventory."),
639
640
  memoryEducation: t("knxUltimateAI.messages.chatContextSourceMemoryEducation", "Session context, AI Education and bounded home memory."),
640
641
  camerasDocs: t("knxUltimateAI.messages.chatContextSourceCamerasDocs", "Detected cameras and relevant help, README and example snippets."),
@@ -668,7 +669,9 @@
668
669
 
669
670
  const directoryLabels = {
670
671
  archiveRoot: t("knxUltimateAI.messages.chatContextDirectoryRoot", "Telegram archive root"),
671
- nodeArchive: t("knxUltimateAI.messages.chatContextDirectoryNode", "This node's telegram archive")
672
+ nodeArchive: t("knxUltimateAI.messages.chatContextDirectoryNode", "This node's telegram archive"),
673
+ adapterArchiveRoot: t("knxUltimateAI.messages.chatContextDirectoryAdapterRoot", "Adapter event archive root"),
674
+ adapterNodeArchive: t("knxUltimateAI.messages.chatContextDirectoryAdapterNode", "This node's adapter event archive")
672
675
  };
673
676
  (Array.isArray(overview.telegramDirectories) ? overview.telegramDirectories : []).forEach(function (item) {
674
677
  if (!item || !item.path) return;
@@ -677,7 +680,9 @@
677
680
  directoryLabels[item.id] || String(item.id || ""),
678
681
  "",
679
682
  item.path,
680
- t("knxUltimateAI.messages.chatContextDirectoryBadge", "KNX")
683
+ /^adapter/.test(String(item.id || ""))
684
+ ? t("knxUltimateAI.messages.chatContextDirectoryAdapterBadge", "Adapter")
685
+ : t("knxUltimateAI.messages.chatContextDirectoryBadge", "KNX")
681
686
  );
682
687
  });
683
688
 
@@ -46,6 +46,13 @@ const {
46
46
  normalizeSearchText,
47
47
  resolveKnxAiCamera
48
48
  } = require('./utils/knxAiCamera')
49
+ const {
50
+ KNX_AI_ADAPTER_HISTORY_MIN_HOURS,
51
+ buildKnxAiHistoryEventKey,
52
+ createKnxAiHistoryAccumulator,
53
+ formatKnxAiAdapterHistoryEventForPrompt,
54
+ normalizeKnxAiAdapterHistoryEvent
55
+ } = require('./utils/knxAiEventHistory')
49
56
  let googleTranslateTTS = null
50
57
  try {
51
58
  googleTranslateTTS = require('google-translate-tts')
@@ -72,6 +79,7 @@ const KNX_AI_LOCAL_LLM_TIMEOUT_MIN_MS = 10 * 60 * 1000
72
79
  const KNX_AI_MINIMAL_CONTEXT_MAX_TOKENS = 16 * 1024
73
80
  const KNX_AI_COMPACT_CONTEXT_MAX_TOKENS = 64 * 1024
74
81
  const KNX_AI_ROUTINE_FEEDBACK_TIMEOUT_MS = 4000
82
+ const KNX_AI_ADAPTER_HISTORY_RETENTION_DAYS = Math.max(1, KNX_AI_TRAFFIC_DEFAULTS.historyStoreRetentionDays)
75
83
 
76
84
  const resolveKnxAiLlmTimeoutMs = ({ provider, configuredTimeoutMs } = {}) => {
77
85
  const configured = Number(configuredTimeoutMs)
@@ -324,6 +332,8 @@ const summarizeKnxAiChatContext = ({ node, nodeId, redUserDir } = {}) => {
324
332
  const configDir = path.join(knxAiDir, 'config')
325
333
  const telegramArchiveRoot = path.join(knxAiDir, 'history')
326
334
  const telegramNodeDir = safeNodeId ? path.join(telegramArchiveRoot, safeNodeId) : ''
335
+ const adapterArchiveRoot = path.join(knxAiDir, 'adapter-history')
336
+ const adapterNodeDir = safeNodeId ? path.join(adapterArchiveRoot, safeNodeId) : ''
327
337
 
328
338
  const files = [
329
339
  {
@@ -346,11 +356,13 @@ const summarizeKnxAiChatContext = ({ node, nodeId, redUserDir } = {}) => {
346
356
  }
347
357
 
348
358
  return {
349
- sources: ['knxTraffic', 'etsProject', 'memoryEducation', 'camerasDocs', 'ttsUltimate'],
359
+ sources: ['knxTraffic', 'adapterHistory', 'etsProject', 'memoryEducation', 'camerasDocs', 'ttsUltimate'],
350
360
  files: files.map(item => Object.assign({}, item, { exists: fs.existsSync(item.path) })),
351
361
  telegramDirectories: [
352
362
  { id: 'archiveRoot', path: telegramArchiveRoot, exists: fs.existsSync(telegramArchiveRoot) },
353
- ...(telegramNodeDir ? [{ id: 'nodeArchive', path: telegramNodeDir, exists: fs.existsSync(telegramNodeDir) }] : [])
363
+ ...(telegramNodeDir ? [{ id: 'nodeArchive', path: telegramNodeDir, exists: fs.existsSync(telegramNodeDir) }] : []),
364
+ { id: 'adapterArchiveRoot', path: adapterArchiveRoot, exists: fs.existsSync(adapterArchiveRoot) },
365
+ ...(adapterNodeDir ? [{ id: 'adapterNodeArchive', path: adapterNodeDir, exists: fs.existsSync(adapterNodeDir) }] : [])
354
366
  ],
355
367
  telegramFilePattern: 'YYYY-MM-DD.jsonl'
356
368
  }
@@ -2505,7 +2517,18 @@ const parseQuestionTimeRange = (question, nowTs = Date.now()) => {
2505
2517
  return { fromTs: yesterdayStart, toTs: yesterdayEnd, label: 'yesterday', explicit: true }
2506
2518
  }
2507
2519
 
2508
- const lastDaysMatch = text.match(/\b(?:last|ultimi)\s+(\d{1,3})\s+(?:day|days|giorno|giorni)\b/)
2520
+ const lastHoursMatch = text.match(/\b(?:last|ultime|ultimi|letzte[n]?|derni[eè]res?|[uú]ltimas?)\s+(\d{1,3})\s+(?:hour|hours|ora|ore|stunde|stunden|heure|heures|hora|horas)\b/)
2521
+ if (lastHoursMatch) {
2522
+ const hours = Math.max(1, Number(lastHoursMatch[1] || 1))
2523
+ return {
2524
+ fromTs: nowTs - (hours * 60 * 60 * 1000),
2525
+ toTs: nowTs,
2526
+ label: `last ${hours} hours`,
2527
+ explicit: true
2528
+ }
2529
+ }
2530
+
2531
+ const lastDaysMatch = text.match(/\b(?:last|ultimi|ultime|letzte[n]?|derni[eè]res?|[uú]ltimos?|[uú]ltimas?)\s+(\d{1,3})\s+(?:day|days|giorno|giorni|tag|tage|tagen|jour|jours|d[ií]a|d[ií]as)\b/)
2509
2532
  if (lastDaysMatch) {
2510
2533
  const days = Math.max(1, Number(lastDaysMatch[1] || 1))
2511
2534
  return {
@@ -6148,6 +6171,9 @@ module.exports = function (RED) {
6148
6171
  node._gaLabelCsvCache = { ref: null, map: {} }
6149
6172
  node._busConnectionWatchTimer = null
6150
6173
  node._historyDiskLastPruneAt = 0
6174
+ node._historyDiskPending = new Map()
6175
+ node._adapterHistoryDiskLastPruneAt = 0
6176
+ node._adapterHistoryDiskPending = new Map()
6151
6177
  node._homeMemory = createEmptyKnxAiHomeMemory()
6152
6178
  node._homeMemoryWriteTimer = null
6153
6179
  node._homeMemoryPeriodicTimer = null
@@ -7269,6 +7295,12 @@ module.exports = function (RED) {
7269
7295
  const maxEvents = Math.min(minimalMode ? 20 : compactMode ? 50 : 240, maxEventsRequested)
7270
7296
  const promptEvents = selectTelegramsForPrompt({ question, maxEvents })
7271
7297
  const recent = Array.isArray(promptEvents.events) ? promptEvents.events : []
7298
+ const adapterPromptEvents = selectAdapterEventsForPrompt({
7299
+ question,
7300
+ maxEvents: minimalMode ? 12 : compactMode ? 30 : 160,
7301
+ range: promptEvents.range
7302
+ })
7303
+ const recentAdapterEvents = Array.isArray(adapterPromptEvents.events) ? adapterPromptEvents.events : []
7272
7304
  const wantsSvgChart = shouldGenerateSvgChart(question)
7273
7305
  const wantsFunctionNodeSourceContext = shouldIncludeFunctionNodeSourceContext(question)
7274
7306
  const areasSnapshot = buildAreasSnapshot({ summary })
@@ -7286,7 +7318,14 @@ module.exports = function (RED) {
7286
7318
  return `${new Date(t.ts).toISOString()} ${t.event} ${t.source} -> ${t.destination}${devName} dpt=${t.dpt} payload=${payloadStr}${rawStr}`
7287
7319
  })
7288
7320
  const recentLines = takeLastItemsByCharBudget(lines, minimalMode ? 1000 : compactMode ? 2200 : 7000)
7289
- const archiveScopeLine = `Prompt event source: ${promptEvents.source}. Time range: ${promptEvents.range && promptEvents.range.label ? promptEvents.range.label : 'recent events'}. Events selected: ${recent.length}.`
7321
+ const archiveScopeLine = `Prompt event source: ${promptEvents.source}. Time range: ${promptEvents.range && promptEvents.range.label ? promptEvents.range.label : 'recent events'}${promptEvents.range && promptEvents.range.clampedToRetention ? ` (clamped to ${promptEvents.range.retentionDays} available day(s))` : ''}. Events selected: ${recent.length}.`
7322
+ const knxArchiveSummary = truncatePromptText(safeStringify(promptEvents.summary || {}), minimalMode ? 1200 : compactMode ? 3000 : 9000)
7323
+ const adapterArchiveSummary = truncatePromptText(safeStringify(adapterPromptEvents.summary || {}), minimalMode ? 1000 : compactMode ? 2600 : 8000)
7324
+ const adapterLines = takeLastItemsByCharBudget(
7325
+ recentAdapterEvents.map(formatKnxAiAdapterHistoryEventForPrompt).filter(Boolean),
7326
+ minimalMode ? 700 : compactMode ? 1800 : 6000
7327
+ )
7328
+ const adapterArchiveScopeLine = `Adapter event source: ${adapterPromptEvents.source}. Time range: ${adapterPromptEvents.range && adapterPromptEvents.range.label ? adapterPromptEvents.range.label : 'last 24 hours'}${adapterPromptEvents.range && adapterPromptEvents.range.clampedToRetention ? ` (clamped to ${adapterPromptEvents.range.retentionDays} available day(s))` : ''}. Events selected: ${recentAdapterEvents.length}.`
7290
7329
 
7291
7330
  let flowContext = ''
7292
7331
  const flowMaxChars = minimalMode ? 600 : compactMode ? 1200 : 5000
@@ -7388,10 +7427,22 @@ module.exports = function (RED) {
7388
7427
  wantsSvgChart ? '- Prefer width via viewBox and include labels + legend when useful.' : '',
7389
7428
  wantsSvgChart ? '' : '',
7390
7429
  archiveScopeLine,
7430
+ 'The KNX archive summary below is calculated from every stored telegram in the requested interval. Use its totals for counts; the selected telegrams are only a relevant/recent sample.',
7431
+ 'KNX historical archive summary (JSON):',
7432
+ knxArchiveSummary,
7391
7433
  '',
7392
7434
  'Selected KNX telegrams:',
7393
7435
  recentLines.join('\n'),
7394
7436
  '',
7437
+ adapterArchiveScopeLine,
7438
+ `Adapter history retention: ${KNX_AI_ADAPTER_HISTORY_RETENTION_DAYS} day(s), with a guaranteed minimum query window of ${KNX_AI_ADAPTER_HISTORY_MIN_HOURS} hours.`,
7439
+ 'The adapter archive summary below is calculated from every stored event in the requested interval. Use its totals for counts; the selected events are only a relevant/recent sample.',
7440
+ 'Adapter historical archive summary (JSON):',
7441
+ adapterArchiveSummary,
7442
+ '',
7443
+ 'Selected adapter events:',
7444
+ adapterLines.length ? adapterLines.join('\n') : '(no stored adapter events in this interval)',
7445
+ '',
7395
7446
  'User request:',
7396
7447
  question || ''
7397
7448
  ].join('\n')
@@ -7445,6 +7496,15 @@ module.exports = function (RED) {
7445
7496
 
7446
7497
  const getHistoryArchiveFile = (dayKey) => path.join(getHistoryArchiveDir(), `${String(dayKey || '').trim() || formatArchiveDayKey(Date.now())}.jsonl`)
7447
7498
 
7499
+ const getAdapterHistoryArchiveDir = () => {
7500
+ const baseDir = (node.serverKNX && node.serverKNX.userDir)
7501
+ ? node.serverKNX.userDir
7502
+ : path.join(RED.settings.userDir, 'knxultimatestorage')
7503
+ return path.join(baseDir, 'knxai', 'adapter-history', node.id)
7504
+ }
7505
+
7506
+ const getAdapterHistoryArchiveFile = dayKey => path.join(getAdapterHistoryArchiveDir(), `${String(dayKey || '').trim() || formatArchiveDayKey(Date.now())}.jsonl`)
7507
+
7448
7508
  const getHomeMemoryFile = () => {
7449
7509
  const baseDir = (node.serverKNX && node.serverKNX.userDir)
7450
7510
  ? node.serverKNX.userDir
@@ -7752,7 +7812,7 @@ module.exports = function (RED) {
7752
7812
  try {
7753
7813
  if (!fs.existsSync(dirPath)) return
7754
7814
  const entries = fs.readdirSync(dirPath, { withFileTypes: true })
7755
- const cutoffTs = now - ((retentionDays - 1) * 24 * 60 * 60 * 1000)
7815
+ const cutoffTs = now - (retentionDays * 24 * 60 * 60 * 1000)
7756
7816
  const cutoffDayKey = formatArchiveDayKey(cutoffTs)
7757
7817
  for (let i = 0; i < entries.length; i++) {
7758
7818
  const entry = entries[i]
@@ -7776,7 +7836,10 @@ module.exports = function (RED) {
7776
7836
  const dayKey = formatArchiveDayKey(telegram.ts || Date.now())
7777
7837
  const filePath = getHistoryArchiveFile(dayKey)
7778
7838
  const line = JSON.stringify(telegram) + '\n'
7839
+ const pendingKey = buildKnxAiHistoryEventKey(telegram, 'knx')
7840
+ if (pendingKey) node._historyDiskPending.set(pendingKey, telegram)
7779
7841
  fs.appendFile(filePath, line, 'utf8', (error) => {
7842
+ if (pendingKey && node._historyDiskPending.get(pendingKey) === telegram) node._historyDiskPending.delete(pendingKey)
7780
7843
  if (error) node.sysLogger?.warn(`KNX AI history append error: ${error.message || error}`)
7781
7844
  })
7782
7845
  pruneHistoryArchiveFiles()
@@ -7820,45 +7883,68 @@ module.exports = function (RED) {
7820
7883
  }
7821
7884
  }
7822
7885
 
7823
- const loadHistorySliceFromDisk = ({ fromTs, toTs, limit = 240 } = {}) => {
7824
- if (node.historyStoreToDisk !== true) return []
7886
+ const loadHistoryQueryFromDisk = ({ fromTs, toTs, limit = 240, question = '' } = {}) => {
7887
+ const emptyAccumulator = () => createKnxAiHistoryAccumulator({ kind: 'knx', question, limit }).finish()
7888
+ if (node.historyStoreToDisk !== true) return emptyAccumulator()
7825
7889
  const archiveDir = getHistoryArchiveDir()
7826
7890
  try {
7827
- if (!fs.existsSync(archiveDir)) return []
7828
7891
  const from = Number(fromTs || 0)
7829
7892
  const to = Number(toTs || 0)
7830
- if (!Number.isFinite(from) || !Number.isFinite(to) || to < from) return []
7893
+ if (!Number.isFinite(from) || !Number.isFinite(to) || to < from) return emptyAccumulator()
7894
+ const accumulator = createKnxAiHistoryAccumulator({ kind: 'knx', question, limit })
7895
+ const pending = node._historyDiskPending instanceof Map ? node._historyDiskPending : new Map()
7831
7896
  const dayKeys = collectArchiveDayKeysBetween({ fromTs: from, toTs: to })
7832
- if (!dayKeys.length) return []
7833
- const items = []
7834
- for (let i = 0; i < dayKeys.length; i++) {
7835
- const filePath = getHistoryArchiveFile(dayKeys[i])
7836
- if (!fs.existsSync(filePath)) continue
7837
- const raw = fs.readFileSync(filePath, 'utf8')
7838
- if (!raw || String(raw).trim() === '') continue
7839
- const lines = raw.split(/\r?\n/)
7840
- for (let j = 0; j < lines.length; j++) {
7841
- const line = lines[j]
7842
- if (!line) continue
7843
- try {
7844
- const telegram = JSON.parse(line)
7845
- const ts = Number(telegram && telegram.ts ? telegram.ts : 0)
7846
- if (!Number.isFinite(ts) || ts < from || ts > to) continue
7847
- items.push(telegram)
7848
- } catch (error) {
7849
- // Ignore malformed archive rows.
7897
+ if (fs.existsSync(archiveDir)) {
7898
+ for (let i = 0; i < dayKeys.length; i++) {
7899
+ const filePath = getHistoryArchiveFile(dayKeys[i])
7900
+ if (!fs.existsSync(filePath)) continue
7901
+ const raw = fs.readFileSync(filePath, 'utf8')
7902
+ if (!raw || String(raw).trim() === '') continue
7903
+ const lines = raw.split(/\r?\n/)
7904
+ for (let j = 0; j < lines.length; j++) {
7905
+ const line = lines[j]
7906
+ if (!line) continue
7907
+ try {
7908
+ const telegram = JSON.parse(line)
7909
+ const ts = Number(telegram && telegram.ts ? telegram.ts : 0)
7910
+ if (!Number.isFinite(ts) || ts < from || ts > to) continue
7911
+ const key = buildKnxAiHistoryEventKey(telegram, 'knx')
7912
+ if (key && pending.has(key)) continue
7913
+ accumulator.add(telegram)
7914
+ } catch (error) {
7915
+ // Ignore malformed archive rows.
7916
+ }
7850
7917
  }
7851
7918
  }
7852
7919
  }
7853
- if (!items.length) return []
7854
- items.sort((a, b) => Number(a.ts || 0) - Number(b.ts || 0))
7855
- return items.slice(-Math.max(1, Number(limit || 1)))
7920
+ pending.forEach(telegram => {
7921
+ const ts = Number(telegram && telegram.ts ? telegram.ts : 0)
7922
+ if (Number.isFinite(ts) && ts >= from && ts <= to) accumulator.add(telegram)
7923
+ })
7924
+ return accumulator.finish()
7856
7925
  } catch (error) {
7857
7926
  node.sysLogger?.warn(`KNX AI history load slice error: ${error.message || error}`)
7858
- return []
7927
+ return emptyAccumulator()
7859
7928
  }
7860
7929
  }
7861
7930
 
7931
+ const clampArchiveRangeToRetention = ({ range, retentionDays }) => {
7932
+ const now = nowMs()
7933
+ const days = Math.max(1, Number(retentionDays) || 1)
7934
+ const earliest = now - (days * 24 * 60 * 60 * 1000)
7935
+ const source = range && typeof range === 'object'
7936
+ ? range
7937
+ : { fromTs: now - (24 * 60 * 60 * 1000), toTs: now, label: 'last 24 hours', explicit: false }
7938
+ const fromTs = Math.max(earliest, Number(source.fromTs || earliest))
7939
+ const toTs = Math.min(now, Number(source.toTs || now))
7940
+ return Object.assign({}, source, {
7941
+ fromTs,
7942
+ toTs: Math.max(fromTs, toTs),
7943
+ retentionDays: days,
7944
+ clampedToRetention: Number(source.fromTs || 0) < earliest
7945
+ })
7946
+ }
7947
+
7862
7948
  const selectTelegramsForPrompt = ({ question, maxEvents }) => {
7863
7949
  const now = nowMs()
7864
7950
  const maxItems = Math.max(10, Number(maxEvents) || 120)
@@ -7866,36 +7952,132 @@ module.exports = function (RED) {
7866
7952
  const fallbackRange = node.historyStoreToDisk === true
7867
7953
  ? { fromTs: now - (24 * 60 * 60 * 1000), toTs: now, label: 'last 24 hours', explicit: false }
7868
7954
  : { fromTs: now - (Math.max(5, Number(node.historyWindowSec || 5)) * 1000), toTs: now, label: 'memory window', explicit: false }
7869
- const range = explicitRange || fallbackRange
7955
+ const range = clampArchiveRangeToRetention({
7956
+ range: explicitRange || fallbackRange,
7957
+ retentionDays: node.historyStoreRetentionDays
7958
+ })
7870
7959
 
7871
7960
  let selected = []
7872
7961
  let source = 'memory'
7962
+ let archiveSummary = null
7873
7963
  if (node.historyStoreToDisk === true) {
7874
- const diskItems = loadHistorySliceFromDisk({ fromTs: range.fromTs, toTs: range.toTs, limit: maxItems * 3 })
7875
- const memoryItems = node._history.filter(t => Number(t && t.ts ? t.ts : 0) >= range.fromTs && Number(t && t.ts ? t.ts : 0) <= range.toTs)
7876
- const dedupe = new Map()
7877
- diskItems.concat(memoryItems).forEach((telegram) => {
7878
- if (!telegram || typeof telegram !== 'object') return
7879
- const key = [
7880
- Number(telegram.ts || 0),
7881
- String(telegram.event || ''),
7882
- String(telegram.source || ''),
7883
- String(telegram.destination || ''),
7884
- normalizeValueForCompare(telegram.payload),
7885
- String(telegram.rawHex || '')
7886
- ].join('|')
7887
- dedupe.set(key, telegram)
7888
- })
7889
- selected = Array.from(dedupe.values()).sort((a, b) => Number(a.ts || 0) - Number(b.ts || 0)).slice(-maxItems)
7890
- source = 'archive+memory'
7964
+ const query = loadHistoryQueryFromDisk({ fromTs: range.fromTs, toTs: range.toTs, limit: maxItems, question })
7965
+ selected = query.events
7966
+ archiveSummary = query.summary
7967
+ source = 'daily JSONL archive'
7891
7968
  } else {
7892
7969
  selected = node._history.slice(-maxItems)
7970
+ const accumulator = createKnxAiHistoryAccumulator({ kind: 'knx', question, limit: maxItems })
7971
+ selected.forEach(telegram => accumulator.add(telegram))
7972
+ const memoryQuery = accumulator.finish()
7973
+ selected = memoryQuery.events
7974
+ archiveSummary = memoryQuery.summary
7893
7975
  }
7894
7976
 
7895
7977
  return {
7896
7978
  events: selected,
7897
7979
  source,
7898
- range
7980
+ range,
7981
+ summary: archiveSummary
7982
+ }
7983
+ }
7984
+
7985
+ const pruneAdapterHistoryArchiveFiles = ({ force = false } = {}) => {
7986
+ const now = nowMs()
7987
+ if (!force && (now - Number(node._adapterHistoryDiskLastPruneAt || 0)) < (60 * 60 * 1000)) return
7988
+ node._adapterHistoryDiskLastPruneAt = now
7989
+ const dirPath = getAdapterHistoryArchiveDir()
7990
+ try {
7991
+ if (!fs.existsSync(dirPath)) return
7992
+ const entries = fs.readdirSync(dirPath, { withFileTypes: true })
7993
+ const retentionDays = Math.max(1, KNX_AI_ADAPTER_HISTORY_RETENTION_DAYS)
7994
+ const cutoffDayKey = formatArchiveDayKey(now - (retentionDays * 24 * 60 * 60 * 1000))
7995
+ entries.forEach(entry => {
7996
+ if (!entry || !entry.isFile()) return
7997
+ const match = String(entry.name || '').match(/^(\d{4}-\d{2}-\d{2})\.jsonl$/)
7998
+ if (!match || match[1] >= cutoffDayKey) return
7999
+ try { fs.unlinkSync(path.join(dirPath, entry.name)) } catch (error) { /* ignore */ }
8000
+ })
8001
+ } catch (error) {
8002
+ node.sysLogger?.warn(`KNX AI adapter history prune error: ${error.message || error}`)
8003
+ }
8004
+ }
8005
+
8006
+ const persistAdapterEventToDisk = ({ event, adapter, provider } = {}) => {
8007
+ const normalized = normalizeKnxAiAdapterHistoryEvent({ event, adapter, provider, nowTs: nowMs() })
8008
+ if (!normalized) return null
8009
+ const archiveDir = getAdapterHistoryArchiveDir()
8010
+ if (!ensureDirectorySync(archiveDir)) return normalized
8011
+ const filePath = getAdapterHistoryArchiveFile(formatArchiveDayKey(normalized.ts))
8012
+ const pendingKey = buildKnxAiHistoryEventKey(normalized, 'adapter')
8013
+ if (pendingKey) node._adapterHistoryDiskPending.set(pendingKey, normalized)
8014
+ fs.appendFile(filePath, `${JSON.stringify(normalized)}\n`, 'utf8', error => {
8015
+ if (pendingKey && node._adapterHistoryDiskPending.get(pendingKey) === normalized) node._adapterHistoryDiskPending.delete(pendingKey)
8016
+ if (error) node.sysLogger?.warn(`KNX AI adapter history append error: ${error.message || error}`)
8017
+ })
8018
+ pruneAdapterHistoryArchiveFiles()
8019
+ return normalized
8020
+ }
8021
+
8022
+ const loadAdapterHistoryQueryFromDisk = ({ fromTs, toTs, limit = 160, question = '' } = {}) => {
8023
+ const accumulator = createKnxAiHistoryAccumulator({ kind: 'adapter', question, limit })
8024
+ const from = Number(fromTs || 0)
8025
+ const to = Number(toTs || 0)
8026
+ if (!Number.isFinite(from) || !Number.isFinite(to) || to < from) return accumulator.finish()
8027
+ const pending = node._adapterHistoryDiskPending instanceof Map ? node._adapterHistoryDiskPending : new Map()
8028
+ try {
8029
+ const archiveDir = getAdapterHistoryArchiveDir()
8030
+ const dayKeys = collectArchiveDayKeysBetween({ fromTs: from, toTs: to })
8031
+ if (fs.existsSync(archiveDir)) {
8032
+ dayKeys.forEach(dayKey => {
8033
+ const filePath = getAdapterHistoryArchiveFile(dayKey)
8034
+ if (!fs.existsSync(filePath)) return
8035
+ const raw = fs.readFileSync(filePath, 'utf8')
8036
+ if (!raw || String(raw).trim() === '') return
8037
+ raw.split(/\r?\n/).forEach(line => {
8038
+ if (!line) return
8039
+ try {
8040
+ const item = JSON.parse(line)
8041
+ const ts = Number(item && item.ts ? item.ts : 0)
8042
+ if (!Number.isFinite(ts) || ts < from || ts > to) return
8043
+ const key = buildKnxAiHistoryEventKey(item, 'adapter')
8044
+ if (key && pending.has(key)) return
8045
+ accumulator.add(item)
8046
+ } catch (error) { /* ignore malformed archive rows */ }
8047
+ })
8048
+ })
8049
+ }
8050
+ pending.forEach(item => {
8051
+ const ts = Number(item && item.ts ? item.ts : 0)
8052
+ if (Number.isFinite(ts) && ts >= from && ts <= to) accumulator.add(item)
8053
+ })
8054
+ } catch (error) {
8055
+ node.sysLogger?.warn(`KNX AI adapter history load error: ${error.message || error}`)
8056
+ }
8057
+ return accumulator.finish()
8058
+ }
8059
+
8060
+ const selectAdapterEventsForPrompt = ({ question, maxEvents, range } = {}) => {
8061
+ const effectiveRange = clampArchiveRangeToRetention({
8062
+ range: range || parseQuestionTimeRange(question, nowMs()) || {
8063
+ fromTs: nowMs() - (KNX_AI_ADAPTER_HISTORY_MIN_HOURS * 60 * 60 * 1000),
8064
+ toTs: nowMs(),
8065
+ label: `last ${KNX_AI_ADAPTER_HISTORY_MIN_HOURS} hours`,
8066
+ explicit: false
8067
+ },
8068
+ retentionDays: KNX_AI_ADAPTER_HISTORY_RETENTION_DAYS
8069
+ })
8070
+ const query = loadAdapterHistoryQueryFromDisk({
8071
+ fromTs: effectiveRange.fromTs,
8072
+ toTs: effectiveRange.toTs,
8073
+ limit: Math.max(1, Number(maxEvents) || 160),
8074
+ question
8075
+ })
8076
+ return {
8077
+ events: query.events,
8078
+ summary: query.summary,
8079
+ source: 'daily JSONL adapter archive',
8080
+ range: effectiveRange
7899
8081
  }
7900
8082
  }
7901
8083
 
@@ -10190,6 +10372,8 @@ module.exports = function (RED) {
10190
10372
  '- For an explicit request to refresh, read, query, or retrieve a current KNX state, create GroupValue_Read operations for the exact relevant objects. Use payload null for reads.',
10191
10373
  '- GroupValue_Read is allowed for exact status, neutral, or command objects in AVAILABLE KNX OBJECTS because it does not modify the bus state.',
10192
10374
  '- For a question that can be answered from recent data, return commands as an empty array. If current data is missing or the user explicitly asks for a fresh read, request it instead of claiming that read-only objects cannot be queried.',
10375
+ '- Historical questions must use the KNX and adapter archive summaries in the supplied analysis context. Totals describe every stored row in the requested interval; selected rows are samples for detail and must not be used as the total count.',
10376
+ '- Adapter history includes automatically detected provider events such as camera motion and smart detections. Do not claim that the absence of an archived event proves physical absence; report only what the adapters recorded.',
10193
10377
  '- Create a GroupValue_Write only when the user clearly asks to control an actuator now.',
10194
10378
  '- Never invent, guess, transform, or substitute a group address or DPT.',
10195
10379
  '- A GroupValue_Write destination must appear in AVAILABLE KNX OBJECTS with role command. Status and neutral objects must never receive GroupValue_Write.',
@@ -10901,9 +11085,14 @@ module.exports = function (RED) {
10901
11085
  }
10902
11086
  }
10903
11087
 
10904
- const handleCameraAdapterEvent = (providerEvent) => {
11088
+ const handleCameraAdapterEvent = (providerEvent, provider = null) => {
10905
11089
  const event = normalizeKnxAiCameraEvent(providerEvent)
10906
- if (!event || event.active === false) return false
11090
+ if (!event) return false
11091
+ const adapter = provider && node._cameraAdapters instanceof Map
11092
+ ? node._cameraAdapters.get(String(provider.adapterId || ''))
11093
+ : null
11094
+ persistAdapterEventToDisk({ event: Object.assign({}, providerEvent, event), adapter, provider })
11095
+ if (event.active === false) return true
10907
11096
  const now = nowMs()
10908
11097
  listAllKnxAiCameraWatches(node._chatContext).filter(watch => cameraWatchMatchesEvent(watch, event)).forEach((watch) => {
10909
11098
  const lastAt = Number(node._cameraWatchLastTriggered.get(watch.id) || 0)
@@ -10958,7 +11147,7 @@ module.exports = function (RED) {
10958
11147
  if (previousProvider === provider && node._cameraProviderUnsubscribers.has(providerId)) return
10959
11148
  if (typeof provider.subscribe === 'function') {
10960
11149
  const unsubscribe = provider.subscribe(event => {
10961
- try { handleCameraAdapterEvent(event) } catch (error) {
11150
+ try { handleCameraAdapterEvent(event, provider) } catch (error) {
10962
11151
  try { node.sysLogger?.warn(`KNX AI camera event error: ${error.message || error}`) } catch (logError) { /* ignore */ }
10963
11152
  }
10964
11153
  })
@@ -12312,6 +12501,7 @@ module.exports = function (RED) {
12312
12501
 
12313
12502
  try {
12314
12503
  pruneHistoryArchiveFiles({ force: true })
12504
+ pruneAdapterHistoryArchiveFiles({ force: true })
12315
12505
  loadRecentHistoryFromDisk()
12316
12506
  loadHomeMemoryFromDisk()
12317
12507
  loadChatContextFromDisk()
@@ -12367,6 +12557,7 @@ module.exports = function (RED) {
12367
12557
  }
12368
12558
 
12369
12559
  module.exports.__test = {
12560
+ KNX_AI_ADAPTER_HISTORY_RETENTION_DAYS,
12370
12561
  KNX_AI_CLOUD_LLM_TIMEOUT_MIN_MS,
12371
12562
  KNX_AI_COMPACT_CONTEXT_MAX_TOKENS,
12372
12563
  KNX_AI_LOCAL_CONTEXT_RETRY_CHAR_BUDGETS,
@@ -12410,6 +12601,7 @@ module.exports.__test = {
12410
12601
  normalizeKnxAiCommandCandidates,
12411
12602
  normalizeKnxAiRoutineDescriptor,
12412
12603
  normalizeLmStudioModelCatalog,
12604
+ parseQuestionTimeRange,
12413
12605
  parseKnxAiConversationResponse,
12414
12606
  postLocalLlmWithContextFallbacks,
12415
12607
  postOpenAiCompatibleChatWithFallbacks,
@@ -51,6 +51,8 @@ Installierte Kamerapakete können KNX AI zur Laufzeit einen Kamera-Adapter berei
51
51
 
52
52
  Der Benutzer kann einen aktuellen Snapshot anfordern oder das Vision-Modell nach dem sichtbaren Inhalt fragen. Die Telegram- und RedBot-Vorlagen senden das Bild als natives Foto mit Bildunterschrift. Außerdem lassen sich dauerhafte Benachrichtigungen für Bewegung, das Überqueren einer intelligenten Linie oder das Betreten einer Einbruchs-/Verweilzone erstellen, optional auf erkannte Personen und eine genau benannte Linie oder Zone begrenzt. Diese Regeln werden in derselben Datei `knxai-chat-context.md` gespeichert und nach einem Neustart von Node-RED wiederhergestellt. UniFi-Ereignisse und Snapshot-Anfragen laufen direkt über den erkannten Anbieter; Ausgang 4 von KNX AI und zusätzliche Flow-Verkabelung sind nicht erforderlich.
53
53
 
54
+ Jedes von einem automatisch erkannten Adapter veröffentlichte Ereignis wird normalisiert und in eine tägliche Datei `YYYY-MM-DD.jsonl` unter `knxultimatestorage/knxai/adapter-history/<node-id>/` geschrieben. Das Archiv bewahrt 10 Tage auf, garantiert mehr als 24 Stunden Historie und speichert Ereignismetadaten, jedoch keine Snapshot-Bilder. Web-Assistent und alle CHAT-Kanäle fragen es zusammen mit dem täglichen KNX-Telegrammarchiv ab. Summen umfassen alle gespeicherten Zeilen; ausgewählte Details sind nur eine relevante Stichprobe.
55
+
54
56
  ### Ansagen mit TTS Ultimate
55
57
  Wenn das optionale Paket `node-red-contrib-tts-ultimate` installiert ist, erscheint es unter den automatisch erkannten Adaptern. Die Auswahl listet alle `ttsultimate`-Nodes in sämtlichen Projekt-Flows mit Flow, Node-Name und konfiguriertem Player auf. Wählen Sie den Node für Chat-Ansagen aus und deployen Sie den Flow.
56
58
 
@@ -90,6 +90,7 @@
90
90
  "chatContextFilesTitle": "Dauerhafte Kontextdateien",
91
91
  "chatContextDirectoriesTitle": "KNX-Telegrammarchiv",
92
92
  "chatContextSourceKnxTraffic": "Aktuelle KNX-Zusammenfassung, Anomalien, Topologie und ausgewählte Telegramme.",
93
+ "chatContextSourceAdapterHistory": "Dauerhaftes Tagesarchiv automatisch erkannter Adapterereignisse einschließlich Kameraerkennungen.",
93
94
  "chatContextSourceEtsProject": "ETS-Semantik und vollständiges Inventar des Node-RED-Projekts.",
94
95
  "chatContextSourceMemoryEducation": "Sitzungskontext, KI-Erziehung und begrenztes Hausgedächtnis.",
95
96
  "chatContextSourceCamerasDocs": "Erkannte Kameras und relevante Auszüge aus Hilfe, README und Beispielen.",
@@ -102,6 +103,9 @@
102
103
  "chatContextDirectoryRoot": "Stammverzeichnis des Telegrammarchivs",
103
104
  "chatContextDirectoryNode": "Telegrammarchiv dieses Nodes",
104
105
  "chatContextDirectoryBadge": "KNX",
106
+ "chatContextDirectoryAdapterRoot": "Stammverzeichnis des Adapterereignisarchivs",
107
+ "chatContextDirectoryAdapterNode": "Adapterereignisarchiv dieses Nodes",
108
+ "chatContextDirectoryAdapterBadge": "Adapter",
105
109
  "chatContextTelegramPattern": "Tagesdateien",
106
110
  "chatAdapterIntro": "Wählen Sie eine Vorlage, um den Code für Ein- und Ausgang einzufügen. Die Liste wird aus der mitgelieferten Chat-Adapter-Datei geladen; der erzeugte Code bleibt bearbeitbar.",
107
111
  "chatAdapterCodeHelp": "Zuordnungen laufen synchron. Geben Sie msg zurück, um fortzufahren, oder keinen Wert, um die Nachricht zu verwerfen. Fehler werden abgefangen und gemeldet, ohne Node-RED anzuhalten.",
@@ -51,13 +51,15 @@ Installed camera packages can publish a camera adapter to KNX AI at runtime. The
51
51
 
52
52
  The user can ask for a current snapshot or ask the vision model what is visible. Telegram and RedBot presets emit the returned image as a native photo with a caption. The user can also create persistent notifications for motion, a smart line crossing or entry into an intrusion/loiter zone, optionally limited to detected people and to an exact named line or zone. These rules are stored in the same `knxai-chat-context.md` file and are restored after Node-RED restarts. UniFi event subscriptions and snapshot requests are made directly through the detected provider; KNX AI output 4 is not involved and no intermediate flow wiring is required.
53
53
 
54
+ Every event published by an automatically detected adapter is normalized and appended to a daily `YYYY-MM-DD.jsonl` file under `knxultimatestorage/knxai/adapter-history/<node-id>/`. The archive keeps 10 days, guarantees more than 24 hours of history and stores event metadata rather than snapshot images. The web Assistant and every CHAT channel query it together with the KNX daily telegram archive. Totals cover every stored row in the requested interval; selected details are only a relevant sample.
55
+
54
56
  ### TTS Ultimate announcements
55
57
  When the optional `node-red-contrib-tts-ultimate` package is installed, it appears among the automatically detected adapters. The selector lists every `ttsultimate` node in all project flows, with its flow, node name and configured player. Choose the node that must handle chat announcements and deploy the flow.
56
58
 
57
59
  Only an explicit request in the current chat message can create an announcement. KNX AI sends the exact text directly to the selected node as `msg.payload`, with `msg.topic = "knx_ai_announcement"`; no intermediate flow wiring is required. TTS Ultimate then handles the configured Sonos player, voice, volume, hailing and queue. Persistent context, AI Education, camera content and inferred events never trigger speech by themselves.
58
60
 
59
61
  ### Chat context overview
60
- The node editor shows a compact card summarizing the sources available to the chat: live KNX traffic, ETS semantics and the Node-RED project, session and home memory, AI Education, detected cameras and relevant documentation. It also lists `knxai-chat-context.md`, `knxai-home-memory.md` and `knxai-config-<node-id>.json`, together with the absolute KNX telegram archive root, the node-specific archive directory and the `YYYY-MM-DD.jsonl` daily-file pattern. The paths are resolved at runtime from the data directory actually used by the configured gateway.
62
+ The node editor shows a compact card summarizing the sources available to the chat: live and archived KNX traffic, persistent adapter events, ETS semantics and the Node-RED project, session and home memory, AI Education, detected cameras and relevant documentation. It also lists the absolute KNX telegram and adapter-event archive directories and the `YYYY-MM-DD.jsonl` daily-file pattern.
61
63
 
62
64
  ## Education-driven proactive home intelligence and bounded memory
63
65
  From ETS hierarchy, names, roles and DPTs, the node builds a deterministic semantic model for covers, windows, doors, lights, temperature, climate, occupancy and alarms using Italian, English, German, French, Spanish and Chinese terms. Its proactive detector watches only reliably recognized non-command cover/window/door states.
@@ -91,6 +91,7 @@
91
91
  "chatContextFilesTitle": "Persistent context files",
92
92
  "chatContextDirectoriesTitle": "KNX telegram archive",
93
93
  "chatContextSourceKnxTraffic": "Live KNX summary, anomalies, topology and selected telegrams.",
94
+ "chatContextSourceAdapterHistory": "Persistent daily history of automatically detected adapter events, including camera detections.",
94
95
  "chatContextSourceEtsProject": "ETS semantics and the full Node-RED project inventory.",
95
96
  "chatContextSourceMemoryEducation": "Session context, AI Education and bounded home memory.",
96
97
  "chatContextSourceCamerasDocs": "Detected cameras and relevant help, README and example snippets.",
@@ -103,6 +104,9 @@
103
104
  "chatContextDirectoryRoot": "Telegram archive root",
104
105
  "chatContextDirectoryNode": "This node's telegram archive",
105
106
  "chatContextDirectoryBadge": "KNX",
107
+ "chatContextDirectoryAdapterRoot": "Adapter event archive root",
108
+ "chatContextDirectoryAdapterNode": "This node's adapter event archive",
109
+ "chatContextDirectoryAdapterBadge": "Adapter",
106
110
  "chatContextTelegramPattern": "Daily files",
107
111
  "chatAdapterIntro": "Choose a mapping preset to insert its input and output code. The list is loaded from the packaged chat-adapter mappings file; the generated code remains editable.",
108
112
  "chatAdapterCodeHelp": "Mappings run synchronously. Return msg to continue or return no value to discard it. Errors are caught and reported without stopping Node-RED.",
@@ -51,6 +51,8 @@ Los paquetes de cámaras instalados pueden publicar en tiempo de ejecución un a
51
51
 
52
52
  El usuario puede pedir una captura actual o preguntar al modelo de visión qué se ve. Los preajustes de Telegram y RedBot envían la imagen como foto nativa con pie. También se pueden crear notificaciones persistentes por movimiento, cruce de una línea inteligente o entrada en una zona de intrusión/merodeo, limitadas opcionalmente a personas detectadas y a una línea o zona concreta por nombre. Estas reglas se guardan en el mismo archivo `knxai-chat-context.md` y se restauran después de reiniciar Node-RED. Las suscripciones a eventos UniFi y las solicitudes de captura se realizan directamente a través del proveedor detectado; no interviene la salida 4 de KNX AI ni hace falta cableado intermedio en el flujo.
53
53
 
54
+ Cada evento publicado por un adaptador detectado automáticamente se normaliza y se añade a un archivo diario `YYYY-MM-DD.jsonl` bajo `knxultimatestorage/knxai/adapter-history/<id-nodo>/`. El archivo conserva 10 días, garantiza más de 24 horas de historial y guarda metadatos, pero no imágenes. El Asistente web y todos los canales CHAT lo consultan junto con el archivo diario KNX. Los totales abarcan todas las filas almacenadas; los detalles seleccionados son solo una muestra relevante.
55
+
54
56
  ### Anuncios con TTS Ultimate
55
57
  Cuando está instalado el paquete opcional `node-red-contrib-tts-ultimate`, aparece entre los adaptadores detectados automáticamente. El selector muestra todos los nodos `ttsultimate` de todos los flows del proyecto, con el flow, el nombre del nodo y el reproductor configurado. Elige el nodo que gestionará los anuncios del chat y despliega el flow.
56
58
 
@@ -83,6 +83,7 @@
83
83
  "chatContextFilesTitle": "Archivos de contexto persistentes",
84
84
  "chatContextDirectoriesTitle": "Archivo de telegramas KNX",
85
85
  "chatContextSourceKnxTraffic": "Resumen KNX actual, anomalías, topología y telegramas seleccionados.",
86
+ "chatContextSourceAdapterHistory": "Historial diario persistente de eventos de los adaptadores detectados, incluidas las detecciones de cámaras.",
86
87
  "chatContextSourceEtsProject": "Semántica ETS e inventario completo del proyecto Node-RED.",
87
88
  "chatContextSourceMemoryEducation": "Contexto de sesión, Educación IA y memoria doméstica limitada.",
88
89
  "chatContextSourceCamerasDocs": "Cámaras detectadas y fragmentos relevantes de la ayuda, README y ejemplos.",
@@ -95,6 +96,9 @@
95
96
  "chatContextDirectoryRoot": "Raíz del archivo de telegramas",
96
97
  "chatContextDirectoryNode": "Archivo de telegramas de este nodo",
97
98
  "chatContextDirectoryBadge": "KNX",
99
+ "chatContextDirectoryAdapterRoot": "Raíz del archivo de eventos de adaptadores",
100
+ "chatContextDirectoryAdapterNode": "Archivo de eventos de adaptadores de este nodo",
101
+ "chatContextDirectoryAdapterBadge": "Adaptador",
98
102
  "chatContextTelegramPattern": "Archivos diarios",
99
103
  "chatAdapterIntro": "Elige un preajuste para insertar su código de mapeo de entrada y salida. La lista se carga desde el archivo de adaptadores de chat incluido; el código generado sigue siendo editable.",
100
104
  "chatAdapterCodeHelp": "Los mapeos se ejecutan de forma síncrona. Devuelve msg para continuar o ningún valor para descartarlo. Los errores se capturan y notifican sin detener Node-RED.",
@@ -51,6 +51,8 @@ Les paquets de caméra installés peuvent publier à l’exécution un adaptateu
51
51
 
52
52
  L’utilisateur peut demander une capture actuelle ou demander au modèle de vision ce qui est visible. Les préréglages Telegram et RedBot envoient l’image comme photo native avec une légende. L’utilisateur peut aussi créer des notifications persistantes pour un mouvement, le franchissement d’une ligne intelligente ou l’entrée dans une zone d’intrusion/de stationnement, avec une limitation facultative aux personnes détectées et à une ligne ou zone nommée précise. Ces règles sont stockées dans le même fichier `knxai-chat-context.md` et restaurées après les redémarrages de Node-RED. Les abonnements aux événements UniFi et les demandes de capture passent directement par le fournisseur détecté ; la sortie 4 de KNX AI et un câblage intermédiaire ne sont pas nécessaires.
53
53
 
54
+ Chaque événement publié par un adaptateur détecté automatiquement est normalisé puis ajouté à un fichier quotidien `YYYY-MM-DD.jsonl` sous `knxultimatestorage/knxai/adapter-history/<id-nœud>/`. L’archive conserve 10 jours, garantit plus de 24 heures d’historique et stocke les métadonnées, mais pas les images. L’Assistant web et tous les canaux CHAT l’interrogent avec l’archive quotidienne KNX. Les totaux couvrent toutes les lignes stockées ; les détails sélectionnés ne sont qu’un échantillon pertinent.
55
+
54
56
  ### Annonces avec TTS Ultimate
55
57
  Lorsque le paquet facultatif `node-red-contrib-tts-ultimate` est installé, il apparaît parmi les adaptateurs détectés automatiquement. Le sélecteur recense tous les nœuds `ttsultimate` de tous les flows du projet, avec le flow, le nom du nœud et le lecteur configuré. Sélectionnez le nœud chargé des annonces du chat, puis déployez le flow.
56
58
 
@@ -83,6 +83,7 @@
83
83
  "chatContextFilesTitle": "Fichiers de contexte persistants",
84
84
  "chatContextDirectoriesTitle": "Archive des télégrammes KNX",
85
85
  "chatContextSourceKnxTraffic": "Résumé KNX actuel, anomalies, topologie et télégrammes sélectionnés.",
86
+ "chatContextSourceAdapterHistory": "Historique quotidien persistant des événements des adaptateurs détectés, y compris les détections des caméras.",
86
87
  "chatContextSourceEtsProject": "Sémantique ETS et inventaire complet du projet Node-RED.",
87
88
  "chatContextSourceMemoryEducation": "Contexte de session, Éducation IA et mémoire domestique limitée.",
88
89
  "chatContextSourceCamerasDocs": "Caméras détectées et extraits pertinents de l'aide, du README et des exemples.",
@@ -95,6 +96,9 @@
95
96
  "chatContextDirectoryRoot": "Racine de l'archive des télégrammes",
96
97
  "chatContextDirectoryNode": "Archive des télégrammes de ce nœud",
97
98
  "chatContextDirectoryBadge": "KNX",
99
+ "chatContextDirectoryAdapterRoot": "Racine de l’archive des événements des adaptateurs",
100
+ "chatContextDirectoryAdapterNode": "Archive des événements des adaptateurs de ce nœud",
101
+ "chatContextDirectoryAdapterBadge": "Adaptateur",
98
102
  "chatContextTelegramPattern": "Fichiers quotidiens",
99
103
  "chatAdapterIntro": "Choisissez un préréglage pour insérer son code de mappage d’entrée et de sortie. La liste est chargée depuis le fichier d’adaptateurs de chat fourni ; le code généré reste modifiable.",
100
104
  "chatAdapterCodeHelp": "Les mappages s’exécutent de façon synchrone. Renvoyez msg pour continuer ou aucune valeur pour l’écarter. Les erreurs sont interceptées et signalées sans arrêter Node-RED.",
@@ -51,13 +51,15 @@ I pacchetti di telecamere installati possono pubblicare a runtime un adapter per
51
51
 
52
52
  L'utente può chiedere uno snapshot aggiornato oppure domandare al modello vision che cosa è visibile. I preset Telegram e RedBot inviano l'immagine come foto nativa con didascalia. L'utente può anche creare notifiche persistenti per movimento, attraversamento di una linea intelligente o ingresso in una zona di intrusione/stazionamento, limitandole facoltativamente alle persone rilevate e a una linea o zona nominata esatta. Le regole vengono salvate nello stesso file `knxai-chat-context.md` e ripristinate dopo i riavvii di Node-RED. Le sottoscrizioni agli eventi UniFi e le richieste snapshot avvengono direttamente tramite il provider rilevato: l'uscita 4 di KNX AI non è coinvolta e non servono collegamenti intermedi nel flow.
53
53
 
54
+ Ogni evento pubblicato da un adapter rilevato automaticamente viene normalizzato e aggiunto a un file giornaliero `YYYY-MM-DD.jsonl` sotto `knxultimatestorage/knxai/adapter-history/<id-nodo>/`. L'archivio conserva 10 giorni, garantisce più di 24 ore di storico e salva i metadati degli eventi, non le immagini. Assistente web e canali CHAT lo interrogano insieme all'archivio giornaliero KNX. I totali comprendono tutte le righe memorizzate nell'intervallo richiesto; i dettagli selezionati sono soltanto un campione pertinente.
55
+
54
56
  ### Annunci con TTS Ultimate
55
57
  Quando è installato il pacchetto opzionale `node-red-contrib-tts-ultimate`, questo compare tra gli adapter rilevati automaticamente. Il selettore elenca tutti i nodi `ttsultimate` presenti in tutti i flow del progetto, indicando flow, nome del nodo e player configurato. Scegli il nodo che deve gestire gli annunci della chat e fai il deploy del flow.
56
58
 
57
59
  Solo una richiesta esplicita nel messaggio corrente della chat può creare un annuncio. KNX AI invia il testo esatto direttamente al nodo scelto come `msg.payload`, con `msg.topic = "knx_ai_announcement"`; non servono collegamenti intermedi nel flow. TTS Ultimate gestisce poi il player Sonos configurato, voce, volume, hailing e coda. Contesto persistente, Educazione AI, contenuto delle telecamere ed eventi dedotti non attivano mai autonomamente la voce.
58
60
 
59
61
  ### Riepilogo del contesto della chat
60
- L'editor del nodo mostra una scheda compatta con le fonti disponibili alla chat: traffico KNX corrente, semantica ETS e progetto Node-RED, memoria di sessione e domestica, Educazione AI, telecamere rilevate e documentazione pertinente. Elenca inoltre `knxai-chat-context.md`, `knxai-home-memory.md` e `knxai-config-<id-nodo>.json`, insieme alla radice assoluta dell'archivio telegrammi KNX, alla cartella specifica del nodo e al formato giornaliero `YYYY-MM-DD.jsonl`. I percorsi vengono risolti a runtime dalla directory dati realmente usata dal gateway configurato.
62
+ L'editor del nodo mostra una scheda compatta con le fonti disponibili alla chat: traffico KNX corrente e archiviato, eventi persistenti degli adapter, semantica ETS e progetto Node-RED, memoria di sessione e domestica, Educazione AI, telecamere rilevate e documentazione pertinente. Elenca anche le directory assolute degli archivi KNX e degli eventi adapter e il formato giornaliero `YYYY-MM-DD.jsonl`.
61
63
 
62
64
  ## Intelligenza domestica proattiva guidata dall'Educazione e memoria limitata
63
65
  Da gerarchia ETS, nomi, ruoli e DPT, il nodo crea un modello semantico deterministico per persiane, finestre, porte, luci, temperatura, clima, presenza e allarmi usando termini italiani, inglesi, tedeschi, francesi, spagnoli e cinesi. Il rilevatore proattivo osserva soltanto stati non di comando di persiane, finestre e porte riconosciuti con sufficiente affidabilità.
@@ -91,6 +91,7 @@
91
91
  "chatContextFilesTitle": "File persistenti di contesto",
92
92
  "chatContextDirectoriesTitle": "Archivio telegrammi KNX",
93
93
  "chatContextSourceKnxTraffic": "Riepilogo KNX corrente, anomalie, topologia e telegrammi selezionati.",
94
+ "chatContextSourceAdapterHistory": "Storico giornaliero persistente degli eventi degli adapter rilevati, comprese le rilevazioni delle telecamere.",
94
95
  "chatContextSourceEtsProject": "Semantica ETS e inventario completo del progetto Node-RED.",
95
96
  "chatContextSourceMemoryEducation": "Contesto di sessione, Educazione AI e memoria domestica limitata.",
96
97
  "chatContextSourceCamerasDocs": "Telecamere rilevate ed estratti pertinenti da help, README ed esempi.",
@@ -103,6 +104,9 @@
103
104
  "chatContextDirectoryRoot": "Radice archivio telegrammi",
104
105
  "chatContextDirectoryNode": "Archivio telegrammi di questo nodo",
105
106
  "chatContextDirectoryBadge": "KNX",
107
+ "chatContextDirectoryAdapterRoot": "Radice archivio eventi adapter",
108
+ "chatContextDirectoryAdapterNode": "Archivio eventi adapter di questo nodo",
109
+ "chatContextDirectoryAdapterBadge": "Adapter",
106
110
  "chatContextTelegramPattern": "File giornalieri",
107
111
  "chatAdapterIntro": "Scegli un preset per inserire il codice di mappatura in ingresso e in uscita. La lista viene caricata dal file degli adattatori chat incluso nel pacchetto; il codice generato resta modificabile.",
108
112
  "chatAdapterCodeHelp": "Le mappature sono sincrone. Restituisci msg per continuare oppure nessun valore per scartarlo. Gli errori vengono intercettati e segnalati senza arrestare Node-RED.",
@@ -51,6 +51,8 @@ Canvas 上的节点状态专门用于显示最近收到的请求,以及 LLM
51
51
 
52
52
  用户可以请求当前快照,或询问视觉模型画面中可见的内容。Telegram 和 RedBot 预设会把图像作为带说明文字的原生照片发送。用户还可以为移动、智能越线或进入入侵/徘徊区域创建持久通知,并可按检测到的人员以及指定名称的线或区域进行限制。这些规则保存在同一个 `knxai-chat-context.md` 文件中,并在 Node-RED 重启后恢复。UniFi 事件订阅和快照请求直接通过检测到的提供方完成;不会使用 KNX AI 输出 4,也不需要中间 Flow 连线。
53
53
 
54
+ 自动检测到的适配器发布的每个事件都会被标准化,并追加到 `knxultimatestorage/knxai/adapter-history/<节点ID>/` 下的每日 `YYYY-MM-DD.jsonl` 文件。存档保留 10 天,保证超过 24 小时的历史,只保存事件元数据,不保存图像。Web 助手和所有 CHAT 渠道会同时查询它与每日 KNX 报文存档。总数涵盖所请求区间内的全部存档行;选出的详情仅是相关样本。
55
+
54
56
  ### 使用 TTS Ultimate 播报
55
57
  安装可选软件包 `node-red-contrib-tts-ultimate` 后,它会显示在自动检测的适配器中。选择器会列出项目所有 Flow 中的全部 `ttsultimate` 节点,并显示 Flow、节点名称和已配置的播放器。请选择负责聊天播报的节点,然后部署 Flow。
56
58
 
@@ -83,6 +83,7 @@
83
83
  "chatContextFilesTitle": "持久化上下文文件",
84
84
  "chatContextDirectoriesTitle": "KNX 报文归档",
85
85
  "chatContextSourceKnxTraffic": "当前 KNX 摘要、异常、拓扑和选定报文。",
86
+ "chatContextSourceAdapterHistory": "自动检测到的适配器事件的持久每日历史记录,包括摄像机检测事件。",
86
87
  "chatContextSourceEtsProject": "ETS 语义和完整的 Node-RED 项目清单。",
87
88
  "chatContextSourceMemoryEducation": "会话上下文、AI 教育和有界家庭记忆。",
88
89
  "chatContextSourceCamerasDocs": "检测到的摄像机以及相关帮助、README 和示例片段。",
@@ -95,6 +96,9 @@
95
96
  "chatContextDirectoryRoot": "报文归档根目录",
96
97
  "chatContextDirectoryNode": "此节点的报文归档",
97
98
  "chatContextDirectoryBadge": "KNX",
99
+ "chatContextDirectoryAdapterRoot": "适配器事件存档根目录",
100
+ "chatContextDirectoryAdapterNode": "此节点的适配器事件存档",
101
+ "chatContextDirectoryAdapterBadge": "适配器",
98
102
  "chatContextTelegramPattern": "每日文件",
99
103
  "chatAdapterIntro": "选择预设即可插入输入和输出映射代码。列表从随包提供的聊天适配器文件加载;生成的代码仍可编辑。",
100
104
  "chatAdapterCodeHelp": "映射同步运行。返回 msg 以继续,或不返回值以丢弃消息。错误会被捕获并报告,不会停止 Node-RED。",
@@ -0,0 +1,246 @@
1
+ const KNX_AI_ADAPTER_HISTORY_MIN_HOURS = 24
2
+ const KNX_AI_HISTORY_DETAILS_MAX_CHARS = 12000
3
+
4
+ const clampText = (value, maxChars = 500) => String(value === undefined || value === null ? '' : value)
5
+ .trim()
6
+ .slice(0, Math.max(0, Number(maxChars) || 0))
7
+
8
+ const normalizeSearchText = value => clampText(value, 4000)
9
+ .normalize('NFKD')
10
+ .replace(/[\u0300-\u036f]/g, '')
11
+ .toLocaleLowerCase()
12
+ .replace(/[^a-z0-9./_-]+/g, ' ')
13
+ .trim()
14
+
15
+ const parseTimestamp = (value, fallback = Date.now()) => {
16
+ if (typeof value === 'number' && Number.isFinite(value)) return value > 100000000000 ? value : value * 1000
17
+ const parsed = new Date(String(value || '')).getTime()
18
+ return Number.isFinite(parsed) ? parsed : fallback
19
+ }
20
+
21
+ const sanitizeHistoryValue = (value, depth = 0, seen = new Set()) => {
22
+ if (value === null || value === undefined) return value
23
+ if (typeof value === 'string') return clampText(value, 1000)
24
+ if (typeof value === 'number' || typeof value === 'boolean') return value
25
+ if (typeof value === 'bigint') return String(value)
26
+ if (Buffer.isBuffer(value)) return `[binary ${value.length} bytes]`
27
+ if (typeof value !== 'object') return clampText(value, 1000)
28
+ if (depth >= 3 || seen.has(value)) return '[nested]'
29
+ seen.add(value)
30
+ if (Array.isArray(value)) {
31
+ const out = value.slice(0, 30).map(item => sanitizeHistoryValue(item, depth + 1, seen))
32
+ seen.delete(value)
33
+ return out
34
+ }
35
+ const out = {}
36
+ Object.keys(value).slice(0, 60).forEach(key => {
37
+ const normalizedKey = clampText(key, 120)
38
+ if (!normalizedKey || /^(data|image|snapshot|buffer|base64)$/i.test(normalizedKey)) return
39
+ out[normalizedKey] = sanitizeHistoryValue(value[key], depth + 1, seen)
40
+ })
41
+ seen.delete(value)
42
+ return out
43
+ }
44
+
45
+ const normalizeKnxAiAdapterHistoryEvent = ({ event, adapter, provider, nowTs = Date.now() } = {}) => {
46
+ const source = event && typeof event === 'object' && !Array.isArray(event) ? event : {}
47
+ const adapterSource = adapter && typeof adapter === 'object' ? adapter : {}
48
+ const providerSource = provider && typeof provider === 'object' ? provider : {}
49
+ const ts = parseTimestamp(source.ts || source.timestamp || source.at || source.start || source.date, nowTs)
50
+ const eventType = clampText(source.eventType || source.type || source.event || source.kind, 120)
51
+ if (!eventType) return null
52
+ const cameraId = clampText(source.cameraId || source.resourceId || source.deviceId, 200)
53
+ const cameraName = clampText(source.cameraName || source.resourceName || source.deviceName, 300)
54
+ const details = sanitizeHistoryValue(source.raw || source.details || source.metadata || {})
55
+ let detailsText = ''
56
+ try { detailsText = JSON.stringify(details) } catch (error) { detailsText = '' }
57
+ return {
58
+ ts,
59
+ at: new Date(ts).toISOString(),
60
+ adapterId: clampText(source.adapterId || providerSource.adapterId || adapterSource.id, 160),
61
+ adapterTitle: clampText(source.adapterTitle || adapterSource.title || adapterSource.name, 240),
62
+ providerId: clampText(source.providerId || providerSource.id, 220),
63
+ providerTitle: clampText(source.providerTitle || providerSource.title || providerSource.name, 240),
64
+ controllerId: clampText(source.controllerId || providerSource.controllerId, 180),
65
+ controllerName: clampText(source.controllerName || providerSource.controllerName, 240),
66
+ resourceType: clampText(source.resourceType || (cameraId || cameraName ? 'camera' : 'adapter'), 80),
67
+ resourceId: cameraId,
68
+ resourceName: cameraName,
69
+ eventType,
70
+ eventId: clampText(source.eventId || source.id, 200),
71
+ active: source.active !== false,
72
+ scopeId: clampText(source.scopeId, 180),
73
+ scopeName: clampText(source.scopeName, 240),
74
+ objectTypes: Array.from(new Set((Array.isArray(source.objectTypes) ? source.objectTypes : [])
75
+ .map(value => clampText(value, 100))
76
+ .filter(Boolean))).slice(0, 24),
77
+ details: detailsText.length <= KNX_AI_HISTORY_DETAILS_MAX_CHARS ? details : { truncated: true }
78
+ }
79
+ }
80
+
81
+ const HISTORY_STOP_WORDS = new Set([
82
+ 'a', 'al', 'alla', 'alle', 'anche', 'and', 'auf', 'aux', 'avec', 'che', 'con', 'da', 'dal', 'dalla', 'das', 'de', 'dei', 'del', 'della', 'des', 'di', 'die', 'do', 'du', 'e', 'el', 'en', 'et', 'for', 'gli', 'ha', 'hanno', 'i', 'il', 'in', 'is', 'la', 'le', 'les', 'lo', 'mit', 'nel', 'nella', 'of', 'on', 'or', 'per', 'pour', 'que', 'qui', 'se', 'sono', 'su', 'the', 'to', 'tra', 'un', 'una', 'und', 'was', 'what', 'with', 'zu'
83
+ ])
84
+
85
+ const historyQuestionTokens = question => Array.from(new Set(normalizeSearchText(question)
86
+ .split(/\s+/)
87
+ .filter(token => token.length >= 2 && !HISTORY_STOP_WORDS.has(token) && !/^\d{1,4}$/.test(token))))
88
+ .slice(0, 24)
89
+
90
+ const incrementCount = (map, key) => {
91
+ const normalized = clampText(key, 500) || '(unknown)'
92
+ map.set(normalized, Number(map.get(normalized) || 0) + 1)
93
+ }
94
+
95
+ const topCounts = (map, limit) => Array.from(map.entries())
96
+ .map(([key, count]) => ({ key, count }))
97
+ .sort((left, right) => right.count - left.count || left.key.localeCompare(right.key))
98
+ .slice(0, Math.max(1, Number(limit) || 1))
99
+
100
+ const buildEventSearchText = (event, kind) => {
101
+ if (kind === 'knx') {
102
+ return normalizeSearchText([
103
+ event.event,
104
+ event.source,
105
+ event.destination,
106
+ event.devicename,
107
+ event.dpt,
108
+ event.payloadmeasureunit,
109
+ typeof event.payload === 'object' ? JSON.stringify(event.payload) : event.payload
110
+ ].join(' '))
111
+ }
112
+ return normalizeSearchText([
113
+ event.adapterId,
114
+ event.adapterTitle,
115
+ event.providerId,
116
+ event.providerTitle,
117
+ event.controllerId,
118
+ event.controllerName,
119
+ event.resourceType,
120
+ event.resourceId,
121
+ event.resourceName,
122
+ event.eventType,
123
+ event.scopeId,
124
+ event.scopeName,
125
+ ...(Array.isArray(event.objectTypes) ? event.objectTypes : []),
126
+ event.details && typeof event.details === 'object' ? JSON.stringify(event.details) : ''
127
+ ].join(' '))
128
+ }
129
+
130
+ const buildKnxAiHistoryEventKey = (event, kind = 'adapter') => {
131
+ if (!event || typeof event !== 'object') return ''
132
+ if (kind === 'knx') {
133
+ let payload = ''
134
+ try { payload = typeof event.payload === 'object' ? JSON.stringify(event.payload) : String(event.payload) } catch (error) { payload = '' }
135
+ return [Number(event.ts || 0), event.event, event.source, event.destination, payload, event.rawHex].join('|')
136
+ }
137
+ return [Number(event.ts || 0), event.adapterId, event.providerId, event.resourceId, event.eventType, event.eventId, event.scopeId, (event.objectTypes || []).join(',')].join('|')
138
+ }
139
+
140
+ const createKnxAiHistoryAccumulator = ({ kind = 'adapter', question = '', limit = 120 } = {}) => {
141
+ const normalizedKind = kind === 'knx' ? 'knx' : 'adapter'
142
+ const maxItems = Math.max(1, Number(limit) || 120)
143
+ const tokens = historyQuestionTokens(question)
144
+ const recent = []
145
+ const relevant = []
146
+ const counts = {
147
+ byEvent: new Map(),
148
+ bySource: new Map(),
149
+ byResource: new Map(),
150
+ byObjectType: new Map(),
151
+ byCombination: new Map()
152
+ }
153
+ let total = 0
154
+ let active = 0
155
+ let firstTs = 0
156
+ let lastTs = 0
157
+
158
+ const add = event => {
159
+ if (!event || typeof event !== 'object') return
160
+ const ts = Number(event.ts || new Date(event.at || '').getTime() || 0)
161
+ if (!Number.isFinite(ts) || ts <= 0) return
162
+ total += 1
163
+ if (event.active !== false) active += 1
164
+ firstTs = firstTs > 0 ? Math.min(firstTs, ts) : ts
165
+ lastTs = Math.max(lastTs, ts)
166
+ const eventName = normalizedKind === 'knx' ? event.event : event.eventType
167
+ const sourceName = normalizedKind === 'knx'
168
+ ? event.source
169
+ : (event.adapterTitle || event.adapterId || event.providerTitle || event.providerId)
170
+ const resourceName = normalizedKind === 'knx'
171
+ ? `${event.destination || '?'}${event.devicename ? ` (${event.devicename})` : ''}`
172
+ : (event.resourceName || event.resourceId || event.controllerName || event.controllerId)
173
+ incrementCount(counts.byEvent, eventName)
174
+ incrementCount(counts.bySource, sourceName)
175
+ incrementCount(counts.byResource, resourceName)
176
+ ;(Array.isArray(event.objectTypes) ? event.objectTypes : []).forEach(value => incrementCount(counts.byObjectType, value))
177
+ const combination = normalizedKind === 'knx'
178
+ ? `${resourceName || '?'} | ${eventName || '?'} | ${clampText(typeof event.payload === 'object' ? JSON.stringify(event.payload) : event.payload, 160)}`
179
+ : `${resourceName || '?'} | ${eventName || '?'}${event.scopeName ? ` | ${event.scopeName}` : ''}${event.objectTypes && event.objectTypes.length ? ` | ${event.objectTypes.join(',')}` : ''}`
180
+ incrementCount(counts.byCombination, combination)
181
+
182
+ recent.push(event)
183
+ if (recent.length > maxItems) recent.shift()
184
+ if (tokens.length) {
185
+ const haystack = buildEventSearchText(event, normalizedKind)
186
+ let score = 0
187
+ tokens.forEach(token => { if (haystack.includes(token)) score += token.length + 2 })
188
+ if (score > 0) {
189
+ relevant.push({ event, score, ts })
190
+ if (relevant.length > maxItems * 4) {
191
+ relevant.sort((left, right) => right.score - left.score || right.ts - left.ts)
192
+ relevant.length = maxItems * 2
193
+ }
194
+ }
195
+ }
196
+ }
197
+
198
+ const finish = () => {
199
+ let events = recent
200
+ if (relevant.length) {
201
+ const selected = relevant
202
+ .sort((left, right) => right.score - left.score || right.ts - left.ts)
203
+ .slice(0, maxItems)
204
+ .map(item => item.event)
205
+ events = selected.sort((left, right) => Number(left.ts || 0) - Number(right.ts || 0))
206
+ }
207
+ return {
208
+ events,
209
+ summary: {
210
+ kind: normalizedKind,
211
+ totalEvents: total,
212
+ activeEvents: active,
213
+ inactiveEvents: total - active,
214
+ firstAt: firstTs ? new Date(firstTs).toISOString() : '',
215
+ lastAt: lastTs ? new Date(lastTs).toISOString() : '',
216
+ selection: relevant.length ? 'question-relevant' : 'most-recent',
217
+ selectedEvents: events.length,
218
+ byEvent: topCounts(counts.byEvent, 20),
219
+ bySource: topCounts(counts.bySource, 12),
220
+ byResource: topCounts(counts.byResource, 30),
221
+ byObjectType: topCounts(counts.byObjectType, 20),
222
+ byCombination: topCounts(counts.byCombination, 40)
223
+ }
224
+ }
225
+ }
226
+
227
+ return { add, finish }
228
+ }
229
+
230
+ const formatKnxAiAdapterHistoryEventForPrompt = event => {
231
+ if (!event || typeof event !== 'object') return ''
232
+ const resource = event.resourceName || event.resourceId || event.controllerName || event.controllerId || '?'
233
+ const scope = event.scopeName || event.scopeId
234
+ const objects = Array.isArray(event.objectTypes) && event.objectTypes.length ? event.objectTypes.join(',') : ''
235
+ return `${event.at || new Date(event.ts || Date.now()).toISOString()} | adapter ${event.adapterTitle || event.adapterId || '?'} | ${event.resourceType || 'resource'} ${resource} | ${event.eventType || '?'} | ${event.active === false ? 'inactive' : 'active'}${scope ? ` | scope ${scope}` : ''}${objects ? ` | objects ${objects}` : ''}`
236
+ }
237
+
238
+ module.exports = {
239
+ KNX_AI_ADAPTER_HISTORY_MIN_HOURS,
240
+ KNX_AI_HISTORY_DETAILS_MAX_CHARS,
241
+ buildKnxAiHistoryEventKey,
242
+ createKnxAiHistoryAccumulator,
243
+ formatKnxAiAdapterHistoryEventForPrompt,
244
+ normalizeKnxAiAdapterHistoryEvent,
245
+ sanitizeHistoryValue
246
+ }
package/package.json CHANGED
@@ -3,7 +3,7 @@
3
3
  "engines": {
4
4
  "node": ">=20.18.1"
5
5
  },
6
- "version": "6.3.19",
6
+ "version": "6.3.21",
7
7
  "description": "KNX Ultimate is the most advanced KNX integration for Node-RED, providing secure KNX/IP communication, routing, ETS project import, Philips Hue, Matter Controller and Matter Bridge (control matter device via KNX and expose KNX GA via Matter), MQTT, diagnostics with AI, virtual devices, and powerful automation nodes. Build professional, reliable, and scalable smart home and building automation projects with minimal effort.",
8
8
  "files": [
9
9
  "nodes/",