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/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-4A8DZYIU.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 +481 -69
- 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 +941 -66
- 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-CSKTxEh5.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;
|
|
@@ -30,7 +33,27 @@ let _store = null;
|
|
|
30
33
|
/**
|
|
31
34
|
* @param {import('../lib/state-store')} store
|
|
32
35
|
*/
|
|
33
|
-
|
|
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
|
-
|
|
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
|
|
194
|
+
system: anthropicSystem(systemMsg),
|
|
124
195
|
messages: userMessages,
|
|
125
|
-
|
|
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
|
-
|
|
155
|
-
|
|
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 =
|
|
376
|
+
const tools = toolsFor(ai, toolContext);
|
|
178
377
|
let msgs = messages;
|
|
179
|
-
let toolsUsed = false;
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
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,
|
|
387
|
+
result = isAnthropic ? await callAnthropic(ai, msgs, tools) : await callOpenAICompat(ai, msgs, tools);
|
|
191
388
|
} catch (e) {
|
|
192
|
-
if (round === 0
|
|
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
|
-
|
|
203
|
-
|
|
204
|
-
const nudgeMsgs = [
|
|
205
|
-
|
|
206
|
-
|
|
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
|
-
//
|
|
215
|
-
|
|
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
|
|
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
|
|
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
|
-
//
|
|
249
|
-
|
|
250
|
-
|
|
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
|
|
546
|
+
system: anthropicSystem(systemMsg),
|
|
337
547
|
messages: userMessages,
|
|
338
|
-
max_tokens:
|
|
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
|
-
|
|
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(
|
|
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
|
|
442
|
-
|
|
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
|
|
451
|
-
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}"` });
|
|
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
|
-
|
|
461
|
-
const
|
|
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 (
|
|
466
|
-
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
|
+
};
|
|
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
|
-
|
|
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
|
|
482
|
-
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}"` });
|
|
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
|
|
775
|
+
let system;
|
|
776
|
+
const useTools = !!context.tools && (await providerSupportsTools(aiWithModel));
|
|
494
777
|
try {
|
|
495
|
-
|
|
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 = [
|
|
792
|
+
const fullMessages = [system, ...providerMessages(messages)];
|
|
510
793
|
|
|
511
794
|
try {
|
|
512
|
-
if (
|
|
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 = {
|
|
516
|
-
|
|
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
|
-
|
|
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 = {
|
|
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
|
+
};
|