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.
- package/CHANGELOG.md +24 -0
- package/examples/KNX AI - Telegrambot Direct Chat.json +1 -17
- package/nodes/knxUltimateAI.html +218 -23
- package/nodes/knxUltimateAI.js +930 -244
- package/nodes/locales/de/knxUltimateAI.html +28 -8
- package/nodes/locales/de/knxUltimateAI.json +24 -4
- package/nodes/locales/en/knxUltimateAI.html +28 -8
- package/nodes/locales/en/knxUltimateAI.json +24 -4
- package/nodes/locales/es/knxUltimateAI.html +28 -8
- package/nodes/locales/es/knxUltimateAI.json +24 -4
- package/nodes/locales/fr/knxUltimateAI.html +28 -8
- package/nodes/locales/fr/knxUltimateAI.json +24 -4
- package/nodes/locales/it/knxUltimateAI.html +28 -8
- package/nodes/locales/it/knxUltimateAI.json +24 -4
- package/nodes/locales/zh-CN/knxUltimateAI.html +28 -8
- package/nodes/locales/zh-CN/knxUltimateAI.json +24 -4
- package/nodes/plugins/knxUltimateAI-vue/assets/app.css +1 -1
- package/nodes/plugins/knxUltimateAI-vue/assets/app.js +4 -4
- package/nodes/utils/knxAiChatContext.js +181 -84
- package/nodes/utils/knxAiEventHistory.js +275 -0
- package/package.json +1 -1
- package/resources/KNXAIChatAdapterMappings.js +14 -12
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
const CHAT_CONTEXT_VERSION =
|
|
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
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
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
|
|
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
|
|
245
|
-
.replace(
|
|
246
|
-
.replace(
|
|
247
|
-
.replace(
|
|
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
|
|
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
|
-
'
|
|
255
|
-
|
|
256
|
-
'
|
|
257
|
-
|
|
258
|
-
'
|
|
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(
|
|
267
|
-
lines.push('
|
|
268
|
-
|
|
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
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
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 {
|
|
297
|
+
return { content: `${lines.join('\n')}\n`, context: target }
|
|
287
298
|
}
|
|
288
299
|
|
|
289
|
-
const
|
|
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 =
|
|
293
|
-
while (Buffer.byteLength(rendered.
|
|
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 =
|
|
313
|
+
rendered = renderKnxAiChatContextFile(bounded)
|
|
303
314
|
bounded = rendered.context
|
|
304
315
|
}
|
|
305
316
|
return {
|
|
306
|
-
|
|
317
|
+
content: rendered.content,
|
|
307
318
|
context: rendered.context,
|
|
308
|
-
bytes: Buffer.byteLength(rendered.
|
|
319
|
+
bytes: Buffer.byteLength(rendered.content, 'utf8'),
|
|
309
320
|
maxBytes: targetBytes
|
|
310
321
|
}
|
|
311
322
|
}
|
|
312
323
|
|
|
313
|
-
const
|
|
314
|
-
const
|
|
315
|
-
const
|
|
316
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
356
|
-
|
|
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.
|
|
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/",
|