node-red-contrib-knx-ultimate 7.1.0-beta.2 → 7.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. package/CHANGELOG.md +9 -0
  2. package/README.md +5 -1
  3. package/nodes/knxUltimateAI.html +1820 -0
  4. package/nodes/knxUltimateAI.js +16797 -0
  5. package/nodes/knxUltimateAIHomeAssistant.html +40 -0
  6. package/nodes/knxUltimateAIHomeAssistant.js +152 -0
  7. package/nodes/knxUltimateHueController.html +1 -1
  8. package/nodes/knxUltimateMatterBridge.html +2 -2
  9. package/nodes/knxUltimateMatterControllerDevice.html +2 -2
  10. package/nodes/locales/de/knxUltimateAI.html +5 -0
  11. package/nodes/locales/de/knxUltimateAI.json +231 -0
  12. package/nodes/locales/de/knxUltimateUtility.json +8 -0
  13. package/nodes/locales/en/knxUltimateAI.html +5 -0
  14. package/nodes/locales/en/knxUltimateAI.json +234 -0
  15. package/nodes/locales/en/knxUltimateUtility.json +8 -0
  16. package/nodes/locales/es/knxUltimateAI.html +5 -0
  17. package/nodes/locales/es/knxUltimateAI.json +231 -0
  18. package/nodes/locales/es/knxUltimateUtility.json +9 -1
  19. package/nodes/locales/fr/knxUltimateAI.html +5 -0
  20. package/nodes/locales/fr/knxUltimateAI.json +231 -0
  21. package/nodes/locales/fr/knxUltimateUtility.json +8 -0
  22. package/nodes/locales/it/knxUltimateAI.html +5 -0
  23. package/nodes/locales/it/knxUltimateAI.json +234 -0
  24. package/nodes/locales/it/knxUltimateUtility.json +8 -0
  25. package/nodes/locales/zh-CN/knxUltimateAI.html +5 -0
  26. package/nodes/locales/zh-CN/knxUltimateAI.json +231 -0
  27. package/nodes/locales/zh-CN/knxUltimateUtility.json +8 -0
  28. package/nodes/plugins/knxUltimate-cerebrum-runtime-plugin.js +79 -0
  29. package/nodes/plugins/knxUltimate-migration-notice-plugin.html +19 -0
  30. package/nodes/plugins/knxUltimateAI-vue/assets/app.css +1 -0
  31. package/nodes/plugins/knxUltimateAI-vue/assets/app.js +13 -0
  32. package/nodes/plugins/knxUltimateAI-vue/assets/chunk-html2canvas.esm.js +5 -0
  33. package/nodes/plugins/knxUltimateAI-vue/assets/chunk-index.es.js +5 -0
  34. package/nodes/plugins/knxUltimateAI-vue/assets/chunk-jspdf.es.min.js +79 -0
  35. package/nodes/plugins/knxUltimateAI-vue/assets/chunk-purify.es.js +3 -0
  36. package/nodes/plugins/knxUltimateAI-vue/index.html +13 -0
  37. package/nodes/plugins/knxUltimateViewer-vue/assets/app.js +2 -2
  38. package/nodes/utils/knxAiCamera.js +302 -0
  39. package/nodes/utils/knxAiCatalogRetrieval.js +349 -0
  40. package/nodes/utils/knxAiCerebrum.js +406 -0
  41. package/nodes/utils/knxAiChatContext.js +496 -0
  42. package/nodes/utils/knxAiEventHistory.js +521 -0
  43. package/nodes/utils/knxAiHomeMemory.js +1021 -0
  44. package/nodes/utils/knxAiScheduler.js +445 -0
  45. package/nodes/utils/knxAiSemanticContext.js +662 -0
  46. package/nodes/utils/knxAiTelegramVoice.js +545 -0
  47. package/nodes/utils/knxAiWebAccess.js +631 -0
  48. package/package.json +17 -9
  49. package/resources/KNXAIChatAdapterMappings.js +416 -0
  50. package/resources/hueControllerMigrationDialog.js +3 -0
  51. package/resources/knxUtilityMigration.js +3 -0
  52. package/resources/legacyMigrationNotice.js +143 -0
