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.
- package/CHANGELOG.md +12 -0
- package/examples/KNX AI - Summary Anomalies and Ask.json +1 -1
- package/nodes/knxUltimateAI.html +129 -50
- package/nodes/knxUltimateAI.js +1311 -197
- package/nodes/locales/de/knxUltimateAI.html +22 -10
- package/nodes/locales/de/knxUltimateAI.json +21 -6
- package/nodes/locales/en/knxUltimateAI.html +22 -10
- package/nodes/locales/en/knxUltimateAI.json +21 -6
- package/nodes/locales/es/knxUltimateAI.html +22 -10
- package/nodes/locales/es/knxUltimateAI.json +21 -6
- package/nodes/locales/fr/knxUltimateAI.html +22 -10
- package/nodes/locales/fr/knxUltimateAI.json +21 -6
- package/nodes/locales/it/knxUltimateAI.html +22 -10
- package/nodes/locales/it/knxUltimateAI.json +21 -6
- package/nodes/locales/zh-CN/knxUltimateAI.html +22 -10
- package/nodes/locales/zh-CN/knxUltimateAI.json +21 -6
- package/nodes/plugins/knxUltimateAI-vue/assets/app.css +1 -1
- package/nodes/plugins/knxUltimateAI-vue/assets/app.js +4 -4
- package/nodes/utils/knxAiHomeMemory.js +4 -32
- package/nodes/utils/knxAiScheduler.js +442 -0
- package/package.json +1 -1
|
@@ -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,
|
|
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 —
|
|
320
|
+
'## AI Education — configured on the KNX AI node',
|
|
337
321
|
'',
|
|
338
|
-
|
|
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.
|
|
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/",
|