smart-home-engine 1.52.1 → 1.53.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,4 +1,4 @@
1
- import{m as O}from"./monaco-langs-DKJI_o9J.js";import{t as I}from"./index-ClJLf8-l.js";/*!-----------------------------------------------------------------------------
1
+ import{m as O}from"./monaco-langs-DKJI_o9J.js";import{t as I}from"./index-CBrAPut6.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-ClJLf8-l.js"></script>
178
+ <script type="module" crossorigin src="/assets/index-CBrAPut6.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-C5e-98I_.css">
181
+ <link rel="stylesheet" crossorigin href="/assets/index-x3Sn9IKo.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.52.1",
3
+ "version": "1.53.1",
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
@@ -691,6 +691,9 @@ if (config.url) {
691
691
  state.lc = state.val === oldState.val ? oldState.lc : ts;
692
692
  store.setObject('mqtt::' + topic, state);
693
693
  store.setObject('var::' + varName, state);
694
+ // dispatch now, as setVariable() does: the broker's echo of the status publish below finds the store
695
+ // already updated, so a { change: true } subscription would never see the change (B-22)
696
+ stateChange(topic, state, oldState, msg);
694
697
  mqtt.publish(topic, JSON.stringify(state), { retain: true });
695
698
  } else {
696
699
  if (!state) {
@@ -701,6 +704,18 @@ if (config.url) {
701
704
  state.ts = new Date().getTime();
702
705
  }
703
706
  oldState = store.getObject('mqtt::' + topic) || {};
707
+ // our own echo: she publishes <prefix>/status/<name> retained after a var/set or she.var.set(), and the
708
+ // broker delivers it back (we subscribe to #). The subscriptions were dispatched at set time; the
709
+ // echo carries the stored state verbatim (same ts and lc), so it is stored again but not dispatched.
710
+ if (
711
+ topicArr[0] === config.variablePrefix &&
712
+ topicArr[1] === 'status' &&
713
+ oldState.ts === state.ts &&
714
+ oldState.lc === state.lc &&
715
+ JSON.stringify(oldState.val) === JSON.stringify(state.val)
716
+ ) {
717
+ return;
718
+ }
704
719
  // lc (last change): prefer a payload-provided value; otherwise set it
705
720
  // on value change and carry the previous lc over when the value is
706
721
  // unchanged. Previously the old lc was simply lost on repeated
package/src/web/ai-api.js CHANGED
@@ -349,6 +349,23 @@ function answerText(content) {
349
349
  * @returns {Promise<{ message: string, usage?: object }>}
350
350
  */
351
351
  /** run a tool; a result of { text, event } sends the event to the chat and hands the text to the model */
