node-red-contrib-knx-ultimate 6.3.19 → 6.3.22

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -31,7 +31,7 @@ Canvas 上的节点状态专门用于显示最近收到的请求,以及 LLM
31
31
  对于 DPT 1.xxx 写入,AI 生成的安全等价值 `true`/`false`、`1`/`0` 和 `on`/`off` 会在本地校验和输出前统一转换为真正的布尔值。
32
32
 
33
33
  ### 最新 KNX 读取
34
- 当用户明确要求当前或最新状态时,AI 可以查询已导入 ETS 目录中的精确对象,包括状态对象和其他只读对象。输出 4 会发送 `msg.destination`、`msg.dpt`、`msg.event = "GroupValue_Read"` 和 `msg.readstatus = true`。节点会为每个 `GroupValue_Response` 或最新写入等待最多 6 秒,然后在输出 3 返回解码值,并在 `msg.knxAi.readResults` 中提供详细信息。读取从不需要确认,也绝不会转换为写入。
34
+ 当用户明确要求当前或最新状态时,AI 可以查询已导入 ETS 目录中的精确对象,包括状态对象和其他只读对象。输出 4 会发送 `msg.destination`、`msg.dpt`、`msg.event = "GroupValue_Read"` 和 `msg.readstatus = true`。节点会为每个 `GroupValue_Response` 或最新写入等待最多 6 秒,然后在输出 3 返回解码值,并在 `msg.knxAi.readResults` 中提供详细信息。读取从不需要确认,也绝不会转换为写入。如果小型本地模型省略操作类型和 payload,精确的 ETS 对象会被安全规范化为读取;包含 payload 的项目仍作为经过验证的写入处理。
35
35
 
36
36
  ### 多步骤对话例行程序
37
37
  “我要离家”“晚安”或“影院模式”等请求可以在不增加编辑器选项的情况下,根据当前状态协调例行程序。第一次 LLM 处理仅接受精确的 ETS 读取(最多 20 个);KNX AI 发送读取,并把最新的 GA/DPT/值结果交给隔离的第二次规划处理。第二次处理最多可准备 12 个已验证写入,但不能再次发起读取循环。启用确认时,整个计划只需一次本地化确认;确认前不会发送任何写入或所请求的 TTS 播报。确认后,每个写入都会重新验证、按顺序转发,并在最多 4 秒内观察总线上匹配的即时反馈。最终回复会区分已观察到反馈的操作与没有即时反馈的操作,但不会把后者报告为设备故障。详细信息位于 `msg.knxAi.routine`、`readResults`、`verifiedCount` 和 `unverifiedCount`。
@@ -51,13 +51,15 @@ Canvas 上的节点状态专门用于显示最近收到的请求,以及 LLM
51
51
 
52
52
  用户可以请求当前快照,或询问视觉模型画面中可见的内容。Telegram 和 RedBot 预设会把图像作为带说明文字的原生照片发送。用户还可以为移动、智能越线或进入入侵/徘徊区域创建持久通知,并可按检测到的人员以及指定名称的线或区域进行限制。这些规则保存在同一个 `knxai-chat-context.md` 文件中,并在 Node-RED 重启后恢复。UniFi 事件订阅和快照请求直接通过检测到的提供方完成;不会使用 KNX AI 输出 4,也不需要中间 Flow 连线。
53
53
 
54
+ 自动检测到的适配器发布的每个事件都会被标准化,并追加到 `knxultimatestorage/knxai/adapter-history/<节点ID>/` 下的每日 `YYYY-MM-DD.jsonl` 文件。存档保留 10 天,保证超过 24 小时的历史,只保存事件元数据,不保存图像。Web 助手和所有 CHAT 渠道会同时查询它与每日 KNX 报文存档。总数涵盖所请求区间内的全部存档行;选出的详情仅是相关样本。
55
+
54
56
  ### 使用 TTS Ultimate 播报
55
57
  安装可选软件包 `node-red-contrib-tts-ultimate` 后,它会显示在自动检测的适配器中。选择器会列出项目所有 Flow 中的全部 `ttsultimate` 节点,并显示 Flow、节点名称和已配置的播放器。请选择负责聊天播报的节点,然后部署 Flow。
