@shykaruu/jarvis-brain 0.4.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 (330) hide show
  1. package/LICENSE +153 -0
  2. package/README.md +428 -0
  3. package/bin/jarvis.ts +449 -0
  4. package/package.json +79 -0
  5. package/roles/activity-observer.yaml +60 -0
  6. package/roles/ceo-founder.yaml +144 -0
  7. package/roles/chief-of-staff.yaml +158 -0
  8. package/roles/dev-lead.yaml +182 -0
  9. package/roles/executive-assistant.yaml +77 -0
  10. package/roles/marketing-director.yaml +168 -0
  11. package/roles/personal-assistant.yaml +266 -0
  12. package/roles/research-specialist.yaml +60 -0
  13. package/roles/specialists/content-writer.yaml +53 -0
  14. package/roles/specialists/customer-support.yaml +57 -0
  15. package/roles/specialists/data-analyst.yaml +57 -0
  16. package/roles/specialists/financial-analyst.yaml +56 -0
  17. package/roles/specialists/hr-specialist.yaml +55 -0
  18. package/roles/specialists/legal-advisor.yaml +58 -0
  19. package/roles/specialists/marketing-strategist.yaml +56 -0
  20. package/roles/specialists/project-coordinator.yaml +55 -0
  21. package/roles/specialists/research-analyst.yaml +58 -0
  22. package/roles/specialists/software-engineer.yaml +57 -0
  23. package/roles/specialists/system-administrator.yaml +57 -0
  24. package/roles/system-admin.yaml +76 -0
  25. package/scripts/ensure-bun.cjs +16 -0
  26. package/src/actions/README.md +421 -0
  27. package/src/actions/app-control/desktop-controller.test.ts +26 -0
  28. package/src/actions/app-control/desktop-controller.ts +438 -0
  29. package/src/actions/app-control/interface.ts +64 -0
  30. package/src/actions/app-control/linux.ts +273 -0
  31. package/src/actions/app-control/macos.ts +54 -0
  32. package/src/actions/app-control/sidecar-launcher.test.ts +23 -0
  33. package/src/actions/app-control/sidecar-launcher.ts +286 -0
  34. package/src/actions/app-control/windows.ts +44 -0
  35. package/src/actions/browser/cdp.ts +138 -0
  36. package/src/actions/browser/chrome-launcher.ts +261 -0
  37. package/src/actions/browser/session.ts +506 -0
  38. package/src/actions/browser/stealth.ts +49 -0
  39. package/src/actions/index.ts +20 -0
  40. package/src/actions/terminal/executor.ts +157 -0
  41. package/src/actions/terminal/wsl-bridge.ts +126 -0
  42. package/src/actions/test.ts +93 -0
  43. package/src/actions/tools/agents.ts +363 -0
  44. package/src/actions/tools/builtin.ts +950 -0
  45. package/src/actions/tools/commitments.ts +192 -0
  46. package/src/actions/tools/content.ts +217 -0
  47. package/src/actions/tools/delegate.ts +147 -0
  48. package/src/actions/tools/desktop.test.ts +55 -0
  49. package/src/actions/tools/desktop.ts +305 -0
  50. package/src/actions/tools/documents.ts +169 -0
  51. package/src/actions/tools/goals.ts +376 -0
  52. package/src/actions/tools/local-tools-guard.ts +31 -0
  53. package/src/actions/tools/registry.ts +173 -0
  54. package/src/actions/tools/research.ts +111 -0
  55. package/src/actions/tools/sidecar-list.ts +57 -0
  56. package/src/actions/tools/sidecar-route.ts +105 -0
  57. package/src/actions/tools/workflows.ts +216 -0
  58. package/src/agents/agent.ts +132 -0
  59. package/src/agents/delegation.ts +107 -0
  60. package/src/agents/hierarchy.ts +113 -0
  61. package/src/agents/index.ts +19 -0
  62. package/src/agents/messaging.ts +125 -0
  63. package/src/agents/orchestrator.ts +592 -0
  64. package/src/agents/role-discovery.ts +61 -0
  65. package/src/agents/sub-agent-runner.ts +309 -0
  66. package/src/agents/task-manager.ts +151 -0
  67. package/src/authority/approval-delivery.ts +59 -0
  68. package/src/authority/approval.ts +196 -0
  69. package/src/authority/audit.ts +158 -0
  70. package/src/authority/authority.test.ts +519 -0
  71. package/src/authority/deferred-executor.ts +103 -0
  72. package/src/authority/emergency.ts +66 -0
  73. package/src/authority/engine.ts +301 -0
  74. package/src/authority/index.ts +12 -0
  75. package/src/authority/learning.ts +111 -0
  76. package/src/authority/tool-action-map.ts +74 -0
  77. package/src/awareness/analytics.ts +466 -0
  78. package/src/awareness/awareness.test.ts +332 -0
  79. package/src/awareness/capture-engine.ts +305 -0
  80. package/src/awareness/context-graph.ts +130 -0
  81. package/src/awareness/context-tracker.ts +349 -0
  82. package/src/awareness/index.ts +25 -0
  83. package/src/awareness/intelligence.ts +321 -0
  84. package/src/awareness/ocr-engine.ts +88 -0
  85. package/src/awareness/service.ts +528 -0
  86. package/src/awareness/struggle-detector.ts +342 -0
  87. package/src/awareness/suggestion-engine.ts +476 -0
  88. package/src/awareness/types.ts +201 -0
  89. package/src/cli/autostart.ts +417 -0
  90. package/src/cli/deps.ts +449 -0
  91. package/src/cli/doctor.ts +238 -0
  92. package/src/cli/helpers.ts +401 -0
  93. package/src/cli/onboard.ts +827 -0
  94. package/src/cli/uninstall.test.ts +37 -0
  95. package/src/cli/uninstall.ts +202 -0
  96. package/src/comms/README.md +329 -0
  97. package/src/comms/auth-error.html +48 -0
  98. package/src/comms/channels/discord.ts +228 -0
  99. package/src/comms/channels/signal.ts +56 -0
  100. package/src/comms/channels/telegram.ts +316 -0
  101. package/src/comms/channels/whatsapp.ts +60 -0
  102. package/src/comms/channels.test.ts +173 -0
  103. package/src/comms/dashboard-auth.ts +75 -0
  104. package/src/comms/desktop-notify.ts +114 -0
  105. package/src/comms/example.ts +129 -0
  106. package/src/comms/index.ts +129 -0
  107. package/src/comms/streaming.ts +149 -0
  108. package/src/comms/voice.test.ts +504 -0
  109. package/src/comms/voice.ts +341 -0
  110. package/src/comms/websocket.test.ts +409 -0
  111. package/src/comms/websocket.ts +669 -0
  112. package/src/config/README.md +389 -0
  113. package/src/config/index.ts +6 -0
  114. package/src/config/loader.test.ts +183 -0
  115. package/src/config/loader.ts +148 -0
  116. package/src/config/types.ts +293 -0
  117. package/src/daemon/README.md +232 -0
  118. package/src/daemon/agent-service-interface.ts +9 -0
  119. package/src/daemon/agent-service.ts +667 -0
  120. package/src/daemon/api-routes.ts +3067 -0
  121. package/src/daemon/background-agent-service.ts +396 -0
  122. package/src/daemon/background-agent.test.ts +78 -0
  123. package/src/daemon/channel-service.ts +201 -0
  124. package/src/daemon/commitment-executor.ts +297 -0
  125. package/src/daemon/dashboard-auth.test.ts +170 -0
  126. package/src/daemon/event-classifier.ts +239 -0
  127. package/src/daemon/event-coalescer.ts +123 -0
  128. package/src/daemon/event-reactor.ts +214 -0
  129. package/src/daemon/flock.c +7 -0
  130. package/src/daemon/health.ts +220 -0
  131. package/src/daemon/index.ts +1070 -0
  132. package/src/daemon/llm-settings.test.ts +78 -0
  133. package/src/daemon/llm-settings.ts +450 -0
  134. package/src/daemon/observer-service.ts +150 -0
  135. package/src/daemon/pid.test.ts +283 -0
  136. package/src/daemon/pid.ts +224 -0
  137. package/src/daemon/research-queue.ts +155 -0
  138. package/src/daemon/services.ts +175 -0
  139. package/src/daemon/ws-service.ts +926 -0
  140. package/src/global.d.ts +4 -0
  141. package/src/goals/accountability.ts +240 -0
  142. package/src/goals/awareness-bridge.ts +185 -0
  143. package/src/goals/estimator.ts +185 -0
  144. package/src/goals/events.ts +28 -0
  145. package/src/goals/goals.test.ts +400 -0
  146. package/src/goals/integration.test.ts +329 -0
  147. package/src/goals/nl-builder.test.ts +220 -0
  148. package/src/goals/nl-builder.ts +256 -0
  149. package/src/goals/rhythm.test.ts +177 -0
  150. package/src/goals/rhythm.ts +275 -0
  151. package/src/goals/service.test.ts +135 -0
  152. package/src/goals/service.ts +407 -0
  153. package/src/goals/types.ts +106 -0
  154. package/src/goals/workflow-bridge.ts +96 -0
  155. package/src/integrations/google-api.ts +134 -0
  156. package/src/integrations/google-auth.ts +175 -0
  157. package/src/llm/README.md +291 -0
  158. package/src/llm/anthropic.ts +400 -0
  159. package/src/llm/gemini.ts +380 -0
  160. package/src/llm/groq.ts +406 -0
  161. package/src/llm/history.ts +147 -0
  162. package/src/llm/index.ts +21 -0
  163. package/src/llm/manager.ts +226 -0
  164. package/src/llm/ollama.ts +316 -0
  165. package/src/llm/openai.ts +411 -0
  166. package/src/llm/openrouter.ts +390 -0
  167. package/src/llm/provider.test.ts +487 -0
  168. package/src/llm/provider.ts +61 -0
  169. package/src/llm/test.ts +88 -0
  170. package/src/observers/README.md +278 -0
  171. package/src/observers/calendar.ts +113 -0
  172. package/src/observers/clipboard.ts +136 -0
  173. package/src/observers/email.ts +109 -0
  174. package/src/observers/example.ts +58 -0
  175. package/src/observers/file-watcher.ts +124 -0
  176. package/src/observers/index.ts +159 -0
  177. package/src/observers/notifications.ts +197 -0
  178. package/src/observers/observers.test.ts +203 -0
  179. package/src/observers/processes.ts +225 -0
  180. package/src/personality/README.md +61 -0
  181. package/src/personality/adapter.ts +196 -0
  182. package/src/personality/index.ts +20 -0
  183. package/src/personality/learner.ts +209 -0
  184. package/src/personality/model.ts +132 -0
  185. package/src/personality/personality.test.ts +236 -0
  186. package/src/roles/README.md +252 -0
  187. package/src/roles/authority.ts +120 -0
  188. package/src/roles/example-usage.ts +198 -0
  189. package/src/roles/index.ts +42 -0
  190. package/src/roles/loader.ts +143 -0
  191. package/src/roles/prompt-builder.ts +218 -0
  192. package/src/roles/test-multi.ts +102 -0
  193. package/src/roles/test-role.yaml +77 -0
  194. package/src/roles/test-utils.ts +93 -0
  195. package/src/roles/test.ts +106 -0
  196. package/src/roles/tool-guide.ts +195 -0
  197. package/src/roles/types.ts +36 -0
  198. package/src/roles/utils.ts +200 -0
  199. package/src/scripts/google-setup.ts +168 -0
  200. package/src/sidecar/connection.ts +179 -0
  201. package/src/sidecar/index.ts +6 -0
  202. package/src/sidecar/manager.ts +542 -0
  203. package/src/sidecar/protocol.ts +85 -0
  204. package/src/sidecar/rpc.ts +161 -0
  205. package/src/sidecar/scheduler.ts +136 -0
  206. package/src/sidecar/types.ts +112 -0
  207. package/src/sidecar/validator.ts +144 -0
  208. package/src/sites/builder-tools.ts +215 -0
  209. package/src/sites/dev-server-manager.ts +286 -0
  210. package/src/sites/fixtures/security-test-site/.jarvis-project.json +6 -0
  211. package/src/sites/fixtures/security-test-site/Makefile +15 -0
  212. package/src/sites/fixtures/security-test-site/README.md +18 -0
  213. package/src/sites/fixtures/security-test-site/index.html +12 -0
  214. package/src/sites/fixtures/security-test-site/index.ts +16 -0
  215. package/src/sites/fixtures/security-test-site/package.json +13 -0
  216. package/src/sites/fixtures/security-test-site/src/app.tsx +780 -0
  217. package/src/sites/fixtures/security-test-site/tsconfig.json +10 -0
  218. package/src/sites/git-manager.ts +240 -0
  219. package/src/sites/github-manager.ts +355 -0
  220. package/src/sites/index.ts +25 -0
  221. package/src/sites/project-manager.ts +389 -0
  222. package/src/sites/proxy.ts +133 -0
  223. package/src/sites/service.ts +136 -0
  224. package/src/sites/templates.ts +169 -0
  225. package/src/sites/types.ts +89 -0
  226. package/src/user/profile-followup.test.ts +84 -0
  227. package/src/user/profile-followup.ts +185 -0
  228. package/src/user/profile.ts +224 -0
  229. package/src/vault/README.md +110 -0
  230. package/src/vault/awareness.ts +341 -0
  231. package/src/vault/commitments.ts +299 -0
  232. package/src/vault/content-pipeline.ts +270 -0
  233. package/src/vault/conversations.ts +173 -0
  234. package/src/vault/dashboard-sessions.ts +44 -0
  235. package/src/vault/documents.ts +130 -0
  236. package/src/vault/entities.ts +185 -0
  237. package/src/vault/extractor.test.ts +356 -0
  238. package/src/vault/extractor.ts +345 -0
  239. package/src/vault/facts.ts +190 -0
  240. package/src/vault/goals.ts +477 -0
  241. package/src/vault/index.ts +87 -0
  242. package/src/vault/keychain.ts +99 -0
  243. package/src/vault/observations.ts +115 -0
  244. package/src/vault/relationships.ts +178 -0
  245. package/src/vault/retrieval.test.ts +139 -0
  246. package/src/vault/retrieval.ts +258 -0
  247. package/src/vault/schema.ts +709 -0
  248. package/src/vault/settings.ts +38 -0
  249. package/src/vault/user-profile.test.ts +113 -0
  250. package/src/vault/user-profile.ts +176 -0
  251. package/src/vault/vectors.ts +92 -0
  252. package/src/vault/webapp-template-seeds.ts +116 -0
  253. package/src/vault/webapp-templates.ts +244 -0
  254. package/src/vault/workflows.ts +403 -0
  255. package/src/workflows/auto-suggest.ts +290 -0
  256. package/src/workflows/engine.ts +366 -0
  257. package/src/workflows/events.ts +24 -0
  258. package/src/workflows/executor.ts +207 -0
  259. package/src/workflows/nl-builder.ts +198 -0
  260. package/src/workflows/nodes/actions/agent-task.ts +73 -0
  261. package/src/workflows/nodes/actions/calendar-action.ts +85 -0
  262. package/src/workflows/nodes/actions/code-execution.ts +73 -0
  263. package/src/workflows/nodes/actions/discord.ts +77 -0
  264. package/src/workflows/nodes/actions/file-write.ts +73 -0
  265. package/src/workflows/nodes/actions/gmail.ts +69 -0
  266. package/src/workflows/nodes/actions/http-request.ts +117 -0
  267. package/src/workflows/nodes/actions/notification.ts +85 -0
  268. package/src/workflows/nodes/actions/run-tool.ts +55 -0
  269. package/src/workflows/nodes/actions/send-message.ts +82 -0
  270. package/src/workflows/nodes/actions/shell-command.ts +76 -0
  271. package/src/workflows/nodes/actions/telegram.ts +60 -0
  272. package/src/workflows/nodes/builtin.ts +119 -0
  273. package/src/workflows/nodes/error/error-handler.ts +37 -0
  274. package/src/workflows/nodes/error/fallback.ts +47 -0
  275. package/src/workflows/nodes/error/retry.ts +82 -0
  276. package/src/workflows/nodes/logic/delay.ts +42 -0
  277. package/src/workflows/nodes/logic/if-else.ts +41 -0
  278. package/src/workflows/nodes/logic/loop.ts +90 -0
  279. package/src/workflows/nodes/logic/merge.ts +38 -0
  280. package/src/workflows/nodes/logic/race.ts +40 -0
  281. package/src/workflows/nodes/logic/switch.ts +59 -0
  282. package/src/workflows/nodes/logic/template-render.ts +53 -0
  283. package/src/workflows/nodes/logic/variable-get.ts +37 -0
  284. package/src/workflows/nodes/logic/variable-set.ts +59 -0
  285. package/src/workflows/nodes/registry.ts +99 -0
  286. package/src/workflows/nodes/transform/aggregate.ts +99 -0
  287. package/src/workflows/nodes/transform/csv-parse.ts +70 -0
  288. package/src/workflows/nodes/transform/json-parse.ts +63 -0
  289. package/src/workflows/nodes/transform/map-filter.ts +84 -0
  290. package/src/workflows/nodes/transform/regex-match.ts +89 -0
  291. package/src/workflows/nodes/triggers/calendar.ts +33 -0
  292. package/src/workflows/nodes/triggers/clipboard.ts +32 -0
  293. package/src/workflows/nodes/triggers/cron.ts +40 -0
  294. package/src/workflows/nodes/triggers/email.ts +40 -0
  295. package/src/workflows/nodes/triggers/file-change.ts +45 -0
  296. package/src/workflows/nodes/triggers/git.ts +46 -0
  297. package/src/workflows/nodes/triggers/manual.ts +23 -0
  298. package/src/workflows/nodes/triggers/poll.ts +81 -0
  299. package/src/workflows/nodes/triggers/process.ts +44 -0
  300. package/src/workflows/nodes/triggers/screen-event.ts +37 -0
  301. package/src/workflows/nodes/triggers/webhook.ts +39 -0
  302. package/src/workflows/safe-eval.ts +139 -0
  303. package/src/workflows/template.ts +118 -0
  304. package/src/workflows/triggers/cron.ts +311 -0
  305. package/src/workflows/triggers/manager.ts +285 -0
  306. package/src/workflows/triggers/observer-bridge.ts +172 -0
  307. package/src/workflows/triggers/poller.ts +201 -0
  308. package/src/workflows/triggers/screen-condition.ts +218 -0
  309. package/src/workflows/triggers/triggers.test.ts +740 -0
  310. package/src/workflows/triggers/webhook.ts +191 -0
  311. package/src/workflows/types.ts +133 -0
  312. package/src/workflows/variables.ts +72 -0
  313. package/src/workflows/workflows.test.ts +383 -0
  314. package/src/workflows/yaml.ts +104 -0
  315. package/ui/dist/index-3gr23jt9.js +112614 -0
  316. package/ui/dist/index-9vmj8127.css +14239 -0
  317. package/ui/dist/index-hy9pc1gm.js +112873 -0
  318. package/ui/dist/index-j2ep5d1w.js +112374 -0
  319. package/ui/dist/index-jt00vjqs.js +112858 -0
  320. package/ui/dist/index-k9ymx5qb.js +112374 -0
  321. package/ui/dist/index.html +16 -0
  322. package/ui/public/audio/pcm-capture-processor.js +11 -0
  323. package/ui/public/openwakeword/models/embedding_model.onnx +0 -0
  324. package/ui/public/openwakeword/models/hey_jarvis_v0.1.onnx +0 -0
  325. package/ui/public/openwakeword/models/melspectrogram.onnx +0 -0
  326. package/ui/public/openwakeword/models/silero_vad.onnx +0 -0
  327. package/ui/public/ort/ort-wasm-simd-threaded.jsep.mjs +106 -0
  328. package/ui/public/ort/ort-wasm-simd-threaded.jsep.wasm +0 -0
  329. package/ui/public/ort/ort-wasm-simd-threaded.mjs +59 -0
  330. package/ui/public/ort/ort-wasm-simd-threaded.wasm +0 -0
