smart-home-engine 1.51.3 → 1.52.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/web/ai-api.js CHANGED
@@ -18,11 +18,14 @@
18
18
 
19
19
  const express = require('express');
20
20
  const fs = require('fs');
21
+ const crypto = require('crypto');
21
22
  const path = require('path');
22
23
 
23
- const { buildSystemPrompt } = require('./ai-context');
24
+ const { buildSystemPrompt, buildSystemPromptParts, houseSection, profileFor, stripThinking, thinkFilter } = require('./ai-context');
24
25
  const { TOOL_DEFINITIONS, TOOL_DEFINITIONS_ANTHROPIC, executeTool } = require('./ai-tools');
25
26
  const { STORAGE_ROOT } = require('../lib/storage');
27
+ const memory = require('./ai-memory');
28
+ const drafts = require('./ai-drafts');
26
29
 
27
30
  const router = express.Router();
28
31
  let _store = null;
@@ -32,11 +35,24 @@ let _store = null;
32
35
  */
33
36
  let _log = console;
34
37
 
38
+ let _introspect = null;
39
+
35
40
  /**
36
41
  * @param {object} store
37
42
  * @param {{ error: Function, warn: Function }} [log] — the daemon's logger; console until init
43
+ * @param {object} [introspect] — read-only views of the daemon (scripts, stats, health, services, devices)
38
44
  */