352
+ /**
353
+ * The tool calls of one round, executed concurrently (roadmap I31): the model is invited to ask for every
354
+ * independent tool in the same turn, and a slow history query must not delay a topic lookup. The call events go
355
+ * out first, in the model's order; each result event goes out as its tool finishes; the contents come back in
356
+ * the model's order, so the tool_result blocks keep their ids.
357
+ */
358
+ async function runToolCalls(calls, ctx, onEvent) {
359
+ for (const c of calls) onEvent?.({ type: 'tool_call', name: c.name, args: c.args });
360
+ return Promise.all(
361
+ calls.map(async (c) => {
362
+ const content = await runToolForChat(c.name, c.args, ctx, onEvent);
363
+ onEvent?.({ type: 'tool_result', name: c.name, content });
364
+ return content;
365
+ }),
366
+ );
367
+ }
368
+
352
369
  async function runToolForChat(name, args, ctx, onEvent) {
353
370
  const out = await executeTool(name, args, ctx);
354
371
  if (out && typeof out === 'object') {
@@ -420,14 +437,12 @@ async function resolveAndGetAnswer(ai, messages, toolContext, onEvent) {
420
437
  if (isAnthropic) {
421
438
  // the assistant turn goes back unchanged: the current models bind their thinking blocks to the turn
422
439
  msgs = [...msgs, { role: 'assistant', content: result.assistantMsg }];
423
- const toolResultBlocks = [];
424
- for (const tc of result.toolCalls) {
425
- const args = tc.input || {};
426
- onEvent?.({ type: 'tool_call', name: tc.name, args });
427
- const content = await runToolForChat(tc.name, args, toolContext, onEvent);
428
- onEvent?.({ type: 'tool_result', name: tc.name, content });
429
- toolResultBlocks.push({ type: 'tool_result', tool_use_id: tc.id, content });
430
- }
440
+ const contents = await runToolCalls(
441
+ result.toolCalls.map((tc) => ({ name: tc.name, args: tc.input || {} })),
442
+ toolContext,
443
+ onEvent,
444
+ );
445
+ const toolResultBlocks = result.toolCalls.map((tc, i) => ({ type: 'tool_result', tool_use_id: tc.id, content: contents[i] }));
431
446
  msgs = [...msgs, { role: 'user', content: toolResultBlocks }];
432
447
  } else {
433
448
  // Strip any draft content alongside tool_calls — if kept, the model reproduces
@@ -435,19 +450,17 @@ async function resolveAndGetAnswer(ai, messages, toolContext, onEvent) {
435
450
  const assistantEntry = { ...result.assistantMsg, role: 'assistant' };
436
451
  if (assistantEntry.content && assistantEntry.tool_calls?.length) assistantEntry.content = null;
437
452
  msgs = [...msgs, assistantEntry];
438
- for (const tc of result.toolCalls) {
439
- const name = tc.function.name;
453
+ const calls = result.toolCalls.map((tc) => {
440
454
  let args;
441
455
  try {
442
456
  args = JSON.parse(tc.function.arguments || '{}');
443
457
  } catch {
444
458
  args = {};
445
459
  }
446
- onEvent?.({ type: 'tool_call', name, args });
447
- const content = await runToolForChat(name, args, toolContext, onEvent);
448
- onEvent?.({ type: 'tool_result', name, content });
449
- msgs = [...msgs, { role: 'tool', tool_call_id: tc.id, content }];
450
- }
460
+ return { name: tc.function.name, args };
461
+ });
462
+ const contents = await runToolCalls(calls, toolContext, onEvent);
463
+ msgs = [...msgs, ...result.toolCalls.map((tc, i) => ({ role: 'tool', tool_call_id: tc.id, content: contents[i] }))];
451
464
  }
452
465
  }
453
466
 
@@ -23,7 +23,8 @@ const ROLE = read('role.md');
23
23
  const CONVENTIONS = read('conventions.md');
24
24
  const API_REF = read('api-ref.md');
25
25
  const API_REF_COMPACT = read('api-ref-compact.md');
26
- const FORMATS = read('formats.md');
26
+ const FORMATS = read('formats.md'); // the hand-over through propose_script (I16, I41)
27
+ const FORMATS_NOTOOLS = read('formats-notools.md'); // the fenced block with the @new-file hint, for chats without tools
27
28
  const TOOLS = read('tools.md');
28
29
  const COMPACT_STEPS = read('compact.md');
29
30
  const DB_VIEW_PROMPT = read('db-view.md');
@@ -98,7 +99,7 @@ function buildSystemPromptParts(requestCtx, currentScript, currentView, currentD
98
99
  staticParts.push(ROLE, CONVENTIONS);
99
100
  if (requestCtx.apiref !== false) staticParts.push(profile === 'compact' ? API_REF_COMPACT : API_REF);
100
101
  if (toolsOffered) staticParts.push(TOOLS);
101
- staticParts.push(FORMATS);
102
+ staticParts.push(toolsOffered ? FORMATS : FORMATS_NOTOOLS);
102
103
  if (profile === 'compact') staticParts.push(COMPACT_STEPS);
103
104
  }
104
105
 
@@ -0,0 +1,84 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * The worker side of the AI chat's run_analysis tool (roadmap I42, decision D-9). It runs the model's code as the
5
+ * body of an async function in a vm context that holds only `data`, `console` and the JavaScript built-ins —
6
+ * no require, no process, no network. Every `data.*` call is a message to the main thread, which answers from the
7
+ * daemon's loaders; the main thread kills the worker when the time is up.
8
+ */
9
+
10
+ const vm = require('node:vm');
11
+ const { parentPort, workerData } = require('node:worker_threads');
12
+
13
+ const MAX_LOG_LINES = 50;
14
+ const logs = [];
15
+ let nextId = 1;
16
+ const pending = new Map();
17
+
18
+ function call(name, args) {
19
+ return new Promise((resolve, reject) => {
20
+ const id = nextId++;
21
+ pending.set(id, { resolve, reject });
22
+ parentPort.postMessage({ type: 'call', id, name, args });
23
+ });
24
+ }
25
+
26
+ parentPort.on('message', (m) => {
27
+ if (m && m.type === 'reply') {
28
+ const p = pending.get(m.id);
29
+ if (!p) return;
30
+ pending.delete(m.id);
31
+ if (m.ok) p.resolve(m.value);
32
+ else p.reject(new Error(m.error));
33
+ }
34
+ });
35
+
36
+ const fmt = (args) =>
37
+ args
38
+ .map((a) => {
39
+ if (typeof a === 'string') return a;
40
+ try {
41
+ return JSON.stringify(a);
42
+ } catch {
43
+ return String(a);
44
+ }
45
+ })
46
+ .join(' ');
47
+ const sandboxConsole = {
48
+ log: (...a) => logs.length < MAX_LOG_LINES && logs.push(fmt(a)),
49
+ info: (...a) => logs.length < MAX_LOG_LINES && logs.push(fmt(a)),
50
+ warn: (...a) => logs.length < MAX_LOG_LINES && logs.push('warn: ' + fmt(a)),
51
+ error: (...a) => logs.length < MAX_LOG_LINES && logs.push('error: ' + fmt(a)),
52
+ };
53
+
54
+ const data = Object.freeze({
55
+ topics: (filter = '') => call('topics', [filter]),
56
+ history: (topics, from, to) => call('history', [topics, from, to]),
57
+ messages: (topics, from, to, limit) => call('messages', [topics, from, to, limit]),
58
+ log: (filter = '', from, to, level, limit) => call('log', [filter, from, to, level, limit]),
59
+ script: (path) => call('script', [path]),
60
+ time: (str) => call('time', [str]),
61
+ from: workerData.from,
62
+ to: workerData.to,
63
+ });
64
+
65
+ const context = vm.createContext({ data, console: sandboxConsole });
66
+
67
+ (async () => {
68
+ let value;
69
+ try {
70
+ const script = new vm.Script('(async () => {\n' + String(workerData.code) + '\n})()', { filename: 'analysis.js' });
71
+ value = await script.runInContext(context, { timeout: workerData.timeoutMs });
72
+ } catch (e) {
73
+ parentPort.postMessage({ type: 'error', message: e && e.message ? e.message : String(e), logs });
74
+ return;
75
+ }
76
+ let json;
77
+ try {
78
+ json = JSON.stringify(value === undefined ? null : value);
79
+ } catch (e) {
80
+ parentPort.postMessage({ type: 'error', message: 'the result is not JSON-serialisable: ' + e.message, logs });
81
+ return;
82
+ }
83
+ parentPort.postMessage({ type: 'done', json, logs });
84
+ })();
@@ -0,0 +1,63 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * The main-thread side of the AI chat's run_analysis tool (roadmap I42, decision D-9): starts the worker with the
5
+ * model's code, answers its data calls from the loaders it is given, and terminates it when the time is up or the
6
+ * heap limit is hit. Nothing of the daemon is reachable from the worker except through the loaders.
7
+ */
8
+
9
+ const path = require('node:path');
10
+ const { Worker } = require('node:worker_threads');
11
+
12
+ const DEFAULT_TIMEOUT_MS = 10000;
13
+ const DEFAULT_HEAP_MB = 256;
14
+
15
+ /**
16
+ * @param {{ code: string, from?: string, to?: string }} job
17
+ * @param {Record<string, (...args: any[]) => any>} loaders topics, history, messages, log, script, time
18
+ * @param {{ timeoutMs?: number, heapMb?: number }} [opts]
19
+ * @returns {Promise<{ json?: string, error?: string, logs: string[], ms: number, calls: number }>}
20
+ */
21
+ function runAnalysis(job, loaders, opts = {}) {
22
+ const timeoutMs = Math.max(1000, Number(opts.timeoutMs) || DEFAULT_TIMEOUT_MS);
23
+ const heapMb = Math.max(32, Number(opts.heapMb) || DEFAULT_HEAP_MB);
24
+ const started = Date.now();
25
+ let calls = 0;
26
+ return new Promise((resolve) => {
27
+ let settled = false;
28
+ const worker = new Worker(path.join(__dirname, 'ai-sandbox-worker.js'), {
29
+ workerData: { code: String(job.code || ''), from: job.from ?? null, to: job.to ?? null, timeoutMs },
30
+ resourceLimits: { maxOldGenerationSizeMb: heapMb, maxYoungGenerationSizeMb: 32 },
31
+ });
32
+ const finish = (result) => {
33
+ if (settled) return;
34
+ settled = true;
35
+ clearTimeout(timer);
36
+ worker.terminate().catch(() => {});
37
+ resolve({ ...result, ms: Date.now() - started, calls });
38
+ };
39
+ const timer = setTimeout(() => finish({ error: `the analysis did not finish within ${timeoutMs / 1000} s and was stopped`, logs: [] }), timeoutMs);
40
+ worker.on('message', async (m) => {
41
+ if (!m || typeof m !== 'object') return;
42
+ if (m.type === 'call') {
43
+ calls++;
44
+ const fn = loaders[m.name];
45
+ try {
46
+ if (typeof fn !== 'function') throw new Error(`data.${m.name} is not available`);
47
+ const value = await fn(...(Array.isArray(m.args) ? m.args : []));
48
+ worker.postMessage({ type: 'reply', id: m.id, ok: true, value });
49
+ } catch (e) {
50
+ worker.postMessage({ type: 'reply', id: m.id, ok: false, error: e && e.message ? e.message : String(e) });
51
+ }
52
+ } else if (m.type === 'done') finish({ json: m.json, logs: m.logs || [] });
53
+ else if (m.type === 'error') finish({ error: m.message, logs: m.logs || [] });
54
+ });
55
+ worker.on('error', (e) => finish({ error: e && e.message ? e.message : String(e), logs: [] }));
56
+ worker.on('exit', (code) => {
57
+ if (!settled)
58
+ finish({ error: code === 0 ? 'the analysis ended without a result' : `the analysis was stopped (exit ${code}; the heap limit of ${heapMb} MB?)`, logs: [] });
59
+ });
60
+ });
61
+ }
62
+
63
+ module.exports = { runAnalysis, DEFAULT_TIMEOUT_MS };