alvin-bot 4.4.1

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 (136) hide show
  1. package/.env.example +43 -0
  2. package/BACKLOG.md +223 -0
  3. package/CHANGELOG.md +63 -0
  4. package/CLAUDE.example.md +152 -0
  5. package/CODE_OF_CONDUCT.md +52 -0
  6. package/CONTRIBUTING.md +72 -0
  7. package/LICENSE +21 -0
  8. package/README.md +529 -0
  9. package/SECURITY.md +38 -0
  10. package/SOUL.example.md +60 -0
  11. package/TOOLS.example.md +42 -0
  12. package/alvin-bot.config.example.json +24 -0
  13. package/bin/cli.js +1088 -0
  14. package/dist/.metadata_never_index +0 -0
  15. package/dist/claude.js +102 -0
  16. package/dist/config.js +65 -0
  17. package/dist/engine.js +90 -0
  18. package/dist/find-claude-binary.js +98 -0
  19. package/dist/handlers/commands.js +1489 -0
  20. package/dist/handlers/document.js +187 -0
  21. package/dist/handlers/message.js +200 -0
  22. package/dist/handlers/photo.js +154 -0
  23. package/dist/handlers/platform-message.js +275 -0
  24. package/dist/handlers/video.js +237 -0
  25. package/dist/handlers/voice.js +148 -0
  26. package/dist/i18n.js +299 -0
  27. package/dist/index.js +442 -0
  28. package/dist/init-data-dir.js +81 -0
  29. package/dist/middleware/auth.js +215 -0
  30. package/dist/migrate.js +139 -0
  31. package/dist/paths.js +87 -0
  32. package/dist/platforms/discord.js +161 -0
  33. package/dist/platforms/index.js +130 -0
  34. package/dist/platforms/signal.js +205 -0
  35. package/dist/platforms/slack.js +318 -0
  36. package/dist/platforms/telegram.js +111 -0
  37. package/dist/platforms/types.js +8 -0
  38. package/dist/platforms/whatsapp.js +648 -0
  39. package/dist/providers/claude-sdk-provider.js +173 -0
  40. package/dist/providers/codex-cli-provider.js +121 -0
  41. package/dist/providers/index.js +7 -0
  42. package/dist/providers/openai-compatible.js +388 -0
  43. package/dist/providers/registry.js +209 -0
  44. package/dist/providers/tool-executor.js +450 -0
  45. package/dist/providers/types.js +205 -0
  46. package/dist/services/access.js +144 -0
  47. package/dist/services/asset-index.js +230 -0
  48. package/dist/services/browser-manager.js +161 -0
  49. package/dist/services/browser.js +121 -0
  50. package/dist/services/compaction.js +129 -0
  51. package/dist/services/cron.js +462 -0
  52. package/dist/services/custom-tools.js +317 -0
  53. package/dist/services/delivery-queue.js +154 -0
  54. package/dist/services/elevenlabs.js +58 -0
  55. package/dist/services/embeddings.js +386 -0
  56. package/dist/services/exec-guard.js +46 -0
  57. package/dist/services/fallback-order.js +151 -0
  58. package/dist/services/heartbeat.js +192 -0
  59. package/dist/services/hooks.js +44 -0
  60. package/dist/services/imagegen.js +72 -0
  61. package/dist/services/language-detect.js +144 -0
  62. package/dist/services/markdown.js +63 -0
  63. package/dist/services/mcp.js +252 -0
  64. package/dist/services/memory.js +133 -0
  65. package/dist/services/personality.js +227 -0
  66. package/dist/services/plugins.js +171 -0
  67. package/dist/services/reminders.js +97 -0
  68. package/dist/services/restart.js +48 -0
  69. package/dist/services/security-audit.js +66 -0
  70. package/dist/services/self-search.js +129 -0
  71. package/dist/services/session.js +93 -0
  72. package/dist/services/skills.js +287 -0
  73. package/dist/services/standing-orders.js +29 -0
  74. package/dist/services/subagents.js +142 -0
  75. package/dist/services/sudo.js +243 -0
  76. package/dist/services/telegram.js +113 -0
  77. package/dist/services/tool-discovery.js +214 -0
  78. package/dist/services/usage-tracker.js +137 -0
  79. package/dist/services/users.js +199 -0
  80. package/dist/services/voice.js +95 -0
  81. package/dist/tui/index.js +507 -0
  82. package/dist/web/canvas.js +30 -0
  83. package/dist/web/doctor-api.js +606 -0
  84. package/dist/web/openai-compat.js +252 -0
  85. package/dist/web/server.js +1351 -0
  86. package/dist/web/setup-api.js +1078 -0
  87. package/docs/mcp.example.json +16 -0
  88. package/docs/screenshots/00-Login.png +0 -0
  89. package/docs/screenshots/01-Chat-Dark-Conversation.png +0 -0
  90. package/docs/screenshots/02-Chat.png +0 -0
  91. package/docs/screenshots/03-Dashboard-Overview.png +0 -0
  92. package/docs/screenshots/04-AI-Models-and-Providers.png +0 -0
  93. package/docs/screenshots/05-Personality-Editor.png +0 -0
  94. package/docs/screenshots/06-Memory-Manager.png +0 -0
  95. package/docs/screenshots/07-Active-Sessions.png +0 -0
  96. package/docs/screenshots/08-File-Browser.png +0 -0
  97. package/docs/screenshots/09-Scheduled-Jobs.png +0 -0
  98. package/docs/screenshots/10-Custom-Tools.png +0 -0
  99. package/docs/screenshots/11-Plugins-and-MCP.png +0 -0
  100. package/docs/screenshots/12-Messaging-Platforms.png +0 -0
  101. package/docs/screenshots/12.1-Messaging-Platforms-WhatsApp-Groups-List.png +0 -0
  102. package/docs/screenshots/12.2-Messaging-Platforms-WA-Group-Details.png +0 -0
  103. package/docs/screenshots/13-User-Management.png +0 -0
  104. package/docs/screenshots/14-Web-Terminal.png +0 -0
  105. package/docs/screenshots/15-Maintenance-and-Health.png +0 -0
  106. package/docs/screenshots/16-Settings-and-Env.png +0 -0
  107. package/docs/screenshots/TG-commands.png +0 -0
  108. package/docs/screenshots/TG.png +0 -0
  109. package/docs/screenshots/_Mac-Installer.png +0 -0
  110. package/docs/tools.example.json +33 -0
  111. package/install.sh +165 -0
  112. package/package.json +190 -0
  113. package/plugins/calendar/index.js +270 -0
  114. package/plugins/email/index.js +231 -0
  115. package/plugins/finance/index.js +254 -0
  116. package/plugins/notes/index.js +227 -0
  117. package/plugins/smarthome/index.js +230 -0
  118. package/plugins/weather/index.js +122 -0
  119. package/skills/apple-notes/SKILL.md +31 -0
  120. package/skills/browse/SKILL.md +136 -0
  121. package/skills/code-project/SKILL.md +43 -0
  122. package/skills/data-analysis/SKILL.md +39 -0
  123. package/skills/document-creation/SKILL.md +48 -0
  124. package/skills/email-summary/SKILL.md +46 -0
  125. package/skills/github/SKILL.md +42 -0
  126. package/skills/summarize/SKILL.md +28 -0
  127. package/skills/system-admin/SKILL.md +39 -0
  128. package/skills/weather/SKILL.md +34 -0
  129. package/skills/web-research/SKILL.md +35 -0
  130. package/web/public/canvas.html +52 -0
  131. package/web/public/css/style.css +555 -0
  132. package/web/public/index.html +189 -0
  133. package/web/public/js/app.js +3102 -0
  134. package/web/public/js/i18n.js +1048 -0
  135. package/web/public/js/icons.js +104 -0
  136. package/web/public/login.html +48 -0
