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.
@@ -1,92 +1,136 @@
1
1
  'use strict';
2
2
 
3
3
  /**
4
- * AI context builder — assembles the full system prompt from per-request flags,
5
- * the current editor context (script or DB view/document), and live data
6
- * from the running daemon (MQTT state, sheDB, Matter devices).
4
+ * AI context builder (roadmap I25) — assembles the system prompt from sections:
7
5
  *
8
- * Kept separate from ai-api.js so prompt files and context logic can evolve
9
- * independently of the HTTP routing / provider adapter code.
6
+ * static (the same for every request of a mode and profile; cached by Anthropic, I22):
7
+ * role · conventions · api reference (full or compact) · tools (when offered) · formats · compact steps
8
+ * dynamic (per request):
9
+ * the house (derived from the daemon: name, prefix, topic prefixes, adapters, the user's notes) ·
10
+ * the current script / view / document · sheDB ids and samples · attachments
11
+ *
12
+ * Profiles: 'capable' for the current hosted models, 'compact' for small local ones — the facts are the same
13
+ * files, the compact profile gets the shorter reference, explicit steps and a budget. Nothing of an installation
14
+ * is in these files: the house section is computed at request time.
10
15
  */
11
16
 
12
17
  const fs = require('fs');
13
18
  const path = require('path');
14
19
 
15
- // Load prompt templates once at startup — plain Markdown files, no escaping needed
16
20
  const P = path.join(__dirname, 'prompts');
17
- const SCRIPTS_BASE_PROMPT = fs.readFileSync(path.join(P, 'scripts-base.md'), 'utf8').trim();
18
- const SHE_API_REF = fs.readFileSync(path.join(P, 'she-api-ref.md'), 'utf8').trim();
19
- const DB_VIEW_PROMPT = fs.readFileSync(path.join(P, 'db-view.md'), 'utf8').trim();
20
- const DB_DOC_PROMPT = fs.readFileSync(path.join(P, 'db-doc.md'), 'utf8').trim();
21
+ const read = (n) => fs.readFileSync(path.join(P, n), 'utf8').trim();
22
+ const ROLE = read('role.md');
23
+ const CONVENTIONS = read('conventions.md');
24
+ const API_REF = read('api-ref.md');
25
+ const API_REF_COMPACT = read('api-ref-compact.md');
26
+ const FORMATS = read('formats.md');
27
+ const TOOLS = read('tools.md');
28
+ const COMPACT_STEPS = read('compact.md');
29
+ const DB_VIEW_PROMPT = read('db-view.md');
30
+ const DB_DOC_PROMPT = read('db-doc.md');
31
+
32
+ const DEFAULT_COMPACT_BUDGET = 24000; // characters, about 6k tokens: a small local model's comfortable share for the prompt
21
33
 
22
34
  /**
23
- * Build the full system prompt, including optional context sections.
24
- *
25
- * @param {object} requestCtx
26
- * { apiref, mqtt, shedb, matter, sampleDocs }
27
- * @param {{ path?: string, content?: string } | null} currentScript
28
- * @param {{ id?: string, filter?: string, map?: string, reduce?: string } | null} currentView
29
- * @param {{ id?: string, content?: string } | null} currentDoc
30
- * @param {import('../lib/state-store') | null} store
31
- * @returns {string}
35
+ * The derived house section: what this installation looks like, from the running daemon — never from a file.
36
+ * @param {{ name?: string, variablePrefix?: string, version?: string } | null} cfg
37
+ * @param {Iterable<[string, object]> | null} entries the state store's mqtt entries
38
+ * @param {Array<{instance: string, adapter?: string|null, connected?: number|null}>} instances
39
+ * @param {Array<{text: string}>} notes the user's confirmed facts (roadmap I29)
32
40
  */
