zubo 0.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 (222) hide show
  1. package/.github/workflows/ci.yml +35 -0
  2. package/README.md +149 -0
  3. package/bun.lock +216 -0
  4. package/desktop/README.md +57 -0
  5. package/desktop/package.json +12 -0
  6. package/desktop/src-tauri/Cargo.toml +25 -0
  7. package/desktop/src-tauri/build.rs +3 -0
  8. package/desktop/src-tauri/icons/README.md +17 -0
  9. package/desktop/src-tauri/icons/icon.png +0 -0
  10. package/desktop/src-tauri/src/main.rs +189 -0
  11. package/desktop/src-tauri/tauri.conf.json +68 -0
  12. package/docs/ROADMAP.md +490 -0
  13. package/migrations/001_init.sql +9 -0
  14. package/migrations/002_memory.sql +33 -0
  15. package/migrations/003_cron.sql +24 -0
  16. package/migrations/004_usage.sql +12 -0
  17. package/migrations/005_secrets.sql +8 -0
  18. package/migrations/006_agents.sql +1 -0
  19. package/migrations/007_workflows.sql +22 -0
  20. package/migrations/008_proactive.sql +24 -0
  21. package/migrations/009_uploads.sql +9 -0
  22. package/migrations/010_observability.sql +22 -0
  23. package/migrations/011_api_keys.sql +7 -0
  24. package/migrations/012_indexes.sql +5 -0
  25. package/migrations/013_budget.sql +11 -0
  26. package/migrations/014_usage_session_idx.sql +2 -0
  27. package/package.json +39 -0
  28. package/site/404.html +156 -0
  29. package/site/CNAME +1 -0
  30. package/site/docs/agents.html +294 -0
  31. package/site/docs/api.html +446 -0
  32. package/site/docs/channels.html +345 -0
  33. package/site/docs/cli.html +238 -0
  34. package/site/docs/config.html +1034 -0
  35. package/site/docs/index.html +433 -0
  36. package/site/docs/integrations.html +381 -0
  37. package/site/docs/memory.html +254 -0
  38. package/site/docs/security.html +375 -0
  39. package/site/docs/skills.html +322 -0
  40. package/site/docs.css +412 -0
  41. package/site/index.html +638 -0
  42. package/site/install.sh +98 -0
  43. package/site/logo.svg +1 -0
  44. package/site/og-image.png +0 -0
  45. package/site/robots.txt +4 -0
  46. package/site/script.js +361 -0
  47. package/site/sitemap.xml +63 -0
  48. package/site/skills.html +532 -0
  49. package/site/style.css +1686 -0
  50. package/src/agent/agents.ts +159 -0
  51. package/src/agent/compaction.ts +53 -0
  52. package/src/agent/context.ts +18 -0
  53. package/src/agent/delegate.ts +118 -0
  54. package/src/agent/loop.ts +318 -0
  55. package/src/agent/prompts.ts +111 -0
  56. package/src/agent/session.ts +87 -0
  57. package/src/agent/teams.ts +116 -0
  58. package/src/agent/workflow-executor.ts +192 -0
  59. package/src/agent/workflow.ts +175 -0
  60. package/src/channels/adapter.ts +21 -0
  61. package/src/channels/dashboard.html.ts +2969 -0
  62. package/src/channels/discord.ts +137 -0
  63. package/src/channels/optional-deps.d.ts +17 -0
  64. package/src/channels/router.ts +199 -0
  65. package/src/channels/signal.ts +133 -0
  66. package/src/channels/slack.ts +101 -0
  67. package/src/channels/telegram.ts +102 -0
  68. package/src/channels/utils.ts +18 -0
  69. package/src/channels/webchat.ts +1797 -0
  70. package/src/channels/whatsapp.ts +119 -0
  71. package/src/config/loader.ts +22 -0
  72. package/src/config/paths.ts +43 -0
  73. package/src/config/schema.ts +121 -0
  74. package/src/db/connection.ts +20 -0
  75. package/src/db/export.ts +148 -0
  76. package/src/db/migrations.ts +42 -0
  77. package/src/index.ts +261 -0
  78. package/src/llm/claude.ts +193 -0
  79. package/src/llm/factory.ts +115 -0
  80. package/src/llm/failover.ts +101 -0
  81. package/src/llm/openai-compat.ts +409 -0
  82. package/src/llm/provider.ts +83 -0
  83. package/src/llm/smart-router.ts +241 -0
  84. package/src/logs.ts +53 -0
  85. package/src/memory/chunker.ts +58 -0
  86. package/src/memory/document-parser.ts +115 -0
  87. package/src/memory/embedder.ts +235 -0
  88. package/src/memory/engine.ts +170 -0
  89. package/src/memory/fts-index.ts +55 -0
  90. package/src/memory/hybrid-search.ts +72 -0
  91. package/src/memory/store.ts +56 -0
  92. package/src/memory/vector-index.ts +72 -0
  93. package/src/model.ts +118 -0
  94. package/src/registry/cli.ts +43 -0
  95. package/src/registry/client.ts +54 -0
  96. package/src/registry/installer.ts +67 -0
  97. package/src/scheduler/briefing.ts +71 -0
  98. package/src/scheduler/cron.ts +258 -0
  99. package/src/scheduler/heartbeat.ts +58 -0
  100. package/src/scheduler/memory-triggers.ts +100 -0
  101. package/src/scheduler/natural-cron.ts +163 -0
  102. package/src/scheduler/proactive.ts +25 -0
  103. package/src/scheduler/recipes.ts +110 -0
  104. package/src/secrets/store.ts +64 -0
  105. package/src/setup.ts +413 -0
  106. package/src/skills.ts +293 -0
  107. package/src/start.ts +373 -0
  108. package/src/status.ts +165 -0
  109. package/src/tools/builtin/connect-service.ts +205 -0
  110. package/src/tools/builtin/cron.ts +126 -0
  111. package/src/tools/builtin/datetime.ts +36 -0
  112. package/src/tools/builtin/delegate-task.ts +81 -0
  113. package/src/tools/builtin/delegate.ts +42 -0
  114. package/src/tools/builtin/diagnose.ts +41 -0
  115. package/src/tools/builtin/google-oauth.ts +379 -0
  116. package/src/tools/builtin/manage-agents.ts +149 -0
  117. package/src/tools/builtin/manage-skills.ts +294 -0
  118. package/src/tools/builtin/manage-teams.ts +89 -0
  119. package/src/tools/builtin/manage-triggers.ts +94 -0
  120. package/src/tools/builtin/manage-workflows.ts +119 -0
  121. package/src/tools/builtin/memory-search.ts +38 -0
  122. package/src/tools/builtin/memory-write.ts +30 -0
  123. package/src/tools/builtin/run-workflow.ts +36 -0
  124. package/src/tools/builtin/secrets.ts +122 -0
  125. package/src/tools/builtin/skill-registry.ts +75 -0
  126. package/src/tools/builtin-integrations/api-helpers.ts +26 -0
  127. package/src/tools/builtin-integrations/github/github_issues/SKILL.md +56 -0
  128. package/src/tools/builtin-integrations/github/github_issues/handler.ts +108 -0
  129. package/src/tools/builtin-integrations/github/github_prs/SKILL.md +57 -0
  130. package/src/tools/builtin-integrations/github/github_prs/handler.ts +113 -0
  131. package/src/tools/builtin-integrations/github/github_repos/SKILL.md +37 -0
  132. package/src/tools/builtin-integrations/github/github_repos/handler.ts +88 -0
  133. package/src/tools/builtin-integrations/google/gmail/SKILL.md +51 -0
  134. package/src/tools/builtin-integrations/google/gmail/handler.ts +125 -0
  135. package/src/tools/builtin-integrations/google/google_calendar/SKILL.md +35 -0
  136. package/src/tools/builtin-integrations/google/google_calendar/handler.ts +105 -0
  137. package/src/tools/builtin-integrations/google/google_docs/SKILL.md +35 -0
  138. package/src/tools/builtin-integrations/google/google_docs/handler.ts +108 -0
  139. package/src/tools/builtin-integrations/google/google_drive/SKILL.md +39 -0
  140. package/src/tools/builtin-integrations/google/google_drive/handler.ts +106 -0
  141. package/src/tools/builtin-integrations/google/google_sheets/SKILL.md +36 -0
  142. package/src/tools/builtin-integrations/google/google_sheets/handler.ts +116 -0
  143. package/src/tools/builtin-integrations/jira/jira_boards/SKILL.md +21 -0
  144. package/src/tools/builtin-integrations/jira/jira_boards/handler.ts +74 -0
  145. package/src/tools/builtin-integrations/jira/jira_issues/SKILL.md +28 -0
  146. package/src/tools/builtin-integrations/jira/jira_issues/handler.ts +140 -0
  147. package/src/tools/builtin-integrations/linear/linear_issues/SKILL.md +30 -0
  148. package/src/tools/builtin-integrations/linear/linear_issues/handler.ts +75 -0
  149. package/src/tools/builtin-integrations/linear/linear_projects/SKILL.md +21 -0
  150. package/src/tools/builtin-integrations/linear/linear_projects/handler.ts +43 -0
  151. package/src/tools/builtin-integrations/notion/notion_databases/SKILL.md +39 -0
  152. package/src/tools/builtin-integrations/notion/notion_databases/handler.ts +83 -0
  153. package/src/tools/builtin-integrations/notion/notion_pages/SKILL.md +43 -0
  154. package/src/tools/builtin-integrations/notion/notion_pages/handler.ts +130 -0
  155. package/src/tools/builtin-integrations/notion/notion_search/SKILL.md +27 -0
  156. package/src/tools/builtin-integrations/notion/notion_search/handler.ts +69 -0
  157. package/src/tools/builtin-integrations/slack/slack_messages/SKILL.md +42 -0
  158. package/src/tools/builtin-integrations/slack/slack_messages/handler.ts +72 -0
  159. package/src/tools/builtin-integrations/twitter/twitter_posts/SKILL.md +24 -0
  160. package/src/tools/builtin-integrations/twitter/twitter_posts/handler.ts +133 -0
  161. package/src/tools/builtin-skills/file-read/SKILL.md +26 -0
  162. package/src/tools/builtin-skills/file-read/handler.ts +66 -0
  163. package/src/tools/builtin-skills/file-write/SKILL.md +30 -0
  164. package/src/tools/builtin-skills/file-write/handler.ts +64 -0
  165. package/src/tools/builtin-skills/http-request/SKILL.md +34 -0
  166. package/src/tools/builtin-skills/http-request/handler.ts +87 -0
  167. package/src/tools/builtin-skills/shell/SKILL.md +26 -0
  168. package/src/tools/builtin-skills/shell/handler.ts +96 -0
  169. package/src/tools/builtin-skills/url-fetch/SKILL.md +26 -0
  170. package/src/tools/builtin-skills/url-fetch/handler.ts +37 -0
  171. package/src/tools/builtin-skills/web-search/SKILL.md +26 -0
  172. package/src/tools/builtin-skills/web-search/handler.ts +50 -0
  173. package/src/tools/executor.ts +205 -0
  174. package/src/tools/integration-installer.ts +106 -0
  175. package/src/tools/permissions.ts +45 -0
  176. package/src/tools/registry.ts +39 -0
  177. package/src/tools/sandbox-runner.ts +56 -0
  178. package/src/tools/sandbox.ts +82 -0
  179. package/src/tools/skill-installer.ts +52 -0
  180. package/src/tools/skill-loader.ts +259 -0
  181. package/src/types/optional-deps.d.ts +23 -0
  182. package/src/util/auth.ts +121 -0
  183. package/src/util/costs.ts +59 -0
  184. package/src/util/error-buffer.ts +32 -0
  185. package/src/util/google-tokens.ts +180 -0
  186. package/src/util/logger.ts +73 -0
  187. package/src/util/perf-collector.ts +35 -0
  188. package/src/util/rate-limiter.ts +70 -0
  189. package/src/util/tokens.ts +17 -0
  190. package/src/voice/stt.ts +57 -0
  191. package/src/voice/tts.ts +103 -0
  192. package/tests/agent/session.test.ts +109 -0
  193. package/tests/agent-loop.test.ts +54 -0
  194. package/tests/auth.test.ts +89 -0
  195. package/tests/channels.test.ts +67 -0
  196. package/tests/compaction.test.ts +44 -0
  197. package/tests/config.test.ts +51 -0
  198. package/tests/costs.test.ts +19 -0
  199. package/tests/cron.test.ts +55 -0
  200. package/tests/db/export.test.ts +219 -0
  201. package/tests/executor.test.ts +144 -0
  202. package/tests/export.test.ts +137 -0
  203. package/tests/helpers/mock-llm.ts +34 -0
  204. package/tests/helpers/test-db.ts +74 -0
  205. package/tests/integration/chat-flow.test.ts +48 -0
  206. package/tests/integrations.test.ts +97 -0
  207. package/tests/memory/engine.test.ts +114 -0
  208. package/tests/memory-engine.test.ts +57 -0
  209. package/tests/permissions.test.ts +21 -0
  210. package/tests/rate-limiter.test.ts +70 -0
  211. package/tests/registry.test.ts +67 -0
  212. package/tests/router.test.ts +36 -0
  213. package/tests/session.test.ts +58 -0
  214. package/tests/skill-loader.test.ts +44 -0
  215. package/tests/tokens.test.ts +30 -0
  216. package/tests/tools/executor.test.ts +130 -0
  217. package/tests/util/auth.test.ts +75 -0
  218. package/tests/util/rate-limiter.test.ts +73 -0
  219. package/tests/voice.test.ts +60 -0
  220. package/tests/webchat.test.ts +88 -0
  221. package/tests/workflow.test.ts +38 -0
  222. package/tsconfig.json +16 -0