39
- function init(store, log) {
45
+ function init(store, log, introspect) {
46
+ _introspect = introspect || null;
47
+ memory.init(path.join(STORAGE_ROOT, 'ai'), (t) => {
48
+ try {
49
+ return require('../lib/secrets').redact(t);
50
+ } catch {
51
+ return t;
52
+ }
53
+ });
54
+ setNotesProvider(() => memory.list());
55
+ drafts.init(path.join(STORAGE_ROOT, 'ai', 'drafts'));
40
56
  if (log) _log = log;
41
57
  _store = store;
42
58
  }
@@ -51,14 +67,62 @@ function init(store, log) {
51
67
  * @param {string|undefined} configPath
52
68
  * @returns {{ provider?: string, baseUrl?: string, model?: string, apiKey?: string }|null}
53
69
  */
54
- function readAiConfig(configPath) {
70
+ function readAiConfig(configPath, providerId) {
55
71
  if (!configPath) return null;
72
+ let raw;
56
73
  try {
57
- const cfg = JSON.parse(fs.readFileSync(configPath, 'utf8'));
58
- return cfg.ai || null;
74
+ raw = JSON.parse(fs.readFileSync(configPath, 'utf8')).ai || null;
59
75
  } catch {
60
76
  return null;
61
77
  }
78
+ return resolveAi(raw, providerId);
79
+ }
80
+
81
+ const ENTRY_KEYS = ['provider', 'baseUrl', 'model', 'apiKey'];
82
+
83
+ /**
84
+ * The provider entries of an `ai` config section (roadmap I14). Two shapes are read:
85
+ * { provider, model, baseUrl, apiKey, …settings } — one entry, id = provider
86
+ * { providers: [{ id, label, provider, model, baseUrl, apiKey }], default, …settings }
87
+ * Settings (toolResultChars, fetchAllow, elasticIndex) live on the section and apply to every entry.
88
+ * @returns {{ providers: Array<object>, defaultId: string|null, settings: object }}
89
+ */
90
+ function normalizeAiConfig(raw) {
91
+ if (!raw || typeof raw !== 'object') return { providers: [], defaultId: null, settings: {} };
92
+ const settings = {};
93
+ for (const k of Object.keys(raw)) if (!ENTRY_KEYS.includes(k) && k !== 'providers' && k !== 'default') settings[k] = raw[k];
94
+ let providers = [];
95
+ if (Array.isArray(raw.providers)) {
96
+ providers = raw.providers
97
+ .filter((e) => e && typeof e === 'object' && e.provider)
98
+ .map((e, i) => ({
99
+ id: String(e.id || e.provider + (i ? '-' + i : '')),
100
+ label: String(e.label || e.id || e.provider),
101
+ provider: e.provider,
102
+ baseUrl: e.baseUrl || '',
103
+ model: e.model || '',
104
+ apiKey: e.apiKey || '',
105
+ }));
106
+ } else if (raw.provider) {
107
+ providers = [
108
+ { id: String(raw.provider), label: String(raw.provider), provider: raw.provider, baseUrl: raw.baseUrl || '', model: raw.model || '', apiKey: raw.apiKey || '' },
109
+ ];
110
+ }
111
+ const defaultId = providers.some((e) => e.id === raw.default) ? raw.default : providers[0]?.id || null;
112
+ return { providers, defaultId, settings };
113
+ }
114
+
115
+ /**
116
+ * One entry merged with the settings, in the shape the provider adapters take; the default entry when no id is
117
+ * given; `null` when nothing is configured; `{ unknown: id }` when the id names no entry.
118
+ */
119
+ function resolveAi(raw, providerId) {
120
+ const { providers, defaultId, settings } = normalizeAiConfig(raw);
121
+ if (!providers.length) return null;
122
+ const id = providerId || defaultId;
123
+ const entry = providers.find((e) => e.id === id);
124
+ if (!entry) return { unknown: String(providerId) };
125
+ return { ...settings, ...entry, providers: providers.map(({ apiKey: _k, ...pub }) => pub), defaultId };
62
126
  }
63
127
 
64
128
  // ---------------------------------------------------------------------------
@@ -77,7 +141,7 @@ async function callOpenAICompat(config, messages, tools) {
77
141
  const headers = { 'Content-Type': 'application/json' };
78
142
  if (config.apiKey) headers['Authorization'] = `Bearer ${config.apiKey}`;
79
143
 
80
- const body = { model: config.model, messages, stream: false };
144
+ const body = { model: config.model, messages: plainMessages(messages), stream: false };
81
145
  if (tools?.length) body.tools = tools;
82
146
 
83
147
  const res = await fetch(url, {
@@ -105,7 +169,7 @@ async function callOpenAICompat(config, messages, tools) {
105
169
  return { toolCalls: choice.message.tool_calls, assistantMsg: choice.message, usage };
106
170
  }
107
171
 
108
- const message = choice?.message?.content ?? choice?.text ?? '';
172
+ const message = stripThinking(choice?.message?.content ?? choice?.text ?? '');
109
173
  return { message, usage };
110
174
  }
111
175
 
@@ -127,7 +191,7 @@ async function callAnthropic(config, messages, tools) {
127
191
 
128
192
  const body = {
129
193
  model: config.model,
130
- system: systemMsg?.content || '',
194
+ system: anthropicSystem(systemMsg),
131
195
  messages: userMessages,
132
196
  // thinking tokens count against this on the current models; 4096 cut answers short
133
197
  max_tokens: 16384,
@@ -146,12 +210,7 @@ async function callAnthropic(config, messages, tools) {
146
210
  }
147
211
 
148
212
  const json = await res.json();
149
- const usage = json.usage
150
- ? {
151
- prompt_tokens: json.usage.input_tokens,
152
- completion_tokens: json.usage.output_tokens,
153
- }
154
- : undefined;
213
+ const usage = json.usage ? anthropicUsage(json.usage) : undefined;
155
214
 
156
215
  // Detect tool use response
157
216
  if (json.stop_reason === 'tool_use') {
@@ -166,6 +225,79 @@ async function callAnthropic(config, messages, tools) {
166
225
  return { message, usage, detail, assistantMsg: json.content };
167
226
  }
168
227
 
228
+ /**
229
+ * The system message with its static part marked (roadmap I22): Anthropic gets it as two blocks with a cache
230
+ * breakpoint after the static one; other providers get the joined text.
231
+ */
232
+ function systemMessage(context, currentScript, currentView, currentDoc, extraFiles, ai, toolsOffered) {
233
+ const profile = profileFor(ai);
234
+ const house = houseSectionFor();
235
+ const opts = { profile, house, toolsOffered, budgetChars: Number(ai?.promptBudgetChars) || undefined };
236
+ const { staticText, dynamicText, dropped } = buildSystemPromptParts(context, currentScript ?? null, currentView ?? null, currentDoc ?? null, _store, extraFiles || [], opts);
237
+ _log.debug(
238
+ `ai chat: profile ${profile}, prompt ${Math.round((staticText.length + dynamicText.length) / 100) / 10}k chars (static ${Math.round(staticText.length / 100) / 10}k)${dropped.length ? ', left out ' + dropped.join(', ') : ''}`,
239
+ );
240
+ return { role: 'system', content: dynamicText ? staticText + '\n\n' + dynamicText : staticText, staticText, dynamicText };
241
+ }
242
+
243
+ /** the derived house section from the daemon's state; empty outside the daemon (tests) */
244
+ let _notesProvider = () => [];
245
+ function setNotesProvider(fn) {
246
+ _notesProvider = fn;
247
+ }
248
+ function houseSectionFor() {
249
+ try {
250
+ const cfg = _introspect?.config ? _introspect.config() : null;
251
+ const instances = _introspect?.instances ? _introspect.instances() : [];
252
+ return houseSection(cfg, _store?.mqttEntries ? _store.mqttEntries() : null, instances, _notesProvider() || []);
253
+ } catch (e) {
254
+ _log.warn('ai chat: house section: ' + e.message);
255
+ return '';
256
+ }
257
+ }
258
+
259
+ // Tool support of a local model (compact profile): Ollama says it in /api/show; others are assumed to support
260
+ // tools and get the retry-without-tools of the resolver when they do not.
261
+ const _toolSupport = new Map(); // `${baseUrl}|${model}` → { ok, at }
262
+ async function providerSupportsTools(ai) {
263
+ if (!ai || ai.provider !== 'ollama') return true;
264
+ const base = (ai.baseUrl || 'http://localhost:11434').replace(/\/$/, '');
265
+ const key = base + '|' + ai.model;
266
+ const cached = _toolSupport.get(key);
267
+ if (cached && Date.now() - cached.at < 10 * 60 * 1000) return cached.ok;
268
+ let ok = true;
269
+ try {
270
+ const r = await fetch(`${base}/api/show`, {
271
+ method: 'POST',
272
+ headers: { 'Content-Type': 'application/json' },
273
+ body: JSON.stringify({ model: ai.model }),
274
+ signal: AbortSignal.timeout(5000),
275
+ });
276
+ if (r.ok) {
277
+ const json = await r.json();
278
+ if (Array.isArray(json.capabilities)) ok = json.capabilities.includes('tools');
279
+ }
280
+ } catch {
281
+ /* unknown: offer the tools */
282
+ }
283
+ _toolSupport.set(key, { ok, at: Date.now() });
284
+ return ok;
285
+ }
286
+
287
+ /** Anthropic's `system`: the static part as a cached block, the dynamic part after it */
288
+ function anthropicSystem(systemMsg) {
289
+ if (!systemMsg) return '';
290
+ if (!systemMsg.staticText) return systemMsg.content || '';
291
+ const blocks = [{ type: 'text', text: systemMsg.staticText, cache_control: { type: 'ephemeral' } }];
292
+ if (systemMsg.dynamicText) blocks.push({ type: 'text', text: systemMsg.dynamicText });
293
+ return blocks;
294
+ }
295
+
296
+ /** role and content only, for the OpenAI-compatible endpoints (the system message carries more) */
297
+ function plainMessages(messages) {
298
+ return messages.map((m) => (m.role === 'system' ? { role: 'system', content: m.content } : m));
299
+ }
300
+
169
301
  /**
170
302
  * What a provider may see of a conversation: role and content only. The chat page stores more on its messages
171
303
  * (tool events, timestamps) and Anthropic rejects unknown fields; empty messages are left out too (Anthropic
@@ -179,6 +311,14 @@ function providerMessages(messages) {
179
311
  .filter((m) => (typeof m.content === 'string' ? m.content.trim() !== '' : Array.isArray(m.content) ? m.content.length > 0 : m.content != null));
180
312
  }
181
313
 
314
+ /** the token counts the journal shows, cache reads included */
315
+ function anthropicUsage(u) {
316
+ const usage = { prompt_tokens: u.input_tokens, completion_tokens: u.output_tokens };
317
+ if (u.cache_read_input_tokens) usage.cache_read_tokens = u.cache_read_input_tokens;
318
+ if (u.cache_creation_input_tokens) usage.cache_write_tokens = u.cache_creation_input_tokens;
319
+ return usage;
320
+ }
321
+
182
322
  /**
183
323
  * The text of an Anthropic answer: every text block joined, other block types (thinking, tool_use) ignored.
184
324
  * @param {Array<{type:string,text?:string}>|undefined} content
@@ -208,9 +348,32 @@ function answerText(content) {
208
348
  * @param {((event: object) => void)|undefined} onEvent
209
349
  * @returns {Promise<{ message: string, usage?: object }>}
210
350
  */
351
+ /** run a tool; a result of { text, event } sends the event to the chat and hands the text to the model */
352
+ async function runToolForChat(name, args, ctx, onEvent) {
353
+ const out = await executeTool(name, args, ctx);
354
+ if (out && typeof out === 'object') {
355
+ if (out.event) onEvent?.(out.event);
356
+ return String(out.text ?? '');
357
+ }
358
+ return String(out ?? '');
359
+ }
360
+
361
+ /** which tools a request may offer: the ones whose backing the context carries */
362
+ function toolAllowed(name, ctx) {
363
+ if (name === 'remember' || name === 'forget') return !!ctx.memory;
364
+ if (name === 'propose_script') return !!(ctx.drafts && ctx.scriptDir);
365
+ if (name === 'publish_mqtt') return !!(ctx.publish && ctx.publish.mode !== 'off' && ctx.publish.send);
366
+ return true;
367
+ }
368
+
369
+ function toolsFor(ai, ctx) {
370
+ const isAnthropic = ai.provider === 'anthropic';
371
+ return (isAnthropic ? TOOL_DEFINITIONS_ANTHROPIC : TOOL_DEFINITIONS).filter((t) => toolAllowed(isAnthropic ? t.name : t.function.name, ctx || {}));
372
+ }
373
+
211
374
  async function resolveAndGetAnswer(ai, messages, toolContext, onEvent) {
212
375
  const isAnthropic = ai.provider === 'anthropic';
213
- const tools = isAnthropic ? TOOL_DEFINITIONS_ANTHROPIC : TOOL_DEFINITIONS;
376
+ const tools = toolsFor(ai, toolContext);
214
377
  let msgs = messages;
215
378
  let toolsUsed = false;
216
379
  // The tools stay on offer in every round (up to MAX_TOOL_ROUNDS): a search that was cut short can be refined,
@@ -261,7 +424,7 @@ async function resolveAndGetAnswer(ai, messages, toolContext, onEvent) {
261
424
  for (const tc of result.toolCalls) {
262
425
  const args = tc.input || {};
263
426
  onEvent?.({ type: 'tool_call', name: tc.name, args });
264
- const content = await executeTool(tc.name, args, toolContext);
427
+ const content = await runToolForChat(tc.name, args, toolContext, onEvent);
265
428
  onEvent?.({ type: 'tool_result', name: tc.name, content });
266
429
  toolResultBlocks.push({ type: 'tool_result', tool_use_id: tc.id, content });
267
430
  }
@@ -281,7 +444,7 @@ async function resolveAndGetAnswer(ai, messages, toolContext, onEvent) {
281
444
  args = {};
282
445
  }
283
446
  onEvent?.({ type: 'tool_call', name, args });
284
- const content = await executeTool(name, args, toolContext);
447
+ const content = await runToolForChat(name, args, toolContext, onEvent);
285
448
  onEvent?.({ type: 'tool_result', name, content });
286
449
  msgs = [...msgs, { role: 'tool', tool_call_id: tc.id, content }];
287
450
  }
@@ -305,7 +468,7 @@ async function resolveAndGetAnswer(ai, messages, toolContext, onEvent) {
305
468
  * @param {(json:object)=>string|null|undefined} tokenExtractor
306
469
  * @param {(token:string)=>void} onToken
307
470
  */
308
- async function parseSseStream(body, tokenExtractor, onToken) {
471
+ async function parseSseStream(body, tokenExtractor, onToken, onEvent) {
309
472
  const reader = body.getReader();
310
473
  const decoder = new TextDecoder();
311
474
  let buffer = '';
@@ -324,6 +487,7 @@ async function parseSseStream(body, tokenExtractor, onToken) {
324
487
  if (data === '[DONE]') return;
325
488
  try {
326
489
  const json = JSON.parse(data);
490
+ if (onEvent) onEvent(json);
327
491
  const token = tokenExtractor(json);
328
492
  if (token) onToken(token);
329
493
  } catch {
@@ -341,6 +505,7 @@ async function parseSseStream(body, tokenExtractor, onToken) {
341
505
  * Calls onToken(str) for each chunk, resolves when stream ends.
342
506
  */
343
507
  async function streamOpenAICompat(config, messages, onToken) {
508
+ onToken = thinkFilter(onToken); // a thinking model's <think>…</think> never reaches the chat
344
509
  const base = (config.baseUrl || 'http://localhost:11434').replace(/\/$/, '');
345
510
  const url = `${base}/v1/chat/completions`;
346
511
  const headers = { 'Content-Type': 'application/json' };
@@ -349,7 +514,7 @@ async function streamOpenAICompat(config, messages, onToken) {
349
514
  const res = await fetch(url, {
350
515
  method: 'POST',
351
516
  headers,
352
- body: JSON.stringify({ model: config.model, messages, stream: true }),
517
+ body: JSON.stringify({ model: config.model, messages: plainMessages(messages), stream: true }),
353
518
  });
354
519
 
355
520
  if (!res.ok) {
@@ -378,9 +543,9 @@ async function streamAnthropic(config, messages, onToken) {
378
543
  headers,
379
544
  body: JSON.stringify({
380
545
  model: config.model,
381
- system: systemMsg?.content || '',
546
+ system: anthropicSystem(systemMsg),
382
547
  messages: userMessages,
383
- max_tokens: 4096,
548
+ max_tokens: 16384,
384
549
  stream: true,
385
550
  }),
386
551
  });
@@ -390,7 +555,17 @@ async function streamAnthropic(config, messages, onToken) {
390
555
  throw new Error(`Anthropic API error ${res.status}: ${text.slice(0, 300)}`);
391
556
  }
392
557
 
393
- await parseSseStream(res.body, (json) => json.delta?.text, onToken);
558
+ let usage;
559
+ await parseSseStream(
560
+ res.body,
561
+ (json) => json.delta?.text,
562
+ onToken,
563
+ (json) => {
564
+ if (json.type === 'message_start' && json.message?.usage) usage = anthropicUsage(json.message.usage);
565
+ if (json.type === 'message_delta' && json.usage?.output_tokens && usage) usage.completion_tokens = json.usage.output_tokens;
566
+ },
567
+ );
568
+ return { usage };
394
569
  }
395
570
 
396
571
  // ---------------------------------------------------------------------------
@@ -405,6 +580,9 @@ router.get('/config', (req, res) => {
405
580
  provider: ai?.provider || '',
406
581
  model: ai?.model || '',
407
582
  baseUrl: ai?.baseUrl || '',
583
+ // the entries without keys, and which one is the default (roadmap I14)
584
+ default: ai?.defaultId || '',
585
+ providers: ai?.providers || [],
408
586
  });
409
587
  });
410
588
 
@@ -446,7 +624,8 @@ async function listAnthropicModels(ai) {
446
624
 
447
625
  // GET /she/ai/models — list available models for the configured provider
448
626
  router.get('/models', async (req, res) => {
449
- const ai = readAiConfig(req.app.locals.configPath);
627
+ const ai = readAiConfig(req.app.locals.configPath, req.query.provider);
628
+ if (ai?.unknown) return res.status(400).json({ error: `unknown AI provider entry "${ai.unknown}"`, models: [] });
450
629
  if (!ai?.provider) return res.json({ models: [] });
451
630
 
452
631
  const base = (ai.baseUrl || 'http://localhost:11434').replace(/\/$/, '');
@@ -484,7 +663,8 @@ router.get('/models', async (req, res) => {
484
663
  // GET /she/ai/model-info — Ollama-specific: version, model details, running models
485
664
  // Query param: ?model=<name> (defaults to configured model)
486
665
  router.get('/model-info', async (req, res) => {
487
- const ai = readAiConfig(req.app.locals.configPath);
666
+ const ai = readAiConfig(req.app.locals.configPath, req.query.provider);
667
+ if (ai?.unknown) return res.status(400).json({ error: `unknown AI provider entry "${ai.unknown}"` });
488
668
  if (!ai?.provider || !ai?.model) return res.status(400).json({ error: 'Not configured' });
489
669
  if (ai.provider !== 'ollama') return res.status(400).json({ error: 'Model info is only available for Ollama' });
490
670
 
@@ -519,8 +699,13 @@ router.get('/model-info', async (req, res) => {
519
699
  router.post('/prompt', (req, res) => {
520
700
  const { context = {}, currentScript, currentView, currentDoc, extraFiles } = req.body || {};
521
701
  try {
522
- const prompt = buildSystemPrompt(context, currentScript ?? null, currentView ?? null, currentDoc ?? null, _store, extraFiles || []);
523
- res.json({ prompt });
702
+ const ai = readAiConfig(req.app.locals.configPath, typeof req.body?.providerOverride === 'string' ? req.body.providerOverride : undefined);
703
+ const prompt = buildSystemPrompt(context, currentScript ?? null, currentView ?? null, currentDoc ?? null, _store, extraFiles || [], {
704
+ profile: profileFor(ai),
705
+ house: houseSectionFor(),
706
+ toolsOffered: !!context.tools,
707
+ });
708
+ res.json({ prompt, profile: profileFor(ai) });
524
709
  } catch (e) {
525
710
  res.status(500).json({ error: e.message });
526
711
  }
@@ -528,8 +713,9 @@ router.post('/prompt', (req, res) => {
528
713
 
529
714
  // POST /she/ai/chat — non-streaming
530
715
  router.post('/chat', async (req, res) => {
531
- const ai = readAiConfig(req.app.locals.configPath);
532
- const { messages = [], currentScript, currentView, currentDoc, context = {}, modelOverride, extraFiles } = req.body || {};
716
+ const { messages = [], currentScript, currentView, currentDoc, context = {}, modelOverride, providerOverride, extraFiles } = req.body || {};
717
+ const ai = readAiConfig(req.app.locals.configPath, typeof providerOverride === 'string' ? providerOverride : undefined);
718
+ if (ai?.unknown) return res.status(400).json({ error: `unknown AI provider entry "${ai.unknown}"` });
533
719
  const effectiveModel = modelOverride && typeof modelOverride === 'string' ? modelOverride : ai?.model;
534
720
  if (!ai?.provider || !effectiveModel) {
535
721
  return res.status(400).json({ error: 'AI provider not configured. Set ai.provider and ai.model in Config.' });
@@ -538,14 +724,24 @@ router.post('/chat', async (req, res) => {
538
724
  if (!Array.isArray(messages)) return res.status(400).json({ error: 'messages must be an array' });
539
725
 
540
726
  const aiWithModel = { ...ai, model: effectiveModel };
541
- _log.debug(`ai chat: ${ai.provider} ${effectiveModel}${modelOverride ? ' (chosen in the chat)' : ' (config)'}`);
542
- const systemPrompt = buildSystemPrompt(context, currentScript ?? null, currentView ?? null, currentDoc ?? null, _store, extraFiles || []);
543
- const fullMessages = [{ role: 'system', content: systemPrompt }, ...providerMessages(messages)];
727
+ _log.debug(`ai chat: ${ai.id !== ai.provider ? ai.id + '/' : ''}${ai.provider} ${effectiveModel}${modelOverride || providerOverride ? ' (chosen in the chat)' : ' (config)'}`);
728
+ const useTools = !!context.tools && (await providerSupportsTools(aiWithModel));
729
+ const fullMessages = [systemMessage(context, currentScript, currentView, currentDoc, extraFiles, aiWithModel, useTools), ...providerMessages(messages)];
544
730
 
545
731
  try {
546
732
  let result;
547
- if (context.tools) {
548
- const toolContext = { store: _store, scriptDir: req.app.locals.scriptDir || null };
733
+ if (useTools) {
734
+ const toolContext = {
735
+ store: _store,
736
+ scriptDir: req.app.locals.scriptDir || null,
737
+ resultChars: ai.toolResultChars,
738
+ fetchAllow: ai.fetchAllow,
739
+ elasticIndex: ai.elasticIndex,
740
+ introspect: _introspect,
741
+ memory,
742
+ drafts,
743
+ publish: publishContext(ai, context, null),
744
+ };
549
745
  result = await resolveAndGetAnswer(aiWithModel, fullMessages, toolContext, undefined);
550
746
  } else if (ai.provider === 'anthropic') {
551
747
  result = await callAnthropic(aiWithModel, fullMessages);
@@ -562,8 +758,9 @@ router.post('/chat', async (req, res) => {
562
758
 
563
759
  // POST /she/ai/chat/stream — SSE streaming
564
760
  router.post('/chat/stream', async (req, res) => {
565
- const ai = readAiConfig(req.app.locals.configPath);
566
- const { messages = [], currentScript, currentView, currentDoc, context = {}, modelOverride, extraFiles } = req.body || {};
761
+ const { messages = [], currentScript, currentView, currentDoc, context = {}, modelOverride, providerOverride, extraFiles } = req.body || {};
762
+ const ai = readAiConfig(req.app.locals.configPath, typeof providerOverride === 'string' ? providerOverride : undefined);
763
+ if (ai?.unknown) return res.status(400).json({ error: `unknown AI provider entry "${ai.unknown}"` });
567
764
  const effectiveModel = modelOverride && typeof modelOverride === 'string' ? modelOverride : ai?.model;
568
765
  if (!ai?.provider || !effectiveModel) {
569
766
  return res.status(400).json({ error: 'AI provider not configured. Set ai.provider and ai.model in Config.' });
@@ -572,12 +769,13 @@ router.post('/chat/stream', async (req, res) => {
572
769
  if (!Array.isArray(messages)) return res.status(400).json({ error: 'messages must be an array' });
573
770
 
574
771
  const aiWithModel = { ...ai, model: effectiveModel };
575
- _log.debug(`ai chat: ${ai.provider} ${effectiveModel}${modelOverride ? ' (chosen in the chat)' : ' (config)'}`);
772
+ _log.debug(`ai chat: ${ai.id !== ai.provider ? ai.id + '/' : ''}${ai.provider} ${effectiveModel}${modelOverride || providerOverride ? ' (chosen in the chat)' : ' (config)'}`);
576
773
 
577
774
  // Build system prompt BEFORE flushing headers so errors can still return a proper HTTP status
578
- let systemPrompt;
775
+ let system;
776
+ const useTools = !!context.tools && (await providerSupportsTools(aiWithModel));
579
777
  try {
580
- systemPrompt = buildSystemPrompt(context, currentScript ?? null, currentView ?? null, currentDoc ?? null, _store, extraFiles || []);
778
+ system = systemMessage(context, currentScript, currentView, currentDoc, extraFiles, aiWithModel, useTools);
581
779
  } catch (e) {
582
780
  return res.status(500).json({ error: `Failed to build system prompt: ${e.message}` });
583
781
  }
@@ -591,15 +789,26 @@ router.post('/chat/stream', async (req, res) => {
591
789
 
592
790
  const send = (data) => res.write(`data: ${JSON.stringify(data)}\n\n`);
593
791
 
594
- const fullMessages = [{ role: 'system', content: systemPrompt }, ...providerMessages(messages)];
792
+ const fullMessages = [system, ...providerMessages(messages)];
595
793
 
596
794
  try {
597
- if (context.tools) {
795
+ if (useTools) {
598
796
  // Tool-calling mode: resolve tools non-streaming (emitting events), then
599
797
  // send the final answer as a single token so the client sees it immediately.
600
- const toolContext = { store: _store, scriptDir: req.app.locals.scriptDir || null };
601
- const { message, detail } = await resolveAndGetAnswer(aiWithModel, fullMessages, toolContext, send);
798
+ const toolContext = {
799
+ store: _store,
800
+ scriptDir: req.app.locals.scriptDir || null,
801
+ resultChars: ai.toolResultChars,
802
+ fetchAllow: ai.fetchAllow,
803
+ elasticIndex: ai.elasticIndex,
804
+ introspect: _introspect,
805
+ memory,
806
+ drafts,
807
+ publish: publishContext(ai, context, send),
808
+ };
809
+ const { message, detail, usage } = await resolveAndGetAnswer(aiWithModel, fullMessages, toolContext, send);
602
810
  if (!message || !message.trim()) throw new Error('The model returned an empty answer' + (detail ? ` (${detail})` : ''));
811
+ if (usage) _log.debug('ai chat: usage ' + JSON.stringify(usage));
603
812
  send({ token: message });
604
813
  } else {
605
814
  let tokens = 0;
@@ -607,12 +816,14 @@ router.post('/chat/stream', async (req, res) => {
607
816
  tokens++;
608
817
  send({ token: t });
609
818
  };
819
+ let streamed;
610
820
  if (ai.provider === 'anthropic') {
611
- await streamAnthropic(aiWithModel, fullMessages, onToken);
821
+ streamed = await streamAnthropic(aiWithModel, fullMessages, onToken);
612
822
  } else {
613
823
  await streamOpenAICompat(aiWithModel, fullMessages, onToken);
614
824
  }
615
825
  if (tokens === 0) throw new Error('The model returned an empty answer');
826
+ if (streamed?.usage) _log.debug('ai chat: usage ' + JSON.stringify(streamed.usage));
616
827
  }
617
828
 
618
829
  res.write('data: [DONE]\n\n');
@@ -624,6 +835,95 @@ router.post('/chat/stream', async (req, res) => {
624
835
  }
625
836
  });
626
837
 
838
+ // ---------------------------------------------------------------------------
839
+ // Publish confirmations (roadmap I20, decision D-2): the tool waits for the user's click
840
+ // ---------------------------------------------------------------------------
841
+
842
+ const _pendingPublish = new Map(); // id → resolve(boolean)
843
+ const PUBLISH_CONFIRM_MS = 120000;
844
+
845
+ /** the publish context of a request: mode from the chat, the allow-list from the config, the daemon's send */
846
+ function publishContext(ai, context, send) {
847
+ const mode = ['confirm', 'all'].includes(context?.publish) ? context.publish : 'off';
848
+ const sendFn = _introspect?.publish;
849
+ return {
850
+ mode,
851
+ allow: Array.isArray(ai?.publishAllow) ? ai.publishAllow : [],
852
+ send: sendFn
853
+ ? async (topic, payload, opts) => {
854
+ await sendFn(topic, payload, opts);
855
+ _log.info(`ai chat: published ${JSON.stringify(payload)} to ${topic}${opts?.retain ? ' (retained)' : ''}`);
856
+ }
857
+ : null,
858
+ confirm: (req) =>
859
+ new Promise((resolve) => {
860
+ const id = 'p' + crypto.randomBytes(4).toString('hex');
861
+ const timer = setTimeout(() => {
862
+ _pendingPublish.delete(id);
863
+ resolve('timeout');
864
+ }, PUBLISH_CONFIRM_MS);
865
+ _pendingPublish.set(id, (ok) => {
866
+ clearTimeout(timer);
867
+ _pendingPublish.delete(id);
868
+ resolve(!!ok);
869
+ });
870
+ send?.({ type: 'publish_request', id, ...req });
871
+ }),
872
+ };
873
+ }
874
+
875
+ router.post('/publish/:id', (req, res) => {
876
+ const resolve = _pendingPublish.get(req.params.id);
877
+ if (!resolve) return res.status(404).json({ error: 'no pending publish with that id (answered or expired)' });
878
+ resolve(req.body?.ok === true);
879
+ res.json({ ok: true });
880
+ });
881
+
882
+ // ---------------------------------------------------------------------------
883
+ // Drafts — GET /she/ai/drafts/:id, POST /she/ai/drafts/:id/status (roadmap I16, decision D-1)
884
+ // ---------------------------------------------------------------------------
885
+
886
+ router.get('/drafts/:id', (req, res) => {
887
+ const d = drafts.get(req.params.id);
888
+ if (!d) return res.status(404).json({ error: 'no such draft' });
889
+ res.json(d);
890
+ });
891
+
892
+ router.post('/drafts/:id/status', (req, res) => {
893
+ const status = req.body?.status;
894
+ if (!['applied', 'discarded', 'open'].includes(status)) return res.status(400).json({ error: 'status must be applied, discarded or open' });
895
+ const d = drafts.setStatus(req.params.id, status);
896
+ if (!d) return res.status(404).json({ error: 'no such draft' });
897
+ _log.info(`ai chat: draft ${d.id} for ${d.path} ${status}`);
898
+ res.json({ ok: true, status: d.status });
899
+ });
900
+
901
+ // ---------------------------------------------------------------------------
902
+ // Memory — GET/POST /she/ai/memory, PUT/DELETE /she/ai/memory/:id (roadmap I29)
903
+ // ---------------------------------------------------------------------------
904
+
905
+ router.get('/memory', (req, res) => {
906
+ res.json({ notes: memory.list(), max: memory.MAX_NOTES, maxChars: memory.MAX_CHARS });
907
+ });
908
+
909
+ router.post('/memory', (req, res) => {
910
+ const r = memory.add(req.body?.text, 'user');
911
+ if (r.error) return res.status(400).json({ error: r.error });
912
+ res.json(r);
913
+ });
914
+
915
+ router.put('/memory/:id', (req, res) => {
916
+ const r = memory.update(req.params.id, req.body?.text);
917
+ if (r.error) return res.status(r.error.startsWith('no note') ? 404 : 400).json({ error: r.error });
918
+ res.json(r);
919
+ });
920
+
921
+ router.delete('/memory/:id', (req, res) => {
922
+ const r = memory.remove(req.params.id);
923
+ if (r.error) return res.status(404).json({ error: r.error });
924
+ res.json({ ok: true });
925
+ });
926
+
627
927
  // ---------------------------------------------------------------------------
628
928
  // Conversation persistence — GET/PUT/DELETE /she/ai/conversations[/:id]
629
929
  // ---------------------------------------------------------------------------
@@ -699,4 +999,24 @@ router.delete('/conversations/:id', (req, res) => {
699
999
  res.json({ ok: true });
700
1000
  });
701
1001
 
702
- module.exports = { router, init, _internal: { callAnthropic, answerText, readAiConfig, listAnthropicModels, ANTHROPIC_FALLBACK_MODELS, providerMessages } };
1002
+ module.exports = {
1003
+ router,
1004
+ init,
1005
+ setNotesProvider,
1006
+ _tools: { toolsFor, toolAllowed },
1007
+ _internal: {
1008
+ callAnthropic,
1009
+ answerText,
1010
+ readAiConfig,
1011
+ normalizeAiConfig,
1012
+ resolveAi,
1013
+ providerSupportsTools,
1014
+ houseSectionFor,
1015
+ listAnthropicModels,
1016
+ ANTHROPIC_FALLBACK_MODELS,
1017
+ providerMessages,
1018
+ anthropicSystem,
1019
+ plainMessages,
1020
+ buildSystemPromptParts,
1021
+ },
1022
+ };