node-red-contrib-knx-ultimate 6.3.14 → 6.3.15

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.
@@ -0,0 +1,277 @@
1
+ const CHAT_CONTEXT_VERSION = 1
2
+ const CHAT_CONTEXT_MAX_BYTES = 512 * 1024
3
+ const CHAT_CONTEXT_MAX_SESSIONS = 50
4
+ const CHAT_CONTEXT_MAX_TURNS_PER_SESSION = 8
5
+ const CHAT_CONTEXT_MAX_INSTRUCTIONS_PER_SESSION = 20
6
+ const CHAT_CONTEXT_MAX_QUESTION_CHARS = 4000
7
+ const CHAT_CONTEXT_MAX_REPLY_CHARS = 8000
8
+ const CHAT_CONTEXT_MAX_INSTRUCTION_CHARS = 2000
9
+
10
+ const clampText = (value, maxChars) => String(value === undefined || value === null ? '' : value)
11
+ .trim()
12
+ .slice(0, Math.max(0, Number(maxChars) || 0))
13
+
14
+ const normalizeSessionId = value => clampText(value || 'default', 160) || 'default'
15
+
16
+ const createEmptyKnxAiChatContext = () => ({
17
+ version: CHAT_CONTEXT_VERSION,
18
+ createdAt: new Date().toISOString(),
19
+ updatedAt: new Date().toISOString(),
20
+ sessions: []
21
+ })
22
+
23
+ const normalizeTurn = (turn) => {
24
+ if (!turn || typeof turn !== 'object' || Array.isArray(turn)) return null
25
+ const question = clampText(turn.question, CHAT_CONTEXT_MAX_QUESTION_CHARS)
26
+ const reply = clampText(turn.reply, CHAT_CONTEXT_MAX_REPLY_CHARS)
27
+ if (!question && !reply) return null
28
+ return {
29
+ at: clampText(turn.at || new Date().toISOString(), 64),
30
+ question,
31
+ reply
32
+ }
33
+ }
34
+
35
+ const normalizeInstruction = (instruction) => {
36
+ if (!instruction || typeof instruction !== 'object' || Array.isArray(instruction)) return null
37
+ const text = clampText(instruction.text, CHAT_CONTEXT_MAX_INSTRUCTION_CHARS)
38
+ if (!text) return null
39
+ return {
40
+ at: clampText(instruction.at || new Date().toISOString(), 64),
41
+ text
42
+ }
43
+ }
44
+
45
+ const normalizeSession = (session) => {
46
+ if (!session || typeof session !== 'object' || Array.isArray(session)) return null
47
+ const id = normalizeSessionId(session.id)
48
+ const turns = (Array.isArray(session.turns) ? session.turns : [])
49
+ .map(normalizeTurn)
50
+ .filter(Boolean)
51
+ .slice(-CHAT_CONTEXT_MAX_TURNS_PER_SESSION)
52
+ const instructions = (Array.isArray(session.instructions) ? session.instructions : [])
53
+ .map(normalizeInstruction)
54
+ .filter(Boolean)
55
+ .slice(-CHAT_CONTEXT_MAX_INSTRUCTIONS_PER_SESSION)
56
+ return {
57
+ id,
58
+ updatedAt: clampText(session.updatedAt || new Date().toISOString(), 64),
59
+ turns,
60
+ instructions
61
+ }
62
+ }
63
+
64
+ const normalizeKnxAiChatContext = (value = {}) => {
65
+ const source = value && typeof value === 'object' && !Array.isArray(value) ? value : {}
66
+ const byId = new Map()
67
+ ;(Array.isArray(source.sessions) ? source.sessions : []).forEach((item) => {
68
+ const session = normalizeSession(item)
69
+ if (!session) return
70
+ byId.delete(session.id)
71
+ byId.set(session.id, session)
72
+ })
73
+ return {
74
+ version: CHAT_CONTEXT_VERSION,
75
+ createdAt: clampText(source.createdAt || new Date().toISOString(), 64),
76
+ updatedAt: clampText(source.updatedAt || new Date().toISOString(), 64),
77
+ sessions: Array.from(byId.values()).slice(-CHAT_CONTEXT_MAX_SESSIONS)
78
+ }
79
+ }
80
+
81
+ const findSession = (context, sessionId) => {
82
+ const id = normalizeSessionId(sessionId)
83
+ return normalizeKnxAiChatContext(context).sessions.find(session => session.id === id) || null
84
+ }
85
+
86
+ const touchSession = (context, sessionId) => {
87
+ const target = normalizeKnxAiChatContext(context)
88
+ const id = normalizeSessionId(sessionId)
89
+ const existing = target.sessions.find(session => session.id === id)
90
+ const session = existing || { id, updatedAt: new Date().toISOString(), turns: [], instructions: [] }
91
+ target.sessions = target.sessions.filter(item => item.id !== id)
92
+ target.sessions.push(session)
93
+ target.sessions = target.sessions.slice(-CHAT_CONTEXT_MAX_SESSIONS)
94
+ target.updatedAt = new Date().toISOString()
95
+ session.updatedAt = target.updatedAt
96
+ return { target, session }
97
+ }
98
+
99
+ const explicitInstructionPatterns = [
100
+ /^\s*(?:please[\s,]+)?remember\b/i,
101
+ /^\s*from now on\b/i,
102
+ /^\s*(?:please[\s,]+)?(?:always|never)\b/i,
103
+ /^\s*(?:per favore[\s,]+)?(?:ricordati|ricorda|ricordarsi|memorizza)\b/i,
104
+ /^\s*(?:d['’]ora in poi|da ora in poi)\b/i,
105
+ /^\s*(?:usa sempre|non usare mai|evita sempre)\b/i,
106
+ /^\s*(?:bitte[\s,]+)?(?:merk dir|erinnere dich)\b/i,
107
+ /^\s*(?:ab jetzt|von jetzt an)\b/i,
108
+ /^\s*(?:s['’]il te pla[iî]t[\s,]+)?(?:souviens-toi|rappelle-toi|m[eé]morise)\b/i,
109
+ /^\s*(?:dor[eé]navant|[àa] partir de maintenant)\b/i,
110
+ /^\s*(?:por favor[\s,]+)?(?:recuerda|memoriza)\b/i,
111
+ /^\s*(?:a partir de ahora|de ahora en adelante)\b/i,
112
+ /^\s*(?:请)?记住/,
113
+ /^\s*从现在开始/
114
+ ]
115
+
116
+ const extractExplicitKnxAiChatInstruction = (question) => {
117
+ const text = clampText(question, CHAT_CONTEXT_MAX_INSTRUCTION_CHARS)
118
+ if (!text) return ''
119
+ return explicitInstructionPatterns.some(pattern => pattern.test(text)) ? text : ''
120
+ }
121
+
122
+ const addKnxAiChatTurn = (context, { sessionId, question, reply, at } = {}) => {
123
+ const { target, session } = touchSession(context, sessionId)
124
+ const turn = normalizeTurn({ at, question, reply })
125
+ if (turn) session.turns.push(turn)
126
+ session.turns = session.turns.slice(-CHAT_CONTEXT_MAX_TURNS_PER_SESSION)
127
+
128
+ const explicitInstruction = extractExplicitKnxAiChatInstruction(question)
129
+ if (explicitInstruction) {
130
+ const normalized = explicitInstruction.toLocaleLowerCase()
131
+ session.instructions = session.instructions.filter(item => item.text.toLocaleLowerCase() !== normalized)
132
+ session.instructions.push({
133
+ at: clampText(at || new Date().toISOString(), 64),
134
+ text: explicitInstruction
135
+ })
136
+ session.instructions = session.instructions.slice(-CHAT_CONTEXT_MAX_INSTRUCTIONS_PER_SESSION)
137
+ }
138
+ return target
139
+ }
140
+
141
+ const clearKnxAiChatSession = (context, sessionId) => {
142
+ const target = normalizeKnxAiChatContext(context)
143
+ const id = normalizeSessionId(sessionId)
144
+ target.sessions = target.sessions.filter(session => session.id !== id)
145
+ target.updatedAt = new Date().toISOString()
146
+ return target
147
+ }
148
+
149
+ const getKnxAiChatSession = (context, sessionId) => {
150
+ const session = findSession(context, sessionId)
151
+ return session || {
152
+ id: normalizeSessionId(sessionId),
153
+ updatedAt: '',
154
+ turns: [],
155
+ instructions: []
156
+ }
157
+ }
158
+
159
+ const buildKnxAiChatPromptContext = ({ context, sessionId, maxChars = 16000 } = {}) => {
160
+ const session = getKnxAiChatSession(context, sessionId)
161
+ if (!session.instructions.length && !session.turns.length) return ''
162
+ const lines = []
163
+ if (session.instructions.length) {
164
+ 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):')
165
+ session.instructions.forEach(item => lines.push(`- ${item.text.replace(/\r?\n/g, ' ')}`))
166
+ }
167
+ if (session.turns.length) {
168
+ if (lines.length) lines.push('')
169
+ lines.push('RECENT CONVERSATION:')
170
+ session.turns.forEach((turn) => {
171
+ lines.push(`User: ${turn.question}`)
172
+ lines.push(`Assistant: ${turn.reply}`)
173
+ })
174
+ }
175
+ return lines.join('\n').slice(0, Math.max(1000, Number(maxChars) || 16000))
176
+ }
177
+
178
+ const escapeMarkdownText = value => clampText(value, CHAT_CONTEXT_MAX_REPLY_CHARS)
179
+ .replace(/\r?\n/g, ' ')
180
+ .replace(/</g, '&lt;')
181
+ .replace(/>/g, '&gt;')
182
+
183
+ const renderKnxAiChatContext = (context) => {
184
+ const target = normalizeKnxAiChatContext(context)
185
+ target.updatedAt = new Date().toISOString()
186
+ const metadata = Buffer.from(JSON.stringify(target), 'utf8').toString('base64')
187
+ const lines = [
188
+ '<!-- KNX_AI_CHAT_CONTEXT_V1_BASE64',
189
+ metadata,
190
+ 'KNX_AI_CHAT_CONTEXT_END -->',
191
+ '',
192
+ '# KNX AI Chat Context',
193
+ '',
194
+ `Updated: ${target.updatedAt}`,
195
+ '',
196
+ 'This bounded file stores recent chat turns and explicit long-term instructions separately for each chat session.',
197
+ ''
198
+ ]
199
+ target.sessions.forEach((session) => {
200
+ lines.push(`## Session ${escapeMarkdownText(session.id)}`, '')
201
+ lines.push('### Persistent instructions', '')
202
+ if (!session.instructions.length) lines.push('_None._')
203
+ session.instructions.forEach(item => lines.push(`- ${escapeMarkdownText(item.at)} — ${escapeMarkdownText(item.text)}`))
204
+ lines.push('', '### Recent turns', '')
205
+ if (!session.turns.length) lines.push('_None._')
206
+ session.turns.forEach((turn) => {
207
+ lines.push(`- ${escapeMarkdownText(turn.at)} — **User:** ${escapeMarkdownText(turn.question)}`)
208
+ lines.push(` **Assistant:** ${escapeMarkdownText(turn.reply)}`)
209
+ })
210
+ lines.push('')
211
+ })
212
+ return { markdown: lines.join('\n'), context: target }
213
+ }
214
+
215
+ const buildKnxAiChatContextMarkdown = ({ context, maxBytes = CHAT_CONTEXT_MAX_BYTES } = {}) => {
216
+ const targetBytes = Math.max(64 * 1024, Math.min(CHAT_CONTEXT_MAX_BYTES, Number(maxBytes) || CHAT_CONTEXT_MAX_BYTES))
217
+ let bounded = normalizeKnxAiChatContext(context)
218
+ let rendered = renderKnxAiChatContext(bounded)
219
+ while (Buffer.byteLength(rendered.markdown, 'utf8') > targetBytes) {
220
+ const sessionWithTurns = bounded.sessions.find(session => session.turns.length > 0)
221
+ if (sessionWithTurns) sessionWithTurns.turns.shift()
222
+ else if (bounded.sessions.length > 1) bounded.sessions.shift()
223
+ else {
224
+ const sessionWithInstructions = bounded.sessions.find(session => session.instructions.length > 0)
225
+ if (!sessionWithInstructions) break
226
+ sessionWithInstructions.instructions.shift()
227
+ }
228
+ rendered = renderKnxAiChatContext(bounded)
229
+ bounded = rendered.context
230
+ }
231
+ return {
232
+ markdown: rendered.markdown,
233
+ context: rendered.context,
234
+ bytes: Buffer.byteLength(rendered.markdown, 'utf8'),
235
+ maxBytes: targetBytes
236
+ }
237
+ }
238
+
239
+ const parseKnxAiChatContextMarkdown = (markdown) => {
240
+ const text = String(markdown || '')
241
+ const match = text.match(/<!-- KNX_AI_CHAT_CONTEXT_V1_BASE64\s*\n([A-Za-z0-9+/=\r\n]+?)\nKNX_AI_CHAT_CONTEXT_END -->/)
242
+ if (!match) return createEmptyKnxAiChatContext()
243
+ try {
244
+ const metadata = Buffer.from(match[1].replace(/\s+/g, ''), 'base64').toString('utf8')
245
+ return normalizeKnxAiChatContext(JSON.parse(metadata))
246
+ } catch (error) {
247
+ return createEmptyKnxAiChatContext()
248
+ }
249
+ }
250
+
251
+ const conversationMapFromKnxAiChatContext = (context) => {
252
+ const result = new Map()
253
+ normalizeKnxAiChatContext(context).sessions.forEach((session) => {
254
+ result.set(session.id, session.turns.map(turn => ({
255
+ question: turn.question,
256
+ reply: turn.reply
257
+ })))
258
+ })
259
+ return result
260
+ }
261
+
262
+ module.exports = {
263
+ CHAT_CONTEXT_MAX_BYTES,
264
+ CHAT_CONTEXT_MAX_INSTRUCTIONS_PER_SESSION,
265
+ CHAT_CONTEXT_MAX_SESSIONS,
266
+ CHAT_CONTEXT_MAX_TURNS_PER_SESSION,
267
+ addKnxAiChatTurn,
268
+ buildKnxAiChatContextMarkdown,
269
+ buildKnxAiChatPromptContext,
270
+ clearKnxAiChatSession,
271
+ conversationMapFromKnxAiChatContext,
272
+ createEmptyKnxAiChatContext,
273
+ extractExplicitKnxAiChatInstruction,
274
+ getKnxAiChatSession,
275
+ normalizeKnxAiChatContext,
276
+ parseKnxAiChatContextMarkdown
277
+ }
@@ -1,7 +1,7 @@
1
1
  const HOME_MEMORY_VERSION = 1
2
2
  const HOME_MEMORY_MIN_KB = 64
3
- const HOME_MEMORY_MAX_KB = 1024
4
- const HOME_MEMORY_DEFAULT_KB = 256
3
+ const HOME_MEMORY_MAX_KB = 5120
4
+ const HOME_MEMORY_DEFAULT_KB = 5120
5
5
  const HOME_MEMORY_MAX_EDUCATION_CHARS = 16000
6
6
  const HOME_MEMORY_MAX_OBSERVATIONS = 120
7
7
  const HOME_MEMORY_MAX_HABITS = 80
package/package.json CHANGED
@@ -3,7 +3,7 @@
3
3
  "engines": {
4
4
  "node": ">=20.18.1"
5
5
  },
6
- "version": "6.3.14",
6
+ "version": "6.3.15",
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/",
@@ -74,6 +74,95 @@ if (confirmation && confirmation.required === true && Array.isArray(confirmation
74
74
  }
75
75
 
76
76
  msg.payload = telegramPayload;
77
+ return msg;`
78
+ },
79
+ {
80
+ id: 'redbot-telegram',
81
+ title: 'RedBot / node-red-contrib-chatbot (Telegram)',
82
+ inputCode: `// RedBot Telegram Receiver -> KNX AI
83
+ // RedBot normalizes Telegram text and inline-button postbacks in msg.payload.
84
+ const redbot = msg.payload;
85
+ if (!redbot || typeof redbot !== 'object') return;
86
+ if (redbot.transport && redbot.transport !== 'telegram') return;
87
+
88
+ const chatId = redbot.chatId;
89
+ const content = typeof redbot.content === 'string' ? redbot.content.trim() : '';
90
+ if (chatId === undefined || chatId === null || content === '') return;
91
+ if (redbot.type !== 'message') return;
92
+
93
+ msg.sessionId = String(chatId);
94
+ msg.language =
95
+ (msg.originalMessage && msg.originalMessage.from && msg.originalMessage.from.language_code) ||
96
+ redbot.language ||
97
+ msg.language ||
98
+ '';
99
+
100
+ const action = content.toLowerCase();
101
+ if (action === 'confirm' || action === 'cancel') {
102
+ msg.topic = action;
103
+ msg.knxAi = Object.assign({}, msg.knxAi, {
104
+ sessionId: String(chatId),
105
+ confirm: action === 'confirm'
106
+ });
107
+ return msg;
108
+ }
109
+
110
+ msg.topic = 'ask';
111
+ msg.prompt = content;
112
+ return msg;`,
113
+ outputCode: `// KNX AI chat output -> RedBot Telegram Sender
114
+ // Preserves RedBot conversation helpers and emits either a plain message
115
+ // or inline postback buttons for KNX confirmation/cancellation.
116
+ const source = msg.inputMessage && typeof msg.inputMessage === 'object'
117
+ ? msg.inputMessage
118
+ : inputMessage;
119
+ const sourcePayload = source && source.payload && typeof source.payload === 'object'
120
+ ? source.payload
121
+ : {};
122
+ const originalMessage = source && source.originalMessage && typeof source.originalMessage === 'object'
123
+ ? source.originalMessage
124
+ : {};
125
+ const chatId = sourcePayload.chatId !== undefined
126
+ ? sourcePayload.chatId
127
+ : originalMessage.chat && originalMessage.chat.id;
128
+ if (chatId === undefined || chatId === null) return;
129
+
130
+ // RedBot's Sender uses these helpers to resolve the active bot and chat context.
131
+ ['originalMessage', 'chat', 'api', 'client', 'redBot'].forEach(function (key) {
132
+ if (msg[key] === undefined && source && source[key] !== undefined) {
133
+ msg[key] = source[key];
134
+ }
135
+ });
136
+ if (typeof msg.chat !== 'function' || typeof msg.client !== 'function') return;
137
+
138
+ let content = msg.payload;
139
+ if (content && typeof content === 'object') {
140
+ content = content.error || content.message || JSON.stringify(content);
141
+ }
142
+ content = String(content === undefined || content === null ? '' : content);
143
+
144
+ const redbotPayload = {
145
+ transport: sourcePayload.transport || 'telegram',
146
+ chatId: chatId,
147
+ type: 'message',
148
+ inbound: false,
149
+ content: content
150
+ };
151
+ if (sourcePayload.userId !== undefined) redbotPayload.userId = sourcePayload.userId;
152
+
153
+ const confirmation = msg.knxAi && msg.knxAi.confirmationRequest;
154
+ if (confirmation && confirmation.required === true && Array.isArray(confirmation.actions)) {
155
+ redbotPayload.type = 'inline-buttons';
156
+ redbotPayload.buttons = confirmation.actions.map(function (action) {
157
+ return {
158
+ type: 'postback',
159
+ label: action.label,
160
+ value: action.callbackData
161
+ };
162
+ });
163
+ }
164
+
165
+ msg.payload = redbotPayload;
77
166
  return msg;`
78
167
  }
79
168
  ]