smart-home-engine 1.51.3 → 1.52.0

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