smart-home-engine 0.14.0 → 0.16.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.
package/src/web/ai-api.js CHANGED
@@ -91,8 +91,8 @@ async function callOpenAICompat(config, messages, tools) {
91
91
  }
92
92
  : undefined;
93
93
 
94
- // Detect tool call response
95
- if (choice?.finish_reason === 'tool_calls' && choice.message?.tool_calls?.length) {
94
+ // Detect tool call response — some models return finish_reason 'stop' even with tool calls
95
+ if (choice?.message?.tool_calls?.length) {
96
96
  return { toolCalls: choice.message.tool_calls, assistantMsg: choice.message, usage };
97
97
  }
98
98
 
@@ -174,22 +174,22 @@ async function resolveAndGetAnswer(ai, messages, toolContext, onEvent) {
174
174
  const isAnthropic = ai.provider === 'anthropic';
175
175
  const tools = isAnthropic ? TOOL_DEFINITIONS_ANTHROPIC : TOOL_DEFINITIONS;
176
176
  let msgs = messages;
177
+ let toolsUsed = false; // once the model has used tools, stop offering them
177
178
 
178
179
  for (let round = 0; round < 6; round++) {
179
- // On the final safety round, don't send tools to avoid infinite loops
180
- const roundTools = round < 5 ? tools : undefined;
180
+ // After the first round of tool calls, don't offer tools again.
181
+ // This forces a plain-text response rather than letting the model
182
+ // keep calling tools indefinitely (and some models return empty
183
+ // content when given tools but no reason to call them).
184
+ const roundTools = toolsUsed ? undefined : tools;
181
185
 
182
186
  let result;
183
187
  try {
184
- result = isAnthropic
185
- ? await callAnthropic(ai, msgs, roundTools)
186
- : await callOpenAICompat(ai, msgs, roundTools);
188
+ result = isAnthropic ? await callAnthropic(ai, msgs, roundTools) : await callOpenAICompat(ai, msgs, roundTools);
187
189
  } catch (e) {
188
190
  if (round === 0 && roundTools) {
189
191
  // Model may not support tool calling — retry without tools
190
- result = isAnthropic
191
- ? await callAnthropic(ai, msgs)
192
- : await callOpenAICompat(ai, msgs);
192
+ result = isAnthropic ? await callAnthropic(ai, msgs) : await callOpenAICompat(ai, msgs);
193
193
  } else {
194
194
  throw e;
195
195
  }
@@ -197,12 +197,21 @@ async function resolveAndGetAnswer(ai, messages, toolContext, onEvent) {
197
197
 
198
198
  // No tool calls → we have the final answer
199
199
  if (!result.toolCalls?.length) {
200
+ // If the model returned empty content after using tools, nudge it once
201
+ if (!result.message && toolsUsed) {
202
+ const nudgeMsgs = [...msgs, { role: 'user', content: 'Based on the information retrieved above, please now provide your complete response.' }];
203
+ const nudged = isAnthropic ? await callAnthropic(ai, nudgeMsgs) : await callOpenAICompat(ai, nudgeMsgs);
204
+ return { message: nudged.message ?? '', usage: nudged.usage };
205
+ }
200
206
  return { message: result.message ?? '', usage: result.usage };
201
207
  }
202
208
 
203
209
  // Execute tool calls and append results to message history
210
+ toolsUsed = true;
204
211
  if (isAnthropic) {
205
- msgs = [...msgs, { role: 'assistant', content: result.assistantMsg }];
212
+ // Strip text blocks when tool_use blocks are present (same draft-reproduction issue)
213
+ const anthropicAssistantContent = result.assistantMsg.some((b) => b.type === 'tool_use') ? result.assistantMsg.filter((b) => b.type !== 'text') : result.assistantMsg;
214
+ msgs = [...msgs, { role: 'assistant', content: anthropicAssistantContent }];
206
215
  const toolResultBlocks = [];
207
216
  for (const tc of result.toolCalls) {
208
217
  const args = tc.input || {};
@@ -213,11 +222,19 @@ async function resolveAndGetAnswer(ai, messages, toolContext, onEvent) {
213
222
  }
214
223
  msgs = [...msgs, { role: 'user', content: toolResultBlocks }];
215
224
  } else {
216
- msgs = [...msgs, { ...result.assistantMsg, role: 'assistant' }];
225
+ // Strip any draft content alongside tool_calls — if kept, the model reproduces
226
+ // the (hallucinated) draft in round 1 instead of using the tool results.
227
+ const assistantEntry = { ...result.assistantMsg, role: 'assistant' };
228
+ if (assistantEntry.content && assistantEntry.tool_calls?.length) assistantEntry.content = null;
229
+ msgs = [...msgs, assistantEntry];
217
230
  for (const tc of result.toolCalls) {
218
231
  const name = tc.function.name;
219
232
  let args;
220
- try { args = JSON.parse(tc.function.arguments || '{}'); } catch { args = {}; }
233
+ try {
234
+ args = JSON.parse(tc.function.arguments || '{}');
235
+ } catch {
236
+ args = {};
237
+ }
221
238
  onEvent?.({ type: 'tool_call', name, args });
222
239
  const content = await executeTool(name, args, toolContext);
223
240
  onEvent?.({ type: 'tool_result', name, content });
@@ -227,9 +244,7 @@ async function resolveAndGetAnswer(ai, messages, toolContext, onEvent) {
227
244
  }
228
245
 
229
246
  // Fallback (should not normally be reached)
230
- const fallback = isAnthropic
231
- ? await callAnthropic(ai, msgs)
232
- : await callOpenAICompat(ai, msgs);
247
+ const fallback = isAnthropic ? await callAnthropic(ai, msgs) : await callOpenAICompat(ai, msgs);
233
248
  return { message: fallback.message ?? '', usage: fallback.usage };
234
249
  }
235
250
 
@@ -358,7 +373,10 @@ router.get('/models', async (req, res) => {
358
373
  const r = await fetch(`${base}/api/tags`);
359
374
  if (!r.ok) throw new Error(`Ollama /api/tags returned ${r.status}`);
360
375
  const json = await r.json();
361
- const models = (json.models || []).map((m) => m.name || m.model).filter(Boolean).sort();
376
+ const models = (json.models || [])
377
+ .map((m) => m.name || m.model)
378
+ .filter(Boolean)
379
+ .sort();
362
380
  return res.json({ models });
363
381
  } else if (ai.provider === 'anthropic') {
364
382
  return res.json({ models: [] }); // no public list endpoint
@@ -369,7 +387,10 @@ router.get('/models', async (req, res) => {
369
387
  const r = await fetch(`${base}/v1/models`, { headers: h });
370
388
  if (!r.ok) throw new Error(`/v1/models returned ${r.status}`);
371
389
  const json = await r.json();
372
- const models = (json.data || []).map((m) => m.id).filter(Boolean).sort();
390
+ const models = (json.data || [])
391
+ .map((m) => m.id)
392
+ .filter(Boolean)
393
+ .sort();
373
394
  return res.json({ models });
374
395
  }
375
396
  } catch (e) {
@@ -385,7 +406,7 @@ router.get('/model-info', async (req, res) => {
385
406
  if (ai.provider !== 'ollama') return res.status(400).json({ error: 'Model info is only available for Ollama' });
386
407
 
387
408
  const base = (ai.baseUrl || 'http://localhost:11434').replace(/\/$/, '');
388
- const model = (typeof req.query.model === 'string' && req.query.model) ? req.query.model : ai.model;
409
+ const model = typeof req.query.model === 'string' && req.query.model ? req.query.model : ai.model;
389
410
 
390
411
  const [versionRes, showRes, psRes] = await Promise.allSettled([
391
412
  fetch(`${base}/api/version`).then((r) => r.json()),
@@ -400,10 +421,21 @@ router.get('/model-info', async (req, res) => {
400
421
  res.json({
401
422
  version: versionRes.status === 'fulfilled' ? versionRes.value.version : null,
402
423
  details: showRes.status === 'fulfilled' ? showRes.value.details : null,
403
- running: psRes.status === 'fulfilled' ? (psRes.value.models || []) : null,
424
+ running: psRes.status === 'fulfilled' ? psRes.value.models || [] : null,
404
425
  });
405
426
  });
406
427
 
428
+ // POST /she/ai/prompt — return the current system prompt for preview
429
+ router.post('/prompt', (req, res) => {
430
+ const { context = {}, currentScript, currentView, currentDoc, extraFiles } = req.body || {};
431
+ try {
432
+ const prompt = buildSystemPrompt(context, currentScript ?? null, currentView ?? null, currentDoc ?? null, _store, extraFiles || []);
433
+ res.json({ prompt });
434
+ } catch (e) {
435
+ res.status(500).json({ error: e.message });
436
+ }
437
+ });
438
+
407
439
  // POST /she/ai/chat — non-streaming
408
440
  router.post('/chat', async (req, res) => {
409
441
  const ai = readAiConfig(req.app.locals.configPath);
@@ -411,11 +443,11 @@ router.post('/chat', async (req, res) => {
411
443
  return res.status(400).json({ error: 'AI provider not configured. Set ai.provider and ai.model in Config.' });
412
444
  }
413
445
 
414
- const { messages = [], currentScript, currentView, currentDoc, context = {}, modelOverride } = req.body || {};
446
+ const { messages = [], currentScript, currentView, currentDoc, context = {}, modelOverride, extraFiles } = req.body || {};
415
447
  if (!Array.isArray(messages)) return res.status(400).json({ error: 'messages must be an array' });
416
448
 
417
- const aiWithModel = (modelOverride && typeof modelOverride === 'string') ? { ...ai, model: modelOverride } : ai;
418
- const systemPrompt = buildSystemPrompt(context, currentScript ?? null, currentView ?? null, currentDoc ?? null, _store);
449
+ const aiWithModel = modelOverride && typeof modelOverride === 'string' ? { ...ai, model: modelOverride } : ai;
450
+ const systemPrompt = buildSystemPrompt(context, currentScript ?? null, currentView ?? null, currentDoc ?? null, _store, extraFiles || []);
419
451
  const fullMessages = [{ role: 'system', content: systemPrompt }, ...messages];
420
452
 
421
453
  try {
@@ -441,10 +473,18 @@ router.post('/chat/stream', async (req, res) => {
441
473
  return res.status(400).json({ error: 'AI provider not configured. Set ai.provider and ai.model in Config.' });
442
474
  }
443
475
 
444
- const { messages = [], currentScript, currentView, currentDoc, context = {}, modelOverride } = req.body || {};
476
+ const { messages = [], currentScript, currentView, currentDoc, context = {}, modelOverride, extraFiles } = req.body || {};
445
477
  if (!Array.isArray(messages)) return res.status(400).json({ error: 'messages must be an array' });
446
478
 
447
- const aiWithModel = (modelOverride && typeof modelOverride === 'string') ? { ...ai, model: modelOverride } : ai;
479
+ const aiWithModel = modelOverride && typeof modelOverride === 'string' ? { ...ai, model: modelOverride } : ai;
480
+
481
+ // Build system prompt BEFORE flushing headers so errors can still return a proper HTTP status
482
+ let systemPrompt;
483
+ try {
484
+ systemPrompt = buildSystemPrompt(context, currentScript ?? null, currentView ?? null, currentDoc ?? null, _store, extraFiles || []);
485
+ } catch (e) {
486
+ return res.status(500).json({ error: `Failed to build system prompt: ${e.message}` });
487
+ }
448
488
 
449
489
  res.set({
450
490
  'Content-Type': 'text/event-stream',
@@ -455,7 +495,6 @@ router.post('/chat/stream', async (req, res) => {
455
495
 
456
496
  const send = (data) => res.write(`data: ${JSON.stringify(data)}\n\n`);
457
497
 
458
- const systemPrompt = buildSystemPrompt(context, currentScript ?? null, currentView ?? null, currentDoc ?? null, _store);
459
498
  const fullMessages = [{ role: 'system', content: systemPrompt }, ...messages];
460
499
 
461
500
  try {
@@ -15,9 +15,9 @@ const path = require('path');
15
15
  // Load prompt templates once at startup — plain Markdown files, no escaping needed
16
16
  const P = path.join(__dirname, 'prompts');
17
17
  const SCRIPTS_BASE_PROMPT = fs.readFileSync(path.join(P, 'scripts-base.md'), 'utf8').trim();
18
- const SHE_API_REF = fs.readFileSync(path.join(P, 'she-api-ref.md'), 'utf8').trim();
19
- const DB_VIEW_PROMPT = fs.readFileSync(path.join(P, 'db-view.md'), 'utf8').trim();
20
- const DB_DOC_PROMPT = fs.readFileSync(path.join(P, 'db-doc.md'), 'utf8').trim();
18
+ const SHE_API_REF = fs.readFileSync(path.join(P, 'she-api-ref.md'), 'utf8').trim();
19
+ const DB_VIEW_PROMPT = fs.readFileSync(path.join(P, 'db-view.md'), 'utf8').trim();
20
+ const DB_DOC_PROMPT = fs.readFileSync(path.join(P, 'db-doc.md'), 'utf8').trim();
21
21
 
22
22
  /**
23
23
  * Build the full system prompt, including optional context sections.
@@ -30,9 +30,9 @@ const DB_DOC_PROMPT = fs.readFileSync(path.join(P, 'db-doc.md'), 'u
30
30
  * @param {import('../lib/state-store') | null} store
31
31
  * @returns {string}
32
32
  */
33
- function buildSystemPrompt(requestCtx, currentScript, currentView, currentDoc, store) {
34
- const isViewMode = !!(currentView?.id);
35
- const isDocMode = !!(currentDoc?.id);
33
+ function buildSystemPrompt(requestCtx, currentScript, currentView, currentDoc, store, extraFiles) {
34
+ const isViewMode = !!currentView?.id;
35
+ const isDocMode = !!currentDoc?.id;
36
36
 
37
37
  let basePrompt;
38
38
  if (isViewMode) {
@@ -54,10 +54,10 @@ function buildSystemPrompt(requestCtx, currentScript, currentView, currentDoc, s
54
54
  }
55
55
 
56
56
  if (currentView?.id) {
57
- const filterStr = (currentView.filter || '').trim();
58
- const mapBody = (currentView.map || '').trim();
57
+ const filterStr = (currentView.filter || '').trim();
58
+ const mapBody = (currentView.map || '').trim();
59
59
  const reduceBody = (currentView.reduce || '').trim();
60
- const viewLines = [`## Current view: ${currentView.id}`];
60
+ const viewLines = [`## Current view: ${currentView.id}`];
61
61
  viewLines.push(`Filter: ${filterStr || '(none)'}`);
62
62
  viewLines.push(`Map:\n\`\`\`javascript\n${mapBody || '// (empty)'}\n\`\`\``);
63
63
  if (reduceBody) {
@@ -69,26 +69,10 @@ function buildSystemPrompt(requestCtx, currentScript, currentView, currentDoc, s
69
69
  }
70
70
 
71
71
  if (currentDoc?.id) {
72
- const content = typeof currentDoc.content === 'string'
73
- ? currentDoc.content
74
- : JSON.stringify(currentDoc.content, null, 2);
72
+ const content = typeof currentDoc.content === 'string' ? currentDoc.content : JSON.stringify(currentDoc.content, null, 2);
75
73
  parts.push(`## Current document: ${currentDoc.id}\n\`\`\`json\n${content}\n\`\`\``);
76
74
  }
77
75
 
78
- if (requestCtx.mqtt && store) {
79
- const topics = [];
80
- for (const [topic, obj] of store.mqttEntries()) {
81
- topics.push(`${topic}: ${JSON.stringify(obj.val)}`);
82
- if (topics.length >= 100) {
83
- topics.push('… (truncated)');
84
- break;
85
- }
86
- }
87
- if (topics.length > 0) {
88
- parts.push(`## Current MQTT state\n${topics.join('\n')}`);
89
- }
90
- }
91
-
92
76
  if (requestCtx.shedb) {
93
77
  try {
94
78
  const core = require('./shedb').getCore();
@@ -118,18 +102,11 @@ function buildSystemPrompt(requestCtx, currentScript, currentView, currentDoc, s
118
102
  }
119
103
  }
120
104
 
121
- if (requestCtx.matter) {
122
- try {
123
- const controller = require('../matter/controller');
124
- if (typeof controller.listPaired === 'function') {
125
- const nodes = controller.listPaired();
126
- if (nodes.length > 0) {
127
- const list = nodes.map((n) => ` nodeId ${n.nodeId}: ${n.label || 'unnamed'}`).join('\n');
128
- parts.push(`## Paired Matter devices\n${list}`);
129
- }
130
- }
131
- } catch {
132
- // matter not initialised — skip silently
105
+ if (extraFiles && extraFiles.length > 0) {
106
+ for (const f of extraFiles) {
107
+ const ext = (f.name.match(/\.([^.]+)$/) || [])[1] || '';
108
+ const lang = ext.toLowerCase();
109
+ parts.push(`## Attached file: ${f.name}\n\`\`\`${lang}\n${f.content}\n\`\`\``);
133
110
  }
134
111
  }
135
112
 
@@ -27,7 +27,8 @@ const TOOL_DEFINITIONS = [
27
27
  description:
28
28
  'Search for MQTT topics currently tracked by the she daemon. ' +
29
29
  'Returns matching topic names and their current values. ' +
30
- 'Use this to discover real topic names before writing scripts.',
30
+ 'Use this to discover real topic names before writing scripts. ' +
31
+ 'Homematic related topics (under the topic tree hm/) end with STATE for switching actuators and with LEVEL for dimmers.',
31
32
  parameters: {
32
33
  type: 'object',
33
34
  properties: {
@@ -44,9 +45,7 @@ const TOOL_DEFINITIONS = [
44
45
  type: 'function',
45
46
  function: {
46
47
  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.',
48
+ description: 'Read the content of a script file from the she scripts directory. ' + 'Use this to review existing scripts before suggesting changes.',
50
49
  parameters: {
51
50
  type: 'object',
52
51
  properties: {
@@ -63,9 +62,7 @@ const TOOL_DEFINITIONS = [
63
62
  type: 'function',
64
63
  function: {
65
64
  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.',
65
+ 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.',
69
66
  parameters: {
70
67
  type: 'object',
71
68
  properties: {
@@ -102,6 +99,73 @@ const TOOL_DEFINITIONS = [
102
99
  },
103
100
  },
104
101
  },
102
+ {
103
+ type: 'function',
104
+ function: {
105
+ name: 'get_mqtt_topic',
106
+ description:
107
+ 'Get the current value and timestamps of a specific MQTT topic from the she state store. ' + 'Use this when you need the exact current state of a known topic.',
108
+ parameters: {
109
+ type: 'object',
110
+ properties: {
111
+ topic: {
112
+ type: 'string',
113
+ description: 'The exact MQTT topic path, e.g. "home/livingroom/light/state".',
114
+ },
115
+ },
116
+ required: ['topic'],
117
+ },
118
+ },
119
+ },
120
+ {
121
+ type: 'function',
122
+ function: {
123
+ name: 'list_shedb_docs',
124
+ description: 'List document IDs in the sheDB document store. ' + 'Use this to discover what documents exist before fetching their content.',
125
+ parameters: {
126
+ type: 'object',
127
+ properties: {
128
+ filter: {
129
+ type: 'string',
130
+ description: 'Optional case-insensitive substring to filter IDs. Pass empty string to list all (capped at 200).',
131
+ },
132
+ },
133
+ required: [],
134
+ },
135
+ },
136
+ },
137
+ {
138
+ type: 'function',
139
+ function: {
140
+ name: 'get_shedb_doc',
141
+ description: 'Retrieve a specific document from the sheDB document store by its ID. ' + 'Use list_shedb_docs first to discover valid IDs.',
142
+ parameters: {
143
+ type: 'object',
144
+ properties: {
145
+ id: {
146
+ type: 'string',
147
+ description: 'The exact document ID to retrieve.',
148
+ },
149
+ },
150
+ required: ['id'],
151
+ },
152
+ },
153
+ },
154
+ {
155
+ type: 'function',
156
+ function: {
157
+ name: 'list_matter_devices',
158
+ description:
159
+ 'List all paired Matter devices with their online status, endpoints and available clusters. ' +
160
+ 'Use this whenever the user asks about a Matter device or smart home hardware. ' +
161
+ 'Use node and endpoint friendly names in matter commands.',
162
+ parameters: {
163
+ type: 'object',
164
+ properties: {},
165
+ required: [],
166
+ },
167
+ },
168
+ },
105
169
  ];
106
170
 
107
171
  /** Same definitions in Anthropic tool format. */
@@ -125,11 +189,24 @@ const TOOL_DEFINITIONS_ANTHROPIC = TOOL_DEFINITIONS.map((t) => ({
125
189
  async function executeTool(name, args, ctx) {
126
190
  try {
127
191
  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}`;
192
+ case 'search_mqtt_topics':
193
+ return toolSearchMqttTopics(args, ctx.store);
194
+ case 'get_mqtt_topic':
195
+ return toolGetMqttTopic(args, ctx.store);
196
+ case 'read_script':
197
+ return toolReadScript(args, ctx.scriptDir);
198
+ case 'get_script_logs':
199
+ return toolGetScriptLogs(args);
200
+ case 'she_fetch':
201
+ return await toolSheFetch(args);
202
+ case 'list_shedb_docs':
203
+ return toolListShedbDocs(args);
204
+ case 'get_shedb_doc':
205
+ return toolGetShedbDoc(args);
206
+ case 'list_matter_devices':
207
+ return toolListMatterDevices();
208
+ default:
209
+ return `Unknown tool: ${name}`;
133
210
  }
134
211
  } catch (e) {
135
212
  return `Tool error (${name}): ${e.message}`;
@@ -202,9 +279,79 @@ async function toolSheFetch({ url }) {
202
279
  const ct = res.headers.get('content-type') || '';
203
280
  const text = await res.text();
204
281
  // Strip HTML tags for cleaner text
205
- const plain = ct.includes('html') ? text.replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim() : text;
282
+ const plain = ct.includes('html')
283
+ ? text
284
+ .replace(/<[^>]+>/g, ' ')
285
+ .replace(/\s+/g, ' ')
286
+ .trim()
287
+ : text;
206
288
  const truncated = plain.length > MAX_FETCH_CHARS ? plain.slice(0, MAX_FETCH_CHARS) + `\n… (truncated, ${plain.length} chars total)` : plain;
207
289
  return `Content of ${url}:\n\n${truncated}`;
208
290
  }
209
291
 
292
+ function toolGetMqttTopic({ topic }, store) {
293
+ if (!store) return 'MQTT state store not available.';
294
+ if (!topic || typeof topic !== 'string') return 'topic argument is required.';
295
+ const obj = store.getObject('mqtt::' + topic);
296
+ if (!obj) return `Topic "${topic}" not found in state store. Use search_mqtt_topics to discover topics.`;
297
+ const ts = new Date(obj.ts).toISOString();
298
+ const lc = new Date(obj.lc ?? obj.ts).toISOString();
299
+ return `${topic}: ${JSON.stringify(obj.val)}\n last updated: ${ts}\n last changed: ${lc}`;
300
+ }
301
+
302
+ function toolListShedbDocs({ filter = '' }) {
303
+ try {
304
+ const core = require('./shedb').getCore();
305
+ if (!core) return 'sheDB not initialised.';
306
+ const ids = Object.keys(core.docs).sort();
307
+ const q = String(filter).toLowerCase();
308
+ const filtered = q ? ids.filter((id) => id.toLowerCase().includes(q)) : ids;
309
+ if (filtered.length === 0) return q ? `No documents found matching "${filter}".` : 'No documents in sheDB.';
310
+ const shown = filtered.slice(0, 200);
311
+ const suffix = filtered.length > 200 ? ` (capped at 200 of ${filtered.length} total)` : '';
312
+ return `${shown.length} document(s)${suffix}:\n${shown.join('\n')}`;
313
+ } catch (e) {
314
+ return `sheDB not available: ${e.message}`;
315
+ }
316
+ }
317
+
318
+ function toolGetShedbDoc({ id }) {
319
+ if (!id || typeof id !== 'string') return 'id argument is required.';
320
+ try {
321
+ const core = require('./shedb').getCore();
322
+ if (!core) return 'sheDB not initialised.';
323
+ const doc = core.docs[id];
324
+ if (doc === undefined) return `Document "${id}" not found. Use list_shedb_docs to see available IDs.`;
325
+ return `## ${id}\n${JSON.stringify(doc, null, 2)}`;
326
+ } catch (e) {
327
+ return `sheDB not available: ${e.message}`;
328
+ }
329
+ }
330
+
331
+ function toolListMatterDevices() {
332
+ try {
333
+ const controller = require('../matter/controller');
334
+ if (typeof controller.listPaired !== 'function') return 'Matter controller not available.';
335
+ const nodes = controller.listPaired();
336
+ if (nodes.length === 0) return 'No Matter devices paired.';
337
+ const lines = [`${nodes.length} paired Matter device(s):`];
338
+ for (const n of nodes) {
339
+ lines.push(`\n### ${n.name || 'Unnamed'} (nodeId: "${n.nodeId}", ${n.online ? 'online' : 'offline'})`);
340
+ try {
341
+ const endpoints = controller.getEndpoints(n.nodeId);
342
+ for (const ep of endpoints) {
343
+ if (ep.endpointId === 0) continue; // skip root endpoint
344
+ const name = ep.name || String(ep.endpointId);
345
+ lines.push(`- endpoint "${name}" (id: ${ep.endpointId}): ${ep.clusters.join(', ')}`);
346
+ }
347
+ } catch {
348
+ /* node may be offline */
349
+ }
350
+ }
351
+ return lines.join('\n');
352
+ } catch (e) {
353
+ return `Matter controller not available: ${e.message}`;
354
+ }
355
+ }
356
+
210
357
  module.exports = { TOOL_DEFINITIONS, TOOL_DEFINITIONS_ANTHROPIC, executeTool };
@@ -56,7 +56,33 @@ function isValidVersion(v) {
56
56
  router.get('/', (req, res) => {
57
57
  const pkg = readPackageJson();
58
58
  const deps = pkg.dependencies || {};
59
- res.json(Object.entries(deps).map(([name, version]) => ({ name, version })));
59
+ res.json(
60
+ Object.entries(deps).map(([name, version]) => {
61
+ let url = `https://www.npmjs.com/package/${encodeURIComponent(name)}`;
62
+ try {
63
+ const meta = JSON.parse(fs.readFileSync(path.join(STORAGE_ROOT, 'node_modules', name, 'package.json'), 'utf8'));
64
+ if (meta.homepage && /^https?:\/\//.test(meta.homepage)) {
65
+ url = meta.homepage;
66
+ } else {
67
+ let repo = typeof meta.repository === 'object' ? meta.repository.url : meta.repository;
68
+ if (typeof repo === 'string' && repo) {
69
+ repo = repo
70
+ .replace(/^git\+/, '')
71
+ .replace(/\.git$/, '')
72
+ .replace(/^git:\/\//, 'https://');
73
+ if (/^https?:\/\//.test(repo)) url = repo;
74
+ else {
75
+ const m = repo.match(/^(?:github:|github\.com[:/])?([\w.-]+\/[\w.-]+)$/);
76
+ if (m) url = `https://github.com/${m[1]}`;
77
+ }
78
+ }
79
+ }
80
+ } catch {
81
+ /* not installed or no metadata */
82
+ }
83
+ return { name, version, url };
84
+ }),
85
+ );
60
86
  });
61
87
 
62
88
  // GET /she/deps/search?q=term — search the npm registry
@@ -77,6 +103,11 @@ router.get('/search', (req, res) => {
77
103
  name: obj.package.name,
78
104
  version: obj.package.version,
79
105
  description: obj.package.description ?? '',
106
+ url:
107
+ obj.package.links?.repository ||
108
+ obj.package.links?.homepage ||
109
+ obj.package.links?.npm ||
110
+ `https://www.npmjs.com/package/${encodeURIComponent(obj.package.name)}`,
80
111
  }));
81
112
  res.json(results);
82
113
  } catch {
@@ -66,8 +66,8 @@ router.get('/devices/:nodeId', (req, res) => {
66
66
  const ctrl = getController();
67
67
  const endpoints = ctrl.getEndpoints(req.params.nodeId);
68
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;
69
+ const rootEp = endpoints.find((ep) => ep.endpointId === 0);
70
+ const name = rootEp?.name ?? endpoints.find((ep) => ep.name)?.name ?? null;
71
71
  const subtitle = ctrl.getDeviceSubtitle(req.params.nodeId);
72
72
  res.json({ nodeId: req.params.nodeId, endpoints, name, subtitle });
73
73
  } catch (err) {
@@ -16,3 +16,10 @@ Use a short kebab-case filename. Do NOT put the hint outside or before the code
16
16
  ### MQTT publishing rules
17
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
18
  Only use `she.mqtt.set()` or `retain: true` for storing persistent state or configuration values that must survive restarts, not for commands.
19
+
20
+ ### Tool usage
21
+ When the user asks about a specific MQTT topic, its current value or state — use `search_mqtt_topics` to discover topics or `get_mqtt_topic` for a direct lookup. Never invent topic names.
22
+ When the user asks about a Matter device or smart home hardware — call `list_matter_devices` to retrieve the actual device list, endpoints and clusters.
23
+ When the user asks about a sheDB document — call `list_shedb_docs` to find the ID, then `get_shedb_doc` to read it.
24
+ Always look up real data before writing scripts that reference specific topics, documents or devices.
25
+ If `search_mqtt_topics` returns several plausible matches and it is unclear which one the user means, ask the user to confirm the correct topic before writing the script — never guess.
@@ -53,6 +53,9 @@ she.db.query(filter, mapFn, [reduceFn]) Synchronous ad-hoc query → Array
53
53
  ```
54
54
 
55
55
  ### Matter
56
+
57
+ Use names for nodeId, endpointId and cluster, not numbers
58
+
56
59
  ```
57
60
  she.matter.sub(nodeId, endpointId, cluster, attr, cb) Subscribe to attribute
58
61
  she.matter.unsub(listenerId)
@@ -70,4 +73,18 @@ she.age(topic) Alias for she.mqtt.age
70
73
  she.now() Current timestamp in ms
71
74
  she.debug / .info / .warn / .error Structured logging (prefixed with script name)
72
75
  she.global Shared mutable object across all scripts
76
+ she.fetch(url, [opts]) HTTP/HTTPS fetch → Promise<string|object>
77
+ Auto-parses JSON by Content-Type.
78
+ Throws on non-2xx status.
79
+ ```
80
+
81
+ ### Script HTTP API
82
+ Scripts can expose HTTP endpoints under `/api/<scriptName>/`.
83
+ ```
84
+ she.api.get(path, handler) GET /api/<script><path> → handler(req)
85
+ she.api.post(path, handler) POST /api/<script><path> → handler(req, body)
86
+ she.api.put(path, handler) PUT /api/<script><path> → handler(req, body)
87
+ she.api.delete(path, handler) DELETE /api/<script><path> → handler(req)
73
88
  ```
89
+ req = { params, query, headers }. Return value (or resolved Promise) is JSON-serialised.
90
+ Express path params supported: she.api.get('/items/:id', (req) => ...).
@@ -171,7 +171,6 @@ router.use((req, res) => {
171
171
  }
172
172
  const absNew = safePath(root, newName);
173
173
  if (!absNew) return res.status(400).json({ error: 'Invalid newPath' });
174
- if (!absNew.endsWith('.js')) return res.status(400).json({ error: 'Only .js files are allowed' });
175
174
  try {
176
175
  fs.mkdirSync(path.dirname(absNew), { recursive: true });
177
176
  fs.renameSync(abs, absNew);
package/src/web/server.js CHANGED
@@ -60,7 +60,9 @@ app.post('/she/restart', (req, res) => {
60
60
 
61
61
  // Runtime stats — script count + MQTT topic count
62
62
  let _getStats = null;
63
- function setStatsProvider(fn) { _getStats = fn; }
63
+ function setStatsProvider(fn) {
64
+ _getStats = fn;
65
+ }
64
66
  app.get('/she/status', (req, res) => {
65
67
  res.json(_getStats ? _getStats() : { scripts: 0, topics: 0 });
66
68
  });
@@ -121,7 +121,7 @@ router.use('/views', (req, res) => {
121
121
  map,
122
122
  reduce: reduce || undefined,
123
123
  ...(mqttpub ? { mqttpub: true } : {}),
124
- ...(retain ? { retain: true } : {}),
124
+ ...(retain ? { retain: true } : {}),
125
125
  };
126
126
  core.query(id, payload);
127
127
  return res.json({ ok: true });
package/src/web/shedb.js CHANGED
@@ -40,7 +40,7 @@ function init({ dbPath, dbPublish, dbRetain, dbPrefix, mqttName, mqtt, log, broa
40
40
  _mqttName = mqttName;
41
41
  _dbPublish = dbPublish;
42
42
  _dbRetain = dbRetain;
43
- _dbPrefix = (dbPrefix && dbPrefix.endsWith('/')) ? dbPrefix : (dbPrefix || 'she/db/') + '/';
43
+ _dbPrefix = dbPrefix && dbPrefix.endsWith('/') ? dbPrefix : (dbPrefix || 'she/db/') + '/';
44
44
  _broadcast = broadcast;
45
45
 
46
46
  _core = new SheDBCore({ dbPath, log });
@@ -78,11 +78,7 @@ function init({ dbPath, dbPublish, dbRetain, dbPrefix, mqttName, mqtt, log, broa
78
78
  // Per-view MQTT publish (independent of global dbPublish setting)
79
79
  const query = _core.queries[id];
80
80
  if (query && query.mqttpub && _mqtt && view && !view.error) {
81
- _mqtt.publish(
82
- _dbPrefix + 'view/' + id,
83
- JSON.stringify(view.result ?? []),
84
- { retain: query.retain === true }
85
- );
81
+ _mqtt.publish(_dbPrefix + 'view/' + id, JSON.stringify(view.result ?? []), { retain: query.retain === true });
86
82
  }
87
83
  });
88
84