smart-home-engine 0.13.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,142 +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, sampleDocs }
121
- * @param {{ path?: string, content?: string }|null} currentScript
122
- * @param {{ id?: string, filter?: string, map?: string, reduce?: string }|null} currentView
123
- * @param {import('../lib/state-store')|null} store
124
- * @returns {string}
125
- */
126
- function buildSystemPrompt(requestCtx, currentScript, currentView, store) {
127
- const isViewMode = !!(currentView?.id);
128
-
129
- const basePrompt = isViewMode
130
- ? `You are SHE Assistant, helping write sheDB MapReduce view definitions for she (smart-home-engine).
131
-
132
- A view has three optional parts:
133
- 1. **Filter** — an MQTT-style topic wildcard (e.g. \`devices/#\`) that selects which document IDs enter the view. Plain string, no code.
134
- 2. **Map** — a JavaScript function body. \`this\` is the current document. Call \`emit(value)\` to include a value in the result array. No \`return\`.
135
- 3. **Reduce** — a JavaScript function body that receives \`result\` (the array from map) and must \`return\` a transformed value.
136
-
137
- When proposing view parts, use these exact formats (include only the parts that change):
138
-
139
- \`\`\`filter
140
- devices/#
141
- \`\`\`
142
-
143
- \`\`\`javascript
144
- // @view-map
145
- if (this.temperature !== undefined) emit(this.temperature);
146
- \`\`\`
147
-
148
- \`\`\`javascript
149
- // @view-reduce
150
- return result.reduce((a, b) => a + b, 0) / result.length;
151
- \`\`\`
152
-
153
- Keep the \`// @view-map\` / \`// @view-reduce\` comment as the very first line of each block — the UI uses it to detect which field to fill in.`
154
- : `You are SHE Assistant, an expert AI pair programmer for she (smart-home-engine).
155
- she is a Node.js daemon that runs user JavaScript scripts in a sandboxed VM for home automation.
156
- 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.
157
- Keep any existing header comments and the 'use strict'; directive.
158
- 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:
159
- \`\`\`javascript
160
- // @new-file: descriptive-name.js
161
- /* global she */
162
- 'use strict';
163
- // ... rest of script
164
- \`\`\`
165
- 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.`;
166
-
167
- const parts = [basePrompt];
168
-
169
- if (requestCtx.apiref) {
170
- parts.push(SHE_API_REF);
171
- }
172
-
173
- if (currentScript?.path && typeof currentScript.content === 'string') {
174
- parts.push(`## Current script: ${currentScript.path}\n\`\`\`javascript\n${currentScript.content}\n\`\`\``);
175
- }
176
-
177
- if (currentView?.id) {
178
- const filterStr = (currentView.filter || '').trim();
179
- const mapBody = (currentView.map || '').trim();
180
- const reduceBody = (currentView.reduce || '').trim();
181
- const viewLines = [`## Current view: ${currentView.id}`];
182
- viewLines.push(`Filter: ${filterStr || '(none)'}`);
183
- viewLines.push(`Map:\n\`\`\`javascript\n${mapBody || '// (empty)'}\n\`\`\``);
184
- if (reduceBody) {
185
- viewLines.push(`Reduce:\n\`\`\`javascript\n${reduceBody}\n\`\`\``);
186
- } else {
187
- viewLines.push('Reduce: (none)');
188
- }
189
- parts.push(viewLines.join('\n'));
190
- }
191
-
192
- if (requestCtx.mqtt && store) {
193
- const topics = [];
194
- for (const [topic, obj] of store.mqttEntries()) {
195
- topics.push(`${topic}: ${JSON.stringify(obj.val)}`);
196
- if (topics.length >= 100) {
197
- topics.push('… (truncated)');
198
- break;
199
- }
200
- }
201
- if (topics.length > 0) {
202
- parts.push(`## Current MQTT state\n${topics.join('\n')}`);
203
- }
204
- }
205
-
206
- if (requestCtx.shedb) {
207
- try {
208
- const core = require('./shedb').getCore();
209
- if (core) {
210
- const ids = Object.keys(core.docs).sort();
211
- if (ids.length > 0) {
212
- parts.push(`## sheDB document IDs (${ids.length} total)\n${ids.slice(0, 200).join('\n')}`);
213
- }
214
- }
215
- } catch {
216
- // shedb not initialised — skip silently
217
- }
218
- }
219
-
220
- if (requestCtx.sampleDocs) {
221
- try {
222
- const core = require('./shedb').getCore();
223
- if (core) {
224
- const ids = Object.keys(core.docs).sort().slice(0, 10);
225
- if (ids.length > 0) {
226
- const sample = ids.map((id) => `### ${id}\n${JSON.stringify(core.docs[id], null, 2)}`).join('\n\n');
227
- parts.push(`## Sample sheDB documents (${ids.length} shown)\n${sample}`);
228
- }
229
- }
230
- } catch {
231
- // shedb not initialised — skip silently
232
- }
233
- }
234
-
235
- if (requestCtx.matter) {
236
- try {
237
- const controller = require('../matter/controller');
238
- if (typeof controller.listPaired === 'function') {
239
- const nodes = controller.listPaired();
240
- if (nodes.length > 0) {
241
- const list = nodes.map((n) => ` nodeId ${n.nodeId}: ${n.label || 'unnamed'}`).join('\n');
242
- parts.push(`## Paired Matter devices\n${list}`);
243
- }
244
- }
245
- } catch {
246
- // matter not initialised — skip silently
247
- }
248
- }
249
-
250
- return parts.join('\n\n');
251
- }
252
-
253
55
  // ---------------------------------------------------------------------------
