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.
@@ -1,4 +1,4 @@
1
- import{m as O}from"./monaco-langs-DKJI_o9J.js";import{t as I}from"./index-D6H4drtd.js";/*!-----------------------------------------------------------------------------
1
+ import{m as O}from"./monaco-langs-DKJI_o9J.js";import{t as I}from"./index-CD6lY3HK.js";/*!-----------------------------------------------------------------------------
2
2
  * Copyright (c) Microsoft Corporation. All rights reserved.
3
3
  * Version: 0.52.2(404545bded1df6ffa41ea0af4e8ddb219018c6c1)
4
4
  * Released under the MIT license
@@ -175,10 +175,10 @@
175
175
  }
176
176
  })();
177
177
  </script>
178
- <script type="module" crossorigin src="/assets/index-D6H4drtd.js"></script>
178
+ <script type="module" crossorigin src="/assets/index-CD6lY3HK.js"></script>
179
179
  <link rel="modulepreload" crossorigin href="/assets/monaco-langs-DKJI_o9J.js">
180
180
  <link rel="stylesheet" crossorigin href="/assets/monaco-langs-DyX1CsEw.css">
181
- <link rel="stylesheet" crossorigin href="/assets/index-DPDW78ru.css">
181
+ <link rel="stylesheet" crossorigin href="/assets/index-BswjzIm1.css">
182
182
  </head>
183
183
  <body>
184
184
  <div id="app"></div>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "smart-home-engine",
3
- "version": "1.51.3",
3
+ "version": "1.52.0",
4
4
  "description": "Node.js based script runner for use in MQTT based Smart Home environments",
5
5
  "main": "src/index.js",
6
6
  "scripts": {
package/src/index.js CHANGED
@@ -44,24 +44,14 @@ require('./lib/storage').ensureRoot();
44
44
  }
45
45
 
46
46
  // ---------------------------------------------------------------------------
47
- // Persistent JSON-Lines log file — written alongside the pino-pretty stream.
48
- // On each daemon start: rotate she.jsonl → she.jsonl.1, then open fresh.
47
+ // Persistent JSON-Lines log file — written alongside the pino-pretty stream,
48
+ // rotated on every start and by size (roadmap B-12; src/lib/log-file.js).
49
49
  // ---------------------------------------------------------------------------
50
50
  const _fs = require('fs');
51
51
  const _path = require('path');
52
52
  const { LOGS_DIR } = require('./lib/storage');
53
53
  const secrets = require('./lib/secrets');
54
- const _logFileCurrent = _path.join(LOGS_DIR, 'she.jsonl');
55
- const _logFilePrev = _path.join(LOGS_DIR, 'she.jsonl.1');
56
- try {
57
- _fs.renameSync(_logFileCurrent, _logFilePrev);
58
- } catch {
59
- /* no previous file — ignore */
60
- }
61
- const _logFileStream = _fs.createWriteStream(_logFileCurrent, { flags: 'w' });
62
- function _writeLogLine(level, msg) {
63
- _logFileStream.write(JSON.stringify({ level, msg, ts: Date.now() }) + '\n');
64
- }
54
+ const _logFile = require('./lib/log-file').createLogFile({ dir: LOGS_DIR });
65
55
  // ---------------------------------------------------------------------------
66
56
 
67
57
  const config = require('./config');
