smart-home-engine 1.51.2 → 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;
@@ -30,7 +33,27 @@ let _store = null;
30
33
  /**
31
34
  * @param {import('../lib/state-store')} store
32
35
  */
33
- function init(store) {
36
+ let _log = console;
37
+
38
+ let _introspect = null;
39
+
40
+ /**
41
+ * @param {object} store
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)
44
+ */
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'));
56
+ if (log) _log = log;
34
57
  _store = store;
35
58
  }
36
59
 
@@ -44,14 +67,62 @@ function init(store) {
44
67
  * @param {string|undefined} configPath
45
68
  * @returns {{ provider?: string, baseUrl?: string, model?: string, apiKey?: string }|null}
46
69
  */
47
- function readAiConfig(configPath) {
70
+ function readAiConfig(configPath, providerId) {
48
71
  if (!configPath) return null;
72
+ let raw;
49
73
  try {
50
- const cfg = JSON.parse(fs.readFileSync(configPath, 'utf8'));
51
- return cfg.ai || null;
74
+ raw = JSON.parse(fs.readFileSync(configPath, 'utf8')).ai || null;
52
75
  } catch {
53
76
  return null;
54
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 };
55
126
  }
56
127
 
57
128
  // ---------------------------------------------------------------------------
@@ -70,7 +141,7 @@ async function callOpenAICompat(config, messages, tools) {
70
141
  const headers = { 'Content-Type': 'application/json' };
71
142
  if (config.apiKey) headers['Authorization'] = `Bearer ${config.apiKey}`;
72
143
 
73
- const body = { model: config.model, messages, stream: false };
144
+ const body = { model: config.model, messages: plainMessages(messages), stream: false };
74
145
  if (tools?.length) body.tools = tools;
75
146
 
76
147
  const res = await fetch(url, {
@@ -98,7 +169,7 @@ async function callOpenAICompat(config, messages, tools) {
98
169
  return { toolCalls: choice.message.tool_calls, assistantMsg: choice.message, usage };
99
170
  }
100
171
 
101
- const message = choice?.message?.content ?? choice?.text ?? '';
172
+ const message = stripThinking(choice?.message?.content ?? choice?.text ?? '');
102
173
  return { message, usage };
103
174
  }
104
175
 
@@ -120,9 +191,10 @@ async function callAnthropic(config, messages, tools) {
120
191
 
121
192
  const body = {
122
193
  model: config.model,
123
- system: systemMsg?.content || '',
194
+ system: anthropicSystem(systemMsg),
124
195
  messages: userMessages,
125
- max_tokens: 4096,
196
+ // thinking tokens count against this on the current models; 4096 cut answers short
197
+ max_tokens: 16384,
126
198
  };
127
199
  if (tools?.length) body.tools = tools;
128
200
 
@@ -138,12 +210,7 @@ async function callAnthropic(config, messages, tools) {
138
210
  }
139
211
 
140
212
  const json = await res.json();
141
- const usage = json.usage
142
- ? {
143
- prompt_tokens: json.usage.input_tokens,
144
- completion_tokens: json.usage.output_tokens,
145
- }
146
- : undefined;
213
+ const usage = json.usage ? anthropicUsage(json.usage) : undefined;
147
214
 
148
215
  // Detect tool use response
149
216
  if (json.stop_reason === 'tool_use') {
@@ -151,8 +218,117 @@ async function callAnthropic(config, messages, tools) {
151
218
  return { toolCalls, assistantMsg: json.content, usage };
152
219
  }
153
220
 
154
- const message = json.content?.[0]?.text ?? '';
155
- return { message, usage };
221
+ // an answer without any text block (e.g. only a thinking block) is reported, not shown as nothing:
222
+ // the caller nudges once and otherwise turns `detail` into the error
223
+ const message = answerText(json.content);
224
+ const detail = message ? undefined : `stop_reason ${json.stop_reason}, content blocks: ${(json.content || []).map((b) => b.type).join(',') || 'none'}`;
225
+ return { message, usage, detail, assistantMsg: json.content };
226
+ }
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
+
301
+ /**
302
+ * What a provider may see of a conversation: role and content only. The chat page stores more on its messages
303
+ * (tool events, timestamps) and Anthropic rejects unknown fields; empty messages are left out too (Anthropic
304
+ * rejects empty text, and the chat saved empty answers before B-13).
305
+ * @param {Array<{role:string,content:any}>} messages
306
+ */
307
+ function providerMessages(messages) {
308
+ return messages
309
+ .filter((m) => m && typeof m.role === 'string')
310
+ .map((m) => ({ role: m.role, content: m.content }))
311
+ .filter((m) => (typeof m.content === 'string' ? m.content.trim() !== '' : Array.isArray(m.content) ? m.content.length > 0 : m.content != null));
312
+ }
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
+
322
+ /**
323
+ * The text of an Anthropic answer: every text block joined, other block types (thinking, tool_use) ignored.
324
+ * @param {Array<{type:string,text?:string}>|undefined} content
325
+ */
326
+ function answerText(content) {
327
+ if (!Array.isArray(content)) return '';
328
+ return content
329
+ .filter((b) => b && b.type === 'text' && typeof b.text === 'string')
330
+ .map((b) => b.text)
331
+ .join('');
156
332
  }
157
333
 
158
334
  // ---------------------------------------------------------------------------
@@ -172,24 +348,45 @@ async function callAnthropic(config, messages, tools) {
172
348
  * @param {((event: object) => void)|undefined} onEvent
173
349
  * @returns {Promise<{ message: string, usage?: object }>}
174
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
+
175
374
  async function resolveAndGetAnswer(ai, messages, toolContext, onEvent) {
176
375
  const isAnthropic = ai.provider === 'anthropic';
177
- const tools = isAnthropic ? TOOL_DEFINITIONS_ANTHROPIC : TOOL_DEFINITIONS;
376
+ const tools = toolsFor(ai, toolContext);
178
377
  let msgs = messages;
179
- let toolsUsed = false; // once the model has used tools, stop offering them
180
-
181
- for (let round = 0; round < 6; round++) {
182
- // After the first round of tool calls, don't offer tools again.
183
- // This forces a plain-text response rather than letting the model
184
- // keep calling tools indefinitely (and some models return empty
185
- // content when given tools but no reason to call them).
186
- const roundTools = toolsUsed ? undefined : tools;
378
+ let toolsUsed = false;
379
+ // The tools stay on offer in every round (up to MAX_TOOL_ROUNDS): a search that was cut short can be refined,
380
+ // and a history with tool_use blocks is only valid when the tools are defined. The round cap ends a model that
381
+ // keeps calling tools; an empty final answer is nudged once below.
382
+ const MAX_TOOL_ROUNDS = 8;
187
383
 
384
+ for (let round = 0; round < MAX_TOOL_ROUNDS; round++) {
188
385
  let result;
189
386
  try {
190
- result = isAnthropic ? await callAnthropic(ai, msgs, roundTools) : await callOpenAICompat(ai, msgs, roundTools);
387
+ result = isAnthropic ? await callAnthropic(ai, msgs, tools) : await callOpenAICompat(ai, msgs, tools);
191
388
  } catch (e) {
192
- if (round === 0 && roundTools) {
389
+ if (round === 0) {
193
390
  // Model may not support tool calling — retry without tools
194
391
  result = isAnthropic ? await callAnthropic(ai, msgs) : await callOpenAICompat(ai, msgs);
195
392
  } else {
@@ -199,11 +396,21 @@ async function resolveAndGetAnswer(ai, messages, toolContext, onEvent) {
199
396
 
200
397
  // No tool calls → we have the final answer
201
398
  if (!result.toolCalls?.length) {
202
- // If the model returned empty content after using tools, nudge it once
203
- if (!result.message && toolsUsed) {
204
- const nudgeMsgs = [...msgs, { role: 'user', content: 'Based on the information retrieved above, please now provide your complete response.' }];
205
- const nudged = isAnthropic ? await callAnthropic(ai, nudgeMsgs) : await callOpenAICompat(ai, nudgeMsgs);
206
- return { message: nudged.message ?? '', usage: nudged.usage };
399
+ if (!result.message) {
400
+ // An answer without text (a thinking block only, or nothing): ask once for the answer itself.
401
+ const nudgeMsgs = [
402
+ ...msgs,
403
+ {
404
+ role: 'user',
405
+ content: toolsUsed
406
+ ? 'Based on the information retrieved above, please now provide your complete response.'
407
+ : 'Please provide your complete response as text.',
408
+ },
409
+ ];
410
+ const nudged = isAnthropic
411
+ ? await callAnthropic(ai, nudgeMsgs, toolsUsed ? tools : undefined)
412
+ : await callOpenAICompat(ai, nudgeMsgs, toolsUsed ? tools : undefined);
413
+ return { message: nudged.message ?? '', usage: nudged.usage, detail: nudged.message ? undefined : nudged.detail || result.detail };
207
414
  }
208
415
  return { message: result.message ?? '', usage: result.usage };
209
416
  }
@@ -211,14 +418,13 @@ async function resolveAndGetAnswer(ai, messages, toolContext, onEvent) {
211
418
  // Execute tool calls and append results to message history
212
419
  toolsUsed = true;
213
420
  if (isAnthropic) {
214
- // Strip text blocks when tool_use blocks are present (same draft-reproduction issue)
215
- const anthropicAssistantContent = result.assistantMsg.some((b) => b.type === 'tool_use') ? result.assistantMsg.filter((b) => b.type !== 'text') : result.assistantMsg;
216
- msgs = [...msgs, { role: 'assistant', content: anthropicAssistantContent }];
421
+ // the assistant turn goes back unchanged: the current models bind their thinking blocks to the turn
422
+ msgs = [...msgs, { role: 'assistant', content: result.assistantMsg }];
217
423
  const toolResultBlocks = [];
218
424
  for (const tc of result.toolCalls) {
219
425
  const args = tc.input || {};
220
426
  onEvent?.({ type: 'tool_call', name: tc.name, args });
221
- const content = await executeTool(tc.name, args, toolContext);
427
+ const content = await runToolForChat(tc.name, args, toolContext, onEvent);
222
428
  onEvent?.({ type: 'tool_result', name: tc.name, content });
223
429
  toolResultBlocks.push({ type: 'tool_result', tool_use_id: tc.id, content });
224
430
  }
@@ -238,16 +444,18 @@ async function resolveAndGetAnswer(ai, messages, toolContext, onEvent) {
238
444
  args = {};
239
445
  }
240
446
  onEvent?.({ type: 'tool_call', name, args });
241
- const content = await executeTool(name, args, toolContext);
447
+ const content = await runToolForChat(name, args, toolContext, onEvent);
242
448
  onEvent?.({ type: 'tool_result', name, content });
243
449
  msgs = [...msgs, { role: 'tool', tool_call_id: tc.id, content }];
244
450
  }
245
451
  }
246
452
  }
247
453
 
248
- // Fallback (should not normally be reached)
249
- const fallback = isAnthropic ? await callAnthropic(ai, msgs) : await callOpenAICompat(ai, msgs);
250
- return { message: fallback.message ?? '', usage: fallback.usage };
454
+ // The round cap was reached: ask for the answer with what was gathered (the tools stay defined - the history
455
+ // holds tool_use blocks - but the request asks for text).
456
+ const finalMsgs = [...msgs, { role: 'user', content: 'Please answer now with the information gathered so far; do not call any more tools.' }];
457
+ const fallback = isAnthropic ? await callAnthropic(ai, finalMsgs, tools) : await callOpenAICompat(ai, finalMsgs, tools);
458
+ return { message: fallback.message ?? '', usage: fallback.usage, detail: fallback.message ? undefined : fallback.detail };
251
459
  }
252
460
 
253
461
  // ---------------------------------------------------------------------------
@@ -260,7 +468,7 @@ async function resolveAndGetAnswer(ai, messages, toolContext, onEvent) {
260
468
  * @param {(json:object)=>string|null|undefined} tokenExtractor
261
469
  * @param {(token:string)=>void} onToken
262
470
  */
263
- async function parseSseStream(body, tokenExtractor, onToken) {
471
+ async function parseSseStream(body, tokenExtractor, onToken, onEvent) {
264
472
  const reader = body.getReader();
265
473
  const decoder = new TextDecoder();
266
474
  let buffer = '';
@@ -279,6 +487,7 @@ async function parseSseStream(body, tokenExtractor, onToken) {
279
487
  if (data === '[DONE]') return;
280
488
  try {
281
489
  const json = JSON.parse(data);
490
+ if (onEvent) onEvent(json);
282
491
  const token = tokenExtractor(json);
283
492
  if (token) onToken(token);
284
493
  } catch {
@@ -296,6 +505,7 @@ async function parseSseStream(body, tokenExtractor, onToken) {
296
505
  * Calls onToken(str) for each chunk, resolves when stream ends.
297
506
  */
298
507
  async function streamOpenAICompat(config, messages, onToken) {
508
+ onToken = thinkFilter(onToken); // a thinking model's <think>…</think> never reaches the chat
299
509
  const base = (config.baseUrl || 'http://localhost:11434').replace(/\/$/, '');
300
510
  const url = `${base}/v1/chat/completions`;
301
511
  const headers = { 'Content-Type': 'application/json' };
@@ -304,7 +514,7 @@ async function streamOpenAICompat(config, messages, onToken) {
304
514
  const res = await fetch(url, {
305
515
  method: 'POST',
306
516
  headers,
307
- body: JSON.stringify({ model: config.model, messages, stream: true }),
517
+ body: JSON.stringify({ model: config.model, messages: plainMessages(messages), stream: true }),
308
518
  });
309
519
 
310
520
  if (!res.ok) {
@@ -333,9 +543,9 @@ async function streamAnthropic(config, messages, onToken) {
333
543
  headers,
334
544
  body: JSON.stringify({
335
545
  model: config.model,
336
- system: systemMsg?.content || '',
546
+ system: anthropicSystem(systemMsg),
337
547
  messages: userMessages,
338
- max_tokens: 4096,
548
+ max_tokens: 16384,
339
549
  stream: true,
340
550
  }),
341
551
  });
@@ -345,7 +555,17 @@ async function streamAnthropic(config, messages, onToken) {
345
555
  throw new Error(`Anthropic API error ${res.status}: ${text.slice(0, 300)}`);
346
556
  }
347
557
 
348
- 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 };
349
569
  }
350
570
 
351
571
  // ---------------------------------------------------------------------------
@@ -360,12 +580,52 @@ router.get('/config', (req, res) => {
360
580
  provider: ai?.provider || '',
361
581
  model: ai?.model || '',
362
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 || [],
363
586
  });
364
587
  });
365
588
 
589
+ // Shown when the Anthropic models endpoint cannot be reached (no key, offline, 401): the current families.
590
+ const ANTHROPIC_FALLBACK_MODELS = ['claude-opus-5-5', 'claude-sonnet-5-5', 'claude-haiku-5-5', 'claude-haiku-4-5'];
591
+
592
+ /**
593
+ * The models the configured Anthropic key may use: GET /v1/models, every page, newest first.
594
+ * On failure the static fallback list with the error, so the chat still offers a choice.
595
+ * @param {{ apiKey?: string }} ai
596
+ * @returns {Promise<{ models: string[], names: Record<string,string>, error?: string }>}
597
+ */
598
+ async function listAnthropicModels(ai) {
599
+ const headers = { 'x-api-key': ai.apiKey || '', 'anthropic-version': '2023-06-01' };
600
+ const all = [];
601
+ try {
602
+ let after = null;
603
+ for (let page = 0; page < 20; page++) {
604
+ const url = 'https://api.anthropic.com/v1/models?limit=100' + (after ? '&after_id=' + encodeURIComponent(after) : '');
605
+ const r = await fetch(url, { headers });
606
+ if (!r.ok) {
607
+ const text = await r.text().catch(() => '');
608
+ throw new Error(`Anthropic API error ${r.status}: ${text.slice(0, 200)}`);
609
+ }
610
+ const json = await r.json();
611
+ for (const m of json.data || []) if (m && m.id) all.push(m);
612
+ if (!json.has_more || !json.last_id) break;
613
+ after = json.last_id;
614
+ }
615
+ } catch (e) {
616
+ _log.warn('ai models: ' + e.message);
617
+ return { models: ANTHROPIC_FALLBACK_MODELS.slice(), names: {}, error: e.message };
618
+ }
619
+ all.sort((a, b) => String(b.created_at || '').localeCompare(String(a.created_at || '')));
620
+ const names = {};
621
+ for (const m of all) if (m.display_name) names[m.id] = m.display_name;
622
+ return { models: all.map((m) => m.id), names };
623
+ }
624
+
366
625
  // GET /she/ai/models — list available models for the configured provider
367
626
  router.get('/models', async (req, res) => {
368
- 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: [] });
369
629
  if (!ai?.provider) return res.json({ models: [] });
370
630
 
371
631
  const base = (ai.baseUrl || 'http://localhost:11434').replace(/\/$/, '');
@@ -381,7 +641,7 @@ router.get('/models', async (req, res) => {
381
641
  .sort();
382
642
  return res.json({ models });
383
643
  } else if (ai.provider === 'anthropic') {
384
- return res.json({ models: [] }); // no public list endpoint
644
+ return res.json(await listAnthropicModels(ai));
385
645
  } else {
386
646
  // OpenAI / LM Studio / etc. — try /v1/models
387
647
  const h = { 'Content-Type': 'application/json' };
@@ -403,7 +663,8 @@ router.get('/models', async (req, res) => {
403
663
  // GET /she/ai/model-info — Ollama-specific: version, model details, running models
404
664
  // Query param: ?model=<name> (defaults to configured model)
405
665
  router.get('/model-info', async (req, res) => {
406
- 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}"` });
407
668
  if (!ai?.provider || !ai?.model) return res.status(400).json({ error: 'Not configured' });
408
669
  if (ai.provider !== 'ollama') return res.status(400).json({ error: 'Model info is only available for Ollama' });
409
670
 
@@ -438,8 +699,13 @@ router.get('/model-info', async (req, res) => {
438
699
  router.post('/prompt', (req, res) => {
439
700
  const { context = {}, currentScript, currentView, currentDoc, extraFiles } = req.body || {};
440
701
  try {
441
- const prompt = buildSystemPrompt(context, currentScript ?? null, currentView ?? null, currentDoc ?? null, _store, extraFiles || []);
442
- 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) });
443
709
  } catch (e) {
444
710
  res.status(500).json({ error: e.message });
445
711
  }
@@ -447,8 +713,9 @@ router.post('/prompt', (req, res) => {
447
713
 
448
714
  // POST /she/ai/chat — non-streaming
449
715
  router.post('/chat', async (req, res) => {
450
- const ai = readAiConfig(req.app.locals.configPath);
451
- 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}"` });
452
719
  const effectiveModel = modelOverride && typeof modelOverride === 'string' ? modelOverride : ai?.model;
453
720
  if (!ai?.provider || !effectiveModel) {
454
721
  return res.status(400).json({ error: 'AI provider not configured. Set ai.provider and ai.model in Config.' });
@@ -457,29 +724,43 @@ router.post('/chat', async (req, res) => {
457
724
  if (!Array.isArray(messages)) return res.status(400).json({ error: 'messages must be an array' });
458
725
 
459
726
  const aiWithModel = { ...ai, model: effectiveModel };
460
- const systemPrompt = buildSystemPrompt(context, currentScript ?? null, currentView ?? null, currentDoc ?? null, _store, extraFiles || []);
461
- const fullMessages = [{ role: 'system', content: systemPrompt }, ...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)];
462
730
 
463
731
  try {
464
732
  let result;
465
- if (context.tools) {
466
- 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
+ };
467
745
  result = await resolveAndGetAnswer(aiWithModel, fullMessages, toolContext, undefined);
468
746
  } else if (ai.provider === 'anthropic') {
469
747
  result = await callAnthropic(aiWithModel, fullMessages);
470
748
  } else {
471
749
  result = await callOpenAICompat(aiWithModel, fullMessages);
472
750
  }
473
- res.json(result);
751
+ if (!result.message || !String(result.message).trim()) throw new Error('The model returned an empty answer' + (result.detail ? ` (${result.detail})` : ''));
752
+ res.json({ message: result.message, usage: result.usage });
474
753
  } catch (e) {
754
+ _log.error('ai chat: ' + e.message);
475
755
  res.status(500).json({ error: e.message });
476
756
  }
477
757
  });
478
758
 
479
759
  // POST /she/ai/chat/stream — SSE streaming
480
760
  router.post('/chat/stream', async (req, res) => {
481
- const ai = readAiConfig(req.app.locals.configPath);
482
- 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}"` });
483
764
  const effectiveModel = modelOverride && typeof modelOverride === 'string' ? modelOverride : ai?.model;
484
765
  if (!ai?.provider || !effectiveModel) {
485
766
  return res.status(400).json({ error: 'AI provider not configured. Set ai.provider and ai.model in Config.' });
@@ -488,11 +769,13 @@ router.post('/chat/stream', async (req, res) => {
488
769
  if (!Array.isArray(messages)) return res.status(400).json({ error: 'messages must be an array' });
489
770
 
490
771
  const aiWithModel = { ...ai, model: effectiveModel };
772
+ _log.debug(`ai chat: ${ai.id !== ai.provider ? ai.id + '/' : ''}${ai.provider} ${effectiveModel}${modelOverride || providerOverride ? ' (chosen in the chat)' : ' (config)'}`);
491
773
 
492
774
  // Build system prompt BEFORE flushing headers so errors can still return a proper HTTP status
493
- let systemPrompt;
775
+ let system;
776
+ const useTools = !!context.tools && (await providerSupportsTools(aiWithModel));
494
777
  try {
495
- systemPrompt = buildSystemPrompt(context, currentScript ?? null, currentView ?? null, currentDoc ?? null, _store, extraFiles || []);
778
+ system = systemMessage(context, currentScript, currentView, currentDoc, extraFiles, aiWithModel, useTools);
496
779
  } catch (e) {
497
780
  return res.status(500).json({ error: `Failed to build system prompt: ${e.message}` });
498
781
  }
@@ -506,32 +789,141 @@ router.post('/chat/stream', async (req, res) => {
506
789
 
507
790
  const send = (data) => res.write(`data: ${JSON.stringify(data)}\n\n`);
508
791
 
509
- const fullMessages = [{ role: 'system', content: systemPrompt }, ...messages];
792
+ const fullMessages = [system, ...providerMessages(messages)];
510
793
 
511
794
  try {
512
- if (context.tools) {
795
+ if (useTools) {
513
796
  // Tool-calling mode: resolve tools non-streaming (emitting events), then
514
797
  // send the final answer as a single token so the client sees it immediately.
515
- const toolContext = { store: _store, scriptDir: req.app.locals.scriptDir || null };
516
- const { message } = 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);
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));
517
812
  send({ token: message });
518
813
  } else {
519
- const onToken = (t) => send({ token: t });
814
+ let tokens = 0;
815
+ const onToken = (t) => {
816
+ tokens++;
817
+ send({ token: t });
818
+ };
819
+ let streamed;
520
820
  if (ai.provider === 'anthropic') {
521
- await streamAnthropic(aiWithModel, fullMessages, onToken);
821
+ streamed = await streamAnthropic(aiWithModel, fullMessages, onToken);
522
822
  } else {
523
823
  await streamOpenAICompat(aiWithModel, fullMessages, onToken);
524
824
  }
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));
525
827
  }
526
828
 
527
829
  res.write('data: [DONE]\n\n');
528
830
  res.end();
529
831
  } catch (e) {
832
+ _log.error('ai chat: ' + e.message);
530
833
  send({ error: e.message });
531
834
  res.end();
532
835
  }
533
836
  });
534
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
+
535
927
  // ---------------------------------------------------------------------------
536
928
  // Conversation persistence — GET/PUT/DELETE /she/ai/conversations[/:id]
537
929
  // ---------------------------------------------------------------------------
@@ -607,4 +999,24 @@ router.delete('/conversations/:id', (req, res) => {
607
999
  res.json({ ok: true });
608
1000
  });
609
1001
 
610
- module.exports = { router, init };
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
+ };