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

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.
@@ -1,4 +1,4 @@
1
- const CHAT_CONTEXT_VERSION = 1
1
+ const CHAT_CONTEXT_VERSION = 3
2
2
  const CHAT_CONTEXT_MAX_BYTES = 512 * 1024
3
3
  const CHAT_CONTEXT_MAX_SESSIONS = 50
4
4
  const CHAT_CONTEXT_MAX_TURNS_PER_SESSION = 8
@@ -7,6 +7,7 @@ const CHAT_CONTEXT_MAX_CAMERA_WATCHES_PER_SESSION = 20
7
7
  const CHAT_CONTEXT_MAX_QUESTION_CHARS = 4000
8
8
  const CHAT_CONTEXT_MAX_REPLY_CHARS = 8000
9
9
  const CHAT_CONTEXT_MAX_INSTRUCTION_CHARS = 2000
10
+ const CHAT_CONTEXT_NATIVE_HEADER = 'KNXAI_CHAT_CONTEXT'
10
11
 
11
12
  const clampText = (value, maxChars) => String(value === undefined || value === null ? '' : value)
12
13
  .trim()
@@ -126,45 +127,36 @@ const touchSession = (context, sessionId) => {
126
127
  return { target, session }
127
128
  }
128
129
 
129
- const explicitInstructionPatterns = [
130
- /^\s*(?:please[\s,]+)?remember\b/i,
131
- /^\s*from now on\b/i,
132
- /^\s*(?:please[\s,]+)?(?:always|never)\b/i,
133
- /^\s*(?:per favore[\s,]+)?(?:ricordati|ricorda|ricordarsi|memorizza)\b/i,
134
- /^\s*(?:d['’]ora in poi|da ora in poi)\b/i,
135
- /^\s*(?:usa sempre|non usare mai|evita sempre)\b/i,
136
- /^\s*(?:bitte[\s,]+)?(?:merk dir|erinnere dich)\b/i,
137
- /^\s*(?:ab jetzt|von jetzt an)\b/i,
138
- /^\s*(?:s['’]il te pla[iî]t[\s,]+)?(?:souviens-toi|rappelle-toi|m[eé]morise)\b/i,
139
- /^\s*(?:dor[eé]navant|[àa] partir de maintenant)\b/i,
140
- /^\s*(?:por favor[\s,]+)?(?:recuerda|memoriza)\b/i,
141
- /^\s*(?:a partir de ahora|de ahora en adelante)\b/i,
142
- /^\s*(?:请)?记住/,
143
- /^\s*从现在开始/
144
- ]
145
-
146
- const extractExplicitKnxAiChatInstruction = (question) => {
147
- const text = clampText(question, CHAT_CONTEXT_MAX_INSTRUCTION_CHARS)
148
- if (!text) return ''
149
- return explicitInstructionPatterns.some(pattern => pattern.test(text)) ? text : ''
150
- }
151
-
152
130
  const addKnxAiChatTurn = (context, { sessionId, question, reply, at } = {}) => {
153
131
  const { target, session } = touchSession(context, sessionId)
154
132
  const turn = normalizeTurn({ at, question, reply })
155
133
  if (turn) session.turns.push(turn)
156
134
  session.turns = session.turns.slice(-CHAT_CONTEXT_MAX_TURNS_PER_SESSION)
135
+ return target
136
+ }
157
137
 
158
- const explicitInstruction = extractExplicitKnxAiChatInstruction(question)
159
- if (explicitInstruction) {
160
- const normalized = explicitInstruction.toLocaleLowerCase()
161
- session.instructions = session.instructions.filter(item => item.text.toLocaleLowerCase() !== normalized)
162
- session.instructions.push({
163
- at: clampText(at || new Date().toISOString(), 64),
164
- text: explicitInstruction
165
- })
166
- session.instructions = session.instructions.slice(-CHAT_CONTEXT_MAX_INSTRUCTIONS_PER_SESSION)
138
+ const addKnxAiChatInstruction = (context, { sessionId, text, at } = {}) => {
139
+ const instruction = normalizeInstruction({ text, at })
140
+ if (!instruction) return normalizeKnxAiChatContext(context)
141
+ const { target, session } = touchSession(context, sessionId)
142
+ const normalizedText = instruction.text.toLocaleLowerCase()
143
+ session.instructions = session.instructions
144
+ .filter(item => item.text.toLocaleLowerCase() !== normalizedText)
145
+ session.instructions.push(instruction)
146
+ session.instructions = session.instructions.slice(-CHAT_CONTEXT_MAX_INSTRUCTIONS_PER_SESSION)
147
+ return target
148
+ }
149
+
150
+ const removeKnxAiChatInstructions = (context, { sessionId, text, all = false } = {}) => {
151
+ const { target, session } = touchSession(context, sessionId)
152
+ if (all === true) {
153
+ session.instructions = []
154
+ return target
167
155
  }
156
+ const normalizedText = clampText(text, CHAT_CONTEXT_MAX_INSTRUCTION_CHARS).toLocaleLowerCase()
157
+ if (!normalizedText) return target
158
+ session.instructions = session.instructions
159
+ .filter(item => item.text.toLocaleLowerCase() !== normalizedText)
168
160
  return target
169
161
  }
170
162
 
@@ -217,7 +209,7 @@ const buildKnxAiChatPromptContext = ({ context, sessionId, maxChars = 16000 } =
217
209
  if (!session.instructions.length && !session.turns.length && !session.cameraWatches.length) return ''
218
210
  const lines = []
219
211
  if (session.instructions.length) {
220
- lines.push('PERSISTENT CHAT INSTRUCTIONS AND PREFERENCES (explicitly supplied by this user; follow them unless they conflict with safety or the KNX contract; newer entries override older conflicting entries):')
212
+ lines.push('PERSISTENT USER-PROVIDED FACTS, PREFERENCES, AND INSTRUCTIONS (follow relevant entries unless they conflict with safety or the KNX contract; newer entries override older conflicting entries):')
221
213
  session.instructions.forEach(item => lines.push(`- ${item.text.replace(/\r?\n/g, ' ')}`))
222
214
  }
223
215
  if (session.turns.length) {
@@ -241,56 +233,75 @@ const buildKnxAiChatPromptContext = ({ context, sessionId, maxChars = 16000 } =
241
233
  return lines.join('\n').slice(0, Math.max(1000, Number(maxChars) || 16000))
242
234
  }
243
235
 
244
- const escapeMarkdownText = value => clampText(value, CHAT_CONTEXT_MAX_REPLY_CHARS)
245
- .replace(/\r?\n/g, ' ')
246
- .replace(/</g, '&lt;')
247
- .replace(/>/g, '&gt;')
236
+ const escapeKnxAiChatContextField = value => String(value === undefined || value === null ? '' : value)
237
+ .replace(/\\/g, '\\\\')
238
+ .replace(/\t/g, '\\t')
239
+ .replace(/\r/g, '\\r')
240
+ .replace(/\n/g, '\\n')
248
241
 
249
- const renderKnxAiChatContext = (context) => {
242
+ const unescapeKnxAiChatContextField = (value) => {
243
+ const source = String(value === undefined || value === null ? '' : value)
244
+ let result = ''
245
+ for (let index = 0; index < source.length; index += 1) {
246
+ const char = source[index]
247
+ if (char !== '\\' || index + 1 >= source.length) {
248
+ result += char
249
+ continue
250
+ }
251
+ const next = source[index + 1]
252
+ if (next === '\\') result += '\\'
253
+ else if (next === 't') result += '\t'
254
+ else if (next === 'r') result += '\r'
255
+ else if (next === 'n') result += '\n'
256
+ else result += `\\${next}`
257
+ index += 1
258
+ }
259
+ return result
260
+ }
261
+
262
+ const buildKnxAiChatContextRecord = (type, fields = []) => [type]
263
+ .concat(fields.map(escapeKnxAiChatContextField))
264
+ .join('\t')
265
+
266
+ const renderKnxAiChatContextFile = (context) => {
250
267
  const target = normalizeKnxAiChatContext(context)
251
268
  target.updatedAt = new Date().toISOString()
252
- const metadata = Buffer.from(JSON.stringify(target), 'utf8').toString('base64')
253
269
  const lines = [
254
- '<!-- KNX_AI_CHAT_CONTEXT_V1_BASE64',
255
- metadata,
256
- 'KNX_AI_CHAT_CONTEXT_END -->',
257
- '',
258
- '# KNX AI Chat Context',
259
- '',
260
- `Updated: ${target.updatedAt}`,
261
- '',
262
- 'This bounded file stores recent chat turns and explicit long-term instructions separately for each chat session.',
263
- ''
270
+ '# KNX AI native chat-learning context',
271
+ '# Tab-separated records. Escapes: \\\\ (backslash), \\t (tab), \\n (newline), \\r (carriage return).',
272
+ '# Records: SESSION, INSTRUCTION, TURN, CAMERA_WATCH, END_SESSION.',
273
+ buildKnxAiChatContextRecord(CHAT_CONTEXT_NATIVE_HEADER, [CHAT_CONTEXT_VERSION]),
274
+ buildKnxAiChatContextRecord('CREATED_AT', [target.createdAt]),
275
+ buildKnxAiChatContextRecord('UPDATED_AT', [target.updatedAt])
264
276
  ]
265
277
  target.sessions.forEach((session) => {
266
- lines.push(`## Session ${escapeMarkdownText(session.id)}`, '')
267
- lines.push('### Persistent instructions', '')
268
- if (!session.instructions.length) lines.push('_None._')
269
- session.instructions.forEach(item => lines.push(`- ${escapeMarkdownText(item.at)} — ${escapeMarkdownText(item.text)}`))
270
- lines.push('', '### Camera watches', '')
271
- if (!session.cameraWatches.length) lines.push('_None._')
278
+ lines.push(buildKnxAiChatContextRecord('SESSION', [session.id, session.updatedAt]))
279
+ session.instructions.forEach(item => lines.push(buildKnxAiChatContextRecord('INSTRUCTION', [item.at, item.text])))
280
+ session.turns.forEach(turn => lines.push(buildKnxAiChatContextRecord('TURN', [turn.at, turn.question, turn.reply])))
272
281
  session.cameraWatches.forEach((watch) => {
273
- const camera = watch.cameraName || watch.cameraId
274
- const scope = watch.scopeName || watch.scopeId
275
- const objects = watch.objectTypes.length ? `; objects ${watch.objectTypes.join(', ')}` : ''
276
- lines.push(`- ${escapeMarkdownText(watch.id)} — ${escapeMarkdownText(camera)}; event ${escapeMarkdownText(watch.eventType)}${scope ? `; scope ${escapeMarkdownText(scope)}` : ''}${objects}; cooldown ${watch.cooldownSeconds}s`)
277
- })
278
- lines.push('', '### Recent turns', '')
279
- if (!session.turns.length) lines.push('_None._')
280
- session.turns.forEach((turn) => {
281
- lines.push(`- ${escapeMarkdownText(turn.at)} — **User:** ${escapeMarkdownText(turn.question)}`)
282
- lines.push(` **Assistant:** ${escapeMarkdownText(turn.reply)}`)
282
+ lines.push(buildKnxAiChatContextRecord('CAMERA_WATCH', [
283
+ watch.id,
284
+ watch.createdAt,
285
+ watch.cameraId,
286
+ watch.cameraName,
287
+ watch.eventType,
288
+ watch.scopeId,
289
+ watch.scopeName,
290
+ watch.cooldownSeconds,
291
+ watch.sendSnapshot ? 'true' : 'false',
292
+ watch.language
293
+ ].concat(watch.objectTypes)))
283
294
  })
284
- lines.push('')
295
+ lines.push('END_SESSION')
285
296
  })
286
- return { markdown: lines.join('\n'), context: target }
297
+ return { content: `${lines.join('\n')}\n`, context: target }
287
298
  }
288
299
 
289
- const buildKnxAiChatContextMarkdown = ({ context, maxBytes = CHAT_CONTEXT_MAX_BYTES } = {}) => {
300
+ const buildKnxAiChatContextFile = ({ context, maxBytes = CHAT_CONTEXT_MAX_BYTES } = {}) => {
290
301
  const targetBytes = Math.max(64 * 1024, Math.min(CHAT_CONTEXT_MAX_BYTES, Number(maxBytes) || CHAT_CONTEXT_MAX_BYTES))
291
302
  let bounded = normalizeKnxAiChatContext(context)
292
- let rendered = renderKnxAiChatContext(bounded)
293
- while (Buffer.byteLength(rendered.markdown, 'utf8') > targetBytes) {
303
+ let rendered = renderKnxAiChatContextFile(bounded)
304
+ while (Buffer.byteLength(rendered.content, 'utf8') > targetBytes) {
294
305
  const sessionWithTurns = bounded.sessions.find(session => session.turns.length > 0)
295
306
  if (sessionWithTurns) sessionWithTurns.turns.shift()
296
307
  else if (bounded.sessions.length > 1) bounded.sessions.shift()
@@ -299,24 +310,108 @@ const buildKnxAiChatContextMarkdown = ({ context, maxBytes = CHAT_CONTEXT_MAX_BY
299
310
  if (!sessionWithInstructions) break
300
311
  sessionWithInstructions.instructions.shift()
301
312
  }
302
- rendered = renderKnxAiChatContext(bounded)
313
+ rendered = renderKnxAiChatContextFile(bounded)
303
314
  bounded = rendered.context
304
315
  }
305
316
  return {
306
- markdown: rendered.markdown,
317
+ content: rendered.content,
307
318
  context: rendered.context,
308
- bytes: Buffer.byteLength(rendered.markdown, 'utf8'),
319
+ bytes: Buffer.byteLength(rendered.content, 'utf8'),
309
320
  maxBytes: targetBytes
310
321
  }
311
322
  }
312
323
 
313
- const parseKnxAiChatContextMarkdown = (markdown) => {
314
- const text = String(markdown || '')
315
- const match = text.match(/<!-- KNX_AI_CHAT_CONTEXT_V1_BASE64\s*\n([A-Za-z0-9+/=\r\n]+?)\nKNX_AI_CHAT_CONTEXT_END -->/)
316
- if (!match) return createEmptyKnxAiChatContext()
324
+ const parseKnxAiChatContextFileStrict = (content) => {
325
+ const source = String(content || '')
326
+ const context = { version: CHAT_CONTEXT_VERSION, createdAt: '', updatedAt: '', sessions: [] }
327
+ let headerSeen = false
328
+ let currentSession = null
329
+
330
+ source.split(/\r?\n/).forEach((line, lineIndex) => {
331
+ if (!line.trim() || line.trimStart().startsWith('#')) return
332
+ const fields = line.split('\t').map(unescapeKnxAiChatContextField)
333
+ const record = fields.shift()
334
+ const fail = message => { throw new Error(`Invalid KNX AI native chat-learning context at line ${lineIndex + 1}: ${message}`) }
335
+
336
+ if (!headerSeen) {
337
+ if (record !== CHAT_CONTEXT_NATIVE_HEADER || String(fields[0] || '') !== String(CHAT_CONTEXT_VERSION)) {
338
+ fail(`expected ${CHAT_CONTEXT_NATIVE_HEADER} ${CHAT_CONTEXT_VERSION} header`)
339
+ }
340
+ headerSeen = true
341
+ return
342
+ }
343
+
344
+ if (record === 'CREATED_AT') {
345
+ if (currentSession) fail('CREATED_AT is not allowed inside a session')
346
+ context.createdAt = fields[0] || ''
347
+ return
348
+ }
349
+ if (record === 'UPDATED_AT') {
350
+ if (currentSession) fail('UPDATED_AT is not allowed inside a session')
351
+ context.updatedAt = fields[0] || ''
352
+ return
353
+ }
354
+ if (record === 'SESSION') {
355
+ if (currentSession) fail('nested SESSION record')
356
+ if (!String(fields[0] || '').trim()) fail('SESSION id is required')
357
+ currentSession = {
358
+ id: fields[0],
359
+ updatedAt: fields[1] || '',
360
+ turns: [],
361
+ instructions: [],
362
+ cameraWatches: []
363
+ }
364
+ return
365
+ }
366
+ if (record === 'END_SESSION') {
367
+ if (!currentSession) fail('END_SESSION without SESSION')
368
+ context.sessions.push(currentSession)
369
+ currentSession = null
370
+ return
371
+ }
372
+ if (!currentSession) fail(`${record || 'empty record'} is not allowed outside a session`)
373
+ if (record === 'INSTRUCTION') {
374
+ if (fields.length < 2 || !String(fields[1] || '').trim()) fail('INSTRUCTION requires timestamp and text')
375
+ currentSession.instructions.push({ at: fields[0], text: fields[1] })
376
+ return
377
+ }
378
+ if (record === 'TURN') {
379
+ if (fields.length < 3 || (!String(fields[1] || '').trim() && !String(fields[2] || '').trim())) {
380
+ fail('TURN requires timestamp, question and reply')
381
+ }
382
+ currentSession.turns.push({ at: fields[0], question: fields[1], reply: fields[2] })
383
+ return
384
+ }
385
+ if (record === 'CAMERA_WATCH') {
386
+ if (fields.length < 10) fail('CAMERA_WATCH has missing fields')
387
+ if (fields[8] !== 'true' && fields[8] !== 'false') fail('CAMERA_WATCH sendSnapshot must be true or false')
388
+ currentSession.cameraWatches.push({
389
+ id: fields[0],
390
+ createdAt: fields[1],
391
+ cameraId: fields[2],
392
+ cameraName: fields[3],
393
+ eventType: fields[4],
394
+ scopeId: fields[5],
395
+ scopeName: fields[6],
396
+ cooldownSeconds: Number(fields[7]),
397
+ sendSnapshot: fields[8] === 'true',
398
+ language: fields[9],
399
+ objectTypes: fields.slice(10)
400
+ })
401
+ return
402
+ }
403
+ fail(`unknown ${record || 'empty'} record`)
404
+ })
405
+
406
+ if (!headerSeen) throw new Error(`The file does not contain a ${CHAT_CONTEXT_NATIVE_HEADER} ${CHAT_CONTEXT_VERSION} header`)
407
+ if (currentSession) throw new Error('Invalid KNX AI native chat-learning context: SESSION without END_SESSION')
408
+ if (!context.createdAt || !context.updatedAt) throw new Error('Invalid KNX AI native chat-learning context: CREATED_AT and UPDATED_AT are required')
409
+ return normalizeKnxAiChatContext(context)
410
+ }
411
+
412
+ const parseKnxAiChatContextFile = (content) => {
317
413
  try {
318
- const metadata = Buffer.from(match[1].replace(/\s+/g, ''), 'base64').toString('utf8')
319
- return normalizeKnxAiChatContext(JSON.parse(metadata))
414
+ return parseKnxAiChatContextFileStrict(content)
320
415
  } catch (error) {
321
416
  return createEmptyKnxAiChatContext()
322
417
  }
@@ -340,18 +435,20 @@ module.exports = {
340
435
  CHAT_CONTEXT_MAX_SESSIONS,
341
436
  CHAT_CONTEXT_MAX_TURNS_PER_SESSION,
342
437
  addKnxAiCameraWatch,
438
+ addKnxAiChatInstruction,
343
439
  addKnxAiChatTurn,
344
- buildKnxAiChatContextMarkdown,
440
+ buildKnxAiChatContextFile,
345
441
  buildKnxAiChatPromptContext,
346
442
  clearKnxAiChatSession,
347
443
  conversationMapFromKnxAiChatContext,
348
444
  createEmptyKnxAiChatContext,
349
- extractExplicitKnxAiChatInstruction,
350
445
  getKnxAiChatSession,
351
446
  listAllKnxAiCameraWatches,
352
447
  listKnxAiCameraWatches,
353
448
  normalizeKnxAiChatContext,
354
449
  normalizeCameraWatch,
355
- parseKnxAiChatContextMarkdown,
356
- removeKnxAiCameraWatches
450
+ parseKnxAiChatContextFile,
451
+ parseKnxAiChatContextFileStrict,
452
+ removeKnxAiCameraWatches,
453
+ removeKnxAiChatInstructions
357
454
  }
@@ -1,5 +1,19 @@
1
1
  const KNX_AI_ADAPTER_HISTORY_MIN_HOURS = 24
2
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
+ })
3
17
 
4
18
  const clampText = (value, maxChars = 500) => String(value === undefined || value === null ? '' : value)
5
19
  .trim()
@@ -42,6 +56,259 @@ const sanitizeHistoryValue = (value, depth = 0, seen = new Set()) => {
42
56
  return out
43
57
  }
44
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
+
45
312
  const normalizeKnxAiAdapterHistoryEvent = ({ event, adapter, provider, nowTs = Date.now() } = {}) => {
46
313
  const source = event && typeof event === 'object' && !Array.isArray(event) ? event : {}
47
314
  const adapterSource = adapter && typeof adapter === 'object' ? adapter : {}
@@ -237,10 +504,18 @@ const formatKnxAiAdapterHistoryEventForPrompt = event => {
237
504
 
238
505
  module.exports = {
239
506
  KNX_AI_ADAPTER_HISTORY_MIN_HOURS,
507
+ KNX_AI_COMPACT_ARCHIVE_COLUMNS,
508
+ KNX_AI_COMPACT_ARCHIVE_EXTENSION,
240
509
  KNX_AI_HISTORY_DETAILS_MAX_CHARS,
241
510
  buildKnxAiHistoryEventKey,
242
511
  createKnxAiHistoryAccumulator,
512
+ decodeKnxAiCompactValue,
513
+ encodeKnxAiCompactValue,
243
514
  formatKnxAiAdapterHistoryEventForPrompt,
515
+ formatKnxAiCompactContextForPrompt,
516
+ formatKnxAiHistorySummaryForPrompt,
244
517
  normalizeKnxAiAdapterHistoryEvent,
518
+ parseKnxAiCompactHistoryRecord,
519
+ serializeKnxAiCompactHistoryRecord,
245
520
  sanitizeHistoryValue
246
521
  }
package/package.json CHANGED
@@ -3,7 +3,7 @@
3
3
  "engines": {
4
4
  "node": ">=20.18.1"
5
5
  },
6
- "version": "6.3.21",
6
+ "version": "6.3.24",
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/",