@@ -0,0 +1,1351 @@
1
+ /**
2
+ * Web Server — Local dashboard for Alvin Bot.
3
+ *
4
+ * Provides:
5
+ * - Static file serving (web/public/)
6
+ * - WebSocket for real-time chat + streaming
7
+ * - REST API for settings, memory, sessions, etc.
8
+ * - Simple password auth (WEB_PASSWORD env var)
9
+ */
10
+ import http from "http";
11
+ import fs from "fs";
12
+ import path from "path";
13
+ import { resolve } from "path";
14
+ import { execSync } from "child_process";
15
+ import { WebSocketServer, WebSocket } from "ws";
16
+ import { getRegistry } from "../engine.js";
17
+ import { getSession, resetSession, getAllSessions } from "../services/session.js";
18
+ import { getMemoryStats, loadLongTermMemory, loadDailyLog } from "../services/memory.js";
19
+ import { getIndexStats } from "../services/embeddings.js";
20
+ import { getLoadedPlugins } from "../services/plugins.js";
21
+ import { getMCPStatus } from "../services/mcp.js";
22
+ import { listProfiles } from "../services/users.js";
23
+ import { listCustomTools, getCustomTools, executeCustomTool } from "../services/custom-tools.js";
24
+ import { buildSystemPrompt, reloadSoul, getSoulContent } from "../services/personality.js";
25
+ import { config } from "../config.js";
26
+ import { handleSetupAPI } from "./setup-api.js";
27
+ import { handleDoctorAPI } from "./doctor-api.js";
28
+ import { handleOpenAICompat } from "./openai-compat.js";
29
+ import { addCanvasClient } from "./canvas.js";
30
+ import { BOT_ROOT, ENV_FILE, PUBLIC_DIR, MEMORY_DIR, MEMORY_FILE, SOUL_FILE, DATA_DIR, MCP_CONFIG, SKILLS_DIR } from "../paths.js";
31
+ const WEB_PORT = parseInt(process.env.WEB_PORT || "3100");
32
+ const WEB_PASSWORD = process.env.WEB_PASSWORD || "";
33
+ /** The actual port the Web UI is running on (may differ from WEB_PORT if busy). */
34
+ let actualWebPort = WEB_PORT;
35
+ // ── MIME Types ──────────────────────────────────────────
36
+ const MIME = {
37
+ ".html": "text/html",
38
+ ".css": "text/css",
39
+ ".js": "application/javascript",
40
+ ".json": "application/json",
41
+ ".png": "image/png",
42
+ ".jpg": "image/jpeg",
43
+ ".svg": "image/svg+xml",
44
+ ".ico": "image/x-icon",
45
+ };
46
+ // ── Auth ────────────────────────────────────────────────
47
+ const activeSessions = new Set();
48
+ function generateToken() {
49
+ return Array.from(crypto.getRandomValues(new Uint8Array(32)))
50
+ .map(b => b.toString(16).padStart(2, "0")).join("");
51
+ }
52
+ function checkAuth(req) {
53
+ if (!WEB_PASSWORD)
54
+ return true; // No password = open access
55
+ const cookie = req.headers.cookie || "";
56
+ const token = cookie.match(/alvinbot_token=([a-f0-9]+)/)?.[1];
57
+ return token ? activeSessions.has(token) : false;
58
+ }
59
+ // ── REST API ────────────────────────────────────────────
60
+ async function handleAPI(req, res, urlPath, body) {
61
+ res.setHeader("Content-Type", "application/json");
62
+ // POST /api/login
63
+ if (urlPath === "/api/login" && req.method === "POST") {
64
+ try {
65
+ const { password } = JSON.parse(body);
66
+ if (!WEB_PASSWORD || password === WEB_PASSWORD) {
67
+ const token = generateToken();
68
+ activeSessions.add(token);
69
+ res.setHeader("Set-Cookie", `alvinbot_token=${token}; Path=/; HttpOnly; SameSite=Strict; Max-Age=86400`);
70
+ res.end(JSON.stringify({ ok: true }));
71
+ }
72
+ else {
73
+ res.statusCode = 401;
74
+ res.end(JSON.stringify({ error: "Wrong password" }));
75
+ }
76
+ }
77
+ catch {
78
+ res.statusCode = 400;
79
+ res.end(JSON.stringify({ error: "Invalid request" }));
80
+ }
81
+ return;
82
+ }
83
+ // POST /api/webhook — external trigger endpoint with bearer auth (no cookie auth needed)
84
+ if (urlPath === "/api/webhook" && req.method === "POST") {
85
+ if (!config.webhookEnabled) {
86
+ res.writeHead(404);
87
+ res.end(JSON.stringify({ error: "Webhooks disabled" }));
88
+ return;
89
+ }
90
+ const authHeader = req.headers.authorization || "";
91
+ if (authHeader !== `Bearer ${config.webhookToken}`) {
92
+ res.writeHead(401);
93
+ res.end(JSON.stringify({ error: "Unauthorized" }));
94
+ return;
95
+ }
96
+ try {
97
+ const payload = JSON.parse(body);
98
+ if (!payload.message) {
99
+ res.writeHead(400);
100
+ res.end(JSON.stringify({ error: "Missing message field" }));
101
+ return;
102
+ }
103
+ const channel = payload.channel || "telegram";
104
+ const chatId = payload.chatId || String(config.allowedUsers[0] || "");
105
+ const { enqueue } = await import("../services/delivery-queue.js");
106
+ const id = enqueue(channel, chatId, `[Webhook: ${payload.event || "unknown"}] ${payload.message}`);
107
+ res.writeHead(200);
108
+ res.end(JSON.stringify({ ok: true, queued: id }));
109
+ }
110
+ catch {
111
+ res.writeHead(400);
112
+ res.end(JSON.stringify({ error: "Invalid JSON body" }));
113
+ }
114
+ return;
115
+ }
116
+ // Auth check for all other API routes
117
+ if (!checkAuth(req)) {
118
+ res.statusCode = 401;
119
+ res.end(JSON.stringify({ error: "Not authenticated" }));
120
+ return;
121
+ }
122
+ // ── Setup APIs (platforms + models) ─────────────────
123
+ const handled = await handleSetupAPI(req, res, urlPath, body);
124
+ if (handled)
125
+ return;
126
+ // ── Doctor & Backup APIs ──────────────────────────
127
+ const doctorHandled = await handleDoctorAPI(req, res, urlPath, body);
128
+ if (doctorHandled)
129
+ return;
130
+ // GET /api/setup-check — is the bot fully configured?
131
+ if (urlPath === "/api/setup-check") {
132
+ const envPath = ENV_FILE;
133
+ let env = {};
134
+ try {
135
+ const lines = fs.readFileSync(envPath, "utf-8").split("\n");
136
+ for (const line of lines) {
137
+ if (line.startsWith("#") || !line.includes("="))
138
+ continue;
139
+ const idx = line.indexOf("=");
140
+ env[line.slice(0, idx).trim()] = line.slice(idx + 1).trim();
141
+ }
142
+ }
143
+ catch { }
144
+ const hasBotToken = !!(env.BOT_TOKEN || process.env.BOT_TOKEN);
145
+ const hasAllowedUsers = !!(env.ALLOWED_USERS || process.env.ALLOWED_USERS);
146
+ const hasPrimaryProvider = !!(env.PRIMARY_PROVIDER || process.env.PRIMARY_PROVIDER);
147
+ // Check which providers have keys
148
+ const providerKeys = {
149
+ groq: !!(env.GROQ_API_KEY || process.env.GROQ_API_KEY),
150
+ openai: !!(env.OPENAI_API_KEY || process.env.OPENAI_API_KEY),
151
+ google: !!(env.GOOGLE_API_KEY || process.env.GOOGLE_API_KEY),
152
+ nvidia: !!(env.NVIDIA_API_KEY || process.env.NVIDIA_API_KEY),
153
+ anthropic: !!(env.ANTHROPIC_API_KEY || process.env.ANTHROPIC_API_KEY),
154
+ openrouter: !!(env.OPENROUTER_API_KEY || process.env.OPENROUTER_API_KEY),
155
+ };
156
+ const hasAnyProvider = hasPrimaryProvider || Object.values(providerKeys).some(Boolean);
157
+ // Check Claude CLI
158
+ let claudeCliInstalled = false;
159
+ try {
160
+ const { execSync } = await import("child_process");
161
+ execSync("claude --version", { timeout: 5000, stdio: "pipe" });
162
+ claudeCliInstalled = true;
163
+ }
164
+ catch { }
165
+ const isComplete = hasBotToken && hasAllowedUsers && hasAnyProvider;
166
+ res.end(JSON.stringify({
167
+ isComplete,
168
+ steps: {
169
+ telegram: { done: hasBotToken && hasAllowedUsers, botToken: hasBotToken, allowedUsers: hasAllowedUsers },
170
+ provider: { done: hasAnyProvider, primary: env.PRIMARY_PROVIDER || process.env.PRIMARY_PROVIDER || "", keys: providerKeys, claudeCli: claudeCliInstalled },
171
+ },
172
+ }));
173
+ return;
174
+ }
175
+ // POST /api/setup-wizard — save all setup data at once (first-run wizard)
176
+ if (urlPath === "/api/setup-wizard" && req.method === "POST") {
177
+ try {
178
+ const data = JSON.parse(body);
179
+ const envPath = ENV_FILE;
180
+ let content = fs.existsSync(envPath) ? fs.readFileSync(envPath, "utf-8") : "";
181
+ const setEnv = (key, value) => {
182
+ const regex = new RegExp(`^${key}=.*$`, "m");
183
+ if (regex.test(content)) {
184
+ content = content.replace(regex, `${key}=${value}`);
185
+ }
186
+ else {
187
+ content = content.trimEnd() + `\n${key}=${value}\n`;
188
+ }
189
+ process.env[key] = value;
190
+ };
191
+ // Step 1: Telegram
192
+ if (data.botToken)
193
+ setEnv("BOT_TOKEN", data.botToken);
194
+ if (data.allowedUsers)
195
+ setEnv("ALLOWED_USERS", data.allowedUsers);
196
+ // Step 2: Provider
197
+ if (data.primaryProvider)
198
+ setEnv("PRIMARY_PROVIDER", data.primaryProvider);
199
+ if (data.apiKey && data.apiKeyEnv)
200
+ setEnv(data.apiKeyEnv, data.apiKey);
201
+ // Step 3: Optional
202
+ if (data.webPassword)
203
+ setEnv("WEB_PASSWORD", data.webPassword);
204
+ fs.writeFileSync(envPath, content);
205
+ res.end(JSON.stringify({ ok: true, note: "Setup complete! Restart needed." }));
206
+ }
207
+ catch (e) {
208
+ res.statusCode = 400;
209
+ res.end(JSON.stringify({ error: e instanceof Error ? e.message : String(e) }));
210
+ }
211
+ return;
212
+ }
213
+ // POST /api/validate-bot-token — validate a Telegram bot token
214
+ if (urlPath === "/api/validate-bot-token" && req.method === "POST") {
215
+ try {
216
+ const { token } = JSON.parse(body);
217
+ if (!token || !token.includes(":")) {
218
+ res.end(JSON.stringify({ ok: false, error: "Invalid token format" }));
219
+ return;
220
+ }
221
+ const tgRes = await fetch(`https://api.telegram.org/bot${token}/getMe`);
222
+ const tgData = await tgRes.json();
223
+ if (tgData.ok) {
224
+ res.end(JSON.stringify({ ok: true, bot: { username: tgData.result.username, firstName: tgData.result.first_name, id: tgData.result.id } }));
225
+ }
226
+ else {
227
+ res.end(JSON.stringify({ ok: false, error: tgData.description || "Invalid token" }));
228
+ }
229
+ }
230
+ catch (e) {
231
+ res.end(JSON.stringify({ ok: false, error: e instanceof Error ? e.message : String(e) }));
232
+ }
233
+ return;
234
+ }
235
+ // GET /api/status
236
+ if (urlPath === "/api/status") {
237
+ let modelInfo = { name: "Not configured", model: "none", status: "unconfigured" };
238
+ try {
239
+ const registry = getRegistry();
240
+ const active = registry.getActive().getInfo();
241
+ modelInfo = { name: active.name, model: active.model, status: active.status };
242
+ }
243
+ catch { /* engine not initialized — no provider configured */ }
244
+ const memory = getMemoryStats();
245
+ const index = getIndexStats();
246
+ const plugins = getLoadedPlugins();
247
+ const mcp = getMCPStatus();
248
+ const users = listProfiles();
249
+ const tools = listCustomTools();
250
+ // Aggregate token usage across all sessions
251
+ const { getAllSessions } = await import("../services/session.js");
252
+ const allSessions = getAllSessions();
253
+ let totalInputTokens = 0, totalOutputTokens = 0, totalCost = 0;
254
+ for (const s of allSessions.values()) {
255
+ totalInputTokens += s.totalInputTokens || 0;
256
+ totalOutputTokens += s.totalOutputTokens || 0;
257
+ totalCost += s.totalCost || 0;
258
+ }
259
+ const { config: appConfig } = await import("../config.js");
260
+ res.end(JSON.stringify({
261
+ bot: { version: "3.0.0", uptime: process.uptime() },
262
+ model: modelInfo,
263
+ memory: { ...memory, vectors: index.entries, indexSize: index.sizeBytes },
264
+ plugins: plugins.length,
265
+ mcp: mcp.length,
266
+ users: users.length,
267
+ tools: tools.length,
268
+ tokens: {
269
+ totalInput: totalInputTokens,
270
+ totalOutput: totalOutputTokens,
271
+ total: totalInputTokens + totalOutputTokens,
272
+ totalCost,
273
+ },
274
+ setup: {
275
+ telegram: !!appConfig.botToken,
276
+ provider: modelInfo.status !== "unconfigured",
277
+ },
278
+ }));
279
+ return;
280
+ }
281
+ // GET /api/models
282
+ if (urlPath === "/api/models") {
283
+ const registry = getRegistry();
284
+ registry.listAll().then(models => {
285
+ res.end(JSON.stringify({ models, active: registry.getActiveKey() }));
286
+ });
287
+ return;
288
+ }
289
+ // POST /api/models/switch
290
+ if (urlPath === "/api/models/switch" && req.method === "POST") {
291
+ try {
292
+ const { key } = JSON.parse(body);
293
+ const registry = getRegistry();
294
+ const ok = registry.switchTo(key);
295
+ res.end(JSON.stringify({ ok, active: registry.getActiveKey() }));
296
+ }
297
+ catch {
298
+ res.statusCode = 400;
299
+ res.end(JSON.stringify({ error: "Invalid request" }));
300
+ }
301
+ return;
302
+ }
303
+ // GET /api/fallback — Get fallback order + health
304
+ if (urlPath === "/api/fallback" && req.method === "GET") {
305
+ try {
306
+ const { getFallbackOrder } = await import("../services/fallback-order.js");
307
+ const { getHealthStatus, isFailedOver } = await import("../services/heartbeat.js");
308
+ const registry = getRegistry();
309
+ const providers = await registry.listAll();
310
+ res.end(JSON.stringify({
311
+ order: getFallbackOrder(),
312
+ health: getHealthStatus(),
313
+ failedOver: isFailedOver(),
314
+ activeProvider: registry.getActiveKey(),
315
+ availableProviders: providers.map(p => ({ key: p.key, name: p.name, status: p.status })),
316
+ }));
317
+ }
318
+ catch (err) {
319
+ res.end(JSON.stringify({ error: String(err) }));
320
+ }
321
+ return;
322
+ }
323
+ // POST /api/fallback — Set fallback order
324
+ if (urlPath === "/api/fallback" && req.method === "POST") {
325
+ try {
326
+ const { primary, fallbacks } = JSON.parse(body);
327
+ const { setFallbackOrder } = await import("../services/fallback-order.js");
328
+ const result = setFallbackOrder(primary, fallbacks, "webui");
329
+ res.end(JSON.stringify({ ok: true, order: result }));
330
+ }
331
+ catch (err) {
332
+ res.statusCode = 400;
333
+ res.end(JSON.stringify({ error: String(err) }));
334
+ }
335
+ return;
336
+ }
337
+ // POST /api/fallback/move — Move provider up/down
338
+ if (urlPath === "/api/fallback/move" && req.method === "POST") {
339
+ try {
340
+ const { key, direction } = JSON.parse(body);
341
+ const fb = await import("../services/fallback-order.js");
342
+ const result = direction === "up" ? fb.moveUp(key, "webui") : fb.moveDown(key, "webui");
343
+ res.end(JSON.stringify({ ok: true, order: result }));
344
+ }
345
+ catch (err) {
346
+ res.statusCode = 400;
347
+ res.end(JSON.stringify({ error: String(err) }));
348
+ }
349
+ return;
350
+ }
351
+ // GET /api/heartbeat — Health status
352
+ if (urlPath === "/api/heartbeat") {
353
+ try {
354
+ const { getHealthStatus, isFailedOver } = await import("../services/heartbeat.js");
355
+ res.end(JSON.stringify({
356
+ health: getHealthStatus(),
357
+ failedOver: isFailedOver(),
358
+ }));
359
+ }
360
+ catch (err) {
361
+ res.end(JSON.stringify({ health: [], failedOver: false }));
362
+ }
363
+ return;
364
+ }
365
+ // GET /api/memory
366
+ if (urlPath === "/api/memory") {
367
+ const ltm = loadLongTermMemory();
368
+ const todayLog = loadDailyLog();
369
+ const stats = getMemoryStats();
370
+ const index = getIndexStats();
371
+ // List daily log files
372
+ let dailyFiles = [];
373
+ try {
374
+ dailyFiles = fs.readdirSync(MEMORY_DIR)
375
+ .filter(f => f.endsWith(".md") && !f.startsWith("."))
376
+ .sort()
377
+ .reverse();
378
+ }
379
+ catch { /* empty */ }
380
+ res.end(JSON.stringify({
381
+ longTermMemory: ltm,
382
+ todayLog,
383
+ dailyFiles,
384
+ stats,
385
+ index: { entries: index.entries, files: index.files, sizeBytes: index.sizeBytes },
386
+ }));
387
+ return;
388
+ }
389
+ // GET /api/memory/:file
390
+ if (urlPath.startsWith("/api/memory/")) {
391
+ const file = urlPath.slice(12);
392
+ if (file.includes("..") || !file.endsWith(".md")) {
393
+ res.statusCode = 400;
394
+ res.end(JSON.stringify({ error: "Invalid file" }));
395
+ return;
396
+ }
397
+ try {
398
+ const content = fs.readFileSync(resolve(MEMORY_DIR, file), "utf-8");
399
+ res.end(JSON.stringify({ file, content }));
400
+ }
401
+ catch {
402
+ res.statusCode = 404;
403
+ res.end(JSON.stringify({ error: "File not found" }));
404
+ }
405
+ return;
406
+ }
407
+ // POST /api/memory/save
408
+ if (urlPath === "/api/memory/save" && req.method === "POST") {
409
+ try {
410
+ const { file, content } = JSON.parse(body);
411
+ if (file === "MEMORY.md") {
412
+ fs.writeFileSync(MEMORY_FILE, content);
413
+ }
414
+ else if (file.endsWith(".md") && !file.includes("..")) {
415
+ fs.writeFileSync(resolve(MEMORY_DIR, file), content);
416
+ }
417
+ else {
418
+ res.statusCode = 400;
419
+ res.end(JSON.stringify({ error: "Invalid file" }));
420
+ return;
421
+ }
422
+ res.end(JSON.stringify({ ok: true }));
423
+ }
424
+ catch {
425
+ res.statusCode = 400;
426
+ res.end(JSON.stringify({ error: "Invalid request" }));
427
+ }
428
+ return;
429
+ }
430
+ // GET /api/plugins
431
+ if (urlPath === "/api/plugins") {
432
+ res.end(JSON.stringify({ plugins: getLoadedPlugins() }));
433
+ return;
434
+ }
435
+ // GET /api/users — Enhanced with session data
436
+ if (urlPath === "/api/users" && req.method === "GET") {
437
+ const { getAllSessions } = await import("../services/session.js");
438
+ const profiles = listProfiles();
439
+ const sessions = getAllSessions();
440
+ const sessionMap = new Map(Array.from(sessions.entries()).map(([k, s]) => [Number(k), s]));
441
+ const enriched = profiles.map(p => {
442
+ const session = sessionMap.get(p.userId);
443
+ return {
444
+ ...p,
445
+ session: session ? {
446
+ isProcessing: session.isProcessing,
447
+ totalCost: session.totalCost,
448
+ historyLength: session.history.length,
449
+ effort: session.effort,
450
+ voiceReply: session.voiceReply,
451
+ startedAt: session.startedAt,
452
+ messageCount: session.messageCount,
453
+ toolUseCount: session.toolUseCount,
454
+ workingDir: session.workingDir,
455
+ hasActiveQuery: !!session.abortController,
456
+ queuedMessages: session.messageQueue.length,
457
+ } : null,
458
+ };
459
+ });
460
+ res.end(JSON.stringify({ users: enriched }));
461
+ return;
462
+ }
463
+ // DELETE /api/users/:id — Kill session + delete user data
464
+ if (urlPath.startsWith("/api/users/") && req.method === "DELETE") {
465
+ const userId = parseInt(urlPath.split("/").pop() || "0");
466
+ if (!userId) {
467
+ res.statusCode = 400;
468
+ res.end(JSON.stringify({ error: "Invalid user ID" }));
469
+ return;
470
+ }
471
+ const { deleteUser } = await import("../services/users.js");
472
+ const result = deleteUser(userId);
473
+ res.end(JSON.stringify({ ok: true, ...result }));
474
+ return;
475
+ }
476
+ // GET /api/tools
477
+ if (urlPath === "/api/tools") {
478
+ const tools = getCustomTools();
479
+ res.end(JSON.stringify({ tools }));
480
+ return;
481
+ }
482
+ // POST /api/tools/execute — run a tool by name
483
+ if (urlPath === "/api/tools/execute" && req.method === "POST") {
484
+ try {
485
+ const { name, params } = JSON.parse(body);
486
+ if (!name) {
487
+ res.statusCode = 400;
488
+ res.end(JSON.stringify({ error: "No tool name" }));
489
+ return;
490
+ }
491
+ const output = await executeCustomTool(name, params || {});
492
+ res.end(JSON.stringify({ ok: true, output }));
493
+ }
494
+ catch (err) {
495
+ const error = err instanceof Error ? err.message : String(err);
496
+ res.end(JSON.stringify({ error }));
497
+ }
498
+ return;
499
+ }
500
+ // ── MCP Management ─────────────────────────────────────
501
+ // GET /api/mcp — list MCP servers + tools
502
+ if (urlPath === "/api/mcp") {
503
+ const { getMCPStatus, getMCPTools, hasMCPConfig } = await import("../services/mcp.js");
504
+ const servers = getMCPStatus();
505
+ const tools = getMCPTools();
506
+ // Read raw config for editing
507
+ const configPath = MCP_CONFIG;
508
+ let rawConfig = { servers: {} };
509
+ try {
510
+ rawConfig = JSON.parse(fs.readFileSync(configPath, "utf-8"));
511
+ }
512
+ catch { }
513
+ res.end(JSON.stringify({ servers, tools, config: rawConfig, hasConfig: hasMCPConfig() }));
514
+ return;
515
+ }
516
+ // POST /api/mcp/add — add a new MCP server
517
+ if (urlPath === "/api/mcp/add" && req.method === "POST") {
518
+ try {
519
+ const { name, command, args, url: serverUrl, env, headers } = JSON.parse(body);
520
+ if (!name) {
521
+ res.statusCode = 400;
522
+ res.end(JSON.stringify({ error: "Name required" }));
523
+ return;
524
+ }
525
+ const configPath = MCP_CONFIG;
526
+ let config = { servers: {} };
527
+ try {
528
+ config = JSON.parse(fs.readFileSync(configPath, "utf-8"));
529
+ }
530
+ catch { }
531
+ const entry = {};
532
+ if (command) {
533
+ entry.command = command;
534
+ entry.args = args || [];
535
+ if (env)
536
+ entry.env = env;
537
+ }
538
+ else if (serverUrl) {
539
+ entry.url = serverUrl;
540
+ if (headers)
541
+ entry.headers = headers;
542
+ }
543
+ else {
544
+ res.statusCode = 400;
545
+ res.end(JSON.stringify({ error: "command or url required" }));
546
+ return;
547
+ }
548
+ config.servers[name] = entry;
549
+ fs.writeFileSync(configPath, JSON.stringify(config, null, 2));
550
+ res.end(JSON.stringify({ ok: true, note: "Restart needed to connect." }));
551
+ }
552
+ catch (e) {
553
+ res.statusCode = 400;
554
+ res.end(JSON.stringify({ error: e instanceof Error ? e.message : String(e) }));
555
+ }
556
+ return;
557
+ }
558
+ // POST /api/mcp/remove — remove an MCP server
559
+ if (urlPath === "/api/mcp/remove" && req.method === "POST") {
560
+ try {
561
+ const { name } = JSON.parse(body);
562
+ const configPath = MCP_CONFIG;
563
+ let config = { servers: {} };
564
+ try {
565
+ config = JSON.parse(fs.readFileSync(configPath, "utf-8"));
566
+ }
567
+ catch { }
568
+ delete config.servers[name];
569
+ fs.writeFileSync(configPath, JSON.stringify(config, null, 2));
570
+ res.end(JSON.stringify({ ok: true }));
571
+ }
572
+ catch (e) {
573
+ res.statusCode = 400;
574
+ res.end(JSON.stringify({ error: e instanceof Error ? e.message : String(e) }));
575
+ }
576
+ return;
577
+ }
578
+ // GET /api/mcp/discover — auto-discover MCP servers on the system
579
+ if (urlPath === "/api/mcp/discover") {
580
+ const discovered = [];
581
+ const { execSync } = await import("child_process");
582
+ // Check for common MCP server npm packages
583
+ const knownServers = [
584
+ { pkg: "@modelcontextprotocol/server-filesystem", name: "filesystem", args: ["/tmp"] },
585
+ { pkg: "@modelcontextprotocol/server-brave-search", name: "brave-search", args: [] },
586
+ { pkg: "@modelcontextprotocol/server-github", name: "github", args: [] },
587
+ { pkg: "@modelcontextprotocol/server-postgres", name: "postgres", args: [] },
588
+ { pkg: "@modelcontextprotocol/server-sqlite", name: "sqlite", args: [] },
589
+ { pkg: "@modelcontextprotocol/server-slack", name: "slack", args: [] },
590
+ { pkg: "@modelcontextprotocol/server-memory", name: "memory", args: [] },
591
+ { pkg: "@modelcontextprotocol/server-puppeteer", name: "puppeteer", args: [] },
592
+ { pkg: "@modelcontextprotocol/server-fetch", name: "web-fetch", args: [] },
593
+ { pkg: "@anthropic/mcp-server-sequential-thinking", name: "sequential-thinking", args: [] },
594
+ ];
595
+ for (const s of knownServers) {
596
+ try {
597
+ execSync(`npx --yes ${s.pkg} --help`, { timeout: 5000, stdio: "pipe", env: { ...process.env, PATH: process.env.PATH + ":/opt/homebrew/bin:/usr/local/bin" } });
598
+ discovered.push({ name: s.name, command: "npx", args: ["-y", s.pkg, ...s.args], source: "npm" });
599
+ }
600
+ catch {
601
+ // Not installed — try checking if globally available
602
+ try {
603
+ execSync(`npm list -g ${s.pkg} --depth=0`, { timeout: 5000, stdio: "pipe" });
604
+ discovered.push({ name: s.name, command: "npx", args: ["-y", s.pkg, ...s.args], source: "npm-global" });
605
+ }
606
+ catch { /* not installed */ }
607
+ }
608
+ }
609
+ // Check for Claude Desktop MCP config
610
+ const homeDir = process.env.HOME || process.env.USERPROFILE || "";
611
+ const claudeConfigPaths = [
612
+ resolve(homeDir, ".config/claude/claude_desktop_config.json"),
613
+ resolve(homeDir, "Library/Application Support/Claude/claude_desktop_config.json"),
614
+ resolve(homeDir, "AppData/Roaming/Claude/claude_desktop_config.json"),
615
+ ];
616
+ for (const cfgPath of claudeConfigPaths) {
617
+ try {
618
+ const cfg = JSON.parse(fs.readFileSync(cfgPath, "utf-8"));
619
+ if (cfg.mcpServers) {
620
+ for (const [name, srv] of Object.entries(cfg.mcpServers)) {
621
+ if (srv.command) {
622
+ discovered.push({ name: `claude-${name}`, command: srv.command, args: srv.args || [], source: "claude-desktop" });
623
+ }
624
+ }
625
+ }
626
+ }
627
+ catch { /* not found */ }
628
+ }
629
+ res.end(JSON.stringify({ discovered }));
630
+ return;
631
+ }
632
+ // ── Skills Management ─────────────────────────────────
633
+ // GET /api/skills — already in setup-api.ts, but add full CRUD here
634
+ // GET /api/skills/detail/:id — get full skill content
635
+ if (urlPath?.match(/^\/api\/skills\/detail\//) && req.method === "GET") {
636
+ const skillId = urlPath.split("/").pop();
637
+ const { getSkills } = await import("../services/skills.js");
638
+ const skill = getSkills().find(s => s.id === skillId);
639
+ if (skill) {
640
+ res.end(JSON.stringify({ ok: true, skill }));
641
+ }
642
+ else {
643
+ res.statusCode = 404;
644
+ res.end(JSON.stringify({ error: "Skill not found" }));
645
+ }
646
+ return;
647
+ }
648
+ // POST /api/skills/create — create a new skill
649
+ if (urlPath === "/api/skills/create" && req.method === "POST") {
650
+ try {
651
+ const { id, name, description, triggers, category, content, priority } = JSON.parse(body);
652
+ if (!id || !name) {
653
+ res.statusCode = 400;
654
+ res.end(JSON.stringify({ error: "id and name required" }));
655
+ return;
656
+ }
657
+ const skillsDir = SKILLS_DIR;
658
+ const skillDir = resolve(skillsDir, id);
659
+ if (!fs.existsSync(skillDir))
660
+ fs.mkdirSync(skillDir, { recursive: true });
661
+ const frontmatter = [
662
+ "---",
663
+ `name: ${name}`,
664
+ description ? `description: ${description}` : "",
665
+ triggers ? `triggers: ${Array.isArray(triggers) ? triggers.join(", ") : triggers}` : "",
666
+ `priority: ${priority || 3}`,
667
+ `category: ${category || "custom"}`,
668
+ "---",
669
+ ].filter(Boolean).join("\n");
670
+ fs.writeFileSync(resolve(skillDir, "SKILL.md"), `${frontmatter}\n\n${content || ""}`);
671
+ // Force reload
672
+ const { loadSkills } = await import("../services/skills.js");
673
+ loadSkills();
674
+ res.end(JSON.stringify({ ok: true }));
675
+ }
676
+ catch (e) {
677
+ res.statusCode = 400;
678
+ res.end(JSON.stringify({ error: e instanceof Error ? e.message : String(e) }));
679
+ }
680
+ return;
681
+ }
682
+ // POST /api/skills/update — update an existing skill
683
+ if (urlPath === "/api/skills/update" && req.method === "POST") {
684
+ try {
685
+ const { id, content } = JSON.parse(body);
686
+ const skillPath = resolve(SKILLS_DIR, id, "SKILL.md");
687
+ if (!fs.existsSync(skillPath)) {
688
+ // Try flat file
689
+ const flatPath = resolve(SKILLS_DIR, id + ".md");
690
+ if (fs.existsSync(flatPath)) {
691
+ fs.writeFileSync(flatPath, content);
692
+ }
693
+ else {
694
+ res.statusCode = 404;
695
+ res.end(JSON.stringify({ error: "Skill not found" }));
696
+ return;
697
+ }
698
+ }
699
+ else {
700
+ fs.writeFileSync(skillPath, content);
701
+ }
702
+ const { loadSkills } = await import("../services/skills.js");
703
+ loadSkills();
704
+ res.end(JSON.stringify({ ok: true }));
705
+ }
706
+ catch (e) {
707
+ res.statusCode = 400;
708
+ res.end(JSON.stringify({ error: e instanceof Error ? e.message : String(e) }));
709
+ }
710
+ return;
711
+ }
712
+ // POST /api/skills/delete — delete a skill
713
+ if (urlPath === "/api/skills/delete" && req.method === "POST") {
714
+ try {
715
+ const { id } = JSON.parse(body);
716
+ const skillDir = resolve(SKILLS_DIR, id);
717
+ const flatFile = resolve(SKILLS_DIR, id + ".md");
718
+ if (fs.existsSync(skillDir)) {
719
+ fs.rmSync(skillDir, { recursive: true });
720
+ }
721
+ else if (fs.existsSync(flatFile)) {
722
+ fs.unlinkSync(flatFile);
723
+ }
724
+ else {
725
+ res.statusCode = 404;
726
+ res.end(JSON.stringify({ error: "Skill not found" }));
727
+ return;
728
+ }
729
+ const { loadSkills } = await import("../services/skills.js");
730
+ loadSkills();
731
+ res.end(JSON.stringify({ ok: true }));
732
+ }
733
+ catch (e) {
734
+ res.statusCode = 400;
735
+ res.end(JSON.stringify({ error: e instanceof Error ? e.message : String(e) }));
736
+ }
737
+ return;
738
+ }
739
+ // GET /api/config
740
+ if (urlPath === "/api/config") {
741
+ res.end(JSON.stringify({
742
+ providers: config.fallbackProviders,
743
+ primaryProvider: config.primaryProvider,
744
+ allowedUsers: config.allowedUsers,
745
+ hasKeys: {
746
+ groq: !!config.apiKeys.groq,
747
+ openai: !!config.apiKeys.openai,
748
+ google: !!config.apiKeys.google,
749
+ nvidia: !!config.apiKeys.nvidia,
750
+ openrouter: !!config.apiKeys.openrouter,
751
+ },
752
+ }));
753
+ return;
754
+ }
755
+ // GET /api/sessions
756
+ if (urlPath === "/api/sessions") {
757
+ const sessions = getAllSessions();
758
+ const profiles = listProfiles();
759
+ const data = Array.from(sessions.entries()).map(([key, session]) => {
760
+ const userId = Number(key.split(":").pop());
761
+ const profile = profiles.find(p => p.userId === userId);
762
+ return {
763
+ userId: key,
764
+ name: profile?.name || `User ${key}`,
765
+ username: profile?.username,
766
+ messageCount: session.messageCount,
767
+ toolUseCount: session.toolUseCount,
768
+ totalCost: session.totalCost,
769
+ totalInputTokens: session.totalInputTokens || 0,
770
+ totalOutputTokens: session.totalOutputTokens || 0,
771
+ effort: session.effort,
772
+ startedAt: session.startedAt,
773
+ lastActivity: session.lastActivity,
774
+ historyLength: session.history.length,
775
+ isProcessing: session.isProcessing,
776
+ provider: Object.keys(session.queriesByProvider).join(", ") || "none",
777
+ };
778
+ });
779
+ res.end(JSON.stringify({ sessions: data }));
780
+ return;
781
+ }
782
+ // GET /api/sessions/:userId/history
783
+ if (urlPath.match(/^\/api\/sessions\/\d+\/history$/)) {
784
+ const userId = parseInt(urlPath.split("/")[3]);
785
+ const session = getSession(userId);
786
+ res.end(JSON.stringify({
787
+ userId,
788
+ history: session.history.map(h => ({ role: h.role, content: h.content.slice(0, 2000) })),
789
+ }));
790
+ return;
791
+ }
792
+ // GET /api/files?path=...
793
+ if (urlPath === "/api/files") {
794
+ const params = new URLSearchParams((req.url || "").split("?")[1] || "");
795
+ const reqPath = params.get("path") || "";
796
+ const basePath = resolve(BOT_ROOT, reqPath || ".");
797
+ // Security: must be within BOT_ROOT
798
+ if (!basePath.startsWith(BOT_ROOT)) {
799
+ res.statusCode = 403;
800
+ res.end(JSON.stringify({ error: "Access denied" }));
801
+ return;
802
+ }
803
+ try {
804
+ const stat = fs.statSync(basePath);
805
+ if (stat.isDirectory()) {
806
+ const entries = fs.readdirSync(basePath, { withFileTypes: true })
807
+ .filter(e => !e.name.startsWith(".") && e.name !== "node_modules")
808
+ .map(e => ({
809
+ name: e.name,
810
+ type: e.isDirectory() ? "dir" : "file",
811
+ size: e.isFile() ? fs.statSync(resolve(basePath, e.name)).size : 0,
812
+ modified: fs.statSync(resolve(basePath, e.name)).mtimeMs,
813
+ }))
814
+ .sort((a, b) => {
815
+ if (a.type !== b.type)
816
+ return a.type === "dir" ? -1 : 1;
817
+ return a.name.localeCompare(b.name);
818
+ });
819
+ res.end(JSON.stringify({ path: reqPath || ".", entries }));
820
+ }
821
+ else {
822
+ // Read file content — text files up to 500KB
823
+ const ext = path.extname(basePath).toLowerCase();
824
+ const textExts = new Set([
825
+ ".md", ".txt", ".json", ".js", ".ts", ".jsx", ".tsx", ".css", ".html", ".htm",
826
+ ".xml", ".svg", ".yml", ".yaml", ".toml", ".ini", ".cfg", ".conf", ".env",
827
+ ".sh", ".bash", ".zsh", ".fish", ".py", ".rb", ".go", ".rs", ".java", ".kt",
828
+ ".c", ".cpp", ".h", ".hpp", ".cs", ".php", ".sql", ".graphql", ".prisma",
829
+ ".dockerfile", ".gitignore", ".gitattributes", ".editorconfig", ".prettierrc",
830
+ ".eslintrc", ".babelrc", ".npmrc", ".nvmrc", ".lock", ".log", ".csv", ".tsv",
831
+ ".mjs", ".cjs", ".mts", ".cts", ".vue", ".svelte", ".astro",
832
+ ]);
833
+ // Files without extension that match known names are always text
834
+ const textNames = new Set([
835
+ "dockerfile", "makefile", "procfile", "gemfile", "rakefile",
836
+ "vagrantfile", "brewfile", "justfile", "taskfile", "cakefile",
837
+ "license", "licence", "readme", "changelog", "authors", "contributors",
838
+ ]);
839
+ const baseName = path.basename(basePath).toLowerCase();
840
+ const isKnownTextName = textNames.has(baseName);
841
+ const isText = textExts.has(ext) || isKnownTextName || (!ext && stat.size < 100_000);
842
+ if (stat.size > 500_000) {
843
+ res.end(JSON.stringify({ path: reqPath, content: `[File too large: ${(stat.size / 1024).toFixed(1)} KB — max 500 KB]`, size: stat.size }));
844
+ }
845
+ else if (isText) {
846
+ try {
847
+ const content = fs.readFileSync(basePath, "utf-8");
848
+ // Quick binary check: if >10% null bytes, it's binary
849
+ const nullCount = [...content.slice(0, 1000)].filter(c => c === "\0").length;
850
+ if (nullCount > 100) {
851
+ res.end(JSON.stringify({ path: reqPath, content: null, size: stat.size, binary: true }));
852
+ }
853
+ else {
854
+ res.end(JSON.stringify({ path: reqPath, content, size: stat.size }));
855
+ }
856
+ }
857
+ catch {
858
+ res.end(JSON.stringify({ path: reqPath, content: null, size: stat.size, binary: true }));
859
+ }
860
+ }
861
+ else {
862
+ res.end(JSON.stringify({ path: reqPath, content: null, size: stat.size, binary: true }));
863
+ }
864
+ }
865
+ }
866
+ catch {
867
+ res.statusCode = 404;
868
+ res.end(JSON.stringify({ error: "Not found" }));
869
+ }
870
+ return;
871
+ }
872
+ // POST /api/files/save
873
+ if (urlPath === "/api/files/save" && req.method === "POST") {
874
+ try {
875
+ const { path: filePath, content } = JSON.parse(body);
876
+ const absPath = resolve(BOT_ROOT, filePath);
877
+ if (!absPath.startsWith(BOT_ROOT)) {
878
+ res.statusCode = 403;
879
+ res.end(JSON.stringify({ error: "Access denied" }));
880
+ return;
881
+ }
882
+ fs.writeFileSync(absPath, content);
883
+ res.end(JSON.stringify({ ok: true }));
884
+ }
885
+ catch (err) {
886
+ res.statusCode = 400;
887
+ const error = err instanceof Error ? err.message : "Invalid request";
888
+ res.end(JSON.stringify({ error }));
889
+ }
890
+ return;
891
+ }
892
+ // POST /api/files/delete
893
+ if (urlPath === "/api/files/delete" && req.method === "POST") {
894
+ try {
895
+ const { path: filePath } = JSON.parse(body);
896
+ const absPath = resolve(BOT_ROOT, filePath);
897
+ if (!absPath.startsWith(BOT_ROOT)) {
898
+ res.statusCode = 403;
899
+ res.end(JSON.stringify({ error: "Access denied" }));
900
+ return;
901
+ }
902
+ // Safety: don't allow deleting critical files
903
+ const critical = [".env", "package.json", "tsconfig.json", "ecosystem.config.cjs"];
904
+ const baseName = path.basename(absPath);
905
+ if (critical.includes(baseName)) {
906
+ res.statusCode = 403;
907
+ res.end(JSON.stringify({ error: `${baseName} cannot be deleted (protected)` }));
908
+ return;
909
+ }
910
+ if (!fs.existsSync(absPath)) {
911
+ res.statusCode = 404;
912
+ res.end(JSON.stringify({ error: "File not found" }));
913
+ return;
914
+ }
915
+ const stat = fs.statSync(absPath);
916
+ if (stat.isDirectory()) {
917
+ res.statusCode = 400;
918
+ res.end(JSON.stringify({ error: "Directories cannot be deleted" }));
919
+ return;
920
+ }
921
+ fs.unlinkSync(absPath);
922
+ res.end(JSON.stringify({ ok: true }));
923
+ }
924
+ catch (err) {
925
+ res.statusCode = 400;
926
+ const error = err instanceof Error ? err.message : "Invalid request";
927
+ res.end(JSON.stringify({ error }));
928
+ }
929
+ return;
930
+ }
931
+ // POST /api/terminal
932
+ if (urlPath === "/api/terminal" && req.method === "POST") {
933
+ try {
934
+ const { command } = JSON.parse(body);
935
+ if (!command) {
936
+ res.statusCode = 400;
937
+ res.end(JSON.stringify({ error: "No command" }));
938
+ return;
939
+ }
940
+ // Security: limit command length
941
+ if (command.length > 10000) {
942
+ res.statusCode = 400;
943
+ res.end(JSON.stringify({ error: "Command too long (max 10000 chars)" }));
944
+ return;
945
+ }
946
+ const cwd = typeof (JSON.parse(body)).cwd === "string" ? resolve(JSON.parse(body).cwd) : BOT_ROOT;
947
+ const output = execSync(command, {
948
+ cwd,
949
+ stdio: "pipe",
950
+ timeout: 120000,
951
+ env: { ...process.env, PATH: process.env.PATH + ":/opt/homebrew/bin:/usr/local/bin" },
952
+ }).toString();
953
+ res.end(JSON.stringify({ output: output.slice(0, 100000) }));
954
+ }
955
+ catch (err) {
956
+ const error = err;
957
+ const stderr = error.stderr?.toString()?.trim() || "";
958
+ res.end(JSON.stringify({ output: stderr || error.message, exitCode: 1 }));
959
+ }
960
+ return;
961
+ }
962
+ // GET /api/env — read .env keys (names only, values masked)
963
+ if (urlPath === "/api/env") {
964
+ try {
965
+ const envContent = fs.existsSync(ENV_FILE) ? fs.readFileSync(ENV_FILE, "utf-8") : "";
966
+ const lines = envContent.split("\n").filter(l => l.includes("=") && !l.startsWith("#"));
967
+ const vars = lines.map(l => {
968
+ const [key, ...rest] = l.split("=");
969
+ const value = rest.join("=").trim();
970
+ // Mask sensitive values
971
+ const masked = key.includes("KEY") || key.includes("TOKEN") || key.includes("PASSWORD") || key.includes("SECRET")
972
+ ? (value.length > 4 ? value.slice(0, 4) + "..." + value.slice(-4) : "****")
973
+ : value;
974
+ return { key: key.trim(), value: masked, hasValue: value.length > 0 };
975
+ });
976
+ res.end(JSON.stringify({ vars }));
977
+ }
978
+ catch {
979
+ res.end(JSON.stringify({ vars: [] }));
980
+ }
981
+ return;
982
+ }
983
+ // POST /api/env/set — update an env var
984
+ if (urlPath === "/api/env/set" && req.method === "POST") {
985
+ try {
986
+ const { key, value } = JSON.parse(body);
987
+ if (!key || typeof key !== "string" || !key.match(/^[A-Z_][A-Z0-9_]*$/)) {
988
+ res.statusCode = 400;
989
+ res.end(JSON.stringify({ error: "Invalid key name" }));
990
+ return;
991
+ }
992
+ let envContent = fs.existsSync(ENV_FILE) ? fs.readFileSync(ENV_FILE, "utf-8") : "";
993
+ const regex = new RegExp(`^${key}=.*$`, "m");
994
+ if (regex.test(envContent)) {
995
+ envContent = envContent.replace(regex, `${key}=${value}`);
996
+ }
997
+ else {
998
+ envContent = envContent.trimEnd() + `\n${key}=${value}\n`;
999
+ }
1000
+ fs.writeFileSync(ENV_FILE, envContent);
1001
+ res.end(JSON.stringify({ ok: true, note: "Restart required for changes to take effect" }));
1002
+ }
1003
+ catch {
1004
+ res.statusCode = 400;
1005
+ res.end(JSON.stringify({ error: "Invalid request" }));
1006
+ }
1007
+ return;
1008
+ }
1009
+ // GET /api/soul — read SOUL.md
1010
+ if (urlPath === "/api/soul") {
1011
+ const content = getSoulContent();
1012
+ res.end(JSON.stringify({ content }));
1013
+ return;
1014
+ }
1015
+ // POST /api/soul/save — update SOUL.md
1016
+ if (urlPath === "/api/soul/save" && req.method === "POST") {
1017
+ try {
1018
+ const { content } = JSON.parse(body);
1019
+ const soulPath = SOUL_FILE;
1020
+ fs.writeFileSync(soulPath, content);
1021
+ reloadSoul();
1022
+ res.end(JSON.stringify({ ok: true }));
1023
+ }
1024
+ catch {
1025
+ res.statusCode = 400;
1026
+ res.end(JSON.stringify({ error: "Invalid request" }));
1027
+ }
1028
+ return;
1029
+ }
1030
+ // GET /api/platforms — platform adapter status
1031
+ if (urlPath === "/api/platforms") {
1032
+ const platforms = [
1033
+ { name: "Telegram", key: "BOT_TOKEN", icon: "📱", configured: !!process.env.BOT_TOKEN },
1034
+ { name: "Discord", key: "DISCORD_TOKEN", icon: "🎮", configured: !!process.env.DISCORD_TOKEN },
1035
+ { name: "WhatsApp", key: "WHATSAPP_ENABLED", icon: "💬", configured: process.env.WHATSAPP_ENABLED === "true" },
1036
+ { name: "Signal", key: "SIGNAL_API_URL", icon: "🔒", configured: !!process.env.SIGNAL_API_URL },
1037
+ { name: "Web UI", key: "WEB_PORT", icon: "🌐", configured: true },
1038
+ ];
1039
+ res.end(JSON.stringify({ platforms }));
1040
+ return;
1041
+ }
1042
+ // POST /api/restart — restart the bot process
1043
+ if (urlPath === "/api/restart" && req.method === "POST") {
1044
+ const { scheduleGracefulRestart } = await import("../services/restart.js");
1045
+ res.end(JSON.stringify({ ok: true, note: "Restarting..." }));
1046
+ scheduleGracefulRestart(500);
1047
+ return;
1048
+ }
1049
+ // POST /api/chat/export — export chat history
1050
+ if (urlPath === "/api/chat/export" && req.method === "POST") {
1051
+ try {
1052
+ const { messages, format } = JSON.parse(body);
1053
+ if (format === "json") {
1054
+ res.setHeader("Content-Type", "application/json");
1055
+ res.end(JSON.stringify({ export: messages }, null, 2));
1056
+ }
1057
+ else {
1058
+ // Markdown
1059
+ const md = messages.map((m) => {
1060
+ const prefix = m.role === "user" ? "**Du:**" : m.role === "assistant" ? "**Alvin Bot:**" : "*System:*";
1061
+ const time = m.time ? ` _(${m.time})_` : "";
1062
+ return `${prefix}${time}\n${m.text}\n`;
1063
+ }).join("\n---\n\n");
1064
+ res.setHeader("Content-Type", "text/markdown");
1065
+ res.end(`# Chat Export — Alvin Bot\n_${new Date().toLocaleString("de-DE")}_\n\n---\n\n${md}`);
1066
+ }
1067
+ }
1068
+ catch {
1069
+ res.statusCode = 400;
1070
+ res.end(JSON.stringify({ error: "Invalid request" }));
1071
+ }
1072
+ return;
1073
+ }
1074
+ // ── WhatsApp Group Management API ────────────────────────────────────
1075
+ // GET /api/whatsapp/groups — list all WhatsApp groups (live from WA)
1076
+ if (urlPath === "/api/whatsapp/groups" && req.method === "GET") {
1077
+ try {
1078
+ const { getWhatsAppAdapter } = await import("../platforms/whatsapp.js");
1079
+ const adapter = getWhatsAppAdapter();
1080
+ if (!adapter) {
1081
+ res.end(JSON.stringify({ groups: [], error: "WhatsApp nicht verbunden" }));
1082
+ return;
1083
+ }
1084
+ const groups = await adapter.getGroups();
1085
+ res.end(JSON.stringify({ groups }));
1086
+ }
1087
+ catch (err) {
1088
+ res.end(JSON.stringify({ groups: [], error: String(err) }));
1089
+ }
1090
+ return;
1091
+ }
1092
+ // GET /api/whatsapp/groups/:id/participants — fetch group participants
1093
+ if (urlPath.match(/^\/api\/whatsapp\/groups\/[^/]+\/participants$/)) {
1094
+ try {
1095
+ const groupId = decodeURIComponent(urlPath.split("/")[4]);
1096
+ const { getWhatsAppAdapter } = await import("../platforms/whatsapp.js");
1097
+ const adapter = getWhatsAppAdapter();
1098
+ if (!adapter) {
1099
+ res.end(JSON.stringify({ participants: [], error: "WhatsApp nicht verbunden" }));
1100
+ return;
1101
+ }
1102
+ const participants = await adapter.getGroupParticipants(groupId);
1103
+ res.end(JSON.stringify({ participants }));
1104
+ }
1105
+ catch (err) {
1106
+ res.end(JSON.stringify({ participants: [], error: String(err) }));
1107
+ }
1108
+ return;
1109
+ }
1110
+ // GET /api/whatsapp/group-rules — get all configured group rules
1111
+ if (urlPath === "/api/whatsapp/group-rules" && req.method === "GET") {
1112
+ const { getGroupRules } = await import("../platforms/whatsapp.js");
1113
+ res.end(JSON.stringify({ rules: getGroupRules() }));
1114
+ return;
1115
+ }
1116
+ // POST /api/whatsapp/group-rules — create or update a group rule
1117
+ if (urlPath === "/api/whatsapp/group-rules" && req.method === "POST") {
1118
+ try {
1119
+ const rule = JSON.parse(body);
1120
+ if (!rule.groupId) {
1121
+ res.statusCode = 400;
1122
+ res.end(JSON.stringify({ error: "groupId ist erforderlich" }));
1123
+ return;
1124
+ }
1125
+ const { upsertGroupRule } = await import("../platforms/whatsapp.js");
1126
+ const saved = upsertGroupRule(rule);
1127
+ res.end(JSON.stringify({ ok: true, rule: saved }));
1128
+ }
1129
+ catch (err) {
1130
+ res.statusCode = 400;
1131
+ res.end(JSON.stringify({ error: String(err) }));
1132
+ }
1133
+ return;
1134
+ }
1135
+ // DELETE /api/whatsapp/group-rules/:id — delete a group rule
1136
+ if (urlPath.match(/^\/api\/whatsapp\/group-rules\//) && req.method === "DELETE") {
1137
+ const groupId = decodeURIComponent(urlPath.split("/").slice(4).join("/"));
1138
+ const { deleteGroupRule } = await import("../platforms/whatsapp.js");
1139
+ const ok = deleteGroupRule(groupId);
1140
+ res.end(JSON.stringify({ ok }));
1141
+ return;
1142
+ }
1143
+ res.statusCode = 404;
1144
+ res.end(JSON.stringify({ error: "Not found" }));
1145
+ }
1146
+ // ── WebSocket Chat ──────────────────────────────────────
1147
+ function handleWebSocket(wss) {
1148
+ wss.on("connection", (ws, req) => {
1149
+ // Auth check
1150
+ if (WEB_PASSWORD && !checkAuth(req)) {
1151
+ ws.close(4001, "Not authenticated");
1152
+ return;
1153
+ }
1154
+ // Canvas WebSocket — separate handler
1155
+ const wsUrl = req.url || "/";
1156
+ if (wsUrl === "/canvas/ws") {
1157
+ addCanvasClient(ws);
1158
+ return;
1159
+ }
1160
+ console.log("WebUI: client connected");
1161
+ ws.on("message", async (data) => {
1162
+ try {
1163
+ const msg = JSON.parse(data.toString());
1164
+ if (msg.type === "chat") {
1165
+ let { text, effort, file } = msg;
1166
+ const userId = config.allowedUsers[0] || 0;
1167
+ // Handle file upload — save to temp and reference in prompt
1168
+ if (file?.dataUrl && file?.name) {
1169
+ try {
1170
+ const dataDir = resolve(DATA_DIR, "web-uploads");
1171
+ if (!fs.existsSync(dataDir))
1172
+ fs.mkdirSync(dataDir, { recursive: true });
1173
+ const safeName = file.name.replace(/[^a-zA-Z0-9._-]/g, "_");
1174
+ const filePath = resolve(dataDir, `${Date.now()}_${safeName}`);
1175
+ const base64Data = file.dataUrl.split(",")[1] || file.dataUrl;
1176
+ fs.writeFileSync(filePath, Buffer.from(base64Data, "base64"));
1177
+ // Replace placeholder with actual file path
1178
+ text = text.replace(/\[File attached:.*?\]/, `[File saved: ${filePath}]`);
1179
+ }
1180
+ catch (err) {
1181
+ console.error("WebUI file upload error:", err);
1182
+ }
1183
+ }
1184
+ const registry = getRegistry();
1185
+ const activeProvider = registry.getActive();
1186
+ const isSDK = activeProvider.config.type === "claude-sdk";
1187
+ const session = getSession(userId);
1188
+ const queryOpts = {
1189
+ prompt: text,
1190
+ systemPrompt: buildSystemPrompt(isSDK, session.language, "web-dashboard"),
1191
+ workingDir: session.workingDir,
1192
+ effort: effort || session.effort,
1193
+ sessionId: isSDK ? session.sessionId : null,
1194
+ history: !isSDK ? session.history : undefined,
1195
+ };
1196
+ let gotDone = false;
1197
+ try {
1198
+ // Stream response
1199
+ for await (const chunk of registry.queryWithFallback(queryOpts)) {
1200
+ if (ws.readyState !== WebSocket.OPEN)
1201
+ break;
1202
+ switch (chunk.type) {
1203
+ case "text":
1204
+ ws.send(JSON.stringify({ type: "text", text: chunk.text, delta: chunk.delta }));
1205
+ break;
1206
+ case "tool_use":
1207
+ ws.send(JSON.stringify({ type: "tool", name: chunk.toolName, input: chunk.toolInput }));
1208
+ break;
1209
+ case "done":
1210
+ gotDone = true;
1211
+ if (chunk.sessionId)
1212
+ session.sessionId = chunk.sessionId;
1213
+ if (chunk.costUsd)
1214
+ session.totalCost += chunk.costUsd;
1215
+ if (chunk.inputTokens)
1216
+ session.totalInputTokens = (session.totalInputTokens || 0) + chunk.inputTokens;
1217
+ if (chunk.outputTokens)
1218
+ session.totalOutputTokens = (session.totalOutputTokens || 0) + chunk.outputTokens;
1219
+ ws.send(JSON.stringify({
1220
+ type: "done", cost: chunk.costUsd, sessionId: chunk.sessionId,
1221
+ inputTokens: chunk.inputTokens, outputTokens: chunk.outputTokens,
1222
+ sessionTokens: { input: session.totalInputTokens || 0, output: session.totalOutputTokens || 0 },
1223
+ }));
1224
+ break;
1225
+ case "error":
1226
+ ws.send(JSON.stringify({ type: "error", error: chunk.error }));
1227
+ gotDone = true; // error counts as done
1228
+ break;
1229
+ case "fallback":
1230
+ ws.send(JSON.stringify({ type: "fallback", from: chunk.failedProvider, to: chunk.providerName }));
1231
+ break;
1232
+ }
1233
+ }
1234
+ // Ensure we always send done (in case stream ended without done/error chunk)
1235
+ if (!gotDone && ws.readyState === WebSocket.OPEN) {
1236
+ ws.send(JSON.stringify({ type: "done", cost: 0 }));
1237
+ }
1238
+ }
1239
+ catch (streamErr) {
1240
+ const errMsg = streamErr instanceof Error ? streamErr.message : String(streamErr);
1241
+ console.error("WebUI stream error:", errMsg);
1242
+ if (ws.readyState === WebSocket.OPEN) {
1243
+ ws.send(JSON.stringify({ type: "error", error: errMsg }));
1244
+ if (!gotDone) {
1245
+ ws.send(JSON.stringify({ type: "done", cost: 0 }));
1246
+ }
1247
+ }
1248
+ }
1249
+ }
1250
+ if (msg.type === "reset") {
1251
+ const userId = config.allowedUsers[0] || 0;
1252
+ resetSession(userId);
1253
+ ws.send(JSON.stringify({ type: "reset", ok: true }));
1254
+ }
1255
+ }
1256
+ catch (err) {
1257
+ const error = err instanceof Error ? err.message : String(err);
1258
+ ws.send(JSON.stringify({ type: "error", error }));
1259
+ }
1260
+ });
1261
+ ws.on("close", () => {
1262
+ console.log("WebUI: client disconnected");
1263
+ });
1264
+ });
1265
+ }
1266
+ // ── Start Server ────────────────────────────────────────
1267
+ export function startWebServer() {
1268
+ const server = http.createServer((req, res) => {
1269
+ let body = "";
1270
+ req.on("data", (chunk) => { body += chunk; });
1271
+ req.on("end", () => {
1272
+ const urlPath = (req.url || "/").split("?")[0];
1273
+ // OpenAI-compatible API (/v1/chat/completions, /v1/models)
1274
+ if (urlPath.startsWith("/v1/")) {
1275
+ handleOpenAICompat(req, res, urlPath, body);
1276
+ return;
1277
+ }
1278
+ // API routes
1279
+ if (urlPath.startsWith("/api/")) {
1280
+ handleAPI(req, res, urlPath, body);
1281
+ return;
1282
+ }
1283
+ // Auth page (if password set and not authenticated)
1284
+ if (WEB_PASSWORD && !checkAuth(req) && urlPath !== "/login.html") {
1285
+ res.writeHead(302, { Location: "/login.html" });
1286
+ res.end();
1287
+ return;
1288
+ }
1289
+ // Canvas UI
1290
+ if (urlPath === "/canvas") {
1291
+ const canvasFile = resolve(PUBLIC_DIR, "canvas.html");
1292
+ try {
1293
+ const content = fs.readFileSync(canvasFile);
1294
+ res.setHeader("Content-Type", "text/html");
1295
+ res.end(content);
1296
+ }
1297
+ catch {
1298
+ res.statusCode = 404;
1299
+ res.end("Not found");
1300
+ }
1301
+ return;
1302
+ }
1303
+ // Static files
1304
+ let filePath = urlPath === "/" ? "/index.html" : urlPath;
1305
+ filePath = resolve(PUBLIC_DIR, filePath.slice(1));
1306
+ // Security: prevent path traversal
1307
+ if (!filePath.startsWith(PUBLIC_DIR)) {
1308
+ res.statusCode = 403;
1309
+ res.end("Forbidden");
1310
+ return;
1311
+ }
1312
+ try {
1313
+ const content = fs.readFileSync(filePath);
1314
+ const ext = path.extname(filePath);
1315
+ res.setHeader("Content-Type", MIME[ext] || "application/octet-stream");
1316
+ res.end(content);
1317
+ }
1318
+ catch {
1319
+ res.statusCode = 404;
1320
+ res.end("Not found");
1321
+ }
1322
+ });
1323
+ });
1324
+ const wss = new WebSocketServer({ server });
1325
+ handleWebSocket(wss);
1326
+ // Smart port: try WEB_PORT, increment if busy (up to +20)
1327
+ const MAX_TRIES = 20;
1328
+ function tryListen(port, attempt = 0) {
1329
+ server.once("error", (err) => {
1330
+ if (err.code === "EADDRINUSE" && attempt < MAX_TRIES) {
1331
+ tryListen(port + 1, attempt + 1);
1332
+ }
1333
+ else {
1334
+ console.error(`❌ Web UI failed to start: ${err.message}`);
1335
+ }
1336
+ });
1337
+ server.listen(port, () => {
1338
+ actualWebPort = port;
1339
+ console.log(`🌐 Web UI: http://localhost:${actualWebPort}`);
1340
+ if (actualWebPort !== WEB_PORT) {
1341
+ console.log(` (Port ${WEB_PORT} was busy, using ${actualWebPort} instead)`);
1342
+ }
1343
+ });
1344
+ }
1345
+ tryListen(WEB_PORT);
1346
+ return server;
1347
+ }
1348
+ /** Get the actual port the Web UI is running on. */
1349
+ export function getWebPort() {
1350
+ return actualWebPort;
1351
+ }