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/README.md +1 -0
- package/dist/web/assets/index-BswjzIm1.css +1 -0
- package/dist/web/assets/index-CD6lY3HK.js +296 -0
- package/dist/web/assets/{tsMode-sNUSGo5H.js → tsMode-CQwwRC_t.js} +1 -1
- package/dist/web/index.html +2 -2
- package/package.json +1 -1
- package/src/index.js +67 -47
- package/src/lib/log-file.js +81 -0
- package/src/lib/logger.js +33 -0
- package/src/lib/script-timers.js +1 -0
- package/src/lib/text-diff.js +116 -0
- package/src/web/ai-api.js +364 -44
- package/src/web/ai-context.js +172 -61
- package/src/web/ai-drafts.js +68 -0
- package/src/web/ai-memory.js +106 -0
- package/src/web/ai-tools.js +938 -70
- package/src/web/log-ws.js +27 -7
- package/src/web/prompts/api-ref-compact.md +16 -0
- package/src/web/prompts/api-ref.md +60 -0
- package/src/web/prompts/compact.md +5 -0
- package/src/web/prompts/conventions.md +5 -0
- package/src/web/prompts/db-doc.md +1 -1
- package/src/web/prompts/db-view.md +3 -3
- package/src/web/prompts/formats.md +14 -0
- package/src/web/prompts/role.md +6 -0
- package/src/web/prompts/tools.md +9 -0
- package/dist/web/assets/index-D6H4drtd.js +0 -295
- package/dist/web/assets/index-DPDW78ru.css +0 -1
- package/src/web/prompts/scripts-base.md +0 -25
- package/src/web/prompts/she-api-ref.md +0 -94
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
|
-
|
|
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
|
|
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 =
|
|
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
|
|
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
|
|
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
|
|
546
|
+
system: anthropicSystem(systemMsg),
|
|
382
547
|
messages: userMessages,
|
|
383
|
-
max_tokens:
|
|
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
|
-
|
|
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
|
|
523
|
-
|
|
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
|
|
532
|
-
const
|
|
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
|
|
543
|
-
const fullMessages = [
|
|
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 (
|
|
548
|
-
const toolContext = {
|
|
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
|
|
566
|
-
const
|
|
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
|
|
775
|
+
let system;
|
|
776
|
+
const useTools = !!context.tools && (await providerSupportsTools(aiWithModel));
|
|
579
777
|
try {
|
|
580
|
-
|
|
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 = [
|
|
792
|
+
const fullMessages = [system, ...providerMessages(messages)];
|
|
595
793
|
|
|
596
794
|
try {
|
|
597
|
-
if (
|
|
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 = {
|
|
601
|
-
|
|
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 = {
|
|
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
|
+
};
|