@@ -83,35 +73,11 @@ const _pino = require('pino')(
83
73
  // Lazy import — log-ws exports a no-op broadcastLog when the HTTP server is not started.
84
74
  const { broadcastLog, broadcast, setWelcomeProvider } = require('./web/log-ws');
85
75
  const shedb = require('./web/shedb');
86
- const log = {
87
- debug: (...args) => {
88
- const msg = secrets.redact(args.join(' '));
89
- _pino.debug(msg);
90
- broadcastLog({ level: 'debug', msg, ts: Date.now() });
91
- _writeLogLine('debug', msg);
92
- },
93
- info: (...args) => {
94
- const msg = secrets.redact(args.join(' '));
95
- _pino.info(msg);
96
- broadcastLog({ level: 'info', msg, ts: Date.now() });
97
- _writeLogLine('info', msg);
98
- },
99
- warn: (...args) => {
100
- const msg = secrets.redact(args.join(' '));
101
- _pino.warn(msg);
102
- broadcastLog({ level: 'warn', msg, ts: Date.now() });
103
- _writeLogLine('warn', msg);
104
- },
105
- error: (...args) => {
106
- const msg = secrets.redact(args.join(' '));
107
- _pino.error(msg);
108
- broadcastLog({ level: 'error', msg, ts: Date.now() });
109
- _writeLogLine('error', msg);
110
- },
111
- setLevel: (level) => {
112
- _pino.level = level;
113
- },
114
- };
76
+ // One level for every sink (roadmap B-12): a line below it is dropped before any work on it.
77
+ const log = require('./lib/logger').createLogger({
78
+ redact: (s) => secrets.redact(s),
79
+ sinks: [(level, msg) => _pino[level](msg), (level, msg) => broadcastLog({ level, msg, ts: Date.now() }), (level, msg) => _logFile.write(level, msg)],
80
+ });
115
81
 
116
82
  // Secrets store (roadmap A5): read once at startup; the UI/CLI keep it current afterwards.
117
83
  {
@@ -177,6 +143,16 @@ process.on('exit', _clearSentinel);
177
143
  // Wall-clock budget for a script's synchronous top-level code (0 = no limit).
178
144
  const SCRIPT_TIMEOUT_MS = typeof config.scriptTimeout === 'number' && config.scriptTimeout >= 0 ? config.scriptTimeout : 5000;
179
145
 
146
+ /** remember which topics a script publishes to (roadmap I19), at most 200 per script */
147
+ function recordPublish(scriptFile, topic) {
148
+ let set = scriptPublishes.get(scriptFile);
149
+ if (!set) {
150
+ set = new Set();
151
+ scriptPublishes.set(scriptFile, set);
152
+ }
153
+ if (set.size < 200 || set.has(topic)) set.add(topic);
154
+ }
155
+
180
156
  /**
181
157
  * Build a short log label for a script file.
182
158
  * Uses the path relative to the configured script dir(s) when possible,
@@ -193,6 +169,7 @@ function makeLabel(filePath) {
193
169
  }
194
170
 
195
171
  log.setLevel(['debug', 'info', 'warn', 'error'].indexOf(config.verbosity) === -1 ? 'info' : config.verbosity);
172
+ _pino.level = log.level;
196
173
 
197
174
  // Safety net: unhandled Promise rejections from async script callbacks are not caught
198
175
  // by the per-script domain (Node.js domains don't intercept Promise rejections).
@@ -271,6 +248,7 @@ const varSubscriptions = []; // store-based var:: subscriptions { key, handler,
271
248
 
272
249
  // Per-script resource tracking for hot-reload
273
250
  const scriptJobs = new Map(); // scriptFile → node-schedule Job[]
251
+ const scriptPublishes = new Map(); // scriptFile → Set<topic> a script published to (roadmap I19; capped)
274
252
  const scriptTimers = new Map(); // scriptFile → Set<timer id>
275
253
 
276
254
  const _global = {};
@@ -448,7 +426,6 @@ require('./web/mqtt-api').init(store, () => mqtt);
448
426
  require('./web/services-api').init(store, () => mqtt, {
449
427
  getMqttConfig: () => ({ url: config.url, username: config.mqttUsername, password: config.mqttPassword }),
450
428
  });
451
- require('./web/ai-api').init(store, log);
452
429
 
453
430
  // MQTT message rate counter — reset on each stats poll
454
431
  let _mqttMsgCount = 0;
@@ -473,7 +450,7 @@ new PerformanceObserver((list) => {
473
450
  }).observe({ type: 'gc', buffered: false });
474
451
 
475
452
  // Register runtime stats provider for GET /she/status
476
- require('./web/server').setStatsProvider(() => {
453
+ const statsSnapshot = () => {
477
454
  let topics = 0;
478
455
  // eslint-disable-next-line no-unused-vars
479
456
  for (const _ of store.mqttEntries()) topics++;
@@ -532,16 +509,55 @@ require('./web/server').setStatsProvider(() => {
532
509
  elMaxMs,
533
510
  safeMode: SAFE_MODE,
534
511
  };
535
- });
512
+ };
513
+ require('./web/server').setStatsProvider(statsSnapshot);
536
514
 
537
515
  // Health probe for GET /she/health (roadmap A1) — cheap, no counting, no allocation.
538
- require('./web/server').setHealthProvider(() => ({
516
+ const healthSnapshot = () => ({
539
517
  started: _started,
540
518
  mqttConfigured: !!config.url,
541
519
  mqttConnected: connected,
542
520
  scripts: Object.keys(scripts).length,
543
521
  safeMode: SAFE_MODE,
544
- }));
522
+ });
523
+ require('./web/server').setHealthProvider(healthSnapshot);
524
+
525
+ // What the AI chat may look at (roadmap I19, I27, I28): read-only views of the daemon's state.
526
+ const nextOf = (job) => {
527
+ try {
528
+ const d = job && typeof job.nextInvocation === 'function' ? job.nextInvocation() : null;
529
+ const date = d && typeof d.toDate === 'function' ? d.toDate() : d;
530
+ return date instanceof Date ? date.getTime() : null;
531
+ } catch {
532
+ return null;
533
+ }
534
+ };
535
+ const introspect = {
536
+ // the AI's publish tool (roadmap I20), guarded and confirmed in ai-api; here only the broker call
537
+ publish: (topic, payload, opts) =>
538
+ new Promise((resolve, reject) => {
539
+ if (!mqtt || !connected) return reject(new Error('MQTT is not connected'));
540
+ mqtt.publish(String(topic), typeof payload === 'object' ? JSON.stringify(payload) : String(payload), opts || {}, (err) => (err ? reject(err) : resolve()));
541
+ }),
542
+ config: () => ({ name: config.name, variablePrefix: config.variablePrefix || 'var', version: pkg.version }),
543
+ scripts: () =>
544
+ Object.keys(scripts).map((file) => ({
545
+ file,
546
+ label: makeLabel(file).slice(0, -1),
547
+ origin: scriptOrigins.get(file) || 'user',
548
+ subscriptions: subscriptions.filter((x) => x._script === file).flatMap((x) => (Array.isArray(x.topic) ? x.topic : [x.topic])),
549
+ varSubscriptions: varSubscriptions.filter((x) => x._script === file).map((x) => x.key),
550
+ publishes: [...(scriptPublishes.get(file) || [])],
551
+ jobs: (scriptJobs.get(file) || []).filter(Boolean).map((job) => ({ next: nextOf(job) })),
552
+ sunEvents: sunEvents.filter((e) => e._script === file).map((e) => ({ pattern: e.pattern, next: nextOf(e._job) })),
553
+ timers: [...(scriptTimers.get(file) || [])].map((t) => ({ due: t.due || null, every: t.every || null })),
554
+ })),
555
+ stats: statsSnapshot,
556
+ health: healthSnapshot,
557
+ instances: () => require('./lib/services-inventory').analyzeServices(store.mqttEntries()).instances,
558
+ devices: () => require('./lib/ha-discovery').analyzeDiscovery(store.mqttEntries()).devices,
559
+ };
560
+ require('./web/ai-api').init(store, log, introspect);
545
561
 
546
562
  // Push current script:running state so the UI green dots survive a browser reload.
547
563
  setWelcomeProvider(() => Object.keys(scripts).map((f) => ({ type: 'script:running', path: makeLabel(f).slice(0, -1), running: true })));
@@ -1089,6 +1105,7 @@ function runScript(script, name, _origin) {
1089
1105
  }
1090
1106
 
1091
1107
  topic = topic.replace(/^([^/]+)\/\/(.+)$/, '$1/set/$2');
1108
+ recordPublish(name, topic);
1092
1109
 
1093
1110
  if (typeof payload === 'object') {
1094
1111
  payload = JSON.stringify(payload);
@@ -1216,6 +1233,8 @@ function runScript(script, name, _origin) {
1216
1233
  setInterval: (fn, delay, ...args) => {
1217
1234
  const wrapped = args.length ? () => fn(...args) : fn;
1218
1235
  const id = setInterval(() => _dispatch(name, wrapped), delay);
1236
+ id.every = delay;
1237
+ id.due = Date.now() + delay;
1219
1238
  _myTimers.add(id);
1220
1239
  return id;
1221
1240
  },
@@ -1442,6 +1461,7 @@ function unloadScript(file) {
1442
1461
  removedTimers += timers.size;
1443
1462
  scriptTimers.delete(file);
1444
1463
  }
1464
+ scriptPublishes.delete(file);
1445
1465
 
1446
1466
  // Remove store-based var:: subscriptions belonging to this script
1447
1467
  for (let i = varSubscriptions.length - 1; i >= 0; i--) {
@@ -0,0 +1,81 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * JSON-lines log file with rotation by size (roadmap B-12).
5
+ *
6
+ * `she.jsonl` is rotated on every daemon start and whenever it passes `maxBytes`:
7
+ * `.jsonl` → `.jsonl.1` → … → `.jsonl.<keep>`; the oldest file is dropped.
8
+ * The stream is written without waiting for the disk; what the thread pool has not
9
+ * written yet sits in the stream's buffer, which is transient.
10
+ */
11
+
12
+ const fs = require('fs');
13
+ const path = require('path');
14
+
15
+ const DEFAULT_MAX_BYTES = 10 * 1024 * 1024;
16
+ const DEFAULT_KEEP = 5;
17
+
18
+ /**
19
+ * @param {{ dir: string, name?: string, maxBytes?: number, keep?: number }} opts
20
+ */
21
+ function createLogFile({ dir, name = 'she.jsonl', maxBytes = DEFAULT_MAX_BYTES, keep = DEFAULT_KEEP }) {
22
+ const current = path.join(dir, name);
23
+ let stream = null;
24
+ let bytes = 0;
25
+
26
+ function shift() {
27
+ // .keep is dropped, .(keep-1) → .keep, …, current → .1
28
+ for (let i = keep; i >= 1; i--) {
29
+ const from = i === 1 ? current : `${current}.${i - 1}`;
30
+ const to = `${current}.${i}`;
31
+ try {
32
+ if (i === keep) fs.rmSync(to, { force: true });
33
+ fs.renameSync(from, to);
34
+ } catch {
35
+ /* a missing file in the chain — nothing to move */
36
+ }
37
+ }
38
+ }
39
+
40
+ function open() {
41
+ shift();
42
+ if (stream) stream.end();
43
+ // open synchronously: the file must exist and be bound before the next rotation renames it,
44
+ // and a stream opened lazily would bind to whatever carries the name by then
45
+ stream = fs.createWriteStream(null, { fd: fs.openSync(current, 'w') });
46
+ stream.on('error', () => {
47
+ /* the log file is a convenience: a disk error must not take the daemon down */
48
+ });
49
+ bytes = 0;
50
+ }
51
+
52
+ open();
53
+
54
+ return {
55
+ /** @param {string} level @param {string} msg */
56
+ write(level, msg) {
57
+ const line = JSON.stringify({ level, msg, ts: Date.now() }) + '\n';
58
+ bytes += Buffer.byteLength(line);
59
+ stream.write(line);
60
+ if (bytes >= maxBytes) open();
61
+ },
62
+ /** the files that exist, newest first — what the AI log tool reads */
63
+ files() {
64
+ const list = [];
65
+ for (let i = 0; i <= keep; i++) {
66
+ const f = i === 0 ? current : `${current}.${i}`;
67
+ if (fs.existsSync(f)) list.push(f);
68
+ }
69
+ return list;
70
+ },
71
+ close() {
72
+ if (stream) stream.end();
73
+ stream = null;
74
+ },
75
+ get path() {
76
+ return current;
77
+ },
78
+ };
79
+ }
80
+
81
+ module.exports = { createLogFile, DEFAULT_MAX_BYTES, DEFAULT_KEEP };
@@ -0,0 +1,33 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * The daemon's logger: one level for every sink (roadmap B-12).
5
+ *
6
+ * A line below the configured level is dropped here, before redaction, serialisation
7
+ * and the sinks — the journal (pino), the UI (ring buffer and websocket) and the file.
8
+ * Until B-12 only pino honoured the level; the other two got every debug line.
9
+ */
10
+
11
+ const LEVELS = { debug: 0, info: 1, warn: 2, error: 3 };
12
+
13
+ /**
14
+ * @param {{ level?: string, redact?: (s: string) => string, sinks: Array<(level: string, msg: string) => void> }} opts
15
+ */
16
+ function createLogger({ level = 'info', redact = (s) => s, sinks = [] }) {
17
+ let threshold = LEVELS[level] ?? LEVELS.info;
18
+ const logger = {};
19
+ for (const name of Object.keys(LEVELS)) {
20
+ logger[name] = (...args) => {
21
+ if (LEVELS[name] < threshold) return;
22
+ const msg = redact(args.join(' '));
23
+ for (const sink of sinks) sink(name, msg);
24
+ };
25
+ }
26
+ logger.setLevel = (l) => {
27
+ threshold = LEVELS[l] ?? LEVELS.info;
28
+ };
29
+ Object.defineProperty(logger, 'level', { get: () => Object.keys(LEVELS).find((k) => LEVELS[k] === threshold) });
30
+ return logger;
31
+ }
32
+
33
+ module.exports = { createLogger, LEVELS };
@@ -19,6 +19,7 @@ function trackTimeout(timers, fn, delay, setTimeoutFn = setTimeout) {
19
19
  timers.delete(id);
20
20
  fn();
21
21
  }, delay);
22
+ if (id && typeof id === 'object') id.due = Date.now() + delay; // when it fires (roadmap I28: list_timers)
22
23
  timers.add(id);
23
24
  return id;
24
25
  }
