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/README.md +14 -10
- package/dist/web/assets/index-Cqfuxa_i.js +220 -0
- package/dist/web/assets/index-DcqBg4oJ.css +1 -0
- package/dist/web/assets/{monaco-langs-DZ6hB11b.js → monaco-langs-Decdf6BV.js} +1 -1
- package/dist/web/assets/{tsMode-BcZhguVQ.js → tsMode-B7q_C6Fy.js} +1 -1
- package/dist/web/index.html +3 -3
- package/package.json +1 -1
- package/src/index.js +94 -76
- package/src/matter/controller.js +107 -17
- package/src/web/ai-api.js +65 -26
- package/src/web/ai-context.js +15 -38
- package/src/web/ai-tools.js +160 -13
- package/src/web/deps-api.js +32 -1
- package/src/web/matter-api.js +2 -2
- package/src/web/prompts/scripts-base.md +7 -0
- package/src/web/prompts/she-api-ref.md +17 -0
- package/src/web/scripts-api.js +0 -1
- package/src/web/server.js +3 -1
- package/src/web/shedb-api.js +1 -1
- package/src/web/shedb.js +2 -6
- package/dist/web/assets/index-BcOZhXqD.css +0 -1
- package/dist/web/assets/index-oMmhHXuR.js +0 -220
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?.
|
|
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
|
-
//
|
|
180
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 {
|
|
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 || [])
|
|
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 || [])
|
|
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 =
|
|
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' ?
|
|
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 =
|
|
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 =
|
|
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 {
|
package/src/web/ai-context.js
CHANGED
|
@@ -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
|
|
19
|
-
const DB_VIEW_PROMPT
|
|
20
|
-
const DB_DOC_PROMPT
|
|
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 = !!
|
|
35
|
-
const isDocMode
|
|
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
|
|
58
|
-
const mapBody
|
|
57
|
+
const filterStr = (currentView.filter || '').trim();
|
|
58
|
+
const mapBody = (currentView.map || '').trim();
|
|
59
59
|
const reduceBody = (currentView.reduce || '').trim();
|
|
60
|
-
const viewLines
|
|
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 (
|
|
122
|
-
|
|
123
|
-
const
|
|
124
|
-
|
|
125
|
-
|
|
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
|
|
package/src/web/ai-tools.js
CHANGED
|
@@ -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':
|
|
129
|
-
|
|
130
|
-
case '
|
|
131
|
-
|
|
132
|
-
|
|
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')
|
|
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 };
|
package/src/web/deps-api.js
CHANGED
|
@@ -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(
|
|
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 {
|
package/src/web/matter-api.js
CHANGED
|
@@ -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) => ...).
|
package/src/web/scripts-api.js
CHANGED
|
@@ -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) {
|
|
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
|
});
|
package/src/web/shedb-api.js
CHANGED
|
@@ -121,7 +121,7 @@ router.use('/views', (req, res) => {
|
|
|
121
121
|
map,
|
|
122
122
|
reduce: reduce || undefined,
|
|
123
123
|
...(mqttpub ? { mqttpub: true } : {}),
|
|
124
|
-
...(retain
|
|
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 =
|
|
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
|
|