heyhank 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 (199) hide show
  1. package/README.md +40 -0
  2. package/bin/cli.ts +168 -0
  3. package/bin/ctl.ts +528 -0
  4. package/bin/generate-token.ts +28 -0
  5. package/dist/apple-touch-icon.png +0 -0
  6. package/dist/assets/AgentsPage-BPhirnCe.js +7 -0
  7. package/dist/assets/AssistantPage-DJ-cMQfb.js +1 -0
  8. package/dist/assets/CronManager-DDbz-yiT.js +1 -0
  9. package/dist/assets/HelpPage-DMfkzERp.js +1 -0
  10. package/dist/assets/IntegrationsPage-CrOitCmJ.js +1 -0
  11. package/dist/assets/MediaPage-CE5rdvkC.js +1 -0
  12. package/dist/assets/PlatformDashboard-Do6F0O2p.js +1 -0
  13. package/dist/assets/Playground-Fc5cdc5p.js +109 -0
  14. package/dist/assets/ProcessPanel-CslEiZkI.js +2 -0
  15. package/dist/assets/PromptsPage-D2EhsdNO.js +4 -0
  16. package/dist/assets/RunsPage-C5BZF5Rx.js +1 -0
  17. package/dist/assets/SandboxManager-a1AVI5q2.js +8 -0
  18. package/dist/assets/SettingsPage-DirhjQrJ.js +51 -0
  19. package/dist/assets/SocialMediaPage-DBuM28vD.js +1 -0
  20. package/dist/assets/TailscalePage-CHiFhZXF.js +1 -0
  21. package/dist/assets/TelephonyPage-x0VV0fOo.js +1 -0
  22. package/dist/assets/TerminalPage-Drwyrnfd.js +1 -0
  23. package/dist/assets/gemini-audio-t-TSU-To.js +17 -0
  24. package/dist/assets/gemini-live-client-C7rqAW7G.js +166 -0
  25. package/dist/assets/index-C8M_PUmX.css +32 -0
  26. package/dist/assets/index-CEqZnThB.js +204 -0
  27. package/dist/assets/sw-register-LSSpj6RU.js +1 -0
  28. package/dist/assets/time-ago-B6r_l9u1.js +1 -0
  29. package/dist/assets/workbox-window.prod.es5-BIl4cyR9.js +2 -0
  30. package/dist/favicon-32-original.png +0 -0
  31. package/dist/favicon-32.png +0 -0
  32. package/dist/favicon.ico +0 -0
  33. package/dist/favicon.svg +8 -0
  34. package/dist/fonts/MesloLGSNerdFontMono-Bold.woff2 +0 -0
  35. package/dist/fonts/MesloLGSNerdFontMono-Regular.woff2 +0 -0
  36. package/dist/heyhank-mascot-poster.png +0 -0
  37. package/dist/heyhank-mascot.mp4 +0 -0
  38. package/dist/heyhank-mascot.webm +0 -0
  39. package/dist/icon-192-original.png +0 -0
  40. package/dist/icon-192.png +0 -0
  41. package/dist/icon-512-original.png +0 -0
  42. package/dist/icon-512.png +0 -0
  43. package/dist/index.html +21 -0
  44. package/dist/logo-192.png +0 -0
  45. package/dist/logo-512.png +0 -0
  46. package/dist/logo-codex.svg +14 -0
  47. package/dist/logo-docker.svg +4 -0
  48. package/dist/logo-original.png +0 -0
  49. package/dist/logo.png +0 -0
  50. package/dist/logo.svg +14 -0
  51. package/dist/manifest.json +24 -0
  52. package/dist/push-sw.js +34 -0
  53. package/dist/sw.js +1 -0
  54. package/dist/workbox-d2a0910a.js +1 -0
  55. package/package.json +109 -0
  56. package/server/agent-cron-migrator.ts +85 -0
  57. package/server/agent-executor.ts +357 -0
  58. package/server/agent-store.ts +185 -0
  59. package/server/agent-timeout.ts +107 -0
  60. package/server/agent-types.ts +122 -0
  61. package/server/ai-validation-settings.ts +37 -0
  62. package/server/ai-validator.ts +181 -0
  63. package/server/anthropic-provider-migration.ts +48 -0
  64. package/server/assistant-store.ts +272 -0
  65. package/server/auth-manager.ts +150 -0
  66. package/server/auto-approve.ts +153 -0
  67. package/server/auto-namer.ts +36 -0
  68. package/server/backend-adapter.ts +54 -0
  69. package/server/cache-headers.ts +61 -0
  70. package/server/calendar-service.ts +434 -0
  71. package/server/claude-adapter.ts +889 -0
  72. package/server/claude-container-auth.ts +30 -0
  73. package/server/claude-session-discovery.ts +157 -0
  74. package/server/claude-session-history.ts +410 -0
  75. package/server/cli-launcher.ts +1303 -0
  76. package/server/codex-adapter.ts +3027 -0
  77. package/server/codex-container-auth.ts +24 -0
  78. package/server/codex-home.ts +27 -0
  79. package/server/codex-ws-proxy.cjs +226 -0
  80. package/server/commands-discovery.ts +81 -0
  81. package/server/constants.ts +7 -0
  82. package/server/container-manager.ts +1053 -0
  83. package/server/cost-tracker.ts +222 -0
  84. package/server/cron-scheduler.ts +243 -0
  85. package/server/cron-store.ts +148 -0
  86. package/server/cron-types.ts +63 -0
  87. package/server/email-service.ts +354 -0
  88. package/server/env-manager.ts +161 -0
  89. package/server/event-bus-types.ts +75 -0
  90. package/server/event-bus.ts +124 -0
  91. package/server/execution-store.ts +170 -0
  92. package/server/federation/node-connection.ts +190 -0
  93. package/server/federation/node-manager.ts +366 -0
  94. package/server/federation/node-store.ts +86 -0
  95. package/server/federation/node-types.ts +121 -0
  96. package/server/fs-utils.ts +15 -0
  97. package/server/git-utils.ts +421 -0
  98. package/server/github-pr.ts +379 -0
  99. package/server/google-media.ts +342 -0
  100. package/server/image-pull-manager.ts +279 -0
  101. package/server/index.ts +491 -0
  102. package/server/internal-ai.ts +237 -0
  103. package/server/kill-switch.ts +99 -0
  104. package/server/llm-providers.ts +342 -0
  105. package/server/logger.ts +259 -0
  106. package/server/mcp-registry.ts +401 -0
  107. package/server/message-bus.ts +271 -0
  108. package/server/message-delivery.ts +128 -0
  109. package/server/metrics-collector.ts +350 -0
  110. package/server/metrics-types.ts +108 -0
  111. package/server/middleware/managed-auth.ts +195 -0
  112. package/server/novnc-proxy.ts +99 -0
  113. package/server/path-resolver.ts +186 -0
  114. package/server/paths.ts +13 -0
  115. package/server/pr-poller.ts +162 -0
  116. package/server/prompt-manager.ts +211 -0
  117. package/server/protocol/claude-upstream/README.md +19 -0
  118. package/server/protocol/claude-upstream/sdk.d.ts.txt +1943 -0
  119. package/server/protocol/codex-upstream/ClientNotification.ts.txt +5 -0
  120. package/server/protocol/codex-upstream/ClientRequest.ts.txt +60 -0
  121. package/server/protocol/codex-upstream/README.md +18 -0
  122. package/server/protocol/codex-upstream/ServerNotification.ts.txt +41 -0
  123. package/server/protocol/codex-upstream/ServerRequest.ts.txt +16 -0
  124. package/server/protocol/codex-upstream/v2/DynamicToolCallParams.ts.txt +6 -0
  125. package/server/protocol/codex-upstream/v2/DynamicToolCallResponse.ts.txt +6 -0
  126. package/server/protocol-monitor.ts +50 -0
  127. package/server/provider-manager.ts +111 -0
  128. package/server/provider-registry.ts +393 -0
  129. package/server/push-notifications.ts +221 -0
  130. package/server/recorder.ts +374 -0
  131. package/server/recording-hub/compat-validator.ts +284 -0
  132. package/server/recording-hub/diagnostics.ts +299 -0
  133. package/server/recording-hub/hub-config.ts +19 -0
  134. package/server/recording-hub/hub-routes.ts +236 -0
  135. package/server/recording-hub/hub-store.ts +265 -0
  136. package/server/recording-hub/replay-adapter.ts +207 -0
  137. package/server/relay-client.ts +320 -0
  138. package/server/reminder-scheduler.ts +38 -0
  139. package/server/replay.ts +78 -0
  140. package/server/routes/agent-routes.ts +264 -0
  141. package/server/routes/assistant-routes.ts +90 -0
  142. package/server/routes/cron-routes.ts +103 -0
  143. package/server/routes/env-routes.ts +95 -0
  144. package/server/routes/federation-routes.ts +76 -0
  145. package/server/routes/fs-routes.ts +622 -0
  146. package/server/routes/git-routes.ts +97 -0
  147. package/server/routes/llm-routes.ts +166 -0
  148. package/server/routes/media-routes.ts +135 -0
  149. package/server/routes/metrics-routes.ts +13 -0
  150. package/server/routes/platform-routes.ts +1379 -0
  151. package/server/routes/prompt-routes.ts +67 -0
  152. package/server/routes/provider-routes.ts +109 -0
  153. package/server/routes/sandbox-routes.ts +127 -0
  154. package/server/routes/settings-routes.ts +285 -0
  155. package/server/routes/skills-routes.ts +100 -0
  156. package/server/routes/socialmedia-routes.ts +208 -0
  157. package/server/routes/system-routes.ts +228 -0
  158. package/server/routes/tailscale-routes.ts +22 -0
  159. package/server/routes/telephony-routes.ts +259 -0
  160. package/server/routes.ts +1379 -0
  161. package/server/sandbox-manager.ts +168 -0
  162. package/server/service.ts +718 -0
  163. package/server/session-creation-service.ts +457 -0
  164. package/server/session-git-info.ts +104 -0
  165. package/server/session-names.ts +67 -0
  166. package/server/session-orchestrator.ts +824 -0
  167. package/server/session-state-machine.ts +207 -0
  168. package/server/session-store.ts +146 -0
  169. package/server/session-types.ts +511 -0
  170. package/server/settings-manager.ts +149 -0
  171. package/server/shared-context.ts +157 -0
  172. package/server/socialmedia/adapter.ts +15 -0
  173. package/server/socialmedia/adapters/ayrshare-adapter.ts +169 -0
  174. package/server/socialmedia/adapters/buffer-adapter.ts +299 -0
  175. package/server/socialmedia/adapters/postiz-adapter.ts +298 -0
  176. package/server/socialmedia/manager.ts +227 -0
  177. package/server/socialmedia/store.ts +98 -0
  178. package/server/socialmedia/types.ts +89 -0
  179. package/server/tailscale-manager.ts +451 -0
  180. package/server/telephony/audio-bridge.ts +331 -0
  181. package/server/telephony/call-manager.ts +457 -0
  182. package/server/telephony/call-types.ts +108 -0
  183. package/server/telephony/telephony-store.ts +119 -0
  184. package/server/terminal-manager.ts +240 -0
  185. package/server/update-checker.ts +192 -0
  186. package/server/usage-limits.ts +225 -0
  187. package/server/web-push.d.ts +51 -0
  188. package/server/worktree-tracker.ts +84 -0
  189. package/server/ws-auth.ts +41 -0
  190. package/server/ws-bridge-browser-ingest.ts +72 -0
  191. package/server/ws-bridge-browser.ts +112 -0
  192. package/server/ws-bridge-cli-ingest.ts +81 -0
  193. package/server/ws-bridge-codex.ts +266 -0
  194. package/server/ws-bridge-controls.ts +20 -0
  195. package/server/ws-bridge-persist.ts +66 -0
  196. package/server/ws-bridge-publish.ts +79 -0
  197. package/server/ws-bridge-replay.ts +61 -0
  198. package/server/ws-bridge-types.ts +121 -0
  199. package/server/ws-bridge.ts +1240 -0
