node-red-contrib-knx-ultimate 6.3.29 → 6.3.32

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,349 @@
1
+ const KNX_AI_CATALOG_MAX_RESEARCH_ROUNDS = 1
2
+ const KNX_AI_CATALOG_MAX_ACTIONS_PER_ROUND = 4
3
+ const KNX_AI_CATALOG_MAX_RESULTS_PER_ACTION = 12
4
+ const KNX_AI_CATALOG_MAX_ACCUMULATED_OBJECTS = 24
5
+
6
+ const normalizeText = (value) => String(value || '')
7
+ .normalize('NFKD')
8
+ .replace(/[\u0300-\u036f]/g, '')
9
+ .toLowerCase()
10
+ .replace(/[^\p{L}\p{N}/]+/gu, ' ')
11
+ .replace(/\s+/g, ' ')
12
+ .trim()
13
+
14
+ const normalizeGa = (value) => String(value || '').trim()
15
+
16
+ const clampLimit = (value) => Math.max(1, Math.min(
17
+ KNX_AI_CATALOG_MAX_RESULTS_PER_ACTION,
18
+ Math.round(Number(value) || 8)
19
+ ))
20
+
21
+ const normalizeStringList = (value, maxItems = 12) => Array.from(new Set(
22
+ (Array.isArray(value) ? value : [])
23
+ .map(item => String(item || '').trim())
24
+ .filter(Boolean)
25
+ )).slice(0, maxItems)
26
+
27
+ const normalizeKnxAiCatalogActions = (value, { maxActions = KNX_AI_CATALOG_MAX_ACTIONS_PER_ROUND } = {}) => {
28
+ const accepted = []
29
+ const allowedOperations = new Set(['search', 'get', 'list_areas', 'browse_area', 'related'])
30
+ const allowedAccess = new Set(['any', 'read-only', 'read-write'])
31
+ const allowedPurposes = new Set(['any', 'read', 'write', 'inspect'])
32
+ ;(Array.isArray(value) ? value : []).slice(0, Math.max(1, Number(maxActions) || 1)).forEach(candidate => {
33
+ const source = candidate && typeof candidate === 'object' && !Array.isArray(candidate) ? candidate : {}
34
+ const operation = String(source.operation || '').trim().toLowerCase()
35
+ if (!allowedOperations.has(operation)) return
36
+ const query = String(source.query || '').trim().slice(0, 300)
37
+ const destinations = normalizeStringList(source.destinations, 20)
38
+ const area = String(source.area || '').trim().slice(0, 300)
39
+ if (operation === 'search' && !query) return
40
+ if ((operation === 'get' || operation === 'related') && !destinations.length) return
41
+ if (operation === 'browse_area' && !area) return
42
+ const semanticKinds = normalizeStringList(source.semanticKinds, 12)
43
+ .map(kind => normalizeText(kind))
44
+ .filter(Boolean)
45
+ const requestedAccess = String(source.access || '').trim().toLowerCase()
46
+ const requestedPurpose = String(source.purpose || '').trim().toLowerCase()
47
+ accepted.push({
48
+ operation,
49
+ query,
50
+ destinations,
51
+ area,
52
+ semanticKinds,
53
+ access: allowedAccess.has(requestedAccess) ? requestedAccess : 'any',
54
+ purpose: allowedPurposes.has(requestedPurpose) ? requestedPurpose : 'any',
55
+ offset: Math.max(0, Math.min(100000, Math.round(Number(source.offset) || 0))),
56
+ limit: clampLimit(source.limit),
57
+ reason: String(source.reason || '').trim().slice(0, 1000)
58
+ })
59
+ })
60
+ return accepted
61
+ }
62
+
63
+ const editDistanceAtMostTwo = (left, right) => {
64
+ if (left === right) return 0
65
+ if (!left || !right || Math.abs(left.length - right.length) > 2) return 3
66
+ let previous = Array.from({ length: right.length + 1 }, (_, index) => index)
67
+ for (let i = 1; i <= left.length; i += 1) {
68
+ const current = [i]
69
+ let rowMinimum = current[0]
70
+ for (let j = 1; j <= right.length; j += 1) {
71
+ const substitution = previous[j - 1] + (left[i - 1] === right[j - 1] ? 0 : 1)
72
+ current[j] = Math.min(previous[j] + 1, current[j - 1] + 1, substitution)
73
+ rowMinimum = Math.min(rowMinimum, current[j])
74
+ }
75
+ if (rowMinimum > 2) return 3
76
+ previous = current
77
+ }
78
+ return previous[right.length]
79
+ }
80
+
81
+ const tokenQuality = (queryToken, candidateToken) => {
82
+ if (queryToken === candidateToken) return 1
83
+ const substringMinimum = /[^\u0000-\u007f]/.test(queryToken) ? 2 : 3
84
+ if (queryToken.length >= substringMinimum && (candidateToken.includes(queryToken) || queryToken.includes(candidateToken))) return 0.82
85
+ if (queryToken.length < 4) return 0
86
+ const distance = editDistanceAtMostTwo(queryToken, candidateToken)
87
+ if (distance === 1) return 0.68
88
+ if (distance === 2 && queryToken.length >= 7) return 0.5
89
+ return 0
90
+ }
91
+
92
+ const buildSearchDocument = (item) => {
93
+ const semantic = item && item.semantic && typeof item.semantic === 'object' ? item.semantic : {}
94
+ const aliases = Array.isArray(item && item.aliases) ? item.aliases : []
95
+ const options = Array.isArray(item && item.valueOptions) ? item.valueOptions : []
96
+ const fields = {
97
+ ga: normalizeText(item && item.ga),
98
+ label: normalizeText(item && item.label),
99
+ etsName: normalizeText(item && item.etsName),
100
+ hierarchy: normalizeText(item && item.hierarchyPath),
101
+ aliases: normalizeText(aliases.join(' ')),
102
+ semantic: normalizeText([semantic.kind, semantic.area, item && item.mainGroup, item && item.middleGroup, ...(Array.isArray(item && item.tags) ? item.tags : [])].join(' ')),
103
+ values: normalizeText(options.map(option => `${option && option.value} ${option && option.label}`).join(' ')),
104
+ dpt: normalizeText(item && item.dpt)
105
+ }
106
+ const combined = Object.values(fields).join(' ').trim()
107
+ return { fields, combined, tokens: Array.from(new Set(combined.split(' ').filter(Boolean))) }
108
+ }
109
+
110
+ const itemPassesFilters = (item, action) => {
111
+ if (action.access === 'read-only' && item && item.readOnly !== true) return false
112
+ if (action.access === 'read-write' && item && item.readOnly === true) return false
113
+ if (action.purpose === 'write' && item && item.readOnly === true) return false
114
+ const semantic = item && item.semantic && typeof item.semantic === 'object' ? item.semantic : {}
115
+ if (action.semanticKinds.length && !action.semanticKinds.includes(normalizeText(semantic.kind))) return false
116
+ return true
117
+ }
118
+
119
+ const purposeScore = (item, purpose) => {
120
+ const readOnly = item && item.readOnly === true
121
+ if (purpose === 'write') return readOnly ? -1000 : 130
122
+ if (purpose === 'read') return readOnly ? 90 : 50
123
+ if (purpose === 'inspect') return readOnly ? 70 : 55
124
+ return 0
125
+ }
126
+
127
+ const scoreSearchMatch = (item, action) => {
128
+ if (!itemPassesFilters(item, action)) return 0
129
+ const query = normalizeText(action.query)
130
+ if (!query) return 0
131
+ const document = buildSearchDocument(item)
132
+ const exactGa = normalizeGa(item && item.ga) === normalizeGa(action.query)
133
+ if (exactGa) return 2000 + purposeScore(item, action.purpose)
134
+ let score = 0
135
+ if (document.fields.label === query) score += 900
136
+ if (document.fields.etsName === query) score += 850
137
+ if (document.fields.aliases.split(' ').includes(query)) score += 800
138
+ if (document.fields.hierarchy === query) score += 750
139
+ if (document.combined.includes(query)) score += 360
140
+ const queryTokens = query.split(' ').filter(Boolean)
141
+ let qualityTotal = 0
142
+ let matchedTokens = 0
143
+ let strongMatches = 0
144
+ for (const queryToken of queryTokens) {
145
+ let best = 0
146
+ for (const candidateToken of document.tokens) {
147
+ best = Math.max(best, tokenQuality(queryToken, candidateToken))
148
+ if (best === 1) break
149
+ }
150
+ if (best >= 0.5) {
151
+ matchedTokens += 1
152
+ qualityTotal += best
153
+ if (best >= 0.68) strongMatches += 1
154
+ }
155
+ }
156
+ if (strongMatches === 0) return 0
157
+ const missingTokens = Math.max(0, queryTokens.length - matchedTokens)
158
+ const coverage = queryTokens.length > 0 ? matchedTokens / queryTokens.length : 0
159
+ score += qualityTotal * 120
160
+ score += coverage * 200
161
+ score += Math.max(0, matchedTokens - 1) * 50
162
+ score -= missingTokens * 18
163
+ score += purposeScore(item, action.purpose)
164
+ if (action.area) {
165
+ const area = normalizeText(action.area)
166
+ const itemArea = normalizeText([
167
+ item && item.hierarchyPath,
168
+ item && item.mainGroup,
169
+ item && item.middleGroup,
170
+ item && item.semantic && item.semantic.area
171
+ ].join(' '))
172
+ if (!itemArea.includes(area)) return 0
173
+ score += 120
174
+ }
175
+ return score
176
+ }
177
+
178
+ const sortScoredItems = (scored) => scored
179
+ .sort((left, right) => right.score - left.score || String(left.item && left.item.hierarchyPath || left.item && left.item.label || left.item && left.item.ga || '').localeCompare(String(right.item && right.item.hierarchyPath || right.item && right.item.label || right.item && right.item.ga || '')))
180
+ .map(entry => entry.item)
181
+
182
+ const findSearchItems = (catalog, action) => sortScoredItems(
183
+ catalog.map(item => ({ item, score: scoreSearchMatch(item, action) })).filter(entry => entry.score > 0)
184
+ )
185
+
186
+ const buildAreaRows = (catalog) => {
187
+ const areas = new Map()
188
+ catalog.forEach(item => {
189
+ const semantic = item && item.semantic && typeof item.semantic === 'object' ? item.semantic : {}
190
+ const candidates = [semantic.area, item && item.hierarchyPath, item && item.middleGroup, item && item.mainGroup]
191
+ .map(value => String(value || '').trim())
192
+ .filter(Boolean)
193
+ const area = candidates[0] || 'Unclassified'
194
+ const key = normalizeText(area)
195
+ if (!key) return
196
+ if (!areas.has(key)) areas.set(key, { area, count: 0, sampleLabels: [] })
197
+ const row = areas.get(key)
198
+ row.count += 1
199
+ const label = String(item && item.label || item && item.ga || '').trim()
200
+ if (label && row.sampleLabels.length < 3 && !row.sampleLabels.includes(label)) row.sampleLabels.push(label)
201
+ })
202
+ return Array.from(areas.values()).sort((left, right) => right.count - left.count || left.area.localeCompare(right.area))
203
+ }
204
+
205
+ const findAreaItems = (catalog, action) => {
206
+ const requestedArea = normalizeText(action.area)
207
+ return sortScoredItems(catalog
208
+ .filter(item => itemPassesFilters(item, action))
209
+ .map(item => {
210
+ const semantic = item && item.semantic && typeof item.semantic === 'object' ? item.semantic : {}
211
+ const areaText = normalizeText([semantic.area, item && item.hierarchyPath, item && item.middleGroup, item && item.mainGroup].join(' '))
212
+ if (!areaText.includes(requestedArea)) return { item, score: 0 }
213
+ return { item, score: 400 + purposeScore(item, action.purpose) }
214
+ })
215
+ .filter(entry => entry.score > 0))
216
+ }
217
+
218
+ const findRelatedItems = (catalog, action) => {
219
+ const requested = new Set(action.destinations.map(normalizeGa))
220
+ const seeds = catalog.filter(item => requested.has(normalizeGa(item && item.ga)))
221
+ if (!seeds.length) return []
222
+ const seedDocuments = seeds.map(seed => ({ seed, tokens: new Set(buildSearchDocument(seed).tokens) }))
223
+ const scored = catalog.map(item => {
224
+ if (!itemPassesFilters(item, action)) return { item, score: 0 }
225
+ let best = requested.has(normalizeGa(item && item.ga)) ? 1000 : 0
226
+ const itemTokens = buildSearchDocument(item).tokens
227
+ seedDocuments.forEach(({ seed, tokens: seedTokens }) => {
228
+ if (seed === item) return
229
+ const seedSemantic = seed && seed.semantic && typeof seed.semantic === 'object' ? seed.semantic : {}
230
+ const itemSemantic = item && item.semantic && typeof item.semantic === 'object' ? item.semantic : {}
231
+ let relationScore = 0
232
+ if (normalizeText(seed.hierarchyPath) && normalizeText(seed.hierarchyPath) === normalizeText(item.hierarchyPath)) relationScore += 260
233
+ if (normalizeText(seedSemantic.area) && normalizeText(seedSemantic.area) === normalizeText(itemSemantic.area)) relationScore += 180
234
+ if (normalizeText(seed.middleGroup) && normalizeText(seed.middleGroup) === normalizeText(item.middleGroup)) relationScore += 100
235
+ if (normalizeText(seed.mainGroup) && normalizeText(seed.mainGroup) === normalizeText(item.mainGroup)) relationScore += 45
236
+ if (normalizeText(seedSemantic.kind) && normalizeText(seedSemantic.kind) === normalizeText(itemSemantic.kind)) relationScore += 65
237
+ const overlap = itemTokens.filter(token => token.length >= 4 && seedTokens.has(token)).length
238
+ relationScore += Math.min(100, overlap * 20)
239
+ if (relationScore <= 0) return
240
+ const score = relationScore + purposeScore(item, action.purpose)
241
+ best = Math.max(best, score)
242
+ })
243
+ return { item, score: best }
244
+ }).filter(entry => entry.score > 0)
245
+ return sortScoredItems(scored)
246
+ }
247
+
248
+ const actionFingerprint = (action) => JSON.stringify({
249
+ operation: action.operation,
250
+ query: normalizeText(action.query),
251
+ destinations: action.destinations.map(normalizeGa).sort(),
252
+ area: normalizeText(action.area),
253
+ semanticKinds: action.semanticKinds.slice().sort(),
254
+ access: action.access,
255
+ purpose: action.purpose,
256
+ offset: action.offset,
257
+ limit: action.limit
258
+ })
259
+
260
+ const executeKnxAiCatalogActions = ({ actions, catalog, priorResults = [] } = {}) => {
261
+ const safeCatalog = Array.isArray(catalog) ? catalog : []
262
+ const normalized = normalizeKnxAiCatalogActions(actions)
263
+ const seen = new Set((Array.isArray(priorResults) ? priorResults : []).map(result => String(result && result.fingerprint || '')).filter(Boolean))
264
+ const results = []
265
+ normalized.forEach(action => {
266
+ const fingerprint = actionFingerprint(action)
267
+ if (seen.has(fingerprint)) return
268
+ seen.add(fingerprint)
269
+ if (action.operation === 'list_areas') {
270
+ const allAreas = buildAreaRows(safeCatalog.filter(item => itemPassesFilters(item, action)))
271
+ results.push({
272
+ action,
273
+ fingerprint,
274
+ ok: true,
275
+ totalMatches: allAreas.length,
276
+ truncated: allAreas.length > action.offset + action.limit,
277
+ areas: allAreas.slice(action.offset, action.offset + action.limit),
278
+ items: []
279
+ })
280
+ return
281
+ }
282
+ let matched = []
283
+ if (action.operation === 'get') {
284
+ const requested = new Set(action.destinations.map(normalizeGa))
285
+ matched = safeCatalog.filter(item => requested.has(normalizeGa(item && item.ga)) && itemPassesFilters(item, action))
286
+ } else if (action.operation === 'search') {
287
+ matched = findSearchItems(safeCatalog, action)
288
+ } else if (action.operation === 'browse_area') {
289
+ matched = findAreaItems(safeCatalog, action)
290
+ } else if (action.operation === 'related') {
291
+ matched = findRelatedItems(safeCatalog, action)
292
+ }
293
+ results.push({
294
+ action,
295
+ fingerprint,
296
+ ok: true,
297
+ totalMatches: matched.length,
298
+ truncated: matched.length > action.offset + action.limit,
299
+ items: matched.slice(action.offset, action.offset + action.limit),
300
+ areas: []
301
+ })
302
+ })
303
+ return results
304
+ }
305
+
306
+ const collectKnxAiCatalogObjects = (results, maxItems = KNX_AI_CATALOG_MAX_ACCUMULATED_OBJECTS) => {
307
+ const byGa = new Map()
308
+ ;(Array.isArray(results) ? results.slice().reverse() : []).forEach(result => {
309
+ ;(Array.isArray(result && result.items) ? result.items : []).forEach(item => {
310
+ const ga = normalizeGa(item && item.ga)
311
+ if (!ga || byGa.has(ga) || byGa.size >= maxItems) return
312
+ byGa.set(ga, item)
313
+ })
314
+ })
315
+ return Array.from(byGa.values())
316
+ }
317
+
318
+ const buildKnxAiCatalogResearchContext = (results) => {
319
+ const source = Array.isArray(results) ? results : []
320
+ if (!source.length) return ''
321
+ const lines = ['LOCAL ETS CATALOG RETRIEVAL RESULTS:']
322
+ source.forEach((result, index) => {
323
+ const action = result && result.action ? result.action : {}
324
+ const target = action.operation === 'search'
325
+ ? ` query=${JSON.stringify(action.query)}`
326
+ : action.operation === 'browse_area'
327
+ ? ` area=${JSON.stringify(action.area)}`
328
+ : (action.operation === 'get' || action.operation === 'related')
329
+ ? ` destinations=${JSON.stringify(action.destinations)}`
330
+ : ''
331
+ lines.push(`[C${index + 1}] ${action.operation || 'unknown'}${target}: ${Number(result && result.totalMatches) || 0} match(es); page offset=${Math.max(0, Number(action.offset) || 0)}, limit=${Math.max(1, Number(action.limit) || 1)}${result && result.truncated ? '; more matches are available with a larger offset' : ''}.`)
332
+ ;(Array.isArray(result && result.areas) ? result.areas : []).forEach(area => {
333
+ lines.push(`- area ${JSON.stringify(area.area)} | ${area.count} objects | examples ${area.sampleLabels.join(', ') || '?'}`)
334
+ })
335
+ })
336
+ lines.push('END LOCAL ETS CATALOG RETRIEVAL RESULTS')
337
+ return lines.join('\n')
338
+ }
339
+
340
+ module.exports = {
341
+ KNX_AI_CATALOG_MAX_ACCUMULATED_OBJECTS,
342
+ KNX_AI_CATALOG_MAX_ACTIONS_PER_ROUND,
343
+ KNX_AI_CATALOG_MAX_RESEARCH_ROUNDS,
344
+ KNX_AI_CATALOG_MAX_RESULTS_PER_ACTION,
345
+ buildKnxAiCatalogResearchContext,
346
+ collectKnxAiCatalogObjects,
347
+ executeKnxAiCatalogActions,
348
+ normalizeKnxAiCatalogActions
349
+ }
@@ -207,6 +207,47 @@ const getKnxAiChatSession = (context, sessionId) => {
207
207
  const buildKnxAiChatPromptContext = ({ context, sessionId, maxChars = 16000 } = {}) => {
208
208
  const session = getKnxAiChatSession(context, sessionId)
209
209
  if (!session.instructions.length && !session.turns.length && !session.cameraWatches.length) return ''
210
+ const boundedChars = Number(maxChars) > 0 ? Math.max(1000, Number(maxChars)) : 0
211
+ if (boundedChars > 0) {
212
+ const fixedBlocks = []
213
+ if (session.instructions.length) {
214
+ const instructionBudget = Math.min(1200, Math.max(400, Math.floor(boundedChars * 0.3)))
215
+ const instructionLines = [
216
+ 'PERSISTENT USER FACTS AND INSTRUCTIONS:',
217
+ ...session.instructions.map(item => `- ${item.text.replace(/\r?\n/g, ' ')}`)
218
+ ]
219
+ fixedBlocks.push(instructionLines.join('\n').slice(0, instructionBudget))
220
+ }
221
+ if (session.cameraWatches.length) {
222
+ const watchLines = [
223
+ 'ACTIVE CAMERA WATCHES:',
224
+ ...session.cameraWatches.map(watch => {
225
+ const camera = watch.cameraName || watch.cameraId
226
+ const scope = watch.scopeName || watch.scopeId
227
+ const objects = watch.objectTypes.length ? `; objects ${watch.objectTypes.join(', ')}` : ''
228
+ return `- ${watch.id}: ${camera}; event ${watch.eventType}${scope ? `; scope ${scope}` : ''}${objects}; cooldown ${watch.cooldownSeconds}s`
229
+ })
230
+ ]
231
+ fixedBlocks.push(watchLines.join('\n').slice(0, Math.min(800, Math.floor(boundedChars * 0.25))))
232
+ }
233
+ const fixedText = fixedBlocks.join('\n\n')
234
+ const turnBudget = Math.max(0, boundedChars - fixedText.length - (fixedText ? 2 : 0))
235
+ const selectedTurns = []
236
+ let used = 0
237
+ for (let index = session.turns.length - 1; index >= 0; index -= 1) {
238
+ const turn = session.turns[index]
239
+ const renderedTurn = `User: ${clampText(turn.question, 700)}\nAssistant: ${clampText(turn.reply, 1400)}`
240
+ const nextSize = renderedTurn.length + (selectedTurns.length ? 1 : 0)
241
+ if (used + nextSize > turnBudget) {
242
+ if (!selectedTurns.length && turnBudget >= 200) selectedTurns.unshift(renderedTurn.slice(0, turnBudget))
243
+ break
244
+ }
245
+ selectedTurns.unshift(renderedTurn)
246
+ used += nextSize
247
+ }
248
+ const conversationBlock = selectedTurns.length ? `RECENT CONVERSATION:\n${selectedTurns.join('\n')}` : ''
249
+ return [fixedText, conversationBlock].filter(Boolean).join('\n\n').slice(0, boundedChars)
250
+ }
210
251
  const lines = []
211
252
  if (session.instructions.length) {
212
253
  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):')
@@ -230,7 +271,8 @@ const buildKnxAiChatPromptContext = ({ context, sessionId, maxChars = 16000 } =
230
271
  lines.push(`- ${watch.id}: ${camera}; event ${watch.eventType}${scope ? `; scope ${scope}` : ''}${objects}; cooldown ${watch.cooldownSeconds}s`)
231
272
  })
232
273
  }
