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.
@@ -15,6 +15,11 @@
15
15
  * she.matter.send(nodeId, endpointId, clusterName, command, args?)
16
16
  * → Promise<result>
17
17
  *
18
+ * nodeId — decimal NodeId string (e.g. '4') OR device name (e.g. 'Matterbridge')
19
+ * endpointId — numeric endpoint id (e.g. 43) OR endpoint name (e.g. 'Licht Werkstatt')
20
+ * Name matching uses basicInformation.nodeLabel / productName for devices and
21
+ * bridgedDeviceBasicInformation.nodeLabel / basicInformation.nodeLabel for endpoints.
22
+ *
18
23
  * All subscriptions registered by a script are automatically cancelled on
19
24
  * hot-reload (cleanup() is called from unloadScript() in index.js).
20
25
  */
@@ -26,11 +31,11 @@ module.exports = function (she, { scriptDomain, scriptName }) {
26
31
  /**
27
32
  * Subscribe to an attribute change on a paired Matter device.
28
33
  *
29
- * @param {string} nodeId Decimal NodeId string
30
- * @param {number} endpointId
31
- * @param {string} clusterName camelCase cluster name, e.g. "onOff"
32
- * @param {string} attrName camelCase attribute name, e.g. "onOff"
33
- * @param {Function} callback (value, oldValue) => void
34
+ * @param {string|number} nodeId Decimal NodeId string/number OR device name
35
+ * @param {number|string} endpointId Numeric endpoint id OR endpoint name
36
+ * @param {string} clusterName camelCase cluster name, e.g. "onOff"
37
+ * @param {string} attrName camelCase attribute name, e.g. "onOff"
38
+ * @param {Function} callback (value, oldValue) => void
34
39
  * @returns {number} listenerId
35
40
  */
36
41
  sub(nodeId, endpointId, clusterName, attrName, callback) {
@@ -53,10 +58,10 @@ module.exports = function (she, { scriptDomain, scriptName }) {
53
58
  /**
54
59
  * Read a single attribute value from a paired Matter device.
55
60
  *
56
- * @param {string} nodeId
57
- * @param {number} endpointId
58
- * @param {string} clusterName camelCase cluster name
59
- * @param {string} attrName camelCase attribute name
61
+ * @param {string|number} nodeId Decimal NodeId string/number OR device name
62
+ * @param {number|string} endpointId Numeric endpoint id OR endpoint name
63
+ * @param {string} clusterName camelCase cluster name
64
+ * @param {string} attrName camelCase attribute name
60
65
  * @returns {Promise<unknown>}
61
66
  */
62
67
  get(nodeId, endpointId, clusterName, attrName) {
@@ -66,15 +71,15 @@ module.exports = function (she, { scriptDomain, scriptName }) {
66
71
  /**
67
72
  * Invoke a cluster command on a paired Matter device.
68
73
  *
69
- * @param {string} nodeId
70
- * @param {number} endpointId
71
- * @param {string} clusterName camelCase cluster name
72
- * @param {string} command camelCase command name
73
- * @param {object} [args={}]
74
+ * @param {string|number} nodeId Decimal NodeId string/number OR device name
75
+ * @param {number|string} endpointId Numeric endpoint id OR endpoint name
76
+ * @param {string} clusterName camelCase cluster name
77
+ * @param {string} command camelCase command name
78
+ * @param {object} [args] Command arguments (omit for void commands)
74
79
  * @returns {Promise<unknown>}
75
80
  */
76
81
  send(nodeId, endpointId, clusterName, command, args) {
77
- return controller.sendCommand(nodeId, endpointId, clusterName, command, args ?? {});
82
+ return controller.sendCommand(nodeId, endpointId, clusterName, command, args);
78
83
  },
79
84
  };
80
85
  };
@@ -129,4 +129,21 @@ module.exports = function (she) {
129
129
  /** Register a callback for MQTT connection lifecycle events ('connect' or 'disconnect'). */
130
130
  on: (event, cb) => she._registerMqttEvent(event, cb),
131
131
  };
132
+
133
+ /**
134
+ * Fetch a URL and return a Promise that resolves to the response body.
135
+ * Resolves to parsed JSON when the Content-Type is application/json, plain text otherwise.
136
+ * Rejects on non-2xx responses.
137
+ * @method fetch
138
+ * @param {string} url
139
+ * @param {RequestInit} [options]
140
+ * @returns {Promise<string|object>}
141
+ */
142
+ she.fetch = function Sandbox_fetch(url, options) {
143
+ return fetch(url, options).then((r) => {
144
+ if (!r.ok) throw new Error(`HTTP ${r.status} ${r.statusText}`);
145
+ const ct = r.headers.get('content-type') || '';
146
+ return ct.includes('json') ? r.json() : r.text();
147
+ });
148
+ };
132
149
  };
package/src/web/ai-api.js CHANGED
@@ -19,6 +19,9 @@
19
19
  const express = require('express');
20
20
  const fs = require('fs');
21
21
 
22
+ const { buildSystemPrompt } = require('./ai-context');
23
+ const { TOOL_DEFINITIONS, TOOL_DEFINITIONS_ANTHROPIC, executeTool } = require('./ai-tools');
24
+
22
25
  const router = express.Router();
23
26
  let _store = null;
24
27
 
@@ -29,71 +32,6 @@ function init(store) {
29
32
  _store = store;
30
33
  }
31
34
 
32
- // ---------------------------------------------------------------------------
33
- // she API reference — injected into the system prompt when requested
34
- // ---------------------------------------------------------------------------
35
- const SHE_API_REF = `## she sandbox API
36
-
37
- Scripts run in a sandboxed VM. The \`she\` object is injected automatically.
38
-
39
- ### Script conventions
40
- - First lines: /* global she */ then 'use strict';
41
- - No require() — the module system is not available
42
- - All subscriptions and schedules persist across reconnects
43
-
44
- ### MQTT
45
- she.mqtt.sub(topic, [opts], cb) Subscribe; wildcards: + (1 level) # (multi)
46
- +//sensor → +/status/sensor shorthand
47
- opts.change: true = only fire when value changes
48
- she.mqtt.pub(topic, payload, [opts]) Publish; opts: { qos, retain }
49
- she.mqtt.get(topic) Current retained value (sync)
50
- she.mqtt.set(topic, val) Publish as retained
51
- she.mqtt.link(src, target, [fn]) Forward src changes to target; optional transform
52
- she.mqtt.age(topic) Seconds since topic last received a message
53
- she.mqtt.on('connect'|'disconnect', cb) MQTT lifecycle events
54
-
55
- ### Scheduling
56
- she.schedule(pattern, [opts], cb)
57
- pattern: cron string | Date | suncalc event name
58
- suncalc events: 'sunrise' 'sunset' 'dawn' 'dusk'
59
- 'nauticalDawn' 'nauticalDusk' 'solarNoon' 'night'
60
- opts.shift: seconds offset (e.g. -1800 = 30 min before event)
61
- opts.random: max random delay in seconds added to the trigger time
62
-
63
- ### Universal key-value API
64
- she.on(key, cb) Subscribe. Key prefixes: mqtt:: var:: matter::
65
- she.set(key, val) Set value (mqtt:: or var:: namespaces)
66
- she.get(key) Current value
67
- she.getObject(key) Current { val, ts, lc } state object
68
-
69
- ### Variable system (var:: namespace)
70
- Topics prefixed with "var" (default) are persisted as retained MQTT messages
71
- and available across scripts via she.get('var::name') / she.set('var::name', v).
72
-
73
- ### sheDB
74
- she.db.get(id) Get document (undefined if not found)
75
- she.db.set(id, doc) Create or overwrite document
76
- she.db.extend(id, partial) Deep-merge partial into existing document
77
- she.db.delete(id) Delete document
78
- she.db.sub(pattern, cb) Subscribe to document changes (MQTT wildcard)
79
- she.db.query(filter, mapFn, [reduceFn]) Synchronous ad-hoc query → Array
80
-
81
- ### Matter
82
- she.matter.sub(nodeId, endpointId, cluster, attr, cb) Subscribe to attribute
83
- she.matter.unsub(listenerId)
84
- she.matter.get(nodeId, endpointId, cluster, attr) → Promise<value>
85
- she.matter.send(nodeId, endpointId, cluster, cmd, [args]) → Promise<result>
86
-
87
- ### Helpers
88
- she.timer(src, target, ms) Pulse target=1 for ms after src goes truthy
89
- she.combineBool(srcs[], target) Publish OR of source values to target
90
- she.combineMax(srcs[], target) Publish maximum of source values to target
91
- she.link(src, target, [fn]) Alias for she.mqtt.link
92
- she.age(topic) Alias for she.mqtt.age
93
- she.now() Current timestamp in ms
94
- she.debug / .info / .warn / .error Structured logging (prefixed with script name)
95
- she.global Shared mutable object across all scripts`;
96
-
97
35
  // ---------------------------------------------------------------------------
98
36
  // Helpers
99
37
  // ---------------------------------------------------------------------------
@@ -114,84 +52,6 @@ function readAiConfig(configPath) {
114
52
  }
115
53
  }
116
54
 
117
- /**
118
- * Build the full system prompt, including optional context sections.
119
- *
120
- * @param {object} requestCtx { apiref, mqtt, shedb, matter }
121
- * @param {{ path?: string, content?: string }|null} currentScript
122
- * @param {import('../lib/state-store')|null} store
123
- * @returns {string}
124
- */
125
- function buildSystemPrompt(requestCtx, currentScript, store) {
126
- const parts = [
127
- `You are SHE Assistant, an expert AI pair programmer for she (smart-home-engine).
128
- she is a Node.js daemon that runs user JavaScript scripts in a sandboxed VM for home automation.
129
- 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.
130
- Keep any existing header comments and the 'use strict'; directive.
131
- 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:
132
- \`\`\`javascript
133
- // @new-file: descriptive-name.js
134
- /* global she */
135
- 'use strict';
136
- // ... rest of script
137
- \`\`\`
138
- 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.`,
139
- ];
140
-
141
- if (requestCtx.apiref) {
142
- parts.push(SHE_API_REF);
143
- }
144
-
145
- if (currentScript?.path && typeof currentScript.content === 'string') {
146
- parts.push(`## Current script: ${currentScript.path}\n\`\`\`javascript\n${currentScript.content}\n\`\`\``);
147
- }
148
-
149
- if (requestCtx.mqtt && store) {
150
- const topics = [];
151
- for (const [topic, obj] of store.mqttEntries()) {
152
- topics.push(`${topic}: ${JSON.stringify(obj.val)}`);
153
- if (topics.length >= 100) {
154
- topics.push('… (truncated)');
155
- break;
156
- }
157
- }
158
- if (topics.length > 0) {
159
- parts.push(`## Current MQTT state\n${topics.join('\n')}`);
160
- }
161
- }
162
-
163
- if (requestCtx.shedb) {
164
- try {
165
- const core = require('./shedb').getCore();
166
- if (core) {
167
- const ids = typeof core.listIds === 'function' ? core.listIds() : [];
168
- if (ids.length > 0) {
169
- parts.push(`## sheDB document IDs (${ids.length} total)\n${ids.slice(0, 200).join('\n')}`);
170
- }
171
- }
172
- } catch {
173
- // shedb not initialised — skip silently
174
- }
175
- }
176
-
177
- if (requestCtx.matter) {
178
- try {
179
- const controller = require('../matter/controller');
180
- if (typeof controller.listPaired === 'function') {
181
- const nodes = controller.listPaired();
182
- if (nodes.length > 0) {
183
- const list = nodes.map((n) => ` nodeId ${n.nodeId}: ${n.label || 'unnamed'}`).join('\n');
184
- parts.push(`## Paired Matter devices\n${list}`);
185
- }
186
- }
187
- } catch {
188
- // matter not initialised — skip silently
189
- }
190
- }
191
-
192
- return parts.join('\n\n');
193
- }
194
-
195
55
  // ---------------------------------------------------------------------------
196
56
  // Provider adapters — non-streaming
197
57
  // ---------------------------------------------------------------------------
@@ -199,17 +59,22 @@ Use a short kebab-case filename. Do NOT put the hint outside or before the code
199
59
  /**
200
60
  * @param {{ baseUrl?: string, model: string, apiKey?: string }} config
201
61
  * @param {Array<{role:string,content:string}>} messages
62
+ * @param {Array|undefined} [tools] — OpenAI tool definitions; omit to disable tool calling
63
+ * @returns {{ message?: string, usage?: object, toolCalls?: Array, assistantMsg?: object }}
202
64
  */
203
- async function callOpenAICompat(config, messages) {
65
+ async function callOpenAICompat(config, messages, tools) {
204
66
  const base = (config.baseUrl || 'http://localhost:11434').replace(/\/$/, '');
205
67
  const url = `${base}/v1/chat/completions`;
206
68
  const headers = { 'Content-Type': 'application/json' };
207
69
  if (config.apiKey) headers['Authorization'] = `Bearer ${config.apiKey}`;
208
70
 
71
+ const body = { model: config.model, messages, stream: false };
72
+ if (tools?.length) body.tools = tools;
73
+
209
74
  const res = await fetch(url, {
210
75
  method: 'POST',
211
76
  headers,
212
- body: JSON.stringify({ model: config.model, messages, stream: false }),
77
+ body: JSON.stringify(body),
213
78
  });
214
79
 
215
80
  if (!res.ok) {
@@ -219,21 +84,29 @@ async function callOpenAICompat(config, messages) {
219
84
 
220
85
  const json = await res.json();
221
86
  const choice = json.choices?.[0];
222
- const message = choice?.message?.content ?? choice?.text ?? '';
223
87
  const usage = json.usage
224
88
  ? {
225
89
  prompt_tokens: json.usage.prompt_tokens,
226
90
  completion_tokens: json.usage.completion_tokens,
227
91
  }
228
92
  : undefined;
93
+
94
+ // Detect tool call response
95
+ if (choice?.finish_reason === 'tool_calls' && choice.message?.tool_calls?.length) {
96
+ return { toolCalls: choice.message.tool_calls, assistantMsg: choice.message, usage };
97
+ }
98
+
99
+ const message = choice?.message?.content ?? choice?.text ?? '';
229
100
  return { message, usage };
230
101
  }
231
102
 
232
103
  /**
233
104
  * @param {{ model: string, apiKey?: string }} config
234
105
  * @param {Array<{role:string,content:string}>} messages — first may be role:'system'
106
+ * @param {Array|undefined} [tools] — Anthropic tool definitions; omit to disable tool calling
107
+ * @returns {{ message?: string, usage?: object, toolCalls?: Array, assistantMsg?: Array }}
235
108
  */
236
- async function callAnthropic(config, messages) {
109
+ async function callAnthropic(config, messages, tools) {
237
110
  const systemMsg = messages.find((m) => m.role === 'system');
238
111
  const userMessages = messages.filter((m) => m.role !== 'system');
239
112
 
@@ -243,15 +116,18 @@ async function callAnthropic(config, messages) {
243
116
  'anthropic-version': '2023-06-01',
244
117
  };
245
118
 
119
+ const body = {
120
+ model: config.model,
121
+ system: systemMsg?.content || '',
122
+ messages: userMessages,
123
+ max_tokens: 4096,
124
+ };
125
+ if (tools?.length) body.tools = tools;
126
+
246
127
  const res = await fetch('https://api.anthropic.com/v1/messages', {
247
128
  method: 'POST',
248
129
  headers,
249
- body: JSON.stringify({
250
- model: config.model,
251
- system: systemMsg?.content || '',
252
- messages: userMessages,
253
- max_tokens: 4096,
254
- }),
130
+ body: JSON.stringify(body),
255
131
  });
256
132
 
257
133
  if (!res.ok) {
@@ -260,16 +136,103 @@ async function callAnthropic(config, messages) {
260
136
  }
261
137
 
262
138
  const json = await res.json();
263
- const message = json.content?.[0]?.text ?? '';
264
139
  const usage = json.usage
265
140
  ? {
266
141
  prompt_tokens: json.usage.input_tokens,
267
142
  completion_tokens: json.usage.output_tokens,
268
143
  }
269
144
  : undefined;
145
+
146
+ // Detect tool use response
147
+ if (json.stop_reason === 'tool_use') {
148
+ const toolCalls = (json.content || []).filter((b) => b.type === 'tool_use');
149
+ return { toolCalls, assistantMsg: json.content, usage };
150
+ }
151
+
152
+ const message = json.content?.[0]?.text ?? '';
270
153
  return { message, usage };
271
154
  }
272
155
 
156
+ // ---------------------------------------------------------------------------
157
+ // Tool-calling resolver
158
+ // ---------------------------------------------------------------------------
159
+
160
+ /**
161
+ * Run the tool-calling loop: call the LLM, execute any tool calls, repeat
162
+ * until the model produces a plain text answer (no more tool calls).
163
+ *
164
+ * Emits { type:'tool_call', name, args } and { type:'tool_result', name, content }
165
+ * events via onEvent (used for SSE feedback to the client).
166
+ *
167
+ * @param {{ provider: string, baseUrl?: string, model: string, apiKey?: string }} ai
168
+ * @param {Array} messages — initial message list (system prompt already included)
169
+ * @param {{ store: any, scriptDir: string|null }} toolContext
170
+ * @param {((event: object) => void)|undefined} onEvent
171
+ * @returns {Promise<{ message: string, usage?: object }>}
172
+ */
173
+ async function resolveAndGetAnswer(ai, messages, toolContext, onEvent) {
174
+ const isAnthropic = ai.provider === 'anthropic';
175
+ const tools = isAnthropic ? TOOL_DEFINITIONS_ANTHROPIC : TOOL_DEFINITIONS;
176
+ let msgs = messages;
177
+
178
+ 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;
181
+
182
+ let result;
183
+ try {
184
+ result = isAnthropic
185
+ ? await callAnthropic(ai, msgs, roundTools)
186
+ : await callOpenAICompat(ai, msgs, roundTools);
187
+ } catch (e) {
188
+ if (round === 0 && roundTools) {
189
+ // Model may not support tool calling — retry without tools
190
+ result = isAnthropic
191
+ ? await callAnthropic(ai, msgs)
192
+ : await callOpenAICompat(ai, msgs);
193
+ } else {
194
+ throw e;
195
+ }
196
+ }
197
+
198
+ // No tool calls → we have the final answer
199
+ if (!result.toolCalls?.length) {
200
+ return { message: result.message ?? '', usage: result.usage };
201
+ }
202
+
203
+ // Execute tool calls and append results to message history
204
+ if (isAnthropic) {
205
+ msgs = [...msgs, { role: 'assistant', content: result.assistantMsg }];
206
+ const toolResultBlocks = [];
207
+ for (const tc of result.toolCalls) {
208
+ const args = tc.input || {};
209
+ onEvent?.({ type: 'tool_call', name: tc.name, args });
210
+ const content = await executeTool(tc.name, args, toolContext);
211
+ onEvent?.({ type: 'tool_result', name: tc.name, content });
212
+ toolResultBlocks.push({ type: 'tool_result', tool_use_id: tc.id, content });
213
+ }
214
+ msgs = [...msgs, { role: 'user', content: toolResultBlocks }];
215
+ } else {
216
+ msgs = [...msgs, { ...result.assistantMsg, role: 'assistant' }];
217
+ for (const tc of result.toolCalls) {
218
+ const name = tc.function.name;
219
+ let args;
220
+ try { args = JSON.parse(tc.function.arguments || '{}'); } catch { args = {}; }
221
+ onEvent?.({ type: 'tool_call', name, args });
222
+ const content = await executeTool(name, args, toolContext);
223
+ onEvent?.({ type: 'tool_result', name, content });
224
+ msgs = [...msgs, { role: 'tool', tool_call_id: tc.id, content }];
225
+ }
226
+ }
227
+ }
228
+
229
+ // Fallback (should not normally be reached)
230
+ const fallback = isAnthropic
231
+ ? await callAnthropic(ai, msgs)
232
+ : await callOpenAICompat(ai, msgs);
233
+ return { message: fallback.message ?? '', usage: fallback.usage };
234
+ }
235
+
273
236
  // ---------------------------------------------------------------------------
274
237
  // Provider adapters — streaming
275
238
  // ---------------------------------------------------------------------------
@@ -448,16 +411,19 @@ router.post('/chat', async (req, res) => {
448
411
  return res.status(400).json({ error: 'AI provider not configured. Set ai.provider and ai.model in Config.' });
449
412
  }
450
413
 
451
- const { messages = [], currentScript, context = {}, modelOverride } = req.body || {};
414
+ const { messages = [], currentScript, currentView, currentDoc, context = {}, modelOverride } = req.body || {};
452
415
  if (!Array.isArray(messages)) return res.status(400).json({ error: 'messages must be an array' });
453
416
 
454
417
  const aiWithModel = (modelOverride && typeof modelOverride === 'string') ? { ...ai, model: modelOverride } : ai;
455
- const systemPrompt = buildSystemPrompt(context, currentScript ?? null, _store);
418
+ const systemPrompt = buildSystemPrompt(context, currentScript ?? null, currentView ?? null, currentDoc ?? null, _store);
456
419
  const fullMessages = [{ role: 'system', content: systemPrompt }, ...messages];
457
420
 
458
421
  try {
459
422
  let result;
460
- if (ai.provider === 'anthropic') {
423
+ if (context.tools) {
424
+ const toolContext = { store: _store, scriptDir: req.app.locals.scriptDir || null };
425
+ result = await resolveAndGetAnswer(aiWithModel, fullMessages, toolContext, undefined);
426
+ } else if (ai.provider === 'anthropic') {
461
427
  result = await callAnthropic(aiWithModel, fullMessages);
462
428
  } else {
463
429
  result = await callOpenAICompat(aiWithModel, fullMessages);
@@ -475,7 +441,7 @@ router.post('/chat/stream', async (req, res) => {
475
441
  return res.status(400).json({ error: 'AI provider not configured. Set ai.provider and ai.model in Config.' });
476
442
  }
477
443
 
478
- const { messages = [], currentScript, context = {}, modelOverride } = req.body || {};
444
+ const { messages = [], currentScript, currentView, currentDoc, context = {}, modelOverride } = req.body || {};
479
445
  if (!Array.isArray(messages)) return res.status(400).json({ error: 'messages must be an array' });
480
446
 
481
447
  const aiWithModel = (modelOverride && typeof modelOverride === 'string') ? { ...ai, model: modelOverride } : ai;
@@ -489,16 +455,23 @@ router.post('/chat/stream', async (req, res) => {
489
455
 
490
456
  const send = (data) => res.write(`data: ${JSON.stringify(data)}\n\n`);
491
457
 
492
- const systemPrompt = buildSystemPrompt(context, currentScript ?? null, _store);
458
+ const systemPrompt = buildSystemPrompt(context, currentScript ?? null, currentView ?? null, currentDoc ?? null, _store);
493
459
  const fullMessages = [{ role: 'system', content: systemPrompt }, ...messages];
494
460
 
495
461
  try {
496
- const onToken = (t) => send({ token: t });
497
-
498
- if (ai.provider === 'anthropic') {
499
- await streamAnthropic(aiWithModel, fullMessages, onToken);
462
+ if (context.tools) {
463
+ // Tool-calling mode: resolve tools non-streaming (emitting events), then
464
+ // send the final answer as a single token so the client sees it immediately.
465
+ const toolContext = { store: _store, scriptDir: req.app.locals.scriptDir || null };
466
+ const { message } = await resolveAndGetAnswer(aiWithModel, fullMessages, toolContext, send);
467
+ send({ token: message });
500
468
  } else {
501
- await streamOpenAICompat(aiWithModel, fullMessages, onToken);
469
+ const onToken = (t) => send({ token: t });
470
+ if (ai.provider === 'anthropic') {
471
+ await streamAnthropic(aiWithModel, fullMessages, onToken);
472
+ } else {
473
+ await streamOpenAICompat(aiWithModel, fullMessages, onToken);
474
+ }
502
475
  }
503
476
 
504
477
  res.write('data: [DONE]\n\n');
@@ -0,0 +1,139 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * AI context builder — assembles the full system prompt from per-request flags,
5
+ * the current editor context (script or DB view/document), and live data
6
+ * from the running daemon (MQTT state, sheDB, Matter devices).
7
+ *
8
+ * Kept separate from ai-api.js so prompt files and context logic can evolve
9
+ * independently of the HTTP routing / provider adapter code.
10
+ */
11
+
12
+ const fs = require('fs');
13
+ const path = require('path');
14
+
15
+ // Load prompt templates once at startup — plain Markdown files, no escaping needed
16
+ const P = path.join(__dirname, 'prompts');
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();
21
+
22
+ /**
23
+ * Build the full system prompt, including optional context sections.
24
+ *
25
+ * @param {object} requestCtx
26
+ * { apiref, mqtt, shedb, matter, sampleDocs }
27
+ * @param {{ path?: string, content?: string } | null} currentScript
28
+ * @param {{ id?: string, filter?: string, map?: string, reduce?: string } | null} currentView
29
+ * @param {{ id?: string, content?: string } | null} currentDoc
30
+ * @param {import('../lib/state-store') | null} store
31
+ * @returns {string}
32
+ */
33
+ function buildSystemPrompt(requestCtx, currentScript, currentView, currentDoc, store) {
34
+ const isViewMode = !!(currentView?.id);
35
+ const isDocMode = !!(currentDoc?.id);
36
+
37
+ let basePrompt;
38
+ if (isViewMode) {
39
+ basePrompt = DB_VIEW_PROMPT;
40
+ } else if (isDocMode) {
41
+ basePrompt = DB_DOC_PROMPT;
42
+ } else {
43
+ basePrompt = SCRIPTS_BASE_PROMPT;
44
+ }
45
+
46
+ const parts = [basePrompt];
47
+
48
+ if (requestCtx.apiref && !isViewMode && !isDocMode) {
49
+ parts.push(SHE_API_REF);
50
+ }
51
+
52
+ if (currentScript?.path && typeof currentScript.content === 'string') {
53
+ parts.push(`## Current script: ${currentScript.path}\n\`\`\`javascript\n${currentScript.content}\n\`\`\``);
54
+ }
55
+
56
+ if (currentView?.id) {
57
+ const filterStr = (currentView.filter || '').trim();
58
+ const mapBody = (currentView.map || '').trim();
59
+ const reduceBody = (currentView.reduce || '').trim();
60
+ const viewLines = [`## Current view: ${currentView.id}`];
61
+ viewLines.push(`Filter: ${filterStr || '(none)'}`);
62
+ viewLines.push(`Map:\n\`\`\`javascript\n${mapBody || '// (empty)'}\n\`\`\``);
63
+ if (reduceBody) {
64
+ viewLines.push(`Reduce:\n\`\`\`javascript\n${reduceBody}\n\`\`\``);
65
+ } else {
66
+ viewLines.push('Reduce: (none)');
67
+ }
68
+ parts.push(viewLines.join('\n'));
69
+ }
70
+
71
+ if (currentDoc?.id) {
72
+ const content = typeof currentDoc.content === 'string'
73
+ ? currentDoc.content
74
+ : JSON.stringify(currentDoc.content, null, 2);
75
+ parts.push(`## Current document: ${currentDoc.id}\n\`\`\`json\n${content}\n\`\`\``);
76
+ }
77
+
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
+ if (requestCtx.shedb) {
93
+ try {
94
+ const core = require('./shedb').getCore();
95
+ if (core) {
96
+ const ids = Object.keys(core.docs).sort();
97
+ if (ids.length > 0) {
98
+ parts.push(`## sheDB document IDs (${ids.length} total)\n${ids.slice(0, 200).join('\n')}`);
99
+ }
100
+ }
101
+ } catch {
102
+ // shedb not initialised — skip silently
103
+ }
104
+ }
105
+
106
+ if (requestCtx.sampleDocs) {
107
+ try {
108
+ const core = require('./shedb').getCore();
109
+ if (core) {
110
+ const ids = Object.keys(core.docs).sort().slice(0, 10);
111
+ if (ids.length > 0) {
112
+ const sample = ids.map((id) => `### ${id}\n${JSON.stringify(core.docs[id], null, 2)}`).join('\n\n');
113
+ parts.push(`## Sample sheDB documents (${ids.length} shown)\n${sample}`);
114
+ }
115
+ }
116
+ } catch {
117
+ // shedb not initialised — skip silently
118
+ }
119
+ }
120
+
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
133
+ }
134
+ }
135
+
136
+ return parts.join('\n\n');
137
+ }
138
+
139
+ module.exports = { buildSystemPrompt };