smart-home-engine 1.52.1 → 1.53.1
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/dist/web/assets/{index-ClJLf8-l.js → index-CBrAPut6.js} +130 -129
- package/dist/web/assets/index-x3Sn9IKo.css +1 -0
- package/dist/web/assets/{tsMode-B7KUL0-b.js → tsMode-CzP5eDeL.js} +1 -1
- package/dist/web/index.html +2 -2
- package/package.json +1 -1
- package/src/index.js +15 -0
- package/src/web/ai-api.js +28 -15
- package/src/web/ai-context.js +3 -2
- package/src/web/ai-sandbox-worker.js +84 -0
- package/src/web/ai-sandbox.js +63 -0
- package/src/web/ai-tools.js +608 -58
- package/src/web/prompts/compact.md +3 -3
- package/src/web/prompts/formats-notools.md +14 -0
- package/src/web/prompts/formats.md +2 -13
- package/src/web/prompts/tools.md +11 -4
- package/dist/web/assets/index-C5e-98I_.css +0 -1
package/src/web/ai-tools.js
CHANGED
|
@@ -38,6 +38,7 @@ const TOOL_DEFINITIONS = [
|
|
|
38
38
|
'Search for MQTT topics currently tracked by the she daemon. ' +
|
|
39
39
|
'Returns matching topic names and their current values. ' +
|
|
40
40
|
'Use this to discover real topic names before writing scripts. ' +
|
|
41
|
+
'For everything about a room (its devices, variables and scripts) call describe_room once instead of several searches. ' +
|
|
41
42
|
'Homematic related topics (under the topic tree hm/) end with STATE for switching actuators and with LEVEL for dimmers.',
|
|
42
43
|
parameters: {
|
|
43
44
|
type: 'object',
|
|
@@ -47,6 +48,11 @@ const TOOL_DEFINITIONS = [
|
|
|
47
48
|
description:
|
|
48
49
|
'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
|
},
|
|
51
|
+
queries: {
|
|
52
|
+
type: 'array',
|
|
53
|
+
items: { type: 'string' },
|
|
54
|
+
description: 'Several queries in one call (filters or substrings), each answered as with "query"; use this instead of one call per query.',
|
|
55
|
+
},
|
|
50
56
|
value: {
|
|
51
57
|
type: 'string',
|
|
52
58
|
description: 'Optional filter on the current value: a plain value for equality ("true", "on", "0"), or a comparison ("> 0", ">= 20", "!= off").',
|
|
@@ -61,7 +67,7 @@ const TOOL_DEFINITIONS = [
|
|
|
61
67
|
description: 'Maximum number of topics to return (1-500, default 50). The result says how many matched in total.',
|
|
62
68
|
},
|
|
63
69
|
},
|
|
64
|
-
required: [
|
|
70
|
+
required: [],
|
|
65
71
|
},
|
|
66
72
|
},
|
|
67
73
|
},
|
|
@@ -69,7 +75,8 @@ const TOOL_DEFINITIONS = [
|
|
|
69
75
|
type: 'function',
|
|
70
76
|
function: {
|
|
71
77
|
name: 'read_script',
|
|
72
|
-
description:
|
|
78
|
+
description:
|
|
79
|
+
'Read a script file from the she scripts directory, with a header of what the daemon knows about it: its subscriptions, what it publishes, its schedules and the scripts wired to it. Use this to review a script before suggesting changes.',
|
|
73
80
|
parameters: {
|
|
74
81
|
type: 'object',
|
|
75
82
|
properties: {
|
|
@@ -77,8 +84,13 @@ const TOOL_DEFINITIONS = [
|
|
|
77
84
|
type: 'string',
|
|
78
85
|
description: 'Script file path relative to the scripts directory, e.g. "lights.js" or "lib/utils.js".',
|
|
79
86
|
},
|
|
87
|
+
paths: {
|
|
88
|
+
type: 'array',
|
|
89
|
+
items: { type: 'string' },
|
|
90
|
+
description: 'Several scripts in one call (up to 10), each with its header; use this instead of one call per file.',
|
|
91
|
+
},
|
|
80
92
|
},
|
|
81
|
-
required: [
|
|
93
|
+
required: [],
|
|
82
94
|
},
|
|
83
95
|
},
|
|
84
96
|
},
|
|
@@ -142,8 +154,13 @@ const TOOL_DEFINITIONS = [
|
|
|
142
154
|
type: 'string',
|
|
143
155
|
description: 'The exact MQTT topic path, e.g. "home/livingroom/light/state".',
|
|
144
156
|
},
|
|
157
|
+
topics: {
|
|
158
|
+
type: 'array',
|
|
159
|
+
items: { type: 'string' },
|
|
160
|
+
description: 'Several topics in one call, or one MQTT filter ("var/status/presence/#", up to 20 resolved); use this instead of one call per topic.',
|
|
161
|
+
},
|
|
145
162
|
},
|
|
146
|
-
required: [
|
|
163
|
+
required: [],
|
|
147
164
|
},
|
|
148
165
|
},
|
|
149
166
|
},
|
|
@@ -227,7 +244,7 @@ const TOOL_DEFINITIONS = [
|
|
|
227
244
|
function: {
|
|
228
245
|
name: 'get_topic_history',
|
|
229
246
|
description:
|
|
230
|
-
'The values of
|
|
247
|
+
'The values of one or several MQTT topics over time from InfluxDB (when she has an Influx integration configured), as compact time series: change points, downsampled when long. Several topics or an MQTT filter ("radar-x/status/#") in one call share the window and the point cap. ' +
|
|
231
248
|
'Use it for every "why", "when", "how often", "since when" question about a topic; search_mqtt_topics only knows the current value.',
|
|
232
249
|
parameters: {
|
|
233
250
|
type: 'object',
|
|
@@ -236,11 +253,42 @@ const TOOL_DEFINITIONS = [
|
|
|
236
253
|
type: 'string',
|
|
237
254
|
description: 'The MQTT topic, e.g. "hm/status/Licht Bad/LEVEL" (the measurement is derived from it; a measurement name is accepted too).',
|
|
238
255
|
},
|
|
256
|
+
topics: {
|
|
257
|
+
type: 'array',
|
|
258
|
+
items: { type: 'string' },
|
|
259
|
+
description:
|
|
260
|
+
'Several topics in one call, or one MQTT filter with + or # ("radar-x/status/#", "zigbee2mqtt/tfk_x/+"); up to 20 topics are resolved from the known topics. Use instead of "topic" for a device or a room.',
|
|
261
|
+
},
|
|
239
262
|
from: { type: 'string', description: 'Start: ISO 8601 or relative ("-2h", "-1d", "-7d"). Default "-24h".' },
|
|
240
263
|
to: { type: 'string', description: 'End: ISO 8601 or relative. Default now.' },
|
|
241
264
|
limit: { type: 'integer', description: 'Maximum points to return (1-500, default 200); longer series are reduced to change points, then thinned.' },
|
|
242
265
|
},
|
|
243
|
-
|
|
266
|
+
},
|
|
267
|
+
},
|
|
268
|
+
},
|
|
269
|
+
{
|
|
270
|
+
type: 'function',
|
|
271
|
+
function: {
|
|
272
|
+
name: 'get_timeline',
|
|
273
|
+
description:
|
|
274
|
+
'The changes of several MQTT topics merged into one chronological list from InfluxDB (when configured): one line per change with the time, the gap to the previous line, the topic and old → new. ' +
|
|
275
|
+
'Use it to reconstruct what happened in a room or around an event (a presence script, a heating cycle) instead of fetching the topics one by one and merging in your head.',
|
|
276
|
+
parameters: {
|
|
277
|
+
type: 'object',
|
|
278
|
+
properties: {
|
|
279
|
+
topics: {
|
|
280
|
+
type: 'array',
|
|
281
|
+
items: { type: 'string' },
|
|
282
|
+
description: 'The topics: a list, or one MQTT filter with + or # ("radar-x/status/#"); up to 20 topics are resolved from the known topics.',
|
|
283
|
+
},
|
|
284
|
+
from: { type: 'string', description: 'Start: ISO 8601 or relative ("-2h", "-1d"). Default "-24h".' },
|
|
285
|
+
to: { type: 'string', description: 'End: ISO 8601 or relative. Default now.' },
|
|
286
|
+
limit: {
|
|
287
|
+
type: 'integer',
|
|
288
|
+
description: 'Maximum lines (1-500, default 200); when there are more changes, the ones closest to a previous change of the same topic are dropped first.',
|
|
289
|
+
},
|
|
290
|
+
},
|
|
291
|
+
required: ['topics'],
|
|
244
292
|
},
|
|
245
293
|
},
|
|
246
294
|
},
|
|
@@ -255,11 +303,15 @@ const TOOL_DEFINITIONS = [
|
|
|
255
303
|
type: 'object',
|
|
256
304
|
properties: {
|
|
257
305
|
topic: { type: 'string', description: 'The exact MQTT topic.' },
|
|
306
|
+
topics: {
|
|
307
|
+
type: 'array',
|
|
308
|
+
items: { type: 'string' },
|
|
309
|
+
description: 'Several topics in one call, or one MQTT filter (up to 10 topics resolved); the messages of all of them in one list, newest first.',
|
|
310
|
+
},
|
|
258
311
|
from: { type: 'string', description: 'Start: ISO 8601 or relative ("-2h"). Default "-24h".' },
|
|
259
312
|
to: { type: 'string', description: 'End: ISO 8601 or relative. Default now.' },
|
|
260
313
|
limit: { type: 'integer', description: 'Maximum messages (1-500, default 100), the newest within the window.' },
|
|
261
314
|
},
|
|
262
|
-
required: ['topic'],
|
|
263
315
|
},
|
|
264
316
|
},
|
|
265
317
|
},
|
|
@@ -303,9 +355,34 @@ const TOOL_DEFINITIONS = [
|
|
|
303
355
|
parameters: {
|
|
304
356
|
type: 'object',
|
|
305
357
|
properties: {
|
|
358
|
+
names: {
|
|
359
|
+
type: 'array',
|
|
360
|
+
items: { type: 'string' },
|
|
361
|
+
description: 'Several device names in one call (each answered as with "name").',
|
|
362
|
+
},
|
|
306
363
|
name: { type: 'string', description: 'Device name as it appears in topics or in the discovery.' },
|
|
307
364
|
limit: { type: 'integer', description: 'Maximum topics per group (default 40).' },
|
|
308
365
|
},
|
|
366
|
+
},
|
|
367
|
+
},
|
|
368
|
+
},
|
|
369
|
+
{
|
|
370
|
+
type: 'function',
|
|
371
|
+
function: {
|
|
372
|
+
name: 'describe_room',
|
|
373
|
+
description:
|
|
374
|
+
'Everything about a room in one call: every device whose topics carry the room word (grouped per device with values and change ages), the variables with the word, the scripts that subscribe to or publish any of it, and the discovery devices. ' +
|
|
375
|
+
'Start here for a question about a room ("why does the light in the workshop …"); then narrow with describe_device, get_timeline or get_topic_history.',
|
|
376
|
+
parameters: {
|
|
377
|
+
type: 'object',
|
|
378
|
+
properties: {
|
|
379
|
+
name: {
|
|
380
|
+
type: 'string',
|
|
381
|
+
description:
|
|
382
|
+
'The room word as it appears in topic segments: "hobbyraum", "bad", "kitchen". Matched case-insensitively inside a segment ("radar_hobbyraum", "Licht Hobbyraum").',
|
|
383
|
+
},
|
|
384
|
+
limit: { type: 'integer', description: 'Maximum topics per device (default 15; the note says how many more there are).' },
|
|
385
|
+
},
|
|
309
386
|
required: ['name'],
|
|
310
387
|
},
|
|
311
388
|
},
|
|
@@ -377,7 +454,7 @@ const TOOL_DEFINITIONS = [
|
|
|
377
454
|
name: 'propose_script',
|
|
378
455
|
description:
|
|
379
456
|
'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.
|
|
457
|
+
'Read the current file first (read_script) and pass the complete new content. This is how code is handed over: never paste the file into the answer as well; describe the change in a few lines instead.',
|
|
381
458
|
parameters: {
|
|
382
459
|
type: 'object',
|
|
383
460
|
properties: {
|
|
@@ -389,6 +466,28 @@ const TOOL_DEFINITIONS = [
|
|
|
389
466
|
},
|
|
390
467
|
},
|
|
391
468
|
},
|
|
469
|
+
{
|
|
470
|
+
type: 'function',
|
|
471
|
+
function: {
|
|
472
|
+
name: 'run_analysis',
|
|
473
|
+
description:
|
|
474
|
+
'Run a short piece of JavaScript you write, in a sandbox with read-only access to the data, and get its return value back as JSON. ' +
|
|
475
|
+
'Use it for research that would take many tool calls or arithmetic over long lists: counts per hour, correlations between topics, gaps, durations. ' +
|
|
476
|
+
'The code is the body of an async function; `return` the result (an object or array, kept small). Every data call must be awaited. Available: `await data.topics(filter)` → [{topic, value, ts, lc}] (filter: an MQTT filter or a substring; ts/lc epoch ms); ' +
|
|
477
|
+
'`await data.history(topics, from, to)` → {topic: [{time, value}]} from InfluxDB (topics: a list or an MQTT filter); `await data.messages(topics, from, to, limit)` → [{time, topic, payload}] from Elasticsearch, newest first (limit up to 5000); ' +
|
|
478
|
+
'`await data.log(filter, from, to, level, limit)` → [{ts, level, msg}] from the log files; `await data.script(path)` → the source; `data.from`/`data.to` → the window you passed; `await data.time("-2h")` → epoch ms. ' +
|
|
479
|
+
'from/to are ISO 8601 or relative ("-6h", "-7d"). No require, no network, no publishing, no she object; console.log lines come back with the result; 10 seconds at most.',
|
|
480
|
+
parameters: {
|
|
481
|
+
type: 'object',
|
|
482
|
+
properties: {
|
|
483
|
+
code: { type: 'string', description: 'The JavaScript to run, as the body of an async function; must `return` the result.' },
|
|
484
|
+
from: { type: 'string', description: 'Optional window start handed to the code as data.from ("-24h").' },
|
|
485
|
+
to: { type: 'string', description: 'Optional window end handed to the code as data.to.' },
|
|
486
|
+
},
|
|
487
|
+
required: ['code'],
|
|
488
|
+
},
|
|
489
|
+
},
|
|
490
|
+
},
|
|
392
491
|
{
|
|
393
492
|
type: 'function',
|
|
394
493
|
function: {
|
|
@@ -441,11 +540,11 @@ async function runTool(name, args, ctx) {
|
|
|
441
540
|
try {
|
|
442
541
|
switch (name) {
|
|
443
542
|
case 'search_mqtt_topics':
|
|
444
|
-
return
|
|
543
|
+
return toolSearchMqttTopicsMulti(args, ctx.store);
|
|
445
544
|
case 'get_mqtt_topic':
|
|
446
|
-
return
|
|
545
|
+
return toolGetMqttTopics(args, ctx.store);
|
|
447
546
|
case 'read_script':
|
|
448
|
-
return
|
|
547
|
+
return toolReadScripts(args, ctx.scriptDir, ctx.introspect);
|
|
449
548
|
case 'get_script_logs':
|
|
450
549
|
return toolGetScriptLogs(args);
|
|
451
550
|
case 'she_fetch':
|
|
@@ -460,6 +559,8 @@ async function runTool(name, args, ctx) {
|
|
|
460
559
|
return await toolGetMatterAttribute(args);
|
|
461
560
|
case 'get_topic_history':
|
|
462
561
|
return await toolGetTopicHistory(args, ctx);
|
|
562
|
+
case 'get_timeline':
|
|
563
|
+
return await toolGetTimeline(args, ctx);
|
|
463
564
|
case 'get_topic_messages':
|
|
464
565
|
return await toolGetTopicMessages(args, ctx);
|
|
465
566
|
case 'list_scripts':
|
|
@@ -467,7 +568,9 @@ async function runTool(name, args, ctx) {
|
|
|
467
568
|
case 'who_publishes':
|
|
468
569
|
return toolWhoPublishes(args, ctx);
|
|
469
570
|
case 'describe_device':
|
|
470
|
-
return
|
|
571
|
+
return toolDescribeDevices(args, ctx);
|
|
572
|
+
case 'describe_room':
|
|
573
|
+
return toolDescribeRoom(args, ctx);
|
|
471
574
|
case 'list_services':
|
|
472
575
|
return toolListServices(args, ctx);
|
|
473
576
|
case 'get_health':
|
|
@@ -476,6 +579,8 @@ async function runTool(name, args, ctx) {
|
|
|
476
579
|
return toolListTimers(args, ctx);
|
|
477
580
|
case 'propose_script':
|
|
478
581
|
return toolProposeScript(args, ctx);
|
|
582
|
+
case 'run_analysis':
|
|
583
|
+
return await toolRunAnalysis(args, ctx);
|
|
479
584
|
case 'publish_mqtt':
|
|
480
585
|
return await toolPublishMqtt(args, ctx);
|
|
481
586
|
case 'remember':
|
|
@@ -568,6 +673,17 @@ function ago(ms) {
|
|
|
568
673
|
// Individual tools
|
|
569
674
|
// ---------------------------------------------------------------------------
|
|
570
675
|
|
|
676
|
+
/** search_mqtt_topics with "queries" (roadmap I39): one search per query, the results one after another */
|
|
677
|
+
function toolSearchMqttTopicsMulti(args, store) {
|
|
678
|
+
const { queries, ...rest } = args || {};
|
|
679
|
+
const list = Array.isArray(queries) ? queries.map((q) => String(q)) : typeof queries === 'string' ? [queries] : null;
|
|
680
|
+
if (!list || list.length === 0) return toolSearchMqttTopics(rest, store);
|
|
681
|
+
const cap = 10;
|
|
682
|
+
const out = list.slice(0, cap).map((q) => `# "${q}"\n${toolSearchMqttTopics({ ...rest, query: q }, store)}`);
|
|
683
|
+
if (list.length > cap) out.push(`… ${list.length - cap} more queries not run; call again with them.`);
|
|
684
|
+
return out.join('\n\n');
|
|
685
|
+
}
|
|
686
|
+
|
|
571
687
|
function toolSearchMqttTopics({ query = '', value, changed_within, offset = 0, limit = 50 }, store) {
|
|
572
688
|
if (!store) return 'MQTT state store not available.';
|
|
573
689
|
const pattern = String(query).trim();
|
|
@@ -596,7 +712,18 @@ function toolSearchMqttTopics({ query = '', value, changed_within, offset = 0, l
|
|
|
596
712
|
return lines.join('\n');
|
|
597
713
|
}
|
|
598
714
|
|
|
599
|
-
|
|
715
|
+
/** read_script with "paths" (roadmap I39): every file with its header, in one result */
|
|
716
|
+
function toolReadScripts(args, scriptDir, introspect) {
|
|
717
|
+
const { paths, ...rest } = args || {};
|
|
718
|
+
const list = Array.isArray(paths) ? paths.map((x) => String(x)) : typeof paths === 'string' ? [paths] : null;
|
|
719
|
+
if (!list || list.length === 0) return toolReadScript(rest, scriptDir, introspect);
|
|
720
|
+
const cap = 10;
|
|
721
|
+
const out = [...new Set(list)].slice(0, cap).map((p) => toolReadScript({ path: p }, scriptDir, introspect));
|
|
722
|
+
if (list.length > cap) out.push(`… ${list.length - cap} more paths not read; call again with them.`);
|
|
723
|
+
return out.join('\n\n');
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
function toolReadScript({ path: relPath }, scriptDir, introspect) {
|
|
600
727
|
if (!scriptDir) return 'Scripts directory not configured.';
|
|
601
728
|
if (!relPath || typeof relPath !== 'string') return 'path argument is required.';
|
|
602
729
|
const abs = path.resolve(scriptDir, relPath.replace(/^\/+/, ''));
|
|
@@ -606,7 +733,46 @@ function toolReadScript({ path: relPath }, scriptDir) {
|
|
|
606
733
|
}
|
|
607
734
|
if (!fs.existsSync(abs)) return `File not found: ${relPath}`;
|
|
608
735
|
const content = fs.readFileSync(abs, 'utf8');
|
|
609
|
-
return `## ${relPath}\n\`\`\`javascript\n${content}\n\`\`\``;
|
|
736
|
+
return `## ${relPath}\n${scriptSurroundings(relPath, introspect)}\`\`\`javascript\n${content}\n\`\`\``;
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
/**
|
|
740
|
+
* What the daemon knows about a loaded script (roadmap I35), as a short header above its source: its
|
|
741
|
+
* subscriptions and seen publishes, its schedules and pending timers, and the scripts wired to it — those that
|
|
742
|
+
* subscribe to what it publishes or publish what it subscribes to. Empty when the daemon's state is not
|
|
743
|
+
* available or the file is not loaded.
|
|
744
|
+
*/
|
|
745
|
+
function scriptSurroundings(relPath, introspect) {
|
|
746
|
+
if (!introspect?.scripts) return '';
|
|
747
|
+
const all = introspect.scripts();
|
|
748
|
+
const norm = (x) => String(x).replace(/^\/+/, '');
|
|
749
|
+
const sc = all.find((x) => norm(x.label) === norm(relPath) || (x.file && norm(x.file).endsWith('/' + norm(relPath))));
|
|
750
|
+
if (!sc) return '';
|
|
751
|
+
const varPrefix = introspect.config ? introspect.config().variablePrefix || 'var' : 'var';
|
|
752
|
+
const some = (arr, n = 10) => (arr.length <= n ? arr.join(', ') : arr.slice(0, n).join(', ') + ` … (${arr.length} total)`);
|
|
753
|
+
const lines = [];
|
|
754
|
+
const subs = [...sc.subscriptions, ...sc.varSubscriptions.map((k) => `${varPrefix}/status/${k}`)];
|
|
755
|
+
if (subs.length) lines.push(`subscribes: ${some(subs)}`);
|
|
756
|
+
if (sc.publishes.length) lines.push(`publishes (seen since start): ${some(sc.publishes)}`);
|
|
757
|
+
const sched = [...sc.jobs.map((j) => `job ${inTime(j.next)}`), ...sc.sunEvents.map((e) => `${e.pattern} ${inTime(e.next)}`)];
|
|
758
|
+
if (sched.length) lines.push(`schedules: ${some(sched, 6)}`);
|
|
759
|
+
if (sc.timers.length) lines.push(`pending timers: ${sc.timers.length}`);
|
|
760
|
+
// a variable published as <prefix>/set/x is read by others as <prefix>/status/x
|
|
761
|
+
const asStatus = (t) => String(t).replace(new RegExp(`^${varPrefix}/set/`), `${varPrefix}/status/`);
|
|
762
|
+
const pubs = new Set(sc.publishes.map(asStatus));
|
|
763
|
+
const subSet = new Set(subs);
|
|
764
|
+
const readers = [];
|
|
765
|
+
const writers = [];
|
|
766
|
+
for (const o of all) {
|
|
767
|
+
if (o === sc) continue;
|
|
768
|
+
const oSubs = [...o.subscriptions, ...o.varSubscriptions.map((k) => `${varPrefix}/status/${k}`)];
|
|
769
|
+
if (oSubs.some((t) => pubs.has(t))) readers.push(o.label);
|
|
770
|
+
if (o.publishes.some((t) => subSet.has(asStatus(t)))) writers.push(o.label);
|
|
771
|
+
}
|
|
772
|
+
if (readers.length) lines.push(`read by: ${some(readers)} (they subscribe to what this script publishes)`);
|
|
773
|
+
if (writers.length) lines.push(`fed by: ${some(writers)} (they publish what this script subscribes to)`);
|
|
774
|
+
if (!lines.length) return '';
|
|
775
|
+
return lines.map((l) => `- ${l}`).join('\n') + '\n';
|
|
610
776
|
}
|
|
611
777
|
|
|
612
778
|
const LEVEL_RANK = { debug: 0, info: 1, warn: 2, error: 3 };
|
|
@@ -623,15 +789,8 @@ function logFilesOldestFirst() {
|
|
|
623
789
|
return names.sort((a, b) => rank(b) - rank(a)).map((n) => path.join(LOGS_DIR, n));
|
|
624
790
|
}
|
|
625
791
|
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
const skip = Math.max(0, Number(offset) || 0);
|
|
629
|
-
const needle = String(script_name).toLowerCase();
|
|
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}.`;
|
|
792
|
+
/** every log entry matching the filter, oldest first: the files on disk, the in-memory ring only without them */
|
|
793
|
+
async function collectLogEntries({ needle = '', fromMs = null, toMs = Date.now(), minRank = 0 } = {}) {
|
|
635
794
|
const keep = (e) => (fromMs === null || e.ts >= fromMs) && e.ts <= toMs && (LEVEL_RANK[e.level] ?? 0) >= minRank && (!needle || e.msg.toLowerCase().includes(needle));
|
|
636
795
|
|
|
637
796
|
// the files on disk; the in-memory ring only when there are none (tests, a read-only data directory)
|
|
@@ -660,6 +819,19 @@ async function toolGetScriptLogs({ script_name = '', from, to, level = 'debug',
|
|
|
660
819
|
if (files.length === 0) {
|
|
661
820
|
for (const e of getLogBuffer()) if (keep(e)) matches.push(e);
|
|
662
821
|
}
|
|
822
|
+
return matches;
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
async function toolGetScriptLogs({ script_name = '', from, to, level = 'debug', limit = 50, offset = 0 } = {}) {
|
|
826
|
+
const cap = Math.min(Math.max(1, Number(limit) || 50), 200);
|
|
827
|
+
const skip = Math.max(0, Number(offset) || 0);
|
|
828
|
+
const needle = String(script_name).toLowerCase();
|
|
829
|
+
const minRank = LEVEL_RANK[String(level).toLowerCase()] ?? 0;
|
|
830
|
+
const now = Date.now();
|
|
831
|
+
const fromMs = from ? parseTime(from, now) : null;
|
|
832
|
+
const toMs = parseTime(to, now);
|
|
833
|
+
if (Number.isNaN(fromMs) || Number.isNaN(toMs)) return `from/to must be ISO 8601 or relative ("-2h"); got from=${from}, to=${to}.`;
|
|
834
|
+
const matches = await collectLogEntries({ needle, fromMs, toMs, minRank });
|
|
663
835
|
const end = Math.max(0, matches.length - skip);
|
|
664
836
|
const recent = matches.slice(Math.max(0, end - cap), end);
|
|
665
837
|
if (recent.length === 0) {
|
|
@@ -698,24 +870,36 @@ function thinSeries(rows, limit) {
|
|
|
698
870
|
return { points: thinned, note: `${rows.length} points, ${changes.length} change points, every ${k}th shown` };
|
|
699
871
|
}
|
|
700
872
|
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
873
|
+
/**
|
|
874
|
+
* The topics a tool call names (roadmap I32): `topics` (a list, or one MQTT filter resolved against the known
|
|
875
|
+
* topics), else `topic`; a filter needs the store. Capped; the note says when the cap cut the list.
|
|
876
|
+
*/
|
|
877
|
+
function resolveTopics({ topic, topics }, store, cap = 20) {
|
|
878
|
+
const raw = [];
|
|
879
|
+
if (Array.isArray(topics)) raw.push(...topics);
|
|
880
|
+
else if (typeof topics === 'string' && topics.trim()) raw.push(topics);
|
|
881
|
+
if (typeof topic === 'string' && topic.trim()) raw.push(topic);
|
|
882
|
+
const out = [];
|
|
883
|
+
let note = '';
|
|
884
|
+
for (const t of raw.map((x) => String(x).trim()).filter(Boolean)) {
|
|
885
|
+
if (/[+#]/.test(t)) {
|
|
886
|
+
if (!store) return { topics: [], error: `"${t}" is a filter, but the MQTT state store is not available to resolve it; name the topics.` };
|
|
887
|
+
const matches = [];
|
|
888
|
+
for (const [name] of store.mqttEntries()) if (mqttWildcard(name, t)) matches.push(name);
|
|
889
|
+
if (matches.length === 0) return { topics: [], error: `No known topic matches "${t}".` };
|
|
890
|
+
matches.sort();
|
|
891
|
+
if (matches.length > cap) note = `${matches.length} topics match "${t}", the first ${cap} are shown; narrow the filter for the rest.`;
|
|
892
|
+
out.push(...matches.slice(0, cap));
|
|
893
|
+
} else out.push(t);
|
|
708
894
|
}
|
|
709
|
-
const
|
|
710
|
-
if (
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
const cap = Math.min(Math.max(1, Number(limit) || 200), 500);
|
|
895
|
+
const unique = [...new Set(out)];
|
|
896
|
+
if (unique.length > cap) note = note || `${unique.length} topics named, the first ${cap} are shown.`;
|
|
897
|
+
return { topics: unique.slice(0, cap), note };
|
|
898
|
+
}
|
|
899
|
+
|
|
900
|
+
/** one topic's rows from Influx 1.x: influx4mqtt's measurements first, then she's own schema */
|
|
901
|
+
async function fetchTopicSeries(influx, topic, fromMs, toMs) {
|
|
717
902
|
const tried = [];
|
|
718
|
-
// influx4mqtt's measurements first, then she's own schema (measurement "mqtt", tag "topic", field "value")
|
|
719
903
|
const queries = measurementCandidates(topic).map((m) => [
|
|
720
904
|
m,
|
|
721
905
|
`SELECT "value" FROM "${m.replace(/"/g, '\\"')}" WHERE time >= ${fromMs}ms AND time <= ${toMs}ms ORDER BY time ASC LIMIT 5000`,
|
|
@@ -726,26 +910,156 @@ async function toolGetTopicHistory({ topic, from = '-24h', to, limit = 200 } = {
|
|
|
726
910
|
]);
|
|
727
911
|
for (const [m, q] of queries) {
|
|
728
912
|
tried.push(m);
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
913
|
+
const rows = await influx.v1Query(q);
|
|
914
|
+
if (rows && rows.length > 0) return { measurement: m, rows: rows.map((r) => ({ time: r.time, value: r.value })), tried };
|
|
915
|
+
}
|
|
916
|
+
return { measurement: null, rows: [], tried };
|
|
917
|
+
}
|
|
918
|
+
|
|
919
|
+
function influxForHistory() {
|
|
920
|
+
let influx;
|
|
921
|
+
try {
|
|
922
|
+
influx = require('../influx');
|
|
923
|
+
} catch {
|
|
924
|
+
return { error: 'InfluxDB is not available.' };
|
|
925
|
+
}
|
|
926
|
+
const mode = influx.getMode?.();
|
|
927
|
+
if (!mode) return { error: 'No InfluxDB integration is configured in she (config "influx"), so there is no history. The current value: get_mqtt_topic.' };
|
|
928
|
+
if (mode !== 'v1') return { error: 'Topic history is implemented for InfluxDB 1.x only so far.' };
|
|
929
|
+
return { influx };
|
|
930
|
+
}
|
|
931
|
+
|
|
932
|
+
async function toolGetTopicHistory({ topic, topics, from = '-24h', to, limit = 200 } = {}, ctx = {}) {
|
|
933
|
+
const resolved = resolveTopics({ topic, topics }, ctx.store);
|
|
934
|
+
if (resolved.error) return resolved.error;
|
|
935
|
+
if (resolved.topics.length === 0) return 'topic (or topics) is required.';
|
|
936
|
+
const { influx, error } = influxForHistory();
|
|
937
|
+
if (error) return error;
|
|
938
|
+
const now = Date.now();
|
|
939
|
+
const fromMs = parseTime(from, now);
|
|
940
|
+
const toMs = parseTime(to, now);
|
|
941
|
+
if (Number.isNaN(fromMs) || Number.isNaN(toMs)) return `from/to must be ISO 8601 or relative ("-2h"); got from=${from}, to=${to}.`;
|
|
942
|
+
const cap = Math.min(Math.max(1, Number(limit) || 200), 500);
|
|
943
|
+
const perTopic = Math.max(10, Math.floor(cap / resolved.topics.length));
|
|
944
|
+
let results;
|
|
945
|
+
try {
|
|
946
|
+
results = await Promise.all(resolved.topics.map((t) => fetchTopicSeries(influx, t, fromMs, toMs)));
|
|
947
|
+
} catch (e) {
|
|
948
|
+
return `InfluxDB query failed: ${e.message}`;
|
|
949
|
+
}
|
|
950
|
+
const blocks = [];
|
|
951
|
+
results.forEach((r, i) => {
|
|
952
|
+
const t = resolved.topics[i];
|
|
953
|
+
if (r.rows.length === 0) {
|
|
954
|
+
blocks.push(`No history for ${t} between ${isoShort(fromMs)} and ${isoShort(toMs)} (tried measurements ${r.tried.map((m) => `"${m}"`).join(', ')}).`);
|
|
955
|
+
return;
|
|
734
956
|
}
|
|
735
|
-
|
|
736
|
-
const series = rows.map((r) => ({ time: r.time, value: r.value }));
|
|
737
|
-
const { points, note } = thinSeries(series, cap);
|
|
957
|
+
const { points, note } = thinSeries(r.rows, perTopic);
|
|
738
958
|
const lines = [
|
|
739
|
-
`History of ${
|
|
959
|
+
`History of ${t} (measurement "${r.measurement}") from ${isoShort(fromMs)} to ${isoShort(toMs)}${r.rows.length >= 5000 ? ', the first 5000 points of the window' : ''}${note ? `; ${note}` : ''}:`,
|
|
740
960
|
];
|
|
741
961
|
for (const pt of points) lines.push(`${isoShort(pt.time)} ${JSON.stringify(pt.value)}`);
|
|
742
|
-
|
|
962
|
+
blocks.push(lines.join('\n'));
|
|
963
|
+
});
|
|
964
|
+
const head =
|
|
965
|
+
resolved.topics.length > 1
|
|
966
|
+
? [`${resolved.topics.length} topics, up to ${perTopic} points each${resolved.note ? `; ${resolved.note}` : ''}.`]
|
|
967
|
+
: resolved.note
|
|
968
|
+
? [resolved.note]
|
|
969
|
+
: [];
|
|
970
|
+
return [...head, ...blocks].join('\n\n');
|
|
971
|
+
}
|
|
972
|
+
|
|
973
|
+
/** a gap between two changes, short: "+12s", "+5m12s", "+2h05m", "+3d 4h" */
|
|
974
|
+
function gapStr(ms) {
|
|
975
|
+
const s = Math.round(ms / 1000);
|
|
976
|
+
if (s < 60) return `+${s}s`;
|
|
977
|
+
const m = Math.floor(s / 60);
|
|
978
|
+
if (m < 60) return `+${m}m${String(s % 60).padStart(2, '0')}s`;
|
|
979
|
+
const h = Math.floor(m / 60);
|
|
980
|
+
if (h < 48) return `+${h}h${String(m % 60).padStart(2, '0')}m`;
|
|
981
|
+
return `+${Math.floor(h / 24)}d ${h % 24}h`;
|
|
982
|
+
}
|
|
983
|
+
|
|
984
|
+
/** the longest common prefix of the topics, cut at a slash */
|
|
985
|
+
function commonTopicPrefix(topics) {
|
|
986
|
+
if (topics.length < 2) return '';
|
|
987
|
+
let prefix = topics[0];
|
|
988
|
+
for (const t of topics.slice(1)) {
|
|
989
|
+
let i = 0;
|
|
990
|
+
while (i < prefix.length && i < t.length && prefix[i] === t[i]) i++;
|
|
991
|
+
prefix = prefix.slice(0, i);
|
|
743
992
|
}
|
|
744
|
-
|
|
993
|
+
const cut = prefix.lastIndexOf('/');
|
|
994
|
+
return cut === -1 ? '' : prefix.slice(0, cut + 1);
|
|
745
995
|
}
|
|
746
996
|
|
|
747
|
-
|
|
748
|
-
|
|
997
|
+
/**
|
|
998
|
+
* The timeline tool (roadmap I33): the change points of several topics merged and sorted. When there are more
|
|
999
|
+
* than the cap, the changes closest to the previous change of the same topic go first (a flapping sensor loses
|
|
1000
|
+
* its flaps, a door its single opening never); the first change of every topic always stays.
|
|
1001
|
+
*/
|
|
1002
|
+
async function toolGetTimeline({ topics, topic, from = '-24h', to, limit = 200 } = {}, ctx = {}) {
|
|
1003
|
+
const resolved = resolveTopics({ topic, topics }, ctx.store);
|
|
1004
|
+
if (resolved.error) return resolved.error;
|
|
1005
|
+
if (resolved.topics.length === 0) return 'topics is required: a list of topics or an MQTT filter.';
|
|
1006
|
+
const { influx, error } = influxForHistory();
|
|
1007
|
+
if (error) return error;
|
|
1008
|
+
const now = Date.now();
|
|
1009
|
+
const fromMs = parseTime(from, now);
|
|
1010
|
+
const toMs = parseTime(to, now);
|
|
1011
|
+
if (Number.isNaN(fromMs) || Number.isNaN(toMs)) return `from/to must be ISO 8601 or relative ("-2h"); got from=${from}, to=${to}.`;
|
|
1012
|
+
const cap = Math.min(Math.max(1, Number(limit) || 200), 500);
|
|
1013
|
+
let results;
|
|
1014
|
+
try {
|
|
1015
|
+
results = await Promise.all(resolved.topics.map((t) => fetchTopicSeries(influx, t, fromMs, toMs)));
|
|
1016
|
+
} catch (e) {
|
|
1017
|
+
return `InfluxDB query failed: ${e.message}`;
|
|
1018
|
+
}
|
|
1019
|
+
const entries = [];
|
|
1020
|
+
const missing = [];
|
|
1021
|
+
results.forEach((r, i) => {
|
|
1022
|
+
const t = resolved.topics[i];
|
|
1023
|
+
if (r.rows.length === 0) {
|
|
1024
|
+
missing.push(t);
|
|
1025
|
+
return;
|
|
1026
|
+
}
|
|
1027
|
+
let prev;
|
|
1028
|
+
let prevTime = null;
|
|
1029
|
+
for (const row of r.rows) {
|
|
1030
|
+
if (prevTime !== null && row.value === prev) continue;
|
|
1031
|
+
entries.push({ time: row.time, topic: t, from: prevTime === null ? undefined : prev, to: row.value, gapSame: prevTime === null ? Infinity : row.time - prevTime });
|
|
1032
|
+
prev = row.value;
|
|
1033
|
+
prevTime = row.time;
|
|
1034
|
+
}
|
|
1035
|
+
});
|
|
1036
|
+
if (entries.length === 0) return `No history for ${resolved.topics.join(', ')} between ${isoShort(fromMs)} and ${isoShort(toMs)}.`;
|
|
1037
|
+
const total = entries.length;
|
|
1038
|
+
let kept = entries;
|
|
1039
|
+
if (entries.length > cap) {
|
|
1040
|
+
// the changes with the largest distance to their topic's previous change stay (the first of each is Infinity)
|
|
1041
|
+
kept = [...entries].sort((a, b) => b.gapSame - a.gapSame).slice(0, cap);
|
|
1042
|
+
}
|
|
1043
|
+
kept.sort((a, b) => a.time - b.time || a.topic.localeCompare(b.topic));
|
|
1044
|
+
const prefix = commonTopicPrefix(resolved.topics);
|
|
1045
|
+
const short = (t) => (prefix && t.startsWith(prefix) ? t.slice(prefix.length) : t);
|
|
1046
|
+
const head = [
|
|
1047
|
+
`Timeline of ${resolved.topics.length} topic(s)${prefix ? ` under ${prefix}` : ''} from ${isoShort(fromMs)} to ${isoShort(toMs)}: ${kept.length}${kept.length < total ? ` of ${total}` : ''} changes${kept.length < total ? ' (the ones closest to a previous change of the same topic dropped)' : ''}${resolved.note ? `; ${resolved.note}` : ''}.`,
|
|
1048
|
+
];
|
|
1049
|
+
if (missing.length) head.push(`No history for: ${missing.map(short).join(', ')}.`);
|
|
1050
|
+
const lines = kept.map((e, i) => {
|
|
1051
|
+
const gap = i === 0 ? '' : gapStr(e.time - kept[i - 1].time);
|
|
1052
|
+
const change = e.from === undefined ? `${JSON.stringify(e.to)} (first value in the window)` : `${JSON.stringify(e.from)} → ${JSON.stringify(e.to)}`;
|
|
1053
|
+
return `${isoShort(e.time)} ${gap.padStart(8)} ${short(e.topic)}: ${change}`;
|
|
1054
|
+
});
|
|
1055
|
+
return [...head, ...lines].join('\n');
|
|
1056
|
+
}
|
|
1057
|
+
|
|
1058
|
+
async function toolGetTopicMessages({ topic, topics, from = '-24h', to, limit = 100 } = {}, ctx = {}) {
|
|
1059
|
+
const resolved = resolveTopics({ topic, topics }, ctx.store, 10);
|
|
1060
|
+
if (resolved.error) return resolved.error;
|
|
1061
|
+
if (resolved.topics.length === 0) return 'topic (or topics) is required.';
|
|
1062
|
+
const many = resolved.topics.length > 1;
|
|
749
1063
|
let client = null;
|
|
750
1064
|
try {
|
|
751
1065
|
client = require('../elastic').getClient();
|
|
@@ -764,21 +1078,24 @@ async function toolGetTopicMessages({ topic, from = '-24h', to, limit = 100 } =
|
|
|
764
1078
|
res = await client.search({
|
|
765
1079
|
index,
|
|
766
1080
|
size: cap,
|
|
767
|
-
query: {
|
|
1081
|
+
query: {
|
|
1082
|
+
bool: { filter: [many ? { terms: { topic: resolved.topics } } : { term: { topic: resolved.topics[0] } }, { range: { '@timestamp': { gte: fromMs, lte: toMs } } }] },
|
|
1083
|
+
},
|
|
768
1084
|
sort: [{ '@timestamp': { order: 'desc' } }],
|
|
769
|
-
_source: ['@timestamp', 'payload'],
|
|
1085
|
+
_source: many ? ['@timestamp', 'topic', 'payload'] : ['@timestamp', 'payload'],
|
|
770
1086
|
});
|
|
771
1087
|
} catch (e) {
|
|
772
1088
|
return `Elasticsearch query failed: ${e.message}`;
|
|
773
1089
|
}
|
|
774
1090
|
const hits = res?.hits?.hits ?? res?.body?.hits?.hits ?? [];
|
|
775
1091
|
const total = res?.hits?.total?.value ?? res?.body?.hits?.total?.value ?? hits.length;
|
|
776
|
-
|
|
777
|
-
|
|
1092
|
+
const label = many ? `${resolved.topics.length} topics (${resolved.topics.join(', ')})` : resolved.topics[0];
|
|
1093
|
+
if (hits.length === 0) return `No messages for ${label} between ${isoShort(fromMs)} and ${isoShort(toMs)} in ${index}.`;
|
|
1094
|
+
const lines = [`${hits.length} of ${total} messages for ${label} from ${isoShort(fromMs)} to ${isoShort(toMs)}, newest first${resolved.note ? `; ${resolved.note}` : ''}:`];
|
|
778
1095
|
for (const h of hits) {
|
|
779
1096
|
const src = h._source || {};
|
|
780
1097
|
const ts = typeof src['@timestamp'] === 'number' ? src['@timestamp'] : Date.parse(src['@timestamp']);
|
|
781
|
-
lines.push(`${isoShort(ts)} ${String(src.payload ?? '')}`);
|
|
1098
|
+
lines.push(`${isoShort(ts)} ${many ? String(src.topic ?? '') + ' ' : ''}${String(src.payload ?? '')}`);
|
|
782
1099
|
}
|
|
783
1100
|
return lines.join('\n');
|
|
784
1101
|
}
|
|
@@ -881,6 +1198,210 @@ function toolDescribeDevice({ name, limit = 40 } = {}, ctx = {}) {
|
|
|
881
1198
|
return lines.join('\n');
|
|
882
1199
|
}
|
|
883
1200
|
|
|
1201
|
+
/** describe_device with "names" (roadmap I34): one sheet per name, in one result */
|
|
1202
|
+
function toolDescribeDevices({ name, names, limit } = {}, ctx = {}) {
|
|
1203
|
+
const list = [...(Array.isArray(names) ? names : typeof names === 'string' && names.trim() ? [names] : []), ...(name ? [name] : [])]
|
|
1204
|
+
.map((n) => String(n).trim())
|
|
1205
|
+
.filter(Boolean);
|
|
1206
|
+
const unique = [...new Set(list)];
|
|
1207
|
+
if (unique.length === 0) return 'name (or names) is required.';
|
|
1208
|
+
if (unique.length === 1) return toolDescribeDevice({ name: unique[0], limit }, ctx);
|
|
1209
|
+
const cap = 10;
|
|
1210
|
+
const out = unique.slice(0, cap).map((n) => `# ${n}\n${toolDescribeDevice({ name: n, limit }, ctx)}`);
|
|
1211
|
+
if (unique.length > cap) out.push(`… ${unique.length - cap} more names not shown; call again with them.`);
|
|
1212
|
+
return out.join('\n\n');
|
|
1213
|
+
}
|
|
1214
|
+
|
|
1215
|
+
/**
|
|
1216
|
+
* The room sheet (roadmap I34): the topics whose segments carry the room word, grouped per device — the device
|
|
1217
|
+
* is the adapter instance plus the segment that carries the word (`zigbee2mqtt/radar_hobbyraum`,
|
|
1218
|
+
* `hm/Licht Hobbyraum`, or the first segment itself when it carries the word, `radar-hobbyraum`) — the
|
|
1219
|
+
* variables, the scripts touching any of it, the discovery devices.
|
|
1220
|
+
*/
|
|
1221
|
+
function toolDescribeRoom({ name, limit = 15 } = {}, ctx = {}) {
|
|
1222
|
+
if (!name) return 'name is required.';
|
|
1223
|
+
const store = ctx.store;
|
|
1224
|
+
if (!store) return 'MQTT state store not available.';
|
|
1225
|
+
const q = String(name).trim().toLowerCase();
|
|
1226
|
+
if (!q) return 'name is required.';
|
|
1227
|
+
const cap = Math.min(Math.max(1, Number(limit) || 15), 100);
|
|
1228
|
+
const now = Date.now();
|
|
1229
|
+
const varPrefix = ctx.introspect?.config ? ctx.introspect.config().variablePrefix || 'var' : 'var';
|
|
1230
|
+
const devices = new Map(); // key → { topics: [{topic, kind, line}] }
|
|
1231
|
+
const variables = [];
|
|
1232
|
+
const roomTopics = new Set();
|
|
1233
|
+
for (const [topic, obj] of store.mqttEntries()) {
|
|
1234
|
+
const segs = topic.split('/');
|
|
1235
|
+
const hitIdx = segs.findIndex((sg) => sg.toLowerCase().includes(q));
|
|
1236
|
+
if (hitIdx === -1) continue;
|
|
1237
|
+
roomTopics.add(topic);
|
|
1238
|
+
const lc = obj.lc ?? obj.ts;
|
|
1239
|
+
const line = `${topic}: ${JSON.stringify(obj.val)}${lc ? ` (changed ${ago(now - lc)} ago)` : ''}`;
|
|
1240
|
+
if (segs[0] === varPrefix) {
|
|
1241
|
+
variables.push(line);
|
|
1242
|
+
continue;
|
|
1243
|
+
}
|
|
1244
|
+
const kind = segs[1] === 'status' ? 'status' : segs[1] === 'set' ? 'set' : segs[1] === 'maintenance' ? 'maintenance' : 'other';
|
|
1245
|
+
const key = hitIdx === 0 ? segs[0] : `${segs[0]}/${segs[hitIdx]}`;
|
|
1246
|
+
if (!devices.has(key)) devices.set(key, []);
|
|
1247
|
+
devices.get(key).push({ kind, line });
|
|
1248
|
+
}
|
|
1249
|
+
if (devices.size === 0 && variables.length === 0) return `No topic segment contains "${name}". Try search_mqtt_topics with a substring, or another spelling of the room.`;
|
|
1250
|
+
const lines = [`Room "${name}": ${devices.size} device(s), ${variables.length} variable(s), ${roomTopics.size} topic(s).`];
|
|
1251
|
+
const order = { status: 0, set: 1, maintenance: 2, other: 3 };
|
|
1252
|
+
for (const [key, list] of [...devices.entries()].sort((a, b) => a[0].localeCompare(b[0]))) {
|
|
1253
|
+
list.sort((a, b) => order[a.kind] - order[b.kind] || a.line.localeCompare(b.line));
|
|
1254
|
+
lines.push(`\n## ${key} (${list.length} topic${list.length === 1 ? '' : 's'})`);
|
|
1255
|
+
lines.push(...list.slice(0, cap).map((x) => x.line));
|
|
1256
|
+
if (list.length > cap) lines.push(`… ${list.length - cap} more; describe_device "${key.split('/').pop()}" for all of them`);
|
|
1257
|
+
}
|
|
1258
|
+
if (variables.length) {
|
|
1259
|
+
lines.push(`\n## variables (${variables.length})`);
|
|
1260
|
+
lines.push(...variables.sort().slice(0, cap));
|
|
1261
|
+
if (variables.length > cap) lines.push(`… ${variables.length - cap} more; search_mqtt_topics "${varPrefix}/status/#" with the room word`);
|
|
1262
|
+
}
|
|
1263
|
+
if (ctx.introspect?.scripts) {
|
|
1264
|
+
const touches = (t) => roomTopics.has(t) || String(t).toLowerCase().includes(q);
|
|
1265
|
+
const scripts = ctx.introspect
|
|
1266
|
+
.scripts()
|
|
1267
|
+
.map((sc) => {
|
|
1268
|
+
const subs = [...sc.subscriptions, ...sc.varSubscriptions.map((k) => `${varPrefix}/status/${k}`)].filter(touches);
|
|
1269
|
+
const pubs = sc.publishes.filter(touches);
|
|
1270
|
+
return { label: sc.label, subs, pubs };
|
|
1271
|
+
})
|
|
1272
|
+
.filter((x) => x.subs.length || x.pubs.length || x.label.toLowerCase().includes(q))
|
|
1273
|
+
.sort((a, b) => a.label.localeCompare(b.label));
|
|
1274
|
+
if (scripts.length) {
|
|
1275
|
+
lines.push(`\n## scripts (${scripts.length})`);
|
|
1276
|
+
const some = (arr, n = 8) => (arr.length <= n ? arr.join(', ') : arr.slice(0, n).join(', ') + ` … (${arr.length})`);
|
|
1277
|
+
for (const sc of scripts) {
|
|
1278
|
+
const parts = [];
|
|
1279
|
+
if (sc.subs.length) parts.push(`subscribes ${some(sc.subs)}`);
|
|
1280
|
+
if (sc.pubs.length) parts.push(`publishes ${some(sc.pubs)}`);
|
|
1281
|
+
lines.push(`- ${sc.label}${parts.length ? ': ' + parts.join('; ') : ''}`);
|
|
1282
|
+
}
|
|
1283
|
+
}
|
|
1284
|
+
}
|
|
1285
|
+
if (ctx.introspect?.devices) {
|
|
1286
|
+
const devs = ctx.introspect.devices().filter(
|
|
1287
|
+
(d) =>
|
|
1288
|
+
String(d.name || d.id || '')
|
|
1289
|
+
.toLowerCase()
|
|
1290
|
+
.includes(q) || (d.refTopics || []).some((t) => roomTopics.has(t)),
|
|
1291
|
+
);
|
|
1292
|
+
if (devs.length) {
|
|
1293
|
+
lines.push(`\n## Home Assistant discovery (${devs.length})`);
|
|
1294
|
+
for (const d of devs)
|
|
1295
|
+
lines.push(
|
|
1296
|
+
`- ${d.name || d.id}${d.orphaned ? ' (orphaned)' : ''}: ${(d.entities || []).map((e) => `${e.component || 'entity'} ${e.name || ''}`.trim()).join(', ') || 'no entities'}`,
|
|
1297
|
+
);
|
|
1298
|
+
}
|
|
1299
|
+
}
|
|
1300
|
+
return lines.join('\n');
|
|
1301
|
+
}
|
|
1302
|
+
|
|
1303
|
+
// ---------------------------------------------------------------------------
|
|
1304
|
+
// run_analysis (roadmap I42, decision D-9): the model's code in a worker sandbox over read-only loaders
|
|
1305
|
+
// ---------------------------------------------------------------------------
|
|
1306
|
+
|
|
1307
|
+
/** the loaders the sandbox may call, built from the tool context; each throws a readable error */
|
|
1308
|
+
function analysisLoaders(ctx) {
|
|
1309
|
+
const store = ctx.store;
|
|
1310
|
+
const needTime = (v, what) => {
|
|
1311
|
+
const ms = parseTime(v);
|
|
1312
|
+
if (Number.isNaN(ms)) throw new Error(`${what} must be ISO 8601 or relative ("-2h"); got ${v}`);
|
|
1313
|
+
return ms;
|
|
1314
|
+
};
|
|
1315
|
+
return {
|
|
1316
|
+
time: (str) => needTime(str, 'time'),
|
|
1317
|
+
topics: (filter = '') => {
|
|
1318
|
+
if (!store) throw new Error('the MQTT state store is not available');
|
|
1319
|
+
const pattern = String(filter ?? '').trim();
|
|
1320
|
+
const isFilter = /[+#]/.test(pattern);
|
|
1321
|
+
const q = pattern.toLowerCase();
|
|
1322
|
+
const out = [];
|
|
1323
|
+
for (const [topic, obj] of store.mqttEntries()) {
|
|
1324
|
+
if (isFilter ? !mqttWildcard(topic, pattern) : q && !topic.toLowerCase().includes(q)) continue;
|
|
1325
|
+
out.push({ topic, value: obj.val, ts: obj.ts ?? null, lc: obj.lc ?? obj.ts ?? null });
|
|
1326
|
+
if (out.length >= 5000) break;
|
|
1327
|
+
}
|
|
1328
|
+
return out;
|
|
1329
|
+
},
|
|
1330
|
+
history: async (topics, from = '-24h', to) => {
|
|
1331
|
+
const resolved = resolveTopics({ topics }, store);
|
|
1332
|
+
if (resolved.error) throw new Error(resolved.error);
|
|
1333
|
+
if (!resolved.topics.length) throw new Error('history needs topics: a list or an MQTT filter');
|
|
1334
|
+
const { influx, error } = influxForHistory();
|
|
1335
|
+
if (error) throw new Error(error);
|
|
1336
|
+
const fromMs = needTime(from, 'from');
|
|
1337
|
+
const toMs = needTime(to, 'to');
|
|
1338
|
+
const results = await Promise.all(resolved.topics.map((t) => fetchTopicSeries(influx, t, fromMs, toMs)));
|
|
1339
|
+
const out = {};
|
|
1340
|
+
results.forEach((r, i) => (out[resolved.topics[i]] = r.rows));
|
|
1341
|
+
return out;
|
|
1342
|
+
},
|
|
1343
|
+
messages: async (topics, from = '-24h', to, limit = 1000) => {
|
|
1344
|
+
const resolved = resolveTopics({ topics }, store, 20);
|
|
1345
|
+
if (resolved.error) throw new Error(resolved.error);
|
|
1346
|
+
if (!resolved.topics.length) throw new Error('messages needs topics: a list or an MQTT filter');
|
|
1347
|
+
let client = null;
|
|
1348
|
+
try {
|
|
1349
|
+
client = require('../elastic').getClient();
|
|
1350
|
+
} catch {
|
|
1351
|
+
/* module missing */
|
|
1352
|
+
}
|
|
1353
|
+
if (!client) throw new Error('no Elasticsearch integration is configured in she (config "elastic"); use data.history (InfluxDB) for values over time instead');
|
|
1354
|
+
const fromMs = needTime(from, 'from');
|
|
1355
|
+
const toMs = needTime(to, 'to');
|
|
1356
|
+
const cap = Math.min(Math.max(1, Number(limit) || 1000), 5000);
|
|
1357
|
+
const res = await client.search({
|
|
1358
|
+
index: ctx.elasticIndex || 'mqtt-*',
|
|
1359
|
+
size: cap,
|
|
1360
|
+
query: { bool: { filter: [{ terms: { topic: resolved.topics } }, { range: { '@timestamp': { gte: fromMs, lte: toMs } } }] } },
|
|
1361
|
+
sort: [{ '@timestamp': { order: 'desc' } }],
|
|
1362
|
+
_source: ['@timestamp', 'topic', 'payload'],
|
|
1363
|
+
});
|
|
1364
|
+
const hits = res?.hits?.hits ?? res?.body?.hits?.hits ?? [];
|
|
1365
|
+
return hits.map((h) => {
|
|
1366
|
+
const src = h._source || {};
|
|
1367
|
+
const ts = typeof src['@timestamp'] === 'number' ? src['@timestamp'] : Date.parse(src['@timestamp']);
|
|
1368
|
+
return { time: ts, topic: src.topic ?? '', payload: src.payload ?? '' };
|
|
1369
|
+
});
|
|
1370
|
+
},
|
|
1371
|
+
log: async (filter = '', from, to, level = 'debug', limit = 500) => {
|
|
1372
|
+
const fromMs = from ? needTime(from, 'from') : null;
|
|
1373
|
+
const toMs = needTime(to, 'to');
|
|
1374
|
+
const minRank = LEVEL_RANK[String(level).toLowerCase()] ?? 0;
|
|
1375
|
+
const cap = Math.min(Math.max(1, Number(limit) || 500), 5000);
|
|
1376
|
+
const matches = await collectLogEntries({ needle: String(filter ?? '').toLowerCase(), fromMs, toMs, minRank });
|
|
1377
|
+
return matches.slice(-cap).map((e) => ({ ts: e.ts, level: e.level, msg: e.msg }));
|
|
1378
|
+
},
|
|
1379
|
+
script: (relPath) => {
|
|
1380
|
+
const p = scriptPathOf(ctx.scriptDir, relPath);
|
|
1381
|
+
if (!p) throw new Error(`"${relPath}" is not a script path`);
|
|
1382
|
+
if (!fs.existsSync(p.abs)) throw new Error(`no script ${p.rel}`);
|
|
1383
|
+
return fs.readFileSync(p.abs, 'utf8');
|
|
1384
|
+
},
|
|
1385
|
+
};
|
|
1386
|
+
}
|
|
1387
|
+
|
|
1388
|
+
async function toolRunAnalysis({ code, from, to } = {}, ctx = {}) {
|
|
1389
|
+
if (!code || typeof code !== 'string' || !code.trim()) return 'code is required: the body of an async function that returns the result.';
|
|
1390
|
+
const { runAnalysis } = require('./ai-sandbox');
|
|
1391
|
+
const cap = Math.max(500, Number(ctx.resultChars) || DEFAULT_RESULT_CHARS);
|
|
1392
|
+
const r = await runAnalysis({ code, from, to }, analysisLoaders(ctx), { timeoutMs: ctx.analysisTimeoutMs });
|
|
1393
|
+
const logText = r.logs.length ? `\nconsole (${r.logs.length} line${r.logs.length === 1 ? '' : 's'}):\n${r.logs.join('\n')}` : '';
|
|
1394
|
+
let text;
|
|
1395
|
+
let result = null;
|
|
1396
|
+
if (r.error) text = `The analysis failed: ${r.error}${logText}`;
|
|
1397
|
+
else {
|
|
1398
|
+
const json = r.json ?? 'null';
|
|
1399
|
+
result = json.length > cap ? json.slice(0, cap) + `\n… cut after ${cap} characters (${json.length} total); return less from the code.` : json;
|
|
1400
|
+
text = `Result after ${r.ms} ms and ${r.calls} data call${r.calls === 1 ? '' : 's'}:\n${result}${logText}`;
|
|
1401
|
+
}
|
|
1402
|
+
return { text, event: { type: 'analysis', code, result, error: r.error || null, logs: r.logs, ms: r.ms } };
|
|
1403
|
+
}
|
|
1404
|
+
|
|
884
1405
|
function toolListServices({ filter = '' } = {}, ctx = {}) {
|
|
885
1406
|
if (!ctx.introspect?.instances) return NO_INTROSPECT;
|
|
886
1407
|
const q = String(filter).toLowerCase();
|
|
@@ -1087,6 +1608,19 @@ async function toolSheFetch({ url }, ctx = {}) {
|
|
|
1087
1608
|
return `Content of ${url}:\n\n${truncated}`;
|
|
1088
1609
|
}
|
|
1089
1610
|
|
|
1611
|
+
/** get_mqtt_topic with "topics" (roadmap I39): a list or one filter, one line per topic */
|
|
1612
|
+
function toolGetMqttTopics(args, store) {
|
|
1613
|
+
const { topics, topic } = args || {};
|
|
1614
|
+
const hasList = Array.isArray(topics) ? topics.length > 0 : typeof topics === 'string' && topics.trim() !== '';
|
|
1615
|
+
if (!hasList) return toolGetMqttTopic({ topic }, store);
|
|
1616
|
+
if (!store) return 'MQTT state store not available.';
|
|
1617
|
+
const resolved = resolveTopics({ topics }, store);
|
|
1618
|
+
if (resolved.error) return resolved.error;
|
|
1619
|
+
const out = resolved.topics.map((t) => toolGetMqttTopic({ topic: t }, store));
|
|
1620
|
+
if (resolved.note) out.push(resolved.note);
|
|
1621
|
+
return out.join('\n');
|
|
1622
|
+
}
|
|
1623
|
+
|
|
1090
1624
|
function toolGetMqttTopic({ topic }, store) {
|
|
1091
1625
|
if (!store) return 'MQTT state store not available.';
|
|
1092
1626
|
if (!topic || typeof topic !== 'string') return 'topic argument is required.';
|
|
@@ -1228,5 +1762,21 @@ module.exports = {
|
|
|
1228
1762
|
TOOL_DEFINITIONS,
|
|
1229
1763
|
TOOL_DEFINITIONS_ANTHROPIC,
|
|
1230
1764
|
executeTool,
|
|
1231
|
-
_internal: {
|
|
1765
|
+
_internal: {
|
|
1766
|
+
isPrivateAddress,
|
|
1767
|
+
isLocalName,
|
|
1768
|
+
parseDuration,
|
|
1769
|
+
valueMatcher,
|
|
1770
|
+
pageOf,
|
|
1771
|
+
parseTime,
|
|
1772
|
+
measurementCandidates,
|
|
1773
|
+
thinSeries,
|
|
1774
|
+
publishRefusal,
|
|
1775
|
+
scriptPathOf,
|
|
1776
|
+
resolveTopics,
|
|
1777
|
+
fetchTopicSeries,
|
|
1778
|
+
influxForHistory,
|
|
1779
|
+
gapStr,
|
|
1780
|
+
commonTopicPrefix,
|
|
1781
|
+
},
|
|
1232
1782
|
};
|