56
58
 
57
59
  只有当前聊天消息中的明确请求才能创建播报。KNX AI 会将准确文本作为 `msg.payload` 直接发送到所选节点,并设置 `msg.topic = "knx_ai_announcement"`;无需在 Flow 中增加中间连线。之后由 TTS Ultimate 处理已配置的 Sonos 播放器、语音、音量、提示音和队列。持久上下文、AI 教育、摄像机内容和推断事件绝不会自行触发语音。
58
60
 
59
61
  ### 聊天上下文概览
60
- 节点编辑器会显示一张紧凑卡片,汇总聊天可用的来源:当前 KNX 流量、ETS 语义与 Node-RED 项目、会话和家庭记忆、AI 教育、检测到的摄像机及相关文档。卡片还会列出 `knxai-chat-context.md`、`knxai-home-memory.md` 和 `knxai-config-<节点-id>.json`,以及 KNX 报文归档的绝对根目录、该节点专用目录和每日文件模式 `YYYY-MM-DD.jsonl`。这些路径会在运行时根据已配置网关实际使用的数据目录解析。
62
+ 节点编辑器会显示一张紧凑卡片,汇总聊天可用的来源:当前 KNX 流量、ETS 语义与 Node-RED 项目、会话和家庭记忆、AI 教育及检测到的摄像机。卡片还会显示最大运行上下文和上次聊天提示词的实际 UTF-8 大小;提供商返回输入令牌数时使用精确值,否则明确标为估算值。卡片还会列出 `knxai-chat-context.md`、`knxai-home-memory.md` 和 `knxai-config-<节点-id>.json`,以及 KNX 报文归档的绝对根目录、该节点专用目录和每日文件模式 `YYYY-MM-DD.jsonl`。这些路径会在运行时根据已配置网关实际使用的数据目录解析。
61
63
 
62
64
  ## 由 AI 教育驱动的主动家庭智能与有限记忆
63
65
  节点会根据 ETS 层级、名称、角色和 DPT 建立确定性的语义模型。不再提供单独的开关或高级主动通知设置。只有启用 LLM 且 **AI 教育**明确要求时,系统才会评估通知。条件、持续时间、静默时段和重复频率完全由 AI 教育定义。没有明确规则或 LLM 无法评估时,不会发送任何消息。
@@ -112,7 +114,7 @@ Canvas 上的节点状态专门用于显示最近收到的请求,以及 LLM
112
114
  - **发送 KNX 命令前请求确认**:默认启用。先显示已验证的修改,在同一聊天会话确认前不会发送任何 KNX 命令。有命令等待确认时,回复始终会使用当前请求的语言附加准确的确认或取消说明。命令会在输出前再次验证。
113
115
  - **适配器预设**:默认为**无适配器**。选择后会加载预定义的输入和输出映射;两者在编辑器中始终保持隐藏。
114
116
  - **AI 教育**:仅由用户管理的权威指导,AI 可以读取但永远不能修改。主动通知及其条件、持续时间、静默时段和重复频率只能在这里定义。
115
- - 相关的帮助、README 和示例片段始终会自动包含;系统会从用户请求中自动识别语言,并在所有受支持语言之间自动回退。
117
+ - 软件包附带的帮助、README、更新日志、Wiki 和示例片段不会加入 Telegram、RedBot 或自定义 CHAT 的提示词;仅网页助手在回答软件包技术问题时仍可使用这些内容。
116
118
  - **Refresh** 按钮:请求 provider 并加载可用模型 ID。加载期间图标会旋转;成功完成时不会显示额外消息。
117
119
 
118
120
  ### Ollama 快速配置(本地)
@@ -123,6 +125,7 @@ Canvas 上的节点状态专门用于显示最近收到的请求,以及 LLM
123
125
  - **2) Install it**:在本机下载并安装模型(例如 `llama3.1`)。
124
126
  - 在刷新/安装模型时,KNX AI 也会在可能情况下尝试自动启动 Ollama 服务。
