@johpaz/hive 1.1.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 (156) hide show
  1. package/CONTRIBUTING.md +44 -0
  2. package/README.md +310 -0
  3. package/package.json +96 -0
  4. package/packages/cli/package.json +28 -0
  5. package/packages/cli/src/commands/agent-run.ts +168 -0
  6. package/packages/cli/src/commands/agents.ts +398 -0
  7. package/packages/cli/src/commands/chat.ts +142 -0
  8. package/packages/cli/src/commands/config.ts +50 -0
  9. package/packages/cli/src/commands/cron.ts +161 -0
  10. package/packages/cli/src/commands/dev.ts +95 -0
  11. package/packages/cli/src/commands/doctor.ts +133 -0
  12. package/packages/cli/src/commands/gateway.ts +443 -0
  13. package/packages/cli/src/commands/logs.ts +57 -0
  14. package/packages/cli/src/commands/mcp.ts +175 -0
  15. package/packages/cli/src/commands/message.ts +77 -0
  16. package/packages/cli/src/commands/onboard.ts +1868 -0
  17. package/packages/cli/src/commands/security.ts +144 -0
  18. package/packages/cli/src/commands/service.ts +50 -0
  19. package/packages/cli/src/commands/sessions.ts +116 -0
  20. package/packages/cli/src/commands/skills.ts +187 -0
  21. package/packages/cli/src/commands/update.ts +25 -0
  22. package/packages/cli/src/index.ts +185 -0
  23. package/packages/cli/src/utils/token.ts +6 -0
  24. package/packages/code-bridge/README.md +78 -0
  25. package/packages/code-bridge/package.json +18 -0
  26. package/packages/code-bridge/src/index.ts +95 -0
  27. package/packages/code-bridge/src/process-manager.ts +212 -0
  28. package/packages/code-bridge/src/schemas.ts +133 -0
  29. package/packages/core/package.json +46 -0
  30. package/packages/core/src/agent/agent-loop.ts +369 -0
  31. package/packages/core/src/agent/compaction.ts +140 -0
  32. package/packages/core/src/agent/context-compiler.ts +378 -0
  33. package/packages/core/src/agent/context-guard.ts +91 -0
  34. package/packages/core/src/agent/context.ts +138 -0
  35. package/packages/core/src/agent/conversation-store.ts +198 -0
  36. package/packages/core/src/agent/curator.ts +158 -0
  37. package/packages/core/src/agent/hooks.ts +166 -0
  38. package/packages/core/src/agent/index.ts +116 -0
  39. package/packages/core/src/agent/llm-client.ts +503 -0
  40. package/packages/core/src/agent/native-tools.ts +505 -0
  41. package/packages/core/src/agent/prompt-builder.ts +532 -0
  42. package/packages/core/src/agent/providers/index.ts +167 -0
  43. package/packages/core/src/agent/providers.ts +1 -0
  44. package/packages/core/src/agent/reflector.ts +170 -0
  45. package/packages/core/src/agent/service.ts +64 -0
  46. package/packages/core/src/agent/stuck-loop.ts +133 -0
  47. package/packages/core/src/agent/supervisor.ts +39 -0
  48. package/packages/core/src/agent/tracer.ts +102 -0
  49. package/packages/core/src/agent/workspace.ts +110 -0
  50. package/packages/core/src/canvas/canvas-manager.test.ts +161 -0
  51. package/packages/core/src/canvas/canvas-manager.ts +319 -0
  52. package/packages/core/src/canvas/canvas-tools.ts +420 -0
  53. package/packages/core/src/canvas/emitter.ts +115 -0
  54. package/packages/core/src/canvas/index.ts +2 -0
  55. package/packages/core/src/channels/base.ts +138 -0
  56. package/packages/core/src/channels/discord.ts +260 -0
  57. package/packages/core/src/channels/index.ts +7 -0
  58. package/packages/core/src/channels/manager.ts +383 -0
  59. package/packages/core/src/channels/slack.ts +287 -0
  60. package/packages/core/src/channels/telegram.ts +502 -0
  61. package/packages/core/src/channels/webchat.ts +128 -0
  62. package/packages/core/src/channels/whatsapp.ts +375 -0
  63. package/packages/core/src/config/index.ts +12 -0
  64. package/packages/core/src/config/loader.ts +529 -0
  65. package/packages/core/src/events/event-bus.ts +169 -0
  66. package/packages/core/src/gateway/index.ts +5 -0
  67. package/packages/core/src/gateway/initializer.ts +290 -0
  68. package/packages/core/src/gateway/lane-queue.ts +169 -0
  69. package/packages/core/src/gateway/resolver.ts +108 -0
  70. package/packages/core/src/gateway/router.ts +124 -0
  71. package/packages/core/src/gateway/server.ts +3317 -0
  72. package/packages/core/src/gateway/session.ts +95 -0
  73. package/packages/core/src/gateway/slash-commands.ts +192 -0
  74. package/packages/core/src/heartbeat/index.ts +157 -0
  75. package/packages/core/src/index.ts +19 -0
  76. package/packages/core/src/integrations/catalog.ts +286 -0
  77. package/packages/core/src/integrations/env.ts +64 -0
  78. package/packages/core/src/integrations/index.ts +2 -0
  79. package/packages/core/src/memory/index.ts +1 -0
  80. package/packages/core/src/memory/notes.ts +68 -0
  81. package/packages/core/src/plugins/api.ts +128 -0
  82. package/packages/core/src/plugins/index.ts +2 -0
  83. package/packages/core/src/plugins/loader.ts +365 -0
  84. package/packages/core/src/resilience/circuit-breaker.ts +225 -0
  85. package/packages/core/src/security/google-chat.ts +269 -0
  86. package/packages/core/src/security/index.ts +192 -0
  87. package/packages/core/src/security/pairing.ts +250 -0
  88. package/packages/core/src/security/rate-limit.ts +270 -0
  89. package/packages/core/src/security/signal.ts +321 -0
  90. package/packages/core/src/state/store.ts +312 -0
  91. package/packages/core/src/storage/bun-sqlite-store.ts +188 -0
  92. package/packages/core/src/storage/crypto.ts +101 -0
  93. package/packages/core/src/storage/db-context.ts +333 -0
  94. package/packages/core/src/storage/onboarding.ts +1087 -0
  95. package/packages/core/src/storage/schema.ts +541 -0
  96. package/packages/core/src/storage/seed.ts +571 -0
  97. package/packages/core/src/storage/sqlite.ts +387 -0
  98. package/packages/core/src/storage/usage.ts +212 -0
  99. package/packages/core/src/tools/bridge-events.ts +74 -0
  100. package/packages/core/src/tools/browser.ts +275 -0
  101. package/packages/core/src/tools/codebridge.ts +421 -0
  102. package/packages/core/src/tools/coordinator-tools.ts +179 -0
  103. package/packages/core/src/tools/cron.ts +611 -0
  104. package/packages/core/src/tools/exec.ts +140 -0
  105. package/packages/core/src/tools/fs.ts +364 -0
  106. package/packages/core/src/tools/index.ts +12 -0
  107. package/packages/core/src/tools/memory.ts +176 -0
  108. package/packages/core/src/tools/notify.ts +113 -0
  109. package/packages/core/src/tools/project-management.ts +376 -0
  110. package/packages/core/src/tools/project.ts +375 -0
  111. package/packages/core/src/tools/read.ts +158 -0
  112. package/packages/core/src/tools/web.ts +436 -0
  113. package/packages/core/src/tools/workspace.ts +171 -0
  114. package/packages/core/src/utils/benchmark.ts +80 -0
  115. package/packages/core/src/utils/crypto.ts +73 -0
  116. package/packages/core/src/utils/date.ts +42 -0
  117. package/packages/core/src/utils/index.ts +4 -0
  118. package/packages/core/src/utils/logger.ts +388 -0
  119. package/packages/core/src/utils/retry.ts +70 -0
  120. package/packages/core/src/voice/index.ts +583 -0
  121. package/packages/core/tsconfig.json +9 -0
  122. package/packages/mcp/package.json +26 -0
  123. package/packages/mcp/src/config.ts +13 -0
  124. package/packages/mcp/src/index.ts +1 -0
  125. package/packages/mcp/src/logger.ts +42 -0
  126. package/packages/mcp/src/manager.ts +434 -0
  127. package/packages/mcp/src/transports/index.ts +67 -0
  128. package/packages/mcp/src/transports/sse.ts +241 -0
  129. package/packages/mcp/src/transports/websocket.ts +159 -0
  130. package/packages/skills/package.json +21 -0
  131. package/packages/skills/src/bundled/agent_management/SKILL.md +24 -0
  132. package/packages/skills/src/bundled/browser_automation/SKILL.md +30 -0
  133. package/packages/skills/src/bundled/context_compact/SKILL.md +35 -0
  134. package/packages/skills/src/bundled/cron_manager/SKILL.md +52 -0
  135. package/packages/skills/src/bundled/file_manager/SKILL.md +76 -0
  136. package/packages/skills/src/bundled/http_client/SKILL.md +24 -0
  137. package/packages/skills/src/bundled/memory/SKILL.md +42 -0
  138. package/packages/skills/src/bundled/project_management/SKILL.md +26 -0
  139. package/packages/skills/src/bundled/shell/SKILL.md +43 -0
  140. package/packages/skills/src/bundled/system_notify/SKILL.md +52 -0
  141. package/packages/skills/src/bundled/voice/SKILL.md +25 -0
  142. package/packages/skills/src/bundled/web_search/SKILL.md +29 -0
  143. package/packages/skills/src/index.ts +1 -0
  144. package/packages/skills/src/loader.ts +282 -0
  145. package/packages/tools/package.json +43 -0
  146. package/packages/tools/src/browser/browser.test.ts +111 -0
  147. package/packages/tools/src/browser/index.ts +272 -0
  148. package/packages/tools/src/canvas/index.ts +220 -0
  149. package/packages/tools/src/cron/cron.test.ts +164 -0
  150. package/packages/tools/src/cron/index.ts +304 -0
  151. package/packages/tools/src/filesystem/filesystem.test.ts +240 -0
  152. package/packages/tools/src/filesystem/index.ts +379 -0
  153. package/packages/tools/src/git/index.ts +239 -0
  154. package/packages/tools/src/index.ts +4 -0
  155. package/packages/tools/src/shell/detect-env.ts +70 -0
  156. package/packages/tools/tsconfig.json +9 -0
