smart-home-engine 1.51.3 → 1.52.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -0
- package/dist/web/assets/index-BswjzIm1.css +1 -0
- package/dist/web/assets/index-CD6lY3HK.js +296 -0
- package/dist/web/assets/{tsMode-sNUSGo5H.js → tsMode-CQwwRC_t.js} +1 -1
- package/dist/web/index.html +2 -2
- package/package.json +1 -1
- package/src/index.js +67 -47
- package/src/lib/log-file.js +81 -0
- package/src/lib/logger.js +33 -0
- package/src/lib/script-timers.js +1 -0
- package/src/lib/text-diff.js +116 -0
- package/src/web/ai-api.js +364 -44
- package/src/web/ai-context.js +172 -61
- package/src/web/ai-drafts.js +68 -0
- package/src/web/ai-memory.js +106 -0
- package/src/web/ai-tools.js +938 -70
- package/src/web/log-ws.js +27 -7
- package/src/web/prompts/api-ref-compact.md +16 -0
- package/src/web/prompts/api-ref.md +60 -0
- package/src/web/prompts/compact.md +5 -0
- package/src/web/prompts/conventions.md +5 -0
- package/src/web/prompts/db-doc.md +1 -1
- package/src/web/prompts/db-view.md +3 -3
- package/src/web/prompts/formats.md +14 -0
- package/src/web/prompts/role.md +6 -0
- package/src/web/prompts/tools.md +9 -0
- package/dist/web/assets/index-D6H4drtd.js +0 -295
- package/dist/web/assets/index-DPDW78ru.css +0 -1
- package/src/web/prompts/scripts-base.md +0 -25
- package/src/web/prompts/she-api-ref.md +0 -94
package/src/web/ai-context.js
CHANGED
|
@@ -1,92 +1,136 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
|
-
* AI context builder — assembles the
|
|
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
|
-
*
|
|
9
|
-
*
|
|
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
|
|
18
|
-
const
|
|
19
|
-
const
|
|
20
|
-
const
|
|
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
|
-
*
|
|
24
|
-
*
|
|
25
|
-
* @param {object}
|
|
26
|
-
*
|
|
27
|
-
* @param {{
|
|
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
|
|
34
|
-
const
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
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
|
-
|
|
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
|
-
|
|
49
|
-
|
|
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
|
-
|
|
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(`
|
|
62
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
141
|
+
sections.push({ name: 'shedb-samples', text: `## Sample sheDB documents (${ids.length} shown)\n${sample}` });
|
|
98
142
|
}
|
|
99
143
|
}
|
|
100
144
|
} catch {
|
|
101
|
-
|
|
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
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
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
|
|
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 };
|