@@ -0,0 +1,1797 @@
1
+ import { existsSync, readFileSync, writeFileSync, readdirSync, unlinkSync } from "fs";
2
+ import { join } from "path";
3
+ import type { ChannelAdapter, InboundMessage } from "./adapter";
4
+ import type { MessageRouter } from "./router";
5
+ import { paths } from "../config/paths";
6
+ import { getDb } from "../db/connection";
7
+ import { getHeartbeatMinutes, restartHeartbeat } from "../scheduler/heartbeat";
8
+ import { logger } from "../util/logger";
9
+ import { DASHBOARD_HTML } from "./dashboard.html";
10
+ import { parseSkillMd, parseSkillExport } from "../tools/skill-loader";
11
+ import { RateLimiter } from "../util/rate-limiter";
12
+ import { initAuth, validateRequest, createApiKey, listApiKeys, deleteApiKey, generateSessionToken } from "../util/auth";
13
+ import { exportDatabase, backupDatabase, importDatabase, getDbStats, getDbSizeBytes } from "../db/export";
14
+
15
+ // Dashboard API helpers
16
+ function readFileOr(path: string, fallback: string): string {
17
+ try {
18
+ if (existsSync(path)) return readFileSync(path, "utf-8");
19
+ } catch (err: any) {
20
+ logger.warn("Failed to read file", { path, error: (err as Error).message });
21
+ }
22
+ return fallback;
23
+ }
24
+
25
+ function getStatusData(): Record<string, string> {
26
+ const data: Record<string, string> = {};
27
+
28
+ // Provider
29
+ try {
30
+ const config = JSON.parse(readFileSync(paths.config, "utf-8"));
31
+ if (config.providers && config.activeProvider) {
32
+ const p = config.providers[config.activeProvider];
33
+ data["Provider"] = `${config.activeProvider}/${p?.model ?? "?"}`;
34
+ } else if (config.anthropicApiKey) {
35
+ data["Provider"] = `anthropic/${config.model ?? "claude-sonnet-4-5"}`;
36
+ }
37
+ // Channels
38
+ const ch: string[] = [];
39
+ if (config.channels?.telegram?.botToken || config.telegramBotToken) ch.push("telegram");
40
+ if (config.channels?.discord?.botToken) ch.push("discord");
41
+ if (config.channels?.webchat) ch.push("webchat");
42
+ data["Channels"] = ch.join(", ") || "none";
43
+ } catch (err: any) {
44
+ logger.warn("Failed to read config for status data", { error: (err as Error).message });
45
+ }
46
+
47
+ // DB stats
48
+ try {
49
+ const db = getDb();
50
+ const msgs = (db.query("SELECT COUNT(*) as c FROM messages").get() as any)?.c ?? 0;
51
+ const mems = (db.query("SELECT COUNT(*) as c FROM memory_chunks").get() as any)?.c ?? 0;
52
+ data["Messages"] = String(msgs);
53
+ data["Memories"] = String(mems);
54
+ } catch (err: any) {
55
+ logger.warn("Failed to read DB stats", { error: (err as Error).message });
56
+ }
57
+
58
+ // Daemon
59
+ try {
60
+ if (existsSync(paths.pidFile)) {
61
+ const pid = parseInt(readFileSync(paths.pidFile, "utf-8").trim(), 10);
62
+ process.kill(pid, 0);
63
+ data["Status"] = "running";
64
+ } else {
65
+ data["Status"] = "running"; // if we're serving this, we're running
66
+ }
67
+ } catch {
68
+ data["Status"] = "running";
69
+ }
70
+
71
+ return data;
72
+ }
73
+
74
+ function getCronJobs(): any[] {
75
+ try {
76
+ const db = getDb();
77
+ return db.query("SELECT * FROM cron_jobs ORDER BY id").all() as any[];
78
+ } catch {
79
+ return [];
80
+ }
81
+ }
82
+
83
+ function getRecentMemoryChunks(): any[] {
84
+ try {
85
+ const db = getDb();
86
+ return db
87
+ .query(
88
+ "SELECT source_file as source, content FROM memory_chunks ORDER BY id DESC LIMIT 20"
89
+ )
90
+ .all() as any[];
91
+ } catch {
92
+ return [];
93
+ }
94
+ }
95
+
96
+ function searchMemoryChunks(query: string): any[] {
97
+ try {
98
+ // Sanitize FTS5 input — strip special operators to prevent query injection
99
+ const sanitized = query.replace(/['"*()[\]{}:^~+\-!/\\]/g, " ").replace(/\b(AND|OR|NOT|NEAR)\b/gi, "").trim();
100
+ const terms = sanitized.split(/\s+/).filter(Boolean);
101
+ if (!terms.length) return [];
102
+ const ftsQuery = terms.join(" OR ");
103
+ const db = getDb();
104
+ return db
105
+ .query(
106
+ "SELECT mc.source_file as source, mc.content FROM memory_fts f JOIN memory_chunks mc ON mc.id = f.rowid WHERE memory_fts MATCH ? ORDER BY rank LIMIT 20"
107
+ )
108
+ .all(ftsQuery) as any[];
109
+ } catch {
110
+ return [];
111
+ }
112
+ }
113
+
114
+ function getSkillsData(): { name: string; description: string; status: string; path: string }[] {
115
+ const skillsDir = paths.skills;
116
+ const results: { name: string; description: string; status: string; path: string }[] = [];
117
+
118
+ try {
119
+ if (!existsSync(skillsDir)) return results;
120
+ const entries = readdirSync(skillsDir) as string[];
121
+
122
+ for (const entry of entries) {
123
+ const dirPath = join(skillsDir, entry);
124
+ const skillMdPath = join(dirPath, "SKILL.md");
125
+ const handlerPath = join(dirPath, "handler.ts");
126
+
127
+ if (!existsSync(handlerPath)) continue;
128
+
129
+ let parsed = null;
130
+ if (existsSync(skillMdPath)) {
131
+ const mdContent = readFileSync(skillMdPath, "utf-8");
132
+ parsed = parseSkillMd(mdContent, dirPath);
133
+ } else {
134
+ // Try single-file format: check for exported skill config via regex
135
+ try {
136
+ const handlerContent = readFileSync(handlerPath, "utf-8");
137
+ const nameMatch = handlerContent.match(/name\s*:\s*["']([^"']+)["']/);
138
+ const descMatch = handlerContent.match(/description\s*:\s*["']([^"']+)["']/);
139
+ if (nameMatch && descMatch) {
140
+ parsed = { name: nameMatch[1], description: descMatch[1] };
141
+ }
142
+ } catch (err: any) {
143
+ logger.warn("Failed to parse skill handler", { error: (err as Error).message });
144
+ }
145
+ }
146
+
147
+ const status = parsed ? "ok" : "error";
148
+ const name = parsed?.name ?? entry;
149
+ const description = parsed?.description?.split("\n")[0].slice(0, 100) ?? "";
150
+
151
+ results.push({ name, description, status, path: dirPath });
152
+ }
153
+ } catch (err: any) {
154
+ logger.warn("Failed to read skills directory", { error: (err as Error).message });
155
+ }
156
+
157
+ return results;
158
+ }
159
+
160
+ function getConfigInfo(): {
161
+ activeProvider: string;
162
+ model: string;
163
+ providers: { name: string; model: string }[];
164
+ } {
165
+ try {
166
+ const config = JSON.parse(readFileSync(paths.config, "utf-8"));
167
+ const providers: { name: string; model: string }[] = [];
168
+
169
+ if (config.providers) {
170
+ for (const [name, p] of Object.entries(config.providers) as [string, any][]) {
171
+ providers.push({ name, model: p.model ?? "" });
172
+ }
173
+ }
174
+
175
+ let activeProvider = config.activeProvider ?? "";
176
+ let model = "";
177
+
178
+ if (config.providers && config.activeProvider) {
179
+ const active = config.providers[config.activeProvider];
180
+ model = active?.model ?? "";
181
+ } else if (config.anthropicApiKey) {
182
+ activeProvider = "anthropic";
183
+ model = config.model ?? "claude-sonnet-4-5-20250929";
184
+ // Include legacy provider in list if not already there
185
+ if (!providers.find((p) => p.name === "anthropic")) {
186
+ providers.push({ name: "anthropic", model });
187
+ }
188
+ }
189
+
190
+ return { activeProvider, model, providers };
191
+ } catch {
192
+ return { activeProvider: "", model: "", providers: [] };
193
+ }
194
+ }
195
+
196
+ function switchModelConfig(provider: string, model: string): { ok: boolean; error?: string } {
197
+ try {
198
+ if (!model) {
199
+ return { ok: false, error: "Model is required" };
200
+ }
201
+
202
+ const config = JSON.parse(readFileSync(paths.config, "utf-8"));
203
+
204
+ if (!config.providers) config.providers = {};
205
+
206
+ if (!config.providers[provider]) {
207
+ return { ok: false, error: `Provider "${provider}" is not configured. Add it via 'zubo setup' first.` };
208
+ }
209
+
210
+ config.activeProvider = provider;
211
+ config.providers[provider].model = model;
212
+ // Keep legacy field in sync
213
+ config.model = model;
214
+
215
+ writeFileSync(paths.config, JSON.stringify(config, null, 2) + "\n");
216
+ return { ok: true };
217
+ } catch (err: any) {
218
+ return { ok: false, error: err.message };
219
+ }
220
+ }
221
+
222
+ function handleDashboardApi(url: URL, req: Request): Response | null {
223
+ const path = url.pathname.replace("/api/dashboard", "");
224
+
225
+ // GET /api/dashboard/status
226
+ if (path === "/status" && req.method === "GET") {
227
+ return Response.json(getStatusData());
228
+ }
229
+
230
+ // GET /api/dashboard/system
231
+ if (path === "/system" && req.method === "GET") {
232
+ return Response.json({
233
+ content: readFileOr(paths.systemPrompt, ""),
234
+ });
235
+ }
236
+
237
+ // PUT /api/dashboard/system
238
+ if (path === "/system" && req.method === "PUT") {
239
+ return (async () => {
240
+ const body = (await req.json()) as { content?: string };
241
+ writeFileSync(paths.systemPrompt, body.content ?? "");
242
+ return Response.json({ ok: true });
243
+ })() as any;
244
+ }
245
+
246
+ // GET /api/dashboard/memory
247
+ if (path === "/memory" && req.method === "GET") {
248
+ return Response.json({
249
+ content: readFileOr(paths.memoryFile, ""),
250
+ });
251
+ }
252
+
253
+ // PUT /api/dashboard/memory
254
+ if (path === "/memory" && req.method === "PUT") {
255
+ return (async () => {
256
+ const body = (await req.json()) as { content?: string };
257
+ writeFileSync(paths.memoryFile, body.content ?? "");
258
+ return Response.json({ ok: true });
259
+ })() as any;
260
+ }
261
+
262
+ // GET /api/dashboard/memory/recent
263
+ if (path === "/memory/recent" && req.method === "GET") {
264
+ return Response.json({ results: getRecentMemoryChunks() });
265
+ }
266
+
267
+ // GET /api/dashboard/memory/search?q=...
268
+ if (path === "/memory/search" && req.method === "GET") {
269
+ const q = url.searchParams.get("q") ?? "";
270
+ return Response.json({ results: searchMemoryChunks(q) });
271
+ }
272
+
273
+ // GET /api/dashboard/cron
274
+ if (path === "/cron" && req.method === "GET") {
275
+ return Response.json({ jobs: getCronJobs() });
276
+ }
277
+
278
+ // GET /api/dashboard/logs
279
+ if (path === "/logs" && req.method === "GET") {
280
+ const content = readFileOr(paths.logFile, "");
281
+ const lines = content.trimEnd().split("\n");
282
+ return Response.json({ content: lines.slice(-100).join("\n") });
283
+ }
284
+
285
+ // GET /api/dashboard/skills
286
+ if (path === "/skills" && req.method === "GET") {
287
+ return Response.json({ skills: getSkillsData() });
288
+ }
289
+
290
+ // GET /api/dashboard/config
291
+ if (path === "/config" && req.method === "GET") {
292
+ return Response.json(getConfigInfo());
293
+ }
294
+
295
+ // PUT /api/dashboard/config/model — switch provider/model
296
+ if (path === "/config/model" && req.method === "PUT") {
297
+ return (async () => {
298
+ const body = (await req.json()) as { provider?: string; model?: string };
299
+ if (!body.provider) {
300
+ return Response.json({ ok: false, error: "provider is required" }, { status: 400 });
301
+ }
302
+ const result = switchModelConfig(body.provider, body.model ?? "");
303
+ return Response.json(result, { status: result.ok ? 200 : 400 });
304
+ })() as any;
305
+ }
306
+
307
+ // GET /api/dashboard/settings/heartbeat
308
+ if (path === "/settings/heartbeat" && req.method === "GET") {
309
+ const minutes = getHeartbeatMinutes();
310
+ // Also read saved value from config
311
+ let configMinutes = 30;
312
+ try {
313
+ const config = JSON.parse(readFileSync(paths.config, "utf-8"));
314
+ configMinutes = config.heartbeatMinutes ?? 30;
315
+ } catch (err: any) {
316
+ logger.warn("Failed to read heartbeat config", { error: (err as Error).message });
317
+ }
318
+ return Response.json({ minutes, configMinutes });
319
+ }
320
+
321
+ // PUT /api/dashboard/settings/heartbeat
322
+ if (path === "/settings/heartbeat" && req.method === "PUT") {
323
+ return (async () => {
324
+ const body = (await req.json()) as { minutes?: number };
325
+ const mins = body.minutes;
326
+ if (!mins || typeof mins !== "number" || mins < 1 || mins > 1440) {
327
+ return Response.json(
328
+ { ok: false, error: "minutes must be between 1 and 1440" },
329
+ { status: 400 }
330
+ );
331
+ }
332
+ // Save to config
333
+ try {
334
+ const config = JSON.parse(readFileSync(paths.config, "utf-8"));
335
+ config.heartbeatMinutes = mins;
336
+ writeFileSync(paths.config, JSON.stringify(config, null, 2) + "\n");
337
+ } catch (err: any) {
338
+ return Response.json({ ok: false, error: "Failed to save config" }, { status: 500 });
339
+ }
340
+ // Apply immediately
341
+ restartHeartbeat(mins);
342
+ return Response.json({ ok: true, minutes: mins });
343
+ })() as any;
344
+ }
345
+
346
+ // GET /api/dashboard/smart-routing
347
+ if (path === "/smart-routing" && req.method === "GET") {
348
+ try {
349
+ const config = JSON.parse(readFileSync(paths.config, "utf-8"));
350
+ return Response.json({
351
+ enabled: config.smartRouting?.enabled ?? false,
352
+ fastProvider: config.smartRouting?.fastProvider ?? "",
353
+ fastModel: config.smartRouting?.fastModel ?? "",
354
+ });
355
+ } catch {
356
+ return Response.json({ enabled: false, fastProvider: "", fastModel: "" });
357
+ }
358
+ }
359
+
360
+ // PUT /api/dashboard/smart-routing
361
+ if (path === "/smart-routing" && req.method === "PUT") {
362
+ return (async () => {
363
+ try {
364
+ const body = (await req.json()) as {
365
+ enabled?: boolean;
366
+ fastProvider?: string;
367
+ fastModel?: string;
368
+ };
369
+ const config = JSON.parse(readFileSync(paths.config, "utf-8"));
370
+ if (!config.smartRouting) {
371
+ config.smartRouting = {};
372
+ }
373
+ if (typeof body.enabled === "boolean") {
374
+ config.smartRouting.enabled = body.enabled;
375
+ }
376
+ if (body.fastProvider !== undefined) {
377
+ config.smartRouting.fastProvider = body.fastProvider;
378
+ }
379
+ if (body.fastModel !== undefined) {
380
+ config.smartRouting.fastModel = body.fastModel;
381
+ }
382
+ writeFileSync(paths.config, JSON.stringify(config, null, 2) + "\n");
383
+ return Response.json({ ok: true });
384
+ } catch (err: any) {
385
+ return Response.json({ ok: false, error: err.message }, { status: 500 });
386
+ }
387
+ })() as any;
388
+ }
389
+
390
+ // GET /api/dashboard/analytics/summary
391
+ if (path === "/analytics/summary" && req.method === "GET") {
392
+ try {
393
+ const db = getDb();
394
+ const summary = db.query(`
395
+ SELECT
396
+ COALESCE(SUM(input_tokens + output_tokens), 0) as totalTokens,
397
+ COALESCE(SUM(cost_usd), 0) as totalCost,
398
+ COALESCE(AVG(CASE WHEN response_time_ms IS NOT NULL THEN response_time_ms END), 0) as avgResponse,
399
+ COUNT(DISTINCT session_id) as sessionCount
400
+ FROM usage
401
+ `).get() as any;
402
+ return Response.json({
403
+ totalTokens: summary.totalTokens,
404
+ estimatedCostUsd: Math.round(summary.totalCost * 10000) / 10000,
405
+ avgResponseTimeMs: Math.round(summary.avgResponse),
406
+ sessionCount: summary.sessionCount,
407
+ });
408
+ } catch {
409
+ return Response.json({ totalTokens: 0, estimatedCostUsd: 0, avgResponseTimeMs: 0, sessionCount: 0 });
410
+ }
411
+ }
412
+
413
+ // GET /api/dashboard/analytics/usage-over-time
414
+ if (path === "/analytics/usage-over-time" && req.method === "GET") {
415
+ try {
416
+ const db = getDb();
417
+ const rows = db.query(
418
+ `SELECT date(created_at) as day, SUM(input_tokens) as input, SUM(output_tokens) as output, COALESCE(SUM(cost_usd), 0) as cost
419
+ FROM usage WHERE created_at >= datetime('now', '-7 days') GROUP BY day ORDER BY day`
420
+ ).all();
421
+ return Response.json({ days: rows });
422
+ } catch {
423
+ return Response.json({ days: [] });
424
+ }
425
+ }
426
+
427
+ // GET /api/dashboard/analytics/tools
428
+ if (path === "/analytics/tools" && req.method === "GET") {
429
+ try {
430
+ const db = getDb();
431
+ const tools = db.query(
432
+ `SELECT tool_name, COUNT(*) as calls, AVG(duration_ms) as avg_ms, SUM(CASE WHEN success = 0 THEN 1 ELSE 0 END) as errors
433
+ FROM tool_metrics GROUP BY tool_name ORDER BY calls DESC LIMIT 20`
434
+ ).all();
435
+ return Response.json({ tools });
436
+ } catch {
437
+ return Response.json({ tools: [] });
438
+ }
439
+ }
440
+
441
+ // GET /api/dashboard/analytics/sessions
442
+ if (path === "/analytics/sessions" && req.method === "GET") {
443
+ try {
444
+ const db = getDb();
445
+ const sessions = db.query(
446
+ `SELECT session_id, provider, model, SUM(input_tokens) as input_tokens, SUM(output_tokens) as output_tokens,
447
+ COALESCE(SUM(cost_usd), 0) as cost, COUNT(*) as requests, MAX(created_at) as last_used
448
+ FROM usage GROUP BY session_id ORDER BY last_used DESC LIMIT 20`
449
+ ).all();
450
+ return Response.json({ sessions });
451
+ } catch {
452
+ return Response.json({ sessions: [] });
453
+ }
454
+ }
455
+
456
+ // GET /api/dashboard/onboarding
457
+ if (path === "/onboarding" && req.method === "GET") {
458
+ try {
459
+ const onboardingPath = join(paths.workspace, ".onboarding.json");
460
+ if (existsSync(onboardingPath)) {
461
+ return Response.json(JSON.parse(readFileSync(onboardingPath, "utf-8")));
462
+ }
463
+ return Response.json({ completed: false, step: 0 });
464
+ } catch {
465
+ return Response.json({ completed: false, step: 0 });
466
+ }
467
+ }
468
+
469
+ // PUT /api/dashboard/onboarding
470
+ if (path === "/onboarding" && req.method === "PUT") {
471
+ return (async () => {
472
+ const body = await req.json() as any;
473
+ const onboardingPath = join(paths.workspace, ".onboarding.json");
474
+ writeFileSync(onboardingPath, JSON.stringify(body, null, 2));
475
+ return Response.json({ ok: true });
476
+ })() as any;
477
+ }
478
+
479
+ // POST /api/dashboard/test-llm
480
+ if (path === "/test-llm" && req.method === "POST") {
481
+ return (async () => {
482
+ try {
483
+ const { loadConfig } = await import("../config/loader");
484
+ const { createProvider } = await import("../llm/factory");
485
+ const config = await loadConfig();
486
+ const llm = createProvider(config);
487
+ const res = await llm.chat({
488
+ system: "You are a test.",
489
+ messages: [{ role: "user", content: [{ type: "text", text: "Say OK" }] }],
490
+ maxTokens: 10,
491
+ });
492
+ const text = res.content.find((b: any) => b.type === "text")?.text ?? "";
493
+ return Response.json({ ok: true, response: text, model: llm.model });
494
+ } catch (err: any) {
495
+ return Response.json({ ok: false, error: err.message });
496
+ }
497
+ })() as any;
498
+ }
499
+
500
+ // GET /api/dashboard/channel-status
501
+ if (path === "/channel-status" && req.method === "GET") {
502
+ try {
503
+ const config = JSON.parse(readFileSync(paths.config, "utf-8"));
504
+ const channels: Record<string, { configured: boolean; enabled: boolean }> = {};
505
+
506
+ channels.webchat = { configured: true, enabled: config.channels?.webchat?.enabled !== false };
507
+ channels.telegram = {
508
+ configured: !!(config.channels?.telegram?.botToken || config.telegramBotToken),
509
+ enabled: config.channels?.telegram?.enabled !== false && !!(config.channels?.telegram?.botToken || config.telegramBotToken),
510
+ };
511
+ channels.discord = {
512
+ configured: !!config.channels?.discord?.botToken,
513
+ enabled: config.channels?.discord?.enabled !== false && !!config.channels?.discord?.botToken,
514
+ };
515
+ channels.slack = {
516
+ configured: !!config.channels?.slack?.botToken,
517
+ enabled: config.channels?.slack?.enabled !== false && !!config.channels?.slack?.botToken,
518
+ };
519
+ channels.whatsapp = {
520
+ configured: !!config.channels?.whatsapp,
521
+ enabled: config.channels?.whatsapp?.enabled !== false && !!config.channels?.whatsapp,
522
+ };
523
+ channels.signal = {
524
+ configured: !!config.channels?.signal?.phoneNumber,
525
+ enabled: config.channels?.signal?.enabled !== false && !!config.channels?.signal?.phoneNumber,
526
+ };
527
+
528
+ return Response.json({ channels });
529
+ } catch {
530
+ return Response.json({ channels: {} });
531
+ }
532
+ }
533
+
534
+ // GET /api/dashboard/registry/search?q=...
535
+ if (path === "/registry/search" && req.method === "GET") {
536
+ return (async () => {
537
+ try {
538
+ const { searchRegistry } = await import("../registry/client");
539
+ const q = url.searchParams.get("q") ?? "";
540
+ const results = await searchRegistry(q);
541
+ return Response.json({ results });
542
+ } catch (err: any) {
543
+ return Response.json({ results: [], error: err.message });
544
+ }
545
+ })() as any;
546
+ }
547
+
548
+ // POST /api/dashboard/registry/install
549
+ if (path === "/registry/install" && req.method === "POST") {
550
+ return (async () => {
551
+ try {
552
+ const body = await req.json() as { name?: string };
553
+ if (!body.name) return Response.json({ ok: false, error: "name required" }, { status: 400 });
554
+ const { installFromRegistry } = await import("../registry/installer");
555
+ const result = await installFromRegistry(body.name);
556
+ return Response.json({ ok: true, ...result });
557
+ } catch (err: any) {
558
+ return Response.json({ ok: false, error: err.message });
559
+ }
560
+ })() as any;
561
+ }
562
+
563
+ // GET /api/dashboard/workflows
564
+ if (path === "/workflows" && req.method === "GET") {
565
+ return (async () => {
566
+ try {
567
+ const { loadWorkflowDefinitions } = await import("../agent/workflow");
568
+ const workflows = loadWorkflowDefinitions();
569
+ return Response.json({ workflows: workflows.map((w: any) => ({ name: w.name, description: w.description, agents: w.agents, steps: w.steps.length })) });
570
+ } catch {
571
+ return Response.json({ workflows: [] });
572
+ }
573
+ })() as any;
574
+ }
575
+
576
+ // GET /api/dashboard/recipes
577
+ if (path === "/recipes" && req.method === "GET") {
578
+ return (async () => {
579
+ try {
580
+ const { WORKFLOW_RECIPES } = await import("../scheduler/recipes");
581
+ const db = getDb();
582
+ // Check which recipes are already installed as cron jobs
583
+ const installedJobs = db.query("SELECT name FROM cron_jobs").all() as { name: string }[];
584
+ const installedNames = new Set(installedJobs.map(j => j.name));
585
+
586
+ const recipes = WORKFLOW_RECIPES.map(r => ({
587
+ ...r,
588
+ installed: installedNames.has(r.id),
589
+ }));
590
+ return Response.json({ recipes });
591
+ } catch (err: any) {
592
+ return Response.json({ recipes: [], error: err.message });
593
+ }
594
+ })() as any;
595
+ }
596
+
597
+ // POST /api/dashboard/recipes/install
598
+ if (path === "/recipes/install" && req.method === "POST") {
599
+ return (async () => {
600
+ try {
601
+ const body = (await req.json()) as { id?: string };
602
+ if (!body.id) return Response.json({ ok: false, error: "Recipe ID required" }, { status: 400 });
603
+
604
+ const { getRecipeById } = await import("../scheduler/recipes");
605
+ const recipe = getRecipeById(body.id);
606
+ if (!recipe) return Response.json({ ok: false, error: "Recipe not found" }, { status: 404 });
607
+
608
+ const db = getDb();
609
+ // Check if already installed
610
+ const existing = db.query("SELECT id FROM cron_jobs WHERE name = ?").get(recipe.id);
611
+ if (existing) return Response.json({ ok: false, error: "Recipe already installed" }, { status: 409 });
612
+
613
+ // Insert as a cron job
614
+ db.prepare(
615
+ "INSERT INTO cron_jobs (name, schedule, task) VALUES (?, ?, ?)"
616
+ ).run(recipe.id, recipe.schedule, recipe.task);
617
+
618
+ return Response.json({ ok: true, name: recipe.name });
619
+ } catch (err: any) {
620
+ return Response.json({ ok: false, error: err.message }, { status: 500 });
621
+ }
622
+ })() as any;
623
+ }
624
+
625
+ // POST /api/dashboard/recipes/uninstall
626
+ if (path === "/recipes/uninstall" && req.method === "POST") {
627
+ return (async () => {
628
+ try {
629
+ const body = (await req.json()) as { id?: string };
630
+ if (!body.id) return Response.json({ ok: false, error: "Recipe ID required" }, { status: 400 });
631
+
632
+ // Validate that this is a known recipe ID — prevent arbitrary cron job deletion
633
+ const { getRecipeById } = await import("../scheduler/recipes");
634
+ if (!getRecipeById(body.id)) {
635
+ return Response.json({ ok: false, error: "Unknown recipe ID" }, { status: 400 });
636
+ }
637
+
638
+ const db = getDb();
639
+ const result = db.prepare("DELETE FROM cron_jobs WHERE name = ?").run(body.id);
640
+ return Response.json({ ok: true, deleted: result.changes > 0 });
641
+ } catch (err: any) {
642
+ return Response.json({ ok: false, error: err.message }, { status: 500 });
643
+ }
644
+ })() as any;
645
+ }
646
+
647
+ // POST /api/dashboard/export — JSON export
648
+ if (path === "/export" && req.method === "POST") {
649
+ try {
650
+ const db = getDb();
651
+ const outputPath = join(paths.workspace, `export-${Date.now()}.json`);
652
+ exportDatabase(db, outputPath);
653
+ const data = readFileSync(outputPath, "utf-8");
654
+ // Clean up temp file
655
+ try { const { unlinkSync } = require("fs"); unlinkSync(outputPath); } catch (err: any) { logger.warn("Failed to clean up export file", { error: (err as Error).message }); }
656
+ return new Response(data, {
657
+ headers: {
658
+ "Content-Type": "application/json",
659
+ "Content-Disposition": `attachment; filename="zubo-export.json"`,
660
+ },
661
+ });
662
+ } catch (err: any) {
663
+ return Response.json({ error: err.message }, { status: 500 });
664
+ }
665
+ }
666
+
667
+ // POST /api/dashboard/backup — SQLite backup
668
+ if (path === "/backup" && req.method === "POST") {
669
+ try {
670
+ const backupPath = backupDatabase(paths.db, paths.workspace);
671
+ return Response.json({ ok: true, path: backupPath });
672
+ } catch (err: any) {
673
+ return Response.json({ error: err.message }, { status: 500 });
674
+ }
675
+ }
676
+
677
+ // POST /api/dashboard/import — JSON import (max 100MB)
678
+ if (path === "/import" && req.method === "POST") {
679
+ return (async () => {
680
+ const tmpPath = join(paths.workspace, `import-${Date.now()}.json`);
681
+ try {
682
+ const contentLength = parseInt(req.headers.get("content-length") ?? "0", 10);
683
+ if (contentLength > 100 * 1024 * 1024) {
684
+ return Response.json({ error: "Import too large (max 100MB)" }, { status: 413 });
685
+ }
686
+ const body = await req.text();
687
+ if (body.length > 100 * 1024 * 1024) {
688
+ return Response.json({ error: "Import too large (max 100MB)" }, { status: 413 });
689
+ }
690
+ writeFileSync(tmpPath, body);
691
+ const db = getDb();
692
+ const result = importDatabase(db, tmpPath);
693
+ return Response.json({ ok: true, ...result });
694
+ } catch (err: any) {
695
+ return Response.json({ error: err.message }, { status: 500 });
696
+ } finally {
697
+ try { const { unlinkSync } = require("fs"); unlinkSync(tmpPath); } catch (err: any) { logger.warn("Failed to clean up import temp file", { error: (err as Error).message }); }
698
+ }
699
+ })() as any;
700
+ }
701
+
702
+ // GET /api/dashboard/db-stats
703
+ if (path === "/db-stats" && req.method === "GET") {
704
+ try {
705
+ const db = getDb();
706
+ const stats = getDbStats(db);
707
+ const sizeBytes = getDbSizeBytes(paths.db);
708
+ return Response.json({ ...stats, sizeBytes });
709
+ } catch {
710
+ return Response.json({ tables: {}, sizeBytes: 0 });
711
+ }
712
+ }
713
+
714
+ // GET /api/dashboard/agents
715
+ if (path === "/agents" && req.method === "GET") {
716
+ return (async () => {
717
+ try {
718
+ const { loadAgentDefinitions } = await import("../agent/agents");
719
+ const agents = loadAgentDefinitions();
720
+ return Response.json({ agents: agents.map((a: any) => ({ name: a.name, description: a.description, tools: a.tools?.length ?? 0 })) });
721
+ } catch {
722
+ return Response.json({ agents: [] });
723
+ }
724
+ })() as any;
725
+ }
726
+
727
+ // GET /api/dashboard/analytics/perf-snapshots
728
+ if (path === "/analytics/perf-snapshots" && req.method === "GET") {
729
+ try {
730
+ const db = getDb();
731
+ const rows = db.query(
732
+ `SELECT rss_mb, heap_mb, db_size_mb, created_at
733
+ FROM perf_snapshots WHERE created_at >= datetime('now', '-7 days')
734
+ ORDER BY created_at`
735
+ ).all();
736
+ return Response.json({ snapshots: rows });
737
+ } catch {
738
+ return Response.json({ snapshots: [] });
739
+ }
740
+ }
741
+
742
+ // GET /api/dashboard/analytics/cost-breakdown
743
+ if (path === "/analytics/cost-breakdown" && req.method === "GET") {
744
+ try {
745
+ const db = getDb();
746
+ const rows = db.query(
747
+ `SELECT provider, model,
748
+ SUM(input_tokens + output_tokens) as total_tokens,
749
+ COALESCE(SUM(cost_usd), 0) as total_cost,
750
+ COUNT(*) as requests
751
+ FROM usage GROUP BY provider, model ORDER BY total_cost DESC`
752
+ ).all();
753
+ return Response.json({ breakdown: rows });
754
+ } catch {
755
+ return Response.json({ breakdown: [] });
756
+ }
757
+ }
758
+
759
+ // GET /api/dashboard/analytics/response-time-trend
760
+ if (path === "/analytics/response-time-trend" && req.method === "GET") {
761
+ try {
762
+ const db = getDb();
763
+ const rows = db.query(
764
+ `SELECT date(created_at) as day,
765
+ ROUND(AVG(response_time_ms)) as avg_ms,
766
+ MIN(response_time_ms) as min_ms,
767
+ MAX(response_time_ms) as max_ms,
768
+ COUNT(*) as requests
769
+ FROM usage WHERE response_time_ms IS NOT NULL
770
+ AND created_at >= datetime('now', '-7 days')
771
+ GROUP BY day ORDER BY day`
772
+ ).all();
773
+ return Response.json({ trend: rows });
774
+ } catch {
775
+ return Response.json({ trend: [] });
776
+ }
777
+ }
778
+
779
+ // GET /api/dashboard/analytics/top-models
780
+ if (path === "/analytics/top-models" && req.method === "GET") {
781
+ try {
782
+ const db = getDb();
783
+ const rows = db.query(
784
+ `SELECT provider, model,
785
+ COUNT(*) as requests,
786
+ SUM(input_tokens + output_tokens) as total_tokens,
787
+ COALESCE(SUM(cost_usd), 0) as total_cost,
788
+ ROUND(AVG(response_time_ms)) as avg_response_ms
789
+ FROM usage GROUP BY provider, model
790
+ ORDER BY total_tokens DESC LIMIT 10`
791
+ ).all();
792
+ return Response.json({ models: rows });
793
+ } catch {
794
+ return Response.json({ models: [] });
795
+ }
796
+ }
797
+
798
+ // --- Budget controls ---
799
+
800
+ // GET /api/dashboard/budget
801
+ if (path === "/budget" && req.method === "GET") {
802
+ try {
803
+ const db = getDb();
804
+ // Ensure table exists (migration may not have run yet)
805
+ db.run(`CREATE TABLE IF NOT EXISTS budget_config (
806
+ id INTEGER PRIMARY KEY CHECK (id = 1),
807
+ daily_limit_usd REAL,
808
+ monthly_limit_usd REAL,
809
+ alert_threshold REAL DEFAULT 0.8,
810
+ paused INTEGER NOT NULL DEFAULT 0,
811
+ updated_at TEXT NOT NULL DEFAULT (datetime('now'))
812
+ )`);
813
+ db.run("INSERT OR IGNORE INTO budget_config (id) VALUES (1)");
814
+
815
+ const config = db.query("SELECT * FROM budget_config WHERE id = 1").get() as any;
816
+
817
+ // Calculate current spend
818
+ const todaySpend = db.query(
819
+ "SELECT COALESCE(SUM(cost_usd), 0) as total FROM usage WHERE date(created_at) = date('now') AND cost_usd IS NOT NULL"
820
+ ).get() as any;
821
+
822
+ const monthSpend = db.query(
823
+ "SELECT COALESCE(SUM(cost_usd), 0) as total FROM usage WHERE created_at >= datetime('now', 'start of month') AND cost_usd IS NOT NULL"
824
+ ).get() as any;
825
+
826
+ // Last 7 days daily breakdown
827
+ const dailyBreakdown = db.query(
828
+ `SELECT date(created_at) as day, COALESCE(SUM(cost_usd), 0) as cost, SUM(input_tokens + output_tokens) as tokens
829
+ FROM usage WHERE created_at >= datetime('now', '-7 days') AND cost_usd IS NOT NULL
830
+ GROUP BY day ORDER BY day`
831
+ ).all();
832
+
833
+ return Response.json({
834
+ daily_limit_usd: config?.daily_limit_usd ?? null,
835
+ monthly_limit_usd: config?.monthly_limit_usd ?? null,
836
+ alert_threshold: config?.alert_threshold ?? 0.8,
837
+ paused: config?.paused === 1,
838
+ today_spend_usd: Math.round((todaySpend?.total ?? 0) * 10000) / 10000,
839
+ month_spend_usd: Math.round((monthSpend?.total ?? 0) * 10000) / 10000,
840
+ daily_breakdown: dailyBreakdown,
841
+ });
842
+ } catch (err: any) {
843
+ return Response.json({ error: err.message }, { status: 500 });
844
+ }
845
+ }
846
+
847
+ // PUT /api/dashboard/budget
848
+ if (path === "/budget" && req.method === "PUT") {
849
+ return (async () => {
850
+ try {
851
+ const body = (await req.json()) as {
852
+ daily_limit_usd?: number | null;
853
+ monthly_limit_usd?: number | null;
854
+ alert_threshold?: number;
855
+ };
856
+ const db = getDb();
857
+ db.run(`CREATE TABLE IF NOT EXISTS budget_config (
858
+ id INTEGER PRIMARY KEY CHECK (id = 1),
859
+ daily_limit_usd REAL,
860
+ monthly_limit_usd REAL,
861
+ alert_threshold REAL DEFAULT 0.8,
862
+ paused INTEGER NOT NULL DEFAULT 0,
863
+ updated_at TEXT NOT NULL DEFAULT (datetime('now'))
864
+ )`);
865
+ db.run("INSERT OR IGNORE INTO budget_config (id) VALUES (1)");
866
+
867
+ db.prepare(
868
+ `UPDATE budget_config SET
869
+ daily_limit_usd = ?,
870
+ monthly_limit_usd = ?,
871
+ alert_threshold = ?,
872
+ paused = 0,
873
+ updated_at = datetime('now')
874
+ WHERE id = 1`
875
+ ).run(
876
+ body.daily_limit_usd ?? null,
877
+ body.monthly_limit_usd ?? null,
878
+ body.alert_threshold ?? 0.8
879
+ );
880
+ return Response.json({ ok: true });
881
+ } catch (err: any) {
882
+ return Response.json({ error: err.message }, { status: 500 });
883
+ }
884
+ })() as any;
885
+ }
886
+
887
+ // --- Privacy dashboard ---
888
+
889
+ // GET /api/dashboard/privacy/summary
890
+ if (path === "/privacy/summary" && req.method === "GET") {
891
+ try {
892
+ const db = getDb();
893
+
894
+ const memoryCount = (db.query("SELECT COUNT(*) as c FROM memory_chunks").get() as any)?.c ?? 0;
895
+ const messageCount = (db.query("SELECT COUNT(*) as c FROM messages").get() as any)?.c ?? 0;
896
+ const sessionCount = (db.query("SELECT COUNT(DISTINCT session_id) as c FROM messages").get() as any)?.c ?? 0;
897
+ const secretCount = (db.query("SELECT COUNT(*) as c FROM secrets").get() as any)?.c ?? 0;
898
+ const cronCount = (db.query("SELECT COUNT(*) as c FROM cron_jobs").get() as any)?.c ?? 0;
899
+ const apiCallCount = (db.query("SELECT COUNT(*) as c FROM usage").get() as any)?.c ?? 0;
900
+ const toolCallCount = (db.query("SELECT COUNT(*) as c FROM tool_metrics").get() as any)?.c ?? 0;
901
+ const totalTokensSent = (db.query("SELECT COALESCE(SUM(input_tokens), 0) as t FROM usage").get() as any)?.t ?? 0;
902
+ const totalTokensReceived = (db.query("SELECT COALESCE(SUM(output_tokens), 0) as t FROM usage").get() as any)?.t ?? 0;
903
+
904
+ // Data by provider
905
+ const providerBreakdown = db.query(
906
+ "SELECT provider, COUNT(*) as calls, SUM(input_tokens) as tokens_sent FROM usage GROUP BY provider ORDER BY calls DESC"
907
+ ).all();
908
+
909
+ return Response.json({
910
+ memoryCount,
911
+ messageCount,
912
+ sessionCount,
913
+ secretCount,
914
+ cronCount,
915
+ apiCallCount,
916
+ toolCallCount,
917
+ totalTokensSent,
918
+ totalTokensReceived,
919
+ providerBreakdown,
920
+ });
921
+ } catch (err: any) {
922
+ return Response.json({ error: err.message }, { status: 500 });
923
+ }
924
+ }
925
+
926
+ // GET /api/dashboard/privacy/api-log
927
+ if (path === "/privacy/api-log" && req.method === "GET") {
928
+ try {
929
+ const db = getDb();
930
+ const limit = parseInt(url.searchParams.get("limit") ?? "50", 10);
931
+ const offset = parseInt(url.searchParams.get("offset") ?? "0", 10);
932
+
933
+ const rows = db.query(
934
+ `SELECT id, session_id, provider, model, input_tokens, output_tokens,
935
+ cost_usd, response_time_ms, created_at
936
+ FROM usage ORDER BY created_at DESC LIMIT ? OFFSET ?`
937
+ ).all(Math.min(limit, 100), offset);
938
+
939
+ const total = (db.query("SELECT COUNT(*) as c FROM usage").get() as any)?.c ?? 0;
940
+
941
+ return Response.json({ rows, total, limit, offset });
942
+ } catch (err: any) {
943
+ return Response.json({ rows: [], total: 0, error: err.message });
944
+ }
945
+ }
946
+
947
+ // GET /api/dashboard/privacy/tool-log
948
+ if (path === "/privacy/tool-log" && req.method === "GET") {
949
+ try {
950
+ const db = getDb();
951
+ const limit = parseInt(url.searchParams.get("limit") ?? "50", 10);
952
+ const offset = parseInt(url.searchParams.get("offset") ?? "0", 10);
953
+
954
+ const rows = db.query(
955
+ `SELECT id, tool_name, session_id, duration_ms, success, created_at
956
+ FROM tool_metrics ORDER BY created_at DESC LIMIT ? OFFSET ?`
957
+ ).all(Math.min(limit, 100), offset);
958
+
959
+ const total = (db.query("SELECT COUNT(*) as c FROM tool_metrics").get() as any)?.c ?? 0;
960
+
961
+ return Response.json({ rows, total, limit, offset });
962
+ } catch (err: any) {
963
+ return Response.json({ rows: [], total: 0, error: err.message });
964
+ }
965
+ }
966
+
967
+ // POST /api/dashboard/privacy/wipe-memories
968
+ if (path === "/privacy/wipe-memories" && req.method === "POST") {
969
+ return (async () => {
970
+ try {
971
+ const body = (await req.json()) as { confirm?: string };
972
+ if (body.confirm !== "DELETE") return Response.json({ ok: false, error: "Confirmation required: send { confirm: \"DELETE\" }" }, { status: 400 });
973
+ const db = getDb();
974
+ db.run("DELETE FROM memory_chunks");
975
+ db.run("DELETE FROM memory_fts");
976
+ return Response.json({ ok: true, message: "All memories deleted" });
977
+ } catch (err: any) {
978
+ return Response.json({ ok: false, error: err.message }, { status: 500 });
979
+ }
980
+ })() as any;
981
+ }
982
+
983
+ // POST /api/dashboard/privacy/wipe-messages
984
+ if (path === "/privacy/wipe-messages" && req.method === "POST") {
985
+ return (async () => {
986
+ try {
987
+ const body = (await req.json()) as { confirm?: string };
988
+ if (body.confirm !== "DELETE") return Response.json({ ok: false, error: "Confirmation required: send { confirm: \"DELETE\" }" }, { status: 400 });
989
+ const db = getDb();
990
+ db.run("DELETE FROM messages");
991
+ return Response.json({ ok: true, message: "All messages deleted" });
992
+ } catch (err: any) {
993
+ return Response.json({ ok: false, error: err.message }, { status: 500 });
994
+ }
995
+ })() as any;
996
+ }
997
+
998
+ // POST /api/dashboard/privacy/wipe-usage
999
+ if (path === "/privacy/wipe-usage" && req.method === "POST") {
1000
+ return (async () => {
1001
+ try {
1002
+ const body = (await req.json()) as { confirm?: string };
1003
+ if (body.confirm !== "DELETE") return Response.json({ ok: false, error: "Confirmation required: send { confirm: \"DELETE\" }" }, { status: 400 });
1004
+ const db = getDb();
1005
+ db.run("DELETE FROM usage");
1006
+ db.run("DELETE FROM tool_metrics");
1007
+ return Response.json({ ok: true, message: "All usage data deleted" });
1008
+ } catch (err: any) {
1009
+ return Response.json({ ok: false, error: err.message }, { status: 500 });
1010
+ }
1011
+ })() as any;
1012
+ }
1013
+
1014
+ // POST /api/dashboard/privacy/wipe-all
1015
+ if (path === "/privacy/wipe-all" && req.method === "POST") {
1016
+ return (async () => {
1017
+ try {
1018
+ const body = (await req.json()) as { confirm?: string };
1019
+ if (body.confirm !== "DELETE_ALL") return Response.json({ ok: false, error: "Confirmation required: send { confirm: \"DELETE_ALL\" }" }, { status: 400 });
1020
+ const db = getDb();
1021
+ db.run("DELETE FROM memory_chunks");
1022
+ db.run("DELETE FROM memory_fts");
1023
+ db.run("DELETE FROM messages");
1024
+ db.run("DELETE FROM usage");
1025
+ db.run("DELETE FROM tool_metrics");
1026
+ db.run("DELETE FROM secrets");
1027
+ db.run("DELETE FROM cron_logs");
1028
+ return Response.json({ ok: true, message: "All data wiped" });
1029
+ } catch (err: any) {
1030
+ return Response.json({ ok: false, error: err.message }, { status: 500 });
1031
+ }
1032
+ })() as any;
1033
+ }
1034
+
1035
+ // --- Secrets management ---
1036
+
1037
+ // GET /api/dashboard/secrets — list all secrets (values masked) + config provider keys
1038
+ if (path === "/secrets" && req.method === "GET") {
1039
+ try {
1040
+ const db = getDb();
1041
+ const rows = db.query("SELECT name, service, updated_at FROM secrets ORDER BY name").all() as { name: string; service: string | null; updated_at: string }[];
1042
+
1043
+ // Also surface provider API keys from config (read-only)
1044
+ const configKeys: { name: string; service: string; updated_at: string; source: string }[] = [];
1045
+ try {
1046
+ const cfg = JSON.parse(readFileSync(paths.config, "utf-8"));
1047
+ if (cfg.providers) {
1048
+ for (const [provider, pCfg] of Object.entries(cfg.providers)) {
1049
+ const pc = pCfg as Record<string, unknown>;
1050
+ if (pc.apiKey && typeof pc.apiKey === "string") {
1051
+ configKeys.push({
1052
+ name: `${provider}_api_key`,
1053
+ service: provider,
1054
+ updated_at: "",
1055
+ source: "config",
1056
+ });
1057
+ }
1058
+ }
1059
+ }
1060
+ } catch (err: any) {
1061
+ logger.warn("Failed to read provider API keys from config", { error: (err as Error).message });
1062
+ }
1063
+
1064
+ const secrets = [
1065
+ ...configKeys,
1066
+ ...rows.map(r => ({ ...r, source: "secrets" })),
1067
+ ];
1068
+ return Response.json({ secrets });
1069
+ } catch {
1070
+ return Response.json({ secrets: [] });
1071
+ }
1072
+ }
1073
+
1074
+ // GET /api/dashboard/secrets/:name — reveal a single secret value
1075
+ if (path.startsWith("/secrets/") && req.method === "GET") {
1076
+ const secretName = decodeURIComponent(path.replace("/secrets/", ""));
1077
+ if (!secretName || !/^[a-z0-9_]+$/.test(secretName)) {
1078
+ return Response.json({ error: "Invalid secret name" }, { status: 400 });
1079
+ }
1080
+ try {
1081
+ // Check config provider keys first
1082
+ if (secretName.endsWith("_api_key")) {
1083
+ const provider = secretName.replace(/_api_key$/, "");
1084
+ try {
1085
+ const cfg = JSON.parse(readFileSync(paths.config, "utf-8"));
1086
+ if (cfg.providers?.[provider]?.apiKey) {
1087
+ return Response.json({ name: secretName, value: cfg.providers[provider].apiKey, source: "config" });
1088
+ }
1089
+ } catch (err: any) {
1090
+ logger.warn("Failed to read provider secret from config", { error: (err as Error).message });
1091
+ }
1092
+ }
1093
+ const db = getDb();
1094
+ const row = db.query("SELECT value FROM secrets WHERE name = ?").get(secretName) as { value: string } | null;
1095
+ if (!row) return Response.json({ error: "Not found" }, { status: 404 });
1096
+ return Response.json({ name: secretName, value: row.value });
1097
+ } catch (err: any) {
1098
+ return Response.json({ error: err.message }, { status: 500 });
1099
+ }
1100
+ }
1101
+
1102
+ // POST /api/dashboard/secrets — create or update a secret
1103
+ if (path === "/secrets" && req.method === "POST") {
1104
+ return (async () => {
1105
+ const body = (await req.json()) as { name?: string; value?: string; service?: string };
1106
+ if (!body.name || !/^[a-z0-9_]+$/.test(body.name)) {
1107
+ return Response.json({ error: "Name must match [a-z0-9_]+" }, { status: 400 });
1108
+ }
1109
+ if (!body.value) {
1110
+ return Response.json({ error: "Value is required" }, { status: 400 });
1111
+ }
1112
+ try {
1113
+ const db = getDb();
1114
+ db.prepare(
1115
+ `INSERT INTO secrets (name, value, service, updated_at)
1116
+ VALUES (?, ?, ?, datetime('now'))
1117
+ ON CONFLICT(name) DO UPDATE SET value = excluded.value, service = excluded.service, updated_at = datetime('now')`
1118
+ ).run(body.name, body.value, body.service ?? null);
1119
+ return Response.json({ ok: true });
1120
+ } catch (err: any) {
1121
+ return Response.json({ error: err.message }, { status: 500 });
1122
+ }
1123
+ })() as any;
1124
+ }
1125
+
1126
+ // DELETE /api/dashboard/secrets/:name — delete a secret
1127
+ if (path.startsWith("/secrets/") && req.method === "DELETE") {
1128
+ const secretName = decodeURIComponent(path.replace("/secrets/", ""));
1129
+ if (!secretName || !/^[a-z0-9_]+$/.test(secretName)) {
1130
+ return Response.json({ error: "Invalid secret name" }, { status: 400 });
1131
+ }
1132
+ try {
1133
+ const db = getDb();
1134
+ const result = db.prepare("DELETE FROM secrets WHERE name = ?").run(secretName);
1135
+ return Response.json({ deleted: result.changes > 0 });
1136
+ } catch (err: any) {
1137
+ return Response.json({ error: err.message }, { status: 500 });
1138
+ }
1139
+ }
1140
+
1141
+ // --- Webhook ingress ---
1142
+
1143
+ // POST /api/dashboard/webhook/:name
1144
+ if (path.startsWith("/webhook/") && req.method === "POST") {
1145
+ const webhookName = path.slice("/webhook/".length);
1146
+ if (!webhookName || !/^[a-z0-9_-]+$/i.test(webhookName)) {
1147
+ return Response.json({ error: "Invalid webhook name" }, { status: 400 });
1148
+ }
1149
+ return (async () => {
1150
+ try {
1151
+ // Budget enforcement — block webhooks when budget exceeded
1152
+ const db = getDb();
1153
+ try {
1154
+ const budgetConfig = db.query("SELECT paused FROM budget_config WHERE id = 1").get() as { paused: number } | null;
1155
+ if (budgetConfig?.paused) {
1156
+ return Response.json({ error: "Budget exceeded — agent is paused" }, { status: 429 });
1157
+ }
1158
+ } catch { /* budget table may not exist */ }
1159
+
1160
+ const payload = await req.json();
1161
+ const summary = JSON.stringify(payload).slice(0, 500);
1162
+ const message = `[Webhook: ${webhookName}] ${summary}`;
1163
+ const { loadConfig } = await import("../config/loader");
1164
+ const { createProvider } = await import("../llm/factory");
1165
+ const config = await loadConfig();
1166
+ const webhookLlm = createProvider(config);
1167
+ const { agentLoop } = await import("../agent/loop");
1168
+ const sessionKey = `webhook:${webhookName}`;
1169
+ const result = await agentLoop(webhookLlm, sessionKey, message);
1170
+ return Response.json({ ok: true, reply: result.reply });
1171
+ } catch (err: any) {
1172
+ return Response.json({ error: err.message }, { status: 500 });
1173
+ }
1174
+ })() as any;
1175
+ }
1176
+
1177
+ // --- Conversation threads CRUD ---
1178
+
1179
+ // GET /api/dashboard/threads — list all threads
1180
+ if (path === "/threads" && req.method === "GET") {
1181
+ try {
1182
+ const db = getDb();
1183
+ const threads = db.query(
1184
+ "SELECT id, title, created_at, updated_at FROM threads ORDER BY updated_at DESC"
1185
+ ).all();
1186
+ return Response.json({ threads });
1187
+ } catch (err: any) {
1188
+ return Response.json({ threads: [], error: err.message });
1189
+ }
1190
+ }
1191
+
1192
+ // POST /api/dashboard/threads — create new thread
1193
+ if (path === "/threads" && req.method === "POST") {
1194
+ return (async () => {
1195
+ const { title } = await req.json().catch(() => ({ title: undefined }));
1196
+ const id = crypto.randomUUID();
1197
+ const db = getDb();
1198
+ db.prepare(
1199
+ "INSERT INTO threads (id, title) VALUES (?, ?)"
1200
+ ).run(id, title || "New conversation");
1201
+ return Response.json({ id, title: title || "New conversation" });
1202
+ })() as any;
1203
+ }
1204
+
1205
+ // PUT /api/dashboard/threads/:id — rename thread
1206
+ if (path.match(/^\/threads\/[a-f0-9-]+$/) && req.method === "PUT") {
1207
+ return (async () => {
1208
+ const threadId = path.split("/").pop()!;
1209
+ const { title } = await req.json();
1210
+ const db = getDb();
1211
+ db.prepare(
1212
+ "UPDATE threads SET title = ?, updated_at = datetime('now') WHERE id = ?"
1213
+ ).run(title, threadId);
1214
+ return Response.json({ ok: true });
1215
+ })() as any;
1216
+ }
1217
+
1218
+ // DELETE /api/dashboard/threads/:id — delete thread and session file
1219
+ if (path.match(/^\/threads\/[a-f0-9-]+$/) && req.method === "DELETE") {
1220
+ const threadId = path.split("/").pop()!;
1221
+ try {
1222
+ const db = getDb();
1223
+ db.prepare("DELETE FROM threads WHERE id = ?").run(threadId);
1224
+ const sessionPath = join(paths.sessions, threadId + ".jsonl");
1225
+ if (existsSync(sessionPath)) unlinkSync(sessionPath);
1226
+ return Response.json({ ok: true });
1227
+ } catch (err: any) {
1228
+ return Response.json({ error: err.message }, { status: 500 });
1229
+ }
1230
+ }
1231
+
1232
+ // GET /api/dashboard/threads/:id/messages — get thread messages
1233
+ if (path.match(/^\/threads\/[a-f0-9-]+\/messages$/) && req.method === "GET") {
1234
+ return (async () => {
1235
+ const threadId = path.split("/")[2];
1236
+ const { loadSession } = await import("../agent/session");
1237
+ const messages = loadSession(threadId, 100);
1238
+ return Response.json({ messages });
1239
+ })() as any;
1240
+ }
1241
+
1242
+ // GET /api/dashboard/threads/:id/export — export thread as markdown
1243
+ if (path.match(/^\/threads\/[a-f0-9-]+\/export$/) && req.method === "GET") {
1244
+ return (async () => {
1245
+ const threadId = path.split("/")[2];
1246
+ const { loadSession } = await import("../agent/session");
1247
+ const messages = loadSession(threadId, 1000);
1248
+ const db = getDb();
1249
+ const thread = db.query(
1250
+ "SELECT title FROM threads WHERE id = ?"
1251
+ ).get(threadId) as { title: string } | null;
1252
+
1253
+ let md = "# " + (thread?.title || "Conversation") + "\n\n";
1254
+ for (const m of messages) {
1255
+ const role = m.role === "user" ? "**You**" : "**Zubo**";
1256
+ const text = Array.isArray(m.content)
1257
+ ? m.content
1258
+ .filter((b: any) => b.type === "text")
1259
+ .map((b: any) => b.text)
1260
+ .join("\n")
1261
+ : String(m.content);
1262
+ md += role + ": " + text + "\n\n";
1263
+ }
1264
+
1265
+ return new Response(md, {
1266
+ headers: {
1267
+ "Content-Type": "text/markdown",
1268
+ "Content-Disposition": `attachment; filename="${threadId.slice(0, 8)}.md"`,
1269
+ },
1270
+ });
1271
+ })() as any;
1272
+ }
1273
+
1274
+ return null;
1275
+ }
1276
+
1277
+ export interface WebChatAdapter extends ChannelAdapter {
1278
+ getPort(): number;
1279
+ }
1280
+
1281
+ // Cached config values — refreshed every 30s to avoid reading disk on every request
1282
+ let _cachedRateLimit: { chatPerMinute: number; uploadPerMinute: number } | null = null;
1283
+ let _cachedAuthEnabled: boolean | null = null;
1284
+ let _configCacheTime = 0;
1285
+ const CONFIG_CACHE_TTL = 30_000;
1286
+
1287
+ function refreshConfigCache(): void {
1288
+ const now = Date.now();
1289
+ if (_cachedRateLimit !== null && now - _configCacheTime < CONFIG_CACHE_TTL) return;
1290
+ _configCacheTime = now;
1291
+ try {
1292
+ const config = JSON.parse(readFileSync(paths.config, "utf-8"));
1293
+ _cachedRateLimit = {
1294
+ chatPerMinute: config.rateLimit?.chatPerMinute ?? 60,
1295
+ uploadPerMinute: config.rateLimit?.uploadPerMinute ?? 10,
1296
+ };
1297
+ _cachedAuthEnabled = config.auth?.enabled === true;
1298
+ } catch {
1299
+ _cachedRateLimit = { chatPerMinute: 60, uploadPerMinute: 10 };
1300
+ _cachedAuthEnabled = false;
1301
+ }
1302
+ }
1303
+
1304
+ function getRateLimitConfig(): { chatPerMinute: number; uploadPerMinute: number } {
1305
+ refreshConfigCache();
1306
+ return _cachedRateLimit!;
1307
+ }
1308
+
1309
+ function isAuthEnabled(): boolean {
1310
+ refreshConfigCache();
1311
+ return _cachedAuthEnabled!;
1312
+ }
1313
+
1314
+ function getClientIp(req: Request, server: any): string {
1315
+ // Prefer actual connection IP from Bun server to prevent header spoofing
1316
+ try {
1317
+ const addr = server?.requestIP?.(req);
1318
+ if (addr?.address) return addr.address;
1319
+ } catch (err: any) {
1320
+ logger.warn("Failed to get client IP from server", { error: (err as Error).message });
1321
+ }
1322
+ // Fallback to x-forwarded-for only if behind a trusted proxy
1323
+ return req.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ?? "127.0.0.1";
1324
+ }
1325
+
1326
+ export function createWebChatAdapter(
1327
+ port: number,
1328
+ router: MessageRouter
1329
+ ): WebChatAdapter {
1330
+ let server: ReturnType<typeof Bun.serve> | null = null;
1331
+ const sessionKey = "webchat:local";
1332
+
1333
+ const rlConfig = getRateLimitConfig();
1334
+ const chatLimiter = new RateLimiter(rlConfig.chatPerMinute, 60_000);
1335
+ const uploadLimiter = new RateLimiter(rlConfig.uploadPerMinute, 60_000);
1336
+
1337
+ return {
1338
+ channelName: "webchat",
1339
+
1340
+ getPort() {
1341
+ return server?.port ?? port;
1342
+ },
1343
+
1344
+ start() {
1345
+ // Ensure threads table exists
1346
+ try {
1347
+ const db = getDb();
1348
+ db.run(`CREATE TABLE IF NOT EXISTS threads (
1349
+ id TEXT PRIMARY KEY,
1350
+ title TEXT NOT NULL DEFAULT 'New conversation',
1351
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
1352
+ updated_at TEXT NOT NULL DEFAULT (datetime('now'))
1353
+ )`);
1354
+ } catch (err: any) {
1355
+ logger.warn("Failed to create threads table", { error: (err as Error).message });
1356
+ }
1357
+
1358
+ server = Bun.serve({
1359
+ port,
1360
+ async fetch(req) {
1361
+ const url = new URL(req.url);
1362
+
1363
+ // Health check
1364
+ if (url.pathname === "/health") {
1365
+ return Response.json({ status: "ok", uptime: Math.floor(process.uptime()) });
1366
+ }
1367
+
1368
+ // Unified UI (Agent chat + Dashboard)
1369
+ if (url.pathname === "/" || url.pathname === "/index.html") {
1370
+ return new Response(DASHBOARD_HTML, {
1371
+ headers: { "Content-Type": "text/html" },
1372
+ });
1373
+ }
1374
+
1375
+ // Legacy /dashboard → redirect to #status
1376
+ if (url.pathname === "/dashboard") {
1377
+ return new Response(null, {
1378
+ status: 302,
1379
+ headers: { Location: "/#status" },
1380
+ });
1381
+ }
1382
+
1383
+ // Health check (no auth required)
1384
+ if (url.pathname === "/api/health") {
1385
+ const uptime = process.uptime();
1386
+ let dbOk = false;
1387
+ try {
1388
+ getDb().query("SELECT 1").get();
1389
+ dbOk = true;
1390
+ } catch {}
1391
+ return Response.json({
1392
+ status: dbOk ? "healthy" : "degraded",
1393
+ uptime: Math.round(uptime),
1394
+ version: "0.1.0",
1395
+ db: dbOk ? "connected" : "error",
1396
+ timestamp: new Date().toISOString(),
1397
+ });
1398
+ }
1399
+
1400
+ // Auth check for /api/* endpoints (if enabled) — runs BEFORE any API handler
1401
+ if (url.pathname.startsWith("/api/") && isAuthEnabled()) {
1402
+ const db = getDb();
1403
+ initAuth(db);
1404
+ if (!validateRequest(db, req)) {
1405
+ return Response.json(
1406
+ { error: "Unauthorized. Provide a valid API key via Authorization: Bearer <key>" },
1407
+ { status: 401, headers: { "WWW-Authenticate": "Bearer" } }
1408
+ );
1409
+ }
1410
+ }
1411
+
1412
+ // Dashboard API
1413
+ if (url.pathname.startsWith("/api/dashboard")) {
1414
+ const result = handleDashboardApi(url, req);
1415
+ if (result) return result;
1416
+ }
1417
+
1418
+ // API key management endpoints
1419
+ if (url.pathname === "/api/keys" && req.method === "POST") {
1420
+ const db = getDb();
1421
+ initAuth(db);
1422
+ const body = (await req.json()) as { label?: string };
1423
+ const result = createApiKey(db, body.label ?? "");
1424
+ return Response.json(result, { status: 201 });
1425
+ }
1426
+ if (url.pathname === "/api/keys" && req.method === "GET") {
1427
+ const db = getDb();
1428
+ initAuth(db);
1429
+ return Response.json({ keys: listApiKeys(db) });
1430
+ }
1431
+ if (url.pathname.startsWith("/api/keys/") && req.method === "DELETE") {
1432
+ const id = parseInt(url.pathname.split("/").pop()!, 10);
1433
+ if (isNaN(id)) return Response.json({ error: "Invalid key ID" }, { status: 400 });
1434
+ const db = getDb();
1435
+ initAuth(db);
1436
+ const deleted = deleteApiKey(db, id);
1437
+ return Response.json({ deleted });
1438
+ }
1439
+
1440
+ // Rate limiting for chat endpoints
1441
+ if (url.pathname.startsWith("/api/chat")) {
1442
+ const ip = getClientIp(req, server);
1443
+ const check = chatLimiter.check(ip);
1444
+ if (!check.allowed) {
1445
+ return Response.json(
1446
+ { error: "Rate limit exceeded" },
1447
+ { status: 429, headers: { "Retry-After": String(Math.ceil((check.retryAfterMs ?? 1000) / 1000)) } }
1448
+ );
1449
+ }
1450
+ }
1451
+
1452
+ // Rate limiting for upload endpoint
1453
+ if (url.pathname === "/api/upload") {
1454
+ const ip = getClientIp(req, server);
1455
+ const check = uploadLimiter.check(ip);
1456
+ if (!check.allowed) {
1457
+ return Response.json(
1458
+ { error: "Rate limit exceeded" },
1459
+ { status: 429, headers: { "Retry-After": String(Math.ceil((check.retryAfterMs ?? 1000) / 1000)) } }
1460
+ );
1461
+ }
1462
+ }
1463
+
1464
+ // Chat history — load last N messages for the web UI
1465
+ if (url.pathname === "/api/chat/history" && req.method === "GET") {
1466
+ try {
1467
+ const { loadSession } = await import("../agent/session");
1468
+ const limit = Math.min(parseInt(url.searchParams.get("limit") ?? "50", 10) || 50, 200);
1469
+ const messages = loadSession("owner", limit);
1470
+ // Map to a simpler format for the UI
1471
+ const history = messages.map((m) => ({
1472
+ role: m.role,
1473
+ content: Array.isArray(m.content)
1474
+ ? m.content
1475
+ .filter((b: any) => b.type === "text")
1476
+ .map((b: any) => b.text ?? "")
1477
+ .join("\n")
1478
+ : String(m.content),
1479
+ })).filter((m) => m.content.trim());
1480
+ return Response.json({ messages: history });
1481
+ } catch {
1482
+ return Response.json({ messages: [] });
1483
+ }
1484
+ }
1485
+
1486
+ // Chat API (non-streaming, backward compat)
1487
+ if (url.pathname === "/api/chat" && req.method === "POST") {
1488
+ try {
1489
+ const body = (await req.json()) as { message?: string };
1490
+ const text = body.message?.trim();
1491
+ if (!text) {
1492
+ return Response.json({ error: "No message" }, { status: 400 });
1493
+ }
1494
+
1495
+ const message: InboundMessage = {
1496
+ channel: "webchat",
1497
+ userId: "local",
1498
+ sessionKey,
1499
+ text,
1500
+ };
1501
+
1502
+ let reply = "";
1503
+ await router.handleMessage(message, async (r) => {
1504
+ reply = r;
1505
+ });
1506
+
1507
+ return Response.json({ reply });
1508
+ } catch (err: any) {
1509
+ return Response.json(
1510
+ { error: err.message },
1511
+ { status: 500 }
1512
+ );
1513
+ }
1514
+ }
1515
+
1516
+ // Chat API (streaming via SSE)
1517
+ if (url.pathname === "/api/chat/stream" && req.method === "POST") {
1518
+ try {
1519
+ const body = (await req.json()) as { message?: string; threadId?: string };
1520
+ const text = body.message?.trim();
1521
+ if (!text) {
1522
+ return Response.json({ error: "No message" }, { status: 400 });
1523
+ }
1524
+
1525
+ // Validate threadId format to prevent path traversal
1526
+ if (body.threadId && !/^[a-f0-9-]{36}$/.test(body.threadId)) {
1527
+ return Response.json({ error: "Invalid thread ID" }, { status: 400 });
1528
+ }
1529
+
1530
+ // Use provided threadId as session, falling back to the shared session
1531
+ const effectiveSessionKey = body.threadId ?? sessionKey;
1532
+
1533
+ const message: InboundMessage = {
1534
+ channel: "webchat",
1535
+ userId: "local",
1536
+ sessionKey: effectiveSessionKey,
1537
+ text,
1538
+ };
1539
+
1540
+ const stream = new ReadableStream({
1541
+ start(controller) {
1542
+ const encoder = new TextEncoder();
1543
+ let closed = false;
1544
+ const send = (event: string, data: any) => {
1545
+ if (closed) return;
1546
+ try {
1547
+ controller.enqueue(
1548
+ encoder.encode(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`)
1549
+ );
1550
+ } catch {
1551
+ closed = true;
1552
+ }
1553
+ };
1554
+ const close = () => {
1555
+ if (closed) return;
1556
+ closed = true;
1557
+ try { controller.close(); } catch {}
1558
+ };
1559
+
1560
+ if (!router.handleMessageStream) {
1561
+ // Fallback to non-streaming
1562
+ router.handleMessage(message, async (reply) => {
1563
+ send("delta", { text: reply });
1564
+ send("done", { reply });
1565
+ close();
1566
+ }).catch((err) => {
1567
+ send("error", { error: err.message });
1568
+ close();
1569
+ });
1570
+ return;
1571
+ }
1572
+
1573
+ router.handleMessageStream(
1574
+ message,
1575
+ (delta) => send("delta", { text: delta }),
1576
+ (name) => send("tool", { name, status: "start" }),
1577
+ (name) => send("tool", { name, status: "end" }),
1578
+ ).then((reply) => {
1579
+ send("done", { reply });
1580
+ close();
1581
+ }).catch((err) => {
1582
+ send("error", { error: err.message });
1583
+ close();
1584
+ });
1585
+ },
1586
+ });
1587
+
1588
+ return new Response(stream, {
1589
+ headers: {
1590
+ "Content-Type": "text/event-stream",
1591
+ "Cache-Control": "no-cache",
1592
+ "Connection": "keep-alive",
1593
+ },
1594
+ });
1595
+ } catch (err: any) {
1596
+ return Response.json(
1597
+ { error: err.message },
1598
+ { status: 500 }
1599
+ );
1600
+ }
1601
+ }
1602
+
1603
+ // Upload endpoint
1604
+ if (url.pathname === "/api/upload" && req.method === "POST") {
1605
+ try {
1606
+ const formData = await req.formData();
1607
+ const file = formData.get("file") as File | null;
1608
+ if (!file) {
1609
+ return Response.json({ error: "No file provided" }, { status: 400 });
1610
+ }
1611
+
1612
+ // Validate size (50MB max)
1613
+ const MAX_SIZE = 50 * 1024 * 1024;
1614
+ if (file.size > MAX_SIZE) {
1615
+ return Response.json({ error: "File too large (max 50MB)" }, { status: 400 });
1616
+ }
1617
+
1618
+ // Validate MIME type — only allow supported document types
1619
+ const ALLOWED_MIME_TYPES = new Set([
1620
+ "text/plain", "text/markdown", "text/csv", "application/pdf",
1621
+ "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
1622
+ "application/json", "application/xml", "text/yaml",
1623
+ ]);
1624
+ const ALLOWED_EXTENSIONS = new Set([
1625
+ ".pdf", ".docx", ".txt", ".md", ".csv", ".json", ".html", ".xml", ".yaml", ".yml", ".ts", ".js", ".py", ".sh",
1626
+ ]);
1627
+ const ext = file.name.includes(".") ? "." + file.name.split(".").pop()!.toLowerCase() : "";
1628
+ if (!ALLOWED_EXTENSIONS.has(ext)) {
1629
+ return Response.json({ error: `Unsupported file type: ${ext}` }, { status: 400 });
1630
+ }
1631
+ if (!ALLOWED_MIME_TYPES.has(file.type) && file.type !== "application/octet-stream") {
1632
+ return Response.json({ error: "File type not allowed: " + file.type }, { status: 400 });
1633
+ }
1634
+
1635
+ const { parseDocument, guessMimeType } = await import("../memory/document-parser");
1636
+ const { writeAndIndexMemory } = await import("../memory/engine");
1637
+ const { chunkText } = await import("../memory/chunker");
1638
+ const { mkdirSync, writeFileSync: fsWriteFileSync } = await import("fs");
1639
+ const pathMod = await import("path");
1640
+
1641
+ // Save file
1642
+ mkdirSync(paths.uploads, { recursive: true });
1643
+ const safeName = file.name.replace(/[^a-zA-Z0-9._-]/g, "_").replace(/^\.+/, "_");
1644
+ const filePath = pathMod.join(paths.uploads, `${Date.now()}_${safeName}`);
1645
+ // Verify resolved path stays within uploads directory
1646
+ if (!pathMod.resolve(filePath).startsWith(pathMod.resolve(paths.uploads))) {
1647
+ return Response.json({ error: "Invalid filename" }, { status: 400 });
1648
+ }
1649
+ const buffer = Buffer.from(await file.arrayBuffer());
1650
+ fsWriteFileSync(filePath, buffer);
1651
+
1652
+ // Parse document
1653
+ const mimeType = file.type || guessMimeType(file.name);
1654
+ const doc = await parseDocument(filePath, mimeType);
1655
+
1656
+ // Chunk and index into memory
1657
+ const chunks = chunkText(doc.text, filePath);
1658
+ const db = getDb();
1659
+ for (const chunk of chunks) {
1660
+ await writeAndIndexMemory(db, `[File: ${file.name}] ${chunk.content}`);
1661
+ }
1662
+
1663
+ // Track upload
1664
+ try {
1665
+ db.prepare(
1666
+ "INSERT INTO uploads (filename, original_name, mime_type, size_bytes, chunk_count) VALUES (?, ?, ?, ?, ?)"
1667
+ ).run(filePath, file.name, mimeType, file.size, chunks.length);
1668
+ } catch (err: any) {
1669
+ logger.warn("Failed to record upload in database", { error: (err as Error).message });
1670
+ }
1671
+
1672
+ return Response.json({
1673
+ uploaded: true,
1674
+ filename: file.name,
1675
+ size: file.size,
1676
+ chunks: chunks.length,
1677
+ wordCount: doc.metadata.wordCount,
1678
+ });
1679
+ } catch (err: any) {
1680
+ return Response.json({ error: err.message }, { status: 500 });
1681
+ }
1682
+ }
1683
+
1684
+ // Voice chat endpoint
1685
+ if (url.pathname === "/api/chat/voice" && req.method === "POST") {
1686
+ try {
1687
+ const formData = await req.formData();
1688
+ const audio = formData.get("audio") as File | null;
1689
+ if (!audio) {
1690
+ return Response.json({ error: "No audio provided" }, { status: 400 });
1691
+ }
1692
+
1693
+ const { getSttProvider } = await import("../voice/stt");
1694
+ const { getTtsProvider } = await import("../voice/tts");
1695
+
1696
+ const stt = getSttProvider();
1697
+ if (!stt) {
1698
+ return Response.json({ error: "STT not configured" }, { status: 400 });
1699
+ }
1700
+
1701
+ // Transcribe
1702
+ const audioBuffer = Buffer.from(await audio.arrayBuffer());
1703
+ const transcript = await stt.transcribe(audioBuffer);
1704
+
1705
+ if (!transcript.trim()) {
1706
+ return Response.json({ error: "Could not transcribe audio" }, { status: 400 });
1707
+ }
1708
+
1709
+ // Route through agent
1710
+ const message: InboundMessage = {
1711
+ channel: "webchat",
1712
+ userId: "local",
1713
+ sessionKey,
1714
+ text: transcript,
1715
+ };
1716
+
1717
+ let reply = "";
1718
+ await router.handleMessage(message, async (r) => {
1719
+ reply = r;
1720
+ });
1721
+
1722
+ // Optionally synthesize TTS response
1723
+ const tts = getTtsProvider();
1724
+ const wantTts = formData.get("tts") === "true";
1725
+ let audioResponse: string | null = null;
1726
+
1727
+ if (tts && wantTts && reply) {
1728
+ const ttsBuffer = await tts.synthesize(reply);
1729
+ audioResponse = ttsBuffer.toString("base64");
1730
+ }
1731
+
1732
+ return Response.json({
1733
+ transcript,
1734
+ reply,
1735
+ audio: audioResponse,
1736
+ audioFormat: tts?.format ?? null,
1737
+ });
1738
+ } catch (err: any) {
1739
+ return Response.json({ error: err.message }, { status: 500 });
1740
+ }
1741
+ }
1742
+
1743
+ // List uploads
1744
+ if (url.pathname === "/api/dashboard/uploads" && req.method === "GET") {
1745
+ try {
1746
+ const db = getDb();
1747
+ const uploads = db.query("SELECT * FROM uploads ORDER BY id DESC LIMIT 50").all();
1748
+ return Response.json({ uploads });
1749
+ } catch {
1750
+ return Response.json({ uploads: [] });
1751
+ }
1752
+ }
1753
+
1754
+ // SSE events endpoint for desktop push notifications
1755
+ if (url.pathname === "/api/events" && req.method === "GET") {
1756
+ const stream = new ReadableStream({
1757
+ start(controller) {
1758
+ const encoder = new TextEncoder();
1759
+ const send = (event: string, data: any) => {
1760
+ controller.enqueue(encoder.encode(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`));
1761
+ };
1762
+ // Send initial connection event
1763
+ send("connected", { timestamp: new Date().toISOString() });
1764
+ // Heartbeat every 30s
1765
+ const interval = setInterval(() => {
1766
+ try { send("ping", { timestamp: new Date().toISOString() }); } catch { clearInterval(interval); }
1767
+ }, 30000);
1768
+ // Clean up on abort
1769
+ req.signal.addEventListener("abort", () => clearInterval(interval));
1770
+ },
1771
+ });
1772
+ return new Response(stream, {
1773
+ headers: { "Content-Type": "text/event-stream", "Cache-Control": "no-cache", "Connection": "keep-alive" },
1774
+ });
1775
+ }
1776
+
1777
+ return new Response("Not Found", { status: 404 });
1778
+ },
1779
+ });
1780
+
1781
+ logger.info(`WebChat + Dashboard at http://localhost:${server.port}`);
1782
+ },
1783
+
1784
+ stop() {
1785
+ if (server) {
1786
+ server.stop();
1787
+ server = null;
1788
+ }
1789
+ },
1790
+
1791
+ async sendMessage(_sessionKey: string, text: string) {
1792
+ logger.debug("WebChat proactive message (not delivered)", {
1793
+ text: text.slice(0, 100),
1794
+ });
1795
+ },
1796
+ };
1797
+ }