node-red-contrib-knx-ultimate 6.3.19 → 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.
@@ -46,6 +46,13 @@ const {
46
46
  normalizeSearchText,
47
47
  resolveKnxAiCamera
48
48
  } = require('./utils/knxAiCamera')
49
+ const {
50
+ KNX_AI_ADAPTER_HISTORY_MIN_HOURS,
51
+ buildKnxAiHistoryEventKey,
52
+ createKnxAiHistoryAccumulator,
53
+ formatKnxAiAdapterHistoryEventForPrompt,
54
+ normalizeKnxAiAdapterHistoryEvent
55
+ } = require('./utils/knxAiEventHistory')
49
56
  let googleTranslateTTS = null
50
57
  try {
51
58
  googleTranslateTTS = require('google-translate-tts')
@@ -71,7 +78,10 @@ const KNX_AI_CLOUD_LLM_TIMEOUT_MIN_MS = 120000
71
78
  const KNX_AI_LOCAL_LLM_TIMEOUT_MIN_MS = 10 * 60 * 1000
72
79
  const KNX_AI_MINIMAL_CONTEXT_MAX_TOKENS = 16 * 1024
73
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
74
83
  const KNX_AI_ROUTINE_FEEDBACK_TIMEOUT_MS = 4000
84
+ const KNX_AI_ADAPTER_HISTORY_RETENTION_DAYS = Math.max(1, KNX_AI_TRAFFIC_DEFAULTS.historyStoreRetentionDays)
75
85
 
76
86
  const resolveKnxAiLlmTimeoutMs = ({ provider, configuredTimeoutMs } = {}) => {
77
87
  const configured = Number(configuredTimeoutMs)
@@ -83,13 +93,77 @@ const resolveKnxAiLlmTimeoutMs = ({ provider, configuredTimeoutMs } = {}) => {
83
93
 
84
94
  const resolveKnxAiPromptContextMode = ({ provider, contextLength } = {}) => {
85
95
  if (provider !== 'ollama' && provider !== 'lmstudio') return 'full'
86
- 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)
87
105
  if (!tokens) return 'full'
88
106
  if (tokens <= KNX_AI_MINIMAL_CONTEXT_MAX_TOKENS) return 'minimal'
89
107
  if (tokens <= KNX_AI_COMPACT_CONTEXT_MAX_TOKENS) return 'compact'
90
108
  return 'full'
91
109
  }
92
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
+
93
167
  const selectKnxAiCatalogForPrompt = ({ catalog, question, mode = 'full' } = {}) => {
94
168
  const source = Array.isArray(catalog) ? catalog : []
95
169
  if (mode === 'full') return source.slice(0, 600)
@@ -324,6 +398,8 @@ const summarizeKnxAiChatContext = ({ node, nodeId, redUserDir } = {}) => {
324
398
  const configDir = path.join(knxAiDir, 'config')
325
399
  const telegramArchiveRoot = path.join(knxAiDir, 'history')
326
400
  const telegramNodeDir = safeNodeId ? path.join(telegramArchiveRoot, safeNodeId) : ''
401
+ const adapterArchiveRoot = path.join(knxAiDir, 'adapter-history')
402
+ const adapterNodeDir = safeNodeId ? path.join(adapterArchiveRoot, safeNodeId) : ''
327
403
 
328
404
  const files = [
329
405
  {
@@ -346,11 +422,20 @@ const summarizeKnxAiChatContext = ({ node, nodeId, redUserDir } = {}) => {
346
422
  }
347
423
 
348
424
  return {
349
- sources: ['knxTraffic', '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'],
350
433
  files: files.map(item => Object.assign({}, item, { exists: fs.existsSync(item.path) })),
351
434
  telegramDirectories: [
352
435
  { id: 'archiveRoot', path: telegramArchiveRoot, exists: fs.existsSync(telegramArchiveRoot) },
353
- ...(telegramNodeDir ? [{ id: 'nodeArchive', path: telegramNodeDir, exists: fs.existsSync(telegramNodeDir) }] : [])
436
+ ...(telegramNodeDir ? [{ id: 'nodeArchive', path: telegramNodeDir, exists: fs.existsSync(telegramNodeDir) }] : []),
437
+ { id: 'adapterArchiveRoot', path: adapterArchiveRoot, exists: fs.existsSync(adapterArchiveRoot) },
438
+ ...(adapterNodeDir ? [{ id: 'adapterNodeArchive', path: adapterNodeDir, exists: fs.existsSync(adapterNodeDir) }] : [])
354
439
  ],
355
440
  telegramFilePattern: 'YYYY-MM-DD.jsonl'
356
441
  }
@@ -1819,7 +1904,26 @@ const coerceKnxAiCommandPayload = (value, { dpt } = {}) => {
1819
1904
  const resolveKnxAiOperationEvent = (candidate) => {
1820
1905
  const item = candidate && typeof candidate === 'object' ? candidate : {}
1821
1906
  const raw = String(item.event || item.operation || item.action || '').trim().toLowerCase()
1822
- 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'
1823
1927
  return 'GroupValue_Write'
1824
1928
  }
1825
1929
 
@@ -2505,7 +2609,18 @@ const parseQuestionTimeRange = (question, nowTs = Date.now()) => {
2505
2609
  return { fromTs: yesterdayStart, toTs: yesterdayEnd, label: 'yesterday', explicit: true }
2506
2610
  }
2507
2611
 
2508
- const lastDaysMatch = text.match(/\b(?:last|ultimi)\s+(\d{1,3})\s+(?:day|days|giorno|giorni)\b/)
2612
+ const lastHoursMatch = text.match(/\b(?:last|ultime|ultimi|letzte[n]?|derni[eè]res?|[uú]ltimas?)\s+(\d{1,3})\s+(?:hour|hours|ora|ore|stunde|stunden|heure|heures|hora|horas)\b/)
2613
+ if (lastHoursMatch) {
2614
+ const hours = Math.max(1, Number(lastHoursMatch[1] || 1))
2615
+ return {
2616
+ fromTs: nowTs - (hours * 60 * 60 * 1000),
2617
+ toTs: nowTs,
2618
+ label: `last ${hours} hours`,
2619
+ explicit: true
2620
+ }
2621
+ }
2622
+
2623
+ const lastDaysMatch = text.match(/\b(?:last|ultimi|ultime|letzte[n]?|derni[eè]res?|[uú]ltimos?|[uú]ltimas?)\s+(\d{1,3})\s+(?:day|days|giorno|giorni|tag|tage|tagen|jour|jours|d[ií]a|d[ií]as)\b/)
2509
2624
  if (lastDaysMatch) {
2510
2625
  const days = Math.max(1, Number(lastDaysMatch[1] || 1))
2511
2626
  return {
@@ -4090,12 +4205,11 @@ const findLmStudioModel = ({ catalog, model }) => {
4090
4205
  }) || null
4091
4206
  }
4092
4207
 
4093
- const ensureLmStudioModelMaxContext = async ({
4208
+ const resolveLmStudioModelContext = async ({
4094
4209
  baseUrl,
4095
4210
  apiKey,
4096
4211
  model,
4097
- get = getJson,
4098
- post = postJson
4212
+ get = getJson
4099
4213
  } = {}) => {
4100
4214
  const selectedModel = String(model || '').trim()
4101
4215
  if (!selectedModel) throw new Error('No Bionic LM Studio model selected')
@@ -4109,74 +4223,40 @@ const ensureLmStudioModelMaxContext = async ({
4109
4223
  model: selectedModel
4110
4224
  })
4111
4225
  if (!descriptor) throw new Error(`Bionic LM Studio model not found: ${selectedModel}`)
4112
- const targetContextLength = Math.max(0, Number(descriptor.maxContextLength) || 0)
4113
- if (!targetContextLength) {
4226
+ const maxContextLength = Math.max(0, Number(descriptor.maxContextLength) || 0)
4227
+ if (!maxContextLength) {
4114
4228
  throw new Error(`Bionic LM Studio did not report max_context_length for model "${descriptor.id}"`)
4115
4229
  }
4116
- 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)
4117
4236
  if (readyInstance) {
4118
4237
  return {
4119
4238
  model: descriptor.id,
4120
4239
  displayName: descriptor.displayName,
4121
4240
  instanceId: readyInstance.id,
4122
- contextLength: targetContextLength,
4123
- maxContextLength: targetContextLength,
4241
+ contextLength: readyInstance.contextLength,
4242
+ maxContextLength,
4243
+ active: true,
4124
4244
  changed: false
4125
4245
  }
4126
4246
  }
4127
4247
 
4128
- const unloadUrl = deriveLmStudioNativeApiUrl(baseUrl, '/api/v1/models/unload')
4129
- const loadUrl = deriveLmStudioNativeApiUrl(baseUrl, '/api/v1/models/load')
4130
- for (const instance of descriptor.loadedInstances) {
4131
- // eslint-disable-next-line no-await-in-loop
4132
- await post({
4133
- url: unloadUrl,
4134
- headers,
4135
- body: { instance_id: instance.id },
4136
- timeoutMs: KNX_AI_LOCAL_LLM_TIMEOUT_MIN_MS
4137
- })
4138
- }
4139
-
4140
- try {
4141
- const loaded = await post({
4142
- url: loadUrl,
4143
- headers,
4144
- body: {
4145
- model: descriptor.id,
4146
- context_length: targetContextLength,
4147
- echo_load_config: true
4148
- },
4149
- timeoutMs: KNX_AI_LOCAL_LLM_TIMEOUT_MIN_MS
4150
- })
4151
- const appliedContextLength = Math.max(0, Number(loaded && loaded.load_config && loaded.load_config.context_length) || targetContextLength)
4152
- return {
4153
- model: descriptor.id,
4154
- displayName: descriptor.displayName,
4155
- instanceId: String(loaded && loaded.instance_id || descriptor.id),
4156
- contextLength: appliedContextLength,
4157
- maxContextLength: targetContextLength,
4158
- changed: true
4159
- }
4160
- } catch (error) {
4161
- const previousContextLength = descriptor.loadedInstances.reduce((max, instance) => Math.max(max, instance.contextLength), 0)
4162
- if (previousContextLength > 0) {
4163
- try {
4164
- await post({
4165
- url: loadUrl,
4166
- headers,
4167
- body: {
4168
- model: descriptor.id,
4169
- context_length: previousContextLength,
4170
- echo_load_config: true
4171
- },
4172
- timeoutMs: KNX_AI_LOCAL_LLM_TIMEOUT_MIN_MS
4173
- })
4174
- } catch (restoreError) { /* best-effort restoration of the previous instance */ }
4175
- }
4176
- const detail = String(error && error.message ? error.message : error)
4177
- const loadError = new Error(`Bionic LM Studio could not load "${descriptor.displayName}" with its maximum context (${targetContextLength} tokens). ${detail}`)
4178
- if (error && error.status !== undefined) loadError.status = error.status
4179
- 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
4180
4260
  }
4181
4261
  }
4182
4262
 
@@ -4269,7 +4349,7 @@ const resolveOllamaModelMaxContext = async ({ baseUrl, model, post = postJson }
4269
4349
  return {
4270
4350
  model: selectedModel,
4271
4351
  maxContextLength,
4272
- contextLength: maxContextLength
4352
+ contextLength: Math.min(maxContextLength, KNX_AI_OLLAMA_CONTEXT_MAX_TOKENS)
4273
4353
  }
4274
4354
  }
4275
4355
 
@@ -5773,7 +5853,7 @@ module.exports = function (RED) {
5773
5853
  if (!apiKey && deployedNode && deployedNode.credentials && deployedNode.credentials.llmApiKey) {
5774
5854
  apiKey = sanitizeApiKey(deployedNode.credentials.llmApiKey)
5775
5855
  }
5776
- const result = await ensureLmStudioModelMaxContext({
5856
+ const result = await resolveLmStudioModelContext({
5777
5857
  baseUrl,
5778
5858
  apiKey,
5779
5859
  model: body.model
@@ -6148,6 +6228,9 @@ module.exports = function (RED) {
6148
6228
  node._gaLabelCsvCache = { ref: null, map: {} }
6149
6229
  node._busConnectionWatchTimer = null
6150
6230
  node._historyDiskLastPruneAt = 0
6231
+ node._historyDiskPending = new Map()
6232
+ node._adapterHistoryDiskLastPruneAt = 0
6233
+ node._adapterHistoryDiskPending = new Map()
6151
6234
  node._homeMemory = createEmptyKnxAiHomeMemory()
6152
6235
  node._homeMemoryWriteTimer = null
6153
6236
  node._homeMemoryPeriodicTimer = null
@@ -7261,7 +7344,7 @@ module.exports = function (RED) {
7261
7344
  }, 90)
7262
7345
  }
7263
7346
 
7264
- const buildLLMPrompt = ({ question, summary, compact = false, languageHint = '' } = {}) => {
7347
+ const buildLLMPrompt = ({ question, summary, compact = false, languageHint = '', includeDocs = true } = {}) => {
7265
7348
  const promptMode = compact === 'minimal' ? 'minimal' : compact === true || compact === 'compact' ? 'compact' : 'full'
7266
7349
  const compactMode = promptMode !== 'full'
7267
7350
  const minimalMode = promptMode === 'minimal'
@@ -7269,6 +7352,12 @@ module.exports = function (RED) {
7269
7352
  const maxEvents = Math.min(minimalMode ? 20 : compactMode ? 50 : 240, maxEventsRequested)
7270
7353
  const promptEvents = selectTelegramsForPrompt({ question, maxEvents })
7271
7354
  const recent = Array.isArray(promptEvents.events) ? promptEvents.events : []
7355
+ const adapterPromptEvents = selectAdapterEventsForPrompt({
7356
+ question,
7357
+ maxEvents: minimalMode ? 12 : compactMode ? 30 : 160,
7358
+ range: promptEvents.range
7359
+ })
7360
+ const recentAdapterEvents = Array.isArray(adapterPromptEvents.events) ? adapterPromptEvents.events : []
7272
7361
  const wantsSvgChart = shouldGenerateSvgChart(question)
7273
7362
  const wantsFunctionNodeSourceContext = shouldIncludeFunctionNodeSourceContext(question)
7274
7363
  const areasSnapshot = buildAreasSnapshot({ summary })
@@ -7286,7 +7375,14 @@ module.exports = function (RED) {
7286
7375
  return `${new Date(t.ts).toISOString()} ${t.event} ${t.source} -> ${t.destination}${devName} dpt=${t.dpt} payload=${payloadStr}${rawStr}`
7287
7376
  })
7288
7377
  const recentLines = takeLastItemsByCharBudget(lines, minimalMode ? 1000 : compactMode ? 2200 : 7000)
7289
- const archiveScopeLine = `Prompt event source: ${promptEvents.source}. Time range: ${promptEvents.range && promptEvents.range.label ? promptEvents.range.label : 'recent events'}. Events selected: ${recent.length}.`
7378
+ const archiveScopeLine = `Prompt event source: ${promptEvents.source}. Time range: ${promptEvents.range && promptEvents.range.label ? promptEvents.range.label : 'recent events'}${promptEvents.range && promptEvents.range.clampedToRetention ? ` (clamped to ${promptEvents.range.retentionDays} available day(s))` : ''}. Events selected: ${recent.length}.`
7379
+ const knxArchiveSummary = truncatePromptText(safeStringify(promptEvents.summary || {}), minimalMode ? 1200 : compactMode ? 3000 : 9000)
7380
+ const adapterArchiveSummary = truncatePromptText(safeStringify(adapterPromptEvents.summary || {}), minimalMode ? 1000 : compactMode ? 2600 : 8000)
7381
+ const adapterLines = takeLastItemsByCharBudget(
7382
+ recentAdapterEvents.map(formatKnxAiAdapterHistoryEventForPrompt).filter(Boolean),
7383
+ minimalMode ? 700 : compactMode ? 1800 : 6000
7384
+ )
7385
+ const adapterArchiveScopeLine = `Adapter event source: ${adapterPromptEvents.source}. Time range: ${adapterPromptEvents.range && adapterPromptEvents.range.label ? adapterPromptEvents.range.label : 'last 24 hours'}${adapterPromptEvents.range && adapterPromptEvents.range.clampedToRetention ? ` (clamped to ${adapterPromptEvents.range.retentionDays} available day(s))` : ''}. Events selected: ${recentAdapterEvents.length}.`
7290
7386
 
7291
7387
  let flowContext = ''
7292
7388
  const flowMaxChars = minimalMode ? 600 : compactMode ? 1200 : 5000
@@ -7327,7 +7423,7 @@ module.exports = function (RED) {
7327
7423
  }
7328
7424
 
7329
7425
  let docsContext = ''
7330
- if (node.llmIncludeDocsSnippets) {
7426
+ if (includeDocs && node.llmIncludeDocsSnippets) {
7331
7427
  const docsMaxCharsConfigured = Math.max(500, Math.min(5000, Number(node.llmDocsMaxChars) || 500))
7332
7428
  const docsMaxChars = minimalMode
7333
7429
  ? Math.min(docsMaxCharsConfigured, 500)
@@ -7388,10 +7484,22 @@ module.exports = function (RED) {
7388
7484
  wantsSvgChart ? '- Prefer width via viewBox and include labels + legend when useful.' : '',
7389
7485
  wantsSvgChart ? '' : '',
7390
7486
  archiveScopeLine,
7487
+ 'The KNX archive summary below is calculated from every stored telegram in the requested interval. Use its totals for counts; the selected telegrams are only a relevant/recent sample.',
7488
+ 'KNX historical archive summary (JSON):',
7489
+ knxArchiveSummary,
7391
7490
  '',
7392
7491
  'Selected KNX telegrams:',
7393
7492
  recentLines.join('\n'),
7394
7493
  '',
7494
+ adapterArchiveScopeLine,
7495
+ `Adapter history retention: ${KNX_AI_ADAPTER_HISTORY_RETENTION_DAYS} day(s), with a guaranteed minimum query window of ${KNX_AI_ADAPTER_HISTORY_MIN_HOURS} hours.`,
7496
+ 'The adapter archive summary below is calculated from every stored event in the requested interval. Use its totals for counts; the selected events are only a relevant/recent sample.',
7497
+ 'Adapter historical archive summary (JSON):',
7498
+ adapterArchiveSummary,
7499
+ '',
7500
+ 'Selected adapter events:',
7501
+ adapterLines.length ? adapterLines.join('\n') : '(no stored adapter events in this interval)',
7502
+ '',
7395
7503
  'User request:',
7396
7504
  question || ''
7397
7505
  ].join('\n')
@@ -7445,6 +7553,15 @@ module.exports = function (RED) {
7445
7553
 
7446
7554
  const getHistoryArchiveFile = (dayKey) => path.join(getHistoryArchiveDir(), `${String(dayKey || '').trim() || formatArchiveDayKey(Date.now())}.jsonl`)
7447
7555
 
7556
+ const getAdapterHistoryArchiveDir = () => {
7557
+ const baseDir = (node.serverKNX && node.serverKNX.userDir)
7558
+ ? node.serverKNX.userDir
7559
+ : path.join(RED.settings.userDir, 'knxultimatestorage')
7560
+ return path.join(baseDir, 'knxai', 'adapter-history', node.id)
7561
+ }
7562
+
7563
+ const getAdapterHistoryArchiveFile = dayKey => path.join(getAdapterHistoryArchiveDir(), `${String(dayKey || '').trim() || formatArchiveDayKey(Date.now())}.jsonl`)
7564
+
7448
7565
  const getHomeMemoryFile = () => {
7449
7566
  const baseDir = (node.serverKNX && node.serverKNX.userDir)
7450
7567
  ? node.serverKNX.userDir
@@ -7752,7 +7869,7 @@ module.exports = function (RED) {
7752
7869
  try {
7753
7870
  if (!fs.existsSync(dirPath)) return
7754
7871
  const entries = fs.readdirSync(dirPath, { withFileTypes: true })
7755
- const cutoffTs = now - ((retentionDays - 1) * 24 * 60 * 60 * 1000)
7872
+ const cutoffTs = now - (retentionDays * 24 * 60 * 60 * 1000)
7756
7873
  const cutoffDayKey = formatArchiveDayKey(cutoffTs)
7757
7874
  for (let i = 0; i < entries.length; i++) {
7758
7875
  const entry = entries[i]
@@ -7776,7 +7893,10 @@ module.exports = function (RED) {
7776
7893
  const dayKey = formatArchiveDayKey(telegram.ts || Date.now())
7777
7894
  const filePath = getHistoryArchiveFile(dayKey)
7778
7895
  const line = JSON.stringify(telegram) + '\n'
7896
+ const pendingKey = buildKnxAiHistoryEventKey(telegram, 'knx')
7897
+ if (pendingKey) node._historyDiskPending.set(pendingKey, telegram)
7779
7898
  fs.appendFile(filePath, line, 'utf8', (error) => {
7899
+ if (pendingKey && node._historyDiskPending.get(pendingKey) === telegram) node._historyDiskPending.delete(pendingKey)
7780
7900
  if (error) node.sysLogger?.warn(`KNX AI history append error: ${error.message || error}`)
7781
7901
  })
7782
7902
  pruneHistoryArchiveFiles()
@@ -7820,45 +7940,68 @@ module.exports = function (RED) {
7820
7940
  }
7821
7941
  }
7822
7942
 
7823
- const loadHistorySliceFromDisk = ({ fromTs, toTs, limit = 240 } = {}) => {
7824
- if (node.historyStoreToDisk !== true) return []
7943
+ const loadHistoryQueryFromDisk = ({ fromTs, toTs, limit = 240, question = '' } = {}) => {
7944
+ const emptyAccumulator = () => createKnxAiHistoryAccumulator({ kind: 'knx', question, limit }).finish()
7945
+ if (node.historyStoreToDisk !== true) return emptyAccumulator()
7825
7946
  const archiveDir = getHistoryArchiveDir()
7826
7947
  try {
7827
- if (!fs.existsSync(archiveDir)) return []
7828
7948
  const from = Number(fromTs || 0)
7829
7949
  const to = Number(toTs || 0)
7830
- if (!Number.isFinite(from) || !Number.isFinite(to) || to < from) return []
7950
+ if (!Number.isFinite(from) || !Number.isFinite(to) || to < from) return emptyAccumulator()
7951
+ const accumulator = createKnxAiHistoryAccumulator({ kind: 'knx', question, limit })
7952
+ const pending = node._historyDiskPending instanceof Map ? node._historyDiskPending : new Map()
7831
7953
  const dayKeys = collectArchiveDayKeysBetween({ fromTs: from, toTs: to })
7832
- if (!dayKeys.length) return []
7833
- const items = []
7834
- for (let i = 0; i < dayKeys.length; i++) {
7835
- const filePath = getHistoryArchiveFile(dayKeys[i])
7836
- if (!fs.existsSync(filePath)) continue
7837
- const raw = fs.readFileSync(filePath, 'utf8')
7838
- if (!raw || String(raw).trim() === '') continue
7839
- const lines = raw.split(/\r?\n/)
7840
- for (let j = 0; j < lines.length; j++) {
7841
- const line = lines[j]
7842
- if (!line) continue
7843
- try {
7844
- const telegram = JSON.parse(line)
7845
- const ts = Number(telegram && telegram.ts ? telegram.ts : 0)
7846
- if (!Number.isFinite(ts) || ts < from || ts > to) continue
7847
- items.push(telegram)
7848
- } catch (error) {
7849
- // Ignore malformed archive rows.
7954
+ if (fs.existsSync(archiveDir)) {
7955
+ for (let i = 0; i < dayKeys.length; i++) {
7956
+ const filePath = getHistoryArchiveFile(dayKeys[i])
7957
+ if (!fs.existsSync(filePath)) continue
7958
+ const raw = fs.readFileSync(filePath, 'utf8')
7959
+ if (!raw || String(raw).trim() === '') continue
7960
+ const lines = raw.split(/\r?\n/)
7961
+ for (let j = 0; j < lines.length; j++) {
7962
+ const line = lines[j]
7963
+ if (!line) continue
7964
+ try {
7965
+ const telegram = JSON.parse(line)
7966
+ const ts = Number(telegram && telegram.ts ? telegram.ts : 0)
7967
+ if (!Number.isFinite(ts) || ts < from || ts > to) continue
7968
+ const key = buildKnxAiHistoryEventKey(telegram, 'knx')
7969
+ if (key && pending.has(key)) continue
7970
+ accumulator.add(telegram)
7971
+ } catch (error) {
7972
+ // Ignore malformed archive rows.
7973
+ }
7850
7974
  }
7851
7975
  }
7852
7976
  }
7853
- if (!items.length) return []
7854
- items.sort((a, b) => Number(a.ts || 0) - Number(b.ts || 0))
7855
- return items.slice(-Math.max(1, Number(limit || 1)))
7977
+ pending.forEach(telegram => {
7978
+ const ts = Number(telegram && telegram.ts ? telegram.ts : 0)
7979
+ if (Number.isFinite(ts) && ts >= from && ts <= to) accumulator.add(telegram)
7980
+ })
7981
+ return accumulator.finish()
7856
7982
  } catch (error) {
7857
7983
  node.sysLogger?.warn(`KNX AI history load slice error: ${error.message || error}`)
7858
- return []
7984
+ return emptyAccumulator()
7859
7985
  }
7860
7986
  }
7861
7987
 
7988
+ const clampArchiveRangeToRetention = ({ range, retentionDays }) => {
7989
+ const now = nowMs()
7990
+ const days = Math.max(1, Number(retentionDays) || 1)
7991
+ const earliest = now - (days * 24 * 60 * 60 * 1000)
7992
+ const source = range && typeof range === 'object'
7993
+ ? range
7994
+ : { fromTs: now - (24 * 60 * 60 * 1000), toTs: now, label: 'last 24 hours', explicit: false }
7995
+ const fromTs = Math.max(earliest, Number(source.fromTs || earliest))
7996
+ const toTs = Math.min(now, Number(source.toTs || now))
7997
+ return Object.assign({}, source, {
7998
+ fromTs,
7999
+ toTs: Math.max(fromTs, toTs),
8000
+ retentionDays: days,
8001
+ clampedToRetention: Number(source.fromTs || 0) < earliest
8002
+ })
8003
+ }
8004
+
7862
8005
  const selectTelegramsForPrompt = ({ question, maxEvents }) => {
7863
8006
  const now = nowMs()
7864
8007
  const maxItems = Math.max(10, Number(maxEvents) || 120)
@@ -7866,36 +8009,132 @@ module.exports = function (RED) {
7866
8009
  const fallbackRange = node.historyStoreToDisk === true
7867
8010
  ? { fromTs: now - (24 * 60 * 60 * 1000), toTs: now, label: 'last 24 hours', explicit: false }
7868
8011
  : { fromTs: now - (Math.max(5, Number(node.historyWindowSec || 5)) * 1000), toTs: now, label: 'memory window', explicit: false }
7869
- const range = explicitRange || fallbackRange
8012
+ const range = clampArchiveRangeToRetention({
8013
+ range: explicitRange || fallbackRange,
8014
+ retentionDays: node.historyStoreRetentionDays
8015
+ })
7870
8016
 
7871
8017
  let selected = []
7872
8018
  let source = 'memory'
8019
+ let archiveSummary = null
7873
8020
  if (node.historyStoreToDisk === true) {
7874
- const diskItems = loadHistorySliceFromDisk({ fromTs: range.fromTs, toTs: range.toTs, limit: maxItems * 3 })
7875
- const memoryItems = node._history.filter(t => Number(t && t.ts ? t.ts : 0) >= range.fromTs && Number(t && t.ts ? t.ts : 0) <= range.toTs)
7876
- const dedupe = new Map()
7877
- diskItems.concat(memoryItems).forEach((telegram) => {
7878
- if (!telegram || typeof telegram !== 'object') return
7879
- const key = [
7880
- Number(telegram.ts || 0),
7881
- String(telegram.event || ''),
7882
- String(telegram.source || ''),
7883
- String(telegram.destination || ''),
7884
- normalizeValueForCompare(telegram.payload),
7885
- String(telegram.rawHex || '')
7886
- ].join('|')
7887
- dedupe.set(key, telegram)
7888
- })
7889
- selected = Array.from(dedupe.values()).sort((a, b) => Number(a.ts || 0) - Number(b.ts || 0)).slice(-maxItems)
7890
- source = 'archive+memory'
8021
+ const query = loadHistoryQueryFromDisk({ fromTs: range.fromTs, toTs: range.toTs, limit: maxItems, question })
8022
+ selected = query.events
8023
+ archiveSummary = query.summary
8024
+ source = 'daily JSONL archive'
7891
8025
  } else {
7892
8026
  selected = node._history.slice(-maxItems)
8027
+ const accumulator = createKnxAiHistoryAccumulator({ kind: 'knx', question, limit: maxItems })
8028
+ selected.forEach(telegram => accumulator.add(telegram))
8029
+ const memoryQuery = accumulator.finish()
8030
+ selected = memoryQuery.events
8031
+ archiveSummary = memoryQuery.summary
7893
8032
  }
7894
8033
 
7895
8034
  return {
7896
8035
  events: selected,
7897
8036
  source,
7898
- range
8037
+ range,
8038
+ summary: archiveSummary
8039
+ }
8040
+ }
8041
+
8042
+ const pruneAdapterHistoryArchiveFiles = ({ force = false } = {}) => {
8043
+ const now = nowMs()
8044
+ if (!force && (now - Number(node._adapterHistoryDiskLastPruneAt || 0)) < (60 * 60 * 1000)) return
8045
+ node._adapterHistoryDiskLastPruneAt = now
8046
+ const dirPath = getAdapterHistoryArchiveDir()
8047
+ try {
8048
+ if (!fs.existsSync(dirPath)) return
8049
+ const entries = fs.readdirSync(dirPath, { withFileTypes: true })
8050
+ const retentionDays = Math.max(1, KNX_AI_ADAPTER_HISTORY_RETENTION_DAYS)
8051
+ const cutoffDayKey = formatArchiveDayKey(now - (retentionDays * 24 * 60 * 60 * 1000))
8052
+ entries.forEach(entry => {
8053
+ if (!entry || !entry.isFile()) return
8054
+ const match = String(entry.name || '').match(/^(\d{4}-\d{2}-\d{2})\.jsonl$/)
8055
+ if (!match || match[1] >= cutoffDayKey) return
8056
+ try { fs.unlinkSync(path.join(dirPath, entry.name)) } catch (error) { /* ignore */ }
8057
+ })
8058
+ } catch (error) {
8059
+ node.sysLogger?.warn(`KNX AI adapter history prune error: ${error.message || error}`)
8060
+ }
8061
+ }
8062
+
8063
+ const persistAdapterEventToDisk = ({ event, adapter, provider } = {}) => {
8064
+ const normalized = normalizeKnxAiAdapterHistoryEvent({ event, adapter, provider, nowTs: nowMs() })
8065
+ if (!normalized) return null
8066
+ const archiveDir = getAdapterHistoryArchiveDir()
8067
+ if (!ensureDirectorySync(archiveDir)) return normalized
8068
+ const filePath = getAdapterHistoryArchiveFile(formatArchiveDayKey(normalized.ts))
8069
+ const pendingKey = buildKnxAiHistoryEventKey(normalized, 'adapter')
8070
+ if (pendingKey) node._adapterHistoryDiskPending.set(pendingKey, normalized)
8071
+ fs.appendFile(filePath, `${JSON.stringify(normalized)}\n`, 'utf8', error => {
8072
+ if (pendingKey && node._adapterHistoryDiskPending.get(pendingKey) === normalized) node._adapterHistoryDiskPending.delete(pendingKey)
8073
+ if (error) node.sysLogger?.warn(`KNX AI adapter history append error: ${error.message || error}`)
8074
+ })
8075
+ pruneAdapterHistoryArchiveFiles()
8076
+ return normalized
8077
+ }
8078
+
8079
+ const loadAdapterHistoryQueryFromDisk = ({ fromTs, toTs, limit = 160, question = '' } = {}) => {
8080
+ const accumulator = createKnxAiHistoryAccumulator({ kind: 'adapter', question, limit })
8081
+ const from = Number(fromTs || 0)
8082
+ const to = Number(toTs || 0)
8083
+ if (!Number.isFinite(from) || !Number.isFinite(to) || to < from) return accumulator.finish()
8084
+ const pending = node._adapterHistoryDiskPending instanceof Map ? node._adapterHistoryDiskPending : new Map()
8085
+ try {
8086
+ const archiveDir = getAdapterHistoryArchiveDir()
8087
+ const dayKeys = collectArchiveDayKeysBetween({ fromTs: from, toTs: to })
8088
+ if (fs.existsSync(archiveDir)) {
8089
+ dayKeys.forEach(dayKey => {
8090
+ const filePath = getAdapterHistoryArchiveFile(dayKey)
8091
+ if (!fs.existsSync(filePath)) return
8092
+ const raw = fs.readFileSync(filePath, 'utf8')
8093
+ if (!raw || String(raw).trim() === '') return
8094
+ raw.split(/\r?\n/).forEach(line => {
8095
+ if (!line) return
8096
+ try {
8097
+ const item = JSON.parse(line)
8098
+ const ts = Number(item && item.ts ? item.ts : 0)
8099
+ if (!Number.isFinite(ts) || ts < from || ts > to) return
8100
+ const key = buildKnxAiHistoryEventKey(item, 'adapter')
8101
+ if (key && pending.has(key)) return
8102
+ accumulator.add(item)
8103
+ } catch (error) { /* ignore malformed archive rows */ }
8104
+ })
8105
+ })
8106
+ }
8107
+ pending.forEach(item => {
8108
+ const ts = Number(item && item.ts ? item.ts : 0)
8109
+ if (Number.isFinite(ts) && ts >= from && ts <= to) accumulator.add(item)
8110
+ })
8111
+ } catch (error) {
8112
+ node.sysLogger?.warn(`KNX AI adapter history load error: ${error.message || error}`)
8113
+ }
8114
+ return accumulator.finish()
8115
+ }
8116
+
8117
+ const selectAdapterEventsForPrompt = ({ question, maxEvents, range } = {}) => {
8118
+ const effectiveRange = clampArchiveRangeToRetention({
8119
+ range: range || parseQuestionTimeRange(question, nowMs()) || {
8120
+ fromTs: nowMs() - (KNX_AI_ADAPTER_HISTORY_MIN_HOURS * 60 * 60 * 1000),
8121
+ toTs: nowMs(),
8122
+ label: `last ${KNX_AI_ADAPTER_HISTORY_MIN_HOURS} hours`,
8123
+ explicit: false
8124
+ },
8125
+ retentionDays: KNX_AI_ADAPTER_HISTORY_RETENTION_DAYS
8126
+ })
8127
+ const query = loadAdapterHistoryQueryFromDisk({
8128
+ fromTs: effectiveRange.fromTs,
8129
+ toTs: effectiveRange.toTs,
8130
+ limit: Math.max(1, Number(maxEvents) || 160),
8131
+ question
8132
+ })
8133
+ return {
8134
+ events: query.events,
8135
+ summary: query.summary,
8136
+ source: 'daily JSONL adapter archive',
8137
+ range: effectiveRange
7899
8138
  }
7900
8139
  }
7901
8140
 
@@ -9636,14 +9875,19 @@ module.exports = function (RED) {
9636
9875
  if (node._lmStudioContextPromise && node._lmStudioContextPromise.key === key) {
9637
9876
  return node._lmStudioContextPromise.promise
9638
9877
  }
9639
- const promise = ensureLmStudioModelMaxContext({
9878
+ const promise = resolveLmStudioModelContext({
9640
9879
  baseUrl: node.llmBaseUrl,
9641
9880
  apiKey: node.llmApiKey,
9642
9881
  model: node.llmModel
9643
9882
  }).then(result => {
9644
9883
  node.llmContextLength = Math.max(0, Number(result && result.contextLength) || node.llmContextLength)
9645
- node._lmStudioContextReadyKey = `${node.llmBaseUrl}\u0000${node.llmModel}\u0000${node.llmContextLength}`
9646
- 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
+ }
9647
9891
  return result
9648
9892
  }).finally(() => {
9649
9893
  if (node._lmStudioContextPromise && node._lmStudioContextPromise.key === key) {
@@ -9660,7 +9904,11 @@ module.exports = function (RED) {
9660
9904
  const model = node.llmModel || 'llama3.1'
9661
9905
  const key = `${url}\u0000${model}`
9662
9906
  if (!force && node._ollamaContextReadyKey === key && node.llmContextLength > 0) {
9663
- 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
+ }
9664
9912
  }
9665
9913
  const resolveContext = () => resolveOllamaModelMaxContext({ baseUrl: url, model })
9666
9914
  let result
@@ -9671,7 +9919,8 @@ module.exports = function (RED) {
9671
9919
  await ensureOllamaServerRunning({ baseUrl: url, autoStart: true, timeoutMs: 22000 })
9672
9920
  result = await resolveContext()
9673
9921
  }
9674
- 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)
9675
9924
  node._ollamaContextReadyKey = key
9676
9925
  return result
9677
9926
  }
@@ -9682,7 +9931,24 @@ module.exports = function (RED) {
9682
9931
  return null
9683
9932
  }
9684
9933
 
9685
- 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 }) => {
9686
9952
  if (!node.llmEnabled) throw new Error('LLM is disabled in node config')
9687
9953
  if (node.llmProvider === 'lmstudio' && !String(node.llmModel || '').trim()) {
9688
9954
  throw new Error('No Bionic LM Studio model selected. Start the LM Studio API server, refresh the model list and select a model.')
@@ -9728,10 +9994,16 @@ module.exports = function (RED) {
9728
9994
  )
9729
9995
  }
9730
9996
  let json
9997
+ let promptUsageSequence = 0
9731
9998
  const requestOllamaChat = requestBody => postLocalLlmWithContextFallbacks({
9732
9999
  body: requestBody,
9733
10000
  enabled: true,
9734
- 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
+ }
9735
10007
  })
9736
10008
  try {
9737
10009
  json = await requestOllamaChat(body)
@@ -9745,6 +10017,7 @@ module.exports = function (RED) {
9745
10017
  throw decorateOllamaConnectionError({ error, url, action: 'chat with the model' })
9746
10018
  }
9747
10019
  }
10020
+ recordExactChatPromptTokens({ sequence: promptUsageSequence, inputTokens: json && json.prompt_eval_count })
9748
10021
  const content = json && json.message && typeof json.message.content === 'string' ? json.message.content : safeStringify(json)
9749
10022
  return { provider: 'ollama', model: body.model, content, finishReason: String(json && json.done_reason ? json.done_reason : '') }
9750
10023
  }
@@ -9775,7 +10048,11 @@ module.exports = function (RED) {
9775
10048
  }]
9776
10049
  }
9777
10050
  if (sys) body.system = sys
10051
+ const promptUsageSequence = trackChatContextUsage
10052
+ ? recordChatPromptUsage({ body, provider: 'anthropic', model: body.model })
10053
+ : 0
9778
10054
  const json = await postJson({ url, headers, body, timeoutMs: effectiveTimeoutMs })
10055
+ recordExactChatPromptTokens({ sequence: promptUsageSequence, inputTokens: json && json.usage && json.usage.input_tokens })
9779
10056
  const content = extractAnthropicText(json)
9780
10057
  const finishReason = String(json && json.stop_reason ? json.stop_reason : '')
9781
10058
  return { provider: 'anthropic', model: body.model, content, finishReason }
@@ -9829,17 +10106,27 @@ module.exports = function (RED) {
9829
10106
  ? (localOutputTokenLimit > 0 ? { max_tokens: Math.min(resolvedMaxTokens, localOutputTokenLimit) } : {})
9830
10107
  : { max_tokens: resolvedMaxTokens }
9831
10108
  let json
10109
+ let promptUsageSequence = 0
9832
10110
  try {
9833
10111
  json = await postLocalLlmWithContextFallbacks({
9834
10112
  body: Object.assign(tokenLimitBody, schemaBody),
9835
10113
  enabled: node.llmProvider === 'lmstudio',
9836
- request: requestBody => postOpenAiCompatibleChatWithFallbacks({
9837
- url,
9838
- headers,
9839
- body: requestBody,
9840
- timeoutMs: effectiveTimeoutMs,
9841
- model: baseBody.model
9842
- })
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
+ }
9843
10130
  })
9844
10131
  } catch (error) {
9845
10132
  if (node.llmProvider === 'lmstudio' && isLikelyConnectionFailure(error)) {
@@ -9849,6 +10136,7 @@ module.exports = function (RED) {
9849
10136
  }
9850
10137
  throw error
9851
10138
  }
10139
+ recordExactChatPromptTokens({ sequence: promptUsageSequence, inputTokens: json && json.usage && json.usage.prompt_tokens })
9852
10140
  const content = extractOpenAICompatText(json) || buildOpenAICompatFallbackText(json)
9853
10141
  const finishReason = String(json && json.choices && json.choices[0] && json.choices[0].finish_reason ? json.choices[0].finish_reason : '')
9854
10142
  return { provider: node.llmProvider === 'lmstudio' ? 'lmstudio' : 'openai_compat', model: baseBody.model, content, finishReason }
@@ -10021,7 +10309,7 @@ module.exports = function (RED) {
10021
10309
  }
10022
10310
  }
10023
10311
 
10024
- const callLLM = async ({ question, sessionId = 'default', languageHint = '' }) => {
10312
+ const callLLM = async ({ question, sessionId = 'default', languageHint = '', includeDocs = true }) => {
10025
10313
  await ensureSelectedLocalModelContext({ autoStartOllama: true })
10026
10314
  const contextMode = resolveKnxAiPromptContextMode({
10027
10315
  provider: node.llmProvider,
@@ -10038,14 +10326,16 @@ module.exports = function (RED) {
10038
10326
  question,
10039
10327
  summary,
10040
10328
  compact: contextMode === 'full' ? false : contextMode,
10041
- languageHint
10329
+ languageHint,
10330
+ includeDocs
10042
10331
  })
10043
10332
  const userContent = chatContext ? `${chatContext}\n\n${prompt}` : prompt
10044
10333
  const configuredMaxTokens = Math.max(10000, Number(node.llmMaxTokens) || 0)
10045
10334
  let ret = await callLLMChat({
10046
10335
  systemPrompt: node.llmSystemPrompt || '',
10047
10336
  userContent,
10048
- maxTokensOverride: configuredMaxTokens
10337
+ maxTokensOverride: configuredMaxTokens,
10338
+ trackChatContextUsage: includeDocs === false
10049
10339
  })
10050
10340
  const finishReason = String(ret && ret.finishReason ? ret.finishReason : '').trim().toLowerCase()
10051
10341
  const lengthLimited = finishReason === 'length' || isOpenAICompatLengthFallbackText(ret && ret.content)
@@ -10056,14 +10346,15 @@ module.exports = function (RED) {
10056
10346
  sessionId,
10057
10347
  maxChars: retryMode === 'minimal' ? 600 : 3000
10058
10348
  })
10059
- const compactBasePrompt = buildLLMPrompt({ question, summary, compact: retryMode, languageHint })
10349
+ const compactBasePrompt = buildLLMPrompt({ question, summary, compact: retryMode, languageHint, includeDocs })
10060
10350
  const compactPrompt = compactChatContext ? `${compactChatContext}\n\n${compactBasePrompt}` : compactBasePrompt
10061
10351
  const retryMaxTokens = Math.min(16000, Math.max(10000, Math.round(configuredMaxTokens * 1.25)))
10062
10352
  try {
10063
10353
  ret = await callLLMChat({
10064
10354
  systemPrompt: node.llmSystemPrompt || '',
10065
10355
  userContent: compactPrompt,
10066
- maxTokensOverride: retryMaxTokens
10356
+ maxTokensOverride: retryMaxTokens,
10357
+ trackChatContextUsage: includeDocs === false
10067
10358
  })
10068
10359
  } catch (retryError) {
10069
10360
  // Keep the first provider answer if retry fails.
@@ -10135,14 +10426,14 @@ module.exports = function (RED) {
10135
10426
  if (contextMode !== 'full') {
10136
10427
  gaLines = takeFirstItemsByCharBudget(gaLines, contextMode === 'minimal' ? 5000 : 18000)
10137
10428
  }
10138
- // Keep conversational channels (Telegram, RedBot, custom adapters, etc.)
10139
- // aligned with the web Assistant: the chat adds its control/camera context
10140
- // 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.
10141
10431
  const analysisContext = buildLLMPrompt({
10142
10432
  question,
10143
10433
  summary,
10144
10434
  compact: contextMode === 'full' ? false : contextMode,
10145
- languageHint
10435
+ languageHint,
10436
+ includeDocs: false
10146
10437
  })
10147
10438
  const fullCameraCatalog = Array.from(node._cameraCatalog.values())
10148
10439
  const cameraSearch = normalizeSearchText(question)
@@ -10184,12 +10475,15 @@ module.exports = function (RED) {
10184
10475
  node.llmSystemPrompt || 'You are a KNX building automation assistant.',
10185
10476
  '',
10186
10477
  'KNX CHAT AND CONTROL CONTRACT:',
10187
- '- 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.',
10188
10480
  '- Use the same language as the user for reply and reason.',
10189
10481
  '- Set language to the ISO code matching the current user request: en, it, de, fr, es, or zh.',
10190
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.',
10191
10483
  '- GroupValue_Read is allowed for exact status, neutral, or command objects in AVAILABLE KNX OBJECTS because it does not modify the bus state.',
10192
10484
  '- For a question that can be answered from recent data, return commands as an empty array. If current data is missing or the user explicitly asks for a fresh read, request it instead of claiming that read-only objects cannot be queried.',
10485
+ '- Historical questions must use the KNX and adapter archive summaries in the supplied analysis context. Totals describe every stored row in the requested interval; selected rows are samples for detail and must not be used as the total count.',
10486
+ '- Adapter history includes automatically detected provider events such as camera motion and smart detections. Do not claim that the absence of an archived event proves physical absence; report only what the adapters recorded.',
10193
10487
  '- Create a GroupValue_Write only when the user clearly asks to control an actuator now.',
10194
10488
  '- Never invent, guess, transform, or substitute a group address or DPT.',
10195
10489
  '- A GroupValue_Write destination must appear in AVAILABLE KNX OBJECTS with role command. Status and neutral objects must never receive GroupValue_Write.',
@@ -10322,7 +10616,8 @@ module.exports = function (RED) {
10322
10616
  required: ['reply', 'language', 'routine', 'commands', 'cameraActions', 'speechActions']
10323
10617
  }
10324
10618
  },
10325
- maxTokensOverride: configuredMaxTokens
10619
+ maxTokensOverride: configuredMaxTokens,
10620
+ trackChatContextUsage: true
10326
10621
  })
10327
10622
 
10328
10623
  let envelope
@@ -10901,9 +11196,14 @@ module.exports = function (RED) {
10901
11196
  }
10902
11197
  }
10903
11198
 
10904
- const handleCameraAdapterEvent = (providerEvent) => {
11199
+ const handleCameraAdapterEvent = (providerEvent, provider = null) => {
10905
11200
  const event = normalizeKnxAiCameraEvent(providerEvent)
10906
- if (!event || event.active === false) return false
11201
+ if (!event) return false
11202
+ const adapter = provider && node._cameraAdapters instanceof Map
11203
+ ? node._cameraAdapters.get(String(provider.adapterId || ''))
11204
+ : null
11205
+ persistAdapterEventToDisk({ event: Object.assign({}, providerEvent, event), adapter, provider })
11206
+ if (event.active === false) return true
10907
11207
  const now = nowMs()
10908
11208
  listAllKnxAiCameraWatches(node._chatContext).filter(watch => cameraWatchMatchesEvent(watch, event)).forEach((watch) => {
10909
11209
  const lastAt = Number(node._cameraWatchLastTriggered.get(watch.id) || 0)
@@ -10958,7 +11258,7 @@ module.exports = function (RED) {
10958
11258
  if (previousProvider === provider && node._cameraProviderUnsubscribers.has(providerId)) return
10959
11259
  if (typeof provider.subscribe === 'function') {
10960
11260
  const unsubscribe = provider.subscribe(event => {
10961
- try { handleCameraAdapterEvent(event) } catch (error) {
11261
+ try { handleCameraAdapterEvent(event, provider) } catch (error) {
10962
11262
  try { node.sysLogger?.warn(`KNX AI camera event error: ${error.message || error}`) } catch (logError) { /* ignore */ }
10963
11263
  }
10964
11264
  })
@@ -11799,7 +12099,7 @@ module.exports = function (RED) {
11799
12099
  allowKnxCommands: node.llmAllowKnxCommands,
11800
12100
  languageHint: requestLanguage
11801
12101
  })
11802
- : await callLLM({ question, sessionId, languageHint: requestLanguage })
12102
+ : await callLLM({ question, sessionId, languageHint: requestLanguage, includeDocs: false })
11803
12103
  const initialRoutine = normalizeKnxAiRoutineDescriptor(ret && ret.routine)
11804
12104
  const inspectionCommands = (Array.isArray(ret && ret.commands) ? ret.commands : [])
11805
12105
  .filter(command => command && command.event === 'GroupValue_Read')
@@ -12312,6 +12612,7 @@ module.exports = function (RED) {
12312
12612
 
12313
12613
  try {
12314
12614
  pruneHistoryArchiveFiles({ force: true })
12615
+ pruneAdapterHistoryArchiveFiles({ force: true })
12315
12616
  loadRecentHistoryFromDisk()
12316
12617
  loadHomeMemoryFromDisk()
12317
12618
  loadChatContextFromDisk()
@@ -12367,11 +12668,14 @@ module.exports = function (RED) {
12367
12668
  }
12368
12669
 
12369
12670
  module.exports.__test = {
12671
+ KNX_AI_ADAPTER_HISTORY_RETENTION_DAYS,
12370
12672
  KNX_AI_CLOUD_LLM_TIMEOUT_MIN_MS,
12371
12673
  KNX_AI_COMPACT_CONTEXT_MAX_TOKENS,
12372
12674
  KNX_AI_LOCAL_CONTEXT_RETRY_CHAR_BUDGETS,
12373
12675
  KNX_AI_LOCAL_LLM_TIMEOUT_MIN_MS,
12676
+ KNX_AI_LMSTUDIO_PROMPT_CONTEXT_MAX_TOKENS,
12374
12677
  KNX_AI_MINIMAL_CONTEXT_MAX_TOKENS,
12678
+ KNX_AI_OLLAMA_CONTEXT_MAX_TOKENS,
12375
12679
  KNX_AI_ROUTINE_FEEDBACK_TIMEOUT_MS,
12376
12680
  KNX_AI_THINKING_DELAY_MS,
12377
12681
  KNX_AI_TRAFFIC_DEFAULTS,
@@ -12390,7 +12694,7 @@ module.exports.__test = {
12390
12694
  detectKnxAiLanguageFromText,
12391
12695
  deriveLmStudioNativeApiUrl,
12392
12696
  dispatchKnxAiTtsUltimateAnnouncement,
12393
- ensureLmStudioModelMaxContext,
12697
+ resolveLmStudioModelContext,
12394
12698
  executeKnxAiChatAdapter,
12395
12699
  extractLlmHttpErrorDetail,
12396
12700
  extractOllamaModelMaxContextLength,
@@ -12410,11 +12714,14 @@ module.exports.__test = {
12410
12714
  normalizeKnxAiCommandCandidates,
12411
12715
  normalizeKnxAiRoutineDescriptor,
12412
12716
  normalizeLmStudioModelCatalog,
12717
+ measureKnxAiPromptContext,
12718
+ parseQuestionTimeRange,
12413
12719
  parseKnxAiConversationResponse,
12414
12720
  postLocalLlmWithContextFallbacks,
12415
12721
  postOpenAiCompatibleChatWithFallbacks,
12416
12722
  resolveKnxAiLanguage,
12417
12723
  resolveKnxAiLlmTimeoutMs,
12724
+ resolveKnxAiOperationalContextLimit,
12418
12725
  resolveKnxAiPromptContextMode,
12419
12726
  resolveKnxAiOperationEvent,
12420
12727
  resolveKnxAiSessionId,