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

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.
@@ -78,6 +78,8 @@ const KNX_AI_CLOUD_LLM_TIMEOUT_MIN_MS = 120000
78
78
  const KNX_AI_LOCAL_LLM_TIMEOUT_MIN_MS = 10 * 60 * 1000
79
79
  const KNX_AI_MINIMAL_CONTEXT_MAX_TOKENS = 16 * 1024
80
80
  const KNX_AI_COMPACT_CONTEXT_MAX_TOKENS = 64 * 1024
81
+ const KNX_AI_LMSTUDIO_PROMPT_CONTEXT_MAX_TOKENS = 16 * 1024
82
+ const KNX_AI_OLLAMA_CONTEXT_MAX_TOKENS = 16 * 1024
81
83
  const KNX_AI_ROUTINE_FEEDBACK_TIMEOUT_MS = 4000
82
84
  const KNX_AI_ADAPTER_HISTORY_RETENTION_DAYS = Math.max(1, KNX_AI_TRAFFIC_DEFAULTS.historyStoreRetentionDays)
83
85
 
@@ -91,13 +93,77 @@ const resolveKnxAiLlmTimeoutMs = ({ provider, configuredTimeoutMs } = {}) => {
91
93
 
92
94
  const resolveKnxAiPromptContextMode = ({ provider, contextLength } = {}) => {
93
95
  if (provider !== 'ollama' && provider !== 'lmstudio') return 'full'
94
- const tokens = Math.max(0, Number(contextLength) || 0)
96
+ const reportedTokens = Math.max(0, Number(contextLength) || 0)
97
+ // Local providers may advertise a 131K model capability even when that is a
98
+ // poor operational choice. Never let the advertised maximum promote KNX AI
99
+ // to the huge "full" prompt. The 16K semantic view retains every action type
100
+ // while selecting only question-relevant KNX/history/project context.
101
+ const localPromptCap = provider === 'lmstudio'
102
+ ? KNX_AI_LMSTUDIO_PROMPT_CONTEXT_MAX_TOKENS
103
+ : KNX_AI_OLLAMA_CONTEXT_MAX_TOKENS
104
+ const tokens = Math.min(reportedTokens || localPromptCap, localPromptCap)
95
105
  if (!tokens) return 'full'
96
106
  if (tokens <= KNX_AI_MINIMAL_CONTEXT_MAX_TOKENS) return 'minimal'
97
107
  if (tokens <= KNX_AI_COMPACT_CONTEXT_MAX_TOKENS) return 'compact'
98
108
  return 'full'
99
109
  }
100
110
 
111
+ const resolveKnxAiOperationalContextLimit = ({ provider, contextLength } = {}) => {
112
+ const normalizedProvider = String(provider || '').trim().toLowerCase()
113
+ const localPromptCap = normalizedProvider === 'lmstudio'
114
+ ? KNX_AI_LMSTUDIO_PROMPT_CONTEXT_MAX_TOKENS
115
+ : normalizedProvider === 'ollama'
116
+ ? KNX_AI_OLLAMA_CONTEXT_MAX_TOKENS
117
+ : 0
118
+ if (!localPromptCap) {
119
+ return {
120
+ provider: normalizedProvider,
121
+ tokens: 0,
122
+ mode: 'provider-managed'
123
+ }
124
+ }
125
+ const activeContextLength = Math.max(0, Number(contextLength) || 0)
126
+ return {
127
+ provider: normalizedProvider,
128
+ tokens: Math.min(activeContextLength || localPromptCap, localPromptCap),
129
+ mode: 'fixed'
130
+ }
131
+ }
132
+
133
+ const measureKnxAiPromptContext = ({ body, provider, model } = {}) => {
134
+ const requestBody = body && typeof body === 'object' ? body : {}
135
+ const textParts = []
136
+ let imageCount = 0
137
+ const appendContent = (content) => {
138
+ if (typeof content === 'string') {
139
+ textParts.push(content)
140
+ return
141
+ }
142
+ if (!Array.isArray(content)) return
143
+ content.forEach(part => {
144
+ if (!part || typeof part !== 'object') return
145
+ if (part.type === 'text' && typeof part.text === 'string') textParts.push(part.text)
146
+ if (part.type === 'image' || part.type === 'image_url') imageCount += 1
147
+ })
148
+ }
149
+ appendContent(requestBody.system)
150
+ ;(Array.isArray(requestBody.messages) ? requestBody.messages : []).forEach(message => {
151
+ if (!message || typeof message !== 'object') return
152
+ appendContent(message.content)
153
+ if (Array.isArray(message.images)) imageCount += message.images.length
154
+ })
155
+ const promptText = textParts.join('\n')
156
+ const bytes = Buffer.byteLength(promptText, 'utf8')
157
+ return {
158
+ provider: String(provider || '').trim().toLowerCase(),
159
+ model: String(model || requestBody.model || '').trim(),
160
+ bytes,
161
+ characters: promptText.length,
162
+ estimatedInputTokens: bytes > 0 ? Math.max(1, Math.ceil(bytes / 4)) : 0,
163
+ imageCount
164
+ }
165
+ }
166
+
101
167
  const selectKnxAiCatalogForPrompt = ({ catalog, question, mode = 'full' } = {}) => {
102
168
  const source = Array.isArray(catalog) ? catalog : []
103
169
  if (mode === 'full') return source.slice(0, 600)
@@ -356,7 +422,14 @@ const summarizeKnxAiChatContext = ({ node, nodeId, redUserDir } = {}) => {
356
422
  }
357
423
 
358
424
  return {
359
- sources: ['knxTraffic', 'adapterHistory', 'etsProject', 'memoryEducation', 'camerasDocs', 'ttsUltimate'],
425
+ contextLimit: resolveKnxAiOperationalContextLimit({
426
+ provider: node && node.llmProvider,
427
+ contextLength: node && node.llmContextLength
428
+ }),
429
+ lastPromptUsage: node && node._lastChatPromptUsage
430
+ ? Object.assign({}, node._lastChatPromptUsage)
431
+ : null,
432
+ sources: ['knxTraffic', 'adapterHistory', 'etsProject', 'memoryEducation', 'cameras', 'ttsUltimate'],
360
433
  files: files.map(item => Object.assign({}, item, { exists: fs.existsSync(item.path) })),
361
434
  telegramDirectories: [
362
435
  { id: 'archiveRoot', path: telegramArchiveRoot, exists: fs.existsSync(telegramArchiveRoot) },
@@ -1831,7 +1904,26 @@ const coerceKnxAiCommandPayload = (value, { dpt } = {}) => {
1831
1904
  const resolveKnxAiOperationEvent = (candidate) => {
1832
1905
  const item = candidate && typeof candidate === 'object' ? candidate : {}
1833
1906
  const raw = String(item.event || item.operation || item.action || '').trim().toLowerCase()
1834
- if (['groupvalue_read', 'read', 'query', 'request_status'].includes(raw)) return 'GroupValue_Read'
1907
+ const normalized = raw.replace(/[\s-]+/g, '_')
1908
+ const compact = normalized.replace(/[^a-z]/g, '')
1909
+ const readNames = new Set([
1910
+ 'groupvalue_read', 'groupvalue_response', 'read', 'query', 'get',
1911
+ 'get_state', 'read_state', 'read_status', 'request_status', 'status'
1912
+ ])
1913
+ if (readNames.has(normalized) || ['groupvalueread', 'groupvalueresponse', 'getstate', 'readstate', 'readstatus', 'requeststatus'].includes(compact)) {
1914
+ return 'GroupValue_Read'
1915
+ }
1916
+ const writeNames = new Set(['groupvalue_write', 'write', 'set', 'set_state', 'command'])
1917
+ if (writeNames.has(normalized) || ['groupvaluewrite', 'setstate'].includes(compact)) return 'GroupValue_Write'
1918
+
1919
+ // Small local models sometimes omit the operation discriminator on a state
1920
+ // query even though they correctly return exact ETS destinations. An item
1921
+ // without a payload cannot be an actuator write, so treat it as a safe read.
1922
+ // Legacy write proposals that contain a payload remain writes and still pass
1923
+ // through command-role, DPT, payload and confirmation validation.
1924
+ const hasPayload = Object.prototype.hasOwnProperty.call(item, 'payload') || Object.prototype.hasOwnProperty.call(item, 'value')
1925
+ const payload = Object.prototype.hasOwnProperty.call(item, 'payload') ? item.payload : item.value
1926
+ if (!hasPayload || payload === null || payload === undefined) return 'GroupValue_Read'
1835
1927
  return 'GroupValue_Write'
1836
1928
  }
1837
1929
 
@@ -4113,12 +4205,11 @@ const findLmStudioModel = ({ catalog, model }) => {
4113
4205
  }) || null
4114
4206
  }
4115
4207
 
4116
- const ensureLmStudioModelMaxContext = async ({
4208
+ const resolveLmStudioModelContext = async ({
4117
4209
  baseUrl,
4118
4210
  apiKey,
4119
4211
  model,
4120
- get = getJson,
4121
- post = postJson
4212
+ get = getJson
4122
4213
  } = {}) => {
4123
4214
  const selectedModel = String(model || '').trim()
4124
4215
  if (!selectedModel) throw new Error('No Bionic LM Studio model selected')
@@ -4132,74 +4223,40 @@ const ensureLmStudioModelMaxContext = async ({
4132
4223
  model: selectedModel
4133
4224
  })
4134
4225
  if (!descriptor) throw new Error(`Bionic LM Studio model not found: ${selectedModel}`)
4135
- const targetContextLength = Math.max(0, Number(descriptor.maxContextLength) || 0)
4136
- if (!targetContextLength) {
4226
+ const maxContextLength = Math.max(0, Number(descriptor.maxContextLength) || 0)
4227
+ if (!maxContextLength) {
4137
4228
  throw new Error(`Bionic LM Studio did not report max_context_length for model "${descriptor.id}"`)
4138
4229
  }
4139
- const readyInstance = descriptor.loadedInstances.find(instance => instance.contextLength === targetContextLength)
4230
+ // A loaded instance reflects the context explicitly chosen in Bionic LM
4231
+ // Studio. Preserve it instead of treating max_context_length (a capability)
4232
+ // as the desired runtime configuration and silently reloading the model.
4233
+ const readyInstance = descriptor.loadedInstances.find(instance => {
4234
+ return instance.id === selectedModel && instance.contextLength > 0
4235
+ }) || descriptor.loadedInstances.find(instance => instance.contextLength > 0)
4140
4236
  if (readyInstance) {
4141
4237
  return {
4142
4238
  model: descriptor.id,
4143
4239
  displayName: descriptor.displayName,
4144
4240
  instanceId: readyInstance.id,
4145
- contextLength: targetContextLength,
4146
- maxContextLength: targetContextLength,
4241
+ contextLength: readyInstance.contextLength,
4242
+ maxContextLength,
4243
+ active: true,
4147
4244
  changed: false
4148
4245
  }
4149
4246
  }
4150
4247
 
4151
- const unloadUrl = deriveLmStudioNativeApiUrl(baseUrl, '/api/v1/models/unload')
4152
- const loadUrl = deriveLmStudioNativeApiUrl(baseUrl, '/api/v1/models/load')
4153
- for (const instance of descriptor.loadedInstances) {
4154
- // eslint-disable-next-line no-await-in-loop
4155
- await post({
4156
- url: unloadUrl,
4157
- headers,
4158
- body: { instance_id: instance.id },
4159
- timeoutMs: KNX_AI_LOCAL_LLM_TIMEOUT_MIN_MS
4160
- })
4161
- }
4162
-
4163
- try {
4164
- const loaded = await post({
4165
- url: loadUrl,
4166
- headers,
4167
- body: {
4168
- model: descriptor.id,
4169
- context_length: targetContextLength,
4170
- echo_load_config: true
4171
- },
4172
- timeoutMs: KNX_AI_LOCAL_LLM_TIMEOUT_MIN_MS
4173
- })
4174
- const appliedContextLength = Math.max(0, Number(loaded && loaded.load_config && loaded.load_config.context_length) || targetContextLength)
4175
- return {
4176
- model: descriptor.id,
4177
- displayName: descriptor.displayName,
4178
- instanceId: String(loaded && loaded.instance_id || descriptor.id),
4179
- contextLength: appliedContextLength,
4180
- maxContextLength: targetContextLength,
4181
- changed: true
4182
- }
4183
- } catch (error) {
4184
- const previousContextLength = descriptor.loadedInstances.reduce((max, instance) => Math.max(max, instance.contextLength), 0)
4185
- if (previousContextLength > 0) {
4186
- try {
4187
- await post({
4188
- url: loadUrl,
4189
- headers,
4190
- body: {
4191
- model: descriptor.id,
4192
- context_length: previousContextLength,
4193
- echo_load_config: true
4194
- },
4195
- timeoutMs: KNX_AI_LOCAL_LLM_TIMEOUT_MIN_MS
4196
- })
4197
- } catch (restoreError) { /* best-effort restoration of the previous instance */ }
4198
- }
4199
- const detail = String(error && error.message ? error.message : error)
4200
- const loadError = new Error(`Bionic LM Studio could not load "${descriptor.displayName}" with its maximum context (${targetContextLength} tokens). ${detail}`)
4201
- if (error && error.status !== undefined) loadError.status = error.status
4202
- throw loadError
4248
+ // Do not load an inactive model through the management API. Bionic LM
4249
+ // Studio's JIT loader must remain free to apply the user's saved per-model
4250
+ // defaults (including context length) when the first chat request arrives.
4251
+ // Until that happens, build a conservative prompt that fits within 16K.
4252
+ return {
4253
+ model: descriptor.id,
4254
+ displayName: descriptor.displayName,
4255
+ instanceId: '',
4256
+ contextLength: Math.min(maxContextLength, KNX_AI_MINIMAL_CONTEXT_MAX_TOKENS),
4257
+ maxContextLength,
4258
+ active: false,
4259
+ changed: false
4203
4260
  }
4204
4261
  }
4205
4262
 
@@ -4292,7 +4349,7 @@ const resolveOllamaModelMaxContext = async ({ baseUrl, model, post = postJson }
4292
4349
  return {
4293
4350
  model: selectedModel,
4294
4351
  maxContextLength,
4295
- contextLength: maxContextLength
4352
+ contextLength: Math.min(maxContextLength, KNX_AI_OLLAMA_CONTEXT_MAX_TOKENS)
4296
4353
  }
4297
4354
  }
4298
4355
 
@@ -5796,7 +5853,7 @@ module.exports = function (RED) {
5796
5853
  if (!apiKey && deployedNode && deployedNode.credentials && deployedNode.credentials.llmApiKey) {
5797
5854
  apiKey = sanitizeApiKey(deployedNode.credentials.llmApiKey)
5798
5855
  }
5799
- const result = await ensureLmStudioModelMaxContext({
5856
+ const result = await resolveLmStudioModelContext({
5800
5857
  baseUrl,
5801
5858
  apiKey,
5802
5859
  model: body.model
@@ -7287,7 +7344,7 @@ module.exports = function (RED) {
7287
7344
  }, 90)
7288
7345
  }
7289
7346
 
7290
- const buildLLMPrompt = ({ question, summary, compact = false, languageHint = '' } = {}) => {
7347
+ const buildLLMPrompt = ({ question, summary, compact = false, languageHint = '', includeDocs = true } = {}) => {
7291
7348
  const promptMode = compact === 'minimal' ? 'minimal' : compact === true || compact === 'compact' ? 'compact' : 'full'
7292
7349
  const compactMode = promptMode !== 'full'
7293
7350
  const minimalMode = promptMode === 'minimal'
@@ -7366,7 +7423,7 @@ module.exports = function (RED) {
7366
7423
  }
7367
7424
 
7368
7425
  let docsContext = ''
7369
- if (node.llmIncludeDocsSnippets) {
7426
+ if (includeDocs && node.llmIncludeDocsSnippets) {
7370
7427
  const docsMaxCharsConfigured = Math.max(500, Math.min(5000, Number(node.llmDocsMaxChars) || 500))
7371
7428
  const docsMaxChars = minimalMode
7372
7429
  ? Math.min(docsMaxCharsConfigured, 500)
@@ -9818,14 +9875,19 @@ module.exports = function (RED) {
9818
9875
  if (node._lmStudioContextPromise && node._lmStudioContextPromise.key === key) {
9819
9876
  return node._lmStudioContextPromise.promise
9820
9877
  }
9821
- const promise = ensureLmStudioModelMaxContext({
9878
+ const promise = resolveLmStudioModelContext({
9822
9879
  baseUrl: node.llmBaseUrl,
9823
9880
  apiKey: node.llmApiKey,
9824
9881
  model: node.llmModel
9825
9882
  }).then(result => {
9826
9883
  node.llmContextLength = Math.max(0, Number(result && result.contextLength) || node.llmContextLength)
9827
- node._lmStudioContextReadyKey = `${node.llmBaseUrl}\u0000${node.llmModel}\u0000${node.llmContextLength}`
9828
- node._lmStudioContextReadyResult = result
9884
+ if (result && result.active === true) {
9885
+ node._lmStudioContextReadyKey = `${node.llmBaseUrl}\u0000${node.llmModel}\u0000${node.llmContextLength}`
9886
+ node._lmStudioContextReadyResult = result
9887
+ } else {
9888
+ node._lmStudioContextReadyKey = ''
9889
+ node._lmStudioContextReadyResult = null
9890
+ }
9829
9891
  return result
9830
9892
  }).finally(() => {
9831
9893
  if (node._lmStudioContextPromise && node._lmStudioContextPromise.key === key) {
@@ -9842,7 +9904,11 @@ module.exports = function (RED) {
9842
9904
  const model = node.llmModel || 'llama3.1'
9843
9905
  const key = `${url}\u0000${model}`
9844
9906
  if (!force && node._ollamaContextReadyKey === key && node.llmContextLength > 0) {
9845
- return { model, maxContextLength: node.llmContextLength, contextLength: node.llmContextLength }
9907
+ return {
9908
+ model,
9909
+ maxContextLength: Math.max(node.llmContextLength, Number(node._ollamaModelMaxContextLength) || 0),
9910
+ contextLength: node.llmContextLength
9911
+ }
9846
9912
  }
9847
9913
  const resolveContext = () => resolveOllamaModelMaxContext({ baseUrl: url, model })
9848
9914
  let result
@@ -9853,7 +9919,8 @@ module.exports = function (RED) {
9853
9919
  await ensureOllamaServerRunning({ baseUrl: url, autoStart: true, timeoutMs: 22000 })
9854
9920
  result = await resolveContext()
9855
9921
  }
9856
- node.llmContextLength = Math.max(0, Number(result && result.maxContextLength) || 0)
9922
+ node._ollamaModelMaxContextLength = Math.max(0, Number(result && result.maxContextLength) || 0)
9923
+ node.llmContextLength = Math.max(0, Number(result && result.contextLength) || 0)
9857
9924
  node._ollamaContextReadyKey = key
9858
9925
  return result
9859
9926
  }
@@ -9864,7 +9931,24 @@ module.exports = function (RED) {
9864
9931
  return null
9865
9932
  }
9866
9933
 
9867
- const callLLMChat = async ({ systemPrompt, userContent, images = [], jsonSchema = null, maxTokensOverride = null }) => {
9934
+ const recordChatPromptUsage = ({ body, provider, model } = {}) => {
9935
+ const sequence = Math.max(0, Number(node._chatPromptUsageSequence) || 0) + 1
9936
+ node._chatPromptUsageSequence = sequence
9937
+ node._lastChatPromptUsageSequence = sequence
9938
+ node._lastChatPromptUsage = Object.assign(
9939
+ { at: new Date().toISOString(), exactInputTokens: 0 },
9940
+ measureKnxAiPromptContext({ body, provider, model })
9941
+ )
9942
+ return sequence
9943
+ }
9944
+
9945
+ const recordExactChatPromptTokens = ({ sequence, inputTokens } = {}) => {
9946
+ const tokens = Math.max(0, Number(inputTokens) || 0)
9947
+ if (!tokens || sequence !== node._lastChatPromptUsageSequence || !node._lastChatPromptUsage) return
9948
+ node._lastChatPromptUsage = Object.assign({}, node._lastChatPromptUsage, { exactInputTokens: Math.round(tokens) })
9949
+ }
9950
+
9951
+ const callLLMChat = async ({ systemPrompt, userContent, images = [], jsonSchema = null, maxTokensOverride = null, trackChatContextUsage = false }) => {
9868
9952
  if (!node.llmEnabled) throw new Error('LLM is disabled in node config')
9869
9953
  if (node.llmProvider === 'lmstudio' && !String(node.llmModel || '').trim()) {
9870
9954
  throw new Error('No Bionic LM Studio model selected. Start the LM Studio API server, refresh the model list and select a model.')
@@ -9910,10 +9994,16 @@ module.exports = function (RED) {
9910
9994
  )
9911
9995
  }
9912
9996
  let json
9997
+ let promptUsageSequence = 0
9913
9998
  const requestOllamaChat = requestBody => postLocalLlmWithContextFallbacks({
9914
9999
  body: requestBody,
9915
10000
  enabled: true,
9916
- request: compactBody => postJson({ url, body: compactBody, timeoutMs: effectiveTimeoutMs })
10001
+ request: compactBody => {
10002
+ if (trackChatContextUsage) {
10003
+ promptUsageSequence = recordChatPromptUsage({ body: compactBody, provider: 'ollama', model: compactBody.model })
10004
+ }
10005
+ return postJson({ url, body: compactBody, timeoutMs: effectiveTimeoutMs })
10006
+ }
9917
10007
  })
9918
10008
  try {
9919
10009
  json = await requestOllamaChat(body)
@@ -9927,6 +10017,7 @@ module.exports = function (RED) {
9927
10017
  throw decorateOllamaConnectionError({ error, url, action: 'chat with the model' })
9928
10018
  }
9929
10019
  }
10020
+ recordExactChatPromptTokens({ sequence: promptUsageSequence, inputTokens: json && json.prompt_eval_count })
9930
10021
  const content = json && json.message && typeof json.message.content === 'string' ? json.message.content : safeStringify(json)
9931
10022
  return { provider: 'ollama', model: body.model, content, finishReason: String(json && json.done_reason ? json.done_reason : '') }
9932
10023
  }
@@ -9957,7 +10048,11 @@ module.exports = function (RED) {
9957
10048
  }]
9958
10049
  }
9959
10050
  if (sys) body.system = sys
10051
+ const promptUsageSequence = trackChatContextUsage
10052
+ ? recordChatPromptUsage({ body, provider: 'anthropic', model: body.model })
10053
+ : 0
9960
10054
  const json = await postJson({ url, headers, body, timeoutMs: effectiveTimeoutMs })
10055
+ recordExactChatPromptTokens({ sequence: promptUsageSequence, inputTokens: json && json.usage && json.usage.input_tokens })
9961
10056
  const content = extractAnthropicText(json)
9962
10057
  const finishReason = String(json && json.stop_reason ? json.stop_reason : '')
9963
10058
  return { provider: 'anthropic', model: body.model, content, finishReason }
@@ -10011,17 +10106,27 @@ module.exports = function (RED) {
10011
10106
  ? (localOutputTokenLimit > 0 ? { max_tokens: Math.min(resolvedMaxTokens, localOutputTokenLimit) } : {})
10012
10107
  : { max_tokens: resolvedMaxTokens }
10013
10108
  let json
10109
+ let promptUsageSequence = 0
10014
10110
  try {
10015
10111
  json = await postLocalLlmWithContextFallbacks({
10016
10112
  body: Object.assign(tokenLimitBody, schemaBody),
10017
10113
  enabled: node.llmProvider === 'lmstudio',
10018
- request: requestBody => postOpenAiCompatibleChatWithFallbacks({
10019
- url,
10020
- headers,
10021
- body: requestBody,
10022
- timeoutMs: effectiveTimeoutMs,
10023
- model: baseBody.model
10024
- })
10114
+ request: requestBody => {
10115
+ if (trackChatContextUsage) {
10116
+ promptUsageSequence = recordChatPromptUsage({
10117
+ body: requestBody,
10118
+ provider: node.llmProvider === 'lmstudio' ? 'lmstudio' : 'openai_compat',
10119
+ model: baseBody.model
10120
+ })
10121
+ }
10122
+ return postOpenAiCompatibleChatWithFallbacks({
10123
+ url,
10124
+ headers,
10125
+ body: requestBody,
10126
+ timeoutMs: effectiveTimeoutMs,
10127
+ model: baseBody.model
10128
+ })
10129
+ }
10025
10130
  })
10026
10131
  } catch (error) {
10027
10132
  if (node.llmProvider === 'lmstudio' && isLikelyConnectionFailure(error)) {
@@ -10031,6 +10136,7 @@ module.exports = function (RED) {
10031
10136
  }
10032
10137
  throw error
10033
10138
  }
10139
+ recordExactChatPromptTokens({ sequence: promptUsageSequence, inputTokens: json && json.usage && json.usage.prompt_tokens })
10034
10140
  const content = extractOpenAICompatText(json) || buildOpenAICompatFallbackText(json)
10035
10141
  const finishReason = String(json && json.choices && json.choices[0] && json.choices[0].finish_reason ? json.choices[0].finish_reason : '')
10036
10142
  return { provider: node.llmProvider === 'lmstudio' ? 'lmstudio' : 'openai_compat', model: baseBody.model, content, finishReason }
@@ -10203,7 +10309,7 @@ module.exports = function (RED) {
10203
10309
  }
10204
10310
  }
10205
10311
 
10206
- const callLLM = async ({ question, sessionId = 'default', languageHint = '' }) => {
10312
+ const callLLM = async ({ question, sessionId = 'default', languageHint = '', includeDocs = true }) => {
10207
10313
  await ensureSelectedLocalModelContext({ autoStartOllama: true })
10208
10314
  const contextMode = resolveKnxAiPromptContextMode({
10209
10315
  provider: node.llmProvider,
@@ -10220,14 +10326,16 @@ module.exports = function (RED) {
10220
10326
  question,
10221
10327
  summary,
10222
10328
  compact: contextMode === 'full' ? false : contextMode,
10223
- languageHint
10329
+ languageHint,
10330
+ includeDocs
10224
10331
  })
10225
10332
  const userContent = chatContext ? `${chatContext}\n\n${prompt}` : prompt
10226
10333
  const configuredMaxTokens = Math.max(10000, Number(node.llmMaxTokens) || 0)
10227
10334
  let ret = await callLLMChat({
10228
10335
  systemPrompt: node.llmSystemPrompt || '',
10229
10336
  userContent,
10230
- maxTokensOverride: configuredMaxTokens
10337
+ maxTokensOverride: configuredMaxTokens,
10338
+ trackChatContextUsage: includeDocs === false
10231
10339
  })
10232
10340
  const finishReason = String(ret && ret.finishReason ? ret.finishReason : '').trim().toLowerCase()
10233
10341
  const lengthLimited = finishReason === 'length' || isOpenAICompatLengthFallbackText(ret && ret.content)
@@ -10238,14 +10346,15 @@ module.exports = function (RED) {
10238
10346
  sessionId,
10239
10347
  maxChars: retryMode === 'minimal' ? 600 : 3000
10240
10348
  })
10241
- const compactBasePrompt = buildLLMPrompt({ question, summary, compact: retryMode, languageHint })
10349
+ const compactBasePrompt = buildLLMPrompt({ question, summary, compact: retryMode, languageHint, includeDocs })
10242
10350
  const compactPrompt = compactChatContext ? `${compactChatContext}\n\n${compactBasePrompt}` : compactBasePrompt
10243
10351
  const retryMaxTokens = Math.min(16000, Math.max(10000, Math.round(configuredMaxTokens * 1.25)))
10244
10352
  try {
10245
10353
  ret = await callLLMChat({
10246
10354
  systemPrompt: node.llmSystemPrompt || '',
10247
10355
  userContent: compactPrompt,
10248
- maxTokensOverride: retryMaxTokens
10356
+ maxTokensOverride: retryMaxTokens,
10357
+ trackChatContextUsage: includeDocs === false
10249
10358
  })
10250
10359
  } catch (retryError) {
10251
10360
  // Keep the first provider answer if retry fails.
@@ -10317,14 +10426,14 @@ module.exports = function (RED) {
10317
10426
  if (contextMode !== 'full') {
10318
10427
  gaLines = takeFirstItemsByCharBudget(gaLines, contextMode === 'minimal' ? 5000 : 18000)
10319
10428
  }
10320
- // Keep conversational channels (Telegram, RedBot, custom adapters, etc.)
10321
- // aligned with the web Assistant: the chat adds its control/camera context
10322
- // below, but starts from the same complete KNX analysis prompt.
10429
+ // Conversational channels keep the live KNX analysis context used by the web
10430
+ // Assistant, but deliberately omit packaged help/README/wiki/example snippets.
10323
10431
  const analysisContext = buildLLMPrompt({
10324
10432
  question,
10325
10433
  summary,
10326
10434
  compact: contextMode === 'full' ? false : contextMode,
10327
- languageHint
10435
+ languageHint,
10436
+ includeDocs: false
10328
10437
  })
10329
10438
  const fullCameraCatalog = Array.from(node._cameraCatalog.values())
10330
10439
  const cameraSearch = normalizeSearchText(question)
@@ -10366,7 +10475,8 @@ module.exports = function (RED) {
10366
10475
  node.llmSystemPrompt || 'You are a KNX building automation assistant.',
10367
10476
  '',
10368
10477
  'KNX CHAT AND CONTROL CONTRACT:',
10369
- '- Return only one JSON object with exactly this shape: {"reply":"text for the user","language":"it","routine":{"active":false,"name":"","phase":"none|inspect|plan"},"commands":[{"event":"GroupValue_Read|GroupValue_Write","destination":"1/2/3","dpt":"1.001","payload":null,"reason":"short reason"}],"cameraActions":[{"type":"snapshot|analyze|watch|unwatch|list_watches","camera":"exact camera name or id","eventType":"smartDetect|smartDetectLine|smartDetectZone|smartDetectLoiterZone|motion|ring|smartAudioDetect","scopeName":"exact zone or line when supplied by the user","objectTypes":["person"],"cooldownSeconds":60,"sendSnapshot":true,"reason":"short reason"}],"speechActions":[{"type":"announce","text":"exact words to speak","reason":"short reason"}]}.',
10478
+ '- Return only one JSON object with exactly this top-level shape: {"reply":"text for the user","language":"it","routine":{"active":false,"name":"","phase":"none"},"commands":[],"cameraActions":[],"speechActions":[]}.',
10479
+ '- Begin with every action array empty. Add an item only when the current user request actually needs that action; never copy placeholder addresses, DPTs, cameras, events, or payloads from these instructions.',
10370
10480
  '- Use the same language as the user for reply and reason.',
10371
10481
  '- Set language to the ISO code matching the current user request: en, it, de, fr, es, or zh.',
10372
10482
  '- For an explicit request to refresh, read, query, or retrieve a current KNX state, create GroupValue_Read operations for the exact relevant objects. Use payload null for reads.',
@@ -10506,7 +10616,8 @@ module.exports = function (RED) {
10506
10616
  required: ['reply', 'language', 'routine', 'commands', 'cameraActions', 'speechActions']
10507
10617
  }
10508
10618
  },
10509
- maxTokensOverride: configuredMaxTokens
10619
+ maxTokensOverride: configuredMaxTokens,
10620
+ trackChatContextUsage: true
10510
10621
  })
10511
10622
 
10512
10623
  let envelope
@@ -11988,7 +12099,7 @@ module.exports = function (RED) {
11988
12099
  allowKnxCommands: node.llmAllowKnxCommands,
11989
12100
  languageHint: requestLanguage
11990
12101
  })
11991
- : await callLLM({ question, sessionId, languageHint: requestLanguage })
12102
+ : await callLLM({ question, sessionId, languageHint: requestLanguage, includeDocs: false })
11992
12103
  const initialRoutine = normalizeKnxAiRoutineDescriptor(ret && ret.routine)
11993
12104
  const inspectionCommands = (Array.isArray(ret && ret.commands) ? ret.commands : [])
11994
12105
  .filter(command => command && command.event === 'GroupValue_Read')
@@ -12562,7 +12673,9 @@ module.exports.__test = {
12562
12673
  KNX_AI_COMPACT_CONTEXT_MAX_TOKENS,
12563
12674
  KNX_AI_LOCAL_CONTEXT_RETRY_CHAR_BUDGETS,
12564
12675
  KNX_AI_LOCAL_LLM_TIMEOUT_MIN_MS,
12676
+ KNX_AI_LMSTUDIO_PROMPT_CONTEXT_MAX_TOKENS,
12565
12677
  KNX_AI_MINIMAL_CONTEXT_MAX_TOKENS,
12678
+ KNX_AI_OLLAMA_CONTEXT_MAX_TOKENS,
12566
12679
  KNX_AI_ROUTINE_FEEDBACK_TIMEOUT_MS,
12567
12680
  KNX_AI_THINKING_DELAY_MS,
12568
12681
  KNX_AI_TRAFFIC_DEFAULTS,
@@ -12581,7 +12694,7 @@ module.exports.__test = {
12581
12694
  detectKnxAiLanguageFromText,
12582
12695
  deriveLmStudioNativeApiUrl,
12583
12696
  dispatchKnxAiTtsUltimateAnnouncement,
12584
- ensureLmStudioModelMaxContext,
12697
+ resolveLmStudioModelContext,
12585
12698
  executeKnxAiChatAdapter,
12586
12699
  extractLlmHttpErrorDetail,
12587
12700
  extractOllamaModelMaxContextLength,
@@ -12601,12 +12714,14 @@ module.exports.__test = {
12601
12714
  normalizeKnxAiCommandCandidates,
12602
12715
  normalizeKnxAiRoutineDescriptor,
12603
12716
  normalizeLmStudioModelCatalog,
12717
+ measureKnxAiPromptContext,
12604
12718
  parseQuestionTimeRange,
12605
12719
  parseKnxAiConversationResponse,
12606
12720
  postLocalLlmWithContextFallbacks,
12607
12721
  postOpenAiCompatibleChatWithFallbacks,
12608
12722
  resolveKnxAiLanguage,
12609
12723
  resolveKnxAiLlmTimeoutMs,
12724
+ resolveKnxAiOperationalContextLimit,
12610
12725
  resolveKnxAiPromptContextMode,
12611
12726
  resolveKnxAiOperationEvent,
12612
12727
  resolveKnxAiSessionId,
@@ -31,7 +31,7 @@ Jede Ask-/Chat-Sitzung speichert ihre letzten 8 Gesprächsschritte und bis zu 20
31
31
  Bei DPT-1.xxx-Schreibvorgängen werden die sicheren KI-Entsprechungen `true`/`false`, `1`/`0` und `on`/`off` vor der lokalen Validierung und Ausgabe in echte Boolesche Werte normalisiert.
32
32
 
33
33
  ### Aktuelle KNX-Lesewerte
34
- Wenn der Benutzer ausdrücklich einen aktuellen oder aktualisierten Zustand anfordert, kann die KI exakte Objekte aus dem importierten ETS-Katalog abfragen, einschließlich Status- und anderer schreibgeschützter Objekte. Ausgang 4 gibt `msg.destination`, `msg.dpt`, `msg.event = "GroupValue_Read"` und `msg.readstatus = true` aus. Der Node wartet bis zu 6 Sekunden auf jede `GroupValue_Response` oder ein aktuelles Write-Telegramm, gibt anschließend die dekodierten Werte an Ausgang 3 zurück und stellt Details in `msg.knxAi.readResults` bereit. Leseoperationen erfordern keine Bestätigung und werden niemals in Schreiboperationen umgewandelt.
34
+ Wenn der Benutzer ausdrücklich einen aktuellen oder aktualisierten Zustand anfordert, kann die KI exakte Objekte aus dem importierten ETS-Katalog abfragen, einschließlich Status- und anderer schreibgeschützter Objekte. Ausgang 4 gibt `msg.destination`, `msg.dpt`, `msg.event = "GroupValue_Read"` und `msg.readstatus = true` aus. Der Node wartet bis zu 6 Sekunden auf jede `GroupValue_Response` oder ein aktuelles Write-Telegramm, gibt anschließend die dekodierten Werte an Ausgang 3 zurück und stellt Details in `msg.knxAi.readResults` bereit. Leseoperationen erfordern keine Bestätigung und werden niemals in Schreiboperationen umgewandelt. Lässt ein kleines lokales Modell Vorgangstyp und Payload weg, werden exakte ETS-Objekte sicher als Leseoperationen normalisiert; ein Element mit Payload bleibt eine validierte Schreiboperation.
35
35
 
36
36
  ### Mehrstufige Gesprächsroutinen
37
37
  Anfragen wie „Ich gehe“, „Gute Nacht“ oder „Kinomodus“ können ohne neue Editor-Option eine zustandsabhängige Routine koordinieren. Im ersten LLM-Durchlauf werden ausschließlich exakte ETS-Leseoperationen akzeptiert (maximal 20); KNX AI sendet sie und übergibt die aktuellen GA-/DPT-/Wert-Ergebnisse an einen zweiten isolierten Planungsdurchlauf. Dieser darf bis zu 12 validierte Schreiboperationen vorbereiten, aber keinen weiteren Lesezyklus starten. Bei aktivierter Bestätigung benötigt der gesamte Plan eine einzige lokalisierte Bestätigung; vorher werden weder Schreiboperationen noch angeforderte TTS-Ansagen ausgegeben. Nach der Bestätigung wird jede Schreiboperation erneut validiert, in Reihenfolge weitergegeben und bis zu 4 Sekunden auf eine passende unmittelbare Bus-Rückmeldung beobachtet. Die Abschlussmeldung unterscheidet beobachtete Rückmeldungen von Vorgängen ohne unmittelbare Rückmeldung, ohne daraus einen Gerätefehler abzuleiten. Details stehen in `msg.knxAi.routine`, `readResults`, `verifiedCount` und `unverifiedCount`.
@@ -59,7 +59,7 @@ Wenn das optionale Paket `node-red-contrib-tts-ultimate` installiert ist, ersche
59
59
  Nur eine ausdrückliche Anfrage in der aktuellen Chat-Nachricht kann eine Ansage erzeugen. KNX AI sendet den exakten Text direkt als `msg.payload` mit `msg.topic = "knx_ai_announcement"` an den ausgewählten Node; eine Zwischenverkabelung im Flow ist nicht erforderlich. TTS Ultimate verwaltet anschließend den konfigurierten Sonos-Player, Stimme, Lautstärke, Hailing und Warteschlange. Persistenter Kontext, KI-Erziehung, Kamerainhalte und abgeleitete Ereignisse lösen niemals selbstständig Sprache aus.
60
60
 
61
61
  ### Übersicht des Chat-Kontexts
62
- Der Node-Editor zeigt eine kompakte Karte mit den für den Chat verfügbaren Quellen: aktuellem KNX-Verkehr, ETS-Semantik und Node-RED-Projekt, Sitzungs- und Hausgedächtnis, KI-Erziehung, erkannten Kameras und relevanter Dokumentation. Außerdem werden `knxai-chat-context.md`, `knxai-home-memory.md` und `knxai-config-<node-id>.json` sowie das absolute Stammverzeichnis des KNX-Telegrammarchivs, das Node-spezifische Verzeichnis und das Tagesdateimuster `YYYY-MM-DD.jsonl` aufgeführt. Die Pfade werden zur Laufzeit aus dem tatsächlich verwendeten Datenverzeichnis des konfigurierten Gateways ermittelt.
62
+ Der Node-Editor zeigt eine kompakte Karte mit den für den Chat verfügbaren Quellen: aktuellem KNX-Verkehr, ETS-Semantik und Node-RED-Projekt, Sitzungs- und Hausgedächtnis, KI-Erziehung und erkannten Kameras. Sie zeigt außerdem den maximalen operativen Kontext und die tatsächliche UTF-8-Größe des letzten Chat-Prompts; gemeldete Eingabe-Token des Anbieters werden exakt verwendet, andernfalls wird der Tokenwert als Schätzung gekennzeichnet. Außerdem werden `knxai-chat-context.md`, `knxai-home-memory.md` und `knxai-config-<node-id>.json` sowie das absolute Stammverzeichnis des KNX-Telegrammarchivs, das Node-spezifische Verzeichnis und das Tagesdateimuster `YYYY-MM-DD.jsonl` aufgeführt. Die Pfade werden zur Laufzeit aus dem tatsächlich verwendeten Datenverzeichnis des konfigurierten Gateways ermittelt.
63
63
 
64
64
  ## Durch KI-Erziehung gesteuerte proaktive Hausintelligenz und begrenztes Gedächtnis
65
65
  Aus ETS-Hierarchie, Namen, Rollen und DPTs erstellt der Node ein deterministisches semantisches Modell. Es gibt keinen separaten Schalter und keine erweiterten proaktiven Einstellungen. Eine Benachrichtigung wird nur bewertet, wenn das LLM aktiv ist und die **KI-Erziehung** sie ausdrücklich verlangt. Ausschließlich die Erziehung bestimmt Bedingungen, Offenzeit, Ruhezeiten und Wiederholung. Ohne eine ausdrückliche Regel oder bei fehlgeschlagener LLM-Auswertung wird nichts gesendet.
@@ -114,7 +114,7 @@ Hier sind alle Felder aufgeführt, wie sie im KNX-AI-Editor sichtbar sind.
114
114
  - **Vor dem Senden von KNX-Befehlen bestätigen lassen**: Standardmäßig aktiv. Zeigt zuerst die validierten Änderungen und sendet nichts, bis dieselbe Chat-Sitzung bestätigt. Wenn Befehle auf Bestätigung warten, fügt die Antwort immer die genauen Anweisungen zum Bestätigen oder Abbrechen in der Sprache der aktuellen Anfrage hinzu. Vor der Ausgabe werden die Befehle erneut validiert.
115
115
  - **Adapter-Vorlage**: Standardmäßig ist **Kein Adapter** gewählt. Die Auswahl lädt das vordefinierte Paar aus Ein- und Ausgangszuordnung; beide bleiben im Editor verborgen.
116
116
  - **KI-Erziehung**: Verbindliche, ausschließlich vom Benutzer verwaltete Hinweise, die die KI lesen, aber nie ändern darf. Nur hier werden proaktive Benachrichtigungen mit Bedingungen, Dauer, Ruhezeiten und Wiederholung angefordert.
117
- - Relevante Auszüge aus Hilfe, README und Beispielen werden immer automatisch einbezogen; ihre Sprache wird aus der Benutzeranfrage erkannt, mit automatischen Rückgriffen auf alle unterstützten Sprachen.
117
+ - Mitgelieferte Auszüge aus Hilfe, README, Changelog, Wiki und Beispielen werden nicht in Prompts von Telegram, RedBot oder benutzerdefinierten CHAT-Adaptern aufgenommen. Sie bleiben nur dem Web-Assistenten für technische Fragen zum Paket verfügbar.
118
118
  - Button **Refresh**: Fragt den Provider ab und lädt verfügbare Modelle. Währenddessen dreht sich das Symbol; ein erfolgreicher Abschluss bleibt absichtlich ohne Meldung.
119
119
 
120
120
  ### Ollama Schnellstart (lokal)
@@ -125,6 +125,7 @@ Hier sind alle Felder aufgeführt, wie sie im KNX-AI-Editor sichtbar sind.
125
125
  - **2) Install it**: lädt und installiert das Modell lokal (z. B. `llama3.1`).
126
126
  - Beim Refresh/Install versucht KNX AI zusätzlich, den Ollama-Server automatisch zu starten.
127
127
  - Bei Installationsfehlern mit Verbindungsproblem prüfen, ob Ollama läuft (Desktop-App oder `ollama serve`).
128
+ - Der von `/api/show` gemeldete maximale Kontext dient nur zur Information. KNX AI sendet immer `num_ctx = 16384` (oder das kleinere Modellmaximum) und verwendet dieselbe relevanzbasierte semantische 16K-Ansicht. So wird keine übergroße KV-Cache-Zuweisung erzeugt, ohne Agentenfunktionen zu entfernen.
128
129
  - Wenn Node-RED in Docker läuft, im Endpoint `host.docker.internal` statt `localhost` verwenden.
129
130
 
130
131
  ### Bionic LM Studio Schnellstart (lokal)
@@ -132,6 +133,7 @@ Hier sind alle Felder aufgeführt, wie sie im KNX-AI-Editor sichtbar sind.
132
133
  - Den LM-Studio-API-Server auf der Seite **Developer** oder mit `lms server start` starten.
133
134
  - Standard-Endpoint: `http://localhost:1234/v1/chat/completions`.
134
135
  - Mit **Refresh** alle von `/v1/models` bereitgestellten Modelle laden; ist kein Modell konfiguriert, wird das erste ausgewählt.
136
+ - Ist ein Modell bereits geladen, behält KNX AI dessen aktive Kontextlänge bei. KNX AI lädt ein inaktives Bionic-Modell niemals über die Verwaltungs-API: Die erste Chat-Anfrage lässt Bionic das Modell per JIT mit den gespeicherten modellspezifischen Standardwerten laden. Unabhängig vom von Bionic gemeldeten Kontext begrenzt KNX AI den eigenen Prompt immer auf eine nach Relevanz ausgewählte semantische 16K-Ansicht; dadurch wird nicht der gesamte 131K-Datensatz gesendet, ohne Denk-, KNX-, Routinen-, Kamera- oder TTS-Funktionen zu entfernen.
135
137
  - Der API-Schlüssel ist optional, sofern die Authentifizierung in den LM-Studio-Servereinstellungen nicht aktiviert ist. In Docker `localhost` durch `host.docker.internal` ersetzen.
136
138
 
137
139
  ## Sicherheitshinweis
@@ -62,10 +62,12 @@
62
62
  },
63
63
  "messages": {
64
64
  "lmStudioContextAvailable": "Maximaler Modellkontext",
65
- "lmStudioContextLoading": "Modell wird mit maximalem Kontext geladen",
66
- "lmStudioContextConfigured": "Maximaler Modellkontext konfiguriert",
65
+ "lmStudioContextLoading": "Aktiver Modellkontext wird geprüft",
66
+ "lmStudioContextInactive": "Modell inaktiv; bei der ersten Anfrage werden die Bionic-Standardwerte verwendet",
67
+ "lmStudioContextConfigured": "Aktiver Modellkontext",
67
68
  "lmStudioContextFailed": "Der Modellkontext konnte nicht konfiguriert werden",
68
69
  "lmStudioContextCurrentlyLoaded": "derzeit geladen",
70
+ "localContextBudget": "KNX-AI-Kontextbudget",
69
71
  "ollamaNotSupported": "Ollama local mode: API key not required. Default endpoint is http://localhost:11434/api/chat.",
70
72
  "ollamaNoModels": "No local Ollama model found. Install one or pick one from the library.",
71
73
  "installingOllamaModel": "Starting Ollama and installing model…",
@@ -86,6 +88,14 @@
86
88
  "chatContextLoading": "Zusammenfassung des Chat-Kontexts wird geladen…",
87
89
  "chatContextUnavailable": "Die Zusammenfassung des Chat-Kontexts ist vorübergehend nicht verfügbar.",
88
90
  "chatContextIntro": "Der Chat erhält diese Quellen automatisch. Die folgenden Pfade werden von dieser Node-RED-Installation tatsächlich verwendet.",
91
+ "chatContextLimitLabel": "Maximaler operativer Kontext",
92
+ "chatContextProviderManaged": "vom ausgewählten Anbieter/Modell verwaltet",
93
+ "chatContextTokens": "Token",
94
+ "chatContextLastPromptLabel": "Tatsächliche Größe des letzten Chat-Prompts",
95
+ "chatContextLastPromptUnavailable": "bis zur ersten Chat-Anfrage nicht verfügbar",
96
+ "chatContextExactInputTokens": "vom Anbieter gemessene Eingabe-Token",
97
+ "chatContextEstimatedInputTokens": "geschätzte Eingabe-Token",
98
+ "chatContextImages": "Bilder",
89
99
  "chatContextSourcesTitle": "Enthaltene Quellen",
90
100
  "chatContextFilesTitle": "Dauerhafte Kontextdateien",
91
101
  "chatContextDirectoriesTitle": "KNX-Telegrammarchiv",
@@ -93,7 +103,7 @@
93
103
  "chatContextSourceAdapterHistory": "Dauerhaftes Tagesarchiv automatisch erkannter Adapterereignisse einschließlich Kameraerkennungen.",
94
104
  "chatContextSourceEtsProject": "ETS-Semantik und vollständiges Inventar des Node-RED-Projekts.",
95
105
  "chatContextSourceMemoryEducation": "Sitzungskontext, KI-Erziehung und begrenztes Hausgedächtnis.",
96
- "chatContextSourceCamerasDocs": "Erkannte Kameras und relevante Auszüge aus Hilfe, README und Beispielen.",
106
+ "chatContextSourceCameras": "Erkannte Kameras und ihre verfügbaren Funktionen.",
97
107
  "chatContextSourceTtsUltimate": "Ausgewählter TTS-Ultimate-Node für Ansagen.",
98
108
  "chatContextSourceBadge": "Quelle",
99
109
  "chatContextFileChatContext": "Dauerhafte Gesprächsverläufe, Anweisungen und Regeln für Kamerabenachrichtigungen.",