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.
Files changed (51) hide show
  1. package/api.js +965 -0
  2. package/blun-cli.js +763 -0
  3. package/blunking-api.js +7 -0
  4. package/bot.js +188 -0
  5. package/browser-controller.js +76 -0
  6. package/chat-memory.js +103 -0
  7. package/file-helper.js +63 -0
  8. package/fuzzy-match.js +78 -0
  9. package/identities.js +106 -0
  10. package/installer.js +160 -0
  11. package/job-manager.js +146 -0
  12. package/local-data.js +71 -0
  13. package/message-builder.js +28 -0
  14. package/noisy-evals.js +38 -0
  15. package/package.json +17 -4
  16. package/palace-memory.js +246 -0
  17. package/reference-inspector.js +228 -0
  18. package/runtime.js +555 -0
  19. package/task-executor.js +104 -0
  20. package/tests/browser-controller.test.js +42 -0
  21. package/tests/cli.test.js +93 -0
  22. package/tests/file-helper.test.js +18 -0
  23. package/tests/installer.test.js +39 -0
  24. package/tests/job-manager.test.js +99 -0
  25. package/tests/merge-compat.test.js +77 -0
  26. package/tests/messages.test.js +23 -0
  27. package/tests/noisy-evals.test.js +12 -0
  28. package/tests/noisy-intent-corpus.test.js +45 -0
  29. package/tests/reference-inspector.test.js +36 -0
  30. package/tests/runtime.test.js +119 -0
  31. package/tests/task-executor.test.js +40 -0
  32. package/tests/tools.test.js +23 -0
  33. package/tests/user-profile.test.js +66 -0
  34. package/tests/website-builder.test.js +66 -0
  35. package/tmp-build-smoke/nicrazy-landing/index.html +53 -0
  36. package/tmp-build-smoke/nicrazy-landing/style.css +110 -0
  37. package/tmp-shot-smoke/website-shot-1776006760424.png +0 -0
  38. package/tmp-shot-smoke/website-shot-1776007850007.png +0 -0
  39. package/tmp-shot-smoke/website-shot-1776007886209.png +0 -0
  40. package/tmp-shot-smoke/website-shot-1776007903766.png +0 -0
  41. package/tmp-shot-smoke/website-shot-1776008737117.png +0 -0
  42. package/tmp-shot-smoke/website-shot-1776008988859.png +0 -0
  43. package/tmp-smoke/nicrazy-landing/index.html +66 -0
  44. package/tmp-smoke/nicrazy-landing/style.css +104 -0
  45. package/tools.js +177 -0
  46. package/user-profile.js +395 -0
  47. package/website-builder.js +394 -0
  48. package/website-shot-1776010648230.png +0 -0
  49. package/website_builder.txt +38 -0
  50. package/bin/blun.js +0 -3196
  51. package/setup.js +0 -30
