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.
- package/README.md +1 -1
- package/dist/web/assets/index-BcOZhXqD.css +1 -0
- package/dist/web/assets/index-oMmhHXuR.js +220 -0
- package/dist/web/assets/{tsMode-BU_qnlmu.js → tsMode-BcZhguVQ.js} +1 -1
- package/dist/web/index.html +5 -4
- package/package.json +85 -87
- package/src/config.js +4 -0
- package/src/index.js +50 -3
- package/src/matter/controller.js +161 -23
- package/src/sandbox/matter-sandbox.js +20 -15
- package/src/sandbox/stdlib.js +17 -0
- package/src/web/ai-api.js +137 -164
- package/src/web/ai-context.js +139 -0
- package/src/web/ai-tools.js +210 -0
- package/src/web/log-ws.js +16 -1
- package/src/web/matter-api.js +7 -2
- package/src/web/prompts/db-doc.md +17 -0
- package/src/web/prompts/db-view.md +30 -0
- package/src/web/prompts/scripts-base.md +18 -0
- package/src/web/prompts/she-api-ref.md +73 -0
- package/src/web/scripts-api.js +4 -8
- package/src/web/server.js +8 -1
- package/dist/web/assets/index-DZTaIKZS.css +0 -1
- package/dist/web/assets/index-G6QfHETZ.js +0 -212
|
@@ -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}
|
|
30
|
-
* @param {number}
|
|
31
|
-
* @param {string}
|
|
32
|
-
* @param {string}
|
|
33
|
-
* @param {Function}
|
|
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}
|
|
59
|
-
* @param {string}
|
|
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}
|
|
70
|
-
* @param {number}
|
|
71
|
-
* @param {string}
|
|
72
|
-
* @param {string}
|
|
73
|
-
* @param {object}
|
|
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
|
};
|
package/src/sandbox/stdlib.js
CHANGED
|
@@ -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(
|
|
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 (
|
|
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
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
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
|
-
|
|
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 };
|