smart-home-engine 0.12.0 → 0.14.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.
@@ -0,0 +1,210 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * AI tool definitions and executor for the she AI assistant.
5
+ *
6
+ * Available tools:
7
+ * search_mqtt_topics — fuzzy-search known MQTT topics in the state store
8
+ * read_script — read a script file from the scripts directory
9
+ * get_script_logs — retrieve recent log entries, optionally filtered by script name
10
+ * she_fetch — fetch a URL and return its text content
11
+ */
12
+
13
+ const fs = require('fs');
14
+ const path = require('path');
15
+ const { getLogBuffer } = require('./log-ws');
16
+
17
+ // ---------------------------------------------------------------------------
18
+ // Tool schemas
19
+ // ---------------------------------------------------------------------------
20
+
21
+ /** Tool definitions in OpenAI function-calling format. */
22
+ const TOOL_DEFINITIONS = [
23
+ {
24
+ type: 'function',
25
+ function: {
26
+ name: 'search_mqtt_topics',
27
+ description:
28
+ 'Search for MQTT topics currently tracked by the she daemon. ' +
29
+ 'Returns matching topic names and their current values. ' +
30
+ 'Use this to discover real topic names before writing scripts.',
31
+ parameters: {
32
+ type: 'object',
33
+ properties: {
34
+ query: {
35
+ type: 'string',
36
+ description: 'Case-insensitive substring to match against topic names. Pass empty string to list all topics (capped at 50).',
37
+ },
38
+ },
39
+ required: ['query'],
40
+ },
41
+ },
42
+ },
43
+ {
44
+ type: 'function',
45
+ function: {
46
+ name: 'read_script',
47
+ description:
48
+ 'Read the content of a script file from the she scripts directory. ' +
49
+ 'Use this to review existing scripts before suggesting changes.',
50
+ parameters: {
51
+ type: 'object',
52
+ properties: {
53
+ path: {
54
+ type: 'string',
55
+ description: 'Script file path relative to the scripts directory, e.g. "lights.js" or "lib/utils.js".',
56
+ },
57
+ },
58
+ required: ['path'],
59
+ },
60
+ },
61
+ },
62
+ {
63
+ type: 'function',
64
+ function: {
65
+ name: 'get_script_logs',
66
+ description:
67
+ 'Retrieve recent log messages from the she daemon. ' +
68
+ 'Filter by script name to diagnose errors or trace what a specific script has been doing.',
69
+ parameters: {
70
+ type: 'object',
71
+ properties: {
72
+ script_name: {
73
+ type: 'string',
74
+ description: 'Filter log lines to those mentioning this script name (file name without extension). Pass empty string to get all recent logs.',
75
+ },
76
+ limit: {
77
+ type: 'integer',
78
+ description: 'Maximum number of log lines to return (1-200, default 50).',
79
+ },
80
+ },
81
+ required: [],
82
+ },
83
+ },
84
+ },
85
+ {
86
+ type: 'function',
87
+ function: {
88
+ name: 'she_fetch',
89
+ description:
90
+ 'Fetch the content of a URL and return it as plain text. ' +
91
+ 'Use this to retrieve documentation, data sheets, or any web resource relevant to the user request. ' +
92
+ 'HTML is stripped to plain text automatically.',
93
+ parameters: {
94
+ type: 'object',
95
+ properties: {
96
+ url: {
97
+ type: 'string',
98
+ description: 'The URL to fetch (http or https).',
99
+ },
100
+ },
101
+ required: ['url'],
102
+ },
103
+ },
104
+ },
105
+ ];
106
+
107
+ /** Same definitions in Anthropic tool format. */
108
+ const TOOL_DEFINITIONS_ANTHROPIC = TOOL_DEFINITIONS.map((t) => ({
109
+ name: t.function.name,
110
+ description: t.function.description,
111
+ input_schema: t.function.parameters,
112
+ }));
113
+
114
+ // ---------------------------------------------------------------------------
115
+ // Executor
116
+ // ---------------------------------------------------------------------------
117
+
118
+ /**
119
+ * Execute a named tool and return its result as a plain string.
120
+ * @param {string} name — tool function name
121
+ * @param {object} args — parsed arguments from LLM
122
+ * @param {{ store: import('../lib/state-store')|null, scriptDir: string|null }} ctx
123
+ * @returns {string}
124
+ */
125
+ async function executeTool(name, args, ctx) {
126
+ try {
127
+ switch (name) {
128
+ case 'search_mqtt_topics': return toolSearchMqttTopics(args, ctx.store);
129
+ case 'read_script': return toolReadScript(args, ctx.scriptDir);
130
+ case 'get_script_logs': return toolGetScriptLogs(args);
131
+ case 'she_fetch': return await toolSheFetch(args);
132
+ default: return `Unknown tool: ${name}`;
133
+ }
134
+ } catch (e) {
135
+ return `Tool error (${name}): ${e.message}`;
136
+ }
137
+ }
138
+
139
+ // ---------------------------------------------------------------------------
140
+ // Individual tools
141
+ // ---------------------------------------------------------------------------
142
+
143
+ function toolSearchMqttTopics({ query = '' }, store) {
144
+ if (!store) return 'MQTT state store not available.';
145
+ const q = query.toLowerCase();
146
+ const results = [];
147
+ for (const [topic, obj] of store.mqttEntries()) {
148
+ if (!q || topic.toLowerCase().includes(q)) {
149
+ results.push(`${topic}: ${JSON.stringify(obj.val)}`);
150
+ if (results.length >= 50) {
151
+ results.push('… (truncated to 50 results, use a more specific query)');
152
+ break;
153
+ }
154
+ }
155
+ }
156
+ if (results.length === 0) {
157
+ return q ? `No MQTT topics found matching "${query}".` : 'No MQTT topics tracked yet.';
158
+ }
159
+ return `Found ${results.length} topic(s):\n${results.join('\n')}`;
160
+ }
161
+
162
+ function toolReadScript({ path: relPath }, scriptDir) {
163
+ if (!scriptDir) return 'Scripts directory not configured.';
164
+ if (!relPath || typeof relPath !== 'string') return 'path argument is required.';
165
+ const abs = path.resolve(scriptDir, relPath.replace(/^\/+/, ''));
166
+ // Path traversal guard
167
+ if (!abs.startsWith(scriptDir + path.sep) && abs !== scriptDir) {
168
+ return 'Access denied: path escapes the scripts directory.';
169
+ }
170
+ if (!fs.existsSync(abs)) return `File not found: ${relPath}`;
171
+ const content = fs.readFileSync(abs, 'utf8');
172
+ return `## ${relPath}\n\`\`\`javascript\n${content}\n\`\`\``;
173
+ }
174
+
175
+ function toolGetScriptLogs({ script_name = '', limit = 50 }) {
176
+ const cap = Math.min(Math.max(1, Number(limit) || 50), 200);
177
+ const buf = getLogBuffer();
178
+ const needle = String(script_name).toLowerCase();
179
+ const filtered = needle ? buf.filter((e) => e.msg.toLowerCase().includes(needle)) : buf;
180
+ const recent = filtered.slice(-cap);
181
+ if (recent.length === 0) {
182
+ return needle ? `No log entries found mentioning "${script_name}".` : 'No log entries in buffer yet.';
183
+ }
184
+ return recent
185
+ .map((e) => {
186
+ const t = new Date(e.ts).toISOString().slice(11, 19);
187
+ return `[${t}] ${e.level.toUpperCase()} ${e.msg}`;
188
+ })
189
+ .join('\n');
190
+ }
191
+
192
+ const MAX_FETCH_CHARS = 8000;
193
+
194
+ async function toolSheFetch({ url }) {
195
+ if (!url || typeof url !== 'string') return 'url argument is required.';
196
+ if (!/^https?:\/\//i.test(url)) return 'Only http and https URLs are supported.';
197
+ const res = await fetch(url, {
198
+ headers: { 'User-Agent': 'she-ai-agent/1.0' },
199
+ signal: AbortSignal.timeout(15000),
200
+ });
201
+ if (!res.ok) return `HTTP error ${res.status} ${res.statusText} fetching ${url}`;
202
+ const ct = res.headers.get('content-type') || '';
203
+ const text = await res.text();
204
+ // Strip HTML tags for cleaner text
205
+ const plain = ct.includes('html') ? text.replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim() : text;
206
+ const truncated = plain.length > MAX_FETCH_CHARS ? plain.slice(0, MAX_FETCH_CHARS) + `\n… (truncated, ${plain.length} chars total)` : plain;
207
+ return `Content of ${url}:\n\n${truncated}`;
208
+ }
209
+
210
+ module.exports = { TOOL_DEFINITIONS, TOOL_DEFINITIONS_ANTHROPIC, executeTool };
package/src/web/log-ws.js CHANGED
@@ -5,6 +5,10 @@ const { WebSocketServer } = require('ws');
5
5
  let _wss = null;
6
6
  const _clients = new Set();
7
7
 
8
+ // Ring buffer of recent log entries for the AI tool get_script_logs
9
+ const _logBuffer = [];
10
+ const LOG_BUFFER_MAX = 500;
11
+
8
12
  /**
9
13
  * Attach a WebSocketServer to an existing http.Server.
10
14
  * Clients connect to /she/ws. Once connected they receive:
@@ -54,12 +58,23 @@ function broadcast(msg) {
54
58
 
55
59
  /**
56
60
  * Broadcast a structured log entry to all connected WebSocket clients.
61
+ * Also stores the entry in the in-memory ring buffer.
57
62
  * @param {{ level: string, msg: string, ts: number }} entry
58
63
  */
59
64
  function broadcastLog(entry) {
65
+ _logBuffer.push(entry);
66
+ if (_logBuffer.length > LOG_BUFFER_MAX) _logBuffer.shift();
60
67
  broadcast({ type: 'log', ...entry });
61
68
  }
62
69
 
70
+ /**
71
+ * Return a snapshot of recent log entries (newest last).
72
+ * @returns {{ level: string, msg: string, ts: number }[]}
73
+ */
74
+ function getLogBuffer() {
75
+ return _logBuffer.slice();
76
+ }
77
+
63
78
  /**
64
79
  * Close the WebSocket server.
65
80
  * @returns {Promise<void>}
@@ -75,4 +90,4 @@ function closeWss() {
75
90
  });
76
91
  }
77
92
 
78
- module.exports = { attachWss, broadcast, broadcastLog, closeWss };
93
+ module.exports = { attachWss, broadcast, broadcastLog, closeWss, getLogBuffer };
@@ -63,8 +63,13 @@ router.post('/commission', async (req, res) => {
63
63
  router.get('/devices/:nodeId', (req, res) => {
64
64
  if (!isReady()) return notReady(res);
65
65
  try {
66
- const endpoints = getController().getEndpoints(req.params.nodeId);
67
- res.json({ nodeId: req.params.nodeId, endpoints });
66
+ const ctrl = getController();
67
+ const endpoints = ctrl.getEndpoints(req.params.nodeId);
68
+ // Derive device-level name and subtitle from the root endpoint (0)
69
+ const rootEp = endpoints.find(ep => ep.endpointId === 0);
70
+ const name = rootEp?.name ?? endpoints.find(ep => ep.name)?.name ?? null;
71
+ const subtitle = ctrl.getDeviceSubtitle(req.params.nodeId);
72
+ res.json({ nodeId: req.params.nodeId, endpoints, name, subtitle });
68
73
  } catch (err) {
69
74
  if (err.message.includes('not found')) return res.status(404).json({ error: err.message });
70
75
  res.status(500).json({ error: err.message });
@@ -0,0 +1,17 @@
1
+ You are SHE Assistant, helping manage sheDB documents for she (smart-home-engine).
2
+
3
+ sheDB is a simple JSON document store. Each document has a string ID (structured like an MQTT topic path, e.g. `devices/lamp1`) and a JSON value (any object, array, or scalar).
4
+
5
+ When proposing a new or updated document, output the document ID and the JSON content in this format:
6
+
7
+ ```json
8
+ // @doc-id: devices/lamp1
9
+ {
10
+ "name": "Living room lamp",
11
+ "type": "light"
12
+ }
13
+ ```
14
+
15
+ The `// @doc-id:` comment must be the very first line inside the JSON block — the UI uses it to pre-fill the document ID field.
16
+
17
+ Document IDs follow MQTT topic conventions: use `/` as separator, lowercase, no spaces.
@@ -0,0 +1,30 @@
1
+ You are SHE Assistant, helping write sheDB MapReduce view definitions for she (smart-home-engine).
2
+
3
+ A view has three optional parts:
4
+
5
+ 1. **Filter** — an MQTT-style topic wildcard that selects which document IDs enter the view. Plain string, no code.
6
+ MQTT wildcards: `+` matches exactly one path segment, `#` matches any number of segments (must be last).
7
+ Examples: `devices/+/state` matches `devices/lamp1/state`. `sensors/#` matches all IDs starting with `sensors/`.
8
+ ⚠️ `*` is NOT a valid MQTT wildcard — never use it. Use `#` for "match everything".
9
+
10
+ 2. **Map** — a JavaScript function body. `this` is the current document. Call `emit(value)` to include a value in the result array. No `return`.
11
+
12
+ 3. **Reduce** — a JavaScript function body that receives `result` (the array from map) and must `return` a transformed value.
13
+
14
+ When proposing view parts, use these exact formats (include only the parts that change):
15
+
16
+ ```filter
17
+ devices/#
18
+ ```
19
+
20
+ ```javascript
21
+ // @view-map
22
+ if (this.temperature !== undefined) emit(this.temperature);
23
+ ```
24
+
25
+ ```javascript
26
+ // @view-reduce
27
+ return result.reduce((a, b) => a + b, 0) / result.length;
28
+ ```
29
+
30
+ Keep the `// @view-map` / `// @view-reduce` comment as the very first line of each block — the UI uses it to detect which field to fill in.
@@ -0,0 +1,18 @@
1
+ You are SHE Assistant, an expert AI pair programmer for she (smart-home-engine).
2
+ she is a Node.js daemon that runs user JavaScript scripts in a sandboxed VM for home automation.
3
+ When proposing changes to a script, always output the COMPLETE new file content in a single fenced ```javascript code block. Never output partial diffs or fragments — the user applies the full file at once.
4
+ Keep any existing header comments and the 'use strict'; directive.
5
+ When the user asks you to CREATE a new script (not modify the current one), place a special hint as the very first line INSIDE the code block (right after the opening ```javascript fence line), like this:
6
+
7
+ ```javascript
8
+ // @new-file: descriptive-name.js
9
+ /* global she */
10
+ 'use strict';
11
+ // ... rest of script
12
+ ```
13
+
14
+ Use a short kebab-case filename. Do NOT put the hint outside or before the code block. The UI will detect it and offer to save the file.
15
+
16
+ ### MQTT publishing rules
17
+ When sending a command to a device (e.g. turning a light on/off, triggering an action), ALWAYS use `she.mqtt.pub()` WITHOUT retain — never set `retain: true` on command topics.
18
+ Only use `she.mqtt.set()` or `retain: true` for storing persistent state or configuration values that must survive restarts, not for commands.
@@ -0,0 +1,73 @@
1
+ ## she sandbox API
2
+
3
+ Scripts run in a sandboxed VM. The `she` object is injected automatically.
4
+
5
+ ### Script conventions
6
+ - First lines: /* global she */ then 'use strict';
7
+ - No require() — the module system is not available
8
+ - All subscriptions and schedules persist across reconnects
9
+
10
+ ### MQTT
11
+ ```
12
+ she.mqtt.sub(topic, [opts], cb) Subscribe; wildcards: + (1 level) # (multi)
13
+ +//sensor → +/status/sensor shorthand
14
+ opts.change: true = only fire when value changes
15
+ she.mqtt.pub(topic, payload, [opts]) Publish; opts: { qos, retain }
16
+ she.mqtt.get(topic) Current retained value (sync)
17
+ she.mqtt.set(topic, val) Publish as retained
18
+ she.mqtt.link(src, target, [fn]) Forward src changes to target; optional transform
19
+ she.mqtt.age(topic) Seconds since topic last received a message
20
+ she.mqtt.on('connect'|'disconnect', cb) MQTT lifecycle events
21
+ ```
22
+
23
+ ### Scheduling
24
+ ```
25
+ she.schedule(pattern, [opts], cb)
26
+ pattern: cron string | Date | suncalc event name
27
+ suncalc events: 'sunrise' 'sunset' 'dawn' 'dusk'
28
+ 'nauticalDawn' 'nauticalDusk' 'solarNoon' 'night'
29
+ opts.shift: seconds offset (e.g. -1800 = 30 min before event)
30
+ opts.random: max random delay in seconds added to the trigger time
31
+ ```
32
+
33
+ ### Universal key-value API
34
+ ```
35
+ she.on(key, cb) Subscribe. Key prefixes: mqtt:: var:: matter::
36
+ she.set(key, val) Set value (mqtt:: or var:: namespaces)
37
+ she.get(key) Current value
38
+ she.getObject(key) Current { val, ts, lc } state object
39
+ ```
40
+
41
+ ### Variable system (var:: namespace)
42
+ Topics prefixed with "var" (default) are persisted as retained MQTT messages
43
+ and available across scripts via she.get('var::name') / she.set('var::name', v).
44
+
45
+ ### sheDB
46
+ ```
47
+ she.db.get(id) Get document (undefined if not found)
48
+ she.db.set(id, doc) Create or overwrite document
49
+ she.db.extend(id, partial) Deep-merge partial into existing document
50
+ she.db.delete(id) Delete document
51
+ she.db.sub(pattern, cb) Subscribe to document changes (MQTT wildcard)
52
+ she.db.query(filter, mapFn, [reduceFn]) Synchronous ad-hoc query → Array
53
+ ```
54
+
55
+ ### Matter
56
+ ```
57
+ she.matter.sub(nodeId, endpointId, cluster, attr, cb) Subscribe to attribute
58
+ she.matter.unsub(listenerId)
59
+ she.matter.get(nodeId, endpointId, cluster, attr) → Promise<value>
60
+ she.matter.send(nodeId, endpointId, cluster, cmd, [args]) → Promise<result>
61
+ ```
62
+
63
+ ### Helpers
64
+ ```
65
+ she.timer(src, target, ms) Pulse target=1 for ms after src goes truthy
66
+ she.combineBool(srcs[], target) Publish OR of source values to target
67
+ she.combineMax(srcs[], target) Publish maximum of source values to target
68
+ she.link(src, target, [fn]) Alias for she.mqtt.link
69
+ she.age(topic) Alias for she.mqtt.age
70
+ she.now() Current timestamp in ms
71
+ she.debug / .info / .warn / .error Structured logging (prefixed with script name)
72
+ she.global Shared mutable object across all scripts
73
+ ```
@@ -28,7 +28,7 @@ function hasShelibMarker(absDir) {
28
28
  return fs.existsSync(path.join(absDir, '.shelib'));
29
29
  }
30
30
 
31
- /** Flat list of all .js files with metadata and lib flag. */
31
+ /** Flat list of all files with metadata and lib flag. */
32
32
  function walk(dir, base, parentIsLib) {
33
33
  let entries;
34
34
  try {
@@ -43,7 +43,7 @@ function walk(dir, base, parentIsLib) {
43
43
  const rel = base ? `${base}/${entry.name}` : entry.name;
44
44
  if (entry.isDirectory()) {
45
45
  results.push(...walk(path.join(dir, entry.name), rel, lib));
46
- } else if (entry.name.endsWith('.js')) {
46
+ } else {
47
47
  const stat = fs.statSync(path.join(dir, entry.name));
48
48
  results.push({ path: rel, size: stat.size, mtime: stat.mtimeMs, lib });
49
49
  }
@@ -52,7 +52,7 @@ function walk(dir, base, parentIsLib) {
52
52
  }
53
53
 
54
54
  /**
55
- * Nested tree of all .js files and subdirectories.
55
+ * Nested tree of all files and subdirectories.
56
56
  * Each node: { type:'file'|'dir', name, path, lib, size?, mtime?, children? }
57
57
  */
58
58
  function buildTree(dir, base, parentIsLib) {
@@ -73,7 +73,7 @@ function buildTree(dir, base, parentIsLib) {
73
73
  const childIsLib = lib || hasShelibMarker(abs);
74
74
  const children = buildTree(abs, rel, childIsLib);
75
75
  result.push({ type: 'dir', name: entry.name, path: rel, lib: childIsLib, children });
76
- } else if (entry.name.endsWith('.js')) {
76
+ } else {
77
77
  const stat = fs.statSync(abs);
78
78
  result.push({ type: 'file', name: entry.name, path: rel, lib, size: stat.size, mtime: stat.mtimeMs });
79
79
  }
@@ -120,10 +120,6 @@ router.use((req, res) => {
120
120
  if (method === 'PUT') {
121
121
  const abs = safePath(root, filePath);
122
122
  if (!abs) return res.status(400).json({ error: 'Invalid path' });
123
- const basename = path.basename(abs);
124
- if (!basename.endsWith('.js') && basename !== '.shelib') {
125
- return res.status(400).json({ error: 'Only .js and .shelib files are allowed' });
126
- }
127
123
  const content = typeof req.body?.content === 'string' ? req.body.content : null;
128
124
  if (content === null) return res.status(400).json({ error: 'Missing body.content string' });
129
125
  try {
package/src/web/server.js CHANGED
@@ -58,6 +58,13 @@ app.post('/she/restart', (req, res) => {
58
58
  setTimeout(() => process.exit(0), 200);
59
59
  });
60
60
 
61
+ // Runtime stats — script count + MQTT topic count
62
+ let _getStats = null;
63
+ function setStatsProvider(fn) { _getStats = fn; }
64
+ app.get('/she/status', (req, res) => {
65
+ res.json(_getStats ? _getStats() : { scripts: 0, topics: 0 });
66
+ });
67
+
61
68
  // Serve the built Svelte SPA from dist/web/
62
69
  // Hashed assets (JS/CSS) are immutable; index.html must never be cached so
63
70
  // browsers always pick up a freshly deployed version.
@@ -136,4 +143,4 @@ function stopServer() {
136
143
  });
137
144
  }
138
145
 
139
- module.exports = { app, registerRoute, startServer, stopServer };
146
+ module.exports = { app, registerRoute, setStatsProvider, startServer, stopServer };