package/api.js ADDED
@@ -0,0 +1,965 @@
1
+ const express = require("express");
2
+ const fs = require("fs");
3
+ const path = require("path");
4
+ const os = require("os");
5
+ const runtime = require("./runtime");
6
+ const tools = require("./tools");
7
+ const memory = require("./chat-memory");
8
+ const palace = require("./palace-memory");
9
+ const fileHelper = require("./file-helper");
10
+ const browser = require("./browser-controller");
11
+ const taskExecutor = require("./task-executor");
12
+ const userProfile = require("./user-profile");
13
+ const jobs = require("./job-manager");
14
+ const { evaluateNoisyIntentCorpus } = require("./noisy-evals");
15
+ const { buildConversationMessages } = require("./message-builder");
16
+ const { inspectReference, extractUrls, autoReferenceChain, buildReferencePromptBlock, shouldAutoReference, screenshotUrl } = require("./reference-inspector");
17
+ const { findFacts, storeFact } = require("./local-data");
18
+ const { buildWebsiteProject, validateWebsiteOutput } = require("./website-builder");
19
+
20
+ const PORT = Number(process.env.BLUN_PORT || 3200);
21
+ const MODEL = process.env.BLUN_MODEL || "blun-king-v500";
22
+ const FAST_MODEL = process.env.BLUN_FAST_MODEL || MODEL;
23
+ const OLLAMA_URL = process.env.OLLAMA_URL || "http://127.0.0.1:11434/api/chat";
24
+ const TOKENS = String(process.env.BLUN_API_TOKENS || process.env.BLUN_API_TOKEN || "")
25
+ .split(",")
26
+ .map((value) => value.trim())
27
+ .filter(Boolean);
28
+
29
+ function isLoopback(ip = "") {
30
+ return ["127.0.0.1", "::1", "::ffff:127.0.0.1"].includes(ip);
31
+ }
32
+
33
+ function auth(req, res, next) {
34
+ if (req.path === "/health") return next();
35
+
36
+ if (TOKENS.length === 0) {
37
+ if (isLoopback(req.ip) || isLoopback(req.socket?.remoteAddress)) return next();
38
+ return res.status(503).json({ error: "Remote access requires BLUN_API_TOKEN or BLUN_API_TOKENS." });
39
+ }
40
+
41
+ const authHeader = req.headers.authorization || "";
42
+ const token = authHeader.startsWith("Bearer ") ? authHeader.slice(7) : null;
43
+ if (!token || !TOKENS.includes(token)) return res.status(401).json({ error: "Unauthorized" });
44
+ next();
45
+ }
46
+
47
+ function requireLoopback(req, res, next) {
48
+ if (isLoopback(req.ip) || isLoopback(req.socket?.remoteAddress)) return next();
49
+ return res.status(403).json({ error: "Browser control is local-only." });
50
+ }
51
+
52
+ async function ollamaChat(messages, model = MODEL) {
53
+ const resp = await fetch(OLLAMA_URL, {
54
+ method: "POST",
55
+ headers: { "Content-Type": "application/json" },
56
+ body: JSON.stringify({ model, messages, stream: false })
57
+ });
58
+ if (!resp.ok) throw new Error(`Ollama error ${resp.status}`);
59
+ const data = await resp.json();
60
+ return String(data.message?.content || "").replace(/<think>[\s\S]*?<\/think>/gi, "").trim();
61
+ }
62
+
63
+ async function generateRecoveredAnswer({ task, session, message, identityId, sessionId, previousAnswer, reasons, model }) {
64
+ const prompt = composePromptWithPalace(task, session, message, identityId, sessionId);
65
+ const retryPrompt = runtime.buildRetryPrompt(reasons);
66
+ const messages = buildConversationMessages({
67
+ prompt,
68
+ session,
69
+ message
70
+ });
71
+ messages.push({ role: "assistant", content: previousAnswer });
72
+ messages.push({ role: "user", content: retryPrompt });
73
+ return ollamaChat(messages, model);
74
+ }
75
+
76
+ async function generateVettedAnswer({ task, session, message, identityId, sessionId, model, history, extraPrompt }) {
77
+ const promptBase = composePromptWithPalace(task, session, message, identityId, sessionId);
78
+ const prompt = [promptBase, extraPrompt].filter(Boolean).join("\n\n");
79
+ const messages = buildConversationMessages({
80
+ prompt,
81
+ session,
82
+ history,
83
+ message
84
+ });
85
+
86
+ let answer = await ollamaChat(messages, model);
87
+ let quality = runtime.selfCheckResponse(answer, task, message);
88
+ let retries = 0;
89
+
90
+ while (task.type !== "chat" && quality.blocked && retries < 2) {
91
+ answer = await generateRecoveredAnswer({
92
+ task,
93
+ session,
94
+ message,
95
+ identityId,
96
+ sessionId,
97
+ previousAnswer: answer,
98
+ reasons: quality.reasons.length ? quality.reasons : ["action_mismatch"],
99
+ model
100
+ });
101
+ quality = runtime.selfCheckResponse(answer, task, message);
102
+ retries += 1;
103
+ }
104
+
105
+ if (task.type !== "chat" && quality.blocked) {
106
+ answer = runtime.buildBlockedActionReply(task, quality.reasons);
107
+ quality = runtime.selfCheckResponse(answer, { ...task, type: "chat" }, message);
108
+ }
109
+
110
+ return { prompt, answer, quality, retried: retries > 0, retries };
111
+ }
112
+
113
+ async function ollamaStream(messages, onToken, model = MODEL) {
114
+ const resp = await fetch(OLLAMA_URL, {
115
+ method: "POST",
116
+ headers: { "Content-Type": "application/json" },
117
+ body: JSON.stringify({ model, messages, stream: true })
118
+ });
119
+ if (!resp.ok) throw new Error(`Ollama error ${resp.status}`);
120
+
121
+ const decoder = new TextDecoder();
122
+ const reader = resp.body.getReader();
123
+ let buffer = "";
124
+ let full = "";
125
+
126
+ while (true) {
127
+ const chunk = await reader.read();
128
+ if (chunk.done) break;
129
+ buffer += decoder.decode(chunk.value, { stream: true });
130
+ const lines = buffer.split("\n");
131
+ buffer = lines.pop() || "";
132
+
133
+ for (const line of lines) {
134
+ const trimmed = line.trim();
135
+ if (!trimmed) continue;
136
+ try {
137
+ const payload = JSON.parse(trimmed);
138
+ const token = String(payload.message?.content || "");
139
+ if (!token) continue;
140
+ full += token;
141
+ const visible = token.replace(/<think>[\s\S]*?<\/think>/gi, "");
142
+ if (visible && onToken) onToken(visible);
143
+ } catch {
144
+ // Ignore malformed stream lines.
145
+ }
146
+ }
147
+ }
148
+
149
+ return full.replace(/<think>[\s\S]*?<\/think>/gi, "").trim();
150
+ }
151
+
152
+ function referenceQueryCandidates(message) {
153
+ const stripped = String(message || "")
154
+ .replace(/https?:\/\/[^\s]+/gi, " ")
155
+ .replace(/[^\p{L}\p{N}.\s-]/gu, " ")
156
+ .split(/\s+/)
157
+ .filter(Boolean);
158
+
159
+ const stop = new Set([
160
+ "bitte", "baue", "bau", "erstelle", "mach", "mache", "webseite", "website", "seite", "landingpage",
161
+ "landing", "referenz", "vorlage", "wie", "bei", "orientier", "orientiere", "angelehnt", "inspiriert",
162
+ "von", "mir", "eine", "einen", "ein", "fuer", "für", "und", "oder", "mit", "aus"
163
+ ]);
164
+
165
+ return stripped
166
+ .filter((part) => !stop.has(part.toLowerCase()))
167
+ .slice(0, 5)
168
+ .join(" ");
169
+ }
170
+
171
+ async function searchReferenceUrls(query) {
172
+ const searchUrl = `https://html.duckduckgo.com/html/?q=${encodeURIComponent(query)}`;
173
+ const resp = await fetch(searchUrl, {
174
+ headers: { "User-Agent": "BLUN-King/5.0 (+reference-search)" }
175
+ });
176
+ if (!resp.ok) return [];
177
+
178
+ const html = await resp.text();
179
+ const found = [...html.matchAll(/result__url[^>]*>\s*([^<\s]+)\s*</gi)]
180
+ .map((match) => match[1])
181
+ .map((value) => {
182
+ if (/^https?:\/\//i.test(value)) return value;
183
+ if (/^[a-z0-9.-]+\.[a-z]{2,}/i.test(value)) return `https://${value}`;
184
+ return null;
185
+ })
186
+ .filter(Boolean);
187
+
188
+ return [...new Set(found)].slice(0, 2);
189
+ }
190
+
191
+ async function resolveReferenceTargets(input, limit = 2) {
192
+ const direct = extractUrls(input).slice(0, limit);
193
+ if (direct.length > 0) return direct;
194
+
195
+ if (!/\b(referenz|vorlage|wie|bei|inspiriert|angelehnt)\b/i.test(String(input || ""))) {
196
+ return [];
197
+ }
198
+
199
+ const query = referenceQueryCandidates(input);
200
+ if (!query) return [];
201
+
202
+ try {
203
+ return await searchReferenceUrls(`${query} website`);
204
+ } catch {
205
+ return [];
206
+ }
207
+ }
208
+
209
+ async function collectReferenceContext(input) {
210
+ const urls = await resolveReferenceTargets(input, 2);
211
+ const references = [];
212
+
213
+ for (const url of urls) {
214
+ try {
215
+ const result = await inspectReference(url, { screenshot: true });
216
+ references.push({
217
+ url,
218
+ title: result.summary.title,
219
+ h1s: result.summary.h1s,
220
+ links: result.summary.links,
221
+ images: result.summary.images,
222
+ responsive: result.summary.responsive,
223
+ text: result.summary.text,
224
+ html: result.html,
225
+ screenshotPath: result.screenshotPath,
226
+ storage: result.storage,
227
+ error: result.error
228
+ });
229
+ } catch (error) {
230
+ references.push({ url, error: error.message });
231
+ }
232
+ }
233
+
234
+ return references;
235
+ }
236
+
237
+ async function collectAutoReferencePrompt(input, task) {
238
+ if (!shouldAutoReference(input, task)) return { references: [], promptBlock: "" };
239
+ const references = await autoReferenceChain(input, { screenshot: true, limit: 2 });
240
+ return {
241
+ references,
242
+ promptBlock: buildReferencePromptBlock(references)
243
+ };
244
+ }
245
+
246
+ async function buildWebsiteArtifact(goal, workdir) {
247
+ const references = await collectReferenceContext(goal);
248
+ const built = buildWebsiteProject(goal, references);
249
+ const validation = validateWebsiteOutput(built.files, built);
250
+
251
+ if (!validation.ok) {
252
+ throw new Error(`Website build blocked: ${validation.issues.join(", ")}`);
253
+ }
254
+
255
+ const create = await tools.executeTool("create_project", { name: built.projectName, files: built.files }, workdir);
256
+ const artifact = fileHelper.writeArtifact(`${built.projectName}.json`, JSON.stringify(built.files, null, 2), "utf8");
257
+
258
+ return {
259
+ brand: built.brand,
260
+ industry: built.industry,
261
+ validation,
262
+ projectRoot: create.projectRoot,
263
+ verified: create.verified,
264
+ references,
265
+ files: create.fileObjects.map((file) => ({
266
+ path: file.path,
267
+ content: file.content
268
+ })),
269
+ artifact: {
270
+ artifact_id: artifact.artifact_id,
271
+ filename: artifact.filename,
272
+ download: `/download/${artifact.artifact_id}`
273
+ }
274
+ };
275
+ }
276
+
277
+ function getIdentityId(req) {
278
+ return req.body.identity && req.body.identity !== "auto" ? String(req.body.identity) : undefined;
279
+ }
280
+
281
+ function composePromptWithPalace(task, session, message, identityId, sessionId) {
282
+ const palaceContext = palace.palaceWakeUp(sessionId);
283
+ const learnedContext = userProfile.buildPromptContext(sessionId, message);
284
+ const basePrompt = runtime.composeSystemPrompt(task, session, message, identityId);
285
+ const parts = [basePrompt];
286
+
287
+ if (learnedContext) {
288
+ parts.push(`[Gelerntes Nutzerwissen]\n${learnedContext.slice(0, 1500)}`);
289
+ }
290
+
291
+ if (palaceContext) {
292
+ parts.push(`[Langzeitgedaechtnis]\n${palaceContext.slice(0, 2000)}`);
293
+ }
294
+
295
+ return parts.join("\n\n");
296
+ }
297
+
298
+ function updateSession(sessionId, task, identityId, message, answer, workdir) {
299
+ memory.appendHistory(sessionId, "user", message);
300
+ memory.appendHistory(sessionId, "assistant", answer);
301
+ palace.palaceLogChat(sessionId, "user", message);
302
+ palace.palaceLogChat(sessionId, "assistant", answer);
303
+
304
+ if ((task.type === "website_builder" || task.type === "file_generation" || task.type === "browser_capture" || task.type === "installation") && !task.followup) {
305
+ memory.setActiveTask(sessionId, {
306
+ type: task.type,
307
+ role: task.role,
308
+ output: task.output,
309
+ workdir,
310
+ lastUserIntent: message,
311
+ identityId
312
+ });
313
+ } else if (!task.followup && task.type === "chat") {
314
+ memory.clearActiveTask(sessionId);
315
+ }
316
+ }
317
+
318
+ async function handleChatRequest(body = {}) {
319
+ const message = String(body.message || "").trim();
320
+ const sessionId = String(body.session_id || "default");
321
+ const workdir = path.resolve(body.workdir || process.cwd());
322
+ const preferredIdentity = body.identity && body.identity !== "auto" ? String(body.identity) : undefined;
323
+
324
+ const session = memory.loadSession(sessionId);
325
+ const routing = runtime.classifyTaskDetailed(message, session);
326
+ const task = routing.task;
327
+ const identity = runtime.resolveIdentity(message, task, session, preferredIdentity);
328
+ const autoReference = await collectAutoReferencePrompt(message, task);
329
+
330
+ if (taskExecutor.isDirectActionTask(task)) {
331
+ const direct = await taskExecutor.executeDirectTask({ task, message, session, workdir });
332
+ updateSession(sessionId, task, identity.id, message, direct.answer, workdir);
333
+ return {
334
+ answer: direct.answer,
335
+ task_type: task.type,
336
+ role: task.role,
337
+ identity: identity.id,
338
+ routing,
339
+ status: direct.status,
340
+ steps: direct.steps || [],
341
+ files: direct.files || [],
342
+ references: direct.references || [],
343
+ artifact: direct.artifact || null,
344
+ install: direct.install,
345
+ quality: { score: 100, flags: [], retried: false },
346
+ tokens: { input: 0, output: 0, total: 0 }
347
+ };
348
+ }
349
+
350
+ const model = task.type === "chat" && message.length < 50 ? FAST_MODEL : MODEL;
351
+ const generated = await generateVettedAnswer({
352
+ task,
353
+ session,
354
+ message,
355
+ identityId: identity.id,
356
+ sessionId,
357
+ model,
358
+ history: body.history,
359
+ extraPrompt: autoReference.promptBlock
360
+ });
361
+ const answer = generated.answer;
362
+ const quality = generated.quality;
363
+ const retried = generated.retried;
364
+
365
+ updateSession(sessionId, task, identity.id, message, answer, workdir);
366
+
367
+ const inputTokens = runtime.estimateTokens(generated.prompt + "\n" + message);
368
+ const outputTokens = runtime.estimateTokens(answer);
369
+
370
+ return {
371
+ answer,
372
+ task_type: task.type,
373
+ role: task.role,
374
+ identity: identity.id,
375
+ routing,
376
+ references: autoReference.references,
377
+ quality: { score: quality.score, flags: quality.reasons, retried, blocked: quality.blocked || false, critical: quality.critical || false },
378
+ tokens: { input: inputTokens, output: outputTokens, total: inputTokens + outputTokens }
379
+ };
380
+ }
381
+
382
+ async function handleAgentRequest(body = {}) {
383
+ const goal = String(body.goal || body.message || "").trim();
384
+ const sessionId = String(body.session_id || "default");
385
+ const workdir = path.resolve(body.workdir || process.cwd());
386
+ const preferredIdentity = body.identity && body.identity !== "auto" ? String(body.identity) : undefined;
387
+ const session = memory.loadSession(sessionId);
388
+ const routing = runtime.classifyTaskDetailed(goal, session);
389
+ const task = routing.task;
390
+ const identity = runtime.resolveIdentity(goal, task, session, preferredIdentity);
391
+ const autoReference = await collectAutoReferencePrompt(goal, task);
392
+
393
+ let status = "completed";
394
+ let steps = [];
395
+ let files = [];
396
+ let artifact = null;
397
+ let answer = "";
398
+ let references = autoReference.references;
399
+ let validation = null;
400
+
401
+ if (taskExecutor.isDirectActionTask(task)) {
402
+ const direct = await taskExecutor.executeDirectTask({ task, message: goal, session, workdir });
403
+ status = direct.status;
404
+ steps = direct.steps || [];
405
+ answer = direct.answer;
406
+ files = direct.files || [];
407
+ references = direct.references || [];
408
+ artifact = direct.artifact || null;
409
+ } else if (task.type === "website_builder") {
410
+ steps.push({ step: 1, action: "intent_detected", detail: "website_builder" });
411
+ steps.push({ step: 2, action: "identity_selected", detail: identity.id });
412
+ const built = await buildWebsiteArtifact(goal, workdir);
413
+ files = built.files;
414
+ artifact = built.artifact;
415
+ references = built.references;
416
+ validation = built.validation;
417
+ steps.push({ step: 3, action: "business_type_lock", detail: built.industry });
418
+ steps.push({ step: 4, action: "reference_analysis", detail: `${references.length} reference(s)` });
419
+ steps.push({ step: 5, action: "validity_gate", detail: validation.issues.length ? validation.issues.join(", ") : "passed" });
420
+ steps.push({ step: 6, action: "writing_files", files: files.map((file) => file.path) });
421
+ answer = `Landingpage gebaut. Business-Type-Lock: ${built.industry}. Referenzlogik und Validity-Gate wurden geprueft.`;
422
+ status = built.verified ? "completed" : "partial";
423
+ } else if (task.type === "file_generation") {
424
+ const filename = body.filename || "artifact.txt";
425
+ const write = await tools.executeTool("write_file", { path: filename, content: goal }, workdir);
426
+ files = [{ path: path.relative(workdir, write.path), content: goal }];
427
+ artifact = fileHelper.writeArtifact(filename, goal);
428
+ steps.push({ step: 1, action: "writing_file", files: [files[0].path] });
429
+ status = write.verified ? "completed" : "partial";
430
+ answer = "Datei erstellt.";
431
+ } else {
432
+ const generated = await generateVettedAnswer({
433
+ task,
434
+ session,
435
+ message: goal,
436
+ identityId: identity.id,
437
+ sessionId,
438
+ model: MODEL,
439
+ extraPrompt: autoReference.promptBlock
440
+ });
441
+ answer = generated.answer;
442
+ steps.push({ step: 1, action: "intent_detected", detail: task.type });
443
+ steps.push({ step: 2, action: "identity_selected", detail: identity.id });
444
+ steps.push({ step: 3, action: "response_generated" });
445
+ if (generated.retried) {
446
+ steps.push({ step: 4, action: "self_check_retry", detail: `${generated.retries} retry` });
447
+ }
448
+ }
449
+
450
+ updateSession(sessionId, task, identity.id, goal, answer, workdir);
451
+
452
+ return {
453
+ status,
454
+ task_type: task.type,
455
+ role: task.role,
456
+ identity: identity.id,
457
+ routing,
458
+ steps,
459
+ answer,
460
+ workdir,
461
+ validation,
462
+ references,
463
+ files,
464
+ artifact,
465
+ verified: status === "completed"
466
+ };
467
+ }
468
+
469
+ function runBackgroundJob(jobId, endpoint, handler, body) {
470
+ queueMicrotask(async () => {
471
+ jobs.updateJob(jobId, { status: "running", startedAt: Date.now(), error: null });
472
+ jobs.appendJobLog(jobId, "started", {
473
+ endpoint,
474
+ hasResumeContext: Boolean(body?.resume_context),
475
+ workdir: body?.workdir || process.cwd()
476
+ });
477
+ try {
478
+ const result = await handler(body);
479
+ jobs.appendJobLog(jobId, "completed", {
480
+ task_type: result.task_type,
481
+ status: result.status || "completed",
482
+ answer: String(result.answer || "").slice(0, 300),
483
+ fileCount: Array.isArray(result.files) ? result.files.length : 0,
484
+ referenceCount: Array.isArray(result.references) ? result.references.length : 0,
485
+ artifact: result.artifact?.filename || null
486
+ });
487
+ jobs.updateJob(jobId, { status: "completed", finishedAt: Date.now(), result });
488
+ } catch (error) {
489
+ jobs.appendJobLog(jobId, "failed", { error: error.message });
490
+ jobs.updateJob(jobId, { status: "failed", finishedAt: Date.now(), error: error.message, result: null });
491
+ }
492
+ });
493
+ }
494
+
495
+ function createApp() {
496
+ const app = express();
497
+ app.use(express.json({ limit: "10mb" }));
498
+ app.use(auth);
499
+
500
+ app.get("/identities", (req, res) => {
501
+ res.json({ identities: runtime.listIdentities() });
502
+ });
503
+
504
+ app.get("/memory/facts", (req, res) => {
505
+ res.json({ facts: findFacts(String(req.query.q || "")) });
506
+ });
507
+
508
+ app.post("/memory/facts", (req, res) => {
509
+ const record = storeFact({
510
+ scope: String(req.body.scope || "global"),
511
+ key: String(req.body.key || ""),
512
+ value: req.body.value,
513
+ tags: Array.isArray(req.body.tags) ? req.body.tags : []
514
+ });
515
+ res.json({ stored: true, record });
516
+ });
517
+
518
+ app.get("/memory/palace", (req, res) => {
519
+ const sessionId = String(req.query.session_id || "default");
520
+ res.json({
521
+ session_id: sessionId,
522
+ stats: palace.palaceStats(sessionId),
523
+ wake_up: palace.palaceWakeUp(sessionId)
524
+ });
525
+ });
526
+
527
+ app.post("/memory/palace/search", (req, res) => {
528
+ const sessionId = String(req.body.session_id || "default");
529
+ res.json({
530
+ session_id: sessionId,
531
+ result: palace.palaceSearch(String(req.body.query || ""), Number(req.body.limit || 5), sessionId)
532
+ });
533
+ });
534
+
535
+ app.post("/reference/inspect", async (req, res) => {
536
+ try {
537
+ const url = String(req.body.url || "").trim();
538
+ if (!url) return res.status(400).json({ error: "url is required" });
539
+ res.json(await inspectReference(url, { screenshot: req.body.screenshot !== false }));
540
+ } catch (error) {
541
+ res.status(500).json({ error: error.message });
542
+ }
543
+ });
544
+
545
+ app.post("/reference/screenshot", async (req, res) => {
546
+ try {
547
+ const url = String(req.body.url || "").trim();
548
+ if (!url) return res.status(400).json({ error: "url is required" });
549
+ res.json(await screenshotUrl(url, { timeout: req.body.timeout }));
550
+ } catch (error) {
551
+ res.status(500).json({ error: error.message });
552
+ }
553
+ });
554
+
555
+ app.get("/browser/status", requireLoopback, async (req, res) => {
556
+ try {
557
+ res.json(await browser.snapshot());
558
+ } catch (error) {
559
+ res.json({ url: null, title: "", text: "", error: error.message });
560
+ }
561
+ });
562
+
563
+ app.post("/browser/open", requireLoopback, async (req, res) => {
564
+ try {
565
+ res.json(await browser.open(String(req.body.url || "")));
566
+ } catch (error) {
567
+ res.status(500).json({ error: error.message });
568
+ }
569
+ });
570
+
571
+ app.post("/browser/click", requireLoopback, async (req, res) => {
572
+ try {
573
+ res.json(await browser.click(String(req.body.selector || "")));
574
+ } catch (error) {
575
+ res.status(500).json({ error: error.message });
576
+ }
577
+ });
578
+
579
+ app.post("/browser/type", requireLoopback, async (req, res) => {
580
+ try {
581
+ res.json(await browser.type(String(req.body.selector || ""), String(req.body.text || "")));
582
+ } catch (error) {
583
+ res.status(500).json({ error: error.message });
584
+ }
585
+ });
586
+
587
+ app.post("/browser/press", requireLoopback, async (req, res) => {
588
+ try {
589
+ res.json(await browser.press(String(req.body.key || "Enter")));
590
+ } catch (error) {
591
+ res.status(500).json({ error: error.message });
592
+ }
593
+ });
594
+
595
+ app.post("/browser/screenshot", requireLoopback, async (req, res) => {
596
+ try {
597
+ const targetPath = path.resolve(String(req.body.path || path.join(process.cwd(), `browser-shot-${Date.now()}.png`)));
598
+ res.json(await browser.screenshot(targetPath));
599
+ } catch (error) {
600
+ res.status(500).json({ error: error.message });
601
+ }
602
+ });
603
+
604
+ app.post("/browser/close", requireLoopback, async (req, res) => {
605
+ try {
606
+ res.json(await browser.close());
607
+ } catch (error) {
608
+ res.status(500).json({ error: error.message });
609
+ }
610
+ });
611
+
612
+ app.post("/chat", async (req, res) => {
613
+ try {
614
+ if (req.body.background) {
615
+ const job = jobs.createJob({
616
+ endpoint: "/chat",
617
+ sessionId: String(req.body.session_id || "default"),
618
+ payload: req.body,
619
+ mode: "chat"
620
+ });
621
+ jobs.appendJobLog(job.id, "queued", { mode: "chat", message: String(req.body.message || "").slice(0, 200) });
622
+ runBackgroundJob(job.id, "/chat", handleChatRequest, req.body);
623
+ return res.json({ status: "queued", job_id: job.id });
624
+ }
625
+
626
+ res.json(await handleChatRequest(req.body));
627
+ } catch (error) {
628
+ res.status(500).json({ error: error.message });
629
+ }
630
+ });
631
+
632
+ app.post("/chat/stream", async (req, res) => {
633
+ try {
634
+ const message = String(req.body.message || "").trim();
635
+ const sessionId = String(req.body.session_id || "default");
636
+ const preferredIdentity = getIdentityId(req);
637
+ const session = memory.loadSession(sessionId);
638
+ const routing = runtime.classifyTaskDetailed(message, session);
639
+ const task = routing.task;
640
+ const identity = runtime.resolveIdentity(message, task, session, preferredIdentity);
641
+
642
+ if (taskExecutor.isDirectActionTask(task)) {
643
+ res.writeHead(200, {
644
+ "Content-Type": "text/event-stream",
645
+ "Cache-Control": "no-cache",
646
+ Connection: "keep-alive"
647
+ });
648
+
649
+ const sendEvent = (type, payload) => {
650
+ res.write(`event: ${type}\n`);
651
+ res.write(`data: ${JSON.stringify(payload)}\n\n`);
652
+ };
653
+
654
+ const direct = await taskExecutor.executeDirectTask({ task, message, session, workdir: process.cwd() });
655
+ updateSession(sessionId, task, identity.id, message, direct.answer, process.cwd());
656
+ sendEvent("meta", { task_type: task.type, role: task.role, identity: identity.id, routing });
657
+ sendEvent("done", {
658
+ answer: direct.answer,
659
+ install: direct.install,
660
+ files: direct.files || [],
661
+ references: direct.references || [],
662
+ artifact: direct.artifact || null,
663
+ quality: { score: 100, flags: [] }
664
+ });
665
+ res.end();
666
+ return;
667
+ }
668
+
669
+ const model = task.type === "chat" && message.length < 50 ? FAST_MODEL : MODEL;
670
+
671
+ res.writeHead(200, {
672
+ "Content-Type": "text/event-stream",
673
+ "Cache-Control": "no-cache",
674
+ Connection: "keep-alive"
675
+ });
676
+
677
+ const sendEvent = (type, payload) => {
678
+ res.write(`event: ${type}\n`);
679
+ res.write(`data: ${JSON.stringify(payload)}\n\n`);
680
+ };
681
+
682
+ const prompt = composePromptWithPalace(task, session, message, identity.id, sessionId);
683
+ const messages = buildConversationMessages({
684
+ prompt,
685
+ session,
686
+ history: req.body.history,
687
+ message
688
+ });
689
+ sendEvent("meta", { task_type: task.type, role: task.role, identity: identity.id, routing });
690
+ let answer = await ollamaStream(messages, (token) => sendEvent("token", { text: token }), model);
691
+ let quality = runtime.selfCheckResponse(answer, task, message);
692
+ let retries = 0;
693
+
694
+ while (task.type !== "chat" && quality.blocked && retries < 2) {
695
+ answer = await generateRecoveredAnswer({
696
+ task,
697
+ session,
698
+ message,
699
+ identityId: identity.id,
700
+ sessionId,
701
+ previousAnswer: answer,
702
+ reasons: quality.reasons.length ? quality.reasons : ["action_mismatch"],
703
+ model
704
+ });
705
+ quality = runtime.selfCheckResponse(answer, task, message);
706
+ retries += 1;
707
+ }
708
+
709
+ if (task.type !== "chat" && quality.blocked) {
710
+ answer = runtime.buildBlockedActionReply(task, quality.reasons);
711
+ quality = runtime.selfCheckResponse(answer, { ...task, type: "chat" }, message);
712
+ }
713
+
714
+ updateSession(sessionId, task, identity.id, message, answer, process.cwd());
715
+ sendEvent("done", { answer, quality: { ...quality, retried: retries > 0 } });
716
+ res.end();
717
+ } catch (error) {
718
+ res.write(`event: error\ndata: ${JSON.stringify({ error: error.message })}\n\n`);
719
+ res.end();
720
+ }
721
+ });
722
+
723
+ app.post("/agent", async (req, res) => {
724
+ try {
725
+ if (req.body.background) {
726
+ const job = jobs.createJob({
727
+ endpoint: "/agent",
728
+ sessionId: String(req.body.session_id || "default"),
729
+ payload: req.body,
730
+ mode: "agent"
731
+ });
732
+ jobs.appendJobLog(job.id, "queued", { mode: "agent", goal: String(req.body.goal || req.body.message || "").slice(0, 200) });
733
+ runBackgroundJob(job.id, "/agent", handleAgentRequest, req.body);
734
+ return res.json({ status: "queued", job_id: job.id });
735
+ }
736
+
737
+ res.json(await handleAgentRequest(req.body));
738
+ } catch (error) {
739
+ res.status(500).json({ error: error.message });
740
+ }
741
+ });
742
+
743
+ app.post("/analyze", async (req, res) => {
744
+ try {
745
+ const type = req.body.type || "website";
746
+ let source = "";
747
+
748
+ if (req.body.html) source = String(req.body.html);
749
+ else if (req.body.url) source = await (await fetch(String(req.body.url))).text();
750
+ else return res.status(400).json({ error: "Provide html or url" });
751
+
752
+ const title = (source.match(/<title[^>]*>([\s\S]*?)<\/title>/i) || [, ""])[1].trim();
753
+ const h1s = [...source.matchAll(/<h1[^>]*>([\s\S]*?)<\/h1>/ig)]
754
+ .map((match) => match[1].replace(/<[^>]+>/g, "").trim())
755
+ .filter(Boolean);
756
+ const links = [...source.matchAll(/<a\b/ig)].length;
757
+ const images = [...source.matchAll(/<img\b/ig)].length;
758
+ const analysis = [
759
+ `Seitentyp: ${type}`,
760
+ h1s.length ? `H1-Fokus: ${h1s[0]}` : "H1-Fokus: unklar",
761
+ "Kurzbewertung: Fokus auf Ziel, Hierarchie, CTA und Conversion statt technischem Head-Audit."
762
+ ].join("\n");
763
+
764
+ res.json({
765
+ analysis,
766
+ meta: { title, h1s, links, images, responsive: /viewport/i.test(source), frameworks: [] }
767
+ });
768
+ } catch (error) {
769
+ res.status(500).json({ error: error.message });
770
+ }
771
+ });
772
+
773
+ app.post("/classify", (req, res) => {
774
+ const routing = runtime.classifyTaskDetailed(String(req.body.message || ""), null);
775
+ const task = routing.task;
776
+ const identity = runtime.resolveIdentity(String(req.body.message || ""), task, null, getIdentityId(req));
777
+ res.json({
778
+ type: task.type,
779
+ role: task.role,
780
+ output: task.output,
781
+ reason: task.reason,
782
+ identity: identity.id,
783
+ confidence: routing.confidence,
784
+ scores: routing.scores
785
+ });
786
+ });
787
+
788
+ app.post("/learn", async (req, res) => {
789
+ try {
790
+ const content = req.body.url ? await (await fetch(String(req.body.url))).text() : String(req.body.content || "");
791
+ const title = String(req.body.title || "learn-input");
792
+ const category = String(req.body.category || "general");
793
+ const userId = String(req.body.user_id || req.body.session_id || "default");
794
+ const filePath = path.join(process.env.BLUN_HOME || path.join(os.homedir(), ".blun"), "knowledge", `${Date.now()}_${title.replace(/[^\w.-]/g, "_")}.txt`);
795
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
796
+ fs.writeFileSync(filePath, content, "utf8");
797
+ palace.palaceLearn(content.slice(0, 8000), category, userId);
798
+
799
+ const learned = userProfile.learnFromText(userId, content, { title, category });
800
+ if (title || category) {
801
+ storeFact({
802
+ scope: userId,
803
+ key: `learn:${category}:${title}`,
804
+ value: {
805
+ title,
806
+ category,
807
+ aliasesLearned: learned.aliasesLearned,
808
+ snippetsLearned: learned.snippetsLearned,
809
+ types: learned.types,
810
+ preview: learned.snippets.slice(0, 2)
811
+ },
812
+ tags: ["learn", category]
813
+ });
814
+ }
815
+
816
+ res.json({
817
+ stored: true,
818
+ category,
819
+ user_id: userId,
820
+ file: filePath,
821
+ content_length: content.length,
822
+ learned
823
+ });
824
+ } catch (error) {
825
+ res.status(500).json({ error: error.message });
826
+ }
827
+ });
828
+
829
+ app.get("/eval/noisy-intents", (req, res) => {
830
+ res.json(evaluateNoisyIntentCorpus());
831
+ });
832
+
833
+ app.get("/jobs", (req, res) => {
834
+ res.json({
835
+ jobs: jobs.listJobs({
836
+ sessionId: req.query.session_id ? String(req.query.session_id) : undefined,
837
+ limit: Number(req.query.limit || 20)
838
+ }).map((job) => ({
839
+ ...job,
840
+ summary: jobs.summarizeJob(job)
841
+ }))
842
+ });
843
+ });
844
+
845
+ app.get("/jobs/:id", (req, res) => {
846
+ const job = jobs.getJob(String(req.params.id || ""));
847
+ if (!job) return res.status(404).json({ error: "Job not found" });
848
+ res.json({
849
+ ...job,
850
+ summary: jobs.summarizeJob(job)
851
+ });
852
+ });
853
+
854
+ app.get("/jobs/:id/logs", (req, res) => {
855
+ const job = jobs.getJob(String(req.params.id || ""));
856
+ if (!job) return res.status(404).json({ error: "Job not found" });
857
+ res.json({ logs: jobs.getJobLogs(job.id) });
858
+ });
859
+
860
+ app.post("/jobs/:id/resume", (req, res) => {
861
+ const cloned = jobs.cloneJobForResume(String(req.params.id || ""));
862
+ if (!cloned) return res.status(404).json({ error: "Job not found" });
863
+ const handler = cloned.endpoint === "/chat" ? handleChatRequest : handleAgentRequest;
864
+ jobs.appendJobLog(cloned.id, "resumed", { from: req.params.id });
865
+ runBackgroundJob(cloned.id, cloned.endpoint, handler, cloned.payload);
866
+ res.json({ status: "queued", job_id: cloned.id, resumed_from: req.params.id });
867
+ });
868
+
869
+ app.post("/generate-file", async (req, res) => {
870
+ try {
871
+ const prompt = String(req.body.prompt || "").trim();
872
+ const format = String(req.body.format || "txt").toLowerCase();
873
+ const filename = String(req.body.filename || `artifact.${format}`);
874
+ const workdir = path.resolve(req.body.workdir || process.cwd());
875
+
876
+ let content = prompt;
877
+ if (format === "html") {
878
+ content = buildWebsiteProject(prompt, []).files["index.html"];
879
+ }
880
+
881
+ const write = await tools.executeTool("write_file", { path: filename, content }, workdir);
882
+ const artifact = fileHelper.writeArtifact(filename, content);
883
+
884
+ res.json({
885
+ artifact_id: artifact.artifact_id,
886
+ filename,
887
+ format,
888
+ size: artifact.size,
889
+ download: `/download/${artifact.artifact_id}`,
890
+ content,
891
+ files: [{ path: path.relative(workdir, write.path), content }],
892
+ workdir,
893
+ verified: write.verified
894
+ });
895
+ } catch (error) {
896
+ res.status(500).json({ error: error.message });
897
+ }
898
+ });
899
+
900
+ app.get("/download/:id", (req, res) => {
901
+ const artifact = fileHelper.findArtifact(req.params.id);
902
+ if (!artifact) return res.status(404).json({ error: "Artifact not found" });
903
+ res.download(artifact.full, artifact.filename);
904
+ });
905
+
906
+ app.get("/artifacts", (req, res) => {
907
+ res.json({ artifacts: fileHelper.listArtifacts() });
908
+ });
909
+
910
+ app.post("/score", (req, res) => {
911
+ const task = { type: req.body.task_type || "chat", role: "default", output: "text" };
912
+ const result = runtime.scoreResponse(String(req.body.answer || ""), task, String(req.body.userMessage || ""));
913
+ res.json({ score: result.score, reasons: result.reasons });
914
+ });
915
+
916
+ app.post("/tools", async (req, res) => {
917
+ try {
918
+ const result = await tools.executeTool(req.body.tool, req.body.params || {}, req.body.workdir || process.cwd());
919
+ res.json(result);
920
+ } catch (error) {
921
+ res.status(500).json({ error: error.message });
922
+ }
923
+ });
924
+
925
+ app.get("/runtime/status", (req, res) => {
926
+ res.json({
927
+ model: MODEL,
928
+ fast_model: FAST_MODEL,
929
+ ollama: OLLAMA_URL,
930
+ uptime: process.uptime(),
931
+ prompt_registry: runtime.REGISTRY,
932
+ identities: runtime.listIdentities().map(({ id, name }) => ({ id, name })),
933
+ token_mode: TOKENS.length ? "token" : "local-only",
934
+ endpoints: ["/chat", "/chat/stream", "/agent", "/analyze", "/classify", "/learn", "/jobs", "/jobs/:id", "/jobs/:id/logs", "/jobs/:id/resume", "/generate-file", "/download/:id", "/artifacts", "/score", "/tools", "/runtime/status", "/versions", "/identities", "/memory/facts", "/memory/palace", "/memory/palace/search", "/reference/inspect", "/reference/screenshot", "/browser/status", "/browser/open", "/browser/click", "/browser/type", "/browser/press", "/browser/screenshot", "/browser/close", "/eval/noisy-intents", "/health"]
935
+ });
936
+ });
937
+
938
+ app.get("/versions", (req, res) => {
939
+ res.json(runtime.REGISTRY);
940
+ });
941
+
942
+ app.get("/health", (req, res) => {
943
+ res.json({ status: "ok", model: MODEL, uptime: Math.round(process.uptime()) });
944
+ });
945
+
946
+ return app;
947
+ }
948
+
949
+ function startServer(port = PORT) {
950
+ const app = createApp();
951
+ const server = app.listen(port, "0.0.0.0", () => {
952
+ const authMode = TOKENS.length ? "token" : "local-only";
953
+ console.log(`BLUN King API listening on http://0.0.0.0:${port} (${authMode})`);
954
+ });
955
+ return { app, server };
956
+ }
957
+
958
+ if (require.main === module) {
959
+ startServer();
960
+ }
961
+
962
+ module.exports = {
963
+ createApp,
964
+ startServer
965
+ };