@@ -0,0 +1,1379 @@
1
+ // ─── Platform Routes ─────────────────────────────────────────────────────────
2
+ // Routes for MessageBus, CostTracker, KillSwitch, SharedContext
3
+
4
+ import type { Hono } from "hono";
5
+ import { messageBus, VALID_MESSAGE_TYPES } from "../message-bus.js";
6
+ import type { MessageType } from "../message-bus.js";
7
+ import { costTracker } from "../cost-tracker.js";
8
+ import * as killSwitch from "../kill-switch.js";
9
+ import * as sharedContext from "../shared-context.js";
10
+ import * as autoApprove from "../auto-approve.js";
11
+ import { getSettings } from "../settings-manager.js";
12
+ import * as pushNotifications from "../push-notifications.js";
13
+ import { getTimeoutConfig } from "../agent-timeout.js";
14
+ import * as assistantStore from "../assistant-store.js";
15
+ import * as emailService from "../email-service.js";
16
+ import * as calendarService from "../calendar-service.js";
17
+ import * as mcpRegistry from "../mcp-registry.js";
18
+ import type { McpServerEntry } from "../mcp-registry.js";
19
+ import type { CalendarAccount } from "../calendar-service.js";
20
+ import { listAgents, createAgent } from "../agent-store.js";
21
+ import { nodeManager } from "../federation/node-manager.js";
22
+ import { readdirSync, readFileSync, existsSync } from "node:fs";
23
+ import { homedir } from "node:os";
24
+ import { join } from "node:path";
25
+
26
+ export function registerPlatformRoutes(api: Hono): void {
27
+ // ─── Message Bus ─────────────────────────────────────────────────────
28
+
29
+ /** List messages with optional filters */
30
+ api.get("/messages", (c) => {
31
+ const query = {
32
+ to: c.req.query("to"),
33
+ from: c.req.query("from"),
34
+ channel: c.req.query("channel"),
35
+ type: c.req.query("type") as MessageType | undefined,
36
+ unreadBy: c.req.query("unreadBy"),
37
+ since: c.req.query("since"),
38
+ limit: c.req.query("limit") ? parseInt(c.req.query("limit")!, 10) : undefined,
39
+ };
40
+ // Remove undefined values
41
+ const cleanQuery = Object.fromEntries(
42
+ Object.entries(query).filter(([, v]) => v !== undefined && v !== null),
43
+ );
44
+ return c.json(messageBus.query(cleanQuery));
45
+ });
46
+
47
+ /** Post a new message */
48
+ api.post("/messages", async (c) => {
49
+ const body = await c.req.json().catch(() => ({}));
50
+ if (!body.from || !body.content || !body.type) {
51
+ return c.json({ error: "from, type, and content are required" }, 400);
52
+ }
53
+ if (!VALID_MESSAGE_TYPES.includes(body.type)) {
54
+ return c.json({ error: `Invalid type. Valid: ${VALID_MESSAGE_TYPES.join(", ")}` }, 400);
55
+ }
56
+ const msg = messageBus.post({
57
+ from: body.from,
58
+ fromName: body.fromName,
59
+ to: body.to,
60
+ channel: body.channel,
61
+ type: body.type,
62
+ content: body.content,
63
+ metadata: body.metadata,
64
+ });
65
+ return c.json(msg, 201);
66
+ });
67
+
68
+ /** Mark a message as read */
69
+ api.post("/messages/:id/read", async (c) => {
70
+ const body = await c.req.json().catch(() => ({}));
71
+ if (!body.agentId) {
72
+ return c.json({ error: "agentId is required" }, 400);
73
+ }
74
+ messageBus.markRead(c.req.param("id"), body.agentId);
75
+ return c.json({ ok: true });
76
+ });
77
+
78
+ /** Mark all messages as read for an agent */
79
+ api.post("/messages/read-all", async (c) => {
80
+ const body = await c.req.json().catch(() => ({}));
81
+ if (!body.agentId) {
82
+ return c.json({ error: "agentId is required" }, 400);
83
+ }
84
+ messageBus.markAllRead(body.agentId);
85
+ return c.json({ ok: true });
86
+ });
87
+
88
+ /** Get unread count for an agent */
89
+ api.get("/messages/unread/:agentId", (c) => {
90
+ const count = messageBus.unreadCount(c.req.param("agentId"));
91
+ return c.json({ count });
92
+ });
93
+
94
+ /** Delete a message */
95
+ api.delete("/messages/:id", (c) => {
96
+ const deleted = messageBus.deleteMessage(c.req.param("id"));
97
+ if (!deleted) return c.json({ error: "Message not found" }, 404);
98
+ return c.json({ ok: true });
99
+ });
100
+
101
+ /** Clear all messages */
102
+ api.delete("/messages", (c) => {
103
+ messageBus.clearAll();
104
+ return c.json({ ok: true });
105
+ });
106
+
107
+ // ─── Cost Tracker ────────────────────────────────────────────────────
108
+
109
+ /** Get all cost records */
110
+ api.get("/costs", (c) => {
111
+ const limit = c.req.query("limit") ? parseInt(c.req.query("limit")!, 10) : 500;
112
+ return c.json(costTracker.getAll(limit));
113
+ });
114
+
115
+ /** Get cost summary */
116
+ api.get("/costs/summary", (c) => {
117
+ return c.json(costTracker.getSummary());
118
+ });
119
+
120
+ /** Upsert a cost record */
121
+ api.post("/costs", async (c) => {
122
+ const body = await c.req.json().catch(() => ({}));
123
+ if (!body.agentId || !body.agentName || !body.model) {
124
+ return c.json({ error: "agentId, agentName, and model are required" }, 400);
125
+ }
126
+ costTracker.upsert({
127
+ agentId: body.agentId,
128
+ agentName: body.agentName,
129
+ model: body.model,
130
+ tokensIn: body.tokensIn ?? 0,
131
+ tokensOut: body.tokensOut ?? 0,
132
+ estimatedCost: body.estimatedCost ?? 0,
133
+ createdAt: body.createdAt ?? new Date().toISOString(),
134
+ closedAt: body.closedAt ?? null,
135
+ });
136
+ return c.json({ ok: true });
137
+ });
138
+
139
+ /** Finalize a cost record */
140
+ api.post("/costs/:agentId/finalize", (c) => {
141
+ costTracker.finalize(c.req.param("agentId"));
142
+ return c.json({ ok: true });
143
+ });
144
+
145
+ /** Get/set spend limit */
146
+ api.get("/costs/limit", (c) => {
147
+ return c.json({ limit: costTracker.getSpendLimit() });
148
+ });
149
+
150
+ api.put("/costs/limit", async (c) => {
151
+ const body = await c.req.json().catch(() => ({}));
152
+ costTracker.setSpendLimit(body.limit ?? null);
153
+ return c.json({ ok: true });
154
+ });
155
+
156
+ /** Reset all cost records */
157
+ api.delete("/costs", (c) => {
158
+ const deleted = costTracker.reset();
159
+ return c.json({ deleted });
160
+ });
161
+
162
+ // ─── Kill Switch ─────────────────────────────────────────────────────
163
+
164
+ /** Get kill switch state */
165
+ api.get("/kill-switch", (c) => {
166
+ return c.json(killSwitch.getKillSwitchState());
167
+ });
168
+
169
+ /** Activate kill switch */
170
+ api.post("/kill-switch/activate", async (c) => {
171
+ const body = await c.req.json().catch(() => ({}));
172
+ const state = killSwitch.activate(body.reason);
173
+ return c.json(state);
174
+ });
175
+
176
+ /** Deactivate kill switch */
177
+ api.post("/kill-switch/deactivate", (c) => {
178
+ const state = killSwitch.deactivate();
179
+ return c.json(state);
180
+ });
181
+
182
+ // ─── Shared Context ──────────────────────────────────────────────────
183
+
184
+ /** List all context files */
185
+ api.get("/shared-context", (c) => {
186
+ return c.json(sharedContext.listContextFiles());
187
+ });
188
+
189
+ /** Get a specific context file */
190
+ api.get("/shared-context/:filename", (c) => {
191
+ const file = sharedContext.getContextFile(c.req.param("filename"));
192
+ if (!file) return c.json({ error: "Not found" }, 404);
193
+ return c.json(file);
194
+ });
195
+
196
+ /** Create or update a context file */
197
+ api.put("/shared-context/:filename", async (c) => {
198
+ const body = await c.req.json().catch(() => ({}));
199
+ if (!body.content && body.content !== "") {
200
+ return c.json({ error: "content is required" }, 400);
201
+ }
202
+ const file = sharedContext.writeContextFile(c.req.param("filename"), body.content);
203
+ return c.json(file);
204
+ });
205
+
206
+ /** Delete a context file */
207
+ api.delete("/shared-context/:filename", (c) => {
208
+ const deleted = sharedContext.deleteContextFile(c.req.param("filename"));
209
+ if (!deleted) return c.json({ error: "Not found" }, 404);
210
+ return c.json({ ok: true });
211
+ });
212
+
213
+ // ─── Auto-Approve ────────────────────────────────────────────────────
214
+
215
+ /** Get auto-approve rules */
216
+ api.get("/auto-approve/rules", (c) => {
217
+ return c.json({ rules: autoApprove.getRules() });
218
+ });
219
+
220
+ /** Evaluate a permission request */
221
+ api.post("/auto-approve/evaluate", async (c) => {
222
+ const body = await c.req.json().catch(() => ({}));
223
+ if (!body.agentId || !body.toolName) {
224
+ return c.json({ error: "agentId and toolName are required" }, 400);
225
+ }
226
+ const result = autoApprove.evaluate(
227
+ body.agentId,
228
+ body.toolName,
229
+ body.toolInput || {},
230
+ body.aiVerdict,
231
+ );
232
+ return c.json(result);
233
+ });
234
+
235
+ // ─── Push Notifications ──────────────────────────────────────────────
236
+
237
+ /** Get VAPID public key */
238
+ api.get("/push/vapid-key", (c) => {
239
+ return c.json({ publicKey: pushNotifications.getPublicVapidKey() });
240
+ });
241
+
242
+ /** Subscribe to push notifications */
243
+ api.post("/push/subscribe", async (c) => {
244
+ const body = await c.req.json().catch(() => ({}));
245
+ if (!body.subscription?.endpoint || !body.subscription?.keys) {
246
+ return c.json({ error: "Valid subscription object required" }, 400);
247
+ }
248
+ pushNotifications.addSubscription(body.subscription);
249
+ return c.json({ ok: true });
250
+ });
251
+
252
+ /** Unsubscribe from push notifications */
253
+ api.post("/push/unsubscribe", (c) => {
254
+ pushNotifications.clearSubscriptions();
255
+ return c.json({ ok: true });
256
+ });
257
+
258
+ /** Send a test notification */
259
+ api.post("/push/test", async (c) => {
260
+ const result = await pushNotifications.sendNotification(
261
+ "🧪 Test Notification",
262
+ "Push notifications are working!",
263
+ { tag: "test" },
264
+ );
265
+ return c.json({ ok: true, ...result });
266
+ });
267
+
268
+ /** Get push subscription count */
269
+ api.get("/push/status", (c) => {
270
+ return c.json({
271
+ subscriptions: pushNotifications.getSubscriptionCount(),
272
+ vapidConfigured: true,
273
+ });
274
+ });
275
+
276
+ // ─── MCP Plugins ────────────────────────────────────────────────────
277
+
278
+ /** List installed MCP plugins — now backed by mcp-registry */
279
+ api.get("/mcp/plugins", (c) => {
280
+ const servers = mcpRegistry.listServers();
281
+ const plugins = servers.map((s) => ({
282
+ name: s.name,
283
+ type: s.type,
284
+ configured: s.enabled && (!s.requiresAuth || s.requiresAuth.every(
285
+ (a) => s.authValues?.[a.field],
286
+ )),
287
+ }));
288
+ return c.json({ plugins, total: plugins.length });
289
+ });
290
+
291
+ // ─── MCP Server Registry ─────────────────────────────────────────────
292
+
293
+ /** List all configured MCP servers */
294
+ api.get("/mcp/servers", (c) => {
295
+ const agentId = c.req.query("agentId");
296
+ const servers = agentId
297
+ ? mcpRegistry.getServersForAgent(agentId)
298
+ : mcpRegistry.listServers();
299
+ return c.json({ servers, total: servers.length });
300
+ });
301
+
302
+ /** List available MCP catalog templates */
303
+ api.get("/mcp/catalog", (c) => {
304
+ return c.json({ catalog: mcpRegistry.getCatalog() });
305
+ });
306
+
307
+ /** Add a new MCP server (or install from catalog via { catalogId }) */
308
+ api.post("/mcp/servers", async (c) => {
309
+ const body = await c.req.json().catch(() => ({} as Record<string, unknown>));
310
+
311
+ try {
312
+ // Install from catalog
313
+ if (body.catalogId) {
314
+ const server = mcpRegistry.addFromCatalog(body.catalogId as string);
315
+ return c.json(server, 201);
316
+ }
317
+
318
+ // Custom server
319
+ if (!body.name || !body.type) {
320
+ return c.json({ error: "name and type are required" }, 400);
321
+ }
322
+ if (body.type === "stdio" && !body.command) {
323
+ return c.json({ error: "command is required for stdio servers" }, 400);
324
+ }
325
+ if ((body.type === "http" || body.type === "sse") && !body.url) {
326
+ return c.json({ error: "url is required for http/sse servers" }, 400);
327
+ }
328
+
329
+ const server = mcpRegistry.addServer({
330
+ name: body.name as string,
331
+ description: (body.description as string) || "",
332
+ type: body.type as "stdio" | "http" | "sse",
333
+ command: body.command as string | undefined,
334
+ args: body.args as string[] | undefined,
335
+ url: body.url as string | undefined,
336
+ headers: body.headers as Record<string, string> | undefined,
337
+ env: body.env as Record<string, string> | undefined,
338
+ enabled: body.enabled !== false,
339
+ assignedAgents: (body.assignedAgents as string[]) || ["*"],
340
+ requiresAuth: body.requiresAuth as McpServerEntry["requiresAuth"],
341
+ authValues: body.authValues as Record<string, string> | undefined,
342
+ });
343
+ return c.json(server, 201);
344
+ } catch (err) {
345
+ return c.json({ error: err instanceof Error ? err.message : String(err) }, 400);
346
+ }
347
+ });
348
+
349
+ /** Update an MCP server config */
350
+ api.put("/mcp/servers/:id", async (c) => {
351
+ const id = c.req.param("id");
352
+ const body = await c.req.json().catch(() => ({} as Record<string, unknown>));
353
+
354
+ const updated = mcpRegistry.updateServer(id, body as Partial<McpServerEntry>);
355
+ if (!updated) return c.json({ error: "Server not found" }, 404);
356
+ return c.json(updated);
357
+ });
358
+
359
+ /** Remove an MCP server */
360
+ api.delete("/mcp/servers/:id", (c) => {
361
+ const removed = mcpRegistry.removeServer(c.req.param("id"));
362
+ if (!removed) return c.json({ error: "Server not found" }, 404);
363
+ return c.json({ ok: true });
364
+ });
365
+
366
+ /** Toggle enable/disable for an MCP server */
367
+ api.post("/mcp/servers/:id/toggle", (c) => {
368
+ const id = c.req.param("id");
369
+ const server = mcpRegistry.getServer(id);
370
+ if (!server) return c.json({ error: "Server not found" }, 404);
371
+
372
+ const updated = mcpRegistry.updateServer(id, { enabled: !server.enabled });
373
+ return c.json(updated);
374
+ });
375
+
376
+ // ─── Agent Timeouts ──────────────────────────────────────────────────
377
+
378
+ /** Get timeout configuration */
379
+ api.get("/agent-timeouts", (c) => {
380
+ return c.json(getTimeoutConfig());
381
+ });
382
+
383
+ // ─── Email Accounts ─────────────────────────────────────────────────
384
+
385
+ /** List all email accounts (without passwords) */
386
+ api.get("/email-accounts", (c) => {
387
+ const accounts = emailService.loadAccounts();
388
+ return c.json(accounts.map((a) => ({
389
+ id: a.id,
390
+ name: a.name,
391
+ email: a.email,
392
+ imap: a.imap,
393
+ smtp: a.smtp,
394
+ auth: { user: a.auth.user, pass: "" }, // never send password
395
+ })));
396
+ });
397
+
398
+ /** Add a new email account */
399
+ api.post("/email-accounts", async (c) => {
400
+ const body = await c.req.json().catch(() => ({} as Record<string, unknown>));
401
+ if (!body.name || !body.email || !body.imap || !body.smtp || !body.auth) {
402
+ return c.json({ error: "name, email, imap, smtp, and auth are required" }, 400);
403
+ }
404
+ const account = emailService.addAccount(body as Omit<emailService.EmailAccount, "id">);
405
+ return c.json({ id: account.id, name: account.name, email: account.email });
406
+ });
407
+
408
+ /** Update an email account */
409
+ api.put("/email-accounts/:id", async (c) => {
410
+ const id = c.req.param("id");
411
+ const body = await c.req.json().catch(() => ({} as Record<string, unknown>));
412
+ const accounts = emailService.loadAccounts();
413
+ const idx = accounts.findIndex((a) => a.id === id);
414
+ if (idx === -1) return c.json({ error: "Account not found" }, 404);
415
+
416
+ // Merge fields
417
+ if (body.name) accounts[idx].name = body.name as string;
418
+ if (body.email) accounts[idx].email = body.email as string;
419
+ if (body.imap) accounts[idx].imap = body.imap as typeof accounts[0]["imap"];
420
+ if (body.smtp) accounts[idx].smtp = body.smtp as typeof accounts[0]["smtp"];
421
+ if (body.auth) {
422
+ const auth = body.auth as { user?: string; pass?: string };
423
+ if (auth.user) accounts[idx].auth.user = auth.user;
424
+ if (auth.pass) accounts[idx].auth.pass = auth.pass; // only update if provided
425
+ }
426
+
427
+ emailService.saveAccounts(accounts);
428
+ return c.json({ ok: true });
429
+ });
430
+
431
+ /** Delete an email account */
432
+ api.delete("/email-accounts/:id", (c) => {
433
+ const removed = emailService.removeAccount(c.req.param("id"));
434
+ if (!removed) return c.json({ error: "Account not found" }, 404);
435
+ return c.json({ ok: true });
436
+ });
437
+
438
+ /** Test email account connection */
439
+ api.post("/email-accounts/:id/test", async (c) => {
440
+ const account = emailService.loadAccounts().find((a) => a.id === c.req.param("id"));
441
+ if (!account) return c.json({ error: "Account not found" }, 404);
442
+ try {
443
+ const emails = await emailService.listEmails(account, { limit: 1 });
444
+ return c.json({ ok: true, message: `Connected successfully. ${emails.length} email(s) found.` });
445
+ } catch (err) {
446
+ return c.json({ ok: false, error: err instanceof Error ? err.message : String(err) });
447
+ }
448
+ });
449
+
450
+ // ─── Calendar Accounts ──────────────────────────────────────────────
451
+
452
+ /** List all calendar accounts (without passwords) */
453
+ api.get("/calendar-accounts", (c) => {
454
+ const accounts = calendarService.loadAccounts();
455
+ return c.json(accounts.map((a) => ({
456
+ id: a.id,
457
+ name: a.name,
458
+ provider: a.provider,
459
+ serverUrl: a.serverUrl,
460
+ auth: { user: a.auth.user, pass: "" },
461
+ defaultCalendarId: a.defaultCalendarId,
462
+ })));
463
+ });
464
+
465
+ /** Add a new calendar account */
466
+ api.post("/calendar-accounts", async (c) => {
467
+ const body = await c.req.json().catch(() => ({} as Record<string, unknown>));
468
+ if (!body.name || !body.provider || !body.auth) {
469
+ return c.json({ error: "name, provider, and auth are required" }, 400);
470
+ }
471
+ const preset = calendarService.PROVIDER_PRESETS[body.provider as string];
472
+ const serverUrl = (body.serverUrl as string) || preset?.serverUrl || "";
473
+ if (!serverUrl) {
474
+ return c.json({ error: "serverUrl is required for custom CalDAV providers" }, 400);
475
+ }
476
+ const account = calendarService.addAccount({
477
+ name: body.name as string,
478
+ provider: body.provider as CalendarAccount["provider"],
479
+ serverUrl,
480
+ auth: body.auth as { user: string; pass: string },
481
+ defaultCalendarId: body.defaultCalendarId as string | undefined,
482
+ });
483
+ return c.json({ id: account.id, name: account.name });
484
+ });
485
+
486
+ /** Update a calendar account */
487
+ api.put("/calendar-accounts/:id", async (c) => {
488
+ const id = c.req.param("id");
489
+ const body = await c.req.json().catch(() => ({} as Record<string, unknown>));
490
+ const accounts = calendarService.loadAccounts();
491
+ const idx = accounts.findIndex((a) => a.id === id);
492
+ if (idx === -1) return c.json({ error: "Account not found" }, 404);
493
+
494
+ if (body.name) accounts[idx].name = body.name as string;
495
+ if (body.provider) accounts[idx].provider = body.provider as CalendarAccount["provider"];
496
+ if (body.serverUrl) accounts[idx].serverUrl = body.serverUrl as string;
497
+ if (body.defaultCalendarId !== undefined) accounts[idx].defaultCalendarId = body.defaultCalendarId as string;
498
+ if (body.auth) {
499
+ const auth = body.auth as { user?: string; pass?: string };
500
+ if (auth.user) accounts[idx].auth.user = auth.user;
501
+ if (auth.pass) accounts[idx].auth.pass = auth.pass;
502
+ }
503
+
504
+ calendarService.saveAccounts(accounts);
505
+ return c.json({ ok: true });
506
+ });
507
+
508
+ /** Delete a calendar account */
509
+ api.delete("/calendar-accounts/:id", (c) => {
510
+ const removed = calendarService.removeAccount(c.req.param("id"));
511
+ if (!removed) return c.json({ error: "Account not found" }, 404);
512
+ return c.json({ ok: true });
513
+ });
514
+
515
+ /** Test calendar account connection */
516
+ api.post("/calendar-accounts/:id/test", async (c) => {
517
+ const account = calendarService.loadAccounts().find((a) => a.id === c.req.param("id"));
518
+ if (!account) return c.json({ error: "Account not found" }, 404);
519
+ try {
520
+ const calendars = await calendarService.listCalendars(account);
521
+ return c.json({ ok: true, message: `Connected successfully. ${calendars.length} calendar(s) found.`, calendars: calendars.map((c) => c.displayName) });
522
+ } catch (err) {
523
+ return c.json({ ok: false, error: err instanceof Error ? err.message : String(err) });
524
+ }
525
+ });
526
+
527
+ /** List calendars for an account */
528
+ api.get("/calendar-accounts/:id/calendars", async (c) => {
529
+ const account = calendarService.loadAccounts().find((a) => a.id === c.req.param("id"));
530
+ if (!account) return c.json({ error: "Account not found" }, 404);
531
+ try {
532
+ const calendars = await calendarService.listCalendars(account);
533
+ return c.json(calendars);
534
+ } catch (err) {
535
+ return c.json({ error: err instanceof Error ? err.message : String(err) }, 500);
536
+ }
537
+ });
538
+
539
+ /** Get calendar provider presets */
540
+ api.get("/calendar-presets", (c) => {
541
+ return c.json(calendarService.PROVIDER_PRESETS);
542
+ });
543
+
544
+ // ─── Gemini Live ───────────────────────────────────────────────────
545
+
546
+ /** Get Gemini API config for voice chat (settings first, env fallback) */
547
+ api.get("/gemini/config", async (c) => {
548
+ const settings = getSettings();
549
+ const apiKey = settings.geminiApiKey.trim() || process.env.GEMINI_API_KEY || "";
550
+ if (!apiKey) {
551
+ return c.json({ error: "Gemini API key not configured. Add it in Settings → Gemini." }, 500);
552
+ }
553
+ // Include agent list for system prompt enrichment
554
+ const agents = listAgents().map((a) => ({ id: a.id, name: a.name, description: a.description, backend: a.backendType }));
555
+
556
+ // Load recent Gemini conversation context for persistence
557
+ const recentNotes = assistantStore.listNotes("gemini-live");
558
+ const contextNotes = recentNotes.slice(-3); // last 3 conversations
559
+
560
+ // Fetch active session status so Gemini knows what's running
561
+ let activeSessions: { sessionId: string; state: string; model?: string; agentName?: string; cwd?: string }[] = [];
562
+ try {
563
+ const port = process.env.PORT || 3100;
564
+ const authHeader = c.req.header("Authorization") || "";
565
+ const sessResp = await fetch(`http://127.0.0.1:${port}/api/sessions`, {
566
+ headers: authHeader ? { Authorization: authHeader } : {},
567
+ });
568
+ if (sessResp.ok) {
569
+ const all = (await sessResp.json()) as { sessionId: string; state: string; model?: string; agentName?: string; cwd?: string }[];
570
+ activeSessions = all
571
+ .filter((s) => s.state !== "exited")
572
+ .map((s) => ({ sessionId: s.sessionId, state: s.state, model: s.model, agentName: s.agentName, cwd: s.cwd }));
573
+ }
574
+ } catch { /* ignore */ }
575
+
576
+ // Merge remote federation sessions
577
+ const remoteSessionsList = nodeManager.getRemoteSessions().map((rs) => ({
578
+ sessionId: rs.sessionId,
579
+ state: rs.status === "running" ? "running" : "connected",
580
+ model: rs.model,
581
+ cwd: rs.cwd,
582
+ nodeName: rs.nodeName,
583
+ }));
584
+
585
+ // Merge remote federation agents
586
+ const remoteAgents = nodeManager.getRemoteAgents().map((ra) => ({
587
+ id: ra.id,
588
+ name: ra.name,
589
+ description: ra.description || "",
590
+ backend: ra.backendType || "claude",
591
+ nodeName: ra.nodeName,
592
+ }));
593
+
594
+ // Load phone contacts for Gemini
595
+ let contacts: { name: string; phone: string; notes?: string }[] = [];
596
+ try {
597
+ const telStore = await import("../telephony/telephony-store.js");
598
+ contacts = telStore.getContacts().map((c) => ({ name: c.name, phone: c.phone, notes: c.notes }));
599
+ } catch { /* ignore */ }
600
+
601
+ return c.json({
602
+ apiKey,
603
+ voice: settings.geminiVoice || "Kore",
604
+ assistantName: settings.assistantName || "",
605
+ userName: settings.userName || "",
606
+ agents: [...agents, ...remoteAgents],
607
+ recentConversations: contextNotes.map((n) => ({ title: n.title, content: n.content })),
608
+ activeSessions: [...activeSessions, ...remoteSessionsList],
609
+ contacts,
610
+ });
611
+ });
612
+
613
+ /** Execute a Gemini tool call by proxying to internal API endpoints */
614
+ api.post("/gemini/tool-call", async (c) => {
615
+ const body = await c.req.json().catch(() => ({} as { name?: string; args?: Record<string, unknown> }));
616
+ const { name, args } = body;
617
+
618
+ // Forward auth header from the original request
619
+ const authHeader = c.req.header("Authorization") || "";
620
+ const headers: Record<string, string> = {
621
+ "Content-Type": "application/json",
622
+ ...(authHeader ? { Authorization: authHeader } : {}),
623
+ };
624
+ const base = `http://127.0.0.1:${process.env.PORT || 3100}/api`;
625
+
626
+ console.log(`[gemini-tool] ${name}`, JSON.stringify(args || {}).slice(0, 200));
627
+ const toolStart = Date.now();
628
+
629
+ try {
630
+ switch (name) {
631
+ // ─── Agent Orchestration ──────────────────────────────────────
632
+ case "run_agent": {
633
+ const agentQuery = (args?.agent as string) || "";
634
+ const task = (args?.task as string) || "";
635
+ if (!agentQuery || !task) {
636
+ return c.json({ result: { error: "agent and task are required" } });
637
+ }
638
+
639
+ // Fuzzy match agent by name or ID
640
+ const agents = listAgents();
641
+ let matched = agents.find((a) => a.id === agentQuery || a.name.toLowerCase() === agentQuery.toLowerCase());
642
+ if (!matched) {
643
+ // Fuzzy: find best match by name similarity
644
+ const q = agentQuery.toLowerCase();
645
+ let bestScore = 0;
646
+ for (const a of agents) {
647
+ const name = a.name.toLowerCase();
648
+ let score = 0;
649
+ if (name.includes(q) || q.includes(name)) score = 0.8;
650
+ else {
651
+ // Word overlap
652
+ const qWords = q.split(/\s+/);
653
+ const nWords = name.split(/\s+/);
654
+ const overlap = qWords.filter((w) => nWords.some((n) => n.includes(w) || w.includes(n))).length;
655
+ score = overlap / Math.max(qWords.length, nWords.length);
656
+ }
657
+ if (score > bestScore) { bestScore = score; matched = a; }
658
+ }
659
+ if (bestScore < 0.3) matched = undefined;
660
+ }
661
+
662
+ if (!matched) {
663
+ const available = agents.map((a) => `"${a.name}" (${a.id})`).join(", ");
664
+ return c.json({ result: { error: `Agent "${agentQuery}" not found. Available: ${available}` } });
665
+ }
666
+
667
+ // Run the agent via /api/agents/:id/run
668
+ const runRes = await fetch(`${base}/agents/${matched.id}/run`, {
669
+ method: "POST",
670
+ headers,
671
+ body: JSON.stringify({ input: task }),
672
+ });
673
+ const runData = await runRes.json() as Record<string, unknown>;
674
+
675
+ if (!runRes.ok) {
676
+ return c.json({ result: { error: `Failed to run agent: ${(runData as { error?: string }).error || "Unknown error"}` } });
677
+ }
678
+
679
+ const sessionId = (runData as { sessionId?: string }).sessionId || null;
680
+ return c.json({
681
+ result: {
682
+ success: true,
683
+ agentName: matched.name,
684
+ agentId: matched.id,
685
+ sessionId,
686
+ model: matched.model,
687
+ message: `Agent "${matched.name}" started with task.${sessionId ? ` Session ID: ${sessionId}. Use monitor_agent_session to check progress.` : ""}`,
688
+ },
689
+ });
690
+ }
691
+
692
+ case "create_agent": {
693
+ const agentName = (args?.name as string) || "";
694
+ const agentPrompt = (args?.prompt as string) || "";
695
+ const agentDesc = (args?.description as string) || "";
696
+ const agentModel = (args?.model as string) || "claude-sonnet-4-20250514";
697
+ const agentCwd = (args?.cwd as string) || "";
698
+ const autoStart = args?.autoStart as boolean;
699
+ const autoTask = (args?.task as string) || "";
700
+
701
+ if (!agentName || !agentPrompt) {
702
+ return c.json({ result: { error: "name and prompt are required" } });
703
+ }
704
+
705
+ try {
706
+ const newAgent = createAgent({
707
+ name: agentName,
708
+ description: agentDesc,
709
+ prompt: agentPrompt,
710
+ backendType: "claude",
711
+ model: agentModel,
712
+ permissionMode: "auto-accept",
713
+ cwd: agentCwd,
714
+ version: 1,
715
+ enabled: true,
716
+ });
717
+
718
+ let sessionId: string | null = null;
719
+ if (autoStart && autoTask) {
720
+ const runRes = await fetch(`${base}/agents/${newAgent.id}/run`, {
721
+ method: "POST",
722
+ headers,
723
+ body: JSON.stringify({ input: autoTask }),
724
+ });
725
+ if (runRes.ok) {
726
+ const runData = await runRes.json() as { sessionId?: string };
727
+ sessionId = runData.sessionId || null;
728
+ }
729
+ }
730
+
731
+ return c.json({
732
+ result: {
733
+ success: true,
734
+ agentName: newAgent.name,
735
+ agentId: newAgent.id,
736
+ ...(sessionId ? { sessionId, message: `Agent "${newAgent.name}" created and started. Session ID: ${sessionId}. Use monitor_agent_session to check progress.` } : { message: `Agent "${newAgent.name}" created successfully. Use run_agent to start it.` }),
737
+ },
738
+ });
739
+ } catch (err) {
740
+ return c.json({ result: { error: `Failed to create agent: ${err instanceof Error ? err.message : "Unknown error"}` } });
741
+ }
742
+ }
743
+
744
+ case "list_sessions": {
745
+ const res = await fetch(`${base}/sessions`, { headers });
746
+ const sessions = await res.json() as Array<Record<string, unknown>>;
747
+ // Return a compact summary
748
+ const summary = sessions
749
+ .filter((s) => s.state !== "exited" && !s.archived)
750
+ .map((s) => ({
751
+ id: s.sessionId,
752
+ state: s.state,
753
+ model: s.model || "unknown",
754
+ backend: s.backendType || "claude",
755
+ cwd: s.cwd,
756
+ name: s.name || null,
757
+ }));
758
+ return c.json({ result: { sessions: summary, count: summary.length } });
759
+ }
760
+
761
+ case "create_session": {
762
+ const backend = (args?.backend as string) || "claude";
763
+ const cwd = (args?.cwd as string) || "/opt/agentplatform/web";
764
+ const message = args?.message as string | undefined;
765
+
766
+ const res = await fetch(`${base}/sessions/create`, {
767
+ method: "POST",
768
+ headers,
769
+ body: JSON.stringify({ backend, cwd }),
770
+ });
771
+ const session = await res.json() as Record<string, unknown>;
772
+
773
+ if (!res.ok) {
774
+ const errMsg = typeof (session as { error?: string }).error === "string" ? (session as { error?: string }).error : "Unknown error";
775
+ return c.json({ result: { error: `Failed to create session: ${errMsg}` } });
776
+ }
777
+
778
+ const result: Record<string, unknown> = {
779
+ sessionId: session.sessionId,
780
+ state: session.state,
781
+ cwd: session.cwd,
782
+ message: `Session created successfully with ${backend}.`,
783
+ };
784
+
785
+ // If there's an initial message, send it via WebSocket bridge endpoint
786
+ if (message && session.sessionId) {
787
+ result.initialMessage = message;
788
+ result.note = "Initial message will be sent once the session is connected. The user should see it in the chat.";
789
+ // Fire and forget — don't block the tool response to Gemini
790
+ fetch(`${base}/gemini/send-to-session`, {
791
+ method: "POST",
792
+ headers,
793
+ body: JSON.stringify({ sessionId: session.sessionId, message }),
794
+ }).catch(() => {});
795
+ }
796
+
797
+ return c.json({ result });
798
+ }
799
+
800
+ case "send_message": {
801
+ const sessionId = args?.session_id as string;
802
+ const message = args?.message as string;
803
+
804
+ if (!sessionId || !message) {
805
+ return c.json({ result: { error: "session_id and message are required" } });
806
+ }
807
+
808
+ // Use the internal send endpoint
809
+ const res = await fetch(`${base}/gemini/send-to-session`, {
810
+ method: "POST",
811
+ headers,
812
+ body: JSON.stringify({ sessionId, message }),
813
+ });
814
+ const data = await res.json() as Record<string, unknown>;
815
+
816
+ if (!res.ok) {
817
+ const errMsg = typeof (data as { error?: string }).error === "string" ? (data as { error?: string }).error : "Unknown error";
818
+ return c.json({ result: { error: `Failed to send message: ${errMsg}` } });
819
+ }
820
+
821
+ return c.json({ result: { success: true, sessionId, message: "Message sent to session." } });
822
+ }
823
+
824
+ case "get_session_status":
825
+ case "monitor_agent_session": {
826
+ const sessionId = args?.session_id as string;
827
+ if (!sessionId) {
828
+ return c.json({ result: { error: "session_id is required" } });
829
+ }
830
+
831
+ const res = await fetch(`${base}/sessions/${sessionId}/agent-status`, { headers });
832
+ if (!res.ok) {
833
+ return c.json({ result: { error: "Session not found" } });
834
+ }
835
+ const status = await res.json() as Record<string, unknown>;
836
+
837
+ // Build a human-readable summary for Gemini
838
+ let summary = "";
839
+ if (status.needsInput) {
840
+ const perms = status.pendingPermissions as Array<{ toolName: string; description: string }>;
841
+ summary = `ATTENTION: Agent needs permission! Pending: ${perms.map((p) => `${p.toolName}${p.description ? ` (${p.description})` : ""}`).join(", ")}. Tell the user immediately!`;
842
+ } else if (status.isCompleted) {
843
+ summary = "Agent has finished. Task is complete.";
844
+ } else if (status.isWorking) {
845
+ summary = "Agent is still working...";
846
+ } else {
847
+ summary = `Agent phase: ${status.phase}`;
848
+ }
849
+
850
+ return c.json({
851
+ result: {
852
+ ...status,
853
+ summary,
854
+ },
855
+ });
856
+ }
857
+
858
+ // ─── Todo Tools ───────────────────────────────────────────────
859
+ case "list_todos": {
860
+ const todos = assistantStore.listTodos({
861
+ done: args?.show_completed ? undefined : false,
862
+ priority: args?.priority as string | undefined,
863
+ category: args?.category as string | undefined,
864
+ });
865
+ return c.json({ result: { todos, count: todos.length } });
866
+ }
867
+
868
+ case "add_todo": {
869
+ const text = args?.text as string;
870
+ if (!text) return c.json({ result: { error: "text is required" } });
871
+ const todo = assistantStore.addTodo(text, args?.priority as string, args?.category as string | undefined);
872
+ return c.json({ result: { todo, message: "Todo added." } });
873
+ }
874
+
875
+ case "complete_todo": {
876
+ const id = args?.id as string;
877
+ if (!id) return c.json({ result: { error: "id is required" } });
878
+ const todo = assistantStore.completeTodo(id);
879
+ return c.json({ result: todo ? { todo, message: "Todo completed." } : { error: "Todo not found" } });
880
+ }
881
+
882
+ case "delete_todo": {
883
+ const id = args?.id as string;
884
+ if (!id) return c.json({ result: { error: "id is required" } });
885
+ const ok = assistantStore.deleteTodo(id);
886
+ return c.json({ result: ok ? { message: "Todo deleted." } : { error: "Todo not found" } });
887
+ }
888
+
889
+ case "update_todo": {
890
+ const id = args?.id as string;
891
+ if (!id) return c.json({ result: { error: "id is required" } });
892
+ const todo = assistantStore.updateTodo(id, {
893
+ text: args?.text as string | undefined,
894
+ priority: args?.priority as string | undefined,
895
+ category: args?.category as string | undefined,
896
+ });
897
+ return c.json({ result: todo ? { todo, message: "Todo updated." } : { error: "Todo not found" } });
898
+ }
899
+
900
+ // ─── Note Tools ──────────────────────────────────────────────
901
+ case "search_notes": {
902
+ const notes = assistantStore.listNotes(args?.query as string | undefined);
903
+ return c.json({ result: { notes, count: notes.length } });
904
+ }
905
+
906
+ case "add_note": {
907
+ const title = args?.title as string;
908
+ const content = args?.content as string || "";
909
+ if (!title) return c.json({ result: { error: "title is required" } });
910
+ const tags = args?.tags ? (args.tags as string).split(",").map((t: string) => t.trim()) : [];
911
+ const note = assistantStore.addNote(title, content, tags);
912
+ return c.json({ result: { note, message: "Note saved." } });
913
+ }
914
+
915
+ case "update_note": {
916
+ const id = args?.id as string;
917
+ if (!id) return c.json({ result: { error: "id is required" } });
918
+ const tags = args?.tags ? (args.tags as string).split(",").map((t: string) => t.trim()) : undefined;
919
+ const note = assistantStore.updateNote(id, {
920
+ title: args?.title as string | undefined,
921
+ content: args?.content as string | undefined,
922
+ tags,
923
+ });
924
+ return c.json({ result: note ? { note, message: "Note updated." } : { error: "Note not found" } });
925
+ }
926
+
927
+ case "delete_note": {
928
+ const id = args?.id as string;
929
+ if (!id) return c.json({ result: { error: "id is required" } });
930
+ const ok = assistantStore.deleteNote(id);
931
+ return c.json({ result: ok ? { message: "Note deleted." } : { error: "Note not found" } });
932
+ }
933
+
934
+ // ─── Reminder Tools ──────────────────────────────────────────
935
+ case "list_reminders": {
936
+ const reminders = assistantStore.listReminders(!!args?.include_fired);
937
+ return c.json({ result: { reminders, count: reminders.length } });
938
+ }
939
+
940
+ case "add_reminder": {
941
+ const text = args?.text as string;
942
+ const triggerAt = args?.trigger_at as string;
943
+ if (!text || !triggerAt) return c.json({ result: { error: "text and trigger_at are required" } });
944
+ const reminder = assistantStore.addReminder(text, triggerAt);
945
+ return c.json({ result: { reminder, message: "Reminder set." } });
946
+ }
947
+
948
+ case "delete_reminder": {
949
+ const id = args?.id as string;
950
+ if (!id) return c.json({ result: { error: "id is required" } });
951
+ const ok = assistantStore.deleteReminder(id);
952
+ return c.json({ result: ok ? { message: "Reminder deleted." } : { error: "Reminder not found" } });
953
+ }
954
+
955
+ // ─── Email Tools ─────────────────────────────────────────────
956
+ case "list_email_accounts": {
957
+ const accounts = emailService.loadAccounts();
958
+ return c.json({ result: { accounts: accounts.map((a) => ({ id: a.id, name: a.name, email: a.email })), count: accounts.length } });
959
+ }
960
+
961
+ case "list_emails": {
962
+ const accountId = args?.account as string;
963
+ if (!accountId) return c.json({ result: { error: "account (name, email or id) is required" } });
964
+ const account = emailService.getAccount(accountId);
965
+ if (!account) return c.json({ result: { error: `Account "${accountId}" not found. Use list_email_accounts to see available accounts.` } });
966
+ const emails = await emailService.listEmails(account, {
967
+ limit: (args?.limit as number) || 10,
968
+ unseen: !!args?.unseen_only,
969
+ });
970
+ return c.json({ result: { emails, count: emails.length, account: account.name } });
971
+ }
972
+
973
+ case "read_email": {
974
+ const accountId = args?.account as string;
975
+ const uid = args?.uid as number;
976
+ if (!accountId || !uid) return c.json({ result: { error: "account and uid are required" } });
977
+ const account = emailService.getAccount(accountId);
978
+ if (!account) return c.json({ result: { error: `Account "${accountId}" not found` } });
979
+ const email = await emailService.readEmail(account, uid);
980
+ if (!email) return c.json({ result: { error: "Email not found" } });
981
+ // Clean up body for voice readability: strip encoding artifacts, excessive whitespace
982
+ let body = email.textBody || "(empty)";
983
+ // Remove common MIME/encoding artifacts
984
+ body = body.replace(/=\r?\n/g, "");
985
+ body = body.replace(/=([0-9A-Fa-f]{2})/g, (_m, hex) => String.fromCharCode(parseInt(hex, 16)));
986
+ body = body.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F]/g, "");
987
+ body = body.replace(/\s+/g, " ").trim();
988
+ // Limit for Gemini tool response (keep it voice-friendly)
989
+ if (body.length > 1500) body = body.slice(0, 1500) + "...";
990
+ return c.json({ result: { subject: email.subject, from: email.from, to: email.to, date: email.date, body } });
991
+ }
992
+
993
+ case "search_emails": {
994
+ const accountId = args?.account as string;
995
+ const query = args?.query as string;
996
+ if (!accountId || !query) return c.json({ result: { error: "account and query are required" } });
997
+ const account = emailService.getAccount(accountId);
998
+ if (!account) return c.json({ result: { error: `Account "${accountId}" not found` } });
999
+ const emails = await emailService.searchEmails(account, query, (args?.limit as number) || 10);
1000
+ return c.json({ result: { emails, count: emails.length } });
1001
+ }
1002
+
1003
+ case "send_email": {
1004
+ const accountId = args?.account as string;
1005
+ const to = args?.to as string;
1006
+ const subject = args?.subject as string;
1007
+ const body = args?.body as string;
1008
+ if (!accountId || !to || !subject || !body) return c.json({ result: { error: "account, to, subject, and body are required" } });
1009
+ const account = emailService.getAccount(accountId);
1010
+ if (!account) return c.json({ result: { error: `Account "${accountId}" not found` } });
1011
+ const result = await emailService.sendEmail(account, to, subject, body);
1012
+ return c.json({ result: { ...result, message: `Email sent from ${account.email} to ${to}` } });
1013
+ }
1014
+
1015
+ case "reply_email": {
1016
+ const accountId = args?.account as string;
1017
+ const uid = args?.uid as number;
1018
+ const body = args?.body as string;
1019
+ if (!accountId || !uid || !body) return c.json({ result: { error: "account, uid, and body are required" } });
1020
+ const account = emailService.getAccount(accountId);
1021
+ if (!account) return c.json({ result: { error: `Account "${accountId}" not found` } });
1022
+ const result = await emailService.replyToEmail(account, uid, body);
1023
+ return c.json({ result: { ...result, message: "Reply sent." } });
1024
+ }
1025
+
1026
+ case "email_summary": {
1027
+ const summary = await emailService.getUnreadSummary();
1028
+ return c.json({ result: { accounts: summary, totalUnread: summary.reduce((s, a) => s + a.unread, 0) } });
1029
+ }
1030
+
1031
+ // ─── Calendar Tools ────────────────────────────────────────────
1032
+ case "list_calendar_accounts": {
1033
+ const accounts = calendarService.loadAccounts();
1034
+ return c.json({ result: { accounts: accounts.map((a) => ({ id: a.id, name: a.name, provider: a.provider })), count: accounts.length } });
1035
+ }
1036
+
1037
+ case "list_events": {
1038
+ const accountId = args?.account as string;
1039
+ if (!accountId) return c.json({ result: { error: "account (name or id) is required" } });
1040
+ const calAccount = calendarService.getAccount(accountId);
1041
+ if (!calAccount) return c.json({ result: { error: `Calendar account "${accountId}" not found. Use list_calendar_accounts to see available accounts.` } });
1042
+ const events = await calendarService.listEvents(calAccount, {
1043
+ from: args?.from as string | undefined,
1044
+ to: args?.to as string | undefined,
1045
+ });
1046
+ return c.json({ result: { events, count: events.length, account: calAccount.name } });
1047
+ }
1048
+
1049
+ case "create_event": {
1050
+ const accountId = args?.account as string;
1051
+ const summary = args?.summary as string;
1052
+ const start = args?.start as string;
1053
+ const end = args?.end as string;
1054
+ if (!accountId || !summary || !start || !end) {
1055
+ return c.json({ result: { error: "account, summary, start, and end are required" } });
1056
+ }
1057
+ const calAccount = calendarService.getAccount(accountId);
1058
+ if (!calAccount) return c.json({ result: { error: `Calendar account "${accountId}" not found` } });
1059
+ const created = await calendarService.createEvent(calAccount, {
1060
+ summary,
1061
+ description: args?.description as string | undefined,
1062
+ location: args?.location as string | undefined,
1063
+ start,
1064
+ end,
1065
+ allDay: !!args?.all_day,
1066
+ });
1067
+ return c.json({ result: { ...created, message: `Event "${summary}" created.` } });
1068
+ }
1069
+
1070
+ case "search_events": {
1071
+ const accountId = args?.account as string;
1072
+ const query = args?.query as string;
1073
+ if (!accountId || !query) return c.json({ result: { error: "account and query are required" } });
1074
+ const calAccount = calendarService.getAccount(accountId);
1075
+ if (!calAccount) return c.json({ result: { error: `Calendar account "${accountId}" not found` } });
1076
+ const events = await calendarService.searchEvents(calAccount, query, {
1077
+ from: args?.from as string | undefined,
1078
+ to: args?.to as string | undefined,
1079
+ });
1080
+ return c.json({ result: { events, count: events.length } });
1081
+ }
1082
+
1083
+ case "delete_event": {
1084
+ const accountId = args?.account as string;
1085
+ const uid = args?.uid as string;
1086
+ if (!accountId || !uid) return c.json({ result: { error: "account and uid are required" } });
1087
+ const calAccount = calendarService.getAccount(accountId);
1088
+ if (!calAccount) return c.json({ result: { error: `Calendar account "${accountId}" not found` } });
1089
+ const deleted = await calendarService.deleteEvent(calAccount, uid);
1090
+ return c.json({ result: deleted ? { message: "Event deleted." } : { error: "Event not found" } });
1091
+ }
1092
+
1093
+ case "calendar_summary": {
1094
+ const summary = await calendarService.getUpcomingSummary();
1095
+ return c.json({ result: { accounts: summary } });
1096
+ }
1097
+
1098
+ // ─── Telephony ──────────────────────────────────────────────────
1099
+ case "make_call": {
1100
+ let phone = (args?.phone as string) || "";
1101
+ const task = (args?.task as string) || "";
1102
+ if (!phone || !task) {
1103
+ return c.json({ result: { error: "phone and task are required" } });
1104
+ }
1105
+ try {
1106
+ // Resolve contact name to phone number if input doesn't look like a number
1107
+ const stripped = phone.replace(/[\s\-().]/g, "");
1108
+ if (!stripped.startsWith("+") && !/^\d{4,}$/.test(stripped)) {
1109
+ const { resolveContactByName } = await import("../telephony/telephony-store.js");
1110
+ const contact = resolveContactByName(phone);
1111
+ if (contact) {
1112
+ phone = contact.phone;
1113
+ } else {
1114
+ return c.json({ result: { error: `Contact "${phone}" not found. Use a phone number in E.164 format or add the contact in Settings > Telephony.` } });
1115
+ }
1116
+ }
1117
+ const { callManager } = await import("../telephony/call-manager.js");
1118
+ const call = await callManager.startCall({ phone, prompt: task, voice: args?.voice as string });
1119
+ return c.json({
1120
+ result: {
1121
+ success: true,
1122
+ callId: call.id,
1123
+ phone: call.phone,
1124
+ status: call.status,
1125
+ message: `Call to ${call.phone} initiated. Status: ${call.status}. Call ID: ${call.id}`,
1126
+ },
1127
+ });
1128
+ } catch (err) {
1129
+ return c.json({ result: { error: `Failed to start call: ${err instanceof Error ? err.message : "Unknown error"}` } });
1130
+ }
1131
+ }
1132
+
1133
+ case "list_active_calls": {
1134
+ try {
1135
+ const { callManager } = await import("../telephony/call-manager.js");
1136
+ const calls = callManager.getActiveCalls();
1137
+ return c.json({
1138
+ result: {
1139
+ calls: calls.map((c) => ({
1140
+ callId: c.id,
1141
+ phone: c.phone,
1142
+ status: c.status,
1143
+ durationSeconds: c.connectedAt ? Math.round((Date.now() - c.connectedAt) / 1000) : 0,
1144
+ prompt: c.prompt,
1145
+ })),
1146
+ message: calls.length > 0
1147
+ ? `${calls.length} active call(s): ${calls.map((c) => `${c.phone} (${c.status})`).join(", ")}`
1148
+ : "No active calls.",
1149
+ },
1150
+ });
1151
+ } catch {
1152
+ return c.json({ result: { calls: [], message: "No active calls." } });
1153
+ }
1154
+ }
1155
+
1156
+ case "end_active_call": {
1157
+ const callId = (args?.call_id as string) || "";
1158
+ if (!callId) return c.json({ result: { error: "call_id is required" } });
1159
+ try {
1160
+ const { callManager } = await import("../telephony/call-manager.js");
1161
+ const result = await callManager.endCall(callId);
1162
+ if (!result) return c.json({ result: { error: "Call not found or already ended" } });
1163
+ return c.json({
1164
+ result: {
1165
+ success: true,
1166
+ summary: result.summary,
1167
+ durationSeconds: result.durationSeconds,
1168
+ message: `Call ended. Duration: ${result.durationSeconds}s. ${result.summary || ""}`,
1169
+ },
1170
+ });
1171
+ } catch (err) {
1172
+ return c.json({ result: { error: err instanceof Error ? err.message : "Failed to end call" } });
1173
+ }
1174
+ }
1175
+
1176
+ // ─── Social Media ──────────────────────────────────────────────
1177
+ case "prepare_social_post": {
1178
+ const postText = (args?.text as string) || "";
1179
+ const postPlatforms = (args?.platforms as string[]) || [];
1180
+ if (!postText) return c.json({ result: { error: "text is required" } });
1181
+ if (!postPlatforms.length) return c.json({ result: { error: "platforms array is required" } });
1182
+ try {
1183
+ const smManager = await import("../socialmedia/manager.js");
1184
+ const post = await smManager.createDraft({
1185
+ text: postText,
1186
+ platforms: postPlatforms as import("../socialmedia/types.js").SocialPlatform[],
1187
+ scheduledAt: (args?.scheduledAt as string) || null,
1188
+ mediaUrls: (args?.mediaUrls as string[]) || [],
1189
+ title: (args?.title as string) || undefined,
1190
+ firstComment: (args?.firstComment as string) || undefined,
1191
+ videoUrl: (args?.videoUrl as string) || undefined,
1192
+ thumbnailUrl: (args?.thumbnailUrl as string) || undefined,
1193
+ createdBy: "gemini",
1194
+ });
1195
+ return c.json({
1196
+ result: {
1197
+ success: true,
1198
+ postId: post.id,
1199
+ status: "draft",
1200
+ platforms: post.platforms,
1201
+ message: `Draft post prepared for ${postPlatforms.join(", ")}. The user can review and publish it from the Social Media page.`,
1202
+ },
1203
+ });
1204
+ } catch (err) {
1205
+ return c.json({ result: { error: err instanceof Error ? err.message : "Failed to prepare post" } });
1206
+ }
1207
+ }
1208
+ case "create_social_post": {
1209
+ const postText = (args?.text as string) || "";
1210
+ const postPlatforms = (args?.platforms as string[]) || [];
1211
+ if (!postText) return c.json({ result: { error: "text is required" } });
1212
+ if (!postPlatforms.length) return c.json({ result: { error: "platforms array is required" } });
1213
+ try {
1214
+ const smManager = await import("../socialmedia/manager.js");
1215
+ const post = await smManager.createPost({
1216
+ text: postText,
1217
+ platforms: postPlatforms as import("../socialmedia/types.js").SocialPlatform[],
1218
+ scheduledAt: (args?.scheduledAt as string) || null,
1219
+ mediaUrls: [],
1220
+ });
1221
+ return c.json({
1222
+ result: {
1223
+ success: true,
1224
+ postId: post.id,
1225
+ status: post.status,
1226
+ platforms: post.platforms,
1227
+ message: `Post created on ${postPlatforms.join(", ")}. Status: ${post.status}.`,
1228
+ },
1229
+ });
1230
+ } catch (err) {
1231
+ return c.json({ result: { error: err instanceof Error ? err.message : "Failed to create post" } });
1232
+ }
1233
+ }
1234
+
1235
+ case "list_social_posts": {
1236
+ try {
1237
+ const smManager = await import("../socialmedia/manager.js");
1238
+ const posts = await smManager.listPosts({
1239
+ limit: (args?.limit as number) || 10,
1240
+ status: (args?.status as string) || undefined,
1241
+ });
1242
+ return c.json({
1243
+ result: {
1244
+ posts: posts.map((p) => ({
1245
+ id: p.id,
1246
+ text: p.text.slice(0, 100),
1247
+ status: p.status,
1248
+ platforms: p.platforms,
1249
+ createdAt: p.createdAt,
1250
+ })),
1251
+ message: posts.length > 0
1252
+ ? `${posts.length} post(s) found.`
1253
+ : "No posts found.",
1254
+ },
1255
+ });
1256
+ } catch (err) {
1257
+ return c.json({ result: { error: err instanceof Error ? err.message : "Failed to list posts" } });
1258
+ }
1259
+ }
1260
+
1261
+ case "get_social_analytics": {
1262
+ const profileId = (args?.profileId as string) || "";
1263
+ if (!profileId) return c.json({ result: { error: "profileId is required" } });
1264
+ try {
1265
+ const smManager = await import("../socialmedia/manager.js");
1266
+ const analytics = await smManager.getAccountAnalytics(profileId);
1267
+ return c.json({ result: { ...analytics, message: `Followers: ${analytics.followers}, Following: ${analytics.following}, Posts: ${analytics.posts}` } });
1268
+ } catch (err) {
1269
+ return c.json({ result: { error: err instanceof Error ? err.message : "Failed to get analytics" } });
1270
+ }
1271
+ }
1272
+
1273
+ case "reply_to_social_comment": {
1274
+ const smPostId = (args?.postId as string) || "";
1275
+ const smCommentId = (args?.commentId as string) || null;
1276
+ const smText = (args?.text as string) || "";
1277
+ if (!smPostId || !smText) return c.json({ result: { error: "postId and text are required" } });
1278
+ try {
1279
+ const smManager = await import("../socialmedia/manager.js");
1280
+ const result = await smManager.replyToComment(smPostId, smCommentId, smText);
1281
+ return c.json({ result: { ...result, message: result.ok ? "Reply sent." : `Failed: ${result.error}` } });
1282
+ } catch (err) {
1283
+ return c.json({ result: { error: err instanceof Error ? err.message : "Failed to reply" } });
1284
+ }
1285
+ }
1286
+
1287
+ default:
1288
+ return c.json({ result: { error: `Unknown tool: ${name}` } });
1289
+ }
1290
+ } catch (err) {
1291
+ return c.json({ result: { error: err instanceof Error ? err.message : String(err) } });
1292
+ }
1293
+ });
1294
+
1295
+ // ─── Gemini Conversation History ──────────────────────────────────────────
1296
+
1297
+ api.get("/gemini/conversations", (c) => {
1298
+ return c.json(assistantStore.listGeminiConversations());
1299
+ });
1300
+
1301
+ api.get("/gemini/conversations/:id", (c) => {
1302
+ const convo = assistantStore.getGeminiConversation(c.req.param("id"));
1303
+ if (!convo) return c.json({ error: "Not found" }, 404);
1304
+ return c.json(convo);
1305
+ });
1306
+
1307
+ api.post("/gemini/conversations", async (c) => {
1308
+ const body = await c.req.json<{
1309
+ messages: Array<{ role: "user" | "gemini" | "system"; text: string; ts: number }>;
1310
+ duration?: number;
1311
+ }>();
1312
+ if (!body.messages || !Array.isArray(body.messages) || body.messages.length === 0) {
1313
+ return c.json({ error: "messages required" }, 400);
1314
+ }
1315
+ const convo = assistantStore.saveGeminiConversation(body.messages, body.duration);
1316
+ return c.json(convo);
1317
+ });
1318
+
1319
+ api.delete("/gemini/conversations/:id", (c) => {
1320
+ const ok = assistantStore.deleteGeminiConversation(c.req.param("id"));
1321
+ return c.json({ ok });
1322
+ });
1323
+
1324
+ // ─── Export / Import (Backup) ─────────────────────────────────────────────
1325
+
1326
+ api.get("/export", (c) => {
1327
+ const agents = listAgents();
1328
+ const settings = getSettings();
1329
+ const notes = assistantStore.listNotes();
1330
+ const todos = assistantStore.listTodos();
1331
+ const reminders = assistantStore.listReminders(true);
1332
+ const conversations = assistantStore.listGeminiConversations();
1333
+ return c.json({
1334
+ version: 1,
1335
+ exportedAt: new Date().toISOString(),
1336
+ agents,
1337
+ settings,
1338
+ notes,
1339
+ todos,
1340
+ reminders,
1341
+ geminiConversations: conversations,
1342
+ });
1343
+ });
1344
+
1345
+ api.post("/import", async (c) => {
1346
+ const body = await c.req.json<{
1347
+ agents?: unknown[];
1348
+ notes?: unknown[];
1349
+ todos?: unknown[];
1350
+ reminders?: unknown[];
1351
+ }>();
1352
+ const imported: Record<string, number> = {};
1353
+ if (Array.isArray(body.agents)) {
1354
+ for (const a of body.agents) {
1355
+ try {
1356
+ createAgent(a as Parameters<typeof createAgent>[0]);
1357
+ imported.agents = (imported.agents || 0) + 1;
1358
+ } catch {}
1359
+ }
1360
+ }
1361
+ if (Array.isArray(body.notes)) {
1362
+ for (const n of body.notes as Array<{ title?: string; content?: string; tags?: string[] }>) {
1363
+ if (n.title && n.content) {
1364
+ assistantStore.addNote(n.title, n.content, n.tags || []);
1365
+ imported.notes = (imported.notes || 0) + 1;
1366
+ }
1367
+ }
1368
+ }
1369
+ if (Array.isArray(body.todos)) {
1370
+ for (const t of body.todos as Array<{ text?: string; priority?: string; category?: string }>) {
1371
+ if (t.text) {
1372
+ assistantStore.addTodo(t.text, t.priority || "medium", t.category);
1373
+ imported.todos = (imported.todos || 0) + 1;
1374
+ }
1375
+ }
1376
+ }
1377
+ return c.json({ imported });
1378
+ });
1379
+ }