254
56
  // Provider adapters — non-streaming
255
57
  // ---------------------------------------------------------------------------
@@ -257,17 +59,22 @@ Use a short kebab-case filename. Do NOT put the hint outside or before the code
257
59
  /**
258
60
  * @param {{ baseUrl?: string, model: string, apiKey?: string }} config
259
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 }}
260
64
  */
261
- async function callOpenAICompat(config, messages) {
65
+ async function callOpenAICompat(config, messages, tools) {
262
66
  const base = (config.baseUrl || 'http://localhost:11434').replace(/\/$/, '');
263
67
  const url = `${base}/v1/chat/completions`;
264
68
  const headers = { 'Content-Type': 'application/json' };
265
69
  if (config.apiKey) headers['Authorization'] = `Bearer ${config.apiKey}`;
266
70
 
71
+ const body = { model: config.model, messages, stream: false };
72
+ if (tools?.length) body.tools = tools;
73
+
267
74
  const res = await fetch(url, {
268
75
  method: 'POST',
269
76
  headers,
270
- body: JSON.stringify({ model: config.model, messages, stream: false }),
77
+ body: JSON.stringify(body),
271
78
  });
272
79
 
273
80
  if (!res.ok) {
@@ -277,21 +84,29 @@ async function callOpenAICompat(config, messages) {
277
84
 
278
85
  const json = await res.json();
279
86
  const choice = json.choices?.[0];
280
- const message = choice?.message?.content ?? choice?.text ?? '';
281
87
  const usage = json.usage
282
88
  ? {
283
89
  prompt_tokens: json.usage.prompt_tokens,
284
90
  completion_tokens: json.usage.completion_tokens,
285
91
  }
286
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 ?? '';
287
100
  return { message, usage };
288
101
  }
289
102
 
290
103
  /**
291
104
  * @param {{ model: string, apiKey?: string }} config
292
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 }}
293
108
  */
294
- async function callAnthropic(config, messages) {
109
+ async function callAnthropic(config, messages, tools) {
295
110
  const systemMsg = messages.find((m) => m.role === 'system');
296
111
  const userMessages = messages.filter((m) => m.role !== 'system');
297
112
 
@@ -301,15 +116,18 @@ async function callAnthropic(config, messages) {
301
116
  'anthropic-version': '2023-06-01',
302
117
  };
303
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
+
304
127
  const res = await fetch('https://api.anthropic.com/v1/messages', {
305
128
  method: 'POST',
306
129
  headers,
307
- body: JSON.stringify({
308
- model: config.model,
309
- system: systemMsg?.content || '',
310
- messages: userMessages,
311
- max_tokens: 4096,
312
- }),
130
+ body: JSON.stringify(body),
313
131
  });
314
132
 
315
133
  if (!res.ok) {
@@ -318,16 +136,103 @@ async function callAnthropic(config, messages) {
318
136
  }
319
137
 
320
138
  const json = await res.json();
321
- const message = json.content?.[0]?.text ?? '';
322
139
  const usage = json.usage
323
140
  ? {
324
141
  prompt_tokens: json.usage.input_tokens,
325
142
  completion_tokens: json.usage.output_tokens,
326
143
  }
327
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 ?? '';
328
153
  return { message, usage };
329
154
  }
330
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
+
331
236
  // ---------------------------------------------------------------------------
332
237
  // Provider adapters — streaming
333
238
  // ---------------------------------------------------------------------------
@@ -506,16 +411,19 @@ router.post('/chat', async (req, res) => {
506
411
  return res.status(400).json({ error: 'AI provider not configured. Set ai.provider and ai.model in Config.' });
507
412
  }
508
413
 
509
- const { messages = [], currentScript, currentView, context = {}, modelOverride } = req.body || {};
414
+ const { messages = [], currentScript, currentView, currentDoc, context = {}, modelOverride } = req.body || {};
510
415
  if (!Array.isArray(messages)) return res.status(400).json({ error: 'messages must be an array' });
511
416
 
512
417
  const aiWithModel = (modelOverride && typeof modelOverride === 'string') ? { ...ai, model: modelOverride } : ai;
513
- const systemPrompt = buildSystemPrompt(context, currentScript ?? null, currentView ?? null, _store);
418
+ const systemPrompt = buildSystemPrompt(context, currentScript ?? null, currentView ?? null, currentDoc ?? null, _store);
514
419
  const fullMessages = [{ role: 'system', content: systemPrompt }, ...messages];
515
420
 
516
421
  try {
517
422
  let result;
518
- 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') {
519
427
  result = await callAnthropic(aiWithModel, fullMessages);
520
428
  } else {
521
429
  result = await callOpenAICompat(aiWithModel, fullMessages);
@@ -533,7 +441,7 @@ router.post('/chat/stream', async (req, res) => {
533
441
  return res.status(400).json({ error: 'AI provider not configured. Set ai.provider and ai.model in Config.' });
534
442
  }
535
443
 
536
- const { messages = [], currentScript, currentView, context = {}, modelOverride } = req.body || {};
444
+ const { messages = [], currentScript, currentView, currentDoc, context = {}, modelOverride } = req.body || {};
537
445
  if (!Array.isArray(messages)) return res.status(400).json({ error: 'messages must be an array' });
538
446
 
539
447
  const aiWithModel = (modelOverride && typeof modelOverride === 'string') ? { ...ai, model: modelOverride } : ai;
@@ -547,16 +455,23 @@ router.post('/chat/stream', async (req, res) => {
547
455
 
548
456
  const send = (data) => res.write(`data: ${JSON.stringify(data)}\n\n`);
549
457
 
550
- const systemPrompt = buildSystemPrompt(context, currentScript ?? null, currentView ?? null, _store);
458
+ const systemPrompt = buildSystemPrompt(context, currentScript ?? null, currentView ?? null, currentDoc ?? null, _store);
551
459
  const fullMessages = [{ role: 'system', content: systemPrompt }, ...messages];
552
460
 
553
461
  try {
554
- const onToken = (t) => send({ token: t });
555
-
556
- if (ai.provider === 'anthropic') {
557
- 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 });
558
468
  } else {
559
- 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
+ }
560
475
  }
561
476
 
562
477
  res.write('data: [DONE]\n\n');