blun-king-cli 4.1.1 → 5.0.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/api.js +965 -0
- package/blun-cli.js +763 -0
- package/blunking-api.js +7 -0
- package/bot.js +188 -0
- package/browser-controller.js +76 -0
- package/chat-memory.js +103 -0
- package/file-helper.js +63 -0
- package/fuzzy-match.js +78 -0
- package/identities.js +106 -0
- package/installer.js +160 -0
- package/job-manager.js +146 -0
- package/local-data.js +71 -0
- package/message-builder.js +28 -0
- package/noisy-evals.js +38 -0
- package/package.json +17 -4
- package/palace-memory.js +246 -0
- package/reference-inspector.js +228 -0
- package/runtime.js +555 -0
- package/task-executor.js +104 -0
- package/tests/browser-controller.test.js +42 -0
- package/tests/cli.test.js +93 -0
- package/tests/file-helper.test.js +18 -0
- package/tests/installer.test.js +39 -0
- package/tests/job-manager.test.js +99 -0
- package/tests/merge-compat.test.js +77 -0
- package/tests/messages.test.js +23 -0
- package/tests/noisy-evals.test.js +12 -0
- package/tests/noisy-intent-corpus.test.js +45 -0
- package/tests/reference-inspector.test.js +36 -0
- package/tests/runtime.test.js +119 -0
- package/tests/task-executor.test.js +40 -0
- package/tests/tools.test.js +23 -0
- package/tests/user-profile.test.js +66 -0
- package/tests/website-builder.test.js +66 -0
- package/tmp-build-smoke/nicrazy-landing/index.html +53 -0
- package/tmp-build-smoke/nicrazy-landing/style.css +110 -0
- package/tmp-shot-smoke/website-shot-1776006760424.png +0 -0
- package/tmp-shot-smoke/website-shot-1776007850007.png +0 -0
- package/tmp-shot-smoke/website-shot-1776007886209.png +0 -0
- package/tmp-shot-smoke/website-shot-1776007903766.png +0 -0
- package/tmp-shot-smoke/website-shot-1776008737117.png +0 -0
- package/tmp-shot-smoke/website-shot-1776008988859.png +0 -0
- package/tmp-smoke/nicrazy-landing/index.html +66 -0
- package/tmp-smoke/nicrazy-landing/style.css +104 -0
- package/tools.js +177 -0
- package/user-profile.js +395 -0
- package/website-builder.js +394 -0
- package/website-shot-1776010648230.png +0 -0
- package/website_builder.txt +38 -0
- package/bin/blun.js +0 -3196
- package/setup.js +0 -30
package/user-profile.js
ADDED
|
@@ -0,0 +1,395 @@
|
|
|
1
|
+
const fs = require("fs");
|
|
2
|
+
const os = require("os");
|
|
3
|
+
const path = require("path");
|
|
4
|
+
const { hasApproxPhrase } = require("./fuzzy-match");
|
|
5
|
+
|
|
6
|
+
const BLUN_HOME = process.env.BLUN_HOME || path.join(os.homedir(), ".blun");
|
|
7
|
+
const DATA_DIR = path.join(BLUN_HOME, "data");
|
|
8
|
+
const PROFILE_FILE = path.join(DATA_DIR, "user-profiles.json");
|
|
9
|
+
|
|
10
|
+
function ensureDataDir() {
|
|
11
|
+
if (!fs.existsSync(DATA_DIR)) fs.mkdirSync(DATA_DIR, { recursive: true });
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function readProfiles() {
|
|
15
|
+
ensureDataDir();
|
|
16
|
+
if (!fs.existsSync(PROFILE_FILE)) return {};
|
|
17
|
+
try {
|
|
18
|
+
return JSON.parse(fs.readFileSync(PROFILE_FILE, "utf8"));
|
|
19
|
+
} catch {
|
|
20
|
+
return {};
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function writeProfiles(profiles) {
|
|
25
|
+
ensureDataDir();
|
|
26
|
+
fs.writeFileSync(PROFILE_FILE, JSON.stringify(profiles, null, 2), "utf8");
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function normalizePhrase(text) {
|
|
30
|
+
return String(text || "")
|
|
31
|
+
.toLowerCase()
|
|
32
|
+
.replace(/https?:\/\/[^\s]+/g, " ")
|
|
33
|
+
.replace(/[^a-z0-9\s-]/g, " ")
|
|
34
|
+
.replace(/\s+/g, " ")
|
|
35
|
+
.trim()
|
|
36
|
+
.slice(0, 120);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function compactText(text, maxLength = 240) {
|
|
40
|
+
return String(text || "")
|
|
41
|
+
.replace(/\s+/g, " ")
|
|
42
|
+
.trim()
|
|
43
|
+
.slice(0, maxLength);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function ensureProfileShape(profile = {}) {
|
|
47
|
+
return {
|
|
48
|
+
installAliases: Array.isArray(profile.installAliases) ? profile.installAliases : [],
|
|
49
|
+
termAliases: Array.isArray(profile.termAliases) ? profile.termAliases : [],
|
|
50
|
+
knowledgeSnippets: Array.isArray(profile.knowledgeSnippets) ? profile.knowledgeSnippets : [],
|
|
51
|
+
typedKnowledge: {
|
|
52
|
+
preferences: Array.isArray(profile.typedKnowledge?.preferences) ? profile.typedKnowledge.preferences : [],
|
|
53
|
+
facts: Array.isArray(profile.typedKnowledge?.facts) ? profile.typedKnowledge.facts : [],
|
|
54
|
+
workflows: Array.isArray(profile.typedKnowledge?.workflows) ? profile.typedKnowledge.workflows : [],
|
|
55
|
+
guardrails: Array.isArray(profile.typedKnowledge?.guardrails) ? profile.typedKnowledge.guardrails : []
|
|
56
|
+
}
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function looksLikePackageName(value) {
|
|
61
|
+
return /^(@[a-z0-9._-]+\/)?[a-z0-9._-]+$/i.test(String(value || "").trim());
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function loadUserProfile(userId) {
|
|
65
|
+
const profiles = readProfiles();
|
|
66
|
+
return ensureProfileShape(profiles[String(userId)]);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function saveUserProfile(userId, profile) {
|
|
70
|
+
const profiles = readProfiles();
|
|
71
|
+
profiles[String(userId)] = ensureProfileShape(profile);
|
|
72
|
+
writeProfiles(profiles);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function learnInstallAlias(userId, phrase, source) {
|
|
76
|
+
if (!userId || !phrase || !source) return;
|
|
77
|
+
const profile = ensureProfileShape(loadUserProfile(userId));
|
|
78
|
+
const normalized = normalizePhrase(phrase);
|
|
79
|
+
if (!normalized) return;
|
|
80
|
+
|
|
81
|
+
const existing = profile.installAliases.find((entry) => entry.phrase === normalized && entry.source === source);
|
|
82
|
+
if (existing) {
|
|
83
|
+
existing.count = (existing.count || 1) + 1;
|
|
84
|
+
existing.updatedAt = Date.now();
|
|
85
|
+
} else {
|
|
86
|
+
profile.installAliases.push({
|
|
87
|
+
phrase: normalized,
|
|
88
|
+
source,
|
|
89
|
+
count: 1,
|
|
90
|
+
updatedAt: Date.now()
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
profile.installAliases = profile.installAliases
|
|
95
|
+
.sort((a, b) => (b.count || 0) - (a.count || 0) || (b.updatedAt || 0) - (a.updatedAt || 0))
|
|
96
|
+
.slice(0, 30);
|
|
97
|
+
|
|
98
|
+
saveUserProfile(userId, profile);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function learnTermAlias(userId, phrase, target) {
|
|
102
|
+
if (!userId || !phrase || !target) return;
|
|
103
|
+
const profile = ensureProfileShape(loadUserProfile(userId));
|
|
104
|
+
const normalized = normalizePhrase(phrase);
|
|
105
|
+
const normalizedTarget = compactText(target, 160);
|
|
106
|
+
if (!normalized || !normalizedTarget) return;
|
|
107
|
+
|
|
108
|
+
const existing = profile.termAliases.find((entry) => entry.phrase === normalized && entry.target === normalizedTarget);
|
|
109
|
+
if (existing) {
|
|
110
|
+
existing.count = (existing.count || 1) + 1;
|
|
111
|
+
existing.updatedAt = Date.now();
|
|
112
|
+
} else {
|
|
113
|
+
profile.termAliases.push({
|
|
114
|
+
phrase: normalized,
|
|
115
|
+
target: normalizedTarget,
|
|
116
|
+
count: 1,
|
|
117
|
+
updatedAt: Date.now()
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
profile.termAliases = profile.termAliases
|
|
122
|
+
.sort((a, b) => (b.count || 0) - (a.count || 0) || (b.updatedAt || 0) - (a.updatedAt || 0))
|
|
123
|
+
.slice(0, 40);
|
|
124
|
+
|
|
125
|
+
if (looksLikePackageName(normalizedTarget)) {
|
|
126
|
+
learnInstallAlias(userId, normalized, normalizedTarget);
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
saveUserProfile(userId, profile);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function learnKnowledgeSnippet(userId, text, meta = {}) {
|
|
134
|
+
if (!userId || !text) return false;
|
|
135
|
+
const profile = ensureProfileShape(loadUserProfile(userId));
|
|
136
|
+
const stored = storeKnowledgeSnippet(profile, text, meta);
|
|
137
|
+
saveUserProfile(userId, profile);
|
|
138
|
+
return stored;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function storeKnowledgeSnippet(profile, text, meta = {}) {
|
|
142
|
+
const snippet = compactText(text, 280);
|
|
143
|
+
if (!snippet) return false;
|
|
144
|
+
const existing = profile.knowledgeSnippets.find((entry) => entry.text === snippet);
|
|
145
|
+
if (existing) {
|
|
146
|
+
existing.count = (existing.count || 1) + 1;
|
|
147
|
+
existing.updatedAt = Date.now();
|
|
148
|
+
if (meta.category) existing.category = meta.category;
|
|
149
|
+
if (meta.title) existing.title = compactText(meta.title, 80);
|
|
150
|
+
} else {
|
|
151
|
+
profile.knowledgeSnippets.push({
|
|
152
|
+
text: snippet,
|
|
153
|
+
category: meta.category ? compactText(meta.category, 40) : "general",
|
|
154
|
+
title: meta.title ? compactText(meta.title, 80) : "",
|
|
155
|
+
count: 1,
|
|
156
|
+
updatedAt: Date.now()
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
profile.knowledgeSnippets = profile.knowledgeSnippets
|
|
161
|
+
.sort((a, b) => (b.count || 0) - (a.count || 0) || (b.updatedAt || 0) - (a.updatedAt || 0))
|
|
162
|
+
.slice(0, 60);
|
|
163
|
+
return true;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function storeTypedEntry(profile, type, text, meta = {}) {
|
|
167
|
+
const snippet = compactText(text, 240);
|
|
168
|
+
if (!snippet) return false;
|
|
169
|
+
|
|
170
|
+
const bucket = profile.typedKnowledge[type];
|
|
171
|
+
const existing = bucket.find((entry) => entry.text === snippet);
|
|
172
|
+
if (existing) {
|
|
173
|
+
existing.count = (existing.count || 1) + 1;
|
|
174
|
+
existing.updatedAt = Date.now();
|
|
175
|
+
if (meta.category) existing.category = compactText(meta.category, 40);
|
|
176
|
+
if (meta.title) existing.title = compactText(meta.title, 80);
|
|
177
|
+
} else {
|
|
178
|
+
bucket.push({
|
|
179
|
+
text: snippet,
|
|
180
|
+
category: meta.category ? compactText(meta.category, 40) : "general",
|
|
181
|
+
title: meta.title ? compactText(meta.title, 80) : "",
|
|
182
|
+
count: 1,
|
|
183
|
+
updatedAt: Date.now()
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
profile.typedKnowledge[type] = bucket
|
|
188
|
+
.sort((a, b) => (b.count || 0) - (a.count || 0) || (b.updatedAt || 0) - (a.updatedAt || 0))
|
|
189
|
+
.slice(0, 30);
|
|
190
|
+
|
|
191
|
+
return true;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function detectKnowledgeType(text) {
|
|
195
|
+
const normalized = normalizePhrase(text);
|
|
196
|
+
if (!normalized) return "facts";
|
|
197
|
+
if (/\b(modell|domain|projekt|server|api|stack|version)\b/.test(normalized) && /\b(ist|sind|nutze|verwende|als)\b/.test(normalized)) return "facts";
|
|
198
|
+
if (/\b(niemals|nie|darfst nicht|verboten|kein html|keine landingpages?)\b/.test(normalized)) return "guardrails";
|
|
199
|
+
if (/^wenn ich\b/.test(normalized) || /\b(dann|erst .* dann|deploy|workflow|ablauf)\b/.test(normalized)) return "workflows";
|
|
200
|
+
if (/\b(immer|standard|bevorzuge|nutze|ich will|bitte immer|am liebsten)\b/.test(normalized)) return "preferences";
|
|
201
|
+
return "facts";
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function resolveInstallAlias(userId, phrase) {
|
|
205
|
+
if (!userId || !phrase) return null;
|
|
206
|
+
const profile = ensureProfileShape(loadUserProfile(userId));
|
|
207
|
+
const normalized = normalizePhrase(phrase);
|
|
208
|
+
if (!normalized) return null;
|
|
209
|
+
|
|
210
|
+
for (const entry of profile.installAliases || []) {
|
|
211
|
+
if (normalized.includes(entry.phrase) || hasApproxPhrase(normalized, entry.phrase)) {
|
|
212
|
+
return entry.source;
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
return null;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function resolveTermAlias(userId, phrase) {
|
|
220
|
+
if (!userId || !phrase) return null;
|
|
221
|
+
const profile = ensureProfileShape(loadUserProfile(userId));
|
|
222
|
+
const normalized = normalizePhrase(phrase);
|
|
223
|
+
if (!normalized) return null;
|
|
224
|
+
|
|
225
|
+
for (const entry of profile.termAliases || []) {
|
|
226
|
+
if (normalized.includes(entry.phrase) || hasApproxPhrase(normalized, entry.phrase)) {
|
|
227
|
+
return entry.target;
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
return null;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function scoreSnippet(snippet, query) {
|
|
235
|
+
const haystack = normalizePhrase(`${snippet.title || ""} ${snippet.category || ""} ${snippet.text || ""}`);
|
|
236
|
+
const needle = normalizePhrase(query);
|
|
237
|
+
if (!needle) return 0;
|
|
238
|
+
const tokens = needle.split(/\s+/).filter(Boolean);
|
|
239
|
+
const tokenScore = tokens.reduce((score, token) => score + (haystack.includes(token) ? 1 : 0), 0);
|
|
240
|
+
const exactBonus = haystack.includes(needle) ? 2 : 0;
|
|
241
|
+
return tokenScore + exactBonus + Math.min(2, (snippet.count || 0) * 0.25);
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
function recallTypedKnowledge(userId, query, limit = 4) {
|
|
245
|
+
if (!userId) return [];
|
|
246
|
+
const profile = ensureProfileShape(loadUserProfile(userId));
|
|
247
|
+
const all = [];
|
|
248
|
+
|
|
249
|
+
for (const [type, entries] of Object.entries(profile.typedKnowledge || {})) {
|
|
250
|
+
for (const entry of entries || []) {
|
|
251
|
+
all.push({
|
|
252
|
+
type,
|
|
253
|
+
...entry,
|
|
254
|
+
score: scoreSnippet(entry, query)
|
|
255
|
+
});
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
return all
|
|
260
|
+
.filter((entry) => entry.score > 0)
|
|
261
|
+
.sort((a, b) => b.score - a.score || (b.updatedAt || 0) - (a.updatedAt || 0))
|
|
262
|
+
.slice(0, limit);
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
function recallKnowledge(userId, query, limit = 4) {
|
|
266
|
+
if (!userId) return [];
|
|
267
|
+
const profile = ensureProfileShape(loadUserProfile(userId));
|
|
268
|
+
|
|
269
|
+
return (profile.knowledgeSnippets || [])
|
|
270
|
+
.map((entry) => ({
|
|
271
|
+
...entry,
|
|
272
|
+
score: scoreSnippet(entry, query)
|
|
273
|
+
}))
|
|
274
|
+
.filter((entry) => entry.score > 0)
|
|
275
|
+
.sort((a, b) => b.score - a.score || (b.updatedAt || 0) - (a.updatedAt || 0))
|
|
276
|
+
.slice(0, limit);
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
function buildPromptContext(userId, query, limit = 4) {
|
|
280
|
+
if (!userId) return "";
|
|
281
|
+
const termAlias = resolveTermAlias(userId, query);
|
|
282
|
+
const snippets = recallKnowledge(userId, query, limit);
|
|
283
|
+
const typedEntries = recallTypedKnowledge(userId, query, limit);
|
|
284
|
+
const lines = [];
|
|
285
|
+
|
|
286
|
+
if (termAlias) {
|
|
287
|
+
lines.push(`- Nutzerbegriff erkannt: "${compactText(query, 60)}" meint hier wahrscheinlich "${termAlias}".`);
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
const typeLabels = {
|
|
291
|
+
preferences: "Praeferenz",
|
|
292
|
+
facts: "Fakt",
|
|
293
|
+
workflows: "Workflow",
|
|
294
|
+
guardrails: "Guardrail"
|
|
295
|
+
};
|
|
296
|
+
|
|
297
|
+
for (const entry of typedEntries) {
|
|
298
|
+
lines.push(`- ${typeLabels[entry.type] || entry.type}: ${entry.text}`);
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
for (const snippet of snippets) {
|
|
302
|
+
const prefix = snippet.title ? `${snippet.title}: ` : "";
|
|
303
|
+
if (!typedEntries.some((entry) => entry.text === snippet.text)) {
|
|
304
|
+
lines.push(`- ${prefix}${snippet.text}`);
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
return lines.length ? lines.join("\n") : "";
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
function extractAliasPairs(text) {
|
|
312
|
+
const pairs = [];
|
|
313
|
+
const lines = String(text || "")
|
|
314
|
+
.split(/\r?\n/)
|
|
315
|
+
.map((line) => compactText(line, 220))
|
|
316
|
+
.filter(Boolean);
|
|
317
|
+
|
|
318
|
+
for (const line of lines) {
|
|
319
|
+
let match = line.match(/^(.+?)\s*(?:=|->|=>|bedeutet|meint)\s*(.+)$/i);
|
|
320
|
+
if (match) {
|
|
321
|
+
pairs.push({ phrase: match[1], target: match[2] });
|
|
322
|
+
continue;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
match = line.match(/^wenn ich\s+(.+?)\s+sage,\s*(?:meine ich|dann meine ich)\s+(.+)$/i);
|
|
326
|
+
if (match) {
|
|
327
|
+
pairs.push({ phrase: match[1], target: match[2] });
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
return pairs
|
|
332
|
+
.map((pair) => ({
|
|
333
|
+
phrase: normalizePhrase(pair.phrase),
|
|
334
|
+
target: compactText(pair.target, 160)
|
|
335
|
+
}))
|
|
336
|
+
.filter((pair) => pair.phrase && pair.target);
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
function extractKnowledgeSnippets(text) {
|
|
340
|
+
const blocks = String(text || "")
|
|
341
|
+
.split(/\n\s*\n/)
|
|
342
|
+
.map((block) => compactText(block, 280))
|
|
343
|
+
.filter((block) => block.length >= 20);
|
|
344
|
+
|
|
345
|
+
return [...new Set(blocks)].slice(0, 6);
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
function learnFromText(userId, text, options = {}) {
|
|
349
|
+
const aliases = extractAliasPairs(text);
|
|
350
|
+
for (const alias of aliases) {
|
|
351
|
+
learnTermAlias(userId, alias.phrase, alias.target);
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
const snippets = extractKnowledgeSnippets(text);
|
|
355
|
+
const profile = ensureProfileShape(loadUserProfile(userId));
|
|
356
|
+
const typeCounts = {
|
|
357
|
+
preferences: 0,
|
|
358
|
+
facts: 0,
|
|
359
|
+
workflows: 0,
|
|
360
|
+
guardrails: 0
|
|
361
|
+
};
|
|
362
|
+
|
|
363
|
+
for (const snippet of snippets) {
|
|
364
|
+
storeKnowledgeSnippet(profile, snippet, options);
|
|
365
|
+
const type = detectKnowledgeType(snippet);
|
|
366
|
+
if (storeTypedEntry(profile, type, snippet, options)) {
|
|
367
|
+
typeCounts[type] += 1;
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
saveUserProfile(userId, profile);
|
|
372
|
+
|
|
373
|
+
return {
|
|
374
|
+
aliasesLearned: aliases.length,
|
|
375
|
+
snippetsLearned: snippets.length,
|
|
376
|
+
types: typeCounts,
|
|
377
|
+
aliases,
|
|
378
|
+
snippets
|
|
379
|
+
};
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
module.exports = {
|
|
383
|
+
PROFILE_FILE,
|
|
384
|
+
loadUserProfile,
|
|
385
|
+
saveUserProfile,
|
|
386
|
+
learnInstallAlias,
|
|
387
|
+
learnTermAlias,
|
|
388
|
+
learnKnowledgeSnippet,
|
|
389
|
+
learnFromText,
|
|
390
|
+
resolveInstallAlias,
|
|
391
|
+
resolveTermAlias,
|
|
392
|
+
recallKnowledge,
|
|
393
|
+
recallTypedKnowledge,
|
|
394
|
+
buildPromptContext
|
|
395
|
+
};
|