@@ -0,0 +1,3317 @@
1
+ import type { Config } from "../config/loader";
2
+ import { loadConfig, getHiveDir } from "../config/loader";
3
+ import { logger, onLogEntry } from "../utils/logger";
4
+ import { sessionManager, parseSessionId } from "./session";
5
+ import { laneQueue } from "./lane-queue";
6
+ import {
7
+ type InboundMessage,
8
+ type OutboundMessage,
9
+ isSlashCommand,
10
+ executeSlashCommand,
11
+ } from "./slash-commands";
12
+ import { ChannelManager } from "../channels/manager";
13
+ import { Agent } from "../agent/index";
14
+ import { AgentRunner } from "../agent/providers/index";
15
+ import type { IncomingMessage } from "../channels/base";
16
+ import { mkdirSync, rmSync, unlinkSync, watch, existsSync } from "node:fs";
17
+ import * as path from "node:path";
18
+ import { cpus as osCpus } from "node:os";
19
+ import { dbService, getDb, getDbPathLazy, type ChatMessageRow } from "../storage/sqlite";
20
+ import { canvasManager } from "../canvas/canvas-manager.ts";
21
+ import { subscribeCanvas, unsubscribeCanvas, emitCanvas, getCanvasSnapshot } from "../canvas/emitter";
22
+ import { subscribeBridge, unsubscribeBridge } from "../tools/bridge-events";
23
+ import { buildSupervisorGraph, rebuildSupervisorGraph, getSupervisorGraph } from "../agent/supervisor";
24
+ import { randomUUID } from "crypto";
25
+ import { resolveContext } from "./resolver";
26
+ import { getUsageStats } from "../storage/usage";
27
+ import { voiceService } from "../voice/index";
28
+ import { Benchmark } from "../utils/benchmark";
29
+ import { initializeGateway, type GatewayInitializationResult } from "./initializer";
30
+ import { encryptConfig, decryptConfig, encryptApiKey, decryptApiKey, maskApiKey } from "../storage/crypto";
31
+ import { resolveBestChannel } from "../tools/cron";
32
+ import { loadContextToStore } from "../storage/bun-sqlite-store";
33
+
34
+ const logSubscribers = new Set<string>();
35
+
36
+ // ─── Tool narration map ───────────────────────────────────────────────────────
37
+ // Maps tool name prefixes/exact names to human-readable Spanish narrations.
38
+ // Shown to the user while the agent executes a tool.
39
+ const TOOL_NARRATIONS: Record<string, string> = {
40
+ // Web
41
+ web_search: "Buscando en la web...",
42
+ web_fetch: "Leyendo pƔgina web...",
43
+ // Files
44
+ read: "Leyendo archivo...",
45
+ write: "Escribiendo archivo...",
46
+ edit: "Editando archivo...",
47
+ exec: "Ejecutando comando...",
48
+ // Cron
49
+ cron_add: "Programando tarea...",
50
+ cron_list: "Consultando tareas programadas...",
51
+ cron_remove: "Eliminando tarea programada...",
52
+ cron_edit: "Actualizando tarea programada...",
53
+ // Projects
54
+ project_create: "Creando proyecto...",
55
+ project_update: "Actualizando proyecto...",
56
+ project_done: "Marcando proyecto como completado...",
57
+ project_fail: "Registrando falla en el proyecto...",
58
+ task_create: "Creando tarea...",
59
+ task_update: "Actualizando tarea...",
60
+ // Agents
61
+ create_agent: "Creando agente worker...",
62
+ find_agent: "Buscando agente disponible...",
63
+ archive_agent: "Archivando agente...",
64
+ // Memory
65
+ save_note: "Guardando nota...",
66
+ memory_write: "Guardando en memoria...",
67
+ memory_read: "Leyendo memoria...",
68
+ memory_search: "Buscando en memoria...",
69
+ memory_delete: "Eliminando de memoria...",
70
+ memory_list: "Listando notas...",
71
+ // Browser
72
+ browser_navigate: "Navegando a la pƔgina...",
73
+ browser_click: "Haciendo clic...",
74
+ browser_type: "Escribiendo en la pƔgina...",
75
+ browser_screenshot:"Tomando captura de pantalla...",
76
+ browser_extract: "Extrayendo información de la pÔgina...",
77
+ // Canvas
78
+ canvas_add_node: "Actualizando canvas...",
79
+ canvas_update: "Actualizando canvas...",
80
+ // Code Bridge
81
+ bridge_send: "Enviando tarea al CLI...",
82
+ bridge_exec: "Ejecutando en el Code Bridge...",
83
+ // Notify
84
+ notify: "Enviando notificación...",
85
+ report_progress: "Reportando progreso...",
86
+ }
87
+
88
+ function getNarration(toolName: string): string {
89
+ if (TOOL_NARRATIONS[toolName]) return TOOL_NARRATIONS[toolName]
90
+ // Prefix matching for MCP tools like "github__create_pr" → "Ejecutando github..."
91
+ const prefix = toolName.split("__")[0]
92
+ if (prefix && prefix !== toolName) return `Ejecutando ${prefix}...`
93
+ // Fallback
94
+ return `Ejecutando ${toolName.replace(/_/g, " ")}...`
95
+ }
96
+
97
+ function expandPath(p: string): string {
98
+ if (p.startsWith("~")) {
99
+ return path.join(process.env.HOME ?? "", p.slice(1));
100
+ }
101
+ return p;
102
+ }
103
+
104
+ // CORS helper for Vite dev server (port 5173)
105
+ const CORS_ORIGINS = ["http://localhost:5173", "http://127.0.0.1:5173", "http://localhost:3000", "http://127.0.0.1:3000"];
106
+ function addCorsHeaders(response: Response, request: Request): Response {
107
+ const origin = request.headers.get("Origin");
108
+ if (!origin) return response;
109
+
110
+ // Allow any localhost origin for development
111
+ const isLocalhost = origin.includes("localhost") || origin.includes("127.0.0.1");
112
+ const isCorsOrigin = CORS_ORIGINS.some(o => origin.includes(o.replace("http://", "")));
113
+
114
+ if (isCorsOrigin || isLocalhost) {
115
+ const headers = new Headers(response.headers);
116
+ headers.set("Access-Control-Allow-Origin", origin);
117
+ headers.set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS");
118
+ headers.set("Access-Control-Allow-Headers", "Content-Type, Authorization, Accept, X-Requested-With");
119
+ headers.set("Access-Control-Allow-Credentials", "true");
120
+ headers.set("Access-Control-Max-Age", "86400");
121
+ return new Response(response.body, {
122
+ status: response.status,
123
+ statusText: response.statusText,
124
+ headers,
125
+ });
126
+ }
127
+ return response;
128
+ }
129
+
130
+ // FIX 1 — Redactar secrets antes de exponer config al dashboard
131
+ // Tokens y API keys nunca viajan completos al cliente
132
+ function redactValue(value: string): string {
133
+ if (!value || value.length < 8) return "••••••••";
134
+ return `${value.slice(0, 4)}••••••••`;
135
+ }
136
+
137
+ function redactConfig(cfg: Config): Record<string, unknown> {
138
+ const redacted = JSON.parse(JSON.stringify(cfg)) as any;
139
+
140
+ // Redactar authToken del gateway
141
+ if (redacted.gateway?.authToken) {
142
+ redacted.gateway.authToken = redactValue(redacted.gateway.authToken);
143
+ }
144
+
145
+ // Redactar API keys de providers
146
+ if (redacted.models?.providers) {
147
+ for (const provider of Object.values(redacted.models.providers) as any[]) {
148
+ if (provider?.apiKey) provider.apiKey = redactValue(provider.apiKey);
149
+ }
150
+ }
151
+
152
+ // Redactar tokens de canales
153
+ if (redacted.channels) {
154
+ for (const channel of Object.values(redacted.channels) as any[]) {
155
+ if (channel?.accounts) {
156
+ for (const acc of Object.values(channel.accounts) as any[]) {
157
+ if (acc?.botToken) acc.botToken = redactValue(acc.botToken);
158
+ if (acc?.appToken) acc.appToken = redactValue(acc.appToken);
159
+ if (acc?.signingSecret) acc.signingSecret = redactValue(acc.signingSecret);
160
+ }
161
+ }
162
+ }
163
+ }
164
+
165
+ // Redactar headers de MCP servers
166
+ if (redacted.mcp?.servers) {
167
+ for (const server of Object.values(redacted.mcp.servers) as any[]) {
168
+ if (server?.headers) {
169
+ for (const [k, v] of Object.entries(server.headers)) {
170
+ const lk = k.toLowerCase();
171
+ if (lk.includes("auth") || lk.includes("token") || lk.includes("key")) {
172
+ server.headers[k] = redactValue(v as string);
173
+ }
174
+ }
175
+ }
176
+ }
177
+ }
178
+
179
+ return redacted;
180
+ }
181
+
182
+ // FIX 3 — Evitar "redaction round-trip"
183
+ // Si recibimos una configuración con headers redactados (terminan en ••••••••),
184
+ // preservamos el valor real que ya tenemos en memoria o BD.
185
+ function mergeHeaders(newHeaders: Record<string, string>, oldHeaders: Record<string, string> = {}): Record<string, string> {
186
+ const merged = { ...newHeaders };
187
+ for (const [key, value] of Object.entries(merged)) {
188
+ if (typeof value === "string" && value.endsWith("••••••••")) {
189
+ if (oldHeaders[key]) {
190
+ merged[key] = oldHeaders[key];
191
+ }
192
+ }
193
+ }
194
+ return merged;
195
+ }
196
+
197
+ interface WebSocketData {
198
+ sessionId: string;
199
+ authenticatedAt: number;
200
+ }
201
+
202
+ export async function startGateway(config: Config): Promise<void> {
203
+ const host = config.gateway?.host ?? "127.0.0.1";
204
+ const port = config.gateway?.port ?? 18790;
205
+ const pidFile = expandPath(config.gateway?.pidFile ?? "~/.hive/gateway.pid");
206
+
207
+ // FIX 2 — startTime para calcular uptime en /status y /api/agents
208
+ const startTime = Date.now();
209
+
210
+ // CPU delta sampling — process.cpuUsage() is cumulative; we diff between calls
211
+ const numCores = osCpus().length || 1;
212
+ let lastCpuSample = process.cpuUsage();
213
+ let lastCpuSampleTime = Date.now();
214
+ const log = logger.child("gateway");
215
+ const mcpLog = logger.child("mcp:api");
216
+
217
+ log.info(`Starting gateway on ${host}:${port}`);
218
+
219
+ // ── Inicialización modular con manejo de errores ──────────────────────────
220
+ let agent: Agent;
221
+ let runner: AgentRunner;
222
+ let channelManager: ChannelManager;
223
+ let dbProvider: string;
224
+ let dbModel: string;
225
+ const agentList = config.agents?.list ?? [];
226
+ const defaultAgent = agentList.find((a) => a.default) ?? agentList[0];
227
+ const workspacePath = expandPath(defaultAgent?.workspace ?? "~/.hive/workspace");
228
+
229
+ // ── Bind port immediately so parent health-check doesn't timeout ──────────
230
+ // The full handler is loaded via server.reload() once initialization finishes
231
+ let server = Bun.serve<WebSocketData>({
232
+ port,
233
+ hostname: host,
234
+ fetch: (_req) => Response.json({ status: "starting" }),
235
+ websocket: { open() { }, message() { }, close() { } },
236
+ });
237
+ log.info(`Port ${port} bound (initializing gateway...)`);
238
+
239
+ try {
240
+ // Usar el inicializador modular para todos los componentes crĆ­ticos
241
+ const init = await initializeGateway(config, pidFile);
242
+
243
+ agent = init.agent;
244
+ runner = init.runner;
245
+ channelManager = init.channelManager;
246
+ dbProvider = init.provider;
247
+ dbModel = init.model;
248
+
249
+ log.info("āœ… Gateway initialization completed successfully");
250
+ } catch (error) {
251
+ log.error(`āŒ Gateway initialization failed: ${(error as Error).message}`);
252
+ log.error("Stack trace:", (error as Error).stack);
253
+ process.exit(1);
254
+ }
255
+
256
+ // Check for insecure binding
257
+ if (host === "0.0.0.0" && config.security?.warnOnInsecureConfig !== false) {
258
+ log.warn("Gateway binding to 0.0.0.0 exposes server to all network interfaces!");
259
+ }
260
+
261
+ // ── CRON Handler setup ─────────────────────────────────────────────────────
262
+ function prepareTools(agentInstance: Agent, sessionId: string) {
263
+ // Tools are now handled by LangGraph internally via the supervisor
264
+ return undefined;
265
+ }
266
+
267
+ agent.on("cron", async (sessionId: string, task: string, jobId?: string, context?: { fecha_usuario: string; hora_usuario: string }) => {
268
+ log.info(`[CRON] Triggered task '${task}' for session ${sessionId}, jobId: ${jobId || 'unknown'} [${context?.fecha_usuario} ${context?.hora_usuario}]`);
269
+
270
+ let notifyChannel = "webchat";
271
+ let notifySessionId = sessionId;
272
+
273
+ if (jobId) {
274
+ try {
275
+ const db = getDb();
276
+ const cronJob = db.query<any, [string]>(
277
+ "SELECT user_id, notify_channel_id, task_config FROM cron_jobs WHERE id = ?"
278
+ ).get(jobId) as any;
279
+
280
+ if (cronJob) {
281
+ // Apply priority chain: explicit job channel → user preference → auto-detect
282
+ notifyChannel = resolveBestChannel(cronJob.user_id, cronJob.notify_channel_id);
283
+
284
+ // For external channels (telegram, discord, etc.) we need the channel-specific
285
+ // session ID (e.g. Telegram chatId), not the internal Hive userId.
286
+ // user_identities.channel_user_id is the exact value the channel uses as sessionId.
287
+ if (notifyChannel !== "webchat") {
288
+ const identity = db.query<{ channel_user_id: string }, [string, string]>(
289
+ "SELECT channel_user_id FROM user_identities WHERE user_id = ? AND channel = ? LIMIT 1"
290
+ ).get(cronJob.user_id, notifyChannel);
291
+ if (identity?.channel_user_id) {
292
+ notifySessionId = identity.channel_user_id;
293
+ } else {
294
+ // No identity found for this channel — fall back to webchat to avoid
295
+ // sending a non-parseable user UUID as a chat ID to an external channel.
296
+ log.warn(`[CRON] No identity found for channel '${notifyChannel}', falling back to webchat`);
297
+ notifyChannel = "webchat";
298
+ notifySessionId = cronJob.user_id;
299
+ }
300
+ } else {
301
+ notifySessionId = cronJob.user_id;
302
+ }
303
+ }
304
+ } catch (e) {
305
+ log.warn(`[CRON] Could not fetch notify channel for job ${jobId}: ${(e as Error).message}`);
306
+ }
307
+ }
308
+
309
+ // Use the resolved session for both internal processing and notifications
310
+ const activeSessionId = notifySessionId;
311
+
312
+ try {
313
+ await channelManager.send(notifyChannel, activeSessionId, {
314
+ content: `ā° Ejecutando tarea: ${task}`,
315
+ type: "progress"
316
+ });
317
+ } catch (notifyErr) {
318
+ log.warn(`[CRON] Could not send start notification: ${(notifyErr as Error).message}`);
319
+ }
320
+
321
+ // Add as 'user' role so it appears in the chat history and the agent sees it as a direct request
322
+ const cronMessage = `[CRON] Scheduled task triggered: ${task}.
323
+ [fecha_usuario: ${context?.fecha_usuario || ""}]
324
+ [hora_usuario: ${context?.hora_usuario || ""}]
325
+ Please execute it now.`;
326
+ dbService.addMessage(activeSessionId, "user", cronMessage);
327
+
328
+ laneQueue.enqueue(activeSessionId, async (_t, signal) => {
329
+ if (signal.aborted) return;
330
+ try {
331
+ const history = dbService.getMessages(activeSessionId);
332
+ const messages = history.map((row: ChatMessageRow) => ({
333
+ role: row.role as "user" | "assistant" | "system",
334
+ content: row.content,
335
+ }));
336
+
337
+ const provider = dbProvider;
338
+
339
+ log.info(`[CRON] Generating response for session ${activeSessionId}...`);
340
+ const response = await runner.generate({
341
+ provider: provider as any,
342
+ messages,
343
+ maxTokens: 4096,
344
+ tools: prepareTools(agent, activeSessionId),
345
+ maxSteps: 15,
346
+ threadId: activeSessionId,
347
+ onStep: async (step) => {
348
+ if (step.type === "text" && step.message) {
349
+ const trimmedMessage = (typeof step.message === "string" ? step.message : "").trim();
350
+ if (trimmedMessage) {
351
+ await channelManager.send(notifyChannel, notifySessionId, {
352
+ content: trimmedMessage,
353
+ type: "progress"
354
+ });
355
+ }
356
+ }
357
+ if (step.type === "tool_result" && step.message) {
358
+ try {
359
+ const result = JSON.parse(step.message);
360
+ if (result._sendToUser || result.status) {
361
+ const userMessage = result.message || result.status || step.message;
362
+ await channelManager.send(notifyChannel, notifySessionId, {
363
+ content: `šŸ“Š ${userMessage}`,
364
+ type: "progress"
365
+ });
366
+ }
367
+ } catch { }
368
+ }
369
+ },
370
+ });
371
+
372
+ const responseContent = response.content?.trim() || "Task completed.";
373
+ dbService.addMessage(sessionId, "assistant", responseContent, {
374
+ response_type: "text",
375
+ channel: "cron"
376
+ });
377
+
378
+ const session = sessionManager.get(sessionId);
379
+ if (session?.ws) {
380
+ session.ws.send(JSON.stringify({ type: "message", sessionId: sessionId, content: responseContent } as OutboundMessage));
381
+ } else {
382
+ await channelManager.send(notifyChannel, notifySessionId, responseContent);
383
+ }
384
+ } catch (error) {
385
+ log.error(`[CRON] Error for session ${sessionId}: ${(error as Error).message}`);
386
+ await channelManager.send(notifyChannel, notifySessionId, {
387
+ content: `āŒ Error en tarea programada: ${(error as Error).message}`,
388
+ });
389
+ }
390
+ });
391
+ });
392
+
393
+ // Set up hot reload watchers
394
+ const watchers: Array<() => void> = [];
395
+
396
+ // Note: Context store, Ethics, Supervisor Graph, LLM runner, and Channel Manager
397
+ // are now initialized by initializeGateway() above
398
+
399
+ // Handle messages from channels (Telegram, Discord, WhatsApp, Slack)
400
+ channelManager.onMessage(async (message: IncomingMessage) => {
401
+ log.info(`šŸ“„ Message from ${message.channel}:${message.accountId}`);
402
+ log.info(` Session: ${message.sessionId}`);
403
+
404
+ const voiceConfig = voiceService.getChannelVoiceConfig(message.channel);
405
+ let messageContent = message.content;
406
+
407
+ let preferAudioResponse = false;
408
+ let inputType: "text" | "audio_transcribed" = "text";
409
+ let sttProviderUsed: string | null = null;
410
+
411
+ if (voiceConfig.voiceEnabled && message.audio) {
412
+ log.info(`šŸŽ™ļø Voice enabled, processing audio...`);
413
+
414
+ if (!voiceConfig.sttProvider) {
415
+ log.warn(`āš ļø STT provider not configured for channel ${message.channel}`);
416
+ await channelManager.send(message.channel, message.sessionId, {
417
+ content: `šŸŽ™ļø Para usar notas de voz, necesitas configurar el proveedor STT en la configuración del canal. Ve a Configuración > Canales > [Tu canal] y configura "Prov. STT" (ej: groq-whisper o openai)`,
418
+ });
419
+ return;
420
+ }
421
+
422
+ try {
423
+ const audioInput = voiceService.normalizeAudioFromChannel(message.channel, message.audio);
424
+ sttProviderUsed = voiceConfig.sttProvider || "groq-whisper";
425
+ messageContent = await voiceService.transcribe(audioInput, sttProviderUsed);
426
+ log.info(`šŸ“ Transcribed: ${messageContent.substring(0, 100)}...`);
427
+
428
+ inputType = "audio_transcribed";
429
+ // If user sent audio and TTS is available, always respond in audio
430
+ preferAudioResponse = !!voiceConfig.ttsProvider;
431
+
432
+ await channelManager.send(message.channel, message.sessionId, {
433
+ content: `šŸŽ™ļø Transcripción: ${messageContent}`,
434
+ type: "message"
435
+ });
436
+ } catch (error) {
437
+ log.error(`āŒ Transcription failed: ${(error as Error).message}`);
438
+ await channelManager.send(message.channel, message.sessionId, {
439
+ content: `Error al transcribir audio: ${(error as Error).message}`,
440
+ });
441
+ return;
442
+ }
443
+ }
444
+
445
+ log.info(` Content: ${messageContent.substring(0, 150)}${messageContent.length > 150 ? "..." : ""}`);
446
+
447
+ const { userId } = resolveContext({
448
+ channel: message.channel,
449
+ channelUserId: message.sessionId,
450
+ });
451
+
452
+ const telegramMeta = message.metadata?.telegram as { messageId?: number } | undefined;
453
+ const messageId = telegramMeta?.messageId?.toString();
454
+ await Promise.all([
455
+ channelManager.markAsRead(message.channel, message.sessionId, messageId),
456
+ channelManager.startTyping(message.channel, message.sessionId),
457
+ ]);
458
+
459
+ // unifiedSessionId = userId del onboarding → historial y thread LangGraph unificados
460
+ const unifiedSessionId = userId;
461
+ // routingSessionId = peerId del canal → para enviar respuestas de vuelta al canal correcto
462
+ const routingSessionId = message.sessionId;
463
+
464
+ const userMetadata = inputType === "audio_transcribed"
465
+ ? { input_type: "audio_transcribed", stt_provider: sttProviderUsed, channel: message.channel }
466
+ : { input_type: "text", channel: message.channel };
467
+
468
+ dbService.addMessage(unifiedSessionId, "user", messageContent, userMetadata);
469
+
470
+ // Obtener la zona horaria del usuario para el timestamp exacto
471
+ const userRow = getDb()
472
+ .query<any, [string]>("SELECT * FROM users WHERE id = ?")
473
+ .get(userId);
474
+ const userTimezone = userRow?.timezone || "UTC";
475
+ const now = new Date();
476
+ let exactTime = "";
477
+ try {
478
+ exactTime = now.toLocaleString("en-US", {
479
+ timeZone: userTimezone,
480
+ dateStyle: "full",
481
+ timeStyle: "long",
482
+ });
483
+ } catch (e) {
484
+ exactTime = now.toISOString();
485
+ }
486
+ const messageContentWithTime = `[Timestamp: ${exactTime} (${userTimezone})]\n${messageContent}`;
487
+
488
+ const messages = [{ role: "user" as const, content: messageContentWithTime }];
489
+
490
+ try {
491
+ log.info(`šŸ¤– Routing to agent loop...`);
492
+
493
+ const response = await runner.generate({
494
+ provider: dbProvider as any,
495
+ messages,
496
+ maxTokens: 4096,
497
+ tools: prepareTools(agent, unifiedSessionId),
498
+ maxSteps: 15,
499
+ threadId: unifiedSessionId,
500
+ userId,
501
+ channel: message.channel,
502
+ onStep: async (step) => {
503
+ // "text" = el agente narra lo que estĆ” pensando/haciendo antes de un tool_call
504
+ if (step.type === "text" && step.message) {
505
+ const trimmedMessage = (typeof step.message === "string" ? step.message : "").trim();
506
+ if (trimmedMessage) {
507
+ log.debug(`[NARRATION] ${trimmedMessage.substring(0, 100)}`);
508
+ await channelManager.send(message.channel, routingSessionId, {
509
+ content: trimmedMessage,
510
+ type: "progress",
511
+ });
512
+ }
513
+ return;
514
+ }
515
+
516
+ // "tool_call" = el agente va a ejecutar una herramienta → narrar al usuario
517
+ if (step.type === "tool_call" && step.toolName) {
518
+ const narration = getNarration(step.toolName);
519
+ log.debug(`[TOOL] ${step.toolName} → "${narration}"`);
520
+ await channelManager.send(message.channel, routingSessionId, {
521
+ content: narration,
522
+ type: "progress",
523
+ });
524
+ return;
525
+ }
526
+
527
+ // "tool_result" = resultado de la herramienta
528
+ // Solo enviamos al usuario si el resultado lo pide explĆ­citamente
529
+ if (step.type === "tool_result" && step.message) {
530
+ try {
531
+ const result = JSON.parse(step.message);
532
+ if (result._sendToUser) {
533
+ const userMessage = result.message || result.status || step.message;
534
+ await channelManager.send(message.channel, routingSessionId, {
535
+ content: userMessage,
536
+ type: "progress",
537
+ });
538
+ }
539
+ } catch {
540
+ // No es JSON estructurado — no enviamos resultados crudos al usuario
541
+ }
542
+ return;
543
+ }
544
+ },
545
+ });
546
+
547
+ const responseContent = response.content || "...";
548
+ log.info(`šŸ“¤ LLM response: ${responseContent.substring(0, 100)}${responseContent.length > 100 ? "..." : ""}`);
549
+
550
+ const shouldSpeak = preferAudioResponse;
551
+ let responseType: "text" | "audio" = "text";
552
+ let ttsProviderUsed: string | null = null;
553
+ let ttsMimeType: string | null = null;
554
+
555
+ if (responseContent && responseContent !== "...") {
556
+ if (shouldSpeak) {
557
+ if (!voiceConfig.ttsProvider) {
558
+ log.warn(`āš ļø TTS provider not configured, user requested audio`);
559
+ await channelManager.send(message.channel, routingSessionId, {
560
+ content: `${responseContent}\n\nšŸ”Š Para recibir respuestas en audio, configura el proveedor TTS en Configuración > Canales > [Tu canal] (ej: elevenlabs, openai-tts)`
561
+ });
562
+ } else {
563
+ try {
564
+ log.info(`šŸ”Š TTS enabled, synthesizing audio...`);
565
+ const audioOutput = await voiceService.speak(responseContent, voiceConfig.ttsProvider, voiceConfig.ttsVoiceId || undefined);
566
+ ttsProviderUsed = voiceConfig.ttsProvider;
567
+ ttsMimeType = audioOutput.mimeType;
568
+ responseType = "audio";
569
+
570
+ const channel = channelManager.getChannel(message.channel);
571
+ if (channel?.sendAudio) {
572
+ await channel.sendAudio(routingSessionId, audioOutput.data as Buffer, audioOutput.mimeType);
573
+ log.info(`āœ… Audio sent to ${routingSessionId}`);
574
+ } else {
575
+ await channelManager.send(message.channel, routingSessionId, { content: responseContent });
576
+ }
577
+ } catch (error) {
578
+ log.error(`āŒ TTS failed: ${(error as Error).message}), sending text instead`);
579
+ await channelManager.send(message.channel, routingSessionId, { content: responseContent });
580
+ }
581
+ }
582
+ } else {
583
+ await channelManager.send(message.channel, routingSessionId, { content: responseContent });
584
+ }
585
+ }
586
+
587
+ const assistantMetadata = {
588
+ response_type: responseType,
589
+ tts_provider: ttsProviderUsed,
590
+ mime_type: ttsMimeType,
591
+ channel: message.channel
592
+ };
593
+ dbService.addMessage(unifiedSessionId, "assistant", responseContent, assistantMetadata);
594
+
595
+ await channelManager.stopTyping(message.channel, routingSessionId);
596
+ log.info(`āœ… Response sent to ${routingSessionId} via ${message.channel}`);
597
+ } catch (error) {
598
+ await channelManager.stopTyping(message.channel, routingSessionId);
599
+ log.error(`āŒ Error: ${(error as Error).message} `);
600
+ await channelManager.send(message.channel, routingSessionId, {
601
+ content: `Error: ${(error as Error).message} `,
602
+ });
603
+ }
604
+ });
605
+
606
+ // ── Auth helper ──────────────────────────────────────────────────────────
607
+ // En modo desarrollo (HIVE_DEV=true), no requerimos autenticación
608
+ const isDev = process.env.HIVE_DEV === "true" || process.env.NODE_ENV === "development";
609
+
610
+ function checkAuth(req: Request, url: URL): boolean {
611
+ // En modo desarrollo, permitir todo
612
+ if (isDev) return true;
613
+
614
+ // Read live from env so the token set during setup/complete takes effect immediately
615
+ const activeToken = process.env.HIVE_AUTH_TOKEN;
616
+ if (!activeToken) return true;
617
+ const authHeader = req.headers.get("authorization");
618
+ const provided = authHeader?.replace(/^Bearer\s+/i, "") ?? url.searchParams.get("token");
619
+ return provided === activeToken;
620
+ }
621
+
622
+ // Reload with full handler now that initialization is complete
623
+ server.reload({
624
+ async fetch(req, server) {
625
+ const start = Date.now();
626
+ const url = new URL(req.url);
627
+ const method = req.method;
628
+
629
+ const logRequest = (status: number, duration: number) => {
630
+ // Skip health checks from spamming logs unless debug
631
+ if (url.pathname === "/health" || url.pathname === "/health/") {
632
+ log.debug(`${method} ${url.pathname} - ${status} (${duration}ms)`);
633
+ } else {
634
+ log.info(`${method} ${url.pathname} - ${status} (${duration}ms)`);
635
+ }
636
+ };
637
+
638
+ const handleRequest = async (): Promise<Response | undefined> => {
639
+
640
+ // ── CORS preflight ────────────────────────────────────────────────────
641
+ if (req.method === "OPTIONS") {
642
+ const origin = req.headers.get("Origin");
643
+ if (origin && (origin.includes("localhost") || origin.includes("127.0.0.1") || CORS_ORIGINS.some(o => origin.includes(o.replace("http://", ""))))) {
644
+ return new Response(null, {
645
+ status: 204,
646
+ headers: {
647
+ "Access-Control-Allow-Origin": origin,
648
+ "Access-Control-Allow-Methods": "GET, POST, PUT, PATCH, DELETE, OPTIONS",
649
+ "Access-Control-Allow-Headers": "Content-Type, Authorization, Accept, X-Requested-With",
650
+ "Access-Control-Allow-Credentials": "true",
651
+ "Access-Control-Max-Age": "86400",
652
+ },
653
+ });
654
+ }
655
+ return new Response(null, { status: 204 });
656
+ }
657
+
658
+ // ── WebSocket upgrade ────────────────────────────────────────────────
659
+ if (url.pathname === "/ws" || url.pathname === "/ws/") {
660
+ // En modo desarrollo, no requerir autenticación para WebSocket
661
+ if (!isDev && !checkAuth(req, url)) {
662
+ return new Response("Unauthorized", { status: 401 });
663
+ }
664
+ const sessionId = url.searchParams.get("session") || process.env.HIVE_USER_ID;
665
+ if (!sessionId) {
666
+ return new Response("Missing session or user ID", { status: 400 });
667
+ }
668
+ const success = server.upgrade(req, {
669
+ data: { sessionId, authenticatedAt: Date.now() },
670
+ });
671
+ if (success) return undefined;
672
+ return new Response("WebSocket upgrade failed", { status: 400 });
673
+ }
674
+
675
+ // ── Bridge Events WebSocket upgrade ────────────────────────────────────
676
+ if (url.pathname === "/bridge-events" || url.pathname === "/bridge-events/") {
677
+ const sessionId = `bridge:${url.searchParams.get("sessionId") ?? (process.env.HIVE_USER_ID ?? "default")}`;
678
+ const success = server.upgrade(req, { data: { sessionId, authenticatedAt: Date.now() } });
679
+ if (success) return undefined;
680
+ return new Response("Bridge events WebSocket upgrade failed", { status: 400 });
681
+ }
682
+
683
+ // ── Canvas WebSocket upgrade ────────────────────────────────────────────
684
+ if (url.pathname === "/canvas" || url.pathname === "/canvas/") {
685
+ // En modo desarrollo, no requerir autenticación para Canvas WebSocket
686
+ let sessionId = url.searchParams.get("sessionId") ?? url.searchParams.get("session");
687
+ if (!sessionId && process.env.HIVE_USER_ID) {
688
+ sessionId = `canvas:${process.env.HIVE_USER_ID}`;
689
+ }
690
+ if (!sessionId) {
691
+ return new Response("Missing session or user ID for canvas", { status: 400 });
692
+ }
693
+ const success = server.upgrade(req, {
694
+ data: { sessionId: sessionId.startsWith("canvas:") ? sessionId : `canvas:${sessionId}`, authenticatedAt: Date.now() },
695
+ });
696
+ if (success) return undefined;
697
+ return new Response("Canvas WebSocket upgrade failed", { status: 400 });
698
+ }
699
+
700
+ // ── Health (must be before UI routing so it works in dev mode too) ───
701
+ if (url.pathname === "/health" || url.pathname === "/health/") {
702
+ return addCorsHeaders(Response.json({ status: "ok", pid: process.pid }), req);
703
+ }
704
+
705
+ // ── Dashboard / UI ────────────────────────────────────────────────────
706
+ // In development: UI is served by Vite on port 5173, Gateway only handles /api and /ws
707
+ // In production: serve static files from packages/hive-ui/dist
708
+
709
+ // Check if this is an API or WebSocket request
710
+ const isApiRequest = url.pathname.startsWith("/api");
711
+ const isWsRequest = url.pathname.startsWith("/ws");
712
+ const isUiRequest = url.pathname === "/ui" || url.pathname === "/ui/" || url.pathname.startsWith("/ui/") || url.pathname.startsWith("/ui?");
713
+ const isSetupRequest = url.pathname === "/setup" || url.pathname === "/setup/" || url.pathname.startsWith("/setup/") || url.pathname.startsWith("/setup?");
714
+
715
+ // In development mode, skip UI handling - Vite handles it directly
716
+ // Only serve static files from dist if they exist (production mode)
717
+ if (!isApiRequest && !isWsRequest) {
718
+ // In development: tell user to use Vite directly
719
+ if (isDev) {
720
+ return new Response(
721
+ "UI not available through Gateway in development.\n\n" +
722
+ "Use Vite directly: http://localhost:5173\n",
723
+ { status: 404, headers: { "Content-Type": "text/plain" } }
724
+ );
725
+ }
726
+
727
+ // In production: serve from dist folder
728
+ const uiDir = path.join(process.cwd(), "packages/hive-ui/dist");
729
+ let subPath = url.pathname;
730
+
731
+ // Normalize path for /ui routes
732
+ if (subPath === "/ui" || subPath === "/ui/") {
733
+ subPath = "/index.html";
734
+ } else if (subPath.startsWith("/ui/")) {
735
+ subPath = subPath.replace(/^\/ui/, "");
736
+ if (!subPath) subPath = "/index.html";
737
+ } else if (subPath === "/") {
738
+ subPath = "/index.html";
739
+ }
740
+
741
+ // Normalize path for /setup routes
742
+ if (subPath === "/setup" || subPath === "/setup/") {
743
+ subPath = "/index.html";
744
+ } else if (subPath.startsWith("/setup/")) {
745
+ subPath = subPath.replace(/^\/setup/, "");
746
+ if (!subPath) subPath = "/index.html";
747
+ }
748
+
749
+ const filePath = path.join(uiDir, subPath);
750
+ const uiFile = Bun.file(filePath);
751
+ if (await uiFile.exists()) {
752
+ return new Response(uiFile);
753
+ }
754
+
755
+ // If it's a UI route and no dist, show message
756
+ if (isUiRequest || isSetupRequest) {
757
+ return new Response(
758
+ "UI not found.\n\n" +
759
+ "To build for production:\n" +
760
+ " cd packages/hive-ui && bun run build\n",
761
+ { status: 404, headers: { "Content-Type": "text/plain" } }
762
+ );
763
+ }
764
+ }
765
+
766
+ // Handle /dashboard redirect for backwards compatibility
767
+ if (url.pathname.startsWith("/dashboard")) {
768
+ const tokenParam = url.searchParams.get("token") ? `? token = ${url.searchParams.get("token")} ` : "";
769
+ return Response.redirect(`/ ui${tokenParam} `, 301);
770
+ }
771
+
772
+ // ── Rutas que requieren autenticación ────────────────────────────────
773
+ if (!checkAuth(req, url)) {
774
+ log.warn(`[AUTH] Unauthorized request to ${url.pathname} from ${req.headers.get("origin")} `);
775
+ return new Response("Unauthorized", { status: 401 });
776
+ }
777
+
778
+ // ── Setup API ────────────────────────────────────────────────────────
779
+ // Only available when hive.db does not exist (first-run mode)
780
+ const isSetupMode = !existsSync(getDbPathLazy());
781
+
782
+ // GET /api/setup/status - Check if setup is needed
783
+ if (url.pathname === "/api/setup/status" || url.pathname === "/api/setup/status/") {
784
+ return addCorsHeaders(Response.json({
785
+ configured: !isSetupMode,
786
+ setupMode: isSetupMode,
787
+ }), req);
788
+ }
789
+
790
+ // POST /api/setup/verify-provider - Verify API key
791
+ if (url.pathname === "/api/setup/verify-provider" && req.method === "POST") {
792
+ const body = await req.json().catch(() => ({}));
793
+ const { provider, apiKey, model } = body;
794
+
795
+ if (!provider || !apiKey) {
796
+ return addCorsHeaders(Response.json({
797
+ success: false,
798
+ error: "Provider and API key are required",
799
+ }, { status: 400 }), req);
800
+ }
801
+
802
+ try {
803
+ let testUrl: string | null = null;
804
+ let testBody: any = null;
805
+ let headers: Record<string, string> = {};
806
+
807
+ const testMessages = [{ role: "user" as const, content: "Say 'ok' if you can read this." }];
808
+
809
+ if (provider === "ollama") {
810
+ // Ollama doesn't require API key, just check if it's running
811
+ try {
812
+ const response = await fetch("http://localhost:11434/api/tags", {
813
+ signal: AbortSignal.timeout(5000),
814
+ });
815
+ return addCorsHeaders(Response.json({
816
+ success: response.ok,
817
+ error: response.ok ? null : "Could not connect to Ollama",
818
+ }), req);
819
+ } catch {
820
+ return addCorsHeaders(Response.json({
821
+ success: false,
822
+ error: "Could not connect to Ollama at http://localhost:11434",
823
+ }), req);
824
+ }
825
+ }
826
+
827
+ if (provider === "anthropic") {
828
+ testUrl = "https://api.anthropic.com/v1/messages";
829
+ testBody = {
830
+ model: model || "claude-sonnet-4-6",
831
+ max_tokens: 10,
832
+ messages: testMessages,
833
+ };
834
+ headers = {
835
+ "Content-Type": "application/json",
836
+ "x-api-key": apiKey,
837
+ "anthropic-version": "2023-06-01",
838
+ "anthropic-dangerous-direct-browser-access": "true",
839
+ };
840
+ } else if (provider === "openai") {
841
+ testUrl = "https://api.openai.com/v1/chat/completions";
842
+ testBody = {
843
+ model: model || "gpt-5.2",
844
+ max_tokens: 10,
845
+ messages: testMessages,
846
+ };
847
+ headers = {
848
+ "Content-Type": "application/json",
849
+ "Authorization": `Bearer ${apiKey}`,
850
+ };
851
+ } else if (provider === "gemini") {
852
+ testUrl = `https://generativelanguage.googleapis.com/v1beta/models/${model || "gemini-2.5-flash"}:generateContent?key=${apiKey}`;
853
+ testBody = {
854
+ contents: [{ parts: [{ text: "Say ok" }] }],
855
+ };
856
+ headers = { "Content-Type": "application/json" };
857
+ } else if (provider === "groq") {
858
+ testUrl = "https://api.groq.com/openai/v1/chat/completions";
859
+ testBody = {
860
+ model: model || "llama-3.3-70b-versatile",
861
+ max_tokens: 10,
862
+ messages: testMessages,
863
+ };
864
+ headers = {
865
+ "Content-Type": "application/json",
866
+ "Authorization": `Bearer ${apiKey}`,
867
+ };
868
+ } else if (provider === "openrouter") {
869
+ testUrl = "https://openrouter.ai/api/v1/chat/completions";
870
+ testBody = {
871
+ model: model || "meta-llama/llama-3.3-70b-instruct",
872
+ max_tokens: 10,
873
+ messages: testMessages,
874
+ };
875
+ headers = {
876
+ "Content-Type": "application/json",
877
+ "Authorization": `Bearer ${apiKey}`,
878
+ };
879
+ }
880
+
881
+ if (!testUrl) {
882
+ return addCorsHeaders(Response.json({
883
+ success: false,
884
+ error: "Unsupported provider",
885
+ }, { status: 400 }), req);
886
+ }
887
+
888
+ const response = await fetch(testUrl, {
889
+ method: "POST",
890
+ headers,
891
+ body: JSON.stringify(testBody),
892
+ signal: AbortSignal.timeout(10000),
893
+ });
894
+
895
+ return addCorsHeaders(Response.json({
896
+ success: response.ok,
897
+ error: response.ok ? null : `API error: ${response.status}`,
898
+ }), req);
899
+ } catch (error) {
900
+ return addCorsHeaders(Response.json({
901
+ success: false,
902
+ error: `Connection error: ${(error as Error).message}`,
903
+ }), req);
904
+ }
905
+ }
906
+
907
+ // POST /api/setup/complete - Complete setup wizard
908
+ if (url.pathname === "/api/setup/complete" && req.method === "POST") {
909
+ if (!isSetupMode) {
910
+ return addCorsHeaders(Response.json({
911
+ success: false,
912
+ error: "Setup already completed. Use config endpoints to modify settings.",
913
+ }, { status: 400 }), req);
914
+ }
915
+
916
+ const body = await req.json().catch(() => ({}));
917
+
918
+ try {
919
+ // Import onboarding functions
920
+ const {
921
+ initOnboardingDb,
922
+ saveUserProfile,
923
+ saveProviderConfig,
924
+ activateChannel,
925
+ saveVoiceConfig,
926
+ } = await import("../storage/onboarding");
927
+ const { randomUUID } = await import("node:crypto");
928
+ const { saveToken } = await import("../integrations/env");
929
+
930
+ // Initialize database with schema and seeds
931
+ initOnboardingDb();
932
+ log.info("āœ… Database initialized with schema and seeds");
933
+
934
+ // Generate IDs
935
+ const userId = `user_${randomUUID().split("-")[0]}`;
936
+ const agentId = `agent_${randomUUID().split("-")[0]}`;
937
+ const channelUserId = randomUUID();
938
+
939
+ // Save user profile
940
+ saveUserProfile({
941
+ userId,
942
+ userName: body.userName || "User",
943
+ userLanguage: body.userLanguage || "es",
944
+ userTimezone: body.userTimezone || "UTC",
945
+ userOccupation: body.userOccupation || "",
946
+ userNotes: body.userNotes || "",
947
+ agentName: body.agentName || "Bee",
948
+ agentId,
949
+ agentDescription: body.agentDescription || "",
950
+ agentTone: "friendly",
951
+ channelUserId,
952
+ });
953
+ log.info(`āœ… User profile saved: ${userId}`);
954
+
955
+ // Save provider config with API key
956
+ if (body.provider && body.apiKey) {
957
+ await saveProviderConfig({
958
+ userId,
959
+ provider: body.provider,
960
+ model: body.model,
961
+ apiKey: body.apiKey,
962
+ });
963
+ log.info(`āœ… Provider config saved: ${body.provider}/${body.model}`);
964
+ }
965
+
966
+ // Activate WebChat channel (always enabled)
967
+ await activateChannel(userId, {
968
+ channelId: "webchat",
969
+ channelUserId,
970
+ config: {},
971
+ });
972
+ log.info("āœ… WebChat channel activated");
973
+
974
+ // Activate other channels if configured
975
+ if (body.channels) {
976
+ for (const [channelId, channelData] of Object.entries(body.channels as Record<string, any>)) {
977
+ if (channelId !== "webchat" && channelData.enabled) {
978
+ await activateChannel(userId, {
979
+ channelId,
980
+ config: channelData.config || {},
981
+ });
982
+ log.info(`āœ… Channel activated: ${channelId}`);
983
+ }
984
+ }
985
+ }
986
+
987
+ // Save voice config if enabled
988
+ if (body.voiceEnabled) {
989
+ await saveVoiceConfig({
990
+ userId,
991
+ channelId: "webchat",
992
+ voiceEnabled: true,
993
+ sttProvider: body.sttProvider || "groq-whisper",
994
+ ttsProvider: body.ttsProvider || "elevenlabs",
995
+ });
996
+ log.info("āœ… Voice config saved");
997
+ }
998
+
999
+ // Generate auth token and persist to .env (loaded by buildDefaultConfig on next start)
1000
+ const authToken = randomUUID().replace(/-/g, "");
1001
+ saveToken("HIVE_AUTH_TOKEN", authToken);
1002
+
1003
+ // Update the running process env + in-memory config so auth works immediately
1004
+ process.env.HIVE_AUTH_TOKEN = authToken;
1005
+ process.env.HIVE_USER_ID = userId;
1006
+ process.env.HIVE_AGENT_ID = agentId;
1007
+ config.gateway = { ...config.gateway, authToken };
1008
+
1009
+ return addCorsHeaders(Response.json({
1010
+ success: true,
1011
+ userId,
1012
+ agentId,
1013
+ authToken,
1014
+ message: "Setup completed successfully",
1015
+ }), req);
1016
+ } catch (error) {
1017
+ log.error(`āŒ Setup failed: ${(error as Error).message}`);
1018
+ return addCorsHeaders(Response.json({
1019
+ success: false,
1020
+ error: (error as Error).message,
1021
+ }, { status: 500 }), req);
1022
+ }
1023
+ }
1024
+
1025
+ // ── Status ───────────────────────────────────────────────────────────
1026
+ // FIX 3 — Cache-Control: max-age=5 para que el dashboard no haga
1027
+ // polling mƔs frecuente de cada 5 segundos (causa del spam en Network tab)
1028
+ if (url.pathname === "/status" || url.pathname === "/status/") {
1029
+ return addCorsHeaders(new Response(
1030
+ JSON.stringify({
1031
+ status: "ok",
1032
+ version: "0.1.7",
1033
+ uptime: Math.floor((Date.now() - startTime) / 1000),
1034
+ gateway: { host, port },
1035
+ sessions: sessionManager.list().map((s) => ({
1036
+ id: s.id,
1037
+ createdAt: s.createdAt,
1038
+ messageCount: s.messageCount,
1039
+ })),
1040
+ channels: channelManager.listChannels(),
1041
+ queue: { activeSessions: 0 },
1042
+ }),
1043
+ {
1044
+ headers: {
1045
+ "Content-Type": "application/json",
1046
+ // Decirle al browser/dashboard que este endpoint no cambia
1047
+ // mĆ”s rĆ”pido que 5 segundos — frena el polling descontrolado
1048
+ "Cache-Control": "max-age=5",
1049
+ },
1050
+ }
1051
+ ), req);
1052
+ }
1053
+
1054
+ // ── Activity Stats (message counts per hour) ──────────────────────────
1055
+ if (url.pathname === "/api/activity-stats" || url.pathname === "/api/activity-stats/") {
1056
+ const hours = Math.min(parseInt(url.searchParams.get("hours") || "12"), 48);
1057
+ try {
1058
+ const db = getDb();
1059
+ const now = Math.floor(Date.now() / 1000);
1060
+ const result: Array<{ time: string; count: number }> = [];
1061
+ for (let i = hours - 1; i >= 0; i--) {
1062
+ const from = now - (i + 1) * 3600;
1063
+ const to = now - i * 3600;
1064
+ const row = db.prepare(
1065
+ `SELECT COUNT(*) as count FROM messages WHERE created_at >= ? AND created_at < ? AND role IN ('user','assistant')`
1066
+ ).get(from, to) as { count: number };
1067
+ result.push({ time: i === 0 ? "Ahora" : `${i}h`, count: row.count });
1068
+ }
1069
+ return addCorsHeaders(Response.json(result, {
1070
+ headers: { "Cache-Control": "max-age=60" }
1071
+ }), req);
1072
+ } catch (error) {
1073
+ log.error(`[API] Failed to get activity stats: ${(error as Error).message}`);
1074
+ return addCorsHeaders(Response.json({ error: "Failed" }, { status: 500 }), req);
1075
+ }
1076
+ }
1077
+
1078
+ // ── System Stats (CPU, Memory, Uptime) ────────────────────────────────
1079
+ if (url.pathname === "/api/system-stats" || url.pathname === "/api/system-stats/") {
1080
+ const mem = process.memoryUsage();
1081
+ const uptimeSeconds = Math.floor((Date.now() - startTime) / 1000);
1082
+
1083
+ // Proper CPU % = delta CPU time / delta wall time (per core)
1084
+ const nowMs = Date.now();
1085
+ const currentCpu = process.cpuUsage();
1086
+ const elapsedUs = (nowMs - lastCpuSampleTime) * 1000;
1087
+ const cpuDeltaUs = (currentCpu.user - lastCpuSample.user) + (currentCpu.system - lastCpuSample.system);
1088
+ const cpuPercent = elapsedUs > 0
1089
+ ? Math.min(100, Math.round((cpuDeltaUs / (elapsedUs * numCores)) * 100))
1090
+ : 0;
1091
+ lastCpuSample = currentCpu;
1092
+ lastCpuSampleTime = nowMs;
1093
+
1094
+ const formatUptime = (seconds: number): string => {
1095
+ const days = Math.floor(seconds / 86400);
1096
+ const hours = Math.floor((seconds % 86400) / 3600);
1097
+ const minutes = Math.floor((seconds % 3600) / 60);
1098
+ if (days > 0) return `${days}d ${hours}h ${minutes}m`;
1099
+ if (hours > 0) return `${hours}h ${minutes}m`;
1100
+ if (minutes > 0) return `${minutes}m`;
1101
+ return `${seconds % 60}s`;
1102
+ };
1103
+
1104
+ const fmt = (b: number) => `${(b / 1024 / 1024).toFixed(1)} MB`;
1105
+
1106
+ const rssMB = Math.round(mem.rss / 1024 / 1024);
1107
+ const heapUsedMB = Math.round(mem.heapUsed / 1024 / 1024);
1108
+ const heapTotalMB = Math.round(mem.heapTotal / 1024 / 1024);
1109
+ // heapUsed can briefly exceed heapTotal while V8 expands — cap at 100
1110
+ const heapPercent = Math.min(100, Math.round((mem.heapUsed / Math.max(mem.heapTotal, 1)) * 100));
1111
+
1112
+ // Recent messages (last 5 min) as activity indicator
1113
+ let recentMessages = 0;
1114
+ try {
1115
+ const since5m = Math.floor(Date.now() / 1000) - 300;
1116
+ recentMessages = (getDb().prepare(
1117
+ `SELECT COUNT(*) as c FROM messages WHERE created_at >= ? AND role IN ('user','assistant')`
1118
+ ).get(since5m) as { c: number }).c;
1119
+ } catch { /* non-critical */ }
1120
+
1121
+ log.debug(`[API] System stats: rss=${rssMB}MB heap=${heapUsedMB}/${heapTotalMB}MB msgs5m=${recentMessages}`);
1122
+
1123
+ return addCorsHeaders(Response.json({
1124
+ cpu: cpuPercent,
1125
+ memory: {
1126
+ rss: rssMB,
1127
+ heapUsed: heapUsedMB,
1128
+ heapTotal: heapTotalMB,
1129
+ heapPercent,
1130
+ external: Math.round(mem.external / 1024 / 1024),
1131
+ // legacy fields kept for compatibility
1132
+ used: heapUsedMB,
1133
+ total: heapTotalMB,
1134
+ percentage: heapPercent,
1135
+ },
1136
+ uptime: formatUptime(uptimeSeconds),
1137
+ connections: sessionManager.list().length,
1138
+ cores: numCores,
1139
+ recentMessages,
1140
+ }, {
1141
+ headers: { "Cache-Control": "max-age=2" }
1142
+ }), req);
1143
+ }
1144
+
1145
+ // ── Interactive Benchmark ────────────────────────────────────────────
1146
+ if (url.pathname === "/api/benchmark" || url.pathname === "/api/benchmark/") {
1147
+ if (req.method === "POST") {
1148
+ log.info(`[API] Running interactive benchmark...`);
1149
+ const bench = new Benchmark("UI Interactive").start();
1150
+
1151
+ // Capture a snapshot of current resource usage
1152
+ const metrics = bench.stop();
1153
+
1154
+ return addCorsHeaders(Response.json({
1155
+ success: true,
1156
+ timestamp: new Date().toISOString(),
1157
+ metrics
1158
+ }), req);
1159
+ }
1160
+ }
1161
+
1162
+ // ── System Reload (Hot Reload for MCP, Tools, Agents) ────────────────
1163
+ if (url.pathname === "/api/system/reload" || url.pathname === "/api/system/reload/") {
1164
+ if (req.method === "POST") {
1165
+ if (!checkAuth(req, url)) {
1166
+ return addCorsHeaders(new Response("Unauthorized", { status: 401 }), req);
1167
+ }
1168
+ log.info(`[API] šŸ”„ System reload requested...`);
1169
+ try {
1170
+ // Re-init the agent to pick up new MCP servers from the DB
1171
+ await agent.initialize();
1172
+ log.info(`[API] Agent re - initialized(MCPs reconnected)`);
1173
+
1174
+ // Rebuild the supervisor graph so sub-agents are refreshed too
1175
+ const mcp = agent.getMCPManager();
1176
+ if (mcp) {
1177
+ await rebuildSupervisorGraph({ mcpManager: mcp });
1178
+ log.info(`[API] Supervisor graph rebuilt`);
1179
+ }
1180
+
1181
+ return addCorsHeaders(Response.json({
1182
+ success: true,
1183
+ message: "System reloaded. New tools, MCP servers, and providers are now active.",
1184
+ timestamp: new Date().toISOString(),
1185
+ }), req);
1186
+ } catch (error) {
1187
+ log.error(`[API] System reload failed: ${(error as Error).message} `);
1188
+ return addCorsHeaders(Response.json({
1189
+ success: false,
1190
+ error: (error as Error).message,
1191
+ }, { status: 500 }), req);
1192
+ }
1193
+ }
1194
+ }
1195
+
1196
+ // ── Usage Stats (tokens, costs) ───────────────────────────────────────
1197
+ if (url.pathname === "/api/usage-stats" || url.pathname === "/api/usage-stats/") {
1198
+ const hours = parseInt(url.searchParams.get("hours") || "24");
1199
+ log.info(`[API] GET / api / usage - stats hours = ${hours} `);
1200
+ try {
1201
+ const stats = getUsageStats(Math.min(hours, 720));
1202
+ log.info(`[API] Usage stats response: totalTokens = ${stats.totalTokens} totalCost = $${stats.totalCostUsd.toFixed(4)} providers = ${Object.keys(stats.byProvider).length} `);
1203
+ return addCorsHeaders(Response.json(stats, {
1204
+ headers: { "Cache-Control": "max-age=30" }
1205
+ }), req);
1206
+ } catch (error) {
1207
+ log.error(`[API] Failed to get usage stats: ${(error as Error).message} `);
1208
+ return addCorsHeaders(Response.json({ error: "Failed to get usage stats" }, { status: 500 }), req);
1209
+ }
1210
+ }
1211
+
1212
+ // ── Config ───────────────────────────────────────────────────────────
1213
+ if (url.pathname === "/api/config") {
1214
+ if (req.method === "GET") {
1215
+ // FIX 1 — nunca devolver el config raw con secrets
1216
+ return addCorsHeaders(Response.json(redactConfig(config)), req);
1217
+ }
1218
+ }
1219
+
1220
+ // ── Projects API ─────────────────────────────────────────────────────
1221
+
1222
+ // GET /api/projects — lista todos los proyectos con sus tareas anidadas
1223
+ if (url.pathname === "/api/projects" || url.pathname === "/api/projects/") {
1224
+ const db = getDb();
1225
+ if (req.method === "GET") {
1226
+ const statusFilter = url.searchParams.get("status");
1227
+ const query = statusFilter
1228
+ ? `SELECT * FROM projects WHERE status = ? ORDER BY created_at DESC`
1229
+ : `SELECT * FROM projects ORDER BY created_at DESC`;
1230
+ const projects = (statusFilter
1231
+ ? db.query(query).all(statusFilter)
1232
+ : db.query(query).all()) as any[];
1233
+
1234
+ const result = projects.map((p: any) => ({
1235
+ ...p,
1236
+ tasks: db.query("SELECT * FROM tasks WHERE project_id = ? ORDER BY id ASC").all(p.id),
1237
+ }));
1238
+ return addCorsHeaders(Response.json(result), req);
1239
+ }
1240
+ }
1241
+
1242
+ if (url.pathname === "/api/projects/active") {
1243
+ if (req.method === "GET") {
1244
+ const db = getDb();
1245
+ const projects = db.query("SELECT * FROM projects WHERE status IN ('active','pending','paused') ORDER BY created_at DESC").all() as any[];
1246
+ const result = projects.map((p: any) => ({
1247
+ ...p,
1248
+ tasks: db.query("SELECT * FROM tasks WHERE project_id = ? ORDER BY id ASC").all(p.id),
1249
+ }));
1250
+ return addCorsHeaders(Response.json(result), req);
1251
+ }
1252
+ }
1253
+
1254
+ if (url.pathname === "/api/projects/history") {
1255
+ if (req.method === "GET") {
1256
+ const page = parseInt(url.searchParams.get("page") || "1");
1257
+ const limit = parseInt(url.searchParams.get("limit") || "20");
1258
+ const offset = (page - 1) * limit;
1259
+
1260
+ const db = getDb();
1261
+ const projects = db.query(
1262
+ "SELECT * FROM projects WHERE status IN ('done','failed') ORDER BY completed_at DESC LIMIT ? OFFSET ?"
1263
+ ).all(limit, offset) as any[];
1264
+
1265
+ const total = db.query("SELECT COUNT(*) as count FROM projects WHERE status IN ('done', 'failed')").get() as { count: number };
1266
+
1267
+ return addCorsHeaders(Response.json({
1268
+ data: projects.map((p: any) => ({
1269
+ ...p,
1270
+ tasks: db.query("SELECT * FROM tasks WHERE project_id = ? ORDER BY id ASC").all(p.id),
1271
+ })),
1272
+ pagination: {
1273
+ page,
1274
+ limit,
1275
+ total: total.count,
1276
+ pages: Math.ceil(total.count / limit)
1277
+ }
1278
+ }), req);
1279
+ }
1280
+ }
1281
+
1282
+ // GET/PATCH /api/projects/:id
1283
+ const projectDetailMatch = url.pathname.match(/^\/api\/projects\/([^/]+)$/);
1284
+ if (projectDetailMatch) {
1285
+ const projectId = projectDetailMatch[1];
1286
+ const db = getDb();
1287
+
1288
+ if (req.method === "GET") {
1289
+ const project = db.query("SELECT * FROM projects WHERE id = ?").get(projectId) as any;
1290
+ if (!project) return new Response("Project not found", { status: 404 });
1291
+
1292
+ const tasks = db.query("SELECT * FROM tasks WHERE project_id = ? ORDER BY id ASC").all(projectId);
1293
+ const subprojects = db.query("SELECT * FROM projects WHERE parent_id = ?").all(projectId);
1294
+ return addCorsHeaders(Response.json({ ...project, tasks, subprojects }), req);
1295
+ }
1296
+
1297
+ if (req.method === "PATCH") {
1298
+ const body = await req.json().catch(() => ({}));
1299
+ const { status } = body;
1300
+ if (!status || !['active', 'paused', 'done', 'failed', 'pending'].includes(status)) {
1301
+ return new Response("Invalid status", { status: 400 });
1302
+ }
1303
+
1304
+ const result = db.query("UPDATE projects SET status = ?, updated_at = unixepoch() WHERE id = ?").run(status, projectId);
1305
+ if (result.changes === 0) return new Response("Project not found", { status: 404 });
1306
+
1307
+ return addCorsHeaders(Response.json({ success: true, status }), req);
1308
+ }
1309
+ }
1310
+
1311
+ // GET /api/projects/:id/tasks
1312
+ const projectTasksMatch = url.pathname.match(/^\/api\/projects\/([^/]+)\/tasks$/);
1313
+ if (projectTasksMatch) {
1314
+ const projectId = projectTasksMatch[1];
1315
+ const db = getDb();
1316
+
1317
+ if (req.method === "GET") {
1318
+ const project = db.query("SELECT id FROM projects WHERE id = ?").get(projectId);
1319
+ if (!project) return new Response("Project not found", { status: 404 });
1320
+ const tasks = db.query("SELECT * FROM tasks WHERE project_id = ? ORDER BY id ASC").all(projectId);
1321
+ return addCorsHeaders(Response.json(tasks), req);
1322
+ }
1323
+ }
1324
+
1325
+ // GET /api/tasks — lista tareas (filtro por agentId opcional)
1326
+ if (url.pathname === "/api/tasks" || url.pathname === "/api/tasks/") {
1327
+ const db = getDb();
1328
+ if (req.method === "GET") {
1329
+ const agentId = url.searchParams.get("agentId");
1330
+ const tasks = agentId
1331
+ ? db.query("SELECT * FROM tasks WHERE agent_id = ? ORDER BY id ASC").all(agentId)
1332
+ : db.query("SELECT * FROM tasks ORDER BY id DESC LIMIT 100").all();
1333
+ return addCorsHeaders(Response.json(tasks), req);
1334
+ }
1335
+ }
1336
+
1337
+ // PATCH /api/tasks/:id
1338
+ const taskDetailMatch = url.pathname.match(/^\/api\/tasks\/(\d+)$/);
1339
+ if (taskDetailMatch) {
1340
+ const taskId = parseInt(taskDetailMatch[1]);
1341
+ const db = getDb();
1342
+
1343
+ if (req.method === "PATCH") {
1344
+ const body = await req.json().catch(() => ({}));
1345
+ const allowed = ["status", "progress", "result", "agent_id"];
1346
+ const fields: string[] = ["updated_at = unixepoch()"];
1347
+ const values: any[] = [];
1348
+
1349
+ for (const key of allowed) {
1350
+ if (body[key] !== undefined) {
1351
+ fields.push(`${key} = ?`);
1352
+ values.push(body[key]);
1353
+ }
1354
+ }
1355
+ values.push(taskId);
1356
+
1357
+ const result = db.query(`UPDATE tasks SET ${fields.join(", ")} WHERE id = ?`).run(...values);
1358
+ if (result.changes === 0) return new Response("Task not found", { status: 404 });
1359
+
1360
+ // Recalcular progreso del proyecto
1361
+ const task = db.query<any, [number]>("SELECT project_id FROM tasks WHERE id = ?").get(taskId);
1362
+ if (task?.project_id) {
1363
+ const avgRow = db.query<any, [string]>("SELECT AVG(progress) as avg FROM tasks WHERE project_id = ?").get(task.project_id) as any;
1364
+ const avg = Math.round(avgRow?.avg ?? 0);
1365
+ db.query("UPDATE projects SET progress = ?, updated_at = unixepoch() WHERE id = ?").run(avg, task.project_id);
1366
+ }
1367
+
1368
+ return addCorsHeaders(Response.json({ success: true }), req);
1369
+ }
1370
+ }
1371
+
1372
+
1373
+
1374
+
1375
+ // ── Channels API ─────────────────────────────────────────────────────
1376
+ if (url.pathname === "/api/channels" || url.pathname === "/api/channels/") {
1377
+ if (req.method === "POST") {
1378
+ const body = await req.json().catch(() => ({}));
1379
+ const { name, accountId, config: channelConfigData } = body;
1380
+ if (!name || !accountId || !channelConfigData) {
1381
+ return addCorsHeaders(new Response("Missing name, accountId or config", { status: 400 }), req);
1382
+ }
1383
+ config.channels = config.channels || {};
1384
+ config.channels[name] = config.channels[name] || { enabled: true, accounts: {} };
1385
+ const channelEntry = config.channels[name] as any;
1386
+ channelEntry.accounts = channelEntry.accounts || {};
1387
+ channelEntry.accounts[accountId] = channelConfigData;
1388
+ await channelManager.removeChannel(name, accountId);
1389
+ await channelManager.startChannel(name, accountId);
1390
+ return addCorsHeaders(Response.json({ success: true }), req);
1391
+ }
1392
+ }
1393
+
1394
+ const channelDetailMatch = url.pathname.match(/^\/api\/channels\/([^/]+)\/([^/]+)$/);
1395
+ if (channelDetailMatch) {
1396
+ const name = channelDetailMatch[1];
1397
+ const accountId = channelDetailMatch[2];
1398
+
1399
+ if (req.method === "GET") {
1400
+ const accConfig = channelManager.getAccountConfig(name, accountId);
1401
+ if (!accConfig) return new Response("Channel account not found", { status: 404 });
1402
+ return Response.json({ name, accountId, config: accConfig });
1403
+ }
1404
+ if (req.method === "PUT") {
1405
+ const body = await req.json().catch(() => ({}));
1406
+ if (!body.config) return new Response("Missing config", { status: 400 });
1407
+ config.channels = config.channels || {};
1408
+ config.channels[name] = config.channels[name] || { enabled: true, accounts: {} };
1409
+ const channelEntry = config.channels[name] as any;
1410
+ channelEntry.accounts = channelEntry.accounts || {};
1411
+ channelEntry.accounts[accountId] = body.config;
1412
+ await channelManager.removeChannel(name, accountId);
1413
+ await channelManager.startChannel(name, accountId);
1414
+ return Response.json({ success: true });
1415
+ }
1416
+ if (req.method === "DELETE") {
1417
+ if (config.channels?.[name]) {
1418
+ const channelEntry = config.channels[name] as any;
1419
+ if (channelEntry.accounts) {
1420
+ delete channelEntry.accounts[accountId];
1421
+ if (Object.keys(channelEntry.accounts).length === 0) {
1422
+ delete config.channels[name];
1423
+ }
1424
+ }
1425
+ await channelManager.removeChannel(name, accountId);
1426
+ }
1427
+ return Response.json({ success: true });
1428
+ }
1429
+ }
1430
+
1431
+ const channelActionMatch = url.pathname.match(
1432
+ /^\/api\/channels\/([^/]+)\/([^/]+)\/(start|stop)$/
1433
+ );
1434
+ if (channelActionMatch) {
1435
+ const [, name, accountId, action] = channelActionMatch;
1436
+ if (req.method === "POST") {
1437
+ try {
1438
+ if (action === "start") await channelManager.startChannel(name, accountId);
1439
+ else await channelManager.stopChannel(name, accountId);
1440
+ return Response.json({ success: true });
1441
+ } catch (error) {
1442
+ return Response.json(
1443
+ { success: false, error: (error as Error).message },
1444
+ { status: 500 }
1445
+ );
1446
+ }
1447
+ }
1448
+ }
1449
+
1450
+ // ── Skills API ───────────────────────────────────────────────────────
1451
+ if (url.pathname === "/api/skills" || url.pathname === "/api/skills/") {
1452
+ if (req.method === "POST") {
1453
+ const body = await req.json().catch(() => ({}));
1454
+ const { name, description, content, raw } = body;
1455
+ if (!name || (!content && !raw)) {
1456
+ return addCorsHeaders(new Response("Missing name or content", { status: 400 }), req);
1457
+ }
1458
+ const managedDir = expandPath(config.skills?.managedDir ?? "~/.hive/skills");
1459
+ const skillDir = path.join(managedDir, name);
1460
+ const skillFile = path.join(skillDir, "SKILL.md");
1461
+ mkdirSync(skillDir, { recursive: true });
1462
+ const skillMd = raw || `-- -\nname: ${name} \ndescription: ${description || ""} \n-- -\n${content} `;
1463
+ await Bun.write(skillFile, skillMd);
1464
+ agent.reloadSkills();
1465
+ return addCorsHeaders(Response.json({ success: true }), req);
1466
+ }
1467
+ }
1468
+
1469
+ // ── Model Config API ─────────────────────────────────────────────────
1470
+ if (url.pathname === "/api/config/models") {
1471
+ if (req.method === "GET") {
1472
+ return addCorsHeaders(Response.json({
1473
+ config: config.models || {},
1474
+ availableProviders: ["openai", "anthropic", "gemini", "kimi", "ollama", "openrouter", "deepseek"],
1475
+ }), req);
1476
+ }
1477
+ if (req.method === "POST") {
1478
+ const body = await req.json().catch(() => ({}));
1479
+ const { defaultProvider, defaults, providers } = body;
1480
+ config.models = config.models || {};
1481
+ if (defaultProvider) config.models.defaultProvider = defaultProvider;
1482
+ if (defaults) config.models.defaults = { ...(config.models.defaults || {}), ...defaults };
1483
+ if (providers) config.models.providers = { ...(config.models.providers || {}), ...providers };
1484
+ await agent.updateConfig(config);
1485
+ return addCorsHeaders(Response.json({ success: true }), req);
1486
+ }
1487
+ }
1488
+
1489
+ // ── MCP API ──────────────────────────────────────────────────────────
1490
+ if (url.pathname.startsWith("/api/mcp/")) {
1491
+ const mcp = agent.getMCPManager();
1492
+ if (!mcp) return new Response("MCP is disabled", { status: 404 });
1493
+
1494
+ if (url.pathname === "/api/mcp/servers") {
1495
+ if (!checkAuth(req, url)) return new Response("Unauthorized", { status: 401 });
1496
+
1497
+ if (req.method === "GET") {
1498
+ // Obtener los MCPs activos del manager
1499
+ const activeServers = mcp.listServers();
1500
+
1501
+ // Obtener todos los MCPs de la base de datos
1502
+ const dbServers = dbService.listMCPServers();
1503
+
1504
+ // Combinar información: DB + estado de conexión en tiempo real
1505
+ const allServers = dbServers.map(s => {
1506
+ const activeServer = activeServers.find((as: any) => as.name === s.name);
1507
+ const isEnabled = s.enabled === 1;
1508
+
1509
+ // Redact headers for safe UI display
1510
+ let headers = undefined;
1511
+ if (s.headers_encrypted && s.headers_iv) {
1512
+ try {
1513
+ const decryptedHeaders = decryptConfig(s.headers_encrypted, s.headers_iv);
1514
+ headers = Object.fromEntries(
1515
+ Object.entries(decryptedHeaders).map(([k, v]) => [
1516
+ k,
1517
+ k.toLowerCase().includes("auth") ||
1518
+ k.toLowerCase().includes("token") ||
1519
+ k.toLowerCase().includes("key")
1520
+ ? `${(v as string).slice(0, 4)}••••••••`
1521
+ : v,
1522
+ ])
1523
+ );
1524
+ } catch (e) {
1525
+ log.error(`Failed to decrypt headers for ${s.name}: ${(e as Error).message} `);
1526
+ }
1527
+ }
1528
+
1529
+ return {
1530
+ name: s.name,
1531
+ enabled: isEnabled,
1532
+ status: isEnabled
1533
+ ? (activeServer?.status || "disconnected")
1534
+ : "not_configured",
1535
+ config: {
1536
+ transport: s.transport,
1537
+ command: s.command,
1538
+ args: s.args ? JSON.parse(s.args) : [],
1539
+ url: s.url,
1540
+ headers,
1541
+ enabled: isEnabled
1542
+ },
1543
+ tools_count: s.tools_count || 0,
1544
+ tools: activeServer?.tools || [],
1545
+ };
1546
+ });
1547
+
1548
+ return addCorsHeaders(Response.json(allServers), req);
1549
+ }
1550
+ if (req.method === "POST") {
1551
+ const body = await req.json().catch(() => ({}));
1552
+ if (!body.name || !body.config) {
1553
+ return new Response("Missing name or config", { status: 400 });
1554
+ }
1555
+ mcpLog.info(`Creating MCP server: ${body.name} `, { config: body.config });
1556
+ // Save to SQLite
1557
+ config.mcp = config.mcp || {};
1558
+ config.mcp.servers = config.mcp.servers || {};
1559
+ config.mcp.servers[body.name] = body.config;
1560
+ await agent.updateConfig(config);
1561
+
1562
+ // Update AgentLoop singleton with new mcpManager
1563
+ const agentLoop = getSupervisorGraph();
1564
+ if (agentLoop) {
1565
+ agentLoop.setMCPManager(agent.getMCPManager());
1566
+ }
1567
+
1568
+ // Encrypt sensitive headers for DB
1569
+ let headersEnc = undefined;
1570
+ if (body.config.headers) {
1571
+ // No hay viejos headers en un POST de server nuevo,
1572
+ // pero por consistencia usamos la función
1573
+ headersEnc = encryptConfig(body.config.headers);
1574
+ }
1575
+
1576
+ // Save to SQLite
1577
+ dbService.saveMCPServer({
1578
+ name: body.name,
1579
+ ...body.config,
1580
+ headers_encrypted: headersEnc?.encrypted,
1581
+ headers_iv: headersEnc?.iv,
1582
+ enabled: body.config.enabled !== false,
1583
+ builtin: false
1584
+ });
1585
+
1586
+ mcpLog.info(`Successfully created MCP server: ${body.name} `);
1587
+ return addCorsHeaders(Response.json({ success: true }), req);
1588
+ }
1589
+ }
1590
+
1591
+ // FIX 5 — separar el endpoint de tools del de detalles del servidor
1592
+ const toolsMatch = url.pathname.match(/^\/api\/mcp\/servers\/([^/]+)\/tools$/);
1593
+ if (toolsMatch && req.method === "GET") {
1594
+ if (!checkAuth(req, url)) return new Response("Unauthorized", { status: 401 });
1595
+ const serverName = decodeURIComponent(toolsMatch[1]);
1596
+ const tools = mcp.getServerTools(serverName);
1597
+ return addCorsHeaders(Response.json(tools), req);
1598
+ }
1599
+
1600
+ const serverMatch = url.pathname.match(/^\/api\/mcp\/servers\/([^/]+)$/);
1601
+ if (serverMatch) {
1602
+ if (!checkAuth(req, url)) return new Response("Unauthorized", { status: 401 });
1603
+ const serverName = decodeURIComponent(serverMatch[1]);
1604
+ const details = mcp.getServerDetails(serverName);
1605
+
1606
+ if (req.method === "GET") {
1607
+ if (!details) return new Response("Server not found", { status: 404 });
1608
+ return addCorsHeaders(Response.json(details), req);
1609
+ }
1610
+ if (req.method === "PUT") {
1611
+ const body = await req.json().catch(() => ({}));
1612
+ if (!body.config) return new Response("Missing config", { status: 400 });
1613
+
1614
+ mcpLog.info(`Updating MCP server: ${serverName} `, { config: body.config });
1615
+ const oldConfig = config.mcp?.servers?.[serverName];
1616
+
1617
+ // Smart merge for headers
1618
+ if (body.config.headers && oldConfig?.headers) {
1619
+ body.config.headers = mergeHeaders(body.config.headers, oldConfig.headers);
1620
+ }
1621
+
1622
+ // Update SQLite
1623
+ config.mcp = config.mcp || {};
1624
+ config.mcp.servers = config.mcp.servers || {};
1625
+ config.mcp.servers[serverName] = body.config;
1626
+ await agent.updateConfig(config);
1627
+
1628
+ // Encrypt sensitive headers for DB
1629
+ let headersEnc = undefined;
1630
+ if (body.config.headers) {
1631
+ headersEnc = encryptConfig(body.config.headers);
1632
+ }
1633
+
1634
+ // Update SQLite
1635
+ dbService.updateMCPServer(serverName, {
1636
+ ...body.config,
1637
+ headers_encrypted: headersEnc?.encrypted,
1638
+ headers_iv: headersEnc?.iv,
1639
+ enabled: body.config.enabled !== false
1640
+ });
1641
+
1642
+ mcpLog.info(`Successfully updated MCP server: ${serverName} `);
1643
+ return addCorsHeaders(Response.json({ success: true }), req);
1644
+ }
1645
+ if (req.method === "DELETE") {
1646
+ mcpLog.info(`Deleting MCP server: ${serverName} `);
1647
+ if (config.mcp?.servers?.[serverName]) {
1648
+ delete config.mcp.servers[serverName];
1649
+ await agent.updateConfig(config);
1650
+ }
1651
+ // Logic to delete from SQLite could be added if desired,
1652
+ // but usually we just disable them or keep the record.
1653
+ // For now, let's just keep it simple and sync yaml.
1654
+ return addCorsHeaders(Response.json({ success: true }), req);
1655
+ }
1656
+ if (req.method === "PATCH") {
1657
+ const body = await req.json().catch(() => ({}));
1658
+ if (body.enabled !== undefined) {
1659
+ mcpLog.info(`${body.enabled ? "Enabling" : "Disabling"} MCP server: ${serverName} `);
1660
+ // Update SQLite
1661
+ config.mcp = config.mcp || {};
1662
+ config.mcp.servers = config.mcp.servers || {};
1663
+ config.mcp.servers[serverName] = {
1664
+ ...config.mcp.servers[serverName],
1665
+ enabled: body.enabled,
1666
+ };
1667
+ await agent.updateConfig(config);
1668
+
1669
+ // Update SQLite
1670
+ dbService.updateMCPServer(serverName, { enabled: body.enabled });
1671
+
1672
+ return addCorsHeaders(Response.json({ success: true, enabled: body.enabled }), req);
1673
+ }
1674
+ return new Response("Missing enabled field", { status: 400 });
1675
+ }
1676
+ if (req.method === "POST") {
1677
+ const body = await req.json().catch(() => ({}));
1678
+ if (body.action === "connect") {
1679
+ // Check if server exists in DB (not in config variable which may be stale)
1680
+ const dbServer = dbService.listMCPServers().find((s: any) => s.name === serverName);
1681
+ if (!dbServer || !dbServer.enabled) {
1682
+ return new Response("Server not found or disabled", { status: 400 });
1683
+ }
1684
+ await mcp.connectServer(serverName);
1685
+
1686
+ // Update tools count after connection
1687
+ const tools = mcp.getServerTools(serverName) || [];
1688
+ dbService.updateMCPServer(serverName, {
1689
+ status: "connected",
1690
+ tools_count: tools.length
1691
+ });
1692
+
1693
+ return addCorsHeaders(Response.json({ success: true, tools_count: tools.length }), req);
1694
+ }
1695
+ if (body.action === "disconnect") {
1696
+ await mcp.disconnectServer(serverName);
1697
+ return addCorsHeaders(Response.json({ success: true }), req);
1698
+ }
1699
+ }
1700
+ }
1701
+ }
1702
+
1703
+ // ── Workspace API ────────────────────────────────────────────────────
1704
+ for (const wsType of ["soul", "user", "ethics"] as const) {
1705
+ if (url.pathname === `/api/workspace/${wsType}`) {
1706
+ const filePath = path.join(workspacePath, `${wsType.toUpperCase()}.md`);
1707
+
1708
+ if (req.method === "GET") {
1709
+ const defaults: Record<string, string> = {
1710
+ soul: "# Agent Soul\n\nDefine your agent's personality here.",
1711
+ user: "# User Profile\n\nAdd user preferences here.",
1712
+ ethics: "# Ethics\n\nDefine ethical guidelines here.",
1713
+ };
1714
+ const wsFile = Bun.file(filePath);
1715
+ const content = (await wsFile.exists())
1716
+ ? await wsFile.text()
1717
+ : defaults[wsType];
1718
+ return addCorsHeaders(new Response(content, { headers: { "Content-Type": "text/plain" } }), req);
1719
+ }
1720
+
1721
+ if (req.method === "POST") {
1722
+ const content = await req.text();
1723
+ mkdirSync(workspacePath, { recursive: true });
1724
+ await Bun.write(filePath, content);
1725
+ if (wsType === "soul") agent.reloadSoul();
1726
+ if (wsType === "user") agent.reloadUser();
1727
+ if (wsType === "ethics") await agent.reloadEthics();
1728
+ return addCorsHeaders(Response.json({ success: true, savedAt: new Date().toISOString() }), req);
1729
+ }
1730
+ }
1731
+ }
1732
+
1733
+ // ── Reload API ───────────────────────────────────────────────────────
1734
+ if (url.pathname === "/api/reload" && req.method === "POST") {
1735
+ try {
1736
+ const newConfig = await loadConfig();
1737
+ await agent.updateConfig(newConfig);
1738
+ await agent.reload();
1739
+ log.info("Configuration reloaded via API");
1740
+ return addCorsHeaders(Response.json({ success: true, message: "Configuration reloaded" }), req);
1741
+ } catch (error) {
1742
+ return addCorsHeaders(Response.json(
1743
+ { success: false, error: (error as Error).message },
1744
+ { status: 500 }
1745
+ ), req);
1746
+ }
1747
+ }
1748
+
1749
+ // ── User Channel Linking API ────────────────────────────────────────────
1750
+ if (url.pathname === "/api/user/channels" && req.method === "POST") {
1751
+ const body = await req.json().catch(() => ({}));
1752
+ const { channel, channelUserId } = body;
1753
+
1754
+ if (!channel || !channelUserId) {
1755
+ return addCorsHeaders(Response.json({ success: false, error: "Missing channel or channelUserId" }, { status: 400 }), req);
1756
+ }
1757
+
1758
+ config.user = config.user || { id: "", name: "User" };
1759
+ config.user.channels = config.user.channels || {};
1760
+ config.user.channels[channel] = channelUserId;
1761
+
1762
+ log.info(`Linked channel ${channel} to user ID ${channelUserId} `);
1763
+
1764
+ return addCorsHeaders(Response.json({ success: true, channels: config.user.channels }), req);
1765
+ }
1766
+
1767
+ if (url.pathname === "/api/user/channels" && req.method === "GET") {
1768
+ return addCorsHeaders(Response.json({
1769
+ user: config.user || { id: "", name: "User", channels: {} },
1770
+ }), req);
1771
+ }
1772
+
1773
+ // ── Agents API ─────────────────────────────────────────────────────
1774
+ if (url.pathname === "/api/agents" && req.method === "GET") {
1775
+ const rows = getDb().query(`
1776
+ SELECT a.*, u.notes as user_preferences,
1777
+ CASE WHEN a.headers_encrypted IS NOT NULL THEN 1 ELSE 0 END as has_headers
1778
+ FROM agents a
1779
+ LEFT JOIN users u ON a.user_id = u.id
1780
+ ORDER BY a.created_at DESC
1781
+ `).all() as any[];
1782
+ const agents = rows.map(row => ({
1783
+ id: row.id,
1784
+ name: row.name,
1785
+ description: row.description,
1786
+ status: row.status,
1787
+ enabled: Boolean(row.enabled),
1788
+ is_coordinator: Boolean(row.is_coordinator),
1789
+ isCoordinator: Boolean(row.is_coordinator),
1790
+ providerId: row.provider_id,
1791
+ modelId: row.model_id,
1792
+ tone: row.tone,
1793
+ hasHeaders: row.has_headers === 1,
1794
+ createdAt: new Date(row.created_at * 1000).toISOString(),
1795
+ // UI specific mocks/defaults
1796
+ taskCount: 0,
1797
+ successRate: 100,
1798
+ }));
1799
+ return addCorsHeaders(Response.json({ agents }), req);
1800
+ }
1801
+
1802
+ if (url.pathname === "/api/agents" && req.method === "POST") {
1803
+ const body = await req.json().catch(() => ({}));
1804
+ const { encrypted: headersEnc, iv: headersIv } = body.headers ? encryptConfig(body.headers) : { encrypted: null, iv: null };
1805
+
1806
+ let agentId: string;
1807
+
1808
+ if (body.id) {
1809
+ agentId = body.id;
1810
+ getDb().query(`
1811
+ INSERT INTO agents(id, name, description, provider_id, model_id, tone, enabled, headers_encrypted, headers_iv)
1812
+ VALUES(?, ?, ?, ?, ?, ?, 1, ?, ?)
1813
+ `).run(
1814
+ agentId,
1815
+ body.name,
1816
+ body.description || "",
1817
+ body.providerId || "openai",
1818
+ body.modelId || "gpt-4o",
1819
+ body.tone || "friendly",
1820
+ headersEnc,
1821
+ headersIv
1822
+ );
1823
+ } else {
1824
+ const result = getDb().query(`
1825
+ INSERT INTO agents(name, description, provider_id, model_id, tone, enabled, headers_encrypted, headers_iv)
1826
+ VALUES(?, ?, ?, ?, ?, 1, ?, ?)
1827
+ RETURNING id
1828
+ `).get(
1829
+ body.name,
1830
+ body.description || "",
1831
+ body.providerId || "openai",
1832
+ body.modelId || "gpt-4o",
1833
+ body.tone || "friendly",
1834
+ headersEnc,
1835
+ headersIv
1836
+ ) as { id: string } | undefined;
1837
+ agentId = result?.id || "";
1838
+ }
1839
+
1840
+ emitCanvas("canvas:node_add", {
1841
+ node: { id: agentId, name: body.name, status: "idle", type: "agent" }
1842
+ });
1843
+
1844
+ // Return the created agent
1845
+ const agent = getDb().query(`
1846
+ SELECT id, name, description, provider_id, model_id, tone, status, enabled, active, created_at
1847
+ FROM agents WHERE id = ?
1848
+ `).get(agentId) as any;
1849
+
1850
+ return addCorsHeaders(Response.json({
1851
+ ok: true,
1852
+ agent: {
1853
+ id: agent.id,
1854
+ name: agent.name,
1855
+ description: agent.description,
1856
+ providerId: agent.provider_id,
1857
+ modelId: agent.model_id,
1858
+ tone: agent.tone,
1859
+ status: agent.status,
1860
+ enabled: agent.enabled === 1,
1861
+ active: agent.active === 1,
1862
+ createdAt: new Date(agent.created_at * 1000).toISOString(),
1863
+ }
1864
+ }), req);
1865
+ }
1866
+
1867
+ // PATCH /api/agents/:id
1868
+ if (url.pathname.startsWith("/api/agents/") && (req.method === "PATCH" || req.method === "PUT")) {
1869
+ const agentId = url.pathname.split("/").pop();
1870
+ const requestHeaders = Object.fromEntries(req.headers.entries());
1871
+ log.info(`[API] PATCH / api / agents / ${agentId} reqHeaders = ${JSON.stringify(requestHeaders)} `);
1872
+ if (!agentId) return addCorsHeaders(new Response("Missing ID", { status: 400 }), req);
1873
+
1874
+ const body = await req.json().catch(() => ({}));
1875
+ log.info(`[API] PATCH full body: ${JSON.stringify(body)} `);
1876
+
1877
+ // Build dynamic SET clause for agents table
1878
+ const updates: string[] = [];
1879
+ const params: any[] = [];
1880
+
1881
+ const possibleFields = [
1882
+ "name", "description", "provider_id", "model_id", "status",
1883
+ "enabled", "tone"
1884
+ ];
1885
+
1886
+ for (const field of possibleFields) {
1887
+ const bodyKey = field.replace(/_([a-z])/g, (g) => g[1].toUpperCase()); // camelCase check
1888
+ let val = body[field] !== undefined ? body[field] : body[bodyKey];
1889
+
1890
+ if (val !== undefined) {
1891
+ updates.push(`${field} = ?`);
1892
+ params.push(typeof val === 'object' ? JSON.stringify(val) : val);
1893
+ }
1894
+ }
1895
+
1896
+ // Handle headers separately for encryption
1897
+ const agentHeaders = body.headers !== undefined ? body.headers : body.config?.headers;
1898
+ log.info(`[API] PATCH agent headers: ${JSON.stringify(agentHeaders)} `);
1899
+ if (agentHeaders !== undefined) {
1900
+ const { encrypted, iv } = encryptConfig(agentHeaders);
1901
+ updates.push("headers_encrypted = ?");
1902
+ params.push(encrypted);
1903
+ updates.push("headers_iv = ?");
1904
+ params.push(iv);
1905
+ }
1906
+
1907
+ // Handle userPreferences separately (stored in users table)
1908
+ const userPreferences = body.userPreferences !== undefined ? body.userPreferences : body.user_preferences;
1909
+ if (userPreferences !== undefined) {
1910
+ // Get the user_id for this agent
1911
+ const agentRow = getDb().query("SELECT user_id FROM agents WHERE id = ?").get(agentId) as any;
1912
+ if (agentRow && agentRow.user_id) {
1913
+ getDb().query(`UPDATE users SET notes = ? WHERE id = ? `).run(userPreferences, agentRow.user_id);
1914
+ }
1915
+ }
1916
+
1917
+ if (updates.length > 0) {
1918
+ params.push(agentId);
1919
+ getDb().query(`UPDATE agents SET ${updates.join(", ")} WHERE id = ? `).run(...params);
1920
+
1921
+ emitCanvas("canvas:node_update", {
1922
+ id: agentId,
1923
+ updates: body
1924
+ });
1925
+ }
1926
+
1927
+ return addCorsHeaders(Response.json({ ok: true }), req);
1928
+ }
1929
+
1930
+ // ── Providers API ───────────────────────────────────────────────────
1931
+ if (url.pathname === "/api/providers" && req.method === "GET") {
1932
+ const rawProviders = getDb().query(`
1933
+ SELECT id, name, base_url, enabled, active, num_ctx,
1934
+ api_key_encrypted, api_key_iv,
1935
+ CASE WHEN api_key_encrypted IS NOT NULL THEN 1 ELSE 0 END as has_api_key,
1936
+ CASE WHEN headers_encrypted IS NOT NULL THEN 1 ELSE 0 END as has_headers
1937
+ FROM providers
1938
+ `).all() as any[];
1939
+
1940
+ const modelsRows = getDb().query(`
1941
+ SELECT id, name, provider_id, enabled, active FROM models
1942
+ `).all() as any[];
1943
+
1944
+ const modelsByProvider: Record<string, any[]> = {};
1945
+ for (const m of modelsRows) {
1946
+ if (!modelsByProvider[m.provider_id]) modelsByProvider[m.provider_id] = [];
1947
+ modelsByProvider[m.provider_id].push({ id: m.id, name: m.name, provider_id: m.provider_id, enabled: !!m.enabled, active: !!m.active });
1948
+ }
1949
+
1950
+ const providers = rawProviders.map((p) => {
1951
+ let masked_api_key: string | null = null;
1952
+ if (p.api_key_encrypted && p.api_key_iv) {
1953
+ try {
1954
+ const plain = decryptApiKey(p.api_key_encrypted, p.api_key_iv);
1955
+ masked_api_key = maskApiKey(plain);
1956
+ } catch { /* silently ignore */ }
1957
+ }
1958
+ return {
1959
+ id: p.id,
1960
+ name: p.name,
1961
+ base_url: p.base_url,
1962
+ enabled: p.enabled,
1963
+ active: p.active,
1964
+ num_ctx: p.num_ctx ?? null,
1965
+ has_api_key: p.has_api_key,
1966
+ has_headers: p.has_headers,
1967
+ masked_api_key,
1968
+ models: modelsByProvider[p.id] || [],
1969
+ };
1970
+ });
1971
+
1972
+ return addCorsHeaders(Response.json({ providers }), req);
1973
+ }
1974
+
1975
+ if (url.pathname === "/api/providers" && req.method === "POST") {
1976
+ const body = await req.json().catch(() => ({}));
1977
+ getDb().query(`
1978
+ INSERT OR REPLACE INTO providers(id, name, base_url, enabled, active)
1979
+ VALUES(?, ?, ?, ?, 1)
1980
+ `).run(body.id, body.name, body.base_url || null, body.enabled !== undefined ? body.enabled : 1);
1981
+ return addCorsHeaders(Response.json({ ok: true }), req);
1982
+ }
1983
+
1984
+ if (url.pathname.match(/^\/api\/providers\/[^/]+\/toggle$/)) {
1985
+ const providerId = url.pathname.split("/")[3];
1986
+ if (req.method === "POST") {
1987
+ const body = await req.json().catch(() => ({}));
1988
+ const { active } = body;
1989
+ if (active === undefined) {
1990
+ return addCorsHeaders(new Response("Missing active field", { status: 400 }), req);
1991
+ }
1992
+ getDb().query(`UPDATE providers SET active = ?, enabled = ? WHERE id = ? `).run(active ? 1 : 0, active ? 1 : 0, providerId);
1993
+ return addCorsHeaders(Response.json({ success: true, active }), req);
1994
+ }
1995
+ }
1996
+
1997
+ const providerIdMatch = url.pathname.match(/^\/api\/providers\/([^/]+)$/);
1998
+ if (providerIdMatch && (req.method === "PUT" || req.method === "PATCH")) {
1999
+ const id = providerIdMatch[1];
2000
+ const body = await req.json().catch(() => ({}));
2001
+ const updates: string[] = [];
2002
+ const params: any[] = [];
2003
+
2004
+ if (body.name) {
2005
+ updates.push("name = ?");
2006
+ params.push(body.name);
2007
+ }
2008
+ const baseUrl = body.base_url !== undefined ? body.base_url : body.baseUrl;
2009
+ if (baseUrl !== undefined) {
2010
+ updates.push("base_url = ?");
2011
+ params.push(baseUrl || null);
2012
+ }
2013
+ if (body.enabled !== undefined) {
2014
+ updates.push("enabled = ?");
2015
+ params.push(body.enabled ? 1 : 0);
2016
+ }
2017
+ if (body.active !== undefined) {
2018
+ updates.push("active = ?");
2019
+ params.push(body.active ? 1 : 0);
2020
+ }
2021
+ if (body.config?.apiKey || body.apiKey) {
2022
+ const apiKey = body.config?.apiKey || body.apiKey;
2023
+ const { encrypted, iv } = encryptApiKey(apiKey);
2024
+ updates.push("api_key_encrypted = ?");
2025
+ params.push(encrypted);
2026
+ updates.push("api_key_iv = ?");
2027
+ params.push(iv);
2028
+ }
2029
+
2030
+ if (body.headers) {
2031
+ const { encrypted, iv } = encryptConfig(body.headers);
2032
+ updates.push("headers_encrypted = ?");
2033
+ params.push(encrypted);
2034
+ updates.push("headers_iv = ?");
2035
+ params.push(iv);
2036
+ }
2037
+
2038
+ const numCtx = body.num_ctx !== undefined ? body.num_ctx : body.numCtx;
2039
+ if (numCtx !== undefined) {
2040
+ updates.push("num_ctx = ?");
2041
+ params.push(numCtx ? Number(numCtx) : null);
2042
+ }
2043
+
2044
+ if (updates.length > 0) {
2045
+ params.push(id);
2046
+ getDb().query(`UPDATE providers SET ${updates.join(", ")} WHERE id = ? `).run(...params);
2047
+ }
2048
+
2049
+ const updated = getDb().query<any, [string]>(`
2050
+ SELECT id, name, base_url, enabled, active, num_ctx,
2051
+ api_key_encrypted, api_key_iv,
2052
+ CASE WHEN api_key_encrypted IS NOT NULL THEN 1 ELSE 0 END as has_api_key,
2053
+ CASE WHEN headers_encrypted IS NOT NULL THEN 1 ELSE 0 END as has_headers
2054
+ FROM providers WHERE id = ?
2055
+ `).get(id);
2056
+
2057
+ let masked_api_key: string | null = null;
2058
+ if (updated?.api_key_encrypted && updated?.api_key_iv) {
2059
+ try {
2060
+ masked_api_key = maskApiKey(decryptApiKey(updated.api_key_encrypted, updated.api_key_iv));
2061
+ } catch { /* ignore */ }
2062
+ }
2063
+
2064
+ const models = getDb().query<any, [string]>(`
2065
+ SELECT id, name, provider_id, enabled, active FROM models WHERE provider_id = ?
2066
+ `).all(id);
2067
+
2068
+ return addCorsHeaders(Response.json({
2069
+ success: true,
2070
+ provider: {
2071
+ id: updated.id,
2072
+ name: updated.name,
2073
+ base_url: updated.base_url,
2074
+ enabled: !!updated.enabled,
2075
+ active: !!updated.active,
2076
+ num_ctx: updated.num_ctx ?? null,
2077
+ has_api_key: !!updated.has_api_key,
2078
+ has_headers: !!updated.has_headers,
2079
+ masked_api_key,
2080
+ models,
2081
+ }
2082
+ }), req);
2083
+ }
2084
+
2085
+ // ── Models API ───────────────────────────────────────────────────
2086
+ // GET /api/models?provider_id=xxx - Get models filtered by provider
2087
+ if (url.pathname === "/api/models" && req.method === "GET") {
2088
+ const searchParams = new URL(req.url).searchParams;
2089
+ const providerId = searchParams.get("provider_id");
2090
+ let models;
2091
+ if (providerId) {
2092
+ models = getDb().query(`
2093
+ SELECT id, name, provider_id, context_window, capabilities, enabled, active
2094
+ FROM models
2095
+ WHERE provider_id = ? AND(enabled = 1 OR active = 1)
2096
+ `).all(providerId);
2097
+ } else {
2098
+ models = getDb().query(`
2099
+ SELECT id, name, provider_id, context_window, capabilities, enabled, active
2100
+ FROM models
2101
+ `).all();
2102
+ }
2103
+ return addCorsHeaders(Response.json({ models }), req);
2104
+ }
2105
+
2106
+ // POST /api/providers/:id/sync-models — sincroniza modelos desde la API local del provider
2107
+ const syncModelsMatch = url.pathname.match(/^\/api\/providers\/([^/]+)\/sync-models$/)
2108
+ if (syncModelsMatch && req.method === "POST") {
2109
+ const providerId = syncModelsMatch[1]
2110
+ const providerRow = getDb().query<any, [string]>(
2111
+ "SELECT * FROM providers WHERE id = ?"
2112
+ ).get(providerId)
2113
+
2114
+ if (!providerRow) {
2115
+ return addCorsHeaders(new Response("Provider not found", { status: 404 }), req)
2116
+ }
2117
+
2118
+ const baseUrl = (providerRow.base_url || "http://localhost:11434").replace(/\/(v1|api)\/?$/, "")
2119
+
2120
+ try {
2121
+ const res = await fetch(`${baseUrl}/api/tags`)
2122
+ if (!res.ok) {
2123
+ return addCorsHeaders(Response.json({ error: `Ollama responded ${res.status}` }, { status: 502 }), req)
2124
+ }
2125
+ const data = await res.json() as { models: Array<{ name: string; size: number; details?: { parameter_size?: string } }> }
2126
+ const ollamaModels = data.models || []
2127
+
2128
+ // Upsert each model — id = "providerId/modelName", name = modelName (what Ollama accepts)
2129
+ const upsert = getDb().query(`
2130
+ INSERT INTO models (id, provider_id, name, model_type, enabled, active)
2131
+ VALUES (?, ?, ?, 'llm', 1, 1)
2132
+ ON CONFLICT(id) DO UPDATE SET name = excluded.name, enabled = 1
2133
+ `)
2134
+ for (const m of ollamaModels) {
2135
+ upsert.run(`${providerId}/${m.name}`, providerId, m.name)
2136
+ }
2137
+
2138
+ // Desactiva modelos que ya no estƔn instalados
2139
+ const installedIds = ollamaModels.map(m => `${providerId}/${m.name}`)
2140
+ const existingModels = getDb().query<any, [string]>(
2141
+ "SELECT id FROM models WHERE provider_id = ?"
2142
+ ).all(providerId) as { id: string }[]
2143
+
2144
+ const disable = getDb().query("UPDATE models SET active = 0, enabled = 0 WHERE id = ?")
2145
+ for (const row of existingModels) {
2146
+ if (!installedIds.includes(row.id)) {
2147
+ disable.run(row.id)
2148
+ }
2149
+ }
2150
+
2151
+ const models = getDb().query<any, [string]>(
2152
+ "SELECT id, name, provider_id, enabled, active FROM models WHERE provider_id = ?"
2153
+ ).all(providerId)
2154
+
2155
+ return addCorsHeaders(Response.json({ success: true, synced: ollamaModels.length, models }), req)
2156
+ } catch (err: any) {
2157
+ return addCorsHeaders(Response.json({ error: `No se pudo conectar a Ollama: ${err.message}` }, { status: 502 }), req)
2158
+ }
2159
+ }
2160
+
2161
+ // POST /api/models - Create a new model
2162
+ if (url.pathname === "/api/models" && req.method === "POST") {
2163
+ const body = await req.json().catch(() => ({}));
2164
+ const { provider_id, name, model_type, context_window, capabilities } = body;
2165
+ if (!provider_id || !name) {
2166
+ return addCorsHeaders(new Response("Missing provider_id or name", { status: 400 }), req);
2167
+ }
2168
+ const id = `${provider_id}/${name}`;
2169
+ getDb().query(`
2170
+ INSERT OR IGNORE INTO models (id, provider_id, name, model_type, context_window, capabilities, enabled, active)
2171
+ VALUES (?, ?, ?, ?, ?, ?, 1, 1)
2172
+ `).run(id, provider_id, name, model_type || "llm", context_window || null, capabilities || null);
2173
+ return addCorsHeaders(Response.json({ success: true, id }), req);
2174
+ }
2175
+
2176
+ if (url.pathname.match(/^\/api\/models\/[^/]+\/toggle$/)) {
2177
+ const modelId = url.pathname.split("/")[3];
2178
+ if (req.method === "POST") {
2179
+ const body = await req.json().catch(() => ({}));
2180
+ const { active } = body;
2181
+ if (active === undefined) {
2182
+ return addCorsHeaders(new Response("Missing active field", { status: 400 }), req);
2183
+ }
2184
+ getDb().query(`UPDATE models SET active = ?, enabled = ? WHERE id = ? `).run(active ? 1 : 0, active ? 1 : 0, modelId);
2185
+ return addCorsHeaders(Response.json({ success: true, active }), req);
2186
+ }
2187
+ }
2188
+
2189
+ // ── Channels API ───────────────────────────────────────────────────
2190
+ if (url.pathname === "/api/channels" && req.method === "GET") {
2191
+ const channels = getDb().query("SELECT id, type, id as account_id, enabled, active, status FROM channels").all();
2192
+ return addCorsHeaders(Response.json({ channels }), req);
2193
+ }
2194
+
2195
+ // ── MCP Servers API ──────────────────────────────────────────────────
2196
+ if (url.pathname === "/api/mcp" && req.method === "GET") {
2197
+ const servers = getDb().query("SELECT id, name, transport, status, enabled, active, builtin FROM mcp_servers").all();
2198
+ return addCorsHeaders(Response.json({ servers }), req);
2199
+ }
2200
+
2201
+ // ── Tools API ─────────────────────────────────────────────────────
2202
+ if (url.pathname === "/api/tools" && req.method === "GET") {
2203
+ const tools = getDb().query("SELECT id, name, description, category, enabled, active FROM tools").all();
2204
+ return addCorsHeaders(Response.json({ tools }), req);
2205
+ }
2206
+
2207
+ if (url.pathname.match(/^\/api\/tools\/[^/]+\/toggle$/)) {
2208
+ const toolId = url.pathname.split("/")[3];
2209
+ if (req.method === "POST") {
2210
+ const body = await req.json().catch(() => ({}));
2211
+ const { active } = body;
2212
+ if (active === undefined) {
2213
+ return addCorsHeaders(new Response("Missing active field", { status: 400 }), req);
2214
+ }
2215
+ getDb().query(`UPDATE tools SET active = ?, enabled = ? WHERE id = ? `).run(active ? 1 : 0, active ? 1 : 0, toolId);
2216
+ return addCorsHeaders(Response.json({ success: true, active }), req);
2217
+ }
2218
+ }
2219
+
2220
+ if (url.pathname.match(/^\/api\/tools\/[^/]+$/)) {
2221
+ const toolId = url.pathname.split("/")[3];
2222
+ if (req.method === "PUT") {
2223
+ const body = await req.json().catch(() => ({}));
2224
+ const { name, description } = body;
2225
+ const updates: string[] = [];
2226
+ const params: any[] = [];
2227
+
2228
+ if (name !== undefined) {
2229
+ updates.push("name = ?");
2230
+ params.push(name);
2231
+ }
2232
+ if (description !== undefined) {
2233
+ updates.push("description = ?");
2234
+ params.push(description);
2235
+ }
2236
+
2237
+ if (updates.length > 0) {
2238
+ params.push(toolId);
2239
+ getDb().query(`UPDATE tools SET ${updates.join(", ")} WHERE id = ?`).run(...params);
2240
+ }
2241
+ return addCorsHeaders(Response.json({ success: true }), req);
2242
+ }
2243
+ }
2244
+
2245
+ // ── Skills API ─────────────────────────────────────────────────────
2246
+ if (url.pathname === "/api/skills" && req.method === "GET") {
2247
+ const skills = getDb().query("SELECT id, name, description, source, enabled, active FROM skills").all();
2248
+ return addCorsHeaders(Response.json({ skills }), req);
2249
+ }
2250
+
2251
+ if (url.pathname === "/api/skills" && req.method === "POST") {
2252
+ const body = await req.json().catch(() => ({}));
2253
+ const { name, description, source, enabled } = body;
2254
+ if (!name) return addCorsHeaders(new Response("Missing name", { status: 400 }), req);
2255
+
2256
+ const id = randomUUID();
2257
+ getDb().query(`
2258
+ INSERT INTO skills(id, name, description, source, enabled, active)
2259
+ VALUES(?, ?, ?, ?, ?, 1)
2260
+ `).run(id, name, description || "", source || "user", enabled !== undefined ? (enabled ? 1 : 0) : 1);
2261
+
2262
+ return addCorsHeaders(Response.json({ success: true, id }), req);
2263
+ }
2264
+
2265
+ if (url.pathname.match(/^\/api\/skills\/[^/]+\/toggle$/)) {
2266
+ const skillId = url.pathname.split("/")[3];
2267
+ if (req.method === "POST") {
2268
+ const body = await req.json().catch(() => ({}));
2269
+ const { active } = body;
2270
+ if (active === undefined) {
2271
+ return addCorsHeaders(new Response("Missing active field", { status: 400 }), req);
2272
+ }
2273
+ getDb().query(`UPDATE skills SET active = ?, enabled = ? WHERE id = ? `).run(active ? 1 : 0, active ? 1 : 0, skillId);
2274
+ await loadContextToStore(getDb());
2275
+ return addCorsHeaders(Response.json({ success: true, active }), req);
2276
+ }
2277
+ }
2278
+
2279
+ if (url.pathname.match(/^\/api\/skills\/[^/]+$/)) {
2280
+ const skillId = url.pathname.split("/")[3];
2281
+ if (req.method === "PUT") {
2282
+ const body = await req.json().catch(() => ({}));
2283
+ const { name, description } = body;
2284
+ const updates: string[] = [];
2285
+ const params: any[] = [];
2286
+
2287
+ if (name !== undefined) {
2288
+ updates.push("name = ?");
2289
+ params.push(name);
2290
+ }
2291
+ if (description !== undefined) {
2292
+ updates.push("description = ?");
2293
+ params.push(description);
2294
+ }
2295
+
2296
+ if (updates.length > 0) {
2297
+ params.push(skillId);
2298
+ getDb().query(`UPDATE skills SET ${updates.join(", ")} WHERE id = ?`).run(...params);
2299
+ await loadContextToStore(getDb());
2300
+ }
2301
+ return addCorsHeaders(Response.json({ success: true }), req);
2302
+ }
2303
+
2304
+ if (req.method === "DELETE") {
2305
+ getDb().query(`DELETE FROM skills WHERE id = ? AND source != 'bundled'`).run(skillId);
2306
+ await loadContextToStore(getDb());
2307
+ return addCorsHeaders(Response.json({ success: true }), req);
2308
+ }
2309
+ }
2310
+
2311
+ // ── Ethics API ──────────────────────────────────────────────────────
2312
+ if (url.pathname === "/api/ethics" && req.method === "GET") {
2313
+ const ethics = getDb().query("SELECT * FROM ethics").all();
2314
+ return addCorsHeaders(Response.json({ ethics }), req);
2315
+ }
2316
+
2317
+ if (url.pathname === "/api/ethics" && req.method === "POST") {
2318
+ const body = await req.json().catch(() => ({}));
2319
+ const { name, description, content, is_default } = body;
2320
+ if (!name || !content) return addCorsHeaders(Response.json({ success: false, error: "Missing name or content", message: "Faltan campos requeridos: nombre y contenido" }, { status: 400 }), req);
2321
+
2322
+ const id = randomUUID();
2323
+ getDb().query(`
2324
+ INSERT INTO ethics(id, name, description, content, is_default, enabled, active)
2325
+ VALUES(?, ?, ?, ?, ?, 1, 1)
2326
+ `).run(id, name, description || "", content, is_default ? 1 : 0);
2327
+
2328
+ return addCorsHeaders(Response.json({ success: true, id, message: `Registro de Ʃtica "${name}" creado correctamente` }), req);
2329
+ }
2330
+
2331
+ const ethicsIdMatch = url.pathname.match(/^\/api\/ethics\/([^/]+)$/);
2332
+ if (ethicsIdMatch) {
2333
+ const id = ethicsIdMatch[1];
2334
+ if (req.method === "PUT") {
2335
+ const body = await req.json().catch(() => ({}));
2336
+ const updates: string[] = [];
2337
+ const params: any[] = [];
2338
+ for (const [key, val] of Object.entries(body)) {
2339
+ if (["name", "description", "content", "is_default", "enabled", "active"].includes(key)) {
2340
+ updates.push(`${key} = ?`);
2341
+ params.push(val);
2342
+ }
2343
+ }
2344
+ if (updates.length > 0) {
2345
+ params.push(id);
2346
+ getDb().query(`UPDATE ethics SET ${updates.join(", ")} WHERE id = ? `).run(...params);
2347
+ }
2348
+ return addCorsHeaders(Response.json({ success: true, message: "Registro de Ʃtica actualizado correctamente" }), req);
2349
+ }
2350
+ if (req.method === "DELETE") {
2351
+ getDb().query("DELETE FROM ethics WHERE id = ?").run(id);
2352
+ return addCorsHeaders(Response.json({ success: true, message: "Registro de Ʃtica eliminado" }), req);
2353
+ }
2354
+ }
2355
+
2356
+ // ── Users API ───────────────────────────────────────────────────────
2357
+ if (url.pathname === "/api/users" && req.method === "GET") {
2358
+ const users = getDb().query("SELECT * FROM users").all();
2359
+ return addCorsHeaders(Response.json({ users }), req);
2360
+ }
2361
+
2362
+ if (url.pathname === "/api/users" && req.method === "POST") {
2363
+ const body = await req.json().catch(() => ({}));
2364
+ const { id, name, language, timezone, occupation, notes, preferred_cron_channel } = body;
2365
+
2366
+ let userId: string;
2367
+ let isUpdate: boolean;
2368
+
2369
+ if (id) {
2370
+ userId = id;
2371
+ isUpdate = true;
2372
+ getDb().query(`
2373
+ INSERT INTO users(id, name, language, timezone, occupation, notes, preferred_cron_channel)
2374
+ VALUES(?, ?, ?, ?, ?, ?, ?)
2375
+ ON CONFLICT(id) DO UPDATE SET
2376
+ name = excluded.name,
2377
+ language = excluded.language,
2378
+ timezone = excluded.timezone,
2379
+ occupation = excluded.occupation,
2380
+ notes = excluded.notes,
2381
+ preferred_cron_channel = excluded.preferred_cron_channel
2382
+ `).run(id, name, language, timezone, occupation, notes, preferred_cron_channel ?? "auto");
2383
+ } else {
2384
+ isUpdate = false;
2385
+ const result = getDb().query(`
2386
+ INSERT INTO users(name, language, timezone, occupation, notes, preferred_cron_channel)
2387
+ VALUES(?, ?, ?, ?, ?, ?)
2388
+ RETURNING id
2389
+ `).get(name, language, timezone, occupation, notes, preferred_cron_channel ?? "auto") as { id: string } | undefined;
2390
+ userId = result?.id || "";
2391
+ }
2392
+
2393
+ return addCorsHeaders(Response.json({
2394
+ success: true,
2395
+ id: userId,
2396
+ message: isUpdate ? "Perfil de usuario actualizado correctamente" : "Usuario creado correctamente",
2397
+ }), req);
2398
+ }
2399
+
2400
+ // ── User settings (partial update) ──────────────────────────────────
2401
+ if (url.pathname === "/api/user/settings" && req.method === "PATCH") {
2402
+ const body = await req.json().catch(() => ({}));
2403
+ const { preferred_cron_channel } = body;
2404
+
2405
+ const user = getDb().query("SELECT id FROM users LIMIT 1").get() as { id: string } | undefined;
2406
+ if (!user) return addCorsHeaders(Response.json({ success: false, error: "No user found", message: "No se encontró ningún usuario" }, { status: 404 }), req);
2407
+
2408
+ const sets: string[] = [];
2409
+ const vals: any[] = [];
2410
+
2411
+ if (preferred_cron_channel !== undefined) {
2412
+ sets.push("preferred_cron_channel = ?");
2413
+ vals.push(preferred_cron_channel);
2414
+ }
2415
+
2416
+ if (sets.length > 0) {
2417
+ vals.push(user.id);
2418
+ getDb().query(`UPDATE users SET ${sets.join(", ")} WHERE id = ?`).run(...vals);
2419
+ }
2420
+
2421
+ return addCorsHeaders(Response.json({ success: true, message: "Configuración de usuario guardada" }), req);
2422
+ }
2423
+
2424
+ // ── MCP Servers API ──────────────────────────────────────────────────
2425
+ if (url.pathname === "/api/mcp" && req.method === "GET") {
2426
+ const servers = getDb().query("SELECT id, name, transport, status, enabled, active, builtin, command, args, url FROM mcp_servers").all();
2427
+ return addCorsHeaders(Response.json({ servers }), req);
2428
+ }
2429
+
2430
+ if (url.pathname.match(/^\/api\/mcp\/[^/]+\/toggle$/)) {
2431
+ const mcpId = url.pathname.split("/")[3];
2432
+ if (req.method === "POST") {
2433
+ const body = await req.json().catch(() => ({}));
2434
+ const { active } = body;
2435
+ if (active === undefined) {
2436
+ return addCorsHeaders(Response.json({ success: false, error: "Missing active field", message: "Falta el campo 'active'" }, { status: 400 }), req);
2437
+ }
2438
+ getDb().query(`UPDATE mcp_servers SET active = ?, enabled = ? WHERE id = ? `).run(active ? 1 : 0, active ? 1 : 0, mcpId);
2439
+ return addCorsHeaders(Response.json({ success: true, active, message: active ? "Servidor MCP activado" : "Servidor MCP desactivado" }), req);
2440
+ }
2441
+ }
2442
+
2443
+ // ── Channels API ───────────────────────────────────────────────────
2444
+ if (url.pathname === "/api/channels" && req.method === "GET") {
2445
+ const channels = getDb().query("SELECT id, type, id as account_id, enabled, active, status FROM channels").all();
2446
+ return addCorsHeaders(Response.json({ channels }), req);
2447
+ }
2448
+
2449
+ // PUT /api/channels/:id - Update channel settings
2450
+ const channelIdMatch = url.pathname.match(/^\/api\/channels\/([^/]+)$/);
2451
+ if (channelIdMatch && req.method === "PUT") {
2452
+ const channelId = channelIdMatch[1];
2453
+ const body = await req.json().catch(() => ({})) as Record<string, unknown>;
2454
+ const allowed = ["voice_enabled", "tts_enabled", "stt_provider", "tts_provider", "tts_voice_id", "step_delivery_mode"] as const;
2455
+ const updates: string[] = [];
2456
+ const params: unknown[] = [];
2457
+ for (const key of allowed) {
2458
+ if (key in body) {
2459
+ updates.push(`${key} = ?`);
2460
+ params.push(typeof body[key] === "boolean" ? (body[key] ? 1 : 0) : body[key]);
2461
+ }
2462
+ }
2463
+ if (updates.length === 0) {
2464
+ return addCorsHeaders(Response.json({ error: "No valid fields to update" }, { status: 400 }), req);
2465
+ }
2466
+ params.push(channelId);
2467
+ (getDb().query(`UPDATE channels SET ${updates.join(", ")} WHERE id = ?`) as any).run(...params);
2468
+ return addCorsHeaders(Response.json({ success: true }), req);
2469
+ }
2470
+
2471
+ if (url.pathname.match(/^\/api\/channels\/[^/]+\/toggle$/)) {
2472
+ const channelId = url.pathname.split("/")[3];
2473
+ if (req.method === "POST") {
2474
+ const body = await req.json().catch(() => ({}));
2475
+ const { active } = body;
2476
+ if (active === undefined) {
2477
+ return addCorsHeaders(Response.json({ success: false, error: "Missing active field", message: "Falta el campo 'active'" }, { status: 400 }), req);
2478
+ }
2479
+ getDb().query(`UPDATE channels SET active = ?, enabled = ? WHERE id = ? `).run(active ? 1 : 0, active ? 1 : 0, channelId);
2480
+ return addCorsHeaders(Response.json({ success: true, active, message: active ? `Canal "${channelId}" activado` : `Canal "${channelId}" desactivado` }), req);
2481
+ }
2482
+ }
2483
+
2484
+ // ── Voice API ───────────────────────────────────────────────────────
2485
+
2486
+ // GET /api/voice/providers - List STT and TTS providers
2487
+ if (url.pathname === "/api/voice/providers" && req.method === "GET") {
2488
+ const providers = getDb().query(`
2489
+ SELECT m.id, m.name, m.model_type, p.name as provider_name, p.id as provider_id,
2490
+ CASE WHEN p.api_key_encrypted IS NOT NULL THEN 1 ELSE 0 END as has_api_key
2491
+ FROM models m
2492
+ JOIN providers p ON m.provider_id = p.id
2493
+ WHERE m.model_type IN('stt', 'tts')
2494
+ `).all();
2495
+ return addCorsHeaders(Response.json({ providers }), req);
2496
+ }
2497
+
2498
+ // GET /api/voice/elevenlabs/voices - List ElevenLabs voices
2499
+ if (url.pathname === "/api/voice/elevenlabs/voices" && req.method === "GET") {
2500
+ try {
2501
+ const voices = await voiceService.getElevenLabsVoices();
2502
+ return addCorsHeaders(Response.json({ voices }), req);
2503
+ } catch (error) {
2504
+ return addCorsHeaders(Response.json({ error: (error as Error).message }), req);
2505
+ }
2506
+ }
2507
+
2508
+ // GET /api/voice/configured-providers - Which voice providers have API keys
2509
+ if (url.pathname === "/api/voice/configured-providers" && req.method === "GET") {
2510
+ const providers = voiceService.getConfiguredVoiceProviders();
2511
+ return addCorsHeaders(Response.json(providers), req);
2512
+ }
2513
+
2514
+ // GET /api/voice/openai/voices - List OpenAI TTS voices
2515
+ if (url.pathname === "/api/voice/openai/voices" && req.method === "GET") {
2516
+ const voices = voiceService.getOpenAIVoices();
2517
+ return addCorsHeaders(Response.json({ voices }), req);
2518
+ }
2519
+
2520
+ // GET /api/voice/gemini/voices - List Gemini TTS voices
2521
+ if (url.pathname === "/api/voice/gemini/voices" && req.method === "GET") {
2522
+ const voices = voiceService.getGeminiVoices();
2523
+ return addCorsHeaders(Response.json({ voices }), req);
2524
+ }
2525
+
2526
+ // GET /api/voice/qwen/voices - List Qwen TTS voices
2527
+ if (url.pathname === "/api/voice/qwen/voices" && req.method === "GET") {
2528
+ const voices = voiceService.getQwenVoices();
2529
+ return addCorsHeaders(Response.json({ voices }), req);
2530
+ }
2531
+
2532
+ // GET /api/channels/:id/voice - Get voice config for a channel
2533
+ const channelVoiceMatch = url.pathname.match(/^\/api\/channels\/([^/]+)\/voice$/);
2534
+ if (channelVoiceMatch && req.method === "GET") {
2535
+ const channelId = channelVoiceMatch[1];
2536
+ const voiceConfig = voiceService.getChannelVoiceConfig(channelId);
2537
+ return addCorsHeaders(Response.json(voiceConfig), req);
2538
+ }
2539
+
2540
+ // PATCH /api/channels/:id/voice - Update voice config for a channel
2541
+ if (channelVoiceMatch && req.method === "PATCH") {
2542
+ const channelId = channelVoiceMatch[1];
2543
+ const body = await req.json().catch(() => ({}));
2544
+
2545
+ const updates: string[] = [];
2546
+ const params: any[] = [];
2547
+
2548
+ if (body.voiceEnabled !== undefined) {
2549
+ updates.push("voice_enabled = ?");
2550
+ params.push(body.voiceEnabled ? 1 : 0);
2551
+ }
2552
+ if (body.ttsEnabled !== undefined) {
2553
+ updates.push("tts_enabled = ?");
2554
+ params.push(body.ttsEnabled ? 1 : 0);
2555
+ }
2556
+ if (body.sttProvider !== undefined) {
2557
+ updates.push("stt_provider = ?");
2558
+ params.push(body.sttProvider);
2559
+ }
2560
+ if (body.ttsProvider !== undefined) {
2561
+ updates.push("tts_provider = ?");
2562
+ params.push(body.ttsProvider);
2563
+ }
2564
+ if (body.ttsVoiceId !== undefined) {
2565
+ updates.push("tts_voice_id = ?");
2566
+ params.push(body.ttsVoiceId);
2567
+ }
2568
+
2569
+ if (updates.length > 0) {
2570
+ params.push(channelId);
2571
+ getDb().query(`UPDATE channels SET ${updates.join(", ")} WHERE id = ? `).run(...params);
2572
+ }
2573
+
2574
+ return addCorsHeaders(Response.json({ success: true }), req);
2575
+ }
2576
+
2577
+ // POST /api/voice/test - Test TTS with a channel's config
2578
+ if (url.pathname === "/api/voice/test" && req.method === "POST") {
2579
+ const body = await req.json().catch(() => ({}));
2580
+ const { channelId, text } = body;
2581
+
2582
+ if (!channelId || !text) {
2583
+ return addCorsHeaders(new Response("Missing channelId or text", { status: 400 }), req);
2584
+ }
2585
+
2586
+ try {
2587
+ const voiceConfig = voiceService.getChannelVoiceConfig(channelId);
2588
+
2589
+ if (!voiceConfig.ttsEnabled || !voiceConfig.ttsProvider) {
2590
+ return addCorsHeaders(Response.json({ error: "TTS not enabled for this channel" }), req);
2591
+ }
2592
+
2593
+ const audioOutput = await voiceService.speak(text, voiceConfig.ttsProvider, voiceConfig.ttsVoiceId || undefined);
2594
+
2595
+ let base64Audio: string;
2596
+ if (audioOutput.type === "buffer") {
2597
+ base64Audio = (audioOutput.data as Buffer).toString("base64");
2598
+ } else {
2599
+ base64Audio = audioOutput.data as string;
2600
+ }
2601
+
2602
+ return addCorsHeaders(Response.json({
2603
+ audio: base64Audio,
2604
+ mimeType: audioOutput.mimeType,
2605
+ }), req);
2606
+ } catch (error) {
2607
+ return addCorsHeaders(Response.json({ error: (error as Error).message }), req);
2608
+ }
2609
+ }
2610
+
2611
+ // ── Chat API ────────────────────────────────────────────────────────
2612
+ if (url.pathname === "/api/chat/history" && req.method === "GET") {
2613
+ const sessionId = url.searchParams.get("sessionId");
2614
+ if (!sessionId) {
2615
+ return addCorsHeaders(new Response("Missing sessionId", { status: 400 }), req);
2616
+ }
2617
+
2618
+ try {
2619
+ const messages = dbService.getMessages(sessionId);
2620
+ return addCorsHeaders(Response.json({ messages }), req);
2621
+ } catch (error) {
2622
+ log.error(`[API] Failed to get chat history: ${(error as Error).message}`);
2623
+ return addCorsHeaders(Response.json({ error: "Failed to get chat history" }, { status: 500 }), req);
2624
+ }
2625
+ }
2626
+ // ── Canvas API ─────────────────────────────────────────────────────
2627
+ if (url.pathname === "/api/canvas" && req.method === "GET") {
2628
+ return addCorsHeaders(Response.json(getCanvasSnapshot()), req);
2629
+ }
2630
+
2631
+ // ── Notes API ────────────────────────────────────────────────────────
2632
+ if (url.pathname === "/api/notes" && req.method === "GET") {
2633
+ const notes = getDb().query("SELECT * FROM notes ORDER BY createdAt DESC").all();
2634
+ return addCorsHeaders(Response.json({ notes }), req);
2635
+ }
2636
+
2637
+ if (url.pathname.match(/^\/api\/notes\/[^/]+\/toggle$/)) {
2638
+ const noteId = url.pathname.split("/")[3];
2639
+ if (req.method === "PATCH") {
2640
+ const body = await req.json().catch(() => ({}));
2641
+ const { active } = body;
2642
+ if (active === undefined) {
2643
+ return addCorsHeaders(Response.json({ success: false, error: "Missing active field", message: "Falta el campo 'active'" }, { status: 400 }), req);
2644
+ }
2645
+ getDb().query(`UPDATE notes SET active = ?, updatedAt = unixepoch() WHERE id = ? `).run(active ? 1 : 0, noteId);
2646
+ return addCorsHeaders(Response.json({ success: true, active: !!active, message: active ? "Nota activada" : "Nota desactivada" }), req);
2647
+ }
2648
+ }
2649
+
2650
+ // ── Cron Jobs API ────────────────────────────────────────────────────
2651
+ if (url.pathname === "/api/cron-jobs" && req.method === "GET") {
2652
+ const jobs = getDb().query("SELECT * FROM cron_jobs ORDER BY created_at DESC").all();
2653
+ return addCorsHeaders(Response.json({ jobs }), req);
2654
+ }
2655
+
2656
+ // Returns active channels with recommended flag for cron channel picker
2657
+ if (url.pathname === "/api/cron-jobs/channels" && req.method === "GET") {
2658
+ const db = getDb();
2659
+ const channels = db.query<any, []>(
2660
+ "SELECT id, type, active, enabled FROM channels ORDER BY id"
2661
+ ).all();
2662
+
2663
+ // Priority order for auto-selection
2664
+ const priority: Record<string, number> = { telegram: 0, discord: 1, webchat: 2 };
2665
+ const sorted = [...channels].sort(
2666
+ (a, b) => (priority[a.id] ?? 99) - (priority[b.id] ?? 99)
2667
+ );
2668
+
2669
+ // Recommended = first active channel in priority order
2670
+ const recommended = sorted.find(c => c.active === 1)?.id ?? "webchat";
2671
+
2672
+ const user = db.query("SELECT preferred_cron_channel FROM users LIMIT 1").get() as
2673
+ | { preferred_cron_channel: string }
2674
+ | undefined;
2675
+
2676
+ const result = channels.map((ch: any) => ({
2677
+ id: ch.id,
2678
+ type: ch.type,
2679
+ active: ch.active === 1,
2680
+ recommended: ch.id === recommended,
2681
+ }));
2682
+
2683
+ return addCorsHeaders(
2684
+ Response.json({ channels: result, recommended, userPreference: user?.preferred_cron_channel ?? "auto" }),
2685
+ req
2686
+ );
2687
+ }
2688
+
2689
+ if (url.pathname.match(/^\/api\/cron-jobs\/[^/]+\/toggle$/)) {
2690
+ const jobId = url.pathname.split("/")[3];
2691
+ if (req.method === "PATCH") {
2692
+ const body = await req.json().catch(() => ({}));
2693
+ const { enabled } = body;
2694
+ if (enabled === undefined) {
2695
+ return addCorsHeaders(Response.json({ success: false, error: "Missing enabled field", message: "Falta el campo 'enabled'" }, { status: 400 }), req);
2696
+ }
2697
+ getDb().query(`UPDATE cron_jobs SET enabled = ?, updated_at = unixepoch() WHERE id = ? `).run(enabled ? 1 : 0, jobId);
2698
+ return addCorsHeaders(Response.json({ success: true, enabled: !!enabled, message: enabled ? "Tarea programada activada" : "Tarea programada desactivada" }), req);
2699
+ }
2700
+ }
2701
+
2702
+ return addCorsHeaders(new Response("Not Found", { status: 404 }), req);
2703
+ };
2704
+
2705
+ try {
2706
+ const response = await handleRequest();
2707
+ const duration = Date.now() - start;
2708
+ if (response) {
2709
+ logRequest(response.status, duration);
2710
+ } else {
2711
+ // Bun upgrade returns undefined on success
2712
+ log.info(`${method} ${url.pathname} - 101 Switching Protocols(${duration}ms)`);
2713
+ }
2714
+ return response;
2715
+ } catch (error) {
2716
+ const duration = Date.now() - start;
2717
+ log.error(`${method} ${url.pathname} - Internal Error(${duration}ms): ${(error as Error).message} `);
2718
+ return addCorsHeaders(Response.json({ success: false, error: (error as Error).message, message: "Error interno del servidor" }, { status: 500 }), req);
2719
+ }
2720
+ },
2721
+
2722
+ websocket: {
2723
+ open(ws) {
2724
+ const data = ws.data;
2725
+ const isCanvas = data.sessionId.startsWith("canvas:");
2726
+ const isBridge = data.sessionId.startsWith("bridge:");
2727
+
2728
+ if (isBridge) {
2729
+ log.info(`Bridge events client connected: ${data.sessionId}`);
2730
+ subscribeBridge(ws as any);
2731
+ ws.send(JSON.stringify({ type: "bridge:connected", sessionId: data.sessionId }));
2732
+ return;
2733
+ }
2734
+
2735
+ if (isCanvas) {
2736
+ log.info(`Canvas session connected: ${data.sessionId} `);
2737
+ canvasManager.registerSession(data.sessionId, ws as any);
2738
+ subscribeCanvas(ws as any);
2739
+ ws.send(JSON.stringify({ type: "canvas:connected", sessionId: data.sessionId }));
2740
+ // Send initial snapshot so canvas shows current state
2741
+ ws.send(JSON.stringify({ type: "canvas:snapshot", data: getCanvasSnapshot() }));
2742
+ return;
2743
+ }
2744
+
2745
+ log.debug(`WebSocket connected: ${data.sessionId} `);
2746
+
2747
+ sessionManager.create(data.sessionId, ws);
2748
+
2749
+ const channel = channelManager.getChannel("webchat") as any;
2750
+ if (channel?.registerConnection) channel.registerConnection(ws);
2751
+
2752
+ // Send status message
2753
+ ws.send(JSON.stringify({
2754
+ type: "status",
2755
+ sessionId: data.sessionId,
2756
+ status: { state: "connected", model: `${dbProvider}/${dbModel}` },
2757
+ } as OutboundMessage));
2758
+
2759
+ // Send welcome message with real user data
2760
+ try {
2761
+ const db = getDb();
2762
+ const user = db.query("SELECT id, name, language FROM users LIMIT 1").get() as { id: string; name: string; language: string } | undefined;
2763
+ const agent = db.query("SELECT id, name, provider_id, model_id FROM agents WHERE is_coordinator = 1 LIMIT 1").get() as { id: string; name: string; provider_id: string; model_id: string } | undefined;
2764
+
2765
+ // Get channels
2766
+ const channels = db.query("SELECT id FROM channels WHERE active = 1").all() as Array<{ id: string }>;
2767
+
2768
+ // Get voice config from webchat channel
2769
+ const voiceConfig = db.query("SELECT voice_enabled, stt_provider, tts_provider FROM channels WHERE id = 'webchat'").get() as { voice_enabled: number; stt_provider: string; tts_provider: string } | undefined;
2770
+
2771
+ // Get code bridge
2772
+ const codeBridge = db.query("SELECT id FROM code_bridge WHERE enabled = 1").all() as Array<{ id: string }>;
2773
+
2774
+ ws.send(JSON.stringify({
2775
+ type: "welcome",
2776
+ sessionId: user?.id || data.sessionId,
2777
+ user: user ? { id: user.id, name: user.name, language: user.language } : null,
2778
+ agent: agent ? { id: agent.id, name: agent.name, provider: agent.provider_id, model: agent.model_id } : null,
2779
+ channels: channels.map(c => c.id),
2780
+ voice: voiceConfig ? {
2781
+ enabled: voiceConfig.voice_enabled === 1,
2782
+ sttProvider: voiceConfig.stt_provider,
2783
+ ttsProvider: voiceConfig.tts_provider
2784
+ } : { enabled: false, sttProvider: null, ttsProvider: null },
2785
+ codeBridge: codeBridge.map(cb => cb.id)
2786
+ } as OutboundMessage));
2787
+ } catch (err) {
2788
+ log.error("Error sending welcome message:", err);
2789
+ }
2790
+ },
2791
+
2792
+ async message(ws, message) {
2793
+ const data = ws.data;
2794
+
2795
+ // Bridge events clients are read-only; ignore any messages they send
2796
+ if (data.sessionId.startsWith("bridge:")) return;
2797
+
2798
+ let msg: InboundMessage;
2799
+ try {
2800
+ msg = JSON.parse(message.toString()) as InboundMessage;
2801
+ } catch {
2802
+ ws.send(JSON.stringify({
2803
+ type: "error",
2804
+ sessionId: data.sessionId,
2805
+ error: "Invalid JSON message",
2806
+ } as OutboundMessage));
2807
+ return;
2808
+ }
2809
+
2810
+ msg.sessionId = msg.sessionId ?? data.sessionId;
2811
+ sessionManager.touch(msg.sessionId);
2812
+
2813
+ if (msg.type === "ping") {
2814
+ ws.send(JSON.stringify({ type: "pong", sessionId: msg.sessionId } as OutboundMessage));
2815
+ return;
2816
+ }
2817
+
2818
+ // Canvas subscribe
2819
+ if (msg.type === "canvas_subscribe") {
2820
+ subscribeCanvas(ws);
2821
+ ws.send(JSON.stringify({
2822
+ type: "canvas:snapshot",
2823
+ data: getCanvasSnapshot(),
2824
+ }));
2825
+ return;
2826
+ }
2827
+
2828
+ // Canvas unsubscribe
2829
+ if (msg.type === "canvas_unsubscribe") {
2830
+ unsubscribeCanvas(ws);
2831
+ return;
2832
+ }
2833
+
2834
+ // Canvas session - handle interactions
2835
+ if (data.sessionId.startsWith("canvas:")) {
2836
+ canvasManager.handleMessage(data.sessionId, message);
2837
+ return;
2838
+ }
2839
+
2840
+ if (msg.type === "command" || (msg.content && isSlashCommand(msg.content))) {
2841
+ const result = await executeSlashCommand(msg.sessionId, msg.content ?? `/${msg.command}`, ws);
2842
+ if (result) {
2843
+ ws.send(JSON.stringify(result));
2844
+ return;
2845
+ }
2846
+ }
2847
+
2848
+ // Logs subscription
2849
+ if (msg.type === "logs_subscribe") {
2850
+ logSubscribers.add(data.sessionId);
2851
+ log.debug(`Session ${data.sessionId} subscribed to logs`);
2852
+ return;
2853
+ }
2854
+
2855
+ if (msg.type === "logs_unsubscribe") {
2856
+ logSubscribers.delete(data.sessionId);
2857
+ log.debug(`Session ${data.sessionId} unsubscribed from logs`);
2858
+ return;
2859
+ }
2860
+
2861
+ // Handle audio messages from WebChat
2862
+ let webchatPreferAudio = false;
2863
+ if (msg.type === "audio" && msg.audio) {
2864
+ log.info(`WebChat audio from session ${msg.sessionId}`);
2865
+
2866
+ const voiceConfig = voiceService.getChannelVoiceConfig("webchat");
2867
+
2868
+ if (!voiceConfig.voiceEnabled) {
2869
+ ws.send(JSON.stringify({
2870
+ type: "error",
2871
+ sessionId: msg.sessionId,
2872
+ error: "Voice input not enabled for this channel"
2873
+ } as OutboundMessage));
2874
+ return;
2875
+ }
2876
+
2877
+ if (!voiceConfig.sttProvider) {
2878
+ ws.send(JSON.stringify({
2879
+ type: "message",
2880
+ sessionId: msg.sessionId,
2881
+ content: "šŸŽ™ļø Para usar notas de voz, configura el proveedor STT en Configuración > Canales > WebChat (ej: groq-whisper)"
2882
+ } as OutboundMessage));
2883
+ return;
2884
+ }
2885
+
2886
+ ws.send(JSON.stringify({
2887
+ type: "typing",
2888
+ isTyping: true,
2889
+ sessionId: msg.sessionId,
2890
+ } as OutboundMessage));
2891
+
2892
+ try {
2893
+ const audioInput = { type: "base64" as const, data: msg.audio, mimeType: "audio/webm" };
2894
+ const sttProvider = voiceConfig.sttProvider || "groq-whisper";
2895
+ const messageContent = await voiceService.transcribe(audioInput, sttProvider);
2896
+
2897
+ log.info(`šŸ“ Transcribed: ${messageContent.substring(0, 100)}...`);
2898
+
2899
+ webchatPreferAudio = true;
2900
+
2901
+ ws.send(JSON.stringify({
2902
+ type: "message",
2903
+ sessionId: msg.sessionId,
2904
+ content: `šŸŽ™ļø Transcripción: ${messageContent}`
2905
+ } as OutboundMessage));
2906
+
2907
+ dbService.addMessage(msg.sessionId, "user", messageContent, {
2908
+ input_type: "audio_transcribed",
2909
+ stt_provider: sttProvider,
2910
+ channel: "webchat"
2911
+ });
2912
+
2913
+ ws.send(JSON.stringify({
2914
+ type: "typing",
2915
+ isTyping: false,
2916
+ sessionId: msg.sessionId,
2917
+ } as OutboundMessage));
2918
+
2919
+ laneQueue.enqueue(msg.sessionId, async (_task, signal) => {
2920
+ if (signal.aborted) {
2921
+ ws.send(JSON.stringify({ type: "typing", isTyping: false, sessionId: msg.sessionId } as OutboundMessage));
2922
+ ws.send(JSON.stringify({ type: "error", sessionId: msg.sessionId, error: "Task cancelled" } as OutboundMessage));
2923
+ return;
2924
+ }
2925
+
2926
+ try {
2927
+ const unifiedSessionId = msg.sessionId;
2928
+ const messages = [{ role: "user" as const, content: messageContent }];
2929
+ log.info(`Generating response for session ${unifiedSessionId}...`);
2930
+
2931
+ const { userId } = resolveContext({
2932
+ channel: "webchat",
2933
+ channelUserId: msg.sessionId,
2934
+ });
2935
+
2936
+ const response = await runner.generate({
2937
+ provider: dbProvider as any,
2938
+ messages,
2939
+ maxTokens: 4096,
2940
+ tools: prepareTools(agent, unifiedSessionId),
2941
+ maxSteps: 15,
2942
+ threadId: unifiedSessionId,
2943
+ userId,
2944
+ onStep: async (step) => {
2945
+ if (signal.aborted) return;
2946
+ if (step.type === "tool_result" && step.message) {
2947
+ try {
2948
+ const result = JSON.parse(step.message);
2949
+ if (result._sendToUser || result.status) {
2950
+ const userMessage = result.message || result.status || step.message;
2951
+ ws.send(JSON.stringify({
2952
+ type: "message",
2953
+ sessionId: unifiedSessionId,
2954
+ content: `šŸ“Š ${userMessage}`,
2955
+ isStep: true,
2956
+ } as unknown as OutboundMessage));
2957
+ return;
2958
+ }
2959
+ } catch { }
2960
+ }
2961
+ log.debug(`[TOOL] ${step.type}: ${step.toolName || ""}`);
2962
+ },
2963
+ });
2964
+
2965
+ const content = response.content?.trim() || "...";
2966
+ log.info(`Response sent to session ${unifiedSessionId} (${content.length} chars)`);
2967
+
2968
+ const voiceCfg = voiceService.getChannelVoiceConfig("webchat");
2969
+ const shouldSpeak = webchatPreferAudio;
2970
+ let responseType: "text" | "audio" = "text";
2971
+ let ttsProviderUsed: string | null = null;
2972
+ let ttsMimeType: string | null = null;
2973
+
2974
+ ws.send(JSON.stringify({ type: "typing", isTyping: false, sessionId: unifiedSessionId } as OutboundMessage));
2975
+ if (content && content !== "...") {
2976
+ if (shouldSpeak) {
2977
+ if (!voiceCfg.ttsProvider) {
2978
+ ws.send(JSON.stringify({
2979
+ type: "message",
2980
+ sessionId: unifiedSessionId,
2981
+ content: `${content}\n\nšŸ”Š Para recibir respuestas en audio, configura el proveedor TTS en Configuración > Canales > WebChat (ej: elevenlabs)`,
2982
+ isStep: false,
2983
+ } as OutboundMessage));
2984
+ } else {
2985
+ try {
2986
+ log.info(`šŸ”Š TTS enabled, synthesizing audio for WebChat...`);
2987
+ const audioOutput = await voiceService.speak(content, voiceCfg.ttsProvider, voiceCfg.ttsVoiceId || undefined);
2988
+ ttsProviderUsed = voiceCfg.ttsProvider;
2989
+ ttsMimeType = audioOutput.mimeType;
2990
+ responseType = "audio";
2991
+ const base64Audio = (audioOutput.data as Buffer).toString("base64");
2992
+ ws.send(JSON.stringify({
2993
+ type: "audio",
2994
+ sessionId: unifiedSessionId,
2995
+ audio: base64Audio,
2996
+ content,
2997
+ mimeType: audioOutput.mimeType,
2998
+ } as OutboundMessage));
2999
+ } catch (ttsError) {
3000
+ log.error(`TTS failed: ${(ttsError as Error).message}), sending text instead`);
3001
+ ws.send(JSON.stringify({ type: "message", sessionId: unifiedSessionId, content, isStep: false } as OutboundMessage));
3002
+ }
3003
+ }
3004
+ } else {
3005
+ ws.send(JSON.stringify({ type: "message", sessionId: unifiedSessionId, content, isStep: false } as OutboundMessage));
3006
+ }
3007
+ }
3008
+
3009
+ dbService.addMessage(unifiedSessionId, "assistant", content, {
3010
+ response_type: responseType,
3011
+ tts_provider: ttsProviderUsed,
3012
+ mime_type: ttsMimeType,
3013
+ channel: "webchat"
3014
+ });
3015
+ } catch (error) {
3016
+ ws.send(JSON.stringify({ type: "typing", isTyping: false, sessionId: msg.sessionId } as OutboundMessage));
3017
+ ws.send(JSON.stringify({
3018
+ type: "error",
3019
+ sessionId: msg.sessionId,
3020
+ error: (error as Error).message,
3021
+ } as OutboundMessage));
3022
+ log.error(`Error for session ${msg.sessionId}: ${(error as Error).message}`);
3023
+ }
3024
+ });
3025
+ } catch (error) {
3026
+ ws.send(JSON.stringify({
3027
+ type: "typing",
3028
+ isTyping: false,
3029
+ sessionId: msg.sessionId,
3030
+ } as OutboundMessage));
3031
+ ws.send(JSON.stringify({
3032
+ type: "error",
3033
+ sessionId: msg.sessionId,
3034
+ error: `Transcription failed: ${(error as Error).message}`
3035
+ } as OutboundMessage));
3036
+ }
3037
+ return;
3038
+ }
3039
+
3040
+ if (msg.type === "message" && msg.content) {
3041
+ log.info(`WebChat message from session ${msg.sessionId}: ${msg.content.substring(0, 100)}`);
3042
+
3043
+ dbService.addMessage(msg.sessionId, "user", msg.content, {
3044
+ input_type: "text",
3045
+ channel: "webchat"
3046
+ });
3047
+
3048
+ // FIX 6 — typing indicator inmediato ANTES de encolar
3049
+ // El usuario ve "escribiendo..." de inmediato, no despuƩs del queue
3050
+ ws.send(JSON.stringify({
3051
+ type: "typing",
3052
+ isTyping: true,
3053
+ sessionId: msg.sessionId,
3054
+ } as OutboundMessage));
3055
+
3056
+ laneQueue.enqueue(msg.sessionId, async (_task, signal) => {
3057
+ if (signal.aborted) {
3058
+ ws.send(JSON.stringify({ type: "typing", isTyping: false, sessionId: msg.sessionId } as OutboundMessage));
3059
+ ws.send(JSON.stringify({ type: "error", sessionId: msg.sessionId, error: "Task cancelled" } as OutboundMessage));
3060
+ return;
3061
+ }
3062
+
3063
+ try {
3064
+ const unifiedSessionId = msg.sessionId;
3065
+ const messages = [{ role: "user" as const, content: msg.content }];
3066
+ log.info(`Generating response for session ${unifiedSessionId}...`);
3067
+
3068
+ const { userId } = resolveContext({
3069
+ channel: "webchat",
3070
+ channelUserId: msg.sessionId,
3071
+ });
3072
+
3073
+ const response = await runner.generate({
3074
+ provider: dbProvider as any,
3075
+ messages,
3076
+ maxTokens: 4096,
3077
+ tools: prepareTools(agent, unifiedSessionId),
3078
+ maxSteps: 15,
3079
+ threadId: unifiedSessionId,
3080
+ userId,
3081
+ onStep: async (step) => {
3082
+ if (signal.aborted) return;
3083
+
3084
+ // Para tool_result, verificar si es un mensaje de progreso
3085
+ if (step.type === "tool_result" && step.message) {
3086
+ try {
3087
+ const result = JSON.parse(step.message);
3088
+ if (result._sendToUser || result.status) {
3089
+ const userMessage = result.message || result.status || step.message;
3090
+ ws.send(JSON.stringify({
3091
+ type: "message",
3092
+ sessionId: unifiedSessionId,
3093
+ content: `šŸ“Š ${userMessage}`,
3094
+ isStep: true,
3095
+ } as unknown as OutboundMessage));
3096
+ return;
3097
+ }
3098
+ } catch {
3099
+ // No es JSON de progreso
3100
+ }
3101
+ }
3102
+
3103
+ log.debug(`[TOOL] ${step.type}: ${step.toolName || ""}`);
3104
+ },
3105
+ });
3106
+
3107
+ const content = response.content?.trim() || "...";
3108
+ log.info(`Response sent to session ${unifiedSessionId} (${content.length} chars)`);
3109
+
3110
+ const voiceConfig = voiceService.getChannelVoiceConfig("webchat");
3111
+ const shouldSpeak = webchatPreferAudio;
3112
+ let responseType: "text" | "audio" = "text";
3113
+ let ttsProviderUsed: string | null = null;
3114
+ let ttsMimeType: string | null = null;
3115
+
3116
+ ws.send(JSON.stringify({ type: "typing", isTyping: false, sessionId: unifiedSessionId } as OutboundMessage));
3117
+ if (content && content !== "...") {
3118
+ if (shouldSpeak) {
3119
+ if (!voiceConfig.ttsProvider) {
3120
+ ws.send(JSON.stringify({
3121
+ type: "message",
3122
+ sessionId: unifiedSessionId,
3123
+ content: `${content}\n\nšŸ”Š Para recibir respuestas en audio, configura el proveedor TTS en Configuración > Canales > WebChat (ej: elevenlabs)`,
3124
+ isStep: false
3125
+ } as OutboundMessage));
3126
+ } else {
3127
+ try {
3128
+ log.info(`šŸ”Š TTS enabled, synthesizing audio for WebChat...`);
3129
+ const audioOutput = await voiceService.speak(content, voiceConfig.ttsProvider, voiceConfig.ttsVoiceId || undefined);
3130
+ ttsProviderUsed = voiceConfig.ttsProvider;
3131
+ ttsMimeType = audioOutput.mimeType;
3132
+ responseType = "audio";
3133
+
3134
+ const base64Audio = (audioOutput.data as Buffer).toString("base64");
3135
+
3136
+ ws.send(JSON.stringify({
3137
+ type: "audio",
3138
+ sessionId: unifiedSessionId,
3139
+ audio: base64Audio,
3140
+ content,
3141
+ mimeType: audioOutput.mimeType,
3142
+ } as OutboundMessage));
3143
+ } catch (ttsError) {
3144
+ log.error(`TTS failed: ${(ttsError as Error).message}), sending text instead`);
3145
+ ws.send(JSON.stringify({ type: "message", sessionId: unifiedSessionId, content, isStep: false } as OutboundMessage));
3146
+ }
3147
+ }
3148
+ } else {
3149
+ ws.send(JSON.stringify({ type: "message", sessionId: unifiedSessionId, content, isStep: false } as OutboundMessage));
3150
+ }
3151
+ }
3152
+
3153
+ dbService.addMessage(unifiedSessionId, "assistant", content, {
3154
+ response_type: responseType,
3155
+ tts_provider: ttsProviderUsed,
3156
+ mime_type: ttsMimeType,
3157
+ channel: "webchat"
3158
+ });
3159
+ } catch (error) {
3160
+ const unifiedSessionId = msg.sessionId;
3161
+ // Detener typing aunque falle — nunca dejar el spinner infinito
3162
+ ws.send(JSON.stringify({ type: "typing", isTyping: false, sessionId: unifiedSessionId } as OutboundMessage));
3163
+ ws.send(JSON.stringify({
3164
+ type: "error",
3165
+ sessionId: unifiedSessionId,
3166
+ error: (error as Error).message,
3167
+ } as OutboundMessage));
3168
+ log.error(`Error for session ${unifiedSessionId}: ${(error as Error).message}`);
3169
+ }
3170
+ });
3171
+
3172
+ return;
3173
+ }
3174
+
3175
+ ws.send(JSON.stringify({
3176
+ type: "error",
3177
+ sessionId: msg.sessionId,
3178
+ error: "Unknown message type",
3179
+ } as OutboundMessage));
3180
+ },
3181
+
3182
+ close(ws) {
3183
+ const data = ws.data;
3184
+ const isCanvas = data.sessionId.startsWith("canvas:");
3185
+ const isBridge = data.sessionId.startsWith("bridge:");
3186
+
3187
+ if (isBridge) {
3188
+ unsubscribeBridge(ws as any);
3189
+ return;
3190
+ }
3191
+
3192
+ if (isCanvas) {
3193
+ canvasManager.unregisterSession(data.sessionId);
3194
+ unsubscribeCanvas(ws as any);
3195
+ return;
3196
+ }
3197
+
3198
+ log.debug(`WebSocket disconnected: ${data.sessionId}`);
3199
+ logSubscribers.delete(data.sessionId);
3200
+ sessionManager.delete(data.sessionId);
3201
+ laneQueue.cancel(data.sessionId);
3202
+
3203
+ const channel = channelManager.getChannel("webchat") as any;
3204
+ if (channel?.unregisterConnection) channel.unregisterConnection(data.sessionId);
3205
+ },
3206
+ },
3207
+ });
3208
+
3209
+ onLogEntry((entry) => {
3210
+ if (logSubscribers.size === 0) return;
3211
+
3212
+ const payload = JSON.stringify({
3213
+ type: "log",
3214
+ sessionId: entry.meta?.sessionId || "system",
3215
+ logEntry: entry,
3216
+ });
3217
+
3218
+ for (const sessionId of logSubscribers) {
3219
+ const session = sessionManager.get(sessionId);
3220
+ if (session?.ws && session.ws.readyState === 1) {
3221
+ try {
3222
+ session.ws.send(payload);
3223
+ } catch {
3224
+ logSubscribers.delete(sessionId);
3225
+ }
3226
+ } else {
3227
+ logSubscribers.delete(sessionId);
3228
+ }
3229
+ }
3230
+ });
3231
+
3232
+ log.info(`Gateway started successfully`);
3233
+
3234
+ // Check if running as child process in dev mode (parent handles browser open)
3235
+ const isGatewayChild = process.env.HIVE_GATEWAY_CHILD === "1";
3236
+
3237
+ // Print URLs based on mode
3238
+ if (isDev) {
3239
+ // In development: UI is served by Vite on port 5173
3240
+ log.info(`[gateway] API: http://${host}:${port}`);
3241
+ log.info(`[gateway] WebSocket: ws://${host}:${port}/ws`);
3242
+ log.info(`[gateway] Canvas: ws://${host}:${port}/canvas`);
3243
+ log.info(`[gateway] Modo: desarrollo`);
3244
+ if (!isGatewayChild) {
3245
+ log.info(`šŸ Administra tu Hive aquĆ­: http://localhost:5173`);
3246
+ }
3247
+ } else {
3248
+ // In production: Gateway serves UI from dist/
3249
+ // Check if this is first-run setup mode
3250
+ const isSetupMode = !existsSync(getDbPathLazy());
3251
+ const baseUrl = `http://${host}:${port}`;
3252
+ const uiUrl = isSetupMode ? `${baseUrl}/setup` : `${baseUrl}/ui`;
3253
+
3254
+ log.info(`[gateway] UI: ${uiUrl}`);
3255
+ log.info(`[gateway] API: http://${host}:${port}`);
3256
+ log.info(`[gateway] WebSocket: ws://${host}:${port}/ws`);
3257
+ log.info(`[gateway] Canvas: ws://${host}:${port}/canvas`);
3258
+
3259
+ log.info(isSetupMode ? `šŸŽ‰ Primer arranque — abriendo wizard de configuración...` : `šŸ Administra tu Hive aquĆ­: ${uiUrl}`);
3260
+
3261
+ // Always open browser on startup (setup and normal mode).
3262
+ // Set NO_BROWSER=1 to skip in headless/server environments.
3263
+ if (!process.env.NO_BROWSER) {
3264
+ try {
3265
+ const platform = process.platform;
3266
+ let shellCmd: string;
3267
+ if (platform === "win32") {
3268
+ shellCmd = `start "" "${uiUrl}"`;
3269
+ } else if (platform === "darwin") {
3270
+ shellCmd = `open "${uiUrl}"`;
3271
+ } else {
3272
+ // Linux: gio open first (GNOME/Wayland native), then xdg-open fallbacks
3273
+ shellCmd = `gio open "${uiUrl}" 2>/dev/null || xdg-open "${uiUrl}" 2>/dev/null || sensible-browser "${uiUrl}" 2>/dev/null || x-www-browser "${uiUrl}" 2>/dev/null || true`;
3274
+ }
3275
+ const shell = platform === "win32" ? "cmd" : "/bin/sh";
3276
+ const shellArg = platform === "win32" ? "/c" : "-c";
3277
+ // Use Bun.spawn (native Bun API) for reliable detached subprocess
3278
+ const proc = Bun.spawn([shell, shellArg, shellCmd], {
3279
+ stdout: "ignore",
3280
+ stderr: "ignore",
3281
+ stdin: "ignore",
3282
+ });
3283
+ proc.unref();
3284
+ } catch (err) {
3285
+ log.warn(`Could not open browser: ${(err as Error).message}`);
3286
+ }
3287
+ }
3288
+ }
3289
+ log.info(`Channels: ${channelManager.listChannels().map((c) => c.name).join(", ") || "none"}`);
3290
+
3291
+ // FIX 7 — SIGTERM desconecta MCP limpiamente antes de cerrar
3292
+ process.on("SIGTERM", async () => {
3293
+ log.info("Received SIGTERM, shutting down gracefully...");
3294
+ watchers.forEach((close) => close());
3295
+ const mcp = agent.getMCPManager();
3296
+ if (mcp) {
3297
+ log.info("Disconnecting MCP servers...");
3298
+ await mcp.disconnectAll().catch(() => { });
3299
+ }
3300
+ await channelManager.stopAll();
3301
+ server.stop();
3302
+ try { unlinkSync(pidFile); } catch { }
3303
+ process.exit(0);
3304
+ });
3305
+
3306
+ process.on("SIGHUP", async () => {
3307
+ log.info("Received SIGHUP, reloading configuration...");
3308
+ try {
3309
+ const newConfig = await loadConfig();
3310
+ await agent.updateConfig(newConfig);
3311
+ await agent.reload();
3312
+ log.info("Configuration reloaded successfully");
3313
+ } catch (error) {
3314
+ log.error(`Failed to reload configuration: ${(error as Error).message}`);
3315
+ }
3316
+ });
3317
+ }