233
- return lines.join('\n').slice(0, Math.max(1000, Number(maxChars) || 16000))
274
+ const rendered = lines.join('\n')
275
+ return rendered
234
276
  }
235
277
 
236
278
  const escapeKnxAiChatContextField = value => String(value === undefined || value === null ? '' : value)
@@ -190,17 +190,16 @@ const inferKnxAiHomeSemantic = (item = {}) => {
190
190
  item.hierarchyPath,
191
191
  item.label,
192
192
  item.etsName,
193
+ Array.isArray(item.aliases) ? item.aliases.join(' ') : '',
193
194
  Array.isArray(item.tags) ? item.tags.join(' ') : ''
194
195
  ].filter(Boolean).join(' ')
195
196
  const normalized = normalizeText(source)
196
197
  const concept = CONCEPT_PATTERNS.find(entry => matches(normalized, entry.patterns))
197
198
  const areaMatch = AREA_PATTERNS.find(entry => matches(normalized, entry.patterns))
198
- const role = String(item.role || 'neutral').trim().toLowerCase()
199
199
  const dpt = String(item.dpt || '').trim()
200
200
  const semantic = {
201
201
  kind: concept ? concept.kind : 'unknown',
202
202
  area: areaMatch ? areaMatch.area : '',
203
- role,
204
203
  confidence: concept ? concept.confidence : 0,
205
204
  sourceLanguage: /[\u3400-\u9fff]/.test(source) ? 'zh-CN' : 'auto',
206
205
  originalLabel: clampText(item.label || item.etsName || item.ga || '', 240),
@@ -239,6 +238,16 @@ const normalizeArray = (value, maxItems) => {
239
238
  .map(item => clonePlain(item, {}))
240
239
  }
241
240
 
241
+ const normalizeSemanticObjects = (value) => normalizeArray(value, HOME_MEMORY_MAX_SEMANTIC_OBJECTS)
242
+ .map(item => ({
243
+ ga: clampText(item.ga, 32),
244
+ dpt: clampText(item.dpt, 32),
245
+ label: clampText(item.label, 240),
246
+ kind: clampText(item.kind, 80),
247
+ area: clampText(item.area, 80),
248
+ confidence: Number.isFinite(Number(item.confidence)) ? Number(item.confidence) : 0
249
+ }))
250
+
242
251
  const normalizeKnxAiHomeMemory = (value = {}) => {
243
252
  const source = value && typeof value === 'object' && !Array.isArray(value) ? value : {}
244
253
  return {
@@ -250,7 +259,7 @@ const normalizeKnxAiHomeMemory = (value = {}) => {
250
259
  observations: normalizeArray(source.observations, HOME_MEMORY_MAX_OBSERVATIONS),
251
260
  habits: normalizeArray(source.habits, HOME_MEMORY_MAX_HABITS),
252
261
  notifications: normalizeArray(source.notifications, HOME_MEMORY_MAX_NOTIFICATIONS),
253
- semanticObjects: normalizeArray(source.semanticObjects, HOME_MEMORY_MAX_SEMANTIC_OBJECTS)
262
+ semanticObjects: normalizeSemanticObjects(source.semanticObjects)
254
263
  }
255
264
  }
256
265
 
@@ -323,11 +332,11 @@ const buildKnxAiHomeMemoryMarkdown = ({ memory, maxKb } = {}) => {
323
332
  '',
324
333
  '## Semantic ETS model',
325
334
  '',
326
- '| GA | DPT | Kind | Area | Role | Confidence | ETS label |',
327
- '|---|---|---|---|---|---:|---|'
335
+ '| GA | DPT | Kind | Area | Confidence | ETS label |',
336
+ '|---|---|---|---|---:|---|'
328
337
  ]
329
338
  bounded.semanticObjects.forEach(item => {
330
- lines.push(`| ${escapeMarkdownCell(item.ga)} | ${escapeMarkdownCell(item.dpt)} | ${escapeMarkdownCell(item.kind)} | ${escapeMarkdownCell(item.area)} | ${escapeMarkdownCell(item.role)} | ${Number(item.confidence || 0).toFixed(2)} | ${escapeMarkdownCell(item.label)} |`)
339
+ lines.push(`| ${escapeMarkdownCell(item.ga)} | ${escapeMarkdownCell(item.dpt)} | ${escapeMarkdownCell(item.kind)} | ${escapeMarkdownCell(item.area)} | ${Number(item.confidence || 0).toFixed(2)} | ${escapeMarkdownCell(item.label)} |`)
331
340
  })
332
341
  lines.push('', '## Learned habits', '')
333
342
  if (!bounded.habits.length) lines.push('_No stable habit has been learned yet._')
@@ -382,7 +391,6 @@ const CLOSED_RE = /\b(closed|close|down|chiuso|chiusa|chiusi|chiuse|geschlossen|
382
391
  const classifyKnxAiOpenState = ({ semantic, dpt, payload, valueOptions } = {}) => {
383
392
  const safeSemantic = semantic && typeof semantic === 'object' ? semantic : {}
384
393
  if (!['cover', 'window', 'door'].includes(safeSemantic.kind)) return null
385
- if (String(safeSemantic.role || '').toLowerCase() === 'command') return null
386
394
  const dptId = String(dpt || safeSemantic.dpt || '').trim()
387
395
  const main = dptId.split('.')[0]
388
396
  if (main === '5' && safeSemantic.kind === 'cover') {
@@ -385,7 +385,10 @@ const buildKnxAiSchedulePromptContext = (store, { sessionId, maxChars = 12000, n
385
385
  const lines = tasks.map(task => {
386
386
  return `${task.id} | kind ${task.kind} | ${task.title} | instruction ${JSON.stringify(task.instruction)} | next ${task.nextRunAt || '-'} | every ${task.intervalMinutes || 0} minute(s) | expires ${task.expiresAt || 'never'}`
387
387
  })
388
- return clampText(lines.join('\n'), Math.max(200, Number(maxChars) || 12000))
388
+ const rendered = lines.join('\n')
389
+ return Number(maxChars) > 0
390
+ ? clampText(rendered, Math.max(200, Number(maxChars)))
391
+ : rendered
389
392
  }
390
393
 
391
394
  const buildKnxAiScheduleMarkdown = (store, { now = Date.now() } = {}) => {