33
- function buildSystemPrompt(requestCtx, currentScript, currentView, currentDoc, store, extraFiles) {
34
- const isViewMode = !!currentView?.id;
35
- const isDocMode = !!currentDoc?.id;
36
-
37
- let basePrompt;
38
- if (isViewMode) {
39
- basePrompt = DB_VIEW_PROMPT;
40
- } else if (isDocMode) {
41
- basePrompt = DB_DOC_PROMPT;
42
- } else {
43
- basePrompt = SCRIPTS_BASE_PROMPT;
41
+ function houseSection(cfg, entries, instances, notes) {
42
+ const lines = ['## This installation'];
43
+ if (cfg?.name)
44
+ lines.push(
45
+ `- the daemon's MQTT name is \`${cfg.name}\`${cfg.version ? ` (she ${cfg.version})` : ''}; its variables live under \`${cfg.variablePrefix || 'var'}/status/…\` and \`${cfg.variablePrefix || 'var'}/set/…\``,
46
+ );
47
+ if (entries) {
48
+ const counts = new Map();
49
+ let total = 0;
50
+ for (const [topic] of entries) {
51
+ total++;
52
+ const first = topic.split('/')[0];
53
+ counts.set(first, (counts.get(first) || 0) + 1);
54
+ }
55
+ const top = [...counts.entries()].sort((a, b) => b[1] - a[1]).slice(0, 15);
56
+ if (total) lines.push(`- about ${roundish(total)} topics on the broker; the largest prefixes: ${top.map(([k, v]) => `\`${k}/\` (${roundish(v)})`).join(', ')}`);
57
+ }
58
+ const known = (instances || []).filter((i) => i.adapter).map((i) => `\`${i.instance}/\` ${i.adapter}${i.connected === 0 ? ' (offline)' : ''}`);
59
+ if (known.length) lines.push(`- adapter instances: ${known.slice(0, 20).join(', ')}${known.length > 20 ? ` … (${known.length})` : ''}`);
60
+ if (notes && notes.length) {
61
+ lines.push('- facts the user confirmed earlier (trust them over guesses):');
62
+ for (const n of notes.slice(0, 300)) lines.push(` - [${n.id}] ${String(n.text).replace(/\s+/g, ' ').slice(0, 200)}`);
44
63
  }
64
+ return lines.length > 1 ? lines.join('\n') : '';
65
+ }
66
+
67
+ /** 8702 → "8700", 143 → "140", 12 → "12": stable across requests, so the cached prefix is not disturbed by every message */
68
+ function roundish(n) {
69
+ if (n < 20) return String(n);
70
+ const m = Math.pow(10, Math.floor(Math.log10(n)) - 1);
71
+ return String(Math.round(n / m) * m);
72
+ }
73
+
74
+ /**
75
+ * Build the full system prompt as one string (the OpenAI-compatible providers, the /prompt route).
76
+ */
77
+ function buildSystemPrompt(requestCtx, currentScript, currentView, currentDoc, store, extraFiles, opts) {
78
+ const { staticText, dynamicText } = buildSystemPromptParts(requestCtx, currentScript, currentView, currentDoc, store, extraFiles, opts);
79
+ return dynamicText ? staticText + '\n\n' + dynamicText : staticText;
80
+ }
45
81
 
46
- const parts = [basePrompt];
82
+ /**
83
+ * The prompt in its static and dynamic part.
84
+ * @param {object} requestCtx { apiref, tools, shedb, sampleDocs }
85
+ * @param {{ profile?: 'capable'|'compact', toolsOffered?: boolean, budgetChars?: number, house?: string }} [opts]
86
+ * @returns {{ staticText: string, dynamicText: string, dropped: string[] }}
87
+ */
88
+ function buildSystemPromptParts(requestCtx, currentScript, currentView, currentDoc, store, extraFiles, opts = {}) {
89
+ const profile = opts.profile === 'compact' ? 'compact' : 'capable';
90
+ const isViewMode = !!currentView?.id;
91
+ const isDocMode = !!currentDoc?.id;
92
+ const toolsOffered = opts.toolsOffered !== undefined ? !!opts.toolsOffered : !!requestCtx.tools;
47
93
 
48
- if (requestCtx.apiref && !isViewMode && !isDocMode) {
49
- parts.push(SHE_API_REF);
94
+ const staticParts = [];
95
+ if (isViewMode) staticParts.push(DB_VIEW_PROMPT);
96
+ else if (isDocMode) staticParts.push(DB_DOC_PROMPT);
97
+ else {
98
+ staticParts.push(ROLE, CONVENTIONS);
99
+ if (requestCtx.apiref !== false) staticParts.push(profile === 'compact' ? API_REF_COMPACT : API_REF);
100
+ if (toolsOffered) staticParts.push(TOOLS);
101
+ staticParts.push(FORMATS);
102
+ if (profile === 'compact') staticParts.push(COMPACT_STEPS);
50
103
  }
51
104
 
105
+ // dynamic sections, each with a name so the budget can drop the least important first
106
+ const sections = [];
107
+ if (opts.house) sections.push({ name: 'house', text: opts.house });
52
108
  if (currentScript?.path && typeof currentScript.content === 'string') {
53
- parts.push(`## Current script: ${currentScript.path}\n\`\`\`javascript\n${currentScript.content}\n\`\`\``);
109
+ sections.push({ name: 'script', text: `## Current script: ${currentScript.path}\n\`\`\`javascript\n${currentScript.content}\n\`\`\`` });
54
110
  }
55
-
56
111
  if (currentView?.id) {
57
112
  const filterStr = (currentView.filter || '').trim();
58
113
  const mapBody = (currentView.map || '').trim();
59
114
  const reduceBody = (currentView.reduce || '').trim();
60
- const viewLines = [`## Current view: ${currentView.id}`];
61
- viewLines.push(`Filter: ${filterStr || '(none)'}`);
62
- viewLines.push(`Map:\n\`\`\`javascript\n${mapBody || '// (empty)'}\n\`\`\``);
63
- if (reduceBody) {
64
- viewLines.push(`Reduce:\n\`\`\`javascript\n${reduceBody}\n\`\`\``);
65
- } else {
66
- viewLines.push('Reduce: (none)');
67
- }
68
- parts.push(viewLines.join('\n'));
115
+ const viewLines = [`## Current view: ${currentView.id}`, `Filter: ${filterStr || '(none)'}`, `Map:\n\`\`\`javascript\n${mapBody || '// (empty)'}\n\`\`\``];
116
+ viewLines.push(reduceBody ? `Reduce:\n\`\`\`javascript\n${reduceBody}\n\`\`\`` : 'Reduce: (none)');
117
+ sections.push({ name: 'view', text: viewLines.join('\n') });
69
118
  }
70
-
71
119
  if (currentDoc?.id) {
72
120
  const content = typeof currentDoc.content === 'string' ? currentDoc.content : JSON.stringify(currentDoc.content, null, 2);
73
- parts.push(`## Current document: ${currentDoc.id}\n\`\`\`json\n${content}\n\`\`\``);
121
+ sections.push({ name: 'doc', text: `## Current document: ${currentDoc.id}\n\`\`\`json\n${content}\n\`\`\`` });
74
122
  }
75
-
76
123
  if (requestCtx.shedb) {
77
124
  try {
78
125
  const core = require('./shedb').getCore();
79
126
  if (core) {
80
127
  const ids = Object.keys(core.docs).sort();
81
- if (ids.length > 0) {
82
- parts.push(`## sheDB document IDs (${ids.length} total)\n${ids.slice(0, 200).join('\n')}`);
83
- }
128
+ if (ids.length > 0) sections.push({ name: 'shedb-ids', text: `## sheDB document IDs (${ids.length} total)\n${ids.slice(0, 200).join('\n')}` });
84
129
  }
85
130
  } catch {
86
- // shedb not initialised — skip silently
131
+ /* shedb not initialised */
87
132
  }
88
133
  }
89
-
90
134
  if (requestCtx.sampleDocs) {
91
135
  try {
92
136
  const core = require('./shedb').getCore();
@@ -94,23 +138,90 @@ function buildSystemPrompt(requestCtx, currentScript, currentView, currentDoc, s
94
138
  const ids = Object.keys(core.docs).sort().slice(0, 10);
95
139
  if (ids.length > 0) {
96
140
  const sample = ids.map((id) => `### ${id}\n${JSON.stringify(core.docs[id], null, 2)}`).join('\n\n');
97
- parts.push(`## Sample sheDB documents (${ids.length} shown)\n${sample}`);
141
+ sections.push({ name: 'shedb-samples', text: `## Sample sheDB documents (${ids.length} shown)\n${sample}` });
98
142
  }
99
143
  }
100
144
  } catch {
101
- // shedb not initialised — skip silently
145
+ /* shedb not initialised */
102
146
  }
103
147
  }
148
+ for (const f of extraFiles || []) {
149
+ const ext = (f.name.match(/\.([^.]+)$/) || [])[1] || '';
150
+ sections.push({ name: 'file:' + f.name, text: `## Attached file: ${f.name}\n\`\`\`${ext.toLowerCase()}\n${f.content}\n\`\`\`` });
151
+ }
104
152
 
105
- if (extraFiles && extraFiles.length > 0) {
106
- for (const f of extraFiles) {
107
- const ext = (f.name.match(/\.([^.]+)$/) || [])[1] || '';
108
- const lang = ext.toLowerCase();
109
- parts.push(`## Attached file: ${f.name}\n\`\`\`${lang}\n${f.content}\n\`\`\``);
153
+ // the budget (compact profile, or an explicit one): drop the least important sections first
154
+ const dropped = [];
155
+ const budget = opts.budgetChars || (profile === 'compact' ? DEFAULT_COMPACT_BUDGET : 0);
156
+ if (budget > 0) {
157
+ const staticLen = staticParts.join('\n\n').length;
158
+ const order = ['shedb-samples', 'shedb-ids', 'file:', 'script'];
159
+ const size = () => staticLen + sections.reduce((n, s) => n + s.text.length + 2, 0);
160
+ for (const kind of order) {
161
+ while (size() > budget) {
162
+ const idx = kind === 'file:' ? sections.map((s) => s.name).findLastIndex((n) => n.startsWith('file:')) : sections.findIndex((s) => s.name === kind);
163
+ if (idx === -1) break;
164
+ dropped.push(sections[idx].name);
165
+ sections.splice(idx, 1);
166
+ }
110
167
  }
168
+ if (dropped.length) sections.push({ name: 'dropped', text: `(Left out to fit the model's context: ${dropped.join(', ')}. Ask for a part of it if needed.)` });
111
169
  }
112
170
 
113
- return parts.join('\n\n');
171
+ return { staticText: staticParts.join('\n\n'), dynamicText: sections.map((s) => s.text).join('\n\n'), dropped };
172
+ }
173
+
174
+ /** which prompt profile fits a provider entry; `profile` on the entry overrides */
175
+ function profileFor(ai) {
176
+ if (ai?.profile === 'compact' || ai?.profile === 'capable') return ai.profile;
177
+ if (!ai) return 'capable';
178
+ if (ai.provider === 'ollama') return 'compact';
179
+ if (ai.provider === 'openai') {
180
+ const u = String(ai.baseUrl || '').toLowerCase();
181
+ if (/localhost|127\.0\.0\.1|\.lan\b|\.local\b|\.home\b|:1234\b|:11434\b|:8080\b/.test(u)) return 'compact';
182
+ }
183
+ return 'capable';
184
+ }
185
+
186
+ /** strip the <think>…</think> blocks a thinking model returns as text (compact profile) */
187
+ function stripThinking(text) {
188
+ return String(text)
189
+ .replace(/<think>[\s\S]*?<\/think>\s*/gi, '')
190
+ .replace(/^<think>[\s\S]*$/i, '')
191
+ .trim();
192
+ }
193
+
194
+ /** a stateful filter for a token stream: tokens inside <think>…</think> are swallowed */
195
+ function thinkFilter(onToken) {
196
+ let inside = false;
197
+ let buf = '';
198
+ return (token) => {
199
+ buf += token;
200
+ for (;;) {
201
+ if (inside) {
202
+ const end = buf.indexOf('</think>');
203
+ if (end === -1) {
204
+ buf = buf.slice(-8); // keep a tail in case the tag is split
205
+ return;
206
+ }
207
+ buf = buf.slice(end + 8).replace(/^\s+/, '');
208
+ inside = false;
209
+ } else {
210
+ const start = buf.indexOf('<think>');
211
+ if (start === -1) {
212
+ // emit all but a possible partial tag at the end
213
+ const keep = buf.lastIndexOf('<');
214
+ const safe = keep === -1 || buf.length - keep > 7 ? buf : buf.slice(0, keep);
215
+ if (safe) onToken(safe);
216
+ buf = buf.slice(safe.length);
217
+ return;
218
+ }
219
+ if (start > 0) onToken(buf.slice(0, start));
220
+ buf = buf.slice(start + 7);
221
+ inside = true;
222
+ }
223
+ }
224
+ };
114
225
  }
115
226
 
116
- module.exports = { buildSystemPrompt };
227
+ module.exports = { buildSystemPrompt, buildSystemPromptParts, houseSection, profileFor, stripThinking, thinkFilter, DEFAULT_COMPACT_BUDGET };
@@ -0,0 +1,68 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Script drafts the AI proposes (roadmap I16, decision D-1): written to `<data-dir>/ai/drafts/<id>.json`, shown in
5
+ * the chat as a diff with an Apply button; nothing lands in the scripts directory without that click. Drafts older
6
+ * than a week are pruned at daemon start.
7
+ */
8
+
9
+ const fs = require('fs');
10
+ const path = require('path');
11
+ const crypto = require('crypto');
12
+
13
+ const KEEP_MS = 7 * 24 * 3600 * 1000;
14
+ let _dir = null;
15
+
16
+ function init(dir) {
17
+ _dir = dir;
18
+ try {
19
+ fs.mkdirSync(_dir, { recursive: true });
20
+ const now = Date.now();
21
+ for (const n of fs.readdirSync(_dir)) {
22
+ if (!n.endsWith('.json')) continue;
23
+ const f = path.join(_dir, n);
24
+ try {
25
+ if (now - fs.statSync(f).mtimeMs > KEEP_MS) fs.rmSync(f, { force: true });
26
+ } catch {
27
+ /* ignore */
28
+ }
29
+ }
30
+ } catch {
31
+ /* the directory is created on the first draft */
32
+ }
33
+ }
34
+
35
+ function file(id) {
36
+ if (!/^d[0-9a-f]{8}$/.test(id)) return null;
37
+ return path.join(_dir, id + '.json');
38
+ }
39
+
40
+ /** @returns {object} the draft */
41
+ function create({ path: scriptPath, content, base, note }) {
42
+ if (!_dir) throw new Error('drafts not initialised');
43
+ fs.mkdirSync(_dir, { recursive: true });
44
+ const draft = { id: 'd' + crypto.randomBytes(4).toString('hex'), path: scriptPath, content, base: base ?? null, note: note || '', createdAt: Date.now(), status: 'open' };
45
+ fs.writeFileSync(file(draft.id), JSON.stringify(draft));
46
+ return draft;
47
+ }
48
+
49
+ function get(id) {
50
+ const f = _dir && file(id);
51
+ if (!f) return null;
52
+ try {
53
+ return JSON.parse(fs.readFileSync(f, 'utf8'));
54
+ } catch {
55
+ return null;
56
+ }
57
+ }
58
+
59
+ function setStatus(id, status) {
60
+ const d = get(id);
61
+ if (!d) return null;
62
+ d.status = status;
63
+ d.updatedAt = Date.now();
64
+ fs.writeFileSync(file(id), JSON.stringify(d));
65
+ return d;
66
+ }
67
+
68
+ module.exports = { init, create, get, setStatus };
@@ -0,0 +1,106 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * The AI chat's memory (roadmap I29, decision D-8): facts the user confirmed, one store per instance in the data
5
+ * directory — `<data-dir>/ai/memory.json`, next to the conversations. Never in the scripts directory (the git
6
+ * auto-commit) and never in a repository. Shown and editable on the AI page; the model may add and remove notes.
7
+ * Every note runs through the secrets redaction, and anything that looks like a key or password is refused.
8
+ */
9
+
10
+ const fs = require('fs');
11
+ const path = require('path');
12
+ const crypto = require('crypto');
13
+
14
+ const MAX_NOTES = 300;
15
+ const MAX_CHARS = 200;
16
+ const SECRET_LIKE =
17
+ /(sk-[a-z0-9]{2,}-?[A-Za-z0-9_-]{8,}|password\s*[:=]|passwort\s*[:=]|api[_ -]?key\s*[:=]|token\s*[:=]|bearer\s+[A-Za-z0-9._-]{16,}|\b[A-Za-z0-9+/]{32,}={0,2}\b)/i;
18
+
19
+ let _file = null;
20
+ let _redact = (s) => s;
21
+ let _notes = null;
22
+
23
+ /**
24
+ * @param {string} dir the ai directory in the data root
25
+ * @param {(s: string) => string} [redact]
26
+ */
27
+ function init(dir, redact) {
28
+ _file = path.join(dir, 'memory.json');
29
+ if (redact) _redact = redact;
30
+ _notes = null;
31
+ }
32
+
33
+ function load() {
34
+ if (_notes) return _notes;
35
+ try {
36
+ const json = JSON.parse(fs.readFileSync(_file, 'utf8'));
37
+ _notes = Array.isArray(json.notes) ? json.notes.filter((n) => n && typeof n.id === 'string' && typeof n.text === 'string') : [];
38
+ } catch {
39
+ _notes = [];
40
+ }
41
+ return _notes;
42
+ }
43
+
44
+ function persist() {
45
+ if (!_file) throw new Error('memory not initialised');
46
+ fs.mkdirSync(path.dirname(_file), { recursive: true });
47
+ const tmp = _file + '.tmp';
48
+ fs.writeFileSync(tmp, JSON.stringify({ notes: _notes }, null, 2));
49
+ fs.renameSync(tmp, _file);
50
+ }
51
+
52
+ /** null when the text may be stored, otherwise the reason */
53
+ function refusal(text) {
54
+ const t = String(text || '').trim();
55
+ if (!t) return 'empty note';
56
+ if (t.length > MAX_CHARS) return `a note has at most ${MAX_CHARS} characters (${t.length} given); split it or shorten it`;
57
+ if (SECRET_LIKE.test(t)) return 'this looks like a key, a token or a password; secrets belong in the Secrets tab, not in the memory';
58
+ return null;
59
+ }
60
+
61
+ function list() {
62
+ if (!_file) return [];
63
+ return load().map((n) => ({ ...n }));
64
+ }
65
+
66
+ /** @returns {{ note?: object, error?: string }} */
67
+ function add(text, source = 'user') {
68
+ if (!_file) return { error: 'memory not initialised' };
69
+ const why = refusal(text);
70
+ if (why) return { error: why };
71
+ const notes = load();
72
+ const clean = _redact(String(text).trim().replace(/\s+/g, ' '));
73
+ const dup = notes.find((n) => n.text.toLowerCase() === clean.toLowerCase());
74
+ if (dup) return { note: { ...dup }, duplicate: true };
75
+ if (notes.length >= MAX_NOTES) return { error: `the memory holds at most ${MAX_NOTES} notes; forget one first` };
76
+ const note = { id: 'n' + crypto.randomBytes(4).toString('hex'), text: clean, createdAt: Date.now(), source: source === 'model' ? 'model' : 'user' };
77
+ notes.push(note);
78
+ persist();
79
+ return { note: { ...note } };
80
+ }
81
+
82
+ function update(id, text) {
83
+ if (!_file) return { error: 'memory not initialised' };
84
+ const why = refusal(text);
85
+ if (why) return { error: why };
86
+ const notes = load();
87
+ const note = notes.find((n) => n.id === id);
88
+ if (!note) return { error: `no note ${id}` };
89
+ note.text = _redact(String(text).trim().replace(/\s+/g, ' '));
90
+ note.source = 'user'; // edited by hand
91
+ note.updatedAt = Date.now();
92
+ persist();
93
+ return { note: { ...note } };
94
+ }
95
+
96
+ function remove(id) {
97
+ if (!_file) return { error: 'memory not initialised' };
98
+ const notes = load();
99
+ const idx = notes.findIndex((n) => n.id === id);
100
+ if (idx === -1) return { error: `no note ${id}` };
101
+ const [note] = notes.splice(idx, 1);
102
+ persist();
103
+ return { note };
104
+ }
105
+
106
+ module.exports = { init, list, add, update, remove, refusal, MAX_NOTES, MAX_CHARS };