node-red-contrib-knx-ultimate 7.1.0-beta.2 → 7.1.2

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.
Files changed (52) hide show
  1. package/CHANGELOG.md +9 -0
  2. package/README.md +5 -1
  3. package/nodes/knxUltimateAI.html +1820 -0
  4. package/nodes/knxUltimateAI.js +16797 -0
  5. package/nodes/knxUltimateAIHomeAssistant.html +40 -0
  6. package/nodes/knxUltimateAIHomeAssistant.js +152 -0
  7. package/nodes/knxUltimateHueController.html +1 -1
  8. package/nodes/knxUltimateMatterBridge.html +2 -2
  9. package/nodes/knxUltimateMatterControllerDevice.html +2 -2
  10. package/nodes/locales/de/knxUltimateAI.html +5 -0
  11. package/nodes/locales/de/knxUltimateAI.json +231 -0
  12. package/nodes/locales/de/knxUltimateUtility.json +8 -0
  13. package/nodes/locales/en/knxUltimateAI.html +5 -0
  14. package/nodes/locales/en/knxUltimateAI.json +234 -0
  15. package/nodes/locales/en/knxUltimateUtility.json +8 -0
  16. package/nodes/locales/es/knxUltimateAI.html +5 -0
  17. package/nodes/locales/es/knxUltimateAI.json +231 -0
  18. package/nodes/locales/es/knxUltimateUtility.json +9 -1
  19. package/nodes/locales/fr/knxUltimateAI.html +5 -0
  20. package/nodes/locales/fr/knxUltimateAI.json +231 -0
  21. package/nodes/locales/fr/knxUltimateUtility.json +8 -0
  22. package/nodes/locales/it/knxUltimateAI.html +5 -0
  23. package/nodes/locales/it/knxUltimateAI.json +234 -0
  24. package/nodes/locales/it/knxUltimateUtility.json +8 -0
  25. package/nodes/locales/zh-CN/knxUltimateAI.html +5 -0
  26. package/nodes/locales/zh-CN/knxUltimateAI.json +231 -0
  27. package/nodes/locales/zh-CN/knxUltimateUtility.json +8 -0
  28. package/nodes/plugins/knxUltimate-cerebrum-runtime-plugin.js +79 -0
  29. package/nodes/plugins/knxUltimate-migration-notice-plugin.html +19 -0
  30. package/nodes/plugins/knxUltimateAI-vue/assets/app.css +1 -0
  31. package/nodes/plugins/knxUltimateAI-vue/assets/app.js +13 -0
  32. package/nodes/plugins/knxUltimateAI-vue/assets/chunk-html2canvas.esm.js +5 -0
  33. package/nodes/plugins/knxUltimateAI-vue/assets/chunk-index.es.js +5 -0
  34. package/nodes/plugins/knxUltimateAI-vue/assets/chunk-jspdf.es.min.js +79 -0
  35. package/nodes/plugins/knxUltimateAI-vue/assets/chunk-purify.es.js +3 -0
  36. package/nodes/plugins/knxUltimateAI-vue/index.html +13 -0
  37. package/nodes/plugins/knxUltimateViewer-vue/assets/app.js +2 -2
  38. package/nodes/utils/knxAiCamera.js +302 -0
  39. package/nodes/utils/knxAiCatalogRetrieval.js +349 -0
  40. package/nodes/utils/knxAiCerebrum.js +406 -0
  41. package/nodes/utils/knxAiChatContext.js +496 -0
  42. package/nodes/utils/knxAiEventHistory.js +521 -0
  43. package/nodes/utils/knxAiHomeMemory.js +1021 -0
  44. package/nodes/utils/knxAiScheduler.js +445 -0
  45. package/nodes/utils/knxAiSemanticContext.js +662 -0
  46. package/nodes/utils/knxAiTelegramVoice.js +545 -0
  47. package/nodes/utils/knxAiWebAccess.js +631 -0
  48. package/package.json +17 -9
  49. package/resources/KNXAIChatAdapterMappings.js +416 -0
  50. package/resources/hueControllerMigrationDialog.js +3 -0
  51. package/resources/knxUtilityMigration.js +3 -0
  52. package/resources/legacyMigrationNotice.js +143 -0
