smart-home-engine 1.52.0 → 1.53.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.
@@ -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: ['query'],
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: 'Read the content of a script file from the she scripts directory. ' + 'Use this to review existing scripts before suggesting changes.',
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: ['path'],
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: ['topic'],
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 an MQTT topic over time from InfluxDB (when she has an Influx integration configured), as a compact time series: change points, downsampled when long. ' +
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
- required: ['topic'],
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
  },
@@ -350,8 +427,8 @@ const TOOL_DEFINITIONS = [
350
427
  function: {
351
428
  name: 'remember',
352
429
  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").',
430
+ "Store a durable fact about this installation for every later chat (shown and editable in the chat's memory panel). One short sentence, no secrets. " +
431
+ 'Use it on your own when you learn something no topic tells — a sensor\'s blind spot ("the PIR in the bathroom cannot see the shower"), what a room or variable is for, a habit of the household, a cause found after a long search — and when the user corrects you or states a preference. Not for what a tool can look up.',
355
432
  parameters: {
356
433
  type: 'object',
357
434
  properties: { text: { type: 'string', description: 'The fact, at most 200 characters.' } },
@@ -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. Use this instead of pasting the whole file into the answer.',
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 toolSearchMqttTopics(args, ctx.store);
543
+ return toolSearchMqttTopicsMulti(args, ctx.store);
445
544
  case 'get_mqtt_topic':
446
- return toolGetMqttTopic(args, ctx.store);
545
+ return toolGetMqttTopics(args, ctx.store);
447
546
  case 'read_script':
448
- return toolReadScript(args, ctx.scriptDir);
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 toolDescribeDevice(args, ctx);
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
- function toolReadScript({ path: relPath }, scriptDir) {
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
- async function toolGetScriptLogs({ script_name = '', from, to, level = 'debug', limit = 50, offset = 0 } = {}) {
627
- const cap = Math.min(Math.max(1, Number(limit) || 50), 200);
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
- 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.';
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 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);
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
- let rows;
730
- try {
731
- rows = await influx.v1Query(q);
732
- } catch (e) {
733
- return `InfluxDB query failed: ${e.message}`;
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
- 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);
957
+ const { points, note } = thinSeries(r.rows, perTopic);
738
958
  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}` : ''}:`,
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
- return lines.join('\n');
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
- return `No history for ${topic} between ${isoShort(fromMs)} and ${isoShort(toMs)} (tried measurements ${tried.map((m) => `"${m}"`).join(', ')}).`;
993
+ const cut = prefix.lastIndexOf('/');
994
+ return cut === -1 ? '' : prefix.slice(0, cut + 1);
745
995
  }
746
996
 
747
- async function toolGetTopicMessages({ topic, from = '-24h', to, limit = 100 } = {}, ctx = {}) {
748
- if (!topic) return 'topic is required.';
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: { bool: { filter: [{ term: { topic: String(topic) } }, { range: { '@timestamp': { gte: fromMs, lte: toMs } } }] } },
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
- 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:`];
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")');
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: { isPrivateAddress, isLocalName, parseDuration, valueMatcher, pageOf, parseTime, measurementCandidates, thinSeries, publishRefusal, scriptPathOf },
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
  };