node-red-contrib-knx-ultimate 6.3.26 → 6.3.28

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.
@@ -26,20 +26,6 @@ const clampText = (value, maxChars) => {
26
26
  return text.length > maxChars ? text.slice(0, maxChars) : text
27
27
  }
28
28
 
29
- const clampUtf8Bytes = (value, maxBytes) => {
30
- const text = String(value === undefined || value === null ? '' : value)
31
- const limit = Math.max(0, Math.floor(Number(maxBytes) || 0))
32
- if (Buffer.byteLength(text, 'utf8') <= limit) return text
33
- let low = 0
34
- let high = text.length
35
- while (low < high) {
36
- const midpoint = Math.ceil((low + high) / 2)
37
- if (Buffer.byteLength(text.slice(0, midpoint), 'utf8') <= limit) low = midpoint
38
- else high = midpoint - 1
39
- }
40
- return text.slice(0, low).replace(/[\uD800-\uDBFF]$/, '')
41
- }
42
-
43
29
  const clonePlain = (value, fallback) => {
44
30
  try {
45
31
  return JSON.parse(JSON.stringify(value))
@@ -315,10 +301,8 @@ const updateKnxAiCoverHabit = (memory, { ga, label, area, durationMinutes, at }
315
301
 
316
302
  const escapeMarkdownCell = (value) => clampText(value, 260).replace(/\|/g, '\\|').replace(/\r?\n/g, ' ')
317
303
 
318
- const buildKnxAiHomeMemoryMarkdown = ({ memory, education, maxKb } = {}) => {
304
+ const buildKnxAiHomeMemoryMarkdown = ({ memory, maxKb } = {}) => {
319
305
  const targetBytes = clampHomeMemoryKb(maxKb) * 1024
320
- const protectedEducation = clampText(education, HOME_MEMORY_MAX_EDUCATION_CHARS)
321
- let renderedEducation = protectedEducation
322
306
  const bounded = normalizeKnxAiHomeMemory(memory)
323
307
 
324
308
  const render = () => {
@@ -333,9 +317,9 @@ const buildKnxAiHomeMemoryMarkdown = ({ memory, education, maxKb } = {}) => {
333
317
  '',
334
318
  `Updated: ${bounded.updatedAt}`,
335
319
  '',
336
- '## AI Education — user managed, read only for AI',
320
+ '## AI Education — configured on the KNX AI node',
337
321
  '',
338
- renderedEducation || '_No user education has been provided._',
322
+ '_AI Education is intentionally not stored in learned memory and the model cannot modify it._',
339
323
  '',
340
324
  '## Semantic ETS model',
341
325
  '',
@@ -373,23 +357,11 @@ const buildKnxAiHomeMemoryMarkdown = ({ memory, education, maxKb } = {}) => {
373
357
  else break
374
358
  markdown = render()
375
359
  }
376
- if (Buffer.byteLength(markdown, 'utf8') > targetBytes && renderedEducation) {
377
- renderedEducation = ''
378
- const emptyEducationMarkdown = render()
379
- const educationBudget = Math.max(0, targetBytes - Buffer.byteLength(emptyEducationMarkdown, 'utf8'))
380
- renderedEducation = clampUtf8Bytes(protectedEducation, educationBudget)
381
- markdown = render()
382
- while (Buffer.byteLength(markdown, 'utf8') > targetBytes && renderedEducation) {
383
- renderedEducation = clampUtf8Bytes(renderedEducation, Buffer.byteLength(renderedEducation, 'utf8') - 16)
384
- markdown = render()
385
- }
386
- }
387
360
  return {
388
361
  markdown,
389
362
  memory: bounded,
390
363
  bytes: Buffer.byteLength(markdown, 'utf8'),
391
- maxBytes: targetBytes,
392
- education: renderedEducation
364
+ maxBytes: targetBytes
393
365
  }
394
366
  }
395
367
 
@@ -0,0 +1,442 @@
1
+ const KNX_AI_SCHEDULE_STORE_VERSION = 1
2
+ const KNX_AI_SCHEDULE_MAX_ACTIVE_TASKS = 32
3
+ const KNX_AI_SCHEDULE_MAX_STORED_TASKS = 64
4
+ const KNX_AI_SCHEDULE_MAX_ACTIONS = 8
5
+ const KNX_AI_SCHEDULE_MIN_INTERVAL_MINUTES = 5
6
+ const KNX_AI_SCHEDULE_MAX_INTERVAL_MINUTES = 365 * 24 * 60
7
+ const KNX_AI_SCHEDULE_MAX_INSTRUCTION_CHARS = 4000
8
+ const KNX_AI_SCHEDULE_ONE_TIME_LEASE_MS = 60 * 1000
9
+
10
+ const clonePlain = (value, fallback) => {
11
+ try {
12
+ return JSON.parse(JSON.stringify(value))
13
+ } catch (error) {
14
+ return fallback
15
+ }
16
+ }
17
+
18
+ const clampText = (value, maxChars) => {
19
+ const text = String(value === undefined || value === null ? '' : value).trim()
20
+ return text.length > maxChars ? text.slice(0, maxChars) : text
21
+ }
22
+
23
+ const toIso = (value) => {
24
+ const timestamp = value instanceof Date ? value.getTime() : Date.parse(String(value || ''))
25
+ return Number.isFinite(timestamp) ? new Date(timestamp).toISOString() : ''
26
+ }
27
+
28
+ const toAbsoluteIso = (value) => {
29
+ const text = String(value === undefined || value === null ? '' : value).trim()
30
+ if (!/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})$/i.test(text)) return ''
31
+ return toIso(text)
32
+ }
33
+
34
+ const normalizeIntervalMinutes = (value) => {
35
+ const numeric = Number(value)
36
+ if (!Number.isFinite(numeric) || numeric <= 0) return 0
37
+ return Math.max(
38
+ KNX_AI_SCHEDULE_MIN_INTERVAL_MINUTES,
39
+ Math.min(KNX_AI_SCHEDULE_MAX_INTERVAL_MINUTES, Math.round(numeric))
40
+ )
41
+ }
42
+
43
+ const createEmptyKnxAiScheduleStore = () => ({
44
+ version: KNX_AI_SCHEDULE_STORE_VERSION,
45
+ createdAt: new Date().toISOString(),
46
+ updatedAt: new Date().toISOString(),
47
+ tasks: []
48
+ })
49
+
50
+ const normalizeKnxAiScheduleTask = (value, { now = Date.now() } = {}) => {
51
+ const source = value && typeof value === 'object' && !Array.isArray(value) ? value : {}
52
+ const id = clampText(source.id, 96).replace(/[^A-Za-z0-9_.-]/g, '')
53
+ const instruction = clampText(source.instruction, KNX_AI_SCHEDULE_MAX_INSTRUCTION_CHARS)
54
+ const startAt = toIso(source.startAt || source.createdAt)
55
+ let nextRunAt = toIso(source.nextRunAt || startAt)
56
+ const expiresAt = toIso(source.expiresAt)
57
+ const intervalMinutes = normalizeIntervalMinutes(source.intervalMinutes)
58
+ const kind = ['monitor', 'reminder', 'command'].includes(String(source.kind || '').trim().toLowerCase())
59
+ ? String(source.kind).trim().toLowerCase()
60
+ : 'reminder'
61
+ const runCount = Math.max(0, Math.floor(Number(source.runCount) || 0))
62
+ const allowedStatuses = new Set(['active', 'running', 'completed', 'cancelled', 'expired'])
63
+ let status = allowedStatuses.has(String(source.status || '').trim().toLowerCase())
64
+ ? String(source.status).trim().toLowerCase()
65
+ : 'active'
66
+ const expiresAtMs = expiresAt ? Date.parse(expiresAt) : 0
67
+ if (status === 'running') status = intervalMinutes > 0 || kind !== 'command' ? 'active' : 'completed'
68
+ if (status === 'active' && expiresAtMs > 0 && expiresAtMs <= Number(now)) {
69
+ status = 'expired'
70
+ nextRunAt = ''
71
+ }
72
+ if (status === 'active' && !nextRunAt) nextRunAt = startAt || new Date(Number(now)).toISOString()
73
+ if (status !== 'active') nextRunAt = ''
74
+ return {
75
+ id,
76
+ kind,
77
+ title: clampText(source.title || instruction, 200),
78
+ instruction,
79
+ sourceRequest: clampText(source.sourceRequest, KNX_AI_SCHEDULE_MAX_INSTRUCTION_CHARS),
80
+ sessionId: clampText(source.sessionId || 'default', 160) || 'default',
81
+ language: clampText(source.language || 'en', 16) || 'en',
82
+ createdAt: toIso(source.createdAt) || new Date(Number(now)).toISOString(),
83
+ startAt: startAt || new Date(Number(now)).toISOString(),
84
+ nextRunAt,
85
+ intervalMinutes,
86
+ expiresAt,
87
+ status,
88
+ runCount,
89
+ lastRunAt: toIso(source.lastRunAt),
90
+ lastStatus: clampText(source.lastStatus, 32),
91
+ lastError: clampText(source.lastError, 1000),
92
+ lastNotificationAt: toIso(source.lastNotificationAt),
93
+ lastNotificationFingerprint: clampText(source.lastNotificationFingerprint, 128),
94
+ reason: clampText(source.reason, 1000)
95
+ }
96
+ }
97
+
98
+ const normalizeKnxAiScheduleStore = (value, { now = Date.now() } = {}) => {
99
+ const source = value && typeof value === 'object' && !Array.isArray(value) ? value : {}
100
+ const seen = new Set()
101
+ const tasks = (Array.isArray(source.tasks) ? source.tasks : [])
102
+ .map(task => normalizeKnxAiScheduleTask(task, { now }))
103
+ .filter(task => {
104
+ if (!task.id || !task.instruction || seen.has(task.id)) return false
105
+ seen.add(task.id)
106
+ return true
107
+ })
108
+ const active = tasks.filter(task => task.status === 'active')
109
+ const inactive = tasks
110
+ .filter(task => task.status !== 'active')
111
+ .sort((left, right) => String(right.lastRunAt || right.createdAt).localeCompare(String(left.lastRunAt || left.createdAt)))
112
+ const bounded = active.concat(inactive.slice(0, Math.max(0, KNX_AI_SCHEDULE_MAX_STORED_TASKS - active.length)))
113
+ .slice(0, KNX_AI_SCHEDULE_MAX_STORED_TASKS)
114
+ return {
115
+ version: KNX_AI_SCHEDULE_STORE_VERSION,
116
+ createdAt: toIso(source.createdAt) || new Date(Number(now)).toISOString(),
117
+ updatedAt: toIso(source.updatedAt) || new Date(Number(now)).toISOString(),
118
+ tasks: bounded
119
+ }
120
+ }
121
+
122
+ const normalizeKnxAiScheduleActions = (value, { now = Date.now() } = {}) => {
123
+ const accepted = []
124
+ const rejected = []
125
+ ;(Array.isArray(value) ? value : []).slice(0, KNX_AI_SCHEDULE_MAX_ACTIONS).forEach((candidate, index) => {
126
+ const source = candidate && typeof candidate === 'object' && !Array.isArray(candidate) ? candidate : {}
127
+ const operation = String(source.operation || '').trim().toLowerCase()
128
+ if (!['create', 'cancel', 'list'].includes(operation)) {
129
+ rejected.push({ sourceIndex: index, reason: 'unsupported schedule operation' })
130
+ return
131
+ }
132
+ if (operation === 'list') {
133
+ accepted.push({
134
+ operation,
135
+ taskId: '',
136
+ all: false,
137
+ kind: 'reminder',
138
+ title: '',
139
+ instruction: '',
140
+ startAt: '',
141
+ intervalMinutes: 0,
142
+ expiresAt: '',
143
+ reason: clampText(source.reason, 1000)
144
+ })
145
+ return
146
+ }
147
+ if (operation === 'cancel') {
148
+ const taskId = clampText(source.taskId || source.id, 96).replace(/[^A-Za-z0-9_.-]/g, '')
149
+ const all = source.all === true
150
+ if (!taskId && !all) {
151
+ rejected.push({ sourceIndex: index, reason: 'schedule cancellation needs an exact task id or all=true' })
152
+ return
153
+ }
154
+ accepted.push({
155
+ operation,
156
+ taskId,
157
+ all,
158
+ kind: 'reminder',
159
+ title: '',
160
+ instruction: '',
161
+ startAt: '',
162
+ intervalMinutes: 0,
163
+ expiresAt: '',
164
+ reason: clampText(source.reason, 1000)
165
+ })
166
+ return
167
+ }
168
+ const title = clampText(source.title, 200)
169
+ const kind = String(source.kind || '').trim().toLowerCase()
170
+ const instruction = clampText(source.instruction || source.task || source.text, KNX_AI_SCHEDULE_MAX_INSTRUCTION_CHARS)
171
+ const requestedStartAt = toAbsoluteIso(source.startAt || source.runAt)
172
+ const rawIntervalMinutes = source.intervalMinutes !== undefined ? source.intervalMinutes : source.repeatEveryMinutes
173
+ const intervalText = String(rawIntervalMinutes === undefined || rawIntervalMinutes === null ? '' : rawIntervalMinutes).trim()
174
+ const numericIntervalMinutes = intervalText === '' ? 0 : Number(rawIntervalMinutes)
175
+ const intervalMinutes = normalizeIntervalMinutes(numericIntervalMinutes)
176
+ const rawExpiresAt = source.expiresAt !== undefined ? source.expiresAt : source.endAt
177
+ const expiresAt = String(rawExpiresAt === undefined || rawExpiresAt === null ? '' : rawExpiresAt).trim()
178
+ ? toAbsoluteIso(rawExpiresAt)
179
+ : ''
180
+ if (!title) {
181
+ rejected.push({ sourceIndex: index, reason: 'schedule title is empty' })
182
+ return
183
+ }
184
+ if (!['monitor', 'reminder', 'command'].includes(kind)) {
185
+ rejected.push({ sourceIndex: index, reason: 'schedule kind must be monitor, reminder, or command' })
186
+ return
187
+ }
188
+ if (!instruction) {
189
+ rejected.push({ sourceIndex: index, reason: 'schedule instruction is empty' })
190
+ return
191
+ }
192
+ if (!requestedStartAt) {
193
+ rejected.push({ sourceIndex: index, reason: 'schedule startAt is not a valid absolute date and time' })
194
+ return
195
+ }
196
+ if (!Number.isFinite(numericIntervalMinutes) || numericIntervalMinutes < 0) {
197
+ rejected.push({ sourceIndex: index, reason: 'schedule intervalMinutes must be zero or a positive number' })
198
+ return
199
+ }
200
+ if (String(rawExpiresAt === undefined || rawExpiresAt === null ? '' : rawExpiresAt).trim() && !expiresAt) {
201
+ rejected.push({ sourceIndex: index, reason: 'schedule expiresAt is not a valid absolute date and time' })
202
+ return
203
+ }
204
+ const startAtMs = Math.max(Number(now), Date.parse(requestedStartAt))
205
+ const normalizedStartAt = new Date(startAtMs).toISOString()
206
+ const expiresAtMs = expiresAt ? Date.parse(expiresAt) : 0
207
+ if (expiresAt && expiresAtMs <= startAtMs) {
208
+ rejected.push({ sourceIndex: index, reason: 'schedule expiresAt must be later than startAt' })
209
+ return
210
+ }
211
+ accepted.push({
212
+ operation,
213
+ taskId: '',
214
+ all: false,
215
+ kind,
216
+ title,
217
+ instruction,
218
+ startAt: normalizedStartAt,
219
+ intervalMinutes,
220
+ expiresAt,
221
+ reason: clampText(source.reason, 1000)
222
+ })
223
+ })
224
+ return { accepted, rejected }
225
+ }
226
+
227
+ const createScheduleId = ({ now, idFactory, existingIds }) => {
228
+ for (let attempt = 0; attempt < 20; attempt += 1) {
229
+ const generated = typeof idFactory === 'function'
230
+ ? String(idFactory())
231
+ : `${Number(now).toString(36)}-${Math.random().toString(36).slice(2, 8)}`
232
+ const candidate = `schedule-${generated}`.replace(/[^A-Za-z0-9_.-]/g, '').slice(0, 96)
233
+ if (candidate && !existingIds.has(candidate)) return candidate
234
+ }
235
+ throw new Error('unable to allocate a unique schedule id')
236
+ }
237
+
238
+ const applyKnxAiScheduleActions = ({ store, actions, sessionId = 'default', language = 'en', sourceRequest = '', now = Date.now(), idFactory } = {}) => {
239
+ const target = normalizeKnxAiScheduleStore(store, { now })
240
+ const normalized = normalizeKnxAiScheduleActions(actions, { now })
241
+ const results = normalized.rejected.map(item => ({ operation: 'invalid', ok: false, error: item.reason, sourceIndex: item.sourceIndex }))
242
+ const ownerSessionId = clampText(sessionId || 'default', 160) || 'default'
243
+ const ownerLanguage = clampText(language || 'en', 16) || 'en'
244
+ const existingIds = new Set(target.tasks.map(task => task.id))
245
+ normalized.accepted.forEach(action => {
246
+ if (action.operation === 'list') {
247
+ results.push({
248
+ operation: 'list',
249
+ ok: true,
250
+ tasks: target.tasks
251
+ .filter(task => task.sessionId === ownerSessionId && task.status === 'active')
252
+ .map(task => clonePlain(task, {}))
253
+ })
254
+ return
255
+ }
256
+ if (action.operation === 'cancel') {
257
+ const matches = target.tasks.filter(task => {
258
+ if (task.sessionId !== ownerSessionId || (task.status !== 'active' && task.lastStatus !== 'running')) return false
259
+ return action.all === true || task.id === action.taskId
260
+ })
261
+ matches.forEach(task => {
262
+ task.status = 'cancelled'
263
+ task.nextRunAt = ''
264
+ task.lastStatus = 'cancelled'
265
+ task.lastError = ''
266
+ })
267
+ results.push({
268
+ operation: 'cancel',
269
+ ok: matches.length > 0,
270
+ taskId: action.taskId,
271
+ all: action.all === true,
272
+ count: matches.length,
273
+ error: matches.length ? '' : 'no matching active schedule was found for this chat'
274
+ })
275
+ return
276
+ }
277
+ const activeCount = target.tasks.filter(task => task.status === 'active').length
278
+ if (activeCount >= KNX_AI_SCHEDULE_MAX_ACTIVE_TASKS) {
279
+ results.push({ operation: 'create', ok: false, error: `the active schedule limit (${KNX_AI_SCHEDULE_MAX_ACTIVE_TASKS}) has been reached` })
280
+ return
281
+ }
282
+ let id
283
+ try {
284
+ id = createScheduleId({ now, idFactory, existingIds })
285
+ } catch (error) {
286
+ results.push({ operation: 'create', ok: false, error: error.message || String(error) })
287
+ return
288
+ }
289
+ existingIds.add(id)
290
+ const task = normalizeKnxAiScheduleTask({
291
+ id,
292
+ kind: action.kind,
293
+ title: action.title,
294
+ instruction: action.instruction,
295
+ sourceRequest: clampText(sourceRequest, KNX_AI_SCHEDULE_MAX_INSTRUCTION_CHARS),
296
+ sessionId: ownerSessionId,
297
+ language: ownerLanguage,
298
+ createdAt: new Date(Number(now)).toISOString(),
299
+ startAt: action.startAt,
300
+ nextRunAt: action.startAt,
301
+ intervalMinutes: action.intervalMinutes,
302
+ expiresAt: action.expiresAt,
303
+ status: 'active',
304
+ reason: action.reason
305
+ }, { now })
306
+ target.tasks.push(task)
307
+ results.push({ operation: 'create', ok: true, task: clonePlain(task, {}) })
308
+ })
309
+ target.updatedAt = new Date(Number(now)).toISOString()
310
+ return {
311
+ store: normalizeKnxAiScheduleStore(target, { now }),
312
+ results,
313
+ accepted: normalized.accepted,
314
+ rejected: normalized.rejected
315
+ }
316
+ }
317
+
318
+ const claimDueKnxAiSchedules = ({ store, now = Date.now(), limit = 1 } = {}) => {
319
+ const target = normalizeKnxAiScheduleStore(store, { now })
320
+ const due = target.tasks
321
+ .filter(task => task.status === 'active' && task.nextRunAt && Date.parse(task.nextRunAt) <= Number(now))
322
+ .sort((left, right) => String(left.nextRunAt).localeCompare(String(right.nextRunAt)))
323
+ .slice(0, Math.max(1, Math.floor(Number(limit) || 1)))
324
+ const claimed = []
325
+ due.forEach(task => {
326
+ const scheduledForMs = Date.parse(task.nextRunAt)
327
+ const intervalMs = task.intervalMinutes > 0 ? task.intervalMinutes * 60 * 1000 : 0
328
+ task.lastRunAt = new Date(Number(now)).toISOString()
329
+ task.lastStatus = 'running'
330
+ task.lastError = ''
331
+ task.runCount += 1
332
+ if (intervalMs > 0) {
333
+ const elapsedIntervals = Math.max(1, Math.floor((Number(now) - scheduledForMs) / intervalMs) + 1)
334
+ const nextRunMs = scheduledForMs + (elapsedIntervals * intervalMs)
335
+ const expiresAtMs = task.expiresAt ? Date.parse(task.expiresAt) : 0
336
+ if (expiresAtMs > 0 && nextRunMs >= expiresAtMs) {
337
+ task.status = 'expired'
338
+ task.nextRunAt = ''
339
+ } else {
340
+ task.nextRunAt = new Date(nextRunMs).toISOString()
341
+ }
342
+ } else {
343
+ task.status = task.kind === 'command' ? 'completed' : 'running'
344
+ task.nextRunAt = task.kind === 'command'
345
+ ? ''
346
+ : new Date(Number(now) + KNX_AI_SCHEDULE_ONE_TIME_LEASE_MS).toISOString()
347
+ }
348
+ claimed.push(clonePlain(task, {}))
349
+ })
350
+ target.updatedAt = new Date(Number(now)).toISOString()
351
+ return { store: normalizeKnxAiScheduleStore(target, { now }), claimed }
352
+ }
353
+
354
+ const completeKnxAiScheduleRun = ({ store, taskId, ok = true, error = '', notified = false, notificationFingerprint = '', now = Date.now() } = {}) => {
355
+ const target = normalizeKnxAiScheduleStore(store, { now })
356
+ const task = target.tasks.find(item => item.id === String(taskId || ''))
357
+ if (!task) return { store: target, task: null }
358
+ if (task.status !== 'cancelled') {
359
+ if (task.intervalMinutes <= 0) {
360
+ task.status = 'completed'
361
+ task.nextRunAt = ''
362
+ }
363
+ task.lastStatus = ok ? 'completed' : 'error'
364
+ task.lastError = ok ? '' : clampText(error, 1000)
365
+ if (notified) {
366
+ task.lastNotificationAt = new Date(Number(now)).toISOString()
367
+ task.lastNotificationFingerprint = clampText(notificationFingerprint, 128)
368
+ }
369
+ }
370
+ target.updatedAt = new Date(Number(now)).toISOString()
371
+ return { store: normalizeKnxAiScheduleStore(target, { now }), task: clonePlain(task, {}) }
372
+ }
373
+
374
+ const listActiveKnxAiSchedules = (store, { sessionId, now = Date.now() } = {}) => {
375
+ const owner = sessionId === undefined ? null : String(sessionId || 'default')
376
+ return normalizeKnxAiScheduleStore(store, { now }).tasks
377
+ .filter(task => task.status === 'active' && (owner === null || task.sessionId === owner))
378
+ .sort((left, right) => String(left.nextRunAt).localeCompare(String(right.nextRunAt)))
379
+ .map(task => clonePlain(task, {}))
380
+ }
381
+
382
+ const buildKnxAiSchedulePromptContext = (store, { sessionId, maxChars = 12000, now = Date.now() } = {}) => {
383
+ const tasks = listActiveKnxAiSchedules(store, { sessionId, now })
384
+ if (!tasks.length) return '(no active schedules for this chat)'
385
+ const lines = tasks.map(task => {
386
+ return `${task.id} | kind ${task.kind} | ${task.title} | instruction ${JSON.stringify(task.instruction)} | next ${task.nextRunAt || '-'} | every ${task.intervalMinutes || 0} minute(s) | expires ${task.expiresAt || 'never'}`
387
+ })
388
+ return clampText(lines.join('\n'), Math.max(200, Number(maxChars) || 12000))
389
+ }
390
+
391
+ const buildKnxAiScheduleMarkdown = (store, { now = Date.now() } = {}) => {
392
+ const target = normalizeKnxAiScheduleStore(store, { now })
393
+ const lines = [
394
+ '# KNX AI Plans and Reminders',
395
+ '',
396
+ 'This file is generated by KNX AI. Manage these tasks in natural-language chat; the JSON file is the authoritative runtime state.',
397
+ '',
398
+ `Updated: ${target.updatedAt}`,
399
+ ''
400
+ ]
401
+ if (!target.tasks.length) lines.push('_No plans or reminders have been stored._', '')
402
+ target.tasks.forEach(task => {
403
+ const instruction = String(task.instruction || '').replace(/\s+/g, ' ').trim()
404
+ lines.push(`## ${task.title || task.id}`)
405
+ lines.push('')
406
+ lines.push(`- ID: \`${task.id}\``)
407
+ lines.push(`- Status: ${task.status}`)
408
+ lines.push(`- Kind: ${task.kind}`)
409
+ lines.push(`- Instruction: ${instruction}`)
410
+ if (task.sourceRequest) lines.push(`- Original user request: ${String(task.sourceRequest).replace(/\s+/g, ' ').trim()}`)
411
+ lines.push(`- First run: ${task.startAt || '-'}`)
412
+ lines.push(`- Next run: ${task.nextRunAt || '-'}`)
413
+ lines.push(`- Repeat every: ${task.intervalMinutes > 0 ? `${task.intervalMinutes} minutes` : 'one time'}`)
414
+ lines.push(`- Expires: ${task.expiresAt || 'never'}`)
415
+ lines.push(`- Last result: ${task.lastStatus || 'not run yet'}${task.lastError ? ` — ${task.lastError}` : ''}`)
416
+ if (task.lastNotificationAt) lines.push(`- Last notification: ${task.lastNotificationAt}${task.lastNotificationFingerprint ? ` (fingerprint ${task.lastNotificationFingerprint})` : ''}`)
417
+ lines.push('')
418
+ })
419
+ return lines.join('\n')
420
+ }
421
+
422
+ module.exports = {
423
+ KNX_AI_SCHEDULE_MAX_ACTIONS,
424
+ KNX_AI_SCHEDULE_MAX_ACTIVE_TASKS,
425
+ KNX_AI_SCHEDULE_MAX_INSTRUCTION_CHARS,
426
+ KNX_AI_SCHEDULE_MAX_INTERVAL_MINUTES,
427
+ KNX_AI_SCHEDULE_MAX_STORED_TASKS,
428
+ KNX_AI_SCHEDULE_MIN_INTERVAL_MINUTES,
429
+ KNX_AI_SCHEDULE_ONE_TIME_LEASE_MS,
430
+ KNX_AI_SCHEDULE_STORE_VERSION,
431
+ applyKnxAiScheduleActions,
432
+ buildKnxAiScheduleMarkdown,
433
+ buildKnxAiSchedulePromptContext,
434
+ claimDueKnxAiSchedules,
435
+ completeKnxAiScheduleRun,
436
+ createEmptyKnxAiScheduleStore,
437
+ listActiveKnxAiSchedules,
438
+ normalizeIntervalMinutes,
439
+ normalizeKnxAiScheduleActions,
440
+ normalizeKnxAiScheduleStore,
441
+ normalizeKnxAiScheduleTask
442
+ }
package/package.json CHANGED
@@ -3,7 +3,7 @@
3
3
  "engines": {
4
4
  "node": ">=20.18.1"
5
5
  },
6
- "version": "6.3.26",
6
+ "version": "6.3.28",
7
7
  "description": "KNX Ultimate is the most advanced KNX integration for Node-RED, providing secure KNX/IP communication, routing, ETS project import, Philips Hue, Matter Controller and Matter Bridge (control matter device via KNX and expose KNX GA via Matter), MQTT and Modbus adapters, diagnostics with AI, virtual devices, and powerful automation nodes. Build professional, reliable, and scalable smart home and building automation projects with minimal effort.",
8
8
  "files": [
9
9
  "nodes/",