@@ -0,0 +1,116 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * A unified diff of two texts, line based (roadmap I16): what the chat shows for a script draft.
5
+ * Plain LCS over lines — scripts are a few hundred lines, so quadratic is fine.
6
+ */
7
+
8
+ function lcsTable(a, b) {
9
+ const n = a.length;
10
+ const m = b.length;
11
+ const t = new Array(n + 1);
12
+ for (let i = 0; i <= n; i++) t[i] = new Uint32Array(m + 1);
13
+ for (let i = n - 1; i >= 0; i--) {
14
+ for (let j = m - 1; j >= 0; j--) {
15
+ t[i][j] = a[i] === b[j] ? t[i + 1][j + 1] + 1 : Math.max(t[i + 1][j], t[i][j + 1]);
16
+ }
17
+ }
18
+ return t;
19
+ }
20
+
21
+ /** @returns {Array<{ kind: ' '|'-'|'+', line: string, a?: number, b?: number }>} */
22
+ function diffLines(a, b) {
23
+ const t = lcsTable(a, b);
24
+ const out = [];
25
+ let i = 0;
26
+ let j = 0;
27
+ while (i < a.length && j < b.length) {
28
+ if (a[i] === b[j]) {
29
+ out.push({ kind: ' ', line: a[i], a: i, b: j });
30
+ i++;
31
+ j++;
32
+ } else if (t[i + 1][j] >= t[i][j + 1]) {
33
+ out.push({ kind: '-', line: a[i], a: i });
34
+ i++;
35
+ } else {
36
+ out.push({ kind: '+', line: b[j], b: j });
37
+ j++;
38
+ }
39
+ }
40
+ while (i < a.length) out.push({ kind: '-', line: a[i++] });
41
+ while (j < b.length) out.push({ kind: '+', line: b[j++] });
42
+ return out;
43
+ }
44
+
45
+ /**
46
+ * @param {string} oldText
47
+ * @param {string} newText
48
+ * @param {{ context?: number, fromName?: string, toName?: string }} [opts]
49
+ * @returns {{ text: string, added: number, removed: number }}
50
+ */
51
+ // a text's lines, without the empty one a trailing newline would produce
52
+ function toLines(text) {
53
+ const str = String(text ?? '');
54
+ if (str === '') return [];
55
+ const lines = str.split('\n');
56
+ if (lines[lines.length - 1] === '') lines.pop();
57
+ return lines;
58
+ }
59
+
60
+ function unifiedDiff(oldText, newText, opts = {}) {
61
+ const context = opts.context ?? 3;
62
+ const a = toLines(oldText);
63
+ const b = toLines(newText);
64
+ const ops = diffLines(a, b);
65
+ let added = 0;
66
+ let removed = 0;
67
+ for (const op of ops) {
68
+ if (op.kind === '+') added++;
69
+ else if (op.kind === '-') removed++;
70
+ }
71
+ const lines = [`--- ${opts.fromName || 'a'}`, `+++ ${opts.toName || 'b'}`];
72
+ if (added === 0 && removed === 0) return { text: '', added, removed };
73
+ // hunks: runs of changes with `context` lines around them
74
+ let idx = 0;
75
+ while (idx < ops.length) {
76
+ if (ops[idx].kind === ' ') {
77
+ idx++;
78
+ continue;
79
+ }
80
+ let start = Math.max(0, idx - context);
81
+ let end = idx;
82
+ while (end < ops.length) {
83
+ // extend the hunk while changes are within 2*context of each other
84
+ let next = end;
85
+ while (next < ops.length && ops[next].kind !== ' ') next++;
86
+ let gap = next;
87
+ while (gap < ops.length && ops[gap].kind === ' ' && gap - next < 2 * context) gap++;
88
+ if (gap < ops.length && ops[gap].kind !== ' ' && gap - next < 2 * context) {
89
+ end = gap;
90
+ continue;
91
+ }
92
+ end = Math.min(ops.length, next + context);
93
+ break;
94
+ }
95
+ const hunk = ops.slice(start, end);
96
+ const aStart = (hunk.find((o) => o.a !== undefined)?.a ?? (hunk[0].b !== undefined ? aIndexBefore(ops, start) : 0)) + 1;
97
+ const bStart = (hunk.find((o) => o.b !== undefined)?.b ?? bIndexBefore(ops, start)) + 1;
98
+ const aLen = hunk.filter((o) => o.kind !== '+').length;
99
+ const bLen = hunk.filter((o) => o.kind !== '-').length;
100
+ lines.push(`@@ -${aStart},${aLen} +${bStart},${bLen} @@`);
101
+ for (const o of hunk) lines.push(o.kind + o.line);
102
+ idx = end;
103
+ }
104
+ return { text: lines.join('\n'), added, removed };
105
+ }
106
+
107
+ function aIndexBefore(ops, k) {
108
+ for (let i = k - 1; i >= 0; i--) if (ops[i].a !== undefined) return ops[i].a + 1;
109
+ return 0;
110
+ }
111
+ function bIndexBefore(ops, k) {
112
+ for (let i = k - 1; i >= 0; i--) if (ops[i].b !== undefined) return ops[i].b + 1;
113
+ return 0;
114
+ }
115
+
116
+ module.exports = { unifiedDiff, diffLines };