smart-home-engine 1.51.2 → 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-4A8DZYIU.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 +481 -69
- 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 +941 -66
- 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-CSKTxEh5.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,7 +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").',
|
|
57
|
+
},
|
|
58
|
+
offset: { type: 'integer', description: 'Skip this many matches (paging; default 0).' },
|
|
59
|
+
limit: {
|
|
60
|
+
type: 'integer',
|
|
61
|
+
description: 'Maximum number of topics to return (1-500, default 50). The result says how many matched in total.',
|
|
38
62
|
},
|
|
39
63
|
},
|
|
40
64
|
required: ['query'],
|
|
@@ -62,18 +86,24 @@ const TOOL_DEFINITIONS = [
|
|
|
62
86
|
type: 'function',
|
|
63
87
|
function: {
|
|
64
88
|
name: 'get_script_logs',
|
|
65
|
-
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").',
|
|
66
92
|
parameters: {
|
|
67
93
|
type: 'object',
|
|
68
94
|
properties: {
|
|
69
95
|
script_name: {
|
|
70
96
|
type: 'string',
|
|
71
|
-
description: 'Filter
|
|
97
|
+
description: 'Filter lines to those mentioning this script name (file name without extension). Empty string: all lines.',
|
|
72
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).' },
|
|
73
102
|
limit: {
|
|
74
103
|
type: 'integer',
|
|
75
|
-
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.',
|
|
76
105
|
},
|
|
106
|
+
offset: { type: 'integer', description: 'Skip this many lines from the newest end (paging into older lines; default 0).' },
|
|
77
107
|
},
|
|
78
108
|
required: [],
|
|
79
109
|
},
|
|
@@ -127,8 +157,10 @@ const TOOL_DEFINITIONS = [
|
|
|
127
157
|
properties: {
|
|
128
158
|
filter: {
|
|
129
159
|
type: 'string',
|
|
130
|
-
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.',
|
|
131
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.' },
|
|
132
164
|
},
|
|
133
165
|
required: [],
|
|
134
166
|
},
|
|
@@ -146,6 +178,10 @@ const TOOL_DEFINITIONS = [
|
|
|
146
178
|
type: 'string',
|
|
147
179
|
description: 'The exact document ID to retrieve.',
|
|
148
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
|
+
},
|
|
149
185
|
},
|
|
150
186
|
required: ['id'],
|
|
151
187
|
},
|
|
@@ -156,16 +192,221 @@ const TOOL_DEFINITIONS = [
|
|
|
156
192
|
function: {
|
|
157
193
|
name: 'list_matter_devices',
|
|
158
194
|
description:
|
|
159
|
-
'List
|
|
160
|
-
'Use
|
|
161
|
-
|
|
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.',
|
|
162
303
|
parameters: {
|
|
163
304
|
type: 'object',
|
|
164
|
-
properties: {
|
|
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".',
|
|
319
|
+
parameters: {
|
|
320
|
+
type: 'object',
|
|
321
|
+
properties: { filter: { type: 'string', description: 'Substring of the instance name, adapter or host. Empty: all.' } },
|
|
322
|
+
required: [],
|
|
323
|
+
},
|
|
324
|
+
},
|
|
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.' } },
|
|
165
344
|
required: [],
|
|
166
345
|
},
|
|
167
346
|
},
|
|
168
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
|
+
},
|
|
169
410
|
];
|
|
170
411
|
|
|
171
412
|
/** Same definitions in Anthropic tool format. */
|
|
@@ -187,6 +428,16 @@ const TOOL_DEFINITIONS_ANTHROPIC = TOOL_DEFINITIONS.map((t) => ({
|
|
|
187
428
|
* @returns {string}
|
|
188
429
|
*/
|
|
189
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) {
|
|
190
441
|
try {
|
|
191
442
|
switch (name) {
|
|
192
443
|
case 'search_mqtt_topics':
|
|
@@ -198,13 +449,39 @@ async function executeTool(name, args, ctx) {
|
|
|
198
449
|
case 'get_script_logs':
|
|
199
450
|
return toolGetScriptLogs(args);
|
|
200
451
|
case 'she_fetch':
|
|
201
|
-
return await toolSheFetch(args);
|
|
452
|
+
return await toolSheFetch(args, ctx);
|
|
202
453
|
case 'list_shedb_docs':
|
|
203
454
|
return toolListShedbDocs(args);
|
|
204
455
|
case 'get_shedb_doc':
|
|
205
456
|
return toolGetShedbDoc(args);
|
|
206
457
|
case 'list_matter_devices':
|
|
207
|
-
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);
|
|
208
485
|
default:
|
|
209
486
|
return `Unknown tool: ${name}`;
|
|
210
487
|
}
|
|
@@ -213,27 +490,110 @@ async function executeTool(name, args, ctx) {
|
|
|
213
490
|
}
|
|
214
491
|
}
|
|
215
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
|
+
|
|
216
567
|
// ---------------------------------------------------------------------------
|
|
217
568
|
// Individual tools
|
|
218
569
|
// ---------------------------------------------------------------------------
|
|
219
570
|
|
|
220
|
-
function toolSearchMqttTopics({ query = '' }, store) {
|
|
571
|
+
function toolSearchMqttTopics({ query = '', value, changed_within, offset = 0, limit = 50 }, store) {
|
|
221
572
|
if (!store) return 'MQTT state store not available.';
|
|
222
|
-
const
|
|
223
|
-
const
|
|
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 = [];
|
|
224
581
|
for (const [topic, obj] of store.mqttEntries()) {
|
|
225
|
-
if (!q
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
}
|
|
231
|
-
}
|
|
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)` : ''}`);
|
|
232
587
|
}
|
|
233
|
-
if (
|
|
234
|
-
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.';
|
|
235
592
|
}
|
|
236
|
-
|
|
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');
|
|
237
597
|
}
|
|
238
598
|
|
|
239
599
|
function toolReadScript({ path: relPath }, scriptDir) {
|
|
@@ -249,33 +609,471 @@ function toolReadScript({ path: relPath }, scriptDir) {
|
|
|
249
609
|
return `## ${relPath}\n\`\`\`javascript\n${content}\n\`\`\``;
|
|
250
610
|
}
|
|
251
611
|
|
|
252
|
-
|
|
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 } = {}) {
|
|
253
627
|
const cap = Math.min(Math.max(1, Number(limit) || 50), 200);
|
|
254
|
-
const
|
|
628
|
+
const skip = Math.max(0, Number(offset) || 0);
|
|
255
629
|
const needle = String(script_name).toLowerCase();
|
|
256
|
-
const
|
|
257
|
-
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);
|
|
258
665
|
if (recent.length === 0) {
|
|
259
|
-
|
|
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.';
|
|
260
708
|
}
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
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.`);
|
|
836
|
+
}
|
|
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}`;
|
|
267
1011
|
}
|
|
268
1012
|
|
|
269
1013
|
const MAX_FETCH_CHARS = 8000;
|
|
270
1014
|
|
|
271
|
-
|
|
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 = {}) {
|
|
272
1053
|
if (!url || typeof url !== 'string') return 'url argument is required.';
|
|
273
1054
|
if (!/^https?:\/\//i.test(url)) return 'Only http and https URLs are supported.';
|
|
274
|
-
const
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
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}`;
|
|
279
1077
|
const ct = res.headers.get('content-type') || '';
|
|
280
1078
|
const text = await res.text();
|
|
281
1079
|
// Strip HTML tags for cleaner text
|
|
@@ -299,7 +1097,7 @@ function toolGetMqttTopic({ topic }, store) {
|
|
|
299
1097
|
return `${topic}: ${JSON.stringify(obj.val)}\n last updated: ${ts}\n last changed: ${lc}`;
|
|
300
1098
|
}
|
|
301
1099
|
|
|
302
|
-
function toolListShedbDocs({ filter = '' }) {
|
|
1100
|
+
function toolListShedbDocs({ filter = '', offset = 0, limit = 200 }) {
|
|
303
1101
|
try {
|
|
304
1102
|
const core = require('./shedb').getCore();
|
|
305
1103
|
if (!core) return 'sheDB not initialised.';
|
|
@@ -307,51 +1105,128 @@ function toolListShedbDocs({ filter = '' }) {
|
|
|
307
1105
|
const q = String(filter).toLowerCase();
|
|
308
1106
|
const filtered = q ? ids.filter((id) => id.toLowerCase().includes(q)) : ids;
|
|
309
1107
|
if (filtered.length === 0) return q ? `No documents found matching "${filter}".` : 'No documents in sheDB.';
|
|
310
|
-
const
|
|
311
|
-
|
|
312
|
-
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')}`;
|
|
313
1110
|
} catch (e) {
|
|
314
1111
|
return `sheDB not available: ${e.message}`;
|
|
315
1112
|
}
|
|
316
1113
|
}
|
|
317
1114
|
|
|
318
|
-
function toolGetShedbDoc({ id }) {
|
|
1115
|
+
function toolGetShedbDoc({ id, path: dotted = '' }) {
|
|
319
1116
|
if (!id || typeof id !== 'string') return 'id argument is required.';
|
|
320
1117
|
try {
|
|
321
1118
|
const core = require('./shedb').getCore();
|
|
322
1119
|
if (!core) return 'sheDB not initialised.';
|
|
323
|
-
|
|
1120
|
+
let doc = core.docs[id];
|
|
324
1121
|
if (doc === undefined) return `Document "${id}" not found. Use list_shedb_docs to see available IDs.`;
|
|
325
|
-
|
|
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)}`;
|
|
326
1129
|
} catch (e) {
|
|
327
1130
|
return `sheDB not available: ${e.message}`;
|
|
328
1131
|
}
|
|
329
1132
|
}
|
|
330
1133
|
|
|
331
|
-
|
|
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() {
|
|
332
1160
|
try {
|
|
333
1161
|
const controller = require('../matter/controller');
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
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
|
+
}
|
|
346
1201
|
}
|
|
347
|
-
|
|
348
|
-
|
|
1202
|
+
const got = (await Promise.all(reads)).filter(Boolean);
|
|
1203
|
+
if (got.length) state = ` — ${got.join(', ')}`;
|
|
349
1204
|
}
|
|
1205
|
+
lines.push(`- endpoint "${name}" (id: ${ep.endpointId}): ${(ep.clusters || []).join(', ')}${state}`);
|
|
350
1206
|
}
|
|
351
|
-
|
|
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)}`;
|
|
352
1222
|
} catch (e) {
|
|
353
|
-
return `
|
|
1223
|
+
return `Could not read ${cluster}.${attribute} of ${node}/${endpoint}: ${e.message}`;
|
|
354
1224
|
}
|
|
355
1225
|
}
|
|
356
1226
|
|
|
357
|
-
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
|
+
};
|