125
127
  - 若安装因连接错误失败,请确认 Ollama 已运行(桌面应用或 `ollama serve`)。
128
+ - `/api/show` 报告的最大上下文仅用于显示。KNX AI 始终发送 `num_ctx = 16384`(若模型最大值更小则使用该值),并使用同一个按相关性选择的 16K 语义视图,从而避免分配过大的 KV 缓存,同时保留代理能力。
126
129
  - 若 Node-RED 运行在 Docker 中,endpoint 请使用 `host.docker.internal` 替代 `localhost`。
127
130
 
128
131
  ### Bionic LM Studio 快速配置(本地)
@@ -130,6 +133,7 @@ Canvas 上的节点状态专门用于显示最近收到的请求,以及 LLM
130
133
  - 在 LM Studio 的 **Developer** 页面启动 API 服务,或运行 `lms server start`。
131
134
  - 默认 endpoint:`http://localhost:1234/v1/chat/completions`。
132
135
  - 点击 **Refresh** 加载 `/v1/models` 提供的全部模型;未配置模型时会自动选择第一个。
136
+ - 如果模型已加载,KNX AI 会保留其当前上下文长度。KNX AI 不会通过管理 API 加载未运行的 Bionic 模型:首次聊天请求会让 Bionic 根据该模型已保存的默认值进行 JIT 加载。无论 Bionic 报告的上下文是多少,KNX AI 都会将自己的提示词限制为按相关性选择的 16K 语义视图;这样可避免发送完整的 131K 数据集,同时保留推理、KNX、例程、摄像头和 TTS 能力。
133
137
  - 除非在 LM Studio 服务设置中启用了身份验证,否则 API Key 可留空。在 Docker 中请将 `localhost` 替换为 `host.docker.internal`。
134
138
 
135
139
  ## 安全说明
@@ -55,10 +55,12 @@
55
55
  },
