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.
@@ -1,4 +1,4 @@
1
- import{m as O}from"./monaco-langs-DKJI_o9J.js";import{t as I}from"./index-CD6lY3HK.js";/*!-----------------------------------------------------------------------------
1
+ import{m as O}from"./monaco-langs-DKJI_o9J.js";import{t as I}from"./index-COKTfw7g.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-CD6lY3HK.js"></script>
178
+ <script type="module" crossorigin src="/assets/index-COKTfw7g.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-BswjzIm1.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.0",
3
+ "version": "1.53.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/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
 
@@ -858,13 +871,17 @@ function publishContext(ai, context, send) {
858
871
  confirm: (req) =>
859
872
  new Promise((resolve) => {
860
873
  const id = 'p' + crypto.randomBytes(4).toString('hex');
874
+ // the chat learns how the request ended (a click, or the two minutes passing), so the card never
875
+ // offers a button for a request the daemon has already forgotten
861
876
  const timer = setTimeout(() => {
862
877
  _pendingPublish.delete(id);
878
+ send?.({ type: 'publish_decided', id, decided: 'timeout' });
863
879
  resolve('timeout');
864
880
  }, PUBLISH_CONFIRM_MS);
865
881
  _pendingPublish.set(id, (ok) => {
866
882
  clearTimeout(timer);
867
883
  _pendingPublish.delete(id);
884
+ send?.({ type: 'publish_decided', id, decided: ok ? 'published' : 'skipped' });
868
885
  resolve(!!ok);
869
886
  });
870
887
  send?.({ type: 'publish_request', id, ...req });
@@ -874,7 +891,7 @@ function publishContext(ai, context, send) {
874
891
 
875
892
  router.post('/publish/:id', (req, res) => {
876
893
  const resolve = _pendingPublish.get(req.params.id);
877
- if (!resolve) return res.status(404).json({ error: 'no pending publish with that id (answered or expired)' });
894
+ if (!resolve) return res.status(410).json({ error: 'this publish request was already answered or expired (two minutes); ask the assistant again' });
878
895
  resolve(req.body?.ok === true);
879
896
  res.json({ ok: true });
880
897
  });
@@ -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');
@@ -58,7 +59,7 @@ function houseSection(cfg, entries, instances, notes) {
58
59
  const known = (instances || []).filter((i) => i.adapter).map((i) => `\`${i.instance}/\` ${i.adapter}${i.connected === 0 ? ' (offline)' : ''}`);
59
60
  if (known.length) lines.push(`- adapter instances: ${known.slice(0, 20).join(', ')}${known.length > 20 ? ` … (${known.length})` : ''}`);
60
61
  if (notes && notes.length) {
61
- lines.push('- facts the user confirmed earlier (trust them over guesses):');
62
+ lines.push('- your memory, facts from earlier chats (trust them over guesses; add with remember, drop with forget):');
62
63
  for (const n of notes.slice(0, 300)) lines.push(` - [${n.id}] ${String(n.text).replace(/\s+/g, ' ').slice(0, 200)}`);
63
64
  }
64
65
  return lines.length > 1 ? lines.join('\n') : '';
@@ -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 };