@@ -0,0 +1,3067 @@
1
+ /**
2
+ * REST API Routes
3
+ *
4
+ * Thin handlers over vault functions and daemon services.
5
+ * Returns a routes object for Bun.serve().
6
+ */
7
+
8
+ import type { HealthMonitor } from './health.ts';
9
+ import type { AgentService } from './agent-service.ts';
10
+ import type { JarvisConfig } from '../config/types.ts';
11
+ import type { EntityType } from '../vault/entities.ts';
12
+ import type { CommitmentPriority, CommitmentStatus } from '../vault/commitments.ts';
13
+ import type { ObservationType } from '../vault/observations.ts';
14
+ import type { ContentStage, ContentType } from '../vault/content-pipeline.ts';
15
+ import type { AuthorityEngine } from '../authority/engine.ts';
16
+ import type { ApprovalManager } from '../authority/approval.ts';
17
+ import type { AuditTrail, AuthorityDecisionType } from '../authority/audit.ts';
18
+ import type { AuthorityLearner } from '../authority/learning.ts';
19
+ import type { EmergencyController } from '../authority/emergency.ts';
20
+ import type { DeferredExecutor } from '../authority/deferred-executor.ts';
21
+ import type { ActionCategory } from '../roles/authority.ts';
22
+
23
+ import { findEntities, getEntity, searchEntitiesByName } from '../vault/entities.ts';
24
+ import { findFacts } from '../vault/facts.ts';
25
+ import { findRelationships, getEntityRelationships } from '../vault/relationships.ts';
26
+ import { getDb } from '../vault/schema.ts';
27
+ import {
28
+ buildClearedDashboardSessionCookie,
29
+ buildDashboardSessionCookie,
30
+ createAuthenticatedDashboardSession,
31
+ getCookie,
32
+ getDashboardSessionFromRequest,
33
+ isDashboardPasswordEnabled,
34
+ revokeDashboardSessionFromRequest,
35
+ safeCompare,
36
+ } from '../comms/dashboard-auth.ts';
37
+ import { findCommitments, getUpcoming, createCommitment, getCommitment, updateCommitmentStatus, reorderCommitments } from '../vault/commitments.ts';
38
+ import { getOrCreateConversation, getMessages, getRecentConversation } from '../vault/conversations.ts';
39
+ import { getRecentObservations } from '../vault/observations.ts';
40
+ import { getPersonality } from '../personality/model.ts';
41
+ import { clearUserProfile, getUserProfile, saveUserProfile } from '../vault/user-profile.ts';
42
+ import {
43
+ USER_PROFILE_QUESTIONS,
44
+ countAnsweredUserProfileQuestions,
45
+ hasUserProfile,
46
+ } from '../user/profile.ts';
47
+ import {
48
+ createContent, getContent, findContent, updateContent, deleteContent,
49
+ advanceStage, regressStage,
50
+ addStageNote, getStageNotes,
51
+ addAttachment, getAttachment, getAttachments, deleteAttachment,
52
+ CONTENT_STAGES, CONTENT_TYPES,
53
+ } from '../vault/content-pipeline.ts';
54
+ import {
55
+ assignPersistentAgentTask,
56
+ listPersistentAgents,
57
+ spawnPersistentAgent,
58
+ terminatePersistentAgent,
59
+ } from '../actions/tools/agents.ts';
60
+
61
+ import { mkdirSync, existsSync } from 'node:fs';
62
+ import path from 'node:path';
63
+ import os from 'node:os';
64
+
65
+ // --- Security helpers ---
66
+
67
+ /** HTML-escape to prevent XSS in inline HTML responses */
68
+ function escapeHtml(str: string): string {
69
+ return str.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;').replace(/'/g, '&#39;');
70
+ }
71
+
72
+ /** Sanitize a single path segment — strip directory separators and dot-dot sequences */
73
+ function sanitizePathSegment(segment: string): string {
74
+ return path.basename(segment.replace(/\.\./g, ''));
75
+ }
76
+
77
+ /** Validate that a resolved path is within the expected base directory */
78
+ function isWithinBase(resolvedPath: string, baseDir: string): boolean {
79
+ const normalizedBase = path.resolve(baseDir) + path.sep;
80
+ const normalizedPath = path.resolve(resolvedPath);
81
+ return normalizedPath.startsWith(normalizedBase) || normalizedPath === path.resolve(baseDir);
82
+ }
83
+
84
+ /** Escape SQL LIKE wildcard characters in user input */
85
+ function escapeLike(s: string): string {
86
+ return s.replace(/[%_\\]/g, '\\$&');
87
+ }
88
+
89
+ /** Sanitize a filename for Content-Disposition headers */
90
+ function sanitizeFilename(name: string): string {
91
+ return name.replace(/[^a-zA-Z0-9_\- .]/g, '');
92
+ }
93
+
94
+ const MAX_UPLOAD_SIZE = 50 * 1024 * 1024; // 50 MB
95
+
96
+ const BLOCKED_MIME_TYPES = new Set([
97
+ 'text/html',
98
+ 'application/xhtml+xml',
99
+ 'application/javascript',
100
+ 'text/javascript',
101
+ 'image/svg+xml',
102
+ 'application/x-httpd-php',
103
+ 'application/x-sh',
104
+ 'application/x-csh',
105
+ ]);
106
+
107
+ import type { WebSocketService } from './ws-service.ts';
108
+ import type { ChannelService } from './channel-service.ts';
109
+
110
+ import type { AwarenessService } from '../awareness/service.ts';
111
+ import { readFileSync } from 'node:fs';
112
+ import {
113
+ getCapture,
114
+ getRecentCaptures,
115
+ getCapturesInRange,
116
+ } from '../vault/awareness.ts';
117
+ import type { SuggestionType } from '../awareness/types.ts';
118
+ import {
119
+ getAutostartName,
120
+ isAutostartInstalled,
121
+ scheduleAutostartRestart,
122
+ } from '../cli/autostart.ts';
123
+
124
+ export type ApiContext = {
125
+ healthMonitor: HealthMonitor;
126
+ agentService: AgentService;
127
+ config: JarvisConfig;
128
+ wsService?: WebSocketService;
129
+ channelService?: ChannelService;
130
+ authorityEngine?: AuthorityEngine;
131
+ approvalManager?: ApprovalManager;
132
+ auditTrail?: AuditTrail;
133
+ learner?: AuthorityLearner;
134
+ emergencyController?: EmergencyController;
135
+ deferredExecutor?: DeferredExecutor;
136
+ awarenessService?: AwarenessService | null;
137
+ workflowEngine?: import('../workflows/engine.ts').WorkflowEngine;
138
+ triggerManager?: import('../workflows/triggers/manager.ts').TriggerManager;
139
+ webhookManager?: import('../workflows/triggers/webhook.ts').WebhookManager;
140
+ nodeRegistry?: import('../workflows/nodes/registry.ts').NodeRegistry;
141
+ nlBuilder?: import('../workflows/nl-builder.ts').NLWorkflowBuilder;
142
+ autoSuggest?: import('../workflows/auto-suggest.ts').WorkflowAutoSuggest;
143
+ goalService?: import('../goals/service.ts').GoalService;
144
+ sidecarManager?: import('../sidecar/manager.ts').SidecarManager;
145
+ siteBuilderService?: import('../sites/service.ts').SiteBuilderService;
146
+ };
147
+
148
+ // CORS headers — scoped to the dashboard origin, not wildcard
149
+ let CORS: Record<string, string> = {
150
+ 'Access-Control-Allow-Origin': 'http://localhost:3142',
151
+ 'Access-Control-Allow-Methods': 'GET, POST, PATCH, DELETE, OPTIONS',
152
+ 'Access-Control-Allow-Headers': 'Content-Type',
153
+ };
154
+
155
+ /** Call once during init to set the correct CORS origin from config */
156
+ export function setCorsOrigin(port: number, host = 'localhost') {
157
+ CORS = {
158
+ 'Access-Control-Allow-Origin': `http://${host}:${port}`,
159
+ 'Access-Control-Allow-Methods': 'GET, POST, PATCH, DELETE, OPTIONS',
160
+ 'Access-Control-Allow-Headers': 'Content-Type',
161
+ };
162
+ }
163
+
164
+ function json(data: unknown, status = 200, headers: Record<string, string> = {}): Response {
165
+ return Response.json(data, { status, headers: { ...CORS, ...headers } });
166
+ }
167
+
168
+ function error(message: string, status = 400): Response {
169
+ return json({ error: message }, status);
170
+ }
171
+
172
+ function getSearchParams(req: Request): URLSearchParams {
173
+ return new URL(req.url).searchParams;
174
+ }
175
+
176
+ type AgentTaskSnapshot = {
177
+ id: string;
178
+ agentId: string;
179
+ status: string;
180
+ task: string;
181
+ startedAt: number;
182
+ completedAt?: number | null;
183
+ };
184
+
185
+ function buildAgentSnapshots(ctx: ApiContext) {
186
+ const orchestrator = ctx.agentService.getOrchestrator();
187
+ const taskManager = ctx.agentService.getTaskManager();
188
+ const latestTaskByAgent = new Map<string, AgentTaskSnapshot>();
189
+ const busyAgents = new Set<string>();
190
+
191
+ if (taskManager) {
192
+ for (const task of taskManager.listTasks()) {
193
+ if (!task.agentId) continue;
194
+ if (!task.completedAt) {
195
+ busyAgents.add(task.agentId);
196
+ }
197
+
198
+ const existing = latestTaskByAgent.get(task.agentId);
199
+ if (!existing || task.startedAt >= existing.startedAt) {
200
+ latestTaskByAgent.set(task.agentId, task);
201
+ }
202
+ }
203
+ }
204
+
205
+ const agents = orchestrator.getAllAgents().map((agent) => {
206
+ const base = agent.toJSON();
207
+ const latestTask = latestTaskByAgent.get(agent.id);
208
+ const busy = busyAgents.has(agent.id) || base.status === 'active' || Boolean(base.current_task);
209
+ return {
210
+ ...base,
211
+ busy,
212
+ latest_task: latestTask ? {
213
+ id: latestTask.id,
214
+ status: latestTask.status,
215
+ task: latestTask.task,
216
+ started_at: latestTask.startedAt,
217
+ completed_at: latestTask.completedAt,
218
+ } : null,
219
+ };
220
+ });
221
+
222
+ return {
223
+ agents,
224
+ latestTaskByAgent,
225
+ taskManager,
226
+ };
227
+ }
228
+
229
+ /**
230
+ * Create all API route handlers.
231
+ */
232
+ export function createApiRoutes(ctx: ApiContext): Record<string, unknown> {
233
+ return {
234
+ // --- Health ---
235
+ '/api/health': {
236
+ GET: () => json(ctx.healthMonitor.getHealth()),
237
+ },
238
+
239
+ // --- Dashboard Auth ---
240
+ '/api/auth/session': {
241
+ GET: (req: Request) => {
242
+ const passwordEnabled = isDashboardPasswordEnabled(ctx.config);
243
+ const tokenEnabled = Boolean(ctx.config.auth?.token);
244
+ const session = passwordEnabled ? getDashboardSessionFromRequest(req) : null;
245
+ const tokenCookie = tokenEnabled ? getCookie(req, 'token') : null;
246
+ const tokenAuthenticated = Boolean(
247
+ tokenEnabled &&
248
+ ctx.config.auth?.token &&
249
+ tokenCookie &&
250
+ safeCompare(tokenCookie, ctx.config.auth.token),
251
+ );
252
+ return json({
253
+ password_enabled: passwordEnabled,
254
+ token_enabled: tokenEnabled,
255
+ authenticated: tokenAuthenticated || (passwordEnabled ? Boolean(session) : true),
256
+ expires_at: session?.expires_at ?? null,
257
+ });
258
+ },
259
+ },
260
+
261
+ '/api/auth/login': {
262
+ POST: async (req: Request) => {
263
+ if (!isDashboardPasswordEnabled(ctx.config) || !ctx.config.dashboard?.password_hash) {
264
+ return error('Dashboard password authentication is not configured', 400);
265
+ }
266
+
267
+ try {
268
+ const body = await req.json() as { password?: string };
269
+ const password = body.password?.trim() ?? '';
270
+ if (!password) return error('Password is required', 400);
271
+
272
+ const valid = await Bun.password.verify(password, ctx.config.dashboard.password_hash);
273
+ if (!valid) return error('Invalid password', 401);
274
+
275
+ const session = createAuthenticatedDashboardSession();
276
+ return json({
277
+ ok: true,
278
+ authenticated: true,
279
+ expires_at: session.expires_at,
280
+ }, 200, {
281
+ 'Set-Cookie': buildDashboardSessionCookie(req, session.id, session.expires_at),
282
+ });
283
+ } catch {
284
+ return error('Invalid request body');
285
+ }
286
+ },
287
+ },
288
+
289
+ '/api/auth/logout': {
290
+ POST: (req: Request) => {
291
+ revokeDashboardSessionFromRequest(req);
292
+ return json({ ok: true }, 200, {
293
+ 'Set-Cookie': buildClearedDashboardSessionCookie(req),
294
+ });
295
+ },
296
+ },
297
+
298
+ // --- Vault: Entities ---
299
+ '/api/vault/entities': {
300
+ GET: (req: Request) => {
301
+ const params = getSearchParams(req);
302
+ const type = params.get('type') as EntityType | null;
303
+ const q = params.get('q');
304
+ const query: { type?: EntityType; nameContains?: string } = {};
305
+ if (type) query.type = type;
306
+ if (q) query.nameContains = q;
307
+ return json(findEntities(query));
308
+ },
309
+ },
310
+
311
+ '/api/vault/entities/:id': {
312
+ GET: (req: Request & { params: { id: string } }) => {
313
+ const entity = getEntity(req.params.id);
314
+ if (!entity) return error('Entity not found', 404);
315
+ return json(entity);
316
+ },
317
+ },
318
+
319
+ '/api/vault/entities/:id/facts': {
320
+ GET: (req: Request & { params: { id: string } }) => {
321
+ return json(findFacts({ subject_id: req.params.id }));
322
+ },
323
+ },
324
+
325
+ '/api/vault/entities/:id/relationships': {
326
+ GET: (req: Request & { params: { id: string } }) => {
327
+ return json(getEntityRelationships(req.params.id));
328
+ },
329
+ },
330
+
331
+ // --- Vault: Facts ---
332
+ '/api/vault/facts': {
333
+ GET: (req: Request) => {
334
+ const params = getSearchParams(req);
335
+ const query: { subject_id?: string; predicate?: string; object?: string } = {};
336
+ const subjectId = params.get('subject_id');
337
+ const predicate = params.get('predicate');
338
+ const object = params.get('object');
339
+ if (subjectId) query.subject_id = subjectId;
340
+ if (predicate) query.predicate = predicate;
341
+ if (object) query.object = object;
342
+ return json(findFacts(query));
343
+ },
344
+ },
345
+
346
+ // --- Vault: Relationships ---
347
+ '/api/vault/relationships': {
348
+ GET: (req: Request) => {
349
+ const params = getSearchParams(req);
350
+ const query: { from_id?: string; to_id?: string; type?: string } = {};
351
+ const fromId = params.get('from_id');
352
+ const toId = params.get('to_id');
353
+ const type = params.get('type');
354
+ if (fromId) query.from_id = fromId;
355
+ if (toId) query.to_id = toId;
356
+ if (type) query.type = type;
357
+ return json(findRelationships(query));
358
+ },
359
+ },
360
+
361
+ // --- Vault: Unified Search ---
362
+ '/api/vault/search': {
363
+ GET: (req: Request) => {
364
+ const params = getSearchParams(req);
365
+ const q = params.get('q')?.trim() || '';
366
+ const type = params.get('type') as EntityType | null;
367
+ const limit = Math.min(parseInt(params.get('limit') ?? '50') || 50, 200);
368
+
369
+ const db = getDb();
370
+ const entityIds = new Set<string>();
371
+
372
+ if (q) {
373
+ // 1. Search entities by name
374
+ const nameMatches = searchEntitiesByName(q);
375
+ for (const e of nameMatches) entityIds.add(e.id);
376
+
377
+ // 2. Search facts by predicate or object
378
+ const safeQ = escapeLike(q);
379
+ const factRows = db.prepare(
380
+ "SELECT DISTINCT subject_id FROM facts WHERE predicate LIKE ? ESCAPE '\\' OR object LIKE ? ESCAPE '\\' LIMIT 200"
381
+ ).all(`%${safeQ}%`, `%${safeQ}%`) as { subject_id: string }[];
382
+ for (const r of factRows) entityIds.add(r.subject_id);
383
+
384
+ // 3. Search relationships by type
385
+ const relRows = db.prepare(
386
+ "SELECT from_id, to_id FROM relationships WHERE type LIKE ? ESCAPE '\\' LIMIT 200"
387
+ ).all(`%${safeQ}%`) as { from_id: string; to_id: string }[];
388
+ for (const r of relRows) {
389
+ entityIds.add(r.from_id);
390
+ entityIds.add(r.to_id);
391
+ }
392
+ } else {
393
+ // No query — return all entities
394
+ const allEntities = findEntities(type ? { type } : {});
395
+ for (const e of allEntities) entityIds.add(e.id);
396
+ }
397
+
398
+ // Filter by type if specified
399
+ const results: Array<{
400
+ entity: ReturnType<typeof getEntity>;
401
+ facts: ReturnType<typeof findFacts>;
402
+ relationships: Array<{ type: string; target: string; direction: 'from' | 'to' }>;
403
+ }> = [];
404
+
405
+ for (const id of entityIds) {
406
+ if (results.length >= limit) break;
407
+ const entity = getEntity(id);
408
+ if (!entity) continue;
409
+ if (type && entity.type !== type) continue;
410
+
411
+ const facts = findFacts({ subject_id: id });
412
+ const rels = getEntityRelationships(id);
413
+ const relationships = rels.map(r => ({
414
+ type: r.type,
415
+ target: r.from_id === id ? r.to_entity.name : r.from_entity.name,
416
+ direction: (r.from_id === id ? 'from' : 'to') as 'from' | 'to',
417
+ }));
418
+
419
+ results.push({ entity, facts, relationships });
420
+ }
421
+
422
+ // Sort by updated_at desc
423
+ results.sort((a, b) => (b.entity!.updated_at) - (a.entity!.updated_at));
424
+
425
+ return json(results);
426
+ },
427
+ },
428
+
429
+ // --- Vault: Commitments ---
430
+ '/api/vault/commitments': {
431
+ GET: (req: Request) => {
432
+ const params = getSearchParams(req);
433
+ const status = params.get('status') as CommitmentStatus | null;
434
+ const priority = params.get('priority') as CommitmentPriority | null;
435
+ const assignedTo = params.get('assigned_to');
436
+ const overdue = params.get('overdue');
437
+ const upcoming = params.get('upcoming');
438
+
439
+ if (upcoming) {
440
+ return json(getUpcoming(parseInt(upcoming) || 10));
441
+ }
442
+
443
+ const query: {
444
+ status?: CommitmentStatus;
445
+ priority?: CommitmentPriority;
446
+ assigned_to?: string;
447
+ overdue?: boolean;
448
+ } = {};
449
+ if (status) query.status = status;
450
+ if (priority) query.priority = priority;
451
+ if (assignedTo) query.assigned_to = assignedTo;
452
+ if (overdue === 'true') query.overdue = true;
453
+ return json(findCommitments(query));
454
+ },
455
+ POST: async (req: Request) => {
456
+ try {
457
+ const body = await req.json() as {
458
+ what: string;
459
+ when_due?: number;
460
+ context?: string;
461
+ priority?: CommitmentPriority;
462
+ assigned_to?: string;
463
+ };
464
+ if (!body.what) return error('Missing "what" field');
465
+ const commitment = createCommitment(body.what, {
466
+ when_due: body.when_due,
467
+ context: body.context,
468
+ priority: body.priority,
469
+ assigned_to: body.assigned_to,
470
+ });
471
+ ctx.wsService?.broadcastTaskUpdate(commitment, 'created');
472
+ return json(commitment, 201);
473
+ } catch (err) {
474
+ return error('Invalid request body');
475
+ }
476
+ },
477
+ },
478
+
479
+ '/api/vault/commitments/reorder': {
480
+ POST: async (req: Request) => {
481
+ try {
482
+ const body = await req.json() as { items: { id: string; sort_order: number }[] };
483
+ if (!body.items || !Array.isArray(body.items)) return error('Missing "items" array');
484
+ reorderCommitments(body.items);
485
+ return json({ ok: true });
486
+ } catch (err) {
487
+ return error('Invalid request body');
488
+ }
489
+ },
490
+ },
491
+
492
+ '/api/vault/commitments/:id': {
493
+ GET: (req: Request & { params: { id: string } }) => {
494
+ const commitment = getCommitment(req.params.id);
495
+ if (!commitment) return error('Commitment not found', 404);
496
+ return json(commitment);
497
+ },
498
+ PATCH: async (req: Request & { params: { id: string } }) => {
499
+ try {
500
+ const body = await req.json() as { status?: CommitmentStatus; result?: string };
501
+ const id = req.params.id;
502
+
503
+ if (!body.status) return error('Missing "status" field');
504
+
505
+ const validStatuses: CommitmentStatus[] = ['pending', 'active', 'completed', 'failed', 'escalated'];
506
+ if (!validStatuses.includes(body.status)) {
507
+ return error(`Invalid status. Must be one of: ${validStatuses.join(', ')}`);
508
+ }
509
+
510
+ const updated = updateCommitmentStatus(id, body.status, body.result);
511
+ if (!updated) return error('Commitment not found', 404);
512
+ ctx.wsService?.broadcastTaskUpdate(updated, 'updated');
513
+ return json(updated);
514
+ } catch (err) {
515
+ return error('Invalid request body');
516
+ }
517
+ },
518
+ },
519
+
520
+ // --- Vault: Conversations ---
521
+ '/api/vault/conversations': {
522
+ GET: (req: Request) => {
523
+ const params = getSearchParams(req);
524
+ const channel = params.get('channel');
525
+ const limit = Math.min(parseInt(params.get('limit') ?? '20') || 20, 100);
526
+
527
+ const db = getDb();
528
+ let rows;
529
+ if (channel && channel !== 'all') {
530
+ rows = db.prepare(
531
+ 'SELECT * FROM conversations WHERE channel = ? ORDER BY last_message_at DESC LIMIT ?'
532
+ ).all(channel, limit);
533
+ } else {
534
+ rows = db.prepare(
535
+ 'SELECT * FROM conversations ORDER BY last_message_at DESC LIMIT ?'
536
+ ).all(limit);
537
+ }
538
+ return json(rows);
539
+ },
540
+ },
541
+
542
+ '/api/vault/conversations/active': {
543
+ GET: (req: Request) => {
544
+ const params = getSearchParams(req);
545
+ const channel = params.get('channel') ?? 'websocket';
546
+
547
+ if (channel === 'all') {
548
+ // Return the most recent conversation per channel
549
+ const channels = ['websocket', 'telegram', 'discord'];
550
+ const results: Record<string, unknown> = {};
551
+ for (const ch of channels) {
552
+ const result = getRecentConversation(ch);
553
+ if (result) results[ch] = result;
554
+ }
555
+ return json(results);
556
+ }
557
+
558
+ const result = getRecentConversation(channel);
559
+ if (!result) return json({ conversation: null, messages: [] });
560
+ return json(result);
561
+ },
562
+ },
563
+
564
+ '/api/vault/conversations/:id/messages': {
565
+ GET: (req: Request & { params: { id: string } }) => {
566
+ const params = getSearchParams(req);
567
+ const limit = parseInt(params.get('limit') ?? '100') || 100;
568
+ const messages = getMessages(req.params.id, { limit });
569
+ return json(messages);
570
+ },
571
+ },
572
+
573
+ // --- Vault: Observations ---
574
+ '/api/vault/observations': {
575
+ GET: (req: Request) => {
576
+ const params = getSearchParams(req);
577
+ const type = params.get('type') as ObservationType | undefined;
578
+ const limit = parseInt(params.get('limit') ?? '50') || 50;
579
+ return json(getRecentObservations(type, limit));
580
+ },
581
+ },
582
+
583
+ // --- Calendar (unified view of scheduled commitments + content) ---
584
+ '/api/calendar': {
585
+ GET: (req: Request) => {
586
+ const params = getSearchParams(req);
587
+ const rangeStart = parseInt(params.get('range_start') ?? '0');
588
+ const rangeEnd = parseInt(params.get('range_end') ?? '0');
589
+
590
+ if (!rangeStart || !rangeEnd) {
591
+ return error('Missing range_start and/or range_end (Unix ms timestamps)');
592
+ }
593
+
594
+ const db = getDb();
595
+ const events: Array<{
596
+ id: string;
597
+ type: 'commitment' | 'content';
598
+ title: string;
599
+ timestamp: number;
600
+ status: string;
601
+ priority?: string;
602
+ content_type?: string;
603
+ stage?: string;
604
+ assigned_to?: string;
605
+ has_due_date?: boolean;
606
+ }> = [];
607
+
608
+ // Commitments with when_due in range
609
+ const dueRows = db.prepare(
610
+ 'SELECT * FROM commitments WHERE when_due IS NOT NULL AND when_due >= ? AND when_due < ?'
611
+ ).all(rangeStart, rangeEnd) as any[];
612
+
613
+ for (const row of dueRows) {
614
+ events.push({
615
+ id: row.id,
616
+ type: 'commitment',
617
+ title: row.what,
618
+ timestamp: row.when_due,
619
+ status: row.status,
620
+ priority: row.priority,
621
+ assigned_to: row.assigned_to ?? undefined,
622
+ has_due_date: true,
623
+ });
624
+ }
625
+
626
+ // Commitments WITHOUT due date — show on created_at date (pending/active only)
627
+ const noDueRows = db.prepare(
628
+ "SELECT * FROM commitments WHERE when_due IS NULL AND status IN ('pending', 'active') AND created_at >= ? AND created_at < ?"
629
+ ).all(rangeStart, rangeEnd) as any[];
630
+
631
+ for (const row of noDueRows) {
632
+ events.push({
633
+ id: row.id,
634
+ type: 'commitment',
635
+ title: row.what,
636
+ timestamp: row.created_at,
637
+ status: row.status,
638
+ priority: row.priority,
639
+ assigned_to: row.assigned_to ?? undefined,
640
+ has_due_date: false,
641
+ });
642
+ }
643
+
644
+ // Content items with scheduled_at in range
645
+ const contentRows = db.prepare(
646
+ 'SELECT * FROM content_items WHERE scheduled_at IS NOT NULL AND scheduled_at >= ? AND scheduled_at < ?'
647
+ ).all(rangeStart, rangeEnd) as any[];
648
+
649
+ for (const row of contentRows) {
650
+ events.push({
651
+ id: row.id,
652
+ type: 'content',
653
+ title: row.title,
654
+ timestamp: row.scheduled_at,
655
+ status: row.stage,
656
+ content_type: row.content_type,
657
+ stage: row.stage,
658
+ });
659
+ }
660
+
661
+ // Sort by timestamp
662
+ events.sort((a, b) => a.timestamp - b.timestamp);
663
+
664
+ return json(events);
665
+ },
666
+ },
667
+
668
+ // --- Agents ---
669
+ '/api/agents': {
670
+ GET: () => {
671
+ return json(buildAgentSnapshots(ctx).agents);
672
+ },
673
+ POST: async (req: Request) => {
674
+ try {
675
+ const taskManager = ctx.agentService.getTaskManager();
676
+ if (!taskManager) return error('Persistent agents are not available.', 503);
677
+
678
+ const body = await req.json() as { specialist?: string; task?: string; context?: string };
679
+ const deps = {
680
+ orchestrator: ctx.agentService.getOrchestrator(),
681
+ llmManager: ctx.agentService.getLLMManager(),
682
+ specialists: ctx.agentService.getSpecialists(),
683
+ taskManager,
684
+ };
685
+
686
+ const spawned = spawnPersistentAgent(deps, body.specialist ?? '');
687
+ let assignment: Awaited<ReturnType<typeof assignPersistentAgentTask>> | null = null;
688
+
689
+ if (body.task?.trim()) {
690
+ assignment = await assignPersistentAgentTask(deps, {
691
+ agentId: spawned.agent.id,
692
+ task: body.task.trim(),
693
+ context: body.context?.trim(),
694
+ });
695
+ }
696
+
697
+ const latestTask = taskManager.getAgentTask(spawned.agent.id);
698
+ const busy = taskManager.isAgentBusy(spawned.agent.id)
699
+ || spawned.agent.status === 'active'
700
+ || Boolean(spawned.agent.agent.current_task);
701
+ return json({
702
+ ...spawned.agent.toJSON(),
703
+ busy,
704
+ latest_task: latestTask ? {
705
+ id: latestTask.id,
706
+ status: latestTask.status,
707
+ task: latestTask.task,
708
+ started_at: latestTask.startedAt,
709
+ completed_at: latestTask.completedAt,
710
+ } : null,
711
+ spawned: spawned.summary,
712
+ assignment,
713
+ }, 201);
714
+ } catch (err) {
715
+ return error(err instanceof Error ? err.message : String(err));
716
+ }
717
+ },
718
+ },
719
+
720
+ '/api/agents/specialists': {
721
+ GET: () => {
722
+ const specialists = Array.from(ctx.agentService.getSpecialists().values()).map((role) => ({
723
+ id: role.id,
724
+ name: role.name,
725
+ description: role.description,
726
+ authority_level: role.authority_level,
727
+ tools: role.tools,
728
+ }));
729
+ return json({ specialists });
730
+ },
731
+ },
732
+
733
+ '/api/agents/:id': {
734
+ DELETE: (req: Request & { params: { id: string } }) => {
735
+ try {
736
+ const taskManager = ctx.agentService.getTaskManager();
737
+ if (!taskManager) return error('Persistent agents are not available.', 503);
738
+ const deps = {
739
+ orchestrator: ctx.agentService.getOrchestrator(),
740
+ llmManager: ctx.agentService.getLLMManager(),
741
+ specialists: ctx.agentService.getSpecialists(),
742
+ taskManager,
743
+ };
744
+ return json(terminatePersistentAgent(deps, req.params.id));
745
+ } catch (err) {
746
+ return error(err instanceof Error ? err.message : String(err));
747
+ }
748
+ },
749
+ },
750
+
751
+ '/api/agents/tree': {
752
+ GET: () => {
753
+ const orchestrator = ctx.agentService.getOrchestrator();
754
+ const all = orchestrator.getAllAgents().map((a) => a.toJSON());
755
+ // Build tree structure
756
+ const primary = all.find((a) => !a.parent_id);
757
+ const children = all.filter((a) => a.parent_id);
758
+ return json({
759
+ primary: primary ?? null,
760
+ children,
761
+ });
762
+ },
763
+ },
764
+
765
+ '/api/agents/tasks': {
766
+ GET: () => {
767
+ const tm = ctx.agentService.getTaskManager();
768
+ if (!tm) {
769
+ return json({
770
+ active_agents: 0,
771
+ agents: [],
772
+ tasks_total: 0,
773
+ tasks_running: 0,
774
+ tasks: [],
775
+ });
776
+ }
777
+ return json(listPersistentAgents({
778
+ orchestrator: ctx.agentService.getOrchestrator(),
779
+ llmManager: ctx.agentService.getLLMManager(),
780
+ specialists: ctx.agentService.getSpecialists(),
781
+ taskManager: tm,
782
+ }));
783
+ },
784
+ },
785
+
786
+ // --- Personality ---
787
+ '/api/personality': {
788
+ GET: () => json(getPersonality()),
789
+ },
790
+
791
+ // --- User Profile Wizard ---
792
+ '/api/user-profile': {
793
+ GET: () => {
794
+ const profile = getUserProfile();
795
+ return json({
796
+ questions: USER_PROFILE_QUESTIONS,
797
+ profile,
798
+ answered_count: countAnsweredUserProfileQuestions(profile),
799
+ total_questions: USER_PROFILE_QUESTIONS.length,
800
+ has_profile: hasUserProfile(profile),
801
+ });
802
+ },
803
+ POST: async (req: Request) => {
804
+ try {
805
+ const body = await req.json() as { answers?: Record<string, unknown> };
806
+ const profile = saveUserProfile(body.answers ?? {});
807
+ return json({
808
+ ok: true,
809
+ profile,
810
+ answered_count: countAnsweredUserProfileQuestions(profile),
811
+ total_questions: USER_PROFILE_QUESTIONS.length,
812
+ message: 'User profile saved.',
813
+ });
814
+ } catch (err) {
815
+ const msg = err instanceof Error ? err.message : String(err);
816
+ return error(`Failed to save user profile: ${msg}`);
817
+ }
818
+ },
819
+ },
820
+
821
+ '/api/user-profile/clear': {
822
+ POST: () => {
823
+ clearUserProfile();
824
+ return json({ ok: true, message: 'User profile cleared.' });
825
+ },
826
+ },
827
+
828
+ // --- Config (sanitized — no API keys) ---
829
+ '/api/config': {
830
+ GET: () => {
831
+ const config = ctx.config;
832
+ return json({
833
+ daemon: config.daemon,
834
+ llm: {
835
+ primary: config.llm.primary,
836
+ fallback: config.llm.fallback,
837
+ anthropic: config.llm.anthropic ? { model: config.llm.anthropic.model } : null,
838
+ openai: config.llm.openai ? { model: config.llm.openai.model } : null,
839
+ groq: config.llm.groq ? { model: config.llm.groq.model } : null,
840
+ ollama: config.llm.ollama ?? null,
841
+ },
842
+ personality: config.personality,
843
+ authority: config.authority,
844
+ heartbeat: config.heartbeat,
845
+ active_role: config.active_role,
846
+ dashboard: {
847
+ password_enabled: isDashboardPasswordEnabled(config),
848
+ },
849
+ });
850
+ },
851
+ },
852
+
853
+ '/api/config/dashboard-auth': {
854
+ GET: () => {
855
+ return json({
856
+ password_enabled: isDashboardPasswordEnabled(ctx.config),
857
+ });
858
+ },
859
+ POST: async (req: Request) => {
860
+ try {
861
+ const body = await req.json() as { password?: string; disable?: boolean };
862
+ const disable = body.disable === true;
863
+ const nextPassword = body.password?.trim() ?? '';
864
+
865
+ if (!disable && nextPassword.length === 0) {
866
+ return error('Password is required unless disabling dashboard protection.');
867
+ }
868
+
869
+ const { loadConfig, saveConfig } = await import('../config/loader.ts');
870
+ const freshConfig = await loadConfig();
871
+ freshConfig.dashboard = freshConfig.dashboard ?? {};
872
+
873
+ if (disable) {
874
+ freshConfig.dashboard.password_hash = undefined;
875
+ await saveConfig(freshConfig);
876
+ ctx.config.dashboard = freshConfig.dashboard;
877
+ ctx.wsService?.setDashboardPasswordHash(undefined);
878
+ revokeDashboardSessionFromRequest(req);
879
+ return json({
880
+ ok: true,
881
+ password_enabled: false,
882
+ message: 'Dashboard password disabled.',
883
+ }, 200, {
884
+ 'Set-Cookie': buildClearedDashboardSessionCookie(req),
885
+ });
886
+ }
887
+
888
+ freshConfig.dashboard.password_hash = await Bun.password.hash(nextPassword);
889
+ await saveConfig(freshConfig);
890
+ ctx.config.dashboard = freshConfig.dashboard;
891
+ ctx.wsService?.setDashboardPasswordHash(freshConfig.dashboard.password_hash);
892
+
893
+ const session = createAuthenticatedDashboardSession();
894
+ return json({
895
+ ok: true,
896
+ password_enabled: true,
897
+ message: 'Dashboard password saved.',
898
+ expires_at: session.expires_at,
899
+ }, 200, {
900
+ 'Set-Cookie': buildDashboardSessionCookie(req, session.id, session.expires_at),
901
+ });
902
+ } catch (err) {
903
+ const msg = err instanceof Error ? err.message : String(err);
904
+ return error(`Failed to save dashboard password: ${msg}`, 500);
905
+ }
906
+ },
907
+ },
908
+
909
+ '/api/system/autostart': {
910
+ GET: () => {
911
+ const installed = isAutostartInstalled();
912
+ const keepaliveSupported = process.platform === 'darwin' || process.platform === 'linux';
913
+ return json({
914
+ platform: process.platform,
915
+ manager: keepaliveSupported ? getAutostartName() : 'unsupported',
916
+ installed,
917
+ keepalive_supported: keepaliveSupported,
918
+ restart_supported: keepaliveSupported && installed,
919
+ });
920
+ },
921
+ },
922
+
923
+ '/api/system/autostart/restart': {
924
+ POST: () => {
925
+ if (!(process.platform === 'darwin' || process.platform === 'linux')) {
926
+ return error('24/7 restart is not supported on this platform.', 400);
927
+ }
928
+ if (!isAutostartInstalled()) {
929
+ return error('JARVIS keepalive mode is not installed yet.', 400);
930
+ }
931
+ const scheduled = scheduleAutostartRestart();
932
+ if (!scheduled) {
933
+ return error('Failed to schedule keepalive service restart.');
934
+ }
935
+ return json({
936
+ ok: true,
937
+ message: `Restarting the JARVIS 24/7 ${getAutostartName()} service.`,
938
+ });
939
+ },
940
+ },
941
+
942
+ // --- LLM Configuration (DB + encrypted keychain) ---
943
+ '/api/config/llm': {
944
+ GET: async () => {
945
+ const { getLLMSettings } = await import('./llm-settings.ts');
946
+ return json(getLLMSettings(ctx.config));
947
+ },
948
+ POST: async (req: Request) => {
949
+ try {
950
+ const body = await req.json() as Record<string, unknown>;
951
+ const { saveLLMSettings, hotReloadLLMProviders } = await import('./llm-settings.ts');
952
+
953
+ saveLLMSettings(ctx.config, body as any);
954
+
955
+ // Hot-reload providers on the shared LLMManager
956
+ const llmManager = ctx.agentService.getLLMManager();
957
+ hotReloadLLMProviders(ctx.config, llmManager);
958
+
959
+ return json({ ok: true, message: 'LLM configuration saved and applied.' });
960
+ } catch (err) {
961
+ const msg = err instanceof Error ? err.message : String(err);
962
+ return error(`Failed to save LLM config: ${msg}`);
963
+ }
964
+ },
965
+ },
966
+
967
+ '/api/config/llm/test': {
968
+ POST: async (req: Request) => {
969
+ try {
970
+ const body = await req.json() as { provider: string; api_key?: string; model?: string; base_url?: string };
971
+ const { testLLMProvider } = await import('./llm-settings.ts');
972
+ const result = await testLLMProvider(body, ctx.config);
973
+ return json(result);
974
+ } catch (err) {
975
+ return error('Invalid request body');
976
+ }
977
+ },
978
+ },
979
+
980
+ // --- Roles ---
981
+ '/api/roles': {
982
+ GET: () => {
983
+ const orchestrator = ctx.agentService.getOrchestrator();
984
+ const primary = orchestrator.getPrimary();
985
+ return json({
986
+ active_role: primary?.agent.role.name ?? ctx.config.active_role,
987
+ // Note: specialist list is injected via prompt-builder, not directly accessible here
988
+ // We'll return what we can from the agent's role
989
+ role: primary?.agent.role ? {
990
+ id: primary.agent.role.id,
991
+ name: primary.agent.role.name,
992
+ authority_level: primary.agent.role.authority_level,
993
+ tools: primary.agent.role.tools,
994
+ sub_roles: primary.agent.role.sub_roles,
995
+ } : null,
996
+ });
997
+ },
998
+ },
999
+
1000
+ // --- Content Pipeline ---
1001
+ '/api/content': {
1002
+ GET: (req: Request) => {
1003
+ const params = getSearchParams(req);
1004
+ const stage = params.get('stage') as ContentStage | null;
1005
+ const content_type = params.get('type') as ContentType | null;
1006
+ const tag = params.get('tag');
1007
+ const query: { stage?: ContentStage; content_type?: ContentType; tag?: string } = {};
1008
+ if (stage) query.stage = stage;
1009
+ if (content_type) query.content_type = content_type;
1010
+ if (tag) query.tag = tag;
1011
+ return json(findContent(query));
1012
+ },
1013
+ POST: async (req: Request) => {
1014
+ try {
1015
+ const body = await req.json() as {
1016
+ title: string;
1017
+ body?: string;
1018
+ content_type?: ContentType;
1019
+ stage?: ContentStage;
1020
+ tags?: string[];
1021
+ created_by?: string;
1022
+ };
1023
+ if (!body.title) return error('Missing "title" field');
1024
+ const item = createContent(body.title, {
1025
+ body: body.body,
1026
+ content_type: body.content_type,
1027
+ stage: body.stage,
1028
+ tags: body.tags,
1029
+ created_by: body.created_by,
1030
+ });
1031
+ ctx.wsService?.broadcastContentUpdate(item, 'created');
1032
+ return json(item, 201);
1033
+ } catch (err) {
1034
+ return error('Invalid request body');
1035
+ }
1036
+ },
1037
+ },
1038
+
1039
+ '/api/content/:id': {
1040
+ GET: (req: Request & { params: { id: string } }) => {
1041
+ const item = getContent(req.params.id);
1042
+ if (!item) return error('Content not found', 404);
1043
+ return json(item);
1044
+ },
1045
+ PATCH: async (req: Request & { params: { id: string } }) => {
1046
+ try {
1047
+ const body = await req.json() as {
1048
+ title?: string;
1049
+ body?: string;
1050
+ content_type?: ContentType;
1051
+ stage?: ContentStage;
1052
+ tags?: string[];
1053
+ scheduled_at?: number | null;
1054
+ published_at?: number | null;
1055
+ published_url?: string | null;
1056
+ sort_order?: number;
1057
+ };
1058
+ const updated = updateContent(req.params.id, body);
1059
+ if (!updated) return error('Content not found', 404);
1060
+ ctx.wsService?.broadcastContentUpdate(updated, 'updated');
1061
+ return json(updated);
1062
+ } catch (err) {
1063
+ return error('Invalid request body');
1064
+ }
1065
+ },
1066
+ DELETE: (req: Request & { params: { id: string } }) => {
1067
+ const existing = getContent(req.params.id);
1068
+ if (!existing) return error('Content not found', 404);
1069
+ deleteContent(req.params.id);
1070
+ ctx.wsService?.broadcastContentUpdate(existing, 'deleted');
1071
+ return json({ ok: true });
1072
+ },
1073
+ },
1074
+
1075
+ '/api/content/:id/advance': {
1076
+ POST: (req: Request & { params: { id: string } }) => {
1077
+ const updated = advanceStage(req.params.id);
1078
+ if (!updated) return error('Cannot advance (not found or already at last stage)', 400);
1079
+ ctx.wsService?.broadcastContentUpdate(updated, 'updated');
1080
+ return json(updated);
1081
+ },
1082
+ },
1083
+
1084
+ '/api/content/:id/regress': {
1085
+ POST: (req: Request & { params: { id: string } }) => {
1086
+ const updated = regressStage(req.params.id);
1087
+ if (!updated) return error('Cannot regress (not found or already at first stage)', 400);
1088
+ ctx.wsService?.broadcastContentUpdate(updated, 'updated');
1089
+ return json(updated);
1090
+ },
1091
+ },
1092
+
1093
+ '/api/content/:id/notes': {
1094
+ GET: (req: Request & { params: { id: string } }) => {
1095
+ const params = getSearchParams(req);
1096
+ const stage = params.get('stage') as ContentStage | null;
1097
+ return json(getStageNotes(req.params.id, stage ?? undefined));
1098
+ },
1099
+ POST: async (req: Request & { params: { id: string } }) => {
1100
+ try {
1101
+ const body = await req.json() as {
1102
+ stage: ContentStage;
1103
+ note: string;
1104
+ author?: string;
1105
+ };
1106
+ if (!body.stage || !body.note) return error('Missing "stage" or "note" field');
1107
+ const note = addStageNote(req.params.id, body.stage, body.note, body.author);
1108
+ // Broadcast content update so UI refreshes
1109
+ const item = getContent(req.params.id);
1110
+ if (item) ctx.wsService?.broadcastContentUpdate(item, 'updated');
1111
+ return json(note, 201);
1112
+ } catch (err) {
1113
+ return error('Invalid request body');
1114
+ }
1115
+ },
1116
+ },
1117
+
1118
+ '/api/content/:id/attachments': {
1119
+ GET: (req: Request & { params: { id: string } }) => {
1120
+ return json(getAttachments(req.params.id));
1121
+ },
1122
+ POST: async (req: Request & { params: { id: string } }) => {
1123
+ try {
1124
+ const contentId = req.params.id;
1125
+ const item = getContent(contentId);
1126
+ if (!item) return error('Content not found', 404);
1127
+
1128
+ const formData = await req.formData();
1129
+ const file = formData.get('file') as File | null;
1130
+ if (!file) return error('Missing "file" in form data');
1131
+
1132
+ // Enforce upload size limit
1133
+ if (file.size > MAX_UPLOAD_SIZE) {
1134
+ return error(`File too large. Maximum size is ${MAX_UPLOAD_SIZE / 1024 / 1024}MB`, 413);
1135
+ }
1136
+
1137
+ // Block dangerous MIME types
1138
+ const mimeType = file.type || 'application/octet-stream';
1139
+ if (BLOCKED_MIME_TYPES.has(mimeType)) {
1140
+ return error(`File type "${mimeType}" is not allowed`, 415);
1141
+ }
1142
+
1143
+ const label = (formData.get('label') as string) || null;
1144
+
1145
+ // Sanitize filename to prevent path traversal
1146
+ const safeName = path.basename(file.name);
1147
+ if (!safeName || safeName === '.' || safeName === '..') {
1148
+ return error('Invalid filename', 400);
1149
+ }
1150
+
1151
+ // Save file to ~/.jarvis/content/<id>/
1152
+ const baseDir = path.join(os.homedir(), '.jarvis', 'content', contentId);
1153
+ if (!existsSync(baseDir)) {
1154
+ mkdirSync(baseDir, { recursive: true });
1155
+ }
1156
+
1157
+ const diskPath = path.resolve(baseDir, safeName);
1158
+ // Verify resolved path stays within the content directory
1159
+ if (!isWithinBase(diskPath, baseDir)) {
1160
+ return error('Invalid filename', 400);
1161
+ }
1162
+
1163
+ await Bun.write(diskPath, file);
1164
+
1165
+ const attachment = addAttachment(
1166
+ contentId,
1167
+ safeName,
1168
+ diskPath,
1169
+ mimeType,
1170
+ file.size,
1171
+ label ?? undefined,
1172
+ );
1173
+
1174
+ ctx.wsService?.broadcastContentUpdate(item, 'updated');
1175
+ return json(attachment, 201);
1176
+ } catch (err) {
1177
+ return error('File upload failed');
1178
+ }
1179
+ },
1180
+ },
1181
+
1182
+ '/api/content/:id/attachments/:aid': {
1183
+ DELETE: (req: Request & { params: { id: string; aid: string } }) => {
1184
+ // Verify attachment belongs to this content item before deleting
1185
+ const attachment = getAttachment(req.params.aid);
1186
+ if (!attachment || attachment.content_id !== req.params.id) {
1187
+ return error('Attachment not found', 404);
1188
+ }
1189
+ const deleted = deleteAttachment(req.params.aid);
1190
+ if (!deleted) return error('Attachment not found', 404);
1191
+ const item = getContent(req.params.id);
1192
+ if (item) ctx.wsService?.broadcastContentUpdate(item, 'updated');
1193
+ return json({ ok: true });
1194
+ },
1195
+ },
1196
+
1197
+ '/api/content/files/:contentId/:filename': {
1198
+ GET: async (req: Request & { params: { contentId: string; filename: string } }) => {
1199
+ // Sanitize path segments to prevent traversal
1200
+ const safeContentId = sanitizePathSegment(req.params.contentId);
1201
+ const safeFilename = sanitizePathSegment(req.params.filename);
1202
+ if (!safeContentId || !safeFilename) {
1203
+ return error('Invalid path', 400);
1204
+ }
1205
+
1206
+ const baseDir = path.join(os.homedir(), '.jarvis', 'content');
1207
+ const filePath = path.resolve(baseDir, safeContentId, safeFilename);
1208
+
1209
+ // Verify resolved path stays within the content directory
1210
+ if (!isWithinBase(filePath, baseDir)) {
1211
+ return error('Invalid path', 400);
1212
+ }
1213
+
1214
+ const file = Bun.file(filePath);
1215
+ if (!await file.exists()) {
1216
+ return error('File not found', 404);
1217
+ }
1218
+
1219
+ return new Response(file, {
1220
+ headers: {
1221
+ ...CORS,
1222
+ 'Content-Disposition': 'attachment',
1223
+ 'X-Content-Type-Options': 'nosniff',
1224
+ },
1225
+ });
1226
+ },
1227
+ },
1228
+
1229
+ // --- Google OAuth Callback ---
1230
+ '/api/auth/google/callback': {
1231
+ GET: async (req: Request) => {
1232
+ const params = getSearchParams(req);
1233
+ const code = params.get('code');
1234
+ const authError = params.get('error');
1235
+
1236
+ if (authError) {
1237
+ return new Response(
1238
+ `<html><body><h1>Authorization Denied</h1><p>${escapeHtml(authError)}</p><p>You can close this tab.</p></body></html>`,
1239
+ { headers: { ...CORS, 'Content-Type': 'text/html' } }
1240
+ );
1241
+ }
1242
+
1243
+ if (!code) {
1244
+ return error('Missing authorization code', 400);
1245
+ }
1246
+
1247
+ // Try to exchange the code using GoogleAuth from context
1248
+ const googleConfig = ctx.config.google;
1249
+ if (!googleConfig?.client_id || !googleConfig?.client_secret) {
1250
+ return error('Google OAuth not configured in config.yaml', 500);
1251
+ }
1252
+
1253
+ try {
1254
+ // Lazy import to avoid circular deps
1255
+ const { GoogleAuth } = await import('../integrations/google-auth.ts');
1256
+ const auth = new GoogleAuth(googleConfig.client_id, googleConfig.client_secret);
1257
+ await auth.exchangeCode(code);
1258
+
1259
+ return new Response(
1260
+ `<html><body style="font-family:system-ui;text-align:center;padding:60px">
1261
+ <h1>JARVIS Google Authorization Complete!</h1>
1262
+ <p>Tokens saved. This window will close automatically.</p>
1263
+ <script>
1264
+ if (window.opener) { window.opener.postMessage('google-auth-complete', window.location.origin); }
1265
+ setTimeout(function() { window.close(); }, 2000);
1266
+ </script>
1267
+ </body></html>`,
1268
+ { headers: { ...CORS, 'Content-Type': 'text/html' } }
1269
+ );
1270
+ } catch (err) {
1271
+ const msg = err instanceof Error ? err.message : String(err);
1272
+ return new Response(
1273
+ `<html><body><h1>Token Exchange Failed</h1><pre>${escapeHtml(msg)}</pre></body></html>`,
1274
+ { headers: { ...CORS, 'Content-Type': 'text/html' }, status: 500 }
1275
+ );
1276
+ }
1277
+ },
1278
+ },
1279
+
1280
+ // --- Google Auth Management ---
1281
+ '/api/auth/google/status': {
1282
+ GET: async () => {
1283
+ const googleConfig = ctx.config.google;
1284
+ const hasCredentials = !!(googleConfig?.client_id && googleConfig?.client_secret);
1285
+
1286
+ if (!hasCredentials) {
1287
+ return json({ status: 'not_configured', has_credentials: false, is_authenticated: false, scopes: [], token_expiry: null });
1288
+ }
1289
+
1290
+ try {
1291
+ const { GoogleAuth } = await import('../integrations/google-auth.ts');
1292
+ const auth = new GoogleAuth(googleConfig!.client_id, googleConfig!.client_secret);
1293
+ const authenticated = auth.isAuthenticated();
1294
+ const tokens = auth.loadTokens();
1295
+
1296
+ return json({
1297
+ status: authenticated ? 'connected' : 'credentials_saved',
1298
+ has_credentials: true,
1299
+ is_authenticated: authenticated,
1300
+ scopes: ['gmail.readonly', 'calendar.readonly'],
1301
+ token_expiry: tokens?.expiry_date ?? null,
1302
+ });
1303
+ } catch {
1304
+ return json({ status: 'credentials_saved', has_credentials: true, is_authenticated: false, scopes: [], token_expiry: null });
1305
+ }
1306
+ },
1307
+ },
1308
+
1309
+ '/api/config/google': {
1310
+ POST: async (req: Request) => {
1311
+ try {
1312
+ const body = await req.json() as { client_id: string; client_secret: string };
1313
+ if (!body.client_id || !body.client_secret) {
1314
+ return error('Missing client_id or client_secret');
1315
+ }
1316
+
1317
+ const { loadConfig, saveConfig } = await import('../config/loader.ts');
1318
+ const freshConfig = await loadConfig();
1319
+ freshConfig.google = { client_id: body.client_id, client_secret: body.client_secret };
1320
+ await saveConfig(freshConfig);
1321
+
1322
+ // Update in-memory config so callback route sees credentials immediately
1323
+ ctx.config.google = freshConfig.google;
1324
+
1325
+ return json({ ok: true });
1326
+ } catch (err) {
1327
+ const msg = err instanceof Error ? err.message : String(err);
1328
+ return error(`Failed to save Google config: ${msg}`, 500);
1329
+ }
1330
+ },
1331
+ },
1332
+
1333
+ '/api/auth/google/init': {
1334
+ POST: async () => {
1335
+ const googleConfig = ctx.config.google;
1336
+ if (!googleConfig?.client_id || !googleConfig?.client_secret) {
1337
+ return error('Google credentials not configured. Save client_id and client_secret first.', 400);
1338
+ }
1339
+
1340
+ try {
1341
+ const { GoogleAuth } = await import('../integrations/google-auth.ts');
1342
+ const auth = new GoogleAuth(googleConfig.client_id, googleConfig.client_secret);
1343
+ const scopes = [
1344
+ 'https://www.googleapis.com/auth/gmail.readonly',
1345
+ 'https://www.googleapis.com/auth/calendar.readonly',
1346
+ ];
1347
+ const authUrl = auth.getAuthUrl(scopes);
1348
+ return json({ auth_url: authUrl });
1349
+ } catch (err) {
1350
+ const msg = err instanceof Error ? err.message : String(err);
1351
+ return error(`Failed to generate auth URL: ${msg}`, 500);
1352
+ }
1353
+ },
1354
+ },
1355
+
1356
+ '/api/auth/google/disconnect': {
1357
+ POST: async () => {
1358
+ try {
1359
+ const tokensPath = path.join(os.homedir(), '.jarvis', 'google-tokens.json');
1360
+ if (existsSync(tokensPath)) {
1361
+ const { unlinkSync } = await import('node:fs');
1362
+ unlinkSync(tokensPath);
1363
+ }
1364
+ return json({ ok: true, message: 'Disconnected. Restart JARVIS to deactivate observers.' });
1365
+ } catch (err) {
1366
+ const msg = err instanceof Error ? err.message : String(err);
1367
+ return error(`Failed to disconnect: ${msg}`, 500);
1368
+ }
1369
+ },
1370
+ },
1371
+
1372
+ // --- Channels ---
1373
+ '/api/channels/status': {
1374
+ GET: () => {
1375
+ if (!ctx.channelService) return json({ channels: {}, stt: null });
1376
+ return json({
1377
+ channels: ctx.channelService.getChannelStatus(),
1378
+ stt: ctx.config.stt?.provider ?? null,
1379
+ });
1380
+ },
1381
+ },
1382
+
1383
+ '/api/config/channels': {
1384
+ GET: () => {
1385
+ const cfg = ctx.config.channels;
1386
+ return json({
1387
+ telegram: cfg?.telegram ? {
1388
+ enabled: cfg.telegram.enabled,
1389
+ has_token: !!cfg.telegram.bot_token,
1390
+ allowed_users: cfg.telegram.allowed_users,
1391
+ } : { enabled: false, has_token: false, allowed_users: [] },
1392
+ discord: cfg?.discord ? {
1393
+ enabled: cfg.discord.enabled,
1394
+ has_token: !!cfg.discord.bot_token,
1395
+ allowed_users: cfg.discord.allowed_users,
1396
+ guild_id: cfg.discord.guild_id ?? null,
1397
+ } : { enabled: false, has_token: false, allowed_users: [], guild_id: null },
1398
+ });
1399
+ },
1400
+ POST: async (req: Request) => {
1401
+ try {
1402
+ const body = await req.json() as Record<string, unknown>;
1403
+ const { loadConfig, saveConfig } = await import('../config/loader.ts');
1404
+ const freshConfig = await loadConfig();
1405
+
1406
+ if (!freshConfig.channels) freshConfig.channels = {};
1407
+
1408
+ if (body.telegram && typeof body.telegram === 'object') {
1409
+ freshConfig.channels.telegram = {
1410
+ ...freshConfig.channels.telegram,
1411
+ ...(body.telegram as Record<string, unknown>),
1412
+ } as any;
1413
+ }
1414
+ if (body.discord && typeof body.discord === 'object') {
1415
+ freshConfig.channels.discord = {
1416
+ ...freshConfig.channels.discord,
1417
+ ...(body.discord as Record<string, unknown>),
1418
+ } as any;
1419
+ }
1420
+
1421
+ await saveConfig(freshConfig);
1422
+ ctx.config.channels = freshConfig.channels;
1423
+
1424
+ return json({ ok: true, message: 'Channel config saved. Restart JARVIS to apply changes.' });
1425
+ } catch (err) {
1426
+ return error('Invalid request body');
1427
+ }
1428
+ },
1429
+ },
1430
+
1431
+ '/api/config/stt': {
1432
+ GET: () => {
1433
+ const stt = ctx.config.stt;
1434
+ return json({
1435
+ provider: stt?.provider ?? 'openai',
1436
+ has_openai_key: !!stt?.openai?.api_key,
1437
+ has_groq_key: !!stt?.groq?.api_key,
1438
+ local_endpoint: stt?.local?.endpoint ?? null,
1439
+ local_server_type: stt?.local?.server_type ?? 'whisper_cpp',
1440
+ });
1441
+ },
1442
+ POST: async (req: Request) => {
1443
+ try {
1444
+ const body = await req.json() as Record<string, unknown>;
1445
+ const { loadConfig, saveConfig } = await import('../config/loader.ts');
1446
+ const freshConfig = await loadConfig();
1447
+ freshConfig.stt = { ...freshConfig.stt, ...body } as any;
1448
+ await saveConfig(freshConfig);
1449
+ ctx.config.stt = freshConfig.stt;
1450
+ return json({ ok: true, message: 'STT config saved. Restart JARVIS to apply changes.' });
1451
+ } catch (err) {
1452
+ return error('Invalid request body');
1453
+ }
1454
+ },
1455
+ },
1456
+
1457
+ '/api/config/tts': {
1458
+ GET: () => {
1459
+ const tts = ctx.config.tts;
1460
+ return json({
1461
+ enabled: tts?.enabled ?? false,
1462
+ provider: tts?.provider ?? 'edge',
1463
+ voice: tts?.voice ?? 'en-US-AriaNeural',
1464
+ rate: tts?.rate ?? '+0%',
1465
+ volume: tts?.volume ?? '+0%',
1466
+ elevenlabs: tts?.elevenlabs ? {
1467
+ has_api_key: !!tts.elevenlabs.api_key,
1468
+ voice_id: tts.elevenlabs.voice_id ?? null,
1469
+ model: tts.elevenlabs.model ?? 'eleven_flash_v2_5',
1470
+ stability: tts.elevenlabs.stability ?? 0.5,
1471
+ similarity_boost: tts.elevenlabs.similarity_boost ?? 0.75,
1472
+ } : null,
1473
+ });
1474
+ },
1475
+ POST: async (req: Request) => {
1476
+ try {
1477
+ const body = await req.json() as Record<string, unknown>;
1478
+ const { loadConfig, saveConfig } = await import('../config/loader.ts');
1479
+ const freshConfig = await loadConfig();
1480
+
1481
+ // Deep-merge elevenlabs sub-object to preserve API key across saves
1482
+ const incomingEl = body.elevenlabs as Record<string, unknown> | undefined;
1483
+ const existingEl = freshConfig.tts?.elevenlabs;
1484
+ delete body.elevenlabs;
1485
+
1486
+ freshConfig.tts = { ...freshConfig.tts, ...body } as any;
1487
+
1488
+ if (incomingEl) {
1489
+ freshConfig.tts!.elevenlabs = {
1490
+ ...existingEl,
1491
+ ...incomingEl,
1492
+ // Keep existing API key if new one not provided
1493
+ api_key: (incomingEl.api_key as string) || existingEl?.api_key || '',
1494
+ } as any;
1495
+ }
1496
+
1497
+ await saveConfig(freshConfig);
1498
+ ctx.config.tts = freshConfig.tts;
1499
+
1500
+ // Hot-reload TTS provider if wsService available
1501
+ if (ctx.wsService && freshConfig.tts) {
1502
+ const { createTTSProvider } = await import('../comms/voice.ts');
1503
+ const provider = createTTSProvider(freshConfig.tts);
1504
+ if (provider) {
1505
+ ctx.wsService.setTTSProvider(provider);
1506
+ }
1507
+ }
1508
+
1509
+ return json({ ok: true, message: 'TTS config saved.' });
1510
+ } catch (err) {
1511
+ return error('Invalid request body');
1512
+ }
1513
+ },
1514
+ },
1515
+
1516
+ // --- TTS Voices ---
1517
+ '/api/tts/voices': {
1518
+ GET: async (req: Request) => {
1519
+ const params = getSearchParams(req);
1520
+ const provider = params.get('provider') ?? 'edge';
1521
+
1522
+ if (provider === 'elevenlabs') {
1523
+ const apiKey = ctx.config.tts?.elevenlabs?.api_key;
1524
+ if (!apiKey) return error('ElevenLabs API key not configured', 400);
1525
+
1526
+ try {
1527
+ const { listElevenLabsVoices } = await import('../comms/voice.ts');
1528
+ const voices = await listElevenLabsVoices(apiKey);
1529
+ return json(voices);
1530
+ } catch (err) {
1531
+ const msg = err instanceof Error ? err.message : String(err);
1532
+ return error(`Failed to fetch ElevenLabs voices: ${msg}`, 500);
1533
+ }
1534
+ }
1535
+
1536
+ // Edge TTS: return hardcoded voice list
1537
+ return json([
1538
+ { voice_id: 'en-US-AriaNeural', name: 'Aria (US Female)', category: 'neural' },
1539
+ { voice_id: 'en-US-GuyNeural', name: 'Guy (US Male)', category: 'neural' },
1540
+ { voice_id: 'en-GB-SoniaNeural', name: 'Sonia (UK Female)', category: 'neural' },
1541
+ { voice_id: 'en-AU-NatashaNeural', name: 'Natasha (AU Female)', category: 'neural' },
1542
+ { voice_id: 'en-US-JennyNeural', name: 'Jenny (US Female)', category: 'neural' },
1543
+ { voice_id: 'en-US-DavisNeural', name: 'Davis (US Male)', category: 'neural' },
1544
+ ]);
1545
+ },
1546
+ },
1547
+
1548
+ // --- Authority & Autonomy ---
1549
+ '/api/authority/status': {
1550
+ GET: () => {
1551
+ const engine = ctx.authorityEngine;
1552
+ const emergency = ctx.emergencyController;
1553
+ const approvals = ctx.approvalManager;
1554
+ if (!engine || !emergency) return json({ enabled: false });
1555
+
1556
+ return json({
1557
+ enabled: true,
1558
+ emergency_state: emergency.getState(),
1559
+ pending_approvals: approvals?.getPending().length ?? 0,
1560
+ config: engine.getConfig(),
1561
+ });
1562
+ },
1563
+ },
1564
+
1565
+ '/api/authority/approvals': {
1566
+ GET: (req: Request) => {
1567
+ if (!ctx.approvalManager) return json([]);
1568
+ const params = getSearchParams(req);
1569
+ const status = params.get('status');
1570
+ if (status === 'pending') {
1571
+ return json(ctx.approvalManager.getPending());
1572
+ }
1573
+ return json(ctx.approvalManager.getHistory({
1574
+ limit: parseInt(params.get('limit') ?? '50') || 50,
1575
+ action: (params.get('action') as ActionCategory) || undefined,
1576
+ agentId: params.get('agent_id') || undefined,
1577
+ status: (params.get('status') as any) || undefined,
1578
+ }));
1579
+ },
1580
+ },
1581
+
1582
+ '/api/authority/approvals/:id/approve': {
1583
+ POST: async (req: Request & { params: { id: string } }) => {
1584
+ if (!ctx.approvalManager || !ctx.deferredExecutor) {
1585
+ return error('Authority system not configured', 500);
1586
+ }
1587
+ const requestId = req.params.id;
1588
+ const approved = ctx.approvalManager.approve(requestId, 'dashboard');
1589
+ if (!approved) return error('Request not found or already decided', 404);
1590
+
1591
+ // Execute the approved tool
1592
+ const result = await ctx.deferredExecutor.executeApproved(requestId);
1593
+
1594
+ // Broadcast the update
1595
+ const updated = ctx.approvalManager.getRequest(requestId);
1596
+ if (updated) ctx.wsService?.broadcastApprovalUpdate(updated);
1597
+
1598
+ return json({ ok: true, result: result.slice(0, 500) });
1599
+ },
1600
+ },
1601
+
1602
+ '/api/authority/approvals/:id/deny': {
1603
+ POST: async (req: Request & { params: { id: string } }) => {
1604
+ if (!ctx.approvalManager || !ctx.deferredExecutor) {
1605
+ return error('Authority system not configured', 500);
1606
+ }
1607
+ const requestId = req.params.id;
1608
+ const denied = ctx.approvalManager.deny(requestId, 'dashboard');
1609
+ if (!denied) return error('Request not found or already decided', 404);
1610
+
1611
+ // Record denial for learning
1612
+ ctx.deferredExecutor.recordDenial(denied);
1613
+
1614
+ // Broadcast the update
1615
+ ctx.wsService?.broadcastApprovalUpdate(denied);
1616
+
1617
+ return json({ ok: true });
1618
+ },
1619
+ },
1620
+
1621
+ '/api/authority/audit': {
1622
+ GET: (req: Request) => {
1623
+ if (!ctx.auditTrail) return json([]);
1624
+ const params = getSearchParams(req);
1625
+ return json(ctx.auditTrail.query({
1626
+ agentId: params.get('agent_id') || undefined,
1627
+ action: (params.get('action') as ActionCategory) || undefined,
1628
+ tool: params.get('tool') || undefined,
1629
+ decision: (params.get('decision') as AuthorityDecisionType) || undefined,
1630
+ since: params.get('since') ? parseInt(params.get('since')!) : undefined,
1631
+ limit: parseInt(params.get('limit') ?? '100') || 100,
1632
+ }));
1633
+ },
1634
+ },
1635
+
1636
+ '/api/authority/audit/stats': {
1637
+ GET: (req: Request) => {
1638
+ if (!ctx.auditTrail) return json({ total: 0, allowed: 0, denied: 0, approvalRequired: 0, byCategory: {} });
1639
+ const params = getSearchParams(req);
1640
+ const since = params.get('since') ? parseInt(params.get('since')!) : undefined;
1641
+ return json(ctx.auditTrail.getStats(since));
1642
+ },
1643
+ },
1644
+
1645
+ '/api/authority/emergency/pause': {
1646
+ POST: () => {
1647
+ if (!ctx.emergencyController) return error('Emergency controller not configured', 500);
1648
+ ctx.emergencyController.pause();
1649
+ return json({ ok: true, state: ctx.emergencyController.getState() });
1650
+ },
1651
+ },
1652
+
1653
+ '/api/authority/emergency/resume': {
1654
+ POST: () => {
1655
+ if (!ctx.emergencyController) return error('Emergency controller not configured', 500);
1656
+ ctx.emergencyController.resume();
1657
+ return json({ ok: true, state: ctx.emergencyController.getState() });
1658
+ },
1659
+ },
1660
+
1661
+ '/api/authority/emergency/kill': {
1662
+ POST: () => {
1663
+ if (!ctx.emergencyController) return error('Emergency controller not configured', 500);
1664
+ ctx.emergencyController.kill();
1665
+ return json({ ok: true, state: ctx.emergencyController.getState() });
1666
+ },
1667
+ },
1668
+
1669
+ '/api/authority/emergency/reset': {
1670
+ POST: () => {
1671
+ if (!ctx.emergencyController) return error('Emergency controller not configured', 500);
1672
+ ctx.emergencyController.reset();
1673
+ return json({ ok: true, state: ctx.emergencyController.getState() });
1674
+ },
1675
+ },
1676
+
1677
+ '/api/authority/config': {
1678
+ GET: () => {
1679
+ if (!ctx.authorityEngine) return json({});
1680
+ return json(ctx.authorityEngine.getConfig());
1681
+ },
1682
+ POST: async (req: Request) => {
1683
+ if (!ctx.authorityEngine) return error('Authority engine not configured', 500);
1684
+ try {
1685
+ const body = await req.json() as Record<string, unknown>;
1686
+ const currentConfig = ctx.authorityEngine.getConfig();
1687
+
1688
+ // Merge updates into current config
1689
+ if (body.governed_categories) currentConfig.governed_categories = body.governed_categories as ActionCategory[];
1690
+ if (body.default_level !== undefined) currentConfig.default_level = body.default_level as number;
1691
+ if (body.overrides) currentConfig.overrides = body.overrides as any[];
1692
+ if (body.context_rules) currentConfig.context_rules = body.context_rules as any[];
1693
+ if (body.learning) currentConfig.learning = { ...currentConfig.learning, ...body.learning as any };
1694
+
1695
+ ctx.authorityEngine.updateConfig(currentConfig);
1696
+
1697
+ // Persist to config.yaml
1698
+ const { loadConfig, saveConfig } = await import('../config/loader.ts');
1699
+ const freshConfig = await loadConfig();
1700
+ freshConfig.authority = {
1701
+ ...freshConfig.authority,
1702
+ default_level: currentConfig.default_level,
1703
+ governed_categories: currentConfig.governed_categories,
1704
+ overrides: currentConfig.overrides,
1705
+ context_rules: currentConfig.context_rules,
1706
+ learning: currentConfig.learning,
1707
+ };
1708
+ await saveConfig(freshConfig);
1709
+
1710
+ return json({ ok: true, config: currentConfig });
1711
+ } catch (err) {
1712
+ return error('Invalid request body');
1713
+ }
1714
+ },
1715
+ },
1716
+
1717
+ '/api/authority/learning/suggestions': {
1718
+ GET: () => {
1719
+ if (!ctx.learner) return json([]);
1720
+ return json(ctx.learner.getSuggestions());
1721
+ },
1722
+ },
1723
+
1724
+ '/api/authority/learning/accept': {
1725
+ POST: async (req: Request) => {
1726
+ if (!ctx.learner || !ctx.authorityEngine) {
1727
+ return error('Learning system not configured', 500);
1728
+ }
1729
+ try {
1730
+ const body = await req.json() as { action: ActionCategory; tool_name: string };
1731
+ if (!body.action) return error('Missing "action" field');
1732
+
1733
+ // Add the override to the engine
1734
+ ctx.authorityEngine.addOverride({
1735
+ action: body.action,
1736
+ allowed: true,
1737
+ requires_approval: false,
1738
+ });
1739
+
1740
+ // Mark suggestion as sent
1741
+ ctx.learner.markSuggestionSent(body.action, body.tool_name ?? '');
1742
+
1743
+ // Persist
1744
+ const { loadConfig, saveConfig } = await import('../config/loader.ts');
1745
+ const freshConfig = await loadConfig();
1746
+ freshConfig.authority = {
1747
+ ...freshConfig.authority,
1748
+ ...ctx.authorityEngine.getConfig(),
1749
+ };
1750
+ await saveConfig(freshConfig);
1751
+
1752
+ return json({ ok: true });
1753
+ } catch (err) {
1754
+ return error('Invalid request body');
1755
+ }
1756
+ },
1757
+ },
1758
+
1759
+ '/api/authority/learning/dismiss': {
1760
+ POST: async (req: Request) => {
1761
+ if (!ctx.learner) return error('Learning system not configured', 500);
1762
+ try {
1763
+ const body = await req.json() as { action: ActionCategory; tool_name: string };
1764
+ if (!body.action) return error('Missing "action" field');
1765
+ ctx.learner.resetPattern(body.action, body.tool_name ?? '');
1766
+ return json({ ok: true });
1767
+ } catch (err) {
1768
+ return error('Invalid request body');
1769
+ }
1770
+ },
1771
+ },
1772
+
1773
+ // --- Awareness (M13) ---
1774
+ '/api/awareness/status': {
1775
+ GET: () => {
1776
+ if (!ctx.awarenessService) return error('Awareness service not running', 503);
1777
+ return json({
1778
+ status: ctx.awarenessService.status(),
1779
+ enabled: ctx.awarenessService.isEnabled(),
1780
+ liveContext: ctx.awarenessService.getLiveContext(),
1781
+ });
1782
+ },
1783
+ },
1784
+
1785
+ '/api/awareness/context': {
1786
+ GET: () => {
1787
+ if (!ctx.awarenessService) return error('Awareness service not running', 503);
1788
+ return json(ctx.awarenessService.getLiveContext());
1789
+ },
1790
+ },
1791
+
1792
+ '/api/awareness/captures': {
1793
+ GET: (req: Request) => {
1794
+ const params = getSearchParams(req);
1795
+ const limit = parseInt(params.get('limit') ?? '50', 10);
1796
+ const app = params.get('app') ?? undefined;
1797
+ return json(getRecentCaptures(limit, app));
1798
+ },
1799
+ },
1800
+
1801
+ '/api/awareness/captures/:id': {
1802
+ GET: (req: Request & { params: { id: string } }) => {
1803
+ const capture = getCapture(req.params.id);
1804
+ if (!capture) return error('Capture not found', 404);
1805
+ return json(capture);
1806
+ },
1807
+ },
1808
+
1809
+ '/api/awareness/captures/:id/image': {
1810
+ GET: (req: Request & { params: { id: string } }) => {
1811
+ const capture = getCapture(req.params.id);
1812
+ if (!capture || !capture.image_path) return error('Image not found', 404);
1813
+ // Validate path stays within the expected captures/data directory
1814
+ const jarvisDir = path.join(os.homedir(), '.jarvis');
1815
+ if (!isWithinBase(capture.image_path, jarvisDir)) {
1816
+ return error('Image not found', 404);
1817
+ }
1818
+ try {
1819
+ const imageData = readFileSync(capture.image_path);
1820
+ return new Response(imageData, {
1821
+ headers: { ...CORS, 'Content-Type': 'image/png', 'Cache-Control': 'public, max-age=3600' },
1822
+ });
1823
+ } catch {
1824
+ return error('Image file not found on disk', 404);
1825
+ }
1826
+ },
1827
+ },
1828
+
1829
+ '/api/awareness/captures/:id/thumbnail': {
1830
+ GET: (req: Request & { params: { id: string } }) => {
1831
+ const capture = getCapture(req.params.id);
1832
+ if (!capture) return error('Capture not found', 404);
1833
+ const jarvisDir = path.join(os.homedir(), '.jarvis');
1834
+ // Prefer thumbnail, fall back to full image
1835
+ if (capture.thumbnail_path && isWithinBase(capture.thumbnail_path, jarvisDir)) {
1836
+ try {
1837
+ const thumbData = readFileSync(capture.thumbnail_path);
1838
+ return new Response(thumbData, {
1839
+ headers: { ...CORS, 'Content-Type': 'image/jpeg', 'Cache-Control': 'public, max-age=3600' },
1840
+ });
1841
+ } catch { /* thumbnail file missing, fall through */ }
1842
+ }
1843
+ if (capture.image_path && isWithinBase(capture.image_path, jarvisDir)) {
1844
+ try {
1845
+ const imageData = readFileSync(capture.image_path);
1846
+ return new Response(imageData, {
1847
+ headers: { ...CORS, 'Content-Type': 'image/png', 'Cache-Control': 'public, max-age=3600' },
1848
+ });
1849
+ } catch { /* fall through */ }
1850
+ }
1851
+ return error('Thumbnail not found', 404);
1852
+ },
1853
+ },
1854
+
1855
+ '/api/awareness/sessions': {
1856
+ GET: (req: Request) => {
1857
+ if (!ctx.awarenessService) return error('Awareness service not running', 503);
1858
+ const params = getSearchParams(req);
1859
+ const limit = parseInt(params.get('limit') ?? '20', 10);
1860
+ return json(ctx.awarenessService.getSessionHistory(limit));
1861
+ },
1862
+ },
1863
+
1864
+ '/api/awareness/suggestions': {
1865
+ GET: (req: Request) => {
1866
+ if (!ctx.awarenessService) return error('Awareness service not running', 503);
1867
+ const params = getSearchParams(req);
1868
+ const limit = parseInt(params.get('limit') ?? '20', 10);
1869
+ const type = params.get('type') as SuggestionType | null;
1870
+ return json(ctx.awarenessService.getRecentSuggestionsList(limit, type ?? undefined));
1871
+ },
1872
+ },
1873
+
1874
+ '/api/awareness/suggestions/:id/dismiss': {
1875
+ PATCH: (req: Request & { params: { id: string } }) => {
1876
+ if (!ctx.awarenessService) return error('Awareness service not running', 503);
1877
+ ctx.awarenessService.dismissSuggestion(req.params.id);
1878
+ return json({ ok: true });
1879
+ },
1880
+ },
1881
+
1882
+ '/api/awareness/suggestions/:id/act': {
1883
+ PATCH: (req: Request & { params: { id: string } }) => {
1884
+ if (!ctx.awarenessService) return error('Awareness service not running', 503);
1885
+ ctx.awarenessService.actOnSuggestion(req.params.id);
1886
+ return json({ ok: true });
1887
+ },
1888
+ },
1889
+
1890
+ '/api/awareness/report': {
1891
+ GET: async (req: Request) => {
1892
+ if (!ctx.awarenessService) return error('Awareness service not running', 503);
1893
+ const params = getSearchParams(req);
1894
+ const date = params.get('date') ?? undefined;
1895
+ try {
1896
+ const report = await ctx.awarenessService.generateReport(date);
1897
+ return json(report);
1898
+ } catch (err) {
1899
+ return error(`Report generation failed: ${err instanceof Error ? err.message : err}`, 500);
1900
+ }
1901
+ },
1902
+ },
1903
+
1904
+ '/api/awareness/stats': {
1905
+ GET: (req: Request) => {
1906
+ const params = getSearchParams(req);
1907
+ const start = parseInt(params.get('start') ?? String(Date.now() - 24 * 60 * 60 * 1000), 10);
1908
+ const end = parseInt(params.get('end') ?? String(Date.now()), 10);
1909
+ return json(getCapturesInRange(start, end));
1910
+ },
1911
+ },
1912
+
1913
+ '/api/awareness/report/weekly': {
1914
+ GET: async (req: Request) => {
1915
+ if (!ctx.awarenessService) return error('Awareness service not available', 503);
1916
+ try {
1917
+ const params = getSearchParams(req);
1918
+ const weekStart = params.get('weekStart') ?? undefined;
1919
+ const report = await ctx.awarenessService.generateWeeklyReport(weekStart);
1920
+ return json(report);
1921
+ } catch (err) {
1922
+ return error(`Weekly report error: ${err instanceof Error ? err.message : err}`);
1923
+ }
1924
+ },
1925
+ },
1926
+
1927
+ '/api/awareness/insights': {
1928
+ GET: (req: Request) => {
1929
+ if (!ctx.awarenessService) return error('Awareness service not available', 503);
1930
+ try {
1931
+ const params = getSearchParams(req);
1932
+ const days = parseInt(params.get('days') ?? '7', 10) || 7;
1933
+ const insights = ctx.awarenessService.getBehavioralInsights(days);
1934
+ return json(insights);
1935
+ } catch (err) {
1936
+ return error(`Insights error: ${err instanceof Error ? err.message : err}`);
1937
+ }
1938
+ },
1939
+ },
1940
+
1941
+ '/api/awareness/toggle': {
1942
+ POST: async (req: Request) => {
1943
+ if (!ctx.awarenessService) return error('Awareness service not available', 503);
1944
+ try {
1945
+ const body = await req.json() as { enabled: boolean };
1946
+ ctx.awarenessService.toggle(body.enabled);
1947
+ return json({ ok: true, enabled: body.enabled });
1948
+ } catch {
1949
+ return error('Invalid request body');
1950
+ }
1951
+ },
1952
+ },
1953
+
1954
+ // --- Workflows (M14) ---
1955
+ '/api/workflows': {
1956
+ GET: (req: Request) => {
1957
+ try {
1958
+ const { findWorkflows } = require('../vault/workflows.ts');
1959
+ const params = getSearchParams(req);
1960
+ const query: any = {};
1961
+ if (params.has('enabled')) query.enabled = params.get('enabled') === 'true';
1962
+ if (params.has('tag')) query.tag = params.get('tag');
1963
+ if (params.has('limit')) query.limit = parseInt(params.get('limit')!);
1964
+ return json(findWorkflows(query));
1965
+ } catch (err) { return error(`${err}`); }
1966
+ },
1967
+ POST: async (req: Request) => {
1968
+ try {
1969
+ const { createWorkflow, createVersion } = require('../vault/workflows.ts');
1970
+ const body = await req.json() as any;
1971
+ if (!body.name) return error('name is required');
1972
+ const wf = createWorkflow(body.name, {
1973
+ description: body.description,
1974
+ authority_level: body.authority_level,
1975
+ tags: body.tags,
1976
+ });
1977
+ if (body.definition) {
1978
+ createVersion(wf.id, body.definition, body.changelog ?? 'Initial version');
1979
+ }
1980
+ return json(wf, 201);
1981
+ } catch (err) { return error(`${err}`); }
1982
+ },
1983
+ },
1984
+
1985
+ '/api/workflows/nodes': {
1986
+ GET: () => {
1987
+ if (!ctx.nodeRegistry) return error('Node registry not available', 503);
1988
+ return json(ctx.nodeRegistry.list().map(n => ({
1989
+ type: n.type, label: n.label, description: n.description,
1990
+ category: n.category, icon: n.icon, color: n.color,
1991
+ configSchema: n.configSchema, inputs: n.inputs, outputs: n.outputs,
1992
+ })));
1993
+ },
1994
+ },
1995
+
1996
+ '/api/workflows/import': {
1997
+ POST: async (req: Request) => {
1998
+ try {
1999
+ const { importWorkflowYaml } = require('../workflows/yaml.ts');
2000
+ const { createWorkflow, createVersion, setVariable } = require('../vault/workflows.ts');
2001
+ const yamlText = await req.text();
2002
+ const imported = importWorkflowYaml(yamlText);
2003
+ const wf = createWorkflow(imported.name, {
2004
+ description: imported.description,
2005
+ authority_level: imported.authority_level,
2006
+ tags: imported.tags,
2007
+ });
2008
+ createVersion(wf.id, imported.definition, 'Imported');
2009
+ for (const [k, v] of Object.entries(imported.variables)) {
2010
+ setVariable(wf.id, k, v);
2011
+ }
2012
+ return json(wf, 201);
2013
+ } catch (err) { return error(`YAML import failed: ${err}`); }
2014
+ },
2015
+ },
2016
+
2017
+ '/api/workflows/:id': {
2018
+ GET: (req: Request) => {
2019
+ try {
2020
+ const { getWorkflow } = require('../vault/workflows.ts');
2021
+ const url = new URL(req.url);
2022
+ const id = url.pathname.split('/').pop()!;
2023
+ const wf = getWorkflow(id);
2024
+ if (!wf) return error('Workflow not found', 404);
2025
+ return json(wf);
2026
+ } catch (err) { return error(`${err}`); }
2027
+ },
2028
+ PATCH: async (req: Request) => {
2029
+ try {
2030
+ const { updateWorkflow } = require('../vault/workflows.ts');
2031
+ const url = new URL(req.url);
2032
+ const id = url.pathname.split('/').pop()!;
2033
+ const body = await req.json() as any;
2034
+ const updated = updateWorkflow(id, body);
2035
+ if (!updated) return error('Workflow not found', 404);
2036
+ return json(updated);
2037
+ } catch (err) { return error(`${err}`); }
2038
+ },
2039
+ DELETE: (req: Request) => {
2040
+ try {
2041
+ const { deleteWorkflow } = require('../vault/workflows.ts');
2042
+ const url = new URL(req.url);
2043
+ const id = url.pathname.split('/').pop()!;
2044
+ ctx.triggerManager?.unregisterWorkflow(id);
2045
+ deleteWorkflow(id);
2046
+ return json({ ok: true });
2047
+ } catch (err) { return error(`${err}`); }
2048
+ },
2049
+ },
2050
+
2051
+ '/api/workflows/:id/versions': {
2052
+ GET: (req: Request) => {
2053
+ try {
2054
+ const { getVersionHistory } = require('../vault/workflows.ts');
2055
+ const url = new URL(req.url);
2056
+ const parts = url.pathname.split('/');
2057
+ const id = parts[parts.length - 2];
2058
+ return json(getVersionHistory(id));
2059
+ } catch (err) { return error(`${err}`); }
2060
+ },
2061
+ POST: async (req: Request) => {
2062
+ try {
2063
+ const { createVersion } = require('../vault/workflows.ts');
2064
+ const url = new URL(req.url);
2065
+ const parts = url.pathname.split('/');
2066
+ const id = parts[parts.length - 2];
2067
+ const body = await req.json() as any;
2068
+ if (!body.definition) return error('definition is required');
2069
+ const version = createVersion(id, body.definition, body.changelog);
2070
+ return json(version, 201);
2071
+ } catch (err) { return error(`${err}`); }
2072
+ },
2073
+ },
2074
+
2075
+ '/api/workflows/:id/execute': {
2076
+ POST: async (req: Request) => {
2077
+ if (!ctx.workflowEngine) return error('Workflow engine not available', 503);
2078
+ try {
2079
+ const url = new URL(req.url);
2080
+ const parts = url.pathname.split('/');
2081
+ const id = parts[parts.length - 2];
2082
+ let triggerData: Record<string, unknown> = {};
2083
+ try { triggerData = await req.json() as any; } catch {}
2084
+ const execution = await ctx.workflowEngine.execute(id!, 'manual', triggerData);
2085
+ return json(execution, 201);
2086
+ } catch (err) { return error(`${err}`); }
2087
+ },
2088
+ },
2089
+
2090
+ '/api/workflows/:id/executions': {
2091
+ GET: (req: Request) => {
2092
+ try {
2093
+ const { findExecutions } = require('../vault/workflows.ts');
2094
+ const url = new URL(req.url);
2095
+ const parts = url.pathname.split('/');
2096
+ const id = parts[parts.length - 2];
2097
+ return json(findExecutions({ workflow_id: id }));
2098
+ } catch (err) { return error(`${err}`); }
2099
+ },
2100
+ },
2101
+
2102
+ '/api/workflows/:id/variables': {
2103
+ GET: (req: Request) => {
2104
+ try {
2105
+ const { getVariables } = require('../vault/workflows.ts');
2106
+ const url = new URL(req.url);
2107
+ const parts = url.pathname.split('/');
2108
+ const id = parts[parts.length - 2];
2109
+ return json(getVariables(id));
2110
+ } catch (err) { return error(`${err}`); }
2111
+ },
2112
+ PATCH: async (req: Request) => {
2113
+ try {
2114
+ const { setVariable, getVariables } = require('../vault/workflows.ts');
2115
+ const url = new URL(req.url);
2116
+ const parts = url.pathname.split('/');
2117
+ const id = parts[parts.length - 2];
2118
+ const body = await req.json() as Record<string, unknown>;
2119
+ for (const [key, value] of Object.entries(body)) {
2120
+ setVariable(id, key, value);
2121
+ }
2122
+ return json(getVariables(id));
2123
+ } catch (err) { return error(`${err}`); }
2124
+ },
2125
+ },
2126
+
2127
+ '/api/workflows/:id/export': {
2128
+ GET: (req: Request) => {
2129
+ try {
2130
+ const { getWorkflow, getLatestVersion, getVariables } = require('../vault/workflows.ts');
2131
+ const { exportWorkflowYaml } = require('../workflows/yaml.ts');
2132
+ const url = new URL(req.url);
2133
+ const parts = url.pathname.split('/');
2134
+ const id = parts[parts.length - 2];
2135
+ const wf = getWorkflow(id);
2136
+ if (!wf) return error('Workflow not found', 404);
2137
+ const version = getLatestVersion(id);
2138
+ if (!version) return error('No version found', 404);
2139
+ const vars = getVariables(id);
2140
+ const yaml = exportWorkflowYaml(wf, version, vars);
2141
+ return new Response(yaml, {
2142
+ headers: {
2143
+ 'Content-Type': 'text/yaml',
2144
+ 'Content-Disposition': `attachment; filename="${sanitizeFilename(wf.name)}.yaml"`,
2145
+ ...CORS,
2146
+ },
2147
+ });
2148
+ } catch (err) { return error(`${err}`); }
2149
+ },
2150
+ },
2151
+
2152
+ '/api/workflows/executions/:executionId': {
2153
+ GET: (req: Request) => {
2154
+ try {
2155
+ const { getExecution, getStepResults } = require('../vault/workflows.ts');
2156
+ const url = new URL(req.url);
2157
+ const executionId = url.pathname.split('/').pop()!;
2158
+ const exec = getExecution(executionId);
2159
+ if (!exec) return error('Execution not found', 404);
2160
+ const steps = getStepResults(executionId);
2161
+ return json({ ...exec, steps });
2162
+ } catch (err) { return error(`${err}`); }
2163
+ },
2164
+ },
2165
+
2166
+ '/api/workflows/executions/:executionId/cancel': {
2167
+ POST: async (req: Request) => {
2168
+ if (!ctx.workflowEngine) return error('Workflow engine not available', 503);
2169
+ try {
2170
+ const url = new URL(req.url);
2171
+ const parts = url.pathname.split('/');
2172
+ const executionId = parts[parts.length - 2];
2173
+ await ctx.workflowEngine.cancel(executionId!);
2174
+ return json({ ok: true });
2175
+ } catch (err) { return error(`${err}`); }
2176
+ },
2177
+ },
2178
+
2179
+ '/api/workflows/nl-chat': {
2180
+ POST: async (req: Request) => {
2181
+ if (!ctx.nlBuilder) return error('NL builder not available', 503);
2182
+ try {
2183
+ const body = await req.json() as { workflowId: string; message: string; history?: Array<{ role: string; content: string }> };
2184
+ const result = await ctx.nlBuilder.chat(
2185
+ body.workflowId,
2186
+ body.message,
2187
+ (body.history ?? []) as Array<{ role: 'user' | 'assistant'; content: string }>,
2188
+ );
2189
+ return json(result);
2190
+ } catch (err) { return error(`${err}`); }
2191
+ },
2192
+ },
2193
+
2194
+ '/api/workflows/suggest': {
2195
+ GET: async () => {
2196
+ if (!ctx.autoSuggest) return error('Auto-suggest not available', 503);
2197
+ try {
2198
+ const suggestions = await ctx.autoSuggest.generateSuggestions();
2199
+ return json(suggestions);
2200
+ } catch (err) { return error(`${err}`); }
2201
+ },
2202
+ },
2203
+
2204
+ '/api/workflows/suggest/:id/dismiss': {
2205
+ POST: async (req: Request) => {
2206
+ if (!ctx.autoSuggest) return error('Auto-suggest not available', 503);
2207
+ try {
2208
+ const url = new URL(req.url);
2209
+ const id = url.pathname.split('/').pop() === 'dismiss'
2210
+ ? url.pathname.split('/').slice(-2, -1)[0]
2211
+ : url.pathname.split('/').pop()!;
2212
+ ctx.autoSuggest.dismiss(id!);
2213
+ return json({ ok: true });
2214
+ } catch (err) { return error(`${err}`); }
2215
+ },
2216
+ },
2217
+
2218
+ '/api/webhooks/:id': {
2219
+ POST: async (req: Request) => {
2220
+ if (!ctx.webhookManager) return error('Webhook manager not available', 503);
2221
+ try {
2222
+ const url = new URL(req.url);
2223
+ const id = url.pathname.split('/').pop()!;
2224
+ return ctx.webhookManager.handleRequest(id, req);
2225
+ } catch (err) { return error(`${err}`); }
2226
+ },
2227
+ GET: async (req: Request) => {
2228
+ if (!ctx.webhookManager) return error('Webhook manager not available', 503);
2229
+ try {
2230
+ const url = new URL(req.url);
2231
+ const id = url.pathname.split('/').pop()!;
2232
+ return ctx.webhookManager.handleRequest(id, req);
2233
+ } catch (err) { return error(`${err}`); }
2234
+ },
2235
+ },
2236
+
2237
+ // ── Goals (M16) ─────────────────────────────────────────────────
2238
+
2239
+ '/api/goals': {
2240
+ GET: (req: Request) => {
2241
+ try {
2242
+ const url = new URL(req.url);
2243
+ const status = url.searchParams.get('status') ?? undefined;
2244
+ const level = url.searchParams.get('level') ?? undefined;
2245
+ const tag = url.searchParams.get('tag') ?? undefined;
2246
+ const health = url.searchParams.get('health') ?? undefined;
2247
+ const parent_id = url.searchParams.get('parent_id');
2248
+ const limit = parseInt(url.searchParams.get('limit') ?? '100', 10);
2249
+ const goals = require('../vault/goals.ts');
2250
+ return json(goals.findGoals({
2251
+ status: status as any,
2252
+ level: level as any,
2253
+ tag,
2254
+ health: health as any,
2255
+ parent_id: parent_id === 'null' ? null : parent_id ?? undefined,
2256
+ limit,
2257
+ }));
2258
+ } catch (err) { return error(`${err}`); }
2259
+ },
2260
+ POST: async (req: Request) => {
2261
+ try {
2262
+ const body = await req.json() as Record<string, unknown>;
2263
+ const mode = body.mode as string | undefined;
2264
+
2265
+ // Natural language → OKR proposal (uses LLM)
2266
+ if (mode === 'propose') {
2267
+ const text = body.text as string;
2268
+ if (!text?.trim()) return error('text is required for propose mode', 400);
2269
+ const { NLGoalBuilder } = await import('../goals/nl-builder.ts');
2270
+ const llmManager = ctx.agentService.getLLMManager();
2271
+ const builder = new NLGoalBuilder(llmManager);
2272
+ const proposal = await builder.parseGoal(text.trim());
2273
+ return json(proposal);
2274
+ }
2275
+
2276
+ // Create goals from a confirmed proposal
2277
+ if (mode === 'create_from_proposal') {
2278
+ const proposal = body.proposal as any;
2279
+ if (!proposal?.objective?.title) return error('proposal with objective required', 400);
2280
+ const { NLGoalBuilder } = await import('../goals/nl-builder.ts');
2281
+ const llmManager = ctx.agentService.getLLMManager();
2282
+ const builder = new NLGoalBuilder(llmManager);
2283
+ const created = builder.createFromProposal(proposal, body.parent_id as string | undefined);
2284
+ return json(created, 201);
2285
+ }
2286
+
2287
+ // Quick create (direct)
2288
+ const title = body.title as string;
2289
+ const level = (body.level as string) ?? 'task';
2290
+ if (!title) return error('title is required', 400);
2291
+ const goals = require('../vault/goals.ts');
2292
+ const goal = goals.createGoal(title, level, body);
2293
+ return json(goal, 201);
2294
+ } catch (err) { return error(`${err}`); }
2295
+ },
2296
+ },
2297
+
2298
+ '/api/goals/roots': {
2299
+ GET: () => {
2300
+ try {
2301
+ const goals = require('../vault/goals.ts');
2302
+ return json(goals.getRootGoals());
2303
+ } catch (err) { return error(`${err}`); }
2304
+ },
2305
+ },
2306
+
2307
+ '/api/goals/overdue': {
2308
+ GET: () => {
2309
+ try {
2310
+ const goals = require('../vault/goals.ts');
2311
+ return json(goals.getOverdueGoals());
2312
+ } catch (err) { return error(`${err}`); }
2313
+ },
2314
+ },
2315
+
2316
+ '/api/goals/metrics': {
2317
+ GET: () => {
2318
+ try {
2319
+ const goals = require('../vault/goals.ts');
2320
+ return json(goals.getGoalMetrics());
2321
+ } catch (err) { return error(`${err}`); }
2322
+ },
2323
+ },
2324
+
2325
+ '/api/goals/reorder': {
2326
+ POST: async (req: Request) => {
2327
+ try {
2328
+ const body = await req.json() as { id: string; sort_order: number }[];
2329
+ const goals = require('../vault/goals.ts');
2330
+ goals.reorderGoals(body);
2331
+ return json({ ok: true });
2332
+ } catch (err) { return error(`${err}`); }
2333
+ },
2334
+ },
2335
+
2336
+ '/api/goals/check-ins': {
2337
+ GET: (req: Request) => {
2338
+ try {
2339
+ const url = new URL(req.url);
2340
+ const type = url.searchParams.get('type') as any;
2341
+ const limit = parseInt(url.searchParams.get('limit') ?? '10', 10);
2342
+ const goals = require('../vault/goals.ts');
2343
+ return json(goals.getRecentCheckIns(type ?? undefined, limit));
2344
+ } catch (err) { return error(`${err}`); }
2345
+ },
2346
+ },
2347
+
2348
+ '/api/goals/daily-actions': {
2349
+ GET: () => {
2350
+ try {
2351
+ const goals = require('../vault/goals.ts');
2352
+ return json(goals.findGoals({ level: 'daily_action', status: 'active', limit: 20 }));
2353
+ } catch (err) { return error(`${err}`); }
2354
+ },
2355
+ },
2356
+
2357
+ '/api/goals/:id': {
2358
+ GET: (req: Request) => {
2359
+ try {
2360
+ const url = new URL(req.url);
2361
+ const id = url.pathname.split('/').pop()!;
2362
+ const goals = require('../vault/goals.ts');
2363
+ const goal = goals.getGoal(id);
2364
+ if (!goal) return error('Goal not found', 404);
2365
+ return json(goal);
2366
+ } catch (err) { return error(`${err}`); }
2367
+ },
2368
+ PATCH: async (req: Request) => {
2369
+ try {
2370
+ const url = new URL(req.url);
2371
+ const id = url.pathname.split('/').pop()!;
2372
+ const body = await req.json() as Record<string, unknown>;
2373
+ const goals = require('../vault/goals.ts');
2374
+ const updated = goals.updateGoal(id, body);
2375
+ if (!updated) return error('Goal not found', 404);
2376
+ return json(updated);
2377
+ } catch (err) { return error(`${err}`); }
2378
+ },
2379
+ DELETE: (req: Request) => {
2380
+ try {
2381
+ const url = new URL(req.url);
2382
+ const id = url.pathname.split('/').pop()!;
2383
+ const goals = require('../vault/goals.ts');
2384
+ const deleted = goals.deleteGoal(id);
2385
+ if (!deleted) return error('Goal not found', 404);
2386
+ return json({ ok: true });
2387
+ } catch (err) { return error(`${err}`); }
2388
+ },
2389
+ },
2390
+
2391
+ '/api/goals/:id/tree': {
2392
+ GET: (req: Request) => {
2393
+ try {
2394
+ const url = new URL(req.url);
2395
+ const parts = url.pathname.split('/');
2396
+ const id = parts[parts.length - 2]!;
2397
+ const goals = require('../vault/goals.ts');
2398
+ return json(goals.getGoalTree(id));
2399
+ } catch (err) { return error(`${err}`); }
2400
+ },
2401
+ },
2402
+
2403
+ '/api/goals/:id/children': {
2404
+ GET: (req: Request) => {
2405
+ try {
2406
+ const url = new URL(req.url);
2407
+ const parts = url.pathname.split('/');
2408
+ const id = parts[parts.length - 2]!;
2409
+ const goals = require('../vault/goals.ts');
2410
+ return json(goals.getGoalChildren(id));
2411
+ } catch (err) { return error(`${err}`); }
2412
+ },
2413
+ },
2414
+
2415
+ '/api/goals/:id/score': {
2416
+ POST: async (req: Request) => {
2417
+ try {
2418
+ const url = new URL(req.url);
2419
+ const parts = url.pathname.split('/');
2420
+ const id = parts[parts.length - 2]!;
2421
+ const body = await req.json() as { score: number; reason: string; source?: string };
2422
+ const goals = require('../vault/goals.ts');
2423
+ const updated = goals.updateGoalScore(id, body.score, body.reason, body.source ?? 'user');
2424
+ if (!updated) return error('Goal not found', 404);
2425
+ return json(updated);
2426
+ } catch (err) { return error(`${err}`); }
2427
+ },
2428
+ },
2429
+
2430
+ '/api/goals/:id/status': {
2431
+ POST: async (req: Request) => {
2432
+ try {
2433
+ const url = new URL(req.url);
2434
+ const parts = url.pathname.split('/');
2435
+ const id = parts[parts.length - 2]!;
2436
+ const body = await req.json() as { status: string };
2437
+ const goals = require('../vault/goals.ts');
2438
+ const updated = goals.updateGoalStatus(id, body.status as any);
2439
+ if (!updated) return error('Goal not found', 404);
2440
+ return json(updated);
2441
+ } catch (err) { return error(`${err}`); }
2442
+ },
2443
+ },
2444
+
2445
+ '/api/goals/:id/health': {
2446
+ POST: async (req: Request) => {
2447
+ try {
2448
+ const url = new URL(req.url);
2449
+ const parts = url.pathname.split('/');
2450
+ const id = parts[parts.length - 2]!;
2451
+ const body = await req.json() as { health: string };
2452
+ const goals = require('../vault/goals.ts');
2453
+ const updated = goals.updateGoalHealth(id, body.health as any);
2454
+ if (!updated) return error('Goal not found', 404);
2455
+ return json(updated);
2456
+ } catch (err) { return error(`${err}`); }
2457
+ },
2458
+ },
2459
+
2460
+ '/api/goals/:id/progress': {
2461
+ GET: (req: Request) => {
2462
+ try {
2463
+ const url = new URL(req.url);
2464
+ const parts = url.pathname.split('/');
2465
+ const id = parts[parts.length - 2]!;
2466
+ const limit = parseInt(url.searchParams.get('limit') ?? '50', 10);
2467
+ const goals = require('../vault/goals.ts');
2468
+ return json(goals.getProgressHistory(id, limit));
2469
+ } catch (err) { return error(`${err}`); }
2470
+ },
2471
+ },
2472
+
2473
+ // --- Documents ---
2474
+ '/api/documents': {
2475
+ GET: (req: Request) => {
2476
+ try {
2477
+ const { findDocuments } = require('../vault/documents.ts');
2478
+ const url = new URL(req.url);
2479
+ const format = url.searchParams.get('format') || undefined;
2480
+ const tag = url.searchParams.get('tag') || undefined;
2481
+ const search = url.searchParams.get('search') || undefined;
2482
+ const query = (format || tag || search) ? { format, tag, search } : undefined;
2483
+ return json(findDocuments(query));
2484
+ } catch (err) { return error(`${err}`); }
2485
+ },
2486
+ },
2487
+
2488
+ '/api/documents/:id': {
2489
+ GET: (req: Request) => {
2490
+ try {
2491
+ const { getDocument } = require('../vault/documents.ts');
2492
+ const url = new URL(req.url);
2493
+ const parts = url.pathname.split('/');
2494
+ const id = parts[parts.length - 1]!;
2495
+ const doc = getDocument(id);
2496
+ if (!doc) return error('Document not found', 404);
2497
+ return json(doc);
2498
+ } catch (err) { return error(`${err}`); }
2499
+ },
2500
+ DELETE: (req: Request) => {
2501
+ try {
2502
+ const { deleteDocument } = require('../vault/documents.ts');
2503
+ const url = new URL(req.url);
2504
+ const parts = url.pathname.split('/');
2505
+ const id = parts[parts.length - 1]!;
2506
+ const deleted = deleteDocument(id);
2507
+ if (!deleted) return error('Document not found', 404);
2508
+ return json({ ok: true });
2509
+ } catch (err) { return error(`${err}`); }
2510
+ },
2511
+ },
2512
+
2513
+ '/api/documents/:id/download': {
2514
+ GET: (req: Request) => {
2515
+ try {
2516
+ const { getDocument } = require('../vault/documents.ts');
2517
+ const url = new URL(req.url);
2518
+ const parts = url.pathname.split('/');
2519
+ const id = parts[parts.length - 2]!;
2520
+ const doc = getDocument(id);
2521
+ if (!doc) return error('Document not found', 404);
2522
+
2523
+ const ext: Record<string, string> = {
2524
+ markdown: '.md', plain: '.txt', html: '.html',
2525
+ json: '.json', csv: '.csv', code: '.txt',
2526
+ };
2527
+ // Serve all formats as safe MIME types to prevent XSS via inline rendering
2528
+ const mime: Record<string, string> = {
2529
+ markdown: 'text/markdown', plain: 'text/plain', html: 'text/plain',
2530
+ json: 'application/json', csv: 'text/csv', code: 'text/plain',
2531
+ };
2532
+
2533
+ const filename = doc.title.replace(/[^a-zA-Z0-9_\- ]/g, '').replace(/\s+/g, '_') + (ext[doc.format] || '.txt');
2534
+
2535
+ return new Response(doc.body, {
2536
+ headers: {
2537
+ 'Content-Type': mime[doc.format] || 'text/plain',
2538
+ 'Content-Disposition': `attachment; filename="${filename}"`,
2539
+ 'X-Content-Type-Options': 'nosniff',
2540
+ },
2541
+ });
2542
+ } catch (err) { return error(`${err}`); }
2543
+ },
2544
+ },
2545
+
2546
+ // --- Sidecars ---
2547
+ '/api/sidecars': {
2548
+ GET: () => {
2549
+ try {
2550
+ if (!ctx.sidecarManager) return error('Sidecar manager not available', 503);
2551
+ return json(ctx.sidecarManager.listSidecars());
2552
+ } catch (err) { return error(`${err}`); }
2553
+ },
2554
+ },
2555
+
2556
+ '/api/sidecars/enroll': {
2557
+ POST: async (req: Request) => {
2558
+ try {
2559
+ if (!ctx.sidecarManager) return error('Sidecar manager not available', 503);
2560
+ const body = await req.json() as { name?: string };
2561
+ if (!body.name) return error('Missing "name" field');
2562
+ const result = await ctx.sidecarManager.enrollSidecar(body.name);
2563
+ return json(result, 201);
2564
+ } catch (err) {
2565
+ const msg = err instanceof Error ? err.message : String(err);
2566
+ if (msg.includes('already enrolled') || msg.includes('may only contain')) {
2567
+ return error(msg, 409);
2568
+ }
2569
+ return error(msg);
2570
+ }
2571
+ },
2572
+ },
2573
+
2574
+ '/api/sidecars/.well-known/jwks.json': {
2575
+ GET: () => {
2576
+ try {
2577
+ if (!ctx.sidecarManager) return error('Sidecar manager not available', 503);
2578
+ return json(ctx.sidecarManager.getJwks());
2579
+ } catch (err) { return error(`${err}`); }
2580
+ },
2581
+ },
2582
+
2583
+ '/api/sidecars/:id/config': {
2584
+ GET: async (req: Request) => {
2585
+ try {
2586
+ if (!ctx.sidecarManager) return error('Sidecar manager not available', 503);
2587
+ const url = new URL(req.url);
2588
+ const parts = url.pathname.split('/');
2589
+ const id = parts[parts.length - 2]!;
2590
+ if (!ctx.sidecarManager.isConnected(id)) {
2591
+ return error('Sidecar is not connected', 409);
2592
+ }
2593
+ const result = await ctx.sidecarManager.dispatchRPC(id, 'get_config', {});
2594
+ return json(result);
2595
+ } catch (err) { return error(`${err}`, 500); }
2596
+ },
2597
+ PATCH: async (req: Request) => {
2598
+ try {
2599
+ if (!ctx.sidecarManager) return error('Sidecar manager not available', 503);
2600
+ const url = new URL(req.url);
2601
+ const parts = url.pathname.split('/');
2602
+ const id = parts[parts.length - 2]!;
2603
+ if (!ctx.sidecarManager.isConnected(id)) {
2604
+ return error('Sidecar is not connected', 409);
2605
+ }
2606
+ const body = await req.json() as Record<string, unknown>;
2607
+ delete body.token;
2608
+ const result = await ctx.sidecarManager.dispatchRPC(id, 'update_config', body);
2609
+ return json(result);
2610
+ } catch (err) { return error(`${err}`, 500); }
2611
+ },
2612
+ },
2613
+
2614
+ '/api/sidecars/:id': {
2615
+ GET: (req: Request) => {
2616
+ try {
2617
+ if (!ctx.sidecarManager) return error('Sidecar manager not available', 503);
2618
+ const url = new URL(req.url);
2619
+ const id = url.pathname.split('/').pop()!;
2620
+ const sidecar = ctx.sidecarManager.getSidecar(id);
2621
+ if (!sidecar) return error('Sidecar not found', 404);
2622
+ return json(sidecar);
2623
+ } catch (err) { return error(`${err}`); }
2624
+ },
2625
+ DELETE: (req: Request) => {
2626
+ try {
2627
+ if (!ctx.sidecarManager) return error('Sidecar manager not available', 503);
2628
+ const url = new URL(req.url);
2629
+ const id = url.pathname.split('/').pop()!;
2630
+ const revoked = ctx.sidecarManager.revokeSidecar(id);
2631
+ if (!revoked) return error('Sidecar not found or already revoked', 404);
2632
+ return json({ success: true });
2633
+ } catch (err) { return error(`${err}`); }
2634
+ },
2635
+ },
2636
+
2637
+ // --- Site Builder ---
2638
+ '/api/sites/templates': {
2639
+ GET: () => {
2640
+ const { TEMPLATES } = require('../sites/templates.ts');
2641
+ return json(TEMPLATES);
2642
+ },
2643
+ },
2644
+
2645
+ '/api/sites/git/check': {
2646
+ GET: async () => {
2647
+ const { GitManager } = require('../sites/git-manager.ts');
2648
+ const installed = await GitManager.isInstalled();
2649
+ if (!installed) return json({ installed: false, authorName: null, authorEmail: null });
2650
+ const author = await GitManager.getGlobalAuthor();
2651
+ return json({ installed: true, authorName: author.name, authorEmail: author.email });
2652
+ },
2653
+ },
2654
+
2655
+ '/api/sites/projects': {
2656
+ GET: async () => {
2657
+ if (!ctx.siteBuilderService) return error('Site builder not available', 503);
2658
+ const projects = await ctx.siteBuilderService.listProjectsWithStatus();
2659
+ return json(projects);
2660
+ },
2661
+ POST: async (req: Request) => {
2662
+ if (!ctx.siteBuilderService) return error('Site builder not available', 503);
2663
+ try {
2664
+ const body = await req.json() as { name: string; template: string; gitAuthor?: { name: string; email: string; global: boolean } };
2665
+ if (!body.name || !body.template) return error('name and template are required');
2666
+ const project = await ctx.siteBuilderService.projectManager.createProject(body.name, body.template, body.gitAuthor);
2667
+ return json(project, 201);
2668
+ } catch (err) {
2669
+ return error(err instanceof Error ? err.message : String(err));
2670
+ }
2671
+ },
2672
+ },
2673
+
2674
+ '/api/sites/projects/:id': {
2675
+ GET: async (req: Request) => {
2676
+ if (!ctx.siteBuilderService) return error('Site builder not available', 503);
2677
+ const id = new URL(req.url).pathname.split('/')[4]!;
2678
+ const project = await ctx.siteBuilderService.getProjectWithStatus(id);
2679
+ if (!project) return error('Project not found', 404);
2680
+ return json(project);
2681
+ },
2682
+ DELETE: async (req: Request) => {
2683
+ if (!ctx.siteBuilderService) return error('Site builder not available', 503);
2684
+ const id = new URL(req.url).pathname.split('/')[4]!;
2685
+ try {
2686
+ await ctx.siteBuilderService.stopProject(id);
2687
+ await ctx.siteBuilderService.projectManager.deleteProject(id);
2688
+ return json({ ok: true });
2689
+ } catch (err) {
2690
+ return error(err instanceof Error ? err.message : String(err));
2691
+ }
2692
+ },
2693
+ },
2694
+
2695
+ '/api/sites/projects/:id/start': {
2696
+ POST: async (req: Request) => {
2697
+ if (!ctx.siteBuilderService) return error('Site builder not available', 503);
2698
+ const id = new URL(req.url).pathname.split('/')[4]!;
2699
+ try {
2700
+ const project = await ctx.siteBuilderService.startProject(id);
2701
+ return json(project);
2702
+ } catch (err) {
2703
+ return error(err instanceof Error ? err.message : String(err));
2704
+ }
2705
+ },
2706
+ },
2707
+
2708
+ '/api/sites/projects/:id/stop': {
2709
+ POST: async (req: Request) => {
2710
+ if (!ctx.siteBuilderService) return error('Site builder not available', 503);
2711
+ const id = new URL(req.url).pathname.split('/')[4]!;
2712
+ try {
2713
+ await ctx.siteBuilderService.stopProject(id);
2714
+ return json({ ok: true });
2715
+ } catch (err) {
2716
+ return error(err instanceof Error ? err.message : String(err));
2717
+ }
2718
+ },
2719
+ },
2720
+
2721
+ '/api/sites/projects/:id/logs': {
2722
+ GET: (req: Request) => {
2723
+ if (!ctx.siteBuilderService) return error('Site builder not available', 503);
2724
+ const id = new URL(req.url).pathname.split('/')[4]!;
2725
+ const limit = parseInt(getSearchParams(req).get('limit') ?? '100', 10);
2726
+ const logs = ctx.siteBuilderService.devServerManager.getLogs(id, limit);
2727
+ return json({ logs });
2728
+ },
2729
+ },
2730
+
2731
+ '/api/sites/projects/:id/files': {
2732
+ GET: (req: Request) => {
2733
+ if (!ctx.siteBuilderService) return error('Site builder not available', 503);
2734
+ const id = new URL(req.url).pathname.split('/')[4]!;
2735
+ try {
2736
+ const tree = ctx.siteBuilderService.projectManager.getFileTree(id);
2737
+ return json(tree);
2738
+ } catch (err) {
2739
+ return error(err instanceof Error ? err.message : String(err));
2740
+ }
2741
+ },
2742
+ },
2743
+
2744
+ '/api/sites/projects/:id/file': {
2745
+ GET: async (req: Request) => {
2746
+ if (!ctx.siteBuilderService) return error('Site builder not available', 503);
2747
+ const id = new URL(req.url).pathname.split('/')[4]!;
2748
+ const filePath = getSearchParams(req).get('path');
2749
+ if (!filePath) return error('path query parameter is required');
2750
+ try {
2751
+ const content = await ctx.siteBuilderService.projectManager.readFile(id, filePath);
2752
+ return json({ path: filePath, content });
2753
+ } catch (err) {
2754
+ return error(err instanceof Error ? err.message : String(err), 404);
2755
+ }
2756
+ },
2757
+ PUT: async (req: Request) => {
2758
+ if (!ctx.siteBuilderService) return error('Site builder not available', 503);
2759
+ const id = new URL(req.url).pathname.split('/')[4]!;
2760
+ try {
2761
+ const body = await req.json() as { path: string; content: string };
2762
+ if (!body.path || body.content === undefined) return error('path and content are required');
2763
+ await ctx.siteBuilderService.projectManager.writeFile(id, body.path, body.content);
2764
+
2765
+ // Auto-commit if enabled
2766
+ const projectPath = ctx.siteBuilderService.projectManager.getProjectPath(id);
2767
+ if (projectPath) {
2768
+ await ctx.siteBuilderService.gitManager.autoCommit(projectPath, `Update ${body.path}`);
2769
+ }
2770
+
2771
+ return json({ ok: true });
2772
+ } catch (err) {
2773
+ return error(err instanceof Error ? err.message : String(err));
2774
+ }
2775
+ },
2776
+ },
2777
+
2778
+ // --- Site Builder: Git ---
2779
+ '/api/sites/projects/:id/git/branches': {
2780
+ GET: async (req: Request) => {
2781
+ if (!ctx.siteBuilderService) return error('Site builder not available', 503);
2782
+ const id = new URL(req.url).pathname.split('/')[4]!;
2783
+ const projectPath = ctx.siteBuilderService.projectManager.getProjectPath(id);
2784
+ if (!projectPath) return error('Project not found', 404);
2785
+ try {
2786
+ const branches = await ctx.siteBuilderService.gitManager.getBranches(projectPath);
2787
+ return json(branches);
2788
+ } catch (err) {
2789
+ return error(err instanceof Error ? err.message : String(err));
2790
+ }
2791
+ },
2792
+ POST: async (req: Request) => {
2793
+ if (!ctx.siteBuilderService) return error('Site builder not available', 503);
2794
+ const id = new URL(req.url).pathname.split('/')[4]!;
2795
+ const projectPath = ctx.siteBuilderService.projectManager.getProjectPath(id);
2796
+ if (!projectPath) return error('Project not found', 404);
2797
+ try {
2798
+ const body = await req.json() as { name: string };
2799
+ if (!body.name) return error('name is required');
2800
+ await ctx.siteBuilderService.gitManager.createBranch(projectPath, body.name);
2801
+ return json({ ok: true, branch: body.name });
2802
+ } catch (err) {
2803
+ return error(err instanceof Error ? err.message : String(err));
2804
+ }
2805
+ },
2806
+ },
2807
+
2808
+ '/api/sites/projects/:id/git/branch': {
2809
+ POST: async (req: Request) => {
2810
+ if (!ctx.siteBuilderService) return error('Site builder not available', 503);
2811
+ const id = new URL(req.url).pathname.split('/')[4]!;
2812
+ const projectPath = ctx.siteBuilderService.projectManager.getProjectPath(id);
2813
+ if (!projectPath) return error('Project not found', 404);
2814
+ try {
2815
+ const body = await req.json() as { name: string };
2816
+ if (!body.name) return error('name is required');
2817
+ await ctx.siteBuilderService.gitManager.switchBranch(projectPath, body.name);
2818
+ return json({ ok: true, branch: body.name });
2819
+ } catch (err) {
2820
+ return error(err instanceof Error ? err.message : String(err));
2821
+ }
2822
+ },
2823
+ },
2824
+
2825
+ '/api/sites/projects/:id/git/log': {
2826
+ GET: async (req: Request) => {
2827
+ if (!ctx.siteBuilderService) return error('Site builder not available', 503);
2828
+ const id = new URL(req.url).pathname.split('/')[4]!;
2829
+ const projectPath = ctx.siteBuilderService.projectManager.getProjectPath(id);
2830
+ if (!projectPath) return error('Project not found', 404);
2831
+ const limit = parseInt(getSearchParams(req).get('limit') ?? '50', 10);
2832
+ try {
2833
+ const commits = await ctx.siteBuilderService.gitManager.getLog(projectPath, limit);
2834
+ return json(commits);
2835
+ } catch (err) {
2836
+ return error(err instanceof Error ? err.message : String(err));
2837
+ }
2838
+ },
2839
+ },
2840
+
2841
+ '/api/sites/projects/:id/git/diff': {
2842
+ GET: async (req: Request) => {
2843
+ if (!ctx.siteBuilderService) return error('Site builder not available', 503);
2844
+ const id = new URL(req.url).pathname.split('/')[4]!;
2845
+ const projectPath = ctx.siteBuilderService.projectManager.getProjectPath(id);
2846
+ if (!projectPath) return error('Project not found', 404);
2847
+ try {
2848
+ const diff = await ctx.siteBuilderService.gitManager.getDiff(projectPath);
2849
+ return json({ diff });
2850
+ } catch (err) {
2851
+ return error(err instanceof Error ? err.message : String(err));
2852
+ }
2853
+ },
2854
+ },
2855
+
2856
+ '/api/sites/projects/:id/git/commit': {
2857
+ POST: async (req: Request) => {
2858
+ if (!ctx.siteBuilderService) return error('Site builder not available', 503);
2859
+ const id = new URL(req.url).pathname.split('/')[4]!;
2860
+ const projectPath = ctx.siteBuilderService.projectManager.getProjectPath(id);
2861
+ if (!projectPath) return error('Project not found', 404);
2862
+ try {
2863
+ const body = await req.json() as { message: string };
2864
+ if (!body.message) return error('message is required');
2865
+ const commit = await ctx.siteBuilderService.gitManager.autoCommit(projectPath, body.message);
2866
+ if (!commit) return json({ ok: false, message: 'Nothing to commit' });
2867
+ return json({ ok: true, commit });
2868
+ } catch (err) {
2869
+ return error(err instanceof Error ? err.message : String(err));
2870
+ }
2871
+ },
2872
+ },
2873
+
2874
+ '/api/sites/projects/:id/git/merge': {
2875
+ POST: async (req: Request) => {
2876
+ if (!ctx.siteBuilderService) return error('Site builder not available', 503);
2877
+ const id = new URL(req.url).pathname.split('/')[4]!;
2878
+ const projectPath = ctx.siteBuilderService.projectManager.getProjectPath(id);
2879
+ if (!projectPath) return error('Project not found', 404);
2880
+ try {
2881
+ const body = await req.json() as { branch: string; strategy?: 'merge' | 'rebase' };
2882
+ if (!body.branch) return error('branch is required');
2883
+
2884
+ const result = body.strategy === 'rebase'
2885
+ ? await ctx.siteBuilderService.gitManager.rebase(projectPath, body.branch)
2886
+ : await ctx.siteBuilderService.gitManager.merge(projectPath, body.branch);
2887
+
2888
+ return json(result);
2889
+ } catch (err) {
2890
+ return error(err instanceof Error ? err.message : String(err));
2891
+ }
2892
+ },
2893
+ },
2894
+
2895
+ // --- Site Builder: GitHub Integration ---
2896
+ '/api/sites/github/token': {
2897
+ GET: async () => {
2898
+ if (!ctx.siteBuilderService) return error('Site builder not available', 503);
2899
+ const gh = ctx.siteBuilderService.githubManager;
2900
+ if (!gh.hasToken()) return json({ hasToken: false, username: null });
2901
+ const { valid, username } = await gh.validateToken();
2902
+ return json({ hasToken: valid, username });
2903
+ },
2904
+ POST: async (req: Request) => {
2905
+ if (!ctx.siteBuilderService) return error('Site builder not available', 503);
2906
+ try {
2907
+ const body = await req.json() as { token: string };
2908
+ if (!body.token) return error('token is required');
2909
+ const gh = ctx.siteBuilderService.githubManager;
2910
+ gh.setToken(body.token);
2911
+ const { valid, username, scopes } = await gh.validateToken();
2912
+ if (!valid) {
2913
+ gh.deleteToken();
2914
+ return error('Invalid token — could not authenticate with GitHub', 401);
2915
+ }
2916
+ return json({ ok: true, username, scopes });
2917
+ } catch (err) {
2918
+ return error(err instanceof Error ? err.message : String(err));
2919
+ }
2920
+ },
2921
+ DELETE: () => {
2922
+ if (!ctx.siteBuilderService) return error('Site builder not available', 503);
2923
+ ctx.siteBuilderService.githubManager.deleteToken();
2924
+ return json({ ok: true });
2925
+ },
2926
+ },
2927
+
2928
+ '/api/sites/github/repos': {
2929
+ GET: async (req: Request) => {
2930
+ if (!ctx.siteBuilderService) return error('Site builder not available', 503);
2931
+ try {
2932
+ const page = parseInt(getSearchParams(req).get('page') ?? '1', 10);
2933
+ const repos = await ctx.siteBuilderService.githubManager.listUserRepos(page);
2934
+ return json(repos);
2935
+ } catch (err) {
2936
+ return error(err instanceof Error ? err.message : String(err));
2937
+ }
2938
+ },
2939
+ },
2940
+
2941
+ '/api/sites/projects/:id/github/repo': {
2942
+ POST: async (req: Request) => {
2943
+ if (!ctx.siteBuilderService) return error('Site builder not available', 503);
2944
+ const id = new URL(req.url).pathname.split('/')[4]!;
2945
+ const projectPath = ctx.siteBuilderService.projectManager.getProjectPath(id);
2946
+ if (!projectPath) return error('Project not found', 404);
2947
+ try {
2948
+ const body = await req.json() as {
2949
+ name?: string; description?: string; private?: boolean;
2950
+ existingRepo?: string; // "owner/repo" format
2951
+ };
2952
+ const gh = ctx.siteBuilderService.githubManager;
2953
+ let owner: string, repo: string, cloneUrl: string, htmlUrl: string;
2954
+
2955
+ if (body.existingRepo) {
2956
+ // Connect to existing repo
2957
+ const [o, r] = body.existingRepo.split('/');
2958
+ if (!o || !r) return error('existingRepo must be in "owner/repo" format');
2959
+ const info = await gh.getRepo(o, r);
2960
+ owner = info.owner; repo = info.repo; cloneUrl = info.cloneUrl; htmlUrl = info.htmlUrl;
2961
+ } else {
2962
+ // Create new repo
2963
+ if (!body.name) return error('name is required (or provide existingRepo)');
2964
+ const info = await gh.createRepo({
2965
+ name: body.name,
2966
+ description: body.description,
2967
+ private: body.private ?? true,
2968
+ });
2969
+ owner = info.owner; repo = info.repo; cloneUrl = info.cloneUrl; htmlUrl = info.htmlUrl;
2970
+ }
2971
+
2972
+ // Add/update remote origin
2973
+ await gh.addRemote(projectPath, cloneUrl);
2974
+
2975
+ // Persist GitHub metadata
2976
+ await ctx.siteBuilderService.projectManager.updateGitHubMeta(id, {
2977
+ owner, repo, remoteUrl: cloneUrl, lastPushedAt: null,
2978
+ });
2979
+
2980
+ const project = await ctx.siteBuilderService.getProjectWithStatus(id);
2981
+ return json(project, 201);
2982
+ } catch (err) {
2983
+ return error(err instanceof Error ? err.message : String(err));
2984
+ }
2985
+ },
2986
+ DELETE: async (req: Request) => {
2987
+ if (!ctx.siteBuilderService) return error('Site builder not available', 503);
2988
+ const id = new URL(req.url).pathname.split('/')[4]!;
2989
+ const projectPath = ctx.siteBuilderService.projectManager.getProjectPath(id);
2990
+ if (!projectPath) return error('Project not found', 404);
2991
+ try {
2992
+ await ctx.siteBuilderService.githubManager.removeRemote(projectPath);
2993
+ await ctx.siteBuilderService.projectManager.updateGitHubMeta(id, null);
2994
+ return json({ ok: true });
2995
+ } catch (err) {
2996
+ return error(err instanceof Error ? err.message : String(err));
2997
+ }
2998
+ },
2999
+ },
3000
+
3001
+ '/api/sites/projects/:id/github/push': {
3002
+ POST: async (req: Request) => {
3003
+ if (!ctx.siteBuilderService) return error('Site builder not available', 503);
3004
+ const id = new URL(req.url).pathname.split('/')[4]!;
3005
+ const projectPath = ctx.siteBuilderService.projectManager.getProjectPath(id);
3006
+ if (!projectPath) return error('Project not found', 404);
3007
+ try {
3008
+ const body = await req.json().catch(() => ({})) as { force?: boolean };
3009
+ const result = await ctx.siteBuilderService.githubManager.push(projectPath, undefined, body.force);
3010
+ if (!result.success) return error(result.error ?? 'Push failed');
3011
+
3012
+ // Update lastPushedAt
3013
+ const project = await ctx.siteBuilderService.projectManager.getProject(id);
3014
+ if (project?.githubUrl) {
3015
+ const meta = require('node:fs').readFileSync(
3016
+ require('node:path').join(projectPath, '.jarvis-project.json'), 'utf-8'
3017
+ );
3018
+ const parsed = JSON.parse(meta);
3019
+ if (parsed.github) {
3020
+ parsed.github.lastPushedAt = Date.now();
3021
+ await Bun.write(require('node:path').join(projectPath, '.jarvis-project.json'), JSON.stringify(parsed, null, 2));
3022
+ }
3023
+ }
3024
+
3025
+ return json({ ok: true });
3026
+ } catch (err) {
3027
+ return error(err instanceof Error ? err.message : String(err));
3028
+ }
3029
+ },
3030
+ },
3031
+
3032
+ '/api/sites/projects/:id/github/pull': {
3033
+ POST: async (req: Request) => {
3034
+ if (!ctx.siteBuilderService) return error('Site builder not available', 503);
3035
+ const id = new URL(req.url).pathname.split('/')[4]!;
3036
+ const projectPath = ctx.siteBuilderService.projectManager.getProjectPath(id);
3037
+ if (!projectPath) return error('Project not found', 404);
3038
+ try {
3039
+ const result = await ctx.siteBuilderService.githubManager.pull(projectPath);
3040
+ return json(result);
3041
+ } catch (err) {
3042
+ return error(err instanceof Error ? err.message : String(err));
3043
+ }
3044
+ },
3045
+ },
3046
+
3047
+ '/api/sites/projects/:id/github/status': {
3048
+ GET: async (req: Request) => {
3049
+ if (!ctx.siteBuilderService) return error('Site builder not available', 503);
3050
+ const id = new URL(req.url).pathname.split('/')[4]!;
3051
+ const projectPath = ctx.siteBuilderService.projectManager.getProjectPath(id);
3052
+ if (!projectPath) return error('Project not found', 404);
3053
+ try {
3054
+ const status = await ctx.siteBuilderService.githubManager.getRemoteStatus(projectPath);
3055
+ return json(status);
3056
+ } catch (err) {
3057
+ return error(err instanceof Error ? err.message : String(err));
3058
+ }
3059
+ },
3060
+ },
3061
+
3062
+ // --- CORS preflight ---
3063
+ '/api/*': {
3064
+ OPTIONS: () => new Response(null, { status: 204, headers: CORS }),
3065
+ },
3066
+ };
3067
+ }