smart-home-engine 1.51.3 → 1.52.0
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/README.md +1 -0
- package/dist/web/assets/index-BswjzIm1.css +1 -0
- package/dist/web/assets/index-CD6lY3HK.js +296 -0
- package/dist/web/assets/{tsMode-sNUSGo5H.js → tsMode-CQwwRC_t.js} +1 -1
- package/dist/web/index.html +2 -2
- package/package.json +1 -1
- package/src/index.js +67 -47
- package/src/lib/log-file.js +81 -0
- package/src/lib/logger.js +33 -0
- package/src/lib/script-timers.js +1 -0
- package/src/lib/text-diff.js +116 -0
- package/src/web/ai-api.js +364 -44
- package/src/web/ai-context.js +172 -61
- package/src/web/ai-drafts.js +68 -0
- package/src/web/ai-memory.js +106 -0
- package/src/web/ai-tools.js +938 -70
- package/src/web/log-ws.js +27 -7
- package/src/web/prompts/api-ref-compact.md +16 -0
- package/src/web/prompts/api-ref.md +60 -0
- package/src/web/prompts/compact.md +5 -0
- package/src/web/prompts/conventions.md +5 -0
- package/src/web/prompts/db-doc.md +1 -1
- package/src/web/prompts/db-view.md +3 -3
- package/src/web/prompts/formats.md +14 -0
- package/src/web/prompts/role.md +6 -0
- package/src/web/prompts/tools.md +9 -0
- package/dist/web/assets/index-D6H4drtd.js +0 -295
- package/dist/web/assets/index-DPDW78ru.css +0 -1
- package/src/web/prompts/scripts-base.md +0 -25
- package/src/web/prompts/she-api-ref.md +0 -94
package/src/web/ai-tools.js
CHANGED
|
@@ -12,7 +12,17 @@
|
|
|
12
12
|
|
|
13
13
|
const fs = require('fs');
|
|
14
14
|
const path = require('path');
|
|
15
|
+
const dns = require('dns');
|
|
16
|
+
const net = require('net');
|
|
17
|
+
const readline = require('readline');
|
|
15
18
|
const { getLogBuffer } = require('./log-ws');
|
|
19
|
+
const mqttWildcard = require('../lib/mqtt-wildcards');
|
|
20
|
+
const { LOGS_DIR } = require('../lib/storage');
|
|
21
|
+
const { unifiedDiff } = require('../lib/text-diff');
|
|
22
|
+
|
|
23
|
+
// A tool result goes into the conversation and stays there for every later turn (roadmap I21):
|
|
24
|
+
// above this many characters it is cut with a note; lists page with offset/limit instead.
|
|
25
|
+
const DEFAULT_RESULT_CHARS = 6000;
|
|
16
26
|
|
|
17
27
|
// ---------------------------------------------------------------------------
|
|
18
28
|
// Tool schemas
|
|
@@ -34,12 +44,21 @@ const TOOL_DEFINITIONS = [
|
|
|
34
44
|
properties: {
|
|
35
45
|
query: {
|
|
36
46
|
type: 'string',
|
|
37
|
-
description:
|
|
47
|
+
description:
|
|
48
|
+
'An MQTT filter with wildcards (+ one level, # the rest: "hm/status/+/LEVEL", "zigbee2mqtt/#") or a case-insensitive substring of the topic name ("bad"). Empty string lists all topics.',
|
|
49
|
+
},
|
|
50
|
+
value: {
|
|
51
|
+
type: 'string',
|
|
52
|
+
description: 'Optional filter on the current value: a plain value for equality ("true", "on", "0"), or a comparison ("> 0", ">= 20", "!= off").',
|
|
53
|
+
},
|
|
54
|
+
changed_within: {
|
|
55
|
+
type: 'string',
|
|
56
|
+
description: 'Optional: only topics whose value changed within this time ("30m", "2h", "1d").',
|
|
38
57
|
},
|
|
58
|
+
offset: { type: 'integer', description: 'Skip this many matches (paging; default 0).' },
|
|
39
59
|
limit: {
|
|
40
60
|
type: 'integer',
|
|
41
|
-
description:
|
|
42
|
-
'Maximum number of topics to return (1-500, default 50). The result says how many matched in total; raise the limit or narrow the query to see the rest.',
|
|
61
|
+
description: 'Maximum number of topics to return (1-500, default 50). The result says how many matched in total.',
|
|
43
62
|
},
|
|
44
63
|
},
|
|
45
64
|
required: ['query'],
|
|
@@ -67,18 +86,24 @@ const TOOL_DEFINITIONS = [
|
|
|
67
86
|
type: 'function',
|
|
68
87
|
function: {
|
|
69
88
|
name: 'get_script_logs',
|
|
70
|
-
description:
|
|
89
|
+
description:
|
|
90
|
+
'Read the daemon log: the log files on disk (every line since the last restarts, rotated at 10 MB) within a time window, or the last lines without one. ' +
|
|
91
|
+
'Filter by script name to diagnose errors or trace what a script has been doing; "from"/"to" take an ISO time or a relative one ("-2h", "-1d").',
|
|
71
92
|
parameters: {
|
|
72
93
|
type: 'object',
|
|
73
94
|
properties: {
|
|
74
95
|
script_name: {
|
|
75
96
|
type: 'string',
|
|
76
|
-
description: 'Filter
|
|
97
|
+
description: 'Filter lines to those mentioning this script name (file name without extension). Empty string: all lines.',
|
|
77
98
|
},
|
|
99
|
+
from: { type: 'string', description: 'Start of the time window: ISO 8601 or relative ("-30m", "-2h", "-1d"). Default: the newest lines only.' },
|
|
100
|
+
to: { type: 'string', description: 'End of the time window: ISO 8601 or relative. Default: now.' },
|
|
101
|
+
level: { type: 'string', description: 'Minimum level: debug, info, warn or error (default debug).' },
|
|
78
102
|
limit: {
|
|
79
103
|
type: 'integer',
|
|
80
|
-
description: 'Maximum number of log lines to return (1-200, default 50).',
|
|
104
|
+
description: 'Maximum number of log lines to return (1-200, default 50), the newest within the window.',
|
|
81
105
|
},
|
|
106
|
+
offset: { type: 'integer', description: 'Skip this many lines from the newest end (paging into older lines; default 0).' },
|
|
82
107
|
},
|
|
83
108
|
required: [],
|
|
84
109
|
},
|
|
@@ -132,8 +157,10 @@ const TOOL_DEFINITIONS = [
|
|
|
132
157
|
properties: {
|
|
133
158
|
filter: {
|
|
134
159
|
type: 'string',
|
|
135
|
-
description: 'Optional case-insensitive substring to filter IDs. Pass empty string to list all
|
|
160
|
+
description: 'Optional case-insensitive substring to filter IDs. Pass empty string to list all.',
|
|
136
161
|
},
|
|
162
|
+
offset: { type: 'integer', description: 'Skip this many matching IDs (paging; default 0).' },
|
|
163
|
+
limit: { type: 'integer', description: 'Maximum IDs to return (1-500, default 200). The result says how many matched in total.' },
|
|
137
164
|
},
|
|
138
165
|
required: [],
|
|
139
166
|
},
|
|
@@ -151,6 +178,10 @@ const TOOL_DEFINITIONS = [
|
|
|
151
178
|
type: 'string',
|
|
152
179
|
description: 'The exact document ID to retrieve.',
|
|
153
180
|
},
|
|
181
|
+
path: {
|
|
182
|
+
type: 'string',
|
|
183
|
+
description: 'Optional dotted path into the document (e.g. "rooms.bath.lights") to read a part of a large document.',
|
|
184
|
+
},
|
|
154
185
|
},
|
|
155
186
|
required: ['id'],
|
|
156
187
|
},
|
|
@@ -161,16 +192,221 @@ const TOOL_DEFINITIONS = [
|
|
|
161
192
|
function: {
|
|
162
193
|
name: 'list_matter_devices',
|
|
163
194
|
description:
|
|
164
|
-
'List
|
|
165
|
-
'Use
|
|
166
|
-
|
|
195
|
+
'List the paired Matter devices with their endpoints and clusters, and (with_state, default true) the common state attributes per endpoint: OnOff, LevelControl, the measurement clusters. ' +
|
|
196
|
+
'Use get_matter_attribute for any other attribute.',
|
|
197
|
+
parameters: {
|
|
198
|
+
type: 'object',
|
|
199
|
+
properties: {
|
|
200
|
+
with_state: { type: 'boolean', description: 'Read the state attributes of online devices (default true).' },
|
|
201
|
+
offset: { type: 'integer', description: 'Skip this many devices (paging; default 0).' },
|
|
202
|
+
limit: { type: 'integer', description: 'Maximum devices to list (default 50).' },
|
|
203
|
+
},
|
|
204
|
+
required: [],
|
|
205
|
+
},
|
|
206
|
+
},
|
|
207
|
+
},
|
|
208
|
+
{
|
|
209
|
+
type: 'function',
|
|
210
|
+
function: {
|
|
211
|
+
name: 'get_matter_attribute',
|
|
212
|
+
description: 'Read one attribute of a Matter device: node (id or name), endpoint (id or name), cluster name and attribute name, as she.matter.get does.',
|
|
213
|
+
parameters: {
|
|
214
|
+
type: 'object',
|
|
215
|
+
properties: {
|
|
216
|
+
node: { type: 'string', description: 'Node id or device name.' },
|
|
217
|
+
endpoint: { type: 'string', description: 'Endpoint id or name.' },
|
|
218
|
+
cluster: { type: 'string', description: 'Cluster name, e.g. "OnOff", "LevelControl", "TemperatureMeasurement".' },
|
|
219
|
+
attribute: { type: 'string', description: 'Attribute name, e.g. "onOff", "currentLevel", "measuredValue".' },
|
|
220
|
+
},
|
|
221
|
+
required: ['node', 'endpoint', 'cluster', 'attribute'],
|
|
222
|
+
},
|
|
223
|
+
},
|
|
224
|
+
},
|
|
225
|
+
{
|
|
226
|
+
type: 'function',
|
|
227
|
+
function: {
|
|
228
|
+
name: 'get_topic_history',
|
|
229
|
+
description:
|
|
230
|
+
'The values of an MQTT topic over time from InfluxDB (when she has an Influx integration configured), as a compact time series: change points, downsampled when long. ' +
|
|
231
|
+
'Use it for every "why", "when", "how often", "since when" question about a topic; search_mqtt_topics only knows the current value.',
|
|
232
|
+
parameters: {
|
|
233
|
+
type: 'object',
|
|
234
|
+
properties: {
|
|
235
|
+
topic: {
|
|
236
|
+
type: 'string',
|
|
237
|
+
description: 'The MQTT topic, e.g. "hm/status/Licht Bad/LEVEL" (the measurement is derived from it; a measurement name is accepted too).',
|
|
238
|
+
},
|
|
239
|
+
from: { type: 'string', description: 'Start: ISO 8601 or relative ("-2h", "-1d", "-7d"). Default "-24h".' },
|
|
240
|
+
to: { type: 'string', description: 'End: ISO 8601 or relative. Default now.' },
|
|
241
|
+
limit: { type: 'integer', description: 'Maximum points to return (1-500, default 200); longer series are reduced to change points, then thinned.' },
|
|
242
|
+
},
|
|
243
|
+
required: ['topic'],
|
|
244
|
+
},
|
|
245
|
+
},
|
|
246
|
+
},
|
|
247
|
+
{
|
|
248
|
+
type: 'function',
|
|
249
|
+
function: {
|
|
250
|
+
name: 'get_topic_messages',
|
|
251
|
+
description:
|
|
252
|
+
'The raw MQTT messages of a topic (every publish with its payload, newest first) from Elasticsearch, when she has an Elastic integration configured. ' +
|
|
253
|
+
'For values over time prefer get_topic_history; use this for the exact messages, repeated publishes and non-numeric payloads.',
|
|
254
|
+
parameters: {
|
|
255
|
+
type: 'object',
|
|
256
|
+
properties: {
|
|
257
|
+
topic: { type: 'string', description: 'The exact MQTT topic.' },
|
|
258
|
+
from: { type: 'string', description: 'Start: ISO 8601 or relative ("-2h"). Default "-24h".' },
|
|
259
|
+
to: { type: 'string', description: 'End: ISO 8601 or relative. Default now.' },
|
|
260
|
+
limit: { type: 'integer', description: 'Maximum messages (1-500, default 100), the newest within the window.' },
|
|
261
|
+
},
|
|
262
|
+
required: ['topic'],
|
|
263
|
+
},
|
|
264
|
+
},
|
|
265
|
+
},
|
|
266
|
+
{
|
|
267
|
+
type: 'function',
|
|
268
|
+
function: {
|
|
269
|
+
name: 'list_scripts',
|
|
270
|
+
description:
|
|
271
|
+
'The loaded scripts with what each one subscribes to, publishes to (as seen since the daemon started), and schedules. ' +
|
|
272
|
+
'Use it to find the script behind a topic or a behaviour before reading files; filter matches the script path or a topic.',
|
|
273
|
+
parameters: {
|
|
274
|
+
type: 'object',
|
|
275
|
+
properties: {
|
|
276
|
+
filter: { type: 'string', description: 'Substring of the script path or of a topic it uses. Empty: all scripts.' },
|
|
277
|
+
offset: { type: 'integer', description: 'Skip this many scripts (paging; default 0).' },
|
|
278
|
+
limit: { type: 'integer', description: 'Maximum scripts (1-200, default 50).' },
|
|
279
|
+
},
|
|
280
|
+
required: [],
|
|
281
|
+
},
|
|
282
|
+
},
|
|
283
|
+
},
|
|
284
|
+
{
|
|
285
|
+
type: 'function',
|
|
286
|
+
function: {
|
|
287
|
+
name: 'who_publishes',
|
|
288
|
+
description: 'Which script publishes a topic (as seen since the daemon started) and which adapter instance owns it (the first topic segment is the instance name).',
|
|
289
|
+
parameters: {
|
|
290
|
+
type: 'object',
|
|
291
|
+
properties: { topic: { type: 'string', description: 'The exact MQTT topic.' } },
|
|
292
|
+
required: ['topic'],
|
|
293
|
+
},
|
|
294
|
+
},
|
|
295
|
+
},
|
|
296
|
+
{
|
|
297
|
+
type: 'function',
|
|
298
|
+
function: {
|
|
299
|
+
name: 'describe_device',
|
|
300
|
+
description:
|
|
301
|
+
'Everything known about one device: its topics grouped into status, set and maintenance with values and change ages, and its Home Assistant discovery entities when announced. ' +
|
|
302
|
+
'The name matches a topic segment (e.g. "Licht Bad", "echo_bad") or a discovery device name.',
|
|
303
|
+
parameters: {
|
|
304
|
+
type: 'object',
|
|
305
|
+
properties: {
|
|
306
|
+
name: { type: 'string', description: 'Device name as it appears in topics or in the discovery.' },
|
|
307
|
+
limit: { type: 'integer', description: 'Maximum topics per group (default 40).' },
|
|
308
|
+
},
|
|
309
|
+
required: ['name'],
|
|
310
|
+
},
|
|
311
|
+
},
|
|
312
|
+
},
|
|
313
|
+
{
|
|
314
|
+
type: 'function',
|
|
315
|
+
function: {
|
|
316
|
+
name: 'list_services',
|
|
317
|
+
description:
|
|
318
|
+
'The adapter instances (xyz2mqtt services) she sees on the broker: name, adapter, version, host, connected state, uptime. Use it for "is X running" and "which adapters are there".',
|
|
167
319
|
parameters: {
|
|
168
320
|
type: 'object',
|
|
169
|
-
properties: {},
|
|
321
|
+
properties: { filter: { type: 'string', description: 'Substring of the instance name, adapter or host. Empty: all.' } },
|
|
170
322
|
required: [],
|
|
171
323
|
},
|
|
172
324
|
},
|
|
173
325
|
},
|
|
326
|
+
{
|
|
327
|
+
type: 'function',
|
|
328
|
+
function: {
|
|
329
|
+
name: 'get_health',
|
|
330
|
+
description:
|
|
331
|
+
'The daemon\'s own state: started, MQTT connected, scripts loaded, safe mode, topics, message rate, handlers, memory, CPU, event-loop lag, Matter and sheDB counts. Use it first for "why is nothing happening".',
|
|
332
|
+
parameters: { type: 'object', properties: {}, required: [] },
|
|
333
|
+
},
|
|
334
|
+
},
|
|
335
|
+
{
|
|
336
|
+
type: 'function',
|
|
337
|
+
function: {
|
|
338
|
+
name: 'list_timers',
|
|
339
|
+
description:
|
|
340
|
+
'Pending one-shot timers, intervals, schedule jobs and sun events per script with their next fire time. Use it for "why did it switch at …" and "what is still going to happen".',
|
|
341
|
+
parameters: {
|
|
342
|
+
type: 'object',
|
|
343
|
+
properties: { script: { type: 'string', description: 'Substring of a script path. Empty: every script with something pending.' } },
|
|
344
|
+
required: [],
|
|
345
|
+
},
|
|
346
|
+
},
|
|
347
|
+
},
|
|
348
|
+
{
|
|
349
|
+
type: 'function',
|
|
350
|
+
function: {
|
|
351
|
+
name: 'remember',
|
|
352
|
+
description:
|
|
353
|
+
'Store a fact about this installation that the user confirmed, for every later chat (shown and editable on the AI page). One short sentence, no secrets. ' +
|
|
354
|
+
'Use it when the user corrects you or explains something about their house that is not in any topic ("the PIR in the bathroom cannot see the shower").',
|
|
355
|
+
parameters: {
|
|
356
|
+
type: 'object',
|
|
357
|
+
properties: { text: { type: 'string', description: 'The fact, at most 200 characters.' } },
|
|
358
|
+
required: ['text'],
|
|
359
|
+
},
|
|
360
|
+
},
|
|
361
|
+
},
|
|
362
|
+
{
|
|
363
|
+
type: 'function',
|
|
364
|
+
function: {
|
|
365
|
+
name: 'forget',
|
|
366
|
+
description: 'Remove a remembered fact by its id (the [n…] in the "This installation" section) when the user says it is wrong or outdated.',
|
|
367
|
+
parameters: {
|
|
368
|
+
type: 'object',
|
|
369
|
+
properties: { id: { type: 'string', description: 'The note id, e.g. "n1a2b3c4".' } },
|
|
370
|
+
required: ['id'],
|
|
371
|
+
},
|
|
372
|
+
},
|
|
373
|
+
},
|
|
374
|
+
{
|
|
375
|
+
type: 'function',
|
|
376
|
+
function: {
|
|
377
|
+
name: 'propose_script',
|
|
378
|
+
description:
|
|
379
|
+
'Propose a new or changed script as a draft: the user sees the diff in the chat and applies it with a click; nothing is written without that. ' +
|
|
380
|
+
'Read the current file first (read_script) and pass the complete new content. Use this instead of pasting the whole file into the answer.',
|
|
381
|
+
parameters: {
|
|
382
|
+
type: 'object',
|
|
383
|
+
properties: {
|
|
384
|
+
path: { type: 'string', description: 'Script path relative to the scripts directory, e.g. "licht/bad.js".' },
|
|
385
|
+
content: { type: 'string', description: 'The complete new file content.' },
|
|
386
|
+
note: { type: 'string', description: 'One sentence on what the change does.' },
|
|
387
|
+
},
|
|
388
|
+
required: ['path', 'content'],
|
|
389
|
+
},
|
|
390
|
+
},
|
|
391
|
+
},
|
|
392
|
+
{
|
|
393
|
+
type: 'function',
|
|
394
|
+
function: {
|
|
395
|
+
name: 'publish_mqtt',
|
|
396
|
+
description:
|
|
397
|
+
'Publish an MQTT message to a command topic (<name>/set/…, var/set/…, zigbee2mqtt/<device>/set), e.g. to test a device. ' +
|
|
398
|
+
'Depending on the switch in the chat the user confirms each publish first. Never publish to a status topic.',
|
|
399
|
+
parameters: {
|
|
400
|
+
type: 'object',
|
|
401
|
+
properties: {
|
|
402
|
+
topic: { type: 'string', description: 'The command topic.' },
|
|
403
|
+
payload: { type: 'string', description: 'The payload as a string (JSON for objects).' },
|
|
404
|
+
retain: { type: 'boolean', description: 'Retain the message (only for state, never for commands; default false).' },
|
|
405
|
+
},
|
|
406
|
+
required: ['topic', 'payload'],
|
|
407
|
+
},
|
|
408
|
+
},
|
|
409
|
+
},
|
|
174
410
|
];
|
|
175
411
|
|
|
176
412
|
/** Same definitions in Anthropic tool format. */
|
|
@@ -192,6 +428,16 @@ const TOOL_DEFINITIONS_ANTHROPIC = TOOL_DEFINITIONS.map((t) => ({
|
|
|
192
428
|
* @returns {string}
|
|
193
429
|
*/
|
|
194
430
|
async function executeTool(name, args, ctx) {
|
|
431
|
+
const cap = Math.max(500, Number(ctx?.resultChars) || DEFAULT_RESULT_CHARS);
|
|
432
|
+
const result = await runTool(name, args || {}, ctx || {});
|
|
433
|
+
// a tool may return { text, event }: the text goes to the model, the event to the chat
|
|
434
|
+
if (typeof result === 'string' && result.length > cap) {
|
|
435
|
+
return result.slice(0, cap) + `\n… cut after ${cap} characters (${result.length} total); narrow the query or page with offset/limit.`;
|
|
436
|
+
}
|
|
437
|
+
return result;
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
async function runTool(name, args, ctx) {
|
|
195
441
|
try {
|
|
196
442
|
switch (name) {
|
|
197
443
|
case 'search_mqtt_topics':
|
|
@@ -203,13 +449,39 @@ async function executeTool(name, args, ctx) {
|
|
|
203
449
|
case 'get_script_logs':
|
|
204
450
|
return toolGetScriptLogs(args);
|
|
205
451
|
case 'she_fetch':
|
|
206
|
-
return await toolSheFetch(args);
|
|
452
|
+
return await toolSheFetch(args, ctx);
|
|
207
453
|
case 'list_shedb_docs':
|
|
208
454
|
return toolListShedbDocs(args);
|
|
209
455
|
case 'get_shedb_doc':
|
|
210
456
|
return toolGetShedbDoc(args);
|
|
211
457
|
case 'list_matter_devices':
|
|
212
|
-
return toolListMatterDevices();
|
|
458
|
+
return await toolListMatterDevices(args);
|
|
459
|
+
case 'get_matter_attribute':
|
|
460
|
+
return await toolGetMatterAttribute(args);
|
|
461
|
+
case 'get_topic_history':
|
|
462
|
+
return await toolGetTopicHistory(args, ctx);
|
|
463
|
+
case 'get_topic_messages':
|
|
464
|
+
return await toolGetTopicMessages(args, ctx);
|
|
465
|
+
case 'list_scripts':
|
|
466
|
+
return toolListScripts(args, ctx);
|
|
467
|
+
case 'who_publishes':
|
|
468
|
+
return toolWhoPublishes(args, ctx);
|
|
469
|
+
case 'describe_device':
|
|
470
|
+
return toolDescribeDevice(args, ctx);
|
|
471
|
+
case 'list_services':
|
|
472
|
+
return toolListServices(args, ctx);
|
|
473
|
+
case 'get_health':
|
|
474
|
+
return toolGetHealth(ctx);
|
|
475
|
+
case 'list_timers':
|
|
476
|
+
return toolListTimers(args, ctx);
|
|
477
|
+
case 'propose_script':
|
|
478
|
+
return toolProposeScript(args, ctx);
|
|
479
|
+
case 'publish_mqtt':
|
|
480
|
+
return await toolPublishMqtt(args, ctx);
|
|
481
|
+
case 'remember':
|
|
482
|
+
return toolRemember(args, ctx);
|
|
483
|
+
case 'forget':
|
|
484
|
+
return toolForget(args, ctx);
|
|
213
485
|
default:
|
|
214
486
|
return `Unknown tool: ${name}`;
|
|
215
487
|
}
|
|
@@ -218,29 +490,110 @@ async function executeTool(name, args, ctx) {
|
|
|
218
490
|
}
|
|
219
491
|
}
|
|
220
492
|
|
|
493
|
+
// ---------------------------------------------------------------------------
|
|
494
|
+
// Helpers
|
|
495
|
+
// ---------------------------------------------------------------------------
|
|
496
|
+
|
|
497
|
+
/** a page of a list with the note the model needs to ask for the rest */
|
|
498
|
+
function pageOf(list, offset, limit, maxLimit, defaultLimit) {
|
|
499
|
+
const skip = Math.max(0, Number(offset) || 0);
|
|
500
|
+
const cap = Math.min(Math.max(1, Number(limit) || defaultLimit), maxLimit);
|
|
501
|
+
const page = list.slice(skip, skip + cap);
|
|
502
|
+
const rest = list.length - skip - page.length;
|
|
503
|
+
const note = rest > 0 ? ` (offset ${skip}; ${rest} more: offset ${skip + page.length})` : skip > 0 ? ` (offset ${skip})` : '';
|
|
504
|
+
return { page, note };
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
/** "30m", "2h", "1d", "90s" or a number of seconds → milliseconds; NaN when unreadable */
|
|
508
|
+
function parseDuration(v) {
|
|
509
|
+
if (typeof v === 'number') return v * 1000;
|
|
510
|
+
const m = String(v || '')
|
|
511
|
+
.trim()
|
|
512
|
+
.match(/^(\d+(?:\.\d+)?)\s*([smhd]?)$/i);
|
|
513
|
+
if (!m) return NaN;
|
|
514
|
+
const n = parseFloat(m[1]);
|
|
515
|
+
return n * { '': 1000, 's': 1000, 'm': 60000, 'h': 3600000, 'd': 86400000 }[m[2].toLowerCase()];
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
/** "> 0", ">= 1", "< 5", "!= off", "== true", "= on" or a plain value (equality) → predicate on a value */
|
|
519
|
+
function valueMatcher(spec) {
|
|
520
|
+
const m = String(spec)
|
|
521
|
+
.trim()
|
|
522
|
+
.match(/^(>=|<=|!=|==|=|>|<)?\s*(.*)$/);
|
|
523
|
+
const op = m[1] || '=';
|
|
524
|
+
const rhsRaw = m[2];
|
|
525
|
+
let rhs = rhsRaw;
|
|
526
|
+
try {
|
|
527
|
+
rhs = JSON.parse(rhsRaw);
|
|
528
|
+
} catch {
|
|
529
|
+
/* a bare word stays a string */
|
|
530
|
+
}
|
|
531
|
+
const num = (x) => (typeof x === 'boolean' ? Number(x) : typeof x === 'number' ? x : parseFloat(x));
|
|
532
|
+
return (val) => {
|
|
533
|
+
if (op === '=' || op === '==') return val === rhs || String(val) === String(rhs);
|
|
534
|
+
if (op === '!=') return !(val === rhs || String(val) === String(rhs));
|
|
535
|
+
const a = num(val);
|
|
536
|
+
const b = num(rhs);
|
|
537
|
+
if (Number.isNaN(a) || Number.isNaN(b)) return false;
|
|
538
|
+
return op === '>' ? a > b : op === '>=' ? a >= b : op === '<' ? a < b : a <= b;
|
|
539
|
+
};
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
/** ISO 8601, epoch ms, "now", or relative "-2h"/"-30m"/"-1d" → epoch ms; NaN when unreadable */
|
|
543
|
+
function parseTime(v, now = Date.now()) {
|
|
544
|
+
if (v === undefined || v === null || v === '' || v === 'now') return now;
|
|
545
|
+
if (typeof v === 'number') return v;
|
|
546
|
+
const str = String(v).trim();
|
|
547
|
+
if (/^-\s*\d/.test(str)) {
|
|
548
|
+
const d = parseDuration(str.slice(1).trim());
|
|
549
|
+
return Number.isNaN(d) ? NaN : now - d;
|
|
550
|
+
}
|
|
551
|
+
const t = Date.parse(str);
|
|
552
|
+
return Number.isNaN(t) ? NaN : t;
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
function isoShort(ms) {
|
|
556
|
+
return new Date(ms).toISOString().replace('T', ' ').slice(0, 19) + 'Z';
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
function ago(ms) {
|
|
560
|
+
const s = Math.round(ms / 1000);
|
|
561
|
+
if (s < 60) return s + 's';
|
|
562
|
+
if (s < 3600) return Math.floor(s / 60) + 'm';
|
|
563
|
+
if (s < 86400) return Math.floor(s / 3600) + 'h';
|
|
564
|
+
return Math.floor(s / 86400) + 'd';
|
|
565
|
+
}
|
|
566
|
+
|
|
221
567
|
// ---------------------------------------------------------------------------
|
|
222
568
|
// Individual tools
|
|
223
569
|
// ---------------------------------------------------------------------------
|
|
224
570
|
|
|
225
|
-
function toolSearchMqttTopics({ query = '', limit = 50 }, store) {
|
|
571
|
+
function toolSearchMqttTopics({ query = '', value, changed_within, offset = 0, limit = 50 }, store) {
|
|
226
572
|
if (!store) return 'MQTT state store not available.';
|
|
227
|
-
const
|
|
228
|
-
const
|
|
229
|
-
const
|
|
230
|
-
|
|
573
|
+
const pattern = String(query).trim();
|
|
574
|
+
const isFilter = /[+#]/.test(pattern);
|
|
575
|
+
const q = pattern.toLowerCase();
|
|
576
|
+
const matchValue = value !== undefined && value !== null && String(value) !== '' ? valueMatcher(value) : null;
|
|
577
|
+
const withinMs = changed_within ? parseDuration(changed_within) : NaN;
|
|
578
|
+
if (changed_within && Number.isNaN(withinMs)) return `changed_within "${changed_within}" is not a duration; use e.g. "30m", "2h" or "1d".`;
|
|
579
|
+
const now = Date.now();
|
|
580
|
+
const hits = [];
|
|
231
581
|
for (const [topic, obj] of store.mqttEntries()) {
|
|
232
|
-
if (!q
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
if (total > results.length) {
|
|
238
|
-
results.push(`… ${results.length} of ${total} matching topics shown; raise the limit (up to 500) or narrow the query for the rest.`);
|
|
582
|
+
if (isFilter ? !mqttWildcard(topic, pattern) : q && !topic.toLowerCase().includes(q)) continue;
|
|
583
|
+
if (matchValue && !matchValue(obj.val)) continue;
|
|
584
|
+
const lc = obj.lc ?? obj.ts;
|
|
585
|
+
if (!Number.isNaN(withinMs) && !(lc && now - lc <= withinMs)) continue;
|
|
586
|
+
hits.push(`${topic}: ${JSON.stringify(obj.val)}${lc ? ` (changed ${ago(now - lc)} ago)` : ''}`);
|
|
239
587
|
}
|
|
240
|
-
if (
|
|
241
|
-
return
|
|
588
|
+
if (hits.length === 0) {
|
|
589
|
+
return pattern
|
|
590
|
+
? `No MQTT topics found matching "${query}"${matchValue ? ` with value ${value}` : ''}${changed_within ? ` changed within ${changed_within}` : ''}.`
|
|
591
|
+
: 'No MQTT topics tracked yet.';
|
|
242
592
|
}
|
|
243
|
-
|
|
593
|
+
const { page, note } = pageOf(hits, offset, limit, 500, 50);
|
|
594
|
+
const lines = [`${page.length} of ${hits.length} matching topic(s)${note}:`, ...page];
|
|
595
|
+
if (hits.length > page.length) lines.push('… raise the limit (up to 500), page with offset, or narrow the query for the rest.');
|
|
596
|
+
return lines.join('\n');
|
|
244
597
|
}
|
|
245
598
|
|
|
246
599
|
function toolReadScript({ path: relPath }, scriptDir) {
|
|
@@ -256,33 +609,471 @@ function toolReadScript({ path: relPath }, scriptDir) {
|
|
|
256
609
|
return `## ${relPath}\n\`\`\`javascript\n${content}\n\`\`\``;
|
|
257
610
|
}
|
|
258
611
|
|
|
259
|
-
|
|
612
|
+
const LEVEL_RANK = { debug: 0, info: 1, warn: 2, error: 3 };
|
|
613
|
+
|
|
614
|
+
/** the log files on disk, oldest first: she.jsonl.N … she.jsonl.1, she.jsonl */
|
|
615
|
+
function logFilesOldestFirst() {
|
|
616
|
+
let names;
|
|
617
|
+
try {
|
|
618
|
+
names = fs.readdirSync(LOGS_DIR).filter((n) => /^she\.jsonl(\.\d+)?$/.test(n));
|
|
619
|
+
} catch {
|
|
620
|
+
return [];
|
|
621
|
+
}
|
|
622
|
+
const rank = (n) => (n === 'she.jsonl' ? 0 : Number(n.slice('she.jsonl.'.length)));
|
|
623
|
+
return names.sort((a, b) => rank(b) - rank(a)).map((n) => path.join(LOGS_DIR, n));
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
async function toolGetScriptLogs({ script_name = '', from, to, level = 'debug', limit = 50, offset = 0 } = {}) {
|
|
260
627
|
const cap = Math.min(Math.max(1, Number(limit) || 50), 200);
|
|
261
|
-
const
|
|
628
|
+
const skip = Math.max(0, Number(offset) || 0);
|
|
262
629
|
const needle = String(script_name).toLowerCase();
|
|
263
|
-
const
|
|
264
|
-
const
|
|
630
|
+
const minRank = LEVEL_RANK[String(level).toLowerCase()] ?? 0;
|
|
631
|
+
const now = Date.now();
|
|
632
|
+
const fromMs = from ? parseTime(from, now) : null;
|
|
633
|
+
const toMs = parseTime(to, now);
|
|
634
|
+
if (Number.isNaN(fromMs) || Number.isNaN(toMs)) return `from/to must be ISO 8601 or relative ("-2h"); got from=${from}, to=${to}.`;
|
|
635
|
+
const keep = (e) => (fromMs === null || e.ts >= fromMs) && e.ts <= toMs && (LEVEL_RANK[e.level] ?? 0) >= minRank && (!needle || e.msg.toLowerCase().includes(needle));
|
|
636
|
+
|
|
637
|
+
// the files on disk; the in-memory ring only when there are none (tests, a read-only data directory)
|
|
638
|
+
const matches = [];
|
|
639
|
+
const files = logFilesOldestFirst();
|
|
640
|
+
for (const file of files) {
|
|
641
|
+
if (fromMs !== null) {
|
|
642
|
+
try {
|
|
643
|
+
if (fs.statSync(file).mtimeMs < fromMs) continue; // nothing in this file is new enough
|
|
644
|
+
} catch {
|
|
645
|
+
continue;
|
|
646
|
+
}
|
|
647
|
+
}
|
|
648
|
+
const rl = readline.createInterface({ input: fs.createReadStream(file), crlfDelay: Infinity });
|
|
649
|
+
for await (const line of rl) {
|
|
650
|
+
if (!line) continue;
|
|
651
|
+
let e;
|
|
652
|
+
try {
|
|
653
|
+
e = JSON.parse(line);
|
|
654
|
+
} catch {
|
|
655
|
+
continue;
|
|
656
|
+
}
|
|
657
|
+
if (e && typeof e.msg === 'string' && keep(e)) matches.push(e);
|
|
658
|
+
}
|
|
659
|
+
}
|
|
660
|
+
if (files.length === 0) {
|
|
661
|
+
for (const e of getLogBuffer()) if (keep(e)) matches.push(e);
|
|
662
|
+
}
|
|
663
|
+
const end = Math.max(0, matches.length - skip);
|
|
664
|
+
const recent = matches.slice(Math.max(0, end - cap), end);
|
|
265
665
|
if (recent.length === 0) {
|
|
266
|
-
|
|
666
|
+
const window = fromMs !== null ? ` between ${isoShort(fromMs)} and ${isoShort(toMs)}` : '';
|
|
667
|
+
return needle ? `No log entries mentioning "${script_name}"${window}.` : `No log entries${window}.`;
|
|
668
|
+
}
|
|
669
|
+
const older = end - recent.length;
|
|
670
|
+
const head = `${recent.length} of ${matches.length} matching lines${older > 0 ? ` (${older} older; offset ${skip + recent.length} for them)` : ''}, oldest first:\n`;
|
|
671
|
+
return head + recent.map((e) => `[${isoShort(e.ts)}] ${String(e.level).toUpperCase()} ${e.msg}`).join('\n');
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
// ---------------------------------------------------------------------------
|
|
675
|
+
// History (roadmap I15, decision D-3): InfluxDB first, Elasticsearch for the raw messages
|
|
676
|
+
// ---------------------------------------------------------------------------
|
|
677
|
+
|
|
678
|
+
/**
|
|
679
|
+
* The measurements influx4mqtt may have written for a topic: `<name>//<path without the status segment>`
|
|
680
|
+
* (what the maintainer's instance writes), `<name>//<rest>`, and the literal topic.
|
|
681
|
+
*/
|
|
682
|
+
function measurementCandidates(topic) {
|
|
683
|
+
const t = String(topic).trim();
|
|
684
|
+
const parts = t.split('/');
|
|
685
|
+
const out = [];
|
|
686
|
+
if (parts.length >= 3 && parts[1] === 'status') out.push(parts[0] + '//' + parts.slice(2).join('/'));
|
|
687
|
+
if (parts.length >= 2) out.push(parts[0] + '//' + parts.slice(1).join('/'));
|
|
688
|
+
out.push(t);
|
|
689
|
+
return [...new Set(out)];
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
/** change points first; when still too many, every k-th point */
|
|
693
|
+
function thinSeries(rows, limit) {
|
|
694
|
+
const changes = rows.filter((r, i) => i === 0 || i === rows.length - 1 || r.value !== rows[i - 1].value);
|
|
695
|
+
if (changes.length <= limit) return { points: changes, note: changes.length < rows.length ? `${rows.length} points reduced to ${changes.length} change points` : '' };
|
|
696
|
+
const k = Math.ceil(changes.length / limit);
|
|
697
|
+
const thinned = changes.filter((_, i) => i % k === 0 || i === changes.length - 1);
|
|
698
|
+
return { points: thinned, note: `${rows.length} points, ${changes.length} change points, every ${k}th shown` };
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
async function toolGetTopicHistory({ topic, from = '-24h', to, limit = 200 } = {}) {
|
|
702
|
+
if (!topic) return 'topic is required.';
|
|
703
|
+
let influx;
|
|
704
|
+
try {
|
|
705
|
+
influx = require('../influx');
|
|
706
|
+
} catch {
|
|
707
|
+
return 'InfluxDB is not available.';
|
|
708
|
+
}
|
|
709
|
+
const mode = influx.getMode?.();
|
|
710
|
+
if (!mode) return 'No InfluxDB integration is configured in she (config "influx"), so there is no history. The current value: get_mqtt_topic.';
|
|
711
|
+
if (mode !== 'v1') return 'Topic history is implemented for InfluxDB 1.x only so far.';
|
|
712
|
+
const now = Date.now();
|
|
713
|
+
const fromMs = parseTime(from, now);
|
|
714
|
+
const toMs = parseTime(to, now);
|
|
715
|
+
if (Number.isNaN(fromMs) || Number.isNaN(toMs)) return `from/to must be ISO 8601 or relative ("-2h"); got from=${from}, to=${to}.`;
|
|
716
|
+
const cap = Math.min(Math.max(1, Number(limit) || 200), 500);
|
|
717
|
+
const tried = [];
|
|
718
|
+
// influx4mqtt's measurements first, then she's own schema (measurement "mqtt", tag "topic", field "value")
|
|
719
|
+
const queries = measurementCandidates(topic).map((m) => [
|
|
720
|
+
m,
|
|
721
|
+
`SELECT "value" FROM "${m.replace(/"/g, '\\"')}" WHERE time >= ${fromMs}ms AND time <= ${toMs}ms ORDER BY time ASC LIMIT 5000`,
|
|
722
|
+
]);
|
|
723
|
+
queries.push([
|
|
724
|
+
`mqtt (topic = ${topic})`,
|
|
725
|
+
`SELECT "value" FROM "mqtt" WHERE "topic" = '${String(topic).replace(/'/g, "\\'")}' AND time >= ${fromMs}ms AND time <= ${toMs}ms ORDER BY time ASC LIMIT 5000`,
|
|
726
|
+
]);
|
|
727
|
+
for (const [m, q] of queries) {
|
|
728
|
+
tried.push(m);
|
|
729
|
+
let rows;
|
|
730
|
+
try {
|
|
731
|
+
rows = await influx.v1Query(q);
|
|
732
|
+
} catch (e) {
|
|
733
|
+
return `InfluxDB query failed: ${e.message}`;
|
|
734
|
+
}
|
|
735
|
+
if (!rows || rows.length === 0) continue;
|
|
736
|
+
const series = rows.map((r) => ({ time: r.time, value: r.value }));
|
|
737
|
+
const { points, note } = thinSeries(series, cap);
|
|
738
|
+
const lines = [
|
|
739
|
+
`History of ${topic} (measurement "${m}") from ${isoShort(fromMs)} to ${isoShort(toMs)}${rows.length >= 5000 ? ', the first 5000 points of the window' : ''}${note ? `; ${note}` : ''}:`,
|
|
740
|
+
];
|
|
741
|
+
for (const pt of points) lines.push(`${isoShort(pt.time)} ${JSON.stringify(pt.value)}`);
|
|
742
|
+
return lines.join('\n');
|
|
743
|
+
}
|
|
744
|
+
return `No history for ${topic} between ${isoShort(fromMs)} and ${isoShort(toMs)} (tried measurements ${tried.map((m) => `"${m}"`).join(', ')}).`;
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
async function toolGetTopicMessages({ topic, from = '-24h', to, limit = 100 } = {}, ctx = {}) {
|
|
748
|
+
if (!topic) return 'topic is required.';
|
|
749
|
+
let client = null;
|
|
750
|
+
try {
|
|
751
|
+
client = require('../elastic').getClient();
|
|
752
|
+
} catch {
|
|
753
|
+
/* module missing */
|
|
754
|
+
}
|
|
755
|
+
if (!client) return 'No Elasticsearch integration is configured in she (config "elastic"), so there is no message list. Try get_topic_history.';
|
|
756
|
+
const now = Date.now();
|
|
757
|
+
const fromMs = parseTime(from, now);
|
|
758
|
+
const toMs = parseTime(to, now);
|
|
759
|
+
if (Number.isNaN(fromMs) || Number.isNaN(toMs)) return `from/to must be ISO 8601 or relative ("-2h"); got from=${from}, to=${to}.`;
|
|
760
|
+
const cap = Math.min(Math.max(1, Number(limit) || 100), 500);
|
|
761
|
+
const index = ctx.elasticIndex || 'mqtt-*';
|
|
762
|
+
let res;
|
|
763
|
+
try {
|
|
764
|
+
res = await client.search({
|
|
765
|
+
index,
|
|
766
|
+
size: cap,
|
|
767
|
+
query: { bool: { filter: [{ term: { topic: String(topic) } }, { range: { '@timestamp': { gte: fromMs, lte: toMs } } }] } },
|
|
768
|
+
sort: [{ '@timestamp': { order: 'desc' } }],
|
|
769
|
+
_source: ['@timestamp', 'payload'],
|
|
770
|
+
});
|
|
771
|
+
} catch (e) {
|
|
772
|
+
return `Elasticsearch query failed: ${e.message}`;
|
|
773
|
+
}
|
|
774
|
+
const hits = res?.hits?.hits ?? res?.body?.hits?.hits ?? [];
|
|
775
|
+
const total = res?.hits?.total?.value ?? res?.body?.hits?.total?.value ?? hits.length;
|
|
776
|
+
if (hits.length === 0) return `No messages for ${topic} between ${isoShort(fromMs)} and ${isoShort(toMs)} in ${index}.`;
|
|
777
|
+
const lines = [`${hits.length} of ${total} messages for ${topic} from ${isoShort(fromMs)} to ${isoShort(toMs)}, newest first:`];
|
|
778
|
+
for (const h of hits) {
|
|
779
|
+
const src = h._source || {};
|
|
780
|
+
const ts = typeof src['@timestamp'] === 'number' ? src['@timestamp'] : Date.parse(src['@timestamp']);
|
|
781
|
+
lines.push(`${isoShort(ts)} ${String(src.payload ?? '')}`);
|
|
782
|
+
}
|
|
783
|
+
return lines.join('\n');
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
// ---------------------------------------------------------------------------
|
|
787
|
+
// The daemon's own state (roadmap I19, I27, I28) through the introspection the daemon hands to init()
|
|
788
|
+
// ---------------------------------------------------------------------------
|
|
789
|
+
|
|
790
|
+
const NO_INTROSPECT = 'The daemon did not expose its state to the AI (older she, or a test).';
|
|
791
|
+
|
|
792
|
+
function inTime(ms) {
|
|
793
|
+
if (!ms) return 'unknown';
|
|
794
|
+
const diff = ms - Date.now();
|
|
795
|
+
return diff >= 0 ? `in ${ago(diff)} (${isoShort(ms)})` : `${ago(-diff)} overdue`;
|
|
796
|
+
}
|
|
797
|
+
|
|
798
|
+
function toolListScripts({ filter = '', offset = 0, limit = 50 } = {}, ctx = {}) {
|
|
799
|
+
if (!ctx.introspect?.scripts) return NO_INTROSPECT;
|
|
800
|
+
const q = String(filter).toLowerCase();
|
|
801
|
+
const all = ctx.introspect.scripts();
|
|
802
|
+
const hit = (sc) => !q || sc.label.toLowerCase().includes(q) || [...sc.subscriptions, ...sc.varSubscriptions, ...sc.publishes].some((t) => String(t).toLowerCase().includes(q));
|
|
803
|
+
const list = all.filter(hit).sort((a, b) => a.label.localeCompare(b.label));
|
|
804
|
+
if (list.length === 0) return q ? `No script matches "${filter}".` : 'No scripts loaded.';
|
|
805
|
+
const { page, note } = pageOf(list, offset, limit, 200, 50);
|
|
806
|
+
const lines = [`${page.length} of ${list.length} script(s)${note}:`];
|
|
807
|
+
const some = (arr, n = 12) => (arr.length <= n ? arr.join(', ') : arr.slice(0, n).join(', ') + ` … (${arr.length} total)`);
|
|
808
|
+
for (const sc of page) {
|
|
809
|
+
lines.push(`\n### ${sc.label}${sc.origin !== 'user' ? ` (${sc.origin})` : ''}`);
|
|
810
|
+
if (sc.subscriptions.length) lines.push(`- subscribes: ${some(sc.subscriptions)}`);
|
|
811
|
+
if (sc.varSubscriptions.length) lines.push(`- variable subscriptions: ${some(sc.varSubscriptions)}`);
|
|
812
|
+
if (sc.publishes.length) lines.push(`- publishes (seen): ${some(sc.publishes)}`);
|
|
813
|
+
const sched = [...sc.jobs.map((j) => `job ${inTime(j.next)}`), ...sc.sunEvents.map((e) => `${e.pattern} ${inTime(e.next)}`)];
|
|
814
|
+
if (sched.length) lines.push(`- schedules: ${some(sched, 6)}`);
|
|
815
|
+
if (sc.timers.length) lines.push(`- pending timers: ${sc.timers.length}`);
|
|
816
|
+
}
|
|
817
|
+
return lines.join('\n');
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
function toolWhoPublishes({ topic } = {}, ctx = {}) {
|
|
821
|
+
if (!topic) return 'topic is required.';
|
|
822
|
+
if (!ctx.introspect?.scripts) return NO_INTROSPECT;
|
|
823
|
+
const t = String(topic);
|
|
824
|
+
const lines = [];
|
|
825
|
+
const scripts = ctx.introspect.scripts().filter((sc) => sc.publishes.includes(t));
|
|
826
|
+
if (scripts.length) lines.push(`Scripts that published ${t} since the daemon started: ${scripts.map((sc) => sc.label).join(', ')}.`);
|
|
827
|
+
else lines.push(`No loaded script has published ${t} since the daemon started (publishes are recorded as they happen).`);
|
|
828
|
+
const instanceName = t.split('/')[0];
|
|
829
|
+
const inst = ctx.introspect.instances ? ctx.introspect.instances().find((i) => i.instance === instanceName) : null;
|
|
830
|
+
if (inst) {
|
|
831
|
+
lines.push(
|
|
832
|
+
`The topic's first segment "${instanceName}" is an adapter instance: ${inst.adapter || 'legacy adapter'}${inst.version ? ' ' + inst.version : ''}${inst.host ? ' on ' + inst.host : ''}, ${inst.connected === null ? 'state unknown' : inst.connected > 1 ? 'connected to its device' : inst.connected === 1 ? 'connected to the broker only' : 'offline'}${/\/status\//.test(t) ? ' — a status topic is written by the adapter from the device' : ''}.`,
|
|
833
|
+
);
|
|
834
|
+
} else if (ctx.introspect.instances) {
|
|
835
|
+
lines.push(`"${instanceName}" is no adapter instance known on the broker.`);
|
|
267
836
|
}
|
|
268
|
-
return
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
837
|
+
return lines.join('\n');
|
|
838
|
+
}
|
|
839
|
+
|
|
840
|
+
function toolDescribeDevice({ name, limit = 40 } = {}, ctx = {}) {
|
|
841
|
+
if (!name) return 'name is required.';
|
|
842
|
+
const store = ctx.store;
|
|
843
|
+
if (!store) return 'MQTT state store not available.';
|
|
844
|
+
const q = String(name).trim().toLowerCase();
|
|
845
|
+
const groups = { status: [], set: [], maintenance: [], other: [] };
|
|
846
|
+
const now = Date.now();
|
|
847
|
+
let total = 0;
|
|
848
|
+
for (const [topic, obj] of store.mqttEntries()) {
|
|
849
|
+
const segs = topic.split('/');
|
|
850
|
+
if (!segs.some((sg) => sg.toLowerCase() === q) && !topic.toLowerCase().includes('/' + q + '/') && !topic.toLowerCase().startsWith(q + '/')) continue;
|
|
851
|
+
total++;
|
|
852
|
+
const kind = segs[1] === 'status' ? 'status' : segs[1] === 'set' ? 'set' : segs[1] === 'maintenance' ? 'maintenance' : 'other';
|
|
853
|
+
const lc = obj.lc ?? obj.ts;
|
|
854
|
+
groups[kind].push(`${topic}: ${JSON.stringify(obj.val)}${lc ? ` (changed ${ago(now - lc)} ago)` : ''}`);
|
|
855
|
+
}
|
|
856
|
+
const lines = [];
|
|
857
|
+
if (total) {
|
|
858
|
+
lines.push(`${total} topic(s) for "${name}":`);
|
|
859
|
+
const cap = Math.min(Math.max(1, Number(limit) || 40), 200);
|
|
860
|
+
for (const kind of ['status', 'set', 'maintenance', 'other']) {
|
|
861
|
+
if (!groups[kind].length) continue;
|
|
862
|
+
lines.push(`\n## ${kind} (${groups[kind].length})`);
|
|
863
|
+
lines.push(...groups[kind].slice(0, cap));
|
|
864
|
+
if (groups[kind].length > cap) lines.push(`… ${groups[kind].length - cap} more; search_mqtt_topics with a filter for the rest`);
|
|
865
|
+
}
|
|
866
|
+
}
|
|
867
|
+
if (ctx.introspect?.devices) {
|
|
868
|
+
const devs = ctx.introspect.devices().filter((d) =>
|
|
869
|
+
String(d.name || d.id || '')
|
|
870
|
+
.toLowerCase()
|
|
871
|
+
.includes(q),
|
|
872
|
+
);
|
|
873
|
+
for (const d of devs) {
|
|
874
|
+
lines.push(`\n## Home Assistant discovery: ${d.name || d.id}${d.orphaned ? ' (orphaned: no state topic alive)' : ''}`);
|
|
875
|
+
for (const e of d.entities || [])
|
|
876
|
+
lines.push(`- ${e.component || e.configTopic?.split('/')[1] || 'entity'}: ${e.name || e.configTopic}${e.stateTopic ? ` ← ${e.stateTopic}` : ''}`);
|
|
877
|
+
if (d.refTopics?.length) lines.push(`- topics referenced: ${d.refTopics.slice(0, 20).join(', ')}${d.refTopics.length > 20 ? ' …' : ''}`);
|
|
878
|
+
}
|
|
879
|
+
}
|
|
880
|
+
if (!lines.length) return `Nothing known under "${name}": no topic segment and no discovery device matches. Try search_mqtt_topics with a substring.`;
|
|
881
|
+
return lines.join('\n');
|
|
882
|
+
}
|
|
883
|
+
|
|
884
|
+
function toolListServices({ filter = '' } = {}, ctx = {}) {
|
|
885
|
+
if (!ctx.introspect?.instances) return NO_INTROSPECT;
|
|
886
|
+
const q = String(filter).toLowerCase();
|
|
887
|
+
const list = ctx.introspect.instances().filter((i) => !q || [i.instance, i.adapter, i.host].some((x) => x && String(x).toLowerCase().includes(q)));
|
|
888
|
+
if (!list.length) return q ? `No adapter instance matches "${filter}".` : 'No adapter instances seen on the broker.';
|
|
889
|
+
const lines = [`${list.length} adapter instance(s):`];
|
|
890
|
+
for (const i of list) {
|
|
891
|
+
const state = i.connected === null ? 'unknown' : i.connected > 1 ? 'connected' : i.connected === 1 ? 'broker only' : 'offline';
|
|
892
|
+
lines.push(
|
|
893
|
+
`- ${i.instance}: ${i.adapter || 'legacy'}${i.version ? ' ' + i.version : ''}${i.host ? ' on ' + i.host : ''}, ${state}${i.uptime ? `, up ${ago(i.uptime)}` : ''}${i.maintenance ? ', maintenance topics' : ''}`,
|
|
894
|
+
);
|
|
895
|
+
}
|
|
896
|
+
return lines.join('\n');
|
|
897
|
+
}
|
|
898
|
+
|
|
899
|
+
function toolGetHealth(ctx = {}) {
|
|
900
|
+
if (!ctx.introspect?.health) return NO_INTROSPECT;
|
|
901
|
+
const h = ctx.introspect.health() || {};
|
|
902
|
+
const st = ctx.introspect.stats ? ctx.introspect.stats() || {} : {};
|
|
903
|
+
const lines = [
|
|
904
|
+
`started: ${h.started}, mqtt: ${h.mqttConfigured ? (h.mqttConnected ? 'connected' : 'DISCONNECTED') : 'not configured'}, scripts loaded: ${h.scripts}, safe mode: ${h.safeMode ? 'YES' : 'no'}`,
|
|
905
|
+
];
|
|
906
|
+
if (st.topics !== undefined) lines.push(`topics: ${st.topics}, messages/s: ${st.mqttMsgPerSec}, handlers: ${st.handlers}`);
|
|
907
|
+
if (st.memMb !== undefined)
|
|
908
|
+
lines.push(`memory: ${st.memMb} MB, cpu: ${st.cpuPercent}%, event loop: ${st.eluPercent}% utilised, lag mean ${st.elMeanMs} ms max ${st.elMaxMs} ms`);
|
|
909
|
+
if (st.matterEnabled) lines.push(`matter: ${st.matterNodes} node(s), ${st.matterEndpoints} endpoint(s)`);
|
|
910
|
+
if (st.dbEnabled) lines.push(`sheDB: ${st.dbDocs} document(s), ${st.dbViews} view(s)`);
|
|
911
|
+
return lines.join('\n');
|
|
912
|
+
}
|
|
913
|
+
|
|
914
|
+
function toolListTimers({ script = '' } = {}, ctx = {}) {
|
|
915
|
+
if (!ctx.introspect?.scripts) return NO_INTROSPECT;
|
|
916
|
+
const q = String(script).toLowerCase();
|
|
917
|
+
const lines = [];
|
|
918
|
+
for (const sc of ctx.introspect.scripts()) {
|
|
919
|
+
if (q && !sc.label.toLowerCase().includes(q)) continue;
|
|
920
|
+
const items = [
|
|
921
|
+
...sc.timers.map((t) => (t.every ? `interval every ${ago(t.every)}, next ${inTime(t.due)}` : `timer ${inTime(t.due)}`)),
|
|
922
|
+
...sc.jobs.map((j) => `schedule job ${inTime(j.next)}`),
|
|
923
|
+
...sc.sunEvents.map((e) => `${e.pattern} ${inTime(e.next)}`),
|
|
924
|
+
];
|
|
925
|
+
if (!items.length) continue;
|
|
926
|
+
lines.push(`\n### ${sc.label}`);
|
|
927
|
+
items.sort();
|
|
928
|
+
lines.push(...items.slice(0, 30).map((x) => '- ' + x));
|
|
929
|
+
if (items.length > 30) lines.push(`… ${items.length - 30} more`);
|
|
930
|
+
}
|
|
931
|
+
if (!lines.length) return q ? `Nothing pending for scripts matching "${script}".` : 'Nothing pending in any script.';
|
|
932
|
+
return `Pending per script:` + lines.join('\n');
|
|
933
|
+
}
|
|
934
|
+
|
|
935
|
+
/** the same rule the scripts API applies: inside the scripts directory, a .js file, no escape */
|
|
936
|
+
function scriptPathOf(scriptDir, relPath) {
|
|
937
|
+
if (!scriptDir || !relPath || typeof relPath !== 'string') return null;
|
|
938
|
+
const rel = relPath.replace(/\\/g, '/').replace(/^\/+/, '');
|
|
939
|
+
if (!rel.endsWith('.js') || rel.includes('..')) return null;
|
|
940
|
+
const abs = path.resolve(scriptDir, rel);
|
|
941
|
+
if (!abs.startsWith(path.resolve(scriptDir) + path.sep)) return null;
|
|
942
|
+
return { rel, abs };
|
|
943
|
+
}
|
|
944
|
+
|
|
945
|
+
function toolProposeScript({ path: relPath, content, note = '' } = {}, ctx = {}) {
|
|
946
|
+
if (!ctx.drafts || !ctx.scriptDir) return 'Drafts are not available here.';
|
|
947
|
+
const p = scriptPathOf(ctx.scriptDir, relPath);
|
|
948
|
+
if (!p) return `"${relPath}" is not a script path inside the scripts directory (a .js file, no "..").`;
|
|
949
|
+
if (typeof content !== 'string' || !content.trim()) return 'content must be the complete new file.';
|
|
950
|
+
let base = null;
|
|
951
|
+
try {
|
|
952
|
+
base = fs.readFileSync(p.abs, 'utf8');
|
|
953
|
+
} catch {
|
|
954
|
+
/* a new file */
|
|
955
|
+
}
|
|
956
|
+
const text = content.endsWith('\n') ? content : content + '\n';
|
|
957
|
+
if (base !== null && base === text) return `The draft equals the current ${p.rel}; nothing to change.`;
|
|
958
|
+
const diff = unifiedDiff(base ?? '', text, { fromName: base === null ? '/dev/null' : p.rel, toName: p.rel });
|
|
959
|
+
const draft = ctx.drafts.create({ path: p.rel, content: text, base, note: String(note || '') });
|
|
960
|
+
const summary = base === null ? `new file, ${diff.added} lines` : `${diff.added} added, ${diff.removed} removed`;
|
|
961
|
+
return {
|
|
962
|
+
text: `Draft ${draft.id} for ${p.rel} (${summary}) is shown to the user with an Apply button; it is not written yet. Say in one sentence what it changes and wait.`,
|
|
963
|
+
event: { type: 'draft', id: draft.id, path: p.rel, note: draft.note, isNew: base === null, diff: diff.text, added: diff.added, removed: diff.removed },
|
|
964
|
+
};
|
|
965
|
+
}
|
|
966
|
+
|
|
967
|
+
const PUBLISH_ALLOW = ['+/set/#', 'zigbee2mqtt/+/set', 'zigbee2mqtt/+/set/#'];
|
|
968
|
+
|
|
969
|
+
/** null when the topic may be published to, otherwise the reason */
|
|
970
|
+
function publishRefusal(topic, allow) {
|
|
971
|
+
const t = String(topic || '').trim();
|
|
972
|
+
if (!t || t.includes('#') || t.includes('+')) return 'the topic must be a concrete topic without wildcards';
|
|
973
|
+
if (/^[^/]+\/status\//.test(t)) return 'a status topic is written by its adapter, not by the chat';
|
|
974
|
+
const patterns = [...PUBLISH_ALLOW, ...(Array.isArray(allow) ? allow : [])];
|
|
975
|
+
if (!patterns.some((p) => mqttWildcard(t, p))) return `only command topics may be published (${patterns.join(', ')}); ai.publishAllow lists more`;
|
|
976
|
+
return null;
|
|
977
|
+
}
|
|
978
|
+
|
|
979
|
+
async function toolPublishMqtt({ topic, payload, retain = false } = {}, ctx = {}) {
|
|
980
|
+
const pub = ctx.publish;
|
|
981
|
+
if (!pub || pub.mode === 'off' || !pub.send) return 'Publishing is switched off in the chat.';
|
|
982
|
+
const why = publishRefusal(topic, pub.allow);
|
|
983
|
+
if (why) return `Refused: ${why}.`;
|
|
984
|
+
const value = payload === undefined || payload === null ? '' : String(payload);
|
|
985
|
+
const doRetain = retain === true;
|
|
986
|
+
if (pub.mode === 'confirm') {
|
|
987
|
+
const decision = await pub.confirm({ topic: String(topic), payload: value, retain: doRetain });
|
|
988
|
+
if (decision === 'timeout') return 'The user did not answer the confirmation; nothing was published.';
|
|
989
|
+
if (!decision) return 'The user declined; nothing was published.';
|
|
990
|
+
}
|
|
991
|
+
try {
|
|
992
|
+
await pub.send(String(topic), value, { retain: doRetain });
|
|
993
|
+
} catch (e) {
|
|
994
|
+
return `Publish failed: ${e.message}`;
|
|
995
|
+
}
|
|
996
|
+
return `Published ${JSON.stringify(value)} to ${topic}${doRetain ? ' (retained)' : ''}.`;
|
|
997
|
+
}
|
|
998
|
+
|
|
999
|
+
function toolRemember({ text } = {}, ctx = {}) {
|
|
1000
|
+
if (!ctx.memory) return 'The memory is not available.';
|
|
1001
|
+
const r = ctx.memory.add(text, 'model');
|
|
1002
|
+
if (r.error) return `Not stored: ${r.error}.`;
|
|
1003
|
+
return r.duplicate ? `Already known as [${r.note.id}]: ${r.note.text}` : `Stored as [${r.note.id}]: ${r.note.text}`;
|
|
1004
|
+
}
|
|
1005
|
+
|
|
1006
|
+
function toolForget({ id } = {}, ctx = {}) {
|
|
1007
|
+
if (!ctx.memory) return 'The memory is not available.';
|
|
1008
|
+
const r = ctx.memory.remove(String(id || '').replace(/^\[|\]$/g, ''));
|
|
1009
|
+
if (r.error) return `Not removed: ${r.error}.`;
|
|
1010
|
+
return `Forgotten: ${r.note.text}`;
|
|
274
1011
|
}
|
|
275
1012
|
|
|
276
1013
|
const MAX_FETCH_CHARS = 8000;
|
|
277
1014
|
|
|
278
|
-
|
|
1015
|
+
/**
|
|
1016
|
+
* Private address ranges and local names the AI may not fetch (roadmap I23, decision D-4): a URL the model
|
|
1017
|
+
* picked up from a page or a document must not reach the LAN. Hosts on the allow-list are exempt.
|
|
1018
|
+
*/
|
|
1019
|
+
function isPrivateAddress(ip) {
|
|
1020
|
+
const v4 = ip.startsWith('::ffff:') ? ip.slice(7) : ip;
|
|
1021
|
+
if (net.isIPv4(v4)) {
|
|
1022
|
+
const [a, b] = v4.split('.').map(Number);
|
|
1023
|
+
return a === 10 || a === 127 || a === 0 || (a === 172 && b >= 16 && b <= 31) || (a === 192 && b === 168) || (a === 169 && b === 254) || (a === 100 && b >= 64 && b <= 127);
|
|
1024
|
+
}
|
|
1025
|
+
const lower = ip.toLowerCase();
|
|
1026
|
+
return lower === '::1' || lower === '::' || /^f[cd]/.test(lower) || /^fe[89ab]/.test(lower);
|
|
1027
|
+
}
|
|
1028
|
+
|
|
1029
|
+
function isLocalName(host) {
|
|
1030
|
+
const h = host.toLowerCase().replace(/\.$/, '');
|
|
1031
|
+
return h === 'localhost' || /\.(local|lan|home|internal|localdomain)$/.test(h) || !h.includes('.');
|
|
1032
|
+
}
|
|
1033
|
+
|
|
1034
|
+
/** null when the host may be fetched, otherwise the reason */
|
|
1035
|
+
async function fetchRefusal(host, allow) {
|
|
1036
|
+
const h = host.toLowerCase();
|
|
1037
|
+
if (allow.some((a) => String(a).toLowerCase() === h)) return null;
|
|
1038
|
+
if (!net.isIP(h) && isLocalName(h)) return `"${host}" is a local name`;
|
|
1039
|
+
let addresses;
|
|
1040
|
+
if (net.isIP(h)) addresses = [h];
|
|
1041
|
+
else {
|
|
1042
|
+
try {
|
|
1043
|
+
addresses = (await dns.promises.lookup(h, { all: true })).map((r) => r.address);
|
|
1044
|
+
} catch (e) {
|
|
1045
|
+
return `"${host}" does not resolve (${e.code || e.message})`;
|
|
1046
|
+
}
|
|
1047
|
+
}
|
|
1048
|
+
const priv = addresses.find(isPrivateAddress);
|
|
1049
|
+
return priv ? `"${host}" is a private address (${priv})` : null;
|
|
1050
|
+
}
|
|
1051
|
+
|
|
1052
|
+
async function toolSheFetch({ url }, ctx = {}) {
|
|
279
1053
|
if (!url || typeof url !== 'string') return 'url argument is required.';
|
|
280
1054
|
if (!/^https?:\/\//i.test(url)) return 'Only http and https URLs are supported.';
|
|
281
|
-
const
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
1055
|
+
const allow = Array.isArray(ctx.fetchAllow) ? ctx.fetchAllow : [];
|
|
1056
|
+
let current = url;
|
|
1057
|
+
let res;
|
|
1058
|
+
for (let hop = 0; hop < 5; hop++) {
|
|
1059
|
+
let parsed;
|
|
1060
|
+
try {
|
|
1061
|
+
parsed = new URL(current);
|
|
1062
|
+
} catch {
|
|
1063
|
+
return `"${current}" is not a valid URL.`;
|
|
1064
|
+
}
|
|
1065
|
+
const why = await fetchRefusal(parsed.hostname, allow);
|
|
1066
|
+
if (why) return `Refused: ${why}; the AI may not fetch private or local addresses (ai.fetchAllow lists exceptions).`;
|
|
1067
|
+
res = await fetch(current, {
|
|
1068
|
+
headers: { 'User-Agent': 'she-ai-agent/1.0' },
|
|
1069
|
+
signal: AbortSignal.timeout(15000),
|
|
1070
|
+
redirect: 'manual',
|
|
1071
|
+
});
|
|
1072
|
+
const location = res.status >= 300 && res.status < 400 && res.headers.get('location');
|
|
1073
|
+
if (!location) break;
|
|
1074
|
+
current = new URL(location, current).toString(); // every hop is checked like the first
|
|
1075
|
+
}
|
|
1076
|
+
if (!res.ok) return `HTTP error ${res.status} ${res.statusText} fetching ${current}`;
|
|
286
1077
|
const ct = res.headers.get('content-type') || '';
|
|
287
1078
|
const text = await res.text();
|
|
288
1079
|
// Strip HTML tags for cleaner text
|
|
@@ -306,7 +1097,7 @@ function toolGetMqttTopic({ topic }, store) {
|
|
|
306
1097
|
return `${topic}: ${JSON.stringify(obj.val)}\n last updated: ${ts}\n last changed: ${lc}`;
|
|
307
1098
|
}
|
|
308
1099
|
|
|
309
|
-
function toolListShedbDocs({ filter = '' }) {
|
|
1100
|
+
function toolListShedbDocs({ filter = '', offset = 0, limit = 200 }) {
|
|
310
1101
|
try {
|
|
311
1102
|
const core = require('./shedb').getCore();
|
|
312
1103
|
if (!core) return 'sheDB not initialised.';
|
|
@@ -314,51 +1105,128 @@ function toolListShedbDocs({ filter = '' }) {
|
|
|
314
1105
|
const q = String(filter).toLowerCase();
|
|
315
1106
|
const filtered = q ? ids.filter((id) => id.toLowerCase().includes(q)) : ids;
|
|
316
1107
|
if (filtered.length === 0) return q ? `No documents found matching "${filter}".` : 'No documents in sheDB.';
|
|
317
|
-
const
|
|
318
|
-
|
|
319
|
-
return `${shown.length} document(s)${suffix}:\n${shown.join('\n')}`;
|
|
1108
|
+
const { page, note } = pageOf(filtered, offset, limit, 500, 200);
|
|
1109
|
+
return `${page.length} of ${filtered.length} document(s)${note}:\n${page.join('\n')}`;
|
|
320
1110
|
} catch (e) {
|
|
321
1111
|
return `sheDB not available: ${e.message}`;
|
|
322
1112
|
}
|
|
323
1113
|
}
|
|
324
1114
|
|
|
325
|
-
function toolGetShedbDoc({ id }) {
|
|
1115
|
+
function toolGetShedbDoc({ id, path: dotted = '' }) {
|
|
326
1116
|
if (!id || typeof id !== 'string') return 'id argument is required.';
|
|
327
1117
|
try {
|
|
328
1118
|
const core = require('./shedb').getCore();
|
|
329
1119
|
if (!core) return 'sheDB not initialised.';
|
|
330
|
-
|
|
1120
|
+
let doc = core.docs[id];
|
|
331
1121
|
if (doc === undefined) return `Document "${id}" not found. Use list_shedb_docs to see available IDs.`;
|
|
332
|
-
|
|
1122
|
+
if (dotted) {
|
|
1123
|
+
for (const key of String(dotted).split('.')) {
|
|
1124
|
+
if (doc === null || typeof doc !== 'object' || !(key in doc)) return `Document "${id}" has no path "${dotted}".`;
|
|
1125
|
+
doc = doc[key];
|
|
1126
|
+
}
|
|
1127
|
+
}
|
|
1128
|
+
return `## ${id}${dotted ? ' › ' + dotted : ''}\n${JSON.stringify(doc, null, 2)}`;
|
|
333
1129
|
} catch (e) {
|
|
334
1130
|
return `sheDB not available: ${e.message}`;
|
|
335
1131
|
}
|
|
336
1132
|
}
|
|
337
1133
|
|
|
338
|
-
|
|
1134
|
+
const STATE_ATTRIBUTES = {
|
|
1135
|
+
OnOff: ['onOff'],
|
|
1136
|
+
LevelControl: ['currentLevel'],
|
|
1137
|
+
ColorControl: ['currentHue', 'currentSaturation', 'colorTemperatureMireds'],
|
|
1138
|
+
TemperatureMeasurement: ['measuredValue'],
|
|
1139
|
+
RelativeHumidityMeasurement: ['measuredValue'],
|
|
1140
|
+
IlluminanceMeasurement: ['measuredValue'],
|
|
1141
|
+
OccupancySensing: ['occupancy'],
|
|
1142
|
+
BooleanState: ['stateValue'],
|
|
1143
|
+
DoorLock: ['lockState'],
|
|
1144
|
+
WindowCovering: ['currentPositionLiftPercent100ths'],
|
|
1145
|
+
Thermostat: ['localTemperature', 'occupiedHeatingSetpoint', 'systemMode'],
|
|
1146
|
+
Switch: ['currentPosition'],
|
|
1147
|
+
PowerSource: ['batPercentRemaining'],
|
|
1148
|
+
};
|
|
1149
|
+
|
|
1150
|
+
function withTimeout(promise, ms) {
|
|
1151
|
+
let timer;
|
|
1152
|
+
const timeout = new Promise((_, reject) => {
|
|
1153
|
+
timer = setTimeout(() => reject(new Error('timeout')), ms);
|
|
1154
|
+
if (timer.unref) timer.unref();
|
|
1155
|
+
});
|
|
1156
|
+
return Promise.race([promise, timeout]).finally(() => clearTimeout(timer));
|
|
1157
|
+
}
|
|
1158
|
+
|
|
1159
|
+
function matterController() {
|
|
339
1160
|
try {
|
|
340
1161
|
const controller = require('../matter/controller');
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
1162
|
+
return typeof controller.listPaired === 'function' ? controller : null;
|
|
1163
|
+
} catch {
|
|
1164
|
+
return null;
|
|
1165
|
+
}
|
|
1166
|
+
}
|
|
1167
|
+
|
|
1168
|
+
async function toolListMatterDevices({ with_state = true, offset = 0, limit = 50 } = {}) {
|
|
1169
|
+
const controller = matterController();
|
|
1170
|
+
if (!controller) return 'Matter controller not available.';
|
|
1171
|
+
const nodes = controller.listPaired();
|
|
1172
|
+
if (nodes.length === 0) return 'No Matter devices paired.';
|
|
1173
|
+
const { page, note } = pageOf(nodes, offset, limit, 200, 50);
|
|
1174
|
+
const lines = [`${page.length} of ${nodes.length} paired Matter device(s)${note}:`];
|
|
1175
|
+
for (const n of page) {
|
|
1176
|
+
lines.push(`\n### ${n.name || 'Unnamed'} (nodeId: "${n.nodeId}", ${n.online ? 'online' : 'offline'})`);
|
|
1177
|
+
let endpoints = [];
|
|
1178
|
+
try {
|
|
1179
|
+
endpoints = controller.getEndpoints(n.nodeId);
|
|
1180
|
+
} catch {
|
|
1181
|
+
/* node may be offline */
|
|
1182
|
+
}
|
|
1183
|
+
for (const ep of endpoints) {
|
|
1184
|
+
if (ep.endpointId === 0) continue; // skip root endpoint
|
|
1185
|
+
const name = ep.name || String(ep.endpointId);
|
|
1186
|
+
let state = '';
|
|
1187
|
+
if (with_state && n.online && typeof controller.getAttribute === 'function') {
|
|
1188
|
+
const reads = [];
|
|
1189
|
+
for (const cluster of ep.clusters || []) {
|
|
1190
|
+
for (const attr of STATE_ATTRIBUTES[cluster] || []) {
|
|
1191
|
+
reads.push(
|
|
1192
|
+
withTimeout(
|
|
1193
|
+
Promise.resolve().then(() => controller.getAttribute(n.nodeId, ep.endpointId, cluster, attr)),
|
|
1194
|
+
3000,
|
|
1195
|
+
).then(
|
|
1196
|
+
(v) => `${cluster}.${attr}=${JSON.stringify(v)}`,
|
|
1197
|
+
() => null,
|
|
1198
|
+
),
|
|
1199
|
+
);
|
|
1200
|
+
}
|
|
353
1201
|
}
|
|
354
|
-
|
|
355
|
-
|
|
1202
|
+
const got = (await Promise.all(reads)).filter(Boolean);
|
|
1203
|
+
if (got.length) state = ` — ${got.join(', ')}`;
|
|
356
1204
|
}
|
|
1205
|
+
lines.push(`- endpoint "${name}" (id: ${ep.endpointId}): ${(ep.clusters || []).join(', ')}${state}`);
|
|
357
1206
|
}
|
|
358
|
-
|
|
1207
|
+
}
|
|
1208
|
+
return lines.join('\n');
|
|
1209
|
+
}
|
|
1210
|
+
|
|
1211
|
+
async function toolGetMatterAttribute({ node, endpoint, cluster, attribute } = {}) {
|
|
1212
|
+
if (!node || endpoint === undefined || endpoint === null || endpoint === '' || !cluster || !attribute) return 'node, endpoint, cluster and attribute are required.';
|
|
1213
|
+
const controller = matterController();
|
|
1214
|
+
if (!controller || typeof controller.getAttribute !== 'function') return 'Matter controller not available.';
|
|
1215
|
+
try {
|
|
1216
|
+
const ep = Number.isNaN(Number(endpoint)) ? String(endpoint) : Number(endpoint);
|
|
1217
|
+
const value = await withTimeout(
|
|
1218
|
+
Promise.resolve().then(() => controller.getAttribute(String(node), ep, String(cluster), String(attribute))),
|
|
1219
|
+
5000,
|
|
1220
|
+
);
|
|
1221
|
+
return `${node} / ${endpoint} / ${cluster}.${attribute} = ${JSON.stringify(value)}`;
|
|
359
1222
|
} catch (e) {
|
|
360
|
-
return `
|
|
1223
|
+
return `Could not read ${cluster}.${attribute} of ${node}/${endpoint}: ${e.message}`;
|
|
361
1224
|
}
|
|
362
1225
|
}
|
|
363
1226
|
|
|
364
|
-
module.exports = {
|
|
1227
|
+
module.exports = {
|
|
1228
|
+
TOOL_DEFINITIONS,
|
|
1229
|
+
TOOL_DEFINITIONS_ANTHROPIC,
|
|
1230
|
+
executeTool,
|
|
1231
|
+
_internal: { isPrivateAddress, isLocalName, parseDuration, valueMatcher, pageOf, parseTime, measurementCandidates, thinSeries, publishRefusal, scriptPathOf },
|
|
1232
|
+
};
|