56
56
  "messages": {
57
57
  "lmStudioContextAvailable": "模型最大上下文",
58
- "lmStudioContextLoading": "正在以最大上下文加载模型",
59
- "lmStudioContextConfigured": "已配置模型最大上下文",
58
+ "lmStudioContextLoading": "正在检查模型的当前上下文",
59
+ "lmStudioContextInactive": "模型未运行;首次请求时将使用 Bionic 默认值",
60
+ "lmStudioContextConfigured": "模型当前上下文",
60
61
  "lmStudioContextFailed": "无法配置模型上下文",
61
62
  "lmStudioContextCurrentlyLoaded": "当前已加载",
63
+ "localContextBudget": "KNX AI 上下文预算",
62
64
  "ollamaNotSupported": "Ollama local mode: API key not required. Default endpoint is http://localhost:11434/api/chat.",
63
65
  "ollamaNoModels": "No local Ollama model found. Install one or pick one from the library.",
64
66
  "installingOllamaModel": "Starting Ollama and installing model…",
@@ -79,13 +81,22 @@
79
81
  "chatContextLoading": "正在加载聊天上下文摘要…",
80
82
  "chatContextUnavailable": "聊天上下文摘要暂时不可用。",
81
83
  "chatContextIntro": "聊天会自动接收这些来源。以下路径是此 Node-RED 安装实际使用的路径。",
84
+ "chatContextLimitLabel": "最大运行上下文",
85
+ "chatContextProviderManaged": "由所选提供商/模型管理",
86
+ "chatContextTokens": "令牌",
87
+ "chatContextLastPromptLabel": "上次聊天提示词的实际大小",
88
+ "chatContextLastPromptUnavailable": "首次聊天请求前不可用",
89
+ "chatContextExactInputTokens": "提供商测量的输入令牌",
90
+ "chatContextEstimatedInputTokens": "估算输入令牌",
91
+ "chatContextImages": "图像",
82
92
  "chatContextSourcesTitle": "包含的来源",
83
93
  "chatContextFilesTitle": "持久化上下文文件",
84
94
  "chatContextDirectoriesTitle": "KNX 报文归档",
85
95
  "chatContextSourceKnxTraffic": "当前 KNX 摘要、异常、拓扑和选定报文。",
96
+ "chatContextSourceAdapterHistory": "自动检测到的适配器事件的持久每日历史记录,包括摄像机检测事件。",
86
97
  "chatContextSourceEtsProject": "ETS 语义和完整的 Node-RED 项目清单。",
87
98
  "chatContextSourceMemoryEducation": "会话上下文、AI 教育和有界家庭记忆。",
88
- "chatContextSourceCamerasDocs": "检测到的摄像机以及相关帮助、README 和示例片段。",
99
+ "chatContextSourceCameras": "检测到的摄像机及其可用功能。",
89
100
  "chatContextSourceTtsUltimate": "已选择用于播报的 TTS Ultimate 节点。",
90
101
  "chatContextSourceBadge": "来源",
91
102
  "chatContextFileChatContext": "持久化对话轮次、指令和摄像机通知规则。",
@@ -95,6 +106,9 @@
95
106
  "chatContextDirectoryRoot": "报文归档根目录",
96
107
  "chatContextDirectoryNode": "此节点的报文归档",
97
108
  "chatContextDirectoryBadge": "KNX",
109
+ "chatContextDirectoryAdapterRoot": "适配器事件存档根目录",
110
+ "chatContextDirectoryAdapterNode": "此节点的适配器事件存档",
111
+ "chatContextDirectoryAdapterBadge": "适配器",
98
112
  "chatContextTelegramPattern": "每日文件",
99
113
  "chatAdapterIntro": "选择预设即可插入输入和输出映射代码。列表从随包提供的聊天适配器文件加载;生成的代码仍可编辑。",
100
114
  "chatAdapterCodeHelp": "映射同步运行。返回 msg 以继续,或不返回值以丢弃消息。错误会被捕获并报告,不会停止 Node-RED。",
@@ -0,0 +1,246 @@
1
+ const KNX_AI_ADAPTER_HISTORY_MIN_HOURS = 24
2
+ const KNX_AI_HISTORY_DETAILS_MAX_CHARS = 12000
3
+
4
+ const clampText = (value, maxChars = 500) => String(value === undefined || value === null ? '' : value)
5
+ .trim()
6
+ .slice(0, Math.max(0, Number(maxChars) || 0))
7
+
8
+ const normalizeSearchText = value => clampText(value, 4000)
9
+ .normalize('NFKD')
10
+ .replace(/[\u0300-\u036f]/g, '')
11
+ .toLocaleLowerCase()
12
+ .replace(/[^a-z0-9./_-]+/g, ' ')
13
+ .trim()
14
+
15
+ const parseTimestamp = (value, fallback = Date.now()) => {
16
+ if (typeof value === 'number' && Number.isFinite(value)) return value > 100000000000 ? value : value * 1000
17
+ const parsed = new Date(String(value || '')).getTime()
18
+ return Number.isFinite(parsed) ? parsed : fallback
19
+ }
20
+
21
+ const sanitizeHistoryValue = (value, depth = 0, seen = new Set()) => {
22
+ if (value === null || value === undefined) return value
23
+ if (typeof value === 'string') return clampText(value, 1000)
24
+ if (typeof value === 'number' || typeof value === 'boolean') return value
25
+ if (typeof value === 'bigint') return String(value)
26
+ if (Buffer.isBuffer(value)) return `[binary ${value.length} bytes]`
27
+ if (typeof value !== 'object') return clampText(value, 1000)
28
+ if (depth >= 3 || seen.has(value)) return '[nested]'
29
+ seen.add(value)
30
+ if (Array.isArray(value)) {
31
+ const out = value.slice(0, 30).map(item => sanitizeHistoryValue(item, depth + 1, seen))
32
+ seen.delete(value)
33
+ return out
34
+ }
35
+ const out = {}
36
+ Object.keys(value).slice(0, 60).forEach(key => {
37
+ const normalizedKey = clampText(key, 120)
38
+ if (!normalizedKey || /^(data|image|snapshot|buffer|base64)$/i.test(normalizedKey)) return
39
+ out[normalizedKey] = sanitizeHistoryValue(value[key], depth + 1, seen)
40
+ })
41
+ seen.delete(value)
42
+ return out
43
+ }
44
+
45
+ const normalizeKnxAiAdapterHistoryEvent = ({ event, adapter, provider, nowTs = Date.now() } = {}) => {
46
+ const source = event && typeof event === 'object' && !Array.isArray(event) ? event : {}
47
+ const adapterSource = adapter && typeof adapter === 'object' ? adapter : {}
48
+ const providerSource = provider && typeof provider === 'object' ? provider : {}
49
+ const ts = parseTimestamp(source.ts || source.timestamp || source.at || source.start || source.date, nowTs)
50
+ const eventType = clampText(source.eventType || source.type || source.event || source.kind, 120)
51
+ if (!eventType) return null
52
+ const cameraId = clampText(source.cameraId || source.resourceId || source.deviceId, 200)
53
+ const cameraName = clampText(source.cameraName || source.resourceName || source.deviceName, 300)
54
+ const details = sanitizeHistoryValue(source.raw || source.details || source.metadata || {})
55
+ let detailsText = ''
56
+ try { detailsText = JSON.stringify(details) } catch (error) { detailsText = '' }
57
+ return {
58
+ ts,
59
+ at: new Date(ts).toISOString(),
60
+ adapterId: clampText(source.adapterId || providerSource.adapterId || adapterSource.id, 160),
61
+ adapterTitle: clampText(source.adapterTitle || adapterSource.title || adapterSource.name, 240),
62
+ providerId: clampText(source.providerId || providerSource.id, 220),
63
+ providerTitle: clampText(source.providerTitle || providerSource.title || providerSource.name, 240),
64
+ controllerId: clampText(source.controllerId || providerSource.controllerId, 180),
65
+ controllerName: clampText(source.controllerName || providerSource.controllerName, 240),
66
+ resourceType: clampText(source.resourceType || (cameraId || cameraName ? 'camera' : 'adapter'), 80),
67
+ resourceId: cameraId,
68
+ resourceName: cameraName,
69
+ eventType,
70
+ eventId: clampText(source.eventId || source.id, 200),
71
+ active: source.active !== false,
72
+ scopeId: clampText(source.scopeId, 180),
73
+ scopeName: clampText(source.scopeName, 240),
74
+ objectTypes: Array.from(new Set((Array.isArray(source.objectTypes) ? source.objectTypes : [])
75
+ .map(value => clampText(value, 100))
76
+ .filter(Boolean))).slice(0, 24),
77
+ details: detailsText.length <= KNX_AI_HISTORY_DETAILS_MAX_CHARS ? details : { truncated: true }
78
+ }
79
+ }
80
+
81
+ const HISTORY_STOP_WORDS = new Set([
82
+ 'a', 'al', 'alla', 'alle', 'anche', 'and', 'auf', 'aux', 'avec', 'che', 'con', 'da', 'dal', 'dalla', 'das', 'de', 'dei', 'del', 'della', 'des', 'di', 'die', 'do', 'du', 'e', 'el', 'en', 'et', 'for', 'gli', 'ha', 'hanno', 'i', 'il', 'in', 'is', 'la', 'le', 'les', 'lo', 'mit', 'nel', 'nella', 'of', 'on', 'or', 'per', 'pour', 'que', 'qui', 'se', 'sono', 'su', 'the', 'to', 'tra', 'un', 'una', 'und', 'was', 'what', 'with', 'zu'
83
+ ])
84
+
85
+ const historyQuestionTokens = question => Array.from(new Set(normalizeSearchText(question)
86
+ .split(/\s+/)
87
+ .filter(token => token.length >= 2 && !HISTORY_STOP_WORDS.has(token) && !/^\d{1,4}$/.test(token))))
88
+ .slice(0, 24)
89
+
90
+ const incrementCount = (map, key) => {
91
+ const normalized = clampText(key, 500) || '(unknown)'
92
+ map.set(normalized, Number(map.get(normalized) || 0) + 1)
93
+ }
94
+
95
+ const topCounts = (map, limit) => Array.from(map.entries())
96
+ .map(([key, count]) => ({ key, count }))
97
+ .sort((left, right) => right.count - left.count || left.key.localeCompare(right.key))
98
+ .slice(0, Math.max(1, Number(limit) || 1))
99
+
100
+ const buildEventSearchText = (event, kind) => {
101
+ if (kind === 'knx') {
102
+ return normalizeSearchText([
103
+ event.event,
104
+ event.source,
105
+ event.destination,
106
+ event.devicename,
107
+ event.dpt,
108
+ event.payloadmeasureunit,
109
+ typeof event.payload === 'object' ? JSON.stringify(event.payload) : event.payload
110
+ ].join(' '))
111
+ }
112
+ return normalizeSearchText([
113
+ event.adapterId,
114
+ event.adapterTitle,
115
+ event.providerId,
116
+ event.providerTitle,
117
+ event.controllerId,
118
+ event.controllerName,
119
+ event.resourceType,
120
+ event.resourceId,
121
+ event.resourceName,
122
+ event.eventType,
123
+ event.scopeId,
124
+ event.scopeName,
125
+ ...(Array.isArray(event.objectTypes) ? event.objectTypes : []),
126
+ event.details && typeof event.details === 'object' ? JSON.stringify(event.details) : ''
127
+ ].join(' '))
128
+ }
129
+
130
+ const buildKnxAiHistoryEventKey = (event, kind = 'adapter') => {
131
+ if (!event || typeof event !== 'object') return ''
132
+ if (kind === 'knx') {
133
+ let payload = ''
134
+ try { payload = typeof event.payload === 'object' ? JSON.stringify(event.payload) : String(event.payload) } catch (error) { payload = '' }
135
+ return [Number(event.ts || 0), event.event, event.source, event.destination, payload, event.rawHex].join('|')
136
+ }
137
+ return [Number(event.ts || 0), event.adapterId, event.providerId, event.resourceId, event.eventType, event.eventId, event.scopeId, (event.objectTypes || []).join(',')].join('|')
138
+ }
139
+
140
+ const createKnxAiHistoryAccumulator = ({ kind = 'adapter', question = '', limit = 120 } = {}) => {
141
+ const normalizedKind = kind === 'knx' ? 'knx' : 'adapter'
142
+ const maxItems = Math.max(1, Number(limit) || 120)
143
+ const tokens = historyQuestionTokens(question)
144
+ const recent = []
145
+ const relevant = []
146
+ const counts = {
147
+ byEvent: new Map(),
148
+ bySource: new Map(),
149
+ byResource: new Map(),
150
+ byObjectType: new Map(),
151
+ byCombination: new Map()
152
+ }
153
+ let total = 0
154
+ let active = 0
155
+ let firstTs = 0
156
+ let lastTs = 0
157
+
158
+ const add = event => {
159
+ if (!event || typeof event !== 'object') return
160
+ const ts = Number(event.ts || new Date(event.at || '').getTime() || 0)
161
+ if (!Number.isFinite(ts) || ts <= 0) return
162
+ total += 1
163
+ if (event.active !== false) active += 1
164
+ firstTs = firstTs > 0 ? Math.min(firstTs, ts) : ts
165
+ lastTs = Math.max(lastTs, ts)
166
+ const eventName = normalizedKind === 'knx' ? event.event : event.eventType
167
+ const sourceName = normalizedKind === 'knx'
168
+ ? event.source
169
+ : (event.adapterTitle || event.adapterId || event.providerTitle || event.providerId)
170
+ const resourceName = normalizedKind === 'knx'
171
+ ? `${event.destination || '?'}${event.devicename ? ` (${event.devicename})` : ''}`
172
+ : (event.resourceName || event.resourceId || event.controllerName || event.controllerId)
173
+ incrementCount(counts.byEvent, eventName)
174
+ incrementCount(counts.bySource, sourceName)
175
+ incrementCount(counts.byResource, resourceName)
176
+ ;(Array.isArray(event.objectTypes) ? event.objectTypes : []).forEach(value => incrementCount(counts.byObjectType, value))
177
+ const combination = normalizedKind === 'knx'
178
+ ? `${resourceName || '?'} | ${eventName || '?'} | ${clampText(typeof event.payload === 'object' ? JSON.stringify(event.payload) : event.payload, 160)}`
179
+ : `${resourceName || '?'} | ${eventName || '?'}${event.scopeName ? ` | ${event.scopeName}` : ''}${event.objectTypes && event.objectTypes.length ? ` | ${event.objectTypes.join(',')}` : ''}`
180
+ incrementCount(counts.byCombination, combination)
181
+
182
+ recent.push(event)
183
+ if (recent.length > maxItems) recent.shift()
184
+ if (tokens.length) {
185
+ const haystack = buildEventSearchText(event, normalizedKind)
186
+ let score = 0
187
+ tokens.forEach(token => { if (haystack.includes(token)) score += token.length + 2 })
188
+ if (score > 0) {
189
+ relevant.push({ event, score, ts })
190
+ if (relevant.length > maxItems * 4) {
191
+ relevant.sort((left, right) => right.score - left.score || right.ts - left.ts)
192
+ relevant.length = maxItems * 2
193
+ }
194
+ }
195
+ }
196
+ }
197
+
198
+ const finish = () => {
199
+ let events = recent
200
+ if (relevant.length) {
201
+ const selected = relevant
202
+ .sort((left, right) => right.score - left.score || right.ts - left.ts)
203
+ .slice(0, maxItems)
204
+ .map(item => item.event)
205
+ events = selected.sort((left, right) => Number(left.ts || 0) - Number(right.ts || 0))
206
+ }
207
+ return {
208
+ events,
209
+ summary: {
210
+ kind: normalizedKind,
211
+ totalEvents: total,
212
+ activeEvents: active,
213
+ inactiveEvents: total - active,
214
+ firstAt: firstTs ? new Date(firstTs).toISOString() : '',
215
+ lastAt: lastTs ? new Date(lastTs).toISOString() : '',
216
+ selection: relevant.length ? 'question-relevant' : 'most-recent',
217
+ selectedEvents: events.length,
218
+ byEvent: topCounts(counts.byEvent, 20),
219
+ bySource: topCounts(counts.bySource, 12),
220
+ byResource: topCounts(counts.byResource, 30),
221
+ byObjectType: topCounts(counts.byObjectType, 20),
222
+ byCombination: topCounts(counts.byCombination, 40)
223
+ }
224
+ }
225
+ }
226
+
227
+ return { add, finish }
228
+ }
229
+
230
+ const formatKnxAiAdapterHistoryEventForPrompt = event => {
231
+ if (!event || typeof event !== 'object') return ''
232
+ const resource = event.resourceName || event.resourceId || event.controllerName || event.controllerId || '?'
233
+ const scope = event.scopeName || event.scopeId
234
+ const objects = Array.isArray(event.objectTypes) && event.objectTypes.length ? event.objectTypes.join(',') : ''
235
+ return `${event.at || new Date(event.ts || Date.now()).toISOString()} | adapter ${event.adapterTitle || event.adapterId || '?'} | ${event.resourceType || 'resource'} ${resource} | ${event.eventType || '?'} | ${event.active === false ? 'inactive' : 'active'}${scope ? ` | scope ${scope}` : ''}${objects ? ` | objects ${objects}` : ''}`
236
+ }
237
+
238
+ module.exports = {
239
+ KNX_AI_ADAPTER_HISTORY_MIN_HOURS,
240
+ KNX_AI_HISTORY_DETAILS_MAX_CHARS,
241
+ buildKnxAiHistoryEventKey,
242
+ createKnxAiHistoryAccumulator,
243
+ formatKnxAiAdapterHistoryEventForPrompt,
244
+ normalizeKnxAiAdapterHistoryEvent,
245
+ sanitizeHistoryValue
246
+ }
package/package.json CHANGED
@@ -3,7 +3,7 @@
3
3
  "engines": {
4
4
  "node": ">=20.18.1"
5
5
  },
6
- "version": "6.3.19",
6
+ "version": "6.3.22",
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, 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/",