smart-home-engine 0.0.1 → 0.10.4
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/LICENSE +21 -0
- package/README.md +76 -0
- package/dist/web/assets/codicon-DCmgc-ay.ttf +0 -0
- package/dist/web/assets/index-Bdf2J0nm.js +140 -0
- package/dist/web/assets/index-DkhtWYJx.css +1 -0
- package/dist/web/assets/monaco-langs-DZ6hB11b.js +1423 -0
- package/dist/web/assets/monaco-langs-DyX1CsEw.css +1 -0
- package/dist/web/assets/tsMode-THvwQw-l.js +16 -0
- package/dist/web/index.html +164 -0
- package/dist/web/monacoeditorwork/editor.worker.bundle.js +13519 -0
- package/dist/web/monacoeditorwork/ts.worker.bundle.js +256353 -0
- package/package.json +84 -10
- package/src/config.js +53 -0
- package/src/elastic.js +19 -0
- package/src/index.js +1184 -0
- package/src/influx.js +25 -0
- package/src/lib/mqtt-wildcards.js +34 -0
- package/src/lib/parse-payload.js +29 -0
- package/src/lib/redis.js +74 -0
- package/src/lib/shedb-core.js +447 -0
- package/src/lib/shedb-worker.js +126 -0
- package/src/lib/state-store.js +97 -0
- package/src/lib/storage.js +74 -0
- package/src/matter/controller.js +307 -0
- package/src/sandbox/api.js +57 -0
- package/src/sandbox/elastic-sandbox.js +88 -0
- package/src/sandbox/influx-sandbox.js +107 -0
- package/src/sandbox/matter-sandbox.js +92 -0
- package/src/sandbox/shedb-sandbox.js +89 -0
- package/src/sandbox/stdlib.js +132 -0
- package/src/scripts/hello.js +3 -0
- package/src/web/ai-api.js +443 -0
- package/src/web/config-api.js +34 -0
- package/src/web/deps-api.js +138 -0
- package/src/web/git-api.js +188 -0
- package/src/web/log-ws.js +71 -0
- package/src/web/matter-api.js +102 -0
- package/src/web/mqtt-api.js +65 -0
- package/src/web/scripts-api.js +192 -0
- package/src/web/server.js +130 -0
- package/src/web/shedb-api.js +140 -0
- package/src/web/shedb.js +168 -0
- package/index.js +0 -0
|
@@ -0,0 +1,443 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* AI Assistant REST API — Express router mounted at /she/ai
|
|
5
|
+
*
|
|
6
|
+
* Proxies chat requests to a configured LLM provider (Ollama, LM Studio,
|
|
7
|
+
* OpenAI, or Anthropic), assembling context (MQTT state, sheDB doc IDs,
|
|
8
|
+
* Matter devices, she API reference) server-side based on per-request flags.
|
|
9
|
+
*
|
|
10
|
+
* Routes:
|
|
11
|
+
* GET /she/ai/config → { configured, provider, model, baseUrl }
|
|
12
|
+
* POST /she/ai/chat → { message, usage? } (non-streaming)
|
|
13
|
+
* POST /she/ai/chat/stream → SSE data: {"token":"..."} (streaming)
|
|
14
|
+
* data: [DONE]
|
|
15
|
+
*
|
|
16
|
+
* Call init(store) once after the state store is created.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
const express = require('express');
|
|
20
|
+
const fs = require('fs');
|
|
21
|
+
|
|
22
|
+
const router = express.Router();
|
|
23
|
+
let _store = null;
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* @param {import('../lib/state-store')} store
|
|
27
|
+
*/
|
|
28
|
+
function init(store) {
|
|
29
|
+
_store = store;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// ---------------------------------------------------------------------------
|
|
33
|
+
// she API reference — injected into the system prompt when requested
|
|
34
|
+
// ---------------------------------------------------------------------------
|
|
35
|
+
const SHE_API_REF = `## she sandbox API
|
|
36
|
+
|
|
37
|
+
Scripts run in a sandboxed VM. The \`she\` object is injected automatically.
|
|
38
|
+
|
|
39
|
+
### Script conventions
|
|
40
|
+
- First lines: /* global she */ then 'use strict';
|
|
41
|
+
- No require() — the module system is not available
|
|
42
|
+
- All subscriptions and schedules persist across reconnects
|
|
43
|
+
|
|
44
|
+
### MQTT
|
|
45
|
+
she.mqtt.sub(topic, [opts], cb) Subscribe; wildcards: + (1 level) # (multi)
|
|
46
|
+
+//sensor → +/status/sensor shorthand
|
|
47
|
+
opts.change: true = only fire when value changes
|
|
48
|
+
she.mqtt.pub(topic, payload, [opts]) Publish; opts: { qos, retain }
|
|
49
|
+
she.mqtt.get(topic) Current retained value (sync)
|
|
50
|
+
she.mqtt.set(topic, val) Publish as retained
|
|
51
|
+
she.mqtt.link(src, target, [fn]) Forward src changes to target; optional transform
|
|
52
|
+
she.mqtt.age(topic) Seconds since topic last received a message
|
|
53
|
+
she.mqtt.on('connect'|'disconnect', cb) MQTT lifecycle events
|
|
54
|
+
|
|
55
|
+
### Scheduling
|
|
56
|
+
she.schedule(pattern, [opts], cb)
|
|
57
|
+
pattern: cron string | Date | suncalc event name
|
|
58
|
+
suncalc events: 'sunrise' 'sunset' 'dawn' 'dusk'
|
|
59
|
+
'nauticalDawn' 'nauticalDusk' 'solarNoon' 'night'
|
|
60
|
+
opts.shift: seconds offset (e.g. -1800 = 30 min before event)
|
|
61
|
+
opts.random: max random delay in seconds added to the trigger time
|
|
62
|
+
|
|
63
|
+
### Universal key-value API
|
|
64
|
+
she.on(key, cb) Subscribe. Key prefixes: mqtt:: var:: matter::
|
|
65
|
+
she.set(key, val) Set value (mqtt:: or var:: namespaces)
|
|
66
|
+
she.get(key) Current value
|
|
67
|
+
she.getObject(key) Current { val, ts, lc } state object
|
|
68
|
+
|
|
69
|
+
### Variable system (var:: namespace)
|
|
70
|
+
Topics prefixed with "var" (default) are persisted as retained MQTT messages
|
|
71
|
+
and available across scripts via she.get('var::name') / she.set('var::name', v).
|
|
72
|
+
|
|
73
|
+
### sheDB
|
|
74
|
+
she.db.get(id) Get document (undefined if not found)
|
|
75
|
+
she.db.set(id, doc) Create or overwrite document
|
|
76
|
+
she.db.extend(id, partial) Deep-merge partial into existing document
|
|
77
|
+
she.db.delete(id) Delete document
|
|
78
|
+
she.db.sub(pattern, cb) Subscribe to document changes (MQTT wildcard)
|
|
79
|
+
she.db.query(filter, mapFn, [reduceFn]) Synchronous ad-hoc query → Array
|
|
80
|
+
|
|
81
|
+
### Matter
|
|
82
|
+
she.matter.sub(nodeId, endpointId, cluster, attr, cb) Subscribe to attribute
|
|
83
|
+
she.matter.unsub(listenerId)
|
|
84
|
+
she.matter.get(nodeId, endpointId, cluster, attr) → Promise<value>
|
|
85
|
+
she.matter.send(nodeId, endpointId, cluster, cmd, [args]) → Promise<result>
|
|
86
|
+
|
|
87
|
+
### Helpers
|
|
88
|
+
she.timer(src, target, ms) Pulse target=1 for ms after src goes truthy
|
|
89
|
+
she.combineBool(srcs[], target) Publish OR of source values to target
|
|
90
|
+
she.combineMax(srcs[], target) Publish maximum of source values to target
|
|
91
|
+
she.link(src, target, [fn]) Alias for she.mqtt.link
|
|
92
|
+
she.age(topic) Alias for she.mqtt.age
|
|
93
|
+
she.now() Current timestamp in ms
|
|
94
|
+
she.debug / .info / .warn / .error Structured logging (prefixed with script name)
|
|
95
|
+
she.global Shared mutable object across all scripts`;
|
|
96
|
+
|
|
97
|
+
// ---------------------------------------------------------------------------
|
|
98
|
+
// Helpers
|
|
99
|
+
// ---------------------------------------------------------------------------
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Read the ai config section from config.json.
|
|
103
|
+
* Returns null if unavailable.
|
|
104
|
+
* @param {string|undefined} configPath
|
|
105
|
+
* @returns {{ provider?: string, baseUrl?: string, model?: string, apiKey?: string }|null}
|
|
106
|
+
*/
|
|
107
|
+
function readAiConfig(configPath) {
|
|
108
|
+
if (!configPath) return null;
|
|
109
|
+
try {
|
|
110
|
+
const cfg = JSON.parse(fs.readFileSync(configPath, 'utf8'));
|
|
111
|
+
return cfg.ai || null;
|
|
112
|
+
} catch {
|
|
113
|
+
return null;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Build the full system prompt, including optional context sections.
|
|
119
|
+
*
|
|
120
|
+
* @param {object} requestCtx { apiref, mqtt, shedb, matter }
|
|
121
|
+
* @param {{ path?: string, content?: string }|null} currentScript
|
|
122
|
+
* @param {import('../lib/state-store')|null} store
|
|
123
|
+
* @returns {string}
|
|
124
|
+
*/
|
|
125
|
+
function buildSystemPrompt(requestCtx, currentScript, store) {
|
|
126
|
+
const parts = [
|
|
127
|
+
`You are SHE Assistant, an expert AI pair programmer for she (smart-home-engine).
|
|
128
|
+
she is a Node.js daemon that runs user JavaScript scripts in a sandboxed VM for home automation.
|
|
129
|
+
When proposing changes to a script, always output the COMPLETE new file content in a single fenced \`\`\`javascript code block. Never output partial diffs or fragments — the user applies the full file at once.
|
|
130
|
+
Keep any existing header comments and the 'use strict'; directive.`,
|
|
131
|
+
];
|
|
132
|
+
|
|
133
|
+
if (requestCtx.apiref) {
|
|
134
|
+
parts.push(SHE_API_REF);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
if (currentScript?.path && typeof currentScript.content === 'string') {
|
|
138
|
+
parts.push(`## Current script: ${currentScript.path}\n\`\`\`javascript\n${currentScript.content}\n\`\`\``);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
if (requestCtx.mqtt && store) {
|
|
142
|
+
const topics = [];
|
|
143
|
+
for (const [topic, obj] of store.mqttEntries()) {
|
|
144
|
+
topics.push(`${topic}: ${JSON.stringify(obj.val)}`);
|
|
145
|
+
if (topics.length >= 100) {
|
|
146
|
+
topics.push('… (truncated)');
|
|
147
|
+
break;
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
if (topics.length > 0) {
|
|
151
|
+
parts.push(`## Current MQTT state\n${topics.join('\n')}`);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
if (requestCtx.shedb) {
|
|
156
|
+
try {
|
|
157
|
+
const core = require('./shedb').getCore();
|
|
158
|
+
if (core) {
|
|
159
|
+
const ids = typeof core.listIds === 'function' ? core.listIds() : [];
|
|
160
|
+
if (ids.length > 0) {
|
|
161
|
+
parts.push(`## sheDB document IDs (${ids.length} total)\n${ids.slice(0, 200).join('\n')}`);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
} catch {
|
|
165
|
+
// shedb not initialised — skip silently
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
if (requestCtx.matter) {
|
|
170
|
+
try {
|
|
171
|
+
const controller = require('../matter/controller');
|
|
172
|
+
if (typeof controller.listPaired === 'function') {
|
|
173
|
+
const nodes = controller.listPaired();
|
|
174
|
+
if (nodes.length > 0) {
|
|
175
|
+
const list = nodes.map((n) => ` nodeId ${n.nodeId}: ${n.label || 'unnamed'}`).join('\n');
|
|
176
|
+
parts.push(`## Paired Matter devices\n${list}`);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
} catch {
|
|
180
|
+
// matter not initialised — skip silently
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
return parts.join('\n\n');
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// ---------------------------------------------------------------------------
|
|
188
|
+
// Provider adapters — non-streaming
|
|
189
|
+
// ---------------------------------------------------------------------------
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* @param {{ baseUrl?: string, model: string, apiKey?: string }} config
|
|
193
|
+
* @param {Array<{role:string,content:string}>} messages
|
|
194
|
+
*/
|
|
195
|
+
async function callOpenAICompat(config, messages) {
|
|
196
|
+
const base = (config.baseUrl || 'http://localhost:11434').replace(/\/$/, '');
|
|
197
|
+
const url = `${base}/v1/chat/completions`;
|
|
198
|
+
const headers = { 'Content-Type': 'application/json' };
|
|
199
|
+
if (config.apiKey) headers['Authorization'] = `Bearer ${config.apiKey}`;
|
|
200
|
+
|
|
201
|
+
const res = await fetch(url, {
|
|
202
|
+
method: 'POST',
|
|
203
|
+
headers,
|
|
204
|
+
body: JSON.stringify({ model: config.model, messages, stream: false }),
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
if (!res.ok) {
|
|
208
|
+
const text = await res.text().catch(() => '');
|
|
209
|
+
throw new Error(`LLM API error ${res.status}: ${text.slice(0, 300)}`);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
const json = await res.json();
|
|
213
|
+
const choice = json.choices?.[0];
|
|
214
|
+
const message = choice?.message?.content ?? choice?.text ?? '';
|
|
215
|
+
const usage = json.usage
|
|
216
|
+
? {
|
|
217
|
+
prompt_tokens: json.usage.prompt_tokens,
|
|
218
|
+
completion_tokens: json.usage.completion_tokens,
|
|
219
|
+
}
|
|
220
|
+
: undefined;
|
|
221
|
+
return { message, usage };
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* @param {{ model: string, apiKey?: string }} config
|
|
226
|
+
* @param {Array<{role:string,content:string}>} messages — first may be role:'system'
|
|
227
|
+
*/
|
|
228
|
+
async function callAnthropic(config, messages) {
|
|
229
|
+
const systemMsg = messages.find((m) => m.role === 'system');
|
|
230
|
+
const userMessages = messages.filter((m) => m.role !== 'system');
|
|
231
|
+
|
|
232
|
+
const headers = {
|
|
233
|
+
'Content-Type': 'application/json',
|
|
234
|
+
'x-api-key': config.apiKey || '',
|
|
235
|
+
'anthropic-version': '2023-06-01',
|
|
236
|
+
};
|
|
237
|
+
|
|
238
|
+
const res = await fetch('https://api.anthropic.com/v1/messages', {
|
|
239
|
+
method: 'POST',
|
|
240
|
+
headers,
|
|
241
|
+
body: JSON.stringify({
|
|
242
|
+
model: config.model,
|
|
243
|
+
system: systemMsg?.content || '',
|
|
244
|
+
messages: userMessages,
|
|
245
|
+
max_tokens: 4096,
|
|
246
|
+
}),
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
if (!res.ok) {
|
|
250
|
+
const text = await res.text().catch(() => '');
|
|
251
|
+
throw new Error(`Anthropic API error ${res.status}: ${text.slice(0, 300)}`);
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
const json = await res.json();
|
|
255
|
+
const message = json.content?.[0]?.text ?? '';
|
|
256
|
+
const usage = json.usage
|
|
257
|
+
? {
|
|
258
|
+
prompt_tokens: json.usage.input_tokens,
|
|
259
|
+
completion_tokens: json.usage.output_tokens,
|
|
260
|
+
}
|
|
261
|
+
: undefined;
|
|
262
|
+
return { message, usage };
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
// ---------------------------------------------------------------------------
|
|
266
|
+
// Provider adapters — streaming
|
|
267
|
+
// ---------------------------------------------------------------------------
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* Parse an SSE ReadableStream, calling onToken for each non-null extracted value.
|
|
271
|
+
* @param {ReadableStream} body
|
|
272
|
+
* @param {(json:object)=>string|null|undefined} tokenExtractor
|
|
273
|
+
* @param {(token:string)=>void} onToken
|
|
274
|
+
*/
|
|
275
|
+
async function parseSseStream(body, tokenExtractor, onToken) {
|
|
276
|
+
const reader = body.getReader();
|
|
277
|
+
const decoder = new TextDecoder();
|
|
278
|
+
let buffer = '';
|
|
279
|
+
|
|
280
|
+
try {
|
|
281
|
+
while (true) {
|
|
282
|
+
const { done, value } = await reader.read();
|
|
283
|
+
if (done) break;
|
|
284
|
+
buffer += decoder.decode(value, { stream: true });
|
|
285
|
+
const lines = buffer.split('\n');
|
|
286
|
+
buffer = lines.pop() ?? '';
|
|
287
|
+
|
|
288
|
+
for (const line of lines) {
|
|
289
|
+
if (!line.startsWith('data: ')) continue;
|
|
290
|
+
const data = line.slice(6).trim();
|
|
291
|
+
if (data === '[DONE]') return;
|
|
292
|
+
try {
|
|
293
|
+
const json = JSON.parse(data);
|
|
294
|
+
const token = tokenExtractor(json);
|
|
295
|
+
if (token) onToken(token);
|
|
296
|
+
} catch {
|
|
297
|
+
// skip malformed JSON lines
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
} finally {
|
|
302
|
+
reader.releaseLock();
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/**
|
|
307
|
+
* Stream tokens from an OpenAI-compatible endpoint.
|
|
308
|
+
* Calls onToken(str) for each chunk, resolves when stream ends.
|
|
309
|
+
*/
|
|
310
|
+
async function streamOpenAICompat(config, messages, onToken) {
|
|
311
|
+
const base = (config.baseUrl || 'http://localhost:11434').replace(/\/$/, '');
|
|
312
|
+
const url = `${base}/v1/chat/completions`;
|
|
313
|
+
const headers = { 'Content-Type': 'application/json' };
|
|
314
|
+
if (config.apiKey) headers['Authorization'] = `Bearer ${config.apiKey}`;
|
|
315
|
+
|
|
316
|
+
const res = await fetch(url, {
|
|
317
|
+
method: 'POST',
|
|
318
|
+
headers,
|
|
319
|
+
body: JSON.stringify({ model: config.model, messages, stream: true }),
|
|
320
|
+
});
|
|
321
|
+
|
|
322
|
+
if (!res.ok) {
|
|
323
|
+
const text = await res.text().catch(() => '');
|
|
324
|
+
throw new Error(`LLM API error ${res.status}: ${text.slice(0, 300)}`);
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
await parseSseStream(res.body, (json) => json.choices?.[0]?.delta?.content, onToken);
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/**
|
|
331
|
+
* Stream tokens from Anthropic Messages API.
|
|
332
|
+
*/
|
|
333
|
+
async function streamAnthropic(config, messages, onToken) {
|
|
334
|
+
const systemMsg = messages.find((m) => m.role === 'system');
|
|
335
|
+
const userMessages = messages.filter((m) => m.role !== 'system');
|
|
336
|
+
|
|
337
|
+
const headers = {
|
|
338
|
+
'Content-Type': 'application/json',
|
|
339
|
+
'x-api-key': config.apiKey || '',
|
|
340
|
+
'anthropic-version': '2023-06-01',
|
|
341
|
+
};
|
|
342
|
+
|
|
343
|
+
const res = await fetch('https://api.anthropic.com/v1/messages', {
|
|
344
|
+
method: 'POST',
|
|
345
|
+
headers,
|
|
346
|
+
body: JSON.stringify({
|
|
347
|
+
model: config.model,
|
|
348
|
+
system: systemMsg?.content || '',
|
|
349
|
+
messages: userMessages,
|
|
350
|
+
max_tokens: 4096,
|
|
351
|
+
stream: true,
|
|
352
|
+
}),
|
|
353
|
+
});
|
|
354
|
+
|
|
355
|
+
if (!res.ok) {
|
|
356
|
+
const text = await res.text().catch(() => '');
|
|
357
|
+
throw new Error(`Anthropic API error ${res.status}: ${text.slice(0, 300)}`);
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
await parseSseStream(res.body, (json) => json.delta?.text, onToken);
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
// ---------------------------------------------------------------------------
|
|
364
|
+
// Routes
|
|
365
|
+
// ---------------------------------------------------------------------------
|
|
366
|
+
|
|
367
|
+
// GET /she/ai/config
|
|
368
|
+
router.get('/config', (req, res) => {
|
|
369
|
+
const ai = readAiConfig(req.app.locals.configPath);
|
|
370
|
+
res.json({
|
|
371
|
+
configured: !!(ai?.provider && ai?.model),
|
|
372
|
+
provider: ai?.provider || '',
|
|
373
|
+
model: ai?.model || '',
|
|
374
|
+
baseUrl: ai?.baseUrl || '',
|
|
375
|
+
});
|
|
376
|
+
});
|
|
377
|
+
|
|
378
|
+
// POST /she/ai/chat — non-streaming
|
|
379
|
+
router.post('/chat', async (req, res) => {
|
|
380
|
+
const ai = readAiConfig(req.app.locals.configPath);
|
|
381
|
+
if (!ai?.provider || !ai?.model) {
|
|
382
|
+
return res.status(400).json({ error: 'AI provider not configured. Set ai.provider and ai.model in Config.' });
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
const { messages = [], currentScript, context = {} } = req.body || {};
|
|
386
|
+
if (!Array.isArray(messages)) return res.status(400).json({ error: 'messages must be an array' });
|
|
387
|
+
|
|
388
|
+
const systemPrompt = buildSystemPrompt(context, currentScript ?? null, _store);
|
|
389
|
+
const fullMessages = [{ role: 'system', content: systemPrompt }, ...messages];
|
|
390
|
+
|
|
391
|
+
try {
|
|
392
|
+
let result;
|
|
393
|
+
if (ai.provider === 'anthropic') {
|
|
394
|
+
result = await callAnthropic(ai, fullMessages);
|
|
395
|
+
} else {
|
|
396
|
+
result = await callOpenAICompat(ai, fullMessages);
|
|
397
|
+
}
|
|
398
|
+
res.json(result);
|
|
399
|
+
} catch (e) {
|
|
400
|
+
res.status(500).json({ error: e.message });
|
|
401
|
+
}
|
|
402
|
+
});
|
|
403
|
+
|
|
404
|
+
// POST /she/ai/chat/stream — SSE streaming
|
|
405
|
+
router.post('/chat/stream', async (req, res) => {
|
|
406
|
+
const ai = readAiConfig(req.app.locals.configPath);
|
|
407
|
+
if (!ai?.provider || !ai?.model) {
|
|
408
|
+
return res.status(400).json({ error: 'AI provider not configured. Set ai.provider and ai.model in Config.' });
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
const { messages = [], currentScript, context = {} } = req.body || {};
|
|
412
|
+
if (!Array.isArray(messages)) return res.status(400).json({ error: 'messages must be an array' });
|
|
413
|
+
|
|
414
|
+
res.set({
|
|
415
|
+
'Content-Type': 'text/event-stream',
|
|
416
|
+
'Cache-Control': 'no-cache',
|
|
417
|
+
'Connection': 'keep-alive',
|
|
418
|
+
});
|
|
419
|
+
res.flushHeaders();
|
|
420
|
+
|
|
421
|
+
const send = (data) => res.write(`data: ${JSON.stringify(data)}\n\n`);
|
|
422
|
+
|
|
423
|
+
const systemPrompt = buildSystemPrompt(context, currentScript ?? null, _store);
|
|
424
|
+
const fullMessages = [{ role: 'system', content: systemPrompt }, ...messages];
|
|
425
|
+
|
|
426
|
+
try {
|
|
427
|
+
const onToken = (t) => send({ token: t });
|
|
428
|
+
|
|
429
|
+
if (ai.provider === 'anthropic') {
|
|
430
|
+
await streamAnthropic(ai, fullMessages, onToken);
|
|
431
|
+
} else {
|
|
432
|
+
await streamOpenAICompat(ai, fullMessages, onToken);
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
res.write('data: [DONE]\n\n');
|
|
436
|
+
res.end();
|
|
437
|
+
} catch (e) {
|
|
438
|
+
send({ error: e.message });
|
|
439
|
+
res.end();
|
|
440
|
+
}
|
|
441
|
+
});
|
|
442
|
+
|
|
443
|
+
module.exports = { router, init };
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const express = require('express');
|
|
4
|
+
const fs = require('fs');
|
|
5
|
+
const path = require('path');
|
|
6
|
+
const { getConfigPath } = require('../lib/storage');
|
|
7
|
+
|
|
8
|
+
const DEFAULT_CONFIG_PATH = getConfigPath();
|
|
9
|
+
|
|
10
|
+
const router = express.Router();
|
|
11
|
+
|
|
12
|
+
router.get('/', (req, res) => {
|
|
13
|
+
const configPath = req.app.locals.configPath || DEFAULT_CONFIG_PATH;
|
|
14
|
+
let fileConfig = {};
|
|
15
|
+
try {
|
|
16
|
+
fileConfig = JSON.parse(fs.readFileSync(configPath, 'utf8'));
|
|
17
|
+
} catch {
|
|
18
|
+
// config file does not exist yet — return empty object
|
|
19
|
+
}
|
|
20
|
+
res.json(fileConfig);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
router.put('/', (req, res) => {
|
|
24
|
+
const configPath = req.app.locals.configPath || DEFAULT_CONFIG_PATH;
|
|
25
|
+
try {
|
|
26
|
+
fs.mkdirSync(path.dirname(configPath), { recursive: true });
|
|
27
|
+
fs.writeFileSync(configPath, JSON.stringify(req.body, null, 2), 'utf8');
|
|
28
|
+
res.json({ ok: true, restartRequired: true, configPath });
|
|
29
|
+
} catch (err) {
|
|
30
|
+
res.status(500).json({ error: err.message });
|
|
31
|
+
}
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
module.exports = { router, DEFAULT_CONFIG_PATH };
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const express = require('express');
|
|
4
|
+
const { execFile } = require('child_process');
|
|
5
|
+
const fs = require('fs');
|
|
6
|
+
const path = require('path');
|
|
7
|
+
const https = require('https');
|
|
8
|
+
const { STORAGE_ROOT } = require('../lib/storage');
|
|
9
|
+
|
|
10
|
+
const router = express.Router();
|
|
11
|
+
|
|
12
|
+
/** Ensure ~/.she/package.json exists so npm commands work. */
|
|
13
|
+
function ensurePackageJson() {
|
|
14
|
+
const pkgPath = path.join(STORAGE_ROOT, 'package.json');
|
|
15
|
+
if (!fs.existsSync(pkgPath)) {
|
|
16
|
+
fs.writeFileSync(
|
|
17
|
+
pkgPath,
|
|
18
|
+
JSON.stringify(
|
|
19
|
+
{
|
|
20
|
+
name: 'she-user-scripts',
|
|
21
|
+
version: '1.0.0',
|
|
22
|
+
private: true,
|
|
23
|
+
description: 'User-installed npm packages for she scripts',
|
|
24
|
+
dependencies: {},
|
|
25
|
+
},
|
|
26
|
+
null,
|
|
27
|
+
2,
|
|
28
|
+
) + '\n',
|
|
29
|
+
'utf8',
|
|
30
|
+
);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function readPackageJson() {
|
|
35
|
+
try {
|
|
36
|
+
return JSON.parse(fs.readFileSync(path.join(STORAGE_ROOT, 'package.json'), 'utf8'));
|
|
37
|
+
} catch {
|
|
38
|
+
return { dependencies: {} };
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Strict npm package-name validation.
|
|
44
|
+
* Allows scoped (@scope/name) and plain names; lowercase; no path traversal.
|
|
45
|
+
*/
|
|
46
|
+
function isValidPkgName(name) {
|
|
47
|
+
return typeof name === 'string' && name.length > 0 && name.length <= 214 && /^(@[a-z0-9][a-z0-9_\-.]*\/)?[a-z0-9][a-z0-9_\-.]*$/.test(name);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Allow semver ranges, tags, and dist-tags (no shell-special chars). */
|
|
51
|
+
function isValidVersion(v) {
|
|
52
|
+
return typeof v === 'string' && v.length > 0 && v.length <= 50 && /^[a-z0-9_\-.*^~>=<|]+$/i.test(v);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// GET /she/deps — list installed packages from ~/.she/package.json
|
|
56
|
+
router.get('/', (req, res) => {
|
|
57
|
+
const pkg = readPackageJson();
|
|
58
|
+
const deps = pkg.dependencies || {};
|
|
59
|
+
res.json(Object.entries(deps).map(([name, version]) => ({ name, version })));
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
// GET /she/deps/search?q=term — search the npm registry
|
|
63
|
+
router.get('/search', (req, res) => {
|
|
64
|
+
const q = String(req.query.q ?? '').trim();
|
|
65
|
+
if (!q) return res.status(400).json({ error: 'Missing q parameter' });
|
|
66
|
+
|
|
67
|
+
const url = `https://registry.npmjs.org/-/v1/search?text=${encodeURIComponent(q)}&size=20`;
|
|
68
|
+
const npmReq = https.get(url, { timeout: 10000 }, (npmRes) => {
|
|
69
|
+
let data = '';
|
|
70
|
+
npmRes.on('data', (chunk) => {
|
|
71
|
+
data += chunk;
|
|
72
|
+
});
|
|
73
|
+
npmRes.on('end', () => {
|
|
74
|
+
try {
|
|
75
|
+
const parsed = JSON.parse(data);
|
|
76
|
+
const results = (parsed.objects ?? []).map((obj) => ({
|
|
77
|
+
name: obj.package.name,
|
|
78
|
+
version: obj.package.version,
|
|
79
|
+
description: obj.package.description ?? '',
|
|
80
|
+
}));
|
|
81
|
+
res.json(results);
|
|
82
|
+
} catch {
|
|
83
|
+
if (!res.headersSent) res.status(502).json({ error: 'Failed to parse npm registry response' });
|
|
84
|
+
}
|
|
85
|
+
});
|
|
86
|
+
});
|
|
87
|
+
npmReq.on('error', (err) => {
|
|
88
|
+
if (!res.headersSent) res.status(502).json({ error: err.message });
|
|
89
|
+
});
|
|
90
|
+
npmReq.on('timeout', () => {
|
|
91
|
+
npmReq.destroy();
|
|
92
|
+
if (!res.headersSent) res.status(504).json({ error: 'npm registry timeout' });
|
|
93
|
+
});
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
// POST /she/deps/install — { name, version? }
|
|
97
|
+
router.post('/install', (req, res) => {
|
|
98
|
+
const name = String(req.body?.name ?? '').trim();
|
|
99
|
+
const version = req.body?.version ? String(req.body.version).trim() : null;
|
|
100
|
+
|
|
101
|
+
if (!isValidPkgName(name)) return res.status(400).json({ error: 'Invalid package name' });
|
|
102
|
+
if (version !== null && !isValidVersion(version)) return res.status(400).json({ error: 'Invalid version specifier' });
|
|
103
|
+
|
|
104
|
+
ensurePackageJson();
|
|
105
|
+
const spec = version ? `${name}@${version}` : name;
|
|
106
|
+
execFile('npm', ['install', '--save', spec], { cwd: STORAGE_ROOT, timeout: 120000 }, (err, stdout, stderr) => {
|
|
107
|
+
if (err) return res.status(500).json({ error: stderr || err.message, stdout });
|
|
108
|
+
res.json({ ok: true, stdout, stderr });
|
|
109
|
+
});
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
// POST /she/deps/remove — { name }
|
|
113
|
+
router.post('/remove', (req, res) => {
|
|
114
|
+
const name = String(req.body?.name ?? '').trim();
|
|
115
|
+
|
|
116
|
+
if (!isValidPkgName(name)) return res.status(400).json({ error: 'Invalid package name' });
|
|
117
|
+
|
|
118
|
+
ensurePackageJson();
|
|
119
|
+
execFile('npm', ['uninstall', '--save', name], { cwd: STORAGE_ROOT, timeout: 60000 }, (err, stdout, stderr) => {
|
|
120
|
+
if (err) return res.status(500).json({ error: stderr || err.message, stdout });
|
|
121
|
+
res.json({ ok: true, stdout, stderr });
|
|
122
|
+
});
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
// POST /she/deps/update — { name }
|
|
126
|
+
router.post('/update', (req, res) => {
|
|
127
|
+
const name = String(req.body?.name ?? '').trim();
|
|
128
|
+
|
|
129
|
+
if (!isValidPkgName(name)) return res.status(400).json({ error: 'Invalid package name' });
|
|
130
|
+
|
|
131
|
+
ensurePackageJson();
|
|
132
|
+
execFile('npm', ['install', '--save', `${name}@latest`], { cwd: STORAGE_ROOT, timeout: 120000 }, (err, stdout, stderr) => {
|
|
133
|
+
if (err) return res.status(500).json({ error: stderr || err.message, stdout });
|
|
134
|
+
res.json({ ok: true, stdout, stderr });
|
|
135
|
+
});
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
module.exports = { router };
|