@@ -0,0 +1,521 @@
1
+ const KNX_AI_ADAPTER_HISTORY_MIN_HOURS = 24
2
+ const KNX_AI_HISTORY_DETAILS_MAX_CHARS = 12000
3
+ const KNX_AI_COMPACT_ARCHIVE_EXTENSION = 'knxctx'
4
+
5
+ const KNX_AI_COMPACT_ARCHIVE_COLUMNS = Object.freeze({
6
+ knx: Object.freeze([
7
+ 'ts', 'event', 'source', 'destination', 'dpt', 'devicename', 'payload',
8
+ 'payloadmeasureunit', 'echoed', 'repeated', 'rawHex', 'dptdesc'
9
+ ]),
10
+ adapter: Object.freeze([
11
+ 'ts', 'adapterId', 'adapterTitle', 'providerId', 'providerTitle',
12
+ 'controllerId', 'controllerName', 'resourceType', 'resourceId',
13
+ 'resourceName', 'eventType', 'eventId', 'active', 'scopeId', 'scopeName',
14
+ 'objectTypes', 'details'
15
+ ])
16
+ })
17
+
18
+ const clampText = (value, maxChars = 500) => String(value === undefined || value === null ? '' : value)
19
+ .trim()
20
+ .slice(0, Math.max(0, Number(maxChars) || 0))
21
+
22
+ const normalizeSearchText = value => clampText(value, 4000)
23
+ .normalize('NFKD')
24
+ .replace(/[\u0300-\u036f]/g, '')
25
+ .toLocaleLowerCase()
26
+ .replace(/[^a-z0-9./_-]+/g, ' ')
27
+ .trim()
28
+
29
+ const parseTimestamp = (value, fallback = Date.now()) => {
30
+ if (typeof value === 'number' && Number.isFinite(value)) return value > 100000000000 ? value : value * 1000
31
+ const parsed = new Date(String(value || '')).getTime()
32
+ return Number.isFinite(parsed) ? parsed : fallback
33
+ }
34
+
35
+ const sanitizeHistoryValue = (value, depth = 0, seen = new Set()) => {
36
+ if (value === null || value === undefined) return value
37
+ if (typeof value === 'string') return clampText(value, 1000)
38
+ if (typeof value === 'number' || typeof value === 'boolean') return value
39
+ if (typeof value === 'bigint') return String(value)
40
+ if (Buffer.isBuffer(value)) return `[binary ${value.length} bytes]`
41
+ if (typeof value !== 'object') return clampText(value, 1000)
42
+ if (depth >= 3 || seen.has(value)) return '[nested]'
43
+ seen.add(value)
44
+ if (Array.isArray(value)) {
45
+ const out = value.slice(0, 30).map(item => sanitizeHistoryValue(item, depth + 1, seen))
46
+ seen.delete(value)
47
+ return out
48
+ }
49
+ const out = {}
50
+ Object.keys(value).slice(0, 60).forEach(key => {
51
+ const normalizedKey = clampText(key, 120)
52
+ if (!normalizedKey || /^(data|image|snapshot|buffer|base64)$/i.test(normalizedKey)) return
53
+ out[normalizedKey] = sanitizeHistoryValue(value[key], depth + 1, seen)
54
+ })
55
+ seen.delete(value)
56
+ return out
57
+ }
58
+
59
+ const encodeCompactText = value => encodeURIComponent(String(value === undefined || value === null ? '' : value))
60
+ .replace(/[!'()*]/g, char => `%${char.charCodeAt(0).toString(16).toUpperCase()}`)
61
+
62
+ const decodeCompactText = value => {
63
+ try { return decodeURIComponent(String(value || '')) } catch (error) { return String(value || '') }
64
+ }
65
+
66
+ const encodeKnxAiCompactValue = (value, depth = 0, seen = new Set()) => {
67
+ if (value === undefined) return 'u'
68
+ if (value === null) return 'z'
69
+ if (typeof value === 'boolean') return value ? 'b1' : 'b0'
70
+ if (typeof value === 'number') return Number.isFinite(value) ? `n${value}` : 'u'
71
+ if (typeof value === 'bigint') return `s${encodeCompactText(String(value))}`
72
+ if (typeof value === 'string') return `s${encodeCompactText(value)}`
73
+ if (Buffer.isBuffer(value)) return `x${value.toString('base64')}`
74
+ if (typeof value !== 'object') return `s${encodeCompactText(String(value))}`
75
+ if (depth >= 4 || seen.has(value)) return `s${encodeCompactText('[nested]')}`
76
+ seen.add(value)
77
+ if (Array.isArray(value)) {
78
+ const encoded = value.slice(0, 40).map(item => encodeKnxAiCompactValue(item, depth + 1, seen))
79
+ seen.delete(value)
80
+ return `a(${encoded.join(',')})`
81
+ }
82
+ const encoded = Object.keys(value).slice(0, 80).map(key => (
83
+ `${encodeCompactText(key)}=${encodeKnxAiCompactValue(value[key], depth + 1, seen)}`
84
+ ))
85
+ seen.delete(value)
86
+ return `o(${encoded.join(';')})`
87
+ }
88
+
89
+ const decodeKnxAiCompactValue = value => {
90
+ const source = String(value || '')
91
+ let offset = 0
92
+ const readUntil = delimiters => {
93
+ const start = offset
94
+ while (offset < source.length && !delimiters.includes(source[offset])) offset += 1
95
+ return source.slice(start, offset)
96
+ }
97
+ const parseValue = () => {
98
+ const tag = source[offset]
99
+ offset += 1
100
+ if (tag === 'u') return undefined
101
+ if (tag === 'z') return null
102
+ if (tag === 'b') {
103
+ const bool = source[offset] === '1'
104
+ offset += 1
105
+ return bool
106
+ }
107
+ if (tag === 'n') {
108
+ const parsed = Number(readUntil([',', ';', ')']))
109
+ return Number.isFinite(parsed) ? parsed : undefined
110
+ }
111
+ if (tag === 's') return decodeCompactText(readUntil([',', ';', ')']))
112
+ if (tag === 'x') {
113
+ const encoded = readUntil([',', ';', ')'])
114
+ try { return Buffer.from(encoded, 'base64') } catch (error) { return encoded }
115
+ }
116
+ if (tag === 'a' && source[offset] === '(') {
117
+ offset += 1
118
+ const out = []
119
+ while (offset < source.length && source[offset] !== ')') {
120
+ out.push(parseValue())
121
+ if (source[offset] === ',') offset += 1
122
+ else if (source[offset] !== ')') break
123
+ }
124
+ if (source[offset] === ')') offset += 1
125
+ return out
126
+ }
127
+ if (tag === 'o' && source[offset] === '(') {
128
+ offset += 1
129
+ const out = {}
130
+ while (offset < source.length && source[offset] !== ')') {
131
+ const key = decodeCompactText(readUntil(['=']))
132
+ if (source[offset] !== '=') break
133
+ offset += 1
134
+ out[key] = parseValue()
135
+ if (source[offset] === ';') offset += 1
136
+ else if (source[offset] !== ')') break
137
+ }
138
+ if (source[offset] === ')') offset += 1
139
+ return out
140
+ }
141
+ return decodeCompactText(source.slice(Math.max(0, offset - 1)))
142
+ }
143
+ try { return parseValue() } catch (error) { return source }
144
+ }
145
+
146
+ const escapeKnxAiCompactField = value => String(value === undefined || value === null ? '' : value)
147
+ .replace(/\\/g, '\\\\')
148
+ .replace(/\t/g, '\\t')
149
+ .replace(/\r/g, '\\r')
150
+ .replace(/\n/g, '\\n')
151
+
152
+ const unescapeKnxAiCompactField = value => String(value || '').replace(/\\([\\tnr])/g, (match, escaped) => {
153
+ if (escaped === 't') return '\t'
154
+ if (escaped === 'r') return '\r'
155
+ if (escaped === 'n') return '\n'
156
+ return '\\'
157
+ })
158
+
159
+ const serializeKnxAiCompactHistoryRecord = (event, kind = 'adapter') => {
160
+ if (!event || typeof event !== 'object') return ''
161
+ const normalizedKind = kind === 'knx' ? 'knx' : 'adapter'
162
+ const values = normalizedKind === 'knx'
163
+ ? [
164
+ Number(event.ts || 0),
165
+ event.event,
166
+ event.source,
167
+ event.destination,
168
+ event.dpt,
169
+ event.devicename,
170
+ encodeKnxAiCompactValue(sanitizeHistoryValue(event.payload)),
171
+ event.payloadmeasureunit,
172
+ event.echoed === true ? 1 : 0,
173
+ event.repeated === true ? 1 : 0,
174
+ event.rawHex,
175
+ event.dptdesc
176
+ ]
177
+ : [
178
+ Number(event.ts || 0),
179
+ event.adapterId,
180
+ event.adapterTitle,
181
+ event.providerId,
182
+ event.providerTitle,
183
+ event.controllerId,
184
+ event.controllerName,
185
+ event.resourceType,
186
+ event.resourceId,
187
+ event.resourceName,
188
+ event.eventType,
189
+ event.eventId,
190
+ event.active === false ? 0 : 1,
191
+ event.scopeId,
192
+ event.scopeName,
193
+ encodeKnxAiCompactValue(Array.isArray(event.objectTypes) ? event.objectTypes : []),
194
+ encodeKnxAiCompactValue(sanitizeHistoryValue(event.details || {}))
195
+ ]
196
+ return values.map(escapeKnxAiCompactField).join('\t')
197
+ }
198
+
199
+ const parseKnxAiCompactHistoryRecord = (line, kind = 'adapter') => {
200
+ const source = String(line || '')
201
+ if (!source.trim()) return null
202
+ const normalizedKind = kind === 'knx' ? 'knx' : 'adapter'
203
+ const values = source.split('\t').map(unescapeKnxAiCompactField)
204
+ const columns = KNX_AI_COMPACT_ARCHIVE_COLUMNS[normalizedKind]
205
+ if (values.length !== columns.length) return null
206
+ const ts = Number(values[0])
207
+ if (!Number.isFinite(ts) || ts <= 0) return null
208
+ if (normalizedKind === 'knx') {
209
+ return {
210
+ ts,
211
+ event: values[1],
212
+ source: values[2],
213
+ destination: values[3],
214
+ dpt: values[4],
215
+ devicename: values[5],
216
+ payload: decodeKnxAiCompactValue(values[6]),
217
+ payloadmeasureunit: values[7],
218
+ echoed: values[8] === '1',
219
+ repeated: values[9] === '1',
220
+ rawHex: values[10] || undefined,
221
+ dptdesc: values[11]
222
+ }
223
+ }
224
+ const objectTypes = decodeKnxAiCompactValue(values[15])
225
+ const details = decodeKnxAiCompactValue(values[16])
226
+ return {
227
+ ts,
228
+ at: new Date(ts).toISOString(),
229
+ adapterId: values[1],
230
+ adapterTitle: values[2],
231
+ providerId: values[3],
232
+ providerTitle: values[4],
233
+ controllerId: values[5],
234
+ controllerName: values[6],
235
+ resourceType: values[7],
236
+ resourceId: values[8],
237
+ resourceName: values[9],
238
+ eventType: values[10],
239
+ eventId: values[11],
240
+ active: values[12] !== '0',
241
+ scopeId: values[13],
242
+ scopeName: values[14],
243
+ objectTypes: Array.isArray(objectTypes) ? objectTypes : [],
244
+ details: details && typeof details === 'object' ? details : {}
245
+ }
246
+ }
247
+
248
+ const compactPromptScalar = value => String(value === undefined || value === null ? '-' : value)
249
+ .replace(/[\r\n\t]+/g, ' ')
250
+ .replace(/\|/g, '\\|')
251
+ .trim()
252
+
253
+ const formatKnxAiCompactContextForPrompt = value => {
254
+ const lines = []
255
+ const walk = (current, path = '') => {
256
+ if (current === undefined || current === null || typeof current !== 'object') {
257
+ lines.push(`${path || 'value'}=${compactPromptScalar(current)}`)
258
+ return
259
+ }
260
+ if (Array.isArray(current)) {
261
+ if (current.every(item => item === null || item === undefined || typeof item !== 'object')) {
262
+ lines.push(`${path || 'values'}=${current.map(compactPromptScalar).join(',')}`)
263
+ return
264
+ }
265
+ current.forEach((item, index) => {
266
+ const itemPath = `${path || 'items'}[${index}]`
267
+ if (!item || typeof item !== 'object' || Array.isArray(item)) {
268
+ walk(item, itemPath)
269
+ return
270
+ }
271
+ const scalarEntries = Object.entries(item).filter(([, child]) => child === null || child === undefined || typeof child !== 'object')
272
+ if (scalarEntries.length) {
273
+ lines.push(`${itemPath} ${scalarEntries.map(([key, child]) => `${key}=${compactPromptScalar(child)}`).join(' | ')}`)
274
+ }
275
+ Object.entries(item).filter(([, child]) => child && typeof child === 'object').forEach(([key, child]) => walk(child, `${itemPath}.${key}`))
276
+ })
277
+ return
278
+ }
279
+ Object.entries(current).forEach(([key, child]) => walk(child, path ? `${path}.${key}` : key))
280
+ }
281
+ walk(value)
282
+ return lines.join('\n')
283
+ }
284
+
285
+ const formatKnxAiHistorySummaryForPrompt = summary => {
286
+ const source = summary && typeof summary === 'object' ? summary : {}
287
+ const lines = [
288
+ [
289
+ `kind=${compactPromptScalar(source.kind)}`,
290
+ `total=${Number(source.totalEvents || 0)}`,
291
+ `active=${Number(source.activeEvents || 0)}`,
292
+ `inactive=${Number(source.inactiveEvents || 0)}`,
293
+ `first=${compactPromptScalar(source.firstAt)}`,
294
+ `last=${compactPromptScalar(source.lastAt)}`,
295
+ `selection=${compactPromptScalar(source.selection)}`,
296
+ `selected=${Number(source.selectedEvents || 0)}`
297
+ ].join(' | ')
298
+ ]
299
+ ;[
300
+ ['events', source.byEvent],
301
+ ['sources', source.bySource],
302
+ ['resources', source.byResource],
303
+ ['objects', source.byObjectType],
304
+ ['combinations', source.byCombination]
305
+ ].forEach(([label, items]) => {
306
+ const entries = (Array.isArray(items) ? items : []).map(item => `${compactPromptScalar(item && item.key)}=${Number(item && item.count || 0)}`)
307
+ if (entries.length) lines.push(`${label}: ${entries.join(' | ')}`)
308
+ })
309
+ return lines.join('\n')
310
+ }
311
+
312
+ const normalizeKnxAiAdapterHistoryEvent = ({ event, adapter, provider, nowTs = Date.now() } = {}) => {
313
+ const source = event && typeof event === 'object' && !Array.isArray(event) ? event : {}
314
+ const adapterSource = adapter && typeof adapter === 'object' ? adapter : {}
315
+ const providerSource = provider && typeof provider === 'object' ? provider : {}
316
+ const ts = parseTimestamp(source.ts || source.timestamp || source.at || source.start || source.date, nowTs)
317
+ const eventType = clampText(source.eventType || source.type || source.event || source.kind, 120)
318
+ if (!eventType) return null
319
+ const cameraId = clampText(source.cameraId || source.resourceId || source.deviceId, 200)
320
+ const cameraName = clampText(source.cameraName || source.resourceName || source.deviceName, 300)
321
+ const details = sanitizeHistoryValue(source.raw || source.details || source.metadata || {})
322
+ let detailsText = ''
323
+ try { detailsText = JSON.stringify(details) } catch (error) { detailsText = '' }
324
+ return {
325
+ ts,
326
+ at: new Date(ts).toISOString(),
327
+ adapterId: clampText(source.adapterId || providerSource.adapterId || adapterSource.id, 160),
328
+ adapterTitle: clampText(source.adapterTitle || adapterSource.title || adapterSource.name, 240),
329
+ providerId: clampText(source.providerId || providerSource.id, 220),
330
+ providerTitle: clampText(source.providerTitle || providerSource.title || providerSource.name, 240),
331
+ controllerId: clampText(source.controllerId || providerSource.controllerId, 180),
332
+ controllerName: clampText(source.controllerName || providerSource.controllerName, 240),
333
+ resourceType: clampText(source.resourceType || (cameraId || cameraName ? 'camera' : 'adapter'), 80),
334
+ resourceId: cameraId,
335
+ resourceName: cameraName,
336
+ eventType,
337
+ eventId: clampText(source.eventId || source.id, 200),
338
+ active: source.active !== false,
339
+ scopeId: clampText(source.scopeId, 180),
340
+ scopeName: clampText(source.scopeName, 240),
341
+ objectTypes: Array.from(new Set((Array.isArray(source.objectTypes) ? source.objectTypes : [])
342
+ .map(value => clampText(value, 100))
343
+ .filter(Boolean))).slice(0, 24),
344
+ details: detailsText.length <= KNX_AI_HISTORY_DETAILS_MAX_CHARS ? details : { truncated: true }
345
+ }
346
+ }
347
+
348
+ const HISTORY_STOP_WORDS = new Set([
349
+ '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'
350
+ ])
351
+
352
+ const historyQuestionTokens = question => Array.from(new Set(normalizeSearchText(question)
353
+ .split(/\s+/)
354
+ .filter(token => token.length >= 2 && !HISTORY_STOP_WORDS.has(token) && !/^\d{1,4}$/.test(token))))
355
+ .slice(0, 24)
356
+
357
+ const incrementCount = (map, key) => {
358
+ const normalized = clampText(key, 500) || '(unknown)'
359
+ map.set(normalized, Number(map.get(normalized) || 0) + 1)
360
+ }
361
+
362
+ const topCounts = (map, limit) => Array.from(map.entries())
363
+ .map(([key, count]) => ({ key, count }))
364
+ .sort((left, right) => right.count - left.count || left.key.localeCompare(right.key))
365
+ .slice(0, Math.max(1, Number(limit) || 1))
366
+
367
+ const buildEventSearchText = (event, kind) => {
368
+ if (kind === 'knx') {
369
+ return normalizeSearchText([
370
+ event.event,
371
+ event.source,
372
+ event.destination,
373
+ event.devicename,
374
+ event.dpt,
375
+ event.payloadmeasureunit,
376
+ typeof event.payload === 'object' ? JSON.stringify(event.payload) : event.payload
377
+ ].join(' '))
378
+ }
379
+ return normalizeSearchText([
380
+ event.adapterId,
381
+ event.adapterTitle,
382
+ event.providerId,
383
+ event.providerTitle,
384
+ event.controllerId,
385
+ event.controllerName,
386
+ event.resourceType,
387
+ event.resourceId,
388
+ event.resourceName,
389
+ event.eventType,
390
+ event.scopeId,
391
+ event.scopeName,
392
+ ...(Array.isArray(event.objectTypes) ? event.objectTypes : []),
393
+ event.details && typeof event.details === 'object' ? JSON.stringify(event.details) : ''
394
+ ].join(' '))
395
+ }
396
+
397
+ const buildKnxAiHistoryEventKey = (event, kind = 'adapter') => {
398
+ if (!event || typeof event !== 'object') return ''
399
+ if (kind === 'knx') {
400
+ let payload = ''
401
+ try { payload = typeof event.payload === 'object' ? JSON.stringify(event.payload) : String(event.payload) } catch (error) { payload = '' }
402
+ return [Number(event.ts || 0), event.event, event.source, event.destination, payload, event.rawHex].join('|')
403
+ }
404
+ return [Number(event.ts || 0), event.adapterId, event.providerId, event.resourceId, event.eventType, event.eventId, event.scopeId, (event.objectTypes || []).join(',')].join('|')
405
+ }
406
+
407
+ const createKnxAiHistoryAccumulator = ({ kind = 'adapter', question = '', limit = 120 } = {}) => {
408
+ const normalizedKind = kind === 'knx' ? 'knx' : 'adapter'
409
+ const maxItems = Math.max(1, Number(limit) || 120)
410
+ const tokens = historyQuestionTokens(question)
411
+ const recent = []
412
+ const relevant = []
413
+ const counts = {
414
+ byEvent: new Map(),
415
+ bySource: new Map(),
416
+ byResource: new Map(),
417
+ byObjectType: new Map(),
418
+ byCombination: new Map()
419
+ }
420
+ let total = 0
421
+ let active = 0
422
+ let firstTs = 0
423
+ let lastTs = 0
424
+
425
+ const add = event => {
426
+ if (!event || typeof event !== 'object') return
427
+ const ts = Number(event.ts || new Date(event.at || '').getTime() || 0)
428
+ if (!Number.isFinite(ts) || ts <= 0) return
429
+ total += 1
430
+ if (event.active !== false) active += 1
431
+ firstTs = firstTs > 0 ? Math.min(firstTs, ts) : ts
432
+ lastTs = Math.max(lastTs, ts)
433
+ const eventName = normalizedKind === 'knx' ? event.event : event.eventType
434
+ const sourceName = normalizedKind === 'knx'
435
+ ? event.source
436
+ : (event.adapterTitle || event.adapterId || event.providerTitle || event.providerId)
437
+ const resourceName = normalizedKind === 'knx'
438
+ ? `${event.destination || '?'}${event.devicename ? ` (${event.devicename})` : ''}`
439
+ : (event.resourceName || event.resourceId || event.controllerName || event.controllerId)
440
+ incrementCount(counts.byEvent, eventName)
441
+ incrementCount(counts.bySource, sourceName)
442
+ incrementCount(counts.byResource, resourceName)
443
+ ;(Array.isArray(event.objectTypes) ? event.objectTypes : []).forEach(value => incrementCount(counts.byObjectType, value))
444
+ const combination = normalizedKind === 'knx'
445
+ ? `${resourceName || '?'} | ${eventName || '?'} | ${clampText(typeof event.payload === 'object' ? JSON.stringify(event.payload) : event.payload, 160)}`
446
+ : `${resourceName || '?'} | ${eventName || '?'}${event.scopeName ? ` | ${event.scopeName}` : ''}${event.objectTypes && event.objectTypes.length ? ` | ${event.objectTypes.join(',')}` : ''}`
447
+ incrementCount(counts.byCombination, combination)
448
+
449
+ recent.push(event)
450
+ if (recent.length > maxItems) recent.shift()
451
+ if (tokens.length) {
452
+ const haystack = buildEventSearchText(event, normalizedKind)
453
+ let score = 0
454
+ tokens.forEach(token => { if (haystack.includes(token)) score += token.length + 2 })
455
+ if (score > 0) {
456
+ relevant.push({ event, score, ts })
457
+ if (relevant.length > maxItems * 4) {
458
+ relevant.sort((left, right) => right.score - left.score || right.ts - left.ts)
459
+ relevant.length = maxItems * 2
460
+ }
461
+ }
462
+ }
463
+ }
464
+
465
+ const finish = () => {
466
+ let events = recent
467
+ if (relevant.length) {
468
+ const selected = relevant
469
+ .sort((left, right) => right.score - left.score || right.ts - left.ts)
470
+ .slice(0, maxItems)
471
+ .map(item => item.event)
472
+ events = selected.sort((left, right) => Number(left.ts || 0) - Number(right.ts || 0))
473
+ }
474
+ return {
475
+ events,
476
+ summary: {
477
+ kind: normalizedKind,
478
+ totalEvents: total,
479
+ activeEvents: active,
480
+ inactiveEvents: total - active,
481
+ firstAt: firstTs ? new Date(firstTs).toISOString() : '',
482
+ lastAt: lastTs ? new Date(lastTs).toISOString() : '',
483
+ selection: relevant.length ? 'question-relevant' : 'most-recent',
484
+ selectedEvents: events.length,
485
+ byEvent: topCounts(counts.byEvent, 20),
486
+ bySource: topCounts(counts.bySource, 12),
487
+ byResource: topCounts(counts.byResource, 30),
488
+ byObjectType: topCounts(counts.byObjectType, 20),
489
+ byCombination: topCounts(counts.byCombination, 40)
490
+ }
491
+ }
492
+ }
493
+
494
+ return { add, finish }
495
+ }
496
+
497
+ const formatKnxAiAdapterHistoryEventForPrompt = event => {
498
+ if (!event || typeof event !== 'object') return ''
499
+ const resource = event.resourceName || event.resourceId || event.controllerName || event.controllerId || '?'
500
+ const scope = event.scopeName || event.scopeId
501
+ const objects = Array.isArray(event.objectTypes) && event.objectTypes.length ? event.objectTypes.join(',') : ''
502
+ 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}` : ''}`
503
+ }
504
+
505
+ module.exports = {
506
+ KNX_AI_ADAPTER_HISTORY_MIN_HOURS,
507
+ KNX_AI_COMPACT_ARCHIVE_COLUMNS,
508
+ KNX_AI_COMPACT_ARCHIVE_EXTENSION,
509
+ KNX_AI_HISTORY_DETAILS_MAX_CHARS,
510
+ buildKnxAiHistoryEventKey,
511
+ createKnxAiHistoryAccumulator,
512
+ decodeKnxAiCompactValue,
513
+ encodeKnxAiCompactValue,
514
+ formatKnxAiAdapterHistoryEventForPrompt,
515
+ formatKnxAiCompactContextForPrompt,
516
+ formatKnxAiHistorySummaryForPrompt,
517
+ normalizeKnxAiAdapterHistoryEvent,
518
+ parseKnxAiCompactHistoryRecord,
519
+ serializeKnxAiCompactHistoryRecord,
520
+ sanitizeHistoryValue
521
+ }