@@ -0,0 +1,445 @@
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
+ const rendered = lines.join('\n')
389
+ return Number(maxChars) > 0
390
+ ? clampText(rendered, Math.max(200, Number(maxChars)))
391
+ : rendered
392
+ }
393
+
394
+ const buildKnxAiScheduleMarkdown = (store, { now = Date.now() } = {}) => {
395
+ const target = normalizeKnxAiScheduleStore(store, { now })
396
+ const lines = [
397
+ '# Cerebrum Ultimate Plans and Reminders',
398
+ '',
399
+ 'This file is generated by Cerebrum Ultimate. Manage these tasks in natural-language chat; the JSON file is the authoritative runtime state.',
400
+ '',
401
+ `Updated: ${target.updatedAt}`,
402
+ ''
403
+ ]
404
+ if (!target.tasks.length) lines.push('_No plans or reminders have been stored._', '')
405
+ target.tasks.forEach(task => {
406
+ const instruction = String(task.instruction || '').replace(/\s+/g, ' ').trim()
407
+ lines.push(`## ${task.title || task.id}`)
408
+ lines.push('')
409
+ lines.push(`- ID: \`${task.id}\``)
410
+ lines.push(`- Status: ${task.status}`)
411
+ lines.push(`- Kind: ${task.kind}`)
412
+ lines.push(`- Instruction: ${instruction}`)
413
+ if (task.sourceRequest) lines.push(`- Original user request: ${String(task.sourceRequest).replace(/\s+/g, ' ').trim()}`)
414
+ lines.push(`- First run: ${task.startAt || '-'}`)
415
+ lines.push(`- Next run: ${task.nextRunAt || '-'}`)
416
+ lines.push(`- Repeat every: ${task.intervalMinutes > 0 ? `${task.intervalMinutes} minutes` : 'one time'}`)
417
+ lines.push(`- Expires: ${task.expiresAt || 'never'}`)
418
+ lines.push(`- Last result: ${task.lastStatus || 'not run yet'}${task.lastError ? ` — ${task.lastError}` : ''}`)
419
+ if (task.lastNotificationAt) lines.push(`- Last notification: ${task.lastNotificationAt}${task.lastNotificationFingerprint ? ` (fingerprint ${task.lastNotificationFingerprint})` : ''}`)
420
+ lines.push('')
421
+ })
422
+ return lines.join('\n')
423
+ }
424
+
425
+ module.exports = {
426
+ KNX_AI_SCHEDULE_MAX_ACTIONS,
427
+ KNX_AI_SCHEDULE_MAX_ACTIVE_TASKS,
428
+ KNX_AI_SCHEDULE_MAX_INSTRUCTION_CHARS,
429
+ KNX_AI_SCHEDULE_MAX_INTERVAL_MINUTES,
430
+ KNX_AI_SCHEDULE_MAX_STORED_TASKS,
431
+ KNX_AI_SCHEDULE_MIN_INTERVAL_MINUTES,
432
+ KNX_AI_SCHEDULE_ONE_TIME_LEASE_MS,
433
+ KNX_AI_SCHEDULE_STORE_VERSION,
434
+ applyKnxAiScheduleActions,
435
+ buildKnxAiScheduleMarkdown,
436
+ buildKnxAiSchedulePromptContext,
437
+ claimDueKnxAiSchedules,
438
+ completeKnxAiScheduleRun,
439
+ createEmptyKnxAiScheduleStore,
440
+ listActiveKnxAiSchedules,
441
+ normalizeIntervalMinutes,
442
+ normalizeKnxAiScheduleActions,
443
+ normalizeKnxAiScheduleStore,
444
+ normalizeKnxAiScheduleTask
445
+ }