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.
- package/CHANGELOG.md +9 -0
- package/README.md +5 -1
- package/nodes/knxUltimateAI.html +1820 -0
- package/nodes/knxUltimateAI.js +16797 -0
- package/nodes/knxUltimateAIHomeAssistant.html +40 -0
- package/nodes/knxUltimateAIHomeAssistant.js +152 -0
- package/nodes/knxUltimateHueController.html +1 -1
- package/nodes/knxUltimateMatterBridge.html +2 -2
- package/nodes/knxUltimateMatterControllerDevice.html +2 -2
- package/nodes/locales/de/knxUltimateAI.html +5 -0
- package/nodes/locales/de/knxUltimateAI.json +231 -0
- package/nodes/locales/de/knxUltimateUtility.json +8 -0
- package/nodes/locales/en/knxUltimateAI.html +5 -0
- package/nodes/locales/en/knxUltimateAI.json +234 -0
- package/nodes/locales/en/knxUltimateUtility.json +8 -0
- package/nodes/locales/es/knxUltimateAI.html +5 -0
- package/nodes/locales/es/knxUltimateAI.json +231 -0
- package/nodes/locales/es/knxUltimateUtility.json +9 -1
- package/nodes/locales/fr/knxUltimateAI.html +5 -0
- package/nodes/locales/fr/knxUltimateAI.json +231 -0
- package/nodes/locales/fr/knxUltimateUtility.json +8 -0
- package/nodes/locales/it/knxUltimateAI.html +5 -0
- package/nodes/locales/it/knxUltimateAI.json +234 -0
- package/nodes/locales/it/knxUltimateUtility.json +8 -0
- package/nodes/locales/zh-CN/knxUltimateAI.html +5 -0
- package/nodes/locales/zh-CN/knxUltimateAI.json +231 -0
- package/nodes/locales/zh-CN/knxUltimateUtility.json +8 -0
- package/nodes/plugins/knxUltimate-cerebrum-runtime-plugin.js +79 -0
- package/nodes/plugins/knxUltimate-migration-notice-plugin.html +19 -0
- package/nodes/plugins/knxUltimateAI-vue/assets/app.css +1 -0
- package/nodes/plugins/knxUltimateAI-vue/assets/app.js +13 -0
- package/nodes/plugins/knxUltimateAI-vue/assets/chunk-html2canvas.esm.js +5 -0
- package/nodes/plugins/knxUltimateAI-vue/assets/chunk-index.es.js +5 -0
- package/nodes/plugins/knxUltimateAI-vue/assets/chunk-jspdf.es.min.js +79 -0
- package/nodes/plugins/knxUltimateAI-vue/assets/chunk-purify.es.js +3 -0
- package/nodes/plugins/knxUltimateAI-vue/index.html +13 -0
- package/nodes/plugins/knxUltimateViewer-vue/assets/app.js +2 -2
- package/nodes/utils/knxAiCamera.js +302 -0
- package/nodes/utils/knxAiCatalogRetrieval.js +349 -0
- package/nodes/utils/knxAiCerebrum.js +406 -0
- package/nodes/utils/knxAiChatContext.js +496 -0
- package/nodes/utils/knxAiEventHistory.js +521 -0
- package/nodes/utils/knxAiHomeMemory.js +1021 -0
- package/nodes/utils/knxAiScheduler.js +445 -0
- package/nodes/utils/knxAiSemanticContext.js +662 -0
- package/nodes/utils/knxAiTelegramVoice.js +545 -0
- package/nodes/utils/knxAiWebAccess.js +631 -0
- package/package.json +17 -9
- package/resources/KNXAIChatAdapterMappings.js +416 -0
- package/resources/hueControllerMigrationDialog.js +3 -0
- package/resources/knxUtilityMigration.js +